From 81e6377ed6da6b4f9ad80947b4ff6ff7314f7cfc Mon Sep 17 00:00:00 2001 From: Jake Mor Date: Sun, 16 Aug 2026 00:04:54 -0400 Subject: [PATCH 001/162] Fix build on Xcode 26.0.1: disambiguate fontScale arithmetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Xcode 26.0.1 fails to type-check the mixed CGFloat/Double expression. Convert to Double once so the operators resolve. Same result. 🌸 Shipped with Kanna — https://kanna.sh Co-Authored-By: Kanna Kanna-Agent: claude/fable --- Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index f6119fb6c..ccf7d6601 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -491,7 +491,7 @@ class DeviceHelper { for: UIScreen.main.traitCollection.userInterfaceStyle ), fontSize: Int(scaledValue.rounded()), - fontScale: ((scaledValue / 16.0) * 100).rounded() / 100, + fontScale: (Double(scaledValue) / 16.0 * 100).rounded() / 100, preferredContentSizeCategory: contentSizeCategoryToken(for: category) ) } From 7a817875178342b937692416d45ce771e8e77645 Mon Sep 17 00:00:00 2001 From: Jake Mor Date: Sun, 16 Aug 2026 00:05:03 -0400 Subject: [PATCH 002/162] Keep subscribers active through empty StoreKit reads and empty web polls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two guards, one principle: nothing that is not an authoritative answer may downgrade a subscriber whose entitlement has not expired. 1. AutomaticPurchaseController.syncSubscriptionStatus: an empty device read no longer sets .inactive while the current .active status holds an unexpired entitlement. StoreKit returns nothing at cold launch before it hydrates, and web/Stripe subscribers never have App Store purchases. Entitlements with no expiry date do not hold the status, so a revoked lifetime purchase still deactivates. 2. WebEntitlementRedeemer.pollWebEntitlements: a response with zero entitlements no longer replaces cached web entitlements that are still within their expiry date. The poll is keyed on appUserId and deviceId alone, so one anomalous response could poison the cache and make every later cold launch read the subscriber as inactive. Production data showed a paying Stripe subscriber flip to INACTIVE ten times at cold launch, 0.8s after start, before the network poll could recover them. The new tests reproduce that flip and fail without the guards. 🌸 Shipped with Kanna — https://kanna.sh Co-Authored-By: Kanna Kanna-Agent: claude/fable --- CHANGELOG.md | 7 + .../AutomaticPurchaseController.swift | 20 +- .../Web/WebEntitlementRedeemer.swift | 22 ++ SuperwallKit.xcodeproj/project.pbxproj | 20 +- .../xcschemes/SuperwallKit.xcscheme | 3 +- .../AutomaticPurchaseControllerTests.swift | 284 ++++++++++++++++++ .../Web/WebEntitlementRedeemerTests.swift | 152 ++++++++++ 7 files changed, 501 insertions(+), 7 deletions(-) create mode 100644 Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 974a4adb3..f389cf434 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. +## Unreleased + +### Fixes + +- Fixes subscribers being reported as `inactive` on cold launch when StoreKit returns no purchases before it finishes loading. This hit web and Stripe subscribers hardest, because they have no App Store purchases at all. The SDK now keeps an `active` subscription status while one of its entitlements is within its expiry date. +- Fixes a web entitlements poll response with zero entitlements erasing cached web entitlements that are still within their expiry date. One bad response could make a paying web subscriber look `inactive` on every later cold launch until a network call recovered them. + ## 4.16.2 ### Enhancements diff --git a/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift b/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift index cf6b92ad4..f3d124de3 100644 --- a/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift +++ b/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift @@ -20,7 +20,10 @@ final class AutomaticPurchaseController { self.entitlementsInfo = entitlementsInfo } - func syncSubscriptionStatus(withPurchases purchases: Set) async { + func syncSubscriptionStatus( + withPurchases purchases: Set, + superwall: Superwall? = nil + ) async { let activePurchases = purchases.filter { $0.isActive } var entitlements: Set = [] @@ -30,10 +33,21 @@ final class AutomaticPurchaseController { } await MainActor.run { [entitlements] in + let superwall = superwall ?? Superwall.shared if entitlements.isEmpty { - Superwall.shared.internallySetSubscriptionStatus(to: .inactive) + // An empty device read is not proof that the user lost access. StoreKit + // can return nothing at cold launch before it hydrates, and web/Stripe + // subscribers never have App Store purchases. Keep an `.active` status + // while one of its entitlements is still within its expiry date. + // Entitlements with no expiry date don't hold the status, so a revoked + // lifetime purchase can still deactivate here. + if case .active(let currentEntitlements) = superwall.subscriptionStatus, + currentEntitlements.contains(where: { ($0.expiresAt ?? .distantPast) > Date() }) { + return + } + superwall.internallySetSubscriptionStatus(to: .inactive, superwall: superwall) } else { - Superwall.shared.internallySetSubscriptionStatus(to: .active(entitlements)) + superwall.internallySetSubscriptionStatus(to: .active(entitlements), superwall: superwall) } } } diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index 2ee581b25..c3a57ff2e 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -920,6 +920,28 @@ actor WebEntitlementRedeemer { deviceId: factory.makeDeviceId() ) + // A response with zero entitlements must not replace cached web + // entitlements that are still within their expiry date. This request is + // keyed on appUserId/deviceId alone, so an alias mismatch or backend + // hiccup can return empty for a still-paying subscriber. If it replaced + // the cache, the next cold launch would read the user as inactive until + // a network poll recovered them. The cost: revoking a web entitlement + // before its expiry date only takes effect once that date passes. + // Entitlements with no expiry date are not protected and remain + // revocable at any time. We skip saving the fetch date so the next + // poll retries without waiting out `entitlementsMaxAge`. + let hasUnexpiredWebEntitlements = existingWebEntitlements.contains { + $0.isActive && ($0.expiresAt ?? .distantPast) > Date() + } + if response.customerInfo.entitlements.isEmpty && hasUnexpiredWebEntitlements { + Logger.debug( + logLevel: .warn, + scope: .webEntitlements, + message: "Ignoring empty web entitlements response because unexpired web entitlements are cached." + ) + return + } + // Update the latest redeem response with the entitlements and customer info from the response. if var latestRedeemResponse = storage.get(LatestRedeemResponse.self) { latestRedeemResponse.customerInfo = response.customerInfo diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 16aca1f44..817f1bf3c 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ @@ -474,6 +474,7 @@ D506526569FAA54E3220A02A /* PurchaseSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F67A5C0CA15AF645709A2545 /* PurchaseSource.swift */; }; D56F32CB484F74EC7E49E581 /* PaywallViewControllerCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BAEECE2DBFEB8817E6C36DA /* PaywallViewControllerCache.swift */; }; D5A1334579BE0B0A207B0BF7 /* TestModeModalViewController+TableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D4F9888B5827992153F5F /* TestModeModalViewController+TableView.swift */; }; + D5C9A71CFB166225C082CAFB /* AutomaticPurchaseControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F3523491EC638DBBBD2133 /* AutomaticPurchaseControllerTests.swift */; }; D66461863D54A56BE9C29310 /* Publisher+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF5A8FFFC23826AD113D525A /* Publisher+Async.swift */; }; D6CA719D79BAA6369D1C01C3 /* AudienceLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71CF4BE5D2A6CEA9F8993C81 /* AudienceLogic.swift */; }; D77DB3187C62B91E3D55DF80 /* ArchiveRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4300EBF7463A42D2FB89371 /* ArchiveRequest.swift */; }; @@ -1167,6 +1168,7 @@ F13CC9902419E7D68B47C184 /* PopupTransitionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupTransitionDelegate.swift; sourceTree = ""; }; F16AFE9C93A441CFB6A95F10 /* String+CamelCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+CamelCase.swift"; sourceTree = ""; }; F2A2A54314BAEAF65B46D322 /* NetworkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkTests.swift; sourceTree = ""; }; + F2F3523491EC638DBBBD2133 /* AutomaticPurchaseControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomaticPurchaseControllerTests.swift; sourceTree = ""; }; F338AF233A9EF2A20B1AC5A5 /* MockPurchaseController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPurchaseController.swift; sourceTree = ""; }; F34468E3988E779132CE101A /* BundleHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleHelper.swift; sourceTree = ""; }; F36CB341B28F250F5252A8DF /* Transaction+LatestSince.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Transaction+LatestSince.swift"; sourceTree = ""; }; @@ -1298,6 +1300,7 @@ 054FCADFEF560A1A736DEFE4 /* StoreKitManagerTests.swift */, C0D3F44546D33F8AA71A79B9 /* Mocks */, 2C05828E18C52F510F7CDFA2 /* Products */, + 42F8F9824C94A7946BD46450 /* Purchase Controller */, 357496424A62506BB5B92AB9 /* Transactions */, ); path = StoreKit; @@ -1799,6 +1802,14 @@ path = Certificates; sourceTree = ""; }; + 42F8F9824C94A7946BD46450 /* Purchase Controller */ = { + isa = PBXGroup; + children = ( + F2F3523491EC638DBBBD2133 /* AutomaticPurchaseControllerTests.swift */, + ); + path = "Purchase Controller"; + sourceTree = ""; + }; 43AE802CA13E36803E2FF13E /* Popup Transition */ = { isa = PBXGroup; children = ( @@ -3148,6 +3159,8 @@ 8F41784FB73AC60BED81E582 /* PBXTargetDependency */, ); name = SuperwallKitTests; + packageProductDependencies = ( + ); productName = SuperwallKitTests; productReference = 92001AC11F099F7B03AF338A /* SuperwallKitTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; @@ -3160,8 +3173,6 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 1430; - TargetAttributes = { - }; }; buildConfigurationList = B7BB212B66F694F1FDA2FA4F /* Build configuration list for PBXProject "SuperwallKit" */; compatibilityVersion = "Xcode 14.0"; @@ -3212,9 +3223,11 @@ zh_Hant, ); mainGroup = 5CE8CEF97A892FFF3D0D8F06; + minimizedProjectReferenceProxies = 1; packageReferences = ( 89F17188BC665EFC6FE5CEFA /* XCRemoteSwiftPackageReference "superscript-ios-next" */, ); + preferredProjectObjectVersion = 77; projectDirPath = ""; projectRoot = ""; targets = ( @@ -3252,6 +3265,7 @@ 59685CE55D34FA6A96A8F890 /* AssignmentLogicTests.swift in Sources */, BC8A62869C7BACE6D0867195 /* AssignmentTests.swift in Sources */, 3CD2C23BAC2EA11174237785 /* AttributionTests.swift in Sources */, + D5C9A71CFB166225C082CAFB /* AutomaticPurchaseControllerTests.swift in Sources */, B0DC8290B081B74CC65E9305 /* CELEvaluatorTests.swift in Sources */, E984458E465D5A834CC52302 /* CacheMock.swift in Sources */, CBFC0D2DCA996A5FF7E5174B /* CacheTests.swift in Sources */, diff --git a/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme b/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme index e2f319bd6..8c5e0a183 100644 --- a/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme +++ b/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme @@ -40,7 +40,8 @@ + skipped = "NO" + parallelizable = "NO"> Entitlement { + return Entitlement( + id: "pro", + type: .serviceLevel, + isActive: true, + productIds: [], + latestProductId: nil, + store: .stripe, + startsAt: Date().addingTimeInterval(-90 * 86_400), + renewedAt: nil, + expiresAt: expiresAt, + isLifetime: false, + willRenew: true, + state: nil, + offerType: nil + ) + } + + private func makeController() -> AutomaticPurchaseController { + return AutomaticPurchaseController( + factory: dependencyContainer, + entitlementsInfo: dependencyContainer.entitlementsInfo + ) + } + + // MARK: - The observed production bug + + @Test("Cold launch with empty device read keeps an unexpired active status") + func testEmptyDeviceRead_keepsUnexpiredActiveStatus() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // A months-long Stripe subscriber. The last session persisted `.active`. + let entitlement = stripeEntitlement(expiresAt: Date().addingTimeInterval(30 * 86_400)) + dependencyContainer.storage.save( + SubscriptionStatus.active([entitlement]), + forType: SubscriptionStatusKey.self + ) + + // The web cache is missing. This happens after a poisoned poll response, + // a storage reset, or a failed cache migration. Without the guard, the + // web-entitlement merge cannot rescue the status. + dependencyContainer.storage.delete(LatestRedeemResponse.self) + + // Cold launch restores the persisted status, as `configure` does. + await MainActor.run { + superwall.subscriptionStatus = + dependencyContainer.storage.get(SubscriptionStatusKey.self) ?? .unknown + } + + // The automatic StoreKit sync reads zero purchases. This is correct for + // a Stripe subscriber: they have no App Store transactions. + let controller = makeController() + await controller.syncSubscriptionStatus(withPurchases: [], superwall: superwall) + + // Before the fix, the status flipped to `.inactive` here. The user's + // event stream showed this flip 10 times, 0.8s after cold launch. + let status = await MainActor.run { superwall.subscriptionStatus } + if case .active(let entitlements) = status { + #expect(entitlements.contains(entitlement)) + } else { + Issue.record("A paying subscriber must not flip to \(status) on an empty device read") + } + } + + @Test("Full cold launch on weak internet keeps access for a Stripe subscriber") + func testStripeSubscriberColdLaunchOnWeakInternet_keepsAccess() async { + guard #available(iOS 14.0, *) else { + return + } + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // Step 1: a previous session persisted `.active` for the Stripe subscriber. + let entitlement = stripeEntitlement(expiresAt: Date().addingTimeInterval(30 * 86_400)) + dependencyContainer.storage.save( + SubscriptionStatus.active([entitlement]), + forType: SubscriptionStatusKey.self + ) + dependencyContainer.storage.delete(LatestRedeemResponse.self) + + // Step 2: cold launch restores the status from disk. + await MainActor.run { + superwall.subscriptionStatus = + dependencyContainer.storage.get(SubscriptionStatusKey.self) ?? .unknown + } + + // Step 3: the web entitlement poll fails. The network is unreachable. + // NetworkMock throws when no response is set. + let options = dependencyContainer.makeSuperwallOptions() + let mockNetwork = NetworkMock( + options: options, + factory: dependencyContainer + ) + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: dependencyContainer.storage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + let config = Config + .stub() + .setting( + \.web2appConfig, + to: .init(entitlementsMaxAge: 60, restoreAccessURL: URL(string: "https://superwall.com")!) + ) + await redeemer.pollWebEntitlements(config: config, isFirstTime: true) + + // Step 4: the StoreKit sync reads zero purchases. + let controller = makeController() + await controller.syncSubscriptionStatus(withPurchases: [], superwall: superwall) + + // The subscriber keeps access with zero network on the critical path. + let status = await MainActor.run { superwall.subscriptionStatus } + if case .active(let entitlements) = status { + #expect(entitlements.contains(entitlement)) + } else { + Issue.record("Status was \(status); a weak-internet cold launch must not remove access") + } + } + + // MARK: - The guard must not block real deactivation + + @Test("Empty device read deactivates an expired status") + func testEmptyDeviceRead_expiredStatus_becomesInactive() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // The subscription lapsed a day ago. + let entitlement = stripeEntitlement(expiresAt: Date().addingTimeInterval(-86_400)) + dependencyContainer.storage.delete(LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .active([entitlement]) + } + + let controller = makeController() + await controller.syncSubscriptionStatus(withPurchases: [], superwall: superwall) + + let status = await MainActor.run { superwall.subscriptionStatus } + #expect(status == .inactive, "An expired status must not survive an empty device read") + } + + @Test("Empty device read deactivates a status with no expiry date") + func testEmptyDeviceRead_nilExpiry_becomesInactive() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // No expiry date means the guard cannot bound the protection, so it + // does not apply. A revoked lifetime purchase deactivates this way. + let entitlement = Entitlement( + id: "pro", + type: .serviceLevel, + isActive: true, + productIds: ["lifetime_product"], + latestProductId: "lifetime_product", + store: .appStore, + startsAt: Date(), + renewedAt: nil, + expiresAt: nil, + isLifetime: true, + willRenew: nil, + state: nil, + offerType: nil + ) + dependencyContainer.storage.delete(LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .active([entitlement]) + } + + let controller = makeController() + await controller.syncSubscriptionStatus(withPurchases: [], superwall: superwall) + + let status = await MainActor.run { superwall.subscriptionStatus } + #expect(status == .inactive, "A status with no expiry date must not hold the guard") + } + + @Test("Empty device read from an inactive status stays inactive") + func testEmptyDeviceRead_inactiveStatus_staysInactive() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + dependencyContainer.storage.delete(LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .inactive + } + + let controller = makeController() + await controller.syncSubscriptionStatus(withPurchases: [], superwall: superwall) + + let status = await MainActor.run { superwall.subscriptionStatus } + #expect(status == .inactive) + } + + // MARK: - Existing behavior is unchanged + + @Test("Empty device read still rescues via cached web entitlements") + func testEmptyDeviceRead_withCachedWebEntitlements_staysActiveViaMerge() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // The status starts inactive, so the guard does not apply. The cached + // web entitlements alone must rescue the user, as before the fix. + let entitlement = stripeEntitlement(expiresAt: Date().addingTimeInterval(30 * 86_400)) + let redeemResponse = RedeemResponse.stub() + .setting( + \.customerInfo, + to: CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: [entitlement]) + ) + dependencyContainer.storage.save(redeemResponse, forType: LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .inactive + } + + let controller = makeController() + await controller.syncSubscriptionStatus(withPurchases: [], superwall: superwall) + + let status = await MainActor.run { superwall.subscriptionStatus } + if case .active(let entitlements) = status { + #expect(entitlements.contains(entitlement)) + } else { + Issue.record("Cached web entitlements must rescue an empty device read") + } + + dependencyContainer.storage.delete(LatestRedeemResponse.self) + } + + @Test("Active purchases set an active status") + func testActivePurchases_setActiveStatus() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + let entitlement = Entitlement(id: "premium") + dependencyContainer.entitlementsInfo.entitlementsByProductId = [ + "monthly_product": [entitlement] + ] + dependencyContainer.storage.delete(LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .unknown + } + + let controller = makeController() + let purchase = Purchase( + id: "monthly_product", + isActive: true, + purchaseDate: Date() + ) + await controller.syncSubscriptionStatus(withPurchases: [purchase], superwall: superwall) + + let status = await MainActor.run { superwall.subscriptionStatus } + if case .active(let entitlements) = status { + #expect(entitlements.contains(entitlement)) + } else { + Issue.record("An active purchase must produce an active status") + } + } +} diff --git a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift index a2c637429..d220b8858 100644 --- a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift +++ b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift @@ -650,6 +650,158 @@ struct WebEntitlementRedeemerTests { #expect(savedRedeemResponse?.customerInfo.entitlements.isEmpty == true, "Saved redeem response should have no entitlements") } + @Test("Empty poll response does not clobber unexpired web entitlements") + func testPollWebEntitlements_emptyResponse_keepsUnexpiredWebEntitlements() async { + guard #available(iOS 14.0, *) else { + return + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // A Stripe subscriber with a paid-through web entitlement in the cache. + let stripeEntitlement = Entitlement( + id: "pro", + type: .serviceLevel, + isActive: true, + productIds: [], + latestProductId: nil, + store: .stripe, + startsAt: Date().addingTimeInterval(-90 * 86_400), + renewedAt: nil, + expiresAt: Date().addingTimeInterval(30 * 86_400), + isLifetime: false, + willRenew: true, + state: nil, + offerType: nil + ) + let previousRedeemResponse = RedeemResponse.stub() + .setting( + \.customerInfo, + to: CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: [stripeEntitlement]) + ) + dependencyContainer.storage.save(previousRedeemResponse, forType: LatestRedeemResponse.self) + dependencyContainer.storage.delete(LastWebEntitlementsFetchDate.self) + await MainActor.run { + superwall.subscriptionStatus = .active([stripeEntitlement]) + } + + // The backend answers with zero entitlements. The poll is keyed on + // appUserId/deviceId alone, so an alias mismatch or backend hiccup + // produces exactly this response for a still-paying subscriber. Before + // the fix, this response poisoned the cache. The next cold launch then + // read the user as inactive until a network poll recovered them. + let options = dependencyContainer.makeSuperwallOptions() + let mockNetwork = NetworkMock( + options: options, + factory: dependencyContainer + ) + mockNetwork.getEntitlementsResponse = EntitlementsResponse( + customerInfo: CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: []) + ) + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: dependencyContainer.storage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + let config = Config + .stub() + .setting( + \.web2appConfig, + to: .init(entitlementsMaxAge: 60, restoreAccessURL: URL(string: "https://superwall.com")!) + ) + await redeemer.pollWebEntitlements(config: config, isFirstTime: true) + + // The cache keeps the unexpired entitlement. + let savedRedeemResponse = dependencyContainer.storage.get(LatestRedeemResponse.self) + #expect( + savedRedeemResponse?.customerInfo.entitlements.contains(stripeEntitlement) == true, + "An empty poll response must not remove an unexpired web entitlement" + ) + + // The status stays active. + let status = await MainActor.run { superwall.subscriptionStatus } + #expect(status == .active([stripeEntitlement])) + + // The fetch date is not saved, so the next poll retries without + // waiting out entitlementsMaxAge. + #expect(dependencyContainer.storage.get(LastWebEntitlementsFetchDate.self) == nil) + + dependencyContainer.storage.delete(LatestRedeemResponse.self) + } + + @Test("Empty poll response removes expired web entitlements") + func testPollWebEntitlements_emptyResponse_removesExpiredWebEntitlements() async { + guard #available(iOS 14.0, *) else { + return + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // The cached entitlement expired a day ago, so nothing protects it. + let expiredEntitlement = Entitlement( + id: "pro", + type: .serviceLevel, + isActive: true, + productIds: [], + latestProductId: nil, + store: .stripe, + startsAt: Date().addingTimeInterval(-90 * 86_400), + renewedAt: nil, + expiresAt: Date().addingTimeInterval(-86_400), + isLifetime: false, + willRenew: false, + state: nil, + offerType: nil + ) + let previousRedeemResponse = RedeemResponse.stub() + .setting( + \.customerInfo, + to: CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: [expiredEntitlement]) + ) + dependencyContainer.storage.save(previousRedeemResponse, forType: LatestRedeemResponse.self) + + let options = dependencyContainer.makeSuperwallOptions() + let mockNetwork = NetworkMock( + options: options, + factory: dependencyContainer + ) + mockNetwork.getEntitlementsResponse = EntitlementsResponse( + customerInfo: CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: []) + ) + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: dependencyContainer.storage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + let config = Config + .stub() + .setting( + \.web2appConfig, + to: .init(entitlementsMaxAge: 60, restoreAccessURL: URL(string: "https://superwall.com")!) + ) + await redeemer.pollWebEntitlements(config: config, isFirstTime: true) + + let savedRedeemResponse = dependencyContainer.storage.get(LatestRedeemResponse.self) + #expect( + savedRedeemResponse?.customerInfo.entitlements.isEmpty == true, + "An empty poll response must remove an expired web entitlement" + ) + + dependencyContainer.storage.delete(LatestRedeemResponse.self) + } + @Test("External purchase controller with mixed web + appStore entitlements - polling removes web entitlements") func testPollWebEntitlements_externalPurchaseController_mixedEntitlements_webRemoved() async { guard #available(iOS 14.0, *) else { From 606f7a7bf4937d531e78f3dfd2396cb68f3164c1 Mon Sep 17 00:00:00 2001 From: Jake Mor Date: Sun, 16 Aug 2026 10:30:51 -0400 Subject: [PATCH 003/162] Scope the anti-downgrade guard by device-read authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refines the guard per review: an empty entitlement set is only a non-answer when the purchases set is completely empty. Refunded and expired transactions stay in the set as inactive (SK2 reads Transaction.all; the SK1 receipt keeps cancelled purchases), so a non-empty set with no active purchases is an authoritative answer and downgrades immediately. This closes the window where a refunded App Store subscription kept access until its pre-refund expiry date. A device read also has no authority over entitlements from other stores. An unexpired Stripe/web entitlement now holds the status even when unrelated inactive App Store purchases exist. This mirrors RevenueCat's model: a local StoreKit read never overwrites cached state it has no authority over (shouldComputeOfflineCustomerInfo requires a nil cache), and their offline path reads currentEntitlements, which already excludes revoked transactions. Also adds the isActive check to the guard predicate so both guards use the same definition of an unexpired entitlement. 🌸 Shipped with Kanna — https://kanna.sh Co-Authored-By: Kanna Kanna-Agent: claude/fable --- .../AutomaticPurchaseController.swift | 44 +++++++++--- .../AutomaticPurchaseControllerTests.swift | 68 ++++++++++++++++++- 2 files changed, 102 insertions(+), 10 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift b/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift index f3d124de3..b532397cb 100644 --- a/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift +++ b/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift @@ -35,15 +35,41 @@ final class AutomaticPurchaseController { await MainActor.run { [entitlements] in let superwall = superwall ?? Superwall.shared if entitlements.isEmpty { - // An empty device read is not proof that the user lost access. StoreKit - // can return nothing at cold launch before it hydrates, and web/Stripe - // subscribers never have App Store purchases. Keep an `.active` status - // while one of its entitlements is still within its expiry date. - // Entitlements with no expiry date don't hold the status, so a revoked - // lifetime purchase can still deactivate here. - if case .active(let currentEntitlements) = superwall.subscriptionStatus, - currentEntitlements.contains(where: { ($0.expiresAt ?? .distantPast) > Date() }) { - return + // A device read with no entitlements can mean two different things, + // and only one of them may demote a subscriber: + // + // - Purchases exist but none is active: an authoritative answer. + // Refunded and expired transactions stay in the set as inactive + // (SK2 reads `Transaction.all`; the SK1 receipt keeps cancelled + // purchases), so this downgrades immediately. + // - The purchases set is completely empty: a non-answer. StoreKit + // returns nothing at cold launch before it hydrates, the SK1 + // receipt can be missing, and web/Stripe subscribers have no App + // Store purchases at all. + // + // On a non-answer, keep an `.active` status while one of its + // entitlements is within its expiry date. A device read also has no + // authority over entitlements from other stores (Stripe, web), so + // those hold the status even when unrelated inactive purchases + // exist. Entitlements with no expiry date never hold the status, so + // a revoked lifetime purchase can still deactivate here. + if case .active(let currentEntitlements) = superwall.subscriptionStatus { + let holdsStatus = currentEntitlements.contains { entitlement in + guard entitlement.isActive, + (entitlement.expiresAt ?? .distantPast) > Date() else { + return false + } + if purchases.isEmpty { + return true + } + if let store = entitlement.store, store != .appStore { + return true + } + return false + } + if holdsStatus { + return + } } superwall.internallySetSubscriptionStatus(to: .inactive, superwall: superwall) } else { diff --git a/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift b/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift index 630c20ca9..43ba4f677 100644 --- a/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift @@ -30,13 +30,17 @@ struct AutomaticPurchaseControllerTests { /// A web entitlement like the one a Stripe subscriber holds. private func stripeEntitlement(expiresAt: Date?) -> Entitlement { + return entitlement(store: .stripe, expiresAt: expiresAt) + } + + private func entitlement(store: EntitlementStore, expiresAt: Date?) -> Entitlement { return Entitlement( id: "pro", type: .serviceLevel, isActive: true, productIds: [], latestProductId: nil, - store: .stripe, + store: store, startsAt: Date().addingTimeInterval(-90 * 86_400), renewedAt: nil, expiresAt: expiresAt, @@ -205,6 +209,68 @@ struct AutomaticPurchaseControllerTests { #expect(status == .inactive, "A status with no expiry date must not hold the guard") } + @Test("Inactive purchases deactivate an unexpired App Store status") + func testInactivePurchases_appStoreStatus_becomesInactive() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // A refunded subscription: the transaction stays in the purchases set + // as inactive (SK2 reads Transaction.all; the SK1 receipt keeps + // cancelled purchases). That is an authoritative answer, so the guard + // must not hold, even though the cached expiry date is in the future. + let appStoreEntitlement = entitlement( + store: .appStore, + expiresAt: Date().addingTimeInterval(300 * 86_400) + ) + dependencyContainer.storage.delete(LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .active([appStoreEntitlement]) + } + + let controller = makeController() + let refundedPurchase = Purchase( + id: "annual_product", + isActive: false, + purchaseDate: Date().addingTimeInterval(-30 * 86_400) + ) + await controller.syncSubscriptionStatus(withPurchases: [refundedPurchase], superwall: superwall) + + let status = await MainActor.run { superwall.subscriptionStatus } + #expect( + status == .inactive, + "A refunded App Store subscription must deactivate on the next device read" + ) + } + + @Test("Inactive purchases cannot refute an unexpired web entitlement") + func testInactivePurchases_webStatus_staysActive() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // A Stripe subscriber with old, inactive App Store transactions (for + // example an expired trial from years ago). The device read is + // authoritative about the App Store only, so it must not demote the + // web entitlement — even with the web cache missing. + let webEntitlement = stripeEntitlement(expiresAt: Date().addingTimeInterval(30 * 86_400)) + dependencyContainer.storage.delete(LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .active([webEntitlement]) + } + + let controller = makeController() + let oldPurchase = Purchase( + id: "old_trial_product", + isActive: false, + purchaseDate: Date().addingTimeInterval(-700 * 86_400) + ) + await controller.syncSubscriptionStatus(withPurchases: [oldPurchase], superwall: superwall) + + let status = await MainActor.run { superwall.subscriptionStatus } + if case .active(let entitlements) = status { + #expect(entitlements.contains(webEntitlement)) + } else { + Issue.record("An App Store read must not refute a web entitlement; got \(status)") + } + } + @Test("Empty device read from an inactive status stays inactive") func testEmptyDeviceRead_inactiveStatus_staysInactive() async { let superwall = Superwall(dependencyContainer: dependencyContainer) From 0ae33586edb5f2aaeadd9ddebdb32d09c3291fb2 Mon Sep 17 00:00:00 2001 From: Jake Mor Date: Sun, 16 Aug 2026 12:31:31 -0400 Subject: [PATCH 004/162] Protect nil-store entitlements and stamp .appStore on SK1 entitlements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: Entitlement.store decodes with no default, so a web or manual grant whose payload omits store is nil — and the guard treated nil as App-Store-refutable, demoting the exact population this PR protects. Flip the predicate so nil holds the status. Flipping alone would break SK1 refund enforcement: SK1ReceiptManager built its receipt-derived entitlements without a store, so they were nil too. Stamp .appStore on them, matching what EntitlementProcessor already does on the SK2 path. After that, every device-derived active entitlement is explicitly .appStore and the only nil-store actives are grants from outside the App Store, which a device read cannot refute. The transition is fail-open: caches written by older versions hold nil-store SK1 entitlements, which the flipped guard protects until the first sync rewrites them with .appStore. One side effect: equality includes store, so SK1 users get a single active-to-active status change event on first launch after upgrading. 🌸 Shipped with Kanna — https://kanna.sh Co-Authored-By: Kanna Kanna-Agent: claude/fable --- .../Receipt Manager/SK1ReceiptManager.swift | 7 +- .../AutomaticPurchaseController.swift | 19 +++--- .../AutomaticPurchaseControllerTests.swift | 64 +++++++++++++++++++ 3 files changed, 78 insertions(+), 12 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK1ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK1ReceiptManager.swift index 3478cd8a1..d692ee75b 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK1ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK1ReceiptManager.swift @@ -97,7 +97,12 @@ final class SK1ReceiptManager: ReceiptManagerType { id: entitlementId, type: entitlementTypes[entitlementId] ?? .serviceLevel, isActive: isActive, - productIds: productIds + productIds: productIds, + // Receipt-derived entitlements are App Store entitlements. The + // anti-downgrade guard relies on this: a device read may only + // refute `.appStore` entitlements, and a nil store marks an + // entitlement as granted outside the App Store (web, manual). + store: .appStore ) ) } diff --git a/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift b/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift index b532397cb..61f5a9425 100644 --- a/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift +++ b/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift @@ -49,23 +49,20 @@ final class AutomaticPurchaseController { // // On a non-answer, keep an `.active` status while one of its // entitlements is within its expiry date. A device read also has no - // authority over entitlements from other stores (Stripe, web), so - // those hold the status even when unrelated inactive purchases - // exist. Entitlements with no expiry date never hold the status, so - // a revoked lifetime purchase can still deactivate here. + // authority over entitlements not granted by the App Store, so those + // hold the status even when unrelated inactive purchases exist. A + // nil store means no App Store transaction unlocks the entitlement + // (web or manual grant — both receipt managers stamp `.appStore` on + // device-derived entitlements), so nil is protected too. Entitlements + // with no expiry date never hold the status, so a revoked lifetime + // purchase can still deactivate here. if case .active(let currentEntitlements) = superwall.subscriptionStatus { let holdsStatus = currentEntitlements.contains { entitlement in guard entitlement.isActive, (entitlement.expiresAt ?? .distantPast) > Date() else { return false } - if purchases.isEmpty { - return true - } - if let store = entitlement.store, store != .appStore { - return true - } - return false + return purchases.isEmpty || entitlement.store != .appStore } if holdsStatus { return diff --git a/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift b/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift index 43ba4f677..67dd52a99 100644 --- a/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift @@ -271,6 +271,70 @@ struct AutomaticPurchaseControllerTests { } } + @Test("Inactive purchases cannot refute a nil-store entitlement") + func testInactivePurchases_nilStoreStatus_staysActive() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // A nil store means no App Store transaction unlocks the entitlement: + // a web or manual grant whose payload omitted `store`. Both receipt + // managers stamp `.appStore` on device-derived entitlements, so a + // device read has no authority here and must not demote it. + let nilStoreEntitlement = Entitlement( + id: "pro", + type: .serviceLevel, + isActive: true, + productIds: [], + latestProductId: nil, + store: nil, + startsAt: Date().addingTimeInterval(-90 * 86_400), + renewedAt: nil, + expiresAt: Date().addingTimeInterval(30 * 86_400), + isLifetime: false, + willRenew: true, + state: nil, + offerType: nil + ) + dependencyContainer.storage.delete(LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .active([nilStoreEntitlement]) + } + + let controller = makeController() + let oldPurchase = Purchase( + id: "old_trial_product", + isActive: false, + purchaseDate: Date().addingTimeInterval(-700 * 86_400) + ) + await controller.syncSubscriptionStatus(withPurchases: [oldPurchase], superwall: superwall) + + let status = await MainActor.run { superwall.subscriptionStatus } + if case .active(let entitlements) = status { + #expect(entitlements.contains(nilStoreEntitlement)) + } else { + Issue.record("A device read must not refute a nil-store entitlement; got \(status)") + } + } + + @Test("SK1 receipt entitlements carry the App Store store") + func testSK1Entitlements_carryAppStoreStore() async { + // The flipped guard predicate protects nil-store entitlements, so + // refund enforcement on StoreKit 1 depends on the receipt manager + // stamping `.appStore` on the entitlements it derives. + let receiptManager = SK1ReceiptManager(receiptData: { MockReceiptData.newReceipt }) + let entitlement = Entitlement(id: "pro") + let snapshot = await receiptManager.loadPurchases( + serverEntitlementsByProductId: ["com.nutcallalert.inapp.optimum": [entitlement]] + ) + + #expect(!snapshot.customerInfo.entitlements.isEmpty) + for derived in snapshot.customerInfo.entitlements { + #expect( + derived.store == .appStore, + "SK1 receipt-derived entitlement \(derived.id) must carry .appStore" + ) + } + } + @Test("Empty device read from an inactive status stays inactive") func testEmptyDeviceRead_inactiveStatus_staysInactive() async { let superwall = Superwall(dependencyContainer: dependencyContainer) From 81f3ea6e0074d64a4cb3281dba6831810b3582a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:14:49 +0200 Subject: [PATCH 005/162] Hold the guard through entitlement-mapping failures An active purchase whose product no longer maps to any entitlement (dropped from config or served by a stale cache) produced an empty entitlement set alongside a non-empty purchases set, which the guard read as an authoritative demotion of a paying subscriber. Hold the status only when a still-active purchase unlocks the cached entitlement, so refunds with unrelated active purchases still deactivate immediately. Co-Authored-By: Claude Fable 5 --- .../AutomaticPurchaseController.swift | 22 ++- .../AutomaticPurchaseControllerTests.swift | 135 ++++++++++++++++-- 2 files changed, 138 insertions(+), 19 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift b/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift index 61f5a9425..d145cdad6 100644 --- a/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift +++ b/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift @@ -32,10 +32,12 @@ final class AutomaticPurchaseController { entitlements = entitlements.union(purchaseEntitlements) } + let activeProductIds = Set(activePurchases.map { $0.id }) + await MainActor.run { [entitlements] in let superwall = superwall ?? Superwall.shared if entitlements.isEmpty { - // A device read with no entitlements can mean two different things, + // A device read with no entitlements can mean different things, // and only one of them may demote a subscriber: // // - Purchases exist but none is active: an authoritative answer. @@ -46,6 +48,9 @@ final class AutomaticPurchaseController { // returns nothing at cold launch before it hydrates, the SK1 // receipt can be missing, and web/Stripe subscribers have no App // Store purchases at all. + // - A purchase is still active but maps to no entitlement: a + // mapping failure (the config no longer knows the product), not + // an authoritative answer for the entitlement it unlocks. // // On a non-answer, keep an `.active` status while one of its // entitlements is within its expiry date. A device read also has no @@ -53,16 +58,23 @@ final class AutomaticPurchaseController { // hold the status even when unrelated inactive purchases exist. A // nil store means no App Store transaction unlocks the entitlement // (web or manual grant — both receipt managers stamp `.appStore` on - // device-derived entitlements), so nil is protected too. Entitlements - // with no expiry date never hold the status, so a revoked lifetime - // purchase can still deactivate here. + // entitlements a receipt transaction unlocks, so active + // device-derived entitlements always carry it), so nil is protected + // too. Entitlements with no expiry date never hold the status, so a + // revoked lifetime purchase can still deactivate here. if case .active(let currentEntitlements) = superwall.subscriptionStatus { let holdsStatus = currentEntitlements.contains { entitlement in guard entitlement.isActive, (entitlement.expiresAt ?? .distantPast) > Date() else { return false } - return purchases.isEmpty || entitlement.store != .appStore + if purchases.isEmpty || entitlement.store != .appStore { + return true + } + // A still-active purchase that unlocks this entitlement means + // the empty entitlement set is a mapping failure, so the read + // cannot refute the entitlement it just confirmed. + return entitlement.productIds.contains { activeProductIds.contains($0) } } if holdsStatus { return diff --git a/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift b/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift index 67dd52a99..4a4ff6c80 100644 --- a/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift @@ -26,6 +26,7 @@ struct AutomaticPurchaseControllerTests { dependencyContainer.storage.delete(LatestRedeemResponse.self) dependencyContainer.storage.delete(SubscriptionStatusKey.self) dependencyContainer.storage.delete(LastWebEntitlementsFetchDate.self) + dependencyContainer.storage.delete(EntitlementsByProductId.self) } /// A web entitlement like the one a Stripe subscriber holds. @@ -271,6 +272,102 @@ struct AutomaticPurchaseControllerTests { } } + @Test("An active purchase with no entitlement mapping cannot demote its entitlement") + func testActiveUnmappedPurchase_matchingCachedEntitlement_staysActive() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // The cached App Store entitlement was unlocked by "annual_product". + // The config has since lost the product (undecodable product dropped, + // or a stale cached config), so the active purchase maps to no + // entitlements. That is a mapping failure, not an authoritative + // "nothing is active" — the read just confirmed the purchase. + let appStoreEntitlement = Entitlement( + id: "pro", + type: .serviceLevel, + isActive: true, + productIds: ["annual_product"], + latestProductId: "annual_product", + store: .appStore, + startsAt: Date().addingTimeInterval(-90 * 86_400), + renewedAt: nil, + expiresAt: Date().addingTimeInterval(30 * 86_400), + isLifetime: false, + willRenew: true, + state: nil, + offerType: nil + ) + dependencyContainer.storage.delete(LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .active([appStoreEntitlement]) + } + + let controller = makeController() + let activePurchase = Purchase( + id: "annual_product", + isActive: true, + purchaseDate: Date().addingTimeInterval(-30 * 86_400) + ) + await controller.syncSubscriptionStatus(withPurchases: [activePurchase], superwall: superwall) + + let status = await MainActor.run { superwall.subscriptionStatus } + if case .active(let entitlements) = status { + #expect(entitlements.contains(appStoreEntitlement)) + } else { + Issue.record("An active purchase must not demote the entitlement it unlocks; got \(status)") + } + } + + @Test("An unrelated active purchase cannot hold a refunded subscription") + func testRefundedSubscription_withUnrelatedActivePurchase_becomesInactive() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // The subscription for "annual_product" was refunded. The user also + // owns an unrelated non-consumable that maps to no entitlement. The + // active-but-unmapped purchase unlocks nothing in the cached status, + // so the refund still deactivates immediately. + let appStoreEntitlement = Entitlement( + id: "pro", + type: .serviceLevel, + isActive: true, + productIds: ["annual_product"], + latestProductId: "annual_product", + store: .appStore, + startsAt: Date().addingTimeInterval(-90 * 86_400), + renewedAt: nil, + expiresAt: Date().addingTimeInterval(300 * 86_400), + isLifetime: false, + willRenew: true, + state: nil, + offerType: nil + ) + dependencyContainer.storage.delete(LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .active([appStoreEntitlement]) + } + + let controller = makeController() + let refundedPurchase = Purchase( + id: "annual_product", + isActive: false, + purchaseDate: Date().addingTimeInterval(-30 * 86_400) + ) + let unrelatedPurchase = Purchase( + id: "coin_doubler", + isActive: true, + purchaseDate: Date().addingTimeInterval(-700 * 86_400) + ) + await controller.syncSubscriptionStatus( + withPurchases: [refundedPurchase, unrelatedPurchase], + superwall: superwall + ) + + let status = await MainActor.run { superwall.subscriptionStatus } + #expect( + status == .inactive, + "An unrelated active purchase must not hold a refunded subscription's status" + ) + } + @Test("Inactive purchases cannot refute a nil-store entitlement") func testInactivePurchases_nilStoreStatus_staysActive() async { let superwall = Superwall(dependencyContainer: dependencyContainer) @@ -315,24 +412,34 @@ struct AutomaticPurchaseControllerTests { } } - @Test("SK1 receipt entitlements carry the App Store store") - func testSK1Entitlements_carryAppStoreStore() async { - // The flipped guard predicate protects nil-store entitlements, so - // refund enforcement on StoreKit 1 depends on the receipt manager - // stamping `.appStore` on the entitlements it derives. + @Test("SK1 stamps .appStore only on entitlements a receipt transaction unlocks") + func testSK1Entitlements_storeReflectsReceiptTransactions() async { + // The guard protects nil-store entitlements, so refund enforcement on + // StoreKit 1 depends on the receipt manager stamping `.appStore` on + // purchased entitlements — while never-purchased entitlements keep a + // nil store, per the `Entitlement.store` contract and the SK2 path. let receiptManager = SK1ReceiptManager(receiptData: { MockReceiptData.newReceipt }) - let entitlement = Entitlement(id: "pro") let snapshot = await receiptManager.loadPurchases( - serverEntitlementsByProductId: ["com.nutcallalert.inapp.optimum": [entitlement]] + serverEntitlementsByProductId: [ + // `newReceipt` contains a transaction for this product. + "CYCLEMAPS_PREMIUM": [Entitlement(id: "pro")], + // No transaction in the receipt unlocks this one. + "com.example.never_purchased": [Entitlement(id: "plus")] + ] ) - #expect(!snapshot.customerInfo.entitlements.isEmpty) - for derived in snapshot.customerInfo.entitlements { - #expect( - derived.store == .appStore, - "SK1 receipt-derived entitlement \(derived.id) must carry .appStore" - ) - } + let purchased = snapshot.customerInfo.entitlements.first { $0.id == "pro" } + #expect( + purchased?.store == .appStore, + "A receipt transaction unlocks `pro`, so it must carry .appStore" + ) + + let neverPurchased = snapshot.customerInfo.entitlements.first { $0.id == "plus" } + #expect(neverPurchased != nil) + #expect( + neverPurchased?.store == nil, + "No transaction unlocks `plus`, so its store must be nil" + ) } @Test("Empty device read from an inactive status stays inactive") From 6e70dcfce39dd32b2576cb1e04ed3ab03984dff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:14:57 +0200 Subject: [PATCH 006/162] Stamp .appStore only on SK1 entitlements a transaction unlocks The unconditional stamp put .appStore on config entitlements no receipt transaction unlocks, contradicting the documented Entitlement.store contract (nil without transactions) and the StoreKit 2 path, and shifting public equality for every SK1 install on upgrade. Derive the store from the receipt's purchased product ids instead; active entitlements always have a purchase, so the anti-downgrade guard's invariant is unchanged. Co-Authored-By: Claude Fable 5 --- .../Receipt Manager/SK1ReceiptManager.swift | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK1ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK1ReceiptManager.swift index d692ee75b..c107fb7f8 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK1ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK1ReceiptManager.swift @@ -72,6 +72,7 @@ final class SK1ReceiptManager: ReceiptManagerType { // Build map of active product IDs for quick lookup let activeProductIds = Set(purchases.filter { $0.isActive }.map { $0.id }) + let purchasedProductIds = Set(purchases.map { $0.id }) // Process all entitlements from config, enhancing them with active status // For SK1, we collect all product IDs per entitlement, then mark as active if ANY product is active @@ -92,17 +93,22 @@ final class SK1ReceiptManager: ReceiptManagerType { // Entitlement is active if ANY of its products is active let isActive = productIds.contains { activeProductIds.contains($0) } + // A receipt transaction for any of the entitlement's products makes + // it an App Store entitlement, matching the StoreKit 2 path. Without + // one the store stays nil, per the `Entitlement.store` contract. The + // anti-downgrade guard relies on active device-derived entitlements + // carrying `.appStore`: a device read may only refute those, and a + // nil store marks a grant from outside the App Store (web, manual). + let store: EntitlementStore? = + productIds.contains { purchasedProductIds.contains($0) } ? .appStore : nil + entitlements.append( Entitlement( id: entitlementId, type: entitlementTypes[entitlementId] ?? .serviceLevel, isActive: isActive, productIds: productIds, - // Receipt-derived entitlements are App Store entitlements. The - // anti-downgrade guard relies on this: a device read may only - // refute `.appStore` entitlements, and a nil store marks an - // entitlement as granted outside the App Store (web, manual). - store: .appStore + store: store ) ) } From 699a8d2ae5e5dbdcde70c24cb8c5862c53961d15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:15:10 +0200 Subject: [PATCH 007/162] Reword changelog entries to match the scoped guard Lead with the developer-visible effect and drop the blanket "while within its expiry date" claim: refunded and expired App Store subscriptions deactivate immediately under the scoped guard. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bf23f08b..a558e3ae9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,8 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Fixes -- Fixes subscribers being reported as `inactive` on cold launch when StoreKit returns no purchases before it finishes loading. This hit web and Stripe subscribers hardest, because they have no App Store purchases at all. The SDK now keeps an `active` subscription status while one of its entitlements is within its expiry date. -- Fixes a web entitlements poll response with zero entitlements erasing cached web entitlements that are still within their expiry date. One bad response could make a paying web subscriber look `inactive` on every later cold launch until a network call recovered them. +- Fixes subscribers being reported as `inactive` on cold launch when the App Store has no purchases to report, which hit web and Stripe subscribers hardest. Refunded and expired App Store subscriptions still deactivate immediately. +- Fixes paying web subscribers being reported as `inactive` on later cold launches after the server temporarily returns no entitlements for them. ## 4.16.3 From 9d1ef98f57cbab5a51b457b6498e31631b9004f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:34:29 +0200 Subject: [PATCH 008/162] Document the uncorrected isActive signal and the web revocation tradeoff In the mapping-failure hold, SK2's subscription-level correction of Purchase.isActive is disabled along with the mapping, so the branch reads the raw transaction-level value and can miss a revocation with no revocationDate; state that in the comment along with the expiry bound. Add the web bullet's revocation tradeoff to the changelog to match the App Store bullet's disclosure. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- .../AutomaticPurchaseController.swift | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a558e3ae9..74f716362 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Fixes - Fixes subscribers being reported as `inactive` on cold launch when the App Store has no purchases to report, which hit web and Stripe subscribers hardest. Refunded and expired App Store subscriptions still deactivate immediately. -- Fixes paying web subscribers being reported as `inactive` on later cold launches after the server temporarily returns no entitlements for them. +- Fixes paying web subscribers being reported as `inactive` on later cold launches after the server temporarily returns no entitlements for them. Revoking a web subscriber's entitlements now takes effect once their expiry date passes. ## 4.16.3 diff --git a/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift b/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift index d145cdad6..d0fbf574d 100644 --- a/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift +++ b/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift @@ -72,8 +72,13 @@ final class AutomaticPurchaseController { return true } // A still-active purchase that unlocks this entitlement means - // the empty entitlement set is a mapping failure, so the read - // cannot refute the entitlement it just confirmed. + // the empty entitlement set is a mapping failure. With the + // mapping missing, SK2's subscription-level correction of + // `Purchase.isActive` is disabled too, so this is the raw + // transaction-level value and can miss a revocation that sets + // no `revocationDate`. The hold is still bounded by the expiry + // gate above, which beats locking out a paying subscriber over + // a lost product mapping. return entitlement.productIds.contains { activeProductIds.contains($0) } } if holdsStatus { From 287a3990806adacbe76900d67f8aab0a0a103e18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:31:53 +0200 Subject: [PATCH 009/162] Correct the revocation wording: empty responses are artifacts Verified against subscriptions-api (code and a live grant/revoke cycle): the server reports a revocation by returning the entitlement as inactive and enumerates every config-mapped entitlement even for users with no purchases, so real revocations arrive non-empty and apply immediately. A fully empty entitlements array is a backend or config artifact, which is the only shape the guard ignores. Update the changelog and the guard comment to stop claiming revocations wait for the expiry date. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- .../Web/WebEntitlementRedeemer.swift | 21 +++++++++++-------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74f716362..ba335135b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Fixes - Fixes subscribers being reported as `inactive` on cold launch when the App Store has no purchases to report, which hit web and Stripe subscribers hardest. Refunded and expired App Store subscriptions still deactivate immediately. -- Fixes paying web subscribers being reported as `inactive` on later cold launches after the server temporarily returns no entitlements for them. Revoking a web subscriber's entitlements now takes effect once their expiry date passes. +- Fixes paying web subscribers being reported as `inactive` on later cold launches after the server temporarily returns no entitlement data for them. Revocations still take effect immediately, because the server reports revoked entitlements as inactive rather than omitting them. ## 4.16.3 diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index c3a57ff2e..10683d7ef 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -921,15 +921,18 @@ actor WebEntitlementRedeemer { ) // A response with zero entitlements must not replace cached web - // entitlements that are still within their expiry date. This request is - // keyed on appUserId/deviceId alone, so an alias mismatch or backend - // hiccup can return empty for a still-paying subscriber. If it replaced - // the cache, the next cold launch would read the user as inactive until - // a network poll recovered them. The cost: revoking a web entitlement - // before its expiry date only takes effect once that date passes. - // Entitlements with no expiry date are not protected and remain - // revocable at any time. We skip saving the fetch date so the next - // poll retries without waiting out `entitlementsMaxAge`. + // entitlements that are still within their expiry date. The server + // reports a revocation by returning the entitlement as inactive — + // and it enumerates every config-mapped entitlement even for users + // with no purchases — so a fully empty array is a backend or config + // artifact (alias mismatch, failed upstream lookup), not a + // revocation. Real revocations arrive non-empty and apply + // immediately through the save below. If an empty response replaced + // the cache, the next cold launch would read the user as inactive + // until a network poll recovered them. Entitlements with no expiry + // date are not protected by this guard. We skip saving the fetch + // date so the next poll retries without waiting out + // `entitlementsMaxAge`. let hasUnexpiredWebEntitlements = existingWebEntitlements.contains { $0.isActive && ($0.expiresAt ?? .distantPast) > Date() } From d1ca3fab3fbc6a63312663ad3c46243f0b25b0f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:09:34 +0200 Subject: [PATCH 010/162] Restore the test scheme to develop's parallel default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-edited parallelizable="NO" only applied to local Xcode runs anyway — CI and scripts/test.sh regenerate the scheme via xcodegen, which drops the attribute — so remove it and keep the committed file matching what regeneration produces. Co-Authored-By: Claude Fable 5 --- .../xcshareddata/xcschemes/SuperwallKit.xcscheme | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme b/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme index 8c5e0a183..e2f319bd6 100644 --- a/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme +++ b/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme @@ -40,8 +40,7 @@ + skipped = "NO"> Date: Wed, 19 Aug 2026 17:27:54 +0200 Subject: [PATCH 011/162] Bump to 4.16.4 and ban Unreleased changelog headings Rename the Unreleased section to 4.16.4 and bump Constants.swift and the podspec with it, since develop and master are both on 4.16.3 and this is a patch fix. Document the rule in CLAUDE.md: unreleased changes always live under the next concrete version; when develop's version is already above master's, append to that section instead of bumping again. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- CLAUDE.md | 4 ++++ Sources/SuperwallKit/Misc/Constants.swift | 2 +- SuperwallKit.podspec | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba335135b..033b50c23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. -## Unreleased +## 4.16.4 ### Fixes diff --git a/CLAUDE.md b/CLAUDE.md index 419b8b5f7..fd5b72bfc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,6 +72,10 @@ When bumping the version, update all three files: 3. `CHANGELOG.md` (add new version entry at top) - Follows semantic versioning +- **Never use an `## Unreleased` heading in `CHANGELOG.md`.** Unreleased changes always live under the next concrete version number (e.g. `## 4.16.4`). +- To pick that number, compare the version on `develop` with the version on `master`: + - If develop's version is **above** master's, a release is already staged — add your entries to that existing top section. Do not bump again. + - If develop's version **equals** master's, start the next release: add a new version section and bump all three files together (patch/minor/major per the change). ### Testing diff --git a/Sources/SuperwallKit/Misc/Constants.swift b/Sources/SuperwallKit/Misc/Constants.swift index d238a7fd6..968fea372 100644 --- a/Sources/SuperwallKit/Misc/Constants.swift +++ b/Sources/SuperwallKit/Misc/Constants.swift @@ -18,5 +18,5 @@ let sdkVersion = """ */ let sdkVersion = """ -4.16.3 +4.16.4 """ diff --git a/SuperwallKit.podspec b/SuperwallKit.podspec index 7e88ffd14..b031453c9 100644 --- a/SuperwallKit.podspec +++ b/SuperwallKit.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "SuperwallKit" - s.version = "4.16.3" + s.version = "4.16.4" 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" From 47a5ba124101480fcd3038822f1b53de83969d99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:30:50 +0200 Subject: [PATCH 012/162] Update CHANGELOG.md --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 033b50c23..0298c0366 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Fixes -- Fixes subscribers being reported as `inactive` on cold launch when the App Store has no purchases to report, which hit web and Stripe subscribers hardest. Refunded and expired App Store subscriptions still deactivate immediately. -- Fixes paying web subscribers being reported as `inactive` on later cold launches after the server temporarily returns no entitlement data for them. Revocations still take effect immediately, because the server reports revoked entitlements as inactive rather than omitting them. +- Fixes issue where paying web users could end up having an inactive subscription status if the server temporarily returns no entitlement data for them. ## 4.16.3 From 3204994fe069206925a66f0a1c64d4965f2d8e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:31:27 +0200 Subject: [PATCH 013/162] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0298c0366..0936211d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Fixes -- Fixes issue where paying web users could end up having an inactive subscription status if the server temporarily returns no entitlement data for them. +- Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them. ## 4.16.3 From 82ff1eebb1f7f374e8a456dcc7d5bd6ec6d8dfa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:37:06 +0200 Subject: [PATCH 014/162] Restore the App Store changelog entry with its precondition The guard only preserves a cached active status whose entitlement is unexpired, so say "subscribers with an unexpired subscription" rather than implying every subscriber is covered. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0936211d5..3179377bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Fixes +- Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. - Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them. ## 4.16.3 From 93ede3985d774b797e076a98ec71905b61df9e31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:30:28 +0200 Subject: [PATCH 015/162] chore(github): require an affected-user link and setup details in bug reports Converts the bug report template to a GitHub issue form so key triage fields are enforced at submission time: a dashboard link to an affected user, SDK/iOS/Xcode versions, installation method, and a description of how the SDK is integrated. Adds optional fields for the last working SDK version, occurrence time, and debug logs, and drops the stale superwall-me/paywall-ios links. Co-Authored-By: Claude Fable 5 --- .github/ISSUE_TEMPLATE/bug_report.md | 32 ------- .github/ISSUE_TEMPLATE/bug_report.yml | 126 ++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 32 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 61085e9d7..000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: "[BUG]" -labels: '' -assignees: '' - ---- - -## New issue checklist -- [ ] I have reviewed the [`README`](https://github.com/superwall-me/paywall-ios/blob/master/README.md) and [documentation](https://docs.superwall.com/docs) -- [ ] I have searched [existing issues](https://github.com/superwall-me/paywall-ios/issues) and this is not a duplicate -- [ ] I have attempted to reproduce the issue and include an example project. - -### General information - -- `Superwall` version: -- iOS version(s): -- CocoaPods/Carthage version (if applicable): -- Xcode version: -- Devices/Simulators affected: -- Reproducible in the demo project? (Yes/No): -- Related issues: - -### Describe the bug -A clear and concise description of what the bug is. The more detail you can provide the faster our team will be able to triage and resolve the issue. - -### Steps to reproduce -Please also include a description of expected vs. actual behaviour - -### Other Information -e.g. stacktraces, suggestions how to fix, links for us to have context, eg. stackoverflow, etc. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..b674a75a8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,126 @@ +name: 🐛 Bug report +description: Report a bug in SuperwallKit. The more you give us up front, the faster we can find the cause. +title: "[BUG] " +body: + - type: checkboxes + id: checklist + attributes: + label: New issue checklist + options: + - label: I have read the [documentation](https://superwall.com/docs) and this doesn't appear to be expected behavior + required: true + - label: I have searched [existing issues](https://github.com/superwall/Superwall-iOS/issues) and this is not a duplicate + required: true + - label: I have tried to reproduce the issue in a minimal sample project I can share + required: false + - type: input + id: affected-user + attributes: + label: Affected user + description: > + A link to a user who hit the bug lets us check their event logs, which is often the + fastest route to a diagnosis. In the + [dashboard](https://superwall.com/select-application?pathname=/applications/:app/users/v2), + open **Users**, search for the app user ID you pass to `Superwall.shared.identify(userId:)` + (or the `$SuperwallAlias:...` ID from the device logs if you don't identify users), + open the user, and paste the page URL. If the bug isn't tied to a user (e.g. a build + error), write "N/A". + placeholder: https://superwall.com/applications//users/v2/ + validations: + required: true + - type: input + id: occurred-at + attributes: + label: When did it happen? + description: Approximate date and time the bug occurred, including timezone. Helps us correlate server-side logs. + placeholder: e.g. 2026-08-15 14:30 UTC + - type: input + id: sdk-version + attributes: + label: SuperwallKit version + placeholder: e.g. 4.16.3 + validations: + required: true + - type: input + id: previous-sdk-version + attributes: + label: Last SDK version that worked + description: If the bug appeared after updating the SDK, which version were you on before? + placeholder: e.g. 4.15.4 + - type: input + id: ios-version + attributes: + label: iOS version(s) + placeholder: e.g. iOS 26.5 + validations: + required: true + - type: input + id: xcode-version + attributes: + label: Xcode version + placeholder: e.g. 26.1 + validations: + required: true + - type: dropdown + id: installation + attributes: + label: Installation method + options: + - Swift Package Manager + - CocoaPods + - Other + validations: + required: true + - type: input + id: devices + attributes: + label: Devices/simulators affected + placeholder: e.g. iPhone 17 Pro, all simulators + - type: dropdown + id: repro-example + attributes: + label: Reproducible in the example app? + options: + - "Yes" + - "No" + - Haven't tried + validations: + required: true + - type: textarea + id: sdk-setup + attributes: + label: How is the SDK set up? + description: | + Briefly describe your integration — it changes which code paths run: + - Are you using a `PurchaseController` (e.g. with RevenueCat)? If so, where do you set `Superwall.shared.subscriptionStatus`? + - How do you present paywalls (`register(placement:)`, `PaywallView`, `getPaywall`)? + - Do you call `Superwall.shared.identify(userId:)`? + validations: + required: true + - type: textarea + id: description + attributes: + label: Describe the bug + description: A clear and concise description of the bug, including expected vs. actual behavior. Screenshots and screen recordings help a lot. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. ... + 2. ... + validations: + required: true + - type: textarea + id: logs + attributes: + label: Debug logs + description: Set `options.logging.level = .debug` in `SuperwallOptions` before configuring the SDK, reproduce the bug, and paste the console output covering when it happened. Automatically formatted as code. + render: shell + - type: textarea + id: other + attributes: + label: Other information + description: Stack traces, crash reports, related issues, links, or anything else that gives context. From ebc7611388caf41b17f5bc1b14de1d067b144b5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:35:34 +0200 Subject: [PATCH 016/162] chore(github): warn against posting identifying user IDs in the affected-user link The dashboard link is auth-gated, but the URL itself carries the app user ID, which some apps set to an email or internal ID. Steer those reporters to the random $SuperwallAlias URL or the dashboard support chat instead. Co-Authored-By: Claude Fable 5 --- .github/ISSUE_TEMPLATE/bug_report.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index b674a75a8..50287452a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -23,7 +23,11 @@ body: [dashboard](https://superwall.com/select-application?pathname=/applications/:app/users/v2), open **Users**, search for the app user ID you pass to `Superwall.shared.identify(userId:)` (or the `$SuperwallAlias:...` ID from the device logs if you don't identify users), - open the user, and paste the page URL. If the bug isn't tied to a user (e.g. a build + open the user, and paste the page URL. The link only opens for your own team and us, + but issues are public and the URL contains the user ID itself — so if your app user IDs + are personally identifying (e.g. an email address), paste the user's `$SuperwallAlias:...` + URL instead (shown on their user page), or share the link privately via the support chat + in the dashboard and mention this issue. If the bug isn't tied to a user (e.g. a build error), write "N/A". placeholder: https://superwall.com/applications//users/v2/ validations: From e1eeafe188a0f4e5148a6ffda25458cd7f8ddf93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:40:15 +0200 Subject: [PATCH 017/162] chore(github): apply review suggestions to the bug report form Auto-apply the bug label, point unidentified reporters at Superwall.shared.userId for their alias (it never appears in plain-text device logs), and prompt redaction of personal data in debug logs. Co-Authored-By: Claude Fable 5 --- .github/ISSUE_TEMPLATE/bug_report.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 50287452a..89cb08a44 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,6 +1,7 @@ name: 🐛 Bug report description: Report a bug in SuperwallKit. The more you give us up front, the faster we can find the cause. title: "[BUG] " +labels: ["bug"] body: - type: checkboxes id: checklist @@ -22,7 +23,7 @@ body: fastest route to a diagnosis. In the [dashboard](https://superwall.com/select-application?pathname=/applications/:app/users/v2), open **Users**, search for the app user ID you pass to `Superwall.shared.identify(userId:)` - (or the `$SuperwallAlias:...` ID from the device logs if you don't identify users), + (or the `$SuperwallAlias:...` ID from `Superwall.shared.userId` if you don't identify users), open the user, and paste the page URL. The link only opens for your own team and us, but issues are public and the URL contains the user ID itself — so if your app user IDs are personally identifying (e.g. an email address), paste the user's `$SuperwallAlias:...` @@ -121,7 +122,7 @@ body: id: logs attributes: label: Debug logs - description: Set `options.logging.level = .debug` in `SuperwallOptions` before configuring the SDK, reproduce the bug, and paste the console output covering when it happened. Automatically formatted as code. + description: Set `options.logging.level = .debug` in `SuperwallOptions` before configuring the SDK, reproduce the bug, and paste the console output covering when it happened. Automatically formatted as code. Please redact anything personal — logs can contain user attributes you've set. render: shell - type: textarea id: other From 6519c3cf78a505292a2877dc4bf2afb24ef5e22f Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Mon, 24 Aug 2026 15:41:35 +0200 Subject: [PATCH 018/162] feat: add dev mode for previewing local paywalls --- CHANGELOG.md | 6 + Examples/Basic/Basic/Info.plist | 7 + .../SuperwallKit/Config/ConfigManager.swift | 30 ++- .../Config/Options/SuperwallOptions.swift | 27 +++ Sources/SuperwallKit/Debug/DebugManager.swift | 24 ++- .../DebugPaywallPickerViewController.swift | 172 ++++++++++++++++++ .../SuperwallKit/Debug/DebugPickerLogic.swift | 63 +++++++ .../Debug/DebugViewController.swift | 167 +++++++++++++---- Sources/SuperwallKit/DeepLinkRouter.swift | 8 + Sources/SuperwallKit/DevServer/DevMode.swift | 46 +++++ .../DevServer/DevServerManifest.swift | 163 +++++++++++++++++ .../DevServer/DevServerPaywall.swift | 55 ++++++ .../DevServer/DevServerPreview.swift | 76 ++++++++ .../SuperwallKit/Models/Paywall/Paywall.swift | 8 +- .../Network/Device Helper/DeviceHelper.swift | 7 + .../Operators/RawPaywallResponse.swift | 38 ++++ .../Request/PaywallRequestManager.swift | 1 + .../TestMode/TestModeManager.swift | 5 + SuperwallKit.xcodeproj/project.pbxproj | 56 ++++++ .../Debug/DebugPickerLogicTests.swift | 81 +++++++++ .../DevServer/DevModeTests.swift | 48 +++++ .../DevServer/DevServerManifestTests.swift | 117 ++++++++++++ .../DevServer/DevServerPaywallTests.swift | 75 ++++++++ 23 files changed, 1235 insertions(+), 45 deletions(-) create mode 100644 Sources/SuperwallKit/Debug/DebugPaywallPickerViewController.swift create mode 100644 Sources/SuperwallKit/Debug/DebugPickerLogic.swift create mode 100644 Sources/SuperwallKit/DevServer/DevMode.swift create mode 100644 Sources/SuperwallKit/DevServer/DevServerManifest.swift create mode 100644 Sources/SuperwallKit/DevServer/DevServerPaywall.swift create mode 100644 Sources/SuperwallKit/DevServer/DevServerPreview.swift create mode 100644 Tests/SuperwallKitTests/Debug/DebugPickerLogicTests.swift create mode 100644 Tests/SuperwallKitTests/DevServer/DevModeTests.swift create mode 100644 Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift create mode 100644 Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 3179377bd..044a0636e 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. +## Unreleased + +### Enhancements + +- Adds `SuperwallOptions.devMode` for development builds: with a `superwall dev` server running, every paywall renders from your live, local paywall code while configuration, placements, audience evaluation and assignment stay real. Simulators find the dev server on localhost automatically; on a physical device set `SuperwallOptions.devServerURL` to the Device URL `superwall dev` prints. Bound paywalls resolve via the dev server's manifest (`superwall.lock`); dev mode also activates test mode, disables preloading, and skips the test mode intro sheet. + ## 4.16.4 ### Fixes diff --git a/Examples/Basic/Basic/Info.plist b/Examples/Basic/Basic/Info.plist index 05ff7f9a9..99411a379 100644 --- a/Examples/Basic/Basic/Info.plist +++ b/Examples/Basic/Basic/Info.plist @@ -13,6 +13,13 @@ + NSAppTransportSecurity + + NSAllowsArbitraryLoadsInWebContent + + NSAllowsLocalNetworking + + UIAppFonts Rubik-Regular.ttf diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index 54aaf9a35..8689eb1f6 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -440,8 +440,12 @@ class ConfigManager { let shouldShowTestModeAlert = isFirstTime || testModeJustActivated if shouldShowTestModeAlert, testModeManager.isTestMode, - let reason = testModeManager.testModeReason { - await presentTestModeModal(reason: reason, config: config) + testModeManager.testModeReason != nil { + if DevMode.isActive(options) { + await applyDefaultTestModeState(testModeManager: testModeManager) + } else if let reason = testModeManager.testModeReason { + await presentTestModeModal(reason: reason, config: config) + } } } @@ -558,7 +562,9 @@ class ConfigManager { /// /// A developer can disable preloading of paywalls by setting ``SuperwallOptions/shouldPreloadPaywalls``. private func preloadPaywalls() async { - guard Superwall.shared.options.paywalls.shouldPreload else { + guard Superwall.shared.options.paywalls.shouldPreload, + !DevMode.isActive(Superwall.shared.options) + else { return } await preloadAllPaywalls() @@ -724,6 +730,24 @@ class ConfigManager { } } + /// Seeds the state the test mode modal would otherwise collect, without + /// presenting it. Used when a dev server drives the SDK: every entitlement + /// starts inactive so paywalls present, and purchases flip them for real. + @MainActor + private func applyDefaultTestModeState(testModeManager: TestModeManager) async { + testModeManager.setEntitlements([]) + let testModeCustomerInfo = CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: [] + ) + testModeManager.overriddenCustomerInfo = testModeCustomerInfo + Superwall.shared.customerInfo = testModeCustomerInfo + testModeManager.overriddenSubscriptionStatus = .inactive + Superwall.shared.subscriptionStatus = .inactive + storage.save(false, forType: IsTestModeActiveSubscription.self) + } + @MainActor private func presentTestModeModal(reason: TestModeReason, config: Config) async { guard diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index c39e7ab20..525988b23 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -388,6 +388,33 @@ public final class SuperwallOptions: NSObject, Encodable { /// - `.always`: Test mode is always activated, regardless of configuration. public var testModeBehavior: TestModeBehavior = .automatic + /// Connects this SDK instance to a running `superwall dev` server, for development builds only. + /// + /// Every paywall the SDK would present then renders from the dev server's live, local + /// paywall code instead of its published version, while configuration, placements, + /// audience evaluation and assignment all stay real. On a simulator this finds the dev + /// server on `localhost` automatically; on a physical device set ``devServerURL`` to + /// the `Device` URL that `superwall dev` prints. + /// + /// Dev mode also activates test mode (simulated purchases, product data from the + /// dashboard), disables paywall preloading, and skips the test mode intro sheet. + /// + /// The host app must allow local networking in its `Info.plist` + /// (`NSAppTransportSecurity` → `NSAllowsLocalNetworking` and + /// `NSAllowsArbitraryLoadsInWebContent`). + public var devMode = false + + /// Where ``devMode`` looks for the `superwall dev` server. Setting this implies ``devMode``. + /// + /// Defaults to `localhost` ports 6100–6104, which reaches a dev server running on the + /// same machine from a simulator. On a physical device set this to the `Device` URL's + /// origin that `superwall dev` prints, e.g. `http://192.168.1.10:6100`. + @nonobjc public var devServerURL: URL? + + var isDevModeEnabled: Bool { + return devMode || devServerURL != nil + } + /// Determines the number of times the SDK will attempt to get the Superwall configuration after a network /// failure before it times out. Defaults to 6. /// diff --git a/Sources/SuperwallKit/Debug/DebugManager.swift b/Sources/SuperwallKit/Debug/DebugManager.swift index bb2ebe140..9b1109285 100644 --- a/Sources/SuperwallKit/Debug/DebugManager.swift +++ b/Sources/SuperwallKit/Debug/DebugManager.swift @@ -12,6 +12,10 @@ final class DebugManager { @MainActor var viewController: DebugViewController? var isDebuggerLaunched = false + /// The surfaces a running `superwall dev` server exposes, and where it lives. + /// Set when the debugger is opened from a `superwall_dev` deep link. + @MainActor var devServer: (base: URL, surfaces: [DevServerSurface])? + private unowned let storage: Storage private unowned let factory: ViewControllerFactory struct DeepLinkOutcome { @@ -68,31 +72,39 @@ final class DebugManager { /// /// Remember to add your URL scheme in settings for QR code scanning to work. @MainActor - func launchDebugger(withPaywallId paywallDatabaseId: String? = nil) async { + func launchDebugger( + withPaywallId paywallDatabaseId: String? = nil, + devSurfaceId: String? = nil + ) async { if Superwall.shared.isPaywallPresented { await Superwall.shared.dismiss() - await launchDebugger(withPaywallId: paywallDatabaseId) + await launchDebugger(withPaywallId: paywallDatabaseId, devSurfaceId: devSurfaceId) } else { if viewController == nil { let milliseconds = 200 let nanoseconds = UInt64(milliseconds * 1_000_000) try? await Task.sleep(nanoseconds: nanoseconds) - await presentDebugger(withPaywallId: paywallDatabaseId) + await presentDebugger(withPaywallId: paywallDatabaseId, devSurfaceId: devSurfaceId) } else { await closeDebugger(animated: true) - await launchDebugger(withPaywallId: paywallDatabaseId) + await launchDebugger(withPaywallId: paywallDatabaseId, devSurfaceId: devSurfaceId) } } } @MainActor - func presentDebugger(withPaywallId paywallDatabaseId: String? = nil) async { + func presentDebugger( + withPaywallId paywallDatabaseId: String? = nil, + devSurfaceId: String? = nil + ) async { isDebuggerLaunched = true if let viewController = viewController { if viewController.isBeingPresented { return } viewController.paywallDatabaseId = paywallDatabaseId + viewController.devServer = devServer + viewController.selectDevSurface(id: devSurfaceId) await viewController.loadPreview() await UIViewController.topMostViewController?.present( viewController, @@ -100,6 +112,8 @@ final class DebugManager { ) } else { let viewController = factory.makeDebugViewController(withDatabaseId: paywallDatabaseId) + viewController.devServer = devServer + viewController.selectDevSurface(id: devSurfaceId) UIViewController.topMostViewController?.present( viewController, animated: true, diff --git a/Sources/SuperwallKit/Debug/DebugPaywallPickerViewController.swift b/Sources/SuperwallKit/Debug/DebugPaywallPickerViewController.swift new file mode 100644 index 000000000..29285c7a5 --- /dev/null +++ b/Sources/SuperwallKit/Debug/DebugPaywallPickerViewController.swift @@ -0,0 +1,172 @@ +// +// DebugPaywallPickerViewController.swift +// SuperwallKit +// +// The debugger's paywall list: a searchable, sectioned table of the local +// surfaces a `superwall dev` server serves and the app's published paywalls. +// + +import UIKit + +@MainActor +final class DebugPaywallPickerViewController: UIViewController { + private let localSurfaceIds: [String] + private let publishedNames: [String] + private let selectedLocalId: String? + private let selectedPublishedIndex: Int? + private let onSelect: (DebugPickerLogic.Kind) -> Void + + private var sections: [DebugPickerLogic.Section] = [] + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .insetGrouped) + table.backgroundColor = darkBackgroundColor + table.separatorColor = UIColor.white.withAlphaComponent(0.1) + table.dataSource = self + table.delegate = self + table.keyboardDismissMode = .onDrag + table.translatesAutoresizingMaskIntoConstraints = false + return table + }() + + private lazy var searchController: UISearchController = { + let controller = UISearchController(searchResultsController: nil) + controller.searchResultsUpdater = self + controller.obscuresBackgroundDuringPresentation = false + controller.searchBar.placeholder = "Search paywalls" + controller.searchBar.tintColor = primaryColor + controller.searchBar.searchTextField.textColor = .white + return controller + }() + + init( + localSurfaceIds: [String], + publishedNames: [String], + selectedLocalId: String?, + selectedPublishedIndex: Int?, + onSelect: @escaping (DebugPickerLogic.Kind) -> Void + ) { + self.localSurfaceIds = localSurfaceIds + self.publishedNames = publishedNames + self.selectedLocalId = selectedLocalId + self.selectedPublishedIndex = selectedPublishedIndex + self.onSelect = onSelect + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = darkBackgroundColor + title = "Paywalls" + + navigationItem.searchController = searchController + navigationItem.hidesSearchBarWhenScrolling = false + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .close, + target: self, + action: #selector(pressedClose) + ) + navigationItem.rightBarButtonItem?.tintColor = primaryColor + + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + reload(query: "") + } + + private func reload(query: String) { + sections = DebugPickerLogic.sections( + localSurfaceIds: localSurfaceIds, + publishedNames: publishedNames, + selectedLocalId: selectedLocalId, + selectedPublishedIndex: selectedPublishedIndex, + query: query + ) + tableView.reloadData() + } + + @objc private func pressedClose() { + dismiss(animated: true) + } +} + +// MARK: - Table + +extension DebugPaywallPickerViewController: UITableViewDataSource, UITableViewDelegate { + func numberOfSections(in tableView: UITableView) -> Int { + return sections.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return sections[section].rows.count + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + return sections[section].title + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let row = sections[indexPath.section].rows[indexPath.row] + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + cell.backgroundColor = lightBackgroundColor + cell.textLabel?.text = row.title + cell.textLabel?.textColor = .white + cell.textLabel?.font = .systemFont(ofSize: 16, weight: row.isSelected ? .semibold : .regular) + cell.accessoryType = row.isSelected ? .checkmark : .none + cell.tintColor = primaryColor + let selected = UIView() + selected.backgroundColor = UIColor.white.withAlphaComponent(0.08) + cell.selectedBackgroundView = selected + return cell + } + + func tableView( + _ tableView: UITableView, + willDisplayHeaderView view: UIView, + forSection section: Int + ) { + guard let header = view as? UITableViewHeaderFooterView else { + return + } + // A grouped header renders through its content configuration on iOS 14+, + // which ignores `textLabel` — the default grey is unreadable on the + // debugger's near-black sheet. + if #available(iOS 14.0, *) { + var configuration = header.defaultContentConfiguration() + configuration.text = sections[section].title + configuration.textProperties.color = UIColor.white.withAlphaComponent(0.5) + configuration.textProperties.font = .systemFont(ofSize: 13, weight: .semibold) + header.contentConfiguration = configuration + } else { + header.textLabel?.textColor = UIColor.white.withAlphaComponent(0.5) + header.textLabel?.font = .systemFont(ofSize: 13, weight: .semibold) + } + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + let row = sections[indexPath.section].rows[indexPath.row] + let onSelect = self.onSelect + dismiss(animated: true) { + onSelect(row.kind) + } + } +} + +// MARK: - Search + +extension DebugPaywallPickerViewController: UISearchResultsUpdating { + func updateSearchResults(for searchController: UISearchController) { + reload(query: searchController.searchBar.text ?? "") + } +} diff --git a/Sources/SuperwallKit/Debug/DebugPickerLogic.swift b/Sources/SuperwallKit/Debug/DebugPickerLogic.swift new file mode 100644 index 000000000..6dd064651 --- /dev/null +++ b/Sources/SuperwallKit/Debug/DebugPickerLogic.swift @@ -0,0 +1,63 @@ +// +// DebugPickerLogic.swift +// SuperwallKit +// +// Builds the debugger's paywall list: the surfaces a running +// `superwall dev` server serves, then the paywalls the app has published. +// + +import Foundation + +enum DebugPickerLogic { + enum Kind: Equatable { + case local(index: Int) + case published(index: Int) + } + + struct Row: Equatable { + let title: String + let kind: Kind + let isSelected: Bool + } + + struct Section: Equatable { + let title: String + let rows: [Row] + } + + static let localTitle = "Local · superwall dev" + static let publishedTitle = "Published" + + static func sections( + localSurfaceIds: [String], + publishedNames: [String], + selectedLocalId: String?, + selectedPublishedIndex: Int?, + query: String = "" + ) -> [Section] { + let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + func matches(_ title: String) -> Bool { + return needle.isEmpty || title.lowercased().contains(needle) + } + + let local = localSurfaceIds.enumerated() + .filter { matches($0.element) } + .map { index, id in + Row(title: id, kind: .local(index: index), isSelected: id == selectedLocalId) + } + let published = publishedNames.enumerated() + .filter { matches($0.element) } + .map { index, name in + Row( + title: name, + kind: .published(index: index), + isSelected: index == selectedPublishedIndex && selectedLocalId == nil + ) + } + + return [ + Section(title: localTitle, rows: local), + Section(title: publishedTitle, rows: published) + ].filter { !$0.rows.isEmpty } + } +} diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index e02936f7a..dcd0b5891 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -118,6 +118,14 @@ final class DebugViewController: UIViewController { var paywallDatabaseId: String? var paywallIdentifier: String? var paywall: Paywall? + + /// Set when the debugger is opened from a `superwall dev` link: the surfaces + /// that server exposes, and where to load them from. + var devServer: (base: URL, surfaces: [DevServerSurface])? + + /// The dev-server surface to render instead of fetching a published paywall. + private var devSurface: DevServerSurface? + /// Backs the "Your Paywalls" picker. /// /// Populated from `GET /v2/paywalls/preview-list`. Empty when the request fails or the app @@ -209,10 +217,30 @@ final class DebugViewController: UIViewController { func loadPreview() async { activityIndicator.startAnimating() previewViewContent?.removeFromSuperview() + await ensureDevServer() await finishLoadingPreview() } + /// Dev mode's local surfaces belong in the debugger however it was opened — + /// a dashboard preview link should list them too, not just a dev link. + private func ensureDevServer() async { + guard devServer == nil, + DevMode.isActive(Superwall.shared.options), + let location = await DevServerLocator.shared.locate( + devServerURL: Superwall.shared.options.devServerURL + ) + else { + return + } + devServer = (base: location.base, surfaces: location.manifest.surfaces) + } + func finishLoadingPreview() async { + if let devSurface = devSurface { + await loadDevServerPreview(surface: devSurface) + return + } + var paywallId: String? if let paywallIdentifier = paywallIdentifier { @@ -247,8 +275,9 @@ final class DebugViewController: UIViewController { ) var paywall = try await paywallRequestManager.getPaywall(from: request) - let productVariables = await storeKitManager.getProductVariables(for: paywall) - paywall.productVariables = productVariables + paywall.productVariables = await withTimeout(seconds: 3) { + await self.storeKitManager.getProductVariables(for: paywall) + } ?? [] self.paywall = paywall self.previewPickerButton.setTitle("\(paywall.name)", for: .normal) @@ -335,41 +364,113 @@ final class DebugViewController: UIViewController { } } - @objc func pressedPreview() { - // Open whenever there is something to switch *to*. That covers an empty list - // (the request failed) and a single-entry list whose one paywall is already - // on screen, without gating on `paywallDatabaseId` — which is nil when the - // deep link carried no `paywall_id` and nothing rendered. That is precisely - // when the picker is most useful, so it must not be inert then. - guard previewPaywalls.contains(where: { $0.id != paywallDatabaseId }) else { return } - - let options: [AlertOption] = previewPaywalls.map { paywall in - var name = paywall.name - - // Optional comparison: with no paywall on screen nothing is marked, which - // is correct rather than a case to guard against. - if paywall.id == paywallDatabaseId { - name = "\(name) ✓" + private func withTimeout( + seconds: Double, + operation: @escaping @Sendable () async -> T + ) async -> T? { + return await withTaskGroup(of: T?.self) { group in + group.addTask { await operation() } + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + return nil } + let result = await group.next() ?? nil + group.cancelAll() + return result + } + } - let alert = AlertOption( - title: name, - action: { [weak self] in - self?.paywallDatabaseId = paywall.id - self?.paywallIdentifier = paywall.identifier - Task { await self?.loadPreview() } - }, - style: .default - ) - return alert + /// Picks the dev-server surface the debugger opens with, if any. + func selectDevSurface(id: String?) { + guard let id = id else { + return } + devSurface = devServer?.surfaces.first { $0.id == id } + } - presentAlert( - title: nil, - message: "Your Paywalls", - options: options, - on: previewPickerButton - ) + /// Renders a surface straight from the dev server, synthesised from the + /// manifest (local URL + the products its `config.ts` declares). Nothing is + /// fetched from the dashboard, so a paywall that has never been pushed — + /// or whose app lives in another environment — still previews. + private func loadDevServerPreview(surface: DevServerSurface) async { + guard + let devServer = devServer, + let location = await DevServerLocator.shared.locate( + devServerURL: Superwall.shared.options.devServerURL + ), + let url = location.manifest.mountURL(for: surface, base: devServer.base) + else { + activityIndicator.stopAnimating() + return + } + + var paywall = Paywall.devServer(surface: surface, url: url) + // Product variables are best-effort here: a surface can name products the + // store has no record of yet, and the preview must still render. + paywall.productVariables = await withTimeout(seconds: 3) { + await self.storeKitManager.getProductVariables(for: paywall) + } ?? [] + self.paywall = paywall + paywallIdentifier = paywall.identifier + paywallDatabaseId = paywall.databaseId + previewPickerButton.setTitle("\(surface.id) (local)", for: .normal) + activityIndicator.stopAnimating() + addPaywallPreview() + } + + /// The published paywalls to offer. The debugger's preview list needs the + /// token a dashboard preview link carries; the downloaded config carries the + /// same paywalls for free, which is what a `superwall dev` link relies on. + private var publishedPaywalls: [(id: String, identifier: String, name: String)] { + if !previewPaywalls.isEmpty { + return previewPaywalls.map { (id: $0.id, identifier: $0.identifier, name: $0.name) } + } + let config = Superwall.shared.dependencyContainer.configManager?.config + return (config?.paywalls ?? []).map { + (id: $0.databaseId, identifier: $0.identifier, name: $0.name) + } + } + + @objc func pressedPreview() { + let devSurfaces = devServer?.surfaces ?? [] + let published = publishedPaywalls + guard !devSurfaces.isEmpty || published.count > 1 || paywallDatabaseId == nil else { + return + } + + let picker = DebugPaywallPickerViewController( + localSurfaceIds: devSurfaces.map { $0.id }, + publishedNames: published.map { $0.name }, + selectedLocalId: devSurface?.id, + selectedPublishedIndex: published.firstIndex { $0.id == paywallDatabaseId } + ) { [weak self] kind in + guard let self = self else { + return + } + switch kind { + case .local(let index): + self.devSurface = devSurfaces[index] + case .published(let index): + self.devSurface = nil + self.paywallDatabaseId = published[index].id + self.paywallIdentifier = published[index].identifier + } + Task { await self.loadPreview() } + } + + let navigationController = UINavigationController(rootViewController: picker) + navigationController.navigationBar.barStyle = .black + navigationController.navigationBar.titleTextAttributes = [.foregroundColor: UIColor.white] + navigationController.modalPresentationStyle = .pageSheet + #if !os(visionOS) + if #available(iOS 15.0, *) { + if let sheet = navigationController.sheetPresentationController { + sheet.detents = [.medium(), .large()] + sheet.prefersGrabberVisible = true + } + } + #endif + present(navigationController, animated: true) } @objc func pressedExitButton() { diff --git a/Sources/SuperwallKit/DeepLinkRouter.swift b/Sources/SuperwallKit/DeepLinkRouter.swift index 39191cf33..db950a3f0 100644 --- a/Sources/SuperwallKit/DeepLinkRouter.swift +++ b/Sources/SuperwallKit/DeepLinkRouter.swift @@ -63,6 +63,10 @@ final class DeepLinkRouter { return true } + if DevServerPreview.handle(url: deepLinkUrl) { + return true + } + // Return true for Superwall deep links (we handled it above) if isSuperwallDeepLink { return true @@ -135,6 +139,10 @@ final class DeepLinkRouter { return true } + if DevServerPreview.outcomeForDeepLink(url: url) != nil { + return true + } + // Check cached config for deepLink_open trigger let cache = Cache() if let config = cache.read(LatestConfig.self) { diff --git a/Sources/SuperwallKit/DevServer/DevMode.swift b/Sources/SuperwallKit/DevServer/DevMode.swift new file mode 100644 index 000000000..21134330c --- /dev/null +++ b/Sources/SuperwallKit/DevServer/DevMode.swift @@ -0,0 +1,46 @@ +// +// DevMode.swift +// SuperwallKit +// +// Dev mode is a development-only facility: it serves paywalls from a local +// `superwall dev` server and simulates purchases. Shipping it to the App +// Store would mean nobody could buy anything, so it is inert in production +// no matter how the SDK was configured. +// + +import Foundation + +enum DevMode { + private static var hasWarnedAboutProduction = false + + /// Whether this build is running somewhere dev mode is allowed. Overridable + /// so tests can exercise the production path, which no simulator can produce. + static var isSandboxEnvironment: () -> Bool = { DeviceHelper.isSandboxEnvironment } + + /// Whether dev mode should actually do anything right now: asked for, and + /// running somewhere it is safe to (simulator, TestFlight, development). + static func isActive(_ options: SuperwallOptions) -> Bool { + guard options.isDevModeEnabled else { + return false + } + guard isSandboxEnvironment() else { + warnAboutProduction() + return false + } + return true + } + + private static func warnAboutProduction() { + guard !hasWarnedAboutProduction else { + return + } + hasWarnedAboutProduction = true + Logger.debug( + logLevel: .warn, + scope: .superwallCore, + message: "SuperwallOptions.devMode is on in a production build, so it is being ignored: " + + "paywalls load their published versions and purchases are real. " + + "Remove devMode before shipping." + ) + } +} diff --git a/Sources/SuperwallKit/DevServer/DevServerManifest.swift b/Sources/SuperwallKit/DevServer/DevServerManifest.swift new file mode 100644 index 000000000..20640d369 --- /dev/null +++ b/Sources/SuperwallKit/DevServer/DevServerManifest.swift @@ -0,0 +1,163 @@ +// +// DevServerManifest.swift +// SuperwallKit +// +// The surface list a running `superwall dev` server exposes at +// /device/manifest.json, used to map dashboard paywalls to locally +// served paywall code when `SuperwallOptions/devMode` is on. +// + +import Foundation + +struct DevServerSurface: Decodable, Equatable { + let kind: String + let id: String + let url: String + let paywallId: String? + let identifier: String? + let products: [String: String]? +} + +struct DevServerManifest: Decodable, Equatable { + let surfaces: [DevServerSurface] + + /// Picks the local surface for a dashboard paywall: an explicit + /// `superwall.lock` binding wins, otherwise a project with exactly one + /// paywall serves it for everything. + func surface(forPaywallDatabaseId databaseId: String) -> DevServerSurface? { + if let bound = surfaces.first(where: { $0.paywallId == databaseId }) { + return bound + } + let paywalls = surfaces.filter { $0.kind == "paywall" } + if paywalls.count == 1 { + return paywalls.first + } + return nil + } + + func mountURL(for surface: DevServerSurface, base: URL) -> URL? { + return URL(string: surface.url, relativeTo: base)?.absoluteURL + } +} + +struct DevServerLocation: Equatable { + let base: URL + let manifest: DevServerManifest +} + +enum DevServerCandidates { + static let defaultPorts = 6100...6104 + + /// The bases dev mode tries, in order: an explicit URL wins, otherwise + /// localhost across the default port range `superwall dev` walks when + /// its preferred port is taken. + static func bases(devServerURL: URL?) -> [URL] { + if let devServerURL = devServerURL { + return [devServerURL] + } + return defaultPorts.compactMap { URL(string: "http://localhost:\($0)") } + } +} + +actor DevServerLocator { + static let shared = DevServerLocator() + + private var cached: (location: DevServerLocation, fetchedAt: Date)? + private var lastMissAt: Date? + private var pinnedBase: URL? + + func pin(base: URL) { + pinnedBase = base + cached = nil + lastMissAt = nil + } + + func locate(devServerURL: URL?) async -> DevServerLocation? { + if let cached = cached, Date().timeIntervalSince(cached.fetchedAt) < 2 { + return cached.location + } + if let lastMissAt = lastMissAt, Date().timeIntervalSince(lastMissAt) < 5 { + return nil + } + + var bases = DevServerCandidates.bases(devServerURL: devServerURL) + if let pinnedBase = pinnedBase { + bases.removeAll { $0 == pinnedBase } + bases.insert(pinnedBase, at: 0) + } + if let cached = cached { + bases.sort { first, _ in first == cached.location.base } + } + + for base in bases { + if let manifest = await fetchManifest(from: base) { + let location = DevServerLocation(base: base, manifest: manifest) + cached = (location, Date()) + lastMissAt = nil + return location + } + } + + cached = nil + lastMissAt = Date() + Logger.debug( + logLevel: .warn, + scope: .superwallCore, + message: "Dev mode is on but no superwall dev server was found at " + + "\(bases.map { $0.absoluteString }.joined(separator: ", ")). " + + "Paywalls will load their published versions. On a physical device, " + + "set SuperwallOptions.devServerURL to the Device URL superwall dev prints." + ) + return nil + } + + private var hasWarnedAboutTransportSecurity = false + + /// App Transport Security blocks plain-http requests unless the app opts in, + /// and the failure is otherwise indistinguishable from "no server there". + private func warnIfBlockedByAppTransportSecurity(_ error: Error, base: URL) { + let code = (error as NSError).code + guard + code == NSURLErrorAppTransportSecurityRequiresSecureConnection, + !hasWarnedAboutTransportSecurity + else { + return + } + hasWarnedAboutTransportSecurity = true + Logger.debug( + logLevel: .error, + scope: .superwallCore, + message: "App Transport Security blocked \(base.absoluteString). Add this to the app's " + + "Info.plist to preview local paywalls:\n" + + "NSAppTransportSecurity\n\n" + + " NSAllowsArbitraryLoadsInWebContent\n" + + " NSAllowsLocalNetworking\n" + ) + } + + private func fetchManifest(from base: URL) async -> DevServerManifest? { + guard let manifestURL = URL(string: "/device/manifest.json", relativeTo: base) else { + return nil + } + var request = URLRequest(url: manifestURL) + request.timeoutInterval = 5 + request.cachePolicy = .reloadIgnoringLocalCacheData + + do { + let data: Data = try await withCheckedThrowingContinuation { continuation in + let task = URLSession.shared.dataTask(with: request) { data, _, error in + if let data = data { + continuation.resume(returning: data) + } else { + continuation.resume(throwing: error ?? URLError(.badServerResponse)) + } + } + task.resume() + } + return try JSONDecoder().decode(DevServerManifest.self, from: data) + } catch { + warnIfBlockedByAppTransportSecurity(error, base: base) + return nil + } + } +} diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift new file mode 100644 index 000000000..20a0697d0 --- /dev/null +++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift @@ -0,0 +1,55 @@ +// +// DevServerPaywall.swift +// SuperwallKit +// +// Builds a `Paywall` for a surface that a running `superwall dev` server +// serves, so the debugger can preview local paywall code that has never +// been pushed to the dashboard. +// + +import Foundation +import UIKit + +extension Paywall { + static func devServer(surface: DevServerSurface, url: URL) -> Paywall { + let products = (surface.products ?? [:]) + .sorted { $0.key < $1.key } + .map { reference, identifier in + Product( + name: reference, + type: .appStore(.init(id: identifier)), + id: identifier, + entitlements: [] + ) + } + + return Paywall( + databaseId: surface.paywallId ?? "dev:\(surface.kind)/\(surface.id)", + identifier: surface.identifier ?? "dev:\(surface.id)", + name: surface.id, + cacheKey: "dev:\(surface.id):\(url.absoluteString)", + buildId: "dev", + url: url, + urlConfig: WebViewURLConfig( + endpoints: [WebViewEndpoint(url: url, timeout: 15, percentage: 100)], + maxAttempts: 1 + ), + htmlSubstitutions: "", + presentation: PaywallPresentationInfo(style: .modal, delay: 0), + backgroundColorHex: "#FFFFFF", + backgroundColor: .white, + darkBackgroundColorHex: nil, + darkBackgroundColor: nil, + productItems: products, + productIds: products.map { $0.id }, + appStoreProductIds: products.map { $0.id }, + responseLoadingInfo: .init(), + webviewLoadingInfo: .init(), + productsLoadingInfo: .init(), + shimmerLoadingInfo: .init(), + paywalljsVersion: "", + isScrollEnabled: true, + introOfferEligibility: .automatic + ) + } +} diff --git a/Sources/SuperwallKit/DevServer/DevServerPreview.swift b/Sources/SuperwallKit/DevServer/DevServerPreview.swift new file mode 100644 index 000000000..0c46780a7 --- /dev/null +++ b/Sources/SuperwallKit/DevServer/DevServerPreview.swift @@ -0,0 +1,76 @@ +// +// DevServerPreview.swift +// SuperwallKit +// +// Handles superwall_dev deep links: scanning the QR that `superwall dev` +// prints opens this in-app picker of the dev server's local surfaces, and +// selecting one presents it through the real paywall pipeline (which the +// dev mode override then points at the local bytes). +// + +import Combine +import Foundation +import UIKit + +enum DevServerPreview { + struct DeepLinkOutcome: Equatable { + let base: URL + let surfaceId: String? + } + + static func outcomeForDeepLink(url: URL) -> DeepLinkOutcome? { + guard + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let items = components.queryItems, + let raw = items.first(where: { $0.name == "superwall_dev" })?.value, + let base = URL(string: raw), + base.scheme == "http" || base.scheme == "https" + else { + return nil + } + let surfaceId = items.first(where: { $0.name == "superwall_dev_surface" })?.value + return DeepLinkOutcome(base: base, surfaceId: surfaceId) + } + + static func handle(url: URL) -> Bool { + guard let outcome = outcomeForDeepLink(url: url) else { + return false + } + Task { @MainActor in + await open(outcome: outcome) + } + return true + } + + @MainActor + private static func open(outcome: DeepLinkOutcome) async { + guard DevMode.isActive(Superwall.shared.options) else { + Logger.debug( + logLevel: .warn, + scope: .superwallCore, + message: "Scanned a superwall dev link, but SuperwallOptions.devMode is off " + + "in this build. Enable devMode to preview local paywalls in the app." + ) + return + } + await DevServerLocator.shared.pin(base: outcome.base) + guard + let location = await DevServerLocator.shared.locate( + devServerURL: Superwall.shared.options.devServerURL + ) + else { + return + } + + guard let debugManager: DebugManager = Superwall.shared.dependencyContainer.debugManager else { + return + } + debugManager.devServer = (base: location.base, surfaces: location.manifest.surfaces) + await debugManager.launchDebugger( + withPaywallId: nil, + devSurfaceId: outcome.surfaceId ?? location.manifest.surfaces.first(where: { + $0.kind == "paywall" + })?.id + ) + } +} diff --git a/Sources/SuperwallKit/Models/Paywall/Paywall.swift b/Sources/SuperwallKit/Models/Paywall/Paywall.swift index 208268048..1033735ca 100644 --- a/Sources/SuperwallKit/Models/Paywall/Paywall.swift +++ b/Sources/SuperwallKit/Models/Paywall/Paywall.swift @@ -28,7 +28,7 @@ struct Paywall: Codable { var url: URL /// An array of potential URLs to load the paywall from. - let urlConfig: WebViewURLConfig + var urlConfig: WebViewURLConfig /// Contains the website modifications that are made on the paywall editor to be accepted /// by the webview. @@ -151,7 +151,7 @@ struct Paywall: Codable { /// A listing of all the files referenced in a paywall to be able to preload the whole /// paywall into a web archive. - let manifest: ArchiveManifest? + var manifest: ArchiveManifest? /// The state of the paywall, updated on paywall did dismiss. var state: [String: Any] = [:] @@ -366,8 +366,8 @@ struct Paywall: Codable { try container.encodeIfPresent(introOfferEligibility, forKey: .introductoryOfferEligibility) } - // Only used in stub - private init( + // Used by the stub and by `Paywall.devServer(surface:url:)`. + init( databaseId: String, identifier: String, name: String, diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index c59f84159..319fd289a 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -592,6 +592,13 @@ class DeviceHelper { return Self.detectSandbox() } + /// Whether the app is running outside App Store production: simulator, + /// TestFlight, or a development build. Unlike ``isSandbox`` this ignores + /// test mode, so it can be used to decide whether test mode may activate. + static var isSandboxEnvironment: Bool { + return detectSandbox() == "true" + } + private static func detectSandbox() -> String { #if targetEnvironment(simulator) return "true" diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift index e4fce9a23..6e7e161e8 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift @@ -16,6 +16,9 @@ extension PaywallRequestManager { placement: request.placementData ) var paywall = try await getPaywallResponse(from: request) + if !request.isDebuggerLaunched { + paywall = await applyDevServerOverrideIfNeeded(to: paywall) + } paywall.presentationId = UUID().uuidString let paywallInfo = paywall.getInfo(fromPlacement: request.placementData) @@ -27,6 +30,41 @@ extension PaywallRequestManager { return paywall } + func applyDevServerOverrideIfNeeded(to paywall: Paywall) async -> Paywall { + let options = factory.makeSuperwallOptions() + guard DevMode.isActive(options) else { + return paywall + } + guard + let location = await DevServerLocator.shared.locate(devServerURL: options.devServerURL), + let surface = location.manifest.surface(forPaywallDatabaseId: paywall.databaseId), + let mountURL = location.manifest.mountURL(for: surface, base: location.base) + else { + return paywall + } + + var paywall = paywall + paywall.url = mountURL + paywall.urlConfig = WebViewURLConfig( + endpoints: [ + WebViewEndpoint( + url: mountURL, + timeout: 15, + percentage: 100 + ) + ], + maxAttempts: 1 + ) + paywall.manifest = nil + + Logger.debug( + logLevel: .info, + scope: .superwallCore, + message: "Dev server override: paywall \(paywall.identifier) renders from \(mountURL.absoluteString)." + ) + return paywall + } + private func getPaywallResponse( from request: PaywallRequest ) async throws -> Paywall { diff --git a/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift b/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift index 4a2ed4026..6ff30e776 100644 --- a/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift +++ b/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift @@ -19,6 +19,7 @@ actor PaywallRequestManager { typealias Factory = DeviceHelperFactory & ConfigManagerFactory & ReceiptFactory + & OptionsFactory init( storeKitManager: StoreKitManager, diff --git a/Sources/SuperwallKit/TestMode/TestModeManager.swift b/Sources/SuperwallKit/TestMode/TestModeManager.swift index 9f5db3044..ab59ab08c 100644 --- a/Sources/SuperwallKit/TestMode/TestModeManager.swift +++ b/Sources/SuperwallKit/TestMode/TestModeManager.swift @@ -109,6 +109,11 @@ final class TestModeManager { /// Evaluates whether the current user should be in test mode based on the config /// and the `testModeBehavior` option. Called on every config refresh. func evaluateTestMode(config: Config, options: SuperwallOptions) { + if DevMode.isActive(options) { + isTestMode = true + testModeReason = .testModeOption + return + } switch options.testModeBehavior { case .never: isTestMode = false diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 817f1bf3c..1e4c1f4e8 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -249,6 +249,16 @@ 744F0D34C800E17CF8462820 /* URLSessionRetryLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C054891709C534F59C93C815 /* URLSessionRetryLogicTests.swift */; }; 746FCA3A2499F7BAF653F205 /* PermissionHandler+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 405C59153A88E6B9D664585A /* PermissionHandler+Notification.swift */; }; 7477EFEA4C42BB441B92D096 /* RawPaywallResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5637C2D7DDA38C11E48DD1C /* RawPaywallResponse.swift */; }; + DE05E27E00000000000001A2 /* DevServerManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE05E27E00000000000001A1 /* DevServerManifest.swift */; }; + DE05E27E00000000000001A5 /* DevServerPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE05E27E00000000000001A4 /* DevServerPreview.swift */; }; + DE05E27E00000000000001A7 /* DevServerPaywall.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE05E27E00000000000001A6 /* DevServerPaywall.swift */; }; + DE05E27E00000000000001AD /* DevMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE05E27E00000000000001AC /* DevMode.swift */; }; + DE05E27E00000000000001A9 /* DebugPickerLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE05E27E00000000000001A8 /* DebugPickerLogic.swift */; }; + DE05E27E00000000000001AB /* DebugPaywallPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE05E27E00000000000001AA /* DebugPaywallPickerViewController.swift */; }; + DE05E27E00000000000001B5 /* DebugPickerLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE05E27E00000000000001B4 /* DebugPickerLogicTests.swift */; }; + DE05E27E00000000000001B2 /* DevServerManifestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE05E27E00000000000001B1 /* DevServerManifestTests.swift */; }; + DE05E27E00000000000001B7 /* DevModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE05E27E00000000000001B6 /* DevModeTests.swift */; }; + DE05E27E00000000000001B9 /* DevServerPaywallTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE05E27E00000000000001B8 /* DevServerPaywallTests.swift */; }; 749117DF0A2364453CCED102 /* LocalizationOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7FFF0EDFF6DA910E4B5CCB7 /* LocalizationOption.swift */; }; 7494124F44F712EC7138C7DF /* UserAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52A0EFBBFE9D2F949EA4C28 /* UserAttributes.swift */; }; 75083E470EB6E25E01F4F28B /* AsyncSequence+Extract.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B1DC32C4ABB60B8323E5D28 /* AsyncSequence+Extract.swift */; }; @@ -1016,6 +1026,16 @@ B48AAFA27917F0BE3ADC6FFB /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; B52A0EFBBFE9D2F949EA4C28 /* UserAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAttributes.swift; sourceTree = ""; }; B5637C2D7DDA38C11E48DD1C /* RawPaywallResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RawPaywallResponse.swift; sourceTree = ""; }; + DE05E27E00000000000001A1 /* DevServerManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerManifest.swift; sourceTree = ""; }; + DE05E27E00000000000001A4 /* DevServerPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerPreview.swift; sourceTree = ""; }; + DE05E27E00000000000001A6 /* DevServerPaywall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerPaywall.swift; sourceTree = ""; }; + DE05E27E00000000000001AC /* DevMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevMode.swift; sourceTree = ""; }; + DE05E27E00000000000001A8 /* DebugPickerLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugPickerLogic.swift; sourceTree = ""; }; + DE05E27E00000000000001AA /* DebugPaywallPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugPaywallPickerViewController.swift; sourceTree = ""; }; + DE05E27E00000000000001B4 /* DebugPickerLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugPickerLogicTests.swift; sourceTree = ""; }; + DE05E27E00000000000001B1 /* DevServerManifestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerManifestTests.swift; sourceTree = ""; }; + DE05E27E00000000000001B6 /* DevModeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevModeTests.swift; sourceTree = ""; }; + DE05E27E00000000000001B8 /* DevServerPaywallTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerPaywallTests.swift; sourceTree = ""; }; B634347011742D475E3F1A27 /* ConfigLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigLogic.swift; sourceTree = ""; }; B6EB705DC16CB1AC24B75BA7 /* pt_PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt_PT; path = pt_PT.lproj/Localizable.strings; sourceTree = ""; }; B6F71D7A7DC8FFB72CA13296 /* PaywallRequestBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallRequestBody.swift; sourceTree = ""; }; @@ -1725,6 +1745,7 @@ isa = PBXGroup; children = ( C733A9BE56EA9E10D75B073B /* SWDebugManagerLogicTests.swift */, + DE05E27E00000000000001B4 /* DebugPickerLogicTests.swift */, ); path = Debug; sourceTree = ""; @@ -2278,6 +2299,7 @@ 97F6AA52B81B82F72AB80D7C /* Debug */, 9C21AAF80FD220C960FE568F /* Delegate */, A34416D82C5BDBAA82A119C1 /* Dependencies */, + DE05E27E00000000000001A3 /* DevServer */, 5943C0902B0D5EC30C0C3B8C /* Game Controller */, C36C5C30F60C1DFCDCF25E16 /* Graveyard */, 66F9C998E9BBCFFCF80386FE /* Identity */, @@ -2296,6 +2318,27 @@ path = SuperwallKit; sourceTree = ""; }; + DE05E27E00000000000001A3 /* DevServer */ = { + isa = PBXGroup; + children = ( + DE05E27E00000000000001A1 /* DevServerManifest.swift */, + DE05E27E00000000000001A4 /* DevServerPreview.swift */, + DE05E27E00000000000001A6 /* DevServerPaywall.swift */, + DE05E27E00000000000001AC /* DevMode.swift */, + ); + path = DevServer; + sourceTree = ""; + }; + DE05E27E00000000000001B3 /* DevServer */ = { + isa = PBXGroup; + children = ( + DE05E27E00000000000001B1 /* DevServerManifestTests.swift */, + DE05E27E00000000000001B6 /* DevModeTests.swift */, + DE05E27E00000000000001B8 /* DevServerPaywallTests.swift */, + ); + path = DevServer; + sourceTree = ""; + }; 86DD4496D1218324B5CBBB89 /* Paywall */ = { isa = PBXGroup; children = ( @@ -2444,6 +2487,8 @@ isa = PBXGroup; children = ( 5383FA48A6E9EF8F30683C9B /* DebugManager.swift */, + DE05E27E00000000000001A8 /* DebugPickerLogic.swift */, + DE05E27E00000000000001AA /* DebugPaywallPickerViewController.swift */, F9098101E599AEB01521FE89 /* DebugViewController.swift */, 299F91895EE88281B5ED8320 /* SWBounceButton.swift */, 3CDFBF0FA8B313E0D84A51DB /* SWConsoleViewController.swift */, @@ -2716,6 +2761,7 @@ D554340BB6652F5FA1F21FF8 /* Config */, 38C02C19ED9C9958A7A61FB1 /* Debug */, 3B16D25FCB6991D55E0F63B3 /* DeepLink */, + DE05E27E00000000000001B3 /* DevServer */, 373AFF230833A951B6E5DF36 /* Identity */, 8DD7B7C5E111EAB0878886B6 /* Logger */, 4D7656D6A565958F58A644AF /* Misc */, @@ -3289,6 +3335,10 @@ 654803E77F7CDBF6282D0110 /* Date+IsWithinAnHourBeforeTests.swift in Sources */, D91750797BB4947F6975B2B9 /* Date+IsoStringTests.swift in Sources */, 01BE837B492223B76A95CB5D /* DeepLinkRouterTests.swift in Sources */, + DE05E27E00000000000001B2 /* DevServerManifestTests.swift in Sources */, + DE05E27E00000000000001B7 /* DevModeTests.swift in Sources */, + DE05E27E00000000000001B9 /* DevServerPaywallTests.swift in Sources */, + DE05E27E00000000000001B5 /* DebugPickerLogicTests.swift in Sources */, 0CA13E721ADB243882536D4A /* DeviceHelperMock.swift in Sources */, 9DBDDD10A1EFC7CD3575D9E5 /* DeviceHelperTests.swift in Sources */, 2743143ED664F942D5D758B1 /* DevicePreloadScriptTests.swift in Sources */, @@ -3678,6 +3728,12 @@ 16622133C8DD73C2B153A32A /* Queue.swift in Sources */, FC051A3A8D640AF49D798B25 /* RawExperiment.swift in Sources */, 7477EFEA4C42BB441B92D096 /* RawPaywallResponse.swift in Sources */, + DE05E27E00000000000001A2 /* DevServerManifest.swift in Sources */, + DE05E27E00000000000001A5 /* DevServerPreview.swift in Sources */, + DE05E27E00000000000001A7 /* DevServerPaywall.swift in Sources */, + DE05E27E00000000000001AD /* DevMode.swift in Sources */, + DE05E27E00000000000001A9 /* DebugPickerLogic.swift in Sources */, + DE05E27E00000000000001AB /* DebugPaywallPickerViewController.swift in Sources */, B3E6E82C0240EE6048360C9B /* RawWebMessageHandler.swift in Sources */, 4E7761949715C8BF8DEEF35C /* ReceiptLogic.swift in Sources */, 5634C4E0E082754F7939BB60 /* ReceiptManager.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Debug/DebugPickerLogicTests.swift b/Tests/SuperwallKitTests/Debug/DebugPickerLogicTests.swift new file mode 100644 index 000000000..f64f3f99d --- /dev/null +++ b/Tests/SuperwallKitTests/Debug/DebugPickerLogicTests.swift @@ -0,0 +1,81 @@ +// +// DebugPickerLogicTests.swift +// SuperwallKitTests +// + +import XCTest +@testable import SuperwallKit + +final class DebugPickerLogicTests: XCTestCase { + func test_splitsLocalSurfacesAndPublishedPaywallsIntoSections() { + let sections = DebugPickerLogic.sections( + localSurfaceIds: ["chatgpt-plus", "pro"], + publishedNames: ["Winback", "Onboarding"], + selectedLocalId: "pro", + selectedPublishedIndex: nil + ) + XCTAssertEqual(sections.map { $0.title }, [ + DebugPickerLogic.localTitle, + DebugPickerLogic.publishedTitle + ]) + XCTAssertEqual(sections[0].rows.map { $0.title }, ["chatgpt-plus", "pro"]) + XCTAssertEqual(sections[0].rows.map { $0.kind }, [.local(index: 0), .local(index: 1)]) + XCTAssertEqual(sections[1].rows.map { $0.kind }, [.published(index: 0), .published(index: 1)]) + } + + func test_marksTheShowingPaywall() { + let local = DebugPickerLogic.sections( + localSurfaceIds: ["pro"], + publishedNames: ["Winback"], + selectedLocalId: "pro", + selectedPublishedIndex: 0 + ) + XCTAssertTrue(local[0].rows[0].isSelected) + // a local surface is on screen, so no published row is marked + XCTAssertFalse(local[1].rows[0].isSelected) + + let published = DebugPickerLogic.sections( + localSurfaceIds: [], + publishedNames: ["Winback", "Onboarding"], + selectedLocalId: nil, + selectedPublishedIndex: 1 + ) + XCTAssertEqual(published[0].rows.filter { $0.isSelected }.map { $0.title }, ["Onboarding"]) + } + + func test_searchFiltersBothSectionsAndDropsEmptyOnes() { + let sections = DebugPickerLogic.sections( + localSurfaceIds: ["chatgpt-plus", "pro"], + publishedNames: ["Winback"], + selectedLocalId: nil, + selectedPublishedIndex: nil, + query: " CHAT " + ) + XCTAssertEqual(sections.count, 1) + XCTAssertEqual(sections[0].title, DebugPickerLogic.localTitle) + XCTAssertEqual(sections[0].rows.map { $0.title }, ["chatgpt-plus"]) + // the row still points at its original index, not the filtered one + XCTAssertEqual(sections[0].rows[0].kind, .local(index: 0)) + } + + func test_keepsIndicesStableWhenSearchHidesEarlierRows() { + let sections = DebugPickerLogic.sections( + localSurfaceIds: ["alpha", "beta", "gamma"], + publishedNames: [], + selectedLocalId: nil, + selectedPublishedIndex: nil, + query: "gamma" + ) + XCTAssertEqual(sections[0].rows.map { $0.kind }, [.local(index: 2)]) + } + + func test_omitsASectionWithNothingInIt() { + let sections = DebugPickerLogic.sections( + localSurfaceIds: [], + publishedNames: ["Winback"], + selectedLocalId: nil, + selectedPublishedIndex: 0 + ) + XCTAssertEqual(sections.map { $0.title }, [DebugPickerLogic.publishedTitle]) + } +} diff --git a/Tests/SuperwallKitTests/DevServer/DevModeTests.swift b/Tests/SuperwallKitTests/DevServer/DevModeTests.swift new file mode 100644 index 000000000..c35482f7c --- /dev/null +++ b/Tests/SuperwallKitTests/DevServer/DevModeTests.swift @@ -0,0 +1,48 @@ +// +// DevModeTests.swift +// SuperwallKitTests +// + +import XCTest +@testable import SuperwallKit + +final class DevModeTests: XCTestCase { + override func tearDown() { + DevMode.isSandboxEnvironment = { DeviceHelper.isSandboxEnvironment } + super.tearDown() + } + + private func options(devMode: Bool = false, devServerURL: URL? = nil) -> SuperwallOptions { + let options = SuperwallOptions() + options.devMode = devMode + options.devServerURL = devServerURL + return options + } + + func test_isInactiveWhenNobodyAskedForIt() { + DevMode.isSandboxEnvironment = { true } + XCTAssertFalse(DevMode.isActive(options())) + } + + func test_isActiveInSandboxWhenTheToggleIsOn() { + DevMode.isSandboxEnvironment = { true } + XCTAssertTrue(DevMode.isActive(options(devMode: true))) + } + + /// The one that matters: an App Store build must behave as if dev mode was + /// never set, so purchases stay real and paywalls stay published. + func test_isInertInProductionEvenWhenTheToggleIsOn() { + DevMode.isSandboxEnvironment = { false } + XCTAssertFalse(DevMode.isActive(options(devMode: true))) + } + + func test_anExplicitDevServerUrlAlsoImpliesDevModeAndIsAlsoGated() throws { + let url = try XCTUnwrap(URL(string: "http://192.168.1.10:6100")) + + DevMode.isSandboxEnvironment = { true } + XCTAssertTrue(DevMode.isActive(options(devServerURL: url))) + + DevMode.isSandboxEnvironment = { false } + XCTAssertFalse(DevMode.isActive(options(devServerURL: url))) + } +} diff --git a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift new file mode 100644 index 000000000..d233f0943 --- /dev/null +++ b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift @@ -0,0 +1,117 @@ +// +// DevServerManifestTests.swift +// SuperwallKitTests +// + +import XCTest +@testable import SuperwallKit + +final class DevServerManifestTests: XCTestCase { + private func manifest(_ json: String) throws -> DevServerManifest { + return try JSONDecoder().decode(DevServerManifest.self, from: Data(json.utf8)) + } + + func test_decodesManifestJson() throws { + let decoded = try manifest(""" + { + "surfaces": [ + { "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro", "paywallId": "12345" }, + { "kind": "funnel", "id": "onboarding", "url": "/preview/funnel/onboarding" } + ] + } + """) + XCTAssertEqual(decoded.surfaces.count, 2) + XCTAssertEqual(decoded.surfaces[0].paywallId, "12345") + XCTAssertNil(decoded.surfaces[1].paywallId) + } + + func test_boundPaywallWinsOverSingleFallback() throws { + let decoded = try manifest(""" + { + "surfaces": [ + { "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro", "paywallId": "12345" }, + { "kind": "paywall", "id": "max", "url": "/preview/paywall/max", "paywallId": "678" } + ] + } + """) + XCTAssertEqual(decoded.surface(forPaywallDatabaseId: "678")?.id, "max") + } + + func test_singlePaywallServesEveryDatabaseId() throws { + let decoded = try manifest(""" + { + "surfaces": [ + { "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro" }, + { "kind": "funnel", "id": "onboarding", "url": "/preview/funnel/onboarding" } + ] + } + """) + XCTAssertEqual(decoded.surface(forPaywallDatabaseId: "anything")?.id, "pro") + } + + func test_severalUnboundPaywallsMatchNothing() throws { + let decoded = try manifest(""" + { + "surfaces": [ + { "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro" }, + { "kind": "paywall", "id": "max", "url": "/preview/paywall/max" } + ] + } + """) + XCTAssertNil(decoded.surface(forPaywallDatabaseId: "anything")) + } + + func test_candidatesDefaultToLocalhostAcrossTheDevPortRange() { + let bases = DevServerCandidates.bases(devServerURL: nil) + XCTAssertEqual( + bases.map { $0.absoluteString }, + (6100...6104).map { "http://localhost:\($0)" } + ) + } + + func test_anExplicitDevServerUrlIsTheOnlyCandidate() throws { + let url = try XCTUnwrap(URL(string: "http://192.168.1.10:7000")) + XCTAssertEqual(DevServerCandidates.bases(devServerURL: url), [url]) + } + + func test_decodesTheIdentifierWhenTheManifestCarriesIt() throws { + let decoded = try manifest(""" + { "surfaces": [{ "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro", "paywallId": "1", "identifier": "pro-slug" }] } + """) + XCTAssertEqual(decoded.surfaces.first?.identifier, "pro-slug") + } + + func test_devLinkOutcomeParsesBaseAndOptionalSurface() throws { + let base = try XCTUnwrap(URL(string: "exampleapp://?superwall_dev=http://192.168.1.10:6100")) + let outcome = try XCTUnwrap(DevServerPreview.outcomeForDeepLink(url: base)) + XCTAssertEqual(outcome.base.absoluteString, "http://192.168.1.10:6100") + XCTAssertNil(outcome.surfaceId) + + let direct = try XCTUnwrap(URL( + string: "exampleapp://?superwall_dev=http://localhost:6100&superwall_dev_surface=chatgpt-plus" + )) + XCTAssertEqual( + DevServerPreview.outcomeForDeepLink(url: direct)?.surfaceId, + "chatgpt-plus" + ) + } + + func test_devLinkOutcomeRejectsNonHttpBasesAndOtherLinks() throws { + let js = try XCTUnwrap(URL(string: "exampleapp://?superwall_dev=javascript:alert(1)")) + XCTAssertNil(DevServerPreview.outcomeForDeepLink(url: js)) + let debug = try XCTUnwrap(URL(string: "exampleapp://?superwall_debug=true&token=abc")) + XCTAssertNil(DevServerPreview.outcomeForDeepLink(url: debug)) + } + + func test_mountUrlResolvesAgainstTheDevServerOrigin() throws { + let decoded = try manifest(""" + { "surfaces": [{ "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro" }] } + """) + let surface = try XCTUnwrap(decoded.surfaces.first) + let base = try XCTUnwrap(URL(string: "http://192.168.1.10:6100")) + XCTAssertEqual( + decoded.mountURL(for: surface, base: base)?.absoluteString, + "http://192.168.1.10:6100/preview/paywall/pro" + ) + } +} diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift new file mode 100644 index 000000000..656a90f9c --- /dev/null +++ b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift @@ -0,0 +1,75 @@ +// +// DevServerPaywallTests.swift +// SuperwallKitTests +// + +import XCTest +@testable import SuperwallKit + +final class DevServerPaywallTests: XCTestCase { + private func surface( + id: String = "pro", + paywallId: String? = nil, + identifier: String? = nil, + products: [String: String]? = nil + ) -> DevServerSurface { + let json = """ + { + "kind": "paywall", + "id": "\(id)", + "url": "/preview/paywall/\(id)", + \(paywallId.map { "\"paywallId\": \"\($0)\"," } ?? "") + \(identifier.map { "\"identifier\": \"\($0)\"," } ?? "") + "products": \(products.map { dict in + "{" + dict.map { "\"\($0.key)\": \"\($0.value)\"" }.sorted().joined(separator: ",") + "}" + } ?? "null") + } + """ + // swiftlint:disable:next force_try + return try! JSONDecoder().decode(DevServerSurface.self, from: Data(json.utf8)) + } + + private let url = URL(string: "http://localhost:6100/preview/paywall/pro")! + + func test_pointsEveryUrlAtTheDevServerAndDisablesTheArchive() { + let paywall = Paywall.devServer(surface: surface(), url: url) + + XCTAssertEqual(paywall.url, url) + XCTAssertEqual(paywall.urlConfig.endpoints.map { $0.url }, [url]) + XCTAssertEqual(paywall.urlConfig.maxAttempts, 1) + // a local build is never the archived, published bytes + XCTAssertNil(paywall.manifest) + XCTAssertFalse(paywall.isUsingManifest) + } + + func test_carriesTheProductsTheSurfaceDeclares() { + let paywall = Paywall.devServer( + surface: surface(products: ["plus": "chatgpt_plus_1999_month", "go": "chatgpt_go_999_month"]), + url: url + ) + + // sorted by reference name, so the order is stable across runs + XCTAssertEqual(paywall.products.map { $0.name }, ["go", "plus"]) + XCTAssertEqual(paywall.productIds, ["chatgpt_go_999_month", "chatgpt_plus_1999_month"]) + XCTAssertEqual(paywall.appStoreProductIds, ["chatgpt_go_999_month", "chatgpt_plus_1999_month"]) + } + + func test_worksForASurfaceThatHasNeverBeenPushed() { + let paywall = Paywall.devServer(surface: surface(id: "draft"), url: url) + + XCTAssertEqual(paywall.name, "draft") + XCTAssertTrue(paywall.databaseId.contains("draft")) + XCTAssertTrue(paywall.identifier.contains("draft")) + XCTAssertTrue(paywall.products.isEmpty) + } + + func test_keepsTheDashboardIdentityOfAPushedSurface() { + let paywall = Paywall.devServer( + surface: surface(paywallId: "253583", identifier: "chatgpt-plus"), + url: url + ) + + XCTAssertEqual(paywall.databaseId, "253583") + XCTAssertEqual(paywall.identifier, "chatgpt-plus") + } +} From b042bb76ba30f1ae1680235f771dd4e47e6427b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:26:03 +0200 Subject: [PATCH 019/162] fix(configure): stop the container escaping to the redeemer mid-init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DependencyContainer.init passed itself as the factory to WebEntitlementRedeemer, whose init immediately spawned a task calling makeIsContainerReady() on a background thread — reading configManager while init was still assigning stored properties. Thread Sanitizer flags this as a data race on every plain configure() call, and the racy guard also made the cold-launch Stripe recovery poll fire only when it happened to lose the race. The redeemer no longer starts any work in its init. The cold-launch poll is now kicked off explicitly by DependencyContainer as the last statement of its init, once every dependency is assigned — which also gives the background task a proper happens-before edge on all of the container's stored properties and makes the poll deterministic. Fixes #504 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + .../Dependencies/DependencyContainer.swift | 4 + .../Web/WebEntitlementRedeemer.swift | 12 ++- SuperwallKit.xcodeproj/project.pbxproj | 18 +++- .../xcschemes/SuperwallKit.xcscheme | 3 +- .../DependencyContainerInitTests.swift | 25 ++++++ .../Web/WebEntitlementRedeemerTests.swift | 83 ++++++++++++++++++- 7 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 Tests/SuperwallKitTests/Dependencies/DependencyContainerInitTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 3179377bd..a8ed9f98a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Fixes - Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. +- Fixes a data race during SDK configuration that Thread Sanitizer flagged on every launch. - Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them. ## 4.16.3 diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index ba9622719..ea5ad4612 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -210,6 +210,10 @@ final class DependencyContainer { productsManager: productsManager, factory: self ) + + // Must stay last: the poll reads container state from a background task, + // so every dependency above has to be assigned before it starts. + webEntitlementRedeemer.pollPendingStripeCheckoutOnColdLaunch() } } diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index 10683d7ef..b01bc80f7 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -112,12 +112,16 @@ actor WebEntitlementRedeemer { name: UIApplication.willEnterForegroundNotification, object: nil ) + } - // Also check once on SDK initialization so pending Stripe checkouts can be - // recovered on cold launch. Guard on factory readiness to avoid accessing - // dependencies (e.g. deviceHelper) before the container is fully set up. + /// Checks once on SDK initialization so pending Stripe checkouts can be + /// recovered on cold launch. + /// + /// Called by `DependencyContainer` at the end of its `init` rather than from + /// this actor's own `init`: the poll reads container state from a background + /// task, so it must not start while the container is still being set up. + nonisolated func pollPendingStripeCheckoutOnColdLaunch() { Task { - guard factory.makeIsContainerReady() else { return } await pollPendingStripeCheckoutOnForegroundIfNeeded() } } diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 817f1bf3c..f7df29d0a 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -167,6 +167,7 @@ 4A4E788046CD308F465B37BF /* ProductsFetcherSK2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57AD390BC73341A49301B4AA /* ProductsFetcherSK2.swift */; }; 4AA4E2CE223DC7CF1678E83C /* TrackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E23B703C00044332FDEBE8 /* TrackTests.swift */; }; 4AB907436D4A84932F09D8B3 /* String+MD5.swift in Sources */ = {isa = PBXBuildFile; fileRef = D449672964023589DA5535E3 /* String+MD5.swift */; }; + 4ABF9FB54105917343865145 /* DependencyContainerInitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 880CE7E95B65A756C372CE05 /* DependencyContainerInitTests.swift */; }; 4B0E203D477E48611797047C /* PaywallViewControllerCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 672776875A4286319C2F2D61 /* PaywallViewControllerCacheTests.swift */; }; 4B4BCB32699C3A1AF7E2BFE6 /* SK2StoreProductCyclesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00929DACD8621FC32F83927 /* SK2StoreProductCyclesTests.swift */; }; 4B54BA9E52A97C486D808A05 /* IntroOfferToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF0596D5BDDE0911046E60D /* IntroOfferToken.swift */; }; @@ -897,6 +898,7 @@ 8719AC2E83128EE469E58C36 /* RedeemRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedeemRequest.swift; sourceTree = ""; }; 87AD727C5A8639E704F7BE98 /* PaywallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallView.swift; sourceTree = ""; }; 87B1E659458AE78C3908562B /* SK2ReceiptManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK2ReceiptManagerTests.swift; sourceTree = ""; }; + 880CE7E95B65A756C372CE05 /* DependencyContainerInitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependencyContainerInitTests.swift; sourceTree = ""; }; 884DF3D8A1CA382BFEE9F8F4 /* ArchiveManifestUsage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveManifestUsage.swift; sourceTree = ""; }; 887834329A06971D86D5282F /* NotificationProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationProtocols.swift; sourceTree = ""; }; 88EBF6FC3090E004EE1377B4 /* PaywallViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewControllerDelegate.swift; sourceTree = ""; }; @@ -2716,6 +2718,7 @@ D554340BB6652F5FA1F21FF8 /* Config */, 38C02C19ED9C9958A7A61FB1 /* Debug */, 3B16D25FCB6991D55E0F63B3 /* DeepLink */, + E9FF04AB866B9CCA7DFBE592 /* Dependencies */, 373AFF230833A951B6E5DF36 /* Identity */, 8DD7B7C5E111EAB0878886B6 /* Logger */, 4D7656D6A565958F58A644AF /* Misc */, @@ -2822,8 +2825,8 @@ children = ( 0E3AC3B23DAAA8C1D125BDD3 /* CoreDataManager.swift */, 50458143450675EF205CE2C3 /* CoreDataStack.swift */, - EC51351CA716C5C3B71E2FA1 /* SuperwallKit_Model.xcdatamodeld */, 2DAF3427CF469F5373C2BFD7 /* Managed Models */, + EC51351CA716C5C3B71E2FA1 /* SuperwallKit_Model.xcdatamodeld */, ); path = "Core Data"; sourceTree = ""; @@ -2996,6 +2999,14 @@ path = Options; sourceTree = ""; }; + E9FF04AB866B9CCA7DFBE592 /* Dependencies */ = { + isa = PBXGroup; + children = ( + 880CE7E95B65A756C372CE05 /* DependencyContainerInitTests.swift */, + ); + path = Dependencies; + sourceTree = ""; + }; ED389E60F716C417202738F0 /* Misc */ = { isa = PBXGroup; children = ( @@ -3173,9 +3184,10 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 1430; + TargetAttributes = { + }; }; buildConfigurationList = B7BB212B66F694F1FDA2FA4F /* Build configuration list for PBXProject "SuperwallKit" */; - compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -3228,6 +3240,7 @@ 89F17188BC665EFC6FE5CEFA /* XCRemoteSwiftPackageReference "superscript-ios-next" */, ); preferredProjectObjectVersion = 77; + productRefGroup = 778C04FFAA9840C37CA3C1CA /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( @@ -3289,6 +3302,7 @@ 654803E77F7CDBF6282D0110 /* Date+IsWithinAnHourBeforeTests.swift in Sources */, D91750797BB4947F6975B2B9 /* Date+IsoStringTests.swift in Sources */, 01BE837B492223B76A95CB5D /* DeepLinkRouterTests.swift in Sources */, + 4ABF9FB54105917343865145 /* DependencyContainerInitTests.swift in Sources */, 0CA13E721ADB243882536D4A /* DeviceHelperMock.swift in Sources */, 9DBDDD10A1EFC7CD3575D9E5 /* DeviceHelperTests.swift in Sources */, 2743143ED664F942D5D758B1 /* DevicePreloadScriptTests.swift in Sources */, diff --git a/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme b/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme index e2f319bd6..8c5e0a183 100644 --- a/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme +++ b/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme @@ -40,7 +40,8 @@ + skipped = "NO" + parallelizable = "NO"> Date: Tue, 25 Aug 2026 16:53:19 +0200 Subject: [PATCH 020/162] review: kick off the cold-launch poll from Superwall's configure init Moves pollPendingStripeCheckoutOnColdLaunch() from the last statement of DependencyContainer.init into Superwall's configure-path convenience init, after self.init(dependencyContainer:) returns. Container completeness is now guaranteed by language rule instead of a "must stay last" comment, and bare DependencyContainer() constructions in tests no longer fire the poll. Also reverts unintended xcodegen scheme drift and trims the TSan repro loop. Co-Authored-By: Claude Fable 5 --- .../SuperwallKit/Dependencies/DependencyContainer.swift | 4 ---- Sources/SuperwallKit/Superwall.swift | 5 +++++ Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift | 7 ++++--- .../xcshareddata/xcschemes/SuperwallKit.xcscheme | 3 +-- .../Dependencies/DependencyContainerInitTests.swift | 7 ++++--- .../Web/WebEntitlementRedeemerTests.swift | 2 +- 6 files changed, 15 insertions(+), 13 deletions(-) diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index ea5ad4612..ba9622719 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -210,10 +210,6 @@ final class DependencyContainer { productsManager: productsManager, factory: self ) - - // Must stay last: the poll reads container state from a background task, - // so every dependency above has to be assigned before it starts. - webEntitlementRedeemer.pollPendingStripeCheckoutOnColdLaunch() } } diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 6148926d6..232a5e38f 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -473,6 +473,11 @@ public final class Superwall: NSObject, ObservableObject { addListeners() + // Recover any Stripe checkout that was pending when the app was killed. + // Kicked off here rather than inside an initializer so the redeemer's + // background poll can only ever see a fully-built dependency container. + dependencyContainer.webEntitlementRedeemer.pollPendingStripeCheckoutOnColdLaunch() + // This task runs on a background thread, even if called from a main thread. // This is because the function isn't marked to run on the main thread, // therefore, we don't need to make this detached. diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index b01bc80f7..0f47992d7 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -117,9 +117,10 @@ actor WebEntitlementRedeemer { /// Checks once on SDK initialization so pending Stripe checkouts can be /// recovered on cold launch. /// - /// Called by `DependencyContainer` at the end of its `init` rather than from - /// this actor's own `init`: the poll reads container state from a background - /// task, so it must not start while the container is still being set up. + /// Called by `Superwall` after the dependency container is fully built, + /// rather than from this actor's own `init`: the poll reads container state + /// from a background task, so it must not start while the container is + /// still being set up. nonisolated func pollPendingStripeCheckoutOnColdLaunch() { Task { await pollPendingStripeCheckoutOnForegroundIfNeeded() diff --git a/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme b/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme index 8c5e0a183..e2f319bd6 100644 --- a/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme +++ b/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme @@ -40,8 +40,7 @@ + skipped = "NO"> Date: Tue, 25 Aug 2026 19:02:30 +0200 Subject: [PATCH 021/162] chore: fold dev mode changelog entry into staged 4.16.4 section Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bf055a39..f156871ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,12 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. -## Unreleased +## 4.16.4 ### Enhancements - Adds `SuperwallOptions.devMode` for development builds: with a `superwall dev` server running, every paywall renders from your live, local paywall code while configuration, placements, audience evaluation and assignment stay real. Simulators find the dev server on localhost automatically; on a physical device set `SuperwallOptions.devServerURL` to the Device URL `superwall dev` prints. Bound paywalls resolve via the dev server's manifest (`superwall.lock`); dev mode also activates test mode, disables preloading, and skips the test mode intro sheet. -## 4.16.4 - ### Fixes - Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. From 5d8c07683f0911a6f9a6d1c4b396bd9d8a825594 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:08:23 +0200 Subject: [PATCH 022/162] chore: restage 4.16.4 release as 4.17.0 Dev mode is a new feature, so the staged release gets a minor bump instead of a patch. Bumps the version in all three places. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- Sources/SuperwallKit/Misc/Constants.swift | 2 +- SuperwallKit.podspec | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f156871ea..3a3891533 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. -## 4.16.4 +## 4.17.0 ### Enhancements diff --git a/Sources/SuperwallKit/Misc/Constants.swift b/Sources/SuperwallKit/Misc/Constants.swift index 968fea372..7ca78bdad 100644 --- a/Sources/SuperwallKit/Misc/Constants.swift +++ b/Sources/SuperwallKit/Misc/Constants.swift @@ -18,5 +18,5 @@ let sdkVersion = """ */ let sdkVersion = """ -4.16.4 +4.17.0 """ diff --git a/SuperwallKit.podspec b/SuperwallKit.podspec index b031453c9..39380da9e 100644 --- a/SuperwallKit.podspec +++ b/SuperwallKit.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "SuperwallKit" - s.version = "4.16.4" + s.version = "4.17.0" 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" From 154f0a265aff98a12045ce08db34e5246b9dd6f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:27:25 +0200 Subject: [PATCH 023/162] review: gate dev links out of production and unfreeze the dev-mode cache Addresses pullfrog's review of 6519c3c: - handleDeepLink no longer claims a superwall_dev link when dev mode is off, so production apps keep routing such URLs down their handler chain. The pre-configuration storeDeepLink path only claims dev links once options are checkable. - A deep-link-supplied dev-server base must now be a host superwall dev could have printed (loopback, .local, private-network ranges) or match the developer-supplied devServerURL, so an arbitrary internet host can no longer be handed the paywall JS bridge. - Dev-mode paywalls skip the request-hash memoisation and fold the mount URL into cacheKey, so a transient server miss no longer pins the published paywall for the process and a moved server reloads the web view. - The debugger's withTimeout now genuinely resumes at the deadline instead of waiting out the slow product call and discarding it. - The cached-base move-to-front uses removeAll/insert instead of an irreflexive sort predicate. - Removes trailing whitespace flagged by SwiftLint. Co-Authored-By: Claude Fable 5 --- .../Debug/DebugViewController.swift | 24 +++-- Sources/SuperwallKit/DeepLinkRouter.swift | 7 +- .../DevServer/DevServerManifest.swift | 3 +- .../DevServer/DevServerPreview.swift | 76 +++++++++++-- .../Operators/RawPaywallResponse.swift | 4 + .../Request/PaywallRequestManager.swift | 12 ++- SuperwallKit.xcodeproj/project.pbxproj | 4 + .../DeepLink/DeepLinkRouterTests.swift | 9 ++ .../DevServer/DevServerPreviewTests.swift | 102 ++++++++++++++++++ 9 files changed, 219 insertions(+), 22 deletions(-) create mode 100644 Tests/SuperwallKitTests/DevServer/DevServerPreviewTests.swift diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index dcd0b5891..764ae4a2c 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -125,7 +125,7 @@ final class DebugViewController: UIViewController { /// The dev-server surface to render instead of fetching a published paywall. private var devSurface: DevServerSurface? - + /// Backs the "Your Paywalls" picker. /// /// Populated from `GET /v2/paywalls/preview-list`. Empty when the request fails or the app @@ -364,20 +364,30 @@ final class DebugViewController: UIViewController { } } + /// Races the operation against a deadline and genuinely resumes at whichever + /// finishes first. A task group can't do this — it awaits every child, and + /// the product path has no cancellation checks to cut a slow call short — + /// so a missed deadline abandons the operation's unstructured task instead. private func withTimeout( seconds: Double, operation: @escaping @Sendable () async -> T ) async -> T? { - return await withTaskGroup(of: T?.self) { group in - group.addTask { await operation() } - group.addTask { + let stream = AsyncStream { continuation in + let operationTask = Task { + continuation.yield(await operation()) + continuation.finish() + } + Task { try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - return nil + operationTask.cancel() + continuation.yield(nil) + continuation.finish() } - let result = await group.next() ?? nil - group.cancelAll() + } + for await result in stream { return result } + return nil } /// Picks the dev-server surface the debugger opens with, if any. diff --git a/Sources/SuperwallKit/DeepLinkRouter.swift b/Sources/SuperwallKit/DeepLinkRouter.swift index db950a3f0..8564bc1cc 100644 --- a/Sources/SuperwallKit/DeepLinkRouter.swift +++ b/Sources/SuperwallKit/DeepLinkRouter.swift @@ -139,7 +139,12 @@ final class DeepLinkRouter { return true } - if DevServerPreview.outcomeForDeepLink(url: url) != nil { + // Dev links count as Superwall's only while dev mode is verifiably on. + // Before initialization there are no options to check (and touching + // `Superwall.shared` would assert), so don't claim the link — it is + // stored above and routed again once config arrives. + if Superwall.isInitialized, + DevServerPreview.canHandle(url: url, options: Superwall.shared.options) { return true } diff --git a/Sources/SuperwallKit/DevServer/DevServerManifest.swift b/Sources/SuperwallKit/DevServer/DevServerManifest.swift index 20640d369..57c2fdf10 100644 --- a/Sources/SuperwallKit/DevServer/DevServerManifest.swift +++ b/Sources/SuperwallKit/DevServer/DevServerManifest.swift @@ -86,7 +86,8 @@ actor DevServerLocator { bases.insert(pinnedBase, at: 0) } if let cached = cached { - bases.sort { first, _ in first == cached.location.base } + bases.removeAll { $0 == cached.location.base } + bases.insert(cached.location.base, at: 0) } for base in bases { diff --git a/Sources/SuperwallKit/DevServer/DevServerPreview.swift b/Sources/SuperwallKit/DevServer/DevServerPreview.swift index 0c46780a7..a88856077 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPreview.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPreview.swift @@ -28,31 +28,85 @@ enum DevServerPreview { else { return nil } - let surfaceId = items.first(where: { $0.name == "superwall_dev_surface" })?.value + let surfaceId = items.first { $0.name == "superwall_dev_surface" }?.value return DeepLinkOutcome(base: base, surfaceId: surfaceId) } - static func handle(url: URL) -> Bool { + /// Whether `handle(url:)` would consume this URL: it parses, dev mode is on, + /// and the base is a host `superwall dev` could actually have printed. + static func canHandle(url: URL, options: SuperwallOptions) -> Bool { guard let outcome = outcomeForDeepLink(url: url) else { return false } - Task { @MainActor in - await open(outcome: outcome) + return DevMode.isActive(options) + && isTrustedBase(outcome.base, devServerURL: options.devServerURL) + } + + /// A deep-link-supplied base may only name a host `superwall dev` ever + /// prints — loopback, `.local`, or a private-network address — or the + /// developer-supplied `devServerURL`, which is trusted input. Anything else + /// is an arbitrary internet host that must not be handed the paywall + /// pipeline's JS bridge. + static func isTrustedBase(_ base: URL, devServerURL: URL?) -> Bool { + if let devServerURL = devServerURL, + base.scheme == devServerURL.scheme, + base.host == devServerURL.host, + base.port == devServerURL.port { + return true + } + guard let host = base.host?.lowercased() else { + return false + } + if host == "localhost" || host == "::1" || host.hasSuffix(".local") { + return true + } + // Every component must be a numeric octet: compactMap alone would let a + // DNS name like 10.0.0.1.evil.example.com pass as a private address. + let components = host.split(separator: ".") + let octets = components.compactMap { UInt8($0) } + if components.count != 4 || octets.count != 4 { + return false + } + switch (octets[0], octets[1]) { + case (127, _), (10, _), (192, 168), (169, 254), (172, 16...31): + return true + default: + return false } - return true } - @MainActor - private static func open(outcome: DeepLinkOutcome) async { - guard DevMode.isActive(Superwall.shared.options) else { + static func handle(url: URL) -> Bool { + guard let outcome = outcomeForDeepLink(url: url) else { + return false + } + let options = Superwall.shared.options + guard DevMode.isActive(options) else { Logger.debug( logLevel: .warn, scope: .superwallCore, message: "Scanned a superwall dev link, but SuperwallOptions.devMode is off " + "in this build. Enable devMode to preview local paywalls in the app." ) - return + return false + } + guard isTrustedBase(outcome.base, devServerURL: options.devServerURL) else { + Logger.debug( + logLevel: .warn, + scope: .superwallCore, + message: "Ignoring a superwall dev link pointing at \(outcome.base.absoluteString): " + + "dev servers only run on localhost, .local hosts, or private-network addresses. " + + "To use another host, set it as SuperwallOptions.devServerURL." + ) + return false } + Task { @MainActor in + await open(outcome: outcome) + } + return true + } + + @MainActor + private static func open(outcome: DeepLinkOutcome) async { await DevServerLocator.shared.pin(base: outcome.base) guard let location = await DevServerLocator.shared.locate( @@ -68,9 +122,9 @@ enum DevServerPreview { debugManager.devServer = (base: location.base, surfaces: location.manifest.surfaces) await debugManager.launchDebugger( withPaywallId: nil, - devSurfaceId: outcome.surfaceId ?? location.manifest.surfaces.first(where: { + devSurfaceId: outcome.surfaceId ?? location.manifest.surfaces.first { $0.kind == "paywall" - })?.id + }?.id ) } } diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift index 6e7e161e8..56e600507 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift @@ -45,6 +45,10 @@ extension PaywallRequestManager { var paywall = paywall paywall.url = mountURL + // A changed cacheKey is what makes an already-cached view controller + // reload its web view; without it a moved dev server or a published + // fallback would present the stale page. + paywall.cacheKey = "dev:\(paywall.cacheKey):\(mountURL.absoluteString)" paywall.urlConfig = WebViewURLConfig( endpoints: [ WebViewEndpoint( diff --git a/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift b/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift index 6ff30e776..c177f12e6 100644 --- a/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift +++ b/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift @@ -126,8 +126,16 @@ actor PaywallRequestManager { isDebuggerLaunched: Bool ) { activeTasks[requestHash] = nil - if !isDebuggerLaunched { - paywallsByHash[requestHash] = paywall + if isDebuggerLaunched { + return } + // The request hash carries no dev-server component, so memoising in dev + // mode would freeze whatever the dev server's state was at first fetch — + // a transient miss would pin the published paywall for the whole process. + // Preloading is off in dev mode, so this caching buys nothing there. + if DevMode.isActive(factory.makeSuperwallOptions()) { + return + } + paywallsByHash[requestHash] = paywall } } diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 9f0cda5c2..699a9b053 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -544,6 +544,7 @@ ED575DD46B84EE351972AC6B /* AdServicesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0B4279B992779CAD5A0694A /* AdServicesResponse.swift */; }; ED66539CDB2C991A812A6CC7 /* PaywallSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12EB4944354482783293010 /* PaywallSummary.swift */; }; EDAEC46845C1DB11CB4C99AE /* SWConsoleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDFBF0FA8B313E0D84A51DB /* SWConsoleViewController.swift */; }; + EE1A7003F266DF3C1481EEBA /* DevServerPreviewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E0A1ED94DE7737BCB7D4D8C /* DevServerPreviewTests.swift */; }; EE5646D09161237C649731F4 /* SWWebViewLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C2AC9214EA750436EF1FE11 /* SWWebViewLogicTests.swift */; }; F0013E500B7F2113857F8161 /* NotificationSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F4CF06DE50031C329ED96F /* NotificationSchedulerTests.swift */; }; F14330769F5384B9F4FD726E /* RestorationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DF71CC25A340374B0A19295 /* RestorationResult.swift */; }; @@ -851,6 +852,7 @@ 6D1887F247BF6F770122F257 /* StorageMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageMock.swift; sourceTree = ""; }; 6DB09C4AF80761DF4205C4C2 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; 6DE89E115B095A63FAC09719 /* StripeProductType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeProductType.swift; sourceTree = ""; }; + 6E0A1ED94DE7737BCB7D4D8C /* DevServerPreviewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerPreviewTests.swift; sourceTree = ""; }; 6EDC14C0D6958144679F149D /* DecodingError+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DecodingError+Extensions.swift"; sourceTree = ""; }; 6F35F68AF572F7CDF174320C /* ContactStoreProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactStoreProxy.swift; sourceTree = ""; }; 70D2B0D671B1A1E665B7CCD8 /* UIViewController+AsyncDismiss.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+AsyncDismiss.swift"; sourceTree = ""; }; @@ -2687,6 +2689,7 @@ 577D25646DA26238881BF6AB /* DevModeTests.swift */, A349A124DD1DF28EEF04592C /* DevServerManifestTests.swift */, A4FD16729844D83A3EAA02FC /* DevServerPaywallTests.swift */, + 6E0A1ED94DE7737BCB7D4D8C /* DevServerPreviewTests.swift */, ); path = DevServer; sourceTree = ""; @@ -3353,6 +3356,7 @@ FEA3AED0B70D730993A16B2C /* DevModeTests.swift in Sources */, 069391992F6191874022F2BA /* DevServerManifestTests.swift in Sources */, 669B86B82B4CCD7BC7D02B55 /* DevServerPaywallTests.swift in Sources */, + EE1A7003F266DF3C1481EEBA /* DevServerPreviewTests.swift in Sources */, 0CA13E721ADB243882536D4A /* DeviceHelperMock.swift in Sources */, 9DBDDD10A1EFC7CD3575D9E5 /* DeviceHelperTests.swift in Sources */, 2743143ED664F942D5D758B1 /* DevicePreloadScriptTests.swift in Sources */, diff --git a/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift b/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift index 3d2be9159..834ff769d 100644 --- a/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift +++ b/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift @@ -95,6 +95,15 @@ struct DeepLinkRouterTests { #expect(result == false) } + // MARK: - Dev Server Preview URLs + + @Test("Returns false for a superwall_dev link when dev mode is off") + func storeDeepLink_devServerLink_devModeOff() { + let url = URL(string: "myapp://?superwall_dev=http://localhost:6100")! + let result = DeepLinkRouter.storeDeepLink(url) + #expect(result == false) + } + // MARK: - Non-Superwall URLs @Test("Returns false for generic app URL") diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPreviewTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPreviewTests.swift new file mode 100644 index 000000000..fd2bd1584 --- /dev/null +++ b/Tests/SuperwallKitTests/DevServer/DevServerPreviewTests.swift @@ -0,0 +1,102 @@ +// +// DevServerPreviewTests.swift +// SuperwallKitTests +// + +import Foundation +import Testing +@testable import SuperwallKit + +@Suite(.serialized) +struct DevServerPreviewTests { + private func options( + devMode: Bool = true, + devServerURL: URL? = nil + ) -> SuperwallOptions { + let options = SuperwallOptions() + options.devMode = devMode + options.devServerURL = devServerURL + return options + } + + // MARK: - Deep link parsing + + @Test("Parses the base and surface from a dev link") + func outcome_parsesBaseAndSurface() throws { + let url = try #require( + URL(string: "myapp://?superwall_dev=http://localhost:6100&superwall_dev_surface=pro") + ) + let outcome = try #require(DevServerPreview.outcomeForDeepLink(url: url)) + #expect(outcome.base.absoluteString == "http://localhost:6100") + #expect(outcome.surfaceId == "pro") + } + + // MARK: - Trusted bases + + @Test( + "Hosts superwall dev can print are trusted", + arguments: [ + "http://localhost:6100", + "http://127.0.0.1:6100", + "http://[::1]:6100", + "http://yusufs-macbook.local:6100", + "http://10.0.1.5:6100", + "http://172.20.10.2:6100", + "http://192.168.1.10:6100", + "http://169.254.5.5:6100" + ] + ) + func trustedBase_privateHosts(base: String) throws { + let url = try #require(URL(string: base)) + #expect(DevServerPreview.isTrustedBase(url, devServerURL: nil)) + } + + @Test( + "Arbitrary internet hosts are not trusted", + arguments: [ + "https://evil.example.com", + "http://8.8.8.8:6100", + "http://172.32.0.1:6100", + "http://10.0.0.1.evil.example.com:6100" + ] + ) + func trustedBase_publicHosts(base: String) throws { + let url = try #require(URL(string: base)) + #expect(!DevServerPreview.isTrustedBase(url, devServerURL: nil)) + } + + @Test("The developer-supplied devServerURL is trusted wherever it points") + func trustedBase_matchingDevServerURL() throws { + let devServerURL = try #require(URL(string: "https://tunnel.example.com:8443")) + let matching = try #require(URL(string: "https://tunnel.example.com:8443")) + let otherPort = try #require(URL(string: "https://tunnel.example.com:9999")) + #expect(DevServerPreview.isTrustedBase(matching, devServerURL: devServerURL)) + #expect(!DevServerPreview.isTrustedBase(otherPort, devServerURL: devServerURL)) + } + + // MARK: - canHandle + + @Test("A dev link is not Superwall's when dev mode is off") + func canHandle_devModeOff() throws { + let url = try #require(URL(string: "myapp://?superwall_dev=http://localhost:6100")) + #expect(!DevServerPreview.canHandle(url: url, options: options(devMode: false))) + } + + @Test("A dev link pointing at a local host is Superwall's when dev mode is on") + func canHandle_devModeOnLocalHost() throws { + DevMode.isSandboxEnvironment = { true } + defer { DevMode.isSandboxEnvironment = { DeviceHelper.isSandboxEnvironment } } + + let url = try #require(URL(string: "myapp://?superwall_dev=http://localhost:6100")) + #expect(DevServerPreview.canHandle(url: url, options: options())) + } + + @Test("A dev link pointing at an internet host is refused even with dev mode on") + func canHandle_devModeOnPublicHost() throws { + DevMode.isSandboxEnvironment = { true } + defer { DevMode.isSandboxEnvironment = { DeviceHelper.isSandboxEnvironment } } + + let url = try #require(URL(string: "myapp://?superwall_dev=https://evil.example.com")) + #expect(!DevServerPreview.canHandle(url: url, options: options())) + } +} From 75da4e8f351d5871e339eb9ce895cebf7a92b082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:48:06 +0200 Subject: [PATCH 024/162] review: pin dev-server mount URLs to the manifest's origin A manifest fetched from a trusted base could still name an absolute URL on any origin, since URL(string:relativeTo:) ignores the base for absolute strings. mountURL now rejects any resolved URL whose scheme, host, or port differs from the base, covering both the request-pipeline and debugger callers. Co-Authored-By: Claude Fable 5 --- .../DevServer/DevServerManifest.swift | 14 +++++++++++++- .../DevServer/DevServerManifestTests.swift | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/DevServer/DevServerManifest.swift b/Sources/SuperwallKit/DevServer/DevServerManifest.swift index 57c2fdf10..5ba6e48b3 100644 --- a/Sources/SuperwallKit/DevServer/DevServerManifest.swift +++ b/Sources/SuperwallKit/DevServer/DevServerManifest.swift @@ -36,7 +36,19 @@ struct DevServerManifest: Decodable, Equatable { } func mountURL(for surface: DevServerSurface, base: URL) -> URL? { - return URL(string: surface.url, relativeTo: base)?.absoluteURL + guard let resolved = URL(string: surface.url, relativeTo: base)?.absoluteURL else { + return nil + } + // An absolute `url` resolves off `base` entirely, so a server reached at a + // trusted address could otherwise name any origin it likes. + guard + resolved.scheme == base.scheme, + resolved.host == base.host, + resolved.port == base.port + else { + return nil + } + return resolved } } diff --git a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift index d233f0943..88ce07bb2 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift @@ -114,4 +114,20 @@ final class DevServerManifestTests: XCTestCase { "http://192.168.1.10:6100/preview/paywall/pro" ) } + + func test_mountUrlRejectsSurfacesPointingOffTheDevServerOrigin() throws { + let decoded = try manifest(""" + { + "surfaces": [ + { "kind": "paywall", "id": "absolute", "url": "https://evil.example.com/x" }, + { "kind": "paywall", "id": "protocol-relative", "url": "//evil.example.com/x" }, + { "kind": "paywall", "id": "other-port", "url": "http://192.168.1.10:9999/x" } + ] + } + """) + let base = try XCTUnwrap(URL(string: "http://192.168.1.10:6100")) + for surface in decoded.surfaces { + XCTAssertNil(decoded.mountURL(for: surface, base: base), surface.id) + } + } } From 4a032b8a71f3531c537cadcdf2801f918688a48c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:25:38 +0200 Subject: [PATCH 025/162] review: log when a dev server surface is rejected as off-origin A representation mismatch (localhost vs 127.0.0.1, or a portless devServerURL against an explicit-port surface url) would otherwise disable the override with no trace, which is the one failure mode this subsystem otherwise always logs. Co-Authored-By: Claude Fable 5 --- Sources/SuperwallKit/DevServer/DevServerManifest.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/SuperwallKit/DevServer/DevServerManifest.swift b/Sources/SuperwallKit/DevServer/DevServerManifest.swift index 5ba6e48b3..fa4d7a255 100644 --- a/Sources/SuperwallKit/DevServer/DevServerManifest.swift +++ b/Sources/SuperwallKit/DevServer/DevServerManifest.swift @@ -46,6 +46,12 @@ struct DevServerManifest: Decodable, Equatable { resolved.host == base.host, resolved.port == base.port else { + Logger.debug( + logLevel: .warn, + scope: .superwallCore, + message: "Ignoring dev server surface \(surface.id): its url \(surface.url) resolves to " + + "\(resolved.absoluteString), which is off \(base.absoluteString)'s origin." + ) return nil } return resolved From d1f5e0df168ceca9efbba29dd2d007ee48b7e35a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:04:19 +0200 Subject: [PATCH 026/162] chore(examples): scope Advanced app ATS to web content and local networking Matches the Basic app, and lets the dev server's plain-http localhost traffic through without the blanket arbitrary-loads exception. Co-Authored-By: Claude Fable 5 --- Examples/Advanced/Advanced/Info.plist | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Examples/Advanced/Advanced/Info.plist b/Examples/Advanced/Advanced/Info.plist index b7789af4d..9050b0e87 100644 --- a/Examples/Advanced/Advanced/Info.plist +++ b/Examples/Advanced/Advanced/Info.plist @@ -17,7 +17,9 @@ NSAppTransportSecurity - NSAllowsArbitraryLoads + NSAllowsArbitraryLoadsInWebContent + + NSAllowsLocalNetworking UIAppFonts From 254d6de4c4fb010f00dbb663db53d54a6e4fb431 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:46:01 +0200 Subject: [PATCH 027/162] chore: split DevServerManifest.swift into one file per type DevServerSurface, DevServerLocation, DevServerCandidates, and DevServerLocator move to their own files; DevServerManifest keeps the manifest model. No behavior change. Co-Authored-By: Claude Fable 5 --- .../DevServer/DevServerCandidates.swift | 22 +++ .../DevServer/DevServerLocation.swift | 14 ++ .../DevServer/DevServerLocator.swift | 116 +++++++++++++++ .../DevServer/DevServerManifest.swift | 132 ------------------ .../DevServer/DevServerSurface.swift | 19 +++ SuperwallKit.xcodeproj/project.pbxproj | 16 +++ 6 files changed, 187 insertions(+), 132 deletions(-) create mode 100644 Sources/SuperwallKit/DevServer/DevServerCandidates.swift create mode 100644 Sources/SuperwallKit/DevServer/DevServerLocation.swift create mode 100644 Sources/SuperwallKit/DevServer/DevServerLocator.swift create mode 100644 Sources/SuperwallKit/DevServer/DevServerSurface.swift diff --git a/Sources/SuperwallKit/DevServer/DevServerCandidates.swift b/Sources/SuperwallKit/DevServer/DevServerCandidates.swift new file mode 100644 index 000000000..02197b1e2 --- /dev/null +++ b/Sources/SuperwallKit/DevServer/DevServerCandidates.swift @@ -0,0 +1,22 @@ +// +// DevServerCandidates.swift +// SuperwallKit +// +// Where dev mode looks for a running `superwall dev` server. +// + +import Foundation + +enum DevServerCandidates { + static let defaultPorts = 6100...6104 + + /// The bases dev mode tries, in order: an explicit URL wins, otherwise + /// localhost across the default port range `superwall dev` walks when + /// its preferred port is taken. + static func bases(devServerURL: URL?) -> [URL] { + if let devServerURL = devServerURL { + return [devServerURL] + } + return defaultPorts.compactMap { URL(string: "http://localhost:\($0)") } + } +} diff --git a/Sources/SuperwallKit/DevServer/DevServerLocation.swift b/Sources/SuperwallKit/DevServer/DevServerLocation.swift new file mode 100644 index 000000000..699529de1 --- /dev/null +++ b/Sources/SuperwallKit/DevServer/DevServerLocation.swift @@ -0,0 +1,14 @@ +// +// DevServerLocation.swift +// SuperwallKit +// +// A found `superwall dev` server: the base it answered on and the +// manifest it served from there. +// + +import Foundation + +struct DevServerLocation: Equatable { + let base: URL + let manifest: DevServerManifest +} diff --git a/Sources/SuperwallKit/DevServer/DevServerLocator.swift b/Sources/SuperwallKit/DevServer/DevServerLocator.swift new file mode 100644 index 000000000..97e12eb29 --- /dev/null +++ b/Sources/SuperwallKit/DevServer/DevServerLocator.swift @@ -0,0 +1,116 @@ +// +// DevServerLocator.swift +// SuperwallKit +// +// Finds the running `superwall dev` server by probing the candidate +// bases for /device/manifest.json, with short-lived caching of both +// hits and misses so paywall requests don't hammer the network. +// + +import Foundation + +actor DevServerLocator { + static let shared = DevServerLocator() + + private var cached: (location: DevServerLocation, fetchedAt: Date)? + private var lastMissAt: Date? + private var pinnedBase: URL? + + func pin(base: URL) { + pinnedBase = base + cached = nil + lastMissAt = nil + } + + func locate(devServerURL: URL?) async -> DevServerLocation? { + if let cached = cached, + Date().timeIntervalSince(cached.fetchedAt) < 2 { + return cached.location + } + if let lastMissAt = lastMissAt, + Date().timeIntervalSince(lastMissAt) < 5 { + return nil + } + + var bases = DevServerCandidates.bases(devServerURL: devServerURL) + if let pinnedBase = pinnedBase { + bases.removeAll { $0 == pinnedBase } + bases.insert(pinnedBase, at: 0) + } + if let cached = cached { + bases.removeAll { $0 == cached.location.base } + bases.insert(cached.location.base, at: 0) + } + + for base in bases { + if let manifest = await fetchManifest(from: base) { + let location = DevServerLocation(base: base, manifest: manifest) + cached = (location, Date()) + lastMissAt = nil + return location + } + } + + cached = nil + lastMissAt = Date() + Logger.debug( + logLevel: .warn, + scope: .superwallCore, + message: "Dev mode is on but no superwall dev server was found at " + + "\(bases.map { $0.absoluteString }.joined(separator: ", ")). " + + "Paywalls will load their published versions. On a physical device, " + + "set SuperwallOptions.devServerURL to the Device URL superwall dev prints." + ) + return nil + } + + private var hasWarnedAboutTransportSecurity = false + + /// App Transport Security blocks plain-http requests unless the app opts in, + /// and the failure is otherwise indistinguishable from "no server there". + private func warnIfBlockedByAppTransportSecurity(_ error: Error, base: URL) { + let code = (error as NSError).code + guard + code == NSURLErrorAppTransportSecurityRequiresSecureConnection, + !hasWarnedAboutTransportSecurity + else { + return + } + hasWarnedAboutTransportSecurity = true + Logger.debug( + logLevel: .error, + scope: .superwallCore, + message: "App Transport Security blocked \(base.absoluteString). Add this to the app's " + + "Info.plist to preview local paywalls:\n" + + "NSAppTransportSecurity\n\n" + + " NSAllowsArbitraryLoadsInWebContent\n" + + " NSAllowsLocalNetworking\n" + ) + } + + private func fetchManifest(from base: URL) async -> DevServerManifest? { + guard let manifestURL = URL(string: "/device/manifest.json", relativeTo: base) else { + return nil + } + var request = URLRequest(url: manifestURL) + request.timeoutInterval = 5 + request.cachePolicy = .reloadIgnoringLocalCacheData + + do { + let data: Data = try await withCheckedThrowingContinuation { continuation in + let task = URLSession.shared.dataTask(with: request) { data, _, error in + if let data = data { + continuation.resume(returning: data) + } else { + continuation.resume(throwing: error ?? URLError(.badServerResponse)) + } + } + task.resume() + } + return try JSONDecoder().decode(DevServerManifest.self, from: data) + } catch { + warnIfBlockedByAppTransportSecurity(error, base: base) + return nil + } + } +} diff --git a/Sources/SuperwallKit/DevServer/DevServerManifest.swift b/Sources/SuperwallKit/DevServer/DevServerManifest.swift index fa4d7a255..2ade494c1 100644 --- a/Sources/SuperwallKit/DevServer/DevServerManifest.swift +++ b/Sources/SuperwallKit/DevServer/DevServerManifest.swift @@ -9,15 +9,6 @@ import Foundation -struct DevServerSurface: Decodable, Equatable { - let kind: String - let id: String - let url: String - let paywallId: String? - let identifier: String? - let products: [String: String]? -} - struct DevServerManifest: Decodable, Equatable { let surfaces: [DevServerSurface] @@ -57,126 +48,3 @@ struct DevServerManifest: Decodable, Equatable { return resolved } } - -struct DevServerLocation: Equatable { - let base: URL - let manifest: DevServerManifest -} - -enum DevServerCandidates { - static let defaultPorts = 6100...6104 - - /// The bases dev mode tries, in order: an explicit URL wins, otherwise - /// localhost across the default port range `superwall dev` walks when - /// its preferred port is taken. - static func bases(devServerURL: URL?) -> [URL] { - if let devServerURL = devServerURL { - return [devServerURL] - } - return defaultPorts.compactMap { URL(string: "http://localhost:\($0)") } - } -} - -actor DevServerLocator { - static let shared = DevServerLocator() - - private var cached: (location: DevServerLocation, fetchedAt: Date)? - private var lastMissAt: Date? - private var pinnedBase: URL? - - func pin(base: URL) { - pinnedBase = base - cached = nil - lastMissAt = nil - } - - func locate(devServerURL: URL?) async -> DevServerLocation? { - if let cached = cached, Date().timeIntervalSince(cached.fetchedAt) < 2 { - return cached.location - } - if let lastMissAt = lastMissAt, Date().timeIntervalSince(lastMissAt) < 5 { - return nil - } - - var bases = DevServerCandidates.bases(devServerURL: devServerURL) - if let pinnedBase = pinnedBase { - bases.removeAll { $0 == pinnedBase } - bases.insert(pinnedBase, at: 0) - } - if let cached = cached { - bases.removeAll { $0 == cached.location.base } - bases.insert(cached.location.base, at: 0) - } - - for base in bases { - if let manifest = await fetchManifest(from: base) { - let location = DevServerLocation(base: base, manifest: manifest) - cached = (location, Date()) - lastMissAt = nil - return location - } - } - - cached = nil - lastMissAt = Date() - Logger.debug( - logLevel: .warn, - scope: .superwallCore, - message: "Dev mode is on but no superwall dev server was found at " - + "\(bases.map { $0.absoluteString }.joined(separator: ", ")). " - + "Paywalls will load their published versions. On a physical device, " - + "set SuperwallOptions.devServerURL to the Device URL superwall dev prints." - ) - return nil - } - - private var hasWarnedAboutTransportSecurity = false - - /// App Transport Security blocks plain-http requests unless the app opts in, - /// and the failure is otherwise indistinguishable from "no server there". - private func warnIfBlockedByAppTransportSecurity(_ error: Error, base: URL) { - let code = (error as NSError).code - guard - code == NSURLErrorAppTransportSecurityRequiresSecureConnection, - !hasWarnedAboutTransportSecurity - else { - return - } - hasWarnedAboutTransportSecurity = true - Logger.debug( - logLevel: .error, - scope: .superwallCore, - message: "App Transport Security blocked \(base.absoluteString). Add this to the app's " - + "Info.plist to preview local paywalls:\n" - + "NSAppTransportSecurity\n\n" - + " NSAllowsArbitraryLoadsInWebContent\n" - + " NSAllowsLocalNetworking\n" - ) - } - - private func fetchManifest(from base: URL) async -> DevServerManifest? { - guard let manifestURL = URL(string: "/device/manifest.json", relativeTo: base) else { - return nil - } - var request = URLRequest(url: manifestURL) - request.timeoutInterval = 5 - request.cachePolicy = .reloadIgnoringLocalCacheData - - do { - let data: Data = try await withCheckedThrowingContinuation { continuation in - let task = URLSession.shared.dataTask(with: request) { data, _, error in - if let data = data { - continuation.resume(returning: data) - } else { - continuation.resume(throwing: error ?? URLError(.badServerResponse)) - } - } - task.resume() - } - return try JSONDecoder().decode(DevServerManifest.self, from: data) - } catch { - warnIfBlockedByAppTransportSecurity(error, base: base) - return nil - } - } -} diff --git a/Sources/SuperwallKit/DevServer/DevServerSurface.swift b/Sources/SuperwallKit/DevServer/DevServerSurface.swift new file mode 100644 index 000000000..974f35db1 --- /dev/null +++ b/Sources/SuperwallKit/DevServer/DevServerSurface.swift @@ -0,0 +1,19 @@ +// +// DevServerSurface.swift +// SuperwallKit +// +// One entry in the surface list a running `superwall dev` server exposes: +// a locally served paywall or funnel, and the dashboard paywall it is +// bound to via `superwall.lock`, if any. +// + +import Foundation + +struct DevServerSurface: Decodable, Equatable { + let kind: String + let id: String + let url: String + let paywallId: String? + let identifier: String? + let products: [String: String]? +} diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 699a9b053..60dfa6522 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -125,6 +125,7 @@ 339F1D07DB57DBEC46940DB6 /* CheckoutWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0B401CD38DBD6D90E4EB3E /* CheckoutWebViewController.swift */; }; 342593FCA24FBEA77FE472C7 /* SK2ReceiptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050BC76657949DBB5F3D551C /* SK2ReceiptManager.swift */; }; 3464196F9088F8A320FE24A4 /* PendingStripeCheckoutPollState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797EC0356AA1065ED11835BF /* PendingStripeCheckoutPollState.swift */; }; + 346A77D3F31E471EB7CC4D5C /* DevServerSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E37562E243AE6632134D94A /* DevServerSurface.swift */; }; 35597883CB038DBEE63E162B /* EventData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86D76FB5809C3B8122778A9 /* EventData.swift */; }; 3652D5EE4C172D623BDEE7E4 /* PresentationIdTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3F306D67A9F3A43D082DD83 /* PresentationIdTests.swift */; }; 369677E9A6E8754CFD20714D /* TrackingParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 764012CF0C0972240A73E3CF /* TrackingParameters.swift */; }; @@ -308,6 +309,7 @@ 8BBC7DE9391A8974DD5B6A32 /* ProductStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7106327DAD1C9044E4A57DD5 /* ProductStore.swift */; }; 8C3A81E3D75F027539933310 /* BottomPaddingAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DB52B223C181E0A8FA1D6D /* BottomPaddingAnimation.swift */; }; 8D0B281D5CB739D6AD5EBC0D /* DebugPaywallPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19162D473A3E154574733AA2 /* DebugPaywallPickerViewController.swift */; }; + 8D22B4A1500BF56E91DC731F /* DevServerLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7781E6093CC05F1467B431D /* DevServerLocator.swift */; }; 8E5661E20F318661BB005E2F /* CustomerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2031E7FE7D2ECC7AFF8519AE /* CustomerInfo.swift */; }; 8EC4001F5273FB1260618E84 /* PaywallRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB5B56470F69FBE1C34EAA8 /* PaywallRequest.swift */; }; 8F18BFB254E432BFBEAB1324 /* LogLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3A82C80F89023672D56AD7 /* LogLevel.swift */; }; @@ -392,6 +394,7 @@ B15607185B9E4229C6C4F240 /* SK2StoreTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3B96E2A1A289D96267EC0BC /* SK2StoreTransaction.swift */; }; B162BE92B3568078BC0ADD1B /* StoreProductBillingPlanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F98E1C9554F6AFAECF9B3430 /* StoreProductBillingPlanTests.swift */; }; B294572426111EC04F225289 /* MockExternalPurchaseControllerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB9C9132109020FA03D1D5C7 /* MockExternalPurchaseControllerFactory.swift */; }; + B29A93B51FE9421DD5E271C2 /* DevServerCandidates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A970BB7B4C3063A22F0B252 /* DevServerCandidates.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 */; }; @@ -497,6 +500,7 @@ DB6FF170AE90FF8623A31E14 /* DispatchQueueBacked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ABC4A0048583B47040C498B /* DispatchQueueBacked.swift */; }; DB7858A959C145FA32F6C9EC /* PaywallPresentationInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 831F679BDAC779043091DB7E /* PaywallPresentationInfoTests.swift */; }; DBF70D987418DD9EB504FBDE /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42956918D4FFA5FBA79F3AA5 /* Constants.swift */; }; + DC1E01DEAD4D0E2F59CBCEF0 /* DevServerLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCD506DA245EEFB3B0DB8D4E /* DevServerLocation.swift */; }; DCE85B4A9DBD672B658F6EB3 /* MockSKPaymentTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B1A6ADFFB9FA982BF69C134 /* MockSKPaymentTransaction.swift */; }; DE2F41FF9D70AB13AD246E49 /* VariantOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 194B8214C0A66407CEDCC0F4 /* VariantOption.swift */; }; DE62F8E261EC7C60FBAAAE1D /* BundleHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = F34468E3988E779132CE101A /* BundleHelper.swift */; }; @@ -810,6 +814,7 @@ 5D44CEC91693B4B900472C1C /* Survey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Survey.swift; sourceTree = ""; }; 5D8D539E4636D23E549B4520 /* TestModePurchaseDrawer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestModePurchaseDrawer.swift; sourceTree = ""; }; 5DD4E7007670C369DD8FF5D9 /* Date+IsWithinAnHourBeforeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+IsWithinAnHourBeforeTests.swift"; sourceTree = ""; }; + 5E37562E243AE6632134D94A /* DevServerSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerSurface.swift; sourceTree = ""; }; 5EB5A772C1F2ECE6D0E0BD69 /* PaywallState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallState.swift; sourceTree = ""; }; 60B80BEE0364C0EF86E2084E /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/Localizable.strings; sourceTree = ""; }; 61062B4B7A0AB23514A2F439 /* SwiftVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftVersion.swift; sourceTree = ""; }; @@ -841,6 +846,7 @@ 6944763A0D07AFA102B023C5 /* PaywallManagerLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallManagerLogicTests.swift; sourceTree = ""; }; 69A4D77D819DDB696834E1B7 /* UIViewController+AsyncPresent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+AsyncPresent.swift"; sourceTree = ""; }; 6A56D712042043783D7CA142 /* ProductPurchaserSK1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPurchaserSK1.swift; sourceTree = ""; }; + 6A970BB7B4C3063A22F0B252 /* DevServerCandidates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerCandidates.swift; sourceTree = ""; }; 6B103FA8F9AE387E7DB4B471 /* LocationPermissionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissionDelegate.swift; sourceTree = ""; }; 6B7CFAF4B3E32AE628A249C8 /* AttributionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributionTests.swift; sourceTree = ""; }; 6B9E9E16EBDA97E736968496 /* PaywallPresentationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallPresentationHandler.swift; sourceTree = ""; }; @@ -1112,6 +1118,7 @@ CC653A44D9B40812BDDD94E7 /* PaywallMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallMessage.swift; sourceTree = ""; }; CC89718EBDD71E09AB5F41DA /* AppSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSessionManager.swift; sourceTree = ""; }; CCAE23C483138D33A1CF8889 /* ProductsFetcherSK1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsFetcherSK1.swift; sourceTree = ""; }; + CCD506DA245EEFB3B0DB8D4E /* DevServerLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerLocation.swift; sourceTree = ""; }; CCFFBE357699F5CAAB803DA7 /* ManagedTriggerRuleOccurrence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedTriggerRuleOccurrence.swift; sourceTree = ""; }; CD8C0C8DA633BE856F5B9EEF /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; CD9298A79020030E9A1357A6 /* API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = ""; }; @@ -1138,6 +1145,7 @@ D6340ACDA40937ACAC66FA3D /* EntitlementPriorityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitlementPriorityTests.swift; sourceTree = ""; }; D69BCC259F5FBE15AB02D662 /* PermissionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionHandler.swift; sourceTree = ""; }; D7434029CB9E4680C85D3FB6 /* PermissionHandler+Microphone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PermissionHandler+Microphone.swift"; sourceTree = ""; }; + D7781E6093CC05F1467B431D /* DevServerLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerLocator.swift; sourceTree = ""; }; D7B0C7BDA06D25D9D5A865A3 /* TestModeManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestModeManagerTests.swift; sourceTree = ""; }; D7E232690489360042465DB2 /* Redeemable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Redeemable.swift; sourceTree = ""; }; D81D656CEA8B5B86458038D4 /* ms */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ms; path = ms.lproj/Localizable.strings; sourceTree = ""; }; @@ -2605,9 +2613,13 @@ isa = PBXGroup; children = ( D9C76F083AB62E59C5DF7FEE /* DevMode.swift */, + 6A970BB7B4C3063A22F0B252 /* DevServerCandidates.swift */, + CCD506DA245EEFB3B0DB8D4E /* DevServerLocation.swift */, + D7781E6093CC05F1467B431D /* DevServerLocator.swift */, ACAF662B855E4A70DDEE66F6 /* DevServerManifest.swift */, 3DE9BCE12E3F4300AD2379B4 /* DevServerPaywall.swift */, 4E9B7111D8087FC1DF3E6B80 /* DevServerPreview.swift */, + 5E37562E243AE6632134D94A /* DevServerSurface.swift */, ); path = DevServer; sourceTree = ""; @@ -3546,9 +3558,13 @@ CB2F2B4DA3709F171E54CBB8 /* DeepLinkRouter.swift in Sources */, 3F4BE7ECC80EEA757454F9B6 /* DependencyContainer.swift in Sources */, 50E0E5F4B476F2F5B8DF299E /* DevMode.swift in Sources */, + B29A93B51FE9421DD5E271C2 /* DevServerCandidates.swift in Sources */, + DC1E01DEAD4D0E2F59CBCEF0 /* DevServerLocation.swift in Sources */, + 8D22B4A1500BF56E91DC731F /* DevServerLocator.swift in Sources */, 789B734B60F87DBC23FC7930 /* DevServerManifest.swift in Sources */, EA6F422EB1C1F0E6882E4AEF /* DevServerPaywall.swift in Sources */, 08C89125100BC25CE015A7B6 /* DevServerPreview.swift in Sources */, + 346A77D3F31E471EB7CC4D5C /* DevServerSurface.swift in Sources */, 7FCDAF6C945FA04FC4C4E8E3 /* DeviceHelper.swift in Sources */, 191AA8FBBF617251EF6F8628 /* DeviceInfo.swift in Sources */, 6CF900F9770237D75585A681 /* DevicePreloadScript.swift in Sources */, From 3482bf2e3b3e9f8db67617a66b0188fb9896f70d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:43:52 +0200 Subject: [PATCH 028/162] style: invert the paywall picker's open guard into a positive early return Co-Authored-By: Claude Fable 5 --- Sources/SuperwallKit/Debug/DebugViewController.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index 764ae4a2c..2a3c6401d 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -444,7 +444,11 @@ final class DebugViewController: UIViewController { @objc func pressedPreview() { let devSurfaces = devServer?.surfaces ?? [] let published = publishedPaywalls - guard !devSurfaces.isEmpty || published.count > 1 || paywallDatabaseId == nil else { + // Nothing to pick from: no local surfaces, at most one published paywall, + // and that paywall is already showing. + if devSurfaces.isEmpty, + published.count <= 1, + paywallDatabaseId != nil { return } From 0d7ce5ed4d4c1669bb03630c2a1d41bd934e8321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:45:03 +0200 Subject: [PATCH 029/162] style: split the paywall picker gate into a three-branch predicate Co-Authored-By: Claude Fable 5 --- .../Debug/DebugViewController.swift | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index 2a3c6401d..dd3b25415 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -441,16 +441,27 @@ final class DebugViewController: UIViewController { } } + /// Whether the picker has anything to offer: local surfaces, a choice of + /// published paywalls, or no paywall selected yet. + private var canOpenPicker: Bool { + if devServer?.surfaces.isEmpty == false { + return true + } + if publishedPaywalls.count > 1 { + return true + } + if paywallDatabaseId == nil { + return true + } + return false + } + @objc func pressedPreview() { - let devSurfaces = devServer?.surfaces ?? [] - let published = publishedPaywalls - // Nothing to pick from: no local surfaces, at most one published paywall, - // and that paywall is already showing. - if devSurfaces.isEmpty, - published.count <= 1, - paywallDatabaseId != nil { + if !canOpenPicker { return } + let devSurfaces = devServer?.surfaces ?? [] + let published = publishedPaywalls let picker = DebugPaywallPickerViewController( localSurfaceIds: devSurfaces.map { $0.id }, From 362950d30dc1bc91cd984d91079edd37f996a4b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:49:37 +0200 Subject: [PATCH 030/162] style: state the picker gate positively with guard Co-Authored-By: Claude Fable 5 --- Sources/SuperwallKit/Debug/DebugViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index dd3b25415..8a830d53a 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -457,7 +457,7 @@ final class DebugViewController: UIViewController { } @objc func pressedPreview() { - if !canOpenPicker { + guard canOpenPicker else { return } let devSurfaces = devServer?.surfaces ?? [] From 11cd922973db9c47793a9cf12e744f3679034118 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:58:56 +0200 Subject: [PATCH 031/162] style: group the ATS warning flag with the locator's other state Co-Authored-By: Claude Fable 5 --- Sources/SuperwallKit/DevServer/DevServerLocator.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/SuperwallKit/DevServer/DevServerLocator.swift b/Sources/SuperwallKit/DevServer/DevServerLocator.swift index 97e12eb29..530ada386 100644 --- a/Sources/SuperwallKit/DevServer/DevServerLocator.swift +++ b/Sources/SuperwallKit/DevServer/DevServerLocator.swift @@ -15,6 +15,7 @@ actor DevServerLocator { private var cached: (location: DevServerLocation, fetchedAt: Date)? private var lastMissAt: Date? private var pinnedBase: URL? + private var hasWarnedAboutTransportSecurity = false func pin(base: URL) { pinnedBase = base @@ -64,8 +65,6 @@ actor DevServerLocator { return nil } - private var hasWarnedAboutTransportSecurity = false - /// App Transport Security blocks plain-http requests unless the app opts in, /// and the failure is otherwise indistinguishable from "no server there". private func warnIfBlockedByAppTransportSecurity(_ error: Error, base: URL) { From 6fed06c8ea7c993cb7f3334c2d5d43cb06a7b608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:00:54 +0200 Subject: [PATCH 032/162] style: split the ATS warning gate into single-condition checks Co-Authored-By: Claude Fable 5 --- Sources/SuperwallKit/DevServer/DevServerLocator.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/SuperwallKit/DevServer/DevServerLocator.swift b/Sources/SuperwallKit/DevServer/DevServerLocator.swift index 530ada386..bb1e2d7b7 100644 --- a/Sources/SuperwallKit/DevServer/DevServerLocator.swift +++ b/Sources/SuperwallKit/DevServer/DevServerLocator.swift @@ -69,10 +69,10 @@ actor DevServerLocator { /// and the failure is otherwise indistinguishable from "no server there". private func warnIfBlockedByAppTransportSecurity(_ error: Error, base: URL) { let code = (error as NSError).code - guard - code == NSURLErrorAppTransportSecurityRequiresSecureConnection, - !hasWarnedAboutTransportSecurity - else { + guard code == NSURLErrorAppTransportSecurityRequiresSecureConnection else { + return + } + if hasWarnedAboutTransportSecurity { return } hasWarnedAboutTransportSecurity = true From c22e09843e5e92e6ef8c665ce43d12dcc227a4b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:09:27 +0200 Subject: [PATCH 033/162] style: split the dev link parse into single-condition guards Also documents outcomeForDeepLink. Co-Authored-By: Claude Fable 5 --- .../DevServer/DevServerPreview.swift | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/Sources/SuperwallKit/DevServer/DevServerPreview.swift b/Sources/SuperwallKit/DevServer/DevServerPreview.swift index a88856077..0faa7368f 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPreview.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPreview.swift @@ -18,14 +18,23 @@ enum DevServerPreview { let surfaceId: String? } + /// Parses a `superwall_dev` deep link: the dev server base carried in the + /// `superwall_dev` query item, which must be a web URL, plus the optional + /// `superwall_dev_surface` to open with. static func outcomeForDeepLink(url: URL) -> DeepLinkOutcome? { - guard - let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let items = components.queryItems, - let raw = items.first(where: { $0.name == "superwall_dev" })?.value, - let base = URL(string: raw), - base.scheme == "http" || base.scheme == "https" - else { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return nil + } + guard let items = components.queryItems else { + return nil + } + guard let raw = items.first(where: { $0.name == "superwall_dev" })?.value else { + return nil + } + guard let base = URL(string: raw) else { + return nil + } + guard base.scheme == "http" || base.scheme == "https" else { return nil } let surfaceId = items.first { $0.name == "superwall_dev_surface" }?.value From cf2020c32004929c7bb076671d1c732895c37d1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:28:01 +0200 Subject: [PATCH 034/162] docs: drop the superwall.lock binding detail from the dev mode changelog entry Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a3891533..bb4698052 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 `SuperwallOptions.devMode` for development builds: with a `superwall dev` server running, every paywall renders from your live, local paywall code while configuration, placements, audience evaluation and assignment stay real. Simulators find the dev server on localhost automatically; on a physical device set `SuperwallOptions.devServerURL` to the Device URL `superwall dev` prints. Bound paywalls resolve via the dev server's manifest (`superwall.lock`); dev mode also activates test mode, disables preloading, and skips the test mode intro sheet. +- Adds `SuperwallOptions.devMode` for development builds: with a `superwall dev` server running, every paywall renders from your live, local paywall code while configuration, placements, audience evaluation and assignment stay real. Simulators find the dev server on localhost automatically; on a physical device set `SuperwallOptions.devServerURL` to the Device URL `superwall dev` prints. Dev mode also activates test mode, disables preloading, and skips the test mode intro sheet. ### Fixes From e7518498cc0233eeb60c500a5d9b3cced4dcbdbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:32:18 +0200 Subject: [PATCH 035/162] review: drop the debugger's product-variables timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missing products fail fast on their own — both fetchers throw noProductsFound without entering their retry ladder — so the 3s race only ever hedged degraded-network cases, at the cost of indirection. The debugger now awaits the store directly, like it does on develop. Co-Authored-By: Claude Fable 5 --- .../Debug/DebugViewController.swift | 34 ++----------------- 1 file changed, 2 insertions(+), 32 deletions(-) diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index 8a830d53a..d8f49680e 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -275,9 +275,7 @@ final class DebugViewController: UIViewController { ) var paywall = try await paywallRequestManager.getPaywall(from: request) - paywall.productVariables = await withTimeout(seconds: 3) { - await self.storeKitManager.getProductVariables(for: paywall) - } ?? [] + paywall.productVariables = await storeKitManager.getProductVariables(for: paywall) self.paywall = paywall self.previewPickerButton.setTitle("\(paywall.name)", for: .normal) @@ -364,32 +362,6 @@ final class DebugViewController: UIViewController { } } - /// Races the operation against a deadline and genuinely resumes at whichever - /// finishes first. A task group can't do this — it awaits every child, and - /// the product path has no cancellation checks to cut a slow call short — - /// so a missed deadline abandons the operation's unstructured task instead. - private func withTimeout( - seconds: Double, - operation: @escaping @Sendable () async -> T - ) async -> T? { - let stream = AsyncStream { continuation in - let operationTask = Task { - continuation.yield(await operation()) - continuation.finish() - } - Task { - try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - operationTask.cancel() - continuation.yield(nil) - continuation.finish() - } - } - for await result in stream { - return result - } - return nil - } - /// Picks the dev-server surface the debugger opens with, if any. func selectDevSurface(id: String?) { guard let id = id else { @@ -417,9 +389,7 @@ final class DebugViewController: UIViewController { var paywall = Paywall.devServer(surface: surface, url: url) // Product variables are best-effort here: a surface can name products the // store has no record of yet, and the preview must still render. - paywall.productVariables = await withTimeout(seconds: 3) { - await self.storeKitManager.getProductVariables(for: paywall) - } ?? [] + paywall.productVariables = await storeKitManager.getProductVariables(for: paywall) self.paywall = paywall paywallIdentifier = paywall.identifier paywallDatabaseId = paywall.databaseId From 6240aa299fb4f4ae4f84e2c60032c2b28ac8be3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:24:41 +0200 Subject: [PATCH 036/162] style: positive-if and multiline-guard formatting in dev mode gates --- Sources/SuperwallKit/Config/ConfigManager.swift | 3 ++- Sources/SuperwallKit/Debug/DebugViewController.swift | 3 ++- Sources/SuperwallKit/DevServer/DevMode.swift | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index 8689eb1f6..a56fa96c0 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -562,7 +562,8 @@ class ConfigManager { /// /// A developer can disable preloading of paywalls by setting ``SuperwallOptions/shouldPreloadPaywalls``. private func preloadPaywalls() async { - guard Superwall.shared.options.paywalls.shouldPreload, + guard + Superwall.shared.options.paywalls.shouldPreload, !DevMode.isActive(Superwall.shared.options) else { return diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index d8f49680e..cd2435ff3 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -224,7 +224,8 @@ final class DebugViewController: UIViewController { /// Dev mode's local surfaces belong in the debugger however it was opened — /// a dashboard preview link should list them too, not just a dev link. private func ensureDevServer() async { - guard devServer == nil, + guard + devServer == nil, DevMode.isActive(Superwall.shared.options), let location = await DevServerLocator.shared.locate( devServerURL: Superwall.shared.options.devServerURL diff --git a/Sources/SuperwallKit/DevServer/DevMode.swift b/Sources/SuperwallKit/DevServer/DevMode.swift index 21134330c..e6a7994fe 100644 --- a/Sources/SuperwallKit/DevServer/DevMode.swift +++ b/Sources/SuperwallKit/DevServer/DevMode.swift @@ -31,7 +31,7 @@ enum DevMode { } private static func warnAboutProduction() { - guard !hasWarnedAboutProduction else { + if hasWarnedAboutProduction { return } hasWarnedAboutProduction = true From b61b66e31b7518371b553282304ede8f3f8345d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:28:33 +0200 Subject: [PATCH 037/162] feat!: replace devMode and devServerURL with SuperwallOptions.devServer One knob instead of two: options.devServer = nil (off, the default), .default (find the server on localhost, for simulators), or .url(_:) (the Device URL superwall dev prints, for physical devices). Folding the URL into the option removes the 'setting the URL implies the mode' rule and every half-configured state. Objective-C gets enableDevServer()/enableDevServer(url:) veneers. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- .../Config/Options/SuperwallOptions.swift | 57 ++++++++++++++----- Sources/SuperwallKit/DevServer/DevMode.swift | 6 +- .../DevServer/DevServerLocator.swift | 2 +- .../DevServer/DevServerPreview.swift | 8 +-- .../DeepLink/DeepLinkRouterTests.swift | 4 +- .../DevServer/DevModeTests.swift | 15 +++-- .../DevServer/DevServerPreviewTests.swift | 20 +++---- 8 files changed, 69 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb4698052..b0a769b55 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 `SuperwallOptions.devMode` for development builds: with a `superwall dev` server running, every paywall renders from your live, local paywall code while configuration, placements, audience evaluation and assignment stay real. Simulators find the dev server on localhost automatically; on a physical device set `SuperwallOptions.devServerURL` to the Device URL `superwall dev` prints. Dev mode also activates test mode, disables preloading, and skips the test mode intro sheet. +- Adds `SuperwallOptions.devServer` for development builds: with a `superwall dev` server running, paywalls render from your live, local paywall code while configuration, placements, audience evaluation and assignment stay real. Use `.default` on a simulator, which finds the dev server on localhost automatically; on a physical device use `.url(...)` with the Device URL `superwall dev` prints. The dev server also activates test mode, disables preloading, and skips the test mode intro sheet. ### Fixes diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index 525988b23..b3366f8b7 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -388,31 +388,58 @@ public final class SuperwallOptions: NSObject, Encodable { /// - `.always`: Test mode is always activated, regardless of configuration. public var testModeBehavior: TestModeBehavior = .automatic + /// A running `superwall dev` server for ``SuperwallOptions/devServer`` to connect to. + public enum DevServer: Equatable { + /// Finds the dev server on `localhost` ports 6100–6104, which reaches a server + /// running on the same machine from a simulator. + case `default` + + /// The dev server at an exact address — the `Device` URL's origin that + /// `superwall dev` prints, e.g. `http://192.168.1.10:6100`. Use this on a + /// physical device, which can't reach your machine via `localhost`. + case url(URL) + } + /// Connects this SDK instance to a running `superwall dev` server, for development builds only. /// - /// Every paywall the SDK would present then renders from the dev server's live, local - /// paywall code instead of its published version, while configuration, placements, - /// audience evaluation and assignment all stay real. On a simulator this finds the dev - /// server on `localhost` automatically; on a physical device set ``devServerURL`` to - /// the `Device` URL that `superwall dev` prints. + /// Paywalls with a local counterpart on the dev server then render from your live, local + /// paywall code instead of their published versions, while configuration, placements, + /// audience evaluation and assignment all stay real. Paywalls without a local counterpart + /// still load their published versions. + /// + /// Use ``DevServer/default`` on a simulator; on a physical device use ``DevServer/url(_:)`` + /// with the `Device` URL that `superwall dev` prints. Defaults to `nil`: no dev server. /// - /// Dev mode also activates test mode (simulated purchases, product data from the + /// The dev server also activates test mode (simulated purchases, product data from the /// dashboard), disables paywall preloading, and skips the test mode intro sheet. /// /// The host app must allow local networking in its `Info.plist` /// (`NSAppTransportSecurity` → `NSAllowsLocalNetworking` and /// `NSAllowsArbitraryLoadsInWebContent`). - public var devMode = false + @nonobjc public var devServer: DevServer? - /// Where ``devMode`` looks for the `superwall dev` server. Setting this implies ``devMode``. - /// - /// Defaults to `localhost` ports 6100–6104, which reaches a dev server running on the - /// same machine from a simulator. On a physical device set this to the `Device` URL's - /// origin that `superwall dev` prints, e.g. `http://192.168.1.10:6100`. - @nonobjc public var devServerURL: URL? + /// Objective-C only: connects to a `superwall dev` server found on `localhost`. + @available(swift, obsoleted: 1.0) + public func enableDevServer() { + devServer = .default + } - var isDevModeEnabled: Bool { - return devMode || devServerURL != nil + /// Objective-C only: connects to the `superwall dev` server at this address. + @available(swift, obsoleted: 1.0) + public func enableDevServer(url: URL) { + devServer = .url(url) + } + + var isDevServerEnabled: Bool { + return devServer != nil + } + + /// Where ``devServer``'s ``DevServer/url(_:)`` case points, if that's what is set. + var devServerURL: URL? { + if case .url(let url) = devServer { + return url + } + return nil } /// Determines the number of times the SDK will attempt to get the Superwall configuration after a network diff --git a/Sources/SuperwallKit/DevServer/DevMode.swift b/Sources/SuperwallKit/DevServer/DevMode.swift index e6a7994fe..c8f87a9dc 100644 --- a/Sources/SuperwallKit/DevServer/DevMode.swift +++ b/Sources/SuperwallKit/DevServer/DevMode.swift @@ -20,7 +20,7 @@ enum DevMode { /// Whether dev mode should actually do anything right now: asked for, and /// running somewhere it is safe to (simulator, TestFlight, development). static func isActive(_ options: SuperwallOptions) -> Bool { - guard options.isDevModeEnabled else { + guard options.isDevServerEnabled else { return false } guard isSandboxEnvironment() else { @@ -38,9 +38,9 @@ enum DevMode { Logger.debug( logLevel: .warn, scope: .superwallCore, - message: "SuperwallOptions.devMode is on in a production build, so it is being ignored: " + message: "SuperwallOptions.devServer is set in a production build, so it is being ignored: " + "paywalls load their published versions and purchases are real. " - + "Remove devMode before shipping." + + "Remove devServer before shipping." ) } } diff --git a/Sources/SuperwallKit/DevServer/DevServerLocator.swift b/Sources/SuperwallKit/DevServer/DevServerLocator.swift index bb1e2d7b7..c3e990d03 100644 --- a/Sources/SuperwallKit/DevServer/DevServerLocator.swift +++ b/Sources/SuperwallKit/DevServer/DevServerLocator.swift @@ -60,7 +60,7 @@ actor DevServerLocator { message: "Dev mode is on but no superwall dev server was found at " + "\(bases.map { $0.absoluteString }.joined(separator: ", ")). " + "Paywalls will load their published versions. On a physical device, " - + "set SuperwallOptions.devServerURL to the Device URL superwall dev prints." + + "set SuperwallOptions.devServer to the Device URL superwall dev prints." ) return nil } diff --git a/Sources/SuperwallKit/DevServer/DevServerPreview.swift b/Sources/SuperwallKit/DevServer/DevServerPreview.swift index 0faa7368f..b72d18f1d 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPreview.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPreview.swift @@ -53,7 +53,7 @@ enum DevServerPreview { /// A deep-link-supplied base may only name a host `superwall dev` ever /// prints — loopback, `.local`, or a private-network address — or the - /// developer-supplied `devServerURL`, which is trusted input. Anything else + /// developer-supplied `devServer` URL, which is trusted input. Anything else /// is an arbitrary internet host that must not be handed the paywall /// pipeline's JS bridge. static func isTrustedBase(_ base: URL, devServerURL: URL?) -> Bool { @@ -93,8 +93,8 @@ enum DevServerPreview { Logger.debug( logLevel: .warn, scope: .superwallCore, - message: "Scanned a superwall dev link, but SuperwallOptions.devMode is off " - + "in this build. Enable devMode to preview local paywalls in the app." + message: "Scanned a superwall dev link, but SuperwallOptions.devServer is not set " + + "in this build. Set devServer to preview local paywalls in the app." ) return false } @@ -104,7 +104,7 @@ enum DevServerPreview { scope: .superwallCore, message: "Ignoring a superwall dev link pointing at \(outcome.base.absoluteString): " + "dev servers only run on localhost, .local hosts, or private-network addresses. " - + "To use another host, set it as SuperwallOptions.devServerURL." + + "To use another host, set it as SuperwallOptions.devServer's url." ) return false } diff --git a/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift b/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift index 834ff769d..1c4401500 100644 --- a/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift +++ b/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift @@ -97,8 +97,8 @@ struct DeepLinkRouterTests { // MARK: - Dev Server Preview URLs - @Test("Returns false for a superwall_dev link when dev mode is off") - func storeDeepLink_devServerLink_devModeOff() { + @Test("Returns false for a superwall_dev link when no dev server is set") + func storeDeepLink_devServerLink_devServerOff() { let url = URL(string: "myapp://?superwall_dev=http://localhost:6100")! let result = DeepLinkRouter.storeDeepLink(url) #expect(result == false) diff --git a/Tests/SuperwallKitTests/DevServer/DevModeTests.swift b/Tests/SuperwallKitTests/DevServer/DevModeTests.swift index c35482f7c..0fdaa1acb 100644 --- a/Tests/SuperwallKitTests/DevServer/DevModeTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevModeTests.swift @@ -12,10 +12,9 @@ final class DevModeTests: XCTestCase { super.tearDown() } - private func options(devMode: Bool = false, devServerURL: URL? = nil) -> SuperwallOptions { + private func options(devServer: SuperwallOptions.DevServer? = nil) -> SuperwallOptions { let options = SuperwallOptions() - options.devMode = devMode - options.devServerURL = devServerURL + options.devServer = devServer return options } @@ -26,23 +25,23 @@ final class DevModeTests: XCTestCase { func test_isActiveInSandboxWhenTheToggleIsOn() { DevMode.isSandboxEnvironment = { true } - XCTAssertTrue(DevMode.isActive(options(devMode: true))) + XCTAssertTrue(DevMode.isActive(options(devServer: .default))) } /// The one that matters: an App Store build must behave as if dev mode was /// never set, so purchases stay real and paywalls stay published. func test_isInertInProductionEvenWhenTheToggleIsOn() { DevMode.isSandboxEnvironment = { false } - XCTAssertFalse(DevMode.isActive(options(devMode: true))) + XCTAssertFalse(DevMode.isActive(options(devServer: .default))) } - func test_anExplicitDevServerUrlAlsoImpliesDevModeAndIsAlsoGated() throws { + func test_anExplicitDevServerUrlIsAlsoGated() throws { let url = try XCTUnwrap(URL(string: "http://192.168.1.10:6100")) DevMode.isSandboxEnvironment = { true } - XCTAssertTrue(DevMode.isActive(options(devServerURL: url))) + XCTAssertTrue(DevMode.isActive(options(devServer: .url(url)))) DevMode.isSandboxEnvironment = { false } - XCTAssertFalse(DevMode.isActive(options(devServerURL: url))) + XCTAssertFalse(DevMode.isActive(options(devServer: .url(url)))) } } diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPreviewTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPreviewTests.swift index fd2bd1584..bfa03db84 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerPreviewTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerPreviewTests.swift @@ -10,12 +10,10 @@ import Testing @Suite(.serialized) struct DevServerPreviewTests { private func options( - devMode: Bool = true, - devServerURL: URL? = nil + devServer: SuperwallOptions.DevServer? = .default ) -> SuperwallOptions { let options = SuperwallOptions() - options.devMode = devMode - options.devServerURL = devServerURL + options.devServer = devServer return options } @@ -76,14 +74,14 @@ struct DevServerPreviewTests { // MARK: - canHandle - @Test("A dev link is not Superwall's when dev mode is off") - func canHandle_devModeOff() throws { + @Test("A dev link is not Superwall's when no dev server is set") + func canHandle_devServerOff() throws { let url = try #require(URL(string: "myapp://?superwall_dev=http://localhost:6100")) - #expect(!DevServerPreview.canHandle(url: url, options: options(devMode: false))) + #expect(!DevServerPreview.canHandle(url: url, options: options(devServer: nil))) } - @Test("A dev link pointing at a local host is Superwall's when dev mode is on") - func canHandle_devModeOnLocalHost() throws { + @Test("A dev link pointing at a local host is Superwall's when a dev server is set") + func canHandle_devServerOnLocalHost() throws { DevMode.isSandboxEnvironment = { true } defer { DevMode.isSandboxEnvironment = { DeviceHelper.isSandboxEnvironment } } @@ -91,8 +89,8 @@ struct DevServerPreviewTests { #expect(DevServerPreview.canHandle(url: url, options: options())) } - @Test("A dev link pointing at an internet host is refused even with dev mode on") - func canHandle_devModeOnPublicHost() throws { + @Test("A dev link pointing at an internet host is refused even with a dev server set") + func canHandle_devServerOnPublicHost() throws { DevMode.isSandboxEnvironment = { true } defer { DevMode.isSandboxEnvironment = { DeviceHelper.isSandboxEnvironment } } From ae9e8196995211bc6ea8d4e42af4222ba0a9d33f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:35:51 +0200 Subject: [PATCH 038/162] chore: retrigger CI after a stuck Pullfrog run From d6f8f63235e831f56772e751564f3b31cc6db394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:00:51 +0200 Subject: [PATCH 039/162] fix(debugger): make Preview work for unpushed dev-server surfaces Preview presents via .fromIdentifier, and a local surface's synthetic dev: identifier has no backend counterpart, so the fetch 404ed into "There isn't a paywall configured to show in this context." The request pipeline now resolves dev: identifiers from the debugger's manifest before consulting statics or the network, which routes the full presentation and product pipeline through the local paywall. Verified end to end in the simulator: dev link -> picker -> Preview presents the local surface with loaded products. All 945 unit tests pass; the resolution glue itself is exercised by that manual flow since it needs a live debugger session. Co-Authored-By: Claude Fable 5 --- .../Operators/RawPaywallResponse.swift | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift index 56e600507..56dcd5694 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift @@ -69,6 +69,35 @@ extension PaywallRequestManager { return paywall } + /// Resolves a synthetic `dev:` identifier — a dev-server surface the + /// debugger selected that has never been pushed to the dashboard — from the + /// debugger's manifest, since the backend has nothing to fetch for it. + private func devServerPaywall(forId paywallId: String?) async -> Paywall? { + guard let paywallId = paywallId else { + return nil + } + guard paywallId.hasPrefix("dev:") else { + return nil + } + guard DevMode.isActive(factory.makeSuperwallOptions()) else { + return nil + } + guard let devServer = await MainActor.run(body: { + Superwall.shared.dependencyContainer.debugManager.devServer + }) else { + return nil + } + guard let surface = devServer.surfaces.first(where: { "dev:\($0.id)" == paywallId }) else { + return nil + } + guard let mountURL = DevServerManifest(surfaces: devServer.surfaces) + .mountURL(for: surface, base: devServer.base) + else { + return nil + } + return Paywall.devServer(surface: surface, url: mountURL) + } + private func getPaywallResponse( from request: PaywallRequest ) async throws -> Paywall { @@ -78,7 +107,9 @@ extension PaywallRequestManager { var paywall: Paywall do { - if let staticPaywall = factory.makeStaticPaywall( + if let devPaywall = await devServerPaywall(forId: paywallId) { + paywall = devPaywall + } else if let staticPaywall = factory.makeStaticPaywall( withId: paywallId, isDebuggerLaunched: request.isDebuggerLaunched ) { From ee963aa166f9674b8f586427b5c06b6fc8e3b912 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:21:30 +0200 Subject: [PATCH 040/162] feat(dev): serve matching local surfaces wholesale instead of patching published paywalls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the dev server has a surface for a paywall, the placement path now presents the synthesized local paywall — products and all — rather than the published paywall with a swapped URL. Mixing the two meant local pages asked for product references the published paywall didn't declare, rendering blank prices. Only the assignment's experiment and fetch timings carry over, keeping holdouts and analytics coherent; bound surfaces keep their real database id. Verified in the simulator: a placement now presents the local paywall with its own products and price, matching the debugger's preview. Co-Authored-By: Claude Fable 5 --- .../Operators/RawPaywallResponse.swift | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift index 56dcd5694..9fad08a31 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift @@ -43,30 +43,23 @@ extension PaywallRequestManager { return paywall } - var paywall = paywall - paywall.url = mountURL - // A changed cacheKey is what makes an already-cached view controller - // reload its web view; without it a moved dev server or a published - // fallback would present the stale page. - paywall.cacheKey = "dev:\(paywall.cacheKey):\(mountURL.absoluteString)" - paywall.urlConfig = WebViewURLConfig( - endpoints: [ - WebViewEndpoint( - url: mountURL, - timeout: 15, - percentage: 100 - ) - ], - maxAttempts: 1 - ) - paywall.manifest = nil + // The local surface replaces the published paywall wholesale, so what + // presents is exactly what its config.ts declares — products included. + // Only the assignment's experiment and the fetch timings carry over, + // keeping holdouts and analytics coherent. The synthesized cacheKey + // embeds the mount URL, so a moved dev server or a published fallback + // reloads the web view instead of presenting the stale page. + var devPaywall = Paywall.devServer(surface: surface, url: mountURL) + devPaywall.experiment = paywall.experiment + devPaywall.responseLoadingInfo = paywall.responseLoadingInfo Logger.debug( logLevel: .info, scope: .superwallCore, - message: "Dev server override: paywall \(paywall.identifier) renders from \(mountURL.absoluteString)." + message: "Dev server override: paywall \(paywall.identifier) is served as local surface " + + "\(surface.id) from \(mountURL.absoluteString)." ) - return paywall + return devPaywall } /// Resolves a synthetic `dev:` identifier — a dev-server surface the From 75c29ba719c5cdce7bc714babade5b68289893b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:32:14 +0200 Subject: [PATCH 041/162] feat(dev): mark locally served paywalls and honour their presentation config Adds PaywallInfo.isLocal, sent as is_local on every paywall event and audience filter param, so the dashboard can flag a presentation that came from a superwall dev server rather than a published paywall. Local surfaces also stop presenting with a hardcoded modal style: the manifest now carries the presentation config declared in config.ts, and the SDK maps style/drawer/popup onto PaywallPresentationStyle. Anything missing or unrecognised falls back to fullscreen, which is the framework documented default. Co-Authored-By: Claude Opus 5 --- .../DevServer/DevServerPaywall.swift | 37 +++++++++++++- .../DevServer/DevServerSurface.swift | 18 +++++++ .../SuperwallKit/Models/Paywall/Paywall.swift | 8 +++ .../Paywall/Presentation/PaywallInfo.swift | 6 +++ .../DevServer/DevServerPaywallTests.swift | 51 ++++++++++++++++++- 5 files changed, 117 insertions(+), 3 deletions(-) diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift index 20a0697d0..c71390859 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift @@ -23,7 +23,7 @@ extension Paywall { ) } - return Paywall( + var paywall = Paywall( databaseId: surface.paywallId ?? "dev:\(surface.kind)/\(surface.id)", identifier: surface.identifier ?? "dev:\(surface.id)", name: surface.id, @@ -35,7 +35,10 @@ extension Paywall { maxAttempts: 1 ), htmlSubstitutions: "", - presentation: PaywallPresentationInfo(style: .modal, delay: 0), + presentation: PaywallPresentationInfo( + style: presentationStyle(for: surface), + delay: 0 + ), backgroundColorHex: "#FFFFFF", backgroundColor: .white, darkBackgroundColorHex: nil, @@ -51,5 +54,35 @@ extension Paywall { isScrollEnabled: true, introOfferEligibility: .automatic ) + paywall.isLocal = true + return paywall + } + + /// Maps a surface's `config.ts` presentation onto the SDK's styles. + /// The framework documents `fullscreen` as its default, so anything + /// missing or unrecognized lands there. + private static func presentationStyle( + for surface: DevServerSurface + ) -> PaywallPresentationStyle { + switch surface.presentation?.style { + case "modal": + return .modal + case "push": + return .push + case "noAnimation": + return .fullscreenNoAnimation + case "drawer": + if let drawer = surface.presentation?.drawer { + return .drawer(height: drawer.height, cornerRadius: drawer.cornerRadius) + } + return .fullscreen + case "popup": + if let popup = surface.presentation?.popup { + return .popup(height: popup.height, width: popup.width, cornerRadius: popup.cornerRadius) + } + return .fullscreen + default: + return .fullscreen + } } } diff --git a/Sources/SuperwallKit/DevServer/DevServerSurface.swift b/Sources/SuperwallKit/DevServer/DevServerSurface.swift index 974f35db1..78f4ba5bc 100644 --- a/Sources/SuperwallKit/DevServer/DevServerSurface.swift +++ b/Sources/SuperwallKit/DevServer/DevServerSurface.swift @@ -10,10 +10,28 @@ import Foundation struct DevServerSurface: Decodable, Equatable { + /// How the paywall asks to be presented, straight from its `config.ts`. + struct Presentation: Decodable, Equatable { + struct Drawer: Decodable, Equatable { + let height: Double + let cornerRadius: Double + } + struct Popup: Decodable, Equatable { + let width: Double + let height: Double + let cornerRadius: Double + } + + let style: String? + let drawer: Drawer? + let popup: Popup? + } + let kind: String let id: String let url: String let paywallId: String? let identifier: String? let products: [String: String]? + let presentation: Presentation? } diff --git a/Sources/SuperwallKit/Models/Paywall/Paywall.swift b/Sources/SuperwallKit/Models/Paywall/Paywall.swift index 1033735ca..f1caa3273 100644 --- a/Sources/SuperwallKit/Models/Paywall/Paywall.swift +++ b/Sources/SuperwallKit/Models/Paywall/Paywall.swift @@ -87,6 +87,10 @@ struct Paywall: Codable { /// Indicates whether scrolling is enabled on the webview. var isScrollEnabled: Bool + /// Whether this paywall was synthesized from a `superwall dev` server + /// surface rather than fetched from the dashboard. + var isLocal = false + /// Indicates how intro offer eligiblity should be treat on products. Defaults to /// `.automatic`. let introOfferEligibility: IntroOfferEligibility @@ -189,6 +193,7 @@ struct Paywall: Codable { case surveys case manifest case isScrollEnabled + case isLocal case introductoryOfferEligibility case responseLoadStartTime @@ -305,6 +310,7 @@ struct Paywall: Codable { manifest = try values.decodeIfPresent(ArchiveManifest.self, forKey: .manifest) isScrollEnabled = try values.decodeIfPresent(Bool.self, forKey: .isScrollEnabled) ?? true + isLocal = try values.decodeIfPresent(Bool.self, forKey: .isLocal) ?? false introOfferEligibility = try values .decodeIfPresent(IntroOfferEligibility.self, forKey: .introductoryOfferEligibility) ?? .automatic } @@ -363,6 +369,7 @@ struct Paywall: Codable { try container.encodeIfPresent(manifest, forKey: .manifest) try container.encodeIfPresent(isScrollEnabled, forKey: .isScrollEnabled) + try container.encode(isLocal, forKey: .isLocal) try container.encodeIfPresent(introOfferEligibility, forKey: .introductoryOfferEligibility) } @@ -471,6 +478,7 @@ struct Paywall: Codable { surveys: surveys, presentation: presentation, isScrollEnabled: isScrollEnabled, + isLocal: isLocal, state: state, introOfferEligibility: introOfferEligibility ) diff --git a/Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift b/Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift index dac01c42d..4c3f91bb6 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift @@ -128,6 +128,9 @@ public final class PaywallInfo: NSObject { /// Indicates whether scrolling of the webview is enabled. public let isScrollEnabled: Bool + /// Whether the paywall was served from a local `superwall dev` server. + public let isLocal: Bool + /// The state of the paywall, updated on paywall did dismiss. public let state: [String: Any] @@ -172,6 +175,7 @@ public final class PaywallInfo: NSObject { surveys: [Survey], presentation: PaywallPresentationInfo, isScrollEnabled: Bool, + isLocal: Bool = false, state: [String: Any], introOfferEligibility: IntroOfferEligibility ) { @@ -241,6 +245,7 @@ public final class PaywallInfo: NSObject { self.closeReason = closeReason self.isScrollEnabled = isScrollEnabled + self.isLocal = isLocal self.state = state self.introOfferEligibility = introOfferEligibility } @@ -325,6 +330,7 @@ public final class PaywallInfo: NSObject { "paywall_product_ids": productIds.joined(separator: ","), "is_free_trial_available": isFreeTrialAvailable as Any, "feature_gating": featureGatingBehavior.description as Any, + "is_local": isLocal, "presented_by": presentedBy as Any ] diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift index 656a90f9c..737d2207d 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift @@ -11,7 +11,8 @@ final class DevServerPaywallTests: XCTestCase { id: String = "pro", paywallId: String? = nil, identifier: String? = nil, - products: [String: String]? = nil + products: [String: String]? = nil, + presentation: String? = nil ) -> DevServerSurface { let json = """ { @@ -20,6 +21,7 @@ final class DevServerPaywallTests: XCTestCase { "url": "/preview/paywall/\(id)", \(paywallId.map { "\"paywallId\": \"\($0)\"," } ?? "") \(identifier.map { "\"identifier\": \"\($0)\"," } ?? "") + \(presentation.map { "\"presentation\": \($0)," } ?? "") "products": \(products.map { dict in "{" + dict.map { "\"\($0.key)\": \"\($0.value)\"" }.sorted().joined(separator: ",") + "}" } ?? "null") @@ -72,4 +74,51 @@ final class DevServerPaywallTests: XCTestCase { XCTAssertEqual(paywall.databaseId, "253583") XCTAssertEqual(paywall.identifier, "chatgpt-plus") } + + func test_isMarkedLocalSoEventsCanSaySo() { + let paywall = Paywall.devServer(surface: surface(), url: url) + + XCTAssertTrue(paywall.isLocal) + let params = paywall.getInfo(fromPlacement: nil).audienceFilterParams() + XCTAssertEqual(params["is_local"] as? Bool, true) + } + + func test_presentsFullscreenWhenTheConfigSaysNothing() { + let paywall = Paywall.devServer(surface: surface(), url: url) + XCTAssertEqual(paywall.presentation.style, .fullscreen) + } + + func test_usesThePresentationStyleTheConfigDeclares() { + let modal = Paywall.devServer( + surface: surface(presentation: #"{"style": "modal"}"#), + url: url + ) + XCTAssertEqual(modal.presentation.style, .modal) + + let drawer = Paywall.devServer( + surface: surface(presentation: #"{"style": "drawer", "drawer": {"height": 420, "cornerRadius": 24}}"#), + url: url + ) + XCTAssertEqual(drawer.presentation.style, .drawer(height: 420, cornerRadius: 24)) + + let popup = Paywall.devServer( + surface: surface(presentation: #"{"style": "popup", "popup": {"width": 300, "height": 500, "cornerRadius": 16}}"#), + url: url + ) + XCTAssertEqual(popup.presentation.style, .popup(height: 500, width: 300, cornerRadius: 16)) + } + + func test_fallsBackToFullscreenWhenAStyleIsUnknownOrIncomplete() { + let unknown = Paywall.devServer( + surface: surface(presentation: #"{"style": "hologram"}"#), + url: url + ) + XCTAssertEqual(unknown.presentation.style, .fullscreen) + + let drawerWithoutGeometry = Paywall.devServer( + surface: surface(presentation: #"{"style": "drawer"}"#), + url: url + ) + XCTAssertEqual(drawerWithoutGeometry.presentation.style, .fullscreen) + } } From 7e87a07c28b9e2be688e4f9523463c634d4eda46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:19:52 +0200 Subject: [PATCH 042/162] fix(events): dedupe subscription status changes by logical state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since 4.10.0, Entitlement equality compares all 13 fields, so repeat writes of the same logical subscription status (bare vs enriched entitlements, sub-second date drift) pass removeDuplicates() and each fires the delegate, a subscriptionStatus_didChange event, and a device_attributes event — up to 6x per session for apps that resolve entitlement state in stages (SW-5801). Add a logical comparison (case + entitlement id/type/isActive) and use it to dedupe the status listener. Public deep equality is unchanged, as customerInfo change detection depends on it. Move the persisted-status save into didSet so metadata-only updates still refresh the cache. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + .../Products/EntitlementsStatus.swift | 20 ++++ .../Products/StoreProduct/Entitlement.swift | 23 +++- Sources/SuperwallKit/Superwall.swift | 7 +- SuperwallKit.xcodeproj/project.pbxproj | 4 + .../xcschemes/SuperwallKit.xcscheme | 3 +- ...bscriptionStatusLogicalEqualityTests.swift | 108 ++++++++++++++++++ 7 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 Tests/SuperwallKitTests/StoreKit/Products/SubscriptionStatusLogicalEqualityTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index a8ed9f98a..64bb8253b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Fixes +- Fixes duplicate device attribute and subscription status change events being tracked when the subscription status is repeatedly set to the same logical state. - Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. - Fixes a data race during SDK configuration that Thread Sanitizer flagged on every launch. - Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them. diff --git a/Sources/SuperwallKit/StoreKit/Products/EntitlementsStatus.swift b/Sources/SuperwallKit/StoreKit/Products/EntitlementsStatus.swift index ef8c9750a..cf337028e 100644 --- a/Sources/SuperwallKit/StoreKit/Products/EntitlementsStatus.swift +++ b/Sources/SuperwallKit/StoreKit/Products/EntitlementsStatus.swift @@ -54,6 +54,26 @@ public enum SubscriptionStatus: Equatable, Codable { } } +// MARK: - Logical Equality +extension SubscriptionStatus { + /// Whether both statuses represent the same logical subscription state. + /// + /// Two `.active` statuses are logically equal when their entitlements have the + /// same identities (`id`, `type`, and `isActive`), even if transaction metadata + /// like expiry dates or renewal state differs. `==` compares that metadata too, + /// so repeat writes of the same logical status can read as changes. + func isLogicallyEqual(to other: SubscriptionStatus) -> Bool { + switch (self, other) { + case (.unknown, .unknown), (.inactive, .inactive): + return true + case let (.active(lhsEntitlements), .active(rhsEntitlements)): + return Set(lhsEntitlements.map(\.identity)) == Set(rhsEntitlements.map(\.identity)) + default: + return false + } + } +} + // MARK: - CustomStringConvertible extension SubscriptionStatus: CustomStringConvertible { public var description: String { diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift index 9e2b0c319..e5b0172c7 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift @@ -255,7 +255,8 @@ public final class Entitlement: NSObject, Codable, Sendable { try container.encodeIfPresent(offerType, forKey: .offerType) } - // Override isEqual to define equality based on `id` and `type` + // Deep equality across all fields. For detecting logical status changes, + // use `identity` instead, which ignores transaction metadata. public override func isEqual(_ object: Any?) -> Bool { guard let other = object as? Entitlement else { return false @@ -294,6 +295,26 @@ public final class Entitlement: NSObject, Codable, Sendable { } } +// MARK: - Logical Identity +extension Entitlement { + /// The fields that define which entitlement this is and whether it grants + /// access. Transaction metadata like dates, product IDs, and renewal state + /// can differ between writes of the same logical status, so it's excluded. + struct Identity: Hashable { + let id: String + let type: EntitlementType + let isActive: Bool + } + + var identity: Identity { + return Identity( + id: id, + type: type, + isActive: isActive + ) + } +} + // MARK: - Entitlement Merging extension Entitlement { /// Determines which entitlement should take priority when merging entitlements with the same ID. diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 232a5e38f..53ea18c92 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -219,6 +219,9 @@ public final class Superwall: NSObject, ObservableObject { subscriptionStatus = resolved return } + // Saved here rather than in the status listener so that metadata-only + // updates, which the listener dedupes, still refresh the cache. + dependencyContainer.storage.save(subscriptionStatus, forType: SubscriptionStatusKey.self) entitlements.subscriptionStatusDidSet(subscriptionStatus) // When using an external purchase controller, update CustomerInfo.entitlements @@ -568,7 +571,7 @@ public final class Superwall: NSObject, ObservableObject { private func listenToSubscriptionStatus() { $subscriptionStatus - .removeDuplicates() + .removeDuplicates { $0.isLogicallyEqual(to: $1) } .dropFirst() .scan((previous: subscriptionStatus, current: subscriptionStatus)) { previousPair, newStatus in // Shift the current value to previous, and set the new status as the current value @@ -585,8 +588,6 @@ public final class Superwall: NSObject, ObservableObject { let oldStatus = statusPair.previous let newStatus = statusPair.current - self.dependencyContainer.storage.save(newStatus, forType: SubscriptionStatusKey.self) - Task { await self.dependencyContainer.delegateAdapter.subscriptionStatusDidChange( from: oldStatus, to: newStatus) diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index f7df29d0a..2b3c61e60 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -63,6 +63,7 @@ 18D39CB7BCF324B44197735D /* TaskRetryingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9D2422F9742D74360FB716B /* TaskRetryingTests.swift */; }; 191AA8FBBF617251EF6F8628 /* DeviceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1EFE389B54C304F2B01620 /* DeviceInfo.swift */; }; 1A6C6E6DAD8C0236FA24FF10 /* CheckNoPaywallAlreadyPresented.swift in Sources */ = {isa = PBXBuildFile; fileRef = 582CE0C5BA6EA57C7FE3EE43 /* CheckNoPaywallAlreadyPresented.swift */; }; + 1B071A6918B1B36BF6BCC0C2 /* SubscriptionStatusLogicalEqualityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D175537791B3A913425F88 /* SubscriptionStatusLogicalEqualityTests.swift */; }; 1BA9EC022F0016E72A5EB01A /* PaywallPresentationRequestStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC211BFF9364E4B10418695B /* PaywallPresentationRequestStatus.swift */; }; 1BDB91B70EAEC10097941381 /* SWBounceButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 299F91895EE88281B5ED8320 /* SWBounceButton.swift */; }; 1E0D00DF75A6779C78145750 /* SurveyPresentationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B66B5A624F8A2E225EACA69 /* SurveyPresentationResult.swift */; }; @@ -964,6 +965,7 @@ A116633D7246EB7BB7AF7229 /* ExpressionEvaluatorMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpressionEvaluatorMock.swift; sourceTree = ""; }; A12EB4944354482783293010 /* PaywallSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallSummary.swift; sourceTree = ""; }; A154A9E99D00B9BD8837B798 /* ExpressionLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpressionLogic.swift; sourceTree = ""; }; + A1D175537791B3A913425F88 /* SubscriptionStatusLogicalEqualityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionStatusLogicalEqualityTests.swift; sourceTree = ""; }; A21ED2D8CB4DDA70E228E8BC /* TaskExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskExecutor.swift; sourceTree = ""; }; A22E703895B07CF172665846 /* PaywallLoadingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallLoadingState.swift; sourceTree = ""; }; A2D40088A465E104CF5C67CC /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = ""; }; @@ -1585,6 +1587,7 @@ BD6BA222CB2EAA4B65F362C5 /* ProductsFetcherSK1.swift */, 0ECD75DF8F3EB6A68A21444D /* ProductsFetcherSK2Tests.swift */, B00929DACD8621FC32F83927 /* SK2StoreProductCyclesTests.swift */, + A1D175537791B3A913425F88 /* SubscriptionStatusLogicalEqualityTests.swift */, 0E9A13ACB22951C865722510 /* Receipt Manager */, 9160DE1504D8084C3DF51ADC /* StoreProduct */, ); @@ -3385,6 +3388,7 @@ B162BE92B3568078BC0ADD1B /* StoreProductBillingPlanTests.swift in Sources */, 5E51E14716E29C9B88B8A6F2 /* StripeTrialEligibilityTests.swift in Sources */, 097719E21BBD153BA6FD6785 /* SubscriptionPeriodPriceTests.swift in Sources */, + 1B071A6918B1B36BF6BCC0C2 /* SubscriptionStatusLogicalEqualityTests.swift in Sources */, E9F892ABB9BDA85F4794E3CF /* SubscriptionStatusResolutionTests.swift in Sources */, 89CC491C60F7CD12D3E73284 /* SurveyManagerTests.swift in Sources */, 252D37DDAA2C97A6E2DDD6B7 /* SurveyTests.swift in Sources */, diff --git a/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme b/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme index e2f319bd6..8c5e0a183 100644 --- a/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme +++ b/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme @@ -40,7 +40,8 @@ + skipped = "NO" + parallelizable = "NO"> Entitlement { + return Entitlement( + id: id, + type: .serviceLevel, + isActive: isActive, + productIds: ["com.example.monthly", "com.example.annual"], + latestProductId: "com.example.monthly", + store: .appStore, + startsAt: Date(timeIntervalSince1970: 1_700_000_000), + renewedAt: Date(timeIntervalSince1970: 1_750_000_000), + expiresAt: expiresAt, + isLifetime: false, + willRenew: true, + state: .subscribed, + offerType: nil + ) + } + + // MARK: - Case comparisons + + @Test + func sameCases_areLogicallyEqual() { + #expect(SubscriptionStatus.unknown.isLogicallyEqual(to: .unknown)) + #expect(SubscriptionStatus.inactive.isLogicallyEqual(to: .inactive)) + } + + @Test + func differentCases_areNotLogicallyEqual() { + #expect(!SubscriptionStatus.unknown.isLogicallyEqual(to: .inactive)) + #expect(!SubscriptionStatus.inactive.isLogicallyEqual(to: .active([Entitlement(id: "premium")]))) + #expect(!SubscriptionStatus.active([Entitlement(id: "premium")]).isLogicallyEqual(to: .unknown)) + } + + // MARK: - Active status comparisons + + @Test + func active_identicalEntitlements_areLogicallyEqual() { + let status: SubscriptionStatus = .active([enrichedEntitlement()]) + let repeatWrite: SubscriptionStatus = .active([enrichedEntitlement()]) + #expect(status.isLogicallyEqual(to: repeatWrite)) + } + + @Test + func active_bareVsEnrichedEntitlement_areLogicallyEqual() { + // An app using a purchase controller writes a bare entitlement while the + // SDK holds an enriched one with transaction metadata. Deep equality says + // these differ; logically the status is unchanged. + let bare: SubscriptionStatus = .active([Entitlement(id: "premium")]) + let enriched: SubscriptionStatus = .active([enrichedEntitlement()]) + #expect(bare != enriched) + #expect(bare.isLogicallyEqual(to: enriched)) + } + + @Test + func active_driftingMetadata_areLogicallyEqual() { + let first: SubscriptionStatus = .active([ + enrichedEntitlement(expiresAt: Date(timeIntervalSince1970: 1_800_000_000)) + ]) + let second: SubscriptionStatus = .active([ + enrichedEntitlement(expiresAt: Date(timeIntervalSince1970: 1_800_000_000.5)) + ]) + #expect(first != second) + #expect(first.isLogicallyEqual(to: second)) + } + + @Test + func active_differentEntitlementIds_areNotLogicallyEqual() { + let premium: SubscriptionStatus = .active([enrichedEntitlement(id: "premium")]) + let pro: SubscriptionStatus = .active([enrichedEntitlement(id: "pro")]) + #expect(!premium.isLogicallyEqual(to: pro)) + } + + @Test + func active_addedEntitlement_areNotLogicallyEqual() { + let one: SubscriptionStatus = .active([enrichedEntitlement(id: "premium")]) + let two: SubscriptionStatus = .active([ + enrichedEntitlement(id: "premium"), + enrichedEntitlement(id: "pro") + ]) + #expect(!one.isLogicallyEqual(to: two)) + } + + @Test + func active_isActiveFlip_areNotLogicallyEqual() { + let active: SubscriptionStatus = .active([enrichedEntitlement(isActive: true)]) + let lapsed: SubscriptionStatus = .active([enrichedEntitlement(isActive: false)]) + #expect(!active.isLogicallyEqual(to: lapsed)) + } +} From a0a81c09bcca7a1b7b46314eb89ed92eb5dd57a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:41:05 +0200 Subject: [PATCH 043/162] review: gate the cache save, document logical-change semantics, add behavioral tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses pullfrog review: skip byte-identical writes when persisting the subscription status, document that subscriptionStatusDidChange fires on logical status changes only (metadata changes surface via customerInfoDidChange), and pin both behavioral hunks with tests — the cache refresh on metadata-only writes and the delegate dedupe. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- .../Delegate/SuperwallDelegate.swift | 9 ++- Sources/SuperwallKit/Superwall.swift | 11 ++-- ...bscriptionStatusLogicalEqualityTests.swift | 64 +++++++++++++++++++ .../Web/MockSuperwallDelegate.swift | 8 +++ 5 files changed, 88 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64bb8253b..597c88778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Fixes -- Fixes duplicate device attribute and subscription status change events being tracked when the subscription status is repeatedly set to the same logical state. +- Fixes duplicate device attribute and subscription status change events being tracked when the subscription status is repeatedly set to the same logical state. As part of this, `subscriptionStatusDidChange` now fires only when the logical status changes — the status case, the set of entitlements, or an entitlement's `isActive` flag. Updates to transaction metadata such as expiry dates or renewal state no longer trigger it; use `customerInfoDidChange` for those. - Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. - Fixes a data race during SDK configuration that Thread Sanitizer flagged on every launch. - Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them. diff --git a/Sources/SuperwallKit/Delegate/SuperwallDelegate.swift b/Sources/SuperwallKit/Delegate/SuperwallDelegate.swift index 2b1ceb936..d9e22acdc 100644 --- a/Sources/SuperwallKit/Delegate/SuperwallDelegate.swift +++ b/Sources/SuperwallKit/Delegate/SuperwallDelegate.swift @@ -16,7 +16,14 @@ import Foundation /// To learn how to conform to the delegate in your app and best practices, see /// [our docs](https://docs.superwall.com/docs/3rd-party-analytics). public protocol SuperwallDelegate: AnyObject { - /// Called when the ``Superwall/subscriptionStatus`` changes. + /// Called when the logical state of ``Superwall/subscriptionStatus`` changes: + /// the status moves between unknown, inactive, and active, an entitlement is + /// gained or lost, or an entitlement's `isActive` flag changes. + /// + /// Updates that only change transaction metadata — such as expiry dates, + /// renewal state, or the store an entitlement came from — don't trigger this + /// callback. Use ``SuperwallDelegate/customerInfoDidChange(from:to:)`` to + /// react to those. /// /// You can use this function to update the state of your application. Alternatively, you can /// use the published property ``Superwall/subscriptionStatus`` to react to diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 53ea18c92..a7f7e9756 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -210,7 +210,7 @@ public final class Superwall: NSObject, ObservableObject { /// /// Otherwise, you can check the delegate function /// ``SuperwallDelegate/subscriptionStatusDidChange(from:to:)`` - /// to receive a callback every time it changes. + /// to receive a callback whenever the logical status changes. @Published public var subscriptionStatus: SubscriptionStatus = .unknown { didSet { @@ -220,8 +220,11 @@ public final class Superwall: NSObject, ObservableObject { return } // Saved here rather than in the status listener so that metadata-only - // updates, which the listener dedupes, still refresh the cache. - dependencyContainer.storage.save(subscriptionStatus, forType: SubscriptionStatusKey.self) + // updates, which the listener dedupes, still refresh the cache. Identical + // writes are skipped so they don't re-encode and rewrite the file. + if oldValue != subscriptionStatus { + dependencyContainer.storage.save(subscriptionStatus, forType: SubscriptionStatusKey.self) + } entitlements.subscriptionStatusDidSet(subscriptionStatus) // When using an external purchase controller, update CustomerInfo.entitlements @@ -569,7 +572,7 @@ public final class Superwall: NSObject, ObservableObject { )) } - private func listenToSubscriptionStatus() { + func listenToSubscriptionStatus() { $subscriptionStatus .removeDuplicates { $0.isLogicallyEqual(to: $1) } .dropFirst() diff --git a/Tests/SuperwallKitTests/StoreKit/Products/SubscriptionStatusLogicalEqualityTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/SubscriptionStatusLogicalEqualityTests.swift index 2fb116399..9bc66e76e 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/SubscriptionStatusLogicalEqualityTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/SubscriptionStatusLogicalEqualityTests.swift @@ -105,4 +105,68 @@ struct SubscriptionStatusLogicalEqualityTests { let lapsed: SubscriptionStatus = .active([enrichedEntitlement(isActive: false)]) #expect(!active.isLogicallyEqual(to: lapsed)) } + + // MARK: - Persistence + + @Test + func setSubscriptionStatus_metadataOnlyUpdate_refreshesCache() { + let dependencyContainer = DependencyContainer() + let superwall = Superwall(dependencyContainer: dependencyContainer) + dependencyContainer.storage.delete(SubscriptionStatusKey.self) + + let first: SubscriptionStatus = .active([ + enrichedEntitlement(expiresAt: Date(timeIntervalSince1970: 1_800_000_000)) + ]) + superwall.subscriptionStatus = first + #expect(dependencyContainer.storage.get(SubscriptionStatusKey.self) == first) + + // Same logical state, different metadata. The listener dedupes this, + // but the persisted cache must still refresh. + let renewed: SubscriptionStatus = .active([ + enrichedEntitlement(expiresAt: Date(timeIntervalSince1970: 1_900_000_000)) + ]) + superwall.subscriptionStatus = renewed + #expect(dependencyContainer.storage.get(SubscriptionStatusKey.self) == renewed) + } + + // MARK: - Listener dedupe + + @Test + func listener_metadataOnlyUpdate_doesNotCallDelegate() async { + let dependencyContainer = DependencyContainer() + let superwall = Superwall(dependencyContainer: dependencyContainer) + let delegate = MockSuperwallDelegate() + dependencyContainer.delegateAdapter.swiftDelegate = delegate + superwall.listenToSubscriptionStatus() + + superwall.subscriptionStatus = .active([Entitlement(id: "premium")]) + await waitUntil { delegate.subscriptionStatusChanges.count == 1 } + #expect(delegate.subscriptionStatusChanges.count == 1) + + // Metadata-only update: same id/type/isActive, enriched with + // transaction metadata. Must not call the delegate again. + superwall.subscriptionStatus = .active([enrichedEntitlement(id: "premium")]) + try? await Task.sleep(nanoseconds: 300_000_000) + #expect(delegate.subscriptionStatusChanges.count == 1) + + // A logical change must still call the delegate. + superwall.subscriptionStatus = .inactive + await waitUntil { delegate.subscriptionStatusChanges.count == 2 } + #expect(delegate.subscriptionStatusChanges.count == 2) + + // A different entitlement id is also a logical change. + superwall.subscriptionStatus = .active([enrichedEntitlement(id: "pro")]) + await waitUntil { delegate.subscriptionStatusChanges.count == 3 } + #expect(delegate.subscriptionStatusChanges.count == 3) + } + + private func waitUntil( + timeout: TimeInterval = 2, + _ condition: @escaping () -> Bool + ) async { + let start = Date() + while !condition() && Date().timeIntervalSince(start) < timeout { + try? await Task.sleep(nanoseconds: 50_000_000) + } + } } diff --git a/Tests/SuperwallKitTests/Web/MockSuperwallDelegate.swift b/Tests/SuperwallKitTests/Web/MockSuperwallDelegate.swift index a43e87cd6..a2d3984a7 100644 --- a/Tests/SuperwallKitTests/Web/MockSuperwallDelegate.swift +++ b/Tests/SuperwallKitTests/Web/MockSuperwallDelegate.swift @@ -16,6 +16,14 @@ final class MockSuperwallDelegate: SuperwallDelegate { var willRedeemCallCount = 0 var willRedeemCalledAt: Date? var didRedeemCalledAt: Date? + var subscriptionStatusChanges: [(from: SubscriptionStatus, to: SubscriptionStatus)] = [] + + func subscriptionStatusDidChange( + from oldValue: SubscriptionStatus, + to newValue: SubscriptionStatus + ) { + subscriptionStatusChanges.append((from: oldValue, to: newValue)) + } func didRedeemLink(result: RedemptionResult) { receivedResult = result From 29146b4c596a4a88d26d6055dc9d543902ebc68a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:04:43 +0200 Subject: [PATCH 044/162] review: mirror logical-change docs onto the ObjC delegate Co-Authored-By: Claude Fable 5 --- .../SuperwallKit/Delegate/SuperwallDelegateObjc.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/Delegate/SuperwallDelegateObjc.swift b/Sources/SuperwallKit/Delegate/SuperwallDelegateObjc.swift index 632478d54..f67171242 100644 --- a/Sources/SuperwallKit/Delegate/SuperwallDelegateObjc.swift +++ b/Sources/SuperwallKit/Delegate/SuperwallDelegateObjc.swift @@ -78,7 +78,14 @@ public protocol SuperwallDelegateObjc: AnyObject { @MainActor @objc optional func handleSuperwallEvent(withInfo eventInfo: SuperwallEventInfo) - /// Called when the ``Superwall/subscriptionStatusObjc`` changes. + /// Called when the logical state of ``Superwall/subscriptionStatusObjc`` changes: + /// the status moves between unknown, inactive, and active, an entitlement is + /// gained or lost, or an entitlement's `isActive` flag changes. + /// + /// Updates that only change transaction metadata — such as expiry dates, + /// renewal state, or the store an entitlement came from — don't trigger this + /// callback. Use ``SuperwallDelegateObjc/customerInfoDidChange(from:to:)`` to + /// react to those. /// /// You can use this function to update the state of your application. /// From 5210fac0f225819ca0c19b235a54efd5c9e72588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:52:41 +0200 Subject: [PATCH 045/162] feat(entitlements): add grantedEntitlements merged into subscription status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a public grantedEntitlements: Set bucket for access granted by the developer's own backend, merged into subscriptionStatus alongside device and web entitlements via the existing prioritized merge. Resolution now tracks the last externally assigned status as an unresolved base and always resolves from it, so clearing granted entitlements can unwind the merge. The persisted status is the base, not the resolved value — persisting the merged value would bake granted entitlements into the restored base and make them uncleanable after a relaunch. ConfigManager's cached-status reads check granted storage separately so subscribed-user fetch heuristics still hold. Granted entitlements reach customerInfo as their own source on both the external purchase controller path and the automatic path, without widening the appStore filter (which would resurrect revoked web entitlements). Adds a grantedEntitlements log scope, a one-time warning when .inactive is assigned while grants exist, a reset() warning, and a granted_entitlement_ids param on the status change event. SW-5794 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 6 +- .../TrackableSuperwallEvent.swift | 10 + .../SuperwallKit/Config/ConfigManager.swift | 41 ++- Sources/SuperwallKit/Logger/LogScope.swift | 3 + Sources/SuperwallKit/Misc/Constants.swift | 2 +- .../Models/Customer Info/CustomerInfo.swift | 24 ++ .../Storage/Cache/CacheKeys.swift | 10 + .../Receipt Manager/ReceiptManager.swift | 2 +- Sources/SuperwallKit/Superwall.swift | 184 +++++++++-- .../Web/WebEntitlementRedeemer.swift | 1 + SuperwallKit.podspec | 2 +- SuperwallKit.xcodeproj/project.pbxproj | 4 + .../Products/GrantedEntitlementsTests.swift | 304 ++++++++++++++++++ 13 files changed, 544 insertions(+), 49 deletions(-) create mode 100644 Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 597c88778..2b5b4772d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,11 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. -## 4.16.4 +## 4.17.0 + +### Enhancements + +- Adds `grantedEntitlements` so you can grant entitlements from your own backend, which the SDK merges with device and web entitlements. ### Fixes diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift index eb366a835..38782734e 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift @@ -324,6 +324,7 @@ enum InternalSuperwallEvent { struct SubscriptionStatusDidChange: TrackableSuperwallEvent { let superwallEvent: SuperwallEvent = .subscriptionStatusDidChange let status: SubscriptionStatus + var grantedEntitlements: Set = [] var audienceFilterParams: [String: Any] = [:] func getSuperwallParameters() async -> [String: Any] { var params: [String: Any] = [ @@ -333,6 +334,15 @@ enum InternalSuperwallEvent { params += [ "active_entitlement_ids": entitlements.map(\.id).joined(separator: ",") ] + // The active set has provenance merged away, so surface which of the + // active entitlements were developer-granted for debugging. + let grantedIds = Set(grantedEntitlements.map(\.id)) + let activeGrantedIds = entitlements.map(\.id).filter { grantedIds.contains($0) } + if !activeGrantedIds.isEmpty { + params += [ + "granted_entitlement_ids": activeGrantedIds.joined(separator: ",") + ] + } } return params } diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index 54aaf9a35..9b06ddcfa 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -150,15 +150,7 @@ class ConfigManager { // Step 1: Determine fetch strategy based on subscription status and cached data let cachedConfig = storage.get(LatestConfig.self) - let cachedSubsStatus = storage.get(SubscriptionStatusKey.self) - - let isTestModeSubscription = storage.get(IsTestModeActiveSubscription.self) ?? false - let isSubscribed: Bool - if case .active = cachedSubsStatus, !isTestModeSubscription { - isSubscribed = true - } else { - isSubscribed = false - } + let isSubscribed = hasActiveCachedSubscription() let shouldFetchAsync = cachedConfig != nil && isSubscribed @@ -211,6 +203,21 @@ class ConfigManager { // MARK: - Config Fetch Helpers + /// Whether the cached subscription state indicates an active subscriber. + /// + /// The persisted status is the unresolved base, before developer-granted + /// entitlements are merged in, so those are checked from their own storage. + private func hasActiveCachedSubscription() -> Bool { + if storage.get(IsTestModeActiveSubscription.self) ?? false { + return false + } + if case .active = storage.get(SubscriptionStatusKey.self) { + return true + } + let grantedEntitlements = storage.get(GrantedEntitlements.self) ?? [] + return grantedEntitlements.contains { $0.isActive } + } + private struct ConfigFetchResult { let config: Config let isUsingCached: Bool @@ -240,13 +247,7 @@ class ConfigManager { // Fetch config synchronously let enableConfigRefresh = cachedConfig?.featureFlags.enableConfigRefresh ?? false - let isActiveSubscription: Bool - if case .active = storage.get(SubscriptionStatusKey.self), - !(storage.get(IsTestModeActiveSubscription.self) ?? false) { - isActiveSubscription = true - } else { - isActiveSubscription = false - } + let isActiveSubscription = hasActiveCachedSubscription() let timeout: TimeInterval = isActiveSubscription ? 0.5 : 1 if let cachedConfig = cachedConfig, @@ -294,13 +295,7 @@ class ConfigManager { // Fetch enrichment with timeout for sync path let enableConfigRefresh = cachedConfig?.featureFlags.enableConfigRefresh ?? false - let isActiveSubscription: Bool - if case .active = storage.get(SubscriptionStatusKey.self), - !(storage.get(IsTestModeActiveSubscription.self) ?? false) { - isActiveSubscription = true - } else { - isActiveSubscription = false - } + let isActiveSubscription = hasActiveCachedSubscription() let timeout: TimeInterval = isActiveSubscription ? 0.5 : 1 let cachedEnrichment = storage.get(LatestEnrichment.self) diff --git a/Sources/SuperwallKit/Logger/LogScope.swift b/Sources/SuperwallKit/Logger/LogScope.swift index 07e74045c..2faa052b2 100644 --- a/Sources/SuperwallKit/Logger/LogScope.swift +++ b/Sources/SuperwallKit/Logger/LogScope.swift @@ -33,6 +33,7 @@ public enum LogScope: Int, Encodable, Sendable, CustomStringConvertible { case paywallViewController case cache case webEntitlements + case grantedEntitlements case all public var description: String { @@ -83,6 +84,8 @@ public enum LogScope: Int, Encodable, Sendable, CustomStringConvertible { return "cache" case .webEntitlements: return "webEntitlements" + case .grantedEntitlements: + return "grantedEntitlements" case .all: return "all" } diff --git a/Sources/SuperwallKit/Misc/Constants.swift b/Sources/SuperwallKit/Misc/Constants.swift index 968fea372..7ca78bdad 100644 --- a/Sources/SuperwallKit/Misc/Constants.swift +++ b/Sources/SuperwallKit/Misc/Constants.swift @@ -18,5 +18,5 @@ let sdkVersion = """ */ let sdkVersion = """ -4.16.4 +4.17.0 """ diff --git a/Sources/SuperwallKit/Models/Customer Info/CustomerInfo.swift b/Sources/SuperwallKit/Models/Customer Info/CustomerInfo.swift index 4c009e978..80a35b5cd 100644 --- a/Sources/SuperwallKit/Models/Customer Info/CustomerInfo.swift +++ b/Sources/SuperwallKit/Models/Customer Info/CustomerInfo.swift @@ -220,6 +220,30 @@ public final class CustomerInfo: NSObject, Codable { subscriptions: baseCustomerInfo.subscriptions, nonSubscriptions: baseCustomerInfo.nonSubscriptions, entitlements: finalEntitlements.sorted { $0.id < $1.id } + ).mergingGrantedEntitlements(from: storage) + } + + /// Returns a copy with developer-granted entitlements merged in as their + /// own source. + /// + /// Granted entitlements are read from their own storage rather than + /// recovered from `subscriptionStatus` — that's an already-merged value + /// where sources are no longer distinguishable. They also can't ride in via + /// the appStore filter in `forExternalPurchaseController`: widening it to + /// admit them would resurrect revoked web entitlements, whose revocation is + /// signalled only by their absence from the web customer info. + func mergingGrantedEntitlements(from storage: Storage) -> CustomerInfo { + let grantedEntitlements = storage.get(GrantedEntitlements.self) ?? [] + if grantedEntitlements.isEmpty { + return self + } + let mergedEntitlements = Entitlement.mergePrioritized( + entitlements + Array(grantedEntitlements) + ) + return CustomerInfo( + subscriptions: subscriptions, + nonSubscriptions: nonSubscriptions, + entitlements: mergedEntitlements.sorted { $0.id < $1.id } ) } } diff --git a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift index daab59c39..ed0774387 100644 --- a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift +++ b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift @@ -201,6 +201,16 @@ enum SubscriptionStatusKey: Storable { typealias Value = SubscriptionStatus } +enum GrantedEntitlements: Storable { + static var key: String { + "store.grantedEntitlements" + } + // App-specific so that `Storage.reset()` doesn't wipe it: the developer owns + // this bucket and its lifecycle, including across `reset()`/`identify()`. + static var directory: SearchPathDirectory = .appSpecificDocuments + typealias Value = Set +} + enum SurveyAssignmentKey: Storable { static var key: String { "store.surveyAssignmentKey" diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift index 8e6fcf6b6..fc8fc16e2 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift @@ -219,7 +219,7 @@ actor ReceiptManager { entitlements: finalEntitlements.sorted { $0.id < $1.id } ) } else { - mergedCustomerInfo = baseCustomerInfo + mergedCustomerInfo = baseCustomerInfo.mergingGrantedEntitlements(from: storage) } await MainActor.run { diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index a7f7e9756..9d2cdafee 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -211,35 +211,138 @@ public final class Superwall: NSObject, ObservableObject { /// Otherwise, you can check the delegate function /// ``SuperwallDelegate/subscriptionStatusDidChange(from:to:)`` /// to receive a callback whenever the logical status changes. + /// + /// - Warning: If you've set ``grantedEntitlements``, they are merged into + /// every value you assign here. Assigning `.inactive` does **not** remove + /// them — the status stays active for as long as ``grantedEntitlements`` + /// contains an active entitlement. To revoke them, set + /// ``grantedEntitlements`` to an empty set. @Published public var subscriptionStatus: SubscriptionStatus = .unknown { didSet { - let resolved = resolvedSubscriptionStatus(subscriptionStatus) - if resolved != subscriptionStatus { - subscriptionStatus = resolved + if isResolvingSubscriptionStatus { + // Write-back of the resolved value from + // applySubscriptionStatusResolution, which runs the side effects. return } - // Saved here rather than in the status listener so that metadata-only - // updates, which the listener dedupes, still refresh the cache. Identical - // writes are skipped so they don't re-encode and rewrite the file. - if oldValue != subscriptionStatus { - dependencyContainer.storage.save(subscriptionStatus, forType: SubscriptionStatusKey.self) - } - entitlements.subscriptionStatusDidSet(subscriptionStatus) - - // When using an external purchase controller, update CustomerInfo.entitlements - // to reflect the entitlements from the purchase controller. - // Skip this in test mode — test mode manages its own CustomerInfo. - if dependencyContainer.makeHasExternalPurchaseController(), - dependencyContainer.testModeManager?.isTestMode != true { - customerInfo = CustomerInfo.forExternalPurchaseController( - storage: dependencyContainer.storage, - subscriptionStatus: subscriptionStatus - ) - } + let oldBase = unresolvedSubscriptionStatus + unresolvedSubscriptionStatus = subscriptionStatus + logIfGrantedEntitlementsOverrideInactive(subscriptionStatus) + applySubscriptionStatusResolution(oldBase: oldBase) } } + /// The last externally assigned subscription status, before resolution + /// merges in ``grantedEntitlements`` or test-mode overrides. + /// + /// Resolution always starts from this base rather than from the published + /// value: the published value is already merged, so re-resolving it could + /// never remove granted entitlements once the developer clears them. + private var unresolvedSubscriptionStatus: SubscriptionStatus = .unknown + + /// Guards the write-back of the resolved status inside + /// `applySubscriptionStatusResolution` so `didSet` doesn't mistake it for a + /// new external assignment and capture the resolved value as the base. + private var isResolvingSubscriptionStatus = false + + /// Whether the one-time warning about granted entitlements overriding an + /// `.inactive` assignment has been logged. + private var hasLoggedGrantedEntitlementsWarning = false + + /// Entitlements granted by your own backend that Superwall can't observe, + /// which the SDK merges into ``subscriptionStatus`` alongside device and web + /// entitlements. + /// + /// Use this when access is granted outside of the App Store and the web — + /// for example, a promotional grant or an account comped by your backend. + /// Don't use this if you compute the full subscription picture in a + /// `PurchaseController` — in that case set ``subscriptionStatus`` directly, + /// otherwise two writers feed one value and the merge will surprise you. + /// + /// Each assignment replaces the previous value outright — granting `[a]` + /// and then `[b]` leaves only `b`. Assign an empty set to revoke. + /// + /// - Warning: This persists across app launches, ``reset()``, and + /// ``identify(userId:options:)``. You must set it again immediately after + /// calling `identify()` or `reset()` if the new user shouldn't inherit the + /// previous user's granted entitlements. + public var grantedEntitlements: Set { + get { + return dependencyContainer.storage.get(GrantedEntitlements.self) ?? [] + } + set { + dependencyContainer.storage.save(newValue, forType: GrantedEntitlements.self) + Logger.debug( + logLevel: .info, + scope: .grantedEntitlements, + message: newValue.isEmpty + ? "Granted entitlements cleared." + : "Granted entitlements set: \(newValue.map(\.id).sorted().joined(separator: ", "))" + ) + applySubscriptionStatusResolution(oldBase: unresolvedSubscriptionStatus) + } + } + + /// Resolves the subscription status from the unresolved base and publishes + /// it, then runs the side effects of a status change. + /// + /// This is the only writer of the resolved value. It's called from the + /// `subscriptionStatus` `didSet` on external assignment and from the + /// ``grantedEntitlements`` setter, whose changes must re-resolve the status + /// without a new base being assigned. + private func applySubscriptionStatusResolution(oldBase: SubscriptionStatus) { + let resolved = resolvedSubscriptionStatus(unresolvedSubscriptionStatus) + if resolved != subscriptionStatus { + isResolvingSubscriptionStatus = true + subscriptionStatus = resolved + isResolvingSubscriptionStatus = false + } + // The unresolved base is persisted, not the resolved value: granted + // entitlements are merged during resolution, and restoring an + // already-merged value would bake them into the base forever, making + // them impossible to clear after a relaunch. + // + // Saved here rather than in the status listener so that metadata-only + // updates, which the listener dedupes, still refresh the cache. Identical + // writes are skipped so they don't re-encode and rewrite the file. + if oldBase != unresolvedSubscriptionStatus { + dependencyContainer.storage.save(unresolvedSubscriptionStatus, forType: SubscriptionStatusKey.self) + } + entitlements.subscriptionStatusDidSet(subscriptionStatus) + + // When using an external purchase controller, update CustomerInfo.entitlements + // to reflect the entitlements from the purchase controller. + // Skip this in test mode — test mode manages its own CustomerInfo. + if dependencyContainer.makeHasExternalPurchaseController(), + dependencyContainer.testModeManager?.isTestMode != true { + customerInfo = CustomerInfo.forExternalPurchaseController( + storage: dependencyContainer.storage, + subscriptionStatus: subscriptionStatus + ) + } + } + + /// Logs a one-time warning when `.inactive` is assigned to + /// `subscriptionStatus` while granted entitlements exist. The status will + /// resolve back to active, which otherwise looks like the SDK ignored the + /// assignment. + private func logIfGrantedEntitlementsOverrideInactive(_ status: SubscriptionStatus) { + guard case .inactive = status, + !hasLoggedGrantedEntitlementsWarning, + !grantedEntitlements.isEmpty + else { + return + } + hasLoggedGrantedEntitlementsWarning = true + Logger.debug( + logLevel: .warn, + scope: .grantedEntitlements, + message: "subscriptionStatus was set to .inactive but grantedEntitlements " + + "is not empty, so the status remains active. Assigning subscriptionStatus " + + "doesn't clear granted entitlements — set grantedEntitlements to an empty set to revoke them." + ) + } + /// Contains the latest information about all of the customer's purchase and subscription data. /// /// This is a published property, so you can subscribe to it to receive updates when it changes. Alternatively, @@ -315,6 +418,11 @@ public final class Superwall: NSObject, ObservableObject { superwall.subscriptionStatus = .active(activeWebEntitlements) } case .unknown: + // Web entitlements deliberately don't promote .unknown to active, + // unlike granted entitlements (see resolvedSubscriptionStatus). This + // branch only runs without an external purchase controller, where the + // AutomaticPurchaseController is guaranteed to resolve .unknown after + // loading purchased products — so .unknown is always transient here. superwall.subscriptionStatus = .unknown } } @@ -435,6 +543,23 @@ public final class Superwall: NSObject, ObservableObject { let override = testModeManager.overriddenSubscriptionStatus { return override } + var status = status + let granted = grantedEntitlements + if !granted.isEmpty { + switch status { + case .active(let entitlements): + status = .active(entitlements.union(granted)) + case .inactive, .unknown: + // .unknown promotes to active, unlike web entitlements (see + // internallySetSubscriptionStatus). With an external purchase + // controller the developer is the only writer of the status, so + // .unknown can be terminal — without promotion, a developer relying + // solely on granted entitlements would be locked out forever. + status = .active(granted) + } + } + // This must run after the granted merge, or a developer-assigned + // .active([]) would collapse to .inactive before granted is applied. if case .active(let entitlements) = status, entitlements.isEmpty { return .inactive @@ -594,7 +719,10 @@ public final class Superwall: NSObject, ObservableObject { Task { await self.dependencyContainer.delegateAdapter.subscriptionStatusDidChange( from: oldStatus, to: newStatus) - let event = InternalSuperwallEvent.SubscriptionStatusDidChange(status: newStatus) + let event = InternalSuperwallEvent.SubscriptionStatusDidChange( + status: newStatus, + grantedEntitlements: self.grantedEntitlements + ) await self.track(event) } Task { @@ -1097,7 +1225,19 @@ public final class Superwall: NSObject, ObservableObject { // MARK: - Reset /// Resets the `userId`, on-device paywall assignments, and data stored /// by Superwall. + /// + /// - Note: ``grantedEntitlements`` are not reset — you own that bucket and + /// its lifecycle. Set it again immediately after calling this if the next + /// user shouldn't inherit the previous user's granted entitlements. public func reset() { + if !grantedEntitlements.isEmpty { + Logger.debug( + logLevel: .warn, + scope: .grantedEntitlements, + message: "reset() was called but grantedEntitlements persist across reset. " + + "Set grantedEntitlements again if the next user shouldn't inherit them." + ) + } reset(duringIdentify: false) } diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index 0f47992d7..7c601fd22 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -527,6 +527,7 @@ actor WebEntitlementRedeemer { } else { let deviceCustomerInfo = storage.get(LatestDeviceCustomerInfo.self) ?? .blank() mergedCustomerInfo = deviceCustomerInfo.merging(with: webCustomerInfo) + .mergingGrantedEntitlements(from: storage) } await MainActor.run { diff --git a/SuperwallKit.podspec b/SuperwallKit.podspec index b031453c9..39380da9e 100644 --- a/SuperwallKit.podspec +++ b/SuperwallKit.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "SuperwallKit" - s.version = "4.16.4" + s.version = "4.17.0" 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 2b3c61e60..696acc8a7 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -464,6 +464,7 @@ CF2064883604B915C8768FC5 /* ASIdManagerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF989B3D90DC3D88FACC4D45 /* ASIdManagerProxy.swift */; }; CF3683E2AD703237EC0CE22E /* PaywallProducts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C95DABA23C6CBEF0AAA63C0 /* PaywallProducts.swift */; }; CFEB0D797815E8EDFB059767 /* Superwall.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F7EDB6D68D0AEDD332E40BB /* Superwall.swift */; }; + D0D4568B72D20652C12AA89B /* GrantedEntitlementsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1099A063D46DE7373CFABC4C /* GrantedEntitlementsTests.swift */; }; D0E19F665C7B230BF3FA122D /* TrackingResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85ED994DEC92BB90ACC6AC2 /* TrackingResult.swift */; }; D1F8771E65157D1B0E05D0B9 /* ManifestDataFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2915A802FACB53B6094B011 /* ManifestDataFetcher.swift */; }; D25B3A24CEE42FC90BFA31D2 /* SuperwallEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75E4096EBF0B8C9693322CD1 /* SuperwallEvent.swift */; }; @@ -633,6 +634,7 @@ 0EC8705042D6AA74D40350A9 /* SK2ObserverModePurchaseDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK2ObserverModePurchaseDetector.swift; sourceTree = ""; }; 0ECD75DF8F3EB6A68A21444D /* ProductsFetcherSK2Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsFetcherSK2Tests.swift; sourceTree = ""; }; 0FDB1F66C8DB4C53466266D8 /* String+SHA256.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+SHA256.swift"; sourceTree = ""; }; + 1099A063D46DE7373CFABC4C /* GrantedEntitlementsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrantedEntitlementsTests.swift; sourceTree = ""; }; 10D5ABDB23D56393EFDCF73A /* NetworkMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMock.swift; sourceTree = ""; }; 115132479C9C41D57C9E3BA9 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 124F219E38F8398A65A7EB32 /* DependencyContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependencyContainer.swift; sourceTree = ""; }; @@ -1584,6 +1586,7 @@ 2C05828E18C52F510F7CDFA2 /* Products */ = { isa = PBXGroup; children = ( + 1099A063D46DE7373CFABC4C /* GrantedEntitlementsTests.swift */, BD6BA222CB2EAA4B65F362C5 /* ProductsFetcherSK1.swift */, 0ECD75DF8F3EB6A68A21444D /* ProductsFetcherSK2Tests.swift */, B00929DACD8621FC32F83927 /* SK2StoreProductCyclesTests.swift */, @@ -3320,6 +3323,7 @@ AC7D527612F631AAADC7D225 /* FileManagerMigratorTests.swift in Sources */, D4B5A9708204AB6D58904733 /* GetPaywallVcOperatorTests.swift in Sources */, 64B962A8B59ACE514B0E48AE /* GetPresenterOperatorTests.swift in Sources */, + D0D4568B72D20652C12AA89B /* GrantedEntitlementsTests.swift in Sources */, AF4AD928FACF9056E00D5920 /* HandleTriggerResultOperatorTests.swift in Sources */, 77EDD2927FF8DCF95579BE3E /* IdentityLogicTests.swift in Sources */, D89E9C69317044050B97B573 /* IdentityManagerMock.swift in Sources */, diff --git a/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift new file mode 100644 index 000000000..7f10ba738 --- /dev/null +++ b/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift @@ -0,0 +1,304 @@ +// +// GrantedEntitlementsTests.swift +// SuperwallKit +// +// Created by Yusuf Tör on 2026-09-01. +// +// swiftlint:disable all + +@testable import SuperwallKit +import Testing +import Foundation + +@Suite(.serialized) +final class GrantedEntitlementsTests { + private let dependencyContainer: DependencyContainer + private let superwall: Superwall + + init() { + dependencyContainer = DependencyContainer() + superwall = Superwall(dependencyContainer: dependencyContainer) + cleanStorage() + } + + deinit { + cleanStorage() + } + + private func cleanStorage() { + dependencyContainer.storage.delete(GrantedEntitlements.self) + dependencyContainer.storage.delete(SubscriptionStatusKey.self) + dependencyContainer.storage.delete(LatestRedeemResponse.self) + dependencyContainer.storage.delete(LatestDeviceCustomerInfo.self) + } + + // MARK: - Helpers + + /// A bare developer-granted entitlement: no transaction history, no store. + private func grantedEntitlement( + id: String = "granted", + isActive: Bool = true + ) -> Entitlement { + return Entitlement( + id: id, + type: .serviceLevel, + isActive: isActive + ) + } + + /// A device entitlement enriched with transaction metadata. + private func deviceEntitlement( + id: String = "premium", + isActive: Bool = true + ) -> Entitlement { + return Entitlement( + id: id, + type: .serviceLevel, + isActive: isActive, + productIds: ["com.example.monthly"], + latestProductId: "com.example.monthly", + store: .appStore, + startsAt: Date(timeIntervalSince1970: 1_700_000_000), + expiresAt: Date(timeIntervalSince1970: 1_800_000_000), + isLifetime: false, + willRenew: true, + state: .subscribed + ) + } + + // MARK: - Merging + + @Test + func settingGranted_activatesInactiveStatus() { + superwall.subscriptionStatus = .inactive + superwall.grantedEntitlements = [grantedEntitlement()] + + #expect(superwall.subscriptionStatus.isActive) + if case .active(let entitlements) = superwall.subscriptionStatus { + #expect(entitlements.map(\.id) == ["granted"]) + } else { + Issue.record("Expected .active status") + } + } + + @Test + func settingGranted_mergesWithActiveStatus() { + superwall.subscriptionStatus = .active([deviceEntitlement(id: "premium")]) + superwall.grantedEntitlements = [grantedEntitlement(id: "granted")] + + if case .active(let entitlements) = superwall.subscriptionStatus { + #expect(Set(entitlements.map(\.id)) == ["premium", "granted"]) + } else { + Issue.record("Expected .active status") + } + } + + @Test + func unknownStatus_promotesToActiveWithGranted() { + superwall.subscriptionStatus = .unknown + superwall.grantedEntitlements = [grantedEntitlement()] + + #expect(superwall.subscriptionStatus.isActive) + } + + @Test + func inactiveGrantedOnly_doesNotActivateStatus() { + superwall.subscriptionStatus = .inactive + superwall.grantedEntitlements = [grantedEntitlement(isActive: false)] + + #expect(!superwall.subscriptionStatus.isActive) + } + + @Test + func assigningInactive_keepsGrantedActive() { + superwall.grantedEntitlements = [grantedEntitlement()] + superwall.subscriptionStatus = .active([deviceEntitlement()]) + + superwall.subscriptionStatus = .inactive + + #expect(superwall.subscriptionStatus.isActive) + if case .active(let entitlements) = superwall.subscriptionStatus { + #expect(entitlements.map(\.id) == ["granted"]) + } + } + + @Test + func emptyActiveAssignment_stillMergesGranted() { + // The empty-.active → .inactive collapse must run after the granted + // merge, not before it. + superwall.grantedEntitlements = [grantedEntitlement()] + superwall.subscriptionStatus = .active([]) + + #expect(superwall.subscriptionStatus.isActive) + } + + // MARK: - Clearing + + @Test + func clearingGranted_restoresBaseStatus() { + superwall.subscriptionStatus = .inactive + superwall.grantedEntitlements = [grantedEntitlement()] + #expect(superwall.subscriptionStatus.isActive) + + superwall.grantedEntitlements = [] + + #expect(superwall.subscriptionStatus == .inactive) + } + + @Test + func replacingGranted_replacesOutright() { + superwall.subscriptionStatus = .inactive + superwall.grantedEntitlements = [grantedEntitlement(id: "a")] + superwall.grantedEntitlements = [grantedEntitlement(id: "b")] + + if case .active(let entitlements) = superwall.subscriptionStatus { + #expect(entitlements.map(\.id) == ["b"]) + } else { + Issue.record("Expected .active status") + } + } + + // MARK: - Merge precedence + + @Test + func granted_losesToActiveDeviceEntitlementWithHistory() { + superwall.subscriptionStatus = .active([deviceEntitlement(id: "premium")]) + superwall.grantedEntitlements = [grantedEntitlement(id: "premium")] + + if case .active(let entitlements) = superwall.subscriptionStatus { + #expect(entitlements.count == 1) + // The device entitlement's transaction metadata must be kept. + #expect(entitlements.first?.latestProductId == "com.example.monthly") + #expect(entitlements.first?.store == .appStore) + } else { + Issue.record("Expected .active status") + } + } + + @Test + func activeGranted_beatsExpiredDeviceEntitlement() { + superwall.subscriptionStatus = .active([deviceEntitlement(id: "premium", isActive: false)]) + superwall.grantedEntitlements = [grantedEntitlement(id: "premium")] + + #expect(superwall.subscriptionStatus.isActive) + if case .active(let entitlements) = superwall.subscriptionStatus { + #expect(entitlements.count == 1) + #expect(entitlements.first?.isActive == true) + // The granted record wins wholesale — it mustn't inherit the expired + // transaction's metadata and masquerade as a store subscription. + #expect(entitlements.first?.latestProductId == nil) + } else { + Issue.record("Expected .active status") + } + } + + // MARK: - Idempotence + + @Test + func reassigningResolvedStatus_isStable() { + superwall.subscriptionStatus = .active([deviceEntitlement(id: "premium")]) + superwall.grantedEntitlements = [grantedEntitlement(id: "granted")] + + let resolved = superwall.subscriptionStatus + superwall.subscriptionStatus = resolved + + // resolve(resolve(x)) == resolve(x): re-resolving an already-merged + // value must settle, not loop or grow. + #expect(superwall.subscriptionStatus == resolved) + } + + // MARK: - Persistence + + @Test + func persistedStatus_isUnresolvedBase() { + superwall.grantedEntitlements = [grantedEntitlement()] + superwall.subscriptionStatus = .inactive + + // The published status is merged, but the persisted one must be the + // unresolved base — otherwise granted entitlements are baked into the + // restored value and can never be cleared after a relaunch. + #expect(superwall.subscriptionStatus.isActive) + #expect(dependencyContainer.storage.get(SubscriptionStatusKey.self) == .inactive) + } + + @Test + func clearingGranted_afterRestore_restoresBaseStatus() { + superwall.grantedEntitlements = [grantedEntitlement()] + superwall.subscriptionStatus = .inactive + #expect(superwall.subscriptionStatus.isActive) + + // Simulate a relaunch: a fresh instance restores the persisted status, + // exactly as Superwall's configure init does. + let relaunched = Superwall(dependencyContainer: dependencyContainer) + relaunched.subscriptionStatus = + dependencyContainer.storage.get(SubscriptionStatusKey.self) ?? .unknown + #expect(relaunched.subscriptionStatus.isActive) + + relaunched.grantedEntitlements = [] + + #expect(relaunched.subscriptionStatus == .inactive) + } + + @Test + func granted_survivesStorageReset() { + superwall.grantedEntitlements = [grantedEntitlement()] + + dependencyContainer.storage.reset() + + #expect(superwall.grantedEntitlements.map(\.id) == ["granted"]) + } + + // MARK: - CustomerInfo + + @Test + func granted_reachesCustomerInfoForExternalPurchaseController() { + superwall.grantedEntitlements = [grantedEntitlement()] + + let customerInfo = CustomerInfo.forExternalPurchaseController( + storage: dependencyContainer.storage, + subscriptionStatus: .inactive + ) + + #expect(customerInfo.entitlements.contains { $0.id == "granted" && $0.isActive }) + } + + @Test + func granted_mergesIntoAutomaticPathCustomerInfo() { + superwall.grantedEntitlements = [grantedEntitlement()] + + let base = CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: [deviceEntitlement(id: "premium")] + ) + let merged = base.mergingGrantedEntitlements(from: dependencyContainer.storage) + + #expect(Set(merged.entitlements.map(\.id)) == ["premium", "granted"]) + } + + // MARK: - Event parameters + + @Test + func statusChangeEvent_includesGrantedEntitlementIds() async { + let granted = grantedEntitlement(id: "granted") + let event = InternalSuperwallEvent.SubscriptionStatusDidChange( + status: .active([deviceEntitlement(id: "premium"), granted]), + grantedEntitlements: [granted] + ) + + let params = await event.getSuperwallParameters() + + #expect(params["granted_entitlement_ids"] as? String == "granted") + } + + @Test + func statusChangeEvent_omitsGrantedParamWhenNoneGranted() async { + let event = InternalSuperwallEvent.SubscriptionStatusDidChange( + status: .active([deviceEntitlement(id: "premium")]) + ) + + let params = await event.getSuperwallParameters() + + #expect(params["granted_entitlement_ids"] == nil) + } +} From 84e9453c04213cca152ed316e7ea30ecf314c5f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:32:22 +0200 Subject: [PATCH 046/162] review: harden granted merge, resolution locking, and cached-status reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses pullfrog and greptile review findings: - Filter grants to active records before merging into the status, so inactive-only grants never promote .inactive/.unknown or enter the active set. - Call Entitlement.mergePrioritized explicitly instead of relying on overload resolution selecting the prioritized Set.union extension. - Serialize status resolution behind a recursive lock so a concurrent assignment can't be mistaken for the resolved write-back and dropped. - Inspect the persisted base's entitlement set in ConfigManager — a developer-assigned .active([]) can now reach disk and must not read as subscribed. - Apply the granted merge on ReceiptManager's external-purchase-controller branch, where the externalOnly ID filter could drop a grant colliding with an expired device entitlement. - Rebuild customerInfo from stored device+web+granted sources when grants change on the automatic path, so clears propagate without waiting for the next receipt load. - Emit only active entitlement IDs in the granted event param; warn about persisting grants on the identify-triggered reset too. Co-Authored-By: Claude Fable 5 --- .../TrackableSuperwallEvent.swift | 4 +- .../SuperwallKit/Config/ConfigManager.swift | 6 +- .../Receipt Manager/ReceiptManager.swift | 6 +- Sources/SuperwallKit/Superwall.swift | 66 ++++++++++++++++--- .../Products/GrantedEntitlementsTests.swift | 33 +++++++++- 5 files changed, 102 insertions(+), 13 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift index 38782734e..67472ed6f 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift @@ -337,7 +337,9 @@ enum InternalSuperwallEvent { // The active set has provenance merged away, so surface which of the // active entitlements were developer-granted for debugging. let grantedIds = Set(grantedEntitlements.map(\.id)) - let activeGrantedIds = entitlements.map(\.id).filter { grantedIds.contains($0) } + let activeGrantedIds = entitlements + .filter { $0.isActive && grantedIds.contains($0.id) } + .map(\.id) if !activeGrantedIds.isEmpty { params += [ "granted_entitlement_ids": activeGrantedIds.joined(separator: ",") diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index 9b06ddcfa..b8cbb15d3 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -211,7 +211,11 @@ class ConfigManager { if storage.get(IsTestModeActiveSubscription.self) ?? false { return false } - if case .active = storage.get(SubscriptionStatusKey.self) { + // The persisted base can hold .active with no active entitlement + // (e.g. a developer-assigned .active([])) — inspect the set rather + // than pattern-matching the case alone. + if case .active(let entitlements) = storage.get(SubscriptionStatusKey.self), + entitlements.contains(where: { $0.isActive }) { return true } let grantedEntitlements = storage.get(GrantedEntitlements.self) ?? [] diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift index fc8fc16e2..244fadbbd 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift @@ -213,11 +213,15 @@ actor ReceiptManager { let allEntitlements = baseCustomerInfo.entitlements + externalOnlyEntitlements let finalEntitlements = Entitlement.mergePrioritized(allEntitlements) + // Granted entitlements merge here too: the externalOnly filter above + // drops a granted entitlement whose ID collides with an expired + // device one, which would leave customerInfo reporting it inactive + // while subscriptionStatus reports it active. mergedCustomerInfo = CustomerInfo( subscriptions: baseCustomerInfo.subscriptions, nonSubscriptions: baseCustomerInfo.nonSubscriptions, entitlements: finalEntitlements.sorted { $0.id < $1.id } - ) + ).mergingGrantedEntitlements(from: storage) } else { mergedCustomerInfo = baseCustomerInfo.mergingGrantedEntitlements(from: storage) } diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 9d2cdafee..7ad3a58fc 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -220,6 +220,15 @@ public final class Superwall: NSObject, ObservableObject { @Published public var subscriptionStatus: SubscriptionStatus = .unknown { didSet { + // Serializes resolution across threads: without it, an assignment + // landing inside another thread's resolved write-back window would be + // mistaken for the write-back and silently dropped. The lock is + // recursive because the write-back re-enters this didSet on the + // same thread. + subscriptionStatusResolutionLock.lock() + defer { + subscriptionStatusResolutionLock.unlock() + } if isResolvingSubscriptionStatus { // Write-back of the resolved value from // applySubscriptionStatusResolution, which runs the side effects. @@ -243,8 +252,14 @@ public final class Superwall: NSObject, ObservableObject { /// Guards the write-back of the resolved status inside /// `applySubscriptionStatusResolution` so `didSet` doesn't mistake it for a /// new external assignment and capture the resolved value as the base. + /// Only read and written while `subscriptionStatusResolutionLock` is held. private var isResolvingSubscriptionStatus = false + /// Serializes status resolution so concurrent assignments from different + /// threads can't interleave with the resolved write-back. Recursive + /// because the write-back re-enters the `subscriptionStatus` `didSet`. + private let subscriptionStatusResolutionLock = NSRecursiveLock() + /// Whether the one-time warning about granted entitlements overriding an /// `.inactive` assignment has been logged. private var hasLoggedGrantedEntitlementsWarning = false @@ -271,6 +286,10 @@ public final class Superwall: NSObject, ObservableObject { return dependencyContainer.storage.get(GrantedEntitlements.self) ?? [] } set { + subscriptionStatusResolutionLock.lock() + defer { + subscriptionStatusResolutionLock.unlock() + } dependencyContainer.storage.save(newValue, forType: GrantedEntitlements.self) Logger.debug( logLevel: .info, @@ -280,9 +299,29 @@ public final class Superwall: NSObject, ObservableObject { : "Granted entitlements set: \(newValue.map(\.id).sorted().joined(separator: ", "))" ) applySubscriptionStatusResolution(oldBase: unresolvedSubscriptionStatus) + refreshAutomaticCustomerInfoAfterGrantChange() } } + /// Recomputes `customerInfo` on the automatic path after a grant change, + /// rebuilding from the stored device and web sources so cleared grants + /// actually disappear. The external purchase controller path is recomputed + /// inside `applySubscriptionStatusResolution`; test mode manages its own + /// customer info. + private func refreshAutomaticCustomerInfoAfterGrantChange() { + if dependencyContainer.makeHasExternalPurchaseController() { + return + } + if dependencyContainer.testModeManager?.isTestMode == true { + return + } + let storage: Storage = dependencyContainer.storage + let deviceCustomerInfo = storage.get(LatestDeviceCustomerInfo.self) ?? .blank() + let webCustomerInfo = storage.get(LatestRedeemResponse.self)?.customerInfo ?? .blank() + customerInfo = deviceCustomerInfo.merging(with: webCustomerInfo) + .mergingGrantedEntitlements(from: storage) + } + /// Resolves the subscription status from the unresolved base and publishes /// it, then runs the side effects of a status change. /// @@ -544,11 +583,18 @@ public final class Superwall: NSObject, ObservableObject { return override } var status = status - let granted = grantedEntitlements + // Only active grants merge into the status, mirroring how web + // entitlements merge (EntitlementsInfo.web filters to active). + // Inactive grants still reach customerInfo for round-trip visibility. + let granted = Set(grantedEntitlements.filter(\.isActive)) if !granted.isEmpty { switch status { case .active(let entitlements): - status = .active(entitlements.union(granted)) + // mergePrioritized explicitly: plain Set.union dedupes by deep + // equality, which would keep both records for a shared ID. + status = .active( + Entitlement.mergePrioritized(Array(entitlements) + Array(granted)) + ) case .inactive, .unknown: // .unknown promotes to active, unlike web entitlements (see // internallySetSubscriptionStatus). With an external purchase @@ -1230,19 +1276,21 @@ public final class Superwall: NSObject, ObservableObject { /// its lifecycle. Set it again immediately after calling this if the next /// user shouldn't inherit the previous user's granted entitlements. public func reset() { + reset(duringIdentify: false) + } + + /// Asynchronously resets. Presentation of paywalls is suspended until reset completes. + func reset(duringIdentify: Bool) { + // Warn here rather than in the public reset() so the identify-triggered + // reset — the actual user-switch moment — warns too. if !grantedEntitlements.isEmpty { Logger.debug( logLevel: .warn, scope: .grantedEntitlements, - message: "reset() was called but grantedEntitlements persist across reset. " - + "Set grantedEntitlements again if the next user shouldn't inherit them." + message: "The user was reset but grantedEntitlements persist across reset() " + + "and identify(). Set grantedEntitlements again if the new user shouldn't inherit them." ) } - reset(duringIdentify: false) - } - - /// Asynchronously resets. Presentation of paywalls is suspended until reset completes. - func reset(duringIdentify: Bool) { dependencyContainer.identityManager.reset(duringIdentify: duringIdentify) // Cancel any in-flight attribution post before wiping its storage, so a // late-completing post can't race the new user's state. diff --git a/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift index 7f10ba738..6f4ca5383 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift @@ -106,7 +106,29 @@ final class GrantedEntitlementsTests { superwall.subscriptionStatus = .inactive superwall.grantedEntitlements = [grantedEntitlement(isActive: false)] - #expect(!superwall.subscriptionStatus.isActive) + // Inactive-only grants must not promote — the status stays .inactive, + // not an .active-cased status that's effectively inactive. + #expect(superwall.subscriptionStatus == .inactive) + } + + @Test + func inactiveGrant_doesNotEnterActiveSet() { + superwall.subscriptionStatus = .active([deviceEntitlement(id: "premium")]) + superwall.grantedEntitlements = [grantedEntitlement(id: "extra", isActive: false)] + + if case .active(let entitlements) = superwall.subscriptionStatus { + #expect(entitlements.map(\.id) == ["premium"]) + } else { + Issue.record("Expected .active status") + } + } + + @Test + func inactiveGrantedOnly_leavesUnknownStatusUnknown() { + superwall.subscriptionStatus = .unknown + superwall.grantedEntitlements = [grantedEntitlement(isActive: false)] + + #expect(superwall.subscriptionStatus == .unknown) } @Test @@ -262,6 +284,15 @@ final class GrantedEntitlementsTests { #expect(customerInfo.entitlements.contains { $0.id == "granted" && $0.isActive }) } + @Test + func grantChange_refreshesCustomerInfoOnAutomaticPath() { + superwall.grantedEntitlements = [grantedEntitlement()] + #expect(superwall.customerInfo.entitlements.contains { $0.id == "granted" && $0.isActive }) + + superwall.grantedEntitlements = [] + #expect(!superwall.customerInfo.entitlements.contains { $0.id == "granted" }) + } + @Test func granted_mergesIntoAutomaticPathCustomerInfo() { superwall.grantedEntitlements = [grantedEntitlement()] From 800b5c3e7440714780737ebc4c03620aeae6d858 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:13:03 +0200 Subject: [PATCH 047/162] review: lock the status store itself, not just resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit didSet has no newValue and re-reads the property, so a store landing mid-resolution on another thread could be captured as that thread's base — including the resolved write-back, baking granted entitlements into the persisted base. Acquire the lock in willSet, before the store, and release it at the end of didSet so store + resolution are atomic. Co-Authored-By: Claude Fable 5 --- Sources/SuperwallKit/Superwall.swift | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 7ad3a58fc..12be7880b 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -219,13 +219,19 @@ public final class Superwall: NSObject, ObservableObject { /// ``grantedEntitlements`` to an empty set. @Published public var subscriptionStatus: SubscriptionStatus = .unknown { - didSet { - // Serializes resolution across threads: without it, an assignment - // landing inside another thread's resolved write-back window would be - // mistaken for the write-back and silently dropped. The lock is - // recursive because the write-back re-enters this didSet on the - // same thread. + willSet { + // Serializes the property store itself, not just the resolution: + // didSet has no newValue and must re-read the property, so a store + // landing mid-resolution on another thread would be captured as that + // thread's base — including the resolved write-back, which would bake + // granted entitlements into the persisted base. Locking before the + // store makes store + resolution atomic. The lock is recursive + // because the write-back re-enters these observers on the same + // thread. Released at the end of didSet — the pair must stay + // balanced across every didSet exit path. subscriptionStatusResolutionLock.lock() + } + didSet { defer { subscriptionStatusResolutionLock.unlock() } From 9235459c60c34f11d50ec947e1daedc4a31f8e55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:31:29 +0200 Subject: [PATCH 048/162] review: snapshot granted once per resolution, add base-race regression test Reads the granted set once per resolution and threads it through the warning check and resolve, halving storage reads under the lock. Adds a concurrent-writers test asserting the persisted base never captures a granted entitlement, so a refactor that narrows the lock can't silently reintroduce the base-capture race. Co-Authored-By: Claude Fable 5 --- Sources/SuperwallKit/Superwall.swift | 28 +++++++++++++------ .../Products/GrantedEntitlementsTests.swift | 27 ++++++++++++++++++ 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 12be7880b..b9e09a191 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -242,8 +242,11 @@ public final class Superwall: NSObject, ObservableObject { } let oldBase = unresolvedSubscriptionStatus unresolvedSubscriptionStatus = subscriptionStatus - logIfGrantedEntitlementsOverrideInactive(subscriptionStatus) - applySubscriptionStatusResolution(oldBase: oldBase) + // Snapshot once: both the warning check and resolution need the + // granted set, and each read hits storage under the lock. + let granted = grantedEntitlements + logIfGrantedEntitlementsOverrideInactive(subscriptionStatus, granted: granted) + applySubscriptionStatusResolution(oldBase: oldBase, granted: granted) } } @@ -304,7 +307,7 @@ public final class Superwall: NSObject, ObservableObject { ? "Granted entitlements cleared." : "Granted entitlements set: \(newValue.map(\.id).sorted().joined(separator: ", "))" ) - applySubscriptionStatusResolution(oldBase: unresolvedSubscriptionStatus) + applySubscriptionStatusResolution(oldBase: unresolvedSubscriptionStatus, granted: newValue) refreshAutomaticCustomerInfoAfterGrantChange() } } @@ -335,8 +338,11 @@ public final class Superwall: NSObject, ObservableObject { /// `subscriptionStatus` `didSet` on external assignment and from the /// ``grantedEntitlements`` setter, whose changes must re-resolve the status /// without a new base being assigned. - private func applySubscriptionStatusResolution(oldBase: SubscriptionStatus) { - let resolved = resolvedSubscriptionStatus(unresolvedSubscriptionStatus) + private func applySubscriptionStatusResolution( + oldBase: SubscriptionStatus, + granted: Set + ) { + let resolved = resolvedSubscriptionStatus(unresolvedSubscriptionStatus, granted: granted) if resolved != subscriptionStatus { isResolvingSubscriptionStatus = true subscriptionStatus = resolved @@ -371,10 +377,13 @@ public final class Superwall: NSObject, ObservableObject { /// `subscriptionStatus` while granted entitlements exist. The status will /// resolve back to active, which otherwise looks like the SDK ignored the /// assignment. - private func logIfGrantedEntitlementsOverrideInactive(_ status: SubscriptionStatus) { + private func logIfGrantedEntitlementsOverrideInactive( + _ status: SubscriptionStatus, + granted: Set + ) { guard case .inactive = status, !hasLoggedGrantedEntitlementsWarning, - !grantedEntitlements.isEmpty + !granted.isEmpty else { return } @@ -581,7 +590,8 @@ public final class Superwall: NSObject, ObservableObject { // MARK: - Value Resolution private func resolvedSubscriptionStatus( - _ status: SubscriptionStatus + _ status: SubscriptionStatus, + granted: Set ) -> SubscriptionStatus { if let testModeManager = dependencyContainer.testModeManager, testModeManager.isTestMode, @@ -592,7 +602,7 @@ public final class Superwall: NSObject, ObservableObject { // Only active grants merge into the status, mirroring how web // entitlements merge (EntitlementsInfo.web filters to active). // Inactive grants still reach customerInfo for round-trip visibility. - let granted = Set(grantedEntitlements.filter(\.isActive)) + let granted = Set(granted.filter(\.isActive)) if !granted.isEmpty { switch status { case .active(let entitlements): diff --git a/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift index 6f4ca5383..6485e5f31 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift @@ -261,6 +261,33 @@ final class GrantedEntitlementsTests { #expect(relaunched.subscriptionStatus == .inactive) } + @Test + func concurrentAssignments_neverPersistGrantedIntoBase() async { + superwall.grantedEntitlements = [grantedEntitlement()] + + // Race external assignments from many threads. Without the lock + // covering the property store, a store landing mid-resolution captures + // another thread's resolved write-back as its base, persisting granted + // entitlements into SubscriptionStatusKey. + await withTaskGroup(of: Void.self) { group in + for index in 0..<50 { + group.addTask { [superwall, deviceEnt = deviceEntitlement(id: "premium")] in + superwall.subscriptionStatus = index.isMultiple(of: 2) + ? .inactive + : .active([deviceEnt]) + } + } + } + + // The persisted base must never contain a granted entitlement. + if case .active(let entitlements) = dependencyContainer.storage.get(SubscriptionStatusKey.self) { + #expect(!entitlements.contains { $0.id == "granted" }) + } + // Whatever base won the race, the published status resolves active + // because of the grant. + #expect(superwall.subscriptionStatus.isActive) + } + @Test func granted_survivesStorageReset() { superwall.grantedEntitlements = [grantedEntitlement()] From de887fa3c24cff1208ed0812b9bc21d43f92b2a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:44:19 +0200 Subject: [PATCH 049/162] review: strengthen the base-race test's assertions The persisted base is now asserted to be exactly one of the assigned values, so any merged base fails unconditionally, and the tautological isActive check is replaced by clearing the grant and asserting the in-memory base is clean. Co-Authored-By: Claude Fable 5 --- .../Products/GrantedEntitlementsTests.swift | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift index 6485e5f31..d8d3beeb7 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift @@ -279,13 +279,19 @@ final class GrantedEntitlementsTests { } } - // The persisted base must never contain a granted entitlement. - if case .active(let entitlements) = dependencyContainer.storage.get(SubscriptionStatusKey.self) { - #expect(!entitlements.contains { $0.id == "granted" }) + // The persisted base must be exactly one of the assigned values — + // any merged base means a resolved write-back was captured. + let persistedBase = dependencyContainer.storage.get(SubscriptionStatusKey.self) + #expect( + persistedBase == .inactive + || persistedBase == .active([deviceEntitlement(id: "premium")]) + ) + // The in-memory base must be clean too: clearing the grant must leave + // a status without it. + superwall.grantedEntitlements = [] + if case .active(let entitlements) = superwall.subscriptionStatus { + #expect(entitlements.map(\.id) == ["premium"]) } - // Whatever base won the race, the published status resolves active - // because of the grant. - #expect(superwall.subscriptionStatus.isActive) } @Test From 430e284dc5c9645a1260ba740cb76ac4a5be7a0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:29:11 +0200 Subject: [PATCH 050/162] refactor: make granted a first-class source in every customer info merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds developer-granted entitlements into the single prioritized merge at each composition site instead of a post-hoc mergingGrantedEntitlements pass, so every site runs one merge and one sort and names granted as a source alongside device and web. merging(with:) gains a granting: parameter; the external-controller composition moves to CustomerInfo as preservingExternalControllerEntitlements. The automatic path in ReceiptManager now merges uniformly, so subscription order without web data follows the same purchase-date sort as with it. Internal writers now assign through setSubscriptionStatus(base:), which resolves before the single store, so the automatic path never publishes a transient unresolved value. The status listener subscribes to a resolved-status subject rather than $subscriptionStatus, so the delegate and status-change event can't flicker inactive→active when the public setter stores a raw .inactive that resolution turns back into the granted status. The .inactive-with-grants warning is now limited to the public setter, where it describes a developer assignment. Co-Authored-By: Claude Fable 5.1 --- .../SuperwallKit/Config/ConfigManager.swift | 6 +- .../Models/Customer Info/CustomerInfo.swift | 87 ++++++++++++------- .../Receipt Manager/ReceiptManager.swift | 45 +++------- Sources/SuperwallKit/Superwall.swift | 70 ++++++++++----- .../TestMode/TestModeTransactionHandler.swift | 10 +-- .../Web/WebEntitlementRedeemer.swift | 7 +- .../Products/GrantedEntitlementsTests.swift | 65 ++++++++++++-- 7 files changed, 192 insertions(+), 98 deletions(-) diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index b8cbb15d3..4474e77e9 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -416,7 +416,7 @@ class ConfigManager { // loadPurchasedProducts / the controller itself will restore it. if testModeJustDeactivated, !factory.makeHasExternalPurchaseController() { - Superwall.shared.subscriptionStatus = .inactive + Superwall.shared.setSubscriptionStatus(base: .inactive) Superwall.shared.customerInfo = CustomerInfo( subscriptions: [], nonSubscriptions: [], @@ -782,11 +782,11 @@ class ConfigManager { if hasActiveEntitlements { let status = SubscriptionStatus.active(result.entitlements) testModeManager.overriddenSubscriptionStatus = status - Superwall.shared.subscriptionStatus = status + Superwall.shared.setSubscriptionStatus(base: status) storage.save(true, forType: IsTestModeActiveSubscription.self) } else { testModeManager.overriddenSubscriptionStatus = .inactive - Superwall.shared.subscriptionStatus = .inactive + Superwall.shared.setSubscriptionStatus(base: .inactive) storage.save(false, forType: IsTestModeActiveSubscription.self) } } diff --git a/Sources/SuperwallKit/Models/Customer Info/CustomerInfo.swift b/Sources/SuperwallKit/Models/Customer Info/CustomerInfo.swift index 80a35b5cd..936e637d6 100644 --- a/Sources/SuperwallKit/Models/Customer Info/CustomerInfo.swift +++ b/Sources/SuperwallKit/Models/Customer Info/CustomerInfo.swift @@ -134,7 +134,10 @@ public final class CustomerInfo: NSObject, Codable { /// /// - Parameter webCustomerInfo: The CustomerInfo from web2app endpoints containing web purchases/redemptions /// - Returns: A new CustomerInfo with merged data from both sources - func merging(with webCustomerInfo: CustomerInfo) -> CustomerInfo { + func merging( + with webCustomerInfo: CustomerInfo, + granting grantedEntitlements: Set = [] + ) -> CustomerInfo { // Merge non-subscription transactions (consumables, non-consumables) // Start with device transactions, then add web transactions that don't already exist var mergedNonSubscriptions = self.nonSubscriptions @@ -155,13 +158,15 @@ public final class CustomerInfo: NSObject, Codable { mergedSubscriptions.append(webSub) } - // Merge entitlements using priority-based merging + // Merge entitlements from device, web, and developer-granted sources + // using priority-based merging. // This uses `Entitlement.mergePrioritized` which intelligently selects the highest // priority entitlement for each ID based on: // - Active status (active > inactive) // - Latest expiration date // - Other priority criteria defined in `shouldTakePriorityOver` - let combinedEntitlements = self.entitlements + webCustomerInfo.entitlements + let combinedEntitlements = + self.entitlements + webCustomerInfo.entitlements + Array(grantedEntitlements) let mergedEntitlements = Entitlement.mergePrioritized(combinedEntitlements) // Return merged CustomerInfo with sorted transactions and entitlements @@ -172,6 +177,44 @@ public final class CustomerInfo: NSObject, Codable { ) } + /// Composes customer info from a fresh device snapshot under an external + /// purchase controller, preserving the entitlements that came from the + /// controller — its active entitlements won't necessarily be in device data. + static func preservingExternalControllerEntitlements( + device deviceCustomerInfo: CustomerInfo, + web webCustomerInfo: CustomerInfo?, + current currentCustomerInfo: CustomerInfo, + granted grantedEntitlements: Set + ) -> CustomerInfo { + // Merge with web customer info if available + let baseCustomerInfo = webCustomerInfo.map { + deviceCustomerInfo.merging(with: $0) + } ?? deviceCustomerInfo + + // Get entitlements that are only in current CustomerInfo (i.e., from external controller) + // by filtering out anything that matches device or web entitlements by ID + let deviceAndWebEntitlementIds = Set(baseCustomerInfo.entitlements.map { $0.id }) + let externalOnlyEntitlements = currentCustomerInfo.entitlements.filter { entitlement in + // Keep external entitlement if it's not already in device/web + !deviceAndWebEntitlementIds.contains(entitlement.id) + } + + // Merge external controller entitlements with device + web + granted. + // Granted entitlements are their own source here: the externalOnly + // filter above would drop one whose ID collides with an expired device + // entitlement, leaving customerInfo reporting it inactive while + // subscriptionStatus reports it active. + let allEntitlements = + baseCustomerInfo.entitlements + externalOnlyEntitlements + Array(grantedEntitlements) + let finalEntitlements = Entitlement.mergePrioritized(allEntitlements) + + return CustomerInfo( + subscriptions: baseCustomerInfo.subscriptions, + nonSubscriptions: baseCustomerInfo.nonSubscriptions, + entitlements: finalEntitlements.sorted { $0.id < $1.id } + ) + } + /// Creates a merged CustomerInfo from device, web, and external purchase controller sources. /// This is a factory method that reads from storage and merges all entitlement sources. /// @@ -211,39 +254,25 @@ public final class CustomerInfo: NSObject, Codable { externalEntitlements = [] } - // Merge: active from external controller + all web + inactive device + // Developer-granted entitlements are read from their own storage rather + // than recovered from subscriptionStatus — that's an already-merged value + // where sources are no longer distinguishable. They also can't ride in via + // the appStore filter above: widening it to admit them would resurrect + // revoked web entitlements, whose revocation is signalled only by their + // absence from webCustomerInfo. + let grantedEntitlements = storage.get(GrantedEntitlements.self) ?? [] + + // Merge: active from external controller + all web + inactive device + granted // This gives us complete history while respecting external controller as source of truth for active status - let allEntitlements = externalEntitlements + webCustomerInfo.entitlements + inactiveDeviceEntitlements + let allEntitlements = + externalEntitlements + webCustomerInfo.entitlements + inactiveDeviceEntitlements + + Array(grantedEntitlements) let finalEntitlements = Entitlement.mergePrioritized(allEntitlements) return CustomerInfo( subscriptions: baseCustomerInfo.subscriptions, nonSubscriptions: baseCustomerInfo.nonSubscriptions, entitlements: finalEntitlements.sorted { $0.id < $1.id } - ).mergingGrantedEntitlements(from: storage) - } - - /// Returns a copy with developer-granted entitlements merged in as their - /// own source. - /// - /// Granted entitlements are read from their own storage rather than - /// recovered from `subscriptionStatus` — that's an already-merged value - /// where sources are no longer distinguishable. They also can't ride in via - /// the appStore filter in `forExternalPurchaseController`: widening it to - /// admit them would resurrect revoked web entitlements, whose revocation is - /// signalled only by their absence from the web customer info. - func mergingGrantedEntitlements(from storage: Storage) -> CustomerInfo { - let grantedEntitlements = storage.get(GrantedEntitlements.self) ?? [] - if grantedEntitlements.isEmpty { - return self - } - let mergedEntitlements = Entitlement.mergePrioritized( - entitlements + Array(grantedEntitlements) - ) - return CustomerInfo( - subscriptions: subscriptions, - nonSubscriptions: nonSubscriptions, - entitlements: mergedEntitlements.sorted { $0.id < $1.id } ) } } diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift index 244fadbbd..23e702dca 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift @@ -187,43 +187,24 @@ actor ReceiptManager { // Save device-only CustomerInfo to storage for use when merging with web entitlements storage.save(onDeviceSnapshot.customerInfo, forType: LatestDeviceCustomerInfo.self) - // Merge with web customer info if available - let baseCustomerInfo: CustomerInfo - if let latestRedeemResponse = storage.get(LatestRedeemResponse.self) { - baseCustomerInfo = onDeviceSnapshot.customerInfo.merging(with: latestRedeemResponse.customerInfo) - } else { - baseCustomerInfo = onDeviceSnapshot.customerInfo - } + // The other sources that merge with the device snapshot. + let webCustomerInfo = storage.get(LatestRedeemResponse.self)?.customerInfo + let grantedEntitlements = storage.get(GrantedEntitlements.self) ?? [] - // If using an external purchase controller, preserve entitlements that came from it - // (The external controller's active entitlements won't necessarily be in device data) let mergedCustomerInfo: CustomerInfo if factory.makeHasExternalPurchaseController() { let currentCustomerInfo = await MainActor.run { Superwall.shared.customerInfo } - - // Get entitlements that are only in current CustomerInfo (i.e., from external controller) - // by filtering out anything that matches device or web entitlements by ID - let deviceAndWebEntitlementIds = Set(baseCustomerInfo.entitlements.map { $0.id }) - let externalOnlyEntitlements = currentCustomerInfo.entitlements.filter { entitlement in - // Keep external entitlement if it's not already in device/web - !deviceAndWebEntitlementIds.contains(entitlement.id) - } - - // Merge external controller entitlements with device + web - let allEntitlements = baseCustomerInfo.entitlements + externalOnlyEntitlements - let finalEntitlements = Entitlement.mergePrioritized(allEntitlements) - - // Granted entitlements merge here too: the externalOnly filter above - // drops a granted entitlement whose ID collides with an expired - // device one, which would leave customerInfo reporting it inactive - // while subscriptionStatus reports it active. - mergedCustomerInfo = CustomerInfo( - subscriptions: baseCustomerInfo.subscriptions, - nonSubscriptions: baseCustomerInfo.nonSubscriptions, - entitlements: finalEntitlements.sorted { $0.id < $1.id } - ).mergingGrantedEntitlements(from: storage) + mergedCustomerInfo = CustomerInfo.preservingExternalControllerEntitlements( + device: onDeviceSnapshot.customerInfo, + web: webCustomerInfo, + current: currentCustomerInfo, + granted: grantedEntitlements + ) } else { - mergedCustomerInfo = baseCustomerInfo.mergingGrantedEntitlements(from: storage) + mergedCustomerInfo = onDeviceSnapshot.customerInfo.merging( + with: webCustomerInfo ?? .blank(), + granting: grantedEntitlements + ) } await MainActor.run { diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index b9e09a191..657ac25e4 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -240,13 +240,11 @@ public final class Superwall: NSObject, ObservableObject { // applySubscriptionStatusResolution, which runs the side effects. return } - let oldBase = unresolvedSubscriptionStatus - unresolvedSubscriptionStatus = subscriptionStatus // Snapshot once: both the warning check and resolution need the // granted set, and each read hits storage under the lock. let granted = grantedEntitlements logIfGrantedEntitlementsOverrideInactive(subscriptionStatus, granted: granted) - applySubscriptionStatusResolution(oldBase: oldBase, granted: granted) + resolveSubscriptionStatus(from: subscriptionStatus, granted: granted) } } @@ -273,6 +271,37 @@ public final class Superwall: NSObject, ObservableObject { /// `.inactive` assignment has been logged. private var hasLoggedGrantedEntitlementsWarning = false + /// Emits the resolved status exactly once per base assignment or grant + /// change. `$subscriptionStatus` can't serve this purpose: `@Published` + /// emits on every store, so a public-setter assignment that resolution + /// changes is published twice — the raw value, then the resolved one. + private let resolvedSubscriptionStatusSubject = PassthroughSubject() + + /// Assigns a new unresolved base and publishes its resolution in a single + /// store, so subscribers never observe the unresolved value. + /// + /// SDK writers use this rather than assigning ``subscriptionStatus``: the + /// public setter has to store the raw value before `didSet` can resolve + /// it, which publishes the raw value first. + func setSubscriptionStatus(base: SubscriptionStatus) { + subscriptionStatusResolutionLock.lock() + defer { + subscriptionStatusResolutionLock.unlock() + } + resolveSubscriptionStatus(from: base, granted: grantedEntitlements) + } + + /// Captures `base` as the unresolved status and resolves from it. Must be + /// called with `subscriptionStatusResolutionLock` held. + private func resolveSubscriptionStatus( + from base: SubscriptionStatus, + granted: Set + ) { + let oldBase = unresolvedSubscriptionStatus + unresolvedSubscriptionStatus = base + applySubscriptionStatusResolution(oldBase: oldBase, granted: granted) + } + /// Entitlements granted by your own backend that Superwall can't observe, /// which the SDK merges into ``subscriptionStatus`` alongside device and web /// entitlements. @@ -307,8 +336,8 @@ public final class Superwall: NSObject, ObservableObject { ? "Granted entitlements cleared." : "Granted entitlements set: \(newValue.map(\.id).sorted().joined(separator: ", "))" ) - applySubscriptionStatusResolution(oldBase: unresolvedSubscriptionStatus, granted: newValue) - refreshAutomaticCustomerInfoAfterGrantChange() + resolveSubscriptionStatus(from: unresolvedSubscriptionStatus, granted: newValue) + refreshAutomaticCustomerInfoAfterGrantChange(granted: newValue) } } @@ -317,7 +346,7 @@ public final class Superwall: NSObject, ObservableObject { /// actually disappear. The external purchase controller path is recomputed /// inside `applySubscriptionStatusResolution`; test mode manages its own /// customer info. - private func refreshAutomaticCustomerInfoAfterGrantChange() { + private func refreshAutomaticCustomerInfoAfterGrantChange(granted: Set) { if dependencyContainer.makeHasExternalPurchaseController() { return } @@ -327,8 +356,7 @@ public final class Superwall: NSObject, ObservableObject { let storage: Storage = dependencyContainer.storage let deviceCustomerInfo = storage.get(LatestDeviceCustomerInfo.self) ?? .blank() let webCustomerInfo = storage.get(LatestRedeemResponse.self)?.customerInfo ?? .blank() - customerInfo = deviceCustomerInfo.merging(with: webCustomerInfo) - .mergingGrantedEntitlements(from: storage) + customerInfo = deviceCustomerInfo.merging(with: webCustomerInfo, granting: granted) } /// Resolves the subscription status from the unresolved base and publishes @@ -371,6 +399,7 @@ public final class Superwall: NSObject, ObservableObject { subscriptionStatus: subscriptionStatus ) } + resolvedSubscriptionStatusSubject.send(subscriptionStatus) } /// Logs a one-time warning when `.inactive` is assigned to @@ -454,31 +483,26 @@ public final class Superwall: NSObject, ObservableObject { } let activeWebEntitlements = dependencyContainer.entitlementsInfo.web let superwall = superwall ?? Superwall.shared + let deviceAndWebStatus: SubscriptionStatus switch status { case .active(let entitlements): // Use mergePrioritized to intelligently merge device and web entitlements // This ensures the highest priority version is kept for each entitlement ID let combinedEntitlements = Array(entitlements) + Array(activeWebEntitlements) let mergedEntitlements = Entitlement.mergePrioritized(combinedEntitlements) - if mergedEntitlements.isEmpty { - superwall.subscriptionStatus = .inactive - } else { - superwall.subscriptionStatus = .active(mergedEntitlements) - } + deviceAndWebStatus = mergedEntitlements.isEmpty ? .inactive : .active(mergedEntitlements) case .inactive: - if activeWebEntitlements.isEmpty { - superwall.subscriptionStatus = .inactive - } else { - superwall.subscriptionStatus = .active(activeWebEntitlements) - } + deviceAndWebStatus = activeWebEntitlements.isEmpty ? .inactive : .active(activeWebEntitlements) case .unknown: // Web entitlements deliberately don't promote .unknown to active, // unlike granted entitlements (see resolvedSubscriptionStatus). This // branch only runs without an external purchase controller, where the // AutomaticPurchaseController is guaranteed to resolve .unknown after // loading purchased products — so .unknown is always transient here. - superwall.subscriptionStatus = .unknown + deviceAndWebStatus = .unknown } + // Granted entitlements merge during resolution, from this base. + superwall.setSubscriptionStatus(base: deviceAndWebStatus) } /// Returns the subscription status of the user. @@ -661,7 +685,7 @@ public final class Superwall: NSObject, ObservableObject { customerInfo = dependencyContainer.storage.get(LatestCustomerInfo.self) ?? .blank() - subscriptionStatus = dependencyContainer.storage.get(SubscriptionStatusKey.self) ?? .unknown + setSubscriptionStatus(base: dependencyContainer.storage.get(SubscriptionStatusKey.self) ?? .unknown) dependencyContainer.entitlementsInfo.subscriptionStatusDidSet(subscriptionStatus) addListeners() @@ -760,7 +784,11 @@ public final class Superwall: NSObject, ObservableObject { } func listenToSubscriptionStatus() { - $subscriptionStatus + // Listens to the resolved stream rather than $subscriptionStatus so the + // delegate and the status-change event never see the transient raw + // value that the public setter stores before resolution. + resolvedSubscriptionStatusSubject + .prepend(subscriptionStatus) .removeDuplicates { $0.isLogicallyEqual(to: $1) } .dropFirst() .scan((previous: subscriptionStatus, current: subscriptionStatus)) { previousPair, newStatus in diff --git a/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift b/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift index 8ca275948..0c7ae8313 100644 --- a/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift +++ b/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift @@ -78,10 +78,10 @@ final class TestModeTransactionHandler { if hasActiveEntitlements { let status = SubscriptionStatus.active(entitlementSet) testModeManager.overriddenSubscriptionStatus = status - Superwall.shared.subscriptionStatus = status + Superwall.shared.setSubscriptionStatus(base: status) } else { testModeManager.overriddenSubscriptionStatus = .inactive - Superwall.shared.subscriptionStatus = .inactive + Superwall.shared.setSubscriptionStatus(base: .inactive) } // Track free trial start if free trial is shown (respecting override) @@ -147,7 +147,7 @@ final class TestModeTransactionHandler { testModeManager.overriddenCustomerInfo = customerInfo Superwall.shared.customerInfo = customerInfo testModeManager.overriddenSubscriptionStatus = .inactive - Superwall.shared.subscriptionStatus = .inactive + Superwall.shared.setSubscriptionStatus(base: .inactive) } else { // Update test mode manager with selected entitlement IDs let activeIds = Set(entitlements.map { $0.id }) @@ -164,10 +164,10 @@ final class TestModeTransactionHandler { if hasActive { let status = SubscriptionStatus.active(entitlements) testModeManager.overriddenSubscriptionStatus = status - Superwall.shared.subscriptionStatus = status + Superwall.shared.setSubscriptionStatus(base: status) } else { testModeManager.overriddenSubscriptionStatus = .inactive - Superwall.shared.subscriptionStatus = .inactive + Superwall.shared.setSubscriptionStatus(base: .inactive) } } diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index 7c601fd22..ef40b363a 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -526,8 +526,11 @@ actor WebEntitlementRedeemer { ) } else { let deviceCustomerInfo = storage.get(LatestDeviceCustomerInfo.self) ?? .blank() - mergedCustomerInfo = deviceCustomerInfo.merging(with: webCustomerInfo) - .mergingGrantedEntitlements(from: storage) + let grantedEntitlements = storage.get(GrantedEntitlements.self) ?? [] + mergedCustomerInfo = deviceCustomerInfo.merging( + with: webCustomerInfo, + granting: grantedEntitlements + ) } await MainActor.run { diff --git a/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift index d8d3beeb7..840a50090 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift @@ -7,6 +7,7 @@ // swiftlint:disable all @testable import SuperwallKit +import Combine import Testing import Foundation @@ -327,17 +328,69 @@ final class GrantedEntitlementsTests { } @Test - func granted_mergesIntoAutomaticPathCustomerInfo() { - superwall.grantedEntitlements = [grantedEntitlement()] - - let base = CustomerInfo( + func merging_treatsGrantedAsASource() { + let device = CustomerInfo( subscriptions: [], nonSubscriptions: [], entitlements: [deviceEntitlement(id: "premium")] ) - let merged = base.mergingGrantedEntitlements(from: dependencyContainer.storage) - #expect(Set(merged.entitlements.map(\.id)) == ["premium", "granted"]) + let merged = device.merging(with: .blank(), granting: [grantedEntitlement()]) + + #expect(merged.entitlements.map(\.id) == ["granted", "premium"]) + } + + // MARK: - Publishing + + @Test + func baseAssignment_publishesOnlyTheResolvedValue() { + superwall.grantedEntitlements = [grantedEntitlement()] + + var published: [SubscriptionStatus] = [] + let cancellable = superwall.$subscriptionStatus + .dropFirst() + .sink { published.append($0) } + defer { cancellable.cancel() } + + superwall.setSubscriptionStatus(base: .active([deviceEntitlement(id: "premium")])) + + // A single store of the merged value — never the raw base first. + #expect(published.count == 1) + if case .active(let entitlements) = published.first { + #expect(Set(entitlements.map(\.id)) == ["premium", "granted"]) + } else { + Issue.record("Expected .active status") + } + } + + @Test + func inactiveWritesWithGrant_doNotNotifyDelegate() async { + let delegate = MockSuperwallDelegate() + dependencyContainer.delegateAdapter.swiftDelegate = delegate + superwall.listenToSubscriptionStatus() + + superwall.grantedEntitlements = [grantedEntitlement()] + await waitUntil { delegate.subscriptionStatusChanges.count == 1 } + #expect(delegate.subscriptionStatusChanges.count == 1) + + // A device poll reporting no subscription, and a direct .inactive + // assignment, both resolve back to the granted status. The delegate + // must not hear about either — in particular it must never see the + // transient .inactive that the public setter stores before resolution. + superwall.setSubscriptionStatus(base: .inactive) + superwall.subscriptionStatus = .inactive + try? await Task.sleep(nanoseconds: 300_000_000) + #expect(delegate.subscriptionStatusChanges.count == 1) + } + + private func waitUntil( + timeout: TimeInterval = 2, + _ condition: @escaping () -> Bool + ) async { + let start = Date() + while !condition() && Date().timeIntervalSince(start) < timeout { + try? await Task.sleep(nanoseconds: 50_000_000) + } } // MARK: - Event parameters From 913ff2ca0ef9b0469e239dc272856284e91bdd41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:54:31 +0200 Subject: [PATCH 051/162] review: keep LogScope raw values stable, cover the external-controller grant collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declares the grantedEntitlements log scope after .all so existing implicit Int raw values — which reach the backend via SuperwallOptions.toDictionary() — don't shift. Adds a test for CustomerInfo.preservingExternalControllerEntitlements that fails if the granted term is dropped from its merge. Co-Authored-By: Claude Fable 5.1 --- Sources/SuperwallKit/Logger/LogScope.swift | 4 ++- .../Products/GrantedEntitlementsTests.swift | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/Logger/LogScope.swift b/Sources/SuperwallKit/Logger/LogScope.swift index 2faa052b2..17e1d0fbf 100644 --- a/Sources/SuperwallKit/Logger/LogScope.swift +++ b/Sources/SuperwallKit/Logger/LogScope.swift @@ -33,8 +33,10 @@ public enum LogScope: Int, Encodable, Sendable, CustomStringConvertible { case paywallViewController case cache case webEntitlements - case grantedEntitlements case all + // Declared after `all` so existing implicit raw values stay stable — they + // reach the backend via SuperwallOptions.toDictionary(). + case grantedEntitlements public var description: String { switch self { diff --git a/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift index 840a50090..7f49761be 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift @@ -318,6 +318,34 @@ final class GrantedEntitlementsTests { #expect(customerInfo.entitlements.contains { $0.id == "granted" && $0.isActive }) } + @Test + func preservingExternalControllerEntitlements_keepsGrantCollidingWithExpiredDevice() { + let expiredDevice = CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: [deviceEntitlement(id: "premium", isActive: false)] + ) + // The controller's current customer info also carries "premium", so + // the externalOnly filter drops it — the grant must survive as its own + // source rather than ride in through that filter. + let current = CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: [deviceEntitlement(id: "premium", isActive: false)] + ) + + let merged = CustomerInfo.preservingExternalControllerEntitlements( + device: expiredDevice, + web: nil, + current: current, + granted: [grantedEntitlement(id: "premium")] + ) + + #expect(merged.entitlements.count == 1) + #expect(merged.entitlements.first?.isActive == true) + #expect(merged.entitlements.first?.latestProductId == nil) + } + @Test func grantChange_refreshesCustomerInfoOnAutomaticPath() { superwall.grantedEntitlements = [grantedEntitlement()] From 730c9ba7d518ca38ef751bfa83edc87f5690d395 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:31:14 +0200 Subject: [PATCH 052/162] refactor: move status publishing into its own file, rename resolved/unresolved to assigned/published MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Superwall.swift kept growing a small state machine named in jargon. The pipeline now lives in SubscriptionStatusPublishing.swift with one doc comment explaining the two values: the assigned status (what a writer handed the SDK, persisted) and the published status (assigned + granted entitlements + test-mode override, what subscriptionStatus holds). Five functions collapse to three — publishSubscriptionStatus, mergedSubscriptionStatus, and the automatic-path customer info refresh — with the developer-assignment warning inlined into publish. The class keeps only the @Published property with its observers and the stored state the pipeline needs. setSubscriptionStatus(base:) becomes setSubscriptionStatus(assigned:). No behaviour change; tests renamed to match. Co-Authored-By: Claude Fable 5.1 --- .../SuperwallKit/Config/ConfigManager.swift | 10 +- .../SubscriptionStatusPublishing.swift | 292 +++++++++++++++ Sources/SuperwallKit/Superwall.swift | 342 ++---------------- .../TestMode/TestModeTransactionHandler.swift | 10 +- SuperwallKit.xcodeproj/project.pbxproj | 4 + .../Products/GrantedEntitlementsTests.swift | 48 +-- 6 files changed, 363 insertions(+), 343 deletions(-) create mode 100644 Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index 4474e77e9..59c46c65c 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -205,13 +205,13 @@ class ConfigManager { /// Whether the cached subscription state indicates an active subscriber. /// - /// The persisted status is the unresolved base, before developer-granted + /// The persisted status is the assigned value, before developer-granted /// entitlements are merged in, so those are checked from their own storage. private func hasActiveCachedSubscription() -> Bool { if storage.get(IsTestModeActiveSubscription.self) ?? false { return false } - // The persisted base can hold .active with no active entitlement + // The persisted status can hold .active with no active entitlement // (e.g. a developer-assigned .active([])) — inspect the set rather // than pattern-matching the case alone. if case .active(let entitlements) = storage.get(SubscriptionStatusKey.self), @@ -416,7 +416,7 @@ class ConfigManager { // loadPurchasedProducts / the controller itself will restore it. if testModeJustDeactivated, !factory.makeHasExternalPurchaseController() { - Superwall.shared.setSubscriptionStatus(base: .inactive) + Superwall.shared.setSubscriptionStatus(assigned: .inactive) Superwall.shared.customerInfo = CustomerInfo( subscriptions: [], nonSubscriptions: [], @@ -782,11 +782,11 @@ class ConfigManager { if hasActiveEntitlements { let status = SubscriptionStatus.active(result.entitlements) testModeManager.overriddenSubscriptionStatus = status - Superwall.shared.setSubscriptionStatus(base: status) + Superwall.shared.setSubscriptionStatus(assigned: status) storage.save(true, forType: IsTestModeActiveSubscription.self) } else { testModeManager.overriddenSubscriptionStatus = .inactive - Superwall.shared.setSubscriptionStatus(base: .inactive) + Superwall.shared.setSubscriptionStatus(assigned: .inactive) storage.save(false, forType: IsTestModeActiveSubscription.self) } } diff --git a/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift new file mode 100644 index 000000000..7843027ed --- /dev/null +++ b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift @@ -0,0 +1,292 @@ +// +// SubscriptionStatusPublishing.swift +// SuperwallKit +// +// Created by Yusuf Tör on 2026-09-02. +// + +import Combine +import Foundation + +/// How ``Superwall/subscriptionStatus`` is produced. +/// +/// Two values are involved: +/// - The **assigned** status: what a writer handed the SDK — device + web +/// entitlements on the automatic path, or the developer's own value when +/// using a purchase controller. Kept in `assignedSubscriptionStatus` and +/// persisted. +/// - The **published** status: the assigned status with +/// ``Superwall/grantedEntitlements`` merged in, any test-mode override +/// applied, and an empty `.active` collapsed to `.inactive`. This is what +/// ``Superwall/subscriptionStatus`` holds. +/// +/// The assigned value is kept because the published one can't be un-merged: +/// clearing granted entitlements recomputes the published status from it. +/// +/// Every writer ends up in `publishSubscriptionStatus`, which runs under +/// `subscriptionStatusLock` so assignments from different threads can't +/// interleave with the write-back of the merged value. +extension Superwall { + // MARK: - Granted entitlements + + /// Entitlements granted by your own backend that Superwall can't observe, + /// which the SDK merges into ``subscriptionStatus`` alongside device and web + /// entitlements. + /// + /// Use this when access is granted outside of the App Store and the web — + /// for example, a promotional grant or an account comped by your backend. + /// Don't use this if you compute the full subscription picture in a + /// `PurchaseController` — in that case set ``subscriptionStatus`` directly, + /// otherwise two writers feed one value and the merge will surprise you. + /// + /// Each assignment replaces the previous value outright — granting `[a]` + /// and then `[b]` leaves only `b`. Assign an empty set to revoke. + /// + /// - Warning: This persists across app launches, ``reset()``, and + /// ``identify(userId:options:)``. You must set it again immediately after + /// calling `identify()` or `reset()` if the new user shouldn't inherit the + /// previous user's granted entitlements. + public var grantedEntitlements: Set { + get { + return dependencyContainer.storage.get(GrantedEntitlements.self) ?? [] + } + set { + subscriptionStatusLock.lock() + defer { + subscriptionStatusLock.unlock() + } + dependencyContainer.storage.save(newValue, forType: GrantedEntitlements.self) + Logger.debug( + logLevel: .info, + scope: .grantedEntitlements, + message: newValue.isEmpty + ? "Granted entitlements cleared." + : "Granted entitlements set: \(newValue.map(\.id).sorted().joined(separator: ", "))" + ) + publishSubscriptionStatus(assigned: assignedSubscriptionStatus, isDeveloperAssignment: false) + refreshAutomaticCustomerInfoAfterGrantChange(granted: newValue) + } + } + + // MARK: - Assigning + + /// Assigns a new status and publishes the merged result in a single + /// store, so subscribers never observe the un-merged value. + /// + /// SDK writers use this rather than assigning ``subscriptionStatus``: the + /// public setter has to store the raw value before `didSet` can merge it, + /// which publishes the raw value first. + func setSubscriptionStatus(assigned status: SubscriptionStatus) { + subscriptionStatusLock.lock() + defer { + subscriptionStatusLock.unlock() + } + publishSubscriptionStatus(assigned: status, isDeveloperAssignment: false) + } + + /// Merges web entitlements into the device status and assigns the result, + /// if there's no external purchase controller. + @MainActor + func internallySetSubscriptionStatus( + to status: SubscriptionStatus, + superwall: Superwall? = nil + ) { + if dependencyContainer.makeHasExternalPurchaseController() { + return + } + let activeWebEntitlements = dependencyContainer.entitlementsInfo.web + let superwall = superwall ?? Superwall.shared + let deviceAndWebStatus: SubscriptionStatus + switch status { + case .active(let entitlements): + // Use mergePrioritized to intelligently merge device and web entitlements + // This ensures the highest priority version is kept for each entitlement ID + let combinedEntitlements = Array(entitlements) + Array(activeWebEntitlements) + let mergedEntitlements = Entitlement.mergePrioritized(combinedEntitlements) + deviceAndWebStatus = mergedEntitlements.isEmpty ? .inactive : .active(mergedEntitlements) + case .inactive: + deviceAndWebStatus = activeWebEntitlements.isEmpty ? .inactive : .active(activeWebEntitlements) + case .unknown: + // Web entitlements deliberately don't promote .unknown to active, + // unlike granted entitlements (see mergedSubscriptionStatus). This + // branch only runs without an external purchase controller, where the + // AutomaticPurchaseController is guaranteed to replace .unknown after + // loading purchased products — so .unknown is always transient here. + deviceAndWebStatus = .unknown + } + superwall.setSubscriptionStatus(assigned: deviceAndWebStatus) + } + + // MARK: - Publishing + + /// Records `assigned`, publishes the merged status in a single store, + /// persists the assigned value, and runs the side effects of a change. + /// Must be called with `subscriptionStatusLock` held. + /// + /// `isDeveloperAssignment` is `true` for writes through the public + /// ``subscriptionStatus`` setter — the only path that warrants warning + /// about granted entitlements overriding an `.inactive` assignment. + func publishSubscriptionStatus( + assigned: SubscriptionStatus, + isDeveloperAssignment: Bool + ) { + // Snapshot once: each read hits storage under the lock. + let granted = grantedEntitlements + + // The status publishes as active regardless, which otherwise looks + // like the SDK ignored the assignment. + if isDeveloperAssignment, + case .inactive = assigned, + !granted.isEmpty, + !hasLoggedGrantedEntitlementsWarning { + hasLoggedGrantedEntitlementsWarning = true + Logger.debug( + logLevel: .warn, + scope: .grantedEntitlements, + message: "subscriptionStatus was set to .inactive but grantedEntitlements " + + "is not empty, so the status remains active. Assigning subscriptionStatus " + + "doesn't clear granted entitlements — set grantedEntitlements to an empty set to revoke them." + ) + } + + let previouslyAssigned = assignedSubscriptionStatus + assignedSubscriptionStatus = assigned + + let merged = mergedSubscriptionStatus(assigned: assigned, granted: granted) + if merged != subscriptionStatus { + isPublishingSubscriptionStatus = true + subscriptionStatus = merged + isPublishingSubscriptionStatus = false + } + + // Persist the assigned value, not the merged one: restoring a merged + // value would bake granted entitlements into the assigned status for + // good, making them impossible to clear after a relaunch. + // + // Saved here rather than in the status listener so that metadata-only + // updates, which the listener dedupes, still refresh the cache. Identical + // writes are skipped so they don't re-encode and rewrite the file. + if previouslyAssigned != assigned { + dependencyContainer.storage.save(assigned, forType: SubscriptionStatusKey.self) + } + entitlements.subscriptionStatusDidSet(subscriptionStatus) + + // When using an external purchase controller, update CustomerInfo.entitlements + // to reflect the entitlements from the purchase controller. + // Skip this in test mode — test mode manages its own CustomerInfo. + if dependencyContainer.makeHasExternalPurchaseController(), + dependencyContainer.testModeManager?.isTestMode != true { + customerInfo = CustomerInfo.forExternalPurchaseController( + storage: dependencyContainer.storage, + subscriptionStatus: subscriptionStatus + ) + } + publishedSubscriptionStatusSubject.send(subscriptionStatus) + } + + /// The status the SDK reports for `assigned`: active granted entitlements + /// merged in, any test-mode override applied, and an empty `.active` + /// collapsed to `.inactive`. + private func mergedSubscriptionStatus( + assigned: SubscriptionStatus, + granted: Set + ) -> SubscriptionStatus { + if let testModeManager = dependencyContainer.testModeManager, + testModeManager.isTestMode, + let override = testModeManager.overriddenSubscriptionStatus { + return override + } + var status = assigned + // Only active grants merge into the status, mirroring how web + // entitlements merge (EntitlementsInfo.web filters to active). + // Inactive grants still reach customerInfo for round-trip visibility. + let activeGrants = Set(granted.filter(\.isActive)) + if !activeGrants.isEmpty { + switch status { + case .active(let entitlements): + // mergePrioritized explicitly: plain Set.union dedupes by deep + // equality, which would keep both records for a shared ID. + status = .active( + Entitlement.mergePrioritized(Array(entitlements) + Array(activeGrants)) + ) + case .inactive, .unknown: + // .unknown promotes to active, unlike web entitlements (see + // internallySetSubscriptionStatus). With an external purchase + // controller the developer is the only writer of the status, so + // .unknown can be terminal — without promotion, a developer relying + // solely on granted entitlements would be locked out forever. + status = .active(activeGrants) + } + } + // This must run after the granted merge, or a developer-assigned + // .active([]) would collapse to .inactive before granted is applied. + if case .active(let entitlements) = status, + entitlements.isEmpty { + return .inactive + } + return status + } + + /// Recomputes `customerInfo` on the automatic path after a grant change, + /// rebuilding from the stored device and web sources so cleared grants + /// actually disappear. The external purchase controller path is recomputed + /// inside `publishSubscriptionStatus`; test mode manages its own customer + /// info. + private func refreshAutomaticCustomerInfoAfterGrantChange(granted: Set) { + if dependencyContainer.makeHasExternalPurchaseController() { + return + } + if dependencyContainer.testModeManager?.isTestMode == true { + return + } + let storage: Storage = dependencyContainer.storage + let deviceCustomerInfo = storage.get(LatestDeviceCustomerInfo.self) ?? .blank() + let webCustomerInfo = storage.get(LatestRedeemResponse.self)?.customerInfo ?? .blank() + customerInfo = deviceCustomerInfo.merging(with: webCustomerInfo, granting: granted) + } + + // MARK: - Listening + + func listenToSubscriptionStatus() { + // Listens to the published stream rather than $subscriptionStatus so the + // delegate and the status-change event never see the transient raw + // value that the public setter stores before merging. + publishedSubscriptionStatusSubject + .prepend(subscriptionStatus) + .removeDuplicates { $0.isLogicallyEqual(to: $1) } + .dropFirst() + .scan((previous: subscriptionStatus, current: subscriptionStatus)) { previousPair, newStatus in + // Shift the current value to previous, and set the new status as the current value + (previous: previousPair.current, current: newStatus) + } + .receive(on: DispatchQueue.main) + .subscribe( + Subscribers.Sink( + receiveCompletion: { _ in }, + receiveValue: { [weak self] statusPair in + guard let self = self else { + return + } + let oldStatus = statusPair.previous + let newStatus = statusPair.current + + Task { + await self.dependencyContainer.delegateAdapter.subscriptionStatusDidChange( + from: oldStatus, to: newStatus) + let event = InternalSuperwallEvent.SubscriptionStatusDidChange( + status: newStatus, + grantedEntitlements: self.grantedEntitlements + ) + await self.track(event) + } + Task { + let deviceAttributes = await self.dependencyContainer.makeSessionDeviceAttributes() + let deviceAttributesPlacement = InternalSuperwallEvent.DeviceAttributes( + deviceAttributes: deviceAttributes) + await self.track(deviceAttributesPlacement) + } + } + ) + ) + } +} diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 657ac25e4..363149727 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -220,211 +220,52 @@ public final class Superwall: NSObject, ObservableObject { @Published public var subscriptionStatus: SubscriptionStatus = .unknown { willSet { - // Serializes the property store itself, not just the resolution: - // didSet has no newValue and must re-read the property, so a store - // landing mid-resolution on another thread would be captured as that - // thread's base — including the resolved write-back, which would bake - // granted entitlements into the persisted base. Locking before the - // store makes store + resolution atomic. The lock is recursive - // because the write-back re-enters these observers on the same - // thread. Released at the end of didSet — the pair must stay - // balanced across every didSet exit path. - subscriptionStatusResolutionLock.lock() + // Locked before the store so the store and the publish in didSet are + // atomic across threads — see SubscriptionStatusPublishing.swift. + // Released at the end of didSet; the pair must stay balanced. + subscriptionStatusLock.lock() } didSet { defer { - subscriptionStatusResolutionLock.unlock() + subscriptionStatusLock.unlock() } - if isResolvingSubscriptionStatus { - // Write-back of the resolved value from - // applySubscriptionStatusResolution, which runs the side effects. + if isPublishingSubscriptionStatus { + // Our own write-back of the merged value. return } - // Snapshot once: both the warning check and resolution need the - // granted set, and each read hits storage under the lock. - let granted = grantedEntitlements - logIfGrantedEntitlementsOverrideInactive(subscriptionStatus, granted: granted) - resolveSubscriptionStatus(from: subscriptionStatus, granted: granted) + publishSubscriptionStatus(assigned: subscriptionStatus, isDeveloperAssignment: true) } } - /// The last externally assigned subscription status, before resolution - /// merges in ``grantedEntitlements`` or test-mode overrides. - /// - /// Resolution always starts from this base rather than from the published - /// value: the published value is already merged, so re-resolving it could - /// never remove granted entitlements once the developer clears them. - private var unresolvedSubscriptionStatus: SubscriptionStatus = .unknown + // MARK: - Subscription status state + // The publishing pipeline lives in SubscriptionStatusPublishing.swift; the + // stored state it needs has to live in the class. - /// Guards the write-back of the resolved status inside - /// `applySubscriptionStatusResolution` so `didSet` doesn't mistake it for a - /// new external assignment and capture the resolved value as the base. - /// Only read and written while `subscriptionStatusResolutionLock` is held. - private var isResolvingSubscriptionStatus = false + /// The status last handed to the SDK — device + web entitlements on the + /// automatic path, or the developer's value with a purchase controller — + /// before granted entitlements and test-mode overrides are merged in. + /// ``subscriptionStatus`` is always derived from this, never the reverse, + /// so clearing granted entitlements can recompute it. + var assignedSubscriptionStatus: SubscriptionStatus = .unknown - /// Serializes status resolution so concurrent assignments from different - /// threads can't interleave with the resolved write-back. Recursive - /// because the write-back re-enters the `subscriptionStatus` `didSet`. - private let subscriptionStatusResolutionLock = NSRecursiveLock() + /// Serializes status assignment and publishing across threads. Recursive + /// because publishing re-enters the ``subscriptionStatus`` observers on + /// the same thread. + let subscriptionStatusLock = NSRecursiveLock() - /// Whether the one-time warning about granted entitlements overriding an - /// `.inactive` assignment has been logged. - private var hasLoggedGrantedEntitlementsWarning = false - - /// Emits the resolved status exactly once per base assignment or grant - /// change. `$subscriptionStatus` can't serve this purpose: `@Published` - /// emits on every store, so a public-setter assignment that resolution - /// changes is published twice — the raw value, then the resolved one. - private let resolvedSubscriptionStatusSubject = PassthroughSubject() - - /// Assigns a new unresolved base and publishes its resolution in a single - /// store, so subscribers never observe the unresolved value. - /// - /// SDK writers use this rather than assigning ``subscriptionStatus``: the - /// public setter has to store the raw value before `didSet` can resolve - /// it, which publishes the raw value first. - func setSubscriptionStatus(base: SubscriptionStatus) { - subscriptionStatusResolutionLock.lock() - defer { - subscriptionStatusResolutionLock.unlock() - } - resolveSubscriptionStatus(from: base, granted: grantedEntitlements) - } + /// Set while `publishSubscriptionStatus` writes the merged value back, so + /// `didSet` doesn't treat that store as a new assignment. Only touched + /// with `subscriptionStatusLock` held. + var isPublishingSubscriptionStatus = false - /// Captures `base` as the unresolved status and resolves from it. Must be - /// called with `subscriptionStatusResolutionLock` held. - private func resolveSubscriptionStatus( - from base: SubscriptionStatus, - granted: Set - ) { - let oldBase = unresolvedSubscriptionStatus - unresolvedSubscriptionStatus = base - applySubscriptionStatusResolution(oldBase: oldBase, granted: granted) - } - - /// Entitlements granted by your own backend that Superwall can't observe, - /// which the SDK merges into ``subscriptionStatus`` alongside device and web - /// entitlements. - /// - /// Use this when access is granted outside of the App Store and the web — - /// for example, a promotional grant or an account comped by your backend. - /// Don't use this if you compute the full subscription picture in a - /// `PurchaseController` — in that case set ``subscriptionStatus`` directly, - /// otherwise two writers feed one value and the merge will surprise you. - /// - /// Each assignment replaces the previous value outright — granting `[a]` - /// and then `[b]` leaves only `b`. Assign an empty set to revoke. - /// - /// - Warning: This persists across app launches, ``reset()``, and - /// ``identify(userId:options:)``. You must set it again immediately after - /// calling `identify()` or `reset()` if the new user shouldn't inherit the - /// previous user's granted entitlements. - public var grantedEntitlements: Set { - get { - return dependencyContainer.storage.get(GrantedEntitlements.self) ?? [] - } - set { - subscriptionStatusResolutionLock.lock() - defer { - subscriptionStatusResolutionLock.unlock() - } - dependencyContainer.storage.save(newValue, forType: GrantedEntitlements.self) - Logger.debug( - logLevel: .info, - scope: .grantedEntitlements, - message: newValue.isEmpty - ? "Granted entitlements cleared." - : "Granted entitlements set: \(newValue.map(\.id).sorted().joined(separator: ", "))" - ) - resolveSubscriptionStatus(from: unresolvedSubscriptionStatus, granted: newValue) - refreshAutomaticCustomerInfoAfterGrantChange(granted: newValue) - } - } + /// Emits the merged status once per assignment or grant change, for the + /// status listener. `$subscriptionStatus` also emits the raw value the + /// public setter stores before merging, which must not reach the delegate. + let publishedSubscriptionStatusSubject = PassthroughSubject() - /// Recomputes `customerInfo` on the automatic path after a grant change, - /// rebuilding from the stored device and web sources so cleared grants - /// actually disappear. The external purchase controller path is recomputed - /// inside `applySubscriptionStatusResolution`; test mode manages its own - /// customer info. - private func refreshAutomaticCustomerInfoAfterGrantChange(granted: Set) { - if dependencyContainer.makeHasExternalPurchaseController() { - return - } - if dependencyContainer.testModeManager?.isTestMode == true { - return - } - let storage: Storage = dependencyContainer.storage - let deviceCustomerInfo = storage.get(LatestDeviceCustomerInfo.self) ?? .blank() - let webCustomerInfo = storage.get(LatestRedeemResponse.self)?.customerInfo ?? .blank() - customerInfo = deviceCustomerInfo.merging(with: webCustomerInfo, granting: granted) - } - - /// Resolves the subscription status from the unresolved base and publishes - /// it, then runs the side effects of a status change. - /// - /// This is the only writer of the resolved value. It's called from the - /// `subscriptionStatus` `didSet` on external assignment and from the - /// ``grantedEntitlements`` setter, whose changes must re-resolve the status - /// without a new base being assigned. - private func applySubscriptionStatusResolution( - oldBase: SubscriptionStatus, - granted: Set - ) { - let resolved = resolvedSubscriptionStatus(unresolvedSubscriptionStatus, granted: granted) - if resolved != subscriptionStatus { - isResolvingSubscriptionStatus = true - subscriptionStatus = resolved - isResolvingSubscriptionStatus = false - } - // The unresolved base is persisted, not the resolved value: granted - // entitlements are merged during resolution, and restoring an - // already-merged value would bake them into the base forever, making - // them impossible to clear after a relaunch. - // - // Saved here rather than in the status listener so that metadata-only - // updates, which the listener dedupes, still refresh the cache. Identical - // writes are skipped so they don't re-encode and rewrite the file. - if oldBase != unresolvedSubscriptionStatus { - dependencyContainer.storage.save(unresolvedSubscriptionStatus, forType: SubscriptionStatusKey.self) - } - entitlements.subscriptionStatusDidSet(subscriptionStatus) - - // When using an external purchase controller, update CustomerInfo.entitlements - // to reflect the entitlements from the purchase controller. - // Skip this in test mode — test mode manages its own CustomerInfo. - if dependencyContainer.makeHasExternalPurchaseController(), - dependencyContainer.testModeManager?.isTestMode != true { - customerInfo = CustomerInfo.forExternalPurchaseController( - storage: dependencyContainer.storage, - subscriptionStatus: subscriptionStatus - ) - } - resolvedSubscriptionStatusSubject.send(subscriptionStatus) - } - - /// Logs a one-time warning when `.inactive` is assigned to - /// `subscriptionStatus` while granted entitlements exist. The status will - /// resolve back to active, which otherwise looks like the SDK ignored the - /// assignment. - private func logIfGrantedEntitlementsOverrideInactive( - _ status: SubscriptionStatus, - granted: Set - ) { - guard case .inactive = status, - !hasLoggedGrantedEntitlementsWarning, - !granted.isEmpty - else { - return - } - hasLoggedGrantedEntitlementsWarning = true - Logger.debug( - logLevel: .warn, - scope: .grantedEntitlements, - message: "subscriptionStatus was set to .inactive but grantedEntitlements " - + "is not empty, so the status remains active. Assigning subscriptionStatus " - + "doesn't clear granted entitlements — set grantedEntitlements to an empty set to revoke them." - ) - } + /// Whether the one-time warning about granted entitlements overriding an + /// `.inactive` assignment has been logged. + var hasLoggedGrantedEntitlementsWarning = false /// Contains the latest information about all of the customer's purchase and subscription data. /// @@ -471,40 +312,6 @@ public final class Superwall: NSObject, ObservableObject { return await dependencyContainer.deviceHelper.getTemplateDevice() } - /// Gets web entitlements and merges them with device entitlements before - /// setting the status if no external purchase controller. - @MainActor - func internallySetSubscriptionStatus( - to status: SubscriptionStatus, - superwall: Superwall? = nil - ) { - if dependencyContainer.makeHasExternalPurchaseController() { - return - } - let activeWebEntitlements = dependencyContainer.entitlementsInfo.web - let superwall = superwall ?? Superwall.shared - let deviceAndWebStatus: SubscriptionStatus - switch status { - case .active(let entitlements): - // Use mergePrioritized to intelligently merge device and web entitlements - // This ensures the highest priority version is kept for each entitlement ID - let combinedEntitlements = Array(entitlements) + Array(activeWebEntitlements) - let mergedEntitlements = Entitlement.mergePrioritized(combinedEntitlements) - deviceAndWebStatus = mergedEntitlements.isEmpty ? .inactive : .active(mergedEntitlements) - case .inactive: - deviceAndWebStatus = activeWebEntitlements.isEmpty ? .inactive : .active(activeWebEntitlements) - case .unknown: - // Web entitlements deliberately don't promote .unknown to active, - // unlike granted entitlements (see resolvedSubscriptionStatus). This - // branch only runs without an external purchase controller, where the - // AutomaticPurchaseController is guaranteed to resolve .unknown after - // loading purchased products — so .unknown is always transient here. - deviceAndWebStatus = .unknown - } - // Granted entitlements merge during resolution, from this base. - superwall.setSubscriptionStatus(base: deviceAndWebStatus) - } - /// Returns the subscription status of the user. /// /// Check the delegate function @@ -613,46 +420,6 @@ public final class Superwall: NSObject, ObservableObject { // MARK: - Value Resolution - private func resolvedSubscriptionStatus( - _ status: SubscriptionStatus, - granted: Set - ) -> SubscriptionStatus { - if let testModeManager = dependencyContainer.testModeManager, - testModeManager.isTestMode, - let override = testModeManager.overriddenSubscriptionStatus { - return override - } - var status = status - // Only active grants merge into the status, mirroring how web - // entitlements merge (EntitlementsInfo.web filters to active). - // Inactive grants still reach customerInfo for round-trip visibility. - let granted = Set(granted.filter(\.isActive)) - if !granted.isEmpty { - switch status { - case .active(let entitlements): - // mergePrioritized explicitly: plain Set.union dedupes by deep - // equality, which would keep both records for a shared ID. - status = .active( - Entitlement.mergePrioritized(Array(entitlements) + Array(granted)) - ) - case .inactive, .unknown: - // .unknown promotes to active, unlike web entitlements (see - // internallySetSubscriptionStatus). With an external purchase - // controller the developer is the only writer of the status, so - // .unknown can be terminal — without promotion, a developer relying - // solely on granted entitlements would be locked out forever. - status = .active(granted) - } - } - // This must run after the granted merge, or a developer-assigned - // .active([]) would collapse to .inactive before granted is applied. - if case .active(let entitlements) = status, - entitlements.isEmpty { - return .inactive - } - return status - } - private func resolvedCustomerInfo( _ info: CustomerInfo ) -> CustomerInfo { @@ -685,7 +452,7 @@ public final class Superwall: NSObject, ObservableObject { customerInfo = dependencyContainer.storage.get(LatestCustomerInfo.self) ?? .blank() - setSubscriptionStatus(base: dependencyContainer.storage.get(SubscriptionStatusKey.self) ?? .unknown) + setSubscriptionStatus(assigned: dependencyContainer.storage.get(SubscriptionStatusKey.self) ?? .unknown) dependencyContainer.entitlementsInfo.subscriptionStatusDidSet(subscriptionStatus) addListeners() @@ -783,49 +550,6 @@ public final class Superwall: NSObject, ObservableObject { )) } - func listenToSubscriptionStatus() { - // Listens to the resolved stream rather than $subscriptionStatus so the - // delegate and the status-change event never see the transient raw - // value that the public setter stores before resolution. - resolvedSubscriptionStatusSubject - .prepend(subscriptionStatus) - .removeDuplicates { $0.isLogicallyEqual(to: $1) } - .dropFirst() - .scan((previous: subscriptionStatus, current: subscriptionStatus)) { previousPair, newStatus in - // Shift the current value to previous, and set the new status as the current value - (previous: previousPair.current, current: newStatus) - } - .receive(on: DispatchQueue.main) - .subscribe( - Subscribers.Sink( - receiveCompletion: { _ in }, - receiveValue: { [weak self] statusPair in - guard let self = self else { - return - } - let oldStatus = statusPair.previous - let newStatus = statusPair.current - - Task { - await self.dependencyContainer.delegateAdapter.subscriptionStatusDidChange( - from: oldStatus, to: newStatus) - let event = InternalSuperwallEvent.SubscriptionStatusDidChange( - status: newStatus, - grantedEntitlements: self.grantedEntitlements - ) - await self.track(event) - } - Task { - let deviceAttributes = await self.dependencyContainer.makeSessionDeviceAttributes() - let deviceAttributesPlacement = InternalSuperwallEvent.DeviceAttributes( - deviceAttributes: deviceAttributes) - await self.track(deviceAttributesPlacement) - } - } - ) - ) - } - private func listenToCustomerInfo() { $customerInfo .removeDuplicates() diff --git a/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift b/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift index 0c7ae8313..b422adec9 100644 --- a/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift +++ b/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift @@ -78,10 +78,10 @@ final class TestModeTransactionHandler { if hasActiveEntitlements { let status = SubscriptionStatus.active(entitlementSet) testModeManager.overriddenSubscriptionStatus = status - Superwall.shared.setSubscriptionStatus(base: status) + Superwall.shared.setSubscriptionStatus(assigned: status) } else { testModeManager.overriddenSubscriptionStatus = .inactive - Superwall.shared.setSubscriptionStatus(base: .inactive) + Superwall.shared.setSubscriptionStatus(assigned: .inactive) } // Track free trial start if free trial is shown (respecting override) @@ -147,7 +147,7 @@ final class TestModeTransactionHandler { testModeManager.overriddenCustomerInfo = customerInfo Superwall.shared.customerInfo = customerInfo testModeManager.overriddenSubscriptionStatus = .inactive - Superwall.shared.setSubscriptionStatus(base: .inactive) + Superwall.shared.setSubscriptionStatus(assigned: .inactive) } else { // Update test mode manager with selected entitlement IDs let activeIds = Set(entitlements.map { $0.id }) @@ -164,10 +164,10 @@ final class TestModeTransactionHandler { if hasActive { let status = SubscriptionStatus.active(entitlements) testModeManager.overriddenSubscriptionStatus = status - Superwall.shared.setSubscriptionStatus(base: status) + Superwall.shared.setSubscriptionStatus(assigned: status) } else { testModeManager.overriddenSubscriptionStatus = .inactive - Superwall.shared.setSubscriptionStatus(base: .inactive) + Superwall.shared.setSubscriptionStatus(assigned: .inactive) } } diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 696acc8a7..e43a125ca 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -436,6 +436,7 @@ C7AB21123540550E513AD28A /* CoreDataManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D9CC1B947A08633E1C7BAE3 /* CoreDataManagerTests.swift */; }; C7E140466315324E9A1B9407 /* PermissionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEB7CF97D0926DCDAB133DB1 /* PermissionStatus.swift */; }; C80ACD3C05345709DAB248FD /* RestoreType.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAEBD34CFE62DCFE0AFD80D6 /* RestoreType.swift */; }; + C8540455BC520DAB75862A5D /* SubscriptionStatusPublishing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 469037ACFCAE3F46CF751E43 /* SubscriptionStatusPublishing.swift */; }; C8671043E6585DE19AAD1DAD /* PaywallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87AD727C5A8639E704F7BE98 /* PaywallView.swift */; }; C86B9D3992BBD1E8A1105F3C /* SuperwallDelegateObjc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33A9E0597972B90E3B9DFFE1 /* SuperwallDelegateObjc.swift */; }; C8BCF3C0404622139D002893 /* PushTransitionLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC74F16DC5A17489E97061EA /* PushTransitionLogic.swift */; }; @@ -748,6 +749,7 @@ 45B3BC4249A9E9BA8E99EC7C /* CustomCallbackRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCallbackRegistry.swift; sourceTree = ""; }; 460B6F98BADD9EC96A978E40 /* SWProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWProduct.swift; sourceTree = ""; }; 4634E3B868871DD24C2555F9 /* SWWebViewLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWWebViewLogic.swift; sourceTree = ""; }; + 469037ACFCAE3F46CF751E43 /* SubscriptionStatusPublishing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionStatusPublishing.swift; sourceTree = ""; }; 46D2598EB46E9A27E2BD5104 /* PreloadingDisabled.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreloadingDisabled.swift; sourceTree = ""; }; 4711FABAB250221629C47688 /* AppStoreProductTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreProductTests.swift; sourceTree = ""; }; 481D47E5121C521DDA268609 /* TriggerRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerRule.swift; sourceTree = ""; }; @@ -2508,6 +2510,7 @@ children = ( C6BB83F17D20143827C28042 /* EntitlementsInfo.swift */, E0A7F2B0E53BE42DC6B52873 /* EntitlementsStatus.swift */, + 469037ACFCAE3F46CF751E43 /* SubscriptionStatusPublishing.swift */, 0D4F1B49114819E9E6923508 /* Product Fetching */, E36E6C0CB40C3F44423C80CF /* Receipt Manager */, CB4191E8F35374E6FF55C5D0 /* StoreProduct */, @@ -3758,6 +3761,7 @@ 91BA5E01D0FB528954ABB937 /* StripeStoreProductDiscount.swift in Sources */, B60C3A3AD25CE2E2C513A2D2 /* Stubbable.swift in Sources */, C34A4AF2C8CD9ACBD2C370F8 /* SubscriptionPeriod.swift in Sources */, + C8540455BC520DAB75862A5D /* SubscriptionStatusPublishing.swift in Sources */, B0AD4A89AD5101360F93652D /* SubscriptionTransaction.swift in Sources */, CFEB0D797815E8EDFB059767 /* Superwall.swift in Sources */, 17C0F1960CD89B6BFA8B2FBB /* SuperwallDelegate.swift in Sources */, diff --git a/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift index 7f49761be..b3173d08f 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift @@ -158,7 +158,7 @@ final class GrantedEntitlementsTests { // MARK: - Clearing @Test - func clearingGranted_restoresBaseStatus() { + func clearingGranted_restoresAssignedStatus() { superwall.subscriptionStatus = .inactive superwall.grantedEntitlements = [grantedEntitlement()] #expect(superwall.subscriptionStatus.isActive) @@ -218,34 +218,34 @@ final class GrantedEntitlementsTests { // MARK: - Idempotence @Test - func reassigningResolvedStatus_isStable() { + func reassigningPublishedStatus_isStable() { superwall.subscriptionStatus = .active([deviceEntitlement(id: "premium")]) superwall.grantedEntitlements = [grantedEntitlement(id: "granted")] let resolved = superwall.subscriptionStatus superwall.subscriptionStatus = resolved - // resolve(resolve(x)) == resolve(x): re-resolving an already-merged - // value must settle, not loop or grow. + // merge(merge(x)) == merge(x): re-assigning an already-merged value + // must settle, not loop or grow. #expect(superwall.subscriptionStatus == resolved) } // MARK: - Persistence @Test - func persistedStatus_isUnresolvedBase() { + func persistedStatus_isAssignedValue() { superwall.grantedEntitlements = [grantedEntitlement()] superwall.subscriptionStatus = .inactive // The published status is merged, but the persisted one must be the - // unresolved base — otherwise granted entitlements are baked into the + // assigned value — otherwise granted entitlements are baked into the // restored value and can never be cleared after a relaunch. #expect(superwall.subscriptionStatus.isActive) #expect(dependencyContainer.storage.get(SubscriptionStatusKey.self) == .inactive) } @Test - func clearingGranted_afterRestore_restoresBaseStatus() { + func clearingGranted_afterRestore_restoresAssignedStatus() { superwall.grantedEntitlements = [grantedEntitlement()] superwall.subscriptionStatus = .inactive #expect(superwall.subscriptionStatus.isActive) @@ -263,13 +263,13 @@ final class GrantedEntitlementsTests { } @Test - func concurrentAssignments_neverPersistGrantedIntoBase() async { + func concurrentAssignments_neverPersistGrantedIntoAssignedStatus() async { superwall.grantedEntitlements = [grantedEntitlement()] // Race external assignments from many threads. Without the lock - // covering the property store, a store landing mid-resolution captures - // another thread's resolved write-back as its base, persisting granted - // entitlements into SubscriptionStatusKey. + // covering the property store, a store landing mid-publish captures + // another thread's merged write-back as its assigned status, persisting + // granted entitlements into SubscriptionStatusKey. await withTaskGroup(of: Void.self) { group in for index in 0..<50 { group.addTask { [superwall, deviceEnt = deviceEntitlement(id: "premium")] in @@ -280,15 +280,15 @@ final class GrantedEntitlementsTests { } } - // The persisted base must be exactly one of the assigned values — - // any merged base means a resolved write-back was captured. - let persistedBase = dependencyContainer.storage.get(SubscriptionStatusKey.self) + // The persisted status must be exactly one of the assigned values — + // any merged value means a write-back was captured as the assigned status. + let persistedStatus = dependencyContainer.storage.get(SubscriptionStatusKey.self) #expect( - persistedBase == .inactive - || persistedBase == .active([deviceEntitlement(id: "premium")]) + persistedStatus == .inactive + || persistedStatus == .active([deviceEntitlement(id: "premium")]) ) - // The in-memory base must be clean too: clearing the grant must leave - // a status without it. + // The in-memory assigned status must be clean too: clearing the grant + // must leave a status without it. superwall.grantedEntitlements = [] if case .active(let entitlements) = superwall.subscriptionStatus { #expect(entitlements.map(\.id) == ["premium"]) @@ -371,7 +371,7 @@ final class GrantedEntitlementsTests { // MARK: - Publishing @Test - func baseAssignment_publishesOnlyTheResolvedValue() { + func sdkAssignment_publishesOnlyTheMergedValue() { superwall.grantedEntitlements = [grantedEntitlement()] var published: [SubscriptionStatus] = [] @@ -380,9 +380,9 @@ final class GrantedEntitlementsTests { .sink { published.append($0) } defer { cancellable.cancel() } - superwall.setSubscriptionStatus(base: .active([deviceEntitlement(id: "premium")])) + superwall.setSubscriptionStatus(assigned: .active([deviceEntitlement(id: "premium")])) - // A single store of the merged value — never the raw base first. + // A single store of the merged value — never the raw assigned value first. #expect(published.count == 1) if case .active(let entitlements) = published.first { #expect(Set(entitlements.map(\.id)) == ["premium", "granted"]) @@ -402,10 +402,10 @@ final class GrantedEntitlementsTests { #expect(delegate.subscriptionStatusChanges.count == 1) // A device poll reporting no subscription, and a direct .inactive - // assignment, both resolve back to the granted status. The delegate + // assignment, both publish the granted status again. The delegate // must not hear about either — in particular it must never see the - // transient .inactive that the public setter stores before resolution. - superwall.setSubscriptionStatus(base: .inactive) + // transient .inactive that the public setter stores before merging. + superwall.setSubscriptionStatus(assigned: .inactive) superwall.subscriptionStatus = .inactive try? await Task.sleep(nanoseconds: 300_000_000) #expect(delegate.subscriptionStatusChanges.count == 1) From 9621c634004f37f98d23888a44246f9f5f7a11f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:52:03 +0200 Subject: [PATCH 053/162] review: have publishSubscriptionStatus take its own lock, pass the granted snapshot in The file split widened the pipeline to internal, so the lock precondition had become a doc comment. The lock is recursive, so publish acquiring it itself makes the invariant structural at no cost to callers that already hold it. Callers now pass the granted snapshot instead of publish re-reading storage under the lock. Co-Authored-By: Claude Fable 5.1 --- .../SubscriptionStatusPublishing.swift | 28 +++++++++++++------ Sources/SuperwallKit/Superwall.swift | 6 +++- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift index 7843027ed..6fbbb4415 100644 --- a/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift +++ b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift @@ -63,7 +63,11 @@ extension Superwall { ? "Granted entitlements cleared." : "Granted entitlements set: \(newValue.map(\.id).sorted().joined(separator: ", "))" ) - publishSubscriptionStatus(assigned: assignedSubscriptionStatus, isDeveloperAssignment: false) + publishSubscriptionStatus( + assigned: assignedSubscriptionStatus, + granted: newValue, + isDeveloperAssignment: false + ) refreshAutomaticCustomerInfoAfterGrantChange(granted: newValue) } } @@ -77,11 +81,11 @@ extension Superwall { /// public setter has to store the raw value before `didSet` can merge it, /// which publishes the raw value first. func setSubscriptionStatus(assigned status: SubscriptionStatus) { - subscriptionStatusLock.lock() - defer { - subscriptionStatusLock.unlock() - } - publishSubscriptionStatus(assigned: status, isDeveloperAssignment: false) + publishSubscriptionStatus( + assigned: status, + granted: grantedEntitlements, + isDeveloperAssignment: false + ) } /// Merges web entitlements into the device status and assigns the result, @@ -121,17 +125,23 @@ extension Superwall { /// Records `assigned`, publishes the merged status in a single store, /// persists the assigned value, and runs the side effects of a change. - /// Must be called with `subscriptionStatusLock` held. + /// + /// Takes `subscriptionStatusLock` itself; callers that already hold it + /// (the ``subscriptionStatus`` observers, the ``grantedEntitlements`` + /// setter) simply recurse. /// /// `isDeveloperAssignment` is `true` for writes through the public /// ``subscriptionStatus`` setter — the only path that warrants warning /// about granted entitlements overriding an `.inactive` assignment. func publishSubscriptionStatus( assigned: SubscriptionStatus, + granted: Set, isDeveloperAssignment: Bool ) { - // Snapshot once: each read hits storage under the lock. - let granted = grantedEntitlements + subscriptionStatusLock.lock() + defer { + subscriptionStatusLock.unlock() + } // The status publishes as active regardless, which otherwise looks // like the SDK ignored the assignment. diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 363149727..360c13120 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -233,7 +233,11 @@ public final class Superwall: NSObject, ObservableObject { // Our own write-back of the merged value. return } - publishSubscriptionStatus(assigned: subscriptionStatus, isDeveloperAssignment: true) + publishSubscriptionStatus( + assigned: subscriptionStatus, + granted: grantedEntitlements, + isDeveloperAssignment: true + ) } } From 9697d8a5c393a4539d432fa832b204babe8ae259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:03:54 +0200 Subject: [PATCH 054/162] review: snapshot granted entitlements inside the publish lock Passing the snapshot in as an argument evaluated the read before the callee locked, so on the setSubscriptionStatus(assigned:) path a concurrent grant write could be missed and the divergence wouldn't self-correct. publishSubscriptionStatus reads the snapshot itself, under its lock, so no caller can get it wrong. Co-Authored-By: Claude Fable 5.1 --- .../SubscriptionStatusPublishing.swift | 19 +++++++------------ Sources/SuperwallKit/Superwall.swift | 6 +----- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift index 6fbbb4415..d7b73763f 100644 --- a/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift +++ b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift @@ -63,11 +63,7 @@ extension Superwall { ? "Granted entitlements cleared." : "Granted entitlements set: \(newValue.map(\.id).sorted().joined(separator: ", "))" ) - publishSubscriptionStatus( - assigned: assignedSubscriptionStatus, - granted: newValue, - isDeveloperAssignment: false - ) + publishSubscriptionStatus(assigned: assignedSubscriptionStatus, isDeveloperAssignment: false) refreshAutomaticCustomerInfoAfterGrantChange(granted: newValue) } } @@ -81,11 +77,7 @@ extension Superwall { /// public setter has to store the raw value before `didSet` can merge it, /// which publishes the raw value first. func setSubscriptionStatus(assigned status: SubscriptionStatus) { - publishSubscriptionStatus( - assigned: status, - granted: grantedEntitlements, - isDeveloperAssignment: false - ) + publishSubscriptionStatus(assigned: status, isDeveloperAssignment: false) } /// Merges web entitlements into the device status and assigns the result, @@ -128,20 +120,23 @@ extension Superwall { /// /// Takes `subscriptionStatusLock` itself; callers that already hold it /// (the ``subscriptionStatus`` observers, the ``grantedEntitlements`` - /// setter) simply recurse. + /// setter) simply recurse. The granted snapshot is read here, under the + /// lock, so a concurrent grant write can't slip between the read and the + /// publish. /// /// `isDeveloperAssignment` is `true` for writes through the public /// ``subscriptionStatus`` setter — the only path that warrants warning /// about granted entitlements overriding an `.inactive` assignment. func publishSubscriptionStatus( assigned: SubscriptionStatus, - granted: Set, isDeveloperAssignment: Bool ) { subscriptionStatusLock.lock() defer { subscriptionStatusLock.unlock() } + // Snapshot once: the warning check and the merge both need it. + let granted = grantedEntitlements // The status publishes as active regardless, which otherwise looks // like the SDK ignored the assignment. diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 360c13120..363149727 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -233,11 +233,7 @@ public final class Superwall: NSObject, ObservableObject { // Our own write-back of the merged value. return } - publishSubscriptionStatus( - assigned: subscriptionStatus, - granted: grantedEntitlements, - isDeveloperAssignment: true - ) + publishSubscriptionStatus(assigned: subscriptionStatus, isDeveloperAssignment: true) } } From 6b95ed50d12b4f447b16e7e016d58113bb5f165c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:15:22 +0200 Subject: [PATCH 055/162] review: fix the 15 findings from the max-effort review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness: - The web-entitlement poll derived the status it assigned from the grant-merged customer info, baking grants into the assigned status so clearing them couldn't revoke. It now derives from device + web only, like the redeem path. - The device-sync hold gate read the published status, so a future-expiry non-App-Store grant could hold a lapsed App Store entitlement in place. It now reads the assigned status. - A developer read-modify-write of the published status handed the grants back as the developer's own. Developer assignments strip records identical to a current grant. - The public @Published setter stored the raw value before didSet could merge, which is what the re-entrancy flag, the willSet/didSet lock and the side subject were working around — and they still left a transient raw emission, a dropped/misclassified nested write, and subscriber callbacks running under the lock. subscriptionStatus is now backed by PublishedSubscriptionStatus, a property wrapper whose setter routes through publishSubscriptionStatus: only merged values are stored, the lock is released before emitting, and a nested assignment re-enters the pipeline. The flag, the observers and the subject are gone; $subscriptionStatus is now AnyPublisher. - merging(with:granting:) keeps the placeholder flag until real data arrives, so grants set before the receipt loads don't fake a loaded customer info. - Grants are merged once up front, so duplicate-ID grants collapse on the promotion path too; the one-shot warning keys on active grants; the test-mode deactivation reset carries the grants in its customer info; the granted_entitlement_ids comment says what the data can say. Efficiency / reuse: - EntitlementsInfo.granted holds the granted set in memory (loaded once, write-through); the cache doesn't memoize misses, so the six inline storage reads were a disk probe per publish for apps that never grant. - The prioritized Set.union overload is used directly; the comment claiming it didn't dedupe was wrong. - hasActiveCachedSubscription uses SubscriptionStatus.isActive. Tests run on an in-memory CacheMock via a new DependencyContainer cache: parameter, so grants can't leak into parallel suites; five new tests cover the wrapper's single emission on developer writes, re-entrant assignment, write-back revocability, duplicate-ID grants, and placeholder preservation. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- .../TrackableSuperwallEvent.swift | 5 +- .../SuperwallKit/Config/ConfigManager.swift | 16 +- .../Dependencies/DependencyContainer.swift | 5 +- .../Models/Customer Info/CustomerInfo.swift | 15 +- .../StoreKit/Products/EntitlementsInfo.swift | 27 +++ .../Receipt Manager/ReceiptManager.swift | 2 +- .../SubscriptionStatusPublishing.swift | 188 +++++++++++------- .../AutomaticPurchaseController.swift | 6 +- Sources/SuperwallKit/Superwall.swift | 72 +++---- .../Web/WebEntitlementRedeemer.swift | 28 +-- .../Products/GrantedEntitlementsTests.swift | 178 +++++++++++------ 12 files changed, 352 insertions(+), 192 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b5b4772d..feb061436 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 `grantedEntitlements` so you can grant entitlements from your own backend, which the SDK merges with device and web entitlements. +- Adds `grantedEntitlements` so you can grant entitlements from your own backend, which the SDK merges with device and web entitlements. The `subscriptionStatus` publisher now only ever emits the merged status. ### Fixes diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift index 67472ed6f..5cddc6b7d 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift @@ -334,8 +334,9 @@ enum InternalSuperwallEvent { params += [ "active_entitlement_ids": entitlements.map(\.id).joined(separator: ",") ] - // The active set has provenance merged away, so surface which of the - // active entitlements were developer-granted for debugging. + // The merged set carries no provenance, so this lists the active IDs + // that are also granted — a device record that beat a grant for the + // same ID is included too. let grantedIds = Set(grantedEntitlements.map(\.id)) let activeGrantedIds = entitlements .filter { $0.isActive && grantedIds.contains($0.id) } diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index 59c46c65c..49c448586 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -211,15 +211,13 @@ class ConfigManager { if storage.get(IsTestModeActiveSubscription.self) ?? false { return false } - // The persisted status can hold .active with no active entitlement - // (e.g. a developer-assigned .active([])) — inspect the set rather - // than pattern-matching the case alone. - if case .active(let entitlements) = storage.get(SubscriptionStatusKey.self), - entitlements.contains(where: { $0.isActive }) { + // isActive rather than a bare `case .active` match: the persisted status + // can be .active with no active entitlement (e.g. a developer-assigned + // .active([])). + if storage.get(SubscriptionStatusKey.self)?.isActive == true { return true } - let grantedEntitlements = storage.get(GrantedEntitlements.self) ?? [] - return grantedEntitlements.contains { $0.isActive } + return entitlementsInfo.granted.contains { $0.isActive } } private struct ConfigFetchResult { @@ -417,11 +415,13 @@ class ConfigManager { if testModeJustDeactivated, !factory.makeHasExternalPurchaseController() { Superwall.shared.setSubscriptionStatus(assigned: .inactive) + // Granted entitlements survive test mode, so the customer info has + // to keep carrying them while the status does. Superwall.shared.customerInfo = CustomerInfo( subscriptions: [], nonSubscriptions: [], entitlements: [] - ) + ).merging(with: .blank(), granting: entitlementsInfo.granted) } await factory.loadPurchasedProducts(config: config) } diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index ba9622719..f03c3aef5 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -51,10 +51,11 @@ final class DependencyContainer { init( apiKey: String = "", purchaseController controller: PurchaseController? = nil, - options: SuperwallOptions? = nil + options: SuperwallOptions? = nil, + cache: Cache = Cache() ) { delegateAdapter = SuperwallDelegateAdapter() - storage = Storage(factory: self) + storage = Storage(factory: self, cache: cache) storage.configure(apiKey: apiKey) entitlementsInfo = EntitlementsInfo( storage: storage, diff --git a/Sources/SuperwallKit/Models/Customer Info/CustomerInfo.swift b/Sources/SuperwallKit/Models/Customer Info/CustomerInfo.swift index 936e637d6..bbc794dea 100644 --- a/Sources/SuperwallKit/Models/Customer Info/CustomerInfo.swift +++ b/Sources/SuperwallKit/Models/Customer Info/CustomerInfo.swift @@ -169,11 +169,14 @@ public final class CustomerInfo: NSObject, Codable { self.entitlements + webCustomerInfo.entitlements + Array(grantedEntitlements) let mergedEntitlements = Entitlement.mergePrioritized(combinedEntitlements) - // Return merged CustomerInfo with sorted transactions and entitlements + // Return merged CustomerInfo with sorted transactions and entitlements. + // It stays a placeholder until real data has arrived from either side — + // granted entitlements alone don't mean the device has been read. return CustomerInfo( subscriptions: mergedSubscriptions.sorted { $0.purchaseDate < $1.purchaseDate }, nonSubscriptions: mergedNonSubscriptions.sorted { $0.purchaseDate < $1.purchaseDate }, - entitlements: mergedEntitlements.sorted { $0.id < $1.id } + entitlements: mergedEntitlements.sorted { $0.id < $1.id }, + isPlaceholder: isPlaceholder && webCustomerInfo.isPlaceholder ) } @@ -227,7 +230,8 @@ public final class CustomerInfo: NSObject, Codable { /// - Returns: A new CustomerInfo with all sources merged static func forExternalPurchaseController( storage: Storage, - subscriptionStatus: SubscriptionStatus + subscriptionStatus: SubscriptionStatus, + granted grantedEntitlements: Set ) -> CustomerInfo { // Get web CustomerInfo let webCustomerInfo = storage.get(LatestRedeemResponse.self)?.customerInfo ?? .blank() @@ -254,13 +258,12 @@ public final class CustomerInfo: NSObject, Codable { externalEntitlements = [] } - // Developer-granted entitlements are read from their own storage rather - // than recovered from subscriptionStatus — that's an already-merged value + // Developer-granted entitlements come in as their own source rather than + // recovered from subscriptionStatus — that's an already-merged value // where sources are no longer distinguishable. They also can't ride in via // the appStore filter above: widening it to admit them would resurrect // revoked web entitlements, whose revocation is signalled only by their // absence from webCustomerInfo. - let grantedEntitlements = storage.get(GrantedEntitlements.self) ?? [] // Merge: active from external controller + all web + inactive device + granted // This gives us complete history while respecting external controller as source of truth for active status diff --git a/Sources/SuperwallKit/StoreKit/Products/EntitlementsInfo.swift b/Sources/SuperwallKit/StoreKit/Products/EntitlementsInfo.swift index 18075dee5..f01443278 100644 --- a/Sources/SuperwallKit/StoreKit/Products/EntitlementsInfo.swift +++ b/Sources/SuperwallKit/StoreKit/Products/EntitlementsInfo.swift @@ -51,6 +51,30 @@ public final class EntitlementsInfo: NSObject, ObservableObject, @unchecked Send return Set(entitlements) } + /// The entitlements granted via ``Superwall/grantedEntitlements``. + /// + /// Loaded from storage once and kept in memory: it's read on every status + /// publish, and a storage miss — the common case, since most apps never + /// grant — isn't memoized by the cache, so reading through would hit disk + /// every time. + var granted: Set { + return queue.sync { + if let granted = backingGranted { + return granted + } + let granted = storage.get(GrantedEntitlements.self) ?? [] + backingGranted = granted + return granted + } + } + + func setGranted(_ granted: Set) { + queue.sync { + backingGranted = granted + storage.save(granted, forType: GrantedEntitlements.self) + } + } + // MARK: - Internal vars /// The entitlements that belong to each product ID. var entitlementsByProductId: [String: Set] = [:] { @@ -64,6 +88,9 @@ public final class EntitlementsInfo: NSObject, ObservableObject, @unchecked Send /// The backing variable for ``EntitlementsInfo/active``. private var backingActive: Set = [] + /// The backing variable for `granted`; `nil` until first read. + private var backingGranted: Set? + /// The backing variable for ``EntitlementsInfo/all``. private var backingAll: Set = [] diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift index 23e702dca..86419a994 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift @@ -189,7 +189,7 @@ actor ReceiptManager { // The other sources that merge with the device snapshot. let webCustomerInfo = storage.get(LatestRedeemResponse.self)?.customerInfo - let grantedEntitlements = storage.get(GrantedEntitlements.self) ?? [] + let grantedEntitlements = Superwall.shared.entitlements.granted let mergedCustomerInfo: CustomerInfo if factory.makeHasExternalPurchaseController() { diff --git a/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift index d7b73763f..c91d1f086 100644 --- a/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift +++ b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift @@ -23,9 +23,10 @@ import Foundation /// The assigned value is kept because the published one can't be un-merged: /// clearing granted entitlements recomputes the published status from it. /// -/// Every writer ends up in `publishSubscriptionStatus`, which runs under -/// `subscriptionStatusLock` so assignments from different threads can't -/// interleave with the write-back of the merged value. +/// Every writer — the public setter included, via ``PublishedSubscriptionStatus`` +/// — ends up in `publishSubscriptionStatus`, the one critical section under +/// `subscriptionStatusLock`. The merged value is stored under the lock and +/// emitted to subscribers after it's released. extension Superwall { // MARK: - Granted entitlements @@ -48,34 +49,29 @@ extension Superwall { /// previous user's granted entitlements. public var grantedEntitlements: Set { get { - return dependencyContainer.storage.get(GrantedEntitlements.self) ?? [] + return entitlements.granted } set { - subscriptionStatusLock.lock() - defer { - subscriptionStatusLock.unlock() - } - dependencyContainer.storage.save(newValue, forType: GrantedEntitlements.self) + entitlements.setGranted(newValue) Logger.debug( - logLevel: .info, + logLevel: .debug, scope: .grantedEntitlements, message: newValue.isEmpty ? "Granted entitlements cleared." : "Granted entitlements set: \(newValue.map(\.id).sorted().joined(separator: ", "))" ) - publishSubscriptionStatus(assigned: assignedSubscriptionStatus, isDeveloperAssignment: false) - refreshAutomaticCustomerInfoAfterGrantChange(granted: newValue) + publishSubscriptionStatus(assigned: nil, isDeveloperAssignment: false) + refreshAutomaticCustomerInfoAfterGrantChange() } } // MARK: - Assigning - /// Assigns a new status and publishes the merged result in a single - /// store, so subscribers never observe the un-merged value. + /// Assigns a new status and publishes the merged result. /// - /// SDK writers use this rather than assigning ``subscriptionStatus``: the - /// public setter has to store the raw value before `didSet` can merge it, - /// which publishes the raw value first. + /// SDK writers use this rather than assigning ``subscriptionStatus``, which + /// is the developer's entry point and gets the developer-assignment + /// treatment described on `publishSubscriptionStatus`. func setSubscriptionStatus(assigned status: SubscriptionStatus) { publishSubscriptionStatus(assigned: status, isDeveloperAssignment: false) } @@ -115,53 +111,59 @@ extension Superwall { // MARK: - Publishing - /// Records `assigned`, publishes the merged status in a single store, - /// persists the assigned value, and runs the side effects of a change. + /// Records the assigned status, stores the merged status and persists the + /// assigned value under the lock, then emits the merged status and runs the + /// remaining side effects outside it. /// - /// Takes `subscriptionStatusLock` itself; callers that already hold it - /// (the ``subscriptionStatus`` observers, the ``grantedEntitlements`` - /// setter) simply recurse. The granted snapshot is read here, under the - /// lock, so a concurrent grant write can't slip between the read and the - /// publish. + /// Pass `nil` as `assigned` to re-publish from the current assigned status + /// — after a grant change — which is then read under the lock. /// /// `isDeveloperAssignment` is `true` for writes through the public - /// ``subscriptionStatus`` setter — the only path that warrants warning - /// about granted entitlements overriding an `.inactive` assignment. + /// ``subscriptionStatus`` setter, which get two extra treatments: records + /// identical to a current grant are stripped, because a developer who reads + /// the published status and writes it back would otherwise hand the grants + /// back as their own and clearing them could never revoke; and an + /// `.inactive` assignment while active grants exist logs a one-time + /// warning, since the status publishes as active and otherwise looks + /// ignored. func publishSubscriptionStatus( - assigned: SubscriptionStatus, + assigned newAssigned: SubscriptionStatus?, isDeveloperAssignment: Bool ) { subscriptionStatusLock.lock() - defer { - subscriptionStatusLock.unlock() - } - // Snapshot once: the warning check and the merge both need it. - let granted = grantedEntitlements - // The status publishes as active regardless, which otherwise looks - // like the SDK ignored the assignment. - if isDeveloperAssignment, - case .inactive = assigned, - !granted.isEmpty, - !hasLoggedGrantedEntitlementsWarning { - hasLoggedGrantedEntitlementsWarning = true - Logger.debug( - logLevel: .warn, - scope: .grantedEntitlements, - message: "subscriptionStatus was set to .inactive but grantedEntitlements " - + "is not empty, so the status remains active. Assigning subscriptionStatus " - + "doesn't clear granted entitlements — set grantedEntitlements to an empty set to revoke them." - ) + // Snapshot once, under the lock, so a concurrent grant write can't slip + // between the read and the store. Active grants are merged here so two + // grants sharing an ID collapse to one record on every path. + let granted = entitlements.granted + let activeGrants = Entitlement.mergePrioritized(Array(granted.filter(\.isActive))) + + var assigned = newAssigned ?? assignedSubscriptionStatus + if isDeveloperAssignment { + if case .active(let assignedEntitlements) = assigned { + assigned = .active(assignedEntitlements.subtracting(granted)) + } + if case .inactive = assigned, + !activeGrants.isEmpty, + !hasLoggedGrantedEntitlementsWarning { + hasLoggedGrantedEntitlementsWarning = true + Logger.debug( + logLevel: .warn, + scope: .grantedEntitlements, + message: "subscriptionStatus was set to .inactive but grantedEntitlements " + + "is not empty, so the status remains active. Assigning subscriptionStatus " + + "doesn't clear granted entitlements — set grantedEntitlements to an empty set to revoke them." + ) + } } let previouslyAssigned = assignedSubscriptionStatus assignedSubscriptionStatus = assigned - let merged = mergedSubscriptionStatus(assigned: assigned, granted: granted) - if merged != subscriptionStatus { - isPublishingSubscriptionStatus = true - subscriptionStatus = merged - isPublishingSubscriptionStatus = false + let merged = mergedSubscriptionStatus(assigned: assigned, activeGrants: activeGrants) + let statusChanged = merged != subscriptionStatus + if statusChanged { + storeMergedSubscriptionStatus(merged) } // Persist the assigned value, not the merged one: restoring a merged @@ -174,6 +176,13 @@ extension Superwall { if previouslyAssigned != assigned { dependencyContainer.storage.save(assigned, forType: SubscriptionStatusKey.self) } + subscriptionStatusLock.unlock() + + // Subscribers and customer info observers run outside the lock, so a + // subscriber that blocks on another thread can't deadlock a writer. + if statusChanged { + emitSubscriptionStatus() + } entitlements.subscriptionStatusDidSet(subscriptionStatus) // When using an external purchase controller, update CustomerInfo.entitlements @@ -183,10 +192,10 @@ extension Superwall { dependencyContainer.testModeManager?.isTestMode != true { customerInfo = CustomerInfo.forExternalPurchaseController( storage: dependencyContainer.storage, - subscriptionStatus: subscriptionStatus + subscriptionStatus: subscriptionStatus, + granted: granted ) } - publishedSubscriptionStatusSubject.send(subscriptionStatus) } /// The status the SDK reports for `assigned`: active granted entitlements @@ -194,7 +203,7 @@ extension Superwall { /// collapsed to `.inactive`. private func mergedSubscriptionStatus( assigned: SubscriptionStatus, - granted: Set + activeGrants: Set ) -> SubscriptionStatus { if let testModeManager = dependencyContainer.testModeManager, testModeManager.isTestMode, @@ -205,15 +214,11 @@ extension Superwall { // Only active grants merge into the status, mirroring how web // entitlements merge (EntitlementsInfo.web filters to active). // Inactive grants still reach customerInfo for round-trip visibility. - let activeGrants = Set(granted.filter(\.isActive)) if !activeGrants.isEmpty { switch status { case .active(let entitlements): - // mergePrioritized explicitly: plain Set.union dedupes by deep - // equality, which would keep both records for a shared ID. - status = .active( - Entitlement.mergePrioritized(Array(entitlements) + Array(activeGrants)) - ) + // Set.union merges by priority, keeping one record per ID. + status = .active(entitlements.union(activeGrants)) case .inactive, .unknown: // .unknown promotes to active, unlike web entitlements (see // internallySetSubscriptionStatus). With an external purchase @@ -237,7 +242,7 @@ extension Superwall { /// actually disappear. The external purchase controller path is recomputed /// inside `publishSubscriptionStatus`; test mode manages its own customer /// info. - private func refreshAutomaticCustomerInfoAfterGrantChange(granted: Set) { + private func refreshAutomaticCustomerInfoAfterGrantChange() { if dependencyContainer.makeHasExternalPurchaseController() { return } @@ -247,17 +252,13 @@ extension Superwall { let storage: Storage = dependencyContainer.storage let deviceCustomerInfo = storage.get(LatestDeviceCustomerInfo.self) ?? .blank() let webCustomerInfo = storage.get(LatestRedeemResponse.self)?.customerInfo ?? .blank() - customerInfo = deviceCustomerInfo.merging(with: webCustomerInfo, granting: granted) + customerInfo = deviceCustomerInfo.merging(with: webCustomerInfo, granting: entitlements.granted) } // MARK: - Listening func listenToSubscriptionStatus() { - // Listens to the published stream rather than $subscriptionStatus so the - // delegate and the status-change event never see the transient raw - // value that the public setter stores before merging. - publishedSubscriptionStatusSubject - .prepend(subscriptionStatus) + $subscriptionStatus .removeDuplicates { $0.isLogicallyEqual(to: $1) } .dropFirst() .scan((previous: subscriptionStatus, current: subscriptionStatus)) { previousPair, newStatus in @@ -295,3 +296,56 @@ extension Superwall { ) } } + +// MARK: - Property wrapper + +/// The property wrapper behind ``Superwall/subscriptionStatus``. +/// +/// Every assignment is routed through `publishSubscriptionStatus`, so the +/// stored and published value is always the merged one — subscribers never +/// see the value a writer assigned before granted entitlements and test-mode +/// overrides were applied. The projected value (`$subscriptionStatus`) replays +/// the current value to new subscribers, like `@Published`. +@propertyWrapper +public struct PublishedSubscriptionStatus { + private var storage: SubscriptionStatus + private let subject: CurrentValueSubject + + public init(wrappedValue: SubscriptionStatus) { + storage = wrappedValue + subject = CurrentValueSubject(wrappedValue) + } + + @available(*, unavailable, message: "Only available on Superwall.") + public var wrappedValue: SubscriptionStatus { + get { fatalError("subscriptionStatus is only readable on Superwall.") } + set { fatalError("\(newValue) can only be assigned on Superwall.") } + } + + public var projectedValue: AnyPublisher { + return subject.eraseToAnyPublisher() + } + + public static subscript( + _enclosingInstance superwall: Superwall, + wrapped wrappedKeyPath: ReferenceWritableKeyPath, + storage storageKeyPath: ReferenceWritableKeyPath + ) -> SubscriptionStatus { + get { + return superwall[keyPath: storageKeyPath].storage + } + set { + superwall.publishSubscriptionStatus(assigned: newValue, isDeveloperAssignment: true) + } + } + + /// Stores a merged status without emitting it. + mutating func store(_ merged: SubscriptionStatus) { + storage = merged + } + + /// Emits a stored status to subscribers. + func emit(_ status: SubscriptionStatus) { + subject.send(status) + } +} diff --git a/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift b/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift index d0fbf574d..7ff8cf7c7 100644 --- a/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift +++ b/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift @@ -62,7 +62,11 @@ final class AutomaticPurchaseController { // device-derived entitlements always carry it), so nil is protected // too. Entitlements with no expiry date never hold the status, so a // revoked lifetime purchase can still deactivate here. - if case .active(let currentEntitlements) = superwall.subscriptionStatus { + // + // The check reads the assigned status (device + web), not the + // published one: that also carries developer-granted entitlements, + // which would hold a lapsed App Store entitlement in place. + if case .active(let currentEntitlements) = superwall.assignedSubscriptionStatus { let holdsStatus = currentEntitlements.contains { entitlement in guard entitlement.isActive, (entitlement.expiresAt ?? .distantPast) > Date() else { diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 363149727..5e1ae27b6 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -206,7 +206,10 @@ public final class Superwall: NSObject, ObservableObject { /// `PurchaseController`, you must set this. /// /// If you're using Combine or SwiftUI, you can subscribe or bind to it to get - /// notified whenever it changes. + /// notified whenever it changes. The publisher only ever emits the merged + /// status — never a value you assigned before granted entitlements were + /// applied. Subscribers are called synchronously on the thread that changed + /// the status, so don't block in them. /// /// Otherwise, you can check the delegate function /// ``SuperwallDelegate/subscriptionStatusDidChange(from:to:)`` @@ -217,29 +220,13 @@ public final class Superwall: NSObject, ObservableObject { /// them — the status stays active for as long as ``grantedEntitlements`` /// contains an active entitlement. To revoke them, set /// ``grantedEntitlements`` to an empty set. - @Published - public var subscriptionStatus: SubscriptionStatus = .unknown { - willSet { - // Locked before the store so the store and the publish in didSet are - // atomic across threads — see SubscriptionStatusPublishing.swift. - // Released at the end of didSet; the pair must stay balanced. - subscriptionStatusLock.lock() - } - didSet { - defer { - subscriptionStatusLock.unlock() - } - if isPublishingSubscriptionStatus { - // Our own write-back of the merged value. - return - } - publishSubscriptionStatus(assigned: subscriptionStatus, isDeveloperAssignment: true) - } - } + @PublishedSubscriptionStatus + public var subscriptionStatus: SubscriptionStatus = .unknown // MARK: - Subscription status state // The publishing pipeline lives in SubscriptionStatusPublishing.swift; the - // stored state it needs has to live in the class. + // stored state it needs, and the two accessors of the wrapper's private + // storage, have to live in the class. /// The status last handed to the SDK — device + web entitlements on the /// automatic path, or the developer's value with a purchase controller — @@ -248,25 +235,42 @@ public final class Superwall: NSObject, ObservableObject { /// so clearing granted entitlements can recompute it. var assignedSubscriptionStatus: SubscriptionStatus = .unknown - /// Serializes status assignment and publishing across threads. Recursive - /// because publishing re-enters the ``subscriptionStatus`` observers on - /// the same thread. + /// Serializes status assignment and publishing across threads. Recursive so + /// a subscriber that assigns the status from within an emission re-enters + /// the pipeline instead of deadlocking. let subscriptionStatusLock = NSRecursiveLock() - /// Set while `publishSubscriptionStatus` writes the merged value back, so - /// `didSet` doesn't treat that store as a new assignment. Only touched - /// with `subscriptionStatusLock` held. - var isPublishingSubscriptionStatus = false - - /// Emits the merged status once per assignment or grant change, for the - /// status listener. `$subscriptionStatus` also emits the raw value the - /// public setter stores before merging, which must not reach the delegate. - let publishedSubscriptionStatusSubject = PassthroughSubject() - /// Whether the one-time warning about granted entitlements overriding an /// `.inactive` assignment has been logged. var hasLoggedGrantedEntitlementsWarning = false + /// Stores the merged status. Only `publishSubscriptionStatus` calls this, + /// with `subscriptionStatusLock` held; the emission follows in + /// `emitSubscriptionStatus()` once the lock is released. + func storeMergedSubscriptionStatus(_ merged: SubscriptionStatus) { + objectWillChange.send() + _subscriptionStatus.store(merged) + } + + /// Emits the stored status to `$subscriptionStatus` subscribers. Called + /// after the lock is released, so subscribers never run inside the SDK's + /// critical section. A store that landed on another thread between the + /// read and the emission is emitted again afterwards, so the publisher's + /// current value can't end up behind the stored one. + func emitSubscriptionStatus() { + subscriptionStatusLock.lock() + let emitted = subscriptionStatus + subscriptionStatusLock.unlock() + _subscriptionStatus.emit(emitted) + + subscriptionStatusLock.lock() + let newest = subscriptionStatus + subscriptionStatusLock.unlock() + if newest != emitted { + _subscriptionStatus.emit(newest) + } + } + /// Contains the latest information about all of the customer's purchase and subscription data. /// /// This is a published property, so you can subscribe to it to receive updates when it changes. Alternatively, diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index ef40b363a..692338d81 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -522,14 +522,14 @@ actor WebEntitlementRedeemer { let subscriptionStatus = await MainActor.run { superwall.subscriptionStatus } mergedCustomerInfo = CustomerInfo.forExternalPurchaseController( storage: storage, - subscriptionStatus: subscriptionStatus + subscriptionStatus: subscriptionStatus, + granted: entitlementsInfo.granted ) } else { let deviceCustomerInfo = storage.get(LatestDeviceCustomerInfo.self) ?? .blank() - let grantedEntitlements = storage.get(GrantedEntitlements.self) ?? [] mergedCustomerInfo = deviceCustomerInfo.merging( with: webCustomerInfo, - granting: grantedEntitlements + granting: entitlementsInfo.granted ) } @@ -978,20 +978,22 @@ actor WebEntitlementRedeemer { return } - let mergedCustomerInfo = await mergeAndApplyCustomerInfo( + _ = await mergeAndApplyCustomerInfo( webCustomerInfo: response.customerInfo, superwall: superwall ) - let activeEntitlements = Set(mergedCustomerInfo.entitlements.filter { $0.isActive }) - if activeEntitlements.isEmpty { - await superwall.internallySetSubscriptionStatus(to: .inactive, superwall: superwall) - } else { - await superwall.internallySetSubscriptionStatus( - to: .active(activeEntitlements), - superwall: superwall - ) - } + // Derive the status from device + web only, as the redeem path does. + // The merged customer info also carries granted entitlements, which + // are applied when the status publishes and must never enter the + // assigned status — or clearing them could never revoke them. + let deviceCustomerInfo = storage.get(LatestDeviceCustomerInfo.self) ?? .blank() + let activeDeviceEntitlements = Set(deviceCustomerInfo.entitlements.filter { $0.isActive }) + let deviceAndWebEntitlements = activeDeviceEntitlements.union( + Set(response.customerInfo.entitlements) + ) + let activeEntitlements = deviceAndWebEntitlements.filter { $0.isActive } + await updateSubscriptionStatus(with: deviceAndWebEntitlements, superwall: superwall) // If there's a paywall, check if we should dismiss it if let paywallVc = superwall.paywallViewController { diff --git a/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift index b3173d08f..ac5d967e2 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift @@ -17,20 +17,12 @@ final class GrantedEntitlementsTests { private let superwall: Superwall init() { - dependencyContainer = DependencyContainer() + // In-memory storage, so nothing this suite writes reaches the on-disk + // store shared with other suites. GrantedEntitlements is app-specific + // and read on every status publish, so a file left behind mid-test would + // leak into suites running in parallel. + dependencyContainer = DependencyContainer(cache: CacheMock()) superwall = Superwall(dependencyContainer: dependencyContainer) - cleanStorage() - } - - deinit { - cleanStorage() - } - - private func cleanStorage() { - dependencyContainer.storage.delete(GrantedEntitlements.self) - dependencyContainer.storage.delete(SubscriptionStatusKey.self) - dependencyContainer.storage.delete(LatestRedeemResponse.self) - dependencyContainer.storage.delete(LatestDeviceCustomerInfo.self) } // MARK: - Helpers @@ -38,12 +30,14 @@ final class GrantedEntitlementsTests { /// A bare developer-granted entitlement: no transaction history, no store. private func grantedEntitlement( id: String = "granted", - isActive: Bool = true + isActive: Bool = true, + expiresAt: Date? = nil ) -> Entitlement { return Entitlement( id: id, type: .serviceLevel, - isActive: isActive + isActive: isActive, + expiresAt: expiresAt ) } @@ -67,6 +61,13 @@ final class GrantedEntitlementsTests { ) } + private func activeIds(_ status: SubscriptionStatus) -> Set? { + if case .active(let entitlements) = status { + return Set(entitlements.map(\.id)) + } + return nil + } + // MARK: - Merging @Test @@ -75,11 +76,7 @@ final class GrantedEntitlementsTests { superwall.grantedEntitlements = [grantedEntitlement()] #expect(superwall.subscriptionStatus.isActive) - if case .active(let entitlements) = superwall.subscriptionStatus { - #expect(entitlements.map(\.id) == ["granted"]) - } else { - Issue.record("Expected .active status") - } + #expect(activeIds(superwall.subscriptionStatus) == ["granted"]) } @Test @@ -87,11 +84,7 @@ final class GrantedEntitlementsTests { superwall.subscriptionStatus = .active([deviceEntitlement(id: "premium")]) superwall.grantedEntitlements = [grantedEntitlement(id: "granted")] - if case .active(let entitlements) = superwall.subscriptionStatus { - #expect(Set(entitlements.map(\.id)) == ["premium", "granted"]) - } else { - Issue.record("Expected .active status") - } + #expect(activeIds(superwall.subscriptionStatus) == ["premium", "granted"]) } @Test @@ -117,11 +110,7 @@ final class GrantedEntitlementsTests { superwall.subscriptionStatus = .active([deviceEntitlement(id: "premium")]) superwall.grantedEntitlements = [grantedEntitlement(id: "extra", isActive: false)] - if case .active(let entitlements) = superwall.subscriptionStatus { - #expect(entitlements.map(\.id) == ["premium"]) - } else { - Issue.record("Expected .active status") - } + #expect(activeIds(superwall.subscriptionStatus) == ["premium"]) } @Test @@ -139,10 +128,7 @@ final class GrantedEntitlementsTests { superwall.subscriptionStatus = .inactive - #expect(superwall.subscriptionStatus.isActive) - if case .active(let entitlements) = superwall.subscriptionStatus { - #expect(entitlements.map(\.id) == ["granted"]) - } + #expect(activeIds(superwall.subscriptionStatus) == ["granted"]) } @Test @@ -155,6 +141,23 @@ final class GrantedEntitlementsTests { #expect(superwall.subscriptionStatus.isActive) } + @Test + func grantsSharingAnId_publishOneRecord() { + superwall.subscriptionStatus = .inactive + superwall.grantedEntitlements = [ + grantedEntitlement(id: "pro", expiresAt: Date(timeIntervalSince1970: 1_800_000_000)), + grantedEntitlement(id: "pro", expiresAt: Date(timeIntervalSince1970: 1_900_000_000)) + ] + + if case .active(let entitlements) = superwall.subscriptionStatus { + #expect(entitlements.count == 1) + // The later expiry wins the merge. + #expect(entitlements.first?.expiresAt == Date(timeIntervalSince1970: 1_900_000_000)) + } else { + Issue.record("Expected .active status") + } + } + // MARK: - Clearing @Test @@ -174,11 +177,25 @@ final class GrantedEntitlementsTests { superwall.grantedEntitlements = [grantedEntitlement(id: "a")] superwall.grantedEntitlements = [grantedEntitlement(id: "b")] - if case .active(let entitlements) = superwall.subscriptionStatus { - #expect(entitlements.map(\.id) == ["b"]) - } else { - Issue.record("Expected .active status") - } + #expect(activeIds(superwall.subscriptionStatus) == ["b"]) + } + + @Test + func writingBackThePublishedStatus_keepsGrantsRevocable() { + superwall.subscriptionStatus = .active([deviceEntitlement(id: "premium")]) + superwall.grantedEntitlements = [grantedEntitlement()] + + // A read-modify-write of the published, grant-merged status must not + // hand the grant back as the developer's own. + superwall.subscriptionStatus = superwall.subscriptionStatus + #expect( + dependencyContainer.storage.get(SubscriptionStatusKey.self) + == .active([deviceEntitlement(id: "premium")]) + ) + + superwall.grantedEntitlements = [] + + #expect(activeIds(superwall.subscriptionStatus) == ["premium"]) } // MARK: - Merge precedence @@ -222,12 +239,12 @@ final class GrantedEntitlementsTests { superwall.subscriptionStatus = .active([deviceEntitlement(id: "premium")]) superwall.grantedEntitlements = [grantedEntitlement(id: "granted")] - let resolved = superwall.subscriptionStatus - superwall.subscriptionStatus = resolved + let published = superwall.subscriptionStatus + superwall.subscriptionStatus = published // merge(merge(x)) == merge(x): re-assigning an already-merged value // must settle, not loop or grow. - #expect(superwall.subscriptionStatus == resolved) + #expect(superwall.subscriptionStatus == published) } // MARK: - Persistence @@ -253,8 +270,9 @@ final class GrantedEntitlementsTests { // Simulate a relaunch: a fresh instance restores the persisted status, // exactly as Superwall's configure init does. let relaunched = Superwall(dependencyContainer: dependencyContainer) - relaunched.subscriptionStatus = - dependencyContainer.storage.get(SubscriptionStatusKey.self) ?? .unknown + relaunched.setSubscriptionStatus( + assigned: dependencyContainer.storage.get(SubscriptionStatusKey.self) ?? .unknown + ) #expect(relaunched.subscriptionStatus.isActive) relaunched.grantedEntitlements = [] @@ -266,10 +284,10 @@ final class GrantedEntitlementsTests { func concurrentAssignments_neverPersistGrantedIntoAssignedStatus() async { superwall.grantedEntitlements = [grantedEntitlement()] - // Race external assignments from many threads. Without the lock - // covering the property store, a store landing mid-publish captures - // another thread's merged write-back as its assigned status, persisting - // granted entitlements into SubscriptionStatusKey. + // Race external assignments from many threads. Without the lock, a store + // landing mid-publish could capture another thread's merged write-back + // as its assigned status, persisting granted entitlements into + // SubscriptionStatusKey. await withTaskGroup(of: Void.self) { group in for index in 0..<50 { group.addTask { [superwall, deviceEnt = deviceEntitlement(id: "premium")] in @@ -312,7 +330,8 @@ final class GrantedEntitlementsTests { let customerInfo = CustomerInfo.forExternalPurchaseController( storage: dependencyContainer.storage, - subscriptionStatus: .inactive + subscriptionStatus: .inactive, + granted: superwall.grantedEntitlements ) #expect(customerInfo.entitlements.contains { $0.id == "granted" && $0.isActive }) @@ -355,6 +374,18 @@ final class GrantedEntitlementsTests { #expect(!superwall.customerInfo.entitlements.contains { $0.id == "granted" }) } + @Test + func grantsBeforeTheDeviceSnapshot_keepCustomerInfoAsPlaceholder() { + #expect(superwall.customerInfo.isPlaceholder) + + superwall.grantedEntitlements = [grantedEntitlement()] + + // Grants alone don't mean the device has been read, so consumers that + // wait for real data keep waiting. + #expect(superwall.customerInfo.isPlaceholder) + #expect(superwall.customerInfo.entitlements.map(\.id) == ["granted"]) + } + @Test func merging_treatsGrantedAsASource() { let device = CustomerInfo( @@ -366,6 +397,7 @@ final class GrantedEntitlementsTests { let merged = device.merging(with: .blank(), granting: [grantedEntitlement()]) #expect(merged.entitlements.map(\.id) == ["granted", "premium"]) + #expect(!merged.isPlaceholder) } // MARK: - Publishing @@ -382,13 +414,46 @@ final class GrantedEntitlementsTests { superwall.setSubscriptionStatus(assigned: .active([deviceEntitlement(id: "premium")])) - // A single store of the merged value — never the raw assigned value first. #expect(published.count == 1) - if case .active(let entitlements) = published.first { - #expect(Set(entitlements.map(\.id)) == ["premium", "granted"]) - } else { - Issue.record("Expected .active status") - } + #expect(published.first.flatMap(activeIds) == ["premium", "granted"]) + } + + @Test + func developerAssignment_publishesOnlyTheMergedValue() { + superwall.grantedEntitlements = [grantedEntitlement()] + + var published: [SubscriptionStatus] = [] + let cancellable = superwall.$subscriptionStatus + .dropFirst() + .sink { published.append($0) } + defer { cancellable.cancel() } + + // The public setter must never publish the raw assigned value first. + superwall.subscriptionStatus = .active([deviceEntitlement(id: "premium")]) + + #expect(published.count == 1) + #expect(published.first.flatMap(activeIds) == ["premium", "granted"]) + } + + @Test + func assignmentFromASubscriber_isApplied() { + var didReassign = false + let cancellable = superwall.$subscriptionStatus + .dropFirst() + .sink { [superwall] status in + // A subscriber that reacts to the first change by assigning again, + // synchronously, from inside the emission. + if !didReassign, case .active = status { + didReassign = true + superwall.subscriptionStatus = .inactive + } + } + defer { cancellable.cancel() } + + superwall.subscriptionStatus = .active([deviceEntitlement(id: "premium")]) + + #expect(superwall.subscriptionStatus == .inactive) + #expect(dependencyContainer.storage.get(SubscriptionStatusKey.self) == .inactive) } @Test @@ -403,8 +468,7 @@ final class GrantedEntitlementsTests { // A device poll reporting no subscription, and a direct .inactive // assignment, both publish the granted status again. The delegate - // must not hear about either — in particular it must never see the - // transient .inactive that the public setter stores before merging. + // must not hear about either. superwall.setSubscriptionStatus(assigned: .inactive) superwall.subscriptionStatus = .inactive try? await Task.sleep(nanoseconds: 300_000_000) From 8fcb8713352d56f2a40aea5fa52ed2f162fa1883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:49:06 +0200 Subject: [PATCH 056/162] review: rebuild external customer info from a consistent snapshot, strip colliding grants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The external-purchase-controller customer info was recomputed after the unlock from a granted snapshot taken before it, paired with a live status read, so a grant write landing in between could leave customerInfo permanently missing the grant. It's now built outside the lock from a consistent (status, granted) snapshot and rebuilt if either moved. Stripping grants from developer assignments compared by deep equality, which misses a grant that won an ID collision — the merge unions product IDs across a shared ID, so the winner no longer equals the grant. The strip now compares ignoring product IDs. The CHANGELOG records the $subscriptionStatus type change. The wrapper stays public because a public property's wrapper type has to be. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 3 +- .../Products/StoreProduct/Entitlement.swift | 24 +++++++++ .../SubscriptionStatusPublishing.swift | 49 +++++++++++++++++-- .../Products/GrantedEntitlementsTests.swift | 14 ++++++ 4 files changed, 86 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index feb061436..f09ec8149 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Enhancements -- Adds `grantedEntitlements` so you can grant entitlements from your own backend, which the SDK merges with device and web entitlements. The `subscriptionStatus` publisher now only ever emits the merged status. +- Adds `grantedEntitlements` so you can grant entitlements from your own backend, which the SDK merges with device and web entitlements. +- `$subscriptionStatus` is now an `AnyPublisher` that only ever emits the merged status; it can no longer be the target of `assign(to:)`. ### Fixes diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift index e5b0172c7..d32a43d75 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift @@ -315,6 +315,30 @@ extension Entitlement { } } +// MARK: - Comparison +extension Entitlement { + /// Whether `other` is this entitlement with possibly different product IDs + /// — the shape a merge leaves behind, since it unions product IDs across a + /// shared ID regardless of which record wins. + func isEqualIgnoringProductIds(to other: Entitlement) -> Bool { + return Entitlement( + id: id, + type: type, + isActive: isActive, + productIds: other.productIds, + latestProductId: latestProductId, + store: store, + startsAt: startsAt, + renewedAt: renewedAt, + expiresAt: expiresAt, + isLifetime: isLifetime, + willRenew: willRenew, + state: state, + offerType: offerType + ) == other + } +} + // MARK: - Entitlement Merging extension Entitlement { /// Determines which entitlement should take priority when merging entitlements with the same ID. diff --git a/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift index c91d1f086..a46f0b78f 100644 --- a/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift +++ b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift @@ -141,7 +141,14 @@ extension Superwall { var assigned = newAssigned ?? assignedSubscriptionStatus if isDeveloperAssignment { if case .active(let assignedEntitlements) = assigned { - assigned = .active(assignedEntitlements.subtracting(granted)) + // Compared ignoring product IDs: the merge unions those across a + // shared ID, so a grant that won a collision comes back carrying the + // loser's product IDs. + assigned = .active( + assignedEntitlements.filter { record in + !granted.contains { $0.isEqualIgnoringProductIds(to: record) } + } + ) } if case .inactive = assigned, !activeGrants.isEmpty, @@ -190,11 +197,43 @@ extension Superwall { // Skip this in test mode — test mode manages its own CustomerInfo. if dependencyContainer.makeHasExternalPurchaseController(), dependencyContainer.testModeManager?.isTestMode != true { + refreshExternalControllerCustomerInfo() + } + } + + /// A consistent pair of the published status and the granted set, read + /// under the lock. + private struct PublishedSnapshot: Equatable { + let status: SubscriptionStatus + let granted: Set + } + + private func publishedSnapshot() -> PublishedSnapshot { + subscriptionStatusLock.lock() + defer { + subscriptionStatusLock.unlock() + } + return PublishedSnapshot(status: subscriptionStatus, granted: entitlements.granted) + } + + /// Recomputes `customerInfo` for the external purchase controller path + /// outside the lock, so `$customerInfo` subscribers don't run inside the + /// SDK's critical section. It's built from a consistent snapshot and + /// rebuilt if either input moved while it ran, so a slower writer can't + /// leave a stale customer info behind a newer publish. + private func refreshExternalControllerCustomerInfo() { + var snapshot = publishedSnapshot() + while true { customerInfo = CustomerInfo.forExternalPurchaseController( storage: dependencyContainer.storage, - subscriptionStatus: subscriptionStatus, - granted: granted + subscriptionStatus: snapshot.status, + granted: snapshot.granted ) + let newest = publishedSnapshot() + if newest == snapshot { + return + } + snapshot = newest } } @@ -306,6 +345,10 @@ extension Superwall { /// see the value a writer assigned before granted entitlements and test-mode /// overrides were applied. The projected value (`$subscriptionStatus`) replays /// the current value to new subscribers, like `@Published`. +/// +/// Public only because a public property's wrapper type has to be; nothing +/// but the projection is meant to be used. The enclosing-instance subscript +/// is the same mechanism `@Published` itself uses to reach its owner. @propertyWrapper public struct PublishedSubscriptionStatus { private var storage: SubscriptionStatus diff --git a/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift index ac5d967e2..2aed81d67 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift @@ -198,6 +198,20 @@ final class GrantedEntitlementsTests { #expect(activeIds(superwall.subscriptionStatus) == ["premium"]) } + @Test + func writingBackACollidingGrant_keepsItRevocable() { + // A lapsed subscription and a grant for the same ID: the grant wins the + // merge but comes back carrying the lapsed record's product IDs, so it + // no longer equals the grant it came from. + superwall.subscriptionStatus = .active([deviceEntitlement(id: "premium", isActive: false)]) + superwall.grantedEntitlements = [grantedEntitlement(id: "premium")] + + superwall.subscriptionStatus = superwall.subscriptionStatus + superwall.grantedEntitlements = [] + + #expect(!superwall.subscriptionStatus.isActive) + } + // MARK: - Merge precedence @Test From 7e6c817c33af1e94dada453708be3651d2194988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:57:50 +0200 Subject: [PATCH 057/162] review: bound the customer info retry, cover the external-controller recompute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retry ran unbounded on the caller's thread, which can be main. It's now a single rebuild: a write landing during it is vanishingly rare and corrected by that write's own publish. Adds the first test to exercise the external-purchase-controller branch — the suite's own container uses the internal controller, so the recompute had no coverage. Splits PublishedSubscriptionStatus into its own file to stay under the file-length limit. Co-Authored-By: Claude Opus 5 --- .../PublishedSubscriptionStatus.swift | 64 +++++++++++++ .../SubscriptionStatusPublishing.swift | 94 +++++-------------- SuperwallKit.xcodeproj/project.pbxproj | 4 + .../Products/GrantedEntitlementsTests.swift | 22 +++++ 4 files changed, 112 insertions(+), 72 deletions(-) create mode 100644 Sources/SuperwallKit/StoreKit/Products/PublishedSubscriptionStatus.swift diff --git a/Sources/SuperwallKit/StoreKit/Products/PublishedSubscriptionStatus.swift b/Sources/SuperwallKit/StoreKit/Products/PublishedSubscriptionStatus.swift new file mode 100644 index 000000000..b8ab86ea5 --- /dev/null +++ b/Sources/SuperwallKit/StoreKit/Products/PublishedSubscriptionStatus.swift @@ -0,0 +1,64 @@ +// +// PublishedSubscriptionStatus.swift +// SuperwallKit +// +// Created by Yusuf Tör on 2026-09-03. +// + +import Combine +import Foundation + +/// The property wrapper behind ``Superwall/subscriptionStatus``. +/// +/// Every assignment is routed through `publishSubscriptionStatus`, so the +/// stored and published value is always the merged one — subscribers never +/// see the value a writer assigned before granted entitlements and test-mode +/// overrides were applied. The projected value (`$subscriptionStatus`) replays +/// the current value to new subscribers, like `@Published`. +/// +/// Public only because a public property's wrapper type has to be; nothing +/// but the projection is meant to be used. The enclosing-instance subscript +/// is the same mechanism `@Published` itself uses to reach its owner. +@propertyWrapper +public struct PublishedSubscriptionStatus { + private var storage: SubscriptionStatus + private let subject: CurrentValueSubject + + public init(wrappedValue: SubscriptionStatus) { + storage = wrappedValue + subject = CurrentValueSubject(wrappedValue) + } + + @available(*, unavailable, message: "Only available on Superwall.") + public var wrappedValue: SubscriptionStatus { + get { fatalError("subscriptionStatus is only readable on Superwall.") } + set { fatalError("\(newValue) can only be assigned on Superwall.") } + } + + public var projectedValue: AnyPublisher { + return subject.eraseToAnyPublisher() + } + + public static subscript( + _enclosingInstance superwall: Superwall, + wrapped wrappedKeyPath: ReferenceWritableKeyPath, + storage storageKeyPath: ReferenceWritableKeyPath + ) -> SubscriptionStatus { + get { + return superwall[keyPath: storageKeyPath].storage + } + set { + superwall.publishSubscriptionStatus(assigned: newValue, isDeveloperAssignment: true) + } + } + + /// Stores a merged status without emitting it. + mutating func store(_ merged: SubscriptionStatus) { + storage = merged + } + + /// Emits a stored status to subscribers. + func emit(_ status: SubscriptionStatus) { + subject.send(status) + } +} diff --git a/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift index a46f0b78f..7c09f1739 100644 --- a/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift +++ b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift @@ -218,25 +218,32 @@ extension Superwall { /// Recomputes `customerInfo` for the external purchase controller path /// outside the lock, so `$customerInfo` subscribers don't run inside the - /// SDK's critical section. It's built from a consistent snapshot and - /// rebuilt if either input moved while it ran, so a slower writer can't - /// leave a stale customer info behind a newer publish. + /// SDK's critical section. It's built from a consistent snapshot, and + /// rebuilt once if either input moved while it ran, so a slower writer + /// can't leave a stale customer info behind a newer publish. + /// + /// The retry is deliberately bounded rather than a loop: this runs + /// synchronously on the caller's thread, which can be the main one, and a + /// write landing during the rebuild is both vanishingly rare and corrected + /// by that write's own publish. private func refreshExternalControllerCustomerInfo() { - var snapshot = publishedSnapshot() - while true { - customerInfo = CustomerInfo.forExternalPurchaseController( - storage: dependencyContainer.storage, - subscriptionStatus: snapshot.status, - granted: snapshot.granted - ) - let newest = publishedSnapshot() - if newest == snapshot { - return - } - snapshot = newest + let snapshot = publishedSnapshot() + customerInfo = customerInfo(for: snapshot) + + let newest = publishedSnapshot() + if newest != snapshot { + customerInfo = customerInfo(for: newest) } } + private func customerInfo(for snapshot: PublishedSnapshot) -> CustomerInfo { + return CustomerInfo.forExternalPurchaseController( + storage: dependencyContainer.storage, + subscriptionStatus: snapshot.status, + granted: snapshot.granted + ) + } + /// The status the SDK reports for `assigned`: active granted entitlements /// merged in, any test-mode override applied, and an empty `.active` /// collapsed to `.inactive`. @@ -335,60 +342,3 @@ extension Superwall { ) } } - -// MARK: - Property wrapper - -/// The property wrapper behind ``Superwall/subscriptionStatus``. -/// -/// Every assignment is routed through `publishSubscriptionStatus`, so the -/// stored and published value is always the merged one — subscribers never -/// see the value a writer assigned before granted entitlements and test-mode -/// overrides were applied. The projected value (`$subscriptionStatus`) replays -/// the current value to new subscribers, like `@Published`. -/// -/// Public only because a public property's wrapper type has to be; nothing -/// but the projection is meant to be used. The enclosing-instance subscript -/// is the same mechanism `@Published` itself uses to reach its owner. -@propertyWrapper -public struct PublishedSubscriptionStatus { - private var storage: SubscriptionStatus - private let subject: CurrentValueSubject - - public init(wrappedValue: SubscriptionStatus) { - storage = wrappedValue - subject = CurrentValueSubject(wrappedValue) - } - - @available(*, unavailable, message: "Only available on Superwall.") - public var wrappedValue: SubscriptionStatus { - get { fatalError("subscriptionStatus is only readable on Superwall.") } - set { fatalError("\(newValue) can only be assigned on Superwall.") } - } - - public var projectedValue: AnyPublisher { - return subject.eraseToAnyPublisher() - } - - public static subscript( - _enclosingInstance superwall: Superwall, - wrapped wrappedKeyPath: ReferenceWritableKeyPath, - storage storageKeyPath: ReferenceWritableKeyPath - ) -> SubscriptionStatus { - get { - return superwall[keyPath: storageKeyPath].storage - } - set { - superwall.publishSubscriptionStatus(assigned: newValue, isDeveloperAssignment: true) - } - } - - /// Stores a merged status without emitting it. - mutating func store(_ merged: SubscriptionStatus) { - storage = merged - } - - /// Emits a stored status to subscribers. - func emit(_ status: SubscriptionStatus) { - subject.send(status) - } -} diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index e43a125ca..fcfd128e2 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -507,6 +507,7 @@ E0F3648081AB86077201EB5D /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83416F0F1B5294C350D5CF70 /* FeatureFlags.swift */; }; E0F69E406F64A1160FF55BFA /* SWProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 460B6F98BADD9EC96A978E40 /* SWProduct.swift */; }; E1A838C9CE62C9479D0C68F4 /* SWDebugManagerLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C733A9BE56EA9E10D75B073B /* SWDebugManagerLogicTests.swift */; }; + E20B1214CA9785C0D0C7AD7A /* PublishedSubscriptionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 328C01066B84891ECF4B7F34 /* PublishedSubscriptionStatus.swift */; }; E2E0E2A82200943E73E3A92A /* AppSessionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59A767F107FB1FBBC2F22DB3 /* AppSessionManagerTests.swift */; }; E315F3C6BBCA8582BF540086 /* GetExperiment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BE5DAD3AB51C8C6B5AB88D2 /* GetExperiment.swift */; }; E3DC0E7597234DC8CC508A33 /* MapSwiftErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81D80A7C5B8A17B83C218656 /* MapSwiftErrors.swift */; }; @@ -720,6 +721,7 @@ 311D187DE8B8A5D0699F31A2 /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hr; path = hr.lproj/Localizable.strings; sourceTree = ""; }; 320AA5349848F696950F441A /* IdentityOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityOptions.swift; sourceTree = ""; }; 323B5E3D7E69FB3E718EED02 /* TestModeModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestModeModal.swift; sourceTree = ""; }; + 328C01066B84891ECF4B7F34 /* PublishedSubscriptionStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublishedSubscriptionStatus.swift; sourceTree = ""; }; 335888285131F832E1C91A8F /* Dictionary+Merging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+Merging.swift"; sourceTree = ""; }; 337B611696DC32BD227CD020 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; 33A9E0597972B90E3B9DFFE1 /* SuperwallDelegateObjc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuperwallDelegateObjc.swift; sourceTree = ""; }; @@ -2510,6 +2512,7 @@ children = ( C6BB83F17D20143827C28042 /* EntitlementsInfo.swift */, E0A7F2B0E53BE42DC6B52873 /* EntitlementsStatus.swift */, + 328C01066B84891ECF4B7F34 /* PublishedSubscriptionStatus.swift */, 469037ACFCAE3F46CF751E43 /* SubscriptionStatusPublishing.swift */, 0D4F1B49114819E9E6923508 /* Product Fetching */, E36E6C0CB40C3F44423C80CF /* Receipt Manager */, @@ -3687,6 +3690,7 @@ F5CCDC90D8CBA5ED0C5BAD2E /* PublicGetPresentationResult.swift in Sources */, 0AB9CCC164DD87C81318AAB0 /* PublicIdentity.swift in Sources */, 8F24AAC773F119481E2C654F /* PublicPresentation.swift in Sources */, + E20B1214CA9785C0D0C7AD7A /* PublishedSubscriptionStatus.swift in Sources */, D66461863D54A56BE9C29310 /* Publisher+Async.swift in Sources */, 4E078EFFD0C1992563021220 /* PurchaseController.swift in Sources */, 0F00D32C125E8B86EA477631 /* PurchaseControllerObjc.swift in Sources */, diff --git a/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift index 2aed81d67..06ec64398 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift @@ -379,6 +379,28 @@ final class GrantedEntitlementsTests { #expect(merged.entitlements.first?.latestProductId == nil) } + @Test + func externalController_customerInfoFollowsStatusAndGrants() { + // The suite's own container uses the internal controller, so this is the + // only test that exercises the external-controller recompute wired into + // publishSubscriptionStatus. + let container = DependencyContainer( + purchaseController: MockPurchaseController(), + cache: CacheMock() + ) + let superwall = Superwall(dependencyContainer: container) + + superwall.grantedEntitlements = [grantedEntitlement()] + superwall.subscriptionStatus = .active([deviceEntitlement(id: "premium")]) + + #expect(Set(superwall.customerInfo.entitlements.map(\.id)) == ["premium", "granted"]) + + // Clearing recomputes too, even though the status stays active. + superwall.grantedEntitlements = [] + + #expect(superwall.customerInfo.entitlements.map(\.id) == ["premium"]) + } + @Test func grantChange_refreshesCustomerInfoOnAutomaticPath() { superwall.grantedEntitlements = [grantedEntitlement()] From 78eca5d468f4fbe81a6074a58c4cfda1f8338177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:16:29 +0200 Subject: [PATCH 058/162] docs: plainer wording in the granted entitlement ids comment Co-Authored-By: Claude Opus 5 --- .../Trackable Events/TrackableSuperwallEvent.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift index 5cddc6b7d..26495cb3c 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift @@ -334,9 +334,9 @@ enum InternalSuperwallEvent { params += [ "active_entitlement_ids": entitlements.map(\.id).joined(separator: ",") ] - // The merged set carries no provenance, so this lists the active IDs - // that are also granted — a device record that beat a grant for the - // same ID is included too. + // The merged set doesn't record where each entitlement came from, so + // this lists the active IDs that are also granted — a device record + // that beat a grant for the same ID is included too. let grantedIds = Set(grantedEntitlements.map(\.id)) let activeGrantedIds = entitlements .filter { $0.isActive && grantedIds.contains($0.id) } From 782ed3e8d656a630c8115f9ba9031ae38082c2f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:50:35 +0200 Subject: [PATCH 059/162] refactor: name what prompted a status publish instead of nil + a flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit publishSubscriptionStatus took an optional status plus an isDeveloperAssignment flag: two parameters for three states, with nil + true a combination that should never exist. It now takes a SubscriptionStatusChange — .developerAssignment, .sdkAssignment or .grantChange — so each caller says what happened and the illegal combination can't be written. Co-Authored-By: Claude Opus 5 --- .../PublishedSubscriptionStatus.swift | 2 +- .../Products/SubscriptionStatusChange.swift | 27 ++++++++++++ .../SubscriptionStatusPublishing.swift | 42 +++++++++++-------- SuperwallKit.xcodeproj/project.pbxproj | 4 ++ 4 files changed, 56 insertions(+), 19 deletions(-) create mode 100644 Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusChange.swift diff --git a/Sources/SuperwallKit/StoreKit/Products/PublishedSubscriptionStatus.swift b/Sources/SuperwallKit/StoreKit/Products/PublishedSubscriptionStatus.swift index b8ab86ea5..c306d610c 100644 --- a/Sources/SuperwallKit/StoreKit/Products/PublishedSubscriptionStatus.swift +++ b/Sources/SuperwallKit/StoreKit/Products/PublishedSubscriptionStatus.swift @@ -48,7 +48,7 @@ public struct PublishedSubscriptionStatus { return superwall[keyPath: storageKeyPath].storage } set { - superwall.publishSubscriptionStatus(assigned: newValue, isDeveloperAssignment: true) + superwall.publishSubscriptionStatus(.developerAssignment(newValue)) } } diff --git a/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusChange.swift b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusChange.swift new file mode 100644 index 000000000..ecacc2412 --- /dev/null +++ b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusChange.swift @@ -0,0 +1,27 @@ +// +// SubscriptionStatusChange.swift +// SuperwallKit +// +// Created by Yusuf Tör on 2026-09-03. +// + +import Foundation + +/// What prompted a publish of ``Superwall/subscriptionStatus``. +/// +/// The three cases are the only ways the published status can change, and +/// each carries exactly what that path knows: the two assignments carry a +/// new status, a grant change carries nothing because it leaves the assigned +/// status alone and only changes what merges into it. +enum SubscriptionStatusChange { + /// The developer assigned ``Superwall/subscriptionStatus`` directly. + case developerAssignment(SubscriptionStatus) + + /// The SDK assigned a status it worked out itself — device + web + /// entitlements, the cold-launch restore, or test mode. + case sdkAssignment(SubscriptionStatus) + + /// ``Superwall/grantedEntitlements`` changed, so the status is republished + /// from the assigned value it already had. + case grantChange +} diff --git a/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift index 7c09f1739..67e327584 100644 --- a/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift +++ b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift @@ -60,7 +60,7 @@ extension Superwall { ? "Granted entitlements cleared." : "Granted entitlements set: \(newValue.map(\.id).sorted().joined(separator: ", "))" ) - publishSubscriptionStatus(assigned: nil, isDeveloperAssignment: false) + publishSubscriptionStatus(.grantChange) refreshAutomaticCustomerInfoAfterGrantChange() } } @@ -73,7 +73,7 @@ extension Superwall { /// is the developer's entry point and gets the developer-assignment /// treatment described on `publishSubscriptionStatus`. func setSubscriptionStatus(assigned status: SubscriptionStatus) { - publishSubscriptionStatus(assigned: status, isDeveloperAssignment: false) + publishSubscriptionStatus(.sdkAssignment(status)) } /// Merges web entitlements into the device status and assigns the result, @@ -115,21 +115,13 @@ extension Superwall { /// assigned value under the lock, then emits the merged status and runs the /// remaining side effects outside it. /// - /// Pass `nil` as `assigned` to re-publish from the current assigned status - /// — after a grant change — which is then read under the lock. - /// - /// `isDeveloperAssignment` is `true` for writes through the public - /// ``subscriptionStatus`` setter, which get two extra treatments: records - /// identical to a current grant are stripped, because a developer who reads - /// the published status and writes it back would otherwise hand the grants - /// back as their own and clearing them could never revoke; and an - /// `.inactive` assignment while active grants exist logs a one-time - /// warning, since the status publishes as active and otherwise looks - /// ignored. - func publishSubscriptionStatus( - assigned newAssigned: SubscriptionStatus?, - isDeveloperAssignment: Bool - ) { + /// A developer assignment gets two extra treatments: records identical to a + /// current grant are stripped, because a developer who reads the published + /// status and writes it back would otherwise hand the grants back as their + /// own and clearing them could never revoke; and an `.inactive` assignment + /// while active grants exist logs a one-time warning, since the status + /// publishes as active and otherwise looks ignored. + func publishSubscriptionStatus(_ change: SubscriptionStatusChange) { subscriptionStatusLock.lock() // Snapshot once, under the lock, so a concurrent grant write can't slip @@ -138,7 +130,21 @@ extension Superwall { let granted = entitlements.granted let activeGrants = Entitlement.mergePrioritized(Array(granted.filter(\.isActive))) - var assigned = newAssigned ?? assignedSubscriptionStatus + var assigned: SubscriptionStatus + let isDeveloperAssignment: Bool + switch change { + case .developerAssignment(let status): + assigned = status + isDeveloperAssignment = true + case .sdkAssignment(let status): + assigned = status + isDeveloperAssignment = false + case .grantChange: + // The assigned status is unchanged; only the grants merged into it are. + assigned = assignedSubscriptionStatus + isDeveloperAssignment = false + } + if isDeveloperAssignment { if case .active(let assignedEntitlements) = assigned { // Compared ignoring product IDs: the merge unions those across a diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index fcfd128e2..2ff8079af 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -121,6 +121,7 @@ 31E937EB414F62268F6C953C /* TestModeInfoCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3548435BDE49161E3BDFA358 /* TestModeInfoCell.swift */; }; 32C1A7BB48AC2A5CB88C448B /* NonSubscriptionTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA1E17A7907C42F27817C958 /* NonSubscriptionTransaction.swift */; }; 3313CD30A969731960FC32BF /* PaywallOverrides.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1439A212719AA2EA8BEA357 /* PaywallOverrides.swift */; }; + 332915728292DE705DB09338 /* SubscriptionStatusChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5813618E0E26698492BC7485 /* SubscriptionStatusChange.swift */; }; 339F1D07DB57DBEC46940DB6 /* CheckoutWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0B401CD38DBD6D90E4EB3E /* CheckoutWebViewController.swift */; }; 342593FCA24FBEA77FE472C7 /* SK2ReceiptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050BC76657949DBB5F3D551C /* SK2ReceiptManager.swift */; }; 3464196F9088F8A320FE24A4 /* PendingStripeCheckoutPollState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797EC0356AA1065ED11835BF /* PendingStripeCheckoutPollState.swift */; }; @@ -789,6 +790,7 @@ 577A16EE2161E2CDEDFA48C0 /* GameControllerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameControllerManager.swift; sourceTree = ""; }; 57AD390BC73341A49301B4AA /* ProductsFetcherSK2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsFetcherSK2.swift; sourceTree = ""; }; 57C7673988B39FB0BDEA8BE4 /* Date+IsoStringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+IsoStringTests.swift"; sourceTree = ""; }; + 5813618E0E26698492BC7485 /* SubscriptionStatusChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionStatusChange.swift; sourceTree = ""; }; 582CE0C5BA6EA57C7FE3EE43 /* CheckNoPaywallAlreadyPresented.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckNoPaywallAlreadyPresented.swift; sourceTree = ""; }; 5836EFACFA00594CE8F9F377 /* Experiment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Experiment.swift; sourceTree = ""; }; 58466FF38687A9F8715F9B54 /* Array+Capability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Capability.swift"; sourceTree = ""; }; @@ -2513,6 +2515,7 @@ C6BB83F17D20143827C28042 /* EntitlementsInfo.swift */, E0A7F2B0E53BE42DC6B52873 /* EntitlementsStatus.swift */, 328C01066B84891ECF4B7F34 /* PublishedSubscriptionStatus.swift */, + 5813618E0E26698492BC7485 /* SubscriptionStatusChange.swift */, 469037ACFCAE3F46CF751E43 /* SubscriptionStatusPublishing.swift */, 0D4F1B49114819E9E6923508 /* Product Fetching */, E36E6C0CB40C3F44423C80CF /* Receipt Manager */, @@ -3765,6 +3768,7 @@ 91BA5E01D0FB528954ABB937 /* StripeStoreProductDiscount.swift in Sources */, B60C3A3AD25CE2E2C513A2D2 /* Stubbable.swift in Sources */, C34A4AF2C8CD9ACBD2C370F8 /* SubscriptionPeriod.swift in Sources */, + 332915728292DE705DB09338 /* SubscriptionStatusChange.swift in Sources */, C8540455BC520DAB75862A5D /* SubscriptionStatusPublishing.swift in Sources */, B0AD4A89AD5101360F93652D /* SubscriptionTransaction.swift in Sources */, CFEB0D797815E8EDFB059767 /* Superwall.swift in Sources */, From f7b54b429acee2ae6d8872be5fa1af82fc9653ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:12:35 +0200 Subject: [PATCH 060/162] chore: stop swiftlint crawling nested git worktrees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .claude/worktrees holds a full copy of the SDK source per worktree, so scripts/lint.sh was reporting another branch's violations against this one — 1248 violations across 1002 files locally, of which 1247 came from a worktree. CI clones fresh so it never saw them. Co-Authored-By: Claude Opus 5 --- .swiftlint.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.swiftlint.yml b/.swiftlint.yml index 50194293a..5db4d245a 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -3,6 +3,9 @@ excluded: - ${PWD}/Pods - ${PWD}/DerivedData - ${PWD}/.build + # Git worktrees live here, each holding a full copy of the SDK source, so + # linting them reports another branch's violations against this one. + - ${PWD}/.claude - ${PWD}/Examples - ${PWD}/Tests - ${PWD}/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/ASN1Swift From a78034c399d00e921d6dcd600ed2011d899a983c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:38:30 +0200 Subject: [PATCH 061/162] refactor: drop a stale customer info build instead of rebuilding it The refresh built the customer info, then rebuilt it from scratch if a concurrent publish had moved the inputs meanwhile. Rebuilding was redundant work to fix an ordering problem, and it still didn't close the window. Dropping the build instead is one build in every case: whoever moved the inputs runs their own refresh with the newer values, so ours would only put older data last. Co-Authored-By: Claude Opus 5 --- .../SubscriptionStatusPublishing.swift | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift index 67e327584..5e29b15ad 100644 --- a/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift +++ b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift @@ -224,30 +224,24 @@ extension Superwall { /// Recomputes `customerInfo` for the external purchase controller path /// outside the lock, so `$customerInfo` subscribers don't run inside the - /// SDK's critical section. It's built from a consistent snapshot, and - /// rebuilt once if either input moved while it ran, so a slower writer - /// can't leave a stale customer info behind a newer publish. + /// SDK's critical section. /// - /// The retry is deliberately bounded rather than a loop: this runs - /// synchronously on the caller's thread, which can be the main one, and a - /// write landing during the rebuild is both vanishingly rare and corrected - /// by that write's own publish. + /// It's built from a consistent snapshot and dropped if a concurrent + /// publish moved either input while it ran: that publish runs its own + /// refresh with the newer values, so assigning ours would put the older + /// customer info last. Uncontended — the normal case, and the only one a + /// synchronous read after a status write depends on — the snapshot is + /// unchanged and the value is assigned before this returns. private func refreshExternalControllerCustomerInfo() { let snapshot = publishedSnapshot() - customerInfo = customerInfo(for: snapshot) - - let newest = publishedSnapshot() - if newest != snapshot { - customerInfo = customerInfo(for: newest) - } - } - - private func customerInfo(for snapshot: PublishedSnapshot) -> CustomerInfo { - return CustomerInfo.forExternalPurchaseController( + let info = CustomerInfo.forExternalPurchaseController( storage: dependencyContainer.storage, subscriptionStatus: snapshot.status, granted: snapshot.granted ) + if publishedSnapshot() == snapshot { + customerInfo = info + } } /// The status the SDK reports for `assigned`: active granted entitlements From 1949e2163c53241e07f44c31c542fdc816a5bf93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:17:37 +0200 Subject: [PATCH 062/162] fix: granted entitlements no longer decide an unknown subscription status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .unknown means the status hasn't been determined, and grants don't determine it. Reporting active on the strength of grants alone opened the paywall presentation gate — which waits for a status other than .unknown — before a purchase controller had answered, so a campaign filtered on an entitlement the developer hadn't reported yet could show a paywall to a subscriber on first launch. It only ever fired with an external purchase controller (the automatic one always replaces .unknown after loading purchases), i.e. only in the purchase-controller-plus-grants setup the docs tell people not to create, where never setting the status already fails loudly via the 5s timeout. Grants now merge as soon as the status is known, matching web entitlements. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../SubscriptionStatusPublishing.swift | 24 +++++++++-------- .../Products/GrantedEntitlementsTests.swift | 26 ++++++++++++++++--- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f09ec8149..b12619e65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Adds `grantedEntitlements` so you can grant entitlements from your own backend, which the SDK merges with device and web entitlements. - `$subscriptionStatus` is now an `AnyPublisher` that only ever emits the merged status; it can no longer be the target of `assign(to:)`. +- Granted entitlements don't change an `unknown` subscription status. They're merged in as soon as the status is known. ### Fixes diff --git a/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift index 5e29b15ad..a46334c03 100644 --- a/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift +++ b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift @@ -99,11 +99,11 @@ extension Superwall { case .inactive: deviceAndWebStatus = activeWebEntitlements.isEmpty ? .inactive : .active(activeWebEntitlements) case .unknown: - // Web entitlements deliberately don't promote .unknown to active, - // unlike granted entitlements (see mergedSubscriptionStatus). This - // branch only runs without an external purchase controller, where the - // AutomaticPurchaseController is guaranteed to replace .unknown after - // loading purchased products — so .unknown is always transient here. + // Neither web nor granted entitlements turn .unknown into a decision + // (see mergedSubscriptionStatus). This branch only runs without an + // external purchase controller, where the AutomaticPurchaseController + // replaces .unknown after loading purchased products, so it's always + // transient here anyway. deviceAndWebStatus = .unknown } superwall.setSubscriptionStatus(assigned: deviceAndWebStatus) @@ -265,13 +265,15 @@ extension Superwall { case .active(let entitlements): // Set.union merges by priority, keeping one record per ID. status = .active(entitlements.union(activeGrants)) - case .inactive, .unknown: - // .unknown promotes to active, unlike web entitlements (see - // internallySetSubscriptionStatus). With an external purchase - // controller the developer is the only writer of the status, so - // .unknown can be terminal — without promotion, a developer relying - // solely on granted entitlements would be locked out forever. + case .inactive: status = .active(activeGrants) + case .unknown: + // Left alone, exactly like web entitlements: .unknown means the + // status hasn't been determined yet, and grants don't determine it + // — they merge as soon as it is. Reporting active here would open + // the paywall presentation gate, which waits for a status other + // than .unknown, before a purchase controller has answered. + break } } // This must run after the granted merge, or a developer-assigned diff --git a/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift index 06ec64398..6672def34 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/GrantedEntitlementsTests.swift @@ -88,11 +88,25 @@ final class GrantedEntitlementsTests { } @Test - func unknownStatus_promotesToActiveWithGranted() { + func unknownStatus_staysUnknownWithGranted() { superwall.subscriptionStatus = .unknown superwall.grantedEntitlements = [grantedEntitlement()] - #expect(superwall.subscriptionStatus.isActive) + // Grants don't decide a status that hasn't been determined — reporting + // active here would let a paywall present before a purchase controller + // has answered. + #expect(superwall.subscriptionStatus == .unknown) + } + + @Test + func grantsSetWhileUnknown_mergeOnceTheStatusIsKnown() { + superwall.subscriptionStatus = .unknown + superwall.grantedEntitlements = [grantedEntitlement()] + #expect(superwall.subscriptionStatus == .unknown) + + superwall.subscriptionStatus = .inactive + + #expect(activeIds(superwall.subscriptionStatus) == ["granted"]) } @Test @@ -100,8 +114,8 @@ final class GrantedEntitlementsTests { superwall.subscriptionStatus = .inactive superwall.grantedEntitlements = [grantedEntitlement(isActive: false)] - // Inactive-only grants must not promote — the status stays .inactive, - // not an .active-cased status that's effectively inactive. + // The status stays .inactive, not an .active-cased status that's + // effectively inactive. #expect(superwall.subscriptionStatus == .inactive) } @@ -494,6 +508,10 @@ final class GrantedEntitlementsTests { @Test func inactiveWritesWithGrant_doNotNotifyDelegate() async { + // The device has answered, so the grant has a determined status to + // merge into. Set before listening so only the grant is observed. + superwall.setSubscriptionStatus(assigned: .inactive) + let delegate = MockSuperwallDelegate() dependencyContainer.delegateAdapter.swiftDelegate = delegate superwall.listenToSubscriptionStatus() From d620a7b1b46ea0e7a4d3d10d239358c632530300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:48:25 +0200 Subject: [PATCH 063/162] docs: say which thread subscribers run on instead of 'don't block' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The warning pointed at a deadlock that no longer exists — the status is emitted after the lock is released — and didn't say what to do about it. The fact that matters is that the callback arrives on whichever thread changed the status, often a background one. Co-Authored-By: Claude Opus 5 --- Sources/SuperwallKit/Superwall.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 5e1ae27b6..f181f5016 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -207,9 +207,9 @@ public final class Superwall: NSObject, ObservableObject { /// /// If you're using Combine or SwiftUI, you can subscribe or bind to it to get /// notified whenever it changes. The publisher only ever emits the merged - /// status — never a value you assigned before granted entitlements were - /// applied. Subscribers are called synchronously on the thread that changed - /// the status, so don't block in them. + /// status – never a value you assigned before granted entitlements were + /// applied. Subscribers are called on whichever thread changed the status, + /// which is often a background one, so use `receive(on:)` before updating UI. /// /// Otherwise, you can check the delegate function /// ``SuperwallDelegate/subscriptionStatusDidChange(from:to:)`` From 6b53761ca4bf5fbf962c7c4666770342dc479fd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:56:37 +0200 Subject: [PATCH 064/162] docs: trim the changelog to what changed since the last release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the entry describing how granted entitlements treat an unknown status: the whole API is new in this version, so nobody upgrading ever saw other behaviour — that belongs in the API docs. Names what $subscriptionStatus changed from, since that type did ship before, and drops a comment restating where the publishing code lives. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 +-- Sources/SuperwallKit/Superwall.swift | 3 --- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b12619e65..df5dbdfe2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Enhancements - Adds `grantedEntitlements` so you can grant entitlements from your own backend, which the SDK merges with device and web entitlements. -- `$subscriptionStatus` is now an `AnyPublisher` that only ever emits the merged status; it can no longer be the target of `assign(to:)`. -- Granted entitlements don't change an `unknown` subscription status. They're merged in as soon as the status is known. +- Changes `$subscriptionStatus` from a `@Published` publisher to an `AnyPublisher`. Subscribing to it works as before, but it can no longer be the target of `assign(to:)`. ### Fixes diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index f181f5016..78c12180a 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -224,9 +224,6 @@ public final class Superwall: NSObject, ObservableObject { public var subscriptionStatus: SubscriptionStatus = .unknown // MARK: - Subscription status state - // The publishing pipeline lives in SubscriptionStatusPublishing.swift; the - // stored state it needs, and the two accessors of the wrapper's private - // storage, have to live in the class. /// The status last handed to the SDK — device + web entitlements on the /// automatic path, or the developer's value with a purchase controller — From 7689d10baae9b69e339720273598add8f2657371 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:51:22 +0200 Subject: [PATCH 065/162] refactor(debugger): hold published paywalls as PaywallSummary The three-member tuple carried exactly the fields PaywallSummary already has, and tripped SwiftLint's large_tuple rule. Co-Authored-By: Claude Opus 5 --- Sources/SuperwallKit/Debug/DebugViewController.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index cd2435ff3..dc5083058 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -402,13 +402,13 @@ final class DebugViewController: UIViewController { /// The published paywalls to offer. The debugger's preview list needs the /// token a dashboard preview link carries; the downloaded config carries the /// same paywalls for free, which is what a `superwall dev` link relies on. - private var publishedPaywalls: [(id: String, identifier: String, name: String)] { + private var publishedPaywalls: [PaywallSummary] { if !previewPaywalls.isEmpty { - return previewPaywalls.map { (id: $0.id, identifier: $0.identifier, name: $0.name) } + return previewPaywalls } let config = Superwall.shared.dependencyContainer.configManager?.config return (config?.paywalls ?? []).map { - (id: $0.databaseId, identifier: $0.identifier, name: $0.name) + PaywallSummary(id: $0.databaseId, identifier: $0.identifier, name: $0.name) } } From eedda06ee9c7376c686dfde7a22e2e75f26fc776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:18:14 +0200 Subject: [PATCH 066/162] review: keep gating real, contain manifest decode failures, share the debugger's dev server Addresses the three open Pullfrog findings on #511. - The dev-server override synthesized the paywall wholesale, so featureGating came from the stub's .nonGated default. A gated dashboard paywall therefore unlocked its feature for a non-paying user under dev mode. It now carries the published paywall's gating over, and the devServer doc says so. - One surface the SDK couldn't read aborted the whole manifest decode, so a single bad entry took dev mode down for every surface and logged it as "no dev server found". Surfaces decode per element now, a bad presentation block costs a surface its style rather than the surface, and a manifest that answers but doesn't parse says exactly that. - Partly specified drawer geometry is legitimate: PaywallPresentationStyle documents a 70% default height. A drawer naming only some of its geometry now presents as a drawer. A popup has no documented default, so a partial one still falls back to fullscreen. - ensureDevServer populated only the view controller's copy, so presenting a local surface from a debugger opened by a dashboard link resolved nil and 404'd. It writes through to the debug manager now. Co-Authored-By: Claude Opus 5 --- .../Config/Options/SuperwallOptions.swift | 7 ++- .../Debug/DebugViewController.swift | 6 +- .../DevServer/DevServerLocator.swift | 16 +++++- .../DevServer/DevServerManifest.swift | 30 ++++++++++ .../DevServer/DevServerPaywall.swift | 25 +++++++-- .../DevServer/DevServerSurface.swift | 55 +++++++++++++++++-- .../Operators/RawPaywallResponse.swift | 12 ++-- .../DevServer/DevServerManifestTests.swift | 53 ++++++++++++++++++ .../DevServer/DevServerPaywallTests.swift | 48 +++++++++++++++- 9 files changed, 230 insertions(+), 22 deletions(-) diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index b3366f8b7..dd4f69b0d 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -404,8 +404,11 @@ public final class SuperwallOptions: NSObject, Encodable { /// /// Paywalls with a local counterpart on the dev server then render from your live, local /// paywall code instead of their published versions, while configuration, placements, - /// audience evaluation and assignment all stay real. Paywalls without a local counterpart - /// still load their published versions. + /// audience evaluation, assignment and feature gating all stay real. Paywalls without a + /// local counterpart still load their published versions. + /// + /// What the local surface does own is what it renders and how: its products and its + /// `config.ts` presentation style replace the published paywall's. /// /// Use ``DevServer/default`` on a simulator; on a physical device use ``DevServer/url(_:)`` /// with the `Device` URL that `superwall dev` prints. Defaults to `nil`: no dev server. diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index dc5083058..f63fa7bb2 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -233,7 +233,11 @@ final class DebugViewController: UIViewController { else { return } - devServer = (base: location.base, surfaces: location.manifest.surfaces) + let located = (base: location.base, surfaces: location.manifest.surfaces) + devServer = located + // Presenting a `dev:` surface resolves it from the debug manager's copy, + // so both stores have to agree however the debugger was opened. + debugManager.devServer = located } func finishLoadingPreview() async { diff --git a/Sources/SuperwallKit/DevServer/DevServerLocator.swift b/Sources/SuperwallKit/DevServer/DevServerLocator.swift index c3e990d03..d354c8551 100644 --- a/Sources/SuperwallKit/DevServer/DevServerLocator.swift +++ b/Sources/SuperwallKit/DevServer/DevServerLocator.swift @@ -106,7 +106,21 @@ actor DevServerLocator { } task.resume() } - return try JSONDecoder().decode(DevServerManifest.self, from: data) + do { + return try JSONDecoder().decode(DevServerManifest.self, from: data) + } catch { + // A server answered; its manifest just didn't parse. Say so, or the + // caller's "no server found" log points at the wrong cause. + Logger.debug( + logLevel: .error, + scope: .superwallCore, + message: "The superwall dev server at \(base.absoluteString) answered with a manifest " + + "this SDK couldn't read. Paywalls will load their published versions. " + + "Check that superwall dev and SuperwallKit are on compatible versions.", + error: error + ) + return nil + } } catch { warnIfBlockedByAppTransportSecurity(error, base: base) return nil diff --git a/Sources/SuperwallKit/DevServer/DevServerManifest.swift b/Sources/SuperwallKit/DevServer/DevServerManifest.swift index 2ade494c1..9f29b81e7 100644 --- a/Sources/SuperwallKit/DevServer/DevServerManifest.swift +++ b/Sources/SuperwallKit/DevServer/DevServerManifest.swift @@ -12,6 +12,36 @@ import Foundation struct DevServerManifest: Decodable, Equatable { let surfaces: [DevServerSurface] + private enum CodingKeys: String, CodingKey { + case surfaces + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + // Per-element decoding: the CLI writing this manifest versions separately + // from the SDK, so one surface the SDK can't read must not take down the + // surfaces it can. + let decoded = try container.decodeIfPresent( + [Throwable].self, + forKey: .surfaces + ) ?? [] + surfaces = decoded.compactMap { try? $0.result.get() } + + let dropped = decoded.count - surfaces.count + if dropped > 0 { + Logger.debug( + logLevel: .warn, + scope: .superwallCore, + message: "Skipped \(dropped) of \(decoded.count) dev server surfaces that couldn't be " + + "read. Those paywalls will load their published versions." + ) + } + } + + init(surfaces: [DevServerSurface]) { + self.surfaces = surfaces + } + /// Picks the local surface for a dashboard paywall: an explicit /// `superwall.lock` binding wins, otherwise a project with exactly one /// paywall serves it for everything. diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift index c71390859..8ea576a50 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift @@ -58,6 +58,11 @@ extension Paywall { return paywall } + /// The height a drawer takes when its `config.ts` doesn't name one, as a + /// percentage of the screen. Matches what `PaywallPresentationStyle/drawer` + /// documents. + private static let defaultDrawerHeight: Double = 70 + /// Maps a surface's `config.ts` presentation onto the SDK's styles. /// The framework documents `fullscreen` as its default, so anything /// missing or unrecognized lands there. @@ -72,13 +77,21 @@ extension Paywall { case "noAnimation": return .fullscreenNoAnimation case "drawer": - if let drawer = surface.presentation?.drawer { - return .drawer(height: drawer.height, cornerRadius: drawer.cornerRadius) - } - return .fullscreen + // A drawer that names only some of its geometry is still a drawer: + // PaywallPresentationStyle documents 70% of the screen as the default + // height, and an unset radius means no rounding. + let drawer = surface.presentation?.drawer + return .drawer( + height: drawer?.height ?? defaultDrawerHeight, + cornerRadius: drawer?.cornerRadius ?? 0 + ) case "popup": - if let popup = surface.presentation?.popup { - return .popup(height: popup.height, width: popup.width, cornerRadius: popup.cornerRadius) + // Unlike the drawer, a popup has no documented default size, so one + // without both dimensions falls back to fullscreen. + if let popup = surface.presentation?.popup, + let height = popup.height, + let width = popup.width { + return .popup(height: height, width: width, cornerRadius: popup.cornerRadius ?? 0) } return .fullscreen default: diff --git a/Sources/SuperwallKit/DevServer/DevServerSurface.swift b/Sources/SuperwallKit/DevServer/DevServerSurface.swift index 78f4ba5bc..df54c0bc2 100644 --- a/Sources/SuperwallKit/DevServer/DevServerSurface.swift +++ b/Sources/SuperwallKit/DevServer/DevServerSurface.swift @@ -11,15 +11,19 @@ import Foundation struct DevServerSurface: Decodable, Equatable { /// How the paywall asks to be presented, straight from its `config.ts`. + /// + /// The geometry is optional throughout: a `config.ts` may set only some of + /// it, and the CLI that writes this manifest versions separately from the + /// SDK, so a block the SDK can't fully read still presents. struct Presentation: Decodable, Equatable { struct Drawer: Decodable, Equatable { - let height: Double - let cornerRadius: Double + let height: Double? + let cornerRadius: Double? } struct Popup: Decodable, Equatable { - let width: Double - let height: Double - let cornerRadius: Double + let width: Double? + let height: Double? + let cornerRadius: Double? } let style: String? @@ -34,4 +38,45 @@ struct DevServerSurface: Decodable, Equatable { let identifier: String? let products: [String: String]? let presentation: Presentation? + + private enum CodingKeys: String, CodingKey { + case kind + case id + case url + case paywallId + case identifier + case products + case presentation + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + kind = try container.decode(String.self, forKey: .kind) + id = try container.decode(String.self, forKey: .id) + url = try container.decode(String.self, forKey: .url) + paywallId = try container.decodeIfPresent(String.self, forKey: .paywallId) + identifier = try container.decodeIfPresent(String.self, forKey: .identifier) + products = try container.decodeIfPresent([String: String].self, forKey: .products) + // Presentation is a hint, not the surface itself. A block this SDK can't + // read costs the surface its style, not its ability to be served. + presentation = try? container.decodeIfPresent(Presentation.self, forKey: .presentation) + } + + init( + kind: String, + id: String, + url: String, + paywallId: String? = nil, + identifier: String? = nil, + products: [String: String]? = nil, + presentation: Presentation? = nil + ) { + self.kind = kind + self.id = id + self.url = url + self.paywallId = paywallId + self.identifier = identifier + self.products = products + self.presentation = presentation + } } diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift index 9fad08a31..3c19ef33f 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift @@ -45,13 +45,17 @@ extension PaywallRequestManager { // The local surface replaces the published paywall wholesale, so what // presents is exactly what its config.ts declares — products included. - // Only the assignment's experiment and the fetch timings carry over, - // keeping holdouts and analytics coherent. The synthesized cacheKey - // embeds the mount URL, so a moved dev server or a published fallback - // reloads the web view instead of presenting the stale page. + // The assignment's experiment and the fetch timings carry over, keeping + // holdouts and analytics coherent. The synthesized cacheKey embeds the + // mount URL, so a moved dev server or a published fallback reloads the + // web view instead of presenting the stale page. var devPaywall = Paywall.devServer(surface: surface, url: mountURL) devPaywall.experiment = paywall.experiment devPaywall.responseLoadingInfo = paywall.responseLoadingInfo + // Feature gating belongs to the dashboard, not to the paywall's code: + // dev mode previews how a paywall looks, and must never be what decides + // whether a non-paying user gets the feature. + devPaywall.featureGating = paywall.featureGating Logger.debug( logLevel: .info, diff --git a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift index 88ce07bb2..f78932c48 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift @@ -130,4 +130,57 @@ final class DevServerManifestTests: XCTestCase { XCTAssertNil(decoded.mountURL(for: surface, base: base), surface.id) } } + + // MARK: - Tolerating what the SDK can't read + + func test_oneUnreadableSurfaceDoesNotDropTheRest() throws { + // `id` is required, so the middle entry can't decode at all. + let decoded = try manifest(""" + { + "surfaces": [ + { "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro" }, + { "kind": "paywall", "url": "/preview/paywall/nameless" }, + { "kind": "paywall", "id": "max", "url": "/preview/paywall/max" } + ] + } + """) + XCTAssertEqual(decoded.surfaces.map { $0.id }, ["pro", "max"]) + } + + func test_malformedPresentationStillServesTheSurface() throws { + let decoded = try manifest(""" + { + "surfaces": [ + { + "kind": "paywall", + "id": "pro", + "url": "/preview/paywall/pro", + "presentation": "not-an-object" + } + ] + } + """) + XCTAssertEqual(decoded.surfaces.map { $0.id }, ["pro"]) + XCTAssertNil(decoded.surfaces[0].presentation) + } + + func test_anUnknownFieldDoesNotDropTheSurface() throws { + let decoded = try manifest(""" + { + "surfaces": [ + { + "kind": "paywall", + "id": "pro", + "url": "/preview/paywall/pro", + "somethingTheCliAddedLater": { "a": 1 } + } + ] + } + """) + XCTAssertEqual(decoded.surfaces.map { $0.id }, ["pro"]) + } + + func test_missingSurfacesKeyDecodesAsEmpty() throws { + XCTAssertTrue(try manifest("{}").surfaces.isEmpty) + } } diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift index 737d2207d..ae9c7db91 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift @@ -108,17 +108,59 @@ final class DevServerPaywallTests: XCTestCase { XCTAssertEqual(popup.presentation.style, .popup(height: 500, width: 300, cornerRadius: 16)) } - func test_fallsBackToFullscreenWhenAStyleIsUnknownOrIncomplete() { + func test_fallsBackToFullscreenWhenAStyleIsUnknown() { let unknown = Paywall.devServer( surface: surface(presentation: #"{"style": "hologram"}"#), url: url ) XCTAssertEqual(unknown.presentation.style, .fullscreen) + } + + // MARK: - Partly specified geometry - let drawerWithoutGeometry = Paywall.devServer( + func test_drawerWithoutGeometryUsesTheDocumentedDefaults() { + let drawer = Paywall.devServer( surface: surface(presentation: #"{"style": "drawer"}"#), url: url ) - XCTAssertEqual(drawerWithoutGeometry.presentation.style, .fullscreen) + // 70% of the screen is what PaywallPresentationStyle.drawer documents. + XCTAssertEqual(drawer.presentation.style, .drawer(height: 70, cornerRadius: 0)) + } + + func test_drawerKeepsTheValuesItDoesNameAndDefaultsTheRest() { + let heightOnly = Paywall.devServer( + surface: surface(presentation: #"{"style": "drawer", "drawer": {"height": 420}}"#), + url: url + ) + XCTAssertEqual(heightOnly.presentation.style, .drawer(height: 420, cornerRadius: 0)) + + let radiusOnly = Paywall.devServer( + surface: surface(presentation: #"{"style": "drawer", "drawer": {"cornerRadius": 24}}"#), + url: url + ) + XCTAssertEqual(radiusOnly.presentation.style, .drawer(height: 70, cornerRadius: 24)) + } + + func test_popupWithoutBothDimensionsFallsBackToFullscreen() { + // A popup has no documented default size, so a partial one can't be honoured. + let heightOnly = Paywall.devServer( + surface: surface(presentation: #"{"style": "popup", "popup": {"height": 500}}"#), + url: url + ) + XCTAssertEqual(heightOnly.presentation.style, .fullscreen) + + let noGeometry = Paywall.devServer( + surface: surface(presentation: #"{"style": "popup"}"#), + url: url + ) + XCTAssertEqual(noGeometry.presentation.style, .fullscreen) + } + + func test_popupWithBothDimensionsDefaultsOnlyItsRadius() { + let popup = Paywall.devServer( + surface: surface(presentation: #"{"style": "popup", "popup": {"width": 300, "height": 500}}"#), + url: url + ) + XCTAssertEqual(popup.presentation.style, .popup(height: 500, width: 300, cornerRadius: 0)) } } From 5181d83e08a3b97c1304198b3aadee7290dcc27c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:38:37 +0200 Subject: [PATCH 067/162] review: inherit dashboard-owned behaviour, keep the manifest identifiable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev-server override synthesizes the paywall, so every field not named was silently taking the local stub's default. featureGating was the first one caught; these are the rest that change behaviour rather than looks. The surface can't express any of them — the manifest carries only kind, id, url, paywallId, identifier, products and presentation — so the dashboard is the only source and there is nothing to override: - computedPropertyRequests, or a local render lacks variables production resolves and you debug a template bug that doesn't exist - introOfferEligibility, which drives displayed trial state and pricing - surveys and localNotifications, dashboard behaviour that otherwise just stops happening Inheritance now lives in Paywall.devServer(surface:url:inheriting:) rather than as a list at the call site, so there is one place to add to. Also fixes the regression from eedda06: making `surfaces` optional removed the only field identifying the JSON as a superwall manifest, so any process answering a candidate port with a JSON object ended the port walk silently. The key is required again, the probe checks the HTTP status, and the unreadable-manifest log no longer claims the responder is a dev server when the probe can't know that. Drops an unused init and a vacuous test. Co-Authored-By: Claude Opus 5 --- .../DevServer/DevServerLocator.swift | 47 +++++--- .../DevServer/DevServerManifest.swift | 9 +- .../DevServer/DevServerPaywall.swift | 49 +++++++-- .../DevServer/DevServerSurface.swift | 18 ---- .../Operators/RawPaywallResponse.swift | 24 ++--- .../DevServer/DevServerManifestTests.swift | 23 ++-- .../DevServer/DevServerPaywallTests.swift | 101 ++++++++++++++++++ 7 files changed, 202 insertions(+), 69 deletions(-) diff --git a/Sources/SuperwallKit/DevServer/DevServerLocator.swift b/Sources/SuperwallKit/DevServer/DevServerLocator.swift index d354c8551..cf2c7814a 100644 --- a/Sources/SuperwallKit/DevServer/DevServerLocator.swift +++ b/Sources/SuperwallKit/DevServer/DevServerLocator.swift @@ -87,6 +87,30 @@ actor DevServerLocator { ) } + /// Logs a body that carries `surfaces` but wouldn't decode. + /// + /// The probe walks several ports and can't know what is listening on each, + /// so this neither claims the responder is a dev server nor stays silent + /// when it plainly is one whose manifest this SDK can't read. + private func logUnreadableManifest(data: Data, base: URL, error: Error) { + let json = try? JSONSerialization.jsonObject(with: data) + guard let object = json as? [String: Any], + object["surfaces"] != nil + else { + // Not a manifest at all — something else answered. The port walk moves + // on, and `locate` reports it if nothing else turns up. + return + } + Logger.debug( + logLevel: .error, + scope: .superwallCore, + message: "Something at \(base.absoluteString) answered /device/manifest.json with a " + + "manifest this SDK couldn't read. Those paywalls will load their published " + + "versions. Check that superwall dev and SuperwallKit are on compatible versions.", + error: error + ) + } + private func fetchManifest(from base: URL) async -> DevServerManifest? { guard let manifestURL = URL(string: "/device/manifest.json", relativeTo: base) else { return nil @@ -96,29 +120,26 @@ actor DevServerLocator { request.cachePolicy = .reloadIgnoringLocalCacheData do { - let data: Data = try await withCheckedThrowingContinuation { continuation in - let task = URLSession.shared.dataTask(with: request) { data, _, error in + let (data, response): (Data, URLResponse?) = try await withCheckedThrowingContinuation { continuation in + let task = URLSession.shared.dataTask(with: request) { data, response, error in if let data = data { - continuation.resume(returning: data) + continuation.resume(returning: (data, response)) } else { continuation.resume(throwing: error ?? URLError(.badServerResponse)) } } task.resume() } + // Something else on this port may answer an unknown path with a JSON + // error body, so the status has to rule that out before the body does. + if let http = response as? HTTPURLResponse, + !(200..<300).contains(http.statusCode) { + return nil + } do { return try JSONDecoder().decode(DevServerManifest.self, from: data) } catch { - // A server answered; its manifest just didn't parse. Say so, or the - // caller's "no server found" log points at the wrong cause. - Logger.debug( - logLevel: .error, - scope: .superwallCore, - message: "The superwall dev server at \(base.absoluteString) answered with a manifest " - + "this SDK couldn't read. Paywalls will load their published versions. " - + "Check that superwall dev and SuperwallKit are on compatible versions.", - error: error - ) + logUnreadableManifest(data: data, base: base, error: error) return nil } } catch { diff --git a/Sources/SuperwallKit/DevServer/DevServerManifest.swift b/Sources/SuperwallKit/DevServer/DevServerManifest.swift index 9f29b81e7..1ab09a8d6 100644 --- a/Sources/SuperwallKit/DevServer/DevServerManifest.swift +++ b/Sources/SuperwallKit/DevServer/DevServerManifest.swift @@ -21,10 +21,11 @@ struct DevServerManifest: Decodable, Equatable { // Per-element decoding: the CLI writing this manifest versions separately // from the SDK, so one surface the SDK can't read must not take down the // surfaces it can. - let decoded = try container.decodeIfPresent( - [Throwable].self, - forKey: .surfaces - ) ?? [] + // Required: `surfaces` is the only thing that tells this JSON apart from + // whatever else might answer on a candidate port, so a body without it + // must fail rather than end the port walk. A project with no surfaces + // still sends `{"surfaces": []}`. + let decoded = try container.decode([Throwable].self, forKey: .surfaces) surfaces = decoded.compactMap { try? $0.result.get() } let dropped = decoded.count - surfaces.count diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift index 8ea576a50..9ad1a2115 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift @@ -11,7 +11,21 @@ import Foundation import UIKit extension Paywall { - static func devServer(surface: DevServerSurface, url: URL) -> Paywall { + /// Builds the paywall a dev server surface presents. + /// + /// - Parameter published: the dashboard paywall this surface stands in for, + /// if any. The surface owns what renders and how — its bytes, products and + /// `config.ts` presentation style. Everything the dashboard configures that + /// a manifest can't express is inherited from `published` instead, so dev + /// mode changes how a paywall looks and never how it behaves. + /// + /// Anything added to `Paywall` later defaults to the local stub's value, so + /// if it is dashboard-owned behaviour it belongs in the inherited list below. + static func devServer( + surface: DevServerSurface, + url: URL, + inheriting published: Paywall? = nil + ) -> Paywall { let products = (surface.products ?? [:]) .sorted { $0.key < $1.key } .map { reference, identifier in @@ -23,11 +37,21 @@ extension Paywall { ) } + let databaseId: String = surface.paywallId ?? "dev:\(surface.kind)/\(surface.id)" + let identifier: String = surface.identifier ?? "dev:\(surface.id)" + let cacheKey = "dev:\(surface.id):\(url.absoluteString)" + let responseLoadingInfo: LoadingInfo = published?.responseLoadingInfo ?? .init() + let featureGating: FeatureGatingBehavior = published?.featureGating ?? .nonGated + let computedPropertyRequests: [ComputedPropertyRequest] = published?.computedPropertyRequests ?? [] + let localNotifications: [LocalNotification] = published?.localNotifications ?? [] + let surveys: [Survey] = published?.surveys ?? [] + let introOfferEligibility: IntroOfferEligibility = published?.introOfferEligibility ?? .automatic + var paywall = Paywall( - databaseId: surface.paywallId ?? "dev:\(surface.kind)/\(surface.id)", - identifier: surface.identifier ?? "dev:\(surface.id)", + databaseId: databaseId, + identifier: identifier, name: surface.id, - cacheKey: "dev:\(surface.id):\(url.absoluteString)", + cacheKey: cacheKey, buildId: "dev", url: url, urlConfig: WebViewURLConfig( @@ -46,15 +70,28 @@ extension Paywall { productItems: products, productIds: products.map { $0.id }, appStoreProductIds: products.map { $0.id }, - responseLoadingInfo: .init(), + responseLoadingInfo: responseLoadingInfo, webviewLoadingInfo: .init(), productsLoadingInfo: .init(), shimmerLoadingInfo: .init(), paywalljsVersion: "", + // Feature gating decides whether a non-paying user gets the feature, so + // it can never come from local paywall code. + featureGating: featureGating, + // Dashboard-configured behaviour that fires around the paywall rather + // than inside it. + localNotifications: localNotifications, + // The variables the page reads: without these, a local render silently + // lacks computed properties that production resolves. + computedPropertyRequests: computedPropertyRequests, + surveys: surveys, isScrollEnabled: true, - introOfferEligibility: .automatic + // Drives displayed trial state and pricing, which is exactly what a + // local preview is checked against. + introOfferEligibility: introOfferEligibility ) paywall.isLocal = true + paywall.experiment = published?.experiment return paywall } diff --git a/Sources/SuperwallKit/DevServer/DevServerSurface.swift b/Sources/SuperwallKit/DevServer/DevServerSurface.swift index df54c0bc2..940c7a834 100644 --- a/Sources/SuperwallKit/DevServer/DevServerSurface.swift +++ b/Sources/SuperwallKit/DevServer/DevServerSurface.swift @@ -61,22 +61,4 @@ struct DevServerSurface: Decodable, Equatable { // read costs the surface its style, not its ability to be served. presentation = try? container.decodeIfPresent(Presentation.self, forKey: .presentation) } - - init( - kind: String, - id: String, - url: String, - paywallId: String? = nil, - identifier: String? = nil, - products: [String: String]? = nil, - presentation: Presentation? = nil - ) { - self.kind = kind - self.id = id - self.url = url - self.paywallId = paywallId - self.identifier = identifier - self.products = products - self.presentation = presentation - } } diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift index 3c19ef33f..cfd89a708 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift @@ -43,19 +43,17 @@ extension PaywallRequestManager { return paywall } - // The local surface replaces the published paywall wholesale, so what - // presents is exactly what its config.ts declares — products included. - // The assignment's experiment and the fetch timings carry over, keeping - // holdouts and analytics coherent. The synthesized cacheKey embeds the - // mount URL, so a moved dev server or a published fallback reloads the - // web view instead of presenting the stale page. - var devPaywall = Paywall.devServer(surface: surface, url: mountURL) - devPaywall.experiment = paywall.experiment - devPaywall.responseLoadingInfo = paywall.responseLoadingInfo - // Feature gating belongs to the dashboard, not to the paywall's code: - // dev mode previews how a paywall looks, and must never be what decides - // whether a non-paying user gets the feature. - devPaywall.featureGating = paywall.featureGating + // The local surface replaces the published paywall's bytes, products and + // presentation, and inherits everything the dashboard configures that a + // manifest can't express — see Paywall.devServer(surface:url:inheriting:). + // The synthesized cacheKey embeds the mount URL, so a moved dev server or + // a published fallback reloads the web view instead of presenting the + // stale page. + let devPaywall = Paywall.devServer( + surface: surface, + url: mountURL, + inheriting: paywall + ) Logger.debug( logLevel: .info, diff --git a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift index f78932c48..222fb1cf6 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift @@ -164,23 +164,16 @@ final class DevServerManifestTests: XCTestCase { XCTAssertNil(decoded.surfaces[0].presentation) } - func test_anUnknownFieldDoesNotDropTheSurface() throws { - let decoded = try manifest(""" - { - "surfaces": [ - { - "kind": "paywall", - "id": "pro", - "url": "/preview/paywall/pro", - "somethingTheCliAddedLater": { "a": 1 } - } - ] + func test_aBodyWithoutSurfacesIsNotAManifest() { + // `surfaces` is what tells this JSON apart from anything else that might + // answer on a candidate port, so these must not decode — otherwise the + // port walk stops on the wrong process. + for body in ["{}", #"{"detail": "Not Found"}"#, #"{"error": {"code": 404}}"#] { + XCTAssertThrowsError(try manifest(body), body) } - """) - XCTAssertEqual(decoded.surfaces.map { $0.id }, ["pro"]) } - func test_missingSurfacesKeyDecodesAsEmpty() throws { - XCTAssertTrue(try manifest("{}").surfaces.isEmpty) + func test_anEmptySurfaceListIsStillAManifest() throws { + XCTAssertTrue(try manifest(#"{"surfaces": []}"#).surfaces.isEmpty) } } diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift index ae9c7db91..937ce3bf6 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift @@ -163,4 +163,105 @@ final class DevServerPaywallTests: XCTestCase { ) XCTAssertEqual(popup.presentation.style, .popup(height: 500, width: 300, cornerRadius: 0)) } + + // MARK: - What the dashboard keeps owning + + /// The manifest can't express any of these, so a bound surface has to take + /// them from the paywall it stands in for or they vanish silently. + func test_inheritsDashboardOwnedBehaviourFromThePublishedPaywall() { + var published = Paywall.stub() + published.featureGating = .gated + published.surveys = [Survey.stub()] + published.localNotifications = [LocalNotification.stub()] + + let paywall = Paywall.devServer( + surface: surface(), + url: url, + inheriting: published + ) + + XCTAssertEqual(paywall.featureGating, .gated) + XCTAssertEqual(paywall.surveys.count, 1) + XCTAssertEqual(paywall.localNotifications.count, 1) + } + + func test_inheritsComputedPropertiesAndIntroOfferEligibility() throws { + let json = """ + { + "computedPropertyRequests": [ + { "type": "HOURS_SINCE", "eventName": "trigger1" } + ], + "introductoryOfferEligibility": "INELIGIBLE" + } + """ + // Decoded rather than hand-built so the test pins the real dashboard shape. + struct Fields: Decodable { + let computedPropertyRequests: [ComputedPropertyRequest] + let introductoryOfferEligibility: IntroOfferEligibility + } + let fields = try JSONDecoder().decode(Fields.self, from: Data(json.utf8)) + + var published = Paywall.stub() + published = Paywall( + databaseId: published.databaseId, + identifier: published.identifier, + name: published.name, + cacheKey: published.cacheKey, + buildId: published.buildId, + url: published.url, + urlConfig: published.urlConfig, + htmlSubstitutions: published.htmlSubstitutions, + presentation: published.presentation, + backgroundColorHex: published.backgroundColorHex, + backgroundColor: published.backgroundColor, + darkBackgroundColorHex: nil, + darkBackgroundColor: nil, + productItems: [], + productIds: [], + appStoreProductIds: [], + responseLoadingInfo: .init(), + webviewLoadingInfo: .init(), + productsLoadingInfo: .init(), + shimmerLoadingInfo: .init(), + paywalljsVersion: "", + computedPropertyRequests: fields.computedPropertyRequests, + isScrollEnabled: true, + introOfferEligibility: fields.introductoryOfferEligibility + ) + + let paywall = Paywall.devServer(surface: surface(), url: url, inheriting: published) + + XCTAssertEqual(paywall.computedPropertyRequests.count, 1) + XCTAssertEqual(paywall.introOfferEligibility, fields.introductoryOfferEligibility) + } + + func test_anUnboundSurfaceKeepsTheSafeDefaults() { + // The debugger previews surfaces with no dashboard counterpart, so there + // is nothing to inherit and gating must stay off rather than guess. + let paywall = Paywall.devServer(surface: surface(), url: url) + + XCTAssertEqual(paywall.featureGating, .nonGated) + XCTAssertTrue(paywall.surveys.isEmpty) + XCTAssertTrue(paywall.localNotifications.isEmpty) + XCTAssertTrue(paywall.computedPropertyRequests.isEmpty) + XCTAssertEqual(paywall.introOfferEligibility, .automatic) + } + + func test_theLocalSurfaceStillOwnsWhatItRenders() { + var published = Paywall.stub() + published.featureGating = .gated + + let paywall = Paywall.devServer( + surface: surface(products: ["plus": "local_product"], presentation: #"{"style": "modal"}"#), + url: url, + inheriting: published + ) + + // Inherited behaviour must not drag the published rendering along with it. + XCTAssertEqual(paywall.url, url) + XCTAssertEqual(paywall.productIds, ["local_product"]) + XCTAssertEqual(paywall.presentation.style, .modal) + XCTAssertTrue(paywall.isLocal) + XCTAssertNil(paywall.manifest) + } } From 8dee6689e7148c8bb1184efaddad2bb3e7b461ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:44:03 +0200 Subject: [PATCH 068/162] fix(dev): let the local paywall keep owning its notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A paywall's config.ts can declare notifications (SuperwallNotificationsConfig in the CLI's runtime package), and they reach the SDK as `schedule_notification` messages rather than through Paywall.localNotifications. So notifications are not dashboard-only, and inheriting them was wrong: NotificationScheduler dedupes on paywallId + type, so for a bound surface the dashboard's copy could win that filter and fire instead of the local one you are iterating on. The other three inherited fields stand — the manifest still can't express featureGating, computedPropertyRequests or introOfferEligibility. Co-Authored-By: Claude Opus 5 --- .../SuperwallKit/DevServer/DevServerPaywall.swift | 10 ++++++---- .../DevServer/DevServerPaywallTests.swift | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift index 9ad1a2115..e3e369cf0 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift @@ -43,7 +43,6 @@ extension Paywall { let responseLoadingInfo: LoadingInfo = published?.responseLoadingInfo ?? .init() let featureGating: FeatureGatingBehavior = published?.featureGating ?? .nonGated let computedPropertyRequests: [ComputedPropertyRequest] = published?.computedPropertyRequests ?? [] - let localNotifications: [LocalNotification] = published?.localNotifications ?? [] let surveys: [Survey] = published?.surveys ?? [] let introOfferEligibility: IntroOfferEligibility = published?.introOfferEligibility ?? .automatic @@ -78,9 +77,12 @@ extension Paywall { // Feature gating decides whether a non-paying user gets the feature, so // it can never come from local paywall code. featureGating: featureGating, - // Dashboard-configured behaviour that fires around the paywall rather - // than inside it. - localNotifications: localNotifications, + // Deliberately not inherited: a local paywall declares its own + // notifications in config.ts, and they reach the SDK as + // `schedule_notification` messages rather than through this field. + // Inheriting the dashboard's would let a stale copy win the + // paywallId+type dedupe in NotificationScheduler. + localNotifications: [], // The variables the page reads: without these, a local render silently // lacks computed properties that production resolves. computedPropertyRequests: computedPropertyRequests, diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift index 937ce3bf6..39fbae170 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift @@ -172,7 +172,6 @@ final class DevServerPaywallTests: XCTestCase { var published = Paywall.stub() published.featureGating = .gated published.surveys = [Survey.stub()] - published.localNotifications = [LocalNotification.stub()] let paywall = Paywall.devServer( surface: surface(), @@ -182,7 +181,18 @@ final class DevServerPaywallTests: XCTestCase { XCTAssertEqual(paywall.featureGating, .gated) XCTAssertEqual(paywall.surveys.count, 1) - XCTAssertEqual(paywall.localNotifications.count, 1) + } + + func test_doesNotInheritNotificationsTheLocalPaywallDeclaresItself() { + // config.ts can declare notifications, and they arrive as + // `schedule_notification` messages. Inheriting the dashboard's would let + // a stale copy win NotificationScheduler's paywallId+type dedupe. + var published = Paywall.stub() + published.localNotifications = [LocalNotification.stub()] + + let paywall = Paywall.devServer(surface: surface(), url: url, inheriting: published) + + XCTAssertTrue(paywall.localNotifications.isEmpty) } func test_inheritsComputedPropertiesAndIntroOfferEligibility() throws { From 002765772df7ecbbf01f7ea4d37ac756930c2a3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:31:22 +0200 Subject: [PATCH 069/162] fix(dev): inherit what the manifest can't carry, honour multi-paywall bindings Matches the SDK to the manifest the shipped CLI actually sends: kind, id, url, paywallId, paywallIds, identifier and products. Everything else a paywall's config.ts declares reaches the SDK only after a push, so a dev-served paywall has to take it from the published paywall it stands in for rather than from a hardcoded stub value. - presentation: a bound paywall was being forced to .fullscreen, losing the drawer or modal style the dashboard configured. The style now falls back to the published paywall's when the manifest declares none. - background colours and isScrollEnabled inherit for the same reason; the hardcoded white also flashed on load in dark mode. - paywallIds: superwall.lock can bind one surface to several paywalls and the CLI already sends the whole set, but matching read only the singular, so a multi-bound surface served its first paywall and fell through to published for the rest. - featureGating and introductoryOfferEligibility are read off the surface first when present. Nothing sends them yet, so this is inert, but it means no matching SDK release is needed once they are carried. - presentation defaults now mirror the CLI's DRAWER_DEFAULTS and POPUP_DEFAULTS (70/15, 80x60/15) instead of a guessed zero radius, so a partly specified block presents the same before and after a push. Co-Authored-By: Claude Opus 5 --- .../DevServer/DevServerManifest.swift | 10 +- .../DevServer/DevServerPaywall.swift | 129 +++++++++++++----- .../DevServer/DevServerSurface.swift | 24 ++++ .../DevServer/DevServerManifestTests.swift | 24 ++++ .../DevServer/DevServerPaywallTests.swift | 114 +++++++++++++--- 5 files changed, 245 insertions(+), 56 deletions(-) diff --git a/Sources/SuperwallKit/DevServer/DevServerManifest.swift b/Sources/SuperwallKit/DevServer/DevServerManifest.swift index 1ab09a8d6..017bbe8a7 100644 --- a/Sources/SuperwallKit/DevServer/DevServerManifest.swift +++ b/Sources/SuperwallKit/DevServer/DevServerManifest.swift @@ -47,7 +47,15 @@ struct DevServerManifest: Decodable, Equatable { /// `superwall.lock` binding wins, otherwise a project with exactly one /// paywall serves it for everything. func surface(forPaywallDatabaseId databaseId: String) -> DevServerSurface? { - if let bound = surfaces.first(where: { $0.paywallId == databaseId }) { + let bound = surfaces.first { surface in + if surface.paywallId == databaseId { + return true + } + // superwall.lock can bind one surface to several paywalls; the CLI + // sends the first as `paywallId` and the whole set as `paywallIds`. + return surface.paywallIds?.contains(databaseId) ?? false + } + if let bound = bound { return bound } let paywalls = surfaces.filter { $0.kind == "paywall" } diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift index e3e369cf0..466534406 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift @@ -26,25 +26,30 @@ extension Paywall { url: URL, inheriting published: Paywall? = nil ) -> Paywall { - let products = (surface.products ?? [:]) - .sorted { $0.key < $1.key } - .map { reference, identifier in - Product( - name: reference, - type: .appStore(.init(id: identifier)), - id: identifier, - entitlements: [] - ) - } + let products = productItems(from: surface) let databaseId: String = surface.paywallId ?? "dev:\(surface.kind)/\(surface.id)" let identifier: String = surface.identifier ?? "dev:\(surface.id)" let cacheKey = "dev:\(surface.id):\(url.absoluteString)" let responseLoadingInfo: LoadingInfo = published?.responseLoadingInfo ?? .init() - let featureGating: FeatureGatingBehavior = published?.featureGating ?? .nonGated + // The surface's own config.ts wins, then the dashboard's published + // setting, then the safe default. That order is what lets an unpushed + // config.ts edit take effect while a bound paywall still behaves like + // production until you change it. + let featureGating: FeatureGatingBehavior = gating(from: surface) + ?? published?.featureGating + ?? .nonGated let computedPropertyRequests: [ComputedPropertyRequest] = published?.computedPropertyRequests ?? [] let surveys: [Survey] = published?.surveys ?? [] - let introOfferEligibility: IntroOfferEligibility = published?.introOfferEligibility ?? .automatic + let introOfferEligibility: IntroOfferEligibility = eligibility(from: surface) + ?? published?.introOfferEligibility + ?? .automatic + let presentation: PaywallPresentationInfo + if let style = presentationStyle(for: surface) { + presentation = PaywallPresentationInfo(style: style, delay: 0) + } else { + presentation = published?.presentation ?? PaywallPresentationInfo(style: .fullscreen, delay: 0) + } var paywall = Paywall( databaseId: databaseId, @@ -58,14 +63,11 @@ extension Paywall { maxAttempts: 1 ), htmlSubstitutions: "", - presentation: PaywallPresentationInfo( - style: presentationStyle(for: surface), - delay: 0 - ), - backgroundColorHex: "#FFFFFF", - backgroundColor: .white, - darkBackgroundColorHex: nil, - darkBackgroundColor: nil, + presentation: presentation, + backgroundColorHex: published?.backgroundColorHex ?? "#FFFFFF", + backgroundColor: published?.backgroundColor ?? .white, + darkBackgroundColorHex: published?.darkBackgroundColorHex, + darkBackgroundColor: published?.darkBackgroundColor, productItems: products, productIds: products.map { $0.id }, appStoreProductIds: products.map { $0.id }, @@ -87,7 +89,7 @@ extension Paywall { // lacks computed properties that production resolves. computedPropertyRequests: computedPropertyRequests, surveys: surveys, - isScrollEnabled: true, + isScrollEnabled: published?.isScrollEnabled ?? true, // Drives displayed trial state and pricing, which is exactly what a // local preview is checked against. introOfferEligibility: introOfferEligibility @@ -97,17 +99,72 @@ extension Paywall { return paywall } - /// The height a drawer takes when its `config.ts` doesn't name one, as a - /// percentage of the screen. Matches what `PaywallPresentationStyle/drawer` - /// documents. + /// The products a surface's `config.ts` declares, in a stable order. + private static func productItems(from surface: DevServerSurface) -> [Product] { + return (surface.products ?? [:]) + .sorted { $0.key < $1.key } + .map { reference, identifier in + Product( + name: reference, + type: .appStore(.init(id: identifier)), + id: identifier, + entitlements: [] + ) + } + } + + /// Maps a surface's `config.ts` feature gating onto the SDK's enum. + /// + /// Returns nil for anything unrecognised — including a CLI newer than this + /// SDK — so the caller falls back rather than guessing how a feature gates. + private static func gating(from surface: DevServerSurface) -> FeatureGatingBehavior? { + switch surface.featureGating { + case "gated": + return .gated + case "nonGated": + return .nonGated + default: + return nil + } + } + + /// Maps a surface's `config.ts` trial eligibility onto the SDK's enum, + /// returning nil for anything unrecognised. + private static func eligibility(from surface: DevServerSurface) -> IntroOfferEligibility? { + switch surface.introductoryOfferEligibility { + case "automatic": + return .automatic + case "alwaysEligible": + return .eligible + case "alwaysIneligible": + return .ineligible + default: + return nil + } + } + + /// Geometry a `config.ts` presentation block gets when it doesn't name its + /// own. These mirror the `superwall` CLI's DRAWER_DEFAULTS and + /// POPUP_DEFAULTS, which resolve the same values on push, so a partly + /// specified block presents identically before and after one. The drawer + /// height also matches what `PaywallPresentationStyle/drawer` documents. private static let defaultDrawerHeight: Double = 70 + private static let defaultDrawerCornerRadius: Double = 15 + private static let defaultPopupWidth: Double = 80 + private static let defaultPopupHeight: Double = 60 + private static let defaultPopupCornerRadius: Double = 15 /// Maps a surface's `config.ts` presentation onto the SDK's styles. /// The framework documents `fullscreen` as its default, so anything /// missing or unrecognized lands there. private static func presentationStyle( for surface: DevServerSurface - ) -> PaywallPresentationStyle { + ) -> PaywallPresentationStyle? { + if surface.presentation == nil { + // The manifest says nothing about presentation, so the dashboard's + // style stands rather than being replaced by a guess. + return nil + } switch surface.presentation?.style { case "modal": return .modal @@ -116,23 +173,21 @@ extension Paywall { case "noAnimation": return .fullscreenNoAnimation case "drawer": - // A drawer that names only some of its geometry is still a drawer: - // PaywallPresentationStyle documents 70% of the screen as the default - // height, and an unset radius means no rounding. + // A drawer that names only some of its geometry is still a drawer. + // These match the CLI's own DRAWER_DEFAULTS, so a partly specified + // drawer looks the same here as it will once it's pushed. let drawer = surface.presentation?.drawer return .drawer( height: drawer?.height ?? defaultDrawerHeight, - cornerRadius: drawer?.cornerRadius ?? 0 + cornerRadius: drawer?.cornerRadius ?? defaultDrawerCornerRadius ) case "popup": - // Unlike the drawer, a popup has no documented default size, so one - // without both dimensions falls back to fullscreen. - if let popup = surface.presentation?.popup, - let height = popup.height, - let width = popup.width { - return .popup(height: height, width: width, cornerRadius: popup.cornerRadius ?? 0) - } - return .fullscreen + let popup = surface.presentation?.popup + return .popup( + height: popup?.height ?? defaultPopupHeight, + width: popup?.width ?? defaultPopupWidth, + cornerRadius: popup?.cornerRadius ?? defaultPopupCornerRadius + ) default: return .fullscreen } diff --git a/Sources/SuperwallKit/DevServer/DevServerSurface.swift b/Sources/SuperwallKit/DevServer/DevServerSurface.swift index 940c7a834..4e4d9c472 100644 --- a/Sources/SuperwallKit/DevServer/DevServerSurface.swift +++ b/Sources/SuperwallKit/DevServer/DevServerSurface.swift @@ -35,18 +35,36 @@ struct DevServerSurface: Decodable, Equatable { let id: String let url: String let paywallId: String? + + /// Every dashboard paywall this surface is bound to, when `superwall.lock` + /// binds it to more than one. The CLI sends `paywallId` for the first and + /// this for the full set. + let paywallIds: [String]? let identifier: String? let products: [String: String]? let presentation: Presentation? + /// `config.ts` feature gating: "gated" or "nonGated". + /// + /// Kept as the raw string so a value this SDK doesn't recognise falls back + /// to the published paywall's setting instead of failing the surface. + let featureGating: String? + + /// `config.ts` trial eligibility: "automatic", "alwaysEligible" or + /// "alwaysIneligible". Raw for the same reason as `featureGating`. + let introductoryOfferEligibility: String? + private enum CodingKeys: String, CodingKey { case kind case id case url case paywallId + case paywallIds case identifier case products case presentation + case featureGating + case introductoryOfferEligibility } init(from decoder: Decoder) throws { @@ -55,10 +73,16 @@ struct DevServerSurface: Decodable, Equatable { id = try container.decode(String.self, forKey: .id) url = try container.decode(String.self, forKey: .url) paywallId = try container.decodeIfPresent(String.self, forKey: .paywallId) + paywallIds = try container.decodeIfPresent([String].self, forKey: .paywallIds) identifier = try container.decodeIfPresent(String.self, forKey: .identifier) products = try container.decodeIfPresent([String: String].self, forKey: .products) // Presentation is a hint, not the surface itself. A block this SDK can't // read costs the surface its style, not its ability to be served. presentation = try? container.decodeIfPresent(Presentation.self, forKey: .presentation) + featureGating = try container.decodeIfPresent(String.self, forKey: .featureGating) + introductoryOfferEligibility = try container.decodeIfPresent( + String.self, + forKey: .introductoryOfferEligibility + ) } } diff --git a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift index 222fb1cf6..d4b45e31a 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift @@ -176,4 +176,28 @@ final class DevServerManifestTests: XCTestCase { func test_anEmptySurfaceListIsStillAManifest() throws { XCTAssertTrue(try manifest(#"{"surfaces": []}"#).surfaces.isEmpty) } + + func test_matchesASurfaceBoundToSeveralPaywalls() { + // superwall.lock can bind one surface to several paywalls: the CLI sends + // the first as `paywallId` and the whole set as `paywallIds`. + // swiftlint:disable:next force_try + let decoded = try! manifest(""" + { + "surfaces": [ + { "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro" }, + { + "kind": "paywall", + "id": "shared", + "url": "/preview/paywall/shared", + "paywallId": "111", + "paywallIds": ["111", "222", "333"] + } + ] + } + """) + XCTAssertEqual(decoded.surface(forPaywallDatabaseId: "111")?.id, "shared") + XCTAssertEqual(decoded.surface(forPaywallDatabaseId: "222")?.id, "shared") + XCTAssertEqual(decoded.surface(forPaywallDatabaseId: "333")?.id, "shared") + XCTAssertNil(decoded.surface(forPaywallDatabaseId: "444")) + } } diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift index 39fbae170..f7bb9e62b 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift @@ -12,7 +12,9 @@ final class DevServerPaywallTests: XCTestCase { paywallId: String? = nil, identifier: String? = nil, products: [String: String]? = nil, - presentation: String? = nil + presentation: String? = nil, + featureGating: String? = nil, + introductoryOfferEligibility: String? = nil ) -> DevServerSurface { let json = """ { @@ -22,6 +24,10 @@ final class DevServerPaywallTests: XCTestCase { \(paywallId.map { "\"paywallId\": \"\($0)\"," } ?? "") \(identifier.map { "\"identifier\": \"\($0)\"," } ?? "") \(presentation.map { "\"presentation\": \($0)," } ?? "") + \(featureGating.map { "\"featureGating\": \"\($0)\"," } ?? "") + \(introductoryOfferEligibility.map { + "\"introductoryOfferEligibility\": \"\($0)\"," + } ?? "") "products": \(products.map { dict in "{" + dict.map { "\"\($0.key)\": \"\($0.value)\"" }.sorted().joined(separator: ",") + "}" } ?? "null") @@ -83,7 +89,24 @@ final class DevServerPaywallTests: XCTestCase { XCTAssertEqual(params["is_local"] as? Bool, true) } - func test_presentsFullscreenWhenTheConfigSaysNothing() { + func test_inheritsTheDashboardStyleWhenTheManifestSaysNothing() { + // The shipped manifest carries no presentation, so a bound paywall has to + // keep the style the dashboard configured rather than snap to fullscreen. + var published = Paywall.stub() + XCTAssertEqual(published.presentation.style, .modal) + + let paywall = Paywall.devServer(surface: surface(), url: url, inheriting: published) + + XCTAssertEqual(paywall.presentation.style, .modal) + + // Visual settings the manifest can't carry come from there too. + published = Paywall.stub() + let visuals = Paywall.devServer(surface: surface(), url: url, inheriting: published) + XCTAssertEqual(visuals.backgroundColorHex, published.backgroundColorHex) + XCTAssertEqual(visuals.isScrollEnabled, published.isScrollEnabled) + } + + func test_presentsFullscreenWhenNothingDeclaresAStyle() { let paywall = Paywall.devServer(surface: surface(), url: url) XCTAssertEqual(paywall.presentation.style, .fullscreen) } @@ -118,13 +141,13 @@ final class DevServerPaywallTests: XCTestCase { // MARK: - Partly specified geometry - func test_drawerWithoutGeometryUsesTheDocumentedDefaults() { + func test_drawerWithoutGeometryUsesTheCliDefaults() { let drawer = Paywall.devServer( surface: surface(presentation: #"{"style": "drawer"}"#), url: url ) - // 70% of the screen is what PaywallPresentationStyle.drawer documents. - XCTAssertEqual(drawer.presentation.style, .drawer(height: 70, cornerRadius: 0)) + // Mirrors the CLI's DRAWER_DEFAULTS, which resolves the same values on push. + XCTAssertEqual(drawer.presentation.style, .drawer(height: 70, cornerRadius: 15)) } func test_drawerKeepsTheValuesItDoesNameAndDefaultsTheRest() { @@ -132,7 +155,7 @@ final class DevServerPaywallTests: XCTestCase { surface: surface(presentation: #"{"style": "drawer", "drawer": {"height": 420}}"#), url: url ) - XCTAssertEqual(heightOnly.presentation.style, .drawer(height: 420, cornerRadius: 0)) + XCTAssertEqual(heightOnly.presentation.style, .drawer(height: 420, cornerRadius: 15)) let radiusOnly = Paywall.devServer( surface: surface(presentation: #"{"style": "drawer", "drawer": {"cornerRadius": 24}}"#), @@ -141,27 +164,28 @@ final class DevServerPaywallTests: XCTestCase { XCTAssertEqual(radiusOnly.presentation.style, .drawer(height: 70, cornerRadius: 24)) } - func test_popupWithoutBothDimensionsFallsBackToFullscreen() { - // A popup has no documented default size, so a partial one can't be honoured. - let heightOnly = Paywall.devServer( - surface: surface(presentation: #"{"style": "popup", "popup": {"height": 500}}"#), - url: url - ) - XCTAssertEqual(heightOnly.presentation.style, .fullscreen) - + func test_popupWithoutGeometryUsesTheCliDefaults() { + // Mirrors the CLI's POPUP_DEFAULTS rather than falling back to fullscreen, + // so a partly specified popup is still a popup. let noGeometry = Paywall.devServer( surface: surface(presentation: #"{"style": "popup"}"#), url: url ) - XCTAssertEqual(noGeometry.presentation.style, .fullscreen) + XCTAssertEqual(noGeometry.presentation.style, .popup(height: 60, width: 80, cornerRadius: 15)) } - func test_popupWithBothDimensionsDefaultsOnlyItsRadius() { - let popup = Paywall.devServer( + func test_popupKeepsTheValuesItDoesNameAndDefaultsTheRest() { + let heightOnly = Paywall.devServer( + surface: surface(presentation: #"{"style": "popup", "popup": {"height": 500}}"#), + url: url + ) + XCTAssertEqual(heightOnly.presentation.style, .popup(height: 500, width: 80, cornerRadius: 15)) + + let sized = Paywall.devServer( surface: surface(presentation: #"{"style": "popup", "popup": {"width": 300, "height": 500}}"#), url: url ) - XCTAssertEqual(popup.presentation.style, .popup(height: 500, width: 300, cornerRadius: 0)) + XCTAssertEqual(sized.presentation.style, .popup(height: 500, width: 300, cornerRadius: 15)) } // MARK: - What the dashboard keeps owning @@ -274,4 +298,58 @@ final class DevServerPaywallTests: XCTestCase { XCTAssertTrue(paywall.isLocal) XCTAssertNil(paywall.manifest) } + + // MARK: - The surface's own settings win + + func test_theSurfacesOwnGatingBeatsThePublishedPaywalls() { + var published = Paywall.stub() + published.featureGating = .gated + + let paywall = Paywall.devServer( + surface: surface(featureGating: "nonGated"), + url: url, + inheriting: published + ) + + // An unpushed config.ts edit has to take effect, or dev mode shows the + // setting you just changed away from. + XCTAssertEqual(paywall.featureGating, .nonGated) + } + + func test_theSurfacesOwnEligibilityBeatsThePublishedPaywalls() { + let paywall = Paywall.devServer( + surface: surface(introductoryOfferEligibility: "alwaysIneligible"), + url: url, + inheriting: Paywall.stub() + ) + XCTAssertEqual(paywall.introOfferEligibility, .ineligible) + } + + func test_settingsTheSurfaceOmitsStillComeFromTheDashboard() { + var published = Paywall.stub() + published.featureGating = .gated + + let paywall = Paywall.devServer(surface: surface(), url: url, inheriting: published) + + XCTAssertEqual(paywall.featureGating, .gated) + } + + func test_aValueThisSdkDoesNotKnowFallsBackRatherThanGuessing() { + var published = Paywall.stub() + published.featureGating = .gated + + // A CLI newer than this SDK must not silently ungate a paywall. + let paywall = Paywall.devServer( + surface: surface(featureGating: "someFutureMode"), + url: url, + inheriting: published + ) + + XCTAssertEqual(paywall.featureGating, .gated) + } + + func test_anUnboundSurfaceStillHonoursItsOwnGating() { + let paywall = Paywall.devServer(surface: surface(featureGating: "gated"), url: url) + XCTAssertEqual(paywall.featureGating, .gated) + } } From bff8c7919bbc602aa93568257e19cd5fea6c4b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:51:58 +0200 Subject: [PATCH 070/162] refactor(dev): read paywall settings from the published paywall only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A paywall's config.ts settings — presentation, feature gating, intro offer eligibility — reach the SDK in the snapshot that `superwall push` uploads, not on the dev server's manifest. Verified against the shipped CLI (1.2.0): buildDeviceManifest emits kind, id, url, paywallId, paywallIds, identifier and products, and a running dev server returns exactly that for a config that declares a drawer presentation. So the surface-level decoding of those settings could never fire. This drops DevServerSurface's presentation, featureGating and introductoryOfferEligibility along with the style mapping and the drawer/popup geometry defaults that mirrored the CLI's, leaving the manifest's real shape and a plain synthesized Decodable. The settings come from the published paywall the surface stands in for, falling back to safe defaults when there isn't one. Removes ten tests that exercised the unreachable paths. Co-Authored-By: Claude Opus 5 --- .../DevServer/DevServerPaywall.swift | 104 ++------------ .../DevServer/DevServerSurface.swift | 77 ++-------- .../DevServer/DevServerManifestTests.swift | 16 --- .../DevServer/DevServerPaywallTests.swift | 136 +----------------- 4 files changed, 23 insertions(+), 310 deletions(-) diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift index 466534406..390b7ca6d 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift @@ -32,24 +32,16 @@ extension Paywall { let identifier: String = surface.identifier ?? "dev:\(surface.id)" let cacheKey = "dev:\(surface.id):\(url.absoluteString)" let responseLoadingInfo: LoadingInfo = published?.responseLoadingInfo ?? .init() - // The surface's own config.ts wins, then the dashboard's published - // setting, then the safe default. That order is what lets an unpushed - // config.ts edit take effect while a bound paywall still behaves like - // production until you change it. - let featureGating: FeatureGatingBehavior = gating(from: surface) - ?? published?.featureGating - ?? .nonGated + // A paywall's config.ts settings reach the SDK in the pushed snapshot, + // not the dev manifest, so they come from the published paywall this + // surface stands in for. Without one — a surface that has never been + // pushed — the safe defaults stand. + let featureGating: FeatureGatingBehavior = published?.featureGating ?? .nonGated let computedPropertyRequests: [ComputedPropertyRequest] = published?.computedPropertyRequests ?? [] let surveys: [Survey] = published?.surveys ?? [] - let introOfferEligibility: IntroOfferEligibility = eligibility(from: surface) - ?? published?.introOfferEligibility - ?? .automatic - let presentation: PaywallPresentationInfo - if let style = presentationStyle(for: surface) { - presentation = PaywallPresentationInfo(style: style, delay: 0) - } else { - presentation = published?.presentation ?? PaywallPresentationInfo(style: .fullscreen, delay: 0) - } + let introOfferEligibility: IntroOfferEligibility = published?.introOfferEligibility ?? .automatic + let presentation = published?.presentation + ?? PaywallPresentationInfo(style: .fullscreen, delay: 0) var paywall = Paywall( databaseId: databaseId, @@ -112,84 +104,4 @@ extension Paywall { ) } } - - /// Maps a surface's `config.ts` feature gating onto the SDK's enum. - /// - /// Returns nil for anything unrecognised — including a CLI newer than this - /// SDK — so the caller falls back rather than guessing how a feature gates. - private static func gating(from surface: DevServerSurface) -> FeatureGatingBehavior? { - switch surface.featureGating { - case "gated": - return .gated - case "nonGated": - return .nonGated - default: - return nil - } - } - - /// Maps a surface's `config.ts` trial eligibility onto the SDK's enum, - /// returning nil for anything unrecognised. - private static func eligibility(from surface: DevServerSurface) -> IntroOfferEligibility? { - switch surface.introductoryOfferEligibility { - case "automatic": - return .automatic - case "alwaysEligible": - return .eligible - case "alwaysIneligible": - return .ineligible - default: - return nil - } - } - - /// Geometry a `config.ts` presentation block gets when it doesn't name its - /// own. These mirror the `superwall` CLI's DRAWER_DEFAULTS and - /// POPUP_DEFAULTS, which resolve the same values on push, so a partly - /// specified block presents identically before and after one. The drawer - /// height also matches what `PaywallPresentationStyle/drawer` documents. - private static let defaultDrawerHeight: Double = 70 - private static let defaultDrawerCornerRadius: Double = 15 - private static let defaultPopupWidth: Double = 80 - private static let defaultPopupHeight: Double = 60 - private static let defaultPopupCornerRadius: Double = 15 - - /// Maps a surface's `config.ts` presentation onto the SDK's styles. - /// The framework documents `fullscreen` as its default, so anything - /// missing or unrecognized lands there. - private static func presentationStyle( - for surface: DevServerSurface - ) -> PaywallPresentationStyle? { - if surface.presentation == nil { - // The manifest says nothing about presentation, so the dashboard's - // style stands rather than being replaced by a guess. - return nil - } - switch surface.presentation?.style { - case "modal": - return .modal - case "push": - return .push - case "noAnimation": - return .fullscreenNoAnimation - case "drawer": - // A drawer that names only some of its geometry is still a drawer. - // These match the CLI's own DRAWER_DEFAULTS, so a partly specified - // drawer looks the same here as it will once it's pushed. - let drawer = surface.presentation?.drawer - return .drawer( - height: drawer?.height ?? defaultDrawerHeight, - cornerRadius: drawer?.cornerRadius ?? defaultDrawerCornerRadius - ) - case "popup": - let popup = surface.presentation?.popup - return .popup( - height: popup?.height ?? defaultPopupHeight, - width: popup?.width ?? defaultPopupWidth, - cornerRadius: popup?.cornerRadius ?? defaultPopupCornerRadius - ) - default: - return .fullscreen - } - } } diff --git a/Sources/SuperwallKit/DevServer/DevServerSurface.swift b/Sources/SuperwallKit/DevServer/DevServerSurface.swift index 4e4d9c472..0e41a519e 100644 --- a/Sources/SuperwallKit/DevServer/DevServerSurface.swift +++ b/Sources/SuperwallKit/DevServer/DevServerSurface.swift @@ -6,83 +6,26 @@ // a locally served paywall or funnel, and the dashboard paywall it is // bound to via `superwall.lock`, if any. // +// The manifest carries identity and products only. Everything else a +// paywall's config.ts declares — presentation, feature gating, intro offer +// eligibility — travels to the dashboard in the pushed snapshot instead, so +// the SDK reads those from the published paywall the surface stands in for. +// import Foundation struct DevServerSurface: Decodable, Equatable { - /// How the paywall asks to be presented, straight from its `config.ts`. - /// - /// The geometry is optional throughout: a `config.ts` may set only some of - /// it, and the CLI that writes this manifest versions separately from the - /// SDK, so a block the SDK can't fully read still presents. - struct Presentation: Decodable, Equatable { - struct Drawer: Decodable, Equatable { - let height: Double? - let cornerRadius: Double? - } - struct Popup: Decodable, Equatable { - let width: Double? - let height: Double? - let cornerRadius: Double? - } - - let style: String? - let drawer: Drawer? - let popup: Popup? - } - let kind: String let id: String let url: String + + /// The dashboard paywall this surface is bound to via `superwall.lock`. let paywallId: String? - /// Every dashboard paywall this surface is bound to, when `superwall.lock` - /// binds it to more than one. The CLI sends `paywallId` for the first and - /// this for the full set. + /// Every paywall this surface is bound to, when the lock binds it to more + /// than one. The CLI sends the first as `paywallId` and the full set here. let paywallIds: [String]? + let identifier: String? let products: [String: String]? - let presentation: Presentation? - - /// `config.ts` feature gating: "gated" or "nonGated". - /// - /// Kept as the raw string so a value this SDK doesn't recognise falls back - /// to the published paywall's setting instead of failing the surface. - let featureGating: String? - - /// `config.ts` trial eligibility: "automatic", "alwaysEligible" or - /// "alwaysIneligible". Raw for the same reason as `featureGating`. - let introductoryOfferEligibility: String? - - private enum CodingKeys: String, CodingKey { - case kind - case id - case url - case paywallId - case paywallIds - case identifier - case products - case presentation - case featureGating - case introductoryOfferEligibility - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - kind = try container.decode(String.self, forKey: .kind) - id = try container.decode(String.self, forKey: .id) - url = try container.decode(String.self, forKey: .url) - paywallId = try container.decodeIfPresent(String.self, forKey: .paywallId) - paywallIds = try container.decodeIfPresent([String].self, forKey: .paywallIds) - identifier = try container.decodeIfPresent(String.self, forKey: .identifier) - products = try container.decodeIfPresent([String: String].self, forKey: .products) - // Presentation is a hint, not the surface itself. A block this SDK can't - // read costs the surface its style, not its ability to be served. - presentation = try? container.decodeIfPresent(Presentation.self, forKey: .presentation) - featureGating = try container.decodeIfPresent(String.self, forKey: .featureGating) - introductoryOfferEligibility = try container.decodeIfPresent( - String.self, - forKey: .introductoryOfferEligibility - ) - } } diff --git a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift index d4b45e31a..a0837b37c 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift @@ -147,22 +147,6 @@ final class DevServerManifestTests: XCTestCase { XCTAssertEqual(decoded.surfaces.map { $0.id }, ["pro", "max"]) } - func test_malformedPresentationStillServesTheSurface() throws { - let decoded = try manifest(""" - { - "surfaces": [ - { - "kind": "paywall", - "id": "pro", - "url": "/preview/paywall/pro", - "presentation": "not-an-object" - } - ] - } - """) - XCTAssertEqual(decoded.surfaces.map { $0.id }, ["pro"]) - XCTAssertNil(decoded.surfaces[0].presentation) - } func test_aBodyWithoutSurfacesIsNotAManifest() { // `surfaces` is what tells this JSON apart from anything else that might diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift index f7bb9e62b..28ce8ec54 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift @@ -11,10 +11,7 @@ final class DevServerPaywallTests: XCTestCase { id: String = "pro", paywallId: String? = nil, identifier: String? = nil, - products: [String: String]? = nil, - presentation: String? = nil, - featureGating: String? = nil, - introductoryOfferEligibility: String? = nil + products: [String: String]? = nil ) -> DevServerSurface { let json = """ { @@ -23,11 +20,6 @@ final class DevServerPaywallTests: XCTestCase { "url": "/preview/paywall/\(id)", \(paywallId.map { "\"paywallId\": \"\($0)\"," } ?? "") \(identifier.map { "\"identifier\": \"\($0)\"," } ?? "") - \(presentation.map { "\"presentation\": \($0)," } ?? "") - \(featureGating.map { "\"featureGating\": \"\($0)\"," } ?? "") - \(introductoryOfferEligibility.map { - "\"introductoryOfferEligibility\": \"\($0)\"," - } ?? "") "products": \(products.map { dict in "{" + dict.map { "\"\($0.key)\": \"\($0.value)\"" }.sorted().joined(separator: ",") + "}" } ?? "null") @@ -111,83 +103,8 @@ final class DevServerPaywallTests: XCTestCase { XCTAssertEqual(paywall.presentation.style, .fullscreen) } - func test_usesThePresentationStyleTheConfigDeclares() { - let modal = Paywall.devServer( - surface: surface(presentation: #"{"style": "modal"}"#), - url: url - ) - XCTAssertEqual(modal.presentation.style, .modal) - - let drawer = Paywall.devServer( - surface: surface(presentation: #"{"style": "drawer", "drawer": {"height": 420, "cornerRadius": 24}}"#), - url: url - ) - XCTAssertEqual(drawer.presentation.style, .drawer(height: 420, cornerRadius: 24)) - - let popup = Paywall.devServer( - surface: surface(presentation: #"{"style": "popup", "popup": {"width": 300, "height": 500, "cornerRadius": 16}}"#), - url: url - ) - XCTAssertEqual(popup.presentation.style, .popup(height: 500, width: 300, cornerRadius: 16)) - } - - func test_fallsBackToFullscreenWhenAStyleIsUnknown() { - let unknown = Paywall.devServer( - surface: surface(presentation: #"{"style": "hologram"}"#), - url: url - ) - XCTAssertEqual(unknown.presentation.style, .fullscreen) - } - // MARK: - Partly specified geometry - func test_drawerWithoutGeometryUsesTheCliDefaults() { - let drawer = Paywall.devServer( - surface: surface(presentation: #"{"style": "drawer"}"#), - url: url - ) - // Mirrors the CLI's DRAWER_DEFAULTS, which resolves the same values on push. - XCTAssertEqual(drawer.presentation.style, .drawer(height: 70, cornerRadius: 15)) - } - - func test_drawerKeepsTheValuesItDoesNameAndDefaultsTheRest() { - let heightOnly = Paywall.devServer( - surface: surface(presentation: #"{"style": "drawer", "drawer": {"height": 420}}"#), - url: url - ) - XCTAssertEqual(heightOnly.presentation.style, .drawer(height: 420, cornerRadius: 15)) - - let radiusOnly = Paywall.devServer( - surface: surface(presentation: #"{"style": "drawer", "drawer": {"cornerRadius": 24}}"#), - url: url - ) - XCTAssertEqual(radiusOnly.presentation.style, .drawer(height: 70, cornerRadius: 24)) - } - - func test_popupWithoutGeometryUsesTheCliDefaults() { - // Mirrors the CLI's POPUP_DEFAULTS rather than falling back to fullscreen, - // so a partly specified popup is still a popup. - let noGeometry = Paywall.devServer( - surface: surface(presentation: #"{"style": "popup"}"#), - url: url - ) - XCTAssertEqual(noGeometry.presentation.style, .popup(height: 60, width: 80, cornerRadius: 15)) - } - - func test_popupKeepsTheValuesItDoesNameAndDefaultsTheRest() { - let heightOnly = Paywall.devServer( - surface: surface(presentation: #"{"style": "popup", "popup": {"height": 500}}"#), - url: url - ) - XCTAssertEqual(heightOnly.presentation.style, .popup(height: 500, width: 80, cornerRadius: 15)) - - let sized = Paywall.devServer( - surface: surface(presentation: #"{"style": "popup", "popup": {"width": 300, "height": 500}}"#), - url: url - ) - XCTAssertEqual(sized.presentation.style, .popup(height: 500, width: 300, cornerRadius: 15)) - } - // MARK: - What the dashboard keeps owning /// The manifest can't express any of these, so a bound surface has to take @@ -286,43 +203,18 @@ final class DevServerPaywallTests: XCTestCase { published.featureGating = .gated let paywall = Paywall.devServer( - surface: surface(products: ["plus": "local_product"], presentation: #"{"style": "modal"}"#), + surface: surface(products: ["plus": "local_product"]), url: url, inheriting: published ) - // Inherited behaviour must not drag the published rendering along with it. + // The surface owns what it serves: its URL and its own products. XCTAssertEqual(paywall.url, url) XCTAssertEqual(paywall.productIds, ["local_product"]) - XCTAssertEqual(paywall.presentation.style, .modal) XCTAssertTrue(paywall.isLocal) XCTAssertNil(paywall.manifest) - } - - // MARK: - The surface's own settings win - - func test_theSurfacesOwnGatingBeatsThePublishedPaywalls() { - var published = Paywall.stub() - published.featureGating = .gated - - let paywall = Paywall.devServer( - surface: surface(featureGating: "nonGated"), - url: url, - inheriting: published - ) - - // An unpushed config.ts edit has to take effect, or dev mode shows the - // setting you just changed away from. - XCTAssertEqual(paywall.featureGating, .nonGated) - } - - func test_theSurfacesOwnEligibilityBeatsThePublishedPaywalls() { - let paywall = Paywall.devServer( - surface: surface(introductoryOfferEligibility: "alwaysIneligible"), - url: url, - inheriting: Paywall.stub() - ) - XCTAssertEqual(paywall.introOfferEligibility, .ineligible) + // Inherited behaviour rides along without dragging the published bytes in. + XCTAssertEqual(paywall.featureGating, .gated) } func test_settingsTheSurfaceOmitsStillComeFromTheDashboard() { @@ -334,22 +226,4 @@ final class DevServerPaywallTests: XCTestCase { XCTAssertEqual(paywall.featureGating, .gated) } - func test_aValueThisSdkDoesNotKnowFallsBackRatherThanGuessing() { - var published = Paywall.stub() - published.featureGating = .gated - - // A CLI newer than this SDK must not silently ungate a paywall. - let paywall = Paywall.devServer( - surface: surface(featureGating: "someFutureMode"), - url: url, - inheriting: published - ) - - XCTAssertEqual(paywall.featureGating, .gated) - } - - func test_anUnboundSurfaceStillHonoursItsOwnGating() { - let paywall = Paywall.devServer(surface: surface(featureGating: "gated"), url: url) - XCTAssertEqual(paywall.featureGating, .gated) - } } From 694331b6d01fe5592e241ec4e8b270aa860af85b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:56:20 +0200 Subject: [PATCH 071/162] fix(dev): take identity from the paywall a surface stands in for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One surface can serve several dashboard paywalls — an explicit multi-way superwall.lock binding, or the single-paywall fallback that matches any database id. Identity was read off the surface, so every paywall it served reported the same paywall_id and paywall_identifier. Those reach the real placements queue, and PaywallManager.getViewController keys its cache on identifier, so the paywalls collapsed onto one cached view controller and one analytics identity. The published paywall already carries the right identity, so it wins now; the surface only supplies it for a `dev:` surface with no published counterpart. Also replaces a test whose isScrollEnabled assertion passed either way, since Paywall.stub() already matched the fallback, with a published paywall whose inheritable fields all differ from the fallbacks. Co-Authored-By: Claude Opus 5 --- .../DevServer/DevServerPaywall.swift | 13 ++- .../DevServer/DevServerPaywallTests.swift | 85 ++++++++++++++++--- 2 files changed, 84 insertions(+), 14 deletions(-) diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift index 390b7ca6d..f3107ec64 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift @@ -28,8 +28,17 @@ extension Paywall { ) -> Paywall { let products = productItems(from: surface) - let databaseId: String = surface.paywallId ?? "dev:\(surface.kind)/\(surface.id)" - let identifier: String = surface.identifier ?? "dev:\(surface.id)" + // Identity comes from the paywall being stood in for, not the surface: + // one surface can serve several dashboard paywalls (an explicit multi-way + // binding, or the single-paywall fallback), and these reach analytics as + // paywall_id/paywall_identifier and key the view controller cache. Taking + // them from the surface would collapse every paywall it serves into one. + let databaseId: String = published?.databaseId + ?? surface.paywallId + ?? "dev:\(surface.kind)/\(surface.id)" + let identifier: String = published?.identifier + ?? surface.identifier + ?? "dev:\(surface.id)" let cacheKey = "dev:\(surface.id):\(url.absoluteString)" let responseLoadingInfo: LoadingInfo = published?.responseLoadingInfo ?? .init() // A paywall's config.ts settings reach the SDK in the pushed snapshot, diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift index 28ce8ec54..dba591162 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift @@ -81,21 +81,82 @@ final class DevServerPaywallTests: XCTestCase { XCTAssertEqual(params["is_local"] as? Bool, true) } - func test_inheritsTheDashboardStyleWhenTheManifestSaysNothing() { - // The shipped manifest carries no presentation, so a bound paywall has to - // keep the style the dashboard configured rather than snap to fullscreen. - var published = Paywall.stub() - XCTAssertEqual(published.presentation.style, .modal) + /// A published paywall whose inheritable fields all differ from the values + /// `Paywall.devServer` would otherwise fall back to, so an assertion on any + /// of them fails if the inheritance is dropped. + private func published( + databaseId: String = "db-1", + identifier: String = "pro_v3" + ) -> Paywall { + let stub = Paywall.stub() + return Paywall( + databaseId: databaseId, + identifier: identifier, + name: "Published Pro", + cacheKey: stub.cacheKey, + buildId: stub.buildId, + url: stub.url, + urlConfig: stub.urlConfig, + htmlSubstitutions: "", + presentation: PaywallPresentationInfo( + style: .drawer(height: 42, cornerRadius: 7), + delay: 250 + ), + backgroundColorHex: "#123456", + backgroundColor: .blue, + darkBackgroundColorHex: "#654321", + darkBackgroundColor: .black, + productItems: [], + productIds: [], + appStoreProductIds: [], + responseLoadingInfo: .init(), + webviewLoadingInfo: .init(), + productsLoadingInfo: .init(), + shimmerLoadingInfo: .init(), + paywalljsVersion: "", + featureGating: .gated, + isScrollEnabled: false, + introOfferEligibility: .ineligible + ) + } - let paywall = Paywall.devServer(surface: surface(), url: url, inheriting: published) + func test_inheritsEverythingTheManifestCannotCarry() { + // The shipped manifest carries none of these, so a bound paywall keeps + // what the dashboard configured rather than the stub's defaults. + let dashboard = published() + let paywall = Paywall.devServer(surface: surface(), url: url, inheriting: dashboard) + + XCTAssertEqual(paywall.presentation.style, .drawer(height: 42, cornerRadius: 7)) + XCTAssertEqual(paywall.presentation.delay, 250) + XCTAssertEqual(paywall.backgroundColorHex, "#123456") + XCTAssertEqual(paywall.darkBackgroundColorHex, "#654321") + XCTAssertFalse(paywall.isScrollEnabled) + XCTAssertEqual(paywall.featureGating, .gated) + XCTAssertEqual(paywall.introOfferEligibility, .ineligible) + } - XCTAssertEqual(paywall.presentation.style, .modal) + func test_takesItsIdentityFromThePaywallItStandsInFor() { + // One surface can serve several dashboard paywalls, and identity reaches + // analytics as paywall_id/paywall_identifier and keys the view controller + // cache — so it has to be the served paywall's, not the surface's. + let first = Paywall.devServer( + surface: surface(paywallId: "111", identifier: "surface_identifier"), + url: url, + inheriting: published(databaseId: "222", identifier: "pro_annual") + ) + XCTAssertEqual(first.databaseId, "222") + XCTAssertEqual(first.identifier, "pro_annual") + + let second = Paywall.devServer( + surface: surface(paywallId: "111", identifier: "surface_identifier"), + url: url, + inheriting: published(databaseId: "333", identifier: "pro_monthly") + ) + XCTAssertEqual(second.databaseId, "333") + XCTAssertEqual(second.identifier, "pro_monthly") - // Visual settings the manifest can't carry come from there too. - published = Paywall.stub() - let visuals = Paywall.devServer(surface: surface(), url: url, inheriting: published) - XCTAssertEqual(visuals.backgroundColorHex, published.backgroundColorHex) - XCTAssertEqual(visuals.isScrollEnabled, published.isScrollEnabled) + // Distinct identities, so they can't collapse onto one cached controller. + XCTAssertNotEqual(first.identifier, second.identifier) } func test_presentsFullscreenWhenNothingDeclaresAStyle() { From a2805673c60ec5a255700b132f520184f6f6a3e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:23:29 +0200 Subject: [PATCH 072/162] docs(dev): stop promising config.ts presentation reaches dev mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three comments still said the local surface owns its presentation style, including the public doc on SuperwallOptions.devServer. It hasn't since bff8c79: presentation travels to the dashboard in the pushed snapshot, so a dev-served paywall inherits it from the published paywall. The public doc now also names the consequence a developer would otherwise hit by surprise — a config.ts presentation, gating or eligibility change isn't visible in dev mode until it's pushed. Co-Authored-By: Claude Opus 5 --- .../SuperwallKit/Config/Options/SuperwallOptions.swift | 8 ++++++-- Sources/SuperwallKit/DevServer/DevServerPaywall.swift | 10 +++++----- .../Paywall/Request/Operators/RawPaywallResponse.swift | 6 +++--- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index dd4f69b0d..17e64d3ee 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -407,8 +407,12 @@ public final class SuperwallOptions: NSObject, Encodable { /// audience evaluation, assignment and feature gating all stay real. Paywalls without a /// local counterpart still load their published versions. /// - /// What the local surface does own is what it renders and how: its products and its - /// `config.ts` presentation style replace the published paywall's. + /// What the local surface owns is what it renders: its content and its products. + /// Everything else the dashboard configures — presentation style, feature gating, + /// intro offer eligibility, surveys — comes from the published paywall, because those + /// settings reach the SDK in the snapshot `superwall push` uploads rather than from + /// the dev server. So a `config.ts` change to any of them is not visible in dev mode + /// until you push it. /// /// Use ``DevServer/default`` on a simulator; on a physical device use ``DevServer/url(_:)`` /// with the `Device` URL that `superwall dev` prints. Defaults to `nil`: no dev server. diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift index f3107ec64..6afb110a7 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift @@ -14,13 +14,13 @@ extension Paywall { /// Builds the paywall a dev server surface presents. /// /// - Parameter published: the dashboard paywall this surface stands in for, - /// if any. The surface owns what renders and how — its bytes, products and - /// `config.ts` presentation style. Everything the dashboard configures that - /// a manifest can't express is inherited from `published` instead, so dev - /// mode changes how a paywall looks and never how it behaves. + /// if any. The surface owns what renders — its bytes and its products. + /// Everything else the dashboard configures, presentation included, is + /// inherited from `published`, so dev mode changes a paywall's content and + /// never its configuration. /// /// Anything added to `Paywall` later defaults to the local stub's value, so - /// if it is dashboard-owned behaviour it belongs in the inherited list below. + /// if the dashboard configures it, it belongs in the inherited list below. static func devServer( surface: DevServerSurface, url: URL, diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift index cfd89a708..60c388bb7 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift @@ -43,9 +43,9 @@ extension PaywallRequestManager { return paywall } - // The local surface replaces the published paywall's bytes, products and - // presentation, and inherits everything the dashboard configures that a - // manifest can't express — see Paywall.devServer(surface:url:inheriting:). + // The local surface replaces the published paywall's bytes and products, + // and inherits everything the dashboard configures that a manifest can't + // express — see Paywall.devServer(surface:url:inheriting:). // The synthesized cacheKey embeds the mount URL, so a moved dev server or // a published fallback reloads the web view instead of presenting the // stale page. From e518f876fef21a0851b3869d3b0de51f92d028d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:39:31 +0200 Subject: [PATCH 073/162] docs(dev): name local notifications as the exception to inheritance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rewritten docs said every dashboard-configured setting comes from the published paywall, which overreaches: Paywall.devServer forces localNotifications to [] so the local config.ts ones win. The public doc was wrong in both directions — the dashboard's notifications never fire in dev mode, and a config.ts notification change is visible without a push. Both the public doc and the factory's now name the exception, and the "belongs in the inherited list" rule reads as conditional on the local paywall having no way of its own to say otherwise, which is the real test. Co-Authored-By: Claude Opus 5 --- Sources/SuperwallKit/Config/Options/SuperwallOptions.swift | 4 ++++ Sources/SuperwallKit/DevServer/DevServerPaywall.swift | 7 ++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index 17e64d3ee..c26c3ba00 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -414,6 +414,10 @@ public final class SuperwallOptions: NSObject, Encodable { /// the dev server. So a `config.ts` change to any of them is not visible in dev mode /// until you push it. /// + /// Local notifications are the one exception: the published paywall's are ignored and + /// the ones your local `config.ts` declares fire straight away, without a push, because + /// they reach the SDK from the paywall itself rather than from the dashboard. + /// /// Use ``DevServer/default`` on a simulator; on a physical device use ``DevServer/url(_:)`` /// with the `Device` URL that `superwall dev` prints. Defaults to `nil`: no dev server. /// diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift index 6afb110a7..b43367c8d 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift @@ -16,11 +16,12 @@ extension Paywall { /// - Parameter published: the dashboard paywall this surface stands in for, /// if any. The surface owns what renders — its bytes and its products. /// Everything else the dashboard configures, presentation included, is - /// inherited from `published`, so dev mode changes a paywall's content and - /// never its configuration. + /// inherited from `published` — bar `localNotifications`, see below — so dev + /// mode changes a paywall's content and never its configuration. /// /// Anything added to `Paywall` later defaults to the local stub's value, so - /// if the dashboard configures it, it belongs in the inherited list below. + /// if the dashboard configures it and the local paywall has no way of its + /// own to say otherwise, it belongs in the inherited list below. static func devServer( surface: DevServerSurface, url: URL, From 1af8f0a2fe9aca71c572b90755dbfb15f6ebccb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:49:43 +0200 Subject: [PATCH 074/162] docs(dev): make the on-device cache exception explicit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onDeviceCache was simply omitted from the Paywall(...) call, so it took the init default .disabled. That is the right behaviour — a dev server reloads the page on every edit and DependencyContainer feeds this into the web view, so an enabled cache could serve a stale copy of the local page — but nothing said so. A maintainer applying the rule the doc comment states would have added it to the inherited list and quietly broken live reload. It is now passed explicitly with the reason, named alongside localNotifications as the second exception in the doc comment, and covered by a test whose published paywall has the cache enabled. Co-Authored-By: Claude Opus 5 --- Sources/SuperwallKit/DevServer/DevServerPaywall.swift | 11 +++++++++-- .../DevServer/DevServerPaywallTests.swift | 8 ++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift index b43367c8d..0ce4d6408 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift @@ -16,8 +16,11 @@ extension Paywall { /// - Parameter published: the dashboard paywall this surface stands in for, /// if any. The surface owns what renders — its bytes and its products. /// Everything else the dashboard configures, presentation included, is - /// inherited from `published` — bar `localNotifications`, see below — so dev - /// mode changes a paywall's content and never its configuration. + /// inherited from `published`, so dev mode changes a paywall's content and + /// never its configuration. Two exceptions, both marked below: + /// `localNotifications`, which the local paywall declares itself, and + /// `onDeviceCache`, which stays `.disabled` so a live-reloading local page + /// is never served from the web view's cache. /// /// Anything added to `Paywall` later defaults to the local stub's value, so /// if the dashboard configures it and the local paywall has no way of its @@ -81,6 +84,10 @@ extension Paywall { // Feature gating decides whether a non-paying user gets the feature, so // it can never come from local paywall code. featureGating: featureGating, + // Deliberately not inherited either: a dev server reloads the page on + // every edit, and DependencyContainer feeds this straight into the web + // view, so an enabled cache could serve a stale copy of the local page. + onDeviceCache: .disabled, // Deliberately not inherited: a local paywall declares its own // notifications in config.ts, and they reach the SDK as // `schedule_notification` messages rather than through this field. diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift index dba591162..0b4a45d3a 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift @@ -115,6 +115,7 @@ final class DevServerPaywallTests: XCTestCase { shimmerLoadingInfo: .init(), paywalljsVersion: "", featureGating: .gated, + onDeviceCache: .enabled, isScrollEnabled: false, introOfferEligibility: .ineligible ) @@ -185,6 +186,13 @@ final class DevServerPaywallTests: XCTestCase { XCTAssertEqual(paywall.surveys.count, 1) } + func test_neverServesALocalPageFromTheWebViewCache() { + // The dashboard configures onDeviceCache, but a dev server reloads on + // every edit, so an inherited .enabled could serve a stale local page. + let paywall = Paywall.devServer(surface: surface(), url: url, inheriting: published()) + XCTAssertEqual(paywall.onDeviceCache, .disabled) + } + func test_doesNotInheritNotificationsTheLocalPaywallDeclaresItself() { // config.ts can declare notifications, and they arrive as // `schedule_notification` messages. Inheriting the dashboard's would let From 8a29ef0cd1cc59bf4fcb414dd89a4555050d1bf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:00:12 +0200 Subject: [PATCH 075/162] fix: send unknown entitlement details as null, not as missing An entitlement supplied by a Purchase Controller carries no renewal metadata, and `encodeIfPresent` dropped those keys entirely. Audience filters give a missing key the type default, so `willRenew` read as `false` and an active subscriber matched a "will not renew" audience. On one app that was every one of 181 matches over a day, while only 10 had actually cancelled. The keys are now written as explicit nulls, which filters treat as unknown: a bare entitlement no longer matches `willRenew == false`, an explicit `false` still does, and `willRenew == null` can ask whether the detail is known at all. Two hops had to change for that to arrive. `Entitlement.encode(to:)` writes the null, and `toPassableValue` maps `NSNull` onto `PassableValue.null` - without that the null fell through to the default branch and reached the filter as an empty map, which compares to nothing. Dates stay omitted rather than nulled: filters compare them with `<` and `>`, and a null on either side of an ordering comparison makes the whole filter evaluate to null, taking unrelated clauses with it. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + ...edEncodingContainer+EncodeNilOrValue.swift | 27 ++++ .../EvaluationContext.swift | 5 + .../Products/StoreProduct/Entitlement.swift | 20 ++- SuperwallKit.xcodeproj/project.pbxproj | 8 + .../EntitlementUnknownFieldsTests.swift | 142 ++++++++++++++++++ 6 files changed, 197 insertions(+), 6 deletions(-) create mode 100644 Sources/SuperwallKit/Misc/Extensions/KeyedEncodingContainer+EncodeNilOrValue.swift create mode 100644 Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/EntitlementUnknownFieldsTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index df5dbdfe2..55035ddf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. - Fixes a data race during SDK configuration that Thread Sanitizer flagged on every launch. - Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them. +- Fixes audiences matching users they shouldn't when you use a Purchase Controller. An entitlement's renewal details are unknown in that setup, and audience filters were reading the missing details as `false`, so an active subscriber could match an audience like "active and will not renew". Unknown details are now sent as null and no longer match. ## 4.16.3 diff --git a/Sources/SuperwallKit/Misc/Extensions/KeyedEncodingContainer+EncodeNilOrValue.swift b/Sources/SuperwallKit/Misc/Extensions/KeyedEncodingContainer+EncodeNilOrValue.swift new file mode 100644 index 000000000..feb1fa8d4 --- /dev/null +++ b/Sources/SuperwallKit/Misc/Extensions/KeyedEncodingContainer+EncodeNilOrValue.swift @@ -0,0 +1,27 @@ +// +// KeyedEncodingContainer+EncodeNilOrValue.swift +// SuperwallKit +// +// Created by Yusuf Tör on 07/09/2026. +// + +import Foundation + +extension KeyedEncodingContainer { + /// Encodes an optional, writing an explicit null when it's `nil`. + /// + /// `encodeIfPresent` leaves the key out entirely, which loses the difference + /// between a value we know to be absent and one we never learned. Audience + /// filters give a missing key the type default, so a dropped `Bool?` reads as + /// `false`. Use this for any field a filter might compare by equality. + mutating func encodeNilOrValue( + _ value: T?, + forKey key: Key + ) throws { + if let value = value { + try encode(value, forKey: key) + } else { + try encodeNil(forKey: key) + } + } +} diff --git a/Sources/SuperwallKit/Paywall/Presentation/Audience Logic/Expression Evaluator/EvaluationContext.swift b/Sources/SuperwallKit/Paywall/Presentation/Audience Logic/Expression Evaluator/EvaluationContext.swift index 24152722d..a1f996b00 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Audience Logic/Expression Evaluator/EvaluationContext.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Audience Logic/Expression Evaluator/EvaluationContext.swift @@ -124,6 +124,11 @@ func toPassableValue(from anyValue: Any) -> PassableValue { } switch anyValue { + case is NSNull: + // A field the SDK explicitly reported as unknown. Without this it would fall + // through to the `default` case and come out as an empty map, which can't be + // compared to anything. + return .null case let value as Int: return .int(value) case let value as UInt64: diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift index d32a43d75..f2c6717b8 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift @@ -244,15 +244,23 @@ public final class Entitlement: NSObject, Codable, Sendable { try container.encode(type, forKey: .type) try container.encodeIfPresent(isActive, forKey: .isActive) try container.encodeIfPresent(productIds, forKey: .productIds) - try container.encodeIfPresent(latestProductId, forKey: .latestProductId) - try container.encodeIfPresent(store, forKey: .store) try container.encodeIfPresent(startsAt, forKey: .startsAt) try container.encodeIfPresent(renewedAt, forKey: .renewedAt) try container.encodeIfPresent(expiresAt, forKey: .expiresAt) - try container.encodeIfPresent(isLifetime, forKey: .isLifetime) - try container.encodeIfPresent(willRenew, forKey: .willRenew) - try container.encodeIfPresent(state, forKey: .state) - try container.encodeIfPresent(offerType, forKey: .offerType) + // Encoded as an explicit null rather than left out, so an audience filter can + // tell "we don't know" from "no". A Purchase Controller can't fill these in, + // and a missing key is given the type default during filter evaluation, which + // made a bare entitlement match `willRenew == false`. + // + // The dates above stay `encodeIfPresent`: filters compare them with `<` and + // `>`, and a null on either side of an ordering comparison makes the whole + // filter evaluate to null, taking unrelated parts of the filter with it. + try container.encodeNilOrValue(latestProductId, forKey: .latestProductId) + try container.encodeNilOrValue(store, forKey: .store) + try container.encodeNilOrValue(isLifetime, forKey: .isLifetime) + try container.encodeNilOrValue(willRenew, forKey: .willRenew) + try container.encodeNilOrValue(state, forKey: .state) + try container.encodeNilOrValue(offerType, forKey: .offerType) } // Deep equality across all fields. For detecting logical status changes, diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 2ff8079af..e161b19b4 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -126,6 +126,7 @@ 342593FCA24FBEA77FE472C7 /* SK2ReceiptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050BC76657949DBB5F3D551C /* SK2ReceiptManager.swift */; }; 3464196F9088F8A320FE24A4 /* PendingStripeCheckoutPollState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797EC0356AA1065ED11835BF /* PendingStripeCheckoutPollState.swift */; }; 35597883CB038DBEE63E162B /* EventData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86D76FB5809C3B8122778A9 /* EventData.swift */; }; + 3584214186291379380E6823 /* KeyedEncodingContainer+EncodeNilOrValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = D326F60B14F6911B6BDFD96B /* KeyedEncodingContainer+EncodeNilOrValue.swift */; }; 3652D5EE4C172D623BDEE7E4 /* PresentationIdTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3F306D67A9F3A43D082DD83 /* PresentationIdTests.swift */; }; 369677E9A6E8754CFD20714D /* TrackingParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 764012CF0C0972240A73E3CF /* TrackingParameters.swift */; }; 36B598D26A38D50CC713FD73 /* ProductsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641BC3C3F8AC2D6E1EF44D55 /* ProductsManager.swift */; }; @@ -377,6 +378,7 @@ AEB9D461AF5103FB7257AD25 /* SwiftyJSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19F010DC597017F5BEAEDE86 /* SwiftyJSON.swift */; }; AECD80682E1909735CCDAA78 /* AdServicesAttributionAttempts.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9D538EA68425ECB218BA3CA /* AdServicesAttributionAttempts.swift */; }; AF4AD928FACF9056E00D5920 /* HandleTriggerResultOperatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B27F0D55EF3480E2B65C8DFD /* HandleTriggerResultOperatorTests.swift */; }; + B002C22F935F04DE75E56183 /* EntitlementUnknownFieldsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6596655637A212AEE276585 /* EntitlementUnknownFieldsTests.swift */; }; B078481CA0ADD4B4F3BEFD15 /* LocalFileSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63B0C49F4A92D8C5C05FA026 /* LocalFileSchemeHandler.swift */; }; B0AD4A89AD5101360F93652D /* SubscriptionTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ACDC7427B6340E9D86F9B0F /* SubscriptionTransaction.swift */; }; B0B0AD9409CEFE7CA8225146 /* Array+SafeRemove.swift in Sources */ = {isa = PBXBuildFile; fileRef = C855DE8F5341D67C614E3AF5 /* Array+SafeRemove.swift */; }; @@ -1113,6 +1115,7 @@ D1443B535E6E1D572A74733F /* SubscriptionPeriod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionPeriod.swift; sourceTree = ""; }; D198C8645A213EEAD622C881 /* FakeLocationAuthorizationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeLocationAuthorizationStatus.swift; sourceTree = ""; }; D31BB6D0C57337C6E929D617 /* ASN1Decoder+UnkeyedDecodingContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ASN1Decoder+UnkeyedDecodingContainer.swift"; sourceTree = ""; }; + D326F60B14F6911B6BDFD96B /* KeyedEncodingContainer+EncodeNilOrValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyedEncodingContainer+EncodeNilOrValue.swift"; sourceTree = ""; }; D3479E4B3365290BC0C0A123 /* StripeProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeProduct.swift; sourceTree = ""; }; D3506FCC35155DF104A1DFCA /* CustomURLSessionMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomURLSessionMock.swift; sourceTree = ""; }; D36898353ABCE620BA7AEE59 /* SuperwallGraveyard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuperwallGraveyard.swift; sourceTree = ""; }; @@ -1125,6 +1128,7 @@ D5BF66C3986E287B7B2F5E27 /* SKProductSubscriptionPeriodMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SKProductSubscriptionPeriodMock.swift; sourceTree = ""; }; D5E2D026C30691F11D4E839F /* SurveyShowCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurveyShowCondition.swift; sourceTree = ""; }; D6340ACDA40937ACAC66FA3D /* EntitlementPriorityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitlementPriorityTests.swift; sourceTree = ""; }; + D6596655637A212AEE276585 /* EntitlementUnknownFieldsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitlementUnknownFieldsTests.swift; sourceTree = ""; }; D69BCC259F5FBE15AB02D662 /* PermissionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionHandler.swift; sourceTree = ""; }; D7434029CB9E4680C85D3FB6 /* PermissionHandler+Microphone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PermissionHandler+Microphone.swift"; sourceTree = ""; }; D7B0C7BDA06D25D9D5A865A3 /* TestModeManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestModeManagerTests.swift; sourceTree = ""; }; @@ -1678,6 +1682,7 @@ 8DE36D141F461F6E945823FA /* Future+Async.swift */, E2243C6BF6BE477794F568ED /* GCControllerElement+buttonName.swift */, FD778E66506BA51BEB5EE89C /* JSONEncoder+Superwall.swift */, + D326F60B14F6911B6BDFD96B /* KeyedEncodingContainer+EncodeNilOrValue.swift */, F4B35EF62D8C986B504B052C /* NSManagedObjectContext+mergeChanges.swift */, AF5A8FFFC23826AD113D525A /* Publisher+Async.swift */, A524F7AAE90E48C3B8D7E99A /* PurchaseResult+Internal.swift */, @@ -2407,6 +2412,7 @@ isa = PBXGroup; children = ( D6340ACDA40937ACAC66FA3D /* EntitlementPriorityTests.swift */, + D6596655637A212AEE276585 /* EntitlementUnknownFieldsTests.swift */, BC580BF1CC720ECBC4E68A28 /* SK2PriceFormatRoundingTests.swift */, F98E1C9554F6AFAECF9B3430 /* StoreProductBillingPlanTests.swift */, 2CF1F5EAC9C4E384EBBE5EA9 /* SubscriptionPeriodPriceTests.swift */, @@ -3324,6 +3330,7 @@ 49A7156A67C8BAB23F97EC39 /* EmailTests.swift in Sources */, A03AC977AD8110290DABECBD /* EntitlementPriorityTests.swift in Sources */, 6BA614F410A95F36CBA42F93 /* EntitlementProcessorTests.swift in Sources */, + B002C22F935F04DE75E56183 /* EntitlementUnknownFieldsTests.swift in Sources */, 1225B991B40D16B7FB4EF1A5 /* EvaluateRulesOperatorTests.swift in Sources */, 158EBAAC2A96F73B93C48E15 /* ExpressionEvaluatorMock.swift in Sources */, 77ED3D92C176CC6D93D625B2 /* ExpressionLogicTests.swift in Sources */, @@ -3583,6 +3590,7 @@ CDFE4C9A23CB583CAC113F59 /* IntroOfferTokenManager.swift in Sources */, E63A7174E3B98690B073C373 /* JSONEncoder+Superwall.swift in Sources */, 3F3774A066285BB0DFE61B61 /* JSONToDict.swift in Sources */, + 3584214186291379380E6823 /* KeyedEncodingContainer+EncodeNilOrValue.swift in Sources */, 5621A2D2FEC048847E22BF6C /* KeypathWritable.swift in Sources */, 88A5CA6515126BD3D09E0563 /* LimitedQueue.swift in Sources */, 68AF64973AC860BE2A41B8D4 /* LoadingInfo.swift in Sources */, diff --git a/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/EntitlementUnknownFieldsTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/EntitlementUnknownFieldsTests.swift new file mode 100644 index 000000000..9b209beda --- /dev/null +++ b/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/EntitlementUnknownFieldsTests.swift @@ -0,0 +1,142 @@ +// +// EntitlementUnknownFieldsTests.swift +// SuperwallKit +// +// Created by Yusuf Tör on 07/09/2026. +// +// swiftlint:disable all + +import Foundation +import Superscript +import Testing +@testable import SuperwallKit + +/// An entitlement that a Purchase Controller supplies carries no renewal +/// metadata. These tests pin down that "we don't know" reaches an audience +/// filter as null rather than as an absent key, because a filter gives an +/// absent key the type default and so reads a dropped `Bool?` as `false`. +@Suite(.serialized) +struct EntitlementUnknownFieldsTests { + /// The same trip an entitlement makes on its way to a filter: + /// `JSONEncoder` -> `JSONSerialization` -> `[String: Any]`. + private func encodedDictionary(_ entitlement: Entitlement) throws -> [String: Any] { + let data = try JSONEncoder().encode(entitlement) + let object = try JSONSerialization.jsonObject(with: data, options: .allowFragments) + return try #require(object as? [String: Any]) + } + + private func evaluate(_ expression: String, entitlement: Entitlement) throws -> String { + let attributes: [String: Any] = [ + "device": [ + "customerInfo": [ + "entitlements": [try encodedDictionary(entitlement)] + ] + ] + ] + + var variablesMap: [String: PassableValue] = [:] + if case let PassableValue.map(dictionary) = toPassableValue(from: attributes) { + variablesMap = dictionary + } + + let executionContext = ExecutionContext( + variables: PassableMap(map: variablesMap), + computed: [:], + device: [:], + expression: expression + ) + let jsonData = try JSONEncoder().encode(executionContext) + let jsonString = try #require(String(data: jsonData, encoding: .utf8)) + + let dependencyContainer = DependencyContainer() + return evaluateWithContext( + definition: jsonString, + context: EvaluationContext(storage: dependencyContainer.storage) + ) + } + + /// The audience from the incident: "active, and will not renew". + private let willNotRenewFilter = """ + device.customerInfo.entitlements.exists(e, e.isActive == true && e.willRenew == false) + """ + + @Test func unknownWillRenewIsEncodedAsExplicitNull() throws { + let entitlement = Entitlement(id: "unlimited_access", isActive: true) + let dictionary = try encodedDictionary(entitlement) + + #expect(dictionary.keys.contains("willRenew")) + #expect(dictionary["willRenew"] is NSNull) + } + + @Test func knownWillRenewIsEncodedAsItsValue() throws { + let entitlement = Entitlement(id: "unlimited_access", isActive: true, willRenew: false) + let dictionary = try encodedDictionary(entitlement) + + #expect(dictionary["willRenew"] as? Bool == false) + } + + /// Dates are deliberately left out rather than nulled: filters compare them + /// with `<` and `>`, and null on either side of an ordering comparison makes + /// the whole filter evaluate to null. + @Test func unknownDatesAreLeftOut() throws { + let entitlement = Entitlement(id: "unlimited_access", isActive: true) + let dictionary = try encodedDictionary(entitlement) + + #expect(dictionary.keys.contains("expiresAt") == false) + #expect(dictionary.keys.contains("startsAt") == false) + #expect(dictionary.keys.contains("renewedAt") == false) + } + + @Test func nsNullBecomesPassableNull() { + if case PassableValue.null = toPassableValue(from: NSNull()) { + return + } + Issue.record("Expected NSNull to become PassableValue.null") + } + + @Test func nullSurvivesTheTripIntoTheFilterContext() throws { + let entitlement = Entitlement(id: "unlimited_access", isActive: true) + let passableValue = toPassableValue(from: try encodedDictionary(entitlement)) + + guard case let PassableValue.map(dictionary) = passableValue else { + Issue.record("Expected a map") + return + } + if case PassableValue.null = try #require(dictionary["willRenew"]) { + return + } + Issue.record("Expected willRenew to be PassableValue.null") + } + + @Test func bareEntitlementDoesNotMatchWillNotRenew() throws { + let entitlement = Entitlement(id: "unlimited_access", isActive: true) + let result = try evaluate(willNotRenewFilter, entitlement: entitlement) + + #expect(result == #"{"Ok":{"type":"bool","value":false}}"#) + } + + @Test func explicitlyNotRenewingStillMatches() throws { + let entitlement = Entitlement(id: "unlimited_access", isActive: true, willRenew: false) + let result = try evaluate(willNotRenewFilter, entitlement: entitlement) + + #expect(result == #"{"Ok":{"type":"bool","value":true}}"#) + } + + @Test func explicitlyRenewingDoesNotMatch() throws { + let entitlement = Entitlement(id: "unlimited_access", isActive: true, willRenew: true) + let result = try evaluate(willNotRenewFilter, entitlement: entitlement) + + #expect(result == #"{"Ok":{"type":"bool","value":false}}"#) + } + + /// A filter can still ask whether the SDK knows the renewal state. + @Test func unknownWillRenewComparesEqualToNull() throws { + let entitlement = Entitlement(id: "unlimited_access", isActive: true) + let result = try evaluate( + "device.customerInfo.entitlements.exists(e, e.willRenew == null)", + entitlement: entitlement + ) + + #expect(result == #"{"Ok":{"type":"bool","value":true}}"#) + } +} From 1647e0e9bc040d5614dc32f0f4dd665fece8cd0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:30:46 +0200 Subject: [PATCH 076/162] test: cover the string-valued fields and tidy the filter helper Review on #517 asked whether the four string-encoded fields behave like the boolean one, since a null string has no overload for `startsWith`, `matches` or `size`. Checked against Superscript 1.0.15: equality against a null string is a plain no-match, identical to the old absent-key result, and the string functions error the same way whether the key is absent or null - the member rewrite already hands them null either way. So the change doesn't move that behaviour. Pinned as tests. Also pins `has(e.willRenew)` flipping to true, which is the mechanism of the fix rather than a side effect. The helper now shares one dependency container and resets its storage like `CELEvaluatorTests` does, passes the same computed/device maps as `CELEvaluator`, and decodes the result instead of matching the raw JSON. Co-Authored-By: Claude Opus 5 --- ...edEncodingContainer+EncodeNilOrValue.swift | 6 ++ .../EntitlementUnknownFieldsTests.swift | 85 ++++++++++++++----- 2 files changed, 72 insertions(+), 19 deletions(-) diff --git a/Sources/SuperwallKit/Misc/Extensions/KeyedEncodingContainer+EncodeNilOrValue.swift b/Sources/SuperwallKit/Misc/Extensions/KeyedEncodingContainer+EncodeNilOrValue.swift index feb1fa8d4..00722c6ec 100644 --- a/Sources/SuperwallKit/Misc/Extensions/KeyedEncodingContainer+EncodeNilOrValue.swift +++ b/Sources/SuperwallKit/Misc/Extensions/KeyedEncodingContainer+EncodeNilOrValue.swift @@ -14,6 +14,12 @@ extension KeyedEncodingContainer { /// between a value we know to be absent and one we never learned. Audience /// filters give a missing key the type default, so a dropped `Bool?` reads as /// `false`. Use this for any field a filter might compare by equality. + /// + /// Plain `encode(_:forKey:)` on an optional already writes a null, since + /// `Optional`'s own `Encodable` conformance calls `encodeNil()`. This spells + /// that out at the call site: the one-character difference between `encode` + /// and `encodeIfPresent` is easy to read as a typo and "tidy up" back into + /// the bug. mutating func encodeNilOrValue( _ value: T?, forKey key: Key diff --git a/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/EntitlementUnknownFieldsTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/EntitlementUnknownFieldsTests.swift index 9b209beda..611099f94 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/EntitlementUnknownFieldsTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/EntitlementUnknownFieldsTests.swift @@ -25,7 +25,16 @@ struct EntitlementUnknownFieldsTests { return try #require(object as? [String: Any]) } - private func evaluate(_ expression: String, entitlement: Entitlement) throws -> String { + /// One container for the suite: building one spins up real storage and a + /// persistent container, which the sibling `CELEvaluatorTests` resets for the + /// same reason. + private static let dependencyContainer: DependencyContainer = { + let container = DependencyContainer() + container.storage.reset() + return container + }() + + private func evaluate(_ expression: String, entitlement: Entitlement) throws -> Bool { let attributes: [String: Any] = [ "device": [ "customerInfo": [ @@ -39,20 +48,36 @@ struct EntitlementUnknownFieldsTests { variablesMap = dictionary } + // Mirrors what CELEvaluator passes in production. + let computedProperties = Dictionary(uniqueKeysWithValues: + ComputedPropertyRequestType.allCases.map { + ($0.description, [PassableValue.string("event_name")]) + } + ) let executionContext = ExecutionContext( variables: PassableMap(map: variablesMap), - computed: [:], - device: [:], + computed: computedProperties, + device: computedProperties, expression: expression ) let jsonData = try JSONEncoder().encode(executionContext) let jsonString = try #require(String(data: jsonData, encoding: .utf8)) - let dependencyContainer = DependencyContainer() - return evaluateWithContext( + let output = evaluateWithContext( definition: jsonString, - context: EvaluationContext(storage: dependencyContainer.storage) + context: EvaluationContext(storage: Self.dependencyContainer.storage) ) + + // Decoded rather than compared byte for byte, so a future Superscript bump + // that reshapes the wrapper doesn't read as a behaviour change. + let outputData = try #require(output.data(using: .utf8)) + let result = try JSONDecoder().decode(EvaluationResult.self, from: outputData) + guard case let .success(value) = result, + case let .bool(matched) = value else { + Issue.record("Expected a boolean result, got \(output)") + return false + } + return matched } /// The audience from the incident: "active, and will not renew". @@ -110,33 +135,55 @@ struct EntitlementUnknownFieldsTests { @Test func bareEntitlementDoesNotMatchWillNotRenew() throws { let entitlement = Entitlement(id: "unlimited_access", isActive: true) - let result = try evaluate(willNotRenewFilter, entitlement: entitlement) - - #expect(result == #"{"Ok":{"type":"bool","value":false}}"#) + #expect(try evaluate(willNotRenewFilter, entitlement: entitlement) == false) } @Test func explicitlyNotRenewingStillMatches() throws { let entitlement = Entitlement(id: "unlimited_access", isActive: true, willRenew: false) - let result = try evaluate(willNotRenewFilter, entitlement: entitlement) - - #expect(result == #"{"Ok":{"type":"bool","value":true}}"#) + #expect(try evaluate(willNotRenewFilter, entitlement: entitlement) == true) } @Test func explicitlyRenewingDoesNotMatch() throws { let entitlement = Entitlement(id: "unlimited_access", isActive: true, willRenew: true) - let result = try evaluate(willNotRenewFilter, entitlement: entitlement) + #expect(try evaluate(willNotRenewFilter, entitlement: entitlement) == false) + } + + /// The string-valued fields are nulled too, so equality against them has to + /// stay a plain no-match rather than becoming an error or a null that would + /// take the rest of the filter with it. + @Test(arguments: [ + "device.customerInfo.entitlements.exists(e, e.store == \"APP_STORE\")", + "device.customerInfo.entitlements.exists(e, e.state == \"SUBSCRIBED\")", + "device.customerInfo.entitlements.exists(e, e.latestProductId == \"pro_yearly\")", + "device.customerInfo.entitlements.exists(e, e.isLifetime == true)" + ]) + func unknownStringFieldsDoNotMatch(expression: String) throws { + let entitlement = Entitlement(id: "unlimited_access", isActive: true) - #expect(result == #"{"Ok":{"type":"bool","value":false}}"#) + #expect(try evaluate(expression, entitlement: entitlement) == false) + } + + /// The whole point of the change: the field is now present, so a filter can + /// tell that the SDK holds no opinion rather than reading a default. + @Test func unknownWillRenewIsReportedAsPresent() throws { + let entitlement = Entitlement(id: "unlimited_access", isActive: true) + + #expect( + try evaluate( + "device.customerInfo.entitlements.exists(e, has(e.willRenew))", + entitlement: entitlement + ) == true + ) } /// A filter can still ask whether the SDK knows the renewal state. @Test func unknownWillRenewComparesEqualToNull() throws { let entitlement = Entitlement(id: "unlimited_access", isActive: true) - let result = try evaluate( - "device.customerInfo.entitlements.exists(e, e.willRenew == null)", - entitlement: entitlement + #expect( + try evaluate( + "device.customerInfo.entitlements.exists(e, e.willRenew == null)", + entitlement: entitlement + ) == true ) - - #expect(result == #"{"Ok":{"type":"bool","value":true}}"#) } } From 9539a22351812ce2b43ee76e7c9291045d44c518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:20:54 +0200 Subject: [PATCH 077/162] refactor: null the unknown fields only for audience filters Writing the nulls in `Entitlement.encode(to:)` unconditionally changed every consumer of that encoding, not just the one that needed it: the enrichment request body, the paywall template variables, the session attributes and the public `getDeviceAttributes()` all started carrying six nulls per bare entitlement. The encoder now opts in through a `reportsUnknownFieldsAsNull` userInfo key, threaded from `makeAudienceFilterAttributes` through `getDeviceAttributes` and `getTemplateDevice` into `DeviceTemplate.toDictionary`. Everything else encodes exactly as it did before, which a test pins. The enrichment endpoint turned out to accept the nulls anyway - it runs no validation on the device payload and never reads the entitlement fields - but there is no reason for the other surfaces to carry them. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- .../Dependencies/DependencyContainer.swift | 3 +- ...edEncodingContainer+EncodeNilOrValue.swift | 51 ++++++++++++++----- .../Network/Device Helper/DeviceHelper.swift | 17 +++++-- .../Templating/Models/DeviceTemplate.swift | 4 +- .../Products/StoreProduct/Entitlement.swift | 29 ++++++----- .../Network/DeviceHelperMock.swift | 2 +- .../EntitlementUnknownFieldsTests.swift | 50 +++++++++++++++++- 8 files changed, 121 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55035ddf0..7b5e5d729 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. - Fixes a data race during SDK configuration that Thread Sanitizer flagged on every launch. - Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them. -- Fixes audiences matching users they shouldn't when you use a Purchase Controller. An entitlement's renewal details are unknown in that setup, and audience filters were reading the missing details as `false`, so an active subscriber could match an audience like "active and will not renew". Unknown details are now sent as null and no longer match. +- Fixes audiences matching users they shouldn't when you use a Purchase Controller. An entitlement's renewal details are unknown in that setup, and audience filters were reading the missing details as `false`, so an active subscriber could match an audience like "active and will not renew". Audience filters now see those details as null and no longer match them. Only audience filters changed — the enrichment request, paywall template variables, session attributes and `getDeviceAttributes()` all keep the shape they had. ## 4.16.3 diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index f03c3aef5..25e57cfdf 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -470,7 +470,8 @@ extension DependencyContainer: AudienceFilterAttributesFactory { let deviceAttributes = await deviceHelper.getDeviceAttributes( since: placement, - computedPropertyRequests: computedPropertyRequests + computedPropertyRequests: computedPropertyRequests, + reportingUnknownFieldsAsNull: true ) return [ "user": userAttributes, diff --git a/Sources/SuperwallKit/Misc/Extensions/KeyedEncodingContainer+EncodeNilOrValue.swift b/Sources/SuperwallKit/Misc/Extensions/KeyedEncodingContainer+EncodeNilOrValue.swift index 00722c6ec..4a19d4918 100644 --- a/Sources/SuperwallKit/Misc/Extensions/KeyedEncodingContainer+EncodeNilOrValue.swift +++ b/Sources/SuperwallKit/Misc/Extensions/KeyedEncodingContainer+EncodeNilOrValue.swift @@ -7,26 +7,51 @@ import Foundation -extension KeyedEncodingContainer { - /// Encodes an optional, writing an explicit null when it's `nil`. +extension CodingUserInfoKey { + /// Set on an encoder to write a field we have no value for as an explicit + /// null instead of leaving the key out. /// - /// `encodeIfPresent` leaves the key out entirely, which loses the difference - /// between a value we know to be absent and one we never learned. Audience - /// filters give a missing key the type default, so a dropped `Bool?` reads as - /// `false`. Use this for any field a filter might compare by equality. + /// Only the audience filter attributes are encoded this way. A filter gives a + /// missing key the type default, so a dropped `Bool?` reads as `false` and a + /// bare Purchase Controller entitlement matched `willRenew == false`. Every + /// other consumer — the enrichment request, paywall template variables, + /// session attributes and `getDeviceAttributes()` — keeps the shape it has + /// always had. + // swiftlint:disable:next force_unwrapping + static let reportsUnknownFieldsAsNull = CodingUserInfoKey(rawValue: "reportsUnknownFieldsAsNull")! +} + +extension Encoder { + /// Whether this encoder wants unknown fields written as explicit nulls. + var reportsUnknownFieldsAsNull: Bool { + userInfo[.reportsUnknownFieldsAsNull] as? Bool ?? false + } +} + +extension JSONEncoder { + /// An encoder that writes unknown optional fields as explicit nulls. + static func reportingUnknownFieldsAsNull() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.userInfo[.reportsUnknownFieldsAsNull] = true + return encoder + } +} + +extension KeyedEncodingContainer { + /// Encodes an optional, either leaving the key out when it's `nil` or writing + /// an explicit null, depending on what the encoder asked for. /// /// Plain `encode(_:forKey:)` on an optional already writes a null, since - /// `Optional`'s own `Encodable` conformance calls `encodeNil()`. This spells - /// that out at the call site: the one-character difference between `encode` - /// and `encodeIfPresent` is easy to read as a typo and "tidy up" back into - /// the bug. - mutating func encodeNilOrValue( + /// `Optional`'s own `Encodable` conformance calls `encodeNil()`. Spelling it + /// out keeps the two behaviours side by side at the call site. + mutating func encode( _ value: T?, - forKey key: Key + forKey key: Key, + nilAsNull: Bool ) throws { if let value = value { try encode(value, forKey: key) - } else { + } else if nilAsNull { try encodeNil(forKey: key) } } diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index c59f84159..709957b94 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -794,11 +794,16 @@ class DeviceHelper { ) } + /// - Parameter reportingUnknownFieldsAsNull: Only the audience filter + /// attributes pass `true`. See ``CodingUserInfoKey/reportsUnknownFieldsAsNull``. func getDeviceAttributes( since placement: PlacementData?, - computedPropertyRequests: [ComputedPropertyRequest] + computedPropertyRequests: [ComputedPropertyRequest], + reportingUnknownFieldsAsNull: Bool = false ) async -> [String: Any] { - var dictionary = await getTemplateDevice() + var dictionary = await getTemplateDevice( + reportingUnknownFieldsAsNull: reportingUnknownFieldsAsNull + ) let computedProperties = await getComputedDevicePropertiesSincePlacement( placement, @@ -952,7 +957,7 @@ class DeviceHelper { } } - func getTemplateDevice() async -> [String: Any] { + func getTemplateDevice(reportingUnknownFieldsAsNull: Bool = false) async -> [String: Any] { let identityInfo = await factory.makeIdentityInfo() let aliases = [identityInfo.aliasId] @@ -1028,7 +1033,11 @@ class DeviceHelper { deviceId: factory.makeDeviceId() ) - var deviceDictionary = template.toDictionary() + var deviceDictionary = template.toDictionary( + encoder: reportingUnknownFieldsAsNull + ? .reportingUnknownFieldsAsNull() + : JSONEncoder() + ) let enrichmentDict: [String: Any] = $enrichment.withSnapshot { enrichment in enrichment?.device.dictionaryObject ?? [:] diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/Templating/Models/DeviceTemplate.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/Templating/Models/DeviceTemplate.swift index 8a77c694b..31427cb16 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/Templating/Models/DeviceTemplate.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/Templating/Models/DeviceTemplate.swift @@ -70,8 +70,8 @@ struct DeviceTemplate: Codable { var localResourceIds: String var deviceId: String - func toDictionary() -> [String: Any] { - guard let data = try? JSONEncoder().encode(self) else { + func toDictionary(encoder: JSONEncoder = JSONEncoder()) -> [String: Any] { + guard let data = try? encoder.encode(self) else { return [:] } let jsonObject = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift index f2c6717b8..1cc50e3fe 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift @@ -247,20 +247,23 @@ public final class Entitlement: NSObject, Codable, Sendable { try container.encodeIfPresent(startsAt, forKey: .startsAt) try container.encodeIfPresent(renewedAt, forKey: .renewedAt) try container.encodeIfPresent(expiresAt, forKey: .expiresAt) - // Encoded as an explicit null rather than left out, so an audience filter can - // tell "we don't know" from "no". A Purchase Controller can't fill these in, - // and a missing key is given the type default during filter evaluation, which - // made a bare entitlement match `willRenew == false`. + // When the encoder asks for it, a detail we have no value for is written as + // an explicit null rather than left out, so an audience filter can tell "we + // don't know" from "no". A Purchase Controller can't fill these in, and a + // filter gives a missing key the type default, which made a bare entitlement + // match `willRenew == false`. Only the filter attributes are encoded that + // way; every other consumer sees the keys omitted as before. // - // The dates above stay `encodeIfPresent`: filters compare them with `<` and - // `>`, and a null on either side of an ordering comparison makes the whole - // filter evaluate to null, taking unrelated parts of the filter with it. - try container.encodeNilOrValue(latestProductId, forKey: .latestProductId) - try container.encodeNilOrValue(store, forKey: .store) - try container.encodeNilOrValue(isLifetime, forKey: .isLifetime) - try container.encodeNilOrValue(willRenew, forKey: .willRenew) - try container.encodeNilOrValue(state, forKey: .state) - try container.encodeNilOrValue(offerType, forKey: .offerType) + // The dates above are never nulled: filters compare them with `<` and `>`, + // and a null on either side of an ordering comparison makes the whole filter + // evaluate to null, taking unrelated parts of the filter with it. + let nilAsNull = encoder.reportsUnknownFieldsAsNull + try container.encode(latestProductId, forKey: .latestProductId, nilAsNull: nilAsNull) + try container.encode(store, forKey: .store, nilAsNull: nilAsNull) + try container.encode(isLifetime, forKey: .isLifetime, nilAsNull: nilAsNull) + try container.encode(willRenew, forKey: .willRenew, nilAsNull: nilAsNull) + try container.encode(state, forKey: .state, nilAsNull: nilAsNull) + try container.encode(offerType, forKey: .offerType, nilAsNull: nilAsNull) } // Deep equality across all fields. For detecting logical status changes, diff --git a/Tests/SuperwallKitTests/Network/DeviceHelperMock.swift b/Tests/SuperwallKitTests/Network/DeviceHelperMock.swift index d90b843dd..ff43ee613 100644 --- a/Tests/SuperwallKitTests/Network/DeviceHelperMock.swift +++ b/Tests/SuperwallKitTests/Network/DeviceHelperMock.swift @@ -24,7 +24,7 @@ final class DeviceHelperMock: DeviceHelper { // Don't actually fetch enrichment in tests - just return immediately } - override func getTemplateDevice() async -> [String: Any] { + override func getTemplateDevice(reportingUnknownFieldsAsNull: Bool = false) async -> [String: Any] { // Return mock device attributes without async calls return [ "publicApiKey": "test_key", diff --git a/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/EntitlementUnknownFieldsTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/EntitlementUnknownFieldsTests.swift index 611099f94..3b57f6690 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/EntitlementUnknownFieldsTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/EntitlementUnknownFieldsTests.swift @@ -19,8 +19,14 @@ import Testing struct EntitlementUnknownFieldsTests { /// The same trip an entitlement makes on its way to a filter: /// `JSONEncoder` -> `JSONSerialization` -> `[String: Any]`. - private func encodedDictionary(_ entitlement: Entitlement) throws -> [String: Any] { - let data = try JSONEncoder().encode(entitlement) + private func encodedDictionary( + _ entitlement: Entitlement, + reportingUnknownFieldsAsNull: Bool = true + ) throws -> [String: Any] { + let encoder = reportingUnknownFieldsAsNull + ? JSONEncoder.reportingUnknownFieldsAsNull() + : JSONEncoder() + let data = try encoder.encode(entitlement) let object = try JSONSerialization.jsonObject(with: data, options: .allowFragments) return try #require(object as? [String: Any]) } @@ -100,6 +106,18 @@ struct EntitlementUnknownFieldsTests { #expect(dictionary["willRenew"] as? Bool == false) } + /// Everything that isn't an audience filter keeps the shape it always had, so + /// the enrichment request, paywall variables, session attributes and + /// `getDeviceAttributes()` don't start carrying nulls. + @Test func unknownFieldsStayOmittedForEveryOtherConsumer() throws { + let entitlement = Entitlement(id: "unlimited_access", isActive: true) + let dictionary = try encodedDictionary(entitlement, reportingUnknownFieldsAsNull: false) + + for key in ["willRenew", "isLifetime", "state", "offerType", "latestProductId", "store"] { + #expect(dictionary.keys.contains(key) == false, "\(key) should be omitted") + } + } + /// Dates are deliberately left out rather than nulled: filters compare them /// with `<` and `>`, and null on either side of an ordering comparison makes /// the whole filter evaluate to null. @@ -112,6 +130,34 @@ struct EntitlementUnknownFieldsTests { #expect(dictionary.keys.contains("renewedAt") == false) } + /// The flag is set on the encoder for the whole device template, and the + /// entitlements sit two levels down inside it. This pins that `userInfo` + /// reaches a nested encoder, which is what the wiring depends on. + @Test func theNullFlagReachesNestedEntitlements() throws { + let customerInfo = CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: [Entitlement(id: "unlimited_access", isActive: true)], + isPlaceholder: false + ) + + func willRenewEntry(using encoder: JSONEncoder) throws -> (present: Bool, isNull: Bool) { + let data = try encoder.encode(customerInfo) + let object = try JSONSerialization.jsonObject(with: data, options: .allowFragments) + let dictionary = try #require(object as? [String: Any]) + let entitlements = try #require(dictionary["entitlements"] as? [[String: Any]]) + let entitlement = try #require(entitlements.first) + return (entitlement.keys.contains("willRenew"), entitlement["willRenew"] is NSNull) + } + + let forFilters = try willRenewEntry(using: .reportingUnknownFieldsAsNull()) + #expect(forFilters.present) + #expect(forFilters.isNull) + + let forEveryoneElse = try willRenewEntry(using: JSONEncoder()) + #expect(forEveryoneElse.present == false) + } + @Test func nsNullBecomesPassableNull() { if case PassableValue.null = toPassableValue(from: NSNull()) { return From f60d255e7283a501c5114e9095a9ce0270810d0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:45:18 +0200 Subject: [PATCH 078/162] test: pin the one call site that turns the null encoding on Every other case builds the encoder by hand, so deleting `reportingUnknownFieldsAsNull: true` from `makeAudienceFilterAttributes` left the whole suite green while restoring the incident. The new test runs the real production call and checks both directions: the filter attributes carry a null `willRenew`, and `getTemplateDevice` still omits the key. Verified it fails when that argument is removed. Also renames the helper file, which no longer holds an `encodeNilOrValue` and is mostly not a `KeyedEncodingContainer` extension. Co-Authored-By: Claude Opus 5 --- ... Encoder+ReportsUnknownFieldsAsNull.swift} | 2 +- SuperwallKit.xcodeproj/project.pbxproj | 8 ++-- .../EntitlementUnknownFieldsTests.swift | 39 +++++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) rename Sources/SuperwallKit/Misc/Extensions/{KeyedEncodingContainer+EncodeNilOrValue.swift => Encoder+ReportsUnknownFieldsAsNull.swift} (97%) diff --git a/Sources/SuperwallKit/Misc/Extensions/KeyedEncodingContainer+EncodeNilOrValue.swift b/Sources/SuperwallKit/Misc/Extensions/Encoder+ReportsUnknownFieldsAsNull.swift similarity index 97% rename from Sources/SuperwallKit/Misc/Extensions/KeyedEncodingContainer+EncodeNilOrValue.swift rename to Sources/SuperwallKit/Misc/Extensions/Encoder+ReportsUnknownFieldsAsNull.swift index 4a19d4918..8eb9bdbc3 100644 --- a/Sources/SuperwallKit/Misc/Extensions/KeyedEncodingContainer+EncodeNilOrValue.swift +++ b/Sources/SuperwallKit/Misc/Extensions/Encoder+ReportsUnknownFieldsAsNull.swift @@ -1,5 +1,5 @@ // -// KeyedEncodingContainer+EncodeNilOrValue.swift +// Encoder+ReportsUnknownFieldsAsNull.swift // SuperwallKit // // Created by Yusuf Tör on 07/09/2026. diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index e161b19b4..a3bcbd605 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -69,6 +69,7 @@ 1E0D00DF75A6779C78145750 /* SurveyPresentationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B66B5A624F8A2E225EACA69 /* SurveyPresentationResult.swift */; }; 1E7EBE0C39AC302D9B5BFF27 /* PublicGameController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 010F0F8FCE0A86D8F2823A47 /* PublicGameController.swift */; }; 1E81A71ADE8A5EAD9E609E1D /* AppSessionManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B78FB9232236AF44369EA92 /* AppSessionManagerMock.swift */; }; + 1EA8A326074CB46980BA7C9C /* Encoder+ReportsUnknownFieldsAsNull.swift in Sources */ = {isa = PBXBuildFile; fileRef = D47AA8B18B4570F0C24B7389 /* Encoder+ReportsUnknownFieldsAsNull.swift */; }; 1F20D775BC035F8A75B79323 /* PaywallMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF61DCA9CF170E0AFC507BAE /* PaywallMessageHandler.swift */; }; 1F9AE04458B942D070FC4832 /* FakeTrackingAuthorizationStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FE43B98D847BB6DE291F0B4 /* FakeTrackingAuthorizationStatusTests.swift */; }; 1FB66F276E4FD5AEC37EC14A /* ContactStoreProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F35F68AF572F7CDF174320C /* ContactStoreProxy.swift */; }; @@ -126,7 +127,6 @@ 342593FCA24FBEA77FE472C7 /* SK2ReceiptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050BC76657949DBB5F3D551C /* SK2ReceiptManager.swift */; }; 3464196F9088F8A320FE24A4 /* PendingStripeCheckoutPollState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797EC0356AA1065ED11835BF /* PendingStripeCheckoutPollState.swift */; }; 35597883CB038DBEE63E162B /* EventData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86D76FB5809C3B8122778A9 /* EventData.swift */; }; - 3584214186291379380E6823 /* KeyedEncodingContainer+EncodeNilOrValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = D326F60B14F6911B6BDFD96B /* KeyedEncodingContainer+EncodeNilOrValue.swift */; }; 3652D5EE4C172D623BDEE7E4 /* PresentationIdTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3F306D67A9F3A43D082DD83 /* PresentationIdTests.swift */; }; 369677E9A6E8754CFD20714D /* TrackingParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 764012CF0C0972240A73E3CF /* TrackingParameters.swift */; }; 36B598D26A38D50CC713FD73 /* ProductsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641BC3C3F8AC2D6E1EF44D55 /* ProductsManager.swift */; }; @@ -1115,13 +1115,13 @@ D1443B535E6E1D572A74733F /* SubscriptionPeriod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionPeriod.swift; sourceTree = ""; }; D198C8645A213EEAD622C881 /* FakeLocationAuthorizationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeLocationAuthorizationStatus.swift; sourceTree = ""; }; D31BB6D0C57337C6E929D617 /* ASN1Decoder+UnkeyedDecodingContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ASN1Decoder+UnkeyedDecodingContainer.swift"; sourceTree = ""; }; - D326F60B14F6911B6BDFD96B /* KeyedEncodingContainer+EncodeNilOrValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyedEncodingContainer+EncodeNilOrValue.swift"; sourceTree = ""; }; D3479E4B3365290BC0C0A123 /* StripeProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeProduct.swift; sourceTree = ""; }; D3506FCC35155DF104A1DFCA /* CustomURLSessionMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomURLSessionMock.swift; sourceTree = ""; }; D36898353ABCE620BA7AEE59 /* SuperwallGraveyard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuperwallGraveyard.swift; sourceTree = ""; }; D38D3A69A26709BFB52C6A3A /* Product.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Product.swift; sourceTree = ""; }; D3E5C31CEEDC9C2853D91C50 /* DeviceTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceTemplate.swift; sourceTree = ""; }; D449672964023589DA5535E3 /* String+MD5.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+MD5.swift"; sourceTree = ""; }; + D47AA8B18B4570F0C24B7389 /* Encoder+ReportsUnknownFieldsAsNull.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Encoder+ReportsUnknownFieldsAsNull.swift"; sourceTree = ""; }; D4F676D3A0F5B540052D36B1 /* PublicGetPresentationResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicGetPresentationResult.swift; sourceTree = ""; }; D561728FDB68F572D5E33223 /* PermissionsHandler+Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PermissionsHandler+Contacts.swift"; sourceTree = ""; }; D570217FF965FF087585931C /* PaywallPreloadingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallPreloadingTests.swift; sourceTree = ""; }; @@ -1678,11 +1678,11 @@ 4B1DC32C4ABB60B8323E5D28 /* AsyncSequence+Extract.swift */, 51407421A3CBF7AF0FC76E60 /* Bundle+Helpers.swift */, B2E6016BF483A4C47FF7A7C0 /* Encodable+Dictionary.swift */, + D47AA8B18B4570F0C24B7389 /* Encoder+ReportsUnknownFieldsAsNull.swift */, 9A7FFEA64AF7F4E09F052FCD /* Error+SafeLocalizedDescription.swift */, 8DE36D141F461F6E945823FA /* Future+Async.swift */, E2243C6BF6BE477794F568ED /* GCControllerElement+buttonName.swift */, FD778E66506BA51BEB5EE89C /* JSONEncoder+Superwall.swift */, - D326F60B14F6911B6BDFD96B /* KeyedEncodingContainer+EncodeNilOrValue.swift */, F4B35EF62D8C986B504B052C /* NSManagedObjectContext+mergeChanges.swift */, AF5A8FFFC23826AD113D525A /* Publisher+Async.swift */, A524F7AAE90E48C3B8D7E99A /* PurchaseResult+Internal.swift */, @@ -3526,6 +3526,7 @@ B89435087910E6B501471622 /* Email.swift in Sources */, 9EAE577E60052F5E1C7B9657 /* EmptyResponse.swift in Sources */, CB1E11FB74879A29DD1C9EB1 /* Encodable+Dictionary.swift in Sources */, + 1EA8A326074CB46980BA7C9C /* Encoder+ReportsUnknownFieldsAsNull.swift in Sources */, 11477D1EB60D1FDA32F5099A /* Endpoint.swift in Sources */, AD26500C2B27829305F76859 /* EndpointKind.swift in Sources */, 14B9EC6E8BAE199C9349838C /* Enrichment.swift in Sources */, @@ -3590,7 +3591,6 @@ CDFE4C9A23CB583CAC113F59 /* IntroOfferTokenManager.swift in Sources */, E63A7174E3B98690B073C373 /* JSONEncoder+Superwall.swift in Sources */, 3F3774A066285BB0DFE61B61 /* JSONToDict.swift in Sources */, - 3584214186291379380E6823 /* KeyedEncodingContainer+EncodeNilOrValue.swift in Sources */, 5621A2D2FEC048847E22BF6C /* KeypathWritable.swift in Sources */, 88A5CA6515126BD3D09E0563 /* LimitedQueue.swift in Sources */, 68AF64973AC860BE2A41B8D4 /* LoadingInfo.swift in Sources */, diff --git a/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/EntitlementUnknownFieldsTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/EntitlementUnknownFieldsTests.swift index 3b57f6690..5ee799249 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/EntitlementUnknownFieldsTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/EntitlementUnknownFieldsTests.swift @@ -158,6 +158,45 @@ struct EntitlementUnknownFieldsTests { #expect(forEveryoneElse.present == false) } + /// Runs the real production call rather than a hand-built encoder, so that + /// removing `reportingUnknownFieldsAsNull: true` from + /// `makeAudienceFilterAttributes` fails a test instead of silently restoring + /// the incident. That one argument is the whole opt-in. + @Test func onlyTheAudienceFilterPathReportsUnknownFieldsAsNull() async throws { + let container = DependencyContainer() + let previous = Superwall.shared.customerInfo + defer { Superwall.shared.customerInfo = previous } + + Superwall.shared.customerInfo = CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: [Entitlement(id: "unlimited_access", isActive: true)], + isPlaceholder: false + ) + + func willRenewEntry(in device: [String: Any]) throws -> (present: Bool, isNull: Bool) { + let customerInfo = try #require(device["customerInfo"] as? [String: Any]) + let entitlements = try #require(customerInfo["entitlements"] as? [[String: Any]]) + let entitlement = try #require( + entitlements.first { $0["identifier"] as? String == "unlimited_access" } + ) + return (entitlement.keys.contains("willRenew"), entitlement["willRenew"] is NSNull) + } + + let filterAttributes = await container.makeAudienceFilterAttributes( + forPlacement: nil, + withComputedProperties: [] + ) + let filterDevice = try #require(filterAttributes["device"] as? [String: Any]) + let forFilters = try willRenewEntry(in: filterDevice) + #expect(forFilters.present) + #expect(forFilters.isNull) + + // The same data on the way to every other consumer keeps its old shape. + let template = await container.deviceHelper.getTemplateDevice() + #expect(try willRenewEntry(in: template).present == false) + } + @Test func nsNullBecomesPassableNull() { if case PassableValue.null = toPassableValue(from: NSNull()) { return From 193dc2558199ef953ff2b472c514941e4023e69b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:49:12 +0200 Subject: [PATCH 079/162] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b5e5d729..2e1880528 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. - Fixes a data race during SDK configuration that Thread Sanitizer flagged on every launch. - Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them. -- Fixes audiences matching users they shouldn't when you use a Purchase Controller. An entitlement's renewal details are unknown in that setup, and audience filters were reading the missing details as `false`, so an active subscriber could match an audience like "active and will not renew". Audience filters now see those details as null and no longer match them. Only audience filters changed — the enrichment request, paywall template variables, session attributes and `getDeviceAttributes()` all keep the shape they had. +- Fixes audiences matching users they shouldn't when you use a Purchase Controller. ## 4.16.3 From 1e97fc2f224748158ce883d7ed7520965fe2ce10 Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Wed, 9 Sep 2026 14:28:20 -0400 Subject: [PATCH 080/162] feat(dev): present a local paywall the way its config.ts says --- CHANGELOG.md | 1 + .../Config/Options/SuperwallOptions.swift | 27 ++-- .../DevServer/DevServerPaywall.swift | 56 ++++--- .../DevServer/DevServerSettings.swift | 125 +++++++++++++++ .../DevServer/DevServerSurface.swift | 37 ++++- .../Operators/RawPaywallResponse.swift | 6 +- SuperwallKit.xcodeproj/project.pbxproj | 8 + .../DevServer/DevServerManifestTests.swift | 105 ++++++++++++ .../DevServer/DevServerPaywallTests.swift | 99 +++++++++++- .../DevServer/DevServerSettingsTests.swift | 149 ++++++++++++++++++ 10 files changed, 569 insertions(+), 44 deletions(-) create mode 100644 Sources/SuperwallKit/DevServer/DevServerSettings.swift create mode 100644 Tests/SuperwallKitTests/DevServer/DevServerSettingsTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 92d6a0bbd..2b8a2f9b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Adds `grantedEntitlements` so you can grant entitlements from your own backend, which the SDK merges with device and web entitlements. - Changes `$subscriptionStatus` from a `@Published` publisher to an `AnyPublisher`. Subscribing to it works as before, but it can no longer be the target of `assign(to:)`. +- Dev mode now presents a locally served paywall the way its `config.ts` says: presentation style, feature gating, scrolling and background colours come from your local code instead of the last pushed version, so changing them is visible without a push. Settings `config.ts` cannot express, and any it leaves out, still come from the published paywall. Needs a `superwall dev` server new enough to send them. - Adds `SuperwallOptions.devServer` for development builds: with a `superwall dev` server running, paywalls render from your live, local paywall code while configuration, placements, audience evaluation and assignment stay real. Use `.default` on a simulator, which finds the dev server on localhost automatically; on a physical device use `.url(...)` with the Device URL `superwall dev` prints. The dev server also activates test mode, disables preloading, and skips the test mode intro sheet. ### Fixes diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index c26c3ba00..566051394 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -404,19 +404,24 @@ public final class SuperwallOptions: NSObject, Encodable { /// /// Paywalls with a local counterpart on the dev server then render from your live, local /// paywall code instead of their published versions, while configuration, placements, - /// audience evaluation, assignment and feature gating all stay real. Paywalls without a - /// local counterpart still load their published versions. + /// audience evaluation, assignment and the enforcement of feature gating all stay real. + /// Paywalls without a local counterpart still load their published versions. /// - /// What the local surface owns is what it renders: its content and its products. - /// Everything else the dashboard configures — presentation style, feature gating, - /// intro offer eligibility, surveys — comes from the published paywall, because those - /// settings reach the SDK in the snapshot `superwall push` uploads rather than from - /// the dev server. So a `config.ts` change to any of them is not visible in dev mode - /// until you push it. + /// What the local surface owns is what it renders and the settings its `config.ts` + /// declares: content, products, presentation style, feature gating, scrolling and + /// background colours all come from your local code, so changing them shows on the + /// next presentation without a push. What `config.ts` cannot express — intro offer + /// eligibility, surveys, computed properties — comes from the published paywall the + /// surface stands in for, and so does any setting your `config.ts` leaves out. /// - /// Local notifications are the one exception: the published paywall's are ignored and - /// the ones your local `config.ts` declares fire straight away, without a push, because - /// they reach the SDK from the paywall itself rather than from the dashboard. + /// A dev server older than this SDK sends no settings at all, in which case every + /// setting comes from the published paywall. + /// + /// Two exceptions. Local notifications are always the local paywall's: the published + /// ones are ignored and the ones your `config.ts` declares fire without a push, because + /// they reach the SDK from the paywall itself rather than from the dashboard. And the + /// on-device cache stays off however either side configures it, so a page you are + /// editing is never served from the web view's cache. /// /// Use ``DevServer/default`` on a simulator; on a physical device use ``DevServer/url(_:)`` /// with the `Device` URL that `superwall dev` prints. Defaults to `nil`: no dev server. diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift index 0ce4d6408..e45068273 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift @@ -14,13 +14,16 @@ extension Paywall { /// Builds the paywall a dev server surface presents. /// /// - Parameter published: the dashboard paywall this surface stands in for, - /// if any. The surface owns what renders — its bytes and its products. - /// Everything else the dashboard configures, presentation included, is - /// inherited from `published`, so dev mode changes a paywall's content and - /// never its configuration. Two exceptions, both marked below: - /// `localNotifications`, which the local paywall declares itself, and - /// `onDeviceCache`, which stays `.disabled` so a live-reloading local page - /// is never served from the web view's cache. + /// if any. The surface owns what renders — its bytes, its products, and + /// whatever its `config.ts` settings say, which the manifest carries. The + /// dashboard owns what the manifest cannot express, so `published` fills + /// every silence: a setting the manifest declares wins, one it leaves out + /// is inherited, and with neither the safe default stands. + /// + /// Two fields are never taken from the manifest, both marked below: + /// `localNotifications`, which the local paywall declares itself in messages + /// rather than here, and `onDeviceCache`, which stays `.disabled` so a + /// live-reloading local page is never served from the web view's cache. /// /// Anything added to `Paywall` later defaults to the local stub's value, so /// if the dashboard configures it and the local paywall has no way of its @@ -45,16 +48,22 @@ extension Paywall { ?? "dev:\(surface.id)" let cacheKey = "dev:\(surface.id):\(url.absoluteString)" let responseLoadingInfo: LoadingInfo = published?.responseLoadingInfo ?? .init() - // A paywall's config.ts settings reach the SDK in the pushed snapshot, - // not the dev manifest, so they come from the published paywall this - // surface stands in for. Without one — a surface that has never been - // pushed — the safe defaults stand. - let featureGating: FeatureGatingBehavior = published?.featureGating ?? .nonGated + // What config.ts declares comes off the manifest; what it cannot express + // comes off the published paywall; with neither, the safe default stands. + let settings = surface.settings + let featureGating: FeatureGatingBehavior = settings?.featureGating + ?? published?.featureGating + ?? .nonGated let computedPropertyRequests: [ComputedPropertyRequest] = published?.computedPropertyRequests ?? [] let surveys: [Survey] = published?.surveys ?? [] let introOfferEligibility: IntroOfferEligibility = published?.introOfferEligibility ?? .automatic - let presentation = published?.presentation - ?? PaywallPresentationInfo(style: .fullscreen, delay: 0) + let presentation = PaywallPresentationInfo( + style: settings?.presentationStyle ?? published?.presentation.style ?? .fullscreen, + delay: published?.presentation.delay ?? 0 + ) + let backgroundColorHex = settings?.backgroundColorHex ?? published?.backgroundColorHex + let darkBackgroundColorHex = settings?.darkBackgroundColorHex + ?? published?.darkBackgroundColorHex var paywall = Paywall( databaseId: databaseId, @@ -69,10 +78,10 @@ extension Paywall { ), htmlSubstitutions: "", presentation: presentation, - backgroundColorHex: published?.backgroundColorHex ?? "#FFFFFF", - backgroundColor: published?.backgroundColor ?? .white, - darkBackgroundColorHex: published?.darkBackgroundColorHex, - darkBackgroundColor: published?.darkBackgroundColor, + backgroundColorHex: backgroundColorHex ?? "#FFFFFF", + backgroundColor: backgroundColorHex.map { UIColor(hexString: $0) } ?? .white, + darkBackgroundColorHex: darkBackgroundColorHex, + darkBackgroundColor: darkBackgroundColorHex.map { UIColor(hexString: $0) }, productItems: products, productIds: products.map { $0.id }, appStoreProductIds: products.map { $0.id }, @@ -81,12 +90,11 @@ extension Paywall { productsLoadingInfo: .init(), shimmerLoadingInfo: .init(), paywalljsVersion: "", - // Feature gating decides whether a non-paying user gets the feature, so - // it can never come from local paywall code. featureGating: featureGating, - // Deliberately not inherited either: a dev server reloads the page on - // every edit, and DependencyContainer feeds this straight into the web - // view, so an enabled cache could serve a stale copy of the local page. + // Never taken from either side: a dev server reloads the page on every + // edit, and DependencyContainer feeds this straight into the web view, + // so an enabled cache — which the manifest reports, because push stamps + // it — could serve a stale copy of the local page. onDeviceCache: .disabled, // Deliberately not inherited: a local paywall declares its own // notifications in config.ts, and they reach the SDK as @@ -98,7 +106,7 @@ extension Paywall { // lacks computed properties that production resolves. computedPropertyRequests: computedPropertyRequests, surveys: surveys, - isScrollEnabled: published?.isScrollEnabled ?? true, + isScrollEnabled: settings?.isScrollEnabled ?? published?.isScrollEnabled ?? true, // Drives displayed trial state and pricing, which is exactly what a // local preview is checked against. introOfferEligibility: introOfferEligibility diff --git a/Sources/SuperwallKit/DevServer/DevServerSettings.swift b/Sources/SuperwallKit/DevServer/DevServerSettings.swift new file mode 100644 index 000000000..148c9dadc --- /dev/null +++ b/Sources/SuperwallKit/DevServer/DevServerSettings.swift @@ -0,0 +1,125 @@ +// +// DevServerSettings.swift +// SuperwallKit +// +// The settings a `superwall dev` surface declares in its `config.ts`, as the +// manifest carries them. +// +// These are the same values `superwall push` stamps on a paywall: the CLI +// computes them once and serves them here so local code presents the way it +// will present once pushed. The keys are the push API's, not the static +// config's, because the dev server stands in for the push — so this type +// does the translation the backend does in production. +// +// Settings the manifest carries that iOS has no per-paywall notion of are +// deliberately absent: `game_controller_enabled` is a SuperwallOption, and +// `web_checkout_destination` only steers the web build. `on_device_cache` is +// read by nothing here on purpose — see Paywall.devServer(surface:url:). +// + +import Foundation + +struct DevServerSettings: Decodable, Equatable { + let presentationStyle: PaywallPresentationStyle? + let featureGating: FeatureGatingBehavior? + let isScrollEnabled: Bool? + let backgroundColorHex: String? + let darkBackgroundColorHex: String? + + private enum CodingKeys: String, CodingKey { + case presentationStyle = "presentation_style" + case featureGating = "feature_gating" + case isScrollEnabled = "scroll_enabled" + case backgroundColorHex = "background_color_hex" + case darkBackgroundColorHex = "dark_background_color_hex" + } + + private enum StyleKeys: String, CodingKey { + case type + case height + case width + case cornerRadius = "corner_radius" + } + + private enum WireStyle: String { + case fullscreen = "FULLSCREEN" + case modal = "MODAL" + case push = "PUSH" + case noAnimation = "NO_ANIMATION" + case drawer = "DRAWER" + case popup = "POPUP" + } + + private enum WireGating: String, Decodable { + case gated + case nonGated = "non_gated" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + presentationStyle = try Self.style(in: container) + switch try container.decodeIfPresent(WireGating.self, forKey: .featureGating) { + case .gated: + featureGating = .gated + case .nonGated: + featureGating = .nonGated + case nil: + featureGating = nil + } + isScrollEnabled = try container.decodeIfPresent(Bool.self, forKey: .isScrollEnabled) + backgroundColorHex = try container.decodeIfPresent(String.self, forKey: .backgroundColorHex) + darkBackgroundColorHex = try container.decodeIfPresent( + String.self, + forKey: .darkBackgroundColorHex + ) + } + + private static func style( + in container: KeyedDecodingContainer + ) throws -> PaywallPresentationStyle? { + guard + let style = try? container.nestedContainer(keyedBy: StyleKeys.self, forKey: .presentationStyle) + else { + return nil + } + + let type = try style.decodeIfPresent(String.self, forKey: .type) + let height = try? style.decode(Double.self, forKey: .height) + let width = try? style.decode(Double.self, forKey: .width) + let cornerRadius = try? style.decode(Double.self, forKey: .cornerRadius) + + switch type.flatMap(WireStyle.init(rawValue:)) { + case .fullscreen: + return .fullscreen + case .modal: + return .modal + case .push: + return .push + case .noAnimation: + return .fullscreenNoAnimation + case .drawer: + guard let height = height, let cornerRadius = cornerRadius else { + return unreadable(type) + } + return .drawer(height: height, cornerRadius: cornerRadius) + case .popup: + guard let height = height, let width = width, let cornerRadius = cornerRadius else { + return unreadable(type) + } + return .popup(height: height, width: width, cornerRadius: cornerRadius) + case nil: + return type == nil ? nil : unreadable(type) + } + } + + private static func unreadable(_ type: String?) -> PaywallPresentationStyle? { + Logger.debug( + logLevel: .warn, + scope: .superwallCore, + message: "Ignoring a dev server presentation style this SDK can't read " + + "(\(type ?? "no type")). The paywall presents as its published version does. " + + "Updating SuperwallKit may fix this." + ) + return nil + } +} diff --git a/Sources/SuperwallKit/DevServer/DevServerSurface.swift b/Sources/SuperwallKit/DevServer/DevServerSurface.swift index 0e41a519e..2664b1de6 100644 --- a/Sources/SuperwallKit/DevServer/DevServerSurface.swift +++ b/Sources/SuperwallKit/DevServer/DevServerSurface.swift @@ -6,10 +6,11 @@ // a locally served paywall or funnel, and the dashboard paywall it is // bound to via `superwall.lock`, if any. // -// The manifest carries identity and products only. Everything else a -// paywall's config.ts declares — presentation, feature gating, intro offer -// eligibility — travels to the dashboard in the pushed snapshot instead, so -// the SDK reads those from the published paywall the surface stands in for. +// The manifest carries identity, products, and the settings the surface's +// config.ts declares. Anything it leaves out — intro offer eligibility, +// computed properties, surveys — reaches the SDK only in the pushed +// snapshot, so those come from the published paywall the surface stands in +// for. // import Foundation @@ -28,4 +29,32 @@ struct DevServerSurface: Decodable, Equatable { let identifier: String? let products: [String: String]? + + /// What the surface's `config.ts` says about presenting it. Absent from a + /// dev server older than the settings block, in which case the published + /// paywall's settings stand. + let settings: DevServerSettings? + + private enum CodingKeys: String, CodingKey { + case kind + case id + case url + case paywallId + case paywallIds + case identifier + case products + case settings + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + kind = try container.decode(String.self, forKey: .kind) + id = try container.decode(String.self, forKey: .id) + url = try container.decode(String.self, forKey: .url) + paywallId = try container.decodeIfPresent(String.self, forKey: .paywallId) + paywallIds = try container.decodeIfPresent([String].self, forKey: .paywallIds) + identifier = try container.decodeIfPresent(String.self, forKey: .identifier) + products = try container.decodeIfPresent([String: String].self, forKey: .products) + settings = try? container.decodeIfPresent(DevServerSettings.self, forKey: .settings) + } } diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift index 60c388bb7..d52d5400c 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift @@ -43,9 +43,9 @@ extension PaywallRequestManager { return paywall } - // The local surface replaces the published paywall's bytes and products, - // and inherits everything the dashboard configures that a manifest can't - // express — see Paywall.devServer(surface:url:inheriting:). + // The local surface replaces the published paywall's bytes, products and + // whatever settings its config.ts declares, and inherits the rest from the + // paywall it stands in for — see Paywall.devServer(surface:url:inheriting:). // The synthesized cacheKey embeds the mount URL, so a moved dev server or // a published fallback reloads the web view instead of presenting the // stale page. diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 6295d3ff5..b617909af 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -240,6 +240,7 @@ 6B01DA1FF5FE0240089DEB36 /* StoreTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE7B78DEECD91AFD89B39F9D /* StoreTransaction.swift */; }; 6BA614F410A95F36CBA42F93 /* EntitlementProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E3DAD767490972EA30257F9 /* EntitlementProcessorTests.swift */; }; 6C752C1F0F303B77FB243D10 /* LocalFileSchemeHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7A8FDBB0F8D450288C3FEA0 /* LocalFileSchemeHandlerTests.swift */; }; + 6C8D27EE5DAB83D7F21F45F0 /* DevServerSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F55EFF3BD77B1D75F3C91DF /* DevServerSettingsTests.swift */; }; 6C98C5DAAC3F493511A57AC3 /* WaitForEntitlementsAndConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DFD245D3CB9044C225E1503 /* WaitForEntitlementsAndConfigTests.swift */; }; 6C9B0FB29EA135B4D9A20705 /* WebViewURLConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22439CFFFC5166F34D0DA524 /* WebViewURLConfig.swift */; }; 6CAB392FECDE6E5F0CA2B476 /* DebugPickerLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0ED0B91277B33BB56F9DFA6 /* DebugPickerLogic.swift */; }; @@ -460,6 +461,7 @@ CB2F2B4DA3709F171E54CBB8 /* DeepLinkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EA0BD57CE7F03A50ACA9D25 /* DeepLinkRouter.swift */; }; CBC90EC17DC1EC4C2FE9DFC8 /* ContactStoreProxyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC2A1B3F139D6D01D2E8A0F /* ContactStoreProxyTests.swift */; }; CBFC0D2DCA996A5FF7E5174B /* CacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4DC3F3B888F2DC4CC4747CB /* CacheTests.swift */; }; + CC82569EF062EE50C1B06373 /* DevServerSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4F1A615FE234CE077CD4F35 /* DevServerSettings.swift */; }; CD324457E6206D0E303A6B15 /* ArchiveURLFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81C5A241FC9EF921D4E08FF1 /* ArchiveURLFetcher.swift */; }; CD7A815C87F9AFF406BFE9A0 /* AuthorizationStatus+PermissionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E97A346D7F7C59DF55F62B /* AuthorizationStatus+PermissionStatus.swift */; }; CD9B53E7E4E5E536971CC743 /* IntroOfferTokenManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAECE41BF5F3184C8D79F1CC /* IntroOfferTokenManagerTests.swift */; }; @@ -825,6 +827,7 @@ 5DD4E7007670C369DD8FF5D9 /* Date+IsWithinAnHourBeforeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+IsWithinAnHourBeforeTests.swift"; sourceTree = ""; }; 5E37562E243AE6632134D94A /* DevServerSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerSurface.swift; sourceTree = ""; }; 5EB5A772C1F2ECE6D0E0BD69 /* PaywallState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallState.swift; sourceTree = ""; }; + 5F55EFF3BD77B1D75F3C91DF /* DevServerSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerSettingsTests.swift; sourceTree = ""; }; 60B80BEE0364C0EF86E2084E /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/Localizable.strings; sourceTree = ""; }; 61062B4B7A0AB23514A2F439 /* SwiftVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftVersion.swift; sourceTree = ""; }; 618BF4D10B7D87FAF8FB48CD /* ProductPurchaserSK1Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPurchaserSK1Tests.swift; sourceTree = ""; }; @@ -1006,6 +1009,7 @@ A40D9BA2449503F4B7F5B7A6 /* Array+Guarded.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Guarded.swift"; sourceTree = ""; }; A4493EE88B00CADF85EF1196 /* PublicIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicIdentity.swift; sourceTree = ""; }; A453816DBA43C08410D12DE6 /* PaywallViewControllerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewControllerMock.swift; sourceTree = ""; }; + A4F1A615FE234CE077CD4F35 /* DevServerSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerSettings.swift; sourceTree = ""; }; A4FD16729844D83A3EAA02FC /* DevServerPaywallTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerPaywallTests.swift; sourceTree = ""; }; A5110E43405C69969E9DA67B /* PublicGetPaywall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicGetPaywall.swift; sourceTree = ""; }; A524F7AAE90E48C3B8D7E99A /* PurchaseResult+Internal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PurchaseResult+Internal.swift"; sourceTree = ""; }; @@ -2634,6 +2638,7 @@ ACAF662B855E4A70DDEE66F6 /* DevServerManifest.swift */, 3DE9BCE12E3F4300AD2379B4 /* DevServerPaywall.swift */, 4E9B7111D8087FC1DF3E6B80 /* DevServerPreview.swift */, + A4F1A615FE234CE077CD4F35 /* DevServerSettings.swift */, 5E37562E243AE6632134D94A /* DevServerSurface.swift */, ); path = DevServer; @@ -2717,6 +2722,7 @@ A349A124DD1DF28EEF04592C /* DevServerManifestTests.swift */, A4FD16729844D83A3EAA02FC /* DevServerPaywallTests.swift */, 6E0A1ED94DE7737BCB7D4D8C /* DevServerPreviewTests.swift */, + 5F55EFF3BD77B1D75F3C91DF /* DevServerSettingsTests.swift */, ); path = DevServer; sourceTree = ""; @@ -3384,6 +3390,7 @@ 069391992F6191874022F2BA /* DevServerManifestTests.swift in Sources */, 669B86B82B4CCD7BC7D02B55 /* DevServerPaywallTests.swift in Sources */, EE1A7003F266DF3C1481EEBA /* DevServerPreviewTests.swift in Sources */, + 6C8D27EE5DAB83D7F21F45F0 /* DevServerSettingsTests.swift in Sources */, 0CA13E721ADB243882536D4A /* DeviceHelperMock.swift in Sources */, 9DBDDD10A1EFC7CD3575D9E5 /* DeviceHelperTests.swift in Sources */, 2743143ED664F942D5D758B1 /* DevicePreloadScriptTests.swift in Sources */, @@ -3581,6 +3588,7 @@ 789B734B60F87DBC23FC7930 /* DevServerManifest.swift in Sources */, EA6F422EB1C1F0E6882E4AEF /* DevServerPaywall.swift in Sources */, 08C89125100BC25CE015A7B6 /* DevServerPreview.swift in Sources */, + CC82569EF062EE50C1B06373 /* DevServerSettings.swift in Sources */, 346A77D3F31E471EB7CC4D5C /* DevServerSurface.swift in Sources */, 7FCDAF6C945FA04FC4C4E8E3 /* DeviceHelper.swift in Sources */, 191AA8FBBF617251EF6F8628 /* DeviceInfo.swift in Sources */, diff --git a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift index a0837b37c..f622db253 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift @@ -184,4 +184,109 @@ final class DevServerManifestTests: XCTestCase { XCTAssertEqual(decoded.surface(forPaywallDatabaseId: "333")?.id, "shared") XCTAssertNil(decoded.surface(forPaywallDatabaseId: "444")) } + + /// The manifest a running `superwall dev` actually serves, copied verbatim + /// from `/device/manifest.json`, so a change on either side of the wire + /// fails here rather than on someone's device. + func test_readsTheManifestTheCliServes() throws { + let decoded = try manifest(""" + { + "surfaces": [ + { + "kind": "paywall", + "id": "drawer", + "url": "/preview/paywall/drawer", + "paywallId": "208552", + "settings": { + "presentation_style": { "type": "DRAWER", "height": 60, "corner_radius": 15 }, + "feature_gating": "gated", + "on_device_cache": false, + "scroll_enabled": true, + "game_controller_enabled": true + } + }, + { + "kind": "paywall", + "id": "demo", + "url": "/preview/paywall/demo", + "products": { "primary": "demo_monthly" }, + "settings": { + "presentation_style": { "type": "FULLSCREEN" }, + "feature_gating": "non_gated", + "on_device_cache": true, + "scroll_enabled": true, + "game_controller_enabled": false, + "background_color_hex": "#ffffff", + "dark_background_color_hex": "#0d0f12" + } + }, + { + "kind": "paywall", + "id": "hosted", + "url": "/preview/paywall/hosted", + "settings": { + "presentation_style": { "type": "FULLSCREEN" }, + "feature_gating": "non_gated", + "on_device_cache": true, + "scroll_enabled": true, + "game_controller_enabled": false, + "web_checkout_destination": "EXTERNAL" + } + } + ] + } + """) + + XCTAssertEqual(decoded.surfaces.count, 3) + let bound = try XCTUnwrap(decoded.surface(forPaywallDatabaseId: "208552")) + let url = try XCTUnwrap(URL(string: "http://localhost:6100/preview/paywall/drawer")) + + // A published paywall that disagrees with config.ts on every setting the + // manifest carries, so nothing here can pass by accident. + let stub = Paywall.stub() + let published = Paywall( + databaseId: "208552", + identifier: "pro_published", + name: "Published Pro", + cacheKey: stub.cacheKey, + buildId: stub.buildId, + url: stub.url, + urlConfig: stub.urlConfig, + htmlSubstitutions: "", + presentation: PaywallPresentationInfo(style: .fullscreen, delay: 300), + backgroundColorHex: "#123456", + backgroundColor: .blue, + darkBackgroundColorHex: nil, + darkBackgroundColor: nil, + productItems: [], + productIds: [], + appStoreProductIds: [], + responseLoadingInfo: .init(), + webviewLoadingInfo: .init(), + productsLoadingInfo: .init(), + shimmerLoadingInfo: .init(), + paywalljsVersion: "", + featureGating: .nonGated, + onDeviceCache: .enabled, + isScrollEnabled: false, + introOfferEligibility: .automatic + ) + + let paywall = Paywall.devServer(surface: bound, url: url, inheriting: published) + + XCTAssertEqual(paywall.presentation.style, .drawer(height: 60, cornerRadius: 15)) + XCTAssertEqual(paywall.presentation.delay, 300) + XCTAssertEqual(paywall.featureGating, .gated) + XCTAssertTrue(paywall.isScrollEnabled) + XCTAssertEqual(paywall.onDeviceCache, .disabled) + + // Web-only and app-level settings have no paywall field to land on, so the + // surfaces carrying them still read cleanly. + let hosted = try XCTUnwrap(decoded.surfaces.first { $0.id == "hosted" }) + XCTAssertEqual(hosted.settings?.presentationStyle, .fullscreen) + + let demo = try XCTUnwrap(decoded.surfaces.first { $0.id == "demo" }) + XCTAssertEqual(demo.settings?.backgroundColorHex, "#ffffff") + XCTAssertEqual(demo.settings?.darkBackgroundColorHex, "#0d0f12") + } } diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift index 0b4a45d3a..1743a785d 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift @@ -11,7 +11,8 @@ final class DevServerPaywallTests: XCTestCase { id: String = "pro", paywallId: String? = nil, identifier: String? = nil, - products: [String: String]? = nil + products: [String: String]? = nil, + settings: String? = nil ) -> DevServerSurface { let json = """ { @@ -20,6 +21,7 @@ final class DevServerPaywallTests: XCTestCase { "url": "/preview/paywall/\(id)", \(paywallId.map { "\"paywallId\": \"\($0)\"," } ?? "") \(identifier.map { "\"identifier\": \"\($0)\"," } ?? "") + \(settings.map { "\"settings\": \($0)," } ?? "") "products": \(products.map { dict in "{" + dict.map { "\"\($0.key)\": \"\($0.value)\"" }.sorted().joined(separator: ",") + "}" } ?? "null") @@ -165,7 +167,100 @@ final class DevServerPaywallTests: XCTestCase { XCTAssertEqual(paywall.presentation.style, .fullscreen) } - // MARK: - Partly specified geometry + // MARK: - What config.ts owns + + /// The settings block is the paywall's own config.ts, so it wins over the + /// published paywall — that is the whole point of serving it: a presentation + /// or background change shows on device with no push. + func test_takesEverySettingTheManifestDeclaresOverTheDashboards() { + let paywall = Paywall.devServer( + surface: surface(settings: """ + { + "presentation_style": { "type": "POPUP", "width": 80, "height": 60, "corner_radius": 15 }, + "feature_gating": "non_gated", + "scroll_enabled": true, + "background_color_hex": "#ffffff", + "dark_background_color_hex": "#0d0f12" + } + """), + url: url, + inheriting: published() + ) + + XCTAssertEqual(paywall.presentation.style, .popup(height: 60, width: 80, cornerRadius: 15)) + XCTAssertEqual(paywall.featureGating, .nonGated) + XCTAssertTrue(paywall.isScrollEnabled) + XCTAssertEqual(paywall.backgroundColorHex, "#ffffff") + XCTAssertEqual(paywall.darkBackgroundColorHex, "#0d0f12") + // The colours are derived from the local hexes, not carried over with them. + XCTAssertEqual(paywall.backgroundColor, UIColor(hexString: "#ffffff")) + XCTAssertEqual(paywall.darkBackgroundColor, UIColor(hexString: "#0d0f12")) + } + + /// config.ts has no word for the loading delay, so overriding the style must + /// not silently reset the dashboard's timing. + func test_keepsThePublishedLoadingDelayWhenTheManifestChangesTheStyle() { + let paywall = Paywall.devServer( + surface: surface(settings: """ + { "presentation_style": { "type": "MODAL" } } + """), + url: url, + inheriting: published() + ) + + XCTAssertEqual(paywall.presentation.style, .modal) + XCTAssertEqual(paywall.presentation.delay, 250) + } + + /// Each key stands alone: the block declaring a style says nothing about + /// gating, which still comes from the dashboard. + func test_inheritsTheSettingsTheBlockLeavesOut() { + let paywall = Paywall.devServer( + surface: surface(settings: """ + { "presentation_style": { "type": "MODAL" } } + """), + url: url, + inheriting: published() + ) + + XCTAssertEqual(paywall.presentation.style, .modal) + XCTAssertEqual(paywall.featureGating, .gated) + XCTAssertFalse(paywall.isScrollEnabled) + XCTAssertEqual(paywall.backgroundColorHex, "#123456") + } + + /// A surface that has never been pushed has nothing to inherit, so its + /// config.ts is all there is — and it presents from it. + func test_presentsAnUnpushedSurfaceFromItsOwnConfig() { + let paywall = Paywall.devServer( + surface: surface(id: "draft", settings: """ + { + "presentation_style": { "type": "DRAWER", "height": 60, "corner_radius": 15 }, + "feature_gating": "gated", + "scroll_enabled": false + } + """), + url: url + ) + + XCTAssertEqual(paywall.presentation.style, .drawer(height: 60, cornerRadius: 15)) + XCTAssertEqual(paywall.featureGating, .gated) + XCTAssertFalse(paywall.isScrollEnabled) + } + + /// The block reports the cache because push stamps it, and dev mode ignores + /// it either way — a live-reloading page must never come from the cache. + func test_stillDisablesTheCacheWhenTheManifestReportsItEnabled() { + let paywall = Paywall.devServer( + surface: surface(settings: """ + { "on_device_cache": true, "presentation_style": { "type": "MODAL" } } + """), + url: url, + inheriting: published() + ) + + XCTAssertEqual(paywall.onDeviceCache, .disabled) + } // MARK: - What the dashboard keeps owning diff --git a/Tests/SuperwallKitTests/DevServer/DevServerSettingsTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerSettingsTests.swift new file mode 100644 index 000000000..0b78d52d2 --- /dev/null +++ b/Tests/SuperwallKitTests/DevServer/DevServerSettingsTests.swift @@ -0,0 +1,149 @@ +// +// DevServerSettingsTests.swift +// SuperwallKitTests +// + +import XCTest +@testable import SuperwallKit + +final class DevServerSettingsTests: XCTestCase { + private func settings(_ json: String) throws -> DevServerSettings { + return try JSONDecoder().decode(DevServerSettings.self, from: Data(json.utf8)) + } + + private func surface(_ json: String) throws -> DevServerSurface { + return try JSONDecoder().decode(DevServerSurface.self, from: Data(json.utf8)) + } + + /// The block a current CLI serves for a paywall whose config.ts says nothing: + /// every key the push stamps, at its default. + func test_readsTheBlockAPaywallWithNoConfigGets() throws { + let decoded = try settings(""" + { + "presentation_style": { "type": "FULLSCREEN" }, + "feature_gating": "non_gated", + "on_device_cache": true, + "scroll_enabled": true, + "game_controller_enabled": false + } + """) + + XCTAssertEqual(decoded.presentationStyle, .fullscreen) + XCTAssertEqual(decoded.featureGating, .nonGated) + XCTAssertEqual(decoded.isScrollEnabled, true) + XCTAssertNil(decoded.backgroundColorHex) + XCTAssertNil(decoded.darkBackgroundColorHex) + } + + func test_readsEveryStyleTheCliCanSend() throws { + let cases: [(String, PaywallPresentationStyle)] = [ + ("{ \"type\": \"FULLSCREEN\" }", .fullscreen), + ("{ \"type\": \"MODAL\" }", .modal), + ("{ \"type\": \"PUSH\" }", .push), + ("{ \"type\": \"NO_ANIMATION\" }", .fullscreenNoAnimation), + ( + "{ \"type\": \"DRAWER\", \"height\": 60, \"corner_radius\": 15 }", + .drawer(height: 60, cornerRadius: 15) + ), + ( + "{ \"type\": \"POPUP\", \"width\": 80, \"height\": 60, \"corner_radius\": 15 }", + .popup(height: 60, width: 80, cornerRadius: 15) + ) + ] + + for (json, expected) in cases { + let decoded = try settings("{ \"presentation_style\": \(json) }") + XCTAssertEqual(decoded.presentationStyle, expected, json) + } + } + + func test_readsGatedAndTheBackgroundHexes() throws { + let decoded = try settings(""" + { + "feature_gating": "gated", + "background_color_hex": "#ffffff", + "dark_background_color_hex": "#0d0f12" + } + """) + + XCTAssertEqual(decoded.featureGating, .gated) + XCTAssertEqual(decoded.backgroundColorHex, "#ffffff") + XCTAssertEqual(decoded.darkBackgroundColorHex, "#0d0f12") + } + + /// Anything the block leaves out has to stay nil rather than default, since + /// nil is what tells `Paywall.devServer` to inherit. + func test_leavesEveryUnsaidSettingNil() throws { + let decoded = try settings("{}") + + XCTAssertNil(decoded.presentationStyle) + XCTAssertNil(decoded.featureGating) + XCTAssertNil(decoded.isScrollEnabled) + XCTAssertNil(decoded.backgroundColorHex) + XCTAssertNil(decoded.darkBackgroundColorHex) + } + + /// A newer CLI naming a style this SDK has never heard of costs the paywall + /// its style, not the settings around it. + func test_dropsAStyleItCannotNameAndKeepsTheRest() throws { + let decoded = try settings(""" + { + "presentation_style": { "type": "HOLOGRAM" }, + "feature_gating": "gated", + "scroll_enabled": false + } + """) + + XCTAssertNil(decoded.presentationStyle) + XCTAssertEqual(decoded.featureGating, .gated) + XCTAssertEqual(decoded.isScrollEnabled, false) + } + + /// Geometry the SDK can't trust is treated the same way: the CLI resolves + /// height and radius before serving them, so a partial one is unreadable + /// rather than something to guess a default for. + func test_dropsGeometryItCannotTrust() throws { + XCTAssertNil( + try settings("{ \"presentation_style\": { \"type\": \"DRAWER\" } }").presentationStyle + ) + XCTAssertNil( + try settings("{ \"presentation_style\": { \"type\": \"POPUP\", \"height\": 60 } }") + .presentationStyle + ) + } + + func test_stillRefusesAGatingItCannotName() throws { + XCTAssertThrowsError(try settings("{ \"feature_gating\": \"sometimes\" }")) + } + + func test_aSurfaceFromAnOlderDevServerCarriesNoSettings() throws { + let decoded = try surface(""" + { "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro" } + """) + + XCTAssertNil(decoded.settings) + } + + /// The surface still serves its local code when the block is unreadable — + /// it presents as its published version does instead of vanishing from the + /// manifest. + func test_keepsASurfaceWhoseSettingsCannotBeRead() throws { + let decoded = try surface(""" + { + "kind": "paywall", + "id": "pro", + "url": "/preview/paywall/pro", + "settings": { "feature_gating": "sometimes" } + } + """) + + XCTAssertEqual(decoded.id, "pro") + XCTAssertNil(decoded.settings) + } + + func test_stillRefusesASurfaceMissingItsIdentity() throws { + XCTAssertThrowsError(try surface(""" + { "kind": "paywall", "url": "/preview/paywall/pro" } + """)) + } +} From 7ba938d3e96adba8ca310d8c145ae5da71cb9eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:51:32 +0200 Subject: [PATCH 081/162] feat(dev): let config.ts pick intro offer eligibility The CLI computes introductory_offer_eligibility into the same settings block it stamps at push time, so a local paywall that asks for alwaysEligible should preview that way instead of falling back to whatever the dashboard last published. Co-Authored-By: Claude Opus 5 --- .../DevServer/DevServerPaywall.swift | 23 ++++++++++++++----- .../DevServer/DevServerSettings.swift | 20 +++++++++++++++- .../DevServer/DevServerSurface.swift | 7 +++--- .../DevServer/DevServerPaywallTests.swift | 4 ++++ .../DevServer/DevServerSettingsTests.swift | 22 +++++++++++++++++- 5 files changed, 64 insertions(+), 12 deletions(-) diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift index e45068273..0e97d64fa 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift @@ -56,11 +56,10 @@ extension Paywall { ?? .nonGated let computedPropertyRequests: [ComputedPropertyRequest] = published?.computedPropertyRequests ?? [] let surveys: [Survey] = published?.surveys ?? [] - let introOfferEligibility: IntroOfferEligibility = published?.introOfferEligibility ?? .automatic - let presentation = PaywallPresentationInfo( - style: settings?.presentationStyle ?? published?.presentation.style ?? .fullscreen, - delay: published?.presentation.delay ?? 0 - ) + let introOfferEligibility: IntroOfferEligibility = settings?.introOfferEligibility + ?? published?.introOfferEligibility + ?? .automatic + let presentation = presentationInfo(settings: settings, inheriting: published) let backgroundColorHex = settings?.backgroundColorHex ?? published?.backgroundColorHex let darkBackgroundColorHex = settings?.darkBackgroundColorHex ?? published?.darkBackgroundColorHex @@ -108,7 +107,7 @@ extension Paywall { surveys: surveys, isScrollEnabled: settings?.isScrollEnabled ?? published?.isScrollEnabled ?? true, // Drives displayed trial state and pricing, which is exactly what a - // local preview is checked against. + // local preview is checked against, so config.ts gets to force it. introOfferEligibility: introOfferEligibility ) paywall.isLocal = true @@ -116,6 +115,18 @@ extension Paywall { return paywall } + /// How the surface presents: config.ts picks the style, and the delay is + /// the dashboard's, since config.ts has no word for it. + private static func presentationInfo( + settings: DevServerSettings?, + inheriting published: Paywall? + ) -> PaywallPresentationInfo { + return PaywallPresentationInfo( + style: settings?.presentationStyle ?? published?.presentation.style ?? .fullscreen, + delay: published?.presentation.delay ?? 0 + ) + } + /// The products a surface's `config.ts` declares, in a stable order. private static func productItems(from surface: DevServerSurface) -> [Product] { return (surface.products ?? [:]) diff --git a/Sources/SuperwallKit/DevServer/DevServerSettings.swift b/Sources/SuperwallKit/DevServer/DevServerSettings.swift index 148c9dadc..5e77cc26b 100644 --- a/Sources/SuperwallKit/DevServer/DevServerSettings.swift +++ b/Sources/SuperwallKit/DevServer/DevServerSettings.swift @@ -22,6 +22,7 @@ import Foundation struct DevServerSettings: Decodable, Equatable { let presentationStyle: PaywallPresentationStyle? let featureGating: FeatureGatingBehavior? + let introOfferEligibility: IntroOfferEligibility? let isScrollEnabled: Bool? let backgroundColorHex: String? let darkBackgroundColorHex: String? @@ -29,6 +30,7 @@ struct DevServerSettings: Decodable, Equatable { private enum CodingKeys: String, CodingKey { case presentationStyle = "presentation_style" case featureGating = "feature_gating" + case introOfferEligibility = "introductory_offer_eligibility" case isScrollEnabled = "scroll_enabled" case backgroundColorHex = "background_color_hex" case darkBackgroundColorHex = "dark_background_color_hex" @@ -55,6 +57,12 @@ struct DevServerSettings: Decodable, Equatable { case nonGated = "non_gated" } + private enum WireEligibility: String, Decodable { + case automatic + case eligible = "always_eligible" + case ineligible = "always_ineligible" + } + init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) presentationStyle = try Self.style(in: container) @@ -66,6 +74,16 @@ struct DevServerSettings: Decodable, Equatable { case nil: featureGating = nil } + switch try container.decodeIfPresent(WireEligibility.self, forKey: .introOfferEligibility) { + case .automatic: + introOfferEligibility = .automatic + case .eligible: + introOfferEligibility = .eligible + case .ineligible: + introOfferEligibility = .ineligible + case nil: + introOfferEligibility = nil + } isScrollEnabled = try container.decodeIfPresent(Bool.self, forKey: .isScrollEnabled) backgroundColorHex = try container.decodeIfPresent(String.self, forKey: .backgroundColorHex) darkBackgroundColorHex = try container.decodeIfPresent( @@ -82,7 +100,7 @@ struct DevServerSettings: Decodable, Equatable { else { return nil } - + let type = try style.decodeIfPresent(String.self, forKey: .type) let height = try? style.decode(Double.self, forKey: .height) let width = try? style.decode(Double.self, forKey: .width) diff --git a/Sources/SuperwallKit/DevServer/DevServerSurface.swift b/Sources/SuperwallKit/DevServer/DevServerSurface.swift index 2664b1de6..5178e60e1 100644 --- a/Sources/SuperwallKit/DevServer/DevServerSurface.swift +++ b/Sources/SuperwallKit/DevServer/DevServerSurface.swift @@ -7,10 +7,9 @@ // bound to via `superwall.lock`, if any. // // The manifest carries identity, products, and the settings the surface's -// config.ts declares. Anything it leaves out — intro offer eligibility, -// computed properties, surveys — reaches the SDK only in the pushed -// snapshot, so those come from the published paywall the surface stands in -// for. +// config.ts declares. Anything it leaves out — computed properties, surveys — +// reaches the SDK only in the pushed snapshot, so those come from the +// published paywall the surface stands in for. // import Foundation diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift index 1743a785d..4aebe96c9 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift @@ -178,6 +178,7 @@ final class DevServerPaywallTests: XCTestCase { { "presentation_style": { "type": "POPUP", "width": 80, "height": 60, "corner_radius": 15 }, "feature_gating": "non_gated", + "introductory_offer_eligibility": "always_eligible", "scroll_enabled": true, "background_color_hex": "#ffffff", "dark_background_color_hex": "#0d0f12" @@ -189,6 +190,8 @@ final class DevServerPaywallTests: XCTestCase { XCTAssertEqual(paywall.presentation.style, .popup(height: 60, width: 80, cornerRadius: 15)) XCTAssertEqual(paywall.featureGating, .nonGated) + // The dashboard says ineligible; the local config.ts is what's being tried. + XCTAssertEqual(paywall.introOfferEligibility, .eligible) XCTAssertTrue(paywall.isScrollEnabled) XCTAssertEqual(paywall.backgroundColorHex, "#ffffff") XCTAssertEqual(paywall.darkBackgroundColorHex, "#0d0f12") @@ -225,6 +228,7 @@ final class DevServerPaywallTests: XCTestCase { XCTAssertEqual(paywall.presentation.style, .modal) XCTAssertEqual(paywall.featureGating, .gated) + XCTAssertEqual(paywall.introOfferEligibility, .ineligible) XCTAssertFalse(paywall.isScrollEnabled) XCTAssertEqual(paywall.backgroundColorHex, "#123456") } diff --git a/Tests/SuperwallKitTests/DevServer/DevServerSettingsTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerSettingsTests.swift index 0b78d52d2..4f346f79f 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerSettingsTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerSettingsTests.swift @@ -24,12 +24,14 @@ final class DevServerSettingsTests: XCTestCase { "feature_gating": "non_gated", "on_device_cache": true, "scroll_enabled": true, - "game_controller_enabled": false + "game_controller_enabled": false, + "introductory_offer_eligibility": "automatic" } """) XCTAssertEqual(decoded.presentationStyle, .fullscreen) XCTAssertEqual(decoded.featureGating, .nonGated) + XCTAssertEqual(decoded.introOfferEligibility, .automatic) XCTAssertEqual(decoded.isScrollEnabled, true) XCTAssertNil(decoded.backgroundColorHex) XCTAssertNil(decoded.darkBackgroundColorHex) @@ -78,6 +80,7 @@ final class DevServerSettingsTests: XCTestCase { XCTAssertNil(decoded.presentationStyle) XCTAssertNil(decoded.featureGating) + XCTAssertNil(decoded.introOfferEligibility) XCTAssertNil(decoded.isScrollEnabled) XCTAssertNil(decoded.backgroundColorHex) XCTAssertNil(decoded.darkBackgroundColorHex) @@ -112,6 +115,23 @@ final class DevServerSettingsTests: XCTestCase { ) } + func test_readsEveryEligibilityTheCliCanSend() throws { + let cases: [(String, IntroOfferEligibility)] = [ + ("automatic", .automatic), + ("always_eligible", .eligible), + ("always_ineligible", .ineligible) + ] + + for (wire, expected) in cases { + let decoded = try settings("{ \"introductory_offer_eligibility\": \"\(wire)\" }") + XCTAssertEqual(decoded.introOfferEligibility, expected, wire) + } + } + + func test_stillRefusesAnEligibilityItCannotName() throws { + XCTAssertThrowsError(try settings("{ \"introductory_offer_eligibility\": \"maybe\" }")) + } + func test_stillRefusesAGatingItCannotName() throws { XCTAssertThrowsError(try settings("{ \"feature_gating\": \"sometimes\" }")) } From a7e0db741688a02e3d77d35cd2d4086d62e839d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:01:15 +0200 Subject: [PATCH 082/162] fix(dev): rebuild the paywall when config.ts settings change A cached PaywallViewController is built from the settings once, and Paywall.update(from:) carries only featureGating across, so an edited config.ts used to live-reload the page inside the frame, scrolling and colours the old settings built. The cache key now covers the settings. An unreadable feature_gating or introductory_offer_eligibility now costs that setting alone and logs, the way an unreadable presentation style already did, rather than throwing the whole block away in silence. Co-Authored-By: Claude Opus 5 --- .../DevServer/DevServerPaywall.swift | 32 ++++++++++++++++++- .../DevServer/DevServerSettings.swift | 29 +++++++++++++---- .../DevServer/DevServerPaywallTests.swift | 23 +++++++++++++ .../DevServer/DevServerSettingsTests.swift | 32 +++++++++++++------ 4 files changed, 98 insertions(+), 18 deletions(-) diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift index 0e97d64fa..22322a8c3 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift @@ -20,6 +20,14 @@ extension Paywall { /// every silence: a setting the manifest declares wins, one it leaves out /// is inherited, and with neither the safe default stands. /// + /// In practice a current dev server declares all of them, so the dashboard's + /// copies go unread — including `featureGating`, which means a paywall gated + /// on the dashboard previews non-gated unless its `config.ts` says + /// `featureGating: "gated"`. That is what pushing it would do too, so a + /// preview showing the feature for free is the honest answer, not a bug. + /// `published` is left to fill in for the keys the CLI only sends sometimes + /// (the background colours) and for a dev server too old to send settings. + /// /// Two fields are never taken from the manifest, both marked below: /// `localNotifications`, which the local paywall declares itself in messages /// rather than here, and `onDeviceCache`, which stays `.disabled` so a @@ -46,7 +54,11 @@ extension Paywall { let identifier: String = published?.identifier ?? surface.identifier ?? "dev:\(surface.id)" - let cacheKey = "dev:\(surface.id):\(url.absoluteString)" + // The settings are part of the key because a cached PaywallViewController + // is built from them once: `Paywall.update(from:)` carries `featureGating` + // across but not the style, scroll flag or colours, so without this an + // edited config.ts would live-reload the page inside a stale native frame. + let cacheKey = "dev:\(surface.id):\(url.absoluteString):\(fingerprint(of: surface.settings))" let responseLoadingInfo: LoadingInfo = published?.responseLoadingInfo ?? .init() // What config.ts declares comes off the manifest; what it cannot express // comes off the published paywall; with neither, the safe default stands. @@ -115,6 +127,24 @@ extension Paywall { return paywall } + /// Identifies a settings block, so a changed `config.ts` gets a new cache + /// key. Only the settings the paywall is built from count. + private static func fingerprint(of settings: DevServerSettings?) -> String { + guard let settings = settings else { + return "none" + } + return [ + settings.presentationStyle.map { "\($0)" }, + settings.featureGating.map { "\($0)" }, + settings.introOfferEligibility.map { "\($0)" }, + settings.isScrollEnabled.map { "\($0)" }, + settings.backgroundColorHex, + settings.darkBackgroundColorHex + ] + .map { $0 ?? "-" } + .joined(separator: "|") + } + /// How the surface presents: config.ts picks the style, and the delay is /// the dashboard's, since config.ts has no word for it. private static func presentationInfo( diff --git a/Sources/SuperwallKit/DevServer/DevServerSettings.swift b/Sources/SuperwallKit/DevServer/DevServerSettings.swift index 5e77cc26b..ff95540f8 100644 --- a/Sources/SuperwallKit/DevServer/DevServerSettings.swift +++ b/Sources/SuperwallKit/DevServer/DevServerSettings.swift @@ -52,12 +52,12 @@ struct DevServerSettings: Decodable, Equatable { case popup = "POPUP" } - private enum WireGating: String, Decodable { + private enum WireGating: String { case gated case nonGated = "non_gated" } - private enum WireEligibility: String, Decodable { + private enum WireEligibility: String { case automatic case eligible = "always_eligible" case ineligible = "always_ineligible" @@ -66,15 +66,20 @@ struct DevServerSettings: Decodable, Equatable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) presentationStyle = try Self.style(in: container) - switch try container.decodeIfPresent(WireGating.self, forKey: .featureGating) { + let gating = try container.decodeIfPresent(String.self, forKey: .featureGating) + switch gating.flatMap(WireGating.init(rawValue:)) { case .gated: featureGating = .gated case .nonGated: featureGating = .nonGated case nil: + if gating != nil { + Self.warnUnreadable("feature gating", gating) + } featureGating = nil } - switch try container.decodeIfPresent(WireEligibility.self, forKey: .introOfferEligibility) { + let eligibility = try container.decodeIfPresent(String.self, forKey: .introOfferEligibility) + switch eligibility.flatMap(WireEligibility.init(rawValue:)) { case .automatic: introOfferEligibility = .automatic case .eligible: @@ -82,6 +87,9 @@ struct DevServerSettings: Decodable, Equatable { case .ineligible: introOfferEligibility = .ineligible case nil: + if eligibility != nil { + Self.warnUnreadable("intro offer eligibility", eligibility) + } introOfferEligibility = nil } isScrollEnabled = try container.decodeIfPresent(Bool.self, forKey: .isScrollEnabled) @@ -131,13 +139,20 @@ struct DevServerSettings: Decodable, Equatable { } private static func unreadable(_ type: String?) -> PaywallPresentationStyle? { + warnUnreadable("presentation style", type) + return nil + } + + /// Reports one setting the CLI names in a way this SDK can't read. The CLI + /// versions separately from the SDK, so a value added after this SDK shipped + /// costs that setting alone — the rest of the block still stands. + private static func warnUnreadable(_ name: String, _ value: String?) { Logger.debug( logLevel: .warn, scope: .superwallCore, - message: "Ignoring a dev server presentation style this SDK can't read " - + "(\(type ?? "no type")). The paywall presents as its published version does. " + message: "Ignoring a dev server \(name) this SDK can't read " + + "(\(value ?? "nothing")). The paywall uses its published version's \(name). " + "Updating SuperwallKit may fix this." ) - return nil } } diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift index 4aebe96c9..9e86db169 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift @@ -162,6 +162,29 @@ final class DevServerPaywallTests: XCTestCase { XCTAssertNotEqual(first.identifier, second.identifier) } + /// A cached PaywallViewController is built from the settings once, so an + /// edited config.ts has to miss the cache rather than live-reload the page + /// inside the frame the old settings built. + func test_givesAChangedConfigItsOwnCacheKey() { + let modal = Paywall.devServer( + surface: surface(settings: "{ \"presentation_style\": { \"type\": \"MODAL\" } }"), + url: url + ) + let fullscreen = Paywall.devServer( + surface: surface(settings: "{ \"presentation_style\": { \"type\": \"FULLSCREEN\" } }"), + url: url + ) + let unchanged = Paywall.devServer( + surface: surface(settings: "{ \"presentation_style\": { \"type\": \"MODAL\" } }"), + url: url + ) + + XCTAssertNotEqual(modal.cacheKey, fullscreen.cacheKey) + XCTAssertNotEqual(modal.cacheKey, Paywall.devServer(surface: surface(), url: url).cacheKey) + // Same config.ts, same key: an unrelated presentation still reuses the view. + XCTAssertEqual(modal.cacheKey, unchanged.cacheKey) + } + func test_presentsFullscreenWhenNothingDeclaresAStyle() { let paywall = Paywall.devServer(surface: surface(), url: url) XCTAssertEqual(paywall.presentation.style, .fullscreen) diff --git a/Tests/SuperwallKitTests/DevServer/DevServerSettingsTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerSettingsTests.swift index 4f346f79f..ba630c8a7 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerSettingsTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerSettingsTests.swift @@ -128,12 +128,23 @@ final class DevServerSettingsTests: XCTestCase { } } - func test_stillRefusesAnEligibilityItCannotName() throws { - XCTAssertThrowsError(try settings("{ \"introductory_offer_eligibility\": \"maybe\" }")) - } + /// A value named by a newer CLI costs that setting alone, the same way an + /// unreadable style does — the paywall falls back to its published gating + /// and eligibility, and keeps everything else its config.ts declares. + func test_dropsAGatingOrEligibilityItCannotNameAndKeepsTheRest() throws { + let decoded = try settings(""" + { + "feature_gating": "sometimes", + "introductory_offer_eligibility": "maybe", + "scroll_enabled": false, + "background_color_hex": "#ffffff" + } + """) - func test_stillRefusesAGatingItCannotName() throws { - XCTAssertThrowsError(try settings("{ \"feature_gating\": \"sometimes\" }")) + XCTAssertNil(decoded.featureGating) + XCTAssertNil(decoded.introOfferEligibility) + XCTAssertEqual(decoded.isScrollEnabled, false) + XCTAssertEqual(decoded.backgroundColorHex, "#ffffff") } func test_aSurfaceFromAnOlderDevServerCarriesNoSettings() throws { @@ -144,21 +155,22 @@ final class DevServerSettingsTests: XCTestCase { XCTAssertNil(decoded.settings) } - /// The surface still serves its local code when the block is unreadable — - /// it presents as its published version does instead of vanishing from the - /// manifest. + /// The surface still serves its local code when a setting is unreadable, and + /// keeps the block: only the setting it can't name falls back to the + /// published paywall's. func test_keepsASurfaceWhoseSettingsCannotBeRead() throws { let decoded = try surface(""" { "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro", - "settings": { "feature_gating": "sometimes" } + "settings": { "feature_gating": "sometimes", "scroll_enabled": false } } """) XCTAssertEqual(decoded.id, "pro") - XCTAssertNil(decoded.settings) + XCTAssertNil(decoded.settings?.featureGating) + XCTAssertEqual(decoded.settings?.isScrollEnabled, false) } func test_stillRefusesASurfaceMissingItsIdentity() throws { From bfa0d23ca4da7febb5f61e79227dc63559ee8f55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:38:01 +0200 Subject: [PATCH 083/162] docs(dev): name intro offer eligibility as config.ts's The public devServer doc and the changelog entry still listed it as something config.ts can't express. Also restores coverage for the surface-level `try?`, which only a malformed block reaches now that an unnameable value costs one setting rather than the whole block. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- .../Config/Options/SuperwallOptions.swift | 10 ++++---- .../DevServer/DevServerSettingsTests.swift | 25 ++++++++++++++++--- 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b8a2f9b0..b3d113532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Adds `grantedEntitlements` so you can grant entitlements from your own backend, which the SDK merges with device and web entitlements. - Changes `$subscriptionStatus` from a `@Published` publisher to an `AnyPublisher`. Subscribing to it works as before, but it can no longer be the target of `assign(to:)`. -- Dev mode now presents a locally served paywall the way its `config.ts` says: presentation style, feature gating, scrolling and background colours come from your local code instead of the last pushed version, so changing them is visible without a push. Settings `config.ts` cannot express, and any it leaves out, still come from the published paywall. Needs a `superwall dev` server new enough to send them. +- Dev mode now presents a locally served paywall the way its `config.ts` says: presentation style, feature gating, intro offer eligibility, scrolling and background colours come from your local code instead of the last pushed version, so changing them is visible without a push. Settings `config.ts` cannot express, and any it leaves out, still come from the published paywall. Needs a `superwall dev` server new enough to send them. - Adds `SuperwallOptions.devServer` for development builds: with a `superwall dev` server running, paywalls render from your live, local paywall code while configuration, placements, audience evaluation and assignment stay real. Use `.default` on a simulator, which finds the dev server on localhost automatically; on a physical device use `.url(...)` with the Device URL `superwall dev` prints. The dev server also activates test mode, disables preloading, and skips the test mode intro sheet. ### Fixes diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index 566051394..a749b88ca 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -408,11 +408,11 @@ public final class SuperwallOptions: NSObject, Encodable { /// Paywalls without a local counterpart still load their published versions. /// /// What the local surface owns is what it renders and the settings its `config.ts` - /// declares: content, products, presentation style, feature gating, scrolling and - /// background colours all come from your local code, so changing them shows on the - /// next presentation without a push. What `config.ts` cannot express — intro offer - /// eligibility, surveys, computed properties — comes from the published paywall the - /// surface stands in for, and so does any setting your `config.ts` leaves out. + /// declares: content, products, presentation style, feature gating, intro offer + /// eligibility, scrolling and background colours all come from your local code, so + /// changing them shows on the next presentation without a push. What `config.ts` + /// cannot express — surveys, computed properties — comes from the published paywall + /// the surface stands in for, and so does any setting your `config.ts` leaves out. /// /// A dev server older than this SDK sends no settings at all, in which case every /// setting comes from the published paywall. diff --git a/Tests/SuperwallKitTests/DevServer/DevServerSettingsTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerSettingsTests.swift index ba630c8a7..325d685f7 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerSettingsTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerSettingsTests.swift @@ -155,10 +155,9 @@ final class DevServerSettingsTests: XCTestCase { XCTAssertNil(decoded.settings) } - /// The surface still serves its local code when a setting is unreadable, and - /// keeps the block: only the setting it can't name falls back to the - /// published paywall's. - func test_keepsASurfaceWhoseSettingsCannotBeRead() throws { + /// A setting the SDK can't name costs that setting alone: the surface keeps + /// its local code and the rest of the block. + func test_keepsASurfaceWhoseSettingIsUnnameable() throws { let decoded = try surface(""" { "kind": "paywall", @@ -173,6 +172,24 @@ final class DevServerSettingsTests: XCTestCase { XCTAssertEqual(decoded.settings?.isScrollEnabled, false) } + /// A block the SDK can't decode at all — a key of the wrong type rather than + /// a value it can't name — is still dropped whole by the `try?` in + /// `DevServerSurface`, and still costs the surface only its settings: it + /// serves its local code and presents as its published version does. + func test_keepsASurfaceWhoseSettingsBlockIsMalformed() throws { + let decoded = try surface(""" + { + "kind": "paywall", + "id": "pro", + "url": "/preview/paywall/pro", + "settings": { "scroll_enabled": "yes" } + } + """) + + XCTAssertEqual(decoded.id, "pro") + XCTAssertNil(decoded.settings) + } + func test_stillRefusesASurfaceMissingItsIdentity() throws { XCTAssertThrowsError(try surface(""" { "kind": "paywall", "url": "/preview/paywall/pro" } From cb48599a47ae3e834871e83ec1cab92dcf0d65d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:20:13 +0200 Subject: [PATCH 084/162] Fix crash when register is called from more than one thread `previousRegisterTask` was swapped with an unsynchronized read-modify-write on the non-isolated `Superwall` class: previousRegisterTask = Task { [weak self, previousRegisterTask] in await previousRegisterTask?.value ... } `register(placement:)` has no isolation in its signature, so two callers on different threads can read the same previous task and both release it. That over-releases the task and crashes in `swift_release` while the task is torn down, which is what apps are seeing as an EXC_BAD_ACCESS on the cooperative pool. It also drops one of the two new tasks, so the serialization the property exists to provide silently stops happening. This is the same bug that was fixed for preloading in 4.16.0. Pull that fix out into a `SerialTaskCoordinator` that holds the swap behind a lock, and use it for both register and preloading. A lock rather than an actor because `register` is synchronous and can't await its way onto the queue without losing the order calls came in. Fixes #364. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../Internal Tracking/Tracking.swift | 8 +- .../SuperwallKit/Config/ConfigManager.swift | 32 +---- .../Misc/SerialTaskCoordinator.swift | 52 +++++++++ .../Presentation/PublicPresentation.swift | 8 +- Sources/SuperwallKit/Superwall.swift | 7 +- SuperwallKit.xcodeproj/project.pbxproj | 8 ++ .../Misc/SerialTaskCoordinatorTests.swift | 110 ++++++++++++++++++ 8 files changed, 188 insertions(+), 38 deletions(-) create mode 100644 Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift create mode 100644 Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e1880528..819444ef6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes duplicate device attribute and subscription status change events being tracked when the subscription status is repeatedly set to the same logical state. As part of this, `subscriptionStatusDidChange` now fires only when the logical status changes — the status case, the set of entitlements, or an entitlement's `isActive` flag. Updates to transaction metadata such as expiry dates or renewal state no longer trigger it; use `customerInfoDidChange` for those. - Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. - Fixes a data race during SDK configuration that Thread Sanitizer flagged on every launch. +- Fixes a crash when `register` is called from more than one thread at a time. - Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them. - Fixes audiences matching users they shouldn't when you use a Purchase Controller. diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift index 2d2f7eff5..b62faa879 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift @@ -90,11 +90,9 @@ extension Superwall { forPlacement placement: Trackable, withData placementData: PlacementData ) async { - // Assign the current register task while capturing the previous one. - previousRegisterTask = Task { [weak self, previousRegisterTask] in - // Wait until the previous register task is finished before continuing. - await previousRegisterTask?.value - + // Wait until any register call already in flight is finished before + // continuing. + registerTaskCoordinator.enqueue { [weak self] in await self?.internallyHandleImplicitTrigger( forPlacement: placement, withData: placementData diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index 49c448586..ecd44fd8d 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -44,9 +44,9 @@ class ConfigManager { private unowned let webEntitlementRedeemer: WebEntitlementRedeemer let expressionEvaluator: CELEvaluator - /// Serializes preloading so concurrent callers can't race on the task + /// Runs preloads one at a time so concurrent callers can't race on the task /// reference. See ``preloadAllPaywalls()``. - private let preloadingCoordinator = PreloadingTaskCoordinator() + private let preloadingCoordinator = SerialTaskCoordinator() typealias Factory = RequestFactory & AudienceFilterAttributesFactory @@ -565,13 +565,10 @@ class ConfigManager { /// Preloads paywalls referenced by triggers. func preloadAllPaywalls() async { - // Chain onto any in-flight preload through the coordinator. The coordinator - // is an actor, so swapping in the new task is serialized. Previously this was - // `self.currentPreloadingTask = Task { ... }` on a non-isolated class, so - // concurrent callers (config refresh, retry, reset, public API) raced on the - // task reference and over-released it, crashing in `swift_release` during - // task teardown. - await preloadingCoordinator.enqueue { [weak self] in + // Wait until any preload already in flight is finished before continuing. + // Preloading is kicked off from several places (config refresh, retry, + // reset, public API), so the queue has to be safe to add to from any thread. + preloadingCoordinator.enqueue { [weak self] in guard let self = self else { return } @@ -612,23 +609,6 @@ class ConfigManager { } } - /// Serializes the read-modify-write of the preloading task so it can't be - /// mutated from multiple tasks at once. Each enqueued operation runs only - /// after the previously enqueued one finishes, preserving the original - /// chaining behavior while making the swap data-race free. - private actor PreloadingTaskCoordinator { - private var currentTask: Task? - - /// Atomically chains `operation` after any in-flight preloading task. - func enqueue(_ operation: @escaping @Sendable () async -> Void) { - let previous = currentTask - currentTask = Task { - await previous?.value - await operation() - } - } - } - /// Preloads paywalls referenced by the provided triggers. func preloadPaywalls(for placementNames: Set) async { guard diff --git a/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift b/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift new file mode 100644 index 000000000..8e88982a5 --- /dev/null +++ b/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift @@ -0,0 +1,52 @@ +// +// SerialTaskCoordinator.swift +// +// +// Created by Yusuf Tör on 11/09/2026. +// + +import Foundation + +/// Runs enqueued operations one at a time, in the order they were enqueued. +/// +/// The usual way of doing this is to keep the last task in a property and swap +/// it for a new task that waits on the old one: +/// +/// ```swift +/// previousTask = Task { [previousTask] in +/// await previousTask?.value +/// ... +/// } +/// ``` +/// +/// That read and that write aren't a single step, so when callers arrive on +/// different threads two of them can read the same previous task and both +/// release it. That over-releases the task and crashes in `swift_release` when +/// it's torn down. It also loses one of the two new tasks, so the chaining the +/// code is there to provide silently stops happening. +/// +/// Holding the swap behind a lock fixes both. +final class SerialTaskCoordinator: @unchecked Sendable { + private let lock = NSLock() + private var currentTask: Task? + + /// The task at the end of the queue, if there is one. + var lastTask: Task? { + lock.lock() + defer { lock.unlock() } + return currentTask + } + + /// Adds `operation` to the end of the queue. It starts only after everything + /// enqueued before it has finished. + func enqueue(_ operation: @escaping @Sendable () async -> Void) { + lock.lock() + defer { lock.unlock() } + + let previous = currentTask + currentTask = Task { + await previous?.value + await operation() + } + } +} diff --git a/Sources/SuperwallKit/Paywall/Presentation/PublicPresentation.swift b/Sources/SuperwallKit/Paywall/Presentation/PublicPresentation.swift index 4bc5d51c1..cc3c2c1ed 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/PublicPresentation.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/PublicPresentation.swift @@ -196,11 +196,9 @@ extension Superwall { } )) - // Assign the current register task while capturing the previous one. - previousRegisterTask = Task { [weak self, previousRegisterTask] in - // Wait until the previous task is finished before continuing. - await previousRegisterTask?.value - + // Wait until any register call already in flight is finished before + // continuing. + registerTaskCoordinator.enqueue { [weak self] in await self?.trackAndPresentPaywall( forPlacement: placement, params: params, diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 78c12180a..fb40e843c 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -389,8 +389,11 @@ public final class Superwall: NSObject, ObservableObject { /// Handles all dependencies. let dependencyContainer: DependencyContainer - /// Used to serially execute register calls. - var previousRegisterTask: Task? + /// Runs register calls one at a time, in the order they came in. + /// + /// `register(placement:)` can be called from any thread, so the queue of + /// register tasks has to be safe to add to from any thread. + let registerTaskCoordinator = SerialTaskCoordinator() /// The integration attributes to send to the server when `appTransactionId` /// is available. Protected by a queue for thread safety. diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index a3bcbd605..d6c5d9ac0 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -82,6 +82,7 @@ 213E7FC262106BFBC44462AC /* SWLocalizationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBE7D1E1AF61D199E17B5C05 /* SWLocalizationViewController.swift */; }; 2205A0CC8F059B3D6231C603 /* PaywallLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A7140A8B0B006F2080D8915 /* PaywallLogicTests.swift */; }; 2231B31B4B9A25778069B20A /* PaddleProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F17CFCD6B3B96A609A5B870 /* PaddleProduct.swift */; }; + 223E12D4EDBF41D8F5515E53 /* SerialTaskCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D555619C0738B6016C62DF0E /* SerialTaskCoordinator.swift */; }; 225D6F5363B1520744EABD86 /* RedeemResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C339E7D08CDD7B1808AD4069 /* RedeemResponseTests.swift */; }; 22B3763157DF592D4E3B27A0 /* InAppReceipt+ASN1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E80C81546752BCF32016A27 /* InAppReceipt+ASN1.swift */; }; 234C1753A4606242CA765CA7 /* ManagedTriggerRuleOccurrence.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCFFBE357699F5CAAB803DA7 /* ManagedTriggerRuleOccurrence.swift */; }; @@ -144,6 +145,7 @@ 3CB65E105ADED11AEE69DEAF /* InAppReceiptAttribute.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9553EC1E394EF7AE8788291 /* InAppReceiptAttribute.swift */; }; 3CD2C23BAC2EA11174237785 /* AttributionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B7CFAF4B3E32AE628A249C8 /* AttributionTests.swift */; }; 3CF2307C2CB994D00A35FADD /* LoadingModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 866F99509EDFBE8BAE10E575 /* LoadingModel.swift */; }; + 3D5684BAF13C86ABED458EB9 /* SerialTaskCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 224B526C168B5DB83E1F50D2 /* SerialTaskCoordinatorTests.swift */; }; 3DCE95BAC148CCC7E6E7F608 /* DeviceTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3E5C31CEEDC9C2853D91C50 /* DeviceTemplate.swift */; }; 3EA92DE86764CBAC557F8522 /* Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27E41300E7467F017BCD5E5C /* Capabilities.swift */; }; 3EE4C1C4EC45718C2EED34E5 /* EventTrackingBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93D8033AAF5549E30ACDA3EA /* EventTrackingBehavior.swift */; }; @@ -677,6 +679,7 @@ 21903EACCA9AA13BB10917EC /* PermissionHandler+Camera.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PermissionHandler+Camera.swift"; sourceTree = ""; }; 21C52F36F0BFF59363EBB4C7 /* GameControllerEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameControllerEvent.swift; sourceTree = ""; }; 22439CFFFC5166F34D0DA524 /* WebViewURLConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewURLConfig.swift; sourceTree = ""; }; + 224B526C168B5DB83E1F50D2 /* SerialTaskCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialTaskCoordinatorTests.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 = ""; }; @@ -1123,6 +1126,7 @@ D449672964023589DA5535E3 /* String+MD5.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+MD5.swift"; sourceTree = ""; }; D47AA8B18B4570F0C24B7389 /* Encoder+ReportsUnknownFieldsAsNull.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Encoder+ReportsUnknownFieldsAsNull.swift"; sourceTree = ""; }; D4F676D3A0F5B540052D36B1 /* PublicGetPresentationResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicGetPresentationResult.swift; sourceTree = ""; }; + D555619C0738B6016C62DF0E /* SerialTaskCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialTaskCoordinator.swift; sourceTree = ""; }; D561728FDB68F572D5E33223 /* PermissionsHandler+Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PermissionsHandler+Contacts.swift"; sourceTree = ""; }; D570217FF965FF087585931C /* PaywallPreloadingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallPreloadingTests.swift; sourceTree = ""; }; D5BF66C3986E287B7B2F5E27 /* SKProductSubscriptionPeriodMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SKProductSubscriptionPeriodMock.swift; sourceTree = ""; }; @@ -1905,6 +1909,7 @@ 4D7656D6A565958F58A644AF /* Misc */ = { isa = PBXGroup; children = ( + 224B526C168B5DB83E1F50D2 /* SerialTaskCoordinatorTests.swift */, F2F75130AF54DFC4C7FA839A /* Extensions */, ); path = Misc; @@ -3037,6 +3042,7 @@ 7ABC4A0048583B47040C498B /* DispatchQueueBacked.swift */, E09C238ADC0B019047FAB1DF /* JSONToDict.swift */, 2E2027BFC214905CBE589AF2 /* KeypathWritable.swift */, + D555619C0738B6016C62DF0E /* SerialTaskCoordinator.swift */, 19F010DC597017F5BEAEDE86 /* SwiftyJSON.swift */, 62EC6A60945A85646E1230C1 /* ThrowableDecodable.swift */, 848592ACE8760E53F2163F01 /* Typealiases.swift */, @@ -3401,6 +3407,7 @@ E1A838C9CE62C9479D0C68F4 /* SWDebugManagerLogicTests.swift in Sources */, C77A626D379969A86B900488 /* SWWebViewLoadingHandlerTests.swift in Sources */, EE5646D09161237C649731F4 /* SWWebViewLogicTests.swift in Sources */, + 3D5684BAF13C86ABED458EB9 /* SerialTaskCoordinatorTests.swift in Sources */, 919A08D7F25BD2DF27A22697 /* StorageMock.swift in Sources */, 713A1F9D9861C6A1E5EB9174 /* StorageTests.swift in Sources */, 701B1B586B6C1E3F0B3AF560 /* StoreKitManagerTests.swift in Sources */, @@ -3750,6 +3757,7 @@ CE411644469AB99AC9DBB492 /* SWWebViewLoadingHandler.swift in Sources */, 074EBBBF7E3B2B207B00A275 /* SWWebViewLogic.swift in Sources */, 1753820CBFBDD8FF70455D71 /* ScaleAnimation.swift in Sources */, + 223E12D4EDBF41D8F5515E53 /* SerialTaskCoordinator.swift in Sources */, AD5EBB6DBA919E3CBC5B85B7 /* SessionEventsRequest.swift in Sources */, 5A6D06700C4E4E2C6C9BC1B2 /* ShimmerView.swift in Sources */, 18B147198F2E19C69013BA4B /* Sk1StoreProduct.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift b/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift new file mode 100644 index 000000000..76af25d36 --- /dev/null +++ b/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift @@ -0,0 +1,110 @@ +// +// SerialTaskCoordinatorTests.swift +// SuperwallKitTests +// +// Created by Yusuf Tör on 11/09/2026. +// + +import Foundation +import Testing + +@testable import SuperwallKit + +@Suite("SerialTaskCoordinator Tests") +struct SerialTaskCoordinatorTests { + /// Records the order operations ran in and how many ran at the same time. + private actor Recorder { + private(set) var order: [Int] = [] + private(set) var maxRunningAtOnce = 0 + private var runningAtOnce = 0 + + func didStart(_ id: Int) { + order.append(id) + runningAtOnce += 1 + maxRunningAtOnce = max(maxRunningAtOnce, runningAtOnce) + } + + func didFinish() { + runningAtOnce -= 1 + } + } + + @Test("Operations run in the order they were enqueued") + func runsOperationsInOrder() async { + let coordinator = SerialTaskCoordinator() + let recorder = Recorder() + + for id in 0..<20 { + coordinator.enqueue { + await recorder.didStart(id) + await Task.yield() + await recorder.didFinish() + } + } + await coordinator.lastTask?.value + + let order = await recorder.order + let maxRunningAtOnce = await recorder.maxRunningAtOnce + #expect(order == Array(0..<20)) + #expect(maxRunningAtOnce == 1) + } + + @Test("Only one operation runs at a time when enqueued from many threads") + func runsOneOperationAtATimeAcrossThreads() async { + let coordinator = SerialTaskCoordinator() + let recorder = Recorder() + let operationCount = 200 + + await withCheckedContinuation { continuation in + let group = DispatchGroup() + + for id in 0.. Date: Fri, 11 Sep 2026 16:48:29 +0200 Subject: [PATCH 085/162] Serialize the task swap on a queue instead of a lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the `DispatchQueueBacked` idiom already used elsewhere in the SDK, and gives each coordinator a named queue so the two uses can be told apart in crash reports and Instruments. Measured both under a caller competing with six threads hammering the same coordinator. The queue is fairer: a worse median while the queue is saturated (37us vs 500ns, because `sync` waits its turn where the lock lets the caller barge in), but a tighter tail. In the burst that actually looks like real traffic — 40 one-shot callers, as in the issue #364 repro — the two have the same median and the queue has the better tail (0.05ms vs 0.15ms worst case). Only the swap runs on the queue. Making a task doesn't start it, so a caller never waits on the work itself. Co-Authored-By: Claude Opus 5 --- .../SuperwallKit/Config/ConfigManager.swift | 2 +- .../Misc/SerialTaskCoordinator.swift | 29 +++++++++++-------- Sources/SuperwallKit/Superwall.swift | 2 +- .../Misc/SerialTaskCoordinatorTests.swift | 6 ++-- 4 files changed, 22 insertions(+), 17 deletions(-) diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index ecd44fd8d..9484a2006 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -46,7 +46,7 @@ class ConfigManager { /// Runs preloads one at a time so concurrent callers can't race on the task /// reference. See ``preloadAllPaywalls()``. - private let preloadingCoordinator = SerialTaskCoordinator() + private let preloadingCoordinator = SerialTaskCoordinator(label: "preloading") typealias Factory = RequestFactory & AudienceFilterAttributesFactory diff --git a/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift b/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift index 8e88982a5..3ddb4ef2e 100644 --- a/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift +++ b/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift @@ -25,28 +25,33 @@ import Foundation /// it's torn down. It also loses one of the two new tasks, so the chaining the /// code is there to provide silently stops happening. /// -/// Holding the swap behind a lock fixes both. +/// Doing the swap on a serial queue fixes both. Only the swap runs on the +/// queue — making a task doesn't start it — so a caller never waits on the +/// work itself, only on another caller's swap. final class SerialTaskCoordinator: @unchecked Sendable { - private let lock = NSLock() + private let queue: DispatchQueue private var currentTask: Task? /// The task at the end of the queue, if there is one. var lastTask: Task? { - lock.lock() - defer { lock.unlock() } - return currentTask + queue.sync { currentTask } + } + + /// - Parameter label: Names the queue so it can be told apart from other + /// coordinators in crash reports and Instruments. + init(label: String) { + queue = DispatchQueue(label: "com.superwall.\(label)") } /// Adds `operation` to the end of the queue. It starts only after everything /// enqueued before it has finished. func enqueue(_ operation: @escaping @Sendable () async -> Void) { - lock.lock() - defer { lock.unlock() } - - let previous = currentTask - currentTask = Task { - await previous?.value - await operation() + queue.sync { + let previous = currentTask + currentTask = Task { + await previous?.value + await operation() + } } } } diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index fb40e843c..afc9c5d91 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -393,7 +393,7 @@ public final class Superwall: NSObject, ObservableObject { /// /// `register(placement:)` can be called from any thread, so the queue of /// register tasks has to be safe to add to from any thread. - let registerTaskCoordinator = SerialTaskCoordinator() + let registerTaskCoordinator = SerialTaskCoordinator(label: "register") /// The integration attributes to send to the server when `appTransactionId` /// is available. Protected by a queue for thread safety. diff --git a/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift b/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift index 76af25d36..f95588b80 100644 --- a/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift +++ b/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift @@ -31,7 +31,7 @@ struct SerialTaskCoordinatorTests { @Test("Operations run in the order they were enqueued") func runsOperationsInOrder() async { - let coordinator = SerialTaskCoordinator() + let coordinator = SerialTaskCoordinator(label: "test") let recorder = Recorder() for id in 0..<20 { @@ -51,7 +51,7 @@ struct SerialTaskCoordinatorTests { @Test("Only one operation runs at a time when enqueued from many threads") func runsOneOperationAtATimeAcrossThreads() async { - let coordinator = SerialTaskCoordinator() + let coordinator = SerialTaskCoordinator(label: "test") let recorder = Recorder() let operationCount = 200 @@ -89,7 +89,7 @@ struct SerialTaskCoordinatorTests { @Test("Operations enqueued after the queue has drained still run") func runsOperationsEnqueuedAfterDraining() async { - let coordinator = SerialTaskCoordinator() + let coordinator = SerialTaskCoordinator(label: "test") let recorder = Recorder() coordinator.enqueue { From 519c7cd42c18d8f7178de051dc650831e2aca7ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:55:22 +0200 Subject: [PATCH 086/162] Hand operations to one task through a stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the lock and the queue guarded a swap that shouldn't need guarding. Feeding operations to a single long-lived consumer task removes the swap instead: there's no task reference to race on, so the crash this fixes stops being possible rather than being locked against. It also drops the last blocking primitive. `preloadAllPaywalls()` is `async`, so its `queue.sync` was blocking a thread in the concurrency pool — it couldn't deadlock, since nothing on that queue ever waited, but it's the kind of thing that ages badly. `yield` blocks nothing. Two differences worth knowing. Operations run inside the consumer task now, so they take its priority rather than each caller's. And tests wait with `drain()` instead of reaching for the last task. Measured the same way as the queue: in the 40-caller burst that looks like the issue #364 repro, median 1.8us against the queue's 4.2us, and every figure stays well under a millisecond. Co-Authored-By: Claude Opus 5 --- .../SuperwallKit/Config/ConfigManager.swift | 2 +- .../Misc/SerialTaskCoordinator.swift | 56 ++++++++++++------- Sources/SuperwallKit/Superwall.swift | 2 +- .../Misc/SerialTaskCoordinatorTests.swift | 14 ++--- 4 files changed, 45 insertions(+), 29 deletions(-) diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index 9484a2006..ecd44fd8d 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -46,7 +46,7 @@ class ConfigManager { /// Runs preloads one at a time so concurrent callers can't race on the task /// reference. See ``preloadAllPaywalls()``. - private let preloadingCoordinator = SerialTaskCoordinator(label: "preloading") + private let preloadingCoordinator = SerialTaskCoordinator() typealias Factory = RequestFactory & AudienceFilterAttributesFactory diff --git a/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift b/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift index 3ddb4ef2e..ae135bb85 100644 --- a/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift +++ b/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift @@ -25,32 +25,48 @@ import Foundation /// it's torn down. It also loses one of the two new tasks, so the chaining the /// code is there to provide silently stops happening. /// -/// Doing the swap on a serial queue fixes both. Only the swap runs on the -/// queue — making a task doesn't start it — so a caller never waits on the -/// work itself, only on another caller's swap. -final class SerialTaskCoordinator: @unchecked Sendable { - private let queue: DispatchQueue - private var currentTask: Task? - - /// The task at the end of the queue, if there is one. - var lastTask: Task? { - queue.sync { currentTask } +/// Handing operations to a single long-lived task through a stream avoids the +/// problem rather than guarding it: there's no task reference to swap. The +/// stream keeps them in the order they were handed over, and ``enqueue(_:)`` +/// only hands one over, so no caller ever waits — including callers already +/// running on the concurrency pool. +final class SerialTaskCoordinator { + typealias Operation = @Sendable () async -> Void + + private let continuation: AsyncStream.Continuation + + init() { + // `AsyncStream` hands over the continuation before its initializer + // returns, so this is always set by the time it's read. + // swiftlint:disable:next implicitly_unwrapped_optional + var continuation: AsyncStream.Continuation! + let operations = AsyncStream(bufferingPolicy: .unbounded) { + continuation = $0 + } + self.continuation = continuation + + Task { + for await operation in operations { + await operation() + } + } } - /// - Parameter label: Names the queue so it can be told apart from other - /// coordinators in crash reports and Instruments. - init(label: String) { - queue = DispatchQueue(label: "com.superwall.\(label)") + deinit { + continuation.finish() } /// Adds `operation` to the end of the queue. It starts only after everything /// enqueued before it has finished. - func enqueue(_ operation: @escaping @Sendable () async -> Void) { - queue.sync { - let previous = currentTask - currentTask = Task { - await previous?.value - await operation() + func enqueue(_ operation: @escaping Operation) { + continuation.yield(operation) + } + + /// Waits for everything enqueued so far to finish. + func drain() async { + await withCheckedContinuation { continuation in + enqueue { + continuation.resume() } } } diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index afc9c5d91..fb40e843c 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -393,7 +393,7 @@ public final class Superwall: NSObject, ObservableObject { /// /// `register(placement:)` can be called from any thread, so the queue of /// register tasks has to be safe to add to from any thread. - let registerTaskCoordinator = SerialTaskCoordinator(label: "register") + let registerTaskCoordinator = SerialTaskCoordinator() /// The integration attributes to send to the server when `appTransactionId` /// is available. Protected by a queue for thread safety. diff --git a/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift b/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift index f95588b80..d6f43a026 100644 --- a/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift +++ b/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift @@ -31,7 +31,7 @@ struct SerialTaskCoordinatorTests { @Test("Operations run in the order they were enqueued") func runsOperationsInOrder() async { - let coordinator = SerialTaskCoordinator(label: "test") + let coordinator = SerialTaskCoordinator() let recorder = Recorder() for id in 0..<20 { @@ -41,7 +41,7 @@ struct SerialTaskCoordinatorTests { await recorder.didFinish() } } - await coordinator.lastTask?.value + await coordinator.drain() let order = await recorder.order let maxRunningAtOnce = await recorder.maxRunningAtOnce @@ -51,7 +51,7 @@ struct SerialTaskCoordinatorTests { @Test("Only one operation runs at a time when enqueued from many threads") func runsOneOperationAtATimeAcrossThreads() async { - let coordinator = SerialTaskCoordinator(label: "test") + let coordinator = SerialTaskCoordinator() let recorder = Recorder() let operationCount = 200 @@ -75,7 +75,7 @@ struct SerialTaskCoordinatorTests { continuation.resume() } } - await coordinator.lastTask?.value + await coordinator.drain() let order = await recorder.order let maxRunningAtOnce = await recorder.maxRunningAtOnce @@ -89,20 +89,20 @@ struct SerialTaskCoordinatorTests { @Test("Operations enqueued after the queue has drained still run") func runsOperationsEnqueuedAfterDraining() async { - let coordinator = SerialTaskCoordinator(label: "test") + let coordinator = SerialTaskCoordinator() let recorder = Recorder() coordinator.enqueue { await recorder.didStart(0) await recorder.didFinish() } - await coordinator.lastTask?.value + await coordinator.drain() coordinator.enqueue { await recorder.didStart(1) await recorder.didFinish() } - await coordinator.lastTask?.value + await coordinator.drain() let order = await recorder.order #expect(order == [0, 1]) From 9ad7439209e23ed6dd21cb64db8200586a7ec023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:03:46 +0200 Subject: [PATCH 087/162] Address review: comment wording, last racy swap, test-only surface - The "wait until ... before continuing" comments read as though awaiting these functions waits for the work. They don't wait; they queue and return. Reworded all three. - `PaywallViewController.enqueueStripeCheckoutTask` was the last copy of the racy swap. It is safe today because every caller is on the main actor, but in Swift 5 mode an off-main caller is only a warning, not an error, so nothing stops someone reintroducing the crash. Moved it onto the coordinator. The closure is `@MainActor` so the callbacks still run where they always have. - `drain()` only ever existed for the tests, so it now lives in the test target rather than the shipped SDK. Co-Authored-By: Claude Opus 5 --- .../Analytics/Internal Tracking/Tracking.swift | 4 ++-- Sources/SuperwallKit/Config/ConfigManager.swift | 6 +++--- Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift | 9 --------- .../Paywall/Presentation/PublicPresentation.swift | 5 +++-- .../View Controller/PaywallViewController.swift | 9 ++++----- .../Misc/SerialTaskCoordinatorTests.swift | 11 +++++++++++ 6 files changed, 23 insertions(+), 21 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift index b62faa879..30e2b0154 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift @@ -90,8 +90,8 @@ extension Superwall { forPlacement placement: Trackable, withData placementData: PlacementData ) async { - // Wait until any register call already in flight is finished before - // continuing. + // Queue the work behind any register call already in flight and return, + // so implicit triggers and `register` calls don't present over each other. registerTaskCoordinator.enqueue { [weak self] in await self?.internallyHandleImplicitTrigger( forPlacement: placement, diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index ecd44fd8d..4f1bf803e 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -565,9 +565,9 @@ class ConfigManager { /// Preloads paywalls referenced by triggers. func preloadAllPaywalls() async { - // Wait until any preload already in flight is finished before continuing. - // Preloading is kicked off from several places (config refresh, retry, - // reset, public API), so the queue has to be safe to add to from any thread. + // Queue the preload behind any that's already in flight and return. It's + // kicked off from several places (config refresh, retry, reset, public + // API), so the queue has to be safe to add to from any thread. preloadingCoordinator.enqueue { [weak self] in guard let self = self else { return diff --git a/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift b/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift index ae135bb85..ae23425c2 100644 --- a/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift +++ b/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift @@ -61,13 +61,4 @@ final class SerialTaskCoordinator { func enqueue(_ operation: @escaping Operation) { continuation.yield(operation) } - - /// Waits for everything enqueued so far to finish. - func drain() async { - await withCheckedContinuation { continuation in - enqueue { - continuation.resume() - } - } - } } diff --git a/Sources/SuperwallKit/Paywall/Presentation/PublicPresentation.swift b/Sources/SuperwallKit/Paywall/Presentation/PublicPresentation.swift index cc3c2c1ed..3618e4653 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/PublicPresentation.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/PublicPresentation.swift @@ -196,8 +196,9 @@ extension Superwall { } )) - // Wait until any register call already in flight is finished before - // continuing. + // Queue the work behind any register call already in flight and return. + // `register` can be called from any thread, so the queue has to be safe to + // add to from any thread. registerTaskCoordinator.enqueue { [weak self] in await self?.trackAndPresentPaywall( forPlacement: placement, diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index 9353a7b2b..260b8a1ad 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -119,7 +119,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate { private var didReceiveStripeCheckoutAbandonMessage = false /// Ensures Stripe checkout callbacks are forwarded to WebEntitlementRedeemer in order. - private var previousStripeCheckoutTask: Task? + private let stripeCheckoutCoordinator = SerialTaskCoordinator() /// Manages intro offer eligibility tokens for SK2 purchases on iOS 18.2+ let introOfferTokenManager: IntroOfferTokenManager @@ -1266,10 +1266,9 @@ extension PaywallViewController: PaywallMessageHandlerDelegate { private func enqueueStripeCheckoutTask( _ operation: @escaping (PaywallViewController) async -> Void ) { - // Assign the current Stripe task while capturing the previous one. - previousStripeCheckoutTask = Task { [weak self, previousStripeCheckoutTask] in - // Wait until the previous task is finished before continuing. - await previousStripeCheckoutTask?.value + // Queue the callback behind any already in flight and return. The closure + // stays on the main actor so the callbacks run where they always have. + stripeCheckoutCoordinator.enqueue { @MainActor [weak self] in guard let self else { return } diff --git a/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift b/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift index d6f43a026..52a47c16a 100644 --- a/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift +++ b/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift @@ -10,6 +10,17 @@ import Testing @testable import SuperwallKit +extension SerialTaskCoordinator { + /// Waits for everything enqueued so far to finish. + fileprivate func drain() async { + await withCheckedContinuation { continuation in + enqueue { + continuation.resume() + } + } + } +} + @Suite("SerialTaskCoordinator Tests") struct SerialTaskCoordinatorTests { /// Records the order operations ran in and how many ran at the same time. From af2d4963097b37e811cc26b2c315b77ceddc7526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:25:22 +0200 Subject: [PATCH 088/162] Run operations at the enqueuer's priority, and restore Sendable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The task draining the stream takes its priority from the thread that made the coordinator — whoever called `configure()`. An app configuring off the main thread would have pinned every later register and presentation to that thread's priority for the rest of the process. `enqueue` now captures `Task.currentPriority` and runs the operation there. Because the drain awaits each operation, this raises an operation to its caller's priority but can't hold one below the drain's own — awaiting escalates it back up. The direction that matters is covered: user-facing work no longer inherits a low-priority `configure()`. `@unchecked Sendable` went away with the lock and nothing replaced it. The type has no mutable state now, so it takes checked `Sendable`, which the compiler verifies. Co-Authored-By: Claude Opus 5 --- .../Misc/SerialTaskCoordinator.swift | 15 ++++++++-- .../Misc/SerialTaskCoordinatorTests.swift | 28 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift b/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift index ae23425c2..2f0380470 100644 --- a/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift +++ b/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift @@ -30,7 +30,7 @@ import Foundation /// stream keeps them in the order they were handed over, and ``enqueue(_:)`` /// only hands one over, so no caller ever waits — including callers already /// running on the concurrency pool. -final class SerialTaskCoordinator { +final class SerialTaskCoordinator: Sendable { typealias Operation = @Sendable () async -> Void private let continuation: AsyncStream.Continuation @@ -59,6 +59,17 @@ final class SerialTaskCoordinator { /// Adds `operation` to the end of the queue. It starts only after everything /// enqueued before it has finished. func enqueue(_ operation: @escaping Operation) { - continuation.yield(operation) + // Run the operation at the priority of whoever enqueued it. The task + // draining the stream takes its priority from the thread that made the + // coordinator, which is whoever called `configure()` — without this, an app + // configuring off the main thread would pin every later operation to that + // thread's priority for the rest of the process. + let priority = Task.currentPriority + continuation.yield { + await Task(priority: priority) { + await operation() + } + .value + } } } diff --git a/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift b/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift index 52a47c16a..04e581d55 100644 --- a/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift +++ b/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift @@ -23,6 +23,15 @@ extension SerialTaskCoordinator { @Suite("SerialTaskCoordinator Tests") struct SerialTaskCoordinatorTests { + /// Records the priority an operation ran at. + private actor PriorityRecorder { + private(set) var recorded: TaskPriority? + + func record(_ priority: TaskPriority) { + recorded = priority + } + } + /// Records the order operations ran in and how many ran at the same time. private actor Recorder { private(set) var order: [Int] = [] @@ -118,4 +127,23 @@ struct SerialTaskCoordinatorTests { let order = await recorder.order #expect(order == [0, 1]) } + + @Test("Operations run at the priority of whoever enqueued them") + func runsAtEnqueuersPriority() async { + // The coordinator is made here, so the task draining its stream takes this + // context's priority — the stand-in for an app calling `configure()`. + let coordinator = SerialTaskCoordinator() + let recorder = PriorityRecorder() + + await Task(priority: .high) { + coordinator.enqueue { + await recorder.record(Task.currentPriority) + } + } + .value + await coordinator.drain() + + let recorded = await recorder.recorded + #expect(recorded == .high) + } } From 4ffdb41ca189b0d5cb744ea5d8d0a67f9193f983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:39:10 +0200 Subject: [PATCH 089/162] Guard the priority test, and correct a comment `TaskPriority.userInitiated` and `.high` are the same value, so if the test harness ever runs bodies at that priority the drain task takes it too and the assertion passes whether or not the priority wrapper is there. Assert the ambient priority is below `.high` so that degrades loudly. The comment in `enqueue` said the drain's priority comes from whoever called `configure()`. That's true of the two coordinators owned by singletons but not of the one on `PaywallViewController`, which is made per view controller. Co-Authored-By: Claude Opus 5 --- Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift | 7 +++---- .../Misc/SerialTaskCoordinatorTests.swift | 4 +++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift b/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift index 2f0380470..ce5aff181 100644 --- a/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift +++ b/Sources/SuperwallKit/Misc/SerialTaskCoordinator.swift @@ -60,10 +60,9 @@ final class SerialTaskCoordinator: Sendable { /// enqueued before it has finished. func enqueue(_ operation: @escaping Operation) { // Run the operation at the priority of whoever enqueued it. The task - // draining the stream takes its priority from the thread that made the - // coordinator, which is whoever called `configure()` — without this, an app - // configuring off the main thread would pin every later operation to that - // thread's priority for the rest of the process. + // draining the stream takes its priority from whatever made the + // coordinator — without this, one made on a low-priority thread would pin + // every later operation to that priority for as long as it lives. let priority = Task.currentPriority continuation.yield { await Task(priority: priority) { diff --git a/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift b/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift index 04e581d55..b6e68d92b 100644 --- a/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift +++ b/Tests/SuperwallKitTests/Misc/SerialTaskCoordinatorTests.swift @@ -131,7 +131,9 @@ struct SerialTaskCoordinatorTests { @Test("Operations run at the priority of whoever enqueued them") func runsAtEnqueuersPriority() async { // The coordinator is made here, so the task draining its stream takes this - // context's priority — the stand-in for an app calling `configure()`. + // context's priority — the stand-in for an app calling `configure()`. If + // that's already `.high`, the assertion below can't tell the two apart. + #expect(Task.currentPriority < .high) let coordinator = SerialTaskCoordinator() let recorder = PriorityRecorder() From a414575a5caa5337e5d218563e3092b9cc193bcd Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 14 Sep 2026 00:15:41 +0200 Subject: [PATCH 090/162] fix: refresh and sync integration device attributes --- CHANGELOG.md | 2 + CLAUDE.md | 8 + .../Attribution/AttributionFetcher.swift | 138 ++++++++++-------- SuperwallKit.xcodeproj/project.pbxproj | 4 + .../AttributionDeviceAttributesTests.swift | 52 +++++++ 5 files changed, 143 insertions(+), 61 deletions(-) create mode 100644 Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e1880528..c7ebe7501 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Fixes +- Refreshes IDFV, IDFA and ATT status for integrations when the app becomes active or integration attributes are set again. Device identifiers now also sync to user attributes for server-side integrations such as AppsFlyer, and revoked consent clears the previous IDFA. + - Fixes duplicate device attribute and subscription status change events being tracked when the subscription status is repeatedly set to the same logical state. As part of this, `subscriptionStatusDidChange` now fires only when the logical status changes — the status case, the set of entitlements, or an entitlement's `isActive` flag. Updates to transaction metadata such as expiry dates or renewal state no longer trigger it; use `customerInfoDidChange` for those. - Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. - Fixes a data race during SDK configuration that Thread Sanitizer flagged on every launch. diff --git a/CLAUDE.md b/CLAUDE.md index fd5b72bfc..d34fc1544 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,3 +114,11 @@ When creating PRs, always include the checklist from `.github/PULL_REQUEST_TEMPL - [ ] I have run `swiftlint` in the main directory and fixed any issues. - [ ] I have updated the SDK documentation as well as the online docs. - [ ] I have reviewed the [contributing guide](https://github.com/superwall-me/paywall-ios/tree/master/.github/CONTRIBUTING.md) + +### Integration device attributes + +AttributionFetcher refreshes IDFV/IDFA/ATT when setting integration attributes and +on app activation after an integration has been configured. Compare the complete +refreshed snapshot, not just provider IDs. Sync device values into user attributes +(the server integration router reads those); explicit nulls clear stale IDs after +ATT revocation. ATT is serialized as a numeric string in integration attributes. diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift index f4f15fbe7..fd39f1ab3 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift @@ -19,6 +19,9 @@ final class AttributionFetcher { private let queue = DispatchQueue(label: "com.superwall.attributionfetcher") private let timerQueue = DispatchQueue(label: "com.superwall.attributionfetcher.timer") private var redeemTimer: DispatchSourceTimer? + private var activeObserver: NSObjectProtocol? + private let deviceAttributesProvider: (() -> [String: String])? + private let syncDeviceAttributes: ([String: Any?]) -> Void private var _integrationAttributes: [String: String] = [:] private unowned let storage: Storage private unowned let webEntitlementRedeemer: WebEntitlementRedeemer @@ -135,12 +138,61 @@ final class AttributionFetcher { init( storage: Storage, deviceHelper: DeviceHelper, - webEntitlementRedeemer: WebEntitlementRedeemer + webEntitlementRedeemer: WebEntitlementRedeemer, + deviceAttributesProvider: (() -> [String: String])? = nil, + syncDeviceAttributes: @escaping ([String: Any?]) -> Void = { + Superwall.shared.setUserAttributes($0) + } ) { + self.syncDeviceAttributes = syncDeviceAttributes + self.deviceAttributesProvider = deviceAttributesProvider self.storage = storage self.deviceHelper = deviceHelper self.webEntitlementRedeemer = webEntitlementRedeemer self._integrationAttributes = storage.get(IntegrationAttributes.self) ?? [:] + if let notification = SystemInfo.applicationDidBecomeActiveNotification { + activeObserver = NotificationCenter.default.addObserver( + forName: notification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.refreshDeviceAttributes() + } + } + } + + deinit { + if let activeObserver { + NotificationCenter.default.removeObserver(activeObserver) + } + } + + private var currentDeviceAttributes: [String: String] { + if let deviceAttributesProvider { + return deviceAttributesProvider() + } + var attributes: [String: String] = [:] + let vendorId = deviceHelper.vendorId + if !vendorId.isEmpty { attributes["idfv"] = vendorId } + #if os(iOS) || targetEnvironment(macCatalyst) || os(tvOS) || os(macOS) || os(visionOS) + if #available(iOS 14, macCatalyst 14, tvOS 14, macOS 11, *) { + let status = TrackingManagerProxy().trackingAuthorizationStatus() + attributes["attStatus"] = String(status) + if status == 3 { attributes["idfa"] = identifierForAdvertisers } + } else { + attributes["idfa"] = identifierForAdvertisers + } + #endif + return attributes + } + + func refreshDeviceAttributes() { + queue.async { [weak self] in + guard let self, !self._integrationAttributes.isEmpty else { return } + if self._mergeIntegrationAttributes(attributes: [:]) { + self._debouncedRedeem() + } + } } func setIntegrationAttribute( @@ -159,30 +211,11 @@ final class AttributionFetcher { queue.async { [weak self] in guard let self = self else { return } - // Check if any values have actually changed - var hasChanges = false - for (key, newValue) in attributes { - let currentValue = self._integrationAttributes[key] - if currentValue != newValue { - hasChanges = true - break - } - } - - // If no changes, don't proceed - guard hasChanges else { - return + // Compare after refreshing device data: the provider ID can stay the + // same while ATT changes or a previously unavailable IDFV appears. + if self._mergeIntegrationAttributes(attributes: attributes) { + self._debouncedRedeem() } - - // Update attributes immediately - self._mergeIntegrationAttributes( - attributes: attributes, - appTransactionId: appTransactionId, - shouldRedeem: false // Don't redeem immediately - ) - - // Debounce only the redeem call - self._debouncedRedeem() } } @@ -222,52 +255,35 @@ final class AttributionFetcher { } } - private func _mergeIntegrationAttributes( - attributes: [String: String?], - appTransactionId: String, - shouldRedeem: Bool = true - ) { + private func _mergeIntegrationAttributes(attributes: [String: String?]) -> Bool { var mergedAttributes = _integrationAttributes - var hasChanges = false - - mergedAttributes["idfa"] = identifierForAdvertisers - - let identifierForVendor = deviceHelper.vendorId - mergedAttributes["idfv"] = identifierForVendor - - for key in attributes.keys { - let newValue = attributes[key] - let currentValue = _integrationAttributes[key] - - if currentValue != newValue { - hasChanges = true - if let value = newValue { - mergedAttributes[key] = value - } else { - mergedAttributes.removeValue(forKey: key) - } - } + for (key, value) in attributes { + mergedAttributes[key] = value + } + let device = currentDeviceAttributes + for key in ["idfa", "idfv", "attStatus"] { + mergedAttributes[key] = device[key] } - // Only proceed if there are actual changes - guard hasChanges else { - return + // The router reads user attributes, not the integration_attributes event. + // Explicit nulls clear an IDFA retained from before consent was revoked. + // Sync even when identifiers are unchanged (e.g. after an identify/reset). + var userAttributes: [String: Any?] = [:] + for key in ["idfa", "idfv", "attStatus"] { + userAttributes[key] = device[key].map { $0 as Any } ?? NSNull() } + syncDeviceAttributes(userAttributes) + guard mergedAttributes != _integrationAttributes else { return false } + let updatedAttributes = mergedAttributes Task { - let attributes = InternalSuperwallEvent.IntegrationAttributes( - audienceFilterParams: mergedAttributes + let event = InternalSuperwallEvent.IntegrationAttributes( + audienceFilterParams: updatedAttributes ) - await Superwall.shared.track(attributes) + await Superwall.shared.track(event) } - storage.save(mergedAttributes, forType: IntegrationAttributes.self) _integrationAttributes = mergedAttributes - - if shouldRedeem { - Task { - await webEntitlementRedeemer.redeem(.integrationAttributes) - } - } + return true } } diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index a3bcbd605..baf5c3a4d 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -291,6 +291,7 @@ 8537CA38FFD40CF7C8A6A691 /* CustomStoreProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = C66CFEB3004DF2C3C3DB44FF /* CustomStoreProduct.swift */; }; 85728EABBC5C73193AC5F876 /* CustomURLSessionMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3506FCC35155DF104A1DFCA /* CustomURLSessionMock.swift */; }; 8583971F8E9E51E9B7A4FCC6 /* PurchasingCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB8384E2DB0A3627BE1CCB7D /* PurchasingCoordinator.swift */; }; + 86C3495D495F42ACE23AE051 /* AttributionDeviceAttributesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7873C89B06D81CB18A116E7D /* AttributionDeviceAttributesTests.swift */; }; 87B66787F6EB43DA80667C36 /* PageViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E321E7EEC07CA9A8B9A5619 /* PageViewData.swift */; }; 880BBB2099D3112F256E6AE2 /* IntroOfferEligibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93FACE677755EAA3EA4E67A8 /* IntroOfferEligibility.swift */; }; 88A5CA6515126BD3D09E0563 /* LimitedQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B921746BEC8F63DDB65C634 /* LimitedQueue.swift */; }; @@ -875,6 +876,7 @@ 76CE4D7606C896027C76520E /* SWDebugManagerLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWDebugManagerLogic.swift; sourceTree = ""; }; 76D61D89D2905A8747E37458 /* InterfaceStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterfaceStyle.swift; sourceTree = ""; }; 77827761E38CB30B06E3ABF2 /* MMPAttributionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMPAttributionManager.swift; sourceTree = ""; }; + 7873C89B06D81CB18A116E7D /* AttributionDeviceAttributesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributionDeviceAttributesTests.swift; sourceTree = ""; }; 787764B249892BBCA1088235 /* StorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageTests.swift; sourceTree = ""; }; 78C15CF29C17FE1EE3BFDEDC /* SubscriptionStatusResolutionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionStatusResolutionTests.swift; sourceTree = ""; }; 797EC0356AA1065ED11835BF /* PendingStripeCheckoutPollState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingStripeCheckoutPollState.swift; sourceTree = ""; }; @@ -2951,6 +2953,7 @@ isa = PBXGroup; children = ( A82783401B92298C47BF14F7 /* AdServicesAttributionTests.swift */, + 7873C89B06D81CB18A116E7D /* AttributionDeviceAttributesTests.swift */, 6B7CFAF4B3E32AE628A249C8 /* AttributionTests.swift */, ); path = Attribution; @@ -3298,6 +3301,7 @@ A9FC64A249BF2242BB526521 /* AppStoreProductTests.swift in Sources */, 59685CE55D34FA6A96A8F890 /* AssignmentLogicTests.swift in Sources */, BC8A62869C7BACE6D0867195 /* AssignmentTests.swift in Sources */, + 86C3495D495F42ACE23AE051 /* AttributionDeviceAttributesTests.swift in Sources */, 3CD2C23BAC2EA11174237785 /* AttributionTests.swift in Sources */, D5C9A71CFB166225C082CAFB /* AutomaticPurchaseControllerTests.swift in Sources */, B0DC8290B081B74CC65E9305 /* CELEvaluatorTests.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift new file mode 100644 index 000000000..c7cc55f1a --- /dev/null +++ b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift @@ -0,0 +1,52 @@ +import Foundation +import Testing +@testable import SuperwallKit + +@Suite(.serialized) +struct AttributionDeviceAttributesTests { + @Test func unchangedProviderRefreshesDeviceIdentifiersAndConsent() { + let container = DependencyContainer() + var device = ["idfv": "vendor-1", "idfa": "advertiser-1", "attStatus": "3"] + var syncedAttributes: [String: Any?] = [:] + let fetcher = AttributionFetcher( + storage: container.storage, + deviceHelper: container.deviceHelper, + webEntitlementRedeemer: container.webEntitlementRedeemer, + deviceAttributesProvider: { device }, + syncDeviceAttributes: { syncedAttributes = $0 } + ) + defer { fetcher.cancelPendingOperations() } + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"], appTransactionId: "tx-1") + #expect(fetcher.integrationAttributes["attStatus"] == "3") + #expect(fetcher.integrationAttributes["idfa"] == "advertiser-1") + + device = ["idfv": "vendor-2", "attStatus": "2"] + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"], appTransactionId: "tx-1") + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(fetcher.integrationAttributes["idfv"] == "vendor-2") + #expect(fetcher.integrationAttributes["attStatus"] == "2") + #expect(fetcher.integrationAttributes["idfa"] == nil) + #expect(syncedAttributes["idfa"] as? NSNull != nil) + #expect(syncedAttributes["idfv"] as? String == "vendor-2") + #expect(syncedAttributes["attStatus"] as? String == "2") + } + + @Test func activationRefreshesWithoutSettingProviderAgain() { + let container = DependencyContainer() + var device = ["idfv": "vendor-1", "attStatus": "0"] + let fetcher = AttributionFetcher( + storage: container.storage, + deviceHelper: container.deviceHelper, + webEntitlementRedeemer: container.webEntitlementRedeemer, + deviceAttributesProvider: { device }, + syncDeviceAttributes: { _ in } + ) + defer { fetcher.cancelPendingOperations() } + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"], appTransactionId: "tx-1") + #expect(fetcher.integrationAttributes["attStatus"] == "0") + device = ["idfv": "vendor-1", "idfa": "advertiser-1", "attStatus": "3"] + fetcher.refreshDeviceAttributes() + #expect(fetcher.integrationAttributes["attStatus"] == "3") + #expect(fetcher.integrationAttributes["idfa"] == "advertiser-1") + } +} From 29239d5fc9d986d9662969172a3ef48df79ebc7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:26:54 +0200 Subject: [PATCH 091/162] Skip the purchased-product fetch on StoreKit 2 at cold launch loadPurchasedProducts runs before configState is published, so every register call at cold launch waits on it. After reading the receipt and syncing the subscription status, it fetched the purchased products from StoreKit, which is a network round trip with 3s retry sleeps. On a weak network that stalled register for 10s or more (see #519 for the data). The fetch only fed two things: the active subscription group IDs used to suppress trials on upgrades, and SK1's intro-offer eligibility. StoreKit 2 transactions already carry the group ID, and SK2's eligibility hook is a no-op, so on SK2 the fetch was answering a question the receipt had already answered. ReceiptManagerType now says whether the groups have to come from products; SK1 keeps the fetch, SK2 skips it and reads the groups off the snapshot. The subscription status, customer info, entitlement map and active products are all still set before config is published, so nothing downstream sees a half-loaded state. That is the difference from #519, which published config before the whole load and left those empty for the length of the StoreKit read. Both phases of the load now log their duration at debug level so the slow call can be pinned down from a device log. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + .../Receipt Manager/ReceiptManager.swift | 41 ++++-- .../Receipt Manager/SK1ReceiptManager.swift | 1 + .../Receipt Manager/SK2ReceiptManager.swift | 6 + SuperwallKit.xcodeproj/project.pbxproj | 4 + ...iptManagerPurchasedProductFetchTests.swift | 122 ++++++++++++++++++ .../ReceiptManagerTrialEligibilityTests.swift | 1 + 7 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerPurchasedProductFetchTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e1880528..6c348a482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes duplicate device attribute and subscription status change events being tracked when the subscription status is repeatedly set to the same logical state. As part of this, `subscriptionStatusDidChange` now fires only when the logical status changes — the status case, the set of entitlements, or an entitlement's `isActive` flag. Updates to transaction metadata such as expiry dates or renewal state no longer trigger it; use `customerInfoDidChange` for those. - Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. +- Fixes slow cold launches for subscribers on a weak network by no longer fetching their purchased products from StoreKit before the SDK is ready. Applies to StoreKit 2. - Fixes a data race during SDK configuration that Thread Sanitizer flagged on every launch. - Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them. - Fixes audiences matching users they shouldn't when you use a Purchase Controller. diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift index 86419a994..5914ec1f5 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift @@ -182,7 +182,9 @@ actor ReceiptManager { let configEntitlementsByProductId = ConfigLogic.extractEntitlements(from: config) // Get device snapshot + let purchasesLoadStart = Date() let onDeviceSnapshot = await manager.loadPurchases(serverEntitlementsByProductId: configEntitlementsByProductId) + logPhase("Loaded purchases from StoreKit.", startedAt: purchasesLoadStart, count: onDeviceSnapshot.purchases.count) // Save device-only CustomerInfo to storage for use when merging with web entitlements storage.save(onDeviceSnapshot.customerInfo, forType: LatestDeviceCustomerInfo.self) @@ -215,16 +217,16 @@ actor ReceiptManager { await receiptDelegate?.syncSubscriptionStatus(purchases: onDeviceSnapshot.purchases) - let purchasedProductIds = Set(onDeviceSnapshot.purchases.map { $0.id }) - - guard let storeProducts = try? await productsManager.products( - identifiers: purchasedProductIds, - forPaywall: nil, - placement: nil - ) else { - // Fetch failed: refresh from the snapshot alone so the set still reflects this load. - // We assign only *after* the await (here and below), never before, so a re-entrant - // `isFreeTrialAvailable` during the suspension can't observe a half-built set. + // StoreKit 2 transactions carry their subscription group ID, so the active + // groups come straight from the snapshot. Only StoreKit 1 has to fetch the + // purchased products to find them. Skipping the fetch keeps a network round + // trip off the cold-launch path, which `configState` waits on. + guard manager.loadsSubscriptionGroupsFromProducts, + let storeProducts = await fetchPurchasedProducts(from: onDeviceSnapshot) + else { + // Fetch skipped or failed: refresh from the snapshot alone so the set still reflects + // this load. We assign only *after* the await (here and below), never before, so a + // re-entrant `isFreeTrialAvailable` during the suspension can't observe a half-built set. activeSubscriptionGroupIds = computeActiveSubscriptionGroupIds(from: onDeviceSnapshot, storeProducts: []) return } @@ -234,6 +236,25 @@ actor ReceiptManager { await manager.loadIntroOfferEligibility(forProducts: storeProducts) } + /// Fetches the purchased products off the snapshot, returning `nil` when the fetch fails. + private func fetchPurchasedProducts(from snapshot: PurchaseSnapshot) async -> Set? { + let startedAt = Date() + let storeProducts = try? await productsManager.products( + identifiers: Set(snapshot.purchases.map { $0.id }), forPaywall: nil, placement: nil + ) + logPhase("Fetched purchased products from StoreKit.", startedAt: startedAt, count: storeProducts?.count ?? 0) + return storeProducts + } + + private func logPhase(_ message: String, startedAt: Date, count: Int) { + Logger.debug( + logLevel: .debug, + scope: .receipts, + message: message, + info: ["duration_ms": Int(Date().timeIntervalSince(startedAt) * 1000), "count": count] + ) + } + /// Determines whether a free trial will actually be granted when the user purchases `storeProduct`. /// /// Stricter than raw `isEligibleForIntroOffer` (which only reflects whether the customer ever diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK1ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK1ReceiptManager.swift index c107fb7f8..295398bff 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK1ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK1ReceiptManager.swift @@ -8,6 +8,7 @@ import Foundation final class SK1ReceiptManager: ReceiptManagerType { + let loadsSubscriptionGroupsFromProducts = true private let receiptData: () -> Data? var purchasedSubscriptionGroupIds: Set? var purchases: Set = [] diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift index ebac7ac9b..f4ad84461 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift @@ -15,6 +15,11 @@ protocol ReceiptManagerType: AnyObject { var latestSubscriptionPeriodType: LatestSubscription.PeriodType? { get async } var latestSubscriptionWillAutoRenew: Bool? { get async } var latestSubscriptionState: LatestSubscription.State? { get async } + /// Whether the active subscription groups have to be read off fetched products. + /// StoreKit 1 receipts don't carry a subscription group ID, so SK1 fetches the + /// purchased products to find them (and to seed its intro-offer eligibility). + /// StoreKit 2 transactions carry the group ID, so SK2 skips that fetch. + var loadsSubscriptionGroupsFromProducts: Bool { get } func loadIntroOfferEligibility(forProducts storeProducts: Set) async func loadPurchases(serverEntitlementsByProductId: [String: Set]) async -> PurchaseSnapshot @@ -28,6 +33,7 @@ struct PurchaseSnapshot { @available(iOS 15.0, *) actor SK2ReceiptManager: ReceiptManagerType { + nonisolated let loadsSubscriptionGroupsFromProducts = false /// Resolves intro-offer eligibility live from StoreKit. Injectable so tests can /// verify eligibility is re-evaluated on every call rather than cached. private let resolveIntroOfferEligibility: @Sendable (StoreProduct) async -> Bool diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index a3bcbd605..dc909f210 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -569,6 +569,7 @@ FA677CF601A228D5B485FFDE /* PopupTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D5F8BE7E93645C0FCA49E4A /* PopupTransition.swift */; }; FA907E1BC8B68F238C791867 /* SuperwallDelegateAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E441343EAC43B2ECF35F929 /* SuperwallDelegateAdapter.swift */; }; FAE2C990CFBD7485E4DBA8F5 /* PresentationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C825F0AD231C62462873E51A /* PresentationRequest.swift */; }; + FAF3AF67B749AE55CACAE5F9 /* ReceiptManagerPurchasedProductFetchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4078AB25B84478F020D1B055 /* ReceiptManagerPurchasedProductFetchTests.swift */; }; FC051A3A8D640AF49D798B25 /* RawExperiment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7611B89AB647CB1AB79CA912 /* RawExperiment.swift */; }; FC27E2B772AEEC425ED9944D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 9EF9D5F77A002F6F0C03C77F /* PrivacyInfo.xcprivacy */; }; FC7A0F08E317F745C3C46E51 /* PermissionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69BCC259F5FBE15AB02D662 /* PermissionHandler.swift */; }; @@ -747,6 +748,7 @@ 3E3E1BAFC4A22DC46C49F00C /* String+RemoveChars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+RemoveChars.swift"; sourceTree = ""; }; 3E828EBAB18CCC0B236EF71D /* CoreDataStackMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStackMock.swift; sourceTree = ""; }; 405C59153A88E6B9D664585A /* PermissionHandler+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PermissionHandler+Notification.swift"; sourceTree = ""; }; + 4078AB25B84478F020D1B055 /* ReceiptManagerPurchasedProductFetchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptManagerPurchasedProductFetchTests.swift; sourceTree = ""; }; 40AE19B5A9B237A2552D5F36 /* IdentityLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityLogicTests.swift; sourceTree = ""; }; 42956918D4FFA5FBA79F3AA5 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 440ABDF6DAE2C15579B93DF1 /* PushTransitionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushTransitionDelegate.swift; sourceTree = ""; }; @@ -1303,6 +1305,7 @@ children = ( 9E3DAD767490972EA30257F9 /* EntitlementProcessorTests.swift */, 39B81D88316F06C0C2757F10 /* MockReceiptData.swift */, + 4078AB25B84478F020D1B055 /* ReceiptManagerPurchasedProductFetchTests.swift */, 03471273DF4C875227102BE2 /* ReceiptManagerTests.swift */, A08CC3D275A02927073952EB /* ReceiptManagerTrialEligibilityTests.swift */, 87B1E659458AE78C3908562B /* SK2ReceiptManagerTests.swift */, @@ -3391,6 +3394,7 @@ A44BAE75AAE4713FAE38F992 /* ProductsFetcherSK1.swift in Sources */, 847E0BD4BDA515E47608F6A1 /* ProductsFetcherSK2Tests.swift in Sources */, 5EF4EE04BAA930A2CC4379A1 /* RawWebMessageHandlerTests.swift in Sources */, + FAF3AF67B749AE55CACAE5F9 /* ReceiptManagerPurchasedProductFetchTests.swift in Sources */, 3BA17B2DA6B69A7B90D39AF9 /* ReceiptManagerTests.swift in Sources */, 498C546594CF7A5DA78575AA /* ReceiptManagerTrialEligibilityTests.swift in Sources */, 225D6F5363B1520744EABD86 /* RedeemResponseTests.swift in Sources */, diff --git a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerPurchasedProductFetchTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerPurchasedProductFetchTests.swift new file mode 100644 index 000000000..9c1902632 --- /dev/null +++ b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerPurchasedProductFetchTests.swift @@ -0,0 +1,122 @@ +// +// ReceiptManagerPurchasedProductFetchTests.swift +// SuperwallKitTests +// +// `loadPurchasedProducts` runs before `configState` is published, so every +// `register` call at cold launch waits on it. StoreKit 2 transactions carry +// their subscription group ID, so the active groups come from the snapshot and +// the purchased-product fetch (a network round trip) is skipped. StoreKit 1 +// receipts don't carry the group ID, so SK1 still fetches. +// + +import Foundation +import Testing +@testable import SuperwallKit + +struct ReceiptManagerPurchasedProductFetchTests { + // Held for the lifetime of each test: `ReceiptManager` keeps an `unowned` + // reference to its factory, so the container must outlive the manager. + let dependencyContainer = DependencyContainer() + + private func makeReceiptManager( + loadsSubscriptionGroupsFromProducts: Bool + ) -> (manager: ReceiptManager, fetcher: CountingProductsFetcher, productsManager: ProductsManager) { + let fetcher = CountingProductsFetcher(entitlementsInfo: dependencyContainer.entitlementsInfo) + let productsManager = ProductsManager( + entitlementsInfo: dependencyContainer.entitlementsInfo, + storeKitVersion: .storeKit1, + productsFetcher: fetcher + ) + let receiptManager = ReceiptManager( + storeKitVersion: .storeKit2, + shouldBypassAppTransactionCheck: true, + productsManager: productsManager, + receiptManager: SnapshotReceiptManagerType( + loadsSubscriptionGroupsFromProducts: loadsSubscriptionGroupsFromProducts + ), + receiptDelegate: nil, + factory: dependencyContainer, + storage: dependencyContainer.storage + ) + return (receiptManager, fetcher, productsManager) + } + + @Test("StoreKit 2 skips the purchased-product fetch and reads groups from the transactions") + func storeKit2SkipsProductFetch() async { + let (manager, fetcher, productsManager) = makeReceiptManager(loadsSubscriptionGroupsFromProducts: false) + _ = productsManager + + await manager.loadPurchasedProducts(config: .stub()) + + #expect(fetcher.fetchCount == 0) + // The active group still gates the trial, so it was read off the transaction. + let gold = StoreProduct( + sk1Product: MockSkProduct(productIdentifier: "com.app.gold", subscriptionGroupIdentifier: "group_A") + ) + #expect(await manager.isFreeTrialAvailable(for: gold) == false) + } + + @Test("StoreKit 1 still fetches the purchased products") + func storeKit1FetchesProducts() async { + let (manager, fetcher, productsManager) = makeReceiptManager(loadsSubscriptionGroupsFromProducts: true) + _ = productsManager + + await manager.loadPurchasedProducts(config: .stub()) + + #expect(fetcher.fetchCount == 1) + } +} + +/// Counts product fetches instead of hitting StoreKit. +private final class CountingProductsFetcher: ProductsFetcherSK1 { + private(set) var fetchCount = 0 + + override func products( + identifiers: Set, + forPaywall paywall: Paywall?, + placement: PlacementData? + ) async throws -> Set { + fetchCount += 1 + return [] + } +} + +/// Returns one active subscription in `group_A`, the way an SK2 snapshot would. +private final class SnapshotReceiptManagerType: ReceiptManagerType { + let loadsSubscriptionGroupsFromProducts: Bool + var purchases: Set = [] + var transactionReceipts: [TransactionReceipt] = [] + var latestSubscriptionPeriodType: LatestSubscription.PeriodType? + var latestSubscriptionWillAutoRenew: Bool? + var latestSubscriptionState: LatestSubscription.State? + + init(loadsSubscriptionGroupsFromProducts: Bool) { + self.loadsSubscriptionGroupsFromProducts = loadsSubscriptionGroupsFromProducts + } + + func loadIntroOfferEligibility(forProducts _: Set) async {} + + func loadPurchases(serverEntitlementsByProductId _: [String: Set]) async -> PurchaseSnapshot { + let silver = SubscriptionTransaction( + transactionId: "1", + productId: "com.app.silver", + purchaseDate: Date(), + willRenew: true, + isRevoked: false, + isInGracePeriod: false, + isInBillingRetryPeriod: false, + isActive: true, + expirationDate: Date().addingTimeInterval(3600), + subscriptionGroupId: "group_A" + ) + purchases = [Purchase(id: "com.app.silver", isActive: true, purchaseDate: Date())] + return PurchaseSnapshot( + purchases: purchases, + customerInfo: CustomerInfo(subscriptions: [silver], nonSubscriptions: [], entitlements: []) + ) + } + + func isEligibleForIntroOffer(_ storeProduct: StoreProduct) async -> Bool { + return true + } +} diff --git a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift index 9c01fcf65..214e42b89 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift @@ -208,6 +208,7 @@ struct ReceiptManagerTrialEligibilityTests { /// Minimal `ReceiptManagerType` whose `isEligibleForIntroOffer` is fully controlled, /// so tests can isolate `ReceiptManager`'s upgrade/crossgrade gating logic. private final class MockReceiptManagerType: ReceiptManagerType { + let loadsSubscriptionGroupsFromProducts = false let isEligibleForIntroOfferResult: Bool var purchases: Set = [] var transactionReceipts: [TransactionReceipt] = [] From eb96e0d769eff8bc0935e0fe8dbc1096f102fc67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:38:10 +0200 Subject: [PATCH 092/162] Log failed purchased-product fetches and fix a stale doc comment The fetch log line said "Fetched" even when the fetch failed, which on StoreKit 1 silently degrades trial gating. It now says so. The activeSubscriptionGroupIds comment still described the set as coming from fetched products on both StoreKit versions. Co-Authored-By: Claude Fable 5.1 --- .../Receipt Manager/Receipt Manager/ReceiptManager.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift index 5914ec1f5..639a0576b 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift @@ -31,9 +31,9 @@ actor ReceiptManager { private unowned let factory: Factory private unowned let storage: Storage /// Subscription group IDs the user currently has an active subscription in. Computed - /// during `loadPurchasedProducts` from the active purchases and their fetched products, - /// so it works for both StoreKit 1 and StoreKit 2. Used to suppress free trials on - /// upgrades/crossgrades/downgrades, which Apple won't apply an intro offer to. + /// during `loadPurchasedProducts`: on StoreKit 2 from the snapshot's transactions, which + /// carry the group ID; on StoreKit 1 from the fetched purchased products. Used to suppress + /// free trials on upgrades/crossgrades/downgrades, which Apple won't apply an intro offer to. private var activeSubscriptionGroupIds: Set static var appTransactionId: String? static var appId: UInt64? @@ -242,7 +242,8 @@ actor ReceiptManager { let storeProducts = try? await productsManager.products( identifiers: Set(snapshot.purchases.map { $0.id }), forPaywall: nil, placement: nil ) - logPhase("Fetched purchased products from StoreKit.", startedAt: startedAt, count: storeProducts?.count ?? 0) + let outcome = storeProducts == nil ? "Failed to fetch" : "Fetched" + logPhase("\(outcome) purchased products from StoreKit.", startedAt: startedAt, count: storeProducts?.count ?? 0) return storeProducts } From 86ebffda665322c44a8dfafef2b13d072784a698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:05:47 +0200 Subject: [PATCH 093/162] Drop the load timing logs Diagnostic timing does not belong in production code. The fetch skip and the doc comment fix stay. Co-Authored-By: Claude Fable 5.1 --- .../Receipt Manager/ReceiptManager.swift | 28 ++++--------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift index 639a0576b..e506621c0 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift @@ -182,9 +182,7 @@ actor ReceiptManager { let configEntitlementsByProductId = ConfigLogic.extractEntitlements(from: config) // Get device snapshot - let purchasesLoadStart = Date() let onDeviceSnapshot = await manager.loadPurchases(serverEntitlementsByProductId: configEntitlementsByProductId) - logPhase("Loaded purchases from StoreKit.", startedAt: purchasesLoadStart, count: onDeviceSnapshot.purchases.count) // Save device-only CustomerInfo to storage for use when merging with web entitlements storage.save(onDeviceSnapshot.customerInfo, forType: LatestDeviceCustomerInfo.self) @@ -222,7 +220,11 @@ actor ReceiptManager { // purchased products to find them. Skipping the fetch keeps a network round // trip off the cold-launch path, which `configState` waits on. guard manager.loadsSubscriptionGroupsFromProducts, - let storeProducts = await fetchPurchasedProducts(from: onDeviceSnapshot) + let storeProducts = try? await productsManager.products( + identifiers: Set(onDeviceSnapshot.purchases.map { $0.id }), + forPaywall: nil, + placement: nil + ) else { // Fetch skipped or failed: refresh from the snapshot alone so the set still reflects // this load. We assign only *after* the await (here and below), never before, so a @@ -236,26 +238,6 @@ actor ReceiptManager { await manager.loadIntroOfferEligibility(forProducts: storeProducts) } - /// Fetches the purchased products off the snapshot, returning `nil` when the fetch fails. - private func fetchPurchasedProducts(from snapshot: PurchaseSnapshot) async -> Set? { - let startedAt = Date() - let storeProducts = try? await productsManager.products( - identifiers: Set(snapshot.purchases.map { $0.id }), forPaywall: nil, placement: nil - ) - let outcome = storeProducts == nil ? "Failed to fetch" : "Fetched" - logPhase("\(outcome) purchased products from StoreKit.", startedAt: startedAt, count: storeProducts?.count ?? 0) - return storeProducts - } - - private func logPhase(_ message: String, startedAt: Date, count: Int) { - Logger.debug( - logLevel: .debug, - scope: .receipts, - message: message, - info: ["duration_ms": Int(Date().timeIntervalSince(startedAt) * 1000), "count": count] - ) - } - /// Determines whether a free trial will actually be granted when the user purchases `storeProduct`. /// /// Stricter than raw `isEligibleForIntroOffer` (which only reflects whether the customer ever From e7941b229c54d4ebe6b4aca41cf0d3082ae2c492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:05:59 +0200 Subject: [PATCH 094/162] Publish config from the saved entitlements before the StoreKit read A subscriber with a cached config still waited on this launch's Transaction.all read before configState was published, so every register call at cold launch stalled for as long as that read took. #519 tried to publish first and left the in-memory purchase state empty for the length of the read: no product-to-entitlement map, no active products for audience filters, no subscription groups for the trial gate. When the customer info saved by the previous launch proves the user is entitled through a date that hasn't arrived yet, config is now published before the read, and the purchase state is rebuilt from that saved copy first: purchases, the product-to-entitlement map (with willRenew and the other fields audience filters read), and the active subscription groups. expiresAt is a date the store asserted and a subscription can't lapse before it, so a future date is a guarantee. Refunds since the last launch are the one thing it can't see, and the read corrects those when it lands. The fast path is skipped with an external purchase controller, in test mode, when there is no saved customer info, and when the saved entitlement has expired; all of those wait for the read as before. Both trial eligibility callers, including the buy-time one in TransactionManager that #519 missed, wait on the load through initialPurchasesLoad. ReceiptRefreshDelegateWrapper moves to its own file to keep ReceiptManager.swift under the lint length limits. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + .../SuperwallKit/Config/ConfigManager.swift | 75 +++- .../Dependencies/DependencyContainer.swift | 12 + .../Dependencies/FactoryProtocols.swift | 3 + .../Receipt Manager/ReceiptManager.swift | 40 +- .../ReceiptRefreshDelegateWrapper.swift | 26 ++ .../Receipt Manager/SK1ReceiptManager.swift | 4 + .../Receipt Manager/SK2ReceiptManager.swift | 7 + .../Transactions/TransactionManager.swift | 4 + SuperwallKit.xcodeproj/project.pbxproj | 8 + .../ConfigManagerEarlyPublishTests.swift | 377 ++++++++++++++++++ ...iptManagerPurchasedProductFetchTests.swift | 4 + .../ReceiptManagerTrialEligibilityTests.swift | 4 + 13 files changed, 545 insertions(+), 20 deletions(-) create mode 100644 Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptRefreshDelegateWrapper.swift create mode 100644 Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c348a482..2099553d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes duplicate device attribute and subscription status change events being tracked when the subscription status is repeatedly set to the same logical state. As part of this, `subscriptionStatusDidChange` now fires only when the logical status changes — the status case, the set of entitlements, or an entitlement's `isActive` flag. Updates to transaction metadata such as expiry dates or renewal state no longer trigger it; use `customerInfoDidChange` for those. - Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. - Fixes slow cold launches for subscribers on a weak network by no longer fetching their purchased products from StoreKit before the SDK is ready. Applies to StoreKit 2. +- Fixes `register` calls stalling at cold launch for subscribers on a weak network. When the saved subscription is still within its expiry, the SDK is now ready before it reads purchases from StoreKit. - Fixes a data race during SDK configuration that Thread Sanitizer flagged on every launch. - Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them. - Fixes audiences matching users they shouldn't when you use a Purchase Controller. diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index 49c448586..48d2553d2 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -35,6 +35,12 @@ class ConfigManager { var configRetryCount = 0 + /// The purchases load that `fetchConfiguration` published `configState` ahead + /// of, when the saved customer info proved the user was still entitled. Trial + /// eligibility waits on it so an upgrade during the load still sees the active + /// subscription groups. + private(set) var initialPurchasesLoad: Task? + private unowned let storeKitManager: StoreKitManager unowned let storage: Storage private unowned let network: Network @@ -185,9 +191,23 @@ class ConfigManager { InternalSuperwallEvent.DeviceAttributes(deviceAttributes: deviceAttributes) ) - // Step 6: Process config and set state - await processConfig(config, isFirstTime: true) - configState.send(.retrieved(config)) + // Step 6: Process config and set state. + // + // `processConfig` reads StoreKit, which can take many seconds on a weak + // network, and every `register` call waits on `configState`. When the + // customer info saved by the previous launch proves the user is entitled + // through a date that hasn't arrived yet, config is published before that + // read and the in-memory purchase state is rebuilt from the saved copy. + // Only changes made since the last launch can then be missed, and the + // read corrects those when it lands. + let didPublishConfig = await processConfig( + config, + isFirstTime: true, + publishingEarlyFrom: shouldFetchAsync ? savedCustomerInfoForEarlyPublish() : nil + ) + if !didPublishConfig { + configState.send(.retrieved(config)) + } // Step 7: Schedule background tasks scheduleBackgroundTasks( @@ -220,6 +240,25 @@ class ConfigManager { return entitlementsInfo.granted.contains { $0.isActive } } + /// The saved customer info when it proves the user is still entitled, so + /// config can be published before this launch's StoreKit read. `expiresAt` is + /// a date the store asserted, and a subscription can't lapse before it, so a + /// future date is a guarantee, not a guess. Refunds are the one thing it can't + /// see, and the read catches those seconds later. Not used with a purchase + /// controller, where the status isn't ours to assume. + private func savedCustomerInfoForEarlyPublish() -> CustomerInfo? { + if factory.makeHasExternalPurchaseController() { + return nil + } + guard let customerInfo = storage.get(LatestCustomerInfo.self) else { + return nil + } + let isStillEntitled = customerInfo.entitlements.contains { + $0.isActive && ($0.expiresAt ?? .distantPast) > Date() + } + return isStillEntitled ? customerInfo : nil + } + private struct ConfigFetchResult { let config: Config let isUsingCached: Bool @@ -387,10 +426,18 @@ class ConfigManager { ) } + /// Applies `config` and loads purchases from StoreKit. + /// + /// - Parameter savedCustomerInfo: When given, `configState` is sent before the + /// StoreKit read starts, with the in-memory purchase state rebuilt from this + /// saved copy. Ignored in test mode, where products come from the API. + /// - Returns: Whether `configState` was sent here. + @discardableResult private func processConfig( _ config: Config, - isFirstTime: Bool - ) async { + isFirstTime: Bool, + publishingEarlyFrom savedCustomerInfo: CustomerInfo? = nil + ) async -> Bool { storage.save( config.featureFlags.disableVerbosePlacements, forType: DisableVerbosePlacements.self) storage.save(config, forType: LatestConfig.self) @@ -404,6 +451,7 @@ class ConfigManager { let testModeJustActivated = !wasTestMode && testModeManager.isTestMode let testModeJustDeactivated = wasTestMode && !testModeManager.isTestMode + var didPublishConfig = false if testModeManager.isTestMode { // In test mode, fetch products from API instead of StoreKit await fetchTestModeProducts(testModeManager: testModeManager) @@ -423,7 +471,20 @@ class ConfigManager { entitlements: [] ).merging(with: .blank(), granting: entitlementsInfo.granted) } - await factory.loadPurchasedProducts(config: config) + if let savedCustomerInfo = savedCustomerInfo { + await factory.restorePurchases(from: savedCustomerInfo, config: config) + // Stored before the send so anything that presents on this config can + // wait for the load through `initialPurchasesLoad`. + let purchasesLoad = Task { [factory] in + await factory.loadPurchasedProducts(config: config) + } + initialPurchasesLoad = purchasesLoad + configState.send(.retrieved(config)) + didPublishConfig = true + await purchasesLoad.value + } else { + await factory.loadPurchasedProducts(config: config) + } } if !testModeManager.isTestMode { @@ -442,6 +503,8 @@ class ConfigManager { let reason = testModeManager.testModeReason { await presentTestModeModal(reason: reason, config: config) } + + return didPublishConfig } /// Reassigns variants and preloads paywalls again. diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index 25e57cfdf..fc4a5d79c 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -598,6 +598,14 @@ extension DependencyContainer: ReceiptFactory { await receiptManager.loadPurchasedProducts(config: config) } + func restorePurchases(from customerInfo: CustomerInfo, config: Config) async { + await receiptManager.restorePurchases(from: customerInfo, config: config) + } + + func waitForInitialPurchasesLoad() async { + await configManager.initialPurchasesLoad?.value + } + func refreshSK1Receipt() async { return await receiptManager.refreshSK1Receipt() } @@ -614,6 +622,10 @@ extension DependencyContainer: ReceiptFactory { return false } } + // Config can be published before the first purchases load finishes (see + // `ConfigManager.fetchConfiguration`). The active subscription groups that + // gate upgrades come from that load, so wait for it. + await waitForInitialPurchasesLoad() return await receiptManager.isFreeTrialAvailable(for: product) } diff --git a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift index e7748fcb6..914b53354 100644 --- a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift +++ b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift @@ -157,6 +157,9 @@ protocol UserAttributesPlacementFactory: AnyObject { protocol ReceiptFactory: AnyObject { func loadPurchasedProducts(config: Config?) async + func restorePurchases(from customerInfo: CustomerInfo, config: Config) async + /// Waits for the purchases load that config was published ahead of, if any. + func waitForInitialPurchasesLoad() async func refreshSK1Receipt() async func isFreeTrialAvailable(for product: StoreProduct) async -> Bool var isTestMode: Bool { get } diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift index e506621c0..b80db09e2 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift @@ -360,21 +360,33 @@ func computeActiveSubscriptionGroupIds( return Set(transactionGroupIds).union(productGroupIds) } -final class ReceiptRefreshDelegateWrapper: NSObject, SKRequestDelegate { - weak var receiptManager: ReceiptManager? - - func requestDidFinish(_ request: SKRequest) { - Task { - await receiptManager?.receiptRefreshDidFinish(request: request) +// MARK: - Restoring from the previous launch + +extension ReceiptManager { + /// Rebuilds the in-memory purchase state from the customer info saved by the + /// previous launch, so config can be published before this launch's StoreKit + /// read finishes. `loadPurchasedProducts` overwrites all of it when it lands. + func restorePurchases(from customerInfo: CustomerInfo, config: Config) async { + let subscriptionPurchases = customerInfo.subscriptions.map { + Purchase(id: $0.productId, isActive: $0.isActive, purchaseDate: $0.purchaseDate) } - } - - func request(_ request: SKRequest, didFailWithError error: Error) { - Task { - await receiptManager?.receiptRefreshDidFail( - request: request, - error: error - ) + let nonSubscriptionPurchases = customerInfo.nonSubscriptions.map { + Purchase(id: $0.productId, isActive: !$0.isRevoked, purchaseDate: $0.purchaseDate) } + let purchases = Set(subscriptionPurchases + nonSubscriptionPurchases) + await manager.seedPurchases(purchases) + + // Config knows every product and the entitlements it unlocks. The saved + // customer info knows which of those were active, and carries fields like + // willRenew that audience filters read, so its copy wins where both have one. + let savedById = Dictionary(customerInfo.entitlements.map { ($0.id, $0) }) { $1 } + let entitlementsByProductId = ConfigLogic.extractEntitlements(from: config) + .mapValues { Set($0.map { savedById[$0.id] ?? $0 }) } + Superwall.shared.entitlements.setEntitlementsFromConfig(entitlementsByProductId) + + activeSubscriptionGroupIds = computeActiveSubscriptionGroupIds( + from: PurchaseSnapshot(purchases: purchases, customerInfo: customerInfo), + storeProducts: [] + ) } } diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptRefreshDelegateWrapper.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptRefreshDelegateWrapper.swift new file mode 100644 index 000000000..0d61ede35 --- /dev/null +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptRefreshDelegateWrapper.swift @@ -0,0 +1,26 @@ +// +// ReceiptRefreshDelegateWrapper.swift +// SuperwallKit +// + +import Foundation +import StoreKit + +final class ReceiptRefreshDelegateWrapper: NSObject, SKRequestDelegate { + weak var receiptManager: ReceiptManager? + + func requestDidFinish(_ request: SKRequest) { + Task { + await receiptManager?.receiptRefreshDidFinish(request: request) + } + } + + func request(_ request: SKRequest, didFailWithError error: Error) { + Task { + await receiptManager?.receiptRefreshDidFail( + request: request, + error: error + ) + } + } +} diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK1ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK1ReceiptManager.swift index 295398bff..2073b6119 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK1ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK1ReceiptManager.swift @@ -26,6 +26,10 @@ final class SK1ReceiptManager: ReceiptManagerType { self.receiptData = receiptData } + func seedPurchases(_ purchases: Set) async { + self.purchases = purchases + } + func loadIntroOfferEligibility(forProducts storeProducts: Set) async { var purchasedSubscriptionGroupIds: Set = [] for storeProduct in storeProducts { diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift index f4ad84461..e616304fa 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift @@ -22,6 +22,9 @@ protocol ReceiptManagerType: AnyObject { var loadsSubscriptionGroupsFromProducts: Bool { get } func loadIntroOfferEligibility(forProducts storeProducts: Set) async + /// Replaces the in-memory purchases with ones rebuilt from the previous launch's + /// customer info. The next `loadPurchases` overwrites them. + func seedPurchases(_ purchases: Set) async func loadPurchases(serverEntitlementsByProductId: [String: Set]) async -> PurchaseSnapshot func isEligibleForIntroOffer(_ storeProduct: StoreProduct) async -> Bool } @@ -68,6 +71,10 @@ actor SK2ReceiptManager: ReceiptManagerType { /// which could surface a free trial that Apple would not actually grant. func loadIntroOfferEligibility(forProducts _: Set) async {} + func seedPurchases(_ purchases: Set) { + self.purchases = purchases + } + func loadPurchases(serverEntitlementsByProductId: [String: Set]) async -> PurchaseSnapshot { var purchases: Set = [] var originalTransactionIds: Set = [] diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index 4ee53ed3e..cc25d5eb1 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -26,6 +26,7 @@ final class TransactionManager { & HasExternalPurchaseControllerFactory & RestoreAccessFactory & TestModeManagerFactory + & ReceiptFactory enum State { case observing case purchasing(PurchaseSource) @@ -731,6 +732,9 @@ final class TransactionManager { return await isCustomProductFreeTrialAvailable(for: product) } + // Same wait as `DependencyContainer.isFreeTrialAvailable`: the buy button + // can be tapped while the first purchases load is still running. + await factory.waitForInitialPurchasesLoad() return await receiptManager.isFreeTrialAvailable(for: product) } diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index dc909f210..f026f4bbd 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -115,6 +115,7 @@ 2EC1D279019CD3FB64E4674A /* TriggerResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25515131DF0AE67E26BFF462 /* TriggerResult.swift */; }; 2F33D9FC5A40496D6922CEBB /* IARError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFE7B1045C0541E66A965FC1 /* IARError.swift */; }; 2F54D64CED54E63F0E7B8711 /* AudioSessionProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7E0E27369A406D3492A11E2 /* AudioSessionProxy.swift */; }; + 2F8329A05DF5BFF975FFA8B7 /* ReceiptRefreshDelegateWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B812E74B11477D98D21421F9 /* ReceiptRefreshDelegateWrapper.swift */; }; 3002A50E92B640B4E3A98662 /* SuperwallKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 04FB15C76DE3D22CB370AFDB /* SuperwallKit.framework */; }; 30113C71D033ADDF01214C75 /* PreloadingDisabled.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46D2598EB46E9A27E2BD5104 /* PreloadingDisabled.swift */; }; 309EC3675C7EF75050B076E7 /* ComputedPropertyRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51445FD3A0C38C2B502EAF1D /* ComputedPropertyRequest.swift */; }; @@ -302,6 +303,7 @@ 8B58E4DC7A49D2DFB3CA1F7F /* ASN1Decoder+SingleValueContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 184CC04EBE95A64F30F83EA0 /* ASN1Decoder+SingleValueContainer.swift */; }; 8BA210D88B69EA78419354E1 /* InternalPresentationLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = B84489E65AE8F692F620866F /* InternalPresentationLogic.swift */; }; 8BBC7DE9391A8974DD5B6A32 /* ProductStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7106327DAD1C9044E4A57DD5 /* ProductStore.swift */; }; + 8BD2DB441D3A8B954B1D13B9 /* ConfigManagerEarlyPublishTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 779C974DE83DCC78D75A59DC /* ConfigManagerEarlyPublishTests.swift */; }; 8C3A81E3D75F027539933310 /* BottomPaddingAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DB52B223C181E0A8FA1D6D /* BottomPaddingAnimation.swift */; }; 8E5661E20F318661BB005E2F /* CustomerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2031E7FE7D2ECC7AFF8519AE /* CustomerInfo.swift */; }; 8EC4001F5273FB1260618E84 /* PaywallRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB5B56470F69FBE1C34EAA8 /* PaywallRequest.swift */; }; @@ -877,6 +879,7 @@ 76CE4D7606C896027C76520E /* SWDebugManagerLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWDebugManagerLogic.swift; sourceTree = ""; }; 76D61D89D2905A8747E37458 /* InterfaceStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterfaceStyle.swift; sourceTree = ""; }; 77827761E38CB30B06E3ABF2 /* MMPAttributionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMPAttributionManager.swift; sourceTree = ""; }; + 779C974DE83DCC78D75A59DC /* ConfigManagerEarlyPublishTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigManagerEarlyPublishTests.swift; sourceTree = ""; }; 787764B249892BBCA1088235 /* StorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageTests.swift; sourceTree = ""; }; 78C15CF29C17FE1EE3BFDEDC /* SubscriptionStatusResolutionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionStatusResolutionTests.swift; sourceTree = ""; }; 797EC0356AA1065ED11835BF /* PendingStripeCheckoutPollState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingStripeCheckoutPollState.swift; sourceTree = ""; }; @@ -1039,6 +1042,7 @@ B7180900DD0767487E671639 /* AssignmentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssignmentTests.swift; sourceTree = ""; }; B730226BC4F32B0A3A0FA6E9 /* ReceiptManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptManager.swift; sourceTree = ""; }; B7E0E27369A406D3492A11E2 /* AudioSessionProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionProxy.swift; sourceTree = ""; }; + B812E74B11477D98D21421F9 /* ReceiptRefreshDelegateWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptRefreshDelegateWrapper.swift; sourceTree = ""; }; B84489E65AE8F692F620866F /* InternalPresentationLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalPresentationLogic.swift; sourceTree = ""; }; B88E86C67F934540D846B8BA /* EmptyResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyResponse.swift; sourceTree = ""; }; B8BC23D4C0614CF0E9E83290 /* MMPMatchResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMPMatchResponseTests.swift; sourceTree = ""; }; @@ -2811,6 +2815,7 @@ isa = PBXGroup; children = ( B730226BC4F32B0A3A0FA6E9 /* ReceiptManager.swift */, + B812E74B11477D98D21421F9 /* ReceiptRefreshDelegateWrapper.swift */, 9C4966E857D1F9596B96910E /* SK1ReceiptManager.swift */, 050BC76657949DBB5F3D551C /* SK2ReceiptManager.swift */, ); @@ -2923,6 +2928,7 @@ isa = PBXGroup; children = ( 97D7F499B2CBFFF0A61F8D72 /* ConfigLogicTests.swift */, + 779C974DE83DCC78D75A59DC /* ConfigManagerEarlyPublishTests.swift */, DAAE50F011668C1D62B86751 /* ConfigManagerMock.swift */, 92A6B82F855E19B9C180C659 /* ConfigManagerTests.swift */, 9C4C1D13B7C7D97BE6ABFEA9 /* Assignments */, @@ -3308,6 +3314,7 @@ CBFC0D2DCA996A5FF7E5174B /* CacheTests.swift in Sources */, 2A07A8F4A55E28D13777D03E /* CheckDebuggerPresentationOperatorTests.swift in Sources */, A73497BB3DCD881318A5CD86 /* ConfigLogicTests.swift in Sources */, + 8BD2DB441D3A8B954B1D13B9 /* ConfigManagerEarlyPublishTests.swift in Sources */, 63CED0C62636A9FD03B7315A /* ConfigManagerMock.swift in Sources */, FCD7C16E35F139DCC2386B91 /* ConfigManagerTests.swift in Sources */, BCF808C7AC319C2B1F0AD52D /* ConfigResponseLogicTests.swift in Sources */, @@ -3725,6 +3732,7 @@ B3E6E82C0240EE6048360C9B /* RawWebMessageHandler.swift in Sources */, 4E7761949715C8BF8DEEF35C /* ReceiptLogic.swift in Sources */, 5634C4E0E082754F7939BB60 /* ReceiptManager.swift in Sources */, + 2F8329A05DF5BFF975FFA8B7 /* ReceiptRefreshDelegateWrapper.swift in Sources */, 4D37588A69A4C770C86FC585 /* RedeemRequest.swift in Sources */, 02C8AEBA38988C0140734957 /* RedeemResponse.swift in Sources */, 654A73B0F1E27315DB1AE2D4 /* Redeemable.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift new file mode 100644 index 000000000..e18b9ecad --- /dev/null +++ b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift @@ -0,0 +1,377 @@ +// +// ConfigManagerEarlyPublishTests.swift +// SuperwallKitTests +// +// A subscriber with a cached config still waited on this launch's StoreKit read +// before `configState` was published, so every `register` call at cold launch +// stalled for as long as that read took. When the customer info saved by the +// previous launch proves the user is entitled through a date that hasn't +// arrived yet, config is now published first and the in-memory purchase state +// is rebuilt from the saved copy until the read lands. +// +// swiftlint:disable all + +import Foundation +@testable import SuperwallKit +import Testing + +@Suite(.serialized) +struct ConfigManagerEarlyPublishTests { + /// Held for the lifetime of each test: the managers keep `unowned` + /// references to the container. + let dependencyContainer = DependencyContainer() + + private struct Harness { + let storage: StorageMock + let configManager: ConfigManager + let receiptManager: ReceiptManager + let receipt: SlowReceiptManagerType + /// Kept alive: other container members hold `unowned` references to the + /// original receipt manager and to the products manager. + let originalReceiptManager: ReceiptManager + let productsManager: ProductsManager + /// Kept alive: `ConfigManager` holds these `unowned`. + let network: NetworkMock + let deviceHelper: DeviceHelperMock + } + + private static let silverProductId = "com.app.silver" + private static let goldProductId = "com.app.gold" + + /// Customer info the way the previous launch would have saved it: one active + /// subscription in `group_A` unlocking `pro`, expiring `expiresIn` from now. + private func savedCustomerInfo(expiresIn: TimeInterval, willRenew: Bool = false) -> CustomerInfo { + let expiresAt = Date().addingTimeInterval(expiresIn) + let silver = SubscriptionTransaction( + transactionId: "1", + productId: Self.silverProductId, + purchaseDate: Date().addingTimeInterval(-3600), + willRenew: willRenew, + isRevoked: false, + isInGracePeriod: false, + isInBillingRetryPeriod: false, + isActive: expiresIn > 0, + expirationDate: expiresAt, + subscriptionGroupId: "group_A" + ) + let pro = Entitlement( + id: "pro", + isActive: expiresIn > 0, + productIds: [Self.silverProductId, Self.goldProductId], + latestProductId: Self.silverProductId, + store: .appStore, + expiresAt: expiresAt, + willRenew: willRenew + ) + return CustomerInfo(subscriptions: [silver], nonSubscriptions: [], entitlements: [pro]) + } + + /// Builds a config manager whose receipt loading takes `loadDelay` seconds + /// on the first call only, so the background refresh that follows does not + /// keep the test alive. `savedCustomerInfo` stands in for what the previous + /// launch wrote to disk. + private func makeHarness( + container: DependencyContainer? = nil, + isSubscribed: Bool, + savedCustomerInfo: CustomerInfo?, + loadDelay: TimeInterval + ) -> Harness { + let dependencyContainer = container ?? self.dependencyContainer + let storage = StorageMock() + let network = NetworkMock( + options: SuperwallOptions(), + factory: dependencyContainer + ) + let deviceHelper = DeviceHelperMock( + api: dependencyContainer.api, + storage: storage, + network: network, + entitlementsInfo: dependencyContainer.entitlementsInfo, + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer + ) + + let receipt = SlowReceiptManagerType(loadDelay: loadDelay) + let productsFetcher = ProductsFetcherSK1Mock( + productCompletionResult: .success([]), + entitlementsInfo: dependencyContainer.entitlementsInfo + ) + let productsManager = ProductsManager( + entitlementsInfo: dependencyContainer.entitlementsInfo, + storeKitVersion: .storeKit1, + productsFetcher: productsFetcher + ) + let originalReceiptManager: ReceiptManager = dependencyContainer.receiptManager + let receiptManager = ReceiptManager( + storeKitVersion: .storeKit2, + shouldBypassAppTransactionCheck: true, + productsManager: productsManager, + receiptManager: receipt, + receiptDelegate: nil, + factory: dependencyContainer, + storage: storage + ) + dependencyContainer.receiptManager = receiptManager + + // The config knows both products and the entitlement they unlock; the + // saved customer info says which is active. + let products = [Self.silverProductId, Self.goldProductId].map { + Product(name: $0, type: .appStore(.init(id: $0)), id: $0, entitlements: [Entitlement(id: "pro")]) + } + let cachedConfig: Config = .stub() + .setting(\.buildId, to: "cached_123") + .setting(\.featureFlags, to: .stub()) + .setting(\.products, to: products) + storage.save(cachedConfig, forType: LatestConfig.self) + + if isSubscribed { + storage.save(SubscriptionStatus.active([.stub()]), forType: SubscriptionStatusKey.self) + } else { + storage.save(SubscriptionStatus.inactive, forType: SubscriptionStatusKey.self) + } + storage.save(savedCustomerInfo ?? .blank(), forType: LatestCustomerInfo.self) + + let enrichment = Enrichment( + user: JSON(["test_user_key": "test_user_value"]), + device: JSON(["test_device_key": "test_device_value"]) + ) + storage.save(enrichment, forType: LatestEnrichment.self) + + let newConfig: Config = .stub() + .setting(\.buildId, to: "fresh_456") + network.configReturnValue = .success(newConfig) + + let configManager = ConfigManager( + options: SuperwallOptions(), + storeKitManager: dependencyContainer.storeKitManager, + storage: storage, + network: network, + paywallManager: dependencyContainer.paywallManager, + deviceHelper: deviceHelper, + entitlementsInfo: dependencyContainer.entitlementsInfo, + webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, + factory: dependencyContainer + ) + dependencyContainer.configManager = configManager + + return Harness( + storage: storage, + configManager: configManager, + receiptManager: receiptManager, + receipt: receipt, + originalReceiptManager: originalReceiptManager, + productsManager: productsManager, + network: network, + deviceHelper: deviceHelper + ) + } + + /// Polls until `configState` holds a config or `timeout` passes. Returns the + /// seconds it waited. + private func waitForConfig( + _ configManager: ConfigManager, + timeout: TimeInterval + ) async -> TimeInterval { + let start = Date() + while configManager.config == nil, Date().timeIntervalSince(start) < timeout { + try? await Task.sleep(nanoseconds: 10_000_000) + } + return Date().timeIntervalSince(start) + } + + private func settle() async { + // Let the background refresh finish before the container goes away. + try? await Task.sleep(nanoseconds: 300_000_000) + } + + @Test("Saved entitlement still valid: config is published before StoreKit finishes") + func publishesBeforeStoreKitWhenSavedEntitlementIsValid() async { + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: savedCustomerInfo(expiresIn: 3600), + loadDelay: 2 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + let waited = await waitForConfig(harness.configManager, timeout: 1.5) + + #expect(harness.configManager.config?.buildId == "cached_123") + #expect(waited < 1, "config took \(waited)s but StoreKit was still loading") + #expect(harness.receipt.didStartLoad, "purchases load must still be kicked off") + #expect(!harness.receipt.didFinishLoad, "config was published only after StoreKit finished") + + await fetch.value + #expect(harness.receipt.didFinishLoad, "fetchConfiguration still waits for the purchases load") + await settle() + } + + @Test("While StoreKit loads, purchase state comes from the saved customer info") + func restoresPurchaseStateFromSavedCustomerInfo() async { + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: savedCustomerInfo(expiresIn: 3600, willRenew: false), + loadDelay: 2 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + _ = await waitForConfig(harness.configManager, timeout: 1.5) + #expect(!harness.receipt.didFinishLoad, "test needs config to be published mid-load") + + // Active products for audience filters. + #expect(await harness.receiptManager.getActiveProductIds() == [Self.silverProductId]) + #expect(await harness.receiptManager.isSubscribed(to: Self.silverProductId)) + + // The product-to-entitlement map paywall products are built from, carrying + // the saved willRenew that audience filters read. + let silverEntitlements = Superwall.shared.entitlements.byProductId(Self.silverProductId) + #expect(silverEntitlements.map(\.id) == ["pro"]) + #expect(silverEntitlements.first?.willRenew == false) + #expect(silverEntitlements.first?.isActive == true) + + await fetch.value + await settle() + } + + @Test("Trial eligibility waits for the purchases load that config no longer waits for") + func trialEligibilityWaitsForInitialPurchasesLoad() async { + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: savedCustomerInfo(expiresIn: 3600), + loadDelay: 1 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + _ = await waitForConfig(harness.configManager, timeout: 1.5) + #expect(!harness.receipt.didFinishLoad, "test needs config to be published mid-load") + + let gold = StoreProduct( + sk1Product: MockSkProduct( + productIdentifier: Self.goldProductId, + subscriptionGroupIdentifier: "group_A" + ) + ) + _ = await dependencyContainer.isFreeTrialAvailable(for: gold) + #expect(harness.receipt.didFinishLoad, "eligibility was answered before the load finished") + + await fetch.value + await settle() + } + + @Test("Saved entitlement expired: purchases still load before config is published") + func waitsWhenSavedEntitlementHasExpired() async { + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: savedCustomerInfo(expiresIn: -60), + loadDelay: 1 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + let waited = await waitForConfig(harness.configManager, timeout: 0.5) + + #expect(harness.configManager.config == nil, "config was published after \(waited)s, before purchases loaded") + + await fetch.value + #expect(harness.receipt.didFinishLoad) + #expect(harness.configManager.config != nil) + await settle() + } + + @Test("An active status on disk with no saved customer info is not enough") + func waitsWithoutSavedCustomerInfo() async { + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: nil, + loadDelay: 1 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + _ = await waitForConfig(harness.configManager, timeout: 0.5) + + #expect(harness.configManager.config == nil) + + await fetch.value + #expect(harness.configManager.config != nil) + await settle() + } + + @Test("Unknown subscriber: purchases still load before config is published") + func syncPathStillLoadsPurchasesBeforePublishing() async { + let harness = makeHarness( + isSubscribed: false, + savedCustomerInfo: savedCustomerInfo(expiresIn: 3600), + loadDelay: 1 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + _ = await waitForConfig(harness.configManager, timeout: 0.5) + + #expect(harness.configManager.config == nil) + + await fetch.value + #expect(harness.receipt.didFinishLoad) + #expect(harness.configManager.config != nil) + await settle() + } + + @Test("With a purchase controller the status isn't ours to assume, so config waits") + func waitsWithExternalPurchaseController() async { + let controllerContainer = DependencyContainer(purchaseController: MockPurchaseController()) + let harness = makeHarness( + container: controllerContainer, + isSubscribed: true, + savedCustomerInfo: savedCustomerInfo(expiresIn: 3600), + loadDelay: 1 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + _ = await waitForConfig(harness.configManager, timeout: 0.5) + + #expect(harness.configManager.config == nil) + + await fetch.value + #expect(harness.configManager.config != nil) + await settle() + } +} + +/// A `ReceiptManagerType` whose first `loadPurchases` sleeps, standing in for a +/// StoreKit read on a weak network. +private final class SlowReceiptManagerType: ReceiptManagerType, @unchecked Sendable { + let loadsSubscriptionGroupsFromProducts = false + private let loadDelay: TimeInterval + private(set) var didStartLoad = false + private(set) var didFinishLoad = false + var purchases: Set = [] + var transactionReceipts: [TransactionReceipt] = [] + var latestSubscriptionPeriodType: LatestSubscription.PeriodType? + var latestSubscriptionWillAutoRenew: Bool? + var latestSubscriptionState: LatestSubscription.State? + + init(loadDelay: TimeInterval) { + self.loadDelay = loadDelay + } + + func loadIntroOfferEligibility(forProducts _: Set) async {} + + func seedPurchases(_ purchases: Set) async { + self.purchases = purchases + } + + func loadPurchases(serverEntitlementsByProductId _: [String: Set]) async -> PurchaseSnapshot { + let isFirstLoad = !didStartLoad + didStartLoad = true + if isFirstLoad { + try? await Task.sleep(nanoseconds: UInt64(loadDelay * 1_000_000_000)) + } + didFinishLoad = true + purchases = [] + return PurchaseSnapshot( + purchases: [], + customerInfo: CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: []) + ) + } + + func isEligibleForIntroOffer(_ storeProduct: StoreProduct) async -> Bool { + return true + } +} diff --git a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerPurchasedProductFetchTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerPurchasedProductFetchTests.swift index 9c1902632..813810dad 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerPurchasedProductFetchTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerPurchasedProductFetchTests.swift @@ -96,6 +96,10 @@ private final class SnapshotReceiptManagerType: ReceiptManagerType { func loadIntroOfferEligibility(forProducts _: Set) async {} + func seedPurchases(_ purchases: Set) async { + self.purchases = purchases + } + func loadPurchases(serverEntitlementsByProductId _: [String: Set]) async -> PurchaseSnapshot { let silver = SubscriptionTransaction( transactionId: "1", diff --git a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift index 214e42b89..3e1ebcb06 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift @@ -222,6 +222,10 @@ private final class MockReceiptManagerType: ReceiptManagerType { func loadIntroOfferEligibility(forProducts _: Set) async {} + func seedPurchases(_ purchases: Set) async { + self.purchases = purchases + } + func loadPurchases(serverEntitlementsByProductId _: [String: Set]) async -> PurchaseSnapshot { return PurchaseSnapshot( purchases: [], From 18875a35f0b6f89565274949e12d572ed42a01e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:29:12 +0200 Subject: [PATCH 095/162] Restore each saved item on its own expiry, and take the fast path with a purchase controller One unexpired entitlement proved the user was still entitled, but the restore then trusted every saved subscription and entitlement's persisted isActive. A second subscription that lapsed since the last launch came back active in activeProducts and the entitlement map until the StoreKit read landed. Each item is now checked against its own expiry; anything past it is restored inactive, and the active subscription groups come from the subscriptions that are still valid. The purchase controller exclusion is gone. The read never sets the status in that setup, and the saved entitlements it preserves carry the same expiry, so there was nothing for the exclusion to protect. The saved copy from a test-mode launch can still hold test entitlements, so it isn't restored when test mode has just turned off. The restore extension moves to ReceiptManager+Restore.swift to keep the main file under the lint length limit. Co-Authored-By: Claude Fable 5.1 --- .../SuperwallKit/Config/ConfigManager.swift | 12 +-- .../ReceiptManager+Restore.swift | 76 ++++++++++++++++++ .../Receipt Manager/ReceiptManager.swift | 35 +------- SuperwallKit.xcodeproj/project.pbxproj | 4 + .../ConfigManagerEarlyPublishTests.swift | 80 +++++++++++++++++-- 5 files changed, 161 insertions(+), 46 deletions(-) create mode 100644 Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index 48d2553d2..68291b6eb 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -244,12 +244,10 @@ class ConfigManager { /// config can be published before this launch's StoreKit read. `expiresAt` is /// a date the store asserted, and a subscription can't lapse before it, so a /// future date is a guarantee, not a guess. Refunds are the one thing it can't - /// see, and the read catches those seconds later. Not used with a purchase - /// controller, where the status isn't ours to assume. + /// see, and the read catches those seconds later. A purchase controller + /// changes nothing here: the read never sets the status in that setup, and + /// the saved entitlements it preserves carry the same expiry. private func savedCustomerInfoForEarlyPublish() -> CustomerInfo? { - if factory.makeHasExternalPurchaseController() { - return nil - } guard let customerInfo = storage.get(LatestCustomerInfo.self) else { return nil } @@ -471,7 +469,9 @@ class ConfigManager { entitlements: [] ).merging(with: .blank(), granting: entitlementsInfo.granted) } - if let savedCustomerInfo = savedCustomerInfo { + // The saved copy from a test-mode launch can still hold test + // entitlements, so it isn't restored when test mode just turned off. + if let savedCustomerInfo = savedCustomerInfo, !testModeJustDeactivated { await factory.restorePurchases(from: savedCustomerInfo, config: config) // Stored before the send so anything that presents on this config can // wait for the load through `initialPurchasesLoad`. diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift new file mode 100644 index 000000000..872552d67 --- /dev/null +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift @@ -0,0 +1,76 @@ +// +// ReceiptManager+Restore.swift +// SuperwallKit +// + +import Foundation + +// MARK: - Restoring from the previous launch + +extension ReceiptManager { + /// Rebuilds the in-memory purchase state from the customer info saved by the + /// previous launch, so config can be published before this launch's StoreKit + /// read finishes. `loadPurchasedProducts` overwrites all of it when it lands. + /// + /// Each saved item carries its own expiry, which the store asserted, so + /// anything past it has lapsed since the last launch and is restored inactive. + func restorePurchases(from customerInfo: CustomerInfo, config: Config) async { + let now = Date() + let activeSubscriptions = customerInfo.subscriptions.filter { + $0.isActive && !hasExpired($0.expirationDate, at: now) + } + let subscriptionPurchases = customerInfo.subscriptions.map { subscription in + Purchase( + id: subscription.productId, + isActive: activeSubscriptions.contains { $0 === subscription }, + purchaseDate: subscription.purchaseDate + ) + } + let nonSubscriptionPurchases = customerInfo.nonSubscriptions.map { + Purchase(id: $0.productId, isActive: !$0.isRevoked, purchaseDate: $0.purchaseDate) + } + let purchases = Set(subscriptionPurchases + nonSubscriptionPurchases) + await manager.seedPurchases(purchases) + + // Config knows every product and the entitlements it unlocks. The saved + // customer info knows which of those were active, and carries fields like + // willRenew that audience filters read, so its copy wins where both have one. + let savedById = Dictionary( + customerInfo.entitlements.map { entitlement in + let lapsed = entitlement.isActive && hasExpired(entitlement.expiresAt, at: now) + return (entitlement.id, lapsed ? deactivated(entitlement) : entitlement) + } + ) { $1 } + let entitlementsByProductId = ConfigLogic.extractEntitlements(from: config) + .mapValues { Set($0.map { savedById[$0.id] ?? $0 }) } + Superwall.shared.entitlements.setEntitlementsFromConfig(entitlementsByProductId) + + activeSubscriptionGroupIds = Set(activeSubscriptions.compactMap { $0.subscriptionGroupId }) + } + + /// A nil expiry never lapses: lifetime purchases and web entitlements without one. + private func hasExpired(_ expiresAt: Date?, at now: Date) -> Bool { + guard let expiresAt = expiresAt else { + return false + } + return expiresAt <= now + } + + private func deactivated(_ entitlement: Entitlement) -> Entitlement { + return Entitlement( + id: entitlement.id, + type: entitlement.type, + isActive: false, + productIds: entitlement.productIds, + latestProductId: entitlement.latestProductId, + store: entitlement.store, + startsAt: entitlement.startsAt, + renewedAt: entitlement.renewedAt, + expiresAt: entitlement.expiresAt, + isLifetime: entitlement.isLifetime, + willRenew: entitlement.willRenew, + state: entitlement.state, + offerType: entitlement.offerType + ) + } +} diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift index b80db09e2..8e7e64626 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift @@ -26,7 +26,7 @@ actor ReceiptManager { private weak var receiptDelegate: ReceiptDelegate? private let storeKitVersion: SuperwallOptions.StoreKitVersion private let shouldBypassAppTransactionCheck: Bool - private let manager: ReceiptManagerType + let manager: ReceiptManagerType private let delegateWrapper: ReceiptRefreshDelegateWrapper private unowned let factory: Factory private unowned let storage: Storage @@ -34,7 +34,7 @@ actor ReceiptManager { /// during `loadPurchasedProducts`: on StoreKit 2 from the snapshot's transactions, which /// carry the group ID; on StoreKit 1 from the fetched purchased products. Used to suppress /// free trials on upgrades/crossgrades/downgrades, which Apple won't apply an intro offer to. - private var activeSubscriptionGroupIds: Set + var activeSubscriptionGroupIds: Set static var appTransactionId: String? static var appId: UInt64? /// Set from `AppTransaction.shared` when available (iOS 16+). @@ -359,34 +359,3 @@ func computeActiveSubscriptionGroupIds( return Set(transactionGroupIds).union(productGroupIds) } - -// MARK: - Restoring from the previous launch - -extension ReceiptManager { - /// Rebuilds the in-memory purchase state from the customer info saved by the - /// previous launch, so config can be published before this launch's StoreKit - /// read finishes. `loadPurchasedProducts` overwrites all of it when it lands. - func restorePurchases(from customerInfo: CustomerInfo, config: Config) async { - let subscriptionPurchases = customerInfo.subscriptions.map { - Purchase(id: $0.productId, isActive: $0.isActive, purchaseDate: $0.purchaseDate) - } - let nonSubscriptionPurchases = customerInfo.nonSubscriptions.map { - Purchase(id: $0.productId, isActive: !$0.isRevoked, purchaseDate: $0.purchaseDate) - } - let purchases = Set(subscriptionPurchases + nonSubscriptionPurchases) - await manager.seedPurchases(purchases) - - // Config knows every product and the entitlements it unlocks. The saved - // customer info knows which of those were active, and carries fields like - // willRenew that audience filters read, so its copy wins where both have one. - let savedById = Dictionary(customerInfo.entitlements.map { ($0.id, $0) }) { $1 } - let entitlementsByProductId = ConfigLogic.extractEntitlements(from: config) - .mapValues { Set($0.map { savedById[$0.id] ?? $0 }) } - Superwall.shared.entitlements.setEntitlementsFromConfig(entitlementsByProductId) - - activeSubscriptionGroupIds = computeActiveSubscriptionGroupIds( - from: PurchaseSnapshot(purchases: purchases, customerInfo: customerInfo), - storeProducts: [] - ) - } -} diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index f026f4bbd..4dcd581c2 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -160,6 +160,7 @@ 42B707D898A49DCB2B33837C /* CustomCallbackRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45B3BC4249A9E9BA8E99EC7C /* CustomCallbackRegistry.swift */; }; 42FDAFD5AB3EC83713941E32 /* SK2ReceiptManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87B1E659458AE78C3908562B /* SK2ReceiptManagerTests.swift */; }; 432FB2AE172725B4ED208416 /* DebugManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5383FA48A6E9EF8F30683C9B /* DebugManager.swift */; }; + 43555DB889239C0F1FB73AE1 /* ReceiptManager+Restore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 120238C4AA04D65D43DB1820 /* ReceiptManager+Restore.swift */; }; 43EF1A9F46DECE0EECFD0368 /* HiddenListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9B0C261DCC1ED74DD2BF3EA /* HiddenListener.swift */; }; 44829144E9EFA0CE4A75BBA1 /* ExpressionLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154A9E99D00B9BD8837B798 /* ExpressionLogic.swift */; }; 44E2AE9B0AED16C48027CD21 /* CustomCallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = E439B70BB6190AFF6DDB81F2 /* CustomCallback.swift */; }; @@ -645,6 +646,7 @@ 1099A063D46DE7373CFABC4C /* GrantedEntitlementsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrantedEntitlementsTests.swift; sourceTree = ""; }; 10D5ABDB23D56393EFDCF73A /* NetworkMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMock.swift; sourceTree = ""; }; 115132479C9C41D57C9E3BA9 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + 120238C4AA04D65D43DB1820 /* ReceiptManager+Restore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReceiptManager+Restore.swift"; sourceTree = ""; }; 124F219E38F8398A65A7EB32 /* DependencyContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependencyContainer.swift; sourceTree = ""; }; 1528915438E6714B1F7F7BD4 /* PaywallRequestManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallRequestManager.swift; sourceTree = ""; }; 153C660FB51D0D1DFE56D462 /* PaywallPresentationStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallPresentationStyle.swift; sourceTree = ""; }; @@ -2815,6 +2817,7 @@ isa = PBXGroup; children = ( B730226BC4F32B0A3A0FA6E9 /* ReceiptManager.swift */, + 120238C4AA04D65D43DB1820 /* ReceiptManager+Restore.swift */, B812E74B11477D98D21421F9 /* ReceiptRefreshDelegateWrapper.swift */, 9C4966E857D1F9596B96910E /* SK1ReceiptManager.swift */, 050BC76657949DBB5F3D551C /* SK2ReceiptManager.swift */, @@ -3731,6 +3734,7 @@ 7477EFEA4C42BB441B92D096 /* RawPaywallResponse.swift in Sources */, B3E6E82C0240EE6048360C9B /* RawWebMessageHandler.swift in Sources */, 4E7761949715C8BF8DEEF35C /* ReceiptLogic.swift in Sources */, + 43555DB889239C0F1FB73AE1 /* ReceiptManager+Restore.swift in Sources */, 5634C4E0E082754F7939BB60 /* ReceiptManager.swift in Sources */, 2F8329A05DF5BFF975FFA8B7 /* ReceiptRefreshDelegateWrapper.swift in Sources */, 4D37588A69A4C770C86FC585 /* RedeemRequest.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift index e18b9ecad..6b697fa2b 100644 --- a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift +++ b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift @@ -37,6 +37,8 @@ struct ConfigManagerEarlyPublishTests { private static let silverProductId = "com.app.silver" private static let goldProductId = "com.app.gold" + /// A second product in its own group, unlocking a separate entitlement. + private static let legacyProductId = "com.app.legacy" /// Customer info the way the previous launch would have saved it: one active /// subscription in `group_A` unlocking `pro`, expiring `expiresIn` from now. @@ -66,6 +68,39 @@ struct ConfigManagerEarlyPublishTests { return CustomerInfo(subscriptions: [silver], nonSubscriptions: [], entitlements: [pro]) } + /// The valid `silver` subscription plus a `legacy` one in `group_B` that was + /// active when saved but whose expiry has since passed. + private func savedCustomerInfoWithLapsedSecondSubscription() -> CustomerInfo { + let valid = savedCustomerInfo(expiresIn: 3600) + let lapsedAt = Date().addingTimeInterval(-60) + let legacy = SubscriptionTransaction( + transactionId: "2", + productId: Self.legacyProductId, + purchaseDate: Date().addingTimeInterval(-7200), + willRenew: true, + isRevoked: false, + isInGracePeriod: false, + isInBillingRetryPeriod: false, + isActive: true, + expirationDate: lapsedAt, + subscriptionGroupId: "group_B" + ) + let legacyEntitlement = Entitlement( + id: "legacy", + isActive: true, + productIds: [Self.legacyProductId], + latestProductId: Self.legacyProductId, + store: .appStore, + expiresAt: lapsedAt, + willRenew: true + ) + return CustomerInfo( + subscriptions: valid.subscriptions + [legacy], + nonSubscriptions: [], + entitlements: valid.entitlements + [legacyEntitlement] + ) + } + /// Builds a config manager whose receipt loading takes `loadDelay` seconds /// on the first call only, so the background refresh that follows does not /// keep the test alive. `savedCustomerInfo` stands in for what the previous @@ -117,7 +152,14 @@ struct ConfigManagerEarlyPublishTests { // saved customer info says which is active. let products = [Self.silverProductId, Self.goldProductId].map { Product(name: $0, type: .appStore(.init(id: $0)), id: $0, entitlements: [Entitlement(id: "pro")]) - } + } + [ + Product( + name: Self.legacyProductId, + type: .appStore(.init(id: Self.legacyProductId)), + id: Self.legacyProductId, + entitlements: [Entitlement(id: "legacy")] + ) + ] let cachedConfig: Config = .stub() .setting(\.buildId, to: "cached_123") .setting(\.featureFlags, to: .stub()) @@ -232,6 +274,29 @@ struct ConfigManagerEarlyPublishTests { await settle() } + @Test("A second subscription that lapsed since the last launch is restored inactive") + func restoresLapsedSecondSubscriptionAsInactive() async { + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: savedCustomerInfoWithLapsedSecondSubscription(), + loadDelay: 2 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + _ = await waitForConfig(harness.configManager, timeout: 1.5) + #expect(!harness.receipt.didFinishLoad, "test needs config to be published mid-load") + + // The still-valid silver keeps the fast path, but legacy is past its expiry. + #expect(await harness.receiptManager.getActiveProductIds() == [Self.silverProductId]) + #expect(await harness.receiptManager.isSubscribed(to: Self.legacyProductId) == false) + let legacyEntitlements = Superwall.shared.entitlements.byProductId(Self.legacyProductId) + #expect(legacyEntitlements.first?.isActive == false) + #expect(legacyEntitlements.first?.willRenew == true, "the rest of the saved copy carries over") + + await fetch.value + await settle() + } + @Test("Trial eligibility waits for the purchases load that config no longer waits for") func trialEligibilityWaitsForInitialPurchasesLoad() async { let harness = makeHarness( @@ -313,23 +378,24 @@ struct ConfigManagerEarlyPublishTests { await settle() } - @Test("With a purchase controller the status isn't ours to assume, so config waits") - func waitsWithExternalPurchaseController() async { + @Test("A purchase controller takes the fast path too") + func publishesEarlyWithExternalPurchaseController() async { let controllerContainer = DependencyContainer(purchaseController: MockPurchaseController()) let harness = makeHarness( container: controllerContainer, isSubscribed: true, savedCustomerInfo: savedCustomerInfo(expiresIn: 3600), - loadDelay: 1 + loadDelay: 2 ) let fetch = Task { await harness.configManager.fetchConfiguration() } - _ = await waitForConfig(harness.configManager, timeout: 0.5) + let waited = await waitForConfig(harness.configManager, timeout: 1.5) - #expect(harness.configManager.config == nil) + #expect(harness.configManager.config != nil) + #expect(waited < 1, "config took \(waited)s but StoreKit was still loading") + #expect(!harness.receipt.didFinishLoad) await fetch.value - #expect(harness.configManager.config != nil) await settle() } } From 3953a56d18cfcb277640a2bd22506d2972e13085 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:43:37 +0200 Subject: [PATCH 096/162] Seed device purchases only, admit lifetime purchases, and pin the expiry checks The saved customer info is the merged copy with web subscriptions appended, but the purchases the StoreKit read produces are device-only. Seeding web rows made activeProducts change shape when the read landed, so only App Store rows are seeded now. Active rows are keyed by transactionId rather than object identity. A lifetime purchase has no expiry to check and can only be refunded, which is the same risk the expiry case accepts, so it takes the fast path too. The restore keeps a nil-expiry item's saved state for the same reason: it can't be shown to have lapsed. The expired fixture now keeps isActive true so the test pins the expiry clause rather than the flag, the blank-customer-info test says what it stores, and a note explains when initialPurchasesLoad is nil. Co-Authored-By: Claude Fable 5.1 --- .../SuperwallKit/Config/ConfigManager.swift | 4 +- .../Dependencies/DependencyContainer.swift | 4 ++ .../ReceiptManager+Restore.swift | 29 +++++--- .../ConfigManagerEarlyPublishTests.swift | 69 ++++++++++++++++--- 4 files changed, 86 insertions(+), 20 deletions(-) diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index 68291b6eb..f791d01c1 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -251,8 +251,10 @@ class ConfigManager { guard let customerInfo = storage.get(LatestCustomerInfo.self) else { return nil } + // A lifetime purchase has no expiry to check and can only be refunded, + // which is the same risk the expiry case already accepts. let isStillEntitled = customerInfo.entitlements.contains { - $0.isActive && ($0.expiresAt ?? .distantPast) > Date() + $0.isActive && ($0.isLifetime == true || ($0.expiresAt ?? .distantPast) > Date()) } return isStillEntitled ? customerInfo : nil } diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index fc4a5d79c..1e17a623a 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -603,6 +603,10 @@ extension DependencyContainer: ReceiptFactory { } func waitForInitialPurchasesLoad() async { + // nil means the load already finished (or config was never published + // early). It is also nil for the moment before `processConfig` stores the + // task; a purchase started that early skips the wait, which only affects + // whether the transaction is reported as a trial start. await configManager.initialPurchasesLoad?.value } diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift index 872552d67..bcf2ea872 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift @@ -14,21 +14,28 @@ extension ReceiptManager { /// /// Each saved item carries its own expiry, which the store asserted, so /// anything past it has lapsed since the last launch and is restored inactive. + /// A nil expiry can't be shown to have lapsed, so it keeps its saved state. func restorePurchases(from customerInfo: CustomerInfo, config: Config) async { let now = Date() + // The saved copy is the merged one, with web subscriptions appended. The + // purchases the read produces are device-only, so only App Store rows are + // seeded; otherwise `activeProducts` would change shape when the read lands. let activeSubscriptions = customerInfo.subscriptions.filter { - $0.isActive && !hasExpired($0.expirationDate, at: now) - } - let subscriptionPurchases = customerInfo.subscriptions.map { subscription in - Purchase( - id: subscription.productId, - isActive: activeSubscriptions.contains { $0 === subscription }, - purchaseDate: subscription.purchaseDate - ) - } - let nonSubscriptionPurchases = customerInfo.nonSubscriptions.map { - Purchase(id: $0.productId, isActive: !$0.isRevoked, purchaseDate: $0.purchaseDate) + $0.store == .appStore && $0.isActive && !hasExpired($0.expirationDate, at: now) } + let activeTransactionIds = Set(activeSubscriptions.map { $0.transactionId }) + let subscriptionPurchases = customerInfo.subscriptions + .filter { $0.store == .appStore } + .map { + Purchase( + id: $0.productId, + isActive: activeTransactionIds.contains($0.transactionId), + purchaseDate: $0.purchaseDate + ) + } + let nonSubscriptionPurchases = customerInfo.nonSubscriptions + .filter { $0.store == .appStore } + .map { Purchase(id: $0.productId, isActive: !$0.isRevoked, purchaseDate: $0.purchaseDate) } let purchases = Set(subscriptionPurchases + nonSubscriptionPurchases) await manager.seedPurchases(purchases) diff --git a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift index 6b697fa2b..bdab0c5fd 100644 --- a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift +++ b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift @@ -39,9 +39,13 @@ struct ConfigManagerEarlyPublishTests { private static let goldProductId = "com.app.gold" /// A second product in its own group, unlocking a separate entitlement. private static let legacyProductId = "com.app.legacy" + private static let webProductId = "com.app.web" - /// Customer info the way the previous launch would have saved it: one active + /// Customer info the way the previous launch would have saved it: one /// subscription in `group_A` unlocking `pro`, expiring `expiresIn` from now. + /// The saved `isActive` flag is always true: when `expiresIn` is negative + /// that's the stale shape, the flag frozen at the last launch and the date + /// since passed. private func savedCustomerInfo(expiresIn: TimeInterval, willRenew: Bool = false) -> CustomerInfo { let expiresAt = Date().addingTimeInterval(expiresIn) let silver = SubscriptionTransaction( @@ -52,13 +56,13 @@ struct ConfigManagerEarlyPublishTests { isRevoked: false, isInGracePeriod: false, isInBillingRetryPeriod: false, - isActive: expiresIn > 0, + isActive: true, expirationDate: expiresAt, subscriptionGroupId: "group_A" ) let pro = Entitlement( id: "pro", - isActive: expiresIn > 0, + isActive: true, productIds: [Self.silverProductId, Self.goldProductId], latestProductId: Self.silverProductId, store: .appStore, @@ -94,13 +98,40 @@ struct ConfigManagerEarlyPublishTests { expiresAt: lapsedAt, willRenew: true ) + // The saved copy is the merged one, so it also carries a web subscription. + let web = SubscriptionTransaction( + transactionId: "3", + productId: Self.webProductId, + purchaseDate: Date().addingTimeInterval(-600), + willRenew: true, + isRevoked: false, + isInGracePeriod: false, + isInBillingRetryPeriod: false, + isActive: true, + expirationDate: Date().addingTimeInterval(3600), + store: .stripe + ) return CustomerInfo( - subscriptions: valid.subscriptions + [legacy], + subscriptions: valid.subscriptions + [legacy, web], nonSubscriptions: [], entitlements: valid.entitlements + [legacyEntitlement] ) } + /// A lifetime purchase: active, no expiry, `isLifetime` set. + private func savedLifetimeCustomerInfo() -> CustomerInfo { + let pro = Entitlement( + id: "pro", + isActive: true, + productIds: [Self.silverProductId], + latestProductId: Self.silverProductId, + store: .appStore, + expiresAt: nil, + isLifetime: true + ) + return CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: [pro]) + } + /// Builds a config manager whose receipt loading takes `loadDelay` seconds /// on the first call only, so the background refresh that follows does not /// keep the test alive. `savedCustomerInfo` stands in for what the previous @@ -286,9 +317,11 @@ struct ConfigManagerEarlyPublishTests { _ = await waitForConfig(harness.configManager, timeout: 1.5) #expect(!harness.receipt.didFinishLoad, "test needs config to be published mid-load") - // The still-valid silver keeps the fast path, but legacy is past its expiry. + // The still-valid silver keeps the fast path, but legacy is past its expiry + // and the web row isn't a device purchase. #expect(await harness.receiptManager.getActiveProductIds() == [Self.silverProductId]) #expect(await harness.receiptManager.isSubscribed(to: Self.legacyProductId) == false) + #expect(await harness.receiptManager.isSubscribed(to: Self.webProductId) == false) let legacyEntitlements = Superwall.shared.entitlements.byProductId(Self.legacyProductId) #expect(legacyEntitlements.first?.isActive == false) #expect(legacyEntitlements.first?.willRenew == true, "the rest of the saved copy carries over") @@ -322,7 +355,25 @@ struct ConfigManagerEarlyPublishTests { await settle() } - @Test("Saved entitlement expired: purchases still load before config is published") + @Test("A lifetime purchase has no expiry and takes the fast path") + func publishesEarlyForLifetimePurchase() async { + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: savedLifetimeCustomerInfo(), + loadDelay: 2 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + let waited = await waitForConfig(harness.configManager, timeout: 1.5) + + #expect(harness.configManager.config != nil) + #expect(waited < 1, "config took \(waited)s but StoreKit was still loading") + + await fetch.value + await settle() + } + + @Test("Saved entitlement still flagged active but past its expiry: config waits") func waitsWhenSavedEntitlementHasExpired() async { let harness = makeHarness( isSubscribed: true, @@ -341,8 +392,10 @@ struct ConfigManagerEarlyPublishTests { await settle() } - @Test("An active status on disk with no saved customer info is not enough") - func waitsWithoutSavedCustomerInfo() async { + @Test("An active status on disk with a blank saved customer info is not enough") + func waitsWithBlankSavedCustomerInfo() async { + // `Superwall.init` persists `.blank()` before the first read, so this is + // the real first-launch shape; the harness stores it for a nil input. let harness = makeHarness( isSubscribed: true, savedCustomerInfo: nil, From 4176c1411bf1d42ff96084ed905a67cb8f3185b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:49:26 +0200 Subject: [PATCH 097/162] Test the fast path for a web-only subscriber A Stripe subscriber's saved entitlement carries its own expiry, so the gate admits them like an App Store one. Their device purchases stay empty during the window, as they are after the read, while the entitlement map carries the web entitlement. Co-Authored-By: Claude Fable 5.1 --- .../ConfigManagerEarlyPublishTests.swift | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift index bdab0c5fd..cb1d4676c 100644 --- a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift +++ b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift @@ -118,6 +118,34 @@ struct ConfigManagerEarlyPublishTests { ) } + /// A web-only subscriber: one Stripe subscription unlocking `pro` on the + /// silver product, no App Store purchases at all. + private func savedWebOnlyCustomerInfo() -> CustomerInfo { + let expiresAt = Date().addingTimeInterval(3600) + let web = SubscriptionTransaction( + transactionId: "web-1", + productId: Self.silverProductId, + purchaseDate: Date().addingTimeInterval(-600), + willRenew: true, + isRevoked: false, + isInGracePeriod: false, + isInBillingRetryPeriod: false, + isActive: true, + expirationDate: expiresAt, + store: .stripe + ) + let pro = Entitlement( + id: "pro", + isActive: true, + productIds: [Self.silverProductId], + latestProductId: Self.silverProductId, + store: .stripe, + expiresAt: expiresAt, + willRenew: true + ) + return CustomerInfo(subscriptions: [web], nonSubscriptions: [], entitlements: [pro]) + } + /// A lifetime purchase: active, no expiry, `isLifetime` set. private func savedLifetimeCustomerInfo() -> CustomerInfo { let pro = Entitlement( @@ -355,6 +383,32 @@ struct ConfigManagerEarlyPublishTests { await settle() } + @Test("A web-only subscriber takes the fast path with no device purchases seeded") + func publishesEarlyForWebOnlySubscriber() async { + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: savedWebOnlyCustomerInfo(), + loadDelay: 2 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + let waited = await waitForConfig(harness.configManager, timeout: 1.5) + + #expect(harness.configManager.config != nil) + #expect(waited < 1, "config took \(waited)s but StoreKit was still loading") + #expect(!harness.receipt.didFinishLoad, "test needs config to be published mid-load") + + // Device purchases stay empty, as they are after the read for a web + // subscriber, while the entitlement map carries the web entitlement. + #expect(await harness.receiptManager.getActiveProductIds().isEmpty) + let silverEntitlements = Superwall.shared.entitlements.byProductId(Self.silverProductId) + #expect(silverEntitlements.first?.isActive == true) + #expect(silverEntitlements.first?.store == .stripe) + + await fetch.value + await settle() + } + @Test("A lifetime purchase has no expiry and takes the fast path") func publishesEarlyForLifetimePurchase() async { let harness = makeHarness( From 0ac31c45985bd581f812ae4097c64d2401eeaa53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:53:38 +0200 Subject: [PATCH 098/162] Take the fast path for developer-granted entitlements A granted entitlement is the developer's own verdict. The StoreKit read merges it back in on every load, so nothing it learns can change it, and waiting for the read bought nothing. An active grant now admits the fast path on its own, regardless of the expiry the developer did or didn't set. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- .../SuperwallKit/Config/ConfigManager.swift | 5 +++++ .../ConfigManagerEarlyPublishTests.swift | 21 +++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2099553d8..73ac89c96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes duplicate device attribute and subscription status change events being tracked when the subscription status is repeatedly set to the same logical state. As part of this, `subscriptionStatusDidChange` now fires only when the logical status changes — the status case, the set of entitlements, or an entitlement's `isActive` flag. Updates to transaction metadata such as expiry dates or renewal state no longer trigger it; use `customerInfoDidChange` for those. - Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. - Fixes slow cold launches for subscribers on a weak network by no longer fetching their purchased products from StoreKit before the SDK is ready. Applies to StoreKit 2. -- Fixes `register` calls stalling at cold launch for subscribers on a weak network. When the saved subscription is still within its expiry, the SDK is now ready before it reads purchases from StoreKit. +- Fixes `register` calls stalling at cold launch for subscribers on a weak network. When the saved entitlements show the user is still subscribed, the SDK is now ready before it reads purchases from StoreKit. - Fixes a data race during SDK configuration that Thread Sanitizer flagged on every launch. - Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them. - Fixes audiences matching users they shouldn't when you use a Purchase Controller. diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index f791d01c1..00cb45226 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -251,6 +251,11 @@ class ConfigManager { guard let customerInfo = storage.get(LatestCustomerInfo.self) else { return nil } + // A developer-granted entitlement is the developer's own verdict. The read + // merges it back in on every load, so nothing it learns can change it. + if entitlementsInfo.granted.contains(where: { $0.isActive }) { + return customerInfo + } // A lifetime purchase has no expiry to check and can only be refunded, // which is the same risk the expiry case already accepts. let isStillEntitled = customerInfo.entitlements.contains { diff --git a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift index cb1d4676c..ca987ec88 100644 --- a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift +++ b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift @@ -409,6 +409,27 @@ struct ConfigManagerEarlyPublishTests { await settle() } + @Test("A developer-granted entitlement takes the fast path with nothing else saved") + func publishesEarlyForGrantedEntitlement() async { + // Granted entitlements carry whatever the developer set; usually no expiry. + dependencyContainer.entitlementsInfo.setGranted([Entitlement(id: "pro")]) + defer { dependencyContainer.entitlementsInfo.setGranted([]) } + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: nil, + loadDelay: 2 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + let waited = await waitForConfig(harness.configManager, timeout: 1.5) + + #expect(harness.configManager.config != nil) + #expect(waited < 1, "config took \(waited)s but StoreKit was still loading") + + await fetch.value + await settle() + } + @Test("A lifetime purchase has no expiry and takes the fast path") func publishesEarlyForLifetimePurchase() async { let harness = makeHarness( From a5dac2f2bb2831240052994c15560a32fd218f7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:01:36 +0200 Subject: [PATCH 099/162] Seed consumables inactive and scope the fast path to StoreKit 2 The read marks a purchase with no expiry active only when it is a non-consumable, but the restore seeded every non-revoked non-subscription active, so consumables showed up in activeProducts for the length of the window and vanished when the read landed. The restore now applies the same rule. The fast path is StoreKit 2 only. A StoreKit 1 receipt is parsed locally, so its read isn't the slow one, and it saves no device rows, so there would be nothing to restore from and activeProducts would sit empty for the whole window. That is the gap this PR exists to avoid. The lifetime fixture now carries the non-consumable row a real lifetime purchaser would have saved, plus a consumable, and the test asserts the restored products mid-read instead of only that config published early. Co-Authored-By: Claude Fable 5.1 --- .../SuperwallKit/Config/ConfigManager.swift | 6 +++ .../ReceiptManager+Restore.swift | 14 ++++-- .../ConfigManagerEarlyPublishTests.swift | 49 +++++++++++++++++-- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index 00cb45226..9c951b812 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -248,6 +248,12 @@ class ConfigManager { /// changes nothing here: the read never sets the status in that setup, and /// the saved entitlements it preserves carry the same expiry. private func savedCustomerInfoForEarlyPublish() -> CustomerInfo? { + // StoreKit 2 only: its read is the slow one, and it is the only one that + // saves the device rows the restore rebuilds from. A StoreKit 1 receipt is + // parsed locally and saves no rows, so there would be nothing to restore. + guard #available(iOS 15.0, *), options.storeKitVersion == .storeKit2 else { + return nil + } guard let customerInfo = storage.get(LatestCustomerInfo.self) else { return nil } diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift index bcf2ea872..20fd8ba05 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift @@ -18,8 +18,9 @@ extension ReceiptManager { func restorePurchases(from customerInfo: CustomerInfo, config: Config) async { let now = Date() // The saved copy is the merged one, with web subscriptions appended. The - // purchases the read produces are device-only, so only App Store rows are - // seeded; otherwise `activeProducts` would change shape when the read lands. + // purchases the StoreKit 2 read produces are device-only, so only App Store + // rows are seeded; otherwise `activeProducts` would change shape when the + // read lands. (The fast path is StoreKit 2 only; see `ConfigManager`.) let activeSubscriptions = customerInfo.subscriptions.filter { $0.store == .appStore && $0.isActive && !hasExpired($0.expirationDate, at: now) } @@ -33,9 +34,16 @@ extension ReceiptManager { purchaseDate: $0.purchaseDate ) } + // Same rule as the read: with no expiry, only a non-consumable stays active. let nonSubscriptionPurchases = customerInfo.nonSubscriptions .filter { $0.store == .appStore } - .map { Purchase(id: $0.productId, isActive: !$0.isRevoked, purchaseDate: $0.purchaseDate) } + .map { + Purchase( + id: $0.productId, + isActive: !$0.isRevoked && !$0.isConsumable, + purchaseDate: $0.purchaseDate + ) + } let purchases = Set(subscriptionPurchases + nonSubscriptionPurchases) await manager.seedPurchases(purchases) diff --git a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift index ca987ec88..f1de0172c 100644 --- a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift +++ b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift @@ -40,6 +40,7 @@ struct ConfigManagerEarlyPublishTests { /// A second product in its own group, unlocking a separate entitlement. private static let legacyProductId = "com.app.legacy" private static let webProductId = "com.app.web" + private static let coinsProductId = "com.app.coins" /// Customer info the way the previous launch would have saved it: one /// subscription in `group_A` unlocking `pro`, expiring `expiresIn` from now. @@ -146,8 +147,23 @@ struct ConfigManagerEarlyPublishTests { return CustomerInfo(subscriptions: [web], nonSubscriptions: [], entitlements: [pro]) } - /// A lifetime purchase: active, no expiry, `isLifetime` set. + /// A lifetime purchase: the non-consumable row that unlocks `pro` with no + /// expiry and `isLifetime` set, plus a consumable the user also bought. private func savedLifetimeCustomerInfo() -> CustomerInfo { + let lifetime = NonSubscriptionTransaction( + transactionId: "life-1", + productId: Self.silverProductId, + purchaseDate: Date().addingTimeInterval(-86_400), + isConsumable: false, + isRevoked: false + ) + let coins = NonSubscriptionTransaction( + transactionId: "coins-1", + productId: Self.coinsProductId, + purchaseDate: Date().addingTimeInterval(-600), + isConsumable: true, + isRevoked: false + ) let pro = Entitlement( id: "pro", isActive: true, @@ -157,7 +173,7 @@ struct ConfigManagerEarlyPublishTests { expiresAt: nil, isLifetime: true ) - return CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: [pro]) + return CustomerInfo(subscriptions: [], nonSubscriptions: [lifetime, coins], entitlements: [pro]) } /// Builds a config manager whose receipt loading takes `loadDelay` seconds @@ -166,6 +182,7 @@ struct ConfigManagerEarlyPublishTests { /// launch wrote to disk. private func makeHarness( container: DependencyContainer? = nil, + storeKitVersion: SuperwallOptions.StoreKitVersion = .storeKit2, isSubscribed: Bool, savedCustomerInfo: CustomerInfo?, loadDelay: TimeInterval @@ -242,8 +259,10 @@ struct ConfigManagerEarlyPublishTests { .setting(\.buildId, to: "fresh_456") network.configReturnValue = .success(newConfig) + let options = SuperwallOptions() + options.storeKitVersion = storeKitVersion let configManager = ConfigManager( - options: SuperwallOptions(), + options: options, storeKitManager: dependencyContainer.storeKitManager, storage: storage, network: network, @@ -443,11 +462,35 @@ struct ConfigManagerEarlyPublishTests { #expect(harness.configManager.config != nil) #expect(waited < 1, "config took \(waited)s but StoreKit was still loading") + #expect(!harness.receipt.didFinishLoad, "test needs config to be published mid-load") + + // The non-consumable is active, the consumable isn't, same as after the read. + #expect(await harness.receiptManager.getActiveProductIds() == [Self.silverProductId]) + #expect(await harness.receiptManager.isSubscribed(to: Self.coinsProductId) == false) await fetch.value await settle() } + @Test("StoreKit 1 has no saved device rows to restore from, so config waits") + func waitsOnStoreKit1() async { + let harness = makeHarness( + storeKitVersion: .storeKit1, + isSubscribed: true, + savedCustomerInfo: savedCustomerInfo(expiresIn: 3600), + loadDelay: 1 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + _ = await waitForConfig(harness.configManager, timeout: 0.5) + + #expect(harness.configManager.config == nil) + + await fetch.value + #expect(harness.configManager.config != nil) + await settle() + } + @Test("Saved entitlement still flagged active but past its expiry: config waits") func waitsWhenSavedEntitlementHasExpired() async { let harness = makeHarness( From 9ece061bad419eacade45a601f6ed50c575884cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:14:18 +0200 Subject: [PATCH 100/162] Say the early publish applies to StoreKit 2 in the changelog Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73ac89c96..f53b36963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes duplicate device attribute and subscription status change events being tracked when the subscription status is repeatedly set to the same logical state. As part of this, `subscriptionStatusDidChange` now fires only when the logical status changes — the status case, the set of entitlements, or an entitlement's `isActive` flag. Updates to transaction metadata such as expiry dates or renewal state no longer trigger it; use `customerInfoDidChange` for those. - Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. - Fixes slow cold launches for subscribers on a weak network by no longer fetching their purchased products from StoreKit before the SDK is ready. Applies to StoreKit 2. -- Fixes `register` calls stalling at cold launch for subscribers on a weak network. When the saved entitlements show the user is still subscribed, the SDK is now ready before it reads purchases from StoreKit. +- Fixes `register` calls stalling at cold launch for subscribers on a weak network. When the saved entitlements show the user is still subscribed, the SDK is now ready before it reads purchases from StoreKit. Applies to StoreKit 2. - Fixes a data race during SDK configuration that Thread Sanitizer flagged on every launch. - Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them. - Fixes audiences matching users they shouldn't when you use a Purchase Controller. From 6ff9697f6c0bc4cd43a31be2bf49ad3514f0db32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:02:19 +0200 Subject: [PATCH 101/162] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f53b36963..d78364687 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes duplicate device attribute and subscription status change events being tracked when the subscription status is repeatedly set to the same logical state. As part of this, `subscriptionStatusDidChange` now fires only when the logical status changes — the status case, the set of entitlements, or an entitlement's `isActive` flag. Updates to transaction metadata such as expiry dates or renewal state no longer trigger it; use `customerInfoDidChange` for those. - Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. - Fixes slow cold launches for subscribers on a weak network by no longer fetching their purchased products from StoreKit before the SDK is ready. Applies to StoreKit 2. -- Fixes `register` calls stalling at cold launch for subscribers on a weak network. When the saved entitlements show the user is still subscribed, the SDK is now ready before it reads purchases from StoreKit. Applies to StoreKit 2. +- Fixes `register` calls stalling at cold launch for subscribers on a weak network. - Fixes a data race during SDK configuration that Thread Sanitizer flagged on every launch. - Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them. - Fixes audiences matching users they shouldn't when you use a Purchase Controller. From 1fab1de72d1401556e322f4d0e63f91ec1d888c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:16:41 +0200 Subject: [PATCH 102/162] Wait for the purchases load only where the answer depends on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wait sat in front of `receiptManager.isFreeTrialAvailable`, so a paywall was held for the whole StoreKit read even when the answer couldn't change: a product with no subscription group, or a customer who is already intro-ineligible, both return at the first guard without ever reading the active subscription groups. For the cohort this PR targets — subscribers, who have usually consumed their intro — that was the common case, and it put the stall back on paywall presentation and the buy button. The load is now passed in and awaited in the one branch that reads the groups. `initialPurchasesLoad` is also read from the purchase path, which has no ordering against the config publish: `Superwall.shared.purchase(_:)` goes straight to `prepareToPurchase` at launch. It's a plain var on a non-isolated class, so that read raced the write on the config fetch. It's behind a queue now, the way `enqueuedIntegrationAttributes` is. Co-Authored-By: Claude Opus 5 --- .../SuperwallKit/Config/ConfigManager.swift | 18 +++++- .../Dependencies/DependencyContainer.swift | 22 ++++--- .../Dependencies/FactoryProtocols.swift | 4 +- .../Receipt Manager/ReceiptManager.swift | 17 +++-- .../Transactions/TransactionManager.swift | 6 +- .../ReceiptManagerTrialEligibilityTests.swift | 62 +++++++++++++++++++ 6 files changed, 109 insertions(+), 20 deletions(-) diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index 9c951b812..daba7ee2a 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -39,7 +39,21 @@ class ConfigManager { /// of, when the saved customer info proved the user was still entitled. Trial /// eligibility waits on it so an upgrade during the load still sees the active /// subscription groups. - private(set) var initialPurchasesLoad: Task? + /// + /// Protected by a queue: it's written on the config fetch and read from the + /// purchase path, which doesn't wait for config. + private var _initialPurchasesLoad: Task? + private let initialPurchasesLoadQueue = DispatchQueue( + label: "com.superwall.initialPurchasesLoad" + ) + + var initialPurchasesLoad: Task? { + initialPurchasesLoadQueue.sync { _initialPurchasesLoad } + } + + private func setInitialPurchasesLoad(_ task: Task) { + initialPurchasesLoadQueue.sync { _initialPurchasesLoad = task } + } private unowned let storeKitManager: StoreKitManager unowned let storage: Storage @@ -491,7 +505,7 @@ class ConfigManager { let purchasesLoad = Task { [factory] in await factory.loadPurchasedProducts(config: config) } - initialPurchasesLoad = purchasesLoad + setInitialPurchasesLoad(purchasesLoad) configState.send(.retrieved(config)) didPublishConfig = true await purchasesLoad.value diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index 1e17a623a..30086229c 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -602,12 +602,12 @@ extension DependencyContainer: ReceiptFactory { await receiptManager.restorePurchases(from: customerInfo, config: config) } - func waitForInitialPurchasesLoad() async { - // nil means the load already finished (or config was never published - // early). It is also nil for the moment before `processConfig` stores the - // task; a purchase started that early skips the wait, which only affects - // whether the transaction is reported as a trial start. - await configManager.initialPurchasesLoad?.value + /// nil means the load already finished, or config was never published early. + /// It is also nil for the moment before `processConfig` stores the task; a + /// purchase started that early skips the wait, which only affects whether the + /// transaction is reported as a trial start. + var initialPurchasesLoad: Task? { + configManager.initialPurchasesLoad } func refreshSK1Receipt() async { @@ -627,10 +627,12 @@ extension DependencyContainer: ReceiptFactory { } } // Config can be published before the first purchases load finishes (see - // `ConfigManager.fetchConfiguration`). The active subscription groups that - // gate upgrades come from that load, so wait for it. - await waitForInitialPurchasesLoad() - return await receiptManager.isFreeTrialAvailable(for: product) + // `ConfigManager.fetchConfiguration`). Only the upgrade check inside reads + // what that load computes, so it waits there rather than here. + return await receiptManager.isFreeTrialAvailable( + for: product, + waitingFor: initialPurchasesLoad + ) } var isTestMode: Bool { diff --git a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift index 914b53354..7e07a6915 100644 --- a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift +++ b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift @@ -158,8 +158,8 @@ protocol UserAttributesPlacementFactory: AnyObject { protocol ReceiptFactory: AnyObject { func loadPurchasedProducts(config: Config?) async func restorePurchases(from customerInfo: CustomerInfo, config: Config) async - /// Waits for the purchases load that config was published ahead of, if any. - func waitForInitialPurchasesLoad() async + /// The purchases load that config was published ahead of, if any. + var initialPurchasesLoad: Task? { get } func refreshSK1Receipt() async func isFreeTrialAvailable(for product: StoreProduct) async -> Bool var isTestMode: Bool { get } diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift index 8e7e64626..c86d633ba 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift @@ -244,7 +244,13 @@ actor ReceiptManager { /// *consumed* an intro in the group): Apple doesn't apply intro offers to upgrades, crossgrades, /// or downgrades, so we also require no active subscription in the product's group. Once the /// existing subscription lapses, a fresh purchase is eligible again. - func isFreeTrialAvailable(for storeProduct: StoreProduct) async -> Bool { + /// - Parameter purchasesLoad: The load that config was published ahead of, if + /// any. Awaited only in the branch that reads the active subscription groups, + /// so a paywall whose answer can't depend on the load isn't held up by it. + func isFreeTrialAvailable( + for storeProduct: StoreProduct, + waitingFor purchasesLoad: Task? = nil + ) async -> Bool { let isEligibleForIntroOffer = await manager.isEligibleForIntroOffer(storeProduct) if !isEligibleForIntroOffer { return false @@ -256,9 +262,12 @@ actor ReceiptManager { return true } - // `activeSubscriptionGroupIds` is populated in `loadPurchasedProducts`, which always - // completes before a paywall opens (config is only marked retrieved after it runs, - // and presentation waits for config), so this reflects current subscription state. + // `activeSubscriptionGroupIds` is populated in `loadPurchasedProducts`. On the + // sync config path that load completes before config is published. On the + // early-publish path it's still running, so wait for it here. The actor is + // free during that wait, which is what lets the load finish. + await purchasesLoad?.value + return !activeSubscriptionGroupIds.contains(subscriptionGroupId) } diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index cc25d5eb1..f39e2bd57 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -734,8 +734,10 @@ final class TransactionManager { // Same wait as `DependencyContainer.isFreeTrialAvailable`: the buy button // can be tapped while the first purchases load is still running. - await factory.waitForInitialPurchasesLoad() - return await receiptManager.isFreeTrialAvailable(for: product) + return await receiptManager.isFreeTrialAvailable( + for: product, + waitingFor: factory.initialPurchasesLoad + ) } /// Custom products don't have StoreKit intro-offer state, so use entitlement history diff --git a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift index 3e1ebcb06..5037e54e0 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift @@ -110,6 +110,68 @@ struct ReceiptManagerTrialEligibilityTests { #expect(await manager.isFreeTrialAvailable(for: gold) == true) } + /// Stands in for the purchases load config was published ahead of. + private func slowPurchasesLoad(seconds: TimeInterval) -> Task { + return Task { + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + } + } + + @Test("A product with no subscription group answers without waiting for the load") + func doesNotWaitForLoadWithoutSubscriptionGroup() async { + let (manager, productsManager) = makeReceiptManager( + isEligibleForIntroOffer: true, + activeSubscriptionGroupIds: [] + ) + _ = productsManager + let consumable = makeProduct(id: "com.app.coins", subscriptionGroup: nil) + let load = slowPurchasesLoad(seconds: 2) + + let startedAt = Date() + let isAvailable = await manager.isFreeTrialAvailable(for: consumable, waitingFor: load) + let waited = Date().timeIntervalSince(startedAt) + + #expect(isAvailable == true) + #expect(waited < 1, "waited \(waited)s on a load that can't change the answer") + load.cancel() + } + + @Test("An intro-ineligible customer answers without waiting for the load") + func doesNotWaitForLoadWhenIntroIneligible() async { + let (manager, productsManager) = makeReceiptManager( + isEligibleForIntroOffer: false, + activeSubscriptionGroupIds: [] + ) + _ = productsManager + let gold = makeProduct(id: "com.app.gold", subscriptionGroup: "group_A") + let load = slowPurchasesLoad(seconds: 2) + + let startedAt = Date() + let isAvailable = await manager.isFreeTrialAvailable(for: gold, waitingFor: load) + let waited = Date().timeIntervalSince(startedAt) + + #expect(isAvailable == false) + #expect(waited < 1, "waited \(waited)s on a load that can't change the answer") + load.cancel() + } + + @Test("The upgrade check waits for the load that computes the active groups") + func waitsForLoadBeforeCheckingActiveGroups() async { + let (manager, productsManager) = makeReceiptManager( + isEligibleForIntroOffer: true, + activeSubscriptionGroupIds: [] + ) + _ = productsManager + let gold = makeProduct(id: "com.app.gold", subscriptionGroup: "group_A") + let load = slowPurchasesLoad(seconds: 0.5) + + let startedAt = Date() + _ = await manager.isFreeTrialAvailable(for: gold, waitingFor: load) + let waited = Date().timeIntervalSince(startedAt) + + #expect(waited >= 0.5, "answered from active groups the load hadn't computed yet") + } + @Test("No trial when StoreKit reports the customer is intro-ineligible") func noTrialWhenIneligible() async { // Ineligible short-circuits before the active-subscription check. From 7a9281152b7a7627cdd2b00db354c95c84f73b97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:16:41 +0200 Subject: [PATCH 103/162] Carry grants into the restore, and don't restore an undated subscription Every load merges the developer's granted entitlements back in, but the restore built the map from config and the saved copy alone. A grant made since the last launch, which the saved copy predates, looked inactive until the read landed. The restore carries grants now, and the developer's verdict wins over the saved copy. A subscription saved with no expiry at all can't be shown to be current, and the read doesn't count one as active either, so it's no longer restored active. A nil expiry still means lifetime for entitlements, which is a different question. Also says the changelog entry applies to StoreKit 2, which is where the fast path is gated. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- .../ReceiptManager+Restore.swift | 24 ++++++--- .../ConfigManagerEarlyPublishTests.swift | 50 +++++++++++++++++++ 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d78364687..7d3dfc99f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes duplicate device attribute and subscription status change events being tracked when the subscription status is repeatedly set to the same logical state. As part of this, `subscriptionStatusDidChange` now fires only when the logical status changes — the status case, the set of entitlements, or an entitlement's `isActive` flag. Updates to transaction metadata such as expiry dates or renewal state no longer trigger it; use `customerInfoDidChange` for those. - Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. - Fixes slow cold launches for subscribers on a weak network by no longer fetching their purchased products from StoreKit before the SDK is ready. Applies to StoreKit 2. -- Fixes `register` calls stalling at cold launch for subscribers on a weak network. +- Fixes `register` calls stalling at cold launch for subscribers on a weak network. Applies to StoreKit 2. - Fixes a data race during SDK configuration that Thread Sanitizer flagged on every launch. - Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them. - Fixes audiences matching users they shouldn't when you use a Purchase Controller. diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift index 20fd8ba05..f2a570f89 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift @@ -21,8 +21,14 @@ extension ReceiptManager { // purchases the StoreKit 2 read produces are device-only, so only App Store // rows are seeded; otherwise `activeProducts` would change shape when the // read lands. (The fast path is StoreKit 2 only; see `ConfigManager`.) + // + // A subscription with no expiry can't be shown to be current, and the read + // doesn't count one as active either, so it isn't restored active. let activeSubscriptions = customerInfo.subscriptions.filter { - $0.store == .appStore && $0.isActive && !hasExpired($0.expirationDate, at: now) + guard let expiresAt = $0.expirationDate else { + return false + } + return $0.store == .appStore && $0.isActive && expiresAt > now } let activeTransactionIds = Set(activeSubscriptions.map { $0.transactionId }) let subscriptionPurchases = customerInfo.subscriptions @@ -50,12 +56,16 @@ extension ReceiptManager { // Config knows every product and the entitlements it unlocks. The saved // customer info knows which of those were active, and carries fields like // willRenew that audience filters read, so its copy wins where both have one. - let savedById = Dictionary( - customerInfo.entitlements.map { entitlement in - let lapsed = entitlement.isActive && hasExpired(entitlement.expiresAt, at: now) - return (entitlement.id, lapsed ? deactivated(entitlement) : entitlement) - } - ) { $1 } + let saved = customerInfo.entitlements.map { entitlement in + let lapsed = entitlement.isActive && hasExpired(entitlement.expiresAt, at: now) + return (entitlement.id, lapsed ? deactivated(entitlement) : entitlement) + } + // Every load merges the developer's grants back in, so the restore carries + // them too. Otherwise a grant made since the last launch, which the saved + // copy predates, would look inactive until the read lands. The developer's + // own verdict wins over the saved copy. + let granted = Superwall.shared.entitlements.granted.map { ($0.id, $0) } + let savedById = Dictionary(saved + granted) { $1 } let entitlementsByProductId = ConfigLogic.extractEntitlements(from: config) .mapValues { Set($0.map { savedById[$0.id] ?? $0 }) } Superwall.shared.entitlements.setEntitlementsFromConfig(entitlementsByProductId) diff --git a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift index f1de0172c..fdfe35a88 100644 --- a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift +++ b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift @@ -147,6 +147,29 @@ struct ConfigManagerEarlyPublishTests { return CustomerInfo(subscriptions: [web], nonSubscriptions: [], entitlements: [pro]) } + /// A valid subscription plus one saved active with no expiry at all, which + /// nothing can show is still current. + private func savedCustomerInfoWithUndatedSubscription() -> CustomerInfo { + let valid = savedCustomerInfo(expiresIn: 3600) + let undated = SubscriptionTransaction( + transactionId: "4", + productId: Self.legacyProductId, + purchaseDate: Date().addingTimeInterval(-7200), + willRenew: false, + isRevoked: false, + isInGracePeriod: false, + isInBillingRetryPeriod: false, + isActive: true, + expirationDate: nil, + subscriptionGroupId: "group_B" + ) + return CustomerInfo( + subscriptions: valid.subscriptions + [undated], + nonSubscriptions: [], + entitlements: valid.entitlements + ) + } + /// A lifetime purchase: the non-consumable row that unlocks `pro` with no /// expiry and `isLifetime` set, plus a consumable the user also bought. private func savedLifetimeCustomerInfo() -> CustomerInfo { @@ -444,6 +467,33 @@ struct ConfigManagerEarlyPublishTests { #expect(harness.configManager.config != nil) #expect(waited < 1, "config took \(waited)s but StoreKit was still loading") + #expect(!harness.receipt.didFinishLoad, "test needs config to be published mid-load") + + // Every load merges grants back in, so the restore has to carry them too. + let silverEntitlements = Superwall.shared.entitlements.byProductId(Self.silverProductId) + #expect(silverEntitlements.first?.id == "pro") + #expect(silverEntitlements.first?.isActive == true) + + await fetch.value + await settle() + } + + @Test("A subscription saved with no expiry is not restored active") + func doesNotRestoreUndatedSubscriptionAsActive() async { + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: savedCustomerInfoWithUndatedSubscription(), + loadDelay: 2 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + _ = await waitForConfig(harness.configManager, timeout: 1.5) + #expect(!harness.receipt.didFinishLoad, "test needs config to be published mid-load") + + // Only the dated, unexpired silver is active. The undated row can't be + // shown to be current, and the read wouldn't count it either. + #expect(await harness.receiptManager.getActiveProductIds() == [Self.silverProductId]) + #expect(await harness.receiptManager.isSubscribed(to: Self.legacyProductId) == false) await fetch.value await settle() From 5b1633547ec9d2915caaaab80349d84c5efb771f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:56:51 +0200 Subject: [PATCH 104/162] Merge grants in the caller, and match the read's merge rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The restore reached into `Superwall.shared.entitlements` for the grants, which is the same object as the injected one in an app but not in a test, so the grant-carry assertion couldn't be exercised. The merge moves to `savedCustomerInfoForEarlyPublish`, which already reads the injected `entitlementsInfo`, and uses `CustomerInfo.merging(with:granting:)` — the call every load already uses. That also fixes the precedence: last-wins let a grant blank the saved expiry and renewal fields when an id came from both, where `mergePrioritized` keeps the richer copy. The config entitlements in the tests are now built inactive, matching what a decoded config carries, so the grant assertion fails without the merge. Also stops the doc comment claiming a nil expiry always keeps its saved state, which stopped being true for subscriptions, and names the StoreKit 2 assumption that lets the trial wait sit after the intro-eligibility check. Co-Authored-By: Claude Opus 5 --- .../SuperwallKit/Config/ConfigManager.swift | 16 ++++++++++---- .../ReceiptManager+Restore.swift | 21 +++++++++---------- .../Receipt Manager/ReceiptManager.swift | 4 ++++ .../ConfigManagerEarlyPublishTests.swift | 11 ++++++++-- 4 files changed, 35 insertions(+), 17 deletions(-) diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index daba7ee2a..33d19f831 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -271,17 +271,25 @@ class ConfigManager { guard let customerInfo = storage.get(LatestCustomerInfo.self) else { return nil } + // Every load merges the developer's grants back in, so the copy handed to + // the restore carries them too. Otherwise a grant made since the last + // launch, which the saved copy predates, would look inactive until the read + // lands. `merging` is what the load uses, so where an id is in both the + // richer copy wins rather than the grant blanking the saved expiry. + let granted = entitlementsInfo.granted + let customerInfoWithGrants = customerInfo.merging(with: .blank(), granting: granted) + // A developer-granted entitlement is the developer's own verdict. The read // merges it back in on every load, so nothing it learns can change it. - if entitlementsInfo.granted.contains(where: { $0.isActive }) { - return customerInfo + if granted.contains(where: { $0.isActive }) { + return customerInfoWithGrants } // A lifetime purchase has no expiry to check and can only be refunded, // which is the same risk the expiry case already accepts. - let isStillEntitled = customerInfo.entitlements.contains { + let isStillEntitled = customerInfoWithGrants.entitlements.contains { $0.isActive && ($0.isLifetime == true || ($0.expiresAt ?? .distantPast) > Date()) } - return isStillEntitled ? customerInfo : nil + return isStillEntitled ? customerInfoWithGrants : nil } private struct ConfigFetchResult { diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift index f2a570f89..2be36c8a0 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift @@ -14,7 +14,8 @@ extension ReceiptManager { /// /// Each saved item carries its own expiry, which the store asserted, so /// anything past it has lapsed since the last launch and is restored inactive. - /// A nil expiry can't be shown to have lapsed, so it keeps its saved state. + /// A nil expiry keeps its saved state, except on a subscription: nothing can + /// show one is still current, so it isn't restored active. func restorePurchases(from customerInfo: CustomerInfo, config: Config) async { let now = Date() // The saved copy is the merged one, with web subscriptions appended. The @@ -56,16 +57,14 @@ extension ReceiptManager { // Config knows every product and the entitlements it unlocks. The saved // customer info knows which of those were active, and carries fields like // willRenew that audience filters read, so its copy wins where both have one. - let saved = customerInfo.entitlements.map { entitlement in - let lapsed = entitlement.isActive && hasExpired(entitlement.expiresAt, at: now) - return (entitlement.id, lapsed ? deactivated(entitlement) : entitlement) - } - // Every load merges the developer's grants back in, so the restore carries - // them too. Otherwise a grant made since the last launch, which the saved - // copy predates, would look inactive until the read lands. The developer's - // own verdict wins over the saved copy. - let granted = Superwall.shared.entitlements.granted.map { ($0.id, $0) } - let savedById = Dictionary(saved + granted) { $1 } + // `savedCustomerInfoForEarlyPublish` has already merged the developer's + // grants into this copy, the same way every load merges them. + let savedById = Dictionary( + customerInfo.entitlements.map { entitlement in + let lapsed = entitlement.isActive && hasExpired(entitlement.expiresAt, at: now) + return (entitlement.id, lapsed ? deactivated(entitlement) : entitlement) + } + ) { $1 } let entitlementsByProductId = ConfigLogic.extractEntitlements(from: config) .mapValues { Set($0.map { savedById[$0.id] ?? $0 }) } Superwall.shared.entitlements.setEntitlementsFromConfig(entitlementsByProductId) diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift index c86d633ba..a0dd94efb 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift @@ -247,6 +247,10 @@ actor ReceiptManager { /// - Parameter purchasesLoad: The load that config was published ahead of, if /// any. Awaited only in the branch that reads the active subscription groups, /// so a paywall whose answer can't depend on the load isn't held up by it. + /// This is only non-nil on StoreKit 2 (see `ConfigManager`), which matters + /// for the placement: StoreKit 2 resolves intro eligibility live from Apple, + /// while StoreKit 1 answers it from state the load fills in, so on StoreKit 1 + /// the eligibility check above would have to wait too. func isFreeTrialAvailable( for storeProduct: StoreProduct, waitingFor purchasesLoad: Task? = nil diff --git a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift index fdfe35a88..39a0daf1a 100644 --- a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift +++ b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift @@ -250,13 +250,20 @@ struct ConfigManagerEarlyPublishTests { // The config knows both products and the entitlement they unlock; the // saved customer info says which is active. let products = [Self.silverProductId, Self.goldProductId].map { - Product(name: $0, type: .appStore(.init(id: $0)), id: $0, entitlements: [Entitlement(id: "pro")]) + // Decoded config entitlements default to inactive; only the saved copy + // or a grant can make one active. + Product( + name: $0, + type: .appStore(.init(id: $0)), + id: $0, + entitlements: [Entitlement(id: "pro", isActive: false)] + ) } + [ Product( name: Self.legacyProductId, type: .appStore(.init(id: Self.legacyProductId)), id: Self.legacyProductId, - entitlements: [Entitlement(id: "legacy")] + entitlements: [Entitlement(id: "legacy", isActive: false)] ) ] let cachedConfig: Config = .stub() From 991eebc63be69b5f0bc7a50c74b9d387e704d13c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:06:51 +0200 Subject: [PATCH 105/162] Address the review on the device-attribute refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate the user-attribute sync on the device snapshot actually changing, so a no-op foreground no longer costs a user_attributes event, a delegate callback and a Core Data write. Reset clears the user's attributes without going through the fetcher, so send them again from there. Latch DeviceHelper.vendorId on the first non-empty read instead of reading it once at init, so an app launched before first unlock picks the IDFV up later — which is what the activation refresh claimed to do. Stop gating the IDFA on an authorized ATT status: it's readable before iOS 14.5 while the status still says notDetermined, a build that can't resolve ATTrackingManager reports that same status, and the all-zero id is already filtered out. Narrow the test seam to the vendor id, ATT status and IDFA so the snapshot assembly is covered, and pin the sync count. Drop the unused appTransactionId parameter, and say in the changelog that the SDK owns idfv, idfa and attStatus. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- CLAUDE.md | 6 +- .../Attribution/AttributionFetcher.swift | 95 +++++++---- .../Network/Device Helper/DeviceHelper.swift | 26 ++- Sources/SuperwallKit/Superwall.swift | 16 +- .../AttributionDeviceAttributesTests.swift | 153 +++++++++++++++--- 6 files changed, 235 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7ebe7501..9a29efdec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Fixes -- Refreshes IDFV, IDFA and ATT status for integrations when the app becomes active or integration attributes are set again. Device identifiers now also sync to user attributes for server-side integrations such as AppsFlyer, and revoked consent clears the previous IDFA. +- Refreshes the IDFV, IDFA and tracking consent for integrations when the app becomes active or integration attributes are set again, and clears the IDFA when consent is revoked. These now also go into the user attributes `idfv`, `idfa` and `attStatus`, so server-side integrations such as AppsFlyer can read them. The SDK owns those three keys and will overwrite any value your app has set on them. - Fixes duplicate device attribute and subscription status change events being tracked when the subscription status is repeatedly set to the same logical state. As part of this, `subscriptionStatusDidChange` now fires only when the logical status changes — the status case, the set of entitlements, or an entitlement's `isActive` flag. Updates to transaction metadata such as expiry dates or renewal state no longer trigger it; use `customerInfoDidChange` for those. - Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. diff --git a/CLAUDE.md b/CLAUDE.md index d34fc1544..354d1d8f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,5 +120,9 @@ When creating PRs, always include the checklist from `.github/PULL_REQUEST_TEMPL AttributionFetcher refreshes IDFV/IDFA/ATT when setting integration attributes and on app activation after an integration has been configured. Compare the complete refreshed snapshot, not just provider IDs. Sync device values into user attributes -(the server integration router reads those); explicit nulls clear stale IDs after +(the server integration router reads those) only when that snapshot changes, since +every sync costs a `user_attributes` event; explicit nulls clear stale IDs after ATT revocation. ATT is serialized as a numeric string in integration attributes. +`idfv`, `idfa` and `attStatus` are SDK-owned user-attribute keys — document any +change to that set in the changelog and the online docs. Don't gate the IDFA on +the ATT status: `identifierForAdvertisers` already filters the all-zero id. diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift index fd39f1ab3..ae01dab33 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift @@ -20,9 +20,19 @@ final class AttributionFetcher { private let timerQueue = DispatchQueue(label: "com.superwall.attributionfetcher.timer") private var redeemTimer: DispatchSourceTimer? private var activeObserver: NSObjectProtocol? - private let deviceAttributesProvider: (() -> [String: String])? + private let vendorIdProvider: (() -> String)? + private let attStatusProvider: (() -> Int?)? + private let idfaProvider: (() -> String?)? private let syncDeviceAttributes: ([String: Any?]) -> Void private var _integrationAttributes: [String: String] = [:] + + /// The last device snapshot handed to `syncDeviceAttributes`. Only a change + /// is worth syncing: every sync costs a `user_attributes` event, a delegate + /// callback, a Core Data row and a re-encode of the whole attribute dict. + private var _lastSyncedDeviceAttributes: [String: String]? + + /// The device keys the SDK owns in both integration and user attributes. + private static let deviceAttributeKeys = ["idfa", "idfv", "attStatus"] private unowned let storage: Storage private unowned let webEntitlementRedeemer: WebEntitlementRedeemer private unowned let deviceHelper: DeviceHelper @@ -139,13 +149,17 @@ final class AttributionFetcher { storage: Storage, deviceHelper: DeviceHelper, webEntitlementRedeemer: WebEntitlementRedeemer, - deviceAttributesProvider: (() -> [String: String])? = nil, + vendorIdProvider: (() -> String)? = nil, + attStatusProvider: (() -> Int?)? = nil, + idfaProvider: (() -> String?)? = nil, syncDeviceAttributes: @escaping ([String: Any?]) -> Void = { Superwall.shared.setUserAttributes($0) } ) { self.syncDeviceAttributes = syncDeviceAttributes - self.deviceAttributesProvider = deviceAttributesProvider + self.vendorIdProvider = vendorIdProvider + self.attStatusProvider = attStatusProvider + self.idfaProvider = idfaProvider self.storage = storage self.deviceHelper = deviceHelper self.webEntitlementRedeemer = webEntitlementRedeemer @@ -167,22 +181,39 @@ final class AttributionFetcher { } } - private var currentDeviceAttributes: [String: String] { - if let deviceAttributesProvider { - return deviceAttributesProvider() + /// The ATT authorization status, or `nil` where the OS has no such concept. + private var attStatus: Int? { + if let attStatusProvider { + return attStatusProvider() } - var attributes: [String: String] = [:] - let vendorId = deviceHelper.vendorId - if !vendorId.isEmpty { attributes["idfv"] = vendorId } #if os(iOS) || targetEnvironment(macCatalyst) || os(tvOS) || os(macOS) || os(visionOS) if #available(iOS 14, macCatalyst 14, tvOS 14, macOS 11, *) { - let status = TrackingManagerProxy().trackingAuthorizationStatus() - attributes["attStatus"] = String(status) - if status == 3 { attributes["idfa"] = identifierForAdvertisers } - } else { - attributes["idfa"] = identifierForAdvertisers + return TrackingManagerProxy().trackingAuthorizationStatus() } #endif + return nil + } + + private var currentDeviceAttributes: [String: String] { + var attributes: [String: String] = [:] + + let vendorId = vendorIdProvider?() ?? deviceHelper.vendorId + if !vendorId.isEmpty { + attributes["idfv"] = vendorId + } + + if let attStatus { + attributes["attStatus"] = String(attStatus) + } + + // Don't gate this on the ATT status. Before iOS 14.5 the IDFA is available + // while ATT still reads `notDetermined`, and `TrackingManagerProxy` returns + // `notDetermined` both for a genuine one and for a build where the class + // can't be found, so the status can't tell those apart. The OS hands back + // the all-zero id when it doesn't want to share one, and + // `identifierForAdvertisers` already filters that out. + attributes["idfa"] = idfaProvider?() ?? identifierForAdvertisers + return attributes } @@ -195,19 +226,25 @@ final class AttributionFetcher { } } + /// Sends the device identifiers again after a reset, which wipes the user's + /// attributes. Without this the sync's change check would see the same device + /// snapshot as before and leave the new user without them. + func resyncDeviceAttributes() { + queue.async { [weak self] in + guard let self, !self._integrationAttributes.isEmpty else { return } + self._lastSyncedDeviceAttributes = nil + _ = self._mergeIntegrationAttributes(attributes: [:]) + } + } + func setIntegrationAttribute( attribute: IntegrationAttribute, - value: String?, - appTransactionId: String + value: String? ) { - let attributes = [attribute.description: value] - mergeIntegrationAttributes(attributes: attributes, appTransactionId: appTransactionId) + mergeIntegrationAttributes(attributes: [attribute.description: value]) } - func mergeIntegrationAttributes( - attributes: [String: String?], - appTransactionId: String - ) { + func mergeIntegrationAttributes(attributes: [String: String?]) { queue.async { [weak self] in guard let self = self else { return } @@ -261,18 +298,20 @@ final class AttributionFetcher { mergedAttributes[key] = value } let device = currentDeviceAttributes - for key in ["idfa", "idfv", "attStatus"] { + for key in Self.deviceAttributeKeys { mergedAttributes[key] = device[key] } // The router reads user attributes, not the integration_attributes event. // Explicit nulls clear an IDFA retained from before consent was revoked. - // Sync even when identifiers are unchanged (e.g. after an identify/reset). - var userAttributes: [String: Any?] = [:] - for key in ["idfa", "idfv", "attStatus"] { - userAttributes[key] = device[key].map { $0 as Any } ?? NSNull() + if device != _lastSyncedDeviceAttributes { + _lastSyncedDeviceAttributes = device + var userAttributes: [String: Any?] = [:] + for key in Self.deviceAttributeKeys { + userAttributes[key] = device[key].map { $0 as Any } ?? NSNull() + } + syncDeviceAttributes(userAttributes) } - syncDeviceAttributes(userAttributes) guard mergedAttributes != _integrationAttributes else { return false } let updatedAttributes = mergedAttributes diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index 709957b94..3ae732e9e 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -64,9 +64,23 @@ class DeviceHelper { UIDevice.modelName }() - let vendorId: String = { - UIDevice.current.identifierForVendor?.uuidString ?? "" - }() + @DispatchQueueBacked + private var cachedVendorId = "" + + /// `identifierForVendor` is `nil` until the device has been unlocked once, so + /// an app launched in the background before first unlock would be stuck with + /// an empty id for the whole process if this were read only at init. Latch the + /// first non-empty read instead, so a later read picks the id up once it + /// exists and every read after that is a plain load. + var vendorId: String { + let cached = cachedVendorId + if !cached.isEmpty { + return cached + } + let vendorId = UIDevice.current.identifierForVendor?.uuidString ?? "" + cachedVendorId = vendorId + return vendorId + } var languageCode: String { if #available(iOS 16, *) { @@ -180,9 +194,9 @@ class DeviceHelper { /// Every appearance-adjacent read in this file — `UIScreen`, `UIFontMetrics`, /// trait collections — must sit behind this check, and the check must come /// first: even `UIFontMetrics.default.scaledValue(for:)` alone trips it. The - /// unguarded `UIDevice` reads at init (`model`, `vendorId`, `interfaceType`) - /// are exempt: they don't touch the trait system, and the sample-app repro - /// keeps its tint with them in place. + /// unguarded `UIDevice` reads (`model` and `interfaceType` at init, `vendorId` + /// on first use) are exempt: they don't touch the trait system, and the + /// sample-app repro keeps its tint with them in place. /// /// A missing application object doesn't always mean that window, though: some /// processes never create one (unit-test runners, app extensions) yet can read diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 78c12180a..90a9168ca 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -923,7 +923,7 @@ public final class Superwall: NSObject, ObservableObject { /// - Parameter props: A dictionary keyed by ``IntegrationAttribute`` specifying /// properties to associate with the user or events for the given provider. public func setIntegrationAttributes(_ props: [IntegrationAttribute: String?]) { - guard let appTransactionId = ReceiptManager.appTransactionId else { + guard ReceiptManager.appTransactionId != nil else { // Atomically merge with existing enqueued attributes mergeEnqueuedAttributes(props) return @@ -934,10 +934,7 @@ public final class Superwall: NSObject, ObservableObject { result[pair.key.description] = pair.value } - dependencyContainer.attributionFetcher.mergeIntegrationAttributes( - attributes: props, - appTransactionId: appTransactionId - ) + dependencyContainer.attributionFetcher.mergeIntegrationAttributes(attributes: props) setUserAttributes(props) } @@ -947,7 +944,7 @@ public final class Superwall: NSObject, ObservableObject { /// - attribute: The ``IntegrationAttribute`` key specifying the integration provider. /// - value: The value to associate with the attribute. Pass `nil` to remove the attribute. public func setIntegrationAttribute(_ attribute: IntegrationAttribute, _ value: String?) { - guard let appTransactionId = ReceiptManager.appTransactionId else { + guard ReceiptManager.appTransactionId != nil else { // Atomically merge with existing enqueued attributes mergeEnqueuedAttributes([attribute: value]) return @@ -956,8 +953,7 @@ public final class Superwall: NSObject, ObservableObject { dependencyContainer.attributionFetcher.setIntegrationAttribute( attribute: attribute, - value: value, - appTransactionId: appTransactionId + value: value ) setUserAttributes([attribute.description: value]) } @@ -1077,6 +1073,10 @@ public final class Superwall: NSObject, ObservableObject { // logout after that would otherwise leave the new user without attributes. dependencyContainer.mmpAttributionManager.reapplyCachedAcquisitionAttributes() + // The device identifiers are install-scoped too, and the reset just wiped + // them out of the user's attributes, so send them to the new user again. + dependencyContainer.attributionFetcher.resyncDeviceAttributes() + dependencyContainer.paywallManager.resetCache() presentationItems.reset() Task { diff --git a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift index c7cc55f1a..48ab8bc58 100644 --- a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift @@ -4,24 +4,47 @@ import Testing @Suite(.serialized) struct AttributionDeviceAttributesTests { - @Test func unchangedProviderRefreshesDeviceIdentifiersAndConsent() { - let container = DependencyContainer() - var device = ["idfv": "vendor-1", "idfa": "advertiser-1", "attStatus": "3"] - var syncedAttributes: [String: Any?] = [:] - let fetcher = AttributionFetcher( + private func makeFetcher( + container: DependencyContainer, + vendorId: @escaping () -> String = { "vendor-1" }, + attStatus: @escaping () -> Int? = { 3 }, + idfa: @escaping () -> String? = { "advertiser-1" }, + sync: @escaping ([String: Any?]) -> Void = { _ in } + ) -> AttributionFetcher { + return AttributionFetcher( storage: container.storage, deviceHelper: container.deviceHelper, webEntitlementRedeemer: container.webEntitlementRedeemer, - deviceAttributesProvider: { device }, - syncDeviceAttributes: { syncedAttributes = $0 } + vendorIdProvider: vendorId, + attStatusProvider: attStatus, + idfaProvider: idfa, + syncDeviceAttributes: sync + ) + } + + @Test func unchangedProviderRefreshesDeviceIdentifiersAndConsent() { + let container = DependencyContainer() + var status: Int? = 3 + var idfa: String? = "advertiser-1" + var vendorId = "vendor-1" + var syncedAttributes: [String: Any?] = [:] + let fetcher = makeFetcher( + container: container, + vendorId: { vendorId }, + attStatus: { status }, + idfa: { idfa }, + sync: { syncedAttributes = $0 } ) defer { fetcher.cancelPendingOperations() } - fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"], appTransactionId: "tx-1") + + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) #expect(fetcher.integrationAttributes["attStatus"] == "3") #expect(fetcher.integrationAttributes["idfa"] == "advertiser-1") - device = ["idfv": "vendor-2", "attStatus": "2"] - fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"], appTransactionId: "tx-1") + vendorId = "vendor-2" + status = 2 + idfa = nil + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") #expect(fetcher.integrationAttributes["idfv"] == "vendor-2") #expect(fetcher.integrationAttributes["attStatus"] == "2") @@ -33,20 +56,112 @@ struct AttributionDeviceAttributesTests { @Test func activationRefreshesWithoutSettingProviderAgain() { let container = DependencyContainer() - var device = ["idfv": "vendor-1", "attStatus": "0"] - let fetcher = AttributionFetcher( - storage: container.storage, - deviceHelper: container.deviceHelper, - webEntitlementRedeemer: container.webEntitlementRedeemer, - deviceAttributesProvider: { device }, - syncDeviceAttributes: { _ in } + var status: Int? = 0 + var idfa: String? + let fetcher = makeFetcher( + container: container, + attStatus: { status }, + idfa: { idfa } ) defer { fetcher.cancelPendingOperations() } - fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"], appTransactionId: "tx-1") + + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) #expect(fetcher.integrationAttributes["attStatus"] == "0") - device = ["idfv": "vendor-1", "idfa": "advertiser-1", "attStatus": "3"] + + status = 3 + idfa = "advertiser-1" fetcher.refreshDeviceAttributes() #expect(fetcher.integrationAttributes["attStatus"] == "3") #expect(fetcher.integrationAttributes["idfa"] == "advertiser-1") } + + @Test func omitsVendorIdWhenItIsUnavailable() { + let container = DependencyContainer() + var syncedAttributes: [String: Any?] = [:] + let fetcher = makeFetcher( + container: container, + vendorId: { "" }, + sync: { syncedAttributes = $0 } + ) + defer { fetcher.cancelPendingOperations() } + + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) + #expect(fetcher.integrationAttributes["idfv"] == nil) + #expect(syncedAttributes["idfv"] as? NSNull != nil) + } + + @Test func keepsIdfaWhileConsentIsUndecided() { + let container = DependencyContainer() + let fetcher = makeFetcher( + container: container, + attStatus: { 0 }, + idfa: { "advertiser-1" } + ) + defer { fetcher.cancelPendingOperations() } + + // Before iOS 14.5 the IDFA is readable while ATT still reads + // `notDetermined`, and a build that can't resolve `ATTrackingManager` + // reports the same status, so the status mustn't gate the IDFA. + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) + #expect(fetcher.integrationAttributes["attStatus"] == "0") + #expect(fetcher.integrationAttributes["idfa"] == "advertiser-1") + } + + @Test func omitsAttStatusWhereTheOsHasNoConsentPrompt() { + let container = DependencyContainer() + var syncedAttributes: [String: Any?] = [:] + let fetcher = makeFetcher( + container: container, + attStatus: { nil }, + sync: { syncedAttributes = $0 } + ) + defer { fetcher.cancelPendingOperations() } + + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) + #expect(fetcher.integrationAttributes["attStatus"] == nil) + #expect(syncedAttributes["attStatus"] as? NSNull != nil) + } + + @Test func onlySyncsUserAttributesWhenTheDeviceSnapshotChanges() { + let container = DependencyContainer() + var status: Int? = 3 + var syncCount = 0 + let fetcher = makeFetcher( + container: container, + attStatus: { status }, + sync: { _ in syncCount += 1 } + ) + defer { fetcher.cancelPendingOperations() } + + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 1) + + fetcher.refreshDeviceAttributes() + fetcher.refreshDeviceAttributes() + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) + #expect(fetcher.integrationAttributes["idfa"] == "advertiser-1") + #expect(syncCount == 1) + + status = 2 + fetcher.refreshDeviceAttributes() + #expect(fetcher.integrationAttributes["attStatus"] == "2") + #expect(syncCount == 2) + + } + + @Test func resyncSendsTheIdentifiersAgainAfterAReset() { + let container = DependencyContainer() + var syncCount = 0 + let fetcher = makeFetcher(container: container, sync: { _ in syncCount += 1 }) + defer { fetcher.cancelPendingOperations() } + + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 1) + + fetcher.resyncDeviceAttributes() + #expect(fetcher.integrationAttributes["idfa"] == "advertiser-1") + #expect(syncCount == 2) + } } From bfb8793214ba7e14ac83211bf0887e50febd5cbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:27:45 +0200 Subject: [PATCH 106/162] Restore the whole integration state after a reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reset deletes the stored integration attributes — they live in the user-specific directory — and empties the user's attributes, so the in-memory dictionary was left as the only copy of the provider ids. resyncDeviceAttributes now writes it back to disk and hands the provider ids to the new user alongside the device identifiers, so the router still has something to route on and the next cold launch still has attributes to refresh. Record why the vendor id latch is safe for the device identity it feeds, and why the app transaction id guard stays now that the fetcher no longer takes the id. Co-Authored-By: Claude Opus 5 --- .../Attribution/AttributionFetcher.swift | 141 +++++++++++------- .../Network/Device Helper/DeviceHelper.swift | 7 + Sources/SuperwallKit/Superwall.swift | 5 + .../AttributionDeviceAttributesTests.swift | 21 ++- 4 files changed, 114 insertions(+), 60 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift index ae01dab33..cb9484c84 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift @@ -181,62 +181,6 @@ final class AttributionFetcher { } } - /// The ATT authorization status, or `nil` where the OS has no such concept. - private var attStatus: Int? { - if let attStatusProvider { - return attStatusProvider() - } - #if os(iOS) || targetEnvironment(macCatalyst) || os(tvOS) || os(macOS) || os(visionOS) - if #available(iOS 14, macCatalyst 14, tvOS 14, macOS 11, *) { - return TrackingManagerProxy().trackingAuthorizationStatus() - } - #endif - return nil - } - - private var currentDeviceAttributes: [String: String] { - var attributes: [String: String] = [:] - - let vendorId = vendorIdProvider?() ?? deviceHelper.vendorId - if !vendorId.isEmpty { - attributes["idfv"] = vendorId - } - - if let attStatus { - attributes["attStatus"] = String(attStatus) - } - - // Don't gate this on the ATT status. Before iOS 14.5 the IDFA is available - // while ATT still reads `notDetermined`, and `TrackingManagerProxy` returns - // `notDetermined` both for a genuine one and for a build where the class - // can't be found, so the status can't tell those apart. The OS hands back - // the all-zero id when it doesn't want to share one, and - // `identifierForAdvertisers` already filters that out. - attributes["idfa"] = idfaProvider?() ?? identifierForAdvertisers - - return attributes - } - - func refreshDeviceAttributes() { - queue.async { [weak self] in - guard let self, !self._integrationAttributes.isEmpty else { return } - if self._mergeIntegrationAttributes(attributes: [:]) { - self._debouncedRedeem() - } - } - } - - /// Sends the device identifiers again after a reset, which wipes the user's - /// attributes. Without this the sync's change check would see the same device - /// snapshot as before and leave the new user without them. - func resyncDeviceAttributes() { - queue.async { [weak self] in - guard let self, !self._integrationAttributes.isEmpty else { return } - self._lastSyncedDeviceAttributes = nil - _ = self._mergeIntegrationAttributes(attributes: [:]) - } - } - func setIntegrationAttribute( attribute: IntegrationAttribute, value: String? @@ -292,7 +236,15 @@ final class AttributionFetcher { } } - private func _mergeIntegrationAttributes(attributes: [String: String?]) -> Bool { + /// - Parameter includeProviderIds: Whether to hand the provider ids to + /// `syncDeviceAttributes` alongside the device identifiers. Only the device + /// identifiers are the SDK's to own; the provider ids are already in the + /// user's attributes except right after a reset, which wipes them. + /// - Returns: Whether the integration attributes changed. + private func _mergeIntegrationAttributes( + attributes: [String: String?], + includeProviderIds: Bool = false + ) -> Bool { var mergedAttributes = _integrationAttributes for (key, value) in attributes { mergedAttributes[key] = value @@ -307,6 +259,11 @@ final class AttributionFetcher { if device != _lastSyncedDeviceAttributes { _lastSyncedDeviceAttributes = device var userAttributes: [String: Any?] = [:] + if includeProviderIds { + for (key, value) in mergedAttributes where !Self.deviceAttributeKeys.contains(key) { + userAttributes[key] = value + } + } for key in Self.deviceAttributeKeys { userAttributes[key] = device[key].map { $0 as Any } ?? NSNull() } @@ -326,3 +283,73 @@ final class AttributionFetcher { return true } } + +// MARK: - Device attributes +extension AttributionFetcher { + /// The ATT authorization status, or `nil` where the OS has no such concept. + private var attStatus: Int? { + if let attStatusProvider { + return attStatusProvider() + } + #if os(iOS) || targetEnvironment(macCatalyst) || os(tvOS) || os(macOS) || os(visionOS) + if #available(iOS 14, macCatalyst 14, tvOS 14, macOS 11, *) { + return TrackingManagerProxy().trackingAuthorizationStatus() + } + #endif + return nil + } + + private var currentDeviceAttributes: [String: String] { + var attributes: [String: String] = [:] + + let vendorId = vendorIdProvider?() ?? deviceHelper.vendorId + if !vendorId.isEmpty { + attributes["idfv"] = vendorId + } + + if let attStatus { + attributes["attStatus"] = String(attStatus) + } + + // Don't gate this on the ATT status. Before iOS 14.5 the IDFA is available + // while ATT still reads `notDetermined`, and `TrackingManagerProxy` returns + // `notDetermined` both for a genuine one and for a build where the class + // can't be found, so the status can't tell those apart. The OS hands back + // the all-zero id when it doesn't want to share one, and + // `identifierForAdvertisers` already filters that out. + attributes["idfa"] = idfaProvider?() ?? identifierForAdvertisers + + return attributes + } + + func refreshDeviceAttributes() { + queue.async { [weak self] in + guard let self, !self._integrationAttributes.isEmpty else { return } + if self._mergeIntegrationAttributes(attributes: [:]) { + self._debouncedRedeem() + } + } + } + + /// Restores the integration attributes for the user that `reset()` just + /// created. + /// + /// A reset clears the user's attributes and deletes the stored copy of the + /// integration attributes, which live in the user-specific directory. That + /// leaves the in-memory dictionary as the only remaining source of the + /// provider ids, so write it back to disk as well as handing the whole set — + /// provider ids and device identifiers — to the new user. Without the file + /// the next cold launch would start empty and the activation refresh would + /// never run again, so a later consent change would never clear the IDFA. + /// + /// No redeem is scheduled: none of the attributes changed, and the identity + /// redeems for the new user once it settles. + func resyncDeviceAttributes() { + queue.async { [weak self] in + guard let self, !self._integrationAttributes.isEmpty else { return } + self.storage.save(self._integrationAttributes, forType: IntegrationAttributes.self) + self._lastSyncedDeviceAttributes = nil + _ = self._mergeIntegrationAttributes(attributes: [:], includeProviderIds: true) + } + } +} diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index 3ae732e9e..0a1255093 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -72,6 +72,13 @@ class DeviceHelper { /// an empty id for the whole process if this were read only at init. Latch the /// first non-empty read instead, so a later read picks the id up once it /// exists and every read after that is a plain load. + /// + /// This also settles `makeDeviceId()`, so the `$SuperwallDevice:` identity can + /// change once mid-process in that window. That's the point: the value it + /// replaces is the empty suffix, which isn't a per-device identity at all — + /// every device in this state shares it — so there's nothing there worth + /// keeping stable or reconciling against. The window closes at first unlock, + /// and the repeated `UIDevice` read inside it is cheap. var vendorId: String { let cached = cachedVendorId if !cached.isEmpty { diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 90a9168ca..9e196b891 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -923,6 +923,9 @@ public final class Superwall: NSObject, ObservableObject { /// - Parameter props: A dictionary keyed by ``IntegrationAttribute`` specifying /// properties to associate with the user or events for the given provider. public func setIntegrationAttributes(_ props: [IntegrationAttribute: String?]) { + // The fetcher doesn't take the app transaction id, but it still has to wait + // for one: setting attributes debounces a redeem, and the redeemer reads the + // id for itself. guard ReceiptManager.appTransactionId != nil else { // Atomically merge with existing enqueued attributes mergeEnqueuedAttributes(props) @@ -944,6 +947,8 @@ public final class Superwall: NSObject, ObservableObject { /// - attribute: The ``IntegrationAttribute`` key specifying the integration provider. /// - value: The value to associate with the attribute. Pass `nil` to remove the attribute. public func setIntegrationAttribute(_ attribute: IntegrationAttribute, _ value: String?) { + // Waits for the app transaction id for the same reason as + // `setIntegrationAttributes(_:)` above. guard ReceiptManager.appTransactionId != nil else { // Atomically merge with existing enqueued attributes mergeEnqueuedAttributes([attribute: value]) diff --git a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift index 48ab8bc58..6caf4c31a 100644 --- a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift @@ -147,21 +147,36 @@ struct AttributionDeviceAttributesTests { fetcher.refreshDeviceAttributes() #expect(fetcher.integrationAttributes["attStatus"] == "2") #expect(syncCount == 2) - } - @Test func resyncSendsTheIdentifiersAgainAfterAReset() { + @Test func resyncRestoresTheWholeSetAfterAReset() { let container = DependencyContainer() + var syncedAttributes: [String: Any?] = [:] var syncCount = 0 - let fetcher = makeFetcher(container: container, sync: { _ in syncCount += 1 }) + let fetcher = makeFetcher( + container: container, + sync: { + syncedAttributes = $0 + syncCount += 1 + } + ) defer { fetcher.cancelPendingOperations() } fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") #expect(syncCount == 1) + #expect(syncedAttributes["appsflyerId"] == nil) + + // A reset deletes the user-specific copy and empties the user's attributes. + container.storage.delete(IntegrationAttributes.self) fetcher.resyncDeviceAttributes() #expect(fetcher.integrationAttributes["idfa"] == "advertiser-1") #expect(syncCount == 2) + // The provider id goes back to the new user, not just the device keys. + #expect(syncedAttributes["appsflyerId"] as? String == "af-1") + #expect(syncedAttributes["idfv"] as? String == "vendor-1") + // And the dictionary is on disk again, so the next launch still refreshes. + #expect(container.storage.get(IntegrationAttributes.self)?["appsflyerId"] == "af-1") } } From 03d47bd3f8a0f53be1fc81547e6253403436a451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:41:57 +0200 Subject: [PATCH 107/162] Deactivate lapsed saved rows before merging grants in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the grant merge into `savedCustomerInfoForEarlyPublish` put it ahead of the restore's lapse pass, which broke the thing the granted short-circuit exists for. With an active grant and a saved row of the same id that lapsed since the last launch, both are active at merge time, so the saved row wins on having transaction history — and the lapse pass then deactivates it, taking the grant down with it. The lapse pass runs first again, on the saved rows alone, and the grants are passed into the restore and merged after. Once the stale row is inactive, `mergePrioritized` prefers the active grant, and the field-by-field precedence for the non-lapsed case is unchanged. Passing the grants in also keeps them injected rather than read from `Superwall.shared`, which is what made this testable. Co-Authored-By: Claude Opus 5 --- .../SuperwallKit/Config/ConfigManager.swift | 22 +++---- .../Dependencies/DependencyContainer.swift | 12 +++- .../Dependencies/FactoryProtocols.swift | 6 +- .../ReceiptManager+Restore.swift | 27 +++++--- .../ConfigManagerEarlyPublishTests.swift | 66 +++++++++++++++++++ 5 files changed, 108 insertions(+), 25 deletions(-) diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index 33d19f831..0e4d5f1ed 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -271,25 +271,17 @@ class ConfigManager { guard let customerInfo = storage.get(LatestCustomerInfo.self) else { return nil } - // Every load merges the developer's grants back in, so the copy handed to - // the restore carries them too. Otherwise a grant made since the last - // launch, which the saved copy predates, would look inactive until the read - // lands. `merging` is what the load uses, so where an id is in both the - // richer copy wins rather than the grant blanking the saved expiry. - let granted = entitlementsInfo.granted - let customerInfoWithGrants = customerInfo.merging(with: .blank(), granting: granted) - // A developer-granted entitlement is the developer's own verdict. The read // merges it back in on every load, so nothing it learns can change it. - if granted.contains(where: { $0.isActive }) { - return customerInfoWithGrants + if entitlementsInfo.granted.contains(where: { $0.isActive }) { + return customerInfo } // A lifetime purchase has no expiry to check and can only be refunded, // which is the same risk the expiry case already accepts. - let isStillEntitled = customerInfoWithGrants.entitlements.contains { + let isStillEntitled = customerInfo.entitlements.contains { $0.isActive && ($0.isLifetime == true || ($0.expiresAt ?? .distantPast) > Date()) } - return isStillEntitled ? customerInfoWithGrants : nil + return isStillEntitled ? customerInfo : nil } private struct ConfigFetchResult { @@ -507,7 +499,11 @@ class ConfigManager { // The saved copy from a test-mode launch can still hold test // entitlements, so it isn't restored when test mode just turned off. if let savedCustomerInfo = savedCustomerInfo, !testModeJustDeactivated { - await factory.restorePurchases(from: savedCustomerInfo, config: config) + await factory.restorePurchases( + from: savedCustomerInfo, + grantedEntitlements: entitlementsInfo.granted, + config: config + ) // Stored before the send so anything that presents on this config can // wait for the load through `initialPurchasesLoad`. let purchasesLoad = Task { [factory] in diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index 30086229c..08681706c 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -598,8 +598,16 @@ extension DependencyContainer: ReceiptFactory { await receiptManager.loadPurchasedProducts(config: config) } - func restorePurchases(from customerInfo: CustomerInfo, config: Config) async { - await receiptManager.restorePurchases(from: customerInfo, config: config) + func restorePurchases( + from customerInfo: CustomerInfo, + grantedEntitlements: Set, + config: Config + ) async { + await receiptManager.restorePurchases( + from: customerInfo, + grantedEntitlements: grantedEntitlements, + config: config + ) } /// nil means the load already finished, or config was never published early. diff --git a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift index 7e07a6915..9b354b99b 100644 --- a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift +++ b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift @@ -157,7 +157,11 @@ protocol UserAttributesPlacementFactory: AnyObject { protocol ReceiptFactory: AnyObject { func loadPurchasedProducts(config: Config?) async - func restorePurchases(from customerInfo: CustomerInfo, config: Config) async + func restorePurchases( + from customerInfo: CustomerInfo, + grantedEntitlements: Set, + config: Config + ) async /// The purchases load that config was published ahead of, if any. var initialPurchasesLoad: Task? { get } func refreshSK1Receipt() async diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift index 2be36c8a0..e35606708 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift @@ -16,7 +16,11 @@ extension ReceiptManager { /// anything past it has lapsed since the last launch and is restored inactive. /// A nil expiry keeps its saved state, except on a subscription: nothing can /// show one is still current, so it isn't restored active. - func restorePurchases(from customerInfo: CustomerInfo, config: Config) async { + func restorePurchases( + from customerInfo: CustomerInfo, + grantedEntitlements: Set, + config: Config + ) async { let now = Date() // The saved copy is the merged one, with web subscriptions appended. The // purchases the StoreKit 2 read produces are device-only, so only App Store @@ -57,14 +61,19 @@ extension ReceiptManager { // Config knows every product and the entitlements it unlocks. The saved // customer info knows which of those were active, and carries fields like // willRenew that audience filters read, so its copy wins where both have one. - // `savedCustomerInfoForEarlyPublish` has already merged the developer's - // grants into this copy, the same way every load merges them. - let savedById = Dictionary( - customerInfo.entitlements.map { entitlement in - let lapsed = entitlement.isActive && hasExpired(entitlement.expiresAt, at: now) - return (entitlement.id, lapsed ? deactivated(entitlement) : entitlement) - } - ) { $1 } + // Lapsed rows are deactivated before the grants go in, so a saved row that + // shares an id with a grant can't drag the grant down with it: once it's + // inactive, `mergePrioritized` prefers the active grant. + let saved = customerInfo.entitlements.map { entitlement in + let lapsed = entitlement.isActive && hasExpired(entitlement.expiresAt, at: now) + return lapsed ? deactivated(entitlement) : entitlement + } + // Every load merges the developer's grants back in, so the restore does too. + // Otherwise a grant made since the last launch, which the saved copy + // predates, would look inactive until the read lands. Merged the way the + // load merges them, so the richer copy wins field by field. + let merged = Entitlement.mergePrioritized(saved + Array(grantedEntitlements)) + let savedById = Dictionary(uniqueKeysWithValues: merged.map { ($0.id, $0) }) let entitlementsByProductId = ConfigLogic.extractEntitlements(from: config) .mapValues { Set($0.map { savedById[$0.id] ?? $0 }) } Superwall.shared.entitlements.setEntitlementsFromConfig(entitlementsByProductId) diff --git a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift index 39a0daf1a..67ccd2c9f 100644 --- a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift +++ b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift @@ -407,6 +407,45 @@ struct ConfigManagerEarlyPublishTests { await settle() } + @Test("The read replaces everything the restore seeded") + func readReplacesRestoredState() async { + // The stand-in read returns an empty snapshot, so anything still showing + // the saved copy after it lands is state the read failed to replace. + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: savedCustomerInfo(expiresIn: 3600), + loadDelay: 1 + ) + let gold = StoreProduct( + sk1Product: MockSkProduct( + productIdentifier: Self.goldProductId, + subscriptionGroupIdentifier: "group_A" + ) + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + _ = await waitForConfig(harness.configManager, timeout: 1.5) + #expect(!harness.receipt.didFinishLoad, "test needs config to be published mid-load") + + // Mid-load: purchases, the entitlement map and the active groups all come + // from the saved copy. The group is seeded, so no trial on an upgrade. + #expect(await harness.receiptManager.getActiveProductIds() == [Self.silverProductId]) + #expect(Superwall.shared.entitlements.byProductId(Self.silverProductId).isEmpty == false) + #expect(await harness.receiptManager.isFreeTrialAvailable(for: gold) == false) + + await fetch.value + #expect(harness.receipt.didFinishLoad) + + // After the read: the snapshot it returned, not the seed. + #expect(await harness.receiptManager.getActiveProductIds().isEmpty) + #expect(await harness.receiptManager.isSubscribed(to: Self.silverProductId) == false) + #expect(Superwall.shared.entitlements.byProductId(Self.silverProductId).isEmpty) + // The seeded group is gone too, so the upgrade is trial-eligible again. + #expect(await harness.receiptManager.isFreeTrialAvailable(for: gold) == true) + + await settle() + } + @Test("Trial eligibility waits for the purchases load that config no longer waits for") func trialEligibilityWaitsForInitialPurchasesLoad() async { let harness = makeHarness( @@ -485,6 +524,33 @@ struct ConfigManagerEarlyPublishTests { await settle() } + @Test("A grant survives a saved row with the same id that has lapsed") + func grantSurvivesLapsedSavedRowWithSameId() async { + // The grant and the saved `pro` row share an id, and the saved row lapsed + // since the last launch. The developer's verdict has to win. + dependencyContainer.entitlementsInfo.setGranted([Entitlement(id: "pro")]) + defer { dependencyContainer.entitlementsInfo.setGranted([]) } + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: savedCustomerInfo(expiresIn: -60), + loadDelay: 2 + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + _ = await waitForConfig(harness.configManager, timeout: 1.5) + #expect(!harness.receipt.didFinishLoad, "test needs config to be published mid-load") + + let silverEntitlements = Superwall.shared.entitlements.byProductId(Self.silverProductId) + #expect(silverEntitlements.first?.id == "pro") + #expect( + silverEntitlements.first?.isActive == true, + "the lapsed saved row deactivated the grant" + ) + + await fetch.value + await settle() + } + @Test("A subscription saved with no expiry is not restored active") func doesNotRestoreUndatedSubscriptionAsActive() async { let harness = makeHarness( From 922b1d75738fdfb2951990163260be680f342ee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:21:18 +0200 Subject: [PATCH 108/162] Keep only the install-scoped attributes across a reset Restoring every integration attribute to the new user handed them the previous person's CRM identity: about half of IntegrationAttribute names a person rather than a device. Split the enum by scope and keep only the install-scoped half across a reset, along with the device identifiers. Do the restore synchronously, since identify redeems for the new user right after the reset and that request reads the stored attributes rather than the fetcher, and build its payload in the one place that needs it rather than through a flag on the merge that silently did nothing on its own. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- .../Attribution/AttributionFetcher.swift | 75 +++++++++++------- .../Attribution/IntegrationAttribute.swift | 53 +++++++++++++ Sources/SuperwallKit/Superwall.swift | 7 +- .../AttributionDeviceAttributesTests.swift | 77 +++++++++++++++++-- 5 files changed, 176 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a29efdec..1af2cf95b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Fixes -- Refreshes the IDFV, IDFA and tracking consent for integrations when the app becomes active or integration attributes are set again, and clears the IDFA when consent is revoked. These now also go into the user attributes `idfv`, `idfa` and `attStatus`, so server-side integrations such as AppsFlyer can read them. The SDK owns those three keys and will overwrite any value your app has set on them. +- Refreshes the IDFV, IDFA and tracking consent for integrations when the app becomes active or integration attributes are set again, and clears the IDFA when consent is revoked. These now also go into the user attributes `idfv`, `idfa` and `attStatus`, so server-side integrations such as AppsFlyer can read them. The SDK owns those three keys and will overwrite any value your app has set on them. `reset()` and identifying a different user now keep the integration attributes that describe the device, such as the AppsFlyer and Adjust IDs, and drop the ones that describe the person, such as the Amplitude and Customer.io user IDs. - Fixes duplicate device attribute and subscription status change events being tracked when the subscription status is repeatedly set to the same logical state. As part of this, `subscriptionStatusDidChange` now fires only when the logical status changes — the status case, the set of entitlements, or an entitlement's `isActive` flag. Updates to transaction metadata such as expiry dates or renewal state no longer trigger it; use `customerInfoDidChange` for those. - Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift index cb9484c84..c5259f39a 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift @@ -236,15 +236,8 @@ final class AttributionFetcher { } } - /// - Parameter includeProviderIds: Whether to hand the provider ids to - /// `syncDeviceAttributes` alongside the device identifiers. Only the device - /// identifiers are the SDK's to own; the provider ids are already in the - /// user's attributes except right after a reset, which wipes them. /// - Returns: Whether the integration attributes changed. - private func _mergeIntegrationAttributes( - attributes: [String: String?], - includeProviderIds: Bool = false - ) -> Bool { + private func _mergeIntegrationAttributes(attributes: [String: String?]) -> Bool { var mergedAttributes = _integrationAttributes for (key, value) in attributes { mergedAttributes[key] = value @@ -259,11 +252,6 @@ final class AttributionFetcher { if device != _lastSyncedDeviceAttributes { _lastSyncedDeviceAttributes = device var userAttributes: [String: Any?] = [:] - if includeProviderIds { - for (key, value) in mergedAttributes where !Self.deviceAttributeKeys.contains(key) { - userAttributes[key] = value - } - } for key in Self.deviceAttributeKeys { userAttributes[key] = device[key].map { $0 as Any } ?? NSNull() } @@ -331,25 +319,56 @@ extension AttributionFetcher { } } - /// Restores the integration attributes for the user that `reset()` just + /// Re-scopes the integration attributes for the user that `reset()` just /// created. /// + /// Identifiers that describe the person — see `isInstallScoped` — belong to + /// whoever was just signed out, so they go. The install-scoped ones describe + /// the same device either way and stay, along with the device identifiers. + /// /// A reset clears the user's attributes and deletes the stored copy of the - /// integration attributes, which live in the user-specific directory. That - /// leaves the in-memory dictionary as the only remaining source of the - /// provider ids, so write it back to disk as well as handing the whole set — - /// provider ids and device identifiers — to the new user. Without the file - /// the next cold launch would start empty and the activation refresh would - /// never run again, so a later consent change would never clear the IDFA. + /// integration attributes, which live in the user-specific directory, while + /// the in-memory copy outlives it. So write what's kept back to disk and hand + /// all of it — not just what changed — to the new user, who has none of it. + /// Without the file the next cold launch would start empty and the activation + /// refresh would never run again, so a later consent change would never clear + /// the IDFA. /// - /// No redeem is scheduled: none of the attributes changed, and the identity - /// redeems for the new user once it settles. - func resyncDeviceAttributes() { - queue.async { [weak self] in - guard let self, !self._integrationAttributes.isEmpty else { return } - self.storage.save(self._integrationAttributes, forType: IntegrationAttributes.self) - self._lastSyncedDeviceAttributes = nil - _ = self._mergeIntegrationAttributes(attributes: [:], includeProviderIds: true) + /// Runs synchronously. `identify` redeems for the new user immediately after + /// the reset, and that request reads the stored attributes rather than this + /// object, so they have to be settled before this returns. No redeem is + /// scheduled from here; the identity redeems on its own. + func resetIntegrationAttributes() { + queue.sync { + if _integrationAttributes.isEmpty { + return + } + var kept = _integrationAttributes.filter { key, _ in + Self.deviceAttributeKeys.contains(key) + || IntegrationAttribute.installScopedKeys.contains(key) + } + if kept.isEmpty { + _integrationAttributes = [:] + _lastSyncedDeviceAttributes = nil + return + } + + let device = currentDeviceAttributes + for key in Self.deviceAttributeKeys { + kept[key] = device[key] + } + _integrationAttributes = kept + _lastSyncedDeviceAttributes = device + storage.save(kept, forType: IntegrationAttributes.self) + + var userAttributes: [String: Any?] = [:] + for (key, value) in kept where !Self.deviceAttributeKeys.contains(key) { + userAttributes[key] = value + } + for key in Self.deviceAttributeKeys { + userAttributes[key] = device[key].map { $0 as Any } ?? NSNull() + } + syncDeviceAttributes(userAttributes) } } } diff --git a/Sources/SuperwallKit/Analytics/Attribution/IntegrationAttribute.swift b/Sources/SuperwallKit/Analytics/Attribution/IntegrationAttribute.swift index d0a713f32..3d58eba0a 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/IntegrationAttribute.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/IntegrationAttribute.swift @@ -131,3 +131,56 @@ extension IntegrationAttribute: CustomStringConvertible { } } } + +// MARK: - Scope +extension IntegrationAttribute { + /// Whether the identifier belongs to the app install rather than to the + /// person using it. + /// + /// A reset or an `identify` to a different user keeps the install-scoped + /// identifiers — they describe the same device either way — and drops the + /// rest, which belong to whoever was just signed out. The switch is + /// exhaustive on purpose: a new integration has to be placed on one side. + var isInstallScoped: Bool { + switch self { + case .adjustId, + .amplitudeDeviceId, + .appsflyerId, + .fbAnonId, + .firebaseAppInstanceId, + .firebaseInstallationId, + .airshipChannelId, + .kochavaDeviceId, + .tenjinId, + .appstackId, + .singularDeviceId: + return true + case .amplitudeUserId, + .brazeAliasName, + .brazeAliasLabel, + .onesignalId, + .iterableUserId, + .iterableCampaignId, + .iterableTemplateId, + .mixpanelDistinctId, + .mparticleId, + .clevertapId, + .posthogUserId, + .customerioId: + return false + } + } + + /// The keys of every identifier that survives a reset. + static let installScopedKeys: Set = { + let all: [IntegrationAttribute] = [ + .adjustId, .amplitudeDeviceId, .amplitudeUserId, .appsflyerId, + .brazeAliasName, .brazeAliasLabel, .onesignalId, .fbAnonId, + .firebaseAppInstanceId, .firebaseInstallationId, .iterableUserId, + .iterableCampaignId, .iterableTemplateId, .mixpanelDistinctId, + .mparticleId, .clevertapId, .airshipChannelId, .kochavaDeviceId, + .tenjinId, .posthogUserId, .customerioId, .appstackId, .singularDeviceId + ] + return Set(all.filter(\.isInstallScoped).map(\.description)) + }() +} diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 9e196b891..06089c7e4 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -1078,9 +1078,10 @@ public final class Superwall: NSObject, ObservableObject { // logout after that would otherwise leave the new user without attributes. dependencyContainer.mmpAttributionManager.reapplyCachedAcquisitionAttributes() - // The device identifiers are install-scoped too, and the reset just wiped - // them out of the user's attributes, so send them to the new user again. - dependencyContainer.attributionFetcher.resyncDeviceAttributes() + // Integration attributes are part install-scoped, part tied to the person + // signing out, and the reset just wiped them out of the user's attributes + // and off disk. Keep the install-scoped half for the new user, drop the rest. + dependencyContainer.attributionFetcher.resetIntegrationAttributes() dependencyContainer.paywallManager.resetCache() presentationItems.reset() diff --git a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift index 6caf4c31a..2706be94c 100644 --- a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift @@ -11,6 +11,9 @@ struct AttributionDeviceAttributesTests { idfa: @escaping () -> String? = { "advertiser-1" }, sync: @escaping ([String: Any?]) -> Void = { _ in } ) -> AttributionFetcher { + // Storage is backed by the same files across containers, so a dictionary + // left by an earlier test would be loaded here as a starting state. + container.storage.delete(IntegrationAttributes.self) return AttributionFetcher( storage: container.storage, deviceHelper: container.deviceHelper, @@ -149,7 +152,7 @@ struct AttributionDeviceAttributesTests { #expect(syncCount == 2) } - @Test func resyncRestoresTheWholeSetAfterAReset() { + @Test func resetKeepsTheInstallScopedAttributesForTheNewUser() { let container = DependencyContainer() var syncedAttributes: [String: Any?] = [:] var syncCount = 0 @@ -162,21 +165,83 @@ struct AttributionDeviceAttributesTests { ) defer { fetcher.cancelPendingOperations() } - fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) + fetcher.mergeIntegrationAttributes( + attributes: [ + "appsflyerId": "af-1", + "amplitudeUserId": "person-1" + ] + ) #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") #expect(syncCount == 1) #expect(syncedAttributes["appsflyerId"] == nil) // A reset deletes the user-specific copy and empties the user's attributes. container.storage.delete(IntegrationAttributes.self) + fetcher.resetIntegrationAttributes() - fetcher.resyncDeviceAttributes() + // The AppsFlyer id describes the device, so the new user keeps it. The + // Amplitude user id describes the person who just signed out. + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(fetcher.integrationAttributes["amplitudeUserId"] == nil) #expect(fetcher.integrationAttributes["idfa"] == "advertiser-1") #expect(syncCount == 2) - // The provider id goes back to the new user, not just the device keys. + + // The kept ids go back to the new user, alongside the device ones. #expect(syncedAttributes["appsflyerId"] as? String == "af-1") + #expect(syncedAttributes["amplitudeUserId"] == nil) #expect(syncedAttributes["idfv"] as? String == "vendor-1") - // And the dictionary is on disk again, so the next launch still refreshes. - #expect(container.storage.get(IntegrationAttributes.self)?["appsflyerId"] == "af-1") + + // And they're on disk again, so the next launch still refreshes and the + // redeem that follows the reset doesn't ship empty metadata. + let stored = container.storage.get(IntegrationAttributes.self) + #expect(stored?["appsflyerId"] == "af-1") + #expect(stored?["amplitudeUserId"] == nil) + } + + @Test func resetKeepsTheDeviceKeysWhenNoProviderIdIsInstallScoped() { + let container = DependencyContainer() + let fetcher = makeFetcher(container: container) + defer { fetcher.cancelPendingOperations() } + + fetcher.mergeIntegrationAttributes(attributes: ["amplitudeUserId": "person-1"]) + #expect(fetcher.integrationAttributes["amplitudeUserId"] == "person-1") + + container.storage.delete(IntegrationAttributes.self) + fetcher.resetIntegrationAttributes() + + // Nothing of the old user's is left, but the device keys are the SDK's own, + // so they stay and the activation refresh keeps working for the new user. + #expect(fetcher.integrationAttributes["amplitudeUserId"] == nil) + #expect(fetcher.integrationAttributes["idfv"] == "vendor-1") + #expect(container.storage.get(IntegrationAttributes.self)?["amplitudeUserId"] == nil) + #expect(container.storage.get(IntegrationAttributes.self)?["idfv"] == "vendor-1") + } + + @Test func resetClearsEverythingWhenTheDeviceOffersNoIdentifiers() { + let container = DependencyContainer() + let fetcher = makeFetcher( + container: container, + vendorId: { "" }, + attStatus: { nil }, + idfa: { nil } + ) + defer { fetcher.cancelPendingOperations() } + + fetcher.mergeIntegrationAttributes(attributes: ["amplitudeUserId": "person-1"]) + #expect(fetcher.integrationAttributes["amplitudeUserId"] == "person-1") + + container.storage.delete(IntegrationAttributes.self) + fetcher.resetIntegrationAttributes() + + #expect(fetcher.integrationAttributes.isEmpty) + #expect(container.storage.get(IntegrationAttributes.self) == nil) + } + + @Test func everyIntegrationAttributeIsScoped() { + #expect(IntegrationAttribute.installScopedKeys.contains("appsflyerId")) + #expect(IntegrationAttribute.installScopedKeys.contains("adjustId")) + #expect(!IntegrationAttribute.installScopedKeys.contains("amplitudeUserId")) + #expect(!IntegrationAttribute.installScopedKeys.contains("customerioId")) + #expect(IntegrationAttribute.installScopedKeys.count == 11) } } From 82e160cc22d33501f7d8ec0adb598e7b8a99b30c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:30:27 +0200 Subject: [PATCH 109/162] Keep the eligible-override intro check on post-read data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `checkAppStoreTrialEligibility`'s `.eligible` branch vetoes the override when there's an active intro offer in the same group, and it reads that from `customerInfo.subscriptions`. Before this PR config wasn't published until the purchases load had finished, so any paywall that built was guaranteed to see a post-read copy. On the early-publish path it would have seen the copy restored from disk, which doesn't know about a trial started in that group since the last launch — and advertising a trial Apple won't grant is what the veto exists to prevent. Awaiting the load restores the old guarantee. A product with no subscription group returns before the wait, and `.ineligible` still short-circuits without consulting the device at all. Not unit tested: the tests around this file replicate the logic rather than driving `AddPaywallProducts`, so a test there wouldn't exercise the wait. It's the same one-line gate covered by `ReceiptManagerTrialEligibilityTests` on the `.automatic` path. Co-Authored-By: Claude Opus 5 --- .../Paywall/Request/Operators/AddPaywallProducts.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift index 4f4bb3b96..46becd33d 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift @@ -333,6 +333,13 @@ extension PaywallRequestManager { return false } + // Config can be published before the first purchases load finishes (see + // `ConfigManager.fetchConfiguration`), and until it does `customerInfo` is + // the copy restored from disk. A trial started in this group since the last + // launch is only in the read, and advertising a trial Apple won't grant is + // the thing this check exists to prevent, so wait for it. + await factory.initialPurchasesLoad?.value + let subscriptions = await MainActor.run { Superwall.shared.customerInfo.subscriptions } From b6cab4c33761dbcc86fc82ec511dc3c3161f74df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:40:22 +0200 Subject: [PATCH 110/162] Close the two ways a person-scoped id could still survive a reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derive the install-scoped key set by walking the raw values instead of from a hand-written list, so a new integration is picked up on its own rather than being dropped on every reset because someone forgot a line. Re-scope the attributes waiting on the app transaction id too. They're replayed in full when the id arrives, so an identify early in launch put the previous person's ids straight back. Say on each case whether it survives a reset — four of the install-scoped ones read as user identifiers otherwise. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 - .../Attribution/IntegrationAttribute.swift | 88 +++++++++++++++---- Sources/SuperwallKit/Superwall.swift | 18 ++++ .../AttributionDeviceAttributesTests.swift | 25 +++++- 4 files changed, 113 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1af2cf95b..05f53aaf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,6 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Fixes - Refreshes the IDFV, IDFA and tracking consent for integrations when the app becomes active or integration attributes are set again, and clears the IDFA when consent is revoked. These now also go into the user attributes `idfv`, `idfa` and `attStatus`, so server-side integrations such as AppsFlyer can read them. The SDK owns those three keys and will overwrite any value your app has set on them. `reset()` and identifying a different user now keep the integration attributes that describe the device, such as the AppsFlyer and Adjust IDs, and drop the ones that describe the person, such as the Amplitude and Customer.io user IDs. - - Fixes duplicate device attribute and subscription status change events being tracked when the subscription status is repeatedly set to the same logical state. As part of this, `subscriptionStatusDidChange` now fires only when the logical status changes — the status case, the set of entitlements, or an entitlement's `isActive` flag. Updates to transaction metadata such as expiry dates or renewal state no longer trigger it; use `customerInfoDidChange` for those. - Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. - Fixes a data race during SDK configuration that Thread Sanitizer flagged on every launch. diff --git a/Sources/SuperwallKit/Analytics/Attribution/IntegrationAttribute.swift b/Sources/SuperwallKit/Analytics/Attribution/IntegrationAttribute.swift index 3d58eba0a..30351b1d6 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/IntegrationAttribute.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/IntegrationAttribute.swift @@ -8,73 +8,120 @@ /// An enum that represents attributes for third-party integrations with Superwall. @objc(SWKIntegrationAttribute) public enum IntegrationAttribute: Int { - /// The unique Adjust identifier for the user. + /// The unique Adjust identifier for the device. + /// + /// Kept when you reset or identify a different user. case adjustId /// The Amplitude device identifier. + /// + /// Kept when you reset or identify a different user. case amplitudeDeviceId /// The Amplitude user identifier. + /// + /// Cleared when you reset or identify a different user. case amplitudeUserId - /// The unique Appsflyer identifier for the user. + /// The unique Appsflyer identifier for the device. + /// + /// Kept when you reset or identify a different user. case appsflyerId /// The Braze `alias_name` in User Alias Object. + /// + /// Cleared when you reset or identify a different user. case brazeAliasName /// The Braze `alias_label` in User Alias Object. + /// + /// Cleared when you reset or identify a different user. case brazeAliasLabel /// The OneSignal User ID (`onesignal_id`) for the user. + /// + /// Cleared when you reset or identify a different user. case onesignalId - /// The Facebook Anonymous identifier for the user. + /// The Facebook Anonymous identifier for the app install. + /// + /// Kept when you reset or identify a different user. case fbAnonId /// The Firebase instance identifier. + /// + /// Kept when you reset or identify a different user. case firebaseAppInstanceId /// The Firebase installation ID. + /// + /// Kept when you reset or identify a different user. case firebaseInstallationId /// The Iterable identifier for the user. + /// + /// Cleared when you reset or identify a different user. case iterableUserId /// The Iterable campaign identifier. + /// + /// Cleared when you reset or identify a different user. case iterableCampaignId /// The Iterable template identifier. + /// + /// Cleared when you reset or identify a different user. case iterableTemplateId /// The Mixpanel user identifier. + /// + /// Cleared when you reset or identify a different user. case mixpanelDistinctId /// The unique mParticle user identifier (mpid). + /// + /// Cleared when you reset or identify a different user. case mparticleId /// The CleverTap user identifier. + /// + /// Cleared when you reset or identify a different user. case clevertapId - /// The Airship channel identifier for the user. + /// The Airship channel identifier, which registers the device rather than + /// the named user. + /// + /// Kept when you reset or identify a different user. case airshipChannelId /// The unique Kochava device identifier. + /// + /// Kept when you reset or identify a different user. case kochavaDeviceId - /// The Tenjin identifier. + /// The Tenjin device identifier. + /// + /// Kept when you reset or identify a different user. case tenjinId /// The PostHog User identifer + /// + /// Cleared when you reset or identify a different user. case posthogUserId /// The Customer.io person's identifier (`id)`. + /// + /// Cleared when you reset or identify a different user. case customerioId - /// The Appstack identifier. + /// The Appstack device identifier. + /// + /// Kept when you reset or identify a different user. case appstackId /// The Singular device identifier (SDID). + /// + /// Kept when you reset or identify a different user. case singularDeviceId } @@ -171,16 +218,23 @@ extension IntegrationAttribute { } } - /// The keys of every identifier that survives a reset. - static let installScopedKeys: Set = { - let all: [IntegrationAttribute] = [ - .adjustId, .amplitudeDeviceId, .amplitudeUserId, .appsflyerId, - .brazeAliasName, .brazeAliasLabel, .onesignalId, .fbAnonId, - .firebaseAppInstanceId, .firebaseInstallationId, .iterableUserId, - .iterableCampaignId, .iterableTemplateId, .mixpanelDistinctId, - .mparticleId, .clevertapId, .airshipChannelId, .kochavaDeviceId, - .tenjinId, .posthogUserId, .customerioId, .appstackId, .singularDeviceId - ] - return Set(all.filter(\.isInstallScoped).map(\.description)) + /// Every case, in declaration order. + /// + /// Walks the raw values rather than listing the cases, so a new integration + /// is picked up on its own and can't be left out of the scope split by + /// accident. + static let allAttributes: [IntegrationAttribute] = { + var attributes: [IntegrationAttribute] = [] + var rawValue = 0 + while let attribute = IntegrationAttribute(rawValue: rawValue) { + attributes.append(attribute) + rawValue += 1 + } + return attributes }() + + /// The keys of every identifier that survives a reset. + static let installScopedKeys: Set = Set( + allAttributes.filter(\.isInstallScoped).map(\.description) + ) } diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 06089c7e4..a763a79bd 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -419,6 +419,23 @@ public final class Superwall: NSObject, ObservableObject { } } + /// Drops the attributes waiting on the app transaction id that identify the + /// person rather than the device. + /// + /// They were set for the user that `reset()` just replaced, so replaying them + /// when the id arrives would hand someone else's identity to the new user — + /// the same reason `AttributionFetcher.resetIntegrationAttributes()` drops + /// them from the attributes it already holds. + func resetEnqueuedIntegrationAttributes() { + enqueuedAttributesQueue.sync { + guard let enqueued = _enqueuedIntegrationAttributes else { + return + } + let kept = enqueued.filter { $0.key.isInstallScoped } + _enqueuedIntegrationAttributes = kept.isEmpty ? nil : kept + } + } + // MARK: - Value Resolution private func resolvedCustomerInfo( @@ -1082,6 +1099,7 @@ public final class Superwall: NSObject, ObservableObject { // signing out, and the reset just wiped them out of the user's attributes // and off disk. Keep the install-scoped half for the new user, drop the rest. dependencyContainer.attributionFetcher.resetIntegrationAttributes() + resetEnqueuedIntegrationAttributes() dependencyContainer.paywallManager.resetCache() presentationItems.reset() diff --git a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift index 2706be94c..a578f8c46 100644 --- a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift @@ -238,10 +238,33 @@ struct AttributionDeviceAttributesTests { } @Test func everyIntegrationAttributeIsScoped() { + // The walk over the raw values has to reach every case, or an integration + // would be treated as person-scoped and dropped on reset without anyone + // deciding that. Bump both numbers when adding one, having picked a side. + #expect(IntegrationAttribute.allAttributes.count == 23) + #expect(IntegrationAttribute.allAttributes.last == .singularDeviceId) + #expect(IntegrationAttribute.installScopedKeys.count == 11) + #expect(IntegrationAttribute.installScopedKeys.contains("appsflyerId")) #expect(IntegrationAttribute.installScopedKeys.contains("adjustId")) #expect(!IntegrationAttribute.installScopedKeys.contains("amplitudeUserId")) #expect(!IntegrationAttribute.installScopedKeys.contains("customerioId")) - #expect(IntegrationAttribute.installScopedKeys.count == 11) + } + + @Test func resetDropsThePersonScopedAttributesWaitingOnTheTransactionId() { + let superwall = Superwall.shared + superwall.enqueuedIntegrationAttributes = [ + .appsflyerId: "af-1", + .amplitudeUserId: "person-1" + ] + defer { superwall.enqueuedIntegrationAttributes = nil } + + // Called directly rather than through `reset()`, whose storage wipe and + // config reset would reach well beyond this suite. + superwall.resetEnqueuedIntegrationAttributes() + + let enqueued = superwall.enqueuedIntegrationAttributes + #expect(enqueued?[.appsflyerId] == "af-1") + #expect(enqueued?[.amplitudeUserId] == nil) } } From 26dc72f4e94b300a9e7b46318db00590fca637a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:53:29 +0200 Subject: [PATCH 111/162] Derive the integration attribute cases from CaseIterable Walking the raw values stops at the first gap, so a case given an explicit non-contiguous value would be left out of the scope split and dropped on every reset. CaseIterable synthesis works on this enum and can't miss one. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../Attribution/IntegrationAttribute.swift | 19 ++----------------- .../AttributionDeviceAttributesTests.swift | 13 ++++++++----- 3 files changed, 11 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05f53aaf0..3e314235f 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 `grantedEntitlements` so you can grant entitlements from your own backend, which the SDK merges with device and web entitlements. +- Adds `CaseIterable` conformance to `IntegrationAttribute`, so you can list every integration the SDK supports. - Changes `$subscriptionStatus` from a `@Published` publisher to an `AnyPublisher`. Subscribing to it works as before, but it can no longer be the target of `assign(to:)`. ### Fixes diff --git a/Sources/SuperwallKit/Analytics/Attribution/IntegrationAttribute.swift b/Sources/SuperwallKit/Analytics/Attribution/IntegrationAttribute.swift index 30351b1d6..213a344ac 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/IntegrationAttribute.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/IntegrationAttribute.swift @@ -7,7 +7,7 @@ /// An enum that represents attributes for third-party integrations with Superwall. @objc(SWKIntegrationAttribute) -public enum IntegrationAttribute: Int { +public enum IntegrationAttribute: Int, CaseIterable { /// The unique Adjust identifier for the device. /// /// Kept when you reset or identify a different user. @@ -218,23 +218,8 @@ extension IntegrationAttribute { } } - /// Every case, in declaration order. - /// - /// Walks the raw values rather than listing the cases, so a new integration - /// is picked up on its own and can't be left out of the scope split by - /// accident. - static let allAttributes: [IntegrationAttribute] = { - var attributes: [IntegrationAttribute] = [] - var rawValue = 0 - while let attribute = IntegrationAttribute(rawValue: rawValue) { - attributes.append(attribute) - rawValue += 1 - } - return attributes - }() - /// The keys of every identifier that survives a reset. static let installScopedKeys: Set = Set( - allAttributes.filter(\.isInstallScoped).map(\.description) + allCases.filter(\.isInstallScoped).map(\.description) ) } diff --git a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift index a578f8c46..7a6059ba0 100644 --- a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift @@ -238,11 +238,14 @@ struct AttributionDeviceAttributesTests { } @Test func everyIntegrationAttributeIsScoped() { - // The walk over the raw values has to reach every case, or an integration - // would be treated as person-scoped and dropped on reset without anyone - // deciding that. Bump both numbers when adding one, having picked a side. - #expect(IntegrationAttribute.allAttributes.count == 23) - #expect(IntegrationAttribute.allAttributes.last == .singularDeviceId) + // Every case has to land on one side of the split, or an integration would + // be dropped on reset without anyone deciding that. + #expect( + IntegrationAttribute.allCases.count + == IntegrationAttribute.installScopedKeys.count + + IntegrationAttribute.allCases.filter { !$0.isInstallScoped }.count + ) + #expect(IntegrationAttribute.allCases.count == 23) #expect(IntegrationAttribute.installScopedKeys.count == 11) #expect(IntegrationAttribute.installScopedKeys.contains("appsflyerId")) From 7b1741e58bd4dd9f15d0297e5c44c8d3fec05d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:00:03 +0200 Subject: [PATCH 112/162] Drop the scope assertion the exhaustive switch already guarantees Co-Authored-By: Claude Opus 5 --- .../Attribution/AttributionDeviceAttributesTests.swift | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift index 7a6059ba0..62651534b 100644 --- a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift @@ -238,13 +238,9 @@ struct AttributionDeviceAttributesTests { } @Test func everyIntegrationAttributeIsScoped() { - // Every case has to land on one side of the split, or an integration would - // be dropped on reset without anyone deciding that. - #expect( - IntegrationAttribute.allCases.count - == IntegrationAttribute.installScopedKeys.count - + IntegrationAttribute.allCases.filter { !$0.isInstallScoped }.count - ) + // Every case lands on one side of the split — the exhaustive switch in + // `isInstallScoped` forces that. Bump both counts when adding a case, so + // nobody adds an integration without picking a side. #expect(IntegrationAttribute.allCases.count == 23) #expect(IntegrationAttribute.installScopedKeys.count == 11) From 2124140641120c16b1f13ecb074ebe0c638dffa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:15:39 +0200 Subject: [PATCH 113/162] Wait for the read in the entitlement-history check too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hasEverHadEntitlement` reads the same disk-restored `customerInfo` as `hasActiveIntroOffer` did, and the Stripe and custom-product paths reach it on both `.automatic` and `.eligible`. During the early-publish window a row the read is about to fill in from a transaction acquired since the last launch still looks like "never had it", so the trial gets offered. Its `isPlaceholder` guard can't cover this: the early publish only happens when a non-blank copy is saved, which is exactly when `isPlaceholder` is false. This only fixes the device half. The web rows the load merges come from the last redeem response, which stays stale until the redeemer runs, so a Stripe subscription redeemed on another device is no fresher than before — that's unchanged from `develop`. Co-Authored-By: Claude Opus 5 --- .../Paywall/Request/Operators/AddPaywallProducts.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift index 46becd33d..f0d3bc148 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift @@ -367,6 +367,13 @@ extension PaywallRequestManager { if productEntitlementIds.isEmpty { return false } + // Same reason as `hasActiveIntroOffer`: config can be published before the + // purchases load finishes, and until it does `customerInfo` is the copy + // restored from disk. The `isPlaceholder` check below can't stand in for + // this — the early publish only happens when a non-blank copy is saved, + // which is exactly when `isPlaceholder` is false. + await factory.initialPurchasesLoad?.value + let customerInfo = await MainActor.run { Superwall.shared.customerInfo } From 6eb2c244cc464dfc16a9911d9ecd8d2841fab65d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:05:37 +0200 Subject: [PATCH 114/162] Wait for the read in the custom-product trial check too `TransactionManager.isFreeTrialAvailable` returns down the custom-product branch before reaching the wait, and `isCustomProductFreeTrialAvailable` reads `Superwall.shared.customerInfo` with the same entitlement-history filter and the same inert `isPlaceholder` guard the other two checks had. So a custom product could advertise its trial from post-read data while the transaction it produced was labelled from the previous launch's copy: the value reaches `didStartOffer`, which picks `.freeTrialStart` over `.subscriptionStart`. The damage is analytics rather than entitlement, but the two halves disagreeing is worse than either answer. That's the last of the four eligibility reads that looked at `customerInfo` without waiting. Co-Authored-By: Claude Opus 5 --- .../StoreKit/Transactions/TransactionManager.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index f39e2bd57..9ac7ffc14 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -751,6 +751,13 @@ final class TransactionManager { return false } + // Same reason as the App Store branch above: config can be published before + // the purchases load finishes, and until it does `customerInfo` is the copy + // restored from disk. The `isPlaceholder` check below can't stand in for + // this — the early publish only happens when a non-blank copy is saved, + // which is exactly when `isPlaceholder` is false. + await factory.initialPurchasesLoad?.value + let customerInfo = await MainActor.run { Superwall.shared.customerInfo } From 3bb6ef4a57a01b2f04d7e8815b663b779f9f6c47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:44:47 +0200 Subject: [PATCH 115/162] Skip the purchases wait when no App Store product is involved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entitlement-history checks waited for the purchases read whatever the product was, including a Stripe or custom one. But the read only ever adds App Store history: it matches transactions against config's product map, so an entitlement nothing on the App Store unlocks comes out the same before and after. Waiting for it there was pure latency. `hasAppStoreProduct(forEntitlementIds:)` answers that from config, which is the only place that can. The saved customer info can't: the case worth waiting for is a purchase made since the last launch, and that's exactly what the saved copy is missing, so it would say "no App Store product" right when the answer needs to be yes. Without config we can't rule the read out, so the caller waits. Both entitlement-history checks use it — the paywall one and the buy-time one — so the trial a paywall advertises and the transaction it produces are still decided from the same state. Co-Authored-By: Claude Opus 5 --- .../Dependencies/DependencyContainer.swift | 14 ++++++ .../Dependencies/FactoryProtocols.swift | 7 +++ .../Operators/AddPaywallProducts.swift | 8 +++- .../Transactions/TransactionManager.swift | 10 ++++- .../DependencyContainerInitTests.swift | 45 +++++++++++++++++++ 5 files changed, 81 insertions(+), 3 deletions(-) diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index 08681706c..5023fbe62 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -484,6 +484,20 @@ extension DependencyContainer: AudienceFilterAttributesFactory { // MARK: - ConfigManagerFactory extension DependencyContainer: ConfigManagerFactory { /// Gets the paywall response from the static config, if the device locale starts with "en" and no more specific version can be found. + func hasAppStoreProduct(forEntitlementIds entitlementIds: Set) -> Bool { + guard let config = configManager.config else { + // Without config we can't rule the read out, so say yes and let the + // caller wait. + return true + } + return config.products.contains { product in + guard case .appStore = product.type else { + return false + } + return product.entitlements.contains { entitlementIds.contains($0.id) } + } + } + func makeStaticPaywall( withId paywallId: String?, isDebuggerLaunched: Bool diff --git a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift index 9b354b99b..a27021240 100644 --- a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift +++ b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift @@ -78,6 +78,13 @@ protocol ConfigManagerFactory: AnyObject { withId paywallId: String?, isDebuggerLaunched: Bool ) -> Paywall? + + /// Whether any App Store product unlocks one of `entitlementIds`. + /// + /// The purchases read only ever adds App Store history, so when nothing on + /// the App Store unlocks an entitlement, the read can't change what the SDK + /// knows about it and callers don't have to wait for it. + func hasAppStoreProduct(forEntitlementIds entitlementIds: Set) -> Bool } protocol IdentityFactory: AnyObject { diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift index f0d3bc148..5bcee7869 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift @@ -372,7 +372,13 @@ extension PaywallRequestManager { // restored from disk. The `isPlaceholder` check below can't stand in for // this — the early publish only happens when a non-blank copy is saved, // which is exactly when `isPlaceholder` is false. - await factory.initialPurchasesLoad?.value + // + // The read only ever adds App Store history, so an entitlement nothing on + // the App Store unlocks can't change when it lands. Those are answered + // without waiting, which is what keeps a web-only paywall quick. + if factory.hasAppStoreProduct(forEntitlementIds: productEntitlementIds) { + await factory.initialPurchasesLoad?.value + } let customerInfo = await MainActor.run { Superwall.shared.customerInfo diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index 9ac7ffc14..19c973f46 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -27,6 +27,7 @@ final class TransactionManager { & RestoreAccessFactory & TestModeManagerFactory & ReceiptFactory + & ConfigManagerFactory enum State { case observing case purchasing(PurchaseSource) @@ -756,7 +757,13 @@ final class TransactionManager { // restored from disk. The `isPlaceholder` check below can't stand in for // this — the early publish only happens when a non-blank copy is saved, // which is exactly when `isPlaceholder` is false. - await factory.initialPurchasesLoad?.value + // + // Waiting on the same rule the paywall used, so the trial it advertised + // and the transaction it produces are decided from the same state. + let productEntitlementIds = Set(product.entitlements.map(\.id)) + if factory.hasAppStoreProduct(forEntitlementIds: productEntitlementIds) { + await factory.initialPurchasesLoad?.value + } let customerInfo = await MainActor.run { Superwall.shared.customerInfo @@ -768,7 +775,6 @@ final class TransactionManager { return false } - let productEntitlementIds = Set(product.entitlements.map(\.id)) let userEntitlementIds = Set( customerInfo.entitlements .filter { $0.latestProductId != nil || $0.store == .superwall || $0.isActive } diff --git a/Tests/SuperwallKitTests/Dependencies/DependencyContainerInitTests.swift b/Tests/SuperwallKitTests/Dependencies/DependencyContainerInitTests.swift index 2e3213da4..9c3a4ea46 100644 --- a/Tests/SuperwallKitTests/Dependencies/DependencyContainerInitTests.swift +++ b/Tests/SuperwallKitTests/Dependencies/DependencyContainerInitTests.swift @@ -24,3 +24,48 @@ struct DependencyContainerInitTests { } } } + +/// The purchases read only ever adds App Store history, so callers ask this +/// before deciding whether an entitlement's answer can change when it lands. +@Suite(.serialized) +struct AppStoreEntitlementLookupTests { + private func makeProduct( + id: String, + entitlementId: String, + isAppStore: Bool + ) -> SuperwallKit.Product { + return SuperwallKit.Product( + name: id, + type: isAppStore ? .appStore(.init(id: id)) : .stripe(.init(id: id, trialDays: nil)), + id: id, + entitlements: [Entitlement(id: entitlementId)] + ) + } + + @Test("Only App Store products put an entitlement within the read's reach") + func onlyAppStoreProductsCount() { + let container = DependencyContainer() + let config: Config = .stub() + .setting( + \.products, + to: [ + makeProduct(id: "com.app.pro", entitlementId: "pro", isAppStore: true), + makeProduct(id: "com.app.web", entitlementId: "web_only", isAppStore: false) + ] + ) + container.configManager.configState.send(.retrieved(config)) + + #expect(container.hasAppStoreProduct(forEntitlementIds: ["pro"])) + #expect(container.hasAppStoreProduct(forEntitlementIds: ["web_only"]) == false) + // A web product sharing an entitlement with an App Store one still waits. + #expect(container.hasAppStoreProduct(forEntitlementIds: ["web_only", "pro"])) + #expect(container.hasAppStoreProduct(forEntitlementIds: []) == false) + } + + @Test("Without config the read can't be ruled out, so callers wait") + func waitsWhenConfigIsMissing() { + let container = DependencyContainer() + #expect(container.configManager.config == nil) + #expect(container.hasAppStoreProduct(forEntitlementIds: ["pro"])) + } +} From f9c3590a0d6f9fb4757b7fec91f0fdb8fb0170e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:13:19 +0200 Subject: [PATCH 116/162] Say what actually decides whether the load can move an entitlement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doc claimed the load "only ever adds App Store history", which isn't what it does — when it lands it re-merges the web and granted sources into the same customer info, so either of those could in principle move. What makes the skip safe is narrower: the redeemer assigns customerInfo itself when web entitlements change, and so does the granted-entitlements setter, so the copy read during the window is never behind on them. That setter has one exception, apps with a purchase controller, so a currently granted entitlement now counts as in reach rather than being a hole in the claim. Renamed to purchasesLoadCouldChange(entitlementIds:) since it is no longer only about App Store products. The function had also been inserted between makeStaticPaywall's doc comment and its declaration, leaving both attached to the wrong thing. Co-Authored-By: Claude Opus 5 --- .../Dependencies/DependencyContainer.swift | 36 +++++++++++-------- .../Dependencies/FactoryProtocols.swift | 14 +++++--- .../Operators/AddPaywallProducts.swift | 7 ++-- .../Transactions/TransactionManager.swift | 2 +- .../DependencyContainerInitTests.swift | 30 ++++++++++++---- 5 files changed, 59 insertions(+), 30 deletions(-) diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index 5023fbe62..f1432f281 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -484,20 +484,6 @@ extension DependencyContainer: AudienceFilterAttributesFactory { // MARK: - ConfigManagerFactory extension DependencyContainer: ConfigManagerFactory { /// Gets the paywall response from the static config, if the device locale starts with "en" and no more specific version can be found. - func hasAppStoreProduct(forEntitlementIds entitlementIds: Set) -> Bool { - guard let config = configManager.config else { - // Without config we can't rule the read out, so say yes and let the - // caller wait. - return true - } - return config.products.contains { product in - guard case .appStore = product.type else { - return false - } - return product.entitlements.contains { entitlementIds.contains($0.id) } - } - } - func makeStaticPaywall( withId paywallId: String?, isDebuggerLaunched: Bool @@ -512,6 +498,28 @@ extension DependencyContainer: ConfigManagerFactory { deviceLocale: deviceInfo.locale ) } + + func purchasesLoadCouldChange(entitlementIds: Set) -> Bool { + if entitlementIds.isEmpty { + return false + } + // A grant made during the window only reaches `customerInfo` through the + // load when the app has a purchase controller, so treat it as in reach. + if entitlementsInfo.granted.contains(where: { entitlementIds.contains($0.id) }) { + return true + } + guard let config = configManager.config else { + // Without config we can't rule the load out, so say yes and let the + // caller wait. + return true + } + return config.products.contains { product in + guard case .appStore = product.type else { + return false + } + return product.entitlements.contains { entitlementIds.contains($0.id) } + } + } } // MARK: - StoreTransactionFactory diff --git a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift index a27021240..75470240f 100644 --- a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift +++ b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift @@ -79,12 +79,16 @@ protocol ConfigManagerFactory: AnyObject { isDebuggerLaunched: Bool ) -> Paywall? - /// Whether any App Store product unlocks one of `entitlementIds`. + /// Whether the purchases load could change what the SDK knows about + /// `entitlementIds`, and so whether callers have to wait for it. /// - /// The purchases read only ever adds App Store history, so when nothing on - /// the App Store unlocks an entitlement, the read can't change what the SDK - /// knows about it and callers don't have to wait for it. - func hasAppStoreProduct(forEntitlementIds entitlementIds: Set) -> Bool + /// The load re-merges the web and granted sources when it lands, so it isn't + /// only the App Store half that can move. But the redeemer assigns + /// `customerInfo` itself when web entitlements change, and so does the + /// granted-entitlements setter, so the copy read during the window is never + /// behind on those — except that the grant refresh skips apps with a purchase + /// controller, which is why an entitlement granted right now counts too. + func purchasesLoadCouldChange(entitlementIds: Set) -> Bool } protocol IdentityFactory: AnyObject { diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift index 5bcee7869..bc7ad3776 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift @@ -373,10 +373,9 @@ extension PaywallRequestManager { // this — the early publish only happens when a non-blank copy is saved, // which is exactly when `isPlaceholder` is false. // - // The read only ever adds App Store history, so an entitlement nothing on - // the App Store unlocks can't change when it lands. Those are answered - // without waiting, which is what keeps a web-only paywall quick. - if factory.hasAppStoreProduct(forEntitlementIds: productEntitlementIds) { + // An entitlement the load can't move is answered without waiting, which is + // what keeps a web-only paywall quick. + if factory.purchasesLoadCouldChange(entitlementIds: productEntitlementIds) { await factory.initialPurchasesLoad?.value } diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index 19c973f46..08dc70660 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -761,7 +761,7 @@ final class TransactionManager { // Waiting on the same rule the paywall used, so the trial it advertised // and the transaction it produces are decided from the same state. let productEntitlementIds = Set(product.entitlements.map(\.id)) - if factory.hasAppStoreProduct(forEntitlementIds: productEntitlementIds) { + if factory.purchasesLoadCouldChange(entitlementIds: productEntitlementIds) { await factory.initialPurchasesLoad?.value } diff --git a/Tests/SuperwallKitTests/Dependencies/DependencyContainerInitTests.swift b/Tests/SuperwallKitTests/Dependencies/DependencyContainerInitTests.swift index 9c3a4ea46..015d8ca4f 100644 --- a/Tests/SuperwallKitTests/Dependencies/DependencyContainerInitTests.swift +++ b/Tests/SuperwallKitTests/Dependencies/DependencyContainerInitTests.swift @@ -55,17 +55,35 @@ struct AppStoreEntitlementLookupTests { ) container.configManager.configState.send(.retrieved(config)) - #expect(container.hasAppStoreProduct(forEntitlementIds: ["pro"])) - #expect(container.hasAppStoreProduct(forEntitlementIds: ["web_only"]) == false) + #expect(container.purchasesLoadCouldChange(entitlementIds: ["pro"])) + #expect(container.purchasesLoadCouldChange(entitlementIds: ["web_only"]) == false) // A web product sharing an entitlement with an App Store one still waits. - #expect(container.hasAppStoreProduct(forEntitlementIds: ["web_only", "pro"])) - #expect(container.hasAppStoreProduct(forEntitlementIds: []) == false) + #expect(container.purchasesLoadCouldChange(entitlementIds: ["web_only", "pro"])) + #expect(container.purchasesLoadCouldChange(entitlementIds: []) == false) } - @Test("Without config the read can't be ruled out, so callers wait") + @Test("Without config the load can't be ruled out, so callers wait") func waitsWhenConfigIsMissing() { let container = DependencyContainer() #expect(container.configManager.config == nil) - #expect(container.hasAppStoreProduct(forEntitlementIds: ["pro"])) + #expect(container.purchasesLoadCouldChange(entitlementIds: ["pro"])) + } + + @Test("A granted entitlement is in reach even when only a web product sells it") + func grantedEntitlementIsInReach() { + let container = DependencyContainer() + let config: Config = .stub() + .setting( + \.products, + to: [makeProduct(id: "com.app.web", entitlementId: "web_only", isAppStore: false)] + ) + container.configManager.configState.send(.retrieved(config)) + #expect(container.purchasesLoadCouldChange(entitlementIds: ["web_only"]) == false) + + // The grant refresh skips purchase-controller apps, so the load is the only + // thing that would bring this into `customerInfo` for them. + container.entitlementsInfo.setGranted([Entitlement(id: "web_only")]) + defer { container.entitlementsInfo.setGranted([]) } + #expect(container.purchasesLoadCouldChange(entitlementIds: ["web_only"])) } } From 153dba7ed2f1d50ba8d7773159eedcdb1470e56e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:21:53 +0200 Subject: [PATCH 117/162] Only treat a grant as in reach for apps with a purchase controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check returned true for any granted entitlement, but the comment next to it explained a narrower case than the code tested. Setting a grant normally rebuilds customerInfo there and then, so the load finds nothing new; it's only apps with a purchase controller, where that refresh is skipped, that need the load to fold a grant in. As written, an app without one waited whenever a paywall's entitlement was granted — and an active grant is itself one of the things that opens the fast path, so that was the user most likely to hit it. The test now covers both containers rather than asserting the broad behaviour. Co-Authored-By: Claude Opus 5 --- .../Dependencies/DependencyContainer.swift | 9 ++++-- .../Dependencies/FactoryProtocols.swift | 2 +- .../DependencyContainerInitTests.swift | 29 ++++++++++++------- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index f1432f281..9a47c4dfb 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -503,9 +503,12 @@ extension DependencyContainer: ConfigManagerFactory { if entitlementIds.isEmpty { return false } - // A grant made during the window only reaches `customerInfo` through the - // load when the app has a purchase controller, so treat it as in reach. - if entitlementsInfo.granted.contains(where: { entitlementIds.contains($0.id) }) { + // Setting a grant normally rebuilds `customerInfo` there and then, so the + // load finds nothing new. That refresh skips apps with a purchase + // controller though, and for them the load is the only thing that folds a + // grant in, so it can still move the answer. + if makeHasExternalPurchaseController(), + entitlementsInfo.granted.contains(where: { entitlementIds.contains($0.id) }) { return true } guard let config = configManager.config else { diff --git a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift index 75470240f..3e7e69d8f 100644 --- a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift +++ b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift @@ -87,7 +87,7 @@ protocol ConfigManagerFactory: AnyObject { /// `customerInfo` itself when web entitlements change, and so does the /// granted-entitlements setter, so the copy read during the window is never /// behind on those — except that the grant refresh skips apps with a purchase - /// controller, which is why an entitlement granted right now counts too. + /// controller, so for those an entitlement granted right now counts too. func purchasesLoadCouldChange(entitlementIds: Set) -> Bool } diff --git a/Tests/SuperwallKitTests/Dependencies/DependencyContainerInitTests.swift b/Tests/SuperwallKitTests/Dependencies/DependencyContainerInitTests.swift index 015d8ca4f..0cff7217c 100644 --- a/Tests/SuperwallKitTests/Dependencies/DependencyContainerInitTests.swift +++ b/Tests/SuperwallKitTests/Dependencies/DependencyContainerInitTests.swift @@ -69,21 +69,28 @@ struct AppStoreEntitlementLookupTests { #expect(container.purchasesLoadCouldChange(entitlementIds: ["pro"])) } - @Test("A granted entitlement is in reach even when only a web product sells it") - func grantedEntitlementIsInReach() { - let container = DependencyContainer() - let config: Config = .stub() + @Test("A grant is only in reach for apps with a purchase controller") + func grantedEntitlementIsInReachOnlyWithPurchaseController() { + let webOnly: Config = .stub() .setting( \.products, to: [makeProduct(id: "com.app.web", entitlementId: "web_only", isAppStore: false)] ) - container.configManager.configState.send(.retrieved(config)) - #expect(container.purchasesLoadCouldChange(entitlementIds: ["web_only"]) == false) - // The grant refresh skips purchase-controller apps, so the load is the only - // thing that would bring this into `customerInfo` for them. - container.entitlementsInfo.setGranted([Entitlement(id: "web_only")]) - defer { container.entitlementsInfo.setGranted([]) } - #expect(container.purchasesLoadCouldChange(entitlementIds: ["web_only"])) + // Without a purchase controller, setting a grant rebuilds `customerInfo` + // there and then, so the load has nothing to add. + let automatic = DependencyContainer() + automatic.configManager.configState.send(.retrieved(webOnly)) + automatic.entitlementsInfo.setGranted([Entitlement(id: "web_only")]) + defer { automatic.entitlementsInfo.setGranted([]) } + #expect(automatic.purchasesLoadCouldChange(entitlementIds: ["web_only"]) == false) + + // With one, that refresh is skipped and the load is the only thing that + // folds the grant in. + let controlled = DependencyContainer(purchaseController: MockPurchaseController()) + controlled.configManager.configState.send(.retrieved(webOnly)) + controlled.entitlementsInfo.setGranted([Entitlement(id: "web_only")]) + defer { controlled.entitlementsInfo.setGranted([]) } + #expect(controlled.purchasesLoadCouldChange(entitlementIds: ["web_only"])) } } From 8f067a767f749ee5a8eeea85506de4a2033580cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:42:27 +0200 Subject: [PATCH 118/162] Drop the granted short-circuit, which named the wrong refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branch rested on `refreshAutomaticCustomerInfoAfterGrantChange` skipping apps with a purchase controller. It does, but it isn't the only rebuild the setter triggers: `publishSubscriptionStatus(.grantChange)` runs first and ends in `refreshExternalControllerCustomerInfo` for exactly that case, which assigns customerInfo from `forExternalPurchaseController` — and that appends the grants unconditionally. So a grant is in customerInfo before the setter returns on both paths, and the load can't reveal it. The branch was buying nothing and costing the fast-path skip for a web-only or custom-only paywall on a granted entitlement. Only the device half can move, which is what the check was about in the first place. Co-Authored-By: Claude Opus 5 --- .../Dependencies/DependencyContainer.swift | 8 -------- .../Dependencies/FactoryProtocols.swift | 10 +++++----- .../DependencyContainerInitTests.swift | 14 +++++++------- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index 9a47c4dfb..b33de6578 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -503,14 +503,6 @@ extension DependencyContainer: ConfigManagerFactory { if entitlementIds.isEmpty { return false } - // Setting a grant normally rebuilds `customerInfo` there and then, so the - // load finds nothing new. That refresh skips apps with a purchase - // controller though, and for them the load is the only thing that folds a - // grant in, so it can still move the answer. - if makeHasExternalPurchaseController(), - entitlementsInfo.granted.contains(where: { entitlementIds.contains($0.id) }) { - return true - } guard let config = configManager.config else { // Without config we can't rule the load out, so say yes and let the // caller wait. diff --git a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift index 3e7e69d8f..f0ea1dce2 100644 --- a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift +++ b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift @@ -83,11 +83,11 @@ protocol ConfigManagerFactory: AnyObject { /// `entitlementIds`, and so whether callers have to wait for it. /// /// The load re-merges the web and granted sources when it lands, so it isn't - /// only the App Store half that can move. But the redeemer assigns - /// `customerInfo` itself when web entitlements change, and so does the - /// granted-entitlements setter, so the copy read during the window is never - /// behind on those — except that the grant refresh skips apps with a purchase - /// controller, so for those an entitlement granted right now counts too. + /// only the App Store half that can move. But both of those assign + /// `customerInfo` themselves as they change — the redeemer when web + /// entitlements arrive, and the granted-entitlements setter on both its + /// paths — so the copy read during the window is never behind on them. That + /// leaves the device half, which is why only App Store products count. func purchasesLoadCouldChange(entitlementIds: Set) -> Bool } diff --git a/Tests/SuperwallKitTests/Dependencies/DependencyContainerInitTests.swift b/Tests/SuperwallKitTests/Dependencies/DependencyContainerInitTests.swift index 0cff7217c..c3d25b508 100644 --- a/Tests/SuperwallKitTests/Dependencies/DependencyContainerInitTests.swift +++ b/Tests/SuperwallKitTests/Dependencies/DependencyContainerInitTests.swift @@ -69,28 +69,28 @@ struct AppStoreEntitlementLookupTests { #expect(container.purchasesLoadCouldChange(entitlementIds: ["pro"])) } - @Test("A grant is only in reach for apps with a purchase controller") - func grantedEntitlementIsInReachOnlyWithPurchaseController() { + @Test("A granted web-only entitlement still skips the wait, with or without a controller") + func grantedWebOnlyEntitlementIsNotInReach() { let webOnly: Config = .stub() .setting( \.products, to: [makeProduct(id: "com.app.web", entitlementId: "web_only", isAppStore: false)] ) - // Without a purchase controller, setting a grant rebuilds `customerInfo` - // there and then, so the load has nothing to add. + // Setting a grant assigns `customerInfo` before the setter returns on both + // paths — `refreshAutomaticCustomerInfoAfterGrantChange` without a purchase + // controller, `refreshExternalControllerCustomerInfo` with one — so the + // load has nothing to add either way. let automatic = DependencyContainer() automatic.configManager.configState.send(.retrieved(webOnly)) automatic.entitlementsInfo.setGranted([Entitlement(id: "web_only")]) defer { automatic.entitlementsInfo.setGranted([]) } #expect(automatic.purchasesLoadCouldChange(entitlementIds: ["web_only"]) == false) - // With one, that refresh is skipped and the load is the only thing that - // folds the grant in. let controlled = DependencyContainer(purchaseController: MockPurchaseController()) controlled.configManager.configState.send(.retrieved(webOnly)) controlled.entitlementsInfo.setGranted([Entitlement(id: "web_only")]) defer { controlled.entitlementsInfo.setGranted([]) } - #expect(controlled.purchasesLoadCouldChange(entitlementIds: ["web_only"])) + #expect(controlled.purchasesLoadCouldChange(entitlementIds: ["web_only"]) == false) } } From bfbfde2e0df8292373187e5258632de16f4ff962 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:55:56 +0200 Subject: [PATCH 119/162] Update CHANGELOG.md --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d3dfc99f..6c348a482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,6 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes duplicate device attribute and subscription status change events being tracked when the subscription status is repeatedly set to the same logical state. As part of this, `subscriptionStatusDidChange` now fires only when the logical status changes — the status case, the set of entitlements, or an entitlement's `isActive` flag. Updates to transaction metadata such as expiry dates or renewal state no longer trigger it; use `customerInfoDidChange` for those. - Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. - Fixes slow cold launches for subscribers on a weak network by no longer fetching their purchased products from StoreKit before the SDK is ready. Applies to StoreKit 2. -- Fixes `register` calls stalling at cold launch for subscribers on a weak network. Applies to StoreKit 2. - Fixes a data race during SDK configuration that Thread Sanitizer flagged on every launch. - Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them. - Fixes audiences matching users they shouldn't when you use a Purchase Controller. From bf25d0dd3c1c2faa88932824f6295efb3c682d13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:37:19 +0200 Subject: [PATCH 120/162] Call them device identifiers, and repair them when an app overwrites them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Device attributes" already means the session device template — the thing getDeviceAttributes() returns and the device_attributes event carries. These three are identifiers plus a consent flag, so name them that way. The sync gate compared against what was last sent rather than what the user's attributes hold, so an app that removed idfv would keep it missing until an identifier itself changed. The public setters now tell the fetcher to forget that record when they touch one of the SDK's keys; the SDK's own sync goes through setDeviceIdentifierAttributes so it doesn't invalidate itself. Reading the attributes back instead would deadlock — identify holds the identity queue while resetIntegrationAttributes waits on the fetcher queue. Hold the vendor id behind a lock rather than a dispatch queue. Every request reads it for X-Vendor-ID, and a queue hop per read is a lot for a compare. Build the user attributes in one named place, so the difference between a null and a missing key is written down once instead of read out of a ?? chain. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 2 +- .../Attribution/AttributionFetcher.swift | 109 +++++++++++------- .../Identity/UserAttributes.swift | 16 +++ .../Network/Device Helper/DeviceHelper.swift | 19 +-- SuperwallKit.xcodeproj/project.pbxproj | 8 +- ...> AttributionDeviceIdentifiersTests.swift} | 55 +++++++-- 6 files changed, 146 insertions(+), 63 deletions(-) rename Tests/SuperwallKitTests/Analytics/Attribution/{AttributionDeviceAttributesTests.swift => AttributionDeviceIdentifiersTests.swift} (83%) diff --git a/CLAUDE.md b/CLAUDE.md index 354d1d8f0..58b693796 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,7 +115,7 @@ When creating PRs, always include the checklist from `.github/PULL_REQUEST_TEMPL - [ ] I have updated the SDK documentation as well as the online docs. - [ ] I have reviewed the [contributing guide](https://github.com/superwall-me/paywall-ios/tree/master/.github/CONTRIBUTING.md) -### Integration device attributes +### Integration device identifiers AttributionFetcher refreshes IDFV/IDFA/ATT when setting integration attributes and on app activation after an integration has been configured. Compare the complete diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift index c5259f39a..4d649c1ad 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift @@ -23,16 +23,16 @@ final class AttributionFetcher { private let vendorIdProvider: (() -> String)? private let attStatusProvider: (() -> Int?)? private let idfaProvider: (() -> String?)? - private let syncDeviceAttributes: ([String: Any?]) -> Void + private let syncDeviceIdentifiers: ([String: Any?]) -> Void private var _integrationAttributes: [String: String] = [:] - /// The last device snapshot handed to `syncDeviceAttributes`. Only a change + /// The identifiers last handed to `syncDeviceIdentifiers`. Only a change /// is worth syncing: every sync costs a `user_attributes` event, a delegate /// callback, a Core Data row and a re-encode of the whole attribute dict. - private var _lastSyncedDeviceAttributes: [String: String]? + private var _lastSyncedDeviceIdentifiers: [String: String]? /// The device keys the SDK owns in both integration and user attributes. - private static let deviceAttributeKeys = ["idfa", "idfv", "attStatus"] + private static let deviceIdentifierKeys = ["idfa", "idfv", "attStatus"] private unowned let storage: Storage private unowned let webEntitlementRedeemer: WebEntitlementRedeemer private unowned let deviceHelper: DeviceHelper @@ -152,11 +152,11 @@ final class AttributionFetcher { vendorIdProvider: (() -> String)? = nil, attStatusProvider: (() -> Int?)? = nil, idfaProvider: (() -> String?)? = nil, - syncDeviceAttributes: @escaping ([String: Any?]) -> Void = { - Superwall.shared.setUserAttributes($0) + syncDeviceIdentifiers: @escaping ([String: Any?]) -> Void = { + Superwall.shared.setDeviceIdentifierAttributes($0) } ) { - self.syncDeviceAttributes = syncDeviceAttributes + self.syncDeviceIdentifiers = syncDeviceIdentifiers self.vendorIdProvider = vendorIdProvider self.attStatusProvider = attStatusProvider self.idfaProvider = idfaProvider @@ -170,7 +170,7 @@ final class AttributionFetcher { object: nil, queue: nil ) { [weak self] _ in - self?.refreshDeviceAttributes() + self?.refreshDeviceIdentifiers() } } } @@ -242,20 +242,16 @@ final class AttributionFetcher { for (key, value) in attributes { mergedAttributes[key] = value } - let device = currentDeviceAttributes - for key in Self.deviceAttributeKeys { + let device = currentDeviceIdentifiers + for key in Self.deviceIdentifierKeys { mergedAttributes[key] = device[key] } // The router reads user attributes, not the integration_attributes event. // Explicit nulls clear an IDFA retained from before consent was revoked. - if device != _lastSyncedDeviceAttributes { - _lastSyncedDeviceAttributes = device - var userAttributes: [String: Any?] = [:] - for key in Self.deviceAttributeKeys { - userAttributes[key] = device[key].map { $0 as Any } ?? NSNull() - } - syncDeviceAttributes(userAttributes) + if device != _lastSyncedDeviceIdentifiers { + _lastSyncedDeviceIdentifiers = device + syncDeviceIdentifiers(Self.userAttributes(for: device)) } guard mergedAttributes != _integrationAttributes else { return false } @@ -272,8 +268,42 @@ final class AttributionFetcher { } } -// MARK: - Device attributes +// MARK: - Device identifiers extension AttributionFetcher { + /// The device identifiers as the user's attributes should carry them. + /// + /// A missing identifier is sent as an explicit `NSNull()` rather than left + /// out: `setUserAttributes` treats a Swift `nil` as "delete this key", which + /// would leave the server holding the last value it saw. A null overwrites + /// it, which is what clears an IDFA after consent is revoked. + private static func userAttributes(for device: [String: String]) -> [String: Any?] { + var userAttributes: [String: Any?] = [:] + for key in deviceIdentifierKeys { + if let value = device[key] { + userAttributes[key] = value + } else { + userAttributes[key] = NSNull() + } + } + return userAttributes + } + + /// Forgets what was last synced when something else writes to one of the + /// keys the SDK owns, so the next merge puts the SDK's value back. + /// + /// The sync gate compares against what the SDK last sent rather than what the + /// user's attributes actually hold, so without this an app that removed + /// `idfv` would keep it missing until an identifier itself changed — which on + /// a settled device may be never. + func forgetSyncedDeviceIdentifiers(ifTouching keys: [String]) { + if !keys.contains(where: { Self.deviceIdentifierKeys.contains($0) }) { + return + } + queue.async { [weak self] in + self?._lastSyncedDeviceIdentifiers = nil + } + } + /// The ATT authorization status, or `nil` where the OS has no such concept. private var attStatus: Int? { if let attStatusProvider { @@ -287,7 +317,7 @@ extension AttributionFetcher { return nil } - private var currentDeviceAttributes: [String: String] { + private var currentDeviceIdentifiers: [String: String] { var attributes: [String: String] = [:] let vendorId = vendorIdProvider?() ?? deviceHelper.vendorId @@ -310,7 +340,7 @@ extension AttributionFetcher { return attributes } - func refreshDeviceAttributes() { + func refreshDeviceIdentifiers() { queue.async { [weak self] in guard let self, !self._integrationAttributes.isEmpty else { return } if self._mergeIntegrationAttributes(attributes: [:]) { @@ -326,49 +356,44 @@ extension AttributionFetcher { /// whoever was just signed out, so they go. The install-scoped ones describe /// the same device either way and stay, along with the device identifiers. /// - /// A reset clears the user's attributes and deletes the stored copy of the - /// integration attributes, which live in the user-specific directory, while - /// the in-memory copy outlives it. So write what's kept back to disk and hand - /// all of it — not just what changed — to the new user, who has none of it. - /// Without the file the next cold launch would start empty and the activation - /// refresh would never run again, so a later consent change would never clear - /// the IDFA. + /// A reset clears the user's attributes and deletes the stored copy, which + /// lives in the user-specific directory, while the in-memory copy outlives + /// it. So write what's kept back to disk and hand all of it — not just what + /// changed — to the new user, who has none of it. Without the file the next + /// cold launch would start empty and the activation refresh would never run + /// again, so a later consent change would never clear the IDFA. /// - /// Runs synchronously. `identify` redeems for the new user immediately after - /// the reset, and that request reads the stored attributes rather than this - /// object, so they have to be settled before this returns. No redeem is - /// scheduled from here; the identity redeems on its own. + /// Runs synchronously: `identify` redeems for the new user right after the + /// reset, and that request reads the stored attributes rather than this + /// object. No redeem is scheduled here; the identity redeems on its own. func resetIntegrationAttributes() { queue.sync { if _integrationAttributes.isEmpty { return } var kept = _integrationAttributes.filter { key, _ in - Self.deviceAttributeKeys.contains(key) + Self.deviceIdentifierKeys.contains(key) || IntegrationAttribute.installScopedKeys.contains(key) } if kept.isEmpty { _integrationAttributes = [:] - _lastSyncedDeviceAttributes = nil + _lastSyncedDeviceIdentifiers = nil return } - let device = currentDeviceAttributes - for key in Self.deviceAttributeKeys { + let device = currentDeviceIdentifiers + for key in Self.deviceIdentifierKeys { kept[key] = device[key] } _integrationAttributes = kept - _lastSyncedDeviceAttributes = device + _lastSyncedDeviceIdentifiers = device storage.save(kept, forType: IntegrationAttributes.self) - var userAttributes: [String: Any?] = [:] - for (key, value) in kept where !Self.deviceAttributeKeys.contains(key) { + var userAttributes = Self.userAttributes(for: device) + for (key, value) in kept where !Self.deviceIdentifierKeys.contains(key) { userAttributes[key] = value } - for key in Self.deviceAttributeKeys { - userAttributes[key] = device[key].map { $0 as Any } ?? NSNull() - } - syncDeviceAttributes(userAttributes) + syncDeviceIdentifiers(userAttributes) } } } diff --git a/Sources/SuperwallKit/Identity/UserAttributes.swift b/Sources/SuperwallKit/Identity/UserAttributes.swift index 174b55907..17035570c 100644 --- a/Sources/SuperwallKit/Identity/UserAttributes.swift +++ b/Sources/SuperwallKit/Identity/UserAttributes.swift @@ -32,6 +32,19 @@ extension Superwall { /// Note: Keys beginning with `$` are reserved for Superwall and will be dropped. Arrays and dictionaries /// as values are not supported at this time, and will be dropped. public func setUserAttributes(_ attributes: [String: Any?]) { + dependencyContainer.attributionFetcher?.forgetSyncedDeviceIdentifiers( + ifTouching: Array(attributes.keys) + ) + mergeAttributes(attributes) + } + + /// Sets the device identifiers the SDK owns without treating the write as one + /// of the app's. + /// + /// `AttributionFetcher` skips a sync when the identifiers haven't changed + /// since it last sent them, and going through the public setter would tell it + /// to forget that and send them again on every merge. + func setDeviceIdentifierAttributes(_ attributes: [String: Any?]) { mergeAttributes(attributes) } @@ -63,6 +76,9 @@ extension Superwall { swiftDictionary[key] = keyValue } + dependencyContainer.attributionFetcher?.forgetSyncedDeviceIdentifiers( + ifTouching: keys + ) mergeAttributes(swiftDictionary) } diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index 0a1255093..44f0fef72 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -64,14 +64,16 @@ class DeviceHelper { UIDevice.modelName }() - @DispatchQueueBacked + private let vendorIdLock = NSLock() private var cachedVendorId = "" /// `identifierForVendor` is `nil` until the device has been unlocked once, so /// an app launched in the background before first unlock would be stuck with /// an empty id for the whole process if this were read only at init. Latch the /// first non-empty read instead, so a later read picks the id up once it - /// exists and every read after that is a plain load. + /// exists and every read after that is a compare behind an uncontended lock. + /// It has to stay that cheap: the `X-Vendor-ID` header reads it on every + /// request, and so do `makeDeviceId()` and `getTemplateDevice()`. /// /// This also settles `makeDeviceId()`, so the `$SuperwallDevice:` identity can /// change once mid-process in that window. That's the point: the value it @@ -80,13 +82,14 @@ class DeviceHelper { /// keeping stable or reconciling against. The window closes at first unlock, /// and the repeated `UIDevice` read inside it is cheap. var vendorId: String { - let cached = cachedVendorId - if !cached.isEmpty { - return cached + vendorIdLock.lock() + defer { vendorIdLock.unlock() } + + if !cachedVendorId.isEmpty { + return cachedVendorId } - let vendorId = UIDevice.current.identifierForVendor?.uuidString ?? "" - cachedVendorId = vendorId - return vendorId + cachedVendorId = UIDevice.current.identifierForVendor?.uuidString ?? "" + return cachedVendorId } var languageCode: String { diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 3fb11395c..ef67ef4d5 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -295,7 +295,6 @@ 8537CA38FFD40CF7C8A6A691 /* CustomStoreProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = C66CFEB3004DF2C3C3DB44FF /* CustomStoreProduct.swift */; }; 85728EABBC5C73193AC5F876 /* CustomURLSessionMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3506FCC35155DF104A1DFCA /* CustomURLSessionMock.swift */; }; 8583971F8E9E51E9B7A4FCC6 /* PurchasingCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB8384E2DB0A3627BE1CCB7D /* PurchasingCoordinator.swift */; }; - 86C3495D495F42ACE23AE051 /* AttributionDeviceAttributesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7873C89B06D81CB18A116E7D /* AttributionDeviceAttributesTests.swift */; }; 87B66787F6EB43DA80667C36 /* PageViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E321E7EEC07CA9A8B9A5619 /* PageViewData.swift */; }; 880BBB2099D3112F256E6AE2 /* IntroOfferEligibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93FACE677755EAA3EA4E67A8 /* IntroOfferEligibility.swift */; }; 88A5CA6515126BD3D09E0563 /* LimitedQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B921746BEC8F63DDB65C634 /* LimitedQueue.swift */; }; @@ -450,6 +449,7 @@ C86B9D3992BBD1E8A1105F3C /* SuperwallDelegateObjc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33A9E0597972B90E3B9DFFE1 /* SuperwallDelegateObjc.swift */; }; C8BCF3C0404622139D002893 /* PushTransitionLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC74F16DC5A17489E97061EA /* PushTransitionLogic.swift */; }; C90E075C3BEE92968BBC4E1B /* LocalizationConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67C4FC41FEE0B47EA402D738 /* LocalizationConfig.swift */; }; + C9766A8FAF76F716D5F16BD2 /* AttributionDeviceIdentifiersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C6C8966D32B889BEA06F70 /* AttributionDeviceIdentifiersTests.swift */; }; C9BE2675C944542B6282E5B5 /* SWProductDiscount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62AC69B94A568B7E14A391A8 /* SWProductDiscount.swift */; }; C9C5355AE4077E6A021F30FF /* IdentityInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C1EB433A4E4342E03BB7744 /* IdentityInfo.swift */; }; C9F6A490E3E09401494E7DCB /* CheckDebuggerPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9777790D2B73EFF94E7C648 /* CheckDebuggerPresentation.swift */; }; @@ -759,6 +759,7 @@ 4078AB25B84478F020D1B055 /* ReceiptManagerPurchasedProductFetchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptManagerPurchasedProductFetchTests.swift; sourceTree = ""; }; 40AE19B5A9B237A2552D5F36 /* IdentityLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityLogicTests.swift; sourceTree = ""; }; 42956918D4FFA5FBA79F3AA5 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + 43C6C8966D32B889BEA06F70 /* AttributionDeviceIdentifiersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributionDeviceIdentifiersTests.swift; sourceTree = ""; }; 440ABDF6DAE2C15579B93DF1 /* PushTransitionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushTransitionDelegate.swift; sourceTree = ""; }; 45AFD6EE9BED296D075A9618 /* ASN1Templates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ASN1Templates.swift; sourceTree = ""; }; 45B3BC4249A9E9BA8E99EC7C /* CustomCallbackRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCallbackRegistry.swift; sourceTree = ""; }; @@ -886,7 +887,6 @@ 76D61D89D2905A8747E37458 /* InterfaceStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterfaceStyle.swift; sourceTree = ""; }; 77827761E38CB30B06E3ABF2 /* MMPAttributionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMPAttributionManager.swift; sourceTree = ""; }; 779C974DE83DCC78D75A59DC /* ConfigManagerEarlyPublishTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigManagerEarlyPublishTests.swift; sourceTree = ""; }; - 7873C89B06D81CB18A116E7D /* AttributionDeviceAttributesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributionDeviceAttributesTests.swift; sourceTree = ""; }; 787764B249892BBCA1088235 /* StorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageTests.swift; sourceTree = ""; }; 78C15CF29C17FE1EE3BFDEDC /* SubscriptionStatusResolutionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionStatusResolutionTests.swift; sourceTree = ""; }; 797EC0356AA1065ED11835BF /* PendingStripeCheckoutPollState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingStripeCheckoutPollState.swift; sourceTree = ""; }; @@ -2970,7 +2970,7 @@ isa = PBXGroup; children = ( A82783401B92298C47BF14F7 /* AdServicesAttributionTests.swift */, - 7873C89B06D81CB18A116E7D /* AttributionDeviceAttributesTests.swift */, + 43C6C8966D32B889BEA06F70 /* AttributionDeviceIdentifiersTests.swift */, 6B7CFAF4B3E32AE628A249C8 /* AttributionTests.swift */, ); path = Attribution; @@ -3319,7 +3319,7 @@ A9FC64A249BF2242BB526521 /* AppStoreProductTests.swift in Sources */, 59685CE55D34FA6A96A8F890 /* AssignmentLogicTests.swift in Sources */, BC8A62869C7BACE6D0867195 /* AssignmentTests.swift in Sources */, - 86C3495D495F42ACE23AE051 /* AttributionDeviceAttributesTests.swift in Sources */, + C9766A8FAF76F716D5F16BD2 /* AttributionDeviceIdentifiersTests.swift in Sources */, 3CD2C23BAC2EA11174237785 /* AttributionTests.swift in Sources */, D5C9A71CFB166225C082CAFB /* AutomaticPurchaseControllerTests.swift in Sources */, B0DC8290B081B74CC65E9305 /* CELEvaluatorTests.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceIdentifiersTests.swift similarity index 83% rename from Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift rename to Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceIdentifiersTests.swift index 62651534b..8242b6645 100644 --- a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceAttributesTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceIdentifiersTests.swift @@ -3,7 +3,7 @@ import Testing @testable import SuperwallKit @Suite(.serialized) -struct AttributionDeviceAttributesTests { +struct AttributionDeviceIdentifiersTests { private func makeFetcher( container: DependencyContainer, vendorId: @escaping () -> String = { "vendor-1" }, @@ -21,7 +21,7 @@ struct AttributionDeviceAttributesTests { vendorIdProvider: vendorId, attStatusProvider: attStatus, idfaProvider: idfa, - syncDeviceAttributes: sync + syncDeviceIdentifiers: sync ) } @@ -73,7 +73,7 @@ struct AttributionDeviceAttributesTests { status = 3 idfa = "advertiser-1" - fetcher.refreshDeviceAttributes() + fetcher.refreshDeviceIdentifiers() #expect(fetcher.integrationAttributes["attStatus"] == "3") #expect(fetcher.integrationAttributes["idfa"] == "advertiser-1") } @@ -140,14 +140,14 @@ struct AttributionDeviceAttributesTests { #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") #expect(syncCount == 1) - fetcher.refreshDeviceAttributes() - fetcher.refreshDeviceAttributes() + fetcher.refreshDeviceIdentifiers() + fetcher.refreshDeviceIdentifiers() fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) #expect(fetcher.integrationAttributes["idfa"] == "advertiser-1") #expect(syncCount == 1) status = 2 - fetcher.refreshDeviceAttributes() + fetcher.refreshDeviceIdentifiers() #expect(fetcher.integrationAttributes["attStatus"] == "2") #expect(syncCount == 2) } @@ -237,6 +237,45 @@ struct AttributionDeviceAttributesTests { #expect(container.storage.get(IntegrationAttributes.self) == nil) } + @Test func resendsTheIdentifiersWhenSomethingElseDropsThem() { + let container = DependencyContainer() + var syncCount = 0 + let fetcher = makeFetcher(container: container, sync: { _ in syncCount += 1 }) + defer { fetcher.cancelPendingOperations() } + + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 1) + + // Nothing about the device changed, so there's nothing to say. + fetcher.refreshDeviceIdentifiers() + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 1) + + // But an app that writes over one of the SDK's keys has to be answered, + // otherwise the router loses the identifier until one of them changes. + fetcher.forgetSyncedDeviceIdentifiers(ifTouching: ["email", "idfv"]) + fetcher.refreshDeviceIdentifiers() + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 2) + } + + @Test func keepsQuietWhenTheAppWritesItsOwnAttributes() { + let container = DependencyContainer() + var syncCount = 0 + let fetcher = makeFetcher(container: container, sync: { _ in syncCount += 1 }) + defer { fetcher.cancelPendingOperations() } + + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 1) + + fetcher.forgetSyncedDeviceIdentifiers(ifTouching: ["email", "name"]) + fetcher.refreshDeviceIdentifiers() + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 1) + } + @Test func everyIntegrationAttributeIsScoped() { // Every case lands on one side of the split — the exhaustive switch in // `isInstallScoped` forces that. Bump both counts when adding a case, so @@ -251,12 +290,12 @@ struct AttributionDeviceAttributesTests { } @Test func resetDropsThePersonScopedAttributesWaitingOnTheTransactionId() { - let superwall = Superwall.shared + let container = DependencyContainer() + let superwall = Superwall(dependencyContainer: container) superwall.enqueuedIntegrationAttributes = [ .appsflyerId: "af-1", .amplitudeUserId: "person-1" ] - defer { superwall.enqueuedIntegrationAttributes = nil } // Called directly rather than through `reset()`, whose storage wipe and // config reset would reach well beyond this suite. From 95e2eaf3e45179c06541b605df2a31c3cef0cf3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:59:05 +0200 Subject: [PATCH 121/162] Lock status reads, emit in store order, and publish corrected state during the early-publish window Addresses the Greptile and Pullfrog review on the 4.17.0 release PR. - `subscriptionStatus` and `assignedSubscriptionStatus` are now read under `subscriptionStatusLock`, the same lock every writer takes. The public getter, the automatic purchase controller and the device helper were reading them unlocked. - Stored statuses are queued under the lock and drained in store order by one thread at a time. Each value is emitted exactly once, never behind a newer one, and no writer waits on a subscriber, so a subscriber that blocks on another thread can't deadlock a writer there. Previously two concurrent writers could both emit the later value and drop the earlier transition. - `objectWillChange.send()` moves out of the critical section to the emission, matching the documented invariant. - `subscriptionStatusDidSet` is handed the merged value while the lock is held, so publishes reach the entitlements queue in store order. - The cold-launch restore now publishes the lapse-corrected status and customer info the same way the read does, so audience filters evaluated while config is published early see the same state as the entitlement map instead of the previous launch's copy. - The `$subscriptionStatus` type change is listed under Breaking Changes. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 5 +- .../PublishedSubscriptionStatus.swift | 8 +- .../ReceiptManager+Restore.swift | 43 +++++ .../Receipt Manager/ReceiptManager.swift | 2 +- .../SubscriptionStatusPublishing.swift | 8 +- Sources/SuperwallKit/Superwall.swift | 74 ++++++-- SuperwallKit.xcodeproj/project.pbxproj | 4 + .../ConfigManagerEarlyPublishTests.swift | 51 +++++- .../SubscriptionStatusEmissionTests.swift | 165 ++++++++++++++++++ 9 files changed, 335 insertions(+), 25 deletions(-) create mode 100644 Tests/SuperwallKitTests/StoreKit/Products/SubscriptionStatusEmissionTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 697b21492..44321049f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,13 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ## 4.17.0 +### Breaking Changes + +- Changes `$subscriptionStatus` from a `@Published` publisher to an `AnyPublisher`. Subscribing to it works as before, but it can no longer be the target of `assign(to:)`. + ### Enhancements - Adds `grantedEntitlements` so you can grant entitlements from your own backend, which the SDK merges with device and web entitlements. -- Changes `$subscriptionStatus` from a `@Published` publisher to an `AnyPublisher`. Subscribing to it works as before, but it can no longer be the target of `assign(to:)`. ### Fixes diff --git a/Sources/SuperwallKit/StoreKit/Products/PublishedSubscriptionStatus.swift b/Sources/SuperwallKit/StoreKit/Products/PublishedSubscriptionStatus.swift index c306d610c..5bbdbb59c 100644 --- a/Sources/SuperwallKit/StoreKit/Products/PublishedSubscriptionStatus.swift +++ b/Sources/SuperwallKit/StoreKit/Products/PublishedSubscriptionStatus.swift @@ -14,7 +14,9 @@ import Foundation /// stored and published value is always the merged one — subscribers never /// see the value a writer assigned before granted entitlements and test-mode /// overrides were applied. The projected value (`$subscriptionStatus`) replays -/// the current value to new subscribers, like `@Published`. +/// the current value to new subscribers, like `@Published`. Reads take the +/// same lock as writes, so a read on one thread never races an assignment +/// on another. /// /// Public only because a public property's wrapper type has to be; nothing /// but the projection is meant to be used. The enclosing-instance subscript @@ -45,6 +47,10 @@ public struct PublishedSubscriptionStatus { storage storageKeyPath: ReferenceWritableKeyPath ) -> SubscriptionStatus { get { + superwall.subscriptionStatusLock.lock() + defer { + superwall.subscriptionStatusLock.unlock() + } return superwall[keyPath: storageKeyPath].storage } set { diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift index e35606708..4edef5bed 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift @@ -79,6 +79,32 @@ extension ReceiptManager { Superwall.shared.entitlements.setEntitlementsFromConfig(entitlementsByProductId) activeSubscriptionGroupIds = Set(activeSubscriptions.compactMap { $0.subscriptionGroupId }) + + // The customer info and status were restored from disk exactly as saved, so + // a row that lapsed since the last launch still reads active through them. + // Publish both lapse-corrected, the way the read does when it lands: the + // status via the same delegate call, from the seeded purchases and the + // corrected entitlement map. Audience filters read these while config is + // already published, so they see the same state as `entitlementsByProductId`. + let subscriptions = customerInfo.subscriptions.map { subscription -> SubscriptionTransaction in + let stillActive: Bool + if subscription.store == .appStore { + stillActive = activeTransactionIds.contains(subscription.transactionId) + } else { + stillActive = subscription.isActive && !hasExpired(subscription.expirationDate, at: now) + } + return subscription.isActive && !stillActive ? deactivated(subscription) : subscription + } + let restoredCustomerInfo = CustomerInfo( + subscriptions: subscriptions, + nonSubscriptions: customerInfo.nonSubscriptions, + entitlements: merged.sorted { $0.id < $1.id }, + isPlaceholder: customerInfo.isPlaceholder + ) + await MainActor.run { + Superwall.shared.customerInfo = restoredCustomerInfo + } + await receiptDelegate?.syncSubscriptionStatus(purchases: purchases) } /// A nil expiry never lapses: lifetime purchases and web entitlements without one. @@ -89,6 +115,23 @@ extension ReceiptManager { return expiresAt <= now } + private func deactivated(_ subscription: SubscriptionTransaction) -> SubscriptionTransaction { + return SubscriptionTransaction( + transactionId: subscription.transactionId, + productId: subscription.productId, + purchaseDate: subscription.purchaseDate, + willRenew: subscription.willRenew, + isRevoked: subscription.isRevoked, + isInGracePeriod: subscription.isInGracePeriod, + isInBillingRetryPeriod: subscription.isInBillingRetryPeriod, + isActive: false, + expirationDate: subscription.expirationDate, + offerType: subscription.offerType, + subscriptionGroupId: subscription.subscriptionGroupId, + store: subscription.store + ) + } + private func deactivated(_ entitlement: Entitlement) -> Entitlement { return Entitlement( id: entitlement.id, diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift index a0dd94efb..9d968e3ec 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift @@ -23,7 +23,7 @@ actor ReceiptManager { private var receiptRefreshCompletion: ((Bool) -> Void)? private unowned let productsManager: ProductsManager - private weak var receiptDelegate: ReceiptDelegate? + weak var receiptDelegate: ReceiptDelegate? private let storeKitVersion: SuperwallOptions.StoreKitVersion private let shouldBypassAppTransactionCheck: Bool let manager: ReceiptManagerType diff --git a/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift index a46334c03..d82abc06e 100644 --- a/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift +++ b/Sources/SuperwallKit/StoreKit/Products/SubscriptionStatusPublishing.swift @@ -26,7 +26,8 @@ import Foundation /// Every writer — the public setter included, via ``PublishedSubscriptionStatus`` /// — ends up in `publishSubscriptionStatus`, the one critical section under /// `subscriptionStatusLock`. The merged value is stored under the lock and -/// emitted to subscribers after it's released. +/// emitted to subscribers after it's released. Every read of either value +/// takes the same lock, so readers never race a writer. extension Superwall { // MARK: - Granted entitlements @@ -189,6 +190,10 @@ extension Superwall { if previouslyAssigned != assigned { dependencyContainer.storage.save(assigned, forType: SubscriptionStatusKey.self) } + // Hands the merged value to the entitlements queue while the lock is + // still held, so two publishes reach it in the order they were stored. + // It only enqueues, so nothing waits on it. + entitlements.subscriptionStatusDidSet(merged) subscriptionStatusLock.unlock() // Subscribers and customer info observers run outside the lock, so a @@ -196,7 +201,6 @@ extension Superwall { if statusChanged { emitSubscriptionStatus() } - entitlements.subscriptionStatusDidSet(subscriptionStatus) // When using an external purchase controller, update CustomerInfo.entitlements // to reflect the entitlements from the purchase controller. diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index fb40e843c..e088d9fa3 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -230,41 +230,79 @@ public final class Superwall: NSObject, ObservableObject { /// before granted entitlements and test-mode overrides are merged in. /// ``subscriptionStatus`` is always derived from this, never the reverse, /// so clearing granted entitlements can recompute it. - var assignedSubscriptionStatus: SubscriptionStatus = .unknown + /// + /// Read and written under `subscriptionStatusLock`, like ``subscriptionStatus``. + var assignedSubscriptionStatus: SubscriptionStatus { + get { + subscriptionStatusLock.lock() + defer { + subscriptionStatusLock.unlock() + } + return lockedAssignedSubscriptionStatus + } + set { + subscriptionStatusLock.lock() + lockedAssignedSubscriptionStatus = newValue + subscriptionStatusLock.unlock() + } + } + private var lockedAssignedSubscriptionStatus: SubscriptionStatus = .unknown - /// Serializes status assignment and publishing across threads. Recursive so + /// Serializes every read and write of the status across threads. Recursive so /// a subscriber that assigns the status from within an emission re-enters /// the pipeline instead of deadlocking. let subscriptionStatusLock = NSRecursiveLock() + /// Merged statuses that have been stored but not yet emitted, oldest first. + /// Guarded by `subscriptionStatusLock`. + private var pendingSubscriptionStatusEmissions: [SubscriptionStatus] = [] + + /// Whether a thread is currently emitting the pending statuses. Guarded by + /// `subscriptionStatusLock`. + private var isEmittingSubscriptionStatus = false + /// Whether the one-time warning about granted entitlements overriding an /// `.inactive` assignment has been logged. var hasLoggedGrantedEntitlementsWarning = false - /// Stores the merged status. Only `publishSubscriptionStatus` calls this, - /// with `subscriptionStatusLock` held; the emission follows in - /// `emitSubscriptionStatus()` once the lock is released. + /// Stores the merged status and queues it for emission. Only + /// `publishSubscriptionStatus` calls this, with `subscriptionStatusLock` + /// held; `emitSubscriptionStatus()` sends it once the lock is released. func storeMergedSubscriptionStatus(_ merged: SubscriptionStatus) { - objectWillChange.send() _subscriptionStatus.store(merged) + pendingSubscriptionStatusEmissions.append(merged) } - /// Emits the stored status to `$subscriptionStatus` subscribers. Called - /// after the lock is released, so subscribers never run inside the SDK's - /// critical section. A store that landed on another thread between the - /// read and the emission is emitted again afterwards, so the publisher's - /// current value can't end up behind the stored one. + /// Emits every stored status to `$subscriptionStatus` subscribers in the + /// order it was stored, with the lock released while subscribers run. + /// + /// One thread drains the queue at a time. A writer that stores while a + /// drain is in progress returns straight away and the draining thread + /// sends its value next, so each value is emitted once, none is emitted + /// behind a newer one, and no writer ever waits on a subscriber. That last + /// point is what stops a subscriber that blocks on another thread from + /// deadlocking a writer on that thread. func emitSubscriptionStatus() { subscriptionStatusLock.lock() - let emitted = subscriptionStatus + if isEmittingSubscriptionStatus { + subscriptionStatusLock.unlock() + return + } + isEmittingSubscriptionStatus = true subscriptionStatusLock.unlock() - _subscriptionStatus.emit(emitted) - subscriptionStatusLock.lock() - let newest = subscriptionStatus - subscriptionStatusLock.unlock() - if newest != emitted { - _subscriptionStatus.emit(newest) + while true { + subscriptionStatusLock.lock() + if pendingSubscriptionStatusEmissions.isEmpty { + isEmittingSubscriptionStatus = false + subscriptionStatusLock.unlock() + return + } + let next = pendingSubscriptionStatusEmissions.removeFirst() + subscriptionStatusLock.unlock() + + objectWillChange.send() + _subscriptionStatus.emit(next) } } diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 931202d17..91cca715b 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -299,6 +299,7 @@ 880BBB2099D3112F256E6AE2 /* IntroOfferEligibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93FACE677755EAA3EA4E67A8 /* IntroOfferEligibility.swift */; }; 88A5CA6515126BD3D09E0563 /* LimitedQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B921746BEC8F63DDB65C634 /* LimitedQueue.swift */; }; 88D22C84ACDED44E3952C786 /* SK2ObserverModePurchaseDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EC8705042D6AA74D40350A9 /* SK2ObserverModePurchaseDetector.swift */; }; + 899C32DE12E630CE296556FA /* SubscriptionStatusEmissionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6765992D172AD32F53E008BD /* SubscriptionStatusEmissionTests.swift */; }; 89CC491C60F7CD12D3E73284 /* SurveyManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B002FEEF20120D3A6B2AE923 /* SurveyManagerTests.swift */; }; 8ACC4731031DA94C709915CF /* Transaction+LatestSince.swift in Sources */ = {isa = PBXBuildFile; fileRef = F36CB341B28F250F5252A8DF /* Transaction+LatestSince.swift */; }; 8AEB577682D9AB9354CB8EE9 /* UIColor+Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2888B05A273E9EB6E9382332 /* UIColor+Hex.swift */; }; @@ -839,6 +840,7 @@ 672776875A4286319C2F2D61 /* PaywallViewControllerCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewControllerCacheTests.swift; sourceTree = ""; }; 6752063E4547657E20072CE7 /* ConfirmHoldoutAssignment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmHoldoutAssignment.swift; sourceTree = ""; }; 67602AF9B2543CAD0B42F3CF /* CacheMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheMock.swift; sourceTree = ""; }; + 6765992D172AD32F53E008BD /* SubscriptionStatusEmissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionStatusEmissionTests.swift; sourceTree = ""; }; 67C4FC41FEE0B47EA402D738 /* LocalizationConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationConfig.swift; sourceTree = ""; }; 67D9C2B45E15025BF7D783AD /* zh_Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = zh_Hans; path = zh_Hans.lproj/Localizable.strings; sourceTree = ""; }; 682AB10207309C439F64BC69 /* PlacementsQueueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlacementsQueueTests.swift; sourceTree = ""; }; @@ -1613,6 +1615,7 @@ BD6BA222CB2EAA4B65F362C5 /* ProductsFetcherSK1.swift */, 0ECD75DF8F3EB6A68A21444D /* ProductsFetcherSK2Tests.swift */, B00929DACD8621FC32F83927 /* SK2StoreProductCyclesTests.swift */, + 6765992D172AD32F53E008BD /* SubscriptionStatusEmissionTests.swift */, A1D175537791B3A913425F88 /* SubscriptionStatusLogicalEqualityTests.swift */, 0E9A13ACB22951C865722510 /* Receipt Manager */, 9160DE1504D8084C3DF51ADC /* StoreProduct */, @@ -3429,6 +3432,7 @@ B162BE92B3568078BC0ADD1B /* StoreProductBillingPlanTests.swift in Sources */, 5E51E14716E29C9B88B8A6F2 /* StripeTrialEligibilityTests.swift in Sources */, 097719E21BBD153BA6FD6785 /* SubscriptionPeriodPriceTests.swift in Sources */, + 899C32DE12E630CE296556FA /* SubscriptionStatusEmissionTests.swift in Sources */, 1B071A6918B1B36BF6BCC0C2 /* SubscriptionStatusLogicalEqualityTests.swift in Sources */, E9F892ABB9BDA85F4794E3CF /* SubscriptionStatusResolutionTests.swift in Sources */, 89CC491C60F7CD12D3E73284 /* SurveyManagerTests.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift index 67ccd2c9f..f8f091244 100644 --- a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift +++ b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift @@ -208,7 +208,8 @@ struct ConfigManagerEarlyPublishTests { storeKitVersion: SuperwallOptions.StoreKitVersion = .storeKit2, isSubscribed: Bool, savedCustomerInfo: CustomerInfo?, - loadDelay: TimeInterval + loadDelay: TimeInterval, + receiptDelegate: ReceiptDelegate? = nil ) -> Harness { let dependencyContainer = container ?? self.dependencyContainer let storage = StorageMock() @@ -241,7 +242,7 @@ struct ConfigManagerEarlyPublishTests { shouldBypassAppTransactionCheck: true, productsManager: productsManager, receiptManager: receipt, - receiptDelegate: nil, + receiptDelegate: receiptDelegate, factory: dependencyContainer, storage: storage ) @@ -407,6 +408,52 @@ struct ConfigManagerEarlyPublishTests { await settle() } + @Test("The restore publishes the lapse-corrected status and customer info") + func restorePublishesCorrectedStatusAndCustomerInfo() async { + // The controller reads the entitlement map the restore writes, which + // belongs to the shared instance, so it has to look at that one. + let purchaseController = AutomaticPurchaseController( + factory: dependencyContainer, + entitlementsInfo: Superwall.shared.entitlements + ) + let harness = makeHarness( + isSubscribed: true, + savedCustomerInfo: savedCustomerInfoWithLapsedSecondSubscription(), + loadDelay: 2, + receiptDelegate: purchaseController + ) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + _ = await waitForConfig(harness.configManager, timeout: 1.5) + #expect(!harness.receipt.didFinishLoad, "test needs config to be published mid-load") + + // The status is rebuilt from the seeded purchases, so silver's `pro` + // is active and the lapsed `legacy` isn't part of it. The shared + // instance can carry web entitlements from other suites, so only these + // two are checked. + guard case .active(let entitlements) = Superwall.shared.subscriptionStatus else { + Issue.record("expected an active status, got \(Superwall.shared.subscriptionStatus)") + await fetch.value + await settle() + return + } + #expect(entitlements.contains { $0.id == "pro" && $0.isActive }) + #expect(!entitlements.contains { $0.id == "legacy" }) + + // The customer info carries every saved row, with the lapsed one corrected. + let customerInfo = Superwall.shared.customerInfo + let legacy = customerInfo.entitlements.first { $0.id == "legacy" } + #expect(legacy?.isActive == false) + #expect(legacy?.willRenew == true, "the rest of the saved copy carries over") + #expect(customerInfo.entitlements.first { $0.id == "pro" }?.isActive == true) + #expect(customerInfo.subscriptions.first { $0.productId == Self.legacyProductId }?.isActive == false) + #expect(customerInfo.subscriptions.first { $0.productId == Self.silverProductId }?.isActive == true) + #expect(customerInfo.subscriptions.first { $0.productId == Self.webProductId }?.isActive == true) + + await fetch.value + await settle() + } + @Test("The read replaces everything the restore seeded") func readReplacesRestoredState() async { // The stand-in read returns an empty snapshot, so anything still showing diff --git a/Tests/SuperwallKitTests/StoreKit/Products/SubscriptionStatusEmissionTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/SubscriptionStatusEmissionTests.swift new file mode 100644 index 000000000..e40cca368 --- /dev/null +++ b/Tests/SuperwallKitTests/StoreKit/Products/SubscriptionStatusEmissionTests.swift @@ -0,0 +1,165 @@ +// +// SubscriptionStatusEmissionTests.swift +// SuperwallKit +// +// Created by Yusuf Tör on 2026-09-16. +// +// swiftlint:disable all + +@testable import SuperwallKit +import Combine +import Testing +import Foundation + +/// How `$subscriptionStatus` emits when the status is read and written from +/// more than one thread at once. +@Suite(.serialized) +final class SubscriptionStatusEmissionTests { + private let dependencyContainer: DependencyContainer + private let superwall: Superwall + + init() { + dependencyContainer = DependencyContainer(cache: CacheMock()) + superwall = Superwall(dependencyContainer: dependencyContainer) + } + + private func status(_ id: String) -> SubscriptionStatus { + return .active([Entitlement(id: id, type: .serviceLevel, isActive: true)]) + } + + /// Every value that concurrent writers store reaches subscribers once, in + /// the order it was stored, and the last emission is the stored value. + @Test + func concurrentWrites_emitEveryStoredValueInOrder() { + let emissions = Locked<[SubscriptionStatus]>([]) + let cancellable = superwall.$subscriptionStatus + .dropFirst() + .sink { emittedStatus in emissions.mutate { $0.append(emittedStatus) } } + + let writeCount = 200 + DispatchQueue.concurrentPerform(iterations: writeCount) { index in + superwall.subscriptionStatus = status("entitlement_\(index)") + } + + let emitted = emissions.value + #expect(emitted.count == writeCount, "each distinct value is emitted exactly once") + let emittedIds = emitted.compactMap { emittedStatus -> String? in + guard case .active(let entitlements) = emittedStatus else { + return nil + } + return entitlements.first?.id + } + #expect(Set(emittedIds).count == writeCount, "no value is emitted twice") + #expect(emitted.last == superwall.subscriptionStatus, "the publisher never ends behind storage") + cancellable.cancel() + } + + /// A subscriber that assigns the status from inside an emission neither + /// deadlocks nor reorders: its value is emitted after the one that + /// triggered it. + @Test + func reentrantAssignment_isEmittedAfterTheTriggeringValue() { + let emissions = Locked<[SubscriptionStatus]>([]) + let cancellable = superwall.$subscriptionStatus + .dropFirst() + .sink { [superwall] emittedStatus in + emissions.mutate { $0.append(emittedStatus) } + if emittedStatus == self.status("first") { + superwall.subscriptionStatus = self.status("second") + } + } + + superwall.subscriptionStatus = status("first") + + #expect(emissions.value == [status("first"), status("second")]) + #expect(superwall.subscriptionStatus == status("second")) + cancellable.cancel() + } + + /// A writer whose value is emitted by another thread's drain doesn't wait + /// for that thread's subscribers, so a subscriber blocked on the writer's + /// thread can't deadlock it. + @Test + func writerNeverWaitsOnASubscriber() async { + let subscriberEntered = DispatchSemaphore(value: 0) + let releaseSubscriber = DispatchSemaphore(value: 0) + let cancellable = superwall.$subscriptionStatus + .dropFirst() + .sink { [superwall] emittedStatus in + if emittedStatus == self.status("blocking") { + subscriberEntered.signal() + releaseSubscriber.wait() + _ = superwall.subscriptionStatus + } + } + + let background = Task.detached { [superwall] in + superwall.subscriptionStatus = self.status("blocking") + } + subscriberEntered.wait() + + // The emitting thread is stuck inside its subscriber. This write must + // return without waiting for it. + let writeReturned = Task.detached { [superwall] in + superwall.subscriptionStatus = self.status("meanwhile") + return true + } + let didReturn = await withTaskGroup(of: Bool.self) { group -> Bool in + group.addTask { await writeReturned.value } + group.addTask { + try? await Task.sleep(nanoseconds: 2_000_000_000) + return false + } + let first = await group.next() ?? false + group.cancelAll() + return first + } + #expect(didReturn, "the writer waited on a blocked subscriber") + + releaseSubscriber.signal() + await background.value + #expect(superwall.subscriptionStatus == status("meanwhile")) + cancellable.cancel() + } + + /// Reads and writes from many threads at once don't tear or crash. + @Test + func concurrentReadsAndWrites_staySafe() { + DispatchQueue.concurrentPerform(iterations: 500) { index in + if index.isMultiple(of: 2) { + superwall.subscriptionStatus = status("entitlement_\(index)") + } else { + _ = superwall.subscriptionStatus + _ = superwall.assignedSubscriptionStatus + } + } + if case .active(let entitlements) = superwall.subscriptionStatus { + #expect(entitlements.count == 1) + } else { + Issue.record("expected an active status") + } + #expect(superwall.assignedSubscriptionStatus == superwall.subscriptionStatus) + } +} + +/// A value guarded by a lock, for collecting emissions off arbitrary threads. +private final class Locked: @unchecked Sendable { + private let lock = NSLock() + private var storage: Value + + init(_ value: Value) { + storage = value + } + + var value: Value { + lock.lock() + defer { lock.unlock() } + return storage + } + + func mutate(_ body: (inout Value) -> Void) { + lock.lock() + defer { lock.unlock() } + body(&storage) + } +} From 1f44b3d3597f24a1a58cb4452b4d1ea421850d93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:05:09 +0200 Subject: [PATCH 122/162] Judge an overwrite by the value, not just the key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forget hook sat on the public setter, but the SDK reaches user attributes through that same door — the enrichment response writes the whole user object straight back, so every cold launch would have forced a resync if that response echoes these keys. Compare what the write carries against what was last sent: a write that agrees isn't an overwrite, whoever made it. That also retires the internal bypass, which nothing could have caught being reverted. Move the key list and the payload helpers to their own type. They need none of the fetcher's insides, and it gives the distinction from the device attributes in getDeviceAttributes() somewhere to live. Say on setUserAttributes that the SDK owns these three keys, since that's what Xcode shows at the call site. Co-Authored-By: Claude Opus 5 --- .../Attribution/AttributionFetcher.swift | 71 +++++++++---------- .../Attribution/DeviceIdentifiers.swift | 52 ++++++++++++++ .../Identity/UserAttributes.swift | 18 ++--- SuperwallKit.xcodeproj/project.pbxproj | 4 ++ .../AttributionDeviceIdentifiersTests.swift | 47 +++++++++++- 5 files changed, 140 insertions(+), 52 deletions(-) create mode 100644 Sources/SuperwallKit/Analytics/Attribution/DeviceIdentifiers.swift diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift index 4d649c1ad..db6fbff27 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift @@ -23,16 +23,14 @@ final class AttributionFetcher { private let vendorIdProvider: (() -> String)? private let attStatusProvider: (() -> Int?)? private let idfaProvider: (() -> String?)? - private let syncDeviceIdentifiers: ([String: Any?]) -> Void + private let syncUserAttributes: ([String: Any?]) -> Void private var _integrationAttributes: [String: String] = [:] - /// The identifiers last handed to `syncDeviceIdentifiers`. Only a change + /// The identifiers last handed to `syncUserAttributes`. Only a change /// is worth syncing: every sync costs a `user_attributes` event, a delegate /// callback, a Core Data row and a re-encode of the whole attribute dict. private var _lastSyncedDeviceIdentifiers: [String: String]? - /// The device keys the SDK owns in both integration and user attributes. - private static let deviceIdentifierKeys = ["idfa", "idfv", "attStatus"] private unowned let storage: Storage private unowned let webEntitlementRedeemer: WebEntitlementRedeemer private unowned let deviceHelper: DeviceHelper @@ -152,11 +150,11 @@ final class AttributionFetcher { vendorIdProvider: (() -> String)? = nil, attStatusProvider: (() -> Int?)? = nil, idfaProvider: (() -> String?)? = nil, - syncDeviceIdentifiers: @escaping ([String: Any?]) -> Void = { - Superwall.shared.setDeviceIdentifierAttributes($0) + syncUserAttributes: @escaping ([String: Any?]) -> Void = { + Superwall.shared.setUserAttributes($0) } ) { - self.syncDeviceIdentifiers = syncDeviceIdentifiers + self.syncUserAttributes = syncUserAttributes self.vendorIdProvider = vendorIdProvider self.attStatusProvider = attStatusProvider self.idfaProvider = idfaProvider @@ -243,7 +241,7 @@ final class AttributionFetcher { mergedAttributes[key] = value } let device = currentDeviceIdentifiers - for key in Self.deviceIdentifierKeys { + for key in DeviceIdentifiers.keys { mergedAttributes[key] = device[key] } @@ -251,7 +249,7 @@ final class AttributionFetcher { // Explicit nulls clear an IDFA retained from before consent was revoked. if device != _lastSyncedDeviceIdentifiers { _lastSyncedDeviceIdentifiers = device - syncDeviceIdentifiers(Self.userAttributes(for: device)) + syncUserAttributes(DeviceIdentifiers.userAttributes(for: device)) } guard mergedAttributes != _integrationAttributes else { return false } @@ -270,37 +268,36 @@ final class AttributionFetcher { // MARK: - Device identifiers extension AttributionFetcher { - /// The device identifiers as the user's attributes should carry them. - /// - /// A missing identifier is sent as an explicit `NSNull()` rather than left - /// out: `setUserAttributes` treats a Swift `nil` as "delete this key", which - /// would leave the server holding the last value it saw. A null overwrites - /// it, which is what clears an IDFA after consent is revoked. - private static func userAttributes(for device: [String: String]) -> [String: Any?] { - var userAttributes: [String: Any?] = [:] - for key in deviceIdentifierKeys { - if let value = device[key] { - userAttributes[key] = value - } else { - userAttributes[key] = NSNull() - } - } - return userAttributes - } - - /// Forgets what was last synced when something else writes to one of the - /// keys the SDK owns, so the next merge puts the SDK's value back. + /// Forgets what was last synced when a write lands on one of the SDK's keys + /// carrying something other than what the SDK put there, so the next merge + /// puts its own value back. /// /// The sync gate compares against what the SDK last sent rather than what the /// user's attributes actually hold, so without this an app that removed /// `idfv` would keep it missing until an identifier itself changed — which on /// a settled device may be never. - func forgetSyncedDeviceIdentifiers(ifTouching keys: [String]) { - if !keys.contains(where: { Self.deviceIdentifierKeys.contains($0) }) { + /// + /// Compares the values rather than just the keys because the SDK's own sync + /// comes back through this same setter, as do internal writers like the + /// enrichment response, which echoes the user's attributes back verbatim. A + /// write that agrees with what was sent isn't an overwrite and mustn't cost a + /// resync. + func forgetSyncedDeviceIdentifiers(ifChangedBy attributes: [String: Any?]) { + let touched = attributes.filter { DeviceIdentifiers.keys.contains($0.key) } + if touched.isEmpty { return } queue.async { [weak self] in - self?._lastSyncedDeviceIdentifiers = nil + guard let self, + let lastSynced = self._lastSyncedDeviceIdentifiers else { + return + } + let isOverwritten = touched.contains { key, value in + !DeviceIdentifiers.isWhatWasSent(value, forKey: key, in: lastSynced) + } + if isOverwritten { + self._lastSyncedDeviceIdentifiers = nil + } } } @@ -372,7 +369,7 @@ extension AttributionFetcher { return } var kept = _integrationAttributes.filter { key, _ in - Self.deviceIdentifierKeys.contains(key) + DeviceIdentifiers.keys.contains(key) || IntegrationAttribute.installScopedKeys.contains(key) } if kept.isEmpty { @@ -382,18 +379,18 @@ extension AttributionFetcher { } let device = currentDeviceIdentifiers - for key in Self.deviceIdentifierKeys { + for key in DeviceIdentifiers.keys { kept[key] = device[key] } _integrationAttributes = kept _lastSyncedDeviceIdentifiers = device storage.save(kept, forType: IntegrationAttributes.self) - var userAttributes = Self.userAttributes(for: device) - for (key, value) in kept where !Self.deviceIdentifierKeys.contains(key) { + var userAttributes = DeviceIdentifiers.userAttributes(for: device) + for (key, value) in kept where !DeviceIdentifiers.keys.contains(key) { userAttributes[key] = value } - syncDeviceIdentifiers(userAttributes) + syncUserAttributes(userAttributes) } } } diff --git a/Sources/SuperwallKit/Analytics/Attribution/DeviceIdentifiers.swift b/Sources/SuperwallKit/Analytics/Attribution/DeviceIdentifiers.swift new file mode 100644 index 000000000..a25add555 --- /dev/null +++ b/Sources/SuperwallKit/Analytics/Attribution/DeviceIdentifiers.swift @@ -0,0 +1,52 @@ +// +// DeviceIdentifiers.swift +// SuperwallKit +// +// Created by Yusuf Tör on 16/09/2026. +// + +import Foundation + +/// The identifiers the SDK owns in both the integration attributes and the +/// user's attributes: the vendor id, the advertising id, and the tracking +/// consent status that governs whether the advertising id exists. +/// +/// Not to be confused with the device attributes behind +/// `Superwall.getDeviceAttributes()`, which describe the device and the session +/// rather than identify it. +enum DeviceIdentifiers { + static let keys = ["idfa", "idfv", "attStatus"] + + /// The identifiers as the user's attributes should carry them. + /// + /// A missing identifier is sent as an explicit `NSNull()` rather than left + /// out: `setUserAttributes` reads a Swift `nil` as "delete this key", which + /// would leave the server holding the last value it saw. A null overwrites + /// that, which is what clears an IDFA once consent is revoked. + static func userAttributes(for identifiers: [String: String]) -> [String: Any?] { + var userAttributes: [String: Any?] = [:] + for key in keys { + if let value = identifiers[key] { + userAttributes[key] = value + } else { + userAttributes[key] = NSNull() + } + } + return userAttributes + } + + /// Whether a user-attribute value is the one last sent for that key. + /// + /// An identifier left out of the snapshot was sent as `NSNull()`, so a null + /// agreeing with an absent identifier is a match, not an overwrite. + static func isWhatWasSent( + _ value: Any?, + forKey key: String, + in lastSynced: [String: String] + ) -> Bool { + if let sent = lastSynced[key] { + return value as? String == sent + } + return value is NSNull + } +} diff --git a/Sources/SuperwallKit/Identity/UserAttributes.swift b/Sources/SuperwallKit/Identity/UserAttributes.swift index 17035570c..90b8fdb84 100644 --- a/Sources/SuperwallKit/Identity/UserAttributes.swift +++ b/Sources/SuperwallKit/Identity/UserAttributes.swift @@ -31,23 +31,17 @@ extension Superwall { /// attributes you'd like to store for the user. Values can be any JSON encodable value, `URL`s or `Date`s. /// Note: Keys beginning with `$` are reserved for Superwall and will be dropped. Arrays and dictionaries /// as values are not supported at this time, and will be dropped. + /// + /// Note: `idfv`, `idfa` and `attStatus` are owned by the SDK, which keeps them + /// in step with the device. A value you set on one of those keys is replaced + /// the next time the app becomes active or you set an integration attribute. public func setUserAttributes(_ attributes: [String: Any?]) { dependencyContainer.attributionFetcher?.forgetSyncedDeviceIdentifiers( - ifTouching: Array(attributes.keys) + ifChangedBy: attributes ) mergeAttributes(attributes) } - /// Sets the device identifiers the SDK owns without treating the write as one - /// of the app's. - /// - /// `AttributionFetcher` skips a sync when the identifiers haven't changed - /// since it last sent them, and going through the public setter would tell it - /// to forget that and send them again on every merge. - func setDeviceIdentifierAttributes(_ attributes: [String: Any?]) { - mergeAttributes(attributes) - } - /// The Objective-C method for setting user attributes for use in your paywalls and the dashboard. /// /// If the existing user attributes dictionary already has a value for a given property, the old @@ -77,7 +71,7 @@ extension Superwall { } dependencyContainer.attributionFetcher?.forgetSyncedDeviceIdentifiers( - ifTouching: keys + ifChangedBy: swiftDictionary ) mergeAttributes(swiftDictionary) } diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index ef67ef4d5..64adc0b08 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -499,6 +499,7 @@ D91750797BB4947F6975B2B9 /* Date+IsoStringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57C7673988B39FB0BDEA8BE4 /* Date+IsoStringTests.swift */; }; D978EAD4FA4865B5E07BF03B /* Future+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DE36D141F461F6E945823FA /* Future+Async.swift */; }; DB6FF170AE90FF8623A31E14 /* DispatchQueueBacked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ABC4A0048583B47040C498B /* DispatchQueueBacked.swift */; }; + DB7506D37B2BE86074BFF901 /* DeviceIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C9D282044746C11A110A325 /* DeviceIdentifiers.swift */; }; DB7858A959C145FA32F6C9EC /* PaywallPresentationInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 831F679BDAC779043091DB7E /* PaywallPresentationInfoTests.swift */; }; DBF70D987418DD9EB504FBDE /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42956918D4FFA5FBA79F3AA5 /* Constants.swift */; }; DCE85B4A9DBD672B658F6EB3 /* MockSKPaymentTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B1A6ADFFB9FA982BF69C134 /* MockSKPaymentTransaction.swift */; }; @@ -973,6 +974,7 @@ 9C2580C3CD6A8BF0C5258665 /* SWWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWWebView.swift; sourceTree = ""; }; 9C4966E857D1F9596B96910E /* SK1ReceiptManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK1ReceiptManager.swift; sourceTree = ""; }; 9C5DCFB58EF4DBC9084A6B89 /* NotificationScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationScheduler.swift; sourceTree = ""; }; + 9C9D282044746C11A110A325 /* DeviceIdentifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceIdentifiers.swift; sourceTree = ""; }; 9DC4D23D1EDDA249C928930D /* PaddingListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaddingListener.swift; sourceTree = ""; }; 9E1EFE389B54C304F2B01620 /* DeviceInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfo.swift; sourceTree = ""; }; 9E3DAD767490972EA30257F9 /* EntitlementProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitlementProcessorTests.swift; sourceTree = ""; }; @@ -2677,6 +2679,7 @@ 64EAB177118BC78B02C3C00A /* AttributionFetcher.swift */, 0B31ACE25727649F21DEEBAF /* AttributionPoster.swift */, D8749262F8F31B90DA975B26 /* AttributionTypeFactory.swift */, + 9C9D282044746C11A110A325 /* DeviceIdentifiers.swift */, BB868CF67AB2AE72895F864E /* IntegrationAttribute.swift */, 77827761E38CB30B06E3ABF2 /* MMPAttributionManager.swift */, ); @@ -3539,6 +3542,7 @@ CB2F2B4DA3709F171E54CBB8 /* DeepLinkRouter.swift in Sources */, 3F4BE7ECC80EEA757454F9B6 /* DependencyContainer.swift in Sources */, 7FCDAF6C945FA04FC4C4E8E3 /* DeviceHelper.swift in Sources */, + DB7506D37B2BE86074BFF901 /* DeviceIdentifiers.swift in Sources */, 191AA8FBBF617251EF6F8628 /* DeviceInfo.swift in Sources */, 6CF900F9770237D75585A681 /* DevicePreloadScript.swift in Sources */, 3DCE95BAC148CCC7E6E7F608 /* DeviceTemplate.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceIdentifiersTests.swift b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceIdentifiersTests.swift index 8242b6645..a28fbe896 100644 --- a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceIdentifiersTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceIdentifiersTests.swift @@ -21,7 +21,7 @@ struct AttributionDeviceIdentifiersTests { vendorIdProvider: vendorId, attStatusProvider: attStatus, idfaProvider: idfa, - syncDeviceIdentifiers: sync + syncUserAttributes: sync ) } @@ -254,7 +254,7 @@ struct AttributionDeviceIdentifiersTests { // But an app that writes over one of the SDK's keys has to be answered, // otherwise the router loses the identifier until one of them changes. - fetcher.forgetSyncedDeviceIdentifiers(ifTouching: ["email", "idfv"]) + fetcher.forgetSyncedDeviceIdentifiers(ifChangedBy: ["email": "a@b.com", "idfv": nil]) fetcher.refreshDeviceIdentifiers() #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") #expect(syncCount == 2) @@ -270,10 +270,51 @@ struct AttributionDeviceIdentifiersTests { #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") #expect(syncCount == 1) - fetcher.forgetSyncedDeviceIdentifiers(ifTouching: ["email", "name"]) + // Keys the SDK doesn't own say nothing about its own. + fetcher.forgetSyncedDeviceIdentifiers(ifChangedBy: ["email": "a@b.com"]) fetcher.refreshDeviceIdentifiers() #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") #expect(syncCount == 1) + + // Neither does a write echoing back exactly what the SDK sent — which is + // what the SDK's own sync and the enrichment response both look like. + fetcher.forgetSyncedDeviceIdentifiers( + ifChangedBy: [ + "idfv": "vendor-1", + "idfa": "advertiser-1", + "attStatus": "3" + ] + ) + fetcher.refreshDeviceIdentifiers() + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 1) + } + + @Test func resendsWhenAnIdentifierTheSdkLeftOutIsGivenAValue() { + let container = DependencyContainer() + var syncCount = 0 + let fetcher = makeFetcher( + container: container, + idfa: { nil }, + sync: { _ in syncCount += 1 } + ) + defer { fetcher.cancelPendingOperations() } + + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 1) + + // The SDK sent `idfa` as an explicit null, so a null back is a match. + fetcher.forgetSyncedDeviceIdentifiers(ifChangedBy: ["idfa": NSNull()]) + fetcher.refreshDeviceIdentifiers() + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 1) + + // An app putting its own value there is not. + fetcher.forgetSyncedDeviceIdentifiers(ifChangedBy: ["idfa": "made-up"]) + fetcher.refreshDeviceIdentifiers() + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 2) } @Test func everyIntegrationAttributeIsScoped() { From c9b7f320bad98492fda057bb480239dc79193e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:31:12 +0200 Subject: [PATCH 123/162] Match the ATT status on what it says, not how it's boxed The SDK sends attStatus as a quoted number, and it comes back through whatever echoes the user's attributes. A server holding it as a JSON number returns an NSNumber carrying the same answer, which the old match read as an overwrite and charged a sync for on every round trip. Co-Authored-By: Claude Opus 5 --- .../Attribution/DeviceIdentifiers.swift | 22 ++++++++++++++++--- .../AttributionDeviceIdentifiersTests.swift | 16 +++++++++++++- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Attribution/DeviceIdentifiers.swift b/Sources/SuperwallKit/Analytics/Attribution/DeviceIdentifiers.swift index a25add555..10276dd32 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/DeviceIdentifiers.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/DeviceIdentifiers.swift @@ -44,9 +44,25 @@ enum DeviceIdentifiers { forKey key: String, in lastSynced: [String: String] ) -> Bool { - if let sent = lastSynced[key] { - return value as? String == sent + guard let sent = lastSynced[key] else { + return value is NSNull } - return value is NSNull + return stringValue(of: value) == sent + } + + /// What a value says, rather than what it's boxed as. + /// + /// `attStatus` goes out as a quoted number and comes back through whatever + /// echoes the user's attributes, so a server holding it as a JSON number + /// returns an `NSNumber` carrying the same answer. Reading that as a + /// different value would charge a redundant sync for every round trip. + private static func stringValue(of value: Any?) -> String? { + if let string = value as? String { + return string + } + if let number = value as? NSNumber { + return number.stringValue + } + return nil } } diff --git a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceIdentifiersTests.swift b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceIdentifiersTests.swift index a28fbe896..deef6e836 100644 --- a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceIdentifiersTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceIdentifiersTests.swift @@ -276,6 +276,20 @@ struct AttributionDeviceIdentifiersTests { #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") #expect(syncCount == 1) + // Nor does the same answer in a different box: whatever echoes the user's + // attributes back may hold `attStatus` as a JSON number rather than the + // quoted one the SDK sent. + fetcher.forgetSyncedDeviceIdentifiers(ifChangedBy: ["attStatus": NSNumber(value: 3)]) + fetcher.refreshDeviceIdentifiers() + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 1) + + // A different status is still a different status. + fetcher.forgetSyncedDeviceIdentifiers(ifChangedBy: ["attStatus": NSNumber(value: 2)]) + fetcher.refreshDeviceIdentifiers() + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 2) + // Neither does a write echoing back exactly what the SDK sent — which is // what the SDK's own sync and the enrichment response both look like. fetcher.forgetSyncedDeviceIdentifiers( @@ -287,7 +301,7 @@ struct AttributionDeviceIdentifiersTests { ) fetcher.refreshDeviceIdentifiers() #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") - #expect(syncCount == 1) + #expect(syncCount == 2) } @Test func resendsWhenAnIdentifierTheSdkLeftOutIsGivenAValue() { From 7deda32e2eed7f1765b557726a6e56864a827a3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:43:30 +0200 Subject: [PATCH 124/162] Don't let a boolean pass for an ATT status NSNumber renders true as "1", which is the restricted status, so an app writing attStatus: true matched what the SDK had sent and kept its boolean instead of having the real status put back. Co-Authored-By: Claude Opus 5 --- .../Attribution/DeviceIdentifiers.swift | 7 +++++- .../AttributionDeviceIdentifiersTests.swift | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/Analytics/Attribution/DeviceIdentifiers.swift b/Sources/SuperwallKit/Analytics/Attribution/DeviceIdentifiers.swift index 10276dd32..f0b4b2c7b 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/DeviceIdentifiers.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/DeviceIdentifiers.swift @@ -56,11 +56,16 @@ enum DeviceIdentifiers { /// echoes the user's attributes, so a server holding it as a JSON number /// returns an `NSNumber` carrying the same answer. Reading that as a /// different value would charge a redundant sync for every round trip. + /// + /// Booleans are not numbers here, whatever `NSNumber` says: `true` renders as + /// `"1"` and would pass for the `restricted` status, so an app writing one + /// would keep it instead of having the real status put back. private static func stringValue(of value: Any?) -> String? { if let string = value as? String { return string } - if let number = value as? NSNumber { + if let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() { return number.stringValue } return nil diff --git a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceIdentifiersTests.swift b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceIdentifiersTests.swift index deef6e836..ae070b2df 100644 --- a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceIdentifiersTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceIdentifiersTests.swift @@ -331,6 +331,28 @@ struct AttributionDeviceIdentifiersTests { #expect(syncCount == 2) } + @Test func treatsABooleanStatusAsAnOverwrite() { + let container = DependencyContainer() + var syncCount = 0 + let fetcher = makeFetcher( + container: container, + attStatus: { 1 }, + sync: { _ in syncCount += 1 } + ) + defer { fetcher.cancelPendingOperations() } + + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) + #expect(fetcher.integrationAttributes["attStatus"] == "1") + #expect(syncCount == 1) + + // `true` renders as "1" through NSNumber, which is what the SDK sent for + // `restricted` — but it isn't a status, so the real one has to go back. + fetcher.forgetSyncedDeviceIdentifiers(ifChangedBy: ["attStatus": true]) + fetcher.refreshDeviceIdentifiers() + #expect(fetcher.integrationAttributes["attStatus"] == "1") + #expect(syncCount == 2) + } + @Test func everyIntegrationAttributeIsScoped() { // Every case lands on one side of the split — the exhaustive switch in // `isInstallScoped` forces that. Bump both counts when adding a case, so From c6fd4f390f62e144a5731220e0a6396cfb411027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:57:32 +0200 Subject: [PATCH 125/162] Leave the saved customer info alone under an external purchase controller With a purchase controller the status is the controller's and the customer info is rebuilt from it, so replacing it from the previous launch's saved copy during the early-publish restore could drop an entitlement the controller set since launch. The restore now returns before touching either, matching the read, which routes that path through `preservingExternalControllerEntitlements`. Also from the Pullfrog review of #526: - The emission-ordering test's blocked subscriber runs on a plain thread with bounded waits, so a regression fails instead of hanging CI. - The concurrent-writers test no longer claims to check order; the re-entrant test, where the store order is known, covers that. - The `subscriptionStatus` docs and the changelog say which thread delivers a change under contention and that a blocking subscriber holds up other writers. - A comment explains why a subscription row with no expiry is restored inactive while its entitlement keeps its saved state. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- .../PublishedSubscriptionStatus.swift | 7 ++-- .../ReceiptManager+Restore.swift | 12 ++++++ .../Receipt Manager/ReceiptManager.swift | 2 +- Sources/SuperwallKit/Superwall.swift | 7 +++- .../SubscriptionStatusEmissionTests.swift | 42 +++++++++---------- 6 files changed, 44 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44321049f..ed3ee65c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Breaking Changes -- Changes `$subscriptionStatus` from a `@Published` publisher to an `AnyPublisher`. Subscribing to it works as before, but it can no longer be the target of `assign(to:)`. +- Changes `$subscriptionStatus` from a `@Published` publisher to an `AnyPublisher`. Subscribing to it works as before, but it can no longer be the target of `assign(to:)`. Changes are now delivered in order, and when several threads change the status at once one of them may deliver the others' changes, so a subscriber that blocks holds up every writer. ### Enhancements diff --git a/Sources/SuperwallKit/StoreKit/Products/PublishedSubscriptionStatus.swift b/Sources/SuperwallKit/StoreKit/Products/PublishedSubscriptionStatus.swift index 5bbdbb59c..47ae1a87b 100644 --- a/Sources/SuperwallKit/StoreKit/Products/PublishedSubscriptionStatus.swift +++ b/Sources/SuperwallKit/StoreKit/Products/PublishedSubscriptionStatus.swift @@ -14,9 +14,10 @@ import Foundation /// stored and published value is always the merged one — subscribers never /// see the value a writer assigned before granted entitlements and test-mode /// overrides were applied. The projected value (`$subscriptionStatus`) replays -/// the current value to new subscribers, like `@Published`. Reads take the -/// same lock as writes, so a read on one thread never races an assignment -/// on another. +/// the latest emitted value to new subscribers, like `@Published`; while +/// emissions are still queued behind a slow subscriber that can trail the +/// stored value until their turn comes. Reads take the same lock as writes, +/// so a read on one thread never races an assignment on another. /// /// Public only because a public property's wrapper type has to be; nothing /// but the projection is meant to be used. The enclosing-instance subscript diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift index 4edef5bed..165ce57d3 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift @@ -86,6 +86,18 @@ extension ReceiptManager { // status via the same delegate call, from the seeded purchases and the // corrected entitlement map. Audience filters read these while config is // already published, so they see the same state as `entitlementsByProductId`. + // + // With an external purchase controller the status is the controller's and + // the customer info is rebuilt from it, so the saved copy is left alone: + // replacing it here would drop an entitlement the controller set since + // launch, and the delegate call is a no-op on that path anyway. + if factory.makeHasExternalPurchaseController() { + return + } + // Rows follow the seeded purchases: an App Store subscription with no + // expiry can't be shown to be current, so it's inactive here even though + // its entitlement, which follows the entitlement rule above, keeps its + // saved state. let subscriptions = customerInfo.subscriptions.map { subscription -> SubscriptionTransaction in let stillActive: Bool if subscription.store == .appStore { diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift index 9d968e3ec..0d266f332 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift @@ -28,7 +28,7 @@ actor ReceiptManager { private let shouldBypassAppTransactionCheck: Bool let manager: ReceiptManagerType private let delegateWrapper: ReceiptRefreshDelegateWrapper - private unowned let factory: Factory + unowned let factory: Factory private unowned let storage: Storage /// Subscription group IDs the user currently has an active subscription in. Computed /// during `loadPurchasedProducts`: on StoreKit 2 from the snapshot's transactions, which diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index e088d9fa3..a6381c78c 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -208,8 +208,11 @@ public final class Superwall: NSObject, ObservableObject { /// If you're using Combine or SwiftUI, you can subscribe or bind to it to get /// notified whenever it changes. The publisher only ever emits the merged /// status – never a value you assigned before granted entitlements were - /// applied. Subscribers are called on whichever thread changed the status, - /// which is often a background one, so use `receive(on:)` before updating UI. + /// applied. Changes are delivered in the order they were made. Subscribers + /// are usually called on the thread that changed the status, which is often + /// a background one; when several threads change it at once, one of them + /// delivers the others' changes too, and a slow subscriber holds those up. + /// Use `receive(on:)` before updating UI, and don't block in a subscriber. /// /// Otherwise, you can check the delegate function /// ``SuperwallDelegate/subscriptionStatusDidChange(from:to:)`` diff --git a/Tests/SuperwallKitTests/StoreKit/Products/SubscriptionStatusEmissionTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/SubscriptionStatusEmissionTests.swift index e40cca368..f8757c235 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/SubscriptionStatusEmissionTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/SubscriptionStatusEmissionTests.swift @@ -27,10 +27,12 @@ final class SubscriptionStatusEmissionTests { return .active([Entitlement(id: id, type: .serviceLevel, isActive: true)]) } - /// Every value that concurrent writers store reaches subscribers once, in - /// the order it was stored, and the last emission is the stored value. + /// Every value that concurrent writers store reaches subscribers exactly + /// once, and the last emission is the stored value. Ordering is covered by + /// `reentrantAssignment_isEmittedAfterTheTriggeringValue`, where the store + /// order is known. @Test - func concurrentWrites_emitEveryStoredValueInOrder() { + func concurrentWrites_emitEveryStoredValueOnce() { let emissions = Locked<[SubscriptionStatus]>([]) let cancellable = superwall.$subscriptionStatus .dropFirst() @@ -80,7 +82,7 @@ final class SubscriptionStatusEmissionTests { /// for that thread's subscribers, so a subscriber blocked on the writer's /// thread can't deadlock it. @Test - func writerNeverWaitsOnASubscriber() async { + func writerNeverWaitsOnASubscriber() { let subscriberEntered = DispatchSemaphore(value: 0) let releaseSubscriber = DispatchSemaphore(value: 0) let cancellable = superwall.$subscriptionStatus @@ -93,36 +95,34 @@ final class SubscriptionStatusEmissionTests { } } - let background = Task.detached { [superwall] in + // Plain threads rather than tasks: the blocked subscriber must not take + // a cooperative-pool thread with it, and every wait is bounded so a + // regression fails the test instead of hanging the run. + let backgroundFinished = DispatchSemaphore(value: 0) + DispatchQueue.global().async { [superwall] in superwall.subscriptionStatus = self.status("blocking") + backgroundFinished.signal() } - subscriberEntered.wait() + #expect(subscriberEntered.wait(timeout: .now() + 2) == .success, "the subscriber never ran") // The emitting thread is stuck inside its subscriber. This write must // return without waiting for it. - let writeReturned = Task.detached { [superwall] in + let writeReturned = DispatchSemaphore(value: 0) + DispatchQueue.global().async { [superwall] in superwall.subscriptionStatus = self.status("meanwhile") - return true + writeReturned.signal() } - let didReturn = await withTaskGroup(of: Bool.self) { group -> Bool in - group.addTask { await writeReturned.value } - group.addTask { - try? await Task.sleep(nanoseconds: 2_000_000_000) - return false - } - let first = await group.next() ?? false - group.cancelAll() - return first - } - #expect(didReturn, "the writer waited on a blocked subscriber") + #expect(writeReturned.wait(timeout: .now() + 2) == .success, "the writer waited on a blocked subscriber") releaseSubscriber.signal() - await background.value + #expect(backgroundFinished.wait(timeout: .now() + 2) == .success, "the drain never finished") #expect(superwall.subscriptionStatus == status("meanwhile")) cancellable.cancel() } - /// Reads and writes from many threads at once don't tear or crash. + /// Reads and writes from many threads at once don't tear or crash. This is + /// a smoke test meant for a Thread Sanitizer run; the locking itself is + /// pinned by the tests above. @Test func concurrentReadsAndWrites_staySafe() { DispatchQueue.concurrentPerform(iterations: 500) { index in From 5209ccb0a2533ca6d7aa3747958781c0aac33a19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:13:23 +0200 Subject: [PATCH 126/162] Pin the external-controller guard on the restore with a test The guard that keeps the early-publish restore from replacing a purchase controller's customer info had nothing asserting it. The new test seeds controller-set state before config loads and checks it is untouched mid-load while purchases and the entitlement map are still seeded. Removing the guard fails it on both counts. Also takes Pullfrog's wording for the changelog entry and the wrapper's doc comment: a blocking subscriber delays delivery, it never blocks the assigner. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- .../PublishedSubscriptionStatus.swift | 4 +- .../ConfigManagerEarlyPublishTests.swift | 47 +++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed3ee65c7..e9f03eebc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Breaking Changes -- Changes `$subscriptionStatus` from a `@Published` publisher to an `AnyPublisher`. Subscribing to it works as before, but it can no longer be the target of `assign(to:)`. Changes are now delivered in order, and when several threads change the status at once one of them may deliver the others' changes, so a subscriber that blocks holds up every writer. +- Changes `$subscriptionStatus` from a `@Published` publisher to an `AnyPublisher`. Subscribing to it works as before, but it can no longer be the target of `assign(to:)`. Changes are now delivered in order, and when several threads change the status at once one of them may deliver the others' changes, so a subscriber that blocks delays every pending change, though it never blocks the code doing the assigning. ### Enhancements diff --git a/Sources/SuperwallKit/StoreKit/Products/PublishedSubscriptionStatus.swift b/Sources/SuperwallKit/StoreKit/Products/PublishedSubscriptionStatus.swift index 47ae1a87b..d4ee2188c 100644 --- a/Sources/SuperwallKit/StoreKit/Products/PublishedSubscriptionStatus.swift +++ b/Sources/SuperwallKit/StoreKit/Products/PublishedSubscriptionStatus.swift @@ -15,8 +15,8 @@ import Foundation /// see the value a writer assigned before granted entitlements and test-mode /// overrides were applied. The projected value (`$subscriptionStatus`) replays /// the latest emitted value to new subscribers, like `@Published`; while -/// emissions are still queued behind a slow subscriber that can trail the -/// stored value until their turn comes. Reads take the same lock as writes, +/// emissions are queued behind a slow subscriber, that replay can trail the +/// stored value until its turn comes. Reads take the same lock as writes, /// so a read on one thread never races an assignment on another. /// /// Public only because a public property's wrapper type has to be; nothing diff --git a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift index f8f091244..89a40faf6 100644 --- a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift +++ b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift @@ -739,6 +739,53 @@ struct ConfigManagerEarlyPublishTests { await fetch.value await settle() } + + @Test("With a purchase controller the restore seeds purchases but leaves the status and customer info alone") + func restoreLeavesControllerStateAloneWithExternalPurchaseController() async { + let controllerContainer = DependencyContainer(purchaseController: MockPurchaseController()) + // Would publish the status if the restore reached it; the guard must stop + // it getting that far. + let purchaseController = AutomaticPurchaseController( + factory: controllerContainer, + entitlementsInfo: Superwall.shared.entitlements + ) + let harness = makeHarness( + container: controllerContainer, + isSubscribed: true, + savedCustomerInfo: savedCustomerInfoWithLapsedSecondSubscription(), + loadDelay: 2, + receiptDelegate: purchaseController + ) + + // State the developer's controller set since launch, which the saved copy + // predates and must not replace. + let controllerEntitlement = Entitlement(id: "controller_only", isActive: true) + let controllerCustomerInfo = CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: [controllerEntitlement] + ) + await MainActor.run { + Superwall.shared.customerInfo = controllerCustomerInfo + } + Superwall.shared.subscriptionStatus = .active([controllerEntitlement]) + let statusBefore = Superwall.shared.subscriptionStatus + + let fetch = Task { await harness.configManager.fetchConfiguration() } + _ = await waitForConfig(harness.configManager, timeout: 1.5) + #expect(!harness.receipt.didFinishLoad, "test needs config to be published mid-load") + + // The purchase state is still seeded from the saved copy. + #expect(await harness.receiptManager.getActiveProductIds() == [Self.silverProductId]) + #expect(Superwall.shared.entitlements.byProductId(Self.silverProductId).isEmpty == false) + + // The controller's state is untouched. + #expect(Superwall.shared.customerInfo == controllerCustomerInfo) + #expect(Superwall.shared.subscriptionStatus == statusBefore) + + await fetch.value + await settle() + } } /// A `ReceiptManagerType` whose first `loadPurchases` sleeps, standing in for a From fb5be35e5cf8d2c69ae2f4d46bb9dd608afa06fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:55:30 +0200 Subject: [PATCH 127/162] Correct lapsed rows under a purchase controller without touching its entitlements Skipping the customer info entirely on the purchase-controller path left a subscription that expired since the last launch reading active in `customerInfo` until the read landed, while the entitlement map had already been corrected. The restore now saves the corrected App Store rows as the device copy and rebuilds the customer info the same way every status publish does on that path, so the controller's entitlements are kept and the lapsed rows read inactive. The status stays the controller's. The customer info work moves into a helper to keep the restore readable, and the controller-path test puts the shared instance's state back when it ends so it can't leak into suites running alongside. Co-Authored-By: Claude Fable 5.1 --- .../ReceiptManager+Restore.swift | 70 ++++++++++++++----- .../Receipt Manager/ReceiptManager.swift | 2 +- .../ConfigManagerEarlyPublishTests.swift | 27 +++++-- 3 files changed, 75 insertions(+), 24 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift index 165ce57d3..c8a7cd032 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift @@ -86,18 +86,36 @@ extension ReceiptManager { // status via the same delegate call, from the seeded purchases and the // corrected entitlement map. Audience filters read these while config is // already published, so they see the same state as `entitlementsByProductId`. - // - // With an external purchase controller the status is the controller's and - // the customer info is rebuilt from it, so the saved copy is left alone: - // replacing it here would drop an entitlement the controller set since - // launch, and the delegate call is a no-op on that path anyway. + await publishRestoredCustomerInfo( + from: customerInfo, + activeTransactionIds: activeTransactionIds, + savedEntitlements: saved, + mergedEntitlements: merged, + grantedEntitlements: grantedEntitlements, + at: now + ) + // With an external purchase controller the status is the controller's. if factory.makeHasExternalPurchaseController() { return } - // Rows follow the seeded purchases: an App Store subscription with no - // expiry can't be shown to be current, so it's inactive here even though - // its entitlement, which follows the entitlement rule above, keeps its - // saved state. + await receiptDelegate?.syncSubscriptionStatus(purchases: purchases) + } + + /// Assigns the saved customer info with rows that lapsed since the last + /// launch marked inactive. + /// + /// Rows follow the seeded purchases: an App Store subscription with no + /// expiry can't be shown to be current, so it's inactive here even though + /// its entitlement, which follows the entitlement rule in `restorePurchases`, + /// keeps its saved state. + private func publishRestoredCustomerInfo( + from customerInfo: CustomerInfo, + activeTransactionIds: Set, + savedEntitlements: [Entitlement], + mergedEntitlements: Set, + grantedEntitlements: Set, + at now: Date + ) async { let subscriptions = customerInfo.subscriptions.map { subscription -> SubscriptionTransaction in let stillActive: Bool if subscription.store == .appStore { @@ -107,16 +125,36 @@ extension ReceiptManager { } return subscription.isActive && !stillActive ? deactivated(subscription) : subscription } - let restoredCustomerInfo = CustomerInfo( - subscriptions: subscriptions, - nonSubscriptions: customerInfo.nonSubscriptions, - entitlements: merged.sorted { $0.id < $1.id }, - isPlaceholder: customerInfo.isPlaceholder - ) + + // With an external purchase controller the status is the controller's, and + // the customer info is rebuilt from it on every publish using the device + // rows in storage. Save the corrected App Store rows there and rebuild the + // same way, so the controller's entitlements are kept while a row that + // lapsed since the last launch reads inactive. + let restoredCustomerInfo: CustomerInfo + if factory.makeHasExternalPurchaseController() { + let deviceCustomerInfo = CustomerInfo( + subscriptions: subscriptions.filter { $0.store == .appStore }, + nonSubscriptions: customerInfo.nonSubscriptions.filter { $0.store == .appStore }, + entitlements: savedEntitlements.filter { $0.store == .appStore }.sorted { $0.id < $1.id } + ) + storage.save(deviceCustomerInfo, forType: LatestDeviceCustomerInfo.self) + restoredCustomerInfo = CustomerInfo.forExternalPurchaseController( + storage: storage, + subscriptionStatus: Superwall.shared.subscriptionStatus, + granted: grantedEntitlements + ) + } else { + restoredCustomerInfo = CustomerInfo( + subscriptions: subscriptions, + nonSubscriptions: customerInfo.nonSubscriptions, + entitlements: mergedEntitlements.sorted { $0.id < $1.id }, + isPlaceholder: customerInfo.isPlaceholder + ) + } await MainActor.run { Superwall.shared.customerInfo = restoredCustomerInfo } - await receiptDelegate?.syncSubscriptionStatus(purchases: purchases) } /// A nil expiry never lapses: lifetime purchases and web entitlements without one. diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift index 0d266f332..8b057d7f7 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift @@ -29,7 +29,7 @@ actor ReceiptManager { let manager: ReceiptManagerType private let delegateWrapper: ReceiptRefreshDelegateWrapper unowned let factory: Factory - private unowned let storage: Storage + unowned let storage: Storage /// Subscription group IDs the user currently has an active subscription in. Computed /// during `loadPurchasedProducts`: on StoreKit 2 from the snapshot's transactions, which /// carry the group ID; on StoreKit 1 from the fetched purchased products. Used to suppress diff --git a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift index 89a40faf6..47e9a7b04 100644 --- a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift +++ b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift @@ -740,11 +740,20 @@ struct ConfigManagerEarlyPublishTests { await settle() } - @Test("With a purchase controller the restore seeds purchases but leaves the status and customer info alone") - func restoreLeavesControllerStateAloneWithExternalPurchaseController() async { + @Test("With a purchase controller the restore keeps the controller's entitlements and status while correcting lapsed rows") + func restoreKeepsControllerStateWithExternalPurchaseController() async { + // Put back afterwards: the SDK never overwrites controller-set state on + // this path, so it would otherwise leak into suites running alongside. + let originalCustomerInfo = Superwall.shared.customerInfo + let originalStatus = Superwall.shared.subscriptionStatus + defer { + Superwall.shared.customerInfo = originalCustomerInfo + Superwall.shared.subscriptionStatus = originalStatus + } + let controllerContainer = DependencyContainer(purchaseController: MockPurchaseController()) - // Would publish the status if the restore reached it; the guard must stop - // it getting that far. + // Would publish the status if the restore reached it; the controller path + // must leave the status to the controller. let purchaseController = AutomaticPurchaseController( factory: controllerContainer, entitlementsInfo: Superwall.shared.entitlements @@ -759,7 +768,7 @@ struct ConfigManagerEarlyPublishTests { // State the developer's controller set since launch, which the saved copy // predates and must not replace. - let controllerEntitlement = Entitlement(id: "controller_only", isActive: true) + let controllerEntitlement = Entitlement(id: "controller_only", isActive: true, store: .appStore) let controllerCustomerInfo = CustomerInfo( subscriptions: [], nonSubscriptions: [], @@ -779,9 +788,13 @@ struct ConfigManagerEarlyPublishTests { #expect(await harness.receiptManager.getActiveProductIds() == [Self.silverProductId]) #expect(Superwall.shared.entitlements.byProductId(Self.silverProductId).isEmpty == false) - // The controller's state is untouched. - #expect(Superwall.shared.customerInfo == controllerCustomerInfo) + // The status is the controller's, and its entitlement survives the rebuild + // while the lapsed saved row reads inactive. #expect(Superwall.shared.subscriptionStatus == statusBefore) + let customerInfo = Superwall.shared.customerInfo + #expect(customerInfo.entitlements.contains { $0.id == "controller_only" && $0.isActive }) + #expect(customerInfo.subscriptions.first { $0.productId == Self.legacyProductId }?.isActive == false) + #expect(customerInfo.subscriptions.first { $0.productId == Self.silverProductId }?.isActive == true) await fetch.value await settle() From 4a0e8eee1ee5dec7f5dccc4b78ccf0285bad12e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:28:53 +0200 Subject: [PATCH 128/162] Leave the customer info alone under a purchase controller after all Rebuilding it during the early-publish window meant carving a device copy out of the merged saved copy, which dropped config-only rows and admitted the controller's own entitlements as device ones, and assigning it raced the controller's own writes. Under a purchase controller the status is the controller's and the read replaces everything seconds later, so the restore returns before touching the customer info or the status, as it did before the early publish existed. The reason is recorded at the return. Co-Authored-By: Claude Fable 5.1 --- .../ReceiptManager+Restore.swift | 51 ++++++------------- .../Receipt Manager/ReceiptManager.swift | 2 +- .../ConfigManagerEarlyPublishTests.swift | 16 +++--- 3 files changed, 23 insertions(+), 46 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift index c8a7cd032..02246434c 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager+Restore.swift @@ -86,18 +86,22 @@ extension ReceiptManager { // status via the same delegate call, from the seeded purchases and the // corrected entitlement map. Audience filters read these while config is // already published, so they see the same state as `entitlementsByProductId`. + // + // Not with an external purchase controller. There the status is the + // controller's and the customer info is rebuilt from it on every publish, + // from the device rows the read saves. The saved copy is merged, so a device + // copy carved out of it would be mis-sourced, and a write here would race + // the controller's own assignments. The rows stay as saved until the read + // lands, which is what happened before the early publish existed. + if factory.makeHasExternalPurchaseController() { + return + } await publishRestoredCustomerInfo( from: customerInfo, activeTransactionIds: activeTransactionIds, - savedEntitlements: saved, mergedEntitlements: merged, - grantedEntitlements: grantedEntitlements, at: now ) - // With an external purchase controller the status is the controller's. - if factory.makeHasExternalPurchaseController() { - return - } await receiptDelegate?.syncSubscriptionStatus(purchases: purchases) } @@ -111,9 +115,7 @@ extension ReceiptManager { private func publishRestoredCustomerInfo( from customerInfo: CustomerInfo, activeTransactionIds: Set, - savedEntitlements: [Entitlement], mergedEntitlements: Set, - grantedEntitlements: Set, at now: Date ) async { let subscriptions = customerInfo.subscriptions.map { subscription -> SubscriptionTransaction in @@ -125,33 +127,12 @@ extension ReceiptManager { } return subscription.isActive && !stillActive ? deactivated(subscription) : subscription } - - // With an external purchase controller the status is the controller's, and - // the customer info is rebuilt from it on every publish using the device - // rows in storage. Save the corrected App Store rows there and rebuild the - // same way, so the controller's entitlements are kept while a row that - // lapsed since the last launch reads inactive. - let restoredCustomerInfo: CustomerInfo - if factory.makeHasExternalPurchaseController() { - let deviceCustomerInfo = CustomerInfo( - subscriptions: subscriptions.filter { $0.store == .appStore }, - nonSubscriptions: customerInfo.nonSubscriptions.filter { $0.store == .appStore }, - entitlements: savedEntitlements.filter { $0.store == .appStore }.sorted { $0.id < $1.id } - ) - storage.save(deviceCustomerInfo, forType: LatestDeviceCustomerInfo.self) - restoredCustomerInfo = CustomerInfo.forExternalPurchaseController( - storage: storage, - subscriptionStatus: Superwall.shared.subscriptionStatus, - granted: grantedEntitlements - ) - } else { - restoredCustomerInfo = CustomerInfo( - subscriptions: subscriptions, - nonSubscriptions: customerInfo.nonSubscriptions, - entitlements: mergedEntitlements.sorted { $0.id < $1.id }, - isPlaceholder: customerInfo.isPlaceholder - ) - } + let restoredCustomerInfo = CustomerInfo( + subscriptions: subscriptions, + nonSubscriptions: customerInfo.nonSubscriptions, + entitlements: mergedEntitlements.sorted { $0.id < $1.id }, + isPlaceholder: customerInfo.isPlaceholder + ) await MainActor.run { Superwall.shared.customerInfo = restoredCustomerInfo } diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift index 8b057d7f7..0d266f332 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift @@ -29,7 +29,7 @@ actor ReceiptManager { let manager: ReceiptManagerType private let delegateWrapper: ReceiptRefreshDelegateWrapper unowned let factory: Factory - unowned let storage: Storage + private unowned let storage: Storage /// Subscription group IDs the user currently has an active subscription in. Computed /// during `loadPurchasedProducts`: on StoreKit 2 from the snapshot's transactions, which /// carry the group ID; on StoreKit 1 from the fetched purchased products. Used to suppress diff --git a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift index 47e9a7b04..4ee091432 100644 --- a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift +++ b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift @@ -740,8 +740,8 @@ struct ConfigManagerEarlyPublishTests { await settle() } - @Test("With a purchase controller the restore keeps the controller's entitlements and status while correcting lapsed rows") - func restoreKeepsControllerStateWithExternalPurchaseController() async { + @Test("With a purchase controller the restore seeds purchases but leaves the status and customer info alone") + func restoreLeavesControllerStateAloneWithExternalPurchaseController() async { // Put back afterwards: the SDK never overwrites controller-set state on // this path, so it would otherwise leak into suites running alongside. let originalCustomerInfo = Superwall.shared.customerInfo @@ -752,8 +752,8 @@ struct ConfigManagerEarlyPublishTests { } let controllerContainer = DependencyContainer(purchaseController: MockPurchaseController()) - // Would publish the status if the restore reached it; the controller path - // must leave the status to the controller. + // Would publish the status if the restore reached it; the guard must stop + // it getting that far. let purchaseController = AutomaticPurchaseController( factory: controllerContainer, entitlementsInfo: Superwall.shared.entitlements @@ -788,13 +788,9 @@ struct ConfigManagerEarlyPublishTests { #expect(await harness.receiptManager.getActiveProductIds() == [Self.silverProductId]) #expect(Superwall.shared.entitlements.byProductId(Self.silverProductId).isEmpty == false) - // The status is the controller's, and its entitlement survives the rebuild - // while the lapsed saved row reads inactive. + // The controller's state is untouched. + #expect(Superwall.shared.customerInfo == controllerCustomerInfo) #expect(Superwall.shared.subscriptionStatus == statusBefore) - let customerInfo = Superwall.shared.customerInfo - #expect(customerInfo.entitlements.contains { $0.id == "controller_only" && $0.isActive }) - #expect(customerInfo.subscriptions.first { $0.productId == Self.legacyProductId }?.isActive == false) - #expect(customerInfo.subscriptions.first { $0.productId == Self.silverProductId }?.isActive == true) await fetch.value await settle() From c29fdce8cbe641f6c99bf4fd0fcc6ae3279f0c71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:26:44 +0200 Subject: [PATCH 129/162] Stop identify() hanging after an install match, and answer paywall overwrites of device keys Identifying a different user resets the previous one from inside the identity manager's queue. The reset re-applied the cached MMP acquisition attributes, which first read the user's attributes with a sync hop onto that same queue, so once an install match had ever resolved every user switch hung the queue for the rest of the process. The re-apply now merges without reading first: the reset just wiped the attributes, so there was nothing to compare against. A paywall's attribute write went straight to the identity manager, so a paywall writing `idfv`, `idfa` or `attStatus` never triggered the resync that `setUserAttributes` does. Both now go through the same check. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + .../Attribution/MMPAttributionManager.swift | 10 +++- .../Identity/UserAttributes.swift | 10 ++++ Sources/SuperwallKit/Superwall.swift | 2 +- SuperwallKit.xcodeproj/project.pbxproj | 4 ++ .../AttributionDeviceIdentifiersTests.swift | 20 ++++++++ .../Identity/IdentifyUserSwitchTests.swift | 48 +++++++++++++++++++ 7 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 Tests/SuperwallKitTests/Identity/IdentifyUserSwitchTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f9fdeb03..1cc8ae8b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes slow cold launches for subscribers on a weak network by no longer fetching their purchased products from StoreKit before the SDK is ready. Applies to StoreKit 2. - Fixes a data race during SDK configuration that Thread Sanitizer flagged on every launch. - Fixes a crash when `register` is called from more than one thread at a time. +- Fixes a hang when identifying a different user after an install attribution match had been found. - Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them. - Fixes audiences matching users they shouldn't when you use a Purchase Controller. diff --git a/Sources/SuperwallKit/Analytics/Attribution/MMPAttributionManager.swift b/Sources/SuperwallKit/Analytics/Attribution/MMPAttributionManager.swift index fa5893e7b..d6690cabf 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/MMPAttributionManager.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/MMPAttributionManager.swift @@ -103,7 +103,15 @@ final class MMPAttributionManager { guard let cached = storage.get(MMPAcquisitionDataStorage.self) else { return } - mergeAcquisitionAttributesIfNeeded(cached) + let attributes = convertJSONToDictionary(attribution: cached) + if attributes.isEmpty { + return + } + // Merged without checking the current attributes first. The reset just + // wiped them, so there is nothing to compare against, and on the + // `identify()` path this runs on the identity manager's queue, where + // reading `userAttributes` would wait on that same queue and hang it. + Superwall.shared.setUserAttributes(attributes) } private func mergeAcquisitionAttributesIfNeeded(_ acquisitionAttributes: [String: JSON]) { diff --git a/Sources/SuperwallKit/Identity/UserAttributes.swift b/Sources/SuperwallKit/Identity/UserAttributes.swift index 90b8fdb84..60f73be93 100644 --- a/Sources/SuperwallKit/Identity/UserAttributes.swift +++ b/Sources/SuperwallKit/Identity/UserAttributes.swift @@ -94,6 +94,16 @@ extension Superwall { setUserAttributes(userAttributes) } + /// Merges attributes set from a paywall and notifies the delegate. Runs the + /// same overwrite check as ``setUserAttributes(_:)-1wq0n``, so a paywall that + /// writes one of the SDK-owned keys is answered by a resync too. + func setUserAttributesFromPaywall(_ attributes: [String: Any]) { + dependencyContainer.attributionFetcher?.forgetSyncedDeviceIdentifiers( + ifChangedBy: attributes + ) + dependencyContainer.identityManager.mergeUserAttributesAndNotify(attributes) + } + private func mergeAttributes(_ attributes: [String: Any?]) { var customAttributes: [String: Any?] = [:] diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 91c9c72f9..5ca20f4de 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -1504,7 +1504,7 @@ extension Superwall: PaywallViewControllerEventDelegate { attributesDict[key] = attribute["value"].object } } - dependencyContainer.identityManager.mergeUserAttributesAndNotify(attributesDict) + setUserAttributesFromPaywall(attributesDict) case let .requestCallback(name, behavior, requestId, _): Logger.debug( logLevel: .debug, diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 2e3935ec1..101c37615 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -178,6 +178,7 @@ 4B0E203D477E48611797047C /* PaywallViewControllerCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 672776875A4286319C2F2D61 /* PaywallViewControllerCacheTests.swift */; }; 4B4BCB32699C3A1AF7E2BFE6 /* SK2StoreProductCyclesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00929DACD8621FC32F83927 /* SK2StoreProductCyclesTests.swift */; }; 4B54BA9E52A97C486D808A05 /* IntroOfferToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF0596D5BDDE0911046E60D /* IntroOfferToken.swift */; }; + 4B5B618A7FAECA65EF29F2C2 /* IdentifyUserSwitchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 255FA775B7C71582F2D4F6EA /* IdentifyUserSwitchTests.swift */; }; 4D37588A69A4C770C86FC585 /* RedeemRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8719AC2E83128EE469E58C36 /* RedeemRequest.swift */; }; 4DE01655FC4CC148DD3D161C /* LoggerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00C04BE1449BE21E13B61A0 /* LoggerMock.swift */; }; 4E078EFFD0C1992563021220 /* PurchaseController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D45DC0981EE9BBE3BF56C48 /* PurchaseController.swift */; }; @@ -697,6 +698,7 @@ 24B8D7F537204EAB13BB7F10 /* FeatureGatingBehaviour.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureGatingBehaviour.swift; sourceTree = ""; }; 24EA03270476CD31B906CDC8 /* GetPaywallResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetPaywallResult.swift; sourceTree = ""; }; 25515131DF0AE67E26BFF462 /* TriggerResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerResult.swift; sourceTree = ""; }; + 255FA775B7C71582F2D4F6EA /* IdentifyUserSwitchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifyUserSwitchTests.swift; sourceTree = ""; }; 258D4AA92CC250CEA27E8A49 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; 258FC2DB67022EF3D9B1FB67 /* Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Endpoint.swift; sourceTree = ""; }; 25D8461640AE665CF5A54016 /* ArchiveManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveManifest.swift; sourceTree = ""; }; @@ -1753,6 +1755,7 @@ isa = PBXGroup; children = ( 0B6BF63B250AE0D83DECFCD0 /* EmailTests.swift */, + 255FA775B7C71582F2D4F6EA /* IdentifyUserSwitchTests.swift */, 40AE19B5A9B237A2552D5F36 /* IdentityLogicTests.swift */, 2714D9FD9F55611B4C5E4E7D /* IdentityManagerMock.swift */, EF6B2C623B994FF663F75719 /* IdentityManagerTests.swift */, @@ -3370,6 +3373,7 @@ 64B962A8B59ACE514B0E48AE /* GetPresenterOperatorTests.swift in Sources */, D0D4568B72D20652C12AA89B /* GrantedEntitlementsTests.swift in Sources */, AF4AD928FACF9056E00D5920 /* HandleTriggerResultOperatorTests.swift in Sources */, + 4B5B618A7FAECA65EF29F2C2 /* IdentifyUserSwitchTests.swift in Sources */, 77EDD2927FF8DCF95579BE3E /* IdentityLogicTests.swift in Sources */, D89E9C69317044050B97B573 /* IdentityManagerMock.swift in Sources */, 1894723C383155082D7C6287 /* IdentityManagerTests.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceIdentifiersTests.swift b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceIdentifiersTests.swift index ae070b2df..1accc7dc6 100644 --- a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceIdentifiersTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceIdentifiersTests.swift @@ -260,6 +260,26 @@ struct AttributionDeviceIdentifiersTests { #expect(syncCount == 2) } + @Test func resendsTheIdentifiersWhenAPaywallOverwritesThem() { + let container = DependencyContainer() + var syncCount = 0 + let fetcher = makeFetcher(container: container, sync: { _ in syncCount += 1 }) + defer { fetcher.cancelPendingOperations() } + container.attributionFetcher = fetcher + let superwall = Superwall(dependencyContainer: container) + + fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 1) + + // A paywall's attribute write doesn't go through `setUserAttributes`, but + // it has to be answered the same way. + superwall.setUserAttributesFromPaywall(["idfv": "made-up"]) + fetcher.refreshDeviceIdentifiers() + #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") + #expect(syncCount == 2) + } + @Test func keepsQuietWhenTheAppWritesItsOwnAttributes() { let container = DependencyContainer() var syncCount = 0 diff --git a/Tests/SuperwallKitTests/Identity/IdentifyUserSwitchTests.swift b/Tests/SuperwallKitTests/Identity/IdentifyUserSwitchTests.swift new file mode 100644 index 000000000..afd39619b --- /dev/null +++ b/Tests/SuperwallKitTests/Identity/IdentifyUserSwitchTests.swift @@ -0,0 +1,48 @@ +// +// IdentifyUserSwitchTests.swift +// SuperwallKit +// +// Created by Yusuf Tör on 2026-09-17. +// +// swiftlint:disable all + +import Foundation +import Testing +@testable import SuperwallKit + +/// Identifying a different user resets the previous one from inside the +/// identity manager's queue. Anything that reset does must not wait on that +/// queue, or every later identity call hangs for the rest of the process. +@Suite(.serialized) +struct IdentifyUserSwitchTests { + @Test("Switching user with cached MMP attribution doesn't hang the identity queue") + func switchingUserWithCachedAcquisitionAttributesKeepsTheQueueAlive() { + let superwall = Superwall.shared + let storage: Storage = superwall.dependencyContainer.storage + let identityManager: IdentityManager = superwall.dependencyContainer.identityManager + + // An install match that resolved on an earlier launch. It's install-scoped, + // so the reset re-applies it to the new user. + storage.save(["acquisition_source": JSON("test_network")], forType: MMPAcquisitionDataStorage.self) + defer { + storage.delete(MMPAcquisitionDataStorage.self) + } + + superwall.identify(userId: "switch-user-a") + superwall.identify(userId: "switch-user-b") + + // A read on the identity queue only returns if the reset above finished. + let queueAnswered = DispatchSemaphore(value: 0) + DispatchQueue.global().async { + _ = identityManager.userAttributes + queueAnswered.signal() + } + guard queueAnswered.wait(timeout: .now() + 3) == .success else { + // Anything else that touches the queue would hang too, so stop here. + Issue.record("the identity queue is hung") + return + } + #expect(identityManager.appUserId == "switch-user-b") + superwall.reset() + } +} From d568e987191a1f27c409e0efb1a12d44c34580f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:32:01 +0200 Subject: [PATCH 130/162] Assert only what other suites can't disturb in the user-switch test The identify-triggered reset is wired to the shared instance, so the test has to run against it. Whether its queue still answers is the only thing no other suite's use of that instance can make false, so that's the only assertion. Co-Authored-By: Claude Fable 5.1 --- .../SuperwallKitTests/Identity/IdentifyUserSwitchTests.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Tests/SuperwallKitTests/Identity/IdentifyUserSwitchTests.swift b/Tests/SuperwallKitTests/Identity/IdentifyUserSwitchTests.swift index afd39619b..4b034edf9 100644 --- a/Tests/SuperwallKitTests/Identity/IdentifyUserSwitchTests.swift +++ b/Tests/SuperwallKitTests/Identity/IdentifyUserSwitchTests.swift @@ -13,6 +13,10 @@ import Testing /// Identifying a different user resets the previous one from inside the /// identity manager's queue. Anything that reset does must not wait on that /// queue, or every later identity call hangs for the rest of the process. +/// +/// The identify-triggered reset is wired to the shared instance, so this runs +/// against it. The only assertion is that its queue still answers, which no +/// other suite's use of the instance can make false. @Suite(.serialized) struct IdentifyUserSwitchTests { @Test("Switching user with cached MMP attribution doesn't hang the identity queue") @@ -42,7 +46,6 @@ struct IdentifyUserSwitchTests { Issue.record("the identity queue is hung") return } - #expect(identityManager.appUserId == "switch-user-b") superwall.reset() } } From 387c6a183866b020f2b3b7524bd7dce4bf38a34c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:39:44 +0200 Subject: [PATCH 131/162] Drop the overwrite check on the paywall attribute path A paywall's attribute message carries what its author wired up in the editor, and nothing there sends `idfv`, `idfa` or `attStatus`; only the SDK knows those. The check guarded a door nobody can reach, so the handler goes back to merging directly. Co-Authored-By: Claude Fable 5.1 --- .../Identity/UserAttributes.swift | 10 ---------- Sources/SuperwallKit/Superwall.swift | 2 +- .../AttributionDeviceIdentifiersTests.swift | 20 ------------------- 3 files changed, 1 insertion(+), 31 deletions(-) diff --git a/Sources/SuperwallKit/Identity/UserAttributes.swift b/Sources/SuperwallKit/Identity/UserAttributes.swift index 60f73be93..90b8fdb84 100644 --- a/Sources/SuperwallKit/Identity/UserAttributes.swift +++ b/Sources/SuperwallKit/Identity/UserAttributes.swift @@ -94,16 +94,6 @@ extension Superwall { setUserAttributes(userAttributes) } - /// Merges attributes set from a paywall and notifies the delegate. Runs the - /// same overwrite check as ``setUserAttributes(_:)-1wq0n``, so a paywall that - /// writes one of the SDK-owned keys is answered by a resync too. - func setUserAttributesFromPaywall(_ attributes: [String: Any]) { - dependencyContainer.attributionFetcher?.forgetSyncedDeviceIdentifiers( - ifChangedBy: attributes - ) - dependencyContainer.identityManager.mergeUserAttributesAndNotify(attributes) - } - private func mergeAttributes(_ attributes: [String: Any?]) { var customAttributes: [String: Any?] = [:] diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 5ca20f4de..91c9c72f9 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -1504,7 +1504,7 @@ extension Superwall: PaywallViewControllerEventDelegate { attributesDict[key] = attribute["value"].object } } - setUserAttributesFromPaywall(attributesDict) + dependencyContainer.identityManager.mergeUserAttributesAndNotify(attributesDict) case let .requestCallback(name, behavior, requestId, _): Logger.debug( logLevel: .debug, diff --git a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceIdentifiersTests.swift b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceIdentifiersTests.swift index 1accc7dc6..ae070b2df 100644 --- a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceIdentifiersTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionDeviceIdentifiersTests.swift @@ -260,26 +260,6 @@ struct AttributionDeviceIdentifiersTests { #expect(syncCount == 2) } - @Test func resendsTheIdentifiersWhenAPaywallOverwritesThem() { - let container = DependencyContainer() - var syncCount = 0 - let fetcher = makeFetcher(container: container, sync: { _ in syncCount += 1 }) - defer { fetcher.cancelPendingOperations() } - container.attributionFetcher = fetcher - let superwall = Superwall(dependencyContainer: container) - - fetcher.mergeIntegrationAttributes(attributes: ["appsflyerId": "af-1"]) - #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") - #expect(syncCount == 1) - - // A paywall's attribute write doesn't go through `setUserAttributes`, but - // it has to be answered the same way. - superwall.setUserAttributesFromPaywall(["idfv": "made-up"]) - fetcher.refreshDeviceIdentifiers() - #expect(fetcher.integrationAttributes["appsflyerId"] == "af-1") - #expect(syncCount == 2) - } - @Test func keepsQuietWhenTheAppWritesItsOwnAttributes() { let container = DependencyContainer() var syncCount = 0 From b56231b6ffa56242cfe272a70cc62d2ec9758703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:44:11 +0200 Subject: [PATCH 132/162] Say on reset(duringIdentify:) which queue it runs on, and check the reapply landed The constraint that nothing on the identify-triggered reset may wait on the identity queue was only recorded at the site that got bitten. It now sits on the function that imposes it. The user-switch test also checks the cached attribution reached the new user, so a reapply that silently no-ops fails it. Co-Authored-By: Claude Fable 5.1 --- Sources/SuperwallKit/Superwall.swift | 4 ++++ .../SuperwallKitTests/Identity/IdentifyUserSwitchTests.swift | 3 +++ 2 files changed, 7 insertions(+) diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 91c9c72f9..08d4f46bc 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -1111,6 +1111,10 @@ public final class Superwall: NSObject, ObservableObject { } /// Asynchronously resets. Presentation of paywalls is suspended until reset completes. + /// + /// When `duringIdentify` is true this runs on the identity manager's queue, + /// so nothing it calls may wait on that queue, such as reading + /// `identityManager.userAttributes`, or the queue hangs for good. func reset(duringIdentify: Bool) { // Warn here rather than in the public reset() so the identify-triggered // reset — the actual user-switch moment — warns too. diff --git a/Tests/SuperwallKitTests/Identity/IdentifyUserSwitchTests.swift b/Tests/SuperwallKitTests/Identity/IdentifyUserSwitchTests.swift index 4b034edf9..e420bc43f 100644 --- a/Tests/SuperwallKitTests/Identity/IdentifyUserSwitchTests.swift +++ b/Tests/SuperwallKitTests/Identity/IdentifyUserSwitchTests.swift @@ -46,6 +46,9 @@ struct IdentifyUserSwitchTests { Issue.record("the identity queue is hung") return } + // The reset re-applies the cached attribution to the new user. Its merge + // was queued from inside the identify block, so it lands ahead of this read. + #expect(identityManager.userAttributes["acquisition_source"] as? String == "test_network") superwall.reset() } } From 091978229993ae97f62ba642218f52aab568984f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:27:16 +0200 Subject: [PATCH 133/162] Keep a paywall's experiment with the request that presented it When two campaigns share a paywall they share one cached view controller. A request used to write its experiment, products and delegate onto that controller as soon as it fetched it, even while another placement's presentation was on screen. The open and purchase events then carried the first placement with the second experiment. A request now applies its data only when it claims the view controller, at present for register and at set for getPaywall, and a controller that's on screen can't be claimed. A getPaywall claim is remembered so the app's own presentation reports the placement it was fetched for, even if register used the same paywall in between. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + .../SuperwallKit/Config/ConfigManager.swift | 1 - .../Manager/Logic/PaywallManagerLogic.swift | 18 +- .../Paywall/Manager/PaywallManager.swift | 14 +- .../Get Paywall/InternalGetPaywall.swift | 3 + .../Presentation/GetPaywallComponents.swift | 3 +- .../Internal/InternalPresentation.swift | 1 + .../Internal/Operators/GetPaywallVC.swift | 9 +- .../Internal/Operators/PresentPaywall.swift | 4 +- .../PaywallViewController.swift | 73 +++++- SuperwallKit.xcodeproj/project.pbxproj | 4 + .../Logic/PaywallManagerLogicTests.swift | 26 +- .../Paywall/Manager/PaywallManagerMock.swift | 1 - ...wallSharedControllerAttributionTests.swift | 231 ++++++++++++++++++ .../PresentPaywallOperatorTests.swift | 2 + .../PaywallViewControllerMock.swift | 1 + 16 files changed, 339 insertions(+), 53 deletions(-) create mode 100644 Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f9fdeb03..5afe86e4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes a crash when `register` is called from more than one thread at a time. - Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them. - Fixes audiences matching users they shouldn't when you use a Purchase Controller. +- Fixes paywall opens and purchases being reported under the wrong experiment when two campaigns share a paywall and a second placement fires while it is on screen. ## 4.16.3 diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index 03f6a7821..765719d40 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -754,7 +754,6 @@ class ConfigManager { for: paywall, isDebuggerLaunched: request.isDebuggerLaunched, isForPresentation: true, - isPreloading: true, delegate: nil ) } diff --git a/Sources/SuperwallKit/Paywall/Manager/Logic/PaywallManagerLogic.swift b/Sources/SuperwallKit/Paywall/Manager/Logic/PaywallManagerLogic.swift index 273d0492a..4cb666527 100644 --- a/Sources/SuperwallKit/Paywall/Manager/Logic/PaywallManagerLogic.swift +++ b/Sources/SuperwallKit/Paywall/Manager/Logic/PaywallManagerLogic.swift @@ -10,32 +10,20 @@ import Foundation enum PaywallManagerLogic { enum Outcome { case loadWebView - case setDelegate case replacePaywall - case updatePaywall } static func handleCachedPaywall( newPaywall: Paywall, oldPaywall: Paywall, - isPreloading: Bool, isForPresentation: Bool ) -> [Outcome] { - var outcome: [Outcome] = [] - guard isForPresentation else { - return outcome + return [] } if newPaywall.cacheKey != oldPaywall.cacheKey { - outcome.append(.replacePaywall) - outcome.append(.loadWebView) - if !isPreloading { - outcome.append(.setDelegate) - } - } else if !isPreloading { - outcome.append(.setDelegate) - outcome.append(.updatePaywall) + return [.replacePaywall, .loadWebView] } - return outcome + return [] } } diff --git a/Sources/SuperwallKit/Paywall/Manager/PaywallManager.swift b/Sources/SuperwallKit/Paywall/Manager/PaywallManager.swift index 9fbfd2d12..2873e1e59 100644 --- a/Sources/SuperwallKit/Paywall/Manager/PaywallManager.swift +++ b/Sources/SuperwallKit/Paywall/Manager/PaywallManager.swift @@ -89,7 +89,6 @@ class PaywallManager { for paywall: Paywall, isDebuggerLaunched: Bool, isForPresentation: Bool, - isPreloading: Bool, delegate: PaywallViewControllerDelegateAdapter? ) async throws -> PaywallViewController { let deviceInfo = factory.makeDeviceInfo() @@ -100,10 +99,17 @@ class PaywallManager { if !isDebuggerLaunched, let viewController = self.cache.getPaywallViewController(forKey: cacheKey) { + // There is one view controller per paywall. While it's on screen it + // belongs to that presentation, so leave it alone. The experiment, + // products and delegate for a request are applied when the request + // claims the view controller, in `set(request:paywall:...)`. + if viewController.isActive { + return viewController + } + let outcomes = PaywallManagerLogic.handleCachedPaywall( newPaywall: paywall, oldPaywall: viewController.paywall, - isPreloading: isPreloading, isForPresentation: isForPresentation ) @@ -113,10 +119,6 @@ class PaywallManager { viewController.loadWebView() case .replacePaywall: viewController.paywall = paywall - case .setDelegate: - viewController.delegate = delegate - case .updatePaywall: - viewController.paywall.update(from: paywall) } } diff --git a/Sources/SuperwallKit/Paywall/Presentation/Get Paywall/InternalGetPaywall.swift b/Sources/SuperwallKit/Paywall/Presentation/Get Paywall/InternalGetPaywall.swift index 3ce86077d..f6c9dabe2 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Get Paywall/InternalGetPaywall.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Get Paywall/InternalGetPaywall.swift @@ -12,6 +12,8 @@ import UIKit extension Superwall { struct PaywallComponents { let viewController: PaywallViewController + /// The paywall resolved for the request, including its experiment. + let paywall: Paywall let presenter: UIViewController? let audienceOutcome: AudienceFilterEvaluationOutcome let debugInfo: [String: Any] @@ -33,6 +35,7 @@ extension Superwall { await paywallComponents.viewController.set( request: request, + paywall: paywallComponents.paywall, paywallStatePublisher: publisher, unsavedOccurrence: paywallComponents.audienceOutcome.unsavedOccurrence ) diff --git a/Sources/SuperwallKit/Paywall/Presentation/GetPaywallComponents.swift b/Sources/SuperwallKit/Paywall/Presentation/GetPaywallComponents.swift index 6e9ddf2a0..54f89444a 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/GetPaywallComponents.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/GetPaywallComponents.swift @@ -37,7 +37,7 @@ extension Superwall { from: audienceOutcome ) - let paywallViewController = try await getPaywallViewController( + let (paywallViewController, paywall) = try await getPaywallViewController( request: request, audienceOutcome: audienceOutcome, debugInfo: debugInfo, @@ -61,6 +61,7 @@ extension Superwall { return PaywallComponents( viewController: paywallViewController, + paywall: paywall, presenter: presenter, audienceOutcome: audienceOutcome, debugInfo: debugInfo diff --git a/Sources/SuperwallKit/Paywall/Presentation/Internal/InternalPresentation.swift b/Sources/SuperwallKit/Paywall/Presentation/Internal/InternalPresentation.swift index 0b40b873c..0a7854907 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Internal/InternalPresentation.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Internal/InternalPresentation.swift @@ -38,6 +38,7 @@ extension Superwall { try await presentPaywallViewController( paywallComponents.viewController, + paywall: paywallComponents.paywall, on: presenter, unsavedOccurrence: paywallComponents.audienceOutcome.unsavedOccurrence, debugInfo: paywallComponents.debugInfo, diff --git a/Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/GetPaywallVC.swift b/Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/GetPaywallVC.swift index 0958c2bad..050ced950 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/GetPaywallVC.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/GetPaywallVC.swift @@ -18,7 +18,9 @@ extension Superwall { /// - paywallStatePublisher: A `PassthroughSubject` that gets sent ``PaywallState`` objects. /// - dependencyContainer: Used with testing only. /// - /// - Returns: A ``PaywallViewController``. + /// - Returns: A ``PaywallViewController`` and the paywall resolved for this + /// request. The paywall is applied to the view controller when the request + /// claims it, so that an earlier request that's on screen isn't changed. /// - throws: An error if unable to retrieve paywall or a paywall is /// already presented. func getPaywallViewController( @@ -27,7 +29,7 @@ extension Superwall { debugInfo: [String: Any], paywallStatePublisher: PassthroughSubject? = nil, dependencyContainer: DependencyContainer - ) async throws -> PaywallViewController { + ) async throws -> (viewController: PaywallViewController, paywall: Paywall) { let experiment = try await getExperiment( request: request, audienceOutcome: audienceOutcome, @@ -70,11 +72,10 @@ extension Superwall { for: paywall, isDebuggerLaunched: paywallRequest.isDebuggerLaunched, isForPresentation: isForPresentation, - isPreloading: false, delegate: delegate ) - return paywallViewController + return (paywallViewController, paywall) } catch { throw await presentationFailure(error, request, debugInfo, paywallStatePublisher) } diff --git a/Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/PresentPaywall.swift b/Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/PresentPaywall.swift index 5beb61b75..9237316da 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/PresentPaywall.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/PresentPaywall.swift @@ -13,7 +13,7 @@ extension Superwall { /// and sends back a `presented` state to the paywall state publisher. /// /// - Parameters: - /// - paywallStatePublisher: A `PassthroughSubject` that gets sent ``PaywallState`` objects. + /// - paywall: The paywall resolved for the request. /// - presenter: The view controller to present that paywall on. /// - unsavedOccurrence: The audience occurrence to save, if available. /// - debugInfo: Information to help with debugging. @@ -24,6 +24,7 @@ extension Superwall { @MainActor func presentPaywallViewController( _ paywallViewController: PaywallViewController, + paywall: Paywall, on presenter: UIViewController, unsavedOccurrence: TriggerAudienceOccurrence?, debugInfo: [String: Any], @@ -43,6 +44,7 @@ extension Superwall { paywallViewController.present( on: presenter, request: request, + paywall: paywall, unsavedOccurrence: unsavedOccurrence, presentationStyleOverride: request.paywallOverrides?.presentationStyle, paywallStatePublisher: paywallStatePublisher diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index 260b8a1ad..ef4dfbfc7 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -41,6 +41,25 @@ public class PaywallViewController: UIViewController, LoadingDelegate { /// The cache key for the view controller. var cacheKey: String + /// What a request applies when it claims the view controller. + private struct Claim { + let request: PresentationRequest + let paywall: Paywall + let paywallStatePublisher: PassthroughSubject + let unsavedOccurrence: TriggerAudienceOccurrence? + } + + /// The claim made through `getPaywall`. Kept so that a view controller the + /// app is holding reports that placement when the app shows it, even if the + /// SDK presented this paywall for another placement in the meantime. + private var handedOutClaim: Claim? + + /// Whether the SDK claimed the view controller after it was handed out. + private var handedOutClaimNeedsRestoring = false + + /// Whether the SDK started the current presentation via ``present(on:request:paywall:unsavedOccurrence:presentationStyleOverride:paywallStatePublisher:completion:)``. + private var isPresentedBySDK = false + /// Determines whether the paywall is presented or not. var isActive: Bool { return isPresented || isBeingPresented @@ -735,20 +754,55 @@ public class PaywallViewController: UIViewController, LoadingDelegate { // MARK: - Presentation Logic - /// Sets data before presenting the paywall. + /// Claims the view controller for a request: binds the request and applies + /// the paywall resolved for it, so the placement, experiment and products + /// reported for this presentation all come from the same request. + /// + /// A view controller that's on screen stays with the request that put it + /// there, so a claim made while it's active is ignored. func set( request: PresentationRequest, + paywall: Paywall, paywallStatePublisher: PassthroughSubject, unsavedOccurrence: TriggerAudienceOccurrence? ) { - self.request = request - self.paywallStateSubject = paywallStatePublisher - self.unsavedOccurrence = unsavedOccurrence + if isActive { + Logger.debug( + logLevel: .warn, + scope: .paywallPresentation, + message: "Ignoring request for a paywall that is already presented.", + info: ["placement": request.presentationInfo.placementName ?? ""] + ) + return + } + let claim = Claim( + request: request, + paywall: paywall, + paywallStatePublisher: paywallStatePublisher, + unsavedOccurrence: unsavedOccurrence + ) + apply(claim) + + if case .getPaywall = request.flags.type { + handedOutClaim = claim + handedOutClaimNeedsRestoring = false + } else if handedOutClaim != nil { + handedOutClaimNeedsRestoring = true + } + } + + private func apply(_ claim: Claim) { + paywall.update(from: claim.paywall) + delegate = claim.request.flags.type.getPaywallVcDelegateAdapter() + request = claim.request + paywallStateSubject = claim.paywallStatePublisher + unsavedOccurrence = claim.unsavedOccurrence } func present( on presenter: UIViewController, request: PresentationRequest, + paywall: Paywall, unsavedOccurrence: TriggerAudienceOccurrence?, presentationStyleOverride: PaywallPresentationStyle?, paywallStatePublisher: PassthroughSubject, @@ -763,11 +817,13 @@ public class PaywallViewController: UIViewController, LoadingDelegate { set( request: request, + paywall: paywall, paywallStatePublisher: paywallStatePublisher, unsavedOccurrence: unsavedOccurrence ) setPresentationStyle(withOverride: presentationStyleOverride) + isPresentedBySDK = true presenter.present( self, @@ -1401,6 +1457,14 @@ extension PaywallViewController { override public func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + // The app is showing a view controller it got from `getPaywall`, and the + // SDK has used it for another placement since. Report the app's placement. + if !isPresentedBySDK, + handedOutClaimNeedsRestoring, + let claim = handedOutClaim { + apply(claim) + handedOutClaimNeedsRestoring = false + } cache?.activePaywallVcKey = cacheKey if isSafariVCPresented { @@ -1668,6 +1732,7 @@ extension PaywallViewController { paywallResult = nil cache?.activePaywallVcKey = nil isPresented = false + isPresentedBySDK = false dismissCompletionBlock?() dismissCompletionBlock = nil diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 2e3935ec1..e7ce55bd8 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -148,6 +148,7 @@ 3CF2307C2CB994D00A35FADD /* LoadingModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 866F99509EDFBE8BAE10E575 /* LoadingModel.swift */; }; 3D5684BAF13C86ABED458EB9 /* SerialTaskCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 224B526C168B5DB83E1F50D2 /* SerialTaskCoordinatorTests.swift */; }; 3DCE95BAC148CCC7E6E7F608 /* DeviceTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3E5C31CEEDC9C2853D91C50 /* DeviceTemplate.swift */; }; + 3E4694244060F6951B127401 /* PaywallSharedControllerAttributionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F86A10F4385C6E329FED2EF3 /* PaywallSharedControllerAttributionTests.swift */; }; 3EA92DE86764CBAC557F8522 /* Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27E41300E7467F017BCD5E5C /* Capabilities.swift */; }; 3EE4C1C4EC45718C2EED34E5 /* EventTrackingBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93D8033AAF5549E30ACDA3EA /* EventTrackingBehavior.swift */; }; 3F3774A066285BB0DFE61B61 /* JSONToDict.swift in Sources */ = {isa = PBXBuildFile; fileRef = E09C238ADC0B019047FAB1DF /* JSONToDict.swift */; }; @@ -1216,6 +1217,7 @@ F6EED7C7E264C38A1A7C3EFB /* StorePayment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorePayment.swift; sourceTree = ""; }; F798D9662212AD1CC75666F3 /* PaywallMessageHandlerDelegateMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallMessageHandlerDelegateMock.swift; sourceTree = ""; }; F85ED994DEC92BB90ACC6AC2 /* TrackingResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackingResult.swift; sourceTree = ""; }; + F86A10F4385C6E329FED2EF3 /* PaywallSharedControllerAttributionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallSharedControllerAttributionTests.swift; sourceTree = ""; }; F9098101E599AEB01521FE89 /* DebugViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugViewController.swift; sourceTree = ""; }; F96ABDCA3686C7F1CAF41B03 /* en_GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en_GB; path = en_GB.lproj/Localizable.strings; sourceTree = ""; }; F98E1C9554F6AFAECF9B3430 /* StoreProductBillingPlanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreProductBillingPlanTests.swift; sourceTree = ""; }; @@ -1956,6 +1958,7 @@ children = ( 58BA95995DE57E811FD65C02 /* PaywallCacheLogicTests.swift */, 5C57C1CCAF97244AE0DC953F /* PaywallManagerMock.swift */, + F86A10F4385C6E329FED2EF3 /* PaywallSharedControllerAttributionTests.swift */, 672776875A4286319C2F2D61 /* PaywallViewControllerCacheTests.swift */, A52293BCD414E92F7B54919D /* Logic */, ); @@ -3407,6 +3410,7 @@ 69F7D9CC12CBC367735DE213 /* PaywallPreloadingTests.swift in Sources */, DB7858A959C145FA32F6C9EC /* PaywallPresentationInfoTests.swift in Sources */, 702CEF940451FCB4AF354D90 /* PaywallPresentationStyleTests.swift in Sources */, + 3E4694244060F6951B127401 /* PaywallSharedControllerAttributionTests.swift in Sources */, 4B0E203D477E48611797047C /* PaywallViewControllerCacheTests.swift in Sources */, 7CC56E289C0A1C93411B68D2 /* PaywallViewControllerDrawerTests.swift in Sources */, 5F1F480AA12C8D17B179D96B /* PaywallViewControllerMock.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Paywall/Manager/Logic/PaywallManagerLogicTests.swift b/Tests/SuperwallKitTests/Paywall/Manager/Logic/PaywallManagerLogicTests.swift index 8e9250e80..87f8f56bf 100644 --- a/Tests/SuperwallKitTests/Paywall/Manager/Logic/PaywallManagerLogicTests.swift +++ b/Tests/SuperwallKitTests/Paywall/Manager/Logic/PaywallManagerLogicTests.swift @@ -15,49 +15,35 @@ struct PaywallManagerLogicTests { let outcomes = PaywallManagerLogic.handleCachedPaywall( newPaywall: .stub(), oldPaywall: .stub(), - isPreloading: false, isForPresentation: false ) #expect(outcomes.isEmpty) } - @Test func handleCachedPaywall_samePaywallURLs_isPreloading() { + @Test func handleCachedPaywall_samePaywall_isForPresentation() { let outcomes = PaywallManagerLogic.handleCachedPaywall( newPaywall: .stub(), oldPaywall: .stub(), - isPreloading: true, - isForPresentation: false - ) - #expect(outcomes.isEmpty) - } - - @Test func handleCachedPaywall_samePaywallURLs_isNotPreloading() { - let outcomes = PaywallManagerLogic.handleCachedPaywall( - newPaywall: .stub(), - oldPaywall: .stub(), - isPreloading: false, isForPresentation: true ) - #expect(outcomes == [.setDelegate, .updatePaywall]) + #expect(outcomes.isEmpty) } - @Test func handleCachedPaywall_diffPaywallURLs_isNotPreloading() { + @Test func handleCachedPaywall_diffPaywall_isNotForPresentation() { let outcomes = PaywallManagerLogic.handleCachedPaywall( newPaywall: .stub().setting(\.url, to: URL(string: "https://twitter.com")!), oldPaywall: .stub() .setting(\.cacheKey, to: "123"), - isPreloading: false, - isForPresentation: true + isForPresentation: false ) - #expect(outcomes == [.replacePaywall, .loadWebView, .setDelegate]) + #expect(outcomes.isEmpty) } - @Test func handleCachedPaywall_diffPaywallURLs_isPreloading() { + @Test func handleCachedPaywall_diffPaywall_isForPresentation() { let outcomes = PaywallManagerLogic.handleCachedPaywall( newPaywall: .stub().setting(\.url, to: URL(string: "https://twitter.com")!), oldPaywall: .stub() .setting(\.cacheKey, to: "123"), - isPreloading: true, isForPresentation: true ) #expect(outcomes == [.replacePaywall, .loadWebView]) diff --git a/Tests/SuperwallKitTests/Paywall/Manager/PaywallManagerMock.swift b/Tests/SuperwallKitTests/Paywall/Manager/PaywallManagerMock.swift index aefe3fa08..82e3f37a4 100644 --- a/Tests/SuperwallKitTests/Paywall/Manager/PaywallManagerMock.swift +++ b/Tests/SuperwallKitTests/Paywall/Manager/PaywallManagerMock.swift @@ -17,7 +17,6 @@ final class PaywallManagerMock: PaywallManager { for paywall: Paywall, isDebuggerLaunched: Bool, isForPresentation: Bool, - isPreloading: Bool, delegate: PaywallViewControllerDelegateAdapter? ) async throws -> PaywallViewController { if let getPaywallError = getPaywallError { diff --git a/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift b/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift new file mode 100644 index 000000000..d4061656a --- /dev/null +++ b/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift @@ -0,0 +1,231 @@ +// +// PaywallSharedControllerAttributionTests.swift +// SuperwallKitTests +// + +// swiftlint:disable all + +import Foundation +import Testing +import Combine +@testable import SuperwallKit + +/// Lets a test say whether the view controller is on screen. +private final class ActivePaywallViewController: PaywallViewController { + var isOnScreen = false + override var isActive: Bool { isOnScreen } +} + +/// Two campaigns share one paywall, so both requests get the one cached view +/// controller. Whatever a request reports for its presentation must all come +/// from that request, and a presentation that's on screen can't be changed by +/// another request. +@MainActor +struct PaywallSharedControllerAttributionTests { + private let dependencyContainer = DependencyContainer() + + private func paywall( + experimentId: String, + variantId: String, + source: String + ) -> Paywall { + var paywall = Paywall.stub() + paywall.experiment = Experiment( + id: experimentId, + groupId: "group", + variant: .init(id: variantId, type: .treatment, paywallId: paywall.identifier) + ) + paywall.presentationSourceType = source + return paywall + } + + private func request( + placement: String, + type: PresentationRequestType = .presentation + ) -> PresentationRequest { + dependencyContainer.makePresentationRequest( + .implicitTrigger(PlacementData(name: placement, parameters: [:], createdAt: Date())), + isDebuggerLaunched: false, + isPaywallPresented: false, + type: type + ) + } + + private var getPaywallType: PresentationRequestType { + .getPaywall(PaywallViewControllerDelegateAdapter(swiftDelegate: nil, objcDelegate: nil)) + } + + private func cacheKey(for paywall: Paywall) -> String { + PaywallCacheLogic.key( + identifier: paywall.identifier, + locale: dependencyContainer.deviceHelper.localeIdentifier + ) + } + + private func claim( + _ viewController: PaywallViewController, + placement: String, + paywall: Paywall, + type: PresentationRequestType = .presentation + ) { + viewController.set( + request: request(placement: placement, type: type), + paywall: paywall, + paywallStatePublisher: .init(), + unsavedOccurrence: nil + ) + } + + /// Puts a view controller for `paywall` in the cache, claimed by `placement`. + private func cachedViewController( + for paywall: Paywall, + placement: String + ) -> ActivePaywallViewController { + let messageHandler = PaywallMessageHandler( + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + permissionHandler: FakePermissionHandler(), + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ) + let webView = SWWebView( + isMac: false, + messageHandler: messageHandler, + isOnDeviceCacheEnabled: true, + factory: dependencyContainer + ) + let cache = dependencyContainer.paywallManager!.cache + let viewController = ActivePaywallViewController( + paywall: paywall, + 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(viewController, forKey: cacheKey(for: paywall)) + claim(viewController, placement: placement, paywall: paywall) + return viewController + } + + private func expectSessionStartAttribution(_ info: PaywallInfo) { + #expect(info.presentedByPlacementWithName == "session_start") + #expect(info.experiment?.id == "180872") + #expect(info.experiment?.variant.id == "632038") + #expect(info.presentationSourceType == "implicit") + } + + @Test + func claimAppliesTheRequestsPaywall() { + let paywallA = paywall(experimentId: "180872", variantId: "632038", source: "implicit") + let viewController = cachedViewController(for: paywallA, placement: "session_start") + + let paywallB = paywall(experimentId: "181395", variantId: "633524", source: "register") + claim(viewController, placement: "campaign_trigger", paywall: paywallB) + + let info = viewController.info + #expect(info.presentedByPlacementWithName == "campaign_trigger") + #expect(info.experiment?.id == "181395") + #expect(info.experiment?.variant.id == "633524") + #expect(info.presentationSourceType == "register") + } + + @Test + func requestWhilePresentedLeavesViewControllerAlone() async throws { + let paywallA = paywall(experimentId: "180872", variantId: "632038", source: "implicit") + let presented = cachedViewController(for: paywallA, placement: "session_start") + presented.isOnScreen = true + + // A main-campaign placement whose variant uses the same paywall fires + // while the session-start paywall is on screen. + let paywallB = paywall(experimentId: "181395", variantId: "633524", source: "register") + let paywallManager = try #require(dependencyContainer.paywallManager) + let viewControllerB = try await paywallManager.getViewController( + for: paywallB, + isDebuggerLaunched: false, + isForPresentation: true, + delegate: nil + ) + + #expect(viewControllerB === presented) + expectSessionStartAttribution(presented.info) + } + + @Test + func presentedViewControllerCannotBeClaimedByAnotherRequest() { + let paywallA = paywall(experimentId: "180872", variantId: "632038", source: "implicit") + let presented = cachedViewController(for: paywallA, placement: "session_start") + presented.isOnScreen = true + + // `getPaywall` claims after fetching. If the view controller went on + // screen for another placement in between, the claim must not take it. + let paywallB = paywall(experimentId: "181395", variantId: "633524", source: "register") + claim(presented, placement: "campaign_trigger", paywall: paywallB) + + expectSessionStartAttribution(presented.info) + } + + @Test + func handedOutViewControllerReportsItsOwnPlacementWhenTheAppShowsIt() { + let paywallEmbedded = paywall(experimentId: "166736", variantId: "634019", source: "getPaywall") + let viewController = cachedViewController(for: paywallEmbedded, placement: "embedded") + // The app fetched it with `getPaywall` and is holding on to it. + claim(viewController, placement: "embedded", paywall: paywallEmbedded, type: getPaywallType) + + // `register` presents the same paywall for another placement, then it's dismissed. + let paywallA = paywall(experimentId: "180872", variantId: "632038", source: "implicit") + claim(viewController, placement: "session_start", paywall: paywallA) + viewController.isOnScreen = true + #expect(viewController.info.presentedByPlacementWithName == "session_start") + viewController.isOnScreen = false + + // The app now shows the view controller it was holding. + viewController.viewWillAppear(false) + + let info = viewController.info + #expect(info.presentedByPlacementWithName == "embedded") + #expect(info.experiment?.id == "166736") + #expect(info.experiment?.variant.id == "634019") + #expect(info.presentationSourceType == "getPaywall") + } + + @Test + func appearingWithoutAHandedOutClaimKeepsTheCurrentRequest() { + let paywallA = paywall(experimentId: "180872", variantId: "632038", source: "implicit") + let viewController = cachedViewController(for: paywallA, placement: "session_start") + + viewController.viewWillAppear(false) + + expectSessionStartAttribution(viewController.info) + } + + @Test + func interleavedRequestsReportThePresentingRequest() async throws { + let paywallManager = try #require(dependencyContainer.paywallManager) + let paywallA = paywall(experimentId: "180872", variantId: "632038", source: "implicit") + let paywallB = paywall(experimentId: "181395", variantId: "633524", source: "register") + + // Both requests fetch the view controller before either presents. + let viewControllerA = try await paywallManager.getViewController( + for: paywallA, + isDebuggerLaunched: false, + isForPresentation: true, + delegate: nil + ) + let viewControllerB = try await paywallManager.getViewController( + for: paywallB, + isDebuggerLaunched: false, + isForPresentation: true, + delegate: nil + ) + #expect(viewControllerB === viewControllerA) + + // Request A presents. Everything it reports must be A's. + claim(viewControllerA, placement: "session_start", paywall: paywallA) + + expectSessionStartAttribution(viewControllerA.info) + } +} diff --git a/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/PresentPaywallOperatorTests.swift b/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/PresentPaywallOperatorTests.swift index 5061dee3f..87084cbb4 100644 --- a/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/PresentPaywallOperatorTests.swift +++ b/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/PresentPaywallOperatorTests.swift @@ -67,6 +67,7 @@ final class PresentPaywallOperatorTests { do { _ = try await Superwall.shared.presentPaywallViewController( paywallVc, + paywall: .stub(), on: UIViewController(), unsavedOccurrence: nil, debugInfo: [:], @@ -135,6 +136,7 @@ final class PresentPaywallOperatorTests { do { _ = try await Superwall.shared.presentPaywallViewController( paywallVc, + paywall: .stub(), on: UIViewController(), unsavedOccurrence: nil, debugInfo: [:], diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerMock.swift b/Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerMock.swift index 0ce75bde5..94643afa3 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerMock.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerMock.swift @@ -15,6 +15,7 @@ final class PaywallViewControllerMock: PaywallViewController { override func present( on presenter: UIViewController, request: PresentationRequest, + paywall: Paywall, unsavedOccurrence: TriggerAudienceOccurrence?, presentationStyleOverride: PaywallPresentationStyle?, paywallStatePublisher: PassthroughSubject, From d714e833a7322485eab82b636114ca6019c9165c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:54:59 +0200 Subject: [PATCH 134/162] Drop the redundant claim log and fix the present operator's doc comment The pipeline already logs when a paywall can't present because another is on screen. The doc comment now lists the view controller parameter and describes the throw instead of a return value that doesn't exist. Co-Authored-By: Claude Fable 5.1 --- .../Presentation/Internal/Operators/PresentPaywall.swift | 3 ++- .../Paywall/View Controller/PaywallViewController.swift | 6 ------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/PresentPaywall.swift b/Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/PresentPaywall.swift index 9237316da..b06a094a0 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/PresentPaywall.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/PresentPaywall.swift @@ -13,6 +13,7 @@ extension Superwall { /// and sends back a `presented` state to the paywall state publisher. /// /// - Parameters: + /// - paywallViewController: The view controller to present. /// - paywall: The paywall resolved for the request. /// - presenter: The view controller to present that paywall on. /// - unsavedOccurrence: The audience occurrence to save, if available. @@ -20,7 +21,7 @@ extension Superwall { /// - request: The request to present the paywall. /// - paywallStatePublisher: A `PassthroughSubject` that gets sent ``PaywallState`` objects. /// - /// - Returns: A publisher that contains info for the next pipeline operator. + /// - Throws: `PresentationPipelineError.paywallAlreadyPresented` if another paywall is on screen. @MainActor func presentPaywallViewController( _ paywallViewController: PaywallViewController, diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index ef4dfbfc7..41d202ef7 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -767,12 +767,6 @@ public class PaywallViewController: UIViewController, LoadingDelegate { unsavedOccurrence: TriggerAudienceOccurrence? ) { if isActive { - Logger.debug( - logLevel: .warn, - scope: .paywallPresentation, - message: "Ignoring request for a paywall that is already presented.", - info: ["placement": request.presentationInfo.placementName ?? ""] - ) return } let claim = Claim( From 208e046462c7f87dc45887502807bf6ff7b21b76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:20:00 +0200 Subject: [PATCH 135/162] Keep a getPaywall claim made while the paywall is on screen, and don't replay used claim state A getPaywall request for a paywall that's currently presented still gets the view controller back. Its claim is now recorded and restored when the app shows the handle after the current presentation ends, instead of being dropped. The claim's occurrence and state publisher are cleared once used, so a restore can't save the occurrence a second time or reinstall a publisher that already completed. A claim applied by a request also clears the SDK-presented flag, so it can't latch if a presentation never appears. Adds a test that pins the manager guard against replacing a presented paywall with a new version, plus tests for the restored claim and the occurrence being saved exactly once. Co-Authored-By: Claude Fable 5.1 --- .../PaywallViewController.swift | 59 +++- ...wallSharedControllerAttributionTests.swift | 258 ++++++++++++++---- 2 files changed, 258 insertions(+), 59 deletions(-) diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index 41d202ef7..b4296593a 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -42,16 +42,38 @@ public class PaywallViewController: UIViewController, LoadingDelegate { var cacheKey: String /// What a request applies when it claims the view controller. - private struct Claim { + /// + /// The occurrence and publisher are cleared once used, so restoring the + /// claim later doesn't save the occurrence again or reinstall a publisher + /// that has already completed. + private final class Claim { let request: PresentationRequest let paywall: Paywall - let paywallStatePublisher: PassthroughSubject - let unsavedOccurrence: TriggerAudienceOccurrence? + var paywallStatePublisher: PassthroughSubject? + var unsavedOccurrence: TriggerAudienceOccurrence? + + init( + request: PresentationRequest, + paywall: Paywall, + paywallStatePublisher: PassthroughSubject, + unsavedOccurrence: TriggerAudienceOccurrence? + ) { + self.request = request + self.paywall = paywall + self.paywallStatePublisher = paywallStatePublisher + self.unsavedOccurrence = unsavedOccurrence + } } + /// The claim currently applied to the view controller. + private var currentClaim: Claim? + /// The claim made through `getPaywall`. Kept so that a view controller the /// app is holding reports that placement when the app shows it, even if the /// SDK presented this paywall for another placement in the meantime. + /// + /// This is held for the life of the cached view controller on purpose: the + /// app can show the handle it was given at any time. private var handedOutClaim: Claim? /// Whether the SDK claimed the view controller after it was handed out. @@ -766,18 +788,34 @@ public class PaywallViewController: UIViewController, LoadingDelegate { paywallStatePublisher: PassthroughSubject, unsavedOccurrence: TriggerAudienceOccurrence? ) { - if isActive { - return - } let claim = Claim( request: request, paywall: paywall, paywallStatePublisher: paywallStatePublisher, unsavedOccurrence: unsavedOccurrence ) + let isHandedOut: Bool + if case .getPaywall = request.flags.type { + isHandedOut = true + } else { + isHandedOut = false + } + + if isActive { + // The app still gets this view controller back from `getPaywall`, so + // keep its claim for when the app shows it after the current + // presentation ends. + if isHandedOut { + handedOutClaim = claim + handedOutClaimNeedsRestoring = true + } + return + } + apply(claim) + isPresentedBySDK = false - if case .getPaywall = request.flags.type { + if isHandedOut { handedOutClaim = claim handedOutClaimNeedsRestoring = false } else if handedOutClaim != nil { @@ -789,8 +827,13 @@ public class PaywallViewController: UIViewController, LoadingDelegate { paywall.update(from: claim.paywall) delegate = claim.request.flags.type.getPaywallVcDelegateAdapter() request = claim.request + if claim.paywallStatePublisher == nil { + // The previous one completed when the paywall was last dismissed. + claim.paywallStatePublisher = PassthroughSubject() + } paywallStateSubject = claim.paywallStatePublisher unsavedOccurrence = claim.unsavedOccurrence + currentClaim = claim } func present( @@ -1573,6 +1616,7 @@ extension PaywallViewController { if let unsavedOccurrence = unsavedOccurrence { storage.coreDataManager.save(triggerAudienceOccurrence: unsavedOccurrence) self.unsavedOccurrence = nil + currentClaim?.unsavedOccurrence = nil } isPresented = true Superwall.shared.dependencyContainer.delegateAdapter.didPresentPaywall(withInfo: info) @@ -1744,6 +1788,7 @@ extension PaywallViewController { if paywall.closeReason.stateShouldComplete { paywallStateSubject?.send(completion: .finished) paywallStateSubject = nil + currentClaim?.paywallStatePublisher = nil } Superwall.shared.dependencyContainer.delegateAdapter.didDismissPaywall(withInfo: info) diff --git a/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift b/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift index d4061656a..0908f5ed7 100644 --- a/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift +++ b/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift @@ -10,10 +10,33 @@ import Testing import Combine @testable import SuperwallKit -/// Lets a test say whether the view controller is on screen. +/// Lets a test say whether the view controller is on screen, and records +/// whether the web view was asked to load. private final class ActivePaywallViewController: PaywallViewController { var isOnScreen = false + var didLoadWebView = false + override var isActive: Bool { isOnScreen } + + override func loadWebView() { + didLoadWebView = true + } +} + +/// Counts occurrence saves instead of writing to Core Data. +private final class OccurrenceCountingCoreDataManager: CoreDataManager { + var savedOccurrences = 0 + + init() { + super.init(coreDataStack: CoreDataStackMock()) + } + + override func save( + triggerAudienceOccurrence audienceOccurence: TriggerAudienceOccurrence, + completion: ((ManagedTriggerRuleOccurrence) -> Void)? = nil + ) { + savedOccurrences += 1 + } } /// Two campaigns share one paywall, so both requests get the one cached view @@ -24,6 +47,11 @@ private final class ActivePaywallViewController: PaywallViewController { struct PaywallSharedControllerAttributionTests { private let dependencyContainer = DependencyContainer() + /// The view controller only holds its storage and dependencies unowned, and + /// showing it starts tracking tasks that outlive the test, so anything it + /// depends on must live for the rest of the process. + private static var retained: [AnyObject] = [] + private func paywall( experimentId: String, variantId: String, @@ -39,6 +67,18 @@ struct PaywallSharedControllerAttributionTests { return paywall } + private var sessionStartPaywall: Paywall { + paywall(experimentId: "180872", variantId: "632038", source: "implicit") + } + + private var campaignPaywall: Paywall { + paywall(experimentId: "181395", variantId: "633524", source: "register") + } + + private var embeddedPaywall: Paywall { + paywall(experimentId: "166736", variantId: "634019", source: "getPaywall") + } + private func request( placement: String, type: PresentationRequestType = .presentation @@ -66,20 +106,22 @@ struct PaywallSharedControllerAttributionTests { _ viewController: PaywallViewController, placement: String, paywall: Paywall, - type: PresentationRequestType = .presentation + type: PresentationRequestType = .presentation, + unsavedOccurrence: TriggerAudienceOccurrence? = nil ) { viewController.set( request: request(placement: placement, type: type), paywall: paywall, paywallStatePublisher: .init(), - unsavedOccurrence: nil + unsavedOccurrence: unsavedOccurrence ) } /// Puts a view controller for `paywall` in the cache, claimed by `placement`. private func cachedViewController( for paywall: Paywall, - placement: String + placement: String, + storage: Storage? = nil ) -> ActivePaywallViewController { let messageHandler = PaywallMessageHandler( receiptManager: dependencyContainer.receiptManager, @@ -98,7 +140,7 @@ struct PaywallSharedControllerAttributionTests { paywall: paywall, deviceHelper: dependencyContainer.deviceHelper, factory: dependencyContainer, - storage: dependencyContainer.storage, + storage: storage ?? dependencyContainer.storage, network: dependencyContainer.network, webView: webView, webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, @@ -111,6 +153,18 @@ struct PaywallSharedControllerAttributionTests { return viewController } + /// Runs the appearance callbacks the way UIKit does when the app shows the view controller. + private func show(_ viewController: PaywallViewController) { + Self.retained.append(dependencyContainer) + viewController.viewWillAppear(false) + viewController.viewDidAppear(false) + } + + private func hide(_ viewController: PaywallViewController) { + viewController.viewWillDisappear(false) + viewController.viewDidDisappear(false) + } + private func expectSessionStartAttribution(_ info: PaywallInfo) { #expect(info.presentedByPlacementWithName == "session_start") #expect(info.experiment?.id == "180872") @@ -118,13 +172,20 @@ struct PaywallSharedControllerAttributionTests { #expect(info.presentationSourceType == "implicit") } + private func expectEmbeddedAttribution(_ info: PaywallInfo) { + #expect(info.presentedByPlacementWithName == "embedded") + #expect(info.experiment?.id == "166736") + #expect(info.experiment?.variant.id == "634019") + #expect(info.presentationSourceType == "getPaywall") + } + + // MARK: - Claiming + @Test func claimAppliesTheRequestsPaywall() { - let paywallA = paywall(experimentId: "180872", variantId: "632038", source: "implicit") - let viewController = cachedViewController(for: paywallA, placement: "session_start") + let viewController = cachedViewController(for: sessionStartPaywall, placement: "session_start") - let paywallB = paywall(experimentId: "181395", variantId: "633524", source: "register") - claim(viewController, placement: "campaign_trigger", paywall: paywallB) + claim(viewController, placement: "campaign_trigger", paywall: campaignPaywall) let info = viewController.info #expect(info.presentedByPlacementWithName == "campaign_trigger") @@ -133,18 +194,44 @@ struct PaywallSharedControllerAttributionTests { #expect(info.presentationSourceType == "register") } + @Test + func interleavedRequestsReportThePresentingRequest() async throws { + let paywallManager = try #require(dependencyContainer.paywallManager) + let paywallA = sessionStartPaywall + + // Both requests fetch the view controller before either presents. + let viewControllerA = try await paywallManager.getViewController( + for: paywallA, + isDebuggerLaunched: false, + isForPresentation: true, + delegate: nil + ) + let viewControllerB = try await paywallManager.getViewController( + for: campaignPaywall, + isDebuggerLaunched: false, + isForPresentation: true, + delegate: nil + ) + #expect(viewControllerB === viewControllerA) + + // Request A presents. Everything it reports must be A's. + claim(viewControllerA, placement: "session_start", paywall: paywallA) + + expectSessionStartAttribution(viewControllerA.info) + } + + // MARK: - While on screen + @Test func requestWhilePresentedLeavesViewControllerAlone() async throws { - let paywallA = paywall(experimentId: "180872", variantId: "632038", source: "implicit") - let presented = cachedViewController(for: paywallA, placement: "session_start") + let presented = cachedViewController(for: sessionStartPaywall, placement: "session_start") presented.isOnScreen = true // A main-campaign placement whose variant uses the same paywall fires // while the session-start paywall is on screen. - let paywallB = paywall(experimentId: "181395", variantId: "633524", source: "register") let paywallManager = try #require(dependencyContainer.paywallManager) let viewControllerB = try await paywallManager.getViewController( - for: paywallB, + for: campaignPaywall, isDebuggerLaunched: false, isForPresentation: true, delegate: nil @@ -155,29 +242,70 @@ struct PaywallSharedControllerAttributionTests { } @Test - func presentedViewControllerCannotBeClaimedByAnotherRequest() { - let paywallA = paywall(experimentId: "180872", variantId: "632038", source: "implicit") + func presentedViewControllerIsNotReplacedByANewPaywallVersion() async throws { + let paywallA = sessionStartPaywall let presented = cachedViewController(for: paywallA, placement: "session_start") presented.isOnScreen = true + // The paywall was republished, so the same request now resolves to a new + // version. The cache would normally swap it in and reload the web view. + var newVersion = campaignPaywall + newVersion.cacheKey = "newVersion" + let paywallManager = try #require(dependencyContainer.paywallManager) + let viewController = try await paywallManager.getViewController( + for: newVersion, + isDebuggerLaunched: false, + isForPresentation: true, + delegate: nil + ) + + #expect(viewController === presented) + #expect(presented.paywall.cacheKey == paywallA.cacheKey) + #expect(!presented.didLoadWebView) + expectSessionStartAttribution(presented.info) + } + + @Test + func presentedViewControllerCannotBeClaimedByAnotherRequest() { + let presented = cachedViewController(for: sessionStartPaywall, placement: "session_start") + presented.isOnScreen = true + // `getPaywall` claims after fetching. If the view controller went on // screen for another placement in between, the claim must not take it. - let paywallB = paywall(experimentId: "181395", variantId: "633524", source: "register") - claim(presented, placement: "campaign_trigger", paywall: paywallB) + claim(presented, placement: "campaign_trigger", paywall: campaignPaywall) expectSessionStartAttribution(presented.info) } + // MARK: - Handed out via getPaywall + + @Test + func getPaywallClaimWhilePresentedIsRestoredWhenTheAppShowsIt() { + let presented = cachedViewController(for: sessionStartPaywall, placement: "session_start") + presented.isOnScreen = true + + // The app calls `getPaywall` for the paywall that's on screen. It gets the + // view controller back, but the presentation must not change. + claim(presented, placement: "embedded", paywall: embeddedPaywall, type: getPaywallType) + expectSessionStartAttribution(presented.info) + #expect(presented.delegate == nil) + + // The presentation ends and the app shows the view controller it holds. + presented.isOnScreen = false + presented.viewWillAppear(false) + + expectEmbeddedAttribution(presented.info) + #expect(presented.delegate != nil) + } + @Test func handedOutViewControllerReportsItsOwnPlacementWhenTheAppShowsIt() { - let paywallEmbedded = paywall(experimentId: "166736", variantId: "634019", source: "getPaywall") - let viewController = cachedViewController(for: paywallEmbedded, placement: "embedded") + let viewController = cachedViewController(for: embeddedPaywall, placement: "embedded") // The app fetched it with `getPaywall` and is holding on to it. - claim(viewController, placement: "embedded", paywall: paywallEmbedded, type: getPaywallType) + claim(viewController, placement: "embedded", paywall: embeddedPaywall, type: getPaywallType) // `register` presents the same paywall for another placement, then it's dismissed. - let paywallA = paywall(experimentId: "180872", variantId: "632038", source: "implicit") - claim(viewController, placement: "session_start", paywall: paywallA) + claim(viewController, placement: "session_start", paywall: sessionStartPaywall) viewController.isOnScreen = true #expect(viewController.info.presentedByPlacementWithName == "session_start") viewController.isOnScreen = false @@ -185,47 +313,73 @@ struct PaywallSharedControllerAttributionTests { // The app now shows the view controller it was holding. viewController.viewWillAppear(false) - let info = viewController.info - #expect(info.presentedByPlacementWithName == "embedded") - #expect(info.experiment?.id == "166736") - #expect(info.experiment?.variant.id == "634019") - #expect(info.presentationSourceType == "getPaywall") + expectEmbeddedAttribution(viewController.info) } @Test - func appearingWithoutAHandedOutClaimKeepsTheCurrentRequest() { - let paywallA = paywall(experimentId: "180872", variantId: "632038", source: "implicit") - let viewController = cachedViewController(for: paywallA, placement: "session_start") + func handedOutOccurrenceIsSavedWhenTheAppFirstShowsIt() { + let coreDataManager = OccurrenceCountingCoreDataManager() + let storage = Storage(factory: dependencyContainer, cache: Cache(), coreDataManager: coreDataManager) + Self.retained.append(storage) + let viewController = cachedViewController( + for: embeddedPaywall, + placement: "embedded", + storage: storage + ) + claim( + viewController, + placement: "embedded", + paywall: embeddedPaywall, + type: getPaywallType, + unsavedOccurrence: .stub() + ) - viewController.viewWillAppear(false) + // `register` claims the paywall before the app has shown its handle. + claim(viewController, placement: "session_start", paywall: sessionStartPaywall) - expectSessionStartAttribution(viewController.info) + show(viewController) + + #expect(coreDataManager.savedOccurrences == 1) + expectEmbeddedAttribution(viewController.info) } @Test - func interleavedRequestsReportThePresentingRequest() async throws { - let paywallManager = try #require(dependencyContainer.paywallManager) - let paywallA = paywall(experimentId: "180872", variantId: "632038", source: "implicit") - let paywallB = paywall(experimentId: "181395", variantId: "633524", source: "register") - - // Both requests fetch the view controller before either presents. - let viewControllerA = try await paywallManager.getViewController( - for: paywallA, - isDebuggerLaunched: false, - isForPresentation: true, - delegate: nil + func restoredClaimDoesNotSaveItsOccurrenceAgain() { + let coreDataManager = OccurrenceCountingCoreDataManager() + let storage = Storage(factory: dependencyContainer, cache: Cache(), coreDataManager: coreDataManager) + Self.retained.append(storage) + let viewController = cachedViewController( + for: embeddedPaywall, + placement: "embedded", + storage: storage ) - let viewControllerB = try await paywallManager.getViewController( - for: paywallB, - isDebuggerLaunched: false, - isForPresentation: true, - delegate: nil + claim( + viewController, + placement: "embedded", + paywall: embeddedPaywall, + type: getPaywallType, + unsavedOccurrence: .stub() ) - #expect(viewControllerB === viewControllerA) - // Request A presents. Everything it reports must be A's. - claim(viewControllerA, placement: "session_start", paywall: paywallA) + // The app shows and hides its handle, which saves the occurrence. + show(viewController) + hide(viewController) + #expect(coreDataManager.savedOccurrences == 1) - expectSessionStartAttribution(viewControllerA.info) + // `register` claims the paywall, then the app shows its handle again. + claim(viewController, placement: "session_start", paywall: sessionStartPaywall) + show(viewController) + + #expect(coreDataManager.savedOccurrences == 1) + expectEmbeddedAttribution(viewController.info) + } + + @Test + func appearingWithoutAHandedOutClaimKeepsTheCurrentRequest() { + let viewController = cachedViewController(for: sessionStartPaywall, placement: "session_start") + + viewController.viewWillAppear(false) + + expectSessionStartAttribution(viewController.info) } } From da4041f8d0462b3e1405a0ca13c1402c8c56e3f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:54:17 +0200 Subject: [PATCH 136/162] Only restore a handed-out claim on an appearance that starts a presentation viewWillAppear also fires while the paywall is still on screen, for example after Safari closes. A second getPaywall for a paywall the app was already showing could then have its claim applied under the live presentation. The restore now keys off presentationWillPrepare, which is reset synchronously when the paywall disappears and cleared on the first appearance. Adds a test for that round trip and one that checks a restored claim gets a fresh state publisher once the old one has completed. Co-Authored-By: Claude Fable 5.1 --- .../PaywallViewController.swift | 9 ++- ...wallSharedControllerAttributionTests.swift | 66 ++++++++++++++++++- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index b4296593a..cceb7dc2f 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -1494,9 +1494,12 @@ extension PaywallViewController { override public func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - // The app is showing a view controller it got from `getPaywall`, and the - // SDK has used it for another placement since. Report the app's placement. - if !isPresentedBySDK, + // The app is showing a view controller it got from `getPaywall`, and + // another request has claimed it since. Report the app's placement. Only + // on an appearance that starts a presentation: `viewWillAppear` also fires + // when the paywall is already on screen, such as after Safari closes. + if presentationWillPrepare, + !isPresentedBySDK, handedOutClaimNeedsRestoring, let claim = handedOutClaim { apply(claim) diff --git a/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift b/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift index 0908f5ed7..4f559b104 100644 --- a/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift +++ b/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift @@ -107,12 +107,13 @@ struct PaywallSharedControllerAttributionTests { placement: String, paywall: Paywall, type: PresentationRequestType = .presentation, - unsavedOccurrence: TriggerAudienceOccurrence? = nil + unsavedOccurrence: TriggerAudienceOccurrence? = nil, + paywallStatePublisher: PassthroughSubject = .init() ) { viewController.set( request: request(placement: placement, type: type), paywall: paywall, - paywallStatePublisher: .init(), + paywallStatePublisher: paywallStatePublisher, unsavedOccurrence: unsavedOccurrence ) } @@ -298,6 +299,31 @@ struct PaywallSharedControllerAttributionTests { #expect(presented.delegate != nil) } + @Test + func secondGetPaywallWhileTheAppShowsItDoesNotChangeThePresentation() { + let viewController = cachedViewController(for: embeddedPaywall, placement: "embedded") + claim(viewController, placement: "embedded", paywall: embeddedPaywall, type: getPaywallType) + show(viewController) + viewController.isOnScreen = true + let delegate = viewController.delegate + + // The app calls `getPaywall` again for the same paywall while it's showing + // it, then something like Safari closing makes it appear again. + claim(viewController, placement: "campaign_trigger", paywall: campaignPaywall, type: getPaywallType) + viewController.viewWillAppear(false) + + expectEmbeddedAttribution(viewController.info) + #expect(viewController.delegate === delegate) + + // Once that presentation ends, the newer claim is what the app gets. + hide(viewController) + viewController.isOnScreen = false + viewController.viewWillAppear(false) + + #expect(viewController.info.presentedByPlacementWithName == "campaign_trigger") + #expect(viewController.info.experiment?.id == "181395") + } + @Test func handedOutViewControllerReportsItsOwnPlacementWhenTheAppShowsIt() { let viewController = cachedViewController(for: embeddedPaywall, placement: "embedded") @@ -374,6 +400,42 @@ struct PaywallSharedControllerAttributionTests { expectEmbeddedAttribution(viewController.info) } + @Test + func restoredClaimGetsAFreshStatePublisherAfterTheOldOneCompleted() async throws { + let viewController = cachedViewController(for: embeddedPaywall, placement: "embedded") + let publisher = PassthroughSubject() + final class Completion { var done = false } + let completion = Completion() + let subscription = publisher.sink( + receiveCompletion: { _ in completion.done = true }, + receiveValue: { _ in } + ) + defer { subscription.cancel() } + claim( + viewController, + placement: "embedded", + paywall: embeddedPaywall, + type: getPaywallType, + paywallStatePublisher: publisher + ) + + // The app shows and hides its handle. Dismissal completes the publisher. + show(viewController) + hide(viewController) + for _ in 0..<200 where !completion.done { + try await Task.sleep(nanoseconds: 10_000_000) + } + #expect(completion.done) + + // `register` claims the paywall, then the app shows its handle again. + claim(viewController, placement: "session_start", paywall: sessionStartPaywall) + show(viewController) + + let stored = try #require(Superwall.shared.presentationItems.last?.statePublisher) + #expect(stored !== publisher) + expectEmbeddedAttribution(viewController.info) + } + @Test func appearingWithoutAHandedOutClaimKeepsTheCurrentRequest() { let viewController = cachedViewController(for: sessionStartPaywall, placement: "session_start") From b1c7a817f7fe9b209d0cceccca389b8fa6d007be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:14:47 +0200 Subject: [PATCH 137/162] Tie the stored publisher check to this test's own presentation Co-Authored-By: Claude Fable 5.1 --- .../Manager/PaywallSharedControllerAttributionTests.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift b/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift index 4f559b104..284a3d23d 100644 --- a/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift +++ b/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift @@ -431,8 +431,9 @@ struct PaywallSharedControllerAttributionTests { claim(viewController, placement: "session_start", paywall: sessionStartPaywall) show(viewController) - let stored = try #require(Superwall.shared.presentationItems.last?.statePublisher) - #expect(stored !== publisher) + let stored = try #require(Superwall.shared.presentationItems.last) + #expect(stored.request.presentationInfo.placementName == "embedded") + #expect(stored.statePublisher !== publisher) expectEmbeddedAttribution(viewController.info) } From 94fb59db81248ac152b1611ed5042eeed20a3a09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:20:03 +0200 Subject: [PATCH 138/162] Update CHANGELOG.md Removes the breaking change mention. Okay technically breaking but unlikely people are using it like that plus it's a fix really. --- CHANGELOG.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45e4441e5..70dae709b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,6 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. ## 4.17.0 - -### Breaking Changes - -- Changes `$subscriptionStatus` from a `@Published` publisher to an `AnyPublisher`. Subscribing to it works as before, but it can no longer be the target of `assign(to:)`. Changes are now delivered in order, and when several threads change the status at once one of them may deliver the others' changes, so a subscriber that blocks delays every pending change, though it never blocks the code doing the assigning. - ### Enhancements - Adds `grantedEntitlements` so you can grant entitlements from your own backend, which the SDK merges with device and web entitlements. From 059a2aa7ac3f07b2e24da172f2b318eb03282e49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:23:50 +0200 Subject: [PATCH 139/162] Show the paywall version a claim resolved, not just its products A claim recorded while the shared controller was on screen can carry a newer version of the paywall than the one loaded. Applying it only copied the products and experiment, so the app could present the old content under the new attribution. A claim whose cache key differs now swaps the paywall in and reloads the web view, the same as the manager does for a controller that isn't on screen. Co-Authored-By: Claude Fable 5.1 --- .../PaywallViewController.swift | 9 +++- ...wallSharedControllerAttributionTests.swift | 45 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index cceb7dc2f..b67cf2f4a 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -824,7 +824,14 @@ public class PaywallViewController: UIViewController, LoadingDelegate { } private func apply(_ claim: Claim) { - paywall.update(from: claim.paywall) + if claim.paywall.cacheKey == paywall.cacheKey { + paywall.update(from: claim.paywall) + } else { + // The request resolved a newer version of the paywall than the one + // loaded, so show that version rather than only its products. + paywall = claim.paywall + loadWebView() + } delegate = claim.request.flags.type.getPaywallVcDelegateAdapter() request = claim.request if claim.paywallStatePublisher == nil { diff --git a/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift b/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift index 284a3d23d..d4d784547 100644 --- a/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift +++ b/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift @@ -195,6 +195,51 @@ struct PaywallSharedControllerAttributionTests { #expect(info.presentationSourceType == "register") } + @Test + func claimWithANewPaywallVersionLoadsThatVersion() { + let viewController = cachedViewController(for: sessionStartPaywall, placement: "session_start") + #expect(!viewController.didLoadWebView) + + var newVersion = campaignPaywall + newVersion.cacheKey = "newVersion" + claim(viewController, placement: "campaign_trigger", paywall: newVersion) + + #expect(viewController.paywall.cacheKey == "newVersion") + #expect(viewController.didLoadWebView) + #expect(viewController.info.experiment?.id == "181395") + } + + @Test + func claimWithTheSamePaywallVersionDoesNotReload() { + let viewController = cachedViewController(for: sessionStartPaywall, placement: "session_start") + + claim(viewController, placement: "campaign_trigger", paywall: campaignPaywall) + + #expect(!viewController.didLoadWebView) + #expect(viewController.info.experiment?.id == "181395") + } + + @Test + func restoredClaimWithANewPaywallVersionLoadsThatVersion() { + let viewController = cachedViewController(for: sessionStartPaywall, placement: "session_start") + viewController.isOnScreen = true + + // The paywall is republished while on screen, and the app fetches it. + var newVersion = embeddedPaywall + newVersion.cacheKey = "newVersion" + claim(viewController, placement: "embedded", paywall: newVersion, type: getPaywallType) + #expect(viewController.paywall.cacheKey == sessionStartPaywall.cacheKey) + #expect(!viewController.didLoadWebView) + + // The presentation ends and the app shows its handle. + viewController.isOnScreen = false + viewController.viewWillAppear(false) + + #expect(viewController.paywall.cacheKey == "newVersion") + #expect(viewController.didLoadWebView) + expectEmbeddedAttribution(viewController.info) + } + @Test func interleavedRequestsReportThePresentingRequest() async throws { let paywallManager = try #require(dependencyContainer.paywallManager) From f88573e45b2a2a37562e36ec9bf145f65a1d1768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:10:47 +0200 Subject: [PATCH 140/162] Install a claim's delegate before reloading, and load once on restore The reload for a different paywall version announced its loading state to the outgoing claim's delegate. The claim's delegate and request are now installed first. A restore that started a load no longer also runs the failed-load reload in the same appearance. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- .../PaywallViewController.swift | 30 +++++++++----- ...wallSharedControllerAttributionTests.swift | 40 ++++++++++++++++++- 3 files changed, 58 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70dae709b..517333eed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes a hang when identifying a different user after an install attribution match had been found. - Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them. - Fixes audiences matching users they shouldn't when you use a Purchase Controller. -- Fixes paywall opens and purchases being reported under the wrong experiment when two campaigns share a paywall and a second placement fires while it is on screen. +- Fixes paywall opens and purchases being reported under the wrong experiment when two campaigns share a paywall and a second placement fires while it is on screen. A paywall fetched with `getPaywall` now shows the version it resolved when presented. ## 4.16.3 diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index b67cf2f4a..df6182126 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -823,15 +823,11 @@ public class PaywallViewController: UIViewController, LoadingDelegate { } } - private func apply(_ claim: Claim) { - if claim.paywall.cacheKey == paywall.cacheKey { - paywall.update(from: claim.paywall) - } else { - // The request resolved a newer version of the paywall than the one - // loaded, so show that version rather than only its products. - paywall = claim.paywall - loadWebView() - } + /// Applies a claim. Returns whether it started a web view load. + @discardableResult + private func apply(_ claim: Claim) -> Bool { + // Install the claim's delegate and request first, so anything the + // reload below announces goes to the claim that owns it. delegate = claim.request.flags.type.getPaywallVcDelegateAdapter() request = claim.request if claim.paywallStatePublisher == nil { @@ -841,6 +837,16 @@ public class PaywallViewController: UIViewController, LoadingDelegate { paywallStateSubject = claim.paywallStatePublisher unsavedOccurrence = claim.unsavedOccurrence currentClaim = claim + + if claim.paywall.cacheKey == paywall.cacheKey { + paywall.update(from: claim.paywall) + return false + } + // The request resolved a different version of the paywall from the one + // loaded, so show that version rather than only its products. + paywall = claim.paywall + loadWebView() + return true } func present( @@ -1505,11 +1511,12 @@ extension PaywallViewController { // another request has claimed it since. Report the app's placement. Only // on an appearance that starts a presentation: `viewWillAppear` also fires // when the paywall is already on screen, such as after Safari closes. + var didStartLoadForClaim = false if presentationWillPrepare, !isPresentedBySDK, handedOutClaimNeedsRestoring, let claim = handedOutClaim { - apply(claim) + didStartLoadForClaim = apply(claim) handedOutClaimNeedsRestoring = false } cache?.activePaywallVcKey = cacheKey @@ -1523,7 +1530,8 @@ extension PaywallViewController { webView.setAllMediaPlaybackSuspended(false) // ignore-xcode-12 } - if webView.loadingHandler.didFailToLoad { + if webView.loadingHandler.didFailToLoad, + !didStartLoadForClaim { loadWebView() } diff --git a/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift b/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift index d4d784547..8b2d1c688 100644 --- a/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift +++ b/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift @@ -14,12 +14,16 @@ import Combine /// whether the web view was asked to load. private final class ActivePaywallViewController: PaywallViewController { var isOnScreen = false - var didLoadWebView = false + var didLoadWebView: Bool { loadWebViewCount > 0 } + var loadWebViewCount = 0 + /// The delegate installed at the moment a load was asked for. + var delegateAtLoad: PaywallViewControllerDelegateAdapter? override var isActive: Bool { isOnScreen } override func loadWebView() { - didLoadWebView = true + loadWebViewCount += 1 + delegateAtLoad = delegate } } @@ -209,6 +213,38 @@ struct PaywallSharedControllerAttributionTests { #expect(viewController.info.experiment?.id == "181395") } + @Test + func reloadForANewVersionIsAnnouncedToTheClaimsDelegate() { + let viewController = cachedViewController(for: sessionStartPaywall, placement: "session_start") + + var newVersion = embeddedPaywall + newVersion.cacheKey = "newVersion" + claim(viewController, placement: "embedded", paywall: newVersion, type: getPaywallType) + + #expect(viewController.delegateAtLoad != nil) + #expect(viewController.delegateAtLoad === viewController.delegate) + } + + @Test + func restoreWithANewVersionStartsOneLoadEvenAfterAFailedLoad() { + let viewController = cachedViewController(for: sessionStartPaywall, placement: "session_start") + // The view is loaded before a paywall is ever shown, which does the first load. + viewController.loadViewIfNeeded() + viewController.loadWebViewCount = 0 + viewController.isOnScreen = true + var newVersion = embeddedPaywall + newVersion.cacheKey = "newVersion" + claim(viewController, placement: "embedded", paywall: newVersion, type: getPaywallType) + viewController.isOnScreen = false + + // The web content process died while off screen. + viewController.webView.loadingHandler.didFailToLoad = true + viewController.viewWillAppear(false) + + #expect(viewController.loadWebViewCount == 1) + #expect(viewController.paywall.cacheKey == "newVersion") + } + @Test func claimWithTheSamePaywallVersionDoesNotReload() { let viewController = cachedViewController(for: sessionStartPaywall, placement: "session_start") From 2e841b5614cc7b711ae7809384d8b70ff5f28377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:19:47 +0200 Subject: [PATCH 141/162] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 517333eed..70dae709b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes a hang when identifying a different user after an install attribution match had been found. - Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them. - Fixes audiences matching users they shouldn't when you use a Purchase Controller. -- Fixes paywall opens and purchases being reported under the wrong experiment when two campaigns share a paywall and a second placement fires while it is on screen. A paywall fetched with `getPaywall` now shows the version it resolved when presented. +- Fixes paywall opens and purchases being reported under the wrong experiment when two campaigns share a paywall and a second placement fires while it is on screen. ## 4.16.3 From 8d3fe5cf3dbd9c0ae5bb11656f3c2385738c04c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:28:48 +0200 Subject: [PATCH 142/162] Surface the web funnel's user attributes on a redeemed code The redeem response has carried redemptionInfo.userAttributes since paywall-next#3876, but our keyed decoder dropped the key, so apps had to set up a webhook to read what someone answered on the web onboarding before they bought. Decode it onto RedemptionInfo and pass it through to the Objective-C model, so it arrives with the result in didRedeemLink. It stays nil when the funnel collected nothing, including for codes bought before the web paywall started recording answers. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../Models/Web2App/RedemptionResult.swift | 39 ++++- .../Models/Web2App/RedemptionResultObjc.swift | 12 +- .../Models/Web2App/RedeemResponseTests.swift | 150 ++++++++++++++++++ 4 files changed, 199 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70dae709b..02f7ac5a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Adds `grantedEntitlements` so you can grant entitlements from your own backend, which the SDK merges with device and web entitlements. - Adds `CaseIterable` conformance to `IntegrationAttribute`, so you can list every integration the SDK supports. +- Adds `userAttributes` to the `RedemptionInfo` you get from `didRedeemLink`, so you can read the answers someone gave on your web paywall funnel after they redeem in your app. ### Fixes diff --git a/Sources/SuperwallKit/Models/Web2App/RedemptionResult.swift b/Sources/SuperwallKit/Models/Web2App/RedemptionResult.swift index fb351afc9..8c5c492ff 100644 --- a/Sources/SuperwallKit/Models/Web2App/RedemptionResult.swift +++ b/Sources/SuperwallKit/Models/Web2App/RedemptionResult.swift @@ -93,6 +93,14 @@ public enum RedemptionResult: Codable { /// The entitlements array public let entitlements: Set + /// The attributes collected on the web paywall funnel that led to the + /// purchase, merged across every checkout the code redeems. + /// + /// This is `nil` when the funnel collected no attributes, which is always + /// the case for purchases made before the web paywall started recording + /// them. Values are strings, numbers, booleans, or arrays of those. + public let userAttributes: [String: Any]? + /// Enum specifiying code ownership. public enum Ownership: Codable { /// The code belongs to the identified user. @@ -428,23 +436,49 @@ public enum RedemptionResult: Codable { } } + enum CodingKeys: String, CodingKey { + case ownership + case purchaserInfo + case paywallInfo + case entitlements + case userAttributes + } + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.ownership = try container.decode(Ownership.self, forKey: .ownership) self.purchaserInfo = try container.decode(PurchaserInfo.self, forKey: .purchaserInfo) self.paywallInfo = try container.decodeIfPresent(PaywallInfo.self, forKey: .paywallInfo) self.entitlements = try container.decode(Set.self, forKey: .entitlements) + + let userAttributesJSON = try container.decodeIfPresent(JSON.self, forKey: .userAttributes) + let userAttributes = userAttributesJSON?.dictionaryObject + self.userAttributes = userAttributes?.isEmpty == true ? nil : userAttributes + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(ownership, forKey: .ownership) + try container.encode(purchaserInfo, forKey: .purchaserInfo) + try container.encodeIfPresent(paywallInfo, forKey: .paywallInfo) + try container.encode(entitlements, forKey: .entitlements) + + if let userAttributes = userAttributes { + try container.encode(JSON(userAttributes), forKey: .userAttributes) + } } init( ownership: Ownership, purchaserInfo: PurchaserInfo, - entitlements: Set + entitlements: Set, + userAttributes: [String: Any]? = nil ) { self.ownership = ownership self.purchaserInfo = purchaserInfo self.entitlements = entitlements self.paywallInfo = nil + self.userAttributes = userAttributes } public func toObjc() -> RedemptionResultObjc.RedemptionInfo { @@ -455,7 +489,8 @@ public enum RedemptionResult: Codable { ownership: objcOwnership, purchaserInfo: objcPurchaserInfo, paywallInfo: objcPaywallInfo, - entitlements: entitlements + entitlements: entitlements, + userAttributes: userAttributes ) } } diff --git a/Sources/SuperwallKit/Models/Web2App/RedemptionResultObjc.swift b/Sources/SuperwallKit/Models/Web2App/RedemptionResultObjc.swift index 383f21b5e..06af0e5fe 100644 --- a/Sources/SuperwallKit/Models/Web2App/RedemptionResultObjc.swift +++ b/Sources/SuperwallKit/Models/Web2App/RedemptionResultObjc.swift @@ -127,16 +127,26 @@ public class RedemptionResultObjc: NSObject { /// The entitlements array. public let entitlements: Set + /// The attributes collected on the web paywall funnel that led to the + /// purchase, merged across every checkout the code redeems. + /// + /// This is `nil` when the funnel collected no attributes, which is always + /// the case for purchases made before the web paywall started recording + /// them. Values are strings, numbers, booleans, or arrays of those. + public let userAttributes: [String: Any]? + public init( ownership: Ownership, purchaserInfo: PurchaserInfo, paywallInfo: PaywallInfo?, - entitlements: Set + entitlements: Set, + userAttributes: [String: Any]? ) { self.ownership = ownership self.purchaserInfo = purchaserInfo self.paywallInfo = paywallInfo self.entitlements = entitlements + self.userAttributes = userAttributes super.init() } } diff --git a/Tests/SuperwallKitTests/Models/Web2App/RedeemResponseTests.swift b/Tests/SuperwallKitTests/Models/Web2App/RedeemResponseTests.swift index f116862ab..3b852bce8 100644 --- a/Tests/SuperwallKitTests/Models/Web2App/RedeemResponseTests.swift +++ b/Tests/SuperwallKitTests/Models/Web2App/RedeemResponseTests.swift @@ -89,6 +89,80 @@ final class RedeemResponseTests { } """.data(using: .utf8)! + let successWithUserAttributes = """ + { + "codes": [ + { + "status": "SUCCESS", + "code": "redemption_8c7916a7-d48b-42c1-8eae-a58e0a57d37d", + "redemptionInfo": { + "ownership": { + "type": "APP_USER", + "appUserId": "abc" + }, + "purchaserInfo": { + "appUserId": "abc", + "email": "asdasd@sdfsdf.com", + "storeIdentifiers": { + "store": "STRIPE", + "stripeCustomerId": "cus_Ryex8C8944aFBa", + "stripeSubscriptionIds": ["sub_123"] + } + }, + "paywallInfo": null, + "entitlements": [], + "userAttributes": { + "goal": "build_muscle", + "experience": 3, + "wantsReminders": true, + "equipment": ["dumbbells", "bench"], + "nickname": null + } + } + } + ], + "customerInfo": { + "subscriptions": [], + "nonSubscriptions": [], + "entitlements": [] + } + } + """.data(using: .utf8)! + + let successWithEmptyUserAttributes = """ + { + "codes": [ + { + "status": "SUCCESS", + "code": "code", + "redemptionInfo": { + "ownership": { + "type": "APP_USER", + "appUserId": "abc" + }, + "purchaserInfo": { + "appUserId": "abc", + "email": null, + "storeIdentifiers": { + "store": "STRIPE", + "stripeCustomerId": "cus_Ryex8C8944aFBa", + "stripeSubscriptionIds": ["sub_123"] + } + }, + "paywallInfo": null, + "entitlements": [], + "userAttributes": {} + } + } + ], + "customerInfo": { + "subscriptions": [], + "nonSubscriptions": [], + "entitlements": [] + } + } + """.data(using: .utf8)! + @Test("Decodes success JSON") func testSuccessRedeemResponseDecoding() throws { let decoder = JSONDecoder() @@ -135,4 +209,80 @@ final class RedeemResponseTests { } #expect(response.customerInfo.entitlements.isEmpty) } + + @Test("Decodes the user attributes collected on the web paywall funnel") + func testDecodesUserAttributes() throws { + let decoder = JSONDecoder() + let response = try decoder.decode(RedeemResponse.self, from: successWithUserAttributes) + + guard case let .success(_, redemptionInfo) = response.results.first else { + Issue.record("Incorrect result type") + return + } + let userAttributes = try #require(redemptionInfo.userAttributes) + + #expect(userAttributes["goal"] as? String == "build_muscle") + #expect(userAttributes["experience"] as? Int == 3) + #expect(userAttributes["wantsReminders"] as? Bool == true) + #expect(userAttributes["equipment"] as? [String] == ["dumbbells", "bench"]) + #expect(userAttributes["nickname"] is NSNull) + } + + @Test("User attributes are nil when the response omits them") + func testUserAttributesAreNilWhenAbsent() throws { + let decoder = JSONDecoder() + let response = try decoder.decode(RedeemResponse.self, from: successData) + + guard case let .success(_, redemptionInfo) = response.results.first else { + Issue.record("Incorrect result type") + return + } + #expect(redemptionInfo.userAttributes == nil) + } + + @Test("User attributes are nil when the response sends an empty object") + func testUserAttributesAreNilWhenEmpty() throws { + let decoder = JSONDecoder() + let response = try decoder.decode(RedeemResponse.self, from: successWithEmptyUserAttributes) + + guard case let .success(_, redemptionInfo) = response.results.first else { + Issue.record("Incorrect result type") + return + } + #expect(redemptionInfo.userAttributes == nil) + } + + @Test("User attributes survive an encode/decode round trip") + func testUserAttributesRoundTrip() throws { + let decoder = JSONDecoder() + let response = try decoder.decode(RedeemResponse.self, from: successWithUserAttributes) + let result = try #require(response.results.first) + + let encoded = try JSONEncoder().encode(result) + let decoded = try decoder.decode(RedemptionResult.self, from: encoded) + + guard case let .success(_, redemptionInfo) = decoded else { + Issue.record("Incorrect result type") + return + } + let userAttributes = try #require(redemptionInfo.userAttributes) + + #expect(userAttributes["goal"] as? String == "build_muscle") + #expect(userAttributes["experience"] as? Int == 3) + #expect(userAttributes["wantsReminders"] as? Bool == true) + #expect(userAttributes["equipment"] as? [String] == ["dumbbells", "bench"]) + } + + @Test("User attributes are carried onto the Objective-C model") + func testUserAttributesToObjc() throws { + let decoder = JSONDecoder() + let response = try decoder.decode(RedeemResponse.self, from: successWithUserAttributes) + let result = try #require(response.results.first) + + let objcResult = result.toObjc() + let userAttributes = try #require(objcResult.redemptionInfo?.userAttributes) + + #expect(userAttributes["goal"] as? String == "build_muscle") + #expect(userAttributes["equipment"] as? [String] == ["dumbbells", "bench"]) + } } From 5946a33911d73c01b6c1603e3817392c7688c663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:37:01 +0200 Subject: [PATCH 143/162] Keep the old Objective-C initialiser and document blank answers Requiring userAttributes on RedemptionInfo's initialiser dropped the public four-argument one, which would break anyone constructing the model and takes the existing Objective-C selector with it. Keep it as an overload that passes no attributes. An answer left blank arrives as NSNull, so say so where the values are described. Co-Authored-By: Claude Opus 5 --- .../Models/Web2App/RedemptionResult.swift | 3 ++- .../Models/Web2App/RedemptionResultObjc.swift | 18 +++++++++++++++++- .../Models/Web2App/RedeemResponseTests.swift | 19 +++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/Sources/SuperwallKit/Models/Web2App/RedemptionResult.swift b/Sources/SuperwallKit/Models/Web2App/RedemptionResult.swift index 8c5c492ff..30e886ff2 100644 --- a/Sources/SuperwallKit/Models/Web2App/RedemptionResult.swift +++ b/Sources/SuperwallKit/Models/Web2App/RedemptionResult.swift @@ -98,7 +98,8 @@ public enum RedemptionResult: Codable { /// /// This is `nil` when the funnel collected no attributes, which is always /// the case for purchases made before the web paywall started recording - /// them. Values are strings, numbers, booleans, or arrays of those. + /// them. Values are strings, numbers, booleans, `NSNull` for an answer that + /// was left blank, or arrays of those. public let userAttributes: [String: Any]? /// Enum specifiying code ownership. diff --git a/Sources/SuperwallKit/Models/Web2App/RedemptionResultObjc.swift b/Sources/SuperwallKit/Models/Web2App/RedemptionResultObjc.swift index 06af0e5fe..443ce16a1 100644 --- a/Sources/SuperwallKit/Models/Web2App/RedemptionResultObjc.swift +++ b/Sources/SuperwallKit/Models/Web2App/RedemptionResultObjc.swift @@ -132,7 +132,8 @@ public class RedemptionResultObjc: NSObject { /// /// This is `nil` when the funnel collected no attributes, which is always /// the case for purchases made before the web paywall started recording - /// them. Values are strings, numbers, booleans, or arrays of those. + /// them. Values are strings, numbers, booleans, `NSNull` for an answer that + /// was left blank, or arrays of those. public let userAttributes: [String: Any]? public init( @@ -149,6 +150,21 @@ public class RedemptionResultObjc: NSObject { self.userAttributes = userAttributes super.init() } + + public convenience init( + ownership: Ownership, + purchaserInfo: PurchaserInfo, + paywallInfo: PaywallInfo?, + entitlements: Set + ) { + self.init( + ownership: ownership, + purchaserInfo: purchaserInfo, + paywallInfo: paywallInfo, + entitlements: entitlements, + userAttributes: nil + ) + } } /// Enum specifying code ownership. diff --git a/Tests/SuperwallKitTests/Models/Web2App/RedeemResponseTests.swift b/Tests/SuperwallKitTests/Models/Web2App/RedeemResponseTests.swift index 3b852bce8..ec6e55506 100644 --- a/Tests/SuperwallKitTests/Models/Web2App/RedeemResponseTests.swift +++ b/Tests/SuperwallKitTests/Models/Web2App/RedeemResponseTests.swift @@ -285,4 +285,23 @@ final class RedeemResponseTests { #expect(userAttributes["goal"] as? String == "build_muscle") #expect(userAttributes["equipment"] as? [String] == ["dumbbells", "bench"]) } + + @Test("The Objective-C model keeps its initialiser that predates user attributes") + func testObjcRedemptionInfoInitWithoutUserAttributes() throws { + let redemptionInfo = RedemptionResultObjc.RedemptionInfo( + ownership: RedemptionResultObjc.Ownership(appUserId: "abc"), + purchaserInfo: RedemptionResultObjc.PurchaserInfo( + appUserId: "abc", + email: nil, + storeIdentifiers: RedemptionResultObjc.StoreIdentifiers( + stripeWithCustomerId: "cus_123", + subscriptionIds: ["sub_123"] + ) + ), + paywallInfo: nil, + entitlements: [] + ) + + #expect(redemptionInfo.userAttributes == nil) + } } From 5d0d919d7616919cdbbb2104bc238db22e338165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:43:52 +0200 Subject: [PATCH 144/162] Pin the blank answer in the round trip test NSNull is the only value with its own encode path, so leaving it out of the assertions was the one regression this test couldn't catch. Co-Authored-By: Claude Opus 5 --- Tests/SuperwallKitTests/Models/Web2App/RedeemResponseTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/SuperwallKitTests/Models/Web2App/RedeemResponseTests.swift b/Tests/SuperwallKitTests/Models/Web2App/RedeemResponseTests.swift index ec6e55506..3ac2fe0f6 100644 --- a/Tests/SuperwallKitTests/Models/Web2App/RedeemResponseTests.swift +++ b/Tests/SuperwallKitTests/Models/Web2App/RedeemResponseTests.swift @@ -271,6 +271,7 @@ final class RedeemResponseTests { #expect(userAttributes["experience"] as? Int == 3) #expect(userAttributes["wantsReminders"] as? Bool == true) #expect(userAttributes["equipment"] as? [String] == ["dumbbells", "bench"]) + #expect(userAttributes["nickname"] is NSNull) } @Test("User attributes are carried onto the Objective-C model") From 630dd616a1a9feba69091b868c6d218b5f232aa4 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Fri, 18 Sep 2026 14:31:45 +0200 Subject: [PATCH 145/162] Only send freeTrial_start to the paywall when a trial started The paywall schedules its trial reminder off freeTrial_start whenever a trial_end_date is present. The SDK sent that message after every internal purchase, with an end date taken from the product's intro offer, which StoreKit exposes even to users who aren't eligible for it. Users charged immediately could therefore get a trial reminder. Pass whether the transaction started a free trial with the transactionComplete message, send freeTrial_start only in that case, and only pass the trial end date when a trial started. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + .../Message Handling/PaywallMessage.swift | 2 +- .../PaywallMessageHandler.swift | 9 +- .../Transactions/TransactionManager.swift | 13 ++- .../PaywallMessageHandlerDelegateMock.swift | 3 + .../PaywallMessageHandlerTests.swift | 97 +++++++++++++++++++ 6 files changed, 121 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70dae709b..fbd156afd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Fixes +- Fixes the paywall being told a free trial started after every purchase. `freeTrial_start` and its trial end date are now only passed to the paywall when the transaction actually started a trial, so trial reminders are no longer scheduled for users who were charged immediately, for example because they had already used their trial. - Refreshes the IDFV, IDFA and tracking consent for integrations when the app becomes active or integration attributes are set again, and clears the IDFA when consent is revoked. These now also go into the user attributes `idfv`, `idfa` and `attStatus`, so server-side integrations such as AppsFlyer can read them. The SDK owns those three keys and will overwrite any value your app has set on them. `reset()` and identifying a different user now keep the integration attributes that describe the device, such as the AppsFlyer and Adjust IDs, and drop the ones that describe the person, such as the Amplitude and Customer.io user IDs. - Fixes duplicate device attribute and subscription status change events being tracked when the subscription status is repeatedly set to the same logical state. As part of this, `subscriptionStatusDidChange` now fires only when the logical status changes — the status case, the set of entitlements, or an entitlement's `isActive` flag. Updates to transaction metadata such as expiry dates or renewal state no longer trigger it; use `customerInfoDidChange` for those. - Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. 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 f39f6c79e..fe946a2dd 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 @@ -86,7 +86,7 @@ enum PaywallMessage: Decodable, Equatable { case transactionRestore case transactionStart - case transactionComplete(trialEndDate: Date?, productIdentifier: String) + case transactionComplete(trialEndDate: Date?, productIdentifier: String, didStartFreeTrial: Bool) case transactionFail case transactionAbandon case transactionTimeout 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 c72828669..ec15c5d49 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 @@ -135,7 +135,7 @@ final class PaywallMessageHandler: WebEventDelegate { Task { await self.pass(placement: transactionStart, from: paywall) } - case let .transactionComplete(trialEndDate, productIdentifier): + case let .transactionComplete(trialEndDate, productIdentifier, didStartFreeTrial): Task { // Send transaction_complete to trigger post-purchase actions let transactionComplete = SuperwallEventObjc.transactionComplete.description @@ -145,6 +145,13 @@ final class PaywallMessageHandler: WebEventDelegate { payload: ["product_identifier": productIdentifier] ) + // Only tell the paywall a trial started when one actually did. The paywall + // schedules its trial reminder off this message whenever a trial_end_date is + // present, and StoreKit exposes the intro offer even to ineligible users. + guard didStartFreeTrial else { + return + } + // Send freeTrial_start for notification scheduling let freeTrialStart = SuperwallEventObjc.freeTrialStart.description var freeTrialPayload: [String: Any] = ["product_identifier": productIdentifier] diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index 08dc70660..33e7b22fc 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -1128,13 +1128,22 @@ final class TransactionManager { let paywallInfo: PaywallInfo let eventSource: InternalSuperwallEvent.Transaction.Source - let trialEndDate = product.trialPeriodEndDate + // The product's intro offer is visible even when the user isn't eligible for it, so only + // report a trial (and its end date) to the paywall when this transaction started one. + let didStartFreeTrial = type == .freeTrialStart + let trialEndDate = didStartFreeTrial ? product.trialPeriodEndDate : nil switch source { case .internal(_, let paywallViewController, _): paywallInfo = await paywallViewController.info eventSource = .internal await paywallViewController.webView.messageHandler - .handle(.transactionComplete(trialEndDate: trialEndDate, productIdentifier: product.productIdentifier)) + .handle( + .transactionComplete( + trialEndDate: trialEndDate, + productIdentifier: product.productIdentifier, + didStartFreeTrial: didStartFreeTrial + ) + ) case .purchaseFunc, .observeFunc: paywallInfo = .empty() 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 22de441dd..23a247015 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 @@ -24,14 +24,17 @@ final class FakePermissionHandler: PermissionHandling { final class FakeWebView: SWWebView { var willHandleJs = false + var evaluatedScripts: [String] = [] #if compiler(>=6.0) override func evaluateJavaScript(_ javaScriptString: String, completionHandler: (@MainActor (Any?, (any Error)?) -> Void)? = nil) { willHandleJs = true + evaluatedScripts.append(javaScriptString) } #else override func evaluateJavaScript(_ javaScriptString: String, completionHandler: ((Any?, (any Error)?) -> Void)? = nil) { willHandleJs = true + evaluatedScripts.append(javaScriptString) } #endif } 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 7abccfa77..5ab673d5d 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 @@ -27,6 +27,103 @@ struct PaywallMessageHandlerTests { return webView.willHandleJs } + /// Decodes the `event_name`s of every `accept64` message passed to the webview. + private func passedEvents(in webView: FakeWebView) -> [[String: Any]] { + return webView.evaluatedScripts.flatMap { script -> [[String: Any]] in + guard + let start = script.range(of: "accept64('"), + let end = script.range(of: "')", range: start.upperBound.. [String: Any]? { + for _ in 0..<250 { + if let event = passedEvents(in: webView).first(where: { $0["event_name"] as? String == name }) { + return event + } + try? await Task.sleep(nanoseconds: 20_000_000) + } + return nil + } + + private func makeHandler() -> (PaywallMessageHandler, FakeWebView, PaywallMessageHandlerDelegateMock) { + 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 + return (messageHandler, webView, delegate) + } + + // 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 + func transactionComplete_withoutTrial_doesNotSendFreeTrialStart() async { + let (messageHandler, webView, delegate) = makeHandler() + _ = delegate + + messageHandler.handle( + .transactionComplete( + trialEndDate: nil, + productIdentifier: "product1", + didStartFreeTrial: false + ) + ) + + let complete = await waitForEvent(named: "transaction_complete", in: webView) + #expect(complete?["product_identifier"] as? String == "product1") + + // transaction_complete is sent first in the same task, so give freeTrial_start a + // chance to arrive before asserting it never did. + try? await Task.sleep(nanoseconds: 300_000_000) + let names = passedEvents(in: webView).compactMap { $0["event_name"] as? String } + #expect(!names.contains("freeTrial_start")) + } + + @Test + func transactionComplete_withTrial_sendsFreeTrialStartWithEndDate() async { + let (messageHandler, webView, delegate) = makeHandler() + _ = delegate + let trialEndDate = Date(timeIntervalSince1970: 1_800_000_000) + + messageHandler.handle( + .transactionComplete( + trialEndDate: trialEndDate, + productIdentifier: "product1", + didStartFreeTrial: true + ) + ) + + let complete = await waitForEvent(named: "transaction_complete", in: webView) + #expect(complete != nil) + + let freeTrialStart = await waitForEvent(named: "freeTrial_start", in: webView) + #expect(freeTrialStart?["product_identifier"] as? String == "product1") + #expect(freeTrialStart?["trial_end_date"] as? Int == 1_800_000_000_000) + } + @Test func handleTemplateParams() async { let dependencyContainer = DependencyContainer() From a6b5c066ac12f5cf66576ac46cd4a6c5d5f76e6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:23:41 +0200 Subject: [PATCH 146/162] Keep the mock alive for the whole of the new message handler tests The handler's delegate is weak and nothing else holds the mock, so the tests relied on `_ = delegate` to keep it around. That only extends the mock's life up to that line, which is before the waits that need it. Use withExtendedLifetime at the end of each test instead. Co-Authored-By: Claude Opus 5 --- .../Message Handling/PaywallMessageHandlerTests.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 5ab673d5d..eb1a94281 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 @@ -82,7 +82,6 @@ struct PaywallMessageHandlerTests { @Test func transactionComplete_withoutTrial_doesNotSendFreeTrialStart() async { let (messageHandler, webView, delegate) = makeHandler() - _ = delegate messageHandler.handle( .transactionComplete( @@ -100,12 +99,15 @@ struct PaywallMessageHandlerTests { try? await Task.sleep(nanoseconds: 300_000_000) let names = passedEvents(in: webView).compactMap { $0["event_name"] as? String } #expect(!names.contains("freeTrial_start")) + + // The handler's delegate is weak and the webview is reached through it, so the + // mock has to outlive the waits above. + withExtendedLifetime(delegate) {} } @Test func transactionComplete_withTrial_sendsFreeTrialStartWithEndDate() async { let (messageHandler, webView, delegate) = makeHandler() - _ = delegate let trialEndDate = Date(timeIntervalSince1970: 1_800_000_000) messageHandler.handle( @@ -122,6 +124,8 @@ struct PaywallMessageHandlerTests { let freeTrialStart = await waitForEvent(named: "freeTrial_start", in: webView) #expect(freeTrialStart?["product_identifier"] as? String == "product1") #expect(freeTrialStart?["trial_end_date"] as? Int == 1_800_000_000_000) + + withExtendedLifetime(delegate) {} } @Test From aecdd16accc4a403b05a0bb90302da6255b0156e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:20:41 +0200 Subject: [PATCH 147/162] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9e9aba0e..f62c55731 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Fixes -- Fixes the paywall being told a free trial started after every purchase. `freeTrial_start` and its trial end date are now only passed to the paywall when the transaction actually started a trial, so trial reminders are no longer scheduled for users who were charged immediately, for example because they had already used their trial. +- Fixes trial reminders being scheduled for users who bought without getting the intro offer. - Refreshes the IDFV, IDFA and tracking consent for integrations when the app becomes active or integration attributes are set again, and clears the IDFA when consent is revoked. These now also go into the user attributes `idfv`, `idfa` and `attStatus`, so server-side integrations such as AppsFlyer can read them. The SDK owns those three keys and will overwrite any value your app has set on them. `reset()` and identifying a different user now keep the integration attributes that describe the device, such as the AppsFlyer and Adjust IDs, and drop the ones that describe the person, such as the Amplitude and Customer.io user IDs. - Fixes duplicate device attribute and subscription status change events being tracked when the subscription status is repeatedly set to the same logical state. As part of this, `subscriptionStatusDidChange` now fires only when the logical status changes — the status case, the set of entitlements, or an entitlement's `isActive` flag. Updates to transaction metadata such as expiry dates or renewal state no longer trigger it; use `customerInfoDidChange` for those. - Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. From 8ffa7e24201796366d759a1400378d42154b91ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:29:44 +0200 Subject: [PATCH 148/162] Update CHANGELOG.md --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c78a5b95b..bcf23ffea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,6 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Adds `CaseIterable` conformance to `IntegrationAttribute`, so you can list every integration the SDK supports. - Adds `userAttributes` to the `RedemptionInfo` you get from `didRedeemLink`, so you can read the answers someone gave on your web paywall funnel after they redeem in your app. - Adds `SuperwallOptions.devServer` for development builds: with a `superwall dev` server running, paywalls render from your live, local paywall code while configuration, placements, audience evaluation and assignment stay real. Use `.default` on a simulator, which finds the dev server on localhost automatically; on a physical device use `.url(...)` with the Device URL `superwall dev` prints. The dev server also activates test mode, disables preloading, and skips the test mode intro sheet. -- Dev mode now presents a locally served paywall the way its `config.ts` says: presentation style, feature gating, intro offer eligibility, scrolling and background colours come from your local code instead of the last pushed version, so changing them is visible without a push. Settings `config.ts` cannot express, and any it leaves out, still come from the published paywall. Needs a `superwall dev` server new enough to send them. ### Fixes From 29413229c26c618614bd44be28939be5b2dacb15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:05:36 +0200 Subject: [PATCH 149/162] Scope the dev server lookup to the address it was asked for The locator's cached hit, cached miss and deep-link pin were global, so pointing SuperwallOptions.devServer at another machine could answer with the old one for a couple of seconds, skip the lookup for five after an unrelated miss, or keep probing a previously scanned server first. It now drops all of that when the requested address changes, and forget() clears a pin on demand. Also narrows the ATS exception in the example apps to NSAllowsLocalNetworking. App Transport Security doesn't apply to plain IP addresses, so NSAllowsArbitraryLoadsInWebContent was only widening what the web view could load from anywhere else. Co-Authored-By: Claude Opus 5 --- Examples/Advanced/Advanced/Info.plist | 2 - Examples/Basic/Basic/Info.plist | 2 - .../Config/Options/SuperwallOptions.swift | 6 +- .../DevServer/DevServerLocator.swift | 62 ++++++-- SuperwallKit.xcodeproj/project.pbxproj | 4 + .../DevServer/DevServerLocatorTests.swift | 142 ++++++++++++++++++ 6 files changed, 199 insertions(+), 19 deletions(-) create mode 100644 Tests/SuperwallKitTests/DevServer/DevServerLocatorTests.swift diff --git a/Examples/Advanced/Advanced/Info.plist b/Examples/Advanced/Advanced/Info.plist index 9050b0e87..34418debb 100644 --- a/Examples/Advanced/Advanced/Info.plist +++ b/Examples/Advanced/Advanced/Info.plist @@ -17,8 +17,6 @@ NSAppTransportSecurity - NSAllowsArbitraryLoadsInWebContent - NSAllowsLocalNetworking diff --git a/Examples/Basic/Basic/Info.plist b/Examples/Basic/Basic/Info.plist index 99411a379..2fe75ce19 100644 --- a/Examples/Basic/Basic/Info.plist +++ b/Examples/Basic/Basic/Info.plist @@ -15,8 +15,6 @@ NSAppTransportSecurity - NSAllowsArbitraryLoadsInWebContent - NSAllowsLocalNetworking diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index a749b88ca..6868f26c0 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -430,8 +430,10 @@ public final class SuperwallOptions: NSObject, Encodable { /// dashboard), disables paywall preloading, and skips the test mode intro sheet. /// /// The host app must allow local networking in its `Info.plist` - /// (`NSAppTransportSecurity` → `NSAllowsLocalNetworking` and - /// `NSAllowsArbitraryLoadsInWebContent`). + /// (`NSAppTransportSecurity` → `NSAllowsLocalNetworking`), which covers the + /// `localhost` and `.local` addresses `superwall dev` prints. App Transport + /// Security doesn't apply to plain IP addresses, so the `Device` URL needs + /// nothing more than that. @nonobjc public var devServer: DevServer? /// Objective-C only: connects to a `superwall dev` server found on `localhost`. diff --git a/Sources/SuperwallKit/DevServer/DevServerLocator.swift b/Sources/SuperwallKit/DevServer/DevServerLocator.swift index cf2c7814a..1eb8fdb0b 100644 --- a/Sources/SuperwallKit/DevServer/DevServerLocator.swift +++ b/Sources/SuperwallKit/DevServer/DevServerLocator.swift @@ -12,18 +12,50 @@ import Foundation actor DevServerLocator { static let shared = DevServerLocator() + /// Loads a request. Injectable so tests can drive the probing and caching + /// without a server on the other end. + typealias Load = (URLRequest) async throws -> (Data, URLResponse?) + + private let load: Load private var cached: (location: DevServerLocation, fetchedAt: Date)? private var lastMissAt: Date? private var pinnedBase: URL? + private var requestedURL: URL? + private var hasBeenAsked = false private var hasWarnedAboutTransportSecurity = false + init(load: @escaping Load = DevServerLocator.loadWithURLSession) { + self.load = load + } + func pin(base: URL) { pinnedBase = base cached = nil lastMissAt = nil } + /// Drops everything this knows about the server it was last pointed at: the + /// cached hit, the cached miss, and any base a deep link pinned. + func forget() { + cached = nil + lastMissAt = nil + pinnedBase = nil + } + func locate(devServerURL: URL?) async -> DevServerLocation? { + // The caches and the pin all describe one server, and `devServer` can be + // pointed somewhere else at any time. Answering for the address that used + // to be there would serve another project's paywalls. + // + // A deep link pins its base before the first `locate`, so the first call + // must not count as a change and throw that pin away. + if hasBeenAsked, + devServerURL != requestedURL { + forget() + } + hasBeenAsked = true + requestedURL = devServerURL + if let cached = cached, Date().timeIntervalSince(cached.fetchedAt) < 2 { return cached.location @@ -65,8 +97,9 @@ actor DevServerLocator { return nil } - /// App Transport Security blocks plain-http requests unless the app opts in, - /// and the failure is otherwise indistinguishable from "no server there". + /// App Transport Security blocks plain-http requests to a named host unless + /// the app opts in, and the failure is otherwise indistinguishable from + /// "no server there". private func warnIfBlockedByAppTransportSecurity(_ error: Error, base: URL) { let code = (error as NSError).code guard code == NSURLErrorAppTransportSecurityRequiresSecureConnection else { @@ -82,7 +115,6 @@ actor DevServerLocator { message: "App Transport Security blocked \(base.absoluteString). Add this to the app's " + "Info.plist to preview local paywalls:\n" + "NSAppTransportSecurity\n\n" - + " NSAllowsArbitraryLoadsInWebContent\n" + " NSAllowsLocalNetworking\n" ) } @@ -120,16 +152,7 @@ actor DevServerLocator { request.cachePolicy = .reloadIgnoringLocalCacheData do { - let (data, response): (Data, URLResponse?) = try await withCheckedThrowingContinuation { continuation in - let task = URLSession.shared.dataTask(with: request) { data, response, error in - if let data = data { - continuation.resume(returning: (data, response)) - } else { - continuation.resume(throwing: error ?? URLError(.badServerResponse)) - } - } - task.resume() - } + let (data, response) = try await load(request) // Something else on this port may answer an unknown path with a JSON // error body, so the status has to rule that out before the body does. if let http = response as? HTTPURLResponse, @@ -147,4 +170,17 @@ actor DevServerLocator { return nil } } + + private static func loadWithURLSession(_ request: URLRequest) async throws -> (Data, URLResponse?) { + return try await withCheckedThrowingContinuation { continuation in + let task = URLSession.shared.dataTask(with: request) { data, response, error in + if let data = data { + continuation.resume(returning: (data, response)) + } else { + continuation.resume(throwing: error ?? URLError(.badServerResponse)) + } + } + task.resume() + } + } } diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index af802c0f7..09676e078 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -556,6 +556,7 @@ E9E0BD599F353CC471B7A546 /* PresentationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00CE1D40C874F73A3BEC090 /* PresentationInfo.swift */; }; E9F80BA7BCAB71270E0E1CC1 /* AttributionFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64EAB177118BC78B02C3C00A /* AttributionFetcher.swift */; }; E9F892ABB9BDA85F4794E3CF /* SubscriptionStatusResolutionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78C15CF29C17FE1EE3BFDEDC /* SubscriptionStatusResolutionTests.swift */; }; + EA21B417513519FEDE6232D4 /* DevServerLocatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21C1CF3FA0D1266FFF8A29FF /* DevServerLocatorTests.swift */; }; EA50607230AA07B509E90E10 /* TestStoreUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7162E1E791297A3BF80B65A4 /* TestStoreUser.swift */; }; EA66951B1DF341C4F0448C9F /* PlacementsQueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 682AB10207309C439F64BC69 /* PlacementsQueueTests.swift */; }; EA6F422EB1C1F0E6882E4AEF /* DevServerPaywall.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE9BCE12E3F4300AD2379B4 /* DevServerPaywall.swift */; }; @@ -705,6 +706,7 @@ 2123B84F5C786278E128152D /* OccurrenceLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OccurrenceLogicTests.swift; sourceTree = ""; }; 214622367ACC8F66EB392105 /* AppSessionLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSessionLogic.swift; sourceTree = ""; }; 21903EACCA9AA13BB10917EC /* PermissionHandler+Camera.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PermissionHandler+Camera.swift"; sourceTree = ""; }; + 21C1CF3FA0D1266FFF8A29FF /* DevServerLocatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerLocatorTests.swift; sourceTree = ""; }; 21C52F36F0BFF59363EBB4C7 /* GameControllerEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameControllerEvent.swift; sourceTree = ""; }; 22439CFFFC5166F34D0DA524 /* WebViewURLConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewURLConfig.swift; sourceTree = ""; }; 224B526C168B5DB83E1F50D2 /* SerialTaskCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialTaskCoordinatorTests.swift; sourceTree = ""; }; @@ -2753,6 +2755,7 @@ isa = PBXGroup; children = ( 577D25646DA26238881BF6AB /* DevModeTests.swift */, + 21C1CF3FA0D1266FFF8A29FF /* DevServerLocatorTests.swift */, A349A124DD1DF28EEF04592C /* DevServerManifestTests.swift */, A4FD16729844D83A3EAA02FC /* DevServerPaywallTests.swift */, 6E0A1ED94DE7737BCB7D4D8C /* DevServerPreviewTests.swift */, @@ -3428,6 +3431,7 @@ 01BE837B492223B76A95CB5D /* DeepLinkRouterTests.swift in Sources */, 4ABF9FB54105917343865145 /* DependencyContainerInitTests.swift in Sources */, FEA3AED0B70D730993A16B2C /* DevModeTests.swift in Sources */, + EA21B417513519FEDE6232D4 /* DevServerLocatorTests.swift in Sources */, 069391992F6191874022F2BA /* DevServerManifestTests.swift in Sources */, 669B86B82B4CCD7BC7D02B55 /* DevServerPaywallTests.swift in Sources */, EE1A7003F266DF3C1481EEBA /* DevServerPreviewTests.swift in Sources */, diff --git a/Tests/SuperwallKitTests/DevServer/DevServerLocatorTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerLocatorTests.swift new file mode 100644 index 000000000..c201bfdc1 --- /dev/null +++ b/Tests/SuperwallKitTests/DevServer/DevServerLocatorTests.swift @@ -0,0 +1,142 @@ +// +// DevServerLocatorTests.swift +// SuperwallKitTests +// + +import Foundation +import Testing +@testable import SuperwallKit + +@Suite(.serialized) +struct DevServerLocatorTests { + private static let manifest = Data(""" + {"surfaces":[{"kind":"paywall","id":"pro","url":"/preview/paywall/pro"}]} + """.utf8) + + /// Answers the manifest probe for the origins it is told are serving, and + /// records everything it was asked for. + private actor Probe { + private(set) var requestedURLs: [URL] = [] + private let serving: Set + + init(serving: Set) { + self.serving = serving + } + + func load(_ request: URLRequest) -> (Data, URLResponse?) { + guard let url = request.url else { + return (Data(), nil) + } + requestedURLs.append(url) + let origin = "\(url.scheme ?? "")://\(url.host ?? ""):\(url.port ?? 0)" + let isServing = serving.contains(origin) + let response = HTTPURLResponse( + url: url, + statusCode: isServing ? 200 : 404, + httpVersion: nil, + headerFields: nil + ) + return (isServing ? DevServerLocatorTests.manifest : Data(), response) + } + } + + private func locator(_ probe: Probe) -> DevServerLocator { + return DevServerLocator { request in + await probe.load(request) + } + } + + private func url(_ string: String) throws -> URL { + return try #require(URL(string: string)) + } + + @Test("Reuses the server it just found for the same address") + func locate_cachesHitForSameAddress() async throws { + let base = try url("http://192.168.1.10:6100") + let probe = Probe(serving: [base.absoluteString]) + let locator = locator(probe) + + let first = await locator.locate(devServerURL: base) + #expect(first?.base == base) + + let second = await locator.locate(devServerURL: base) + #expect(second?.base == base) + + let requestedURLs = await probe.requestedURLs + #expect(requestedURLs.count == 1) + } + + @Test("Pointing devServer somewhere else drops the cached server") + func locate_discardsHitWhenAddressChanges() async throws { + let old = try url("http://192.168.1.10:6100") + let new = try url("http://192.168.1.20:6100") + let probe = Probe(serving: [old.absoluteString, new.absoluteString]) + let locator = locator(probe) + + let first = await locator.locate(devServerURL: old) + #expect(first?.base == old) + + let second = await locator.locate(devServerURL: new) + #expect(second?.base == new) + } + + @Test("A miss at one address doesn't suppress the lookup at another") + func locate_discardsMissWhenAddressChanges() async throws { + let missing = try url("http://192.168.1.10:6100") + let running = try url("http://192.168.1.20:6100") + let probe = Probe(serving: [running.absoluteString]) + let locator = locator(probe) + + let first = await locator.locate(devServerURL: missing) + #expect(first == nil) + + let second = await locator.locate(devServerURL: running) + #expect(second?.base == running) + } + + @Test("A deep link's pin wins over the address devServer names") + func locate_pinnedBaseWinsOverConfiguredAddress() async throws { + let configured = try url("http://192.168.1.10:6100") + let pinned = try url("http://192.168.1.30:6100") + let probe = Probe(serving: [pinned.absoluteString]) + let locator = locator(probe) + + await locator.pin(base: pinned) + + let located = await locator.locate(devServerURL: configured) + #expect(located?.base == pinned) + } + + @Test("Pointing devServer somewhere else drops a deep link's pin") + func locate_discardsPinWhenAddressChanges() async throws { + let configured = try url("http://192.168.1.10:6100") + let pinned = try url("http://192.168.1.30:6100") + let new = try url("http://192.168.1.20:6100") + let probe = Probe(serving: [pinned.absoluteString, new.absoluteString]) + let locator = locator(probe) + + _ = await locator.locate(devServerURL: configured) + await locator.pin(base: pinned) + let beforeChange = await locator.locate(devServerURL: configured) + #expect(beforeChange?.base == pinned) + + let located = await locator.locate(devServerURL: new) + #expect(located?.base == new) + } + + @Test("Forgetting drops the cached server and the pin") + func forget_clearsCacheAndPin() async throws { + let configured = try url("http://192.168.1.10:6100") + let pinned = try url("http://192.168.1.30:6100") + let probe = Probe(serving: [pinned.absoluteString]) + let locator = locator(probe) + + await locator.pin(base: pinned) + let beforeForgetting = await locator.locate(devServerURL: configured) + #expect(beforeForgetting?.base == pinned) + + await locator.forget() + let located = await locator.locate(devServerURL: configured) + #expect(located == nil) + } +} From c1ae97676ba2ae09e79f73e60acb7288f57f535f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:33:19 +0200 Subject: [PATCH 150/162] Address review feedback on the dev server and shared paywall controller - A lookup still in flight for the previous address can no longer write its answer into the locator's cache: probing suspends the actor, so another lookup can point devServer somewhere else in the meantime. - The debugger now presents a selected dev surface under its synthetic `dev:` id rather than paywall.identifier. A surface the CLI has pushed carries the dashboard identifier, and presenting under that would have fetched the published version instead of the local bytes. - Dev server settings read presentation styles with the push API's own enum, so `NONE` inherits the dashboard's style instead of being reported as a value this SDK can't read. - Fixes a stale DocC link to `SuperwallOptions/devMode`. - Covers the SDK presenting a paywall the app is holding from `getPaywall`, which had no test in its `isPresentedBySDK` state. - The inheritance test pinned "INELIGIBLE", which isn't a raw value the SDK knows, so it decoded to the same default the assertion expected either way. Co-Authored-By: Claude Opus 5 --- .../Debug/DebugViewController.swift | 2 +- .../DevServer/DevServerLocator.swift | 10 ++- .../DevServer/DevServerManifest.swift | 2 +- .../DevServer/DevServerSettings.swift | 28 ++++--- .../DevServer/DevServerSurface.swift | 8 ++ .../Operators/RawPaywallResponse.swift | 2 +- .../DevServer/DevServerLocatorTests.swift | 82 +++++++++++++++++++ .../DevServer/DevServerPaywallTests.swift | 12 ++- .../DevServer/DevServerSettingsTests.swift | 15 ++++ ...wallSharedControllerAttributionTests.swift | 42 ++++++++++ 10 files changed, 186 insertions(+), 17 deletions(-) diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index f63fa7bb2..957bfcab4 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -396,7 +396,7 @@ final class DebugViewController: UIViewController { // store has no record of yet, and the preview must still render. paywall.productVariables = await storeKitManager.getProductVariables(for: paywall) self.paywall = paywall - paywallIdentifier = paywall.identifier + paywallIdentifier = surface.previewIdentifier paywallDatabaseId = paywall.databaseId previewPickerButton.setTitle("\(surface.id) (local)", for: .normal) activityIndicator.stopAnimating() diff --git a/Sources/SuperwallKit/DevServer/DevServerLocator.swift b/Sources/SuperwallKit/DevServer/DevServerLocator.swift index 1eb8fdb0b..6c0ee2d05 100644 --- a/Sources/SuperwallKit/DevServer/DevServerLocator.swift +++ b/Sources/SuperwallKit/DevServer/DevServerLocator.swift @@ -76,7 +76,15 @@ actor DevServerLocator { } for base in bases { - if let manifest = await fetchManifest(from: base) { + let manifest = await fetchManifest(from: base) + // Probing suspends the actor, so `devServer` can be pointed somewhere + // else while this one is in flight. What comes back describes the + // address that was asked for rather than the one in force now, so it + // neither lands in the cache nor goes back to the caller. + if devServerURL != requestedURL { + return nil + } + if let manifest = manifest { let location = DevServerLocation(base: base, manifest: manifest) cached = (location, Date()) lastMissAt = nil diff --git a/Sources/SuperwallKit/DevServer/DevServerManifest.swift b/Sources/SuperwallKit/DevServer/DevServerManifest.swift index 017bbe8a7..6d8afc4a6 100644 --- a/Sources/SuperwallKit/DevServer/DevServerManifest.swift +++ b/Sources/SuperwallKit/DevServer/DevServerManifest.swift @@ -4,7 +4,7 @@ // // The surface list a running `superwall dev` server exposes at // /device/manifest.json, used to map dashboard paywalls to locally -// served paywall code when `SuperwallOptions/devMode` is on. +// served paywall code when `SuperwallOptions/devServer` is set. // import Foundation diff --git a/Sources/SuperwallKit/DevServer/DevServerSettings.swift b/Sources/SuperwallKit/DevServer/DevServerSettings.swift index ff95540f8..7328405f2 100644 --- a/Sources/SuperwallKit/DevServer/DevServerSettings.swift +++ b/Sources/SuperwallKit/DevServer/DevServerSettings.swift @@ -43,14 +43,9 @@ struct DevServerSettings: Decodable, Equatable { case cornerRadius = "corner_radius" } - private enum WireStyle: String { - case fullscreen = "FULLSCREEN" - case modal = "MODAL" - case push = "PUSH" - case noAnimation = "NO_ANIMATION" - case drawer = "DRAWER" - case popup = "POPUP" - } + /// The CLI sends the push API's presentation styles, so read them with the + /// push API's own enum rather than a copy that drifts from it. + private typealias WireStyle = PaywallPresentationStyle.InternalPresentationStyle private enum WireGating: String { case gated @@ -114,14 +109,21 @@ struct DevServerSettings: Decodable, Equatable { let width = try? style.decode(Double.self, forKey: .width) let cornerRadius = try? style.decode(Double.self, forKey: .cornerRadius) - switch type.flatMap(WireStyle.init(rawValue:)) { + guard let wireStyle = type.flatMap(WireStyle.init(rawValue:)) else { + if type == nil { + return nil + } + return unreadable(type) + } + + switch wireStyle { case .fullscreen: return .fullscreen case .modal: return .modal case .push: return .push - case .noAnimation: + case .fullscreenNoAnimation: return .fullscreenNoAnimation case .drawer: guard let height = height, let cornerRadius = cornerRadius else { @@ -133,8 +135,10 @@ struct DevServerSettings: Decodable, Equatable { return unreadable(type) } return .popup(height: height, width: width, cornerRadius: cornerRadius) - case nil: - return type == nil ? nil : unreadable(type) + case .none: + // `NONE` is the push API's "use the dashboard's", which is what a missing + // style already means here, so it inherits rather than being unreadable. + return nil } } diff --git a/Sources/SuperwallKit/DevServer/DevServerSurface.swift b/Sources/SuperwallKit/DevServer/DevServerSurface.swift index 5178e60e1..0ab7c00c7 100644 --- a/Sources/SuperwallKit/DevServer/DevServerSurface.swift +++ b/Sources/SuperwallKit/DevServer/DevServerSurface.swift @@ -34,6 +34,14 @@ struct DevServerSurface: Decodable, Equatable { /// paywall's settings stand. let settings: DevServerSettings? + /// The identifier the debugger presents this surface under. Always the + /// synthetic `dev:` form, never the dashboard `identifier` the manifest may + /// carry: only this one routes the presentation back to the local surface + /// instead of fetching the paywall's published version. + var previewIdentifier: String { + return "dev:\(id)" + } + private enum CodingKeys: String, CodingKey { case kind case id diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift index d52d5400c..e56cdb86a 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift @@ -82,7 +82,7 @@ extension PaywallRequestManager { }) else { return nil } - guard let surface = devServer.surfaces.first(where: { "dev:\($0.id)" == paywallId }) else { + guard let surface = devServer.surfaces.first(where: { $0.previewIdentifier == paywallId }) else { return nil } guard let mountURL = DevServerManifest(surfaces: devServer.surfaces) diff --git a/Tests/SuperwallKitTests/DevServer/DevServerLocatorTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerLocatorTests.swift index c201bfdc1..2a3bc7496 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerLocatorTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerLocatorTests.swift @@ -40,12 +40,69 @@ struct DevServerLocatorTests { } } + /// Holds a request for one origin until the test releases it, so two + /// lookups can be interleaved. + private actor GatedProbe { + private let serving: Set + private let gatedOrigin: String + private var gate: [CheckedContinuation] = [] + private var arrival: CheckedContinuation? + private var hasArrived = false + + init(serving: Set, gating gatedOrigin: String) { + self.serving = serving + self.gatedOrigin = gatedOrigin + } + + /// Returns once a request for the gated origin is waiting. + func waitUntilGated() async { + if hasArrived { + return + } + await withCheckedContinuation { arrival = $0 } + } + + func release() { + for continuation in gate { + continuation.resume() + } + gate = [] + } + + func load(_ request: URLRequest) async -> (Data, URLResponse?) { + guard let url = request.url else { + return (Data(), nil) + } + let origin = "\(url.scheme ?? "")://\(url.host ?? ""):\(url.port ?? 0)" + if origin == gatedOrigin { + hasArrived = true + arrival?.resume() + arrival = nil + await withCheckedContinuation { gate.append($0) } + } + let isServing = serving.contains(origin) + let response = HTTPURLResponse( + url: url, + statusCode: isServing ? 200 : 404, + httpVersion: nil, + headerFields: nil + ) + return (isServing ? DevServerLocatorTests.manifest : Data(), response) + } + } + private func locator(_ probe: Probe) -> DevServerLocator { return DevServerLocator { request in await probe.load(request) } } + private func locator(_ probe: GatedProbe) -> DevServerLocator { + return DevServerLocator { request in + await probe.load(request) + } + } + private func url(_ string: String) throws -> URL { return try #require(URL(string: string)) } @@ -124,6 +181,31 @@ struct DevServerLocatorTests { #expect(located?.base == new) } + @Test("A lookup still in flight for the old address can't refill the cache") + func locate_staleLookupDoesNotOverwriteTheNewAddress() async throws { + let old = try url("http://192.168.1.10:6100") + let new = try url("http://192.168.1.20:6100") + let probe = GatedProbe( + serving: [old.absoluteString, new.absoluteString], + gating: old.absoluteString + ) + let locator = locator(probe) + + let stale = Task { await locator.locate(devServerURL: old) } + await probe.waitUntilGated() + + // devServer is pointed at the new address while the old probe is waiting. + let current = await locator.locate(devServerURL: new) + #expect(current?.base == new) + + await probe.release() + let staleResult = await stale.value + #expect(staleResult == nil) + + let afterStaleFinished = await locator.locate(devServerURL: new) + #expect(afterStaleFinished?.base == new) + } + @Test("Forgetting drops the cached server and the pin") func forget_clearsCacheAndPin() async throws { let configured = try url("http://192.168.1.10:6100") diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift index 9e86db169..c06d7beec 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift @@ -75,6 +75,16 @@ final class DevServerPaywallTests: XCTestCase { XCTAssertEqual(paywall.identifier, "chatgpt-plus") } + /// The paywall is named by the dashboard, but presenting under that name + /// fetches its published version, so the debugger opens a surface under the + /// id that resolves back to the local bytes. + func test_theDebuggerPreviewsUnderTheSyntheticDevId() { + let pushed = surface(paywallId: "253583", identifier: "chatgpt-plus") + + XCTAssertEqual(Paywall.devServer(surface: pushed, url: url).identifier, "chatgpt-plus") + XCTAssertEqual(pushed.previewIdentifier, "dev:pro") + } + func test_isMarkedLocalSoEventsCanSaySo() { let paywall = Paywall.devServer(surface: surface(), url: url) @@ -333,7 +343,7 @@ final class DevServerPaywallTests: XCTestCase { "computedPropertyRequests": [ { "type": "HOURS_SINCE", "eventName": "trigger1" } ], - "introductoryOfferEligibility": "INELIGIBLE" + "introductoryOfferEligibility": "ALWAYS_INELIGIBLE" } """ // Decoded rather than hand-built so the test pins the real dashboard shape. diff --git a/Tests/SuperwallKitTests/DevServer/DevServerSettingsTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerSettingsTests.swift index 325d685f7..55a1b26ce 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerSettingsTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerSettingsTests.swift @@ -102,6 +102,21 @@ final class DevServerSettingsTests: XCTestCase { XCTAssertEqual(decoded.isScrollEnabled, false) } + /// `NONE` is the push API's "use the dashboard's". The SDK reads it fine, it + /// just leaves the style to the published paywall, which is what a missing + /// style means here — so it isn't a value this SDK can't read. + func test_readsNoneAsInheritingTheDashboardsStyle() throws { + let decoded = try settings(""" + { + "presentation_style": { "type": "NONE" }, + "feature_gating": "gated" + } + """) + + XCTAssertNil(decoded.presentationStyle) + XCTAssertEqual(decoded.featureGating, .gated) + } + /// Geometry the SDK can't trust is treated the same way: the CLI resolves /// height and radius before serving them, so a partial one is unreadable /// rather than something to guess a default for. diff --git a/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift b/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift index 8b2d1c688..07978e1e4 100644 --- a/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift +++ b/Tests/SuperwallKitTests/Paywall/Manager/PaywallSharedControllerAttributionTests.swift @@ -8,6 +8,7 @@ import Foundation import Testing import Combine +import UIKit @testable import SuperwallKit /// Lets a test say whether the view controller is on screen, and records @@ -27,6 +28,21 @@ private final class ActivePaywallViewController: PaywallViewController { } } +/// Stands in for the view controller UIKit would present from: the SDK's +/// `present` only needs the presenter to say the presentation finished. +private final class CompletingPresenter: UIViewController { + var presented: UIViewController? + + override func present( + _ viewControllerToPresent: UIViewController, + animated: Bool, + completion: (() -> Void)? = nil + ) { + presented = viewControllerToPresent + completion?() + } +} + /// Counts occurrence saves instead of writing to Core Data. private final class OccurrenceCountingCoreDataManager: CoreDataManager { var savedOccurrences = 0 @@ -423,6 +439,32 @@ struct PaywallSharedControllerAttributionTests { expectEmbeddedAttribution(viewController.info) } + /// The mirror of the test above: the same armed handle, but the SDK is the + /// one presenting, so the appearance belongs to the SDK's request. + @Test + func sdkPresentationReportsItsOwnPlacementOverAHandedOutClaim() { + let viewController = cachedViewController(for: embeddedPaywall, placement: "embedded") + // The app fetched it with `getPaywall` and is holding on to it. + claim(viewController, placement: "embedded", paywall: embeddedPaywall, type: getPaywallType) + + // `register` presents the same paywall for another placement. + let presenter = CompletingPresenter() + viewController.present( + on: presenter, + request: request(placement: "session_start"), + paywall: sessionStartPaywall, + unsavedOccurrence: nil, + presentationStyleOverride: nil, + paywallStatePublisher: .init(), + completion: { _ in } + ) + #expect(presenter.presented === viewController) + + viewController.viewWillAppear(false) + + expectSessionStartAttribution(viewController.info) + } + @Test func handedOutOccurrenceIsSavedWhenTheAppFirstShowsIt() { let coreDataManager = OccurrenceCountingCoreDataManager() From e7f2760bd1fc07d614ad5f7592c86123f3d0dad8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:49:18 +0200 Subject: [PATCH 151/162] Hold the dev server lookup to the server it set out to find Comparing the requested address wasn't enough: a superwall_dev link pinning a base mid-walk leaves the address unchanged, so a walk that suspended before the pin could still nil the pinned hit and arm the five second miss cache, blanking the preview. A generation counter bumped by pin() and forget() is captured before the walk and checked before anything is written. Also corrects what the devServer docs say about App Transport Security. ATS stopped allowing connections to plain IP addresses by default in iOS 17, and NSAllowsLocalNetworking is what re-enables them, so that key is required for the Device URL rather than beside the point for it. Co-Authored-By: Claude Opus 5 --- .../Config/Options/SuperwallOptions.swift | 8 ++--- .../DevServer/DevServerLocator.swift | 17 +++++++--- .../DevServer/DevServerLocatorTests.swift | 31 ++++++++++++++++++- 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index 6868f26c0..e0a0bf34f 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -430,10 +430,10 @@ public final class SuperwallOptions: NSObject, Encodable { /// dashboard), disables paywall preloading, and skips the test mode intro sheet. /// /// The host app must allow local networking in its `Info.plist` - /// (`NSAppTransportSecurity` → `NSAllowsLocalNetworking`), which covers the - /// `localhost` and `.local` addresses `superwall dev` prints. App Transport - /// Security doesn't apply to plain IP addresses, so the `Device` URL needs - /// nothing more than that. + /// (`NSAppTransportSecurity` → `NSAllowsLocalNetworking`). That one key covers + /// every address `superwall dev` prints: `localhost`, `.local` hosts, and the + /// plain IP address in the `Device` URL, which App Transport Security stopped + /// permitting by default in iOS 17. @nonobjc public var devServer: DevServer? /// Objective-C only: connects to a `superwall dev` server found on `localhost`. diff --git a/Sources/SuperwallKit/DevServer/DevServerLocator.swift b/Sources/SuperwallKit/DevServer/DevServerLocator.swift index 6c0ee2d05..e7d6bcd17 100644 --- a/Sources/SuperwallKit/DevServer/DevServerLocator.swift +++ b/Sources/SuperwallKit/DevServer/DevServerLocator.swift @@ -22,6 +22,9 @@ actor DevServerLocator { private var pinnedBase: URL? private var requestedURL: URL? private var hasBeenAsked = false + /// Bumped whenever what this is looking for changes. A walk that suspended + /// before the change is answering about a server nobody is asking for now. + private var generation = 0 private var hasWarnedAboutTransportSecurity = false init(load: @escaping Load = DevServerLocator.loadWithURLSession) { @@ -32,6 +35,7 @@ actor DevServerLocator { pinnedBase = base cached = nil lastMissAt = nil + generation += 1 } /// Drops everything this knows about the server it was last pointed at: the @@ -40,6 +44,7 @@ actor DevServerLocator { cached = nil lastMissAt = nil pinnedBase = nil + generation += 1 } func locate(devServerURL: URL?) async -> DevServerLocation? { @@ -55,6 +60,7 @@ actor DevServerLocator { } hasBeenAsked = true requestedURL = devServerURL + let walkGeneration = generation if let cached = cached, Date().timeIntervalSince(cached.fetchedAt) < 2 { @@ -77,11 +83,12 @@ actor DevServerLocator { for base in bases { let manifest = await fetchManifest(from: base) - // Probing suspends the actor, so `devServer` can be pointed somewhere - // else while this one is in flight. What comes back describes the - // address that was asked for rather than the one in force now, so it - // neither lands in the cache nor goes back to the caller. - if devServerURL != requestedURL { + // Probing suspends the actor for as long as each candidate takes to + // answer, so `devServer` can be repointed or a deep link can pin a base + // while this walk is in flight. What comes back then describes a server + // nobody is asking for, so it neither lands in the cache nor goes back + // to the caller. + if generation != walkGeneration { return nil } if let manifest = manifest { diff --git a/Tests/SuperwallKitTests/DevServer/DevServerLocatorTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerLocatorTests.swift index 2a3bc7496..4270ee839 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerLocatorTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerLocatorTests.swift @@ -48,6 +48,7 @@ struct DevServerLocatorTests { private var gate: [CheckedContinuation] = [] private var arrival: CheckedContinuation? private var hasArrived = false + private var isOpen = false init(serving: Set, gating gatedOrigin: String) { self.serving = serving @@ -63,6 +64,7 @@ struct DevServerLocatorTests { } func release() { + isOpen = true for continuation in gate { continuation.resume() } @@ -74,7 +76,8 @@ struct DevServerLocatorTests { return (Data(), nil) } let origin = "\(url.scheme ?? "")://\(url.host ?? ""):\(url.port ?? 0)" - if origin == gatedOrigin { + if origin == gatedOrigin, + !isOpen { hasArrived = true arrival?.resume() arrival = nil @@ -206,6 +209,32 @@ struct DevServerLocatorTests { #expect(afterStaleFinished?.base == new) } + @Test("A pin landing mid-lookup wins over the walk already in flight") + func locate_pinDuringLookupWinsOverTheWalkInFlight() async throws { + let configured = try url("http://192.168.1.10:6100") + let pinned = try url("http://192.168.1.30:6100") + let probe = GatedProbe( + serving: [configured.absoluteString, pinned.absoluteString], + gating: configured.absoluteString + ) + let locator = locator(probe) + + let inFlight = Task { await locator.locate(devServerURL: configured) } + await probe.waitUntilGated() + + // A superwall_dev link lands while that walk is still waiting. + await locator.pin(base: pinned) + let afterPin = await locator.locate(devServerURL: configured) + #expect(afterPin?.base == pinned) + + await probe.release() + let inFlightResult = await inFlight.value + #expect(inFlightResult == nil) + + let afterWalkFinished = await locator.locate(devServerURL: configured) + #expect(afterWalkFinished?.base == pinned) + } + @Test("Forgetting drops the cached server and the pin") func forget_clearsCacheAndPin() async throws { let configured = try url("http://192.168.1.10:6100") From 1a4c22e00a64af2f574e3b7ffb39fd2790f1a028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:00:01 +0200 Subject: [PATCH 152/162] Let the early publish test wait for the purchases load to start Making the load's Task doesn't run its first line, so config can be published before the load's body is scheduled and the assertion that it was kicked off raced with it. It now polls for up to a second, which still fails if the load is never started and leaves the two second load delay intact for the "didn't finish" assertion below it. Co-Authored-By: Claude Opus 5 --- .../Config/ConfigManagerEarlyPublishTests.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift index 4ee091432..633166248 100644 --- a/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift +++ b/Tests/SuperwallKitTests/Config/ConfigManagerEarlyPublishTests.swift @@ -330,6 +330,19 @@ struct ConfigManagerEarlyPublishTests { return Date().timeIntervalSince(start) } + /// Polls until the purchases load has started or `timeout` passes. Making + /// the load's `Task` doesn't run its first line, so it can start a moment + /// after config is published rather than before. + private func waitForLoadToStart( + _ receipt: SlowReceiptManagerType, + timeout: TimeInterval + ) async { + let start = Date() + while !receipt.didStartLoad, Date().timeIntervalSince(start) < timeout { + try? await Task.sleep(nanoseconds: 10_000_000) + } + } + private func settle() async { // Let the background refresh finish before the container goes away. try? await Task.sleep(nanoseconds: 300_000_000) @@ -348,6 +361,7 @@ struct ConfigManagerEarlyPublishTests { #expect(harness.configManager.config?.buildId == "cached_123") #expect(waited < 1, "config took \(waited)s but StoreKit was still loading") + await waitForLoadToStart(harness.receipt, timeout: 1) #expect(harness.receipt.didStartLoad, "purchases load must still be kicked off") #expect(!harness.receipt.didFinishLoad, "config was published only after StoreKit finished") From 6e75fa730c434f7df5d667a8208501c89faf354d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:53:26 +0200 Subject: [PATCH 153/162] Hold only the entitlements a device read has no authority over The anti-downgrade guard kept the whole status once any entitlement in it held. A refunded App Store entitlement next to a live web one therefore kept granting access. Each entitlement is now judged on its own, and the status is reassigned with just the ones that hold. Co-Authored-By: Claude Fable 5.1 --- .../AutomaticPurchaseController.swift | 15 ++++- .../AutomaticPurchaseControllerTests.swift | 63 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift b/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift index 7ff8cf7c7..33b98a019 100644 --- a/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift +++ b/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift @@ -66,8 +66,12 @@ final class AutomaticPurchaseController { // The check reads the assigned status (device + web), not the // published one: that also carries developer-granted entitlements, // which would hold a lapsed App Store entitlement in place. + // + // Each entitlement is judged on its own. One that holds keeps only + // itself: a refunded App Store entitlement next to a live web one + // must still drop out, not ride along with it. if case .active(let currentEntitlements) = superwall.assignedSubscriptionStatus { - let holdsStatus = currentEntitlements.contains { entitlement in + let heldEntitlements = currentEntitlements.filter { entitlement in guard entitlement.isActive, (entitlement.expiresAt ?? .distantPast) > Date() else { return false @@ -85,7 +89,14 @@ final class AutomaticPurchaseController { // a lost product mapping. return entitlement.productIds.contains { activeProductIds.contains($0) } } - if holdsStatus { + if heldEntitlements == currentEntitlements { + return + } + if !heldEntitlements.isEmpty { + superwall.internallySetSubscriptionStatus( + to: .active(heldEntitlements), + superwall: superwall + ) return } } diff --git a/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift b/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift index 4a4ff6c80..54d8e616c 100644 --- a/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift @@ -368,6 +368,69 @@ struct AutomaticPurchaseControllerTests { ) } + @Test("A live web entitlement cannot hold a refunded App Store entitlement in place") + func testRefundedAppStoreEntitlement_besideWebEntitlement_isDropped() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // A Stripe subscriber who also bought "annual_product" on the App Store + // and then got it refunded. The device read has no authority over the + // web entitlement, so the status stays active, but it is authoritative + // about the App Store one: that must not survive alongside the web one. + let webEntitlement = Entitlement( + id: "web_pro", + type: .serviceLevel, + isActive: true, + productIds: [], + latestProductId: nil, + store: .stripe, + startsAt: Date().addingTimeInterval(-90 * 86_400), + renewedAt: nil, + expiresAt: Date().addingTimeInterval(30 * 86_400), + isLifetime: false, + willRenew: true, + state: nil, + offerType: nil + ) + let refundedEntitlement = Entitlement( + id: "pro", + type: .serviceLevel, + isActive: true, + productIds: ["annual_product"], + latestProductId: "annual_product", + store: .appStore, + startsAt: Date().addingTimeInterval(-90 * 86_400), + renewedAt: nil, + expiresAt: Date().addingTimeInterval(300 * 86_400), + isLifetime: false, + willRenew: true, + state: nil, + offerType: nil + ) + dependencyContainer.storage.delete(LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .active([webEntitlement, refundedEntitlement]) + } + + let controller = makeController() + let refundedPurchase = Purchase( + id: "annual_product", + isActive: false, + purchaseDate: Date().addingTimeInterval(-30 * 86_400) + ) + await controller.syncSubscriptionStatus(withPurchases: [refundedPurchase], superwall: superwall) + + let status = await MainActor.run { superwall.subscriptionStatus } + if case .active(let entitlements) = status { + #expect(entitlements.contains(webEntitlement)) + #expect( + !entitlements.contains { $0.id == refundedEntitlement.id }, + "A refunded App Store entitlement must not keep granting access beside a web one" + ) + } else { + Issue.record("A web entitlement must survive an App Store refund; got \(status)") + } + } + @Test("Inactive purchases cannot refute a nil-store entitlement") func testInactivePurchases_nilStoreStatus_staysActive() async { let superwall = Superwall(dependencyContainer: dependencyContainer) From 4f2139a3c8703d7b89e0df94ce827043e5470976 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:53:26 +0200 Subject: [PATCH 154/162] Refresh the dev server manifest before previewing or presenting a local surface The debugger resolved dev: identifiers from the manifest captured when it opened, so edits to a surface's config.ts kept the old products and presentation settings. The manifest is now fetched again on every preview load and before each presentation, and the selected surface is resolved from it. The old snapshot only stands in when the server can't be reached. Co-Authored-By: Claude Fable 5.1 --- .../Debug/DebugViewController.swift | 20 ++++-- .../DevServer/DevServerManifest.swift | 5 ++ .../DevServer/DevServerPreview.swift | 31 ++++++++ .../Operators/RawPaywallResponse.swift | 36 +++++++--- .../DevServer/DevServerPreviewTests.swift | 71 +++++++++++++++++++ 5 files changed, 149 insertions(+), 14 deletions(-) diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index 957bfcab4..c1415a4df 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -223,9 +223,13 @@ final class DebugViewController: UIViewController { /// Dev mode's local surfaces belong in the debugger however it was opened — /// a dashboard preview link should list them too, not just a dev link. + /// + /// The manifest is fetched again on every load, not only the first: a + /// surface's products and presentation settings come from its config.ts, + /// and edits made since the debugger opened must show up in the preview. + /// When the server can't be reached the last snapshot stands. private func ensureDevServer() async { guard - devServer == nil, DevMode.isActive(Superwall.shared.options), let location = await DevServerLocator.shared.locate( devServerURL: Superwall.shared.options.devServerURL @@ -238,6 +242,11 @@ final class DebugViewController: UIViewController { // Presenting a `dev:` surface resolves it from the debug manager's copy, // so both stores have to agree however the debugger was opened. debugManager.devServer = located + // The selected surface was picked from the previous manifest; carry the + // selection over to the fresh one so the preview renders what it says now. + if let devSurface = devSurface { + self.devSurface = located.surfaces.first { $0.id == devSurface.id } ?? devSurface + } } func finishLoadingPreview() async { @@ -379,13 +388,14 @@ final class DebugViewController: UIViewController { /// manifest (local URL + the products its `config.ts` declares). Nothing is /// fetched from the dashboard, so a paywall that has never been pushed — /// or whose app lives in another environment — still previews. + /// + /// `ensureDevServer` has just refreshed `devServer`, so the surface passed + /// in is the one the manifest describes now. private func loadDevServerPreview(surface: DevServerSurface) async { guard let devServer = devServer, - let location = await DevServerLocator.shared.locate( - devServerURL: Superwall.shared.options.devServerURL - ), - let url = location.manifest.mountURL(for: surface, base: devServer.base) + let url = DevServerManifest(surfaces: devServer.surfaces) + .mountURL(for: surface, base: devServer.base) else { activityIndicator.stopAnimating() return diff --git a/Sources/SuperwallKit/DevServer/DevServerManifest.swift b/Sources/SuperwallKit/DevServer/DevServerManifest.swift index 6d8afc4a6..43595b054 100644 --- a/Sources/SuperwallKit/DevServer/DevServerManifest.swift +++ b/Sources/SuperwallKit/DevServer/DevServerManifest.swift @@ -65,6 +65,11 @@ struct DevServerManifest: Decodable, Equatable { return nil } + /// The surface the debugger presents under a `dev:` identifier. + func surface(forPreviewIdentifier previewIdentifier: String) -> DevServerSurface? { + return surfaces.first { $0.previewIdentifier == previewIdentifier } + } + func mountURL(for surface: DevServerSurface, base: URL) -> URL? { guard let resolved = URL(string: surface.url, relativeTo: base)?.absoluteURL else { return nil diff --git a/Sources/SuperwallKit/DevServer/DevServerPreview.swift b/Sources/SuperwallKit/DevServer/DevServerPreview.swift index b72d18f1d..9ba469a50 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPreview.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPreview.swift @@ -84,6 +84,37 @@ enum DevServerPreview { } } + /// Where a `dev:` surface is served from, and what its config.ts says now. + /// + /// The surface's products and presentation settings live in its config.ts, + /// which the developer can edit at any time after the debugger opened. So + /// the manifest the server is serving now wins, and the snapshot taken when + /// the debugger opened only stands in while the server can't be reached. + static func resolveSurface( + previewIdentifier: String, + fresh: DevServerLocation?, + snapshot: (base: URL, surfaces: [DevServerSurface])? + ) -> (surface: DevServerSurface, url: URL)? { + let location: DevServerLocation + if let fresh = fresh { + location = fresh + } else if let snapshot = snapshot { + location = DevServerLocation( + base: snapshot.base, + manifest: DevServerManifest(surfaces: snapshot.surfaces) + ) + } else { + return nil + } + guard let surface = location.manifest.surface(forPreviewIdentifier: previewIdentifier) else { + return nil + } + guard let url = location.manifest.mountURL(for: surface, base: location.base) else { + return nil + } + return (surface, url) + } + static func handle(url: URL) -> Bool { guard let outcome = outcomeForDeepLink(url: url) else { return false diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift index e56cdb86a..27a0c9114 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift @@ -66,7 +66,12 @@ extension PaywallRequestManager { /// Resolves a synthetic `dev:` identifier — a dev-server surface the /// debugger selected that has never been pushed to the dashboard — from the - /// debugger's manifest, since the backend has nothing to fetch for it. + /// dev server's manifest, since the backend has nothing to fetch for it. + /// + /// The manifest is fetched again here rather than read from the copy the + /// debugger took when it opened: the surface's products and presentation + /// settings come from its config.ts, and edits made since must reach the + /// next presentation. private func devServerPaywall(forId paywallId: String?) async -> Paywall? { guard let paywallId = paywallId else { return nil @@ -74,23 +79,36 @@ extension PaywallRequestManager { guard paywallId.hasPrefix("dev:") else { return nil } - guard DevMode.isActive(factory.makeSuperwallOptions()) else { + let options = factory.makeSuperwallOptions() + guard DevMode.isActive(options) else { return nil } - guard let devServer = await MainActor.run(body: { + // `dev:` identifiers only come from the debugger, so without its snapshot + // there is nothing to present and no reason to probe for a server. + guard let snapshot = await MainActor.run(body: { Superwall.shared.dependencyContainer.debugManager.devServer }) else { return nil } - guard let surface = devServer.surfaces.first(where: { $0.previewIdentifier == paywallId }) else { - return nil + let fresh = await DevServerLocator.shared.locate(devServerURL: options.devServerURL) + if let fresh = fresh { + // The debugger's picker and any later presentation read this copy, so + // it has to describe the same server the presentation does. + await MainActor.run { + Superwall.shared.dependencyContainer.debugManager.devServer = ( + base: fresh.base, + surfaces: fresh.manifest.surfaces + ) + } } - guard let mountURL = DevServerManifest(surfaces: devServer.surfaces) - .mountURL(for: surface, base: devServer.base) - else { + guard let resolved = DevServerPreview.resolveSurface( + previewIdentifier: paywallId, + fresh: fresh, + snapshot: snapshot + ) else { return nil } - return Paywall.devServer(surface: surface, url: mountURL) + return Paywall.devServer(surface: resolved.surface, url: resolved.url) } private func getPaywallResponse( diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPreviewTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPreviewTests.swift index bfa03db84..ae3cc604c 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerPreviewTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerPreviewTests.swift @@ -17,6 +17,77 @@ struct DevServerPreviewTests { return options } + private func surface(id: String, products: [String: String]?) throws -> DevServerSurface { + let productsJSON = products.map { dict in + "{" + dict.map { "\"\($0.key)\": \"\($0.value)\"" }.sorted().joined(separator: ",") + "}" + } ?? "null" + let json = """ + {"kind": "paywall", "id": "\(id)", "url": "/preview/paywall/\(id)", "products": \(productsJSON)} + """ + return try JSONDecoder().decode(DevServerSurface.self, from: Data(json.utf8)) + } + + // MARK: - Resolving a dev: surface + + @Test("A dev: surface is resolved from the manifest the server serves now") + func resolveSurface_prefersTheFreshManifest() throws { + // The debugger opened while config.ts declared one product. It has since + // been edited to declare another, and the server's manifest says so. + let base = try #require(URL(string: "http://localhost:6100")) + let stale = try surface(id: "pro", products: ["primary": "old_product"]) + let edited = try surface(id: "pro", products: ["primary": "new_product"]) + + let resolved = try #require( + DevServerPreview.resolveSurface( + previewIdentifier: "dev:pro", + fresh: DevServerLocation(base: base, manifest: DevServerManifest(surfaces: [edited])), + snapshot: (base: base, surfaces: [stale]) + ) + ) + #expect(resolved.surface == edited) + #expect(resolved.url.absoluteString == "http://localhost:6100/preview/paywall/pro") + } + + @Test("A dev: surface falls back to the debugger's snapshot when the server is unreachable") + func resolveSurface_fallsBackToTheSnapshot() throws { + let base = try #require(URL(string: "http://localhost:6100")) + let snapshotSurface = try surface(id: "pro", products: ["primary": "old_product"]) + + let resolved = try #require( + DevServerPreview.resolveSurface( + previewIdentifier: "dev:pro", + fresh: nil, + snapshot: (base: base, surfaces: [snapshotSurface]) + ) + ) + #expect(resolved.surface == snapshotSurface) + #expect(resolved.url.absoluteString == "http://localhost:6100/preview/paywall/pro") + } + + @Test("A dev: surface the server no longer lists is not resolved from the snapshot") + func resolveSurface_freshManifestWithoutTheSurface_returnsNil() throws { + let base = try #require(URL(string: "http://localhost:6100")) + let removed = try surface(id: "pro", products: nil) + let other = try surface(id: "winback", products: nil) + + let resolved = DevServerPreview.resolveSurface( + previewIdentifier: "dev:pro", + fresh: DevServerLocation(base: base, manifest: DevServerManifest(surfaces: [other])), + snapshot: (base: base, surfaces: [removed]) + ) + #expect(resolved == nil) + } + + @Test("Without a snapshot or a server there is nothing to resolve") + func resolveSurface_nothingToResolve() { + let resolved = DevServerPreview.resolveSurface( + previewIdentifier: "dev:pro", + fresh: nil, + snapshot: nil + ) + #expect(resolved == nil) + } + // MARK: - Deep link parsing @Test("Parses the base and surface from a dev link") From dcc2e9ef9b04a9eab4d0493b8537274d984c138e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:27:41 +0200 Subject: [PATCH 155/162] Send configState from processConfig instead of from its callers Both callers sent configState right after processConfig returned, unless the early publish branch had already sent it and said so through a return value. The send now lives at the end of processConfig itself, after the test mode modal, so the callers and the return value go. Also drops a redundant nil check on the test mode reason that was unwrapped again a line later. Co-Authored-By: Claude Fable 5.1 --- .../SuperwallKit/Config/ConfigManager.swift | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index 0de849187..bc2c09581 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -143,7 +143,6 @@ class ConfigManager { await paywallManager.removePaywalls(withIds: removedOrChangedPaywallIds) await processConfig(newConfig, isFirstTime: false) - configState.send(.retrieved(newConfig)) let configRefresh = InternalSuperwallEvent.ConfigRefresh( buildId: newConfig.buildId, @@ -214,14 +213,11 @@ class ConfigManager { // read and the in-memory purchase state is rebuilt from the saved copy. // Only changes made since the last launch can then be missed, and the // read corrects those when it lands. - let didPublishConfig = await processConfig( + await processConfig( config, isFirstTime: true, publishingEarlyFrom: shouldFetchAsync ? savedCustomerInfoForEarlyPublish() : nil ) - if !didPublishConfig { - configState.send(.retrieved(config)) - } // Step 7: Schedule background tasks scheduleBackgroundTasks( @@ -451,18 +447,16 @@ class ConfigManager { ) } - /// Applies `config` and loads purchases from StoreKit. + /// Applies `config`, loads purchases from StoreKit, and sends `configState`. /// /// - Parameter savedCustomerInfo: When given, `configState` is sent before the /// StoreKit read starts, with the in-memory purchase state rebuilt from this /// saved copy. Ignored in test mode, where products come from the API. - /// - Returns: Whether `configState` was sent here. - @discardableResult private func processConfig( _ config: Config, isFirstTime: Bool, publishingEarlyFrom savedCustomerInfo: CustomerInfo? = nil - ) async -> Bool { + ) async { storage.save( config.featureFlags.disableVerbosePlacements, forType: DisableVerbosePlacements.self) storage.save(config, forType: LatestConfig.self) @@ -476,7 +470,7 @@ class ConfigManager { let testModeJustActivated = !wasTestMode && testModeManager.isTestMode let testModeJustDeactivated = wasTestMode && !testModeManager.isTestMode - var didPublishConfig = false + var hasPublishedConfig = false if testModeManager.isTestMode { // In test mode, fetch products from API instead of StoreKit await fetchTestModeProducts(testModeManager: testModeManager) @@ -511,7 +505,7 @@ class ConfigManager { } setInitialPurchasesLoad(purchasesLoad) configState.send(.retrieved(config)) - didPublishConfig = true + hasPublishedConfig = true await purchasesLoad.value } else { await factory.loadPurchasedProducts(config: config) @@ -528,18 +522,21 @@ class ConfigManager { } // Show test mode alert if it's the first time OR if test mode just became active - let shouldShowTestModeAlert = isFirstTime || testModeJustActivated - if shouldShowTestModeAlert, + if isFirstTime || testModeJustActivated, testModeManager.isTestMode, - testModeManager.testModeReason != nil { + let reason = testModeManager.testModeReason { if DevMode.isActive(options) { await applyDefaultTestModeState(testModeManager: testModeManager) - } else if let reason = testModeManager.testModeReason { + } else { await presentTestModeModal(reason: reason, config: config) } } - return didPublishConfig + // Last on purpose: the test mode modal above waits for the tester to pick + // entitlements, and anything waiting on config must see that choice. + if !hasPublishedConfig { + configState.send(.retrieved(config)) + } } /// Reassigns variants and preloads paywalls again. From d63ddd5dd23bf2a6bf384f4cd6ad712222c9e283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:20:42 +0200 Subject: [PATCH 156/162] Keep entitlements a device read has no authority over, whether or not they hold The per-entitlement filter used one predicate for two questions. A lifetime App Store unlock has no expiry, so it can't hold the status up, and the filter also dropped it from the status on an empty read that said nothing about it. Holding and surviving are now separate: an entitlement survives unless the read had authority over it and did not confirm it. Co-Authored-By: Claude Fable 5.1 --- .../AutomaticPurchaseController.swift | 42 ++++++++++-------- .../AutomaticPurchaseControllerTests.swift | 44 +++++++++++++++++++ 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift b/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift index 33b98a019..2de4c7ee3 100644 --- a/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift +++ b/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift @@ -67,17 +67,18 @@ final class AutomaticPurchaseController { // published one: that also carries developer-granted entitlements, // which would hold a lapsed App Store entitlement in place. // - // Each entitlement is judged on its own. One that holds keeps only - // itself: a refunded App Store entitlement next to a live web one - // must still drop out, not ride along with it. + // Two separate questions. Whether an entitlement holds the status up + // needs an expiry the read can be bounded by. Whether it stays in + // the status only needs the read to have no authority over it: a + // lifetime App Store unlock has no expiry, so it can't hold, but an + // empty read said nothing about it either, so it is not dropped + // while another entitlement holds. Only an App Store entitlement + // the read had authority over and did not confirm is dropped — a + // refunded subscription next to a live web one, say. if case .active(let currentEntitlements) = superwall.assignedSubscriptionStatus { - let heldEntitlements = currentEntitlements.filter { entitlement in - guard entitlement.isActive, - (entitlement.expiresAt ?? .distantPast) > Date() else { - return false - } + func isRefuted(_ entitlement: Entitlement) -> Bool { if purchases.isEmpty || entitlement.store != .appStore { - return true + return false } // A still-active purchase that unlocks this entitlement means // the empty entitlement set is a mapping failure. With the @@ -85,18 +86,23 @@ final class AutomaticPurchaseController { // `Purchase.isActive` is disabled too, so this is the raw // transaction-level value and can miss a revocation that sets // no `revocationDate`. The hold is still bounded by the expiry - // gate above, which beats locking out a paying subscriber over + // gate below, which beats locking out a paying subscriber over // a lost product mapping. - return entitlement.productIds.contains { activeProductIds.contains($0) } + return !entitlement.productIds.contains { activeProductIds.contains($0) } } - if heldEntitlements == currentEntitlements { - return + let holdsStatus = currentEntitlements.contains { entitlement in + entitlement.isActive + && (entitlement.expiresAt ?? .distantPast) > Date() + && !isRefuted(entitlement) } - if !heldEntitlements.isEmpty { - superwall.internallySetSubscriptionStatus( - to: .active(heldEntitlements), - superwall: superwall - ) + if holdsStatus { + let survivors = currentEntitlements.filter { !isRefuted($0) } + if survivors != currentEntitlements { + superwall.internallySetSubscriptionStatus( + to: .active(survivors), + superwall: superwall + ) + } return } } diff --git a/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift b/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift index 54d8e616c..d9cf15e6d 100644 --- a/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift @@ -431,6 +431,50 @@ struct AutomaticPurchaseControllerTests { } } + @Test("An empty device read keeps a lifetime App Store entitlement beside a held one") + func testEmptyDeviceRead_lifetimeBesideHeldEntitlement_keepsBoth() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // A lifetime unlock has no expiry, so it can't hold the status up on its + // own. But an empty read is a non-answer about it too, so while the web + // subscription holds, the lifetime entitlement must not be dropped and + // persisted as gone. + let webEntitlement = stripeEntitlement(expiresAt: Date().addingTimeInterval(30 * 86_400)) + let lifetimeEntitlement = Entitlement( + id: "lifetime", + type: .serviceLevel, + isActive: true, + productIds: ["lifetime_product"], + latestProductId: "lifetime_product", + store: .appStore, + startsAt: Date().addingTimeInterval(-90 * 86_400), + renewedAt: nil, + expiresAt: nil, + isLifetime: true, + willRenew: nil, + state: nil, + offerType: nil + ) + dependencyContainer.storage.delete(LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .active([webEntitlement, lifetimeEntitlement]) + } + + let controller = makeController() + await controller.syncSubscriptionStatus(withPurchases: [], superwall: superwall) + + let status = await MainActor.run { superwall.subscriptionStatus } + if case .active(let entitlements) = status { + #expect(entitlements.contains(webEntitlement)) + #expect( + entitlements.contains(lifetimeEntitlement), + "An empty read has no authority over a lifetime unlock, so it must survive" + ) + } else { + Issue.record("A held web entitlement must keep the status active; got \(status)") + } + } + @Test("Inactive purchases cannot refute a nil-store entitlement") func testInactivePurchases_nilStoreStatus_staysActive() async { let superwall = Superwall(dependencyContainer: dependencyContainer) From d8736484695eafe877949c624205fcb11c2686b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:20:42 +0200 Subject: [PATCH 157/162] Drop a selected dev surface the refreshed manifest no longer lists The preview kept rendering a surface that had been removed from the project, while presenting it failed. The selection is now cleared with a warning, matching the presentation path, and an empty selection stops the spinner instead of leaving it running. Co-Authored-By: Claude Fable 5.1 --- .../Debug/DebugViewController.swift | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index c1415a4df..56ebfd2c2 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -227,7 +227,8 @@ final class DebugViewController: UIViewController { /// The manifest is fetched again on every load, not only the first: a /// surface's products and presentation settings come from its config.ts, /// and edits made since the debugger opened must show up in the preview. - /// When the server can't be reached the last snapshot stands. + /// When the server can't be reached the last snapshot stands; a surface the + /// server no longer lists is dropped, since presenting it would fail too. private func ensureDevServer() async { guard DevMode.isActive(Superwall.shared.options), @@ -244,8 +245,23 @@ final class DebugViewController: UIViewController { debugManager.devServer = located // The selected surface was picked from the previous manifest; carry the // selection over to the fresh one so the preview renders what it says now. - if let devSurface = devSurface { - self.devSurface = located.surfaces.first { $0.id == devSurface.id } ?? devSurface + guard let devSurface = devSurface else { + return + } + if let refreshed = located.surfaces.first(where: { $0.id == devSurface.id }) { + self.devSurface = refreshed + return + } + Logger.debug( + logLevel: .warn, + scope: .debugViewController, + message: "The dev server no longer lists the surface \(devSurface.id). " + + "Pick another paywall to preview." + ) + self.devSurface = nil + if paywallIdentifier == devSurface.previewIdentifier { + paywallIdentifier = nil + paywallDatabaseId = nil } } @@ -276,6 +292,9 @@ final class DebugViewController: UIViewController { return } } else { + // Nothing selected: leave the picker for the developer rather than a + // spinner with nothing behind it. + activityIndicator.stopAnimating() return } From 62e294a6042cc19fb176d2c27d726fe73ac6507d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:20:42 +0200 Subject: [PATCH 158/162] Pin the intro offer eligibility key in the manifest fixture The CLI serializer writes introductory_offer_eligibility on every surface in lowercase, defaulting to automatic. The fixture now carries the key so a casing change on either side fails here. Co-Authored-By: Claude Fable 5.1 --- .../DevServer/DevServerManifestTests.swift | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift index f622db253..344022964 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift @@ -185,9 +185,14 @@ final class DevServerManifestTests: XCTestCase { XCTAssertNil(decoded.surface(forPaywallDatabaseId: "444")) } - /// The manifest a running `superwall dev` actually serves, copied verbatim - /// from `/device/manifest.json`, so a change on either side of the wire - /// fails here rather than on someone's device. + /// The manifest a running `superwall dev` actually serves, copied from + /// `/device/manifest.json`, so a change on either side of the wire fails + /// here rather than on someone's device. The CLI writes every key from one + /// serializer (`packages/runtime/src/config.ts`, `paywallSettingsOf`): + /// `presentation_style.type` and `web_checkout_destination` in the push + /// API's uppercase, `feature_gating` and `introductory_offer_eligibility` + /// lowercase, and the eligibility key on every surface, defaulting to + /// `automatic`. func test_readsTheManifestTheCliServes() throws { let decoded = try manifest(""" { @@ -202,7 +207,8 @@ final class DevServerManifestTests: XCTestCase { "feature_gating": "gated", "on_device_cache": false, "scroll_enabled": true, - "game_controller_enabled": true + "game_controller_enabled": true, + "introductory_offer_eligibility": "always_ineligible" } }, { @@ -216,6 +222,7 @@ final class DevServerManifestTests: XCTestCase { "on_device_cache": true, "scroll_enabled": true, "game_controller_enabled": false, + "introductory_offer_eligibility": "automatic", "background_color_hex": "#ffffff", "dark_background_color_hex": "#0d0f12" } @@ -230,6 +237,7 @@ final class DevServerManifestTests: XCTestCase { "on_device_cache": true, "scroll_enabled": true, "game_controller_enabled": false, + "introductory_offer_eligibility": "automatic", "web_checkout_destination": "EXTERNAL" } } @@ -279,6 +287,7 @@ final class DevServerManifestTests: XCTestCase { XCTAssertEqual(paywall.featureGating, .gated) XCTAssertTrue(paywall.isScrollEnabled) XCTAssertEqual(paywall.onDeviceCache, .disabled) + XCTAssertEqual(paywall.introOfferEligibility, .ineligible) // Web-only and app-level settings have no paywall field to land on, so the // surfaces carrying them still read cleanly. From dbaebd5f1a9cf387e15a8477e2f764e9fea3af3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:54:02 +0200 Subject: [PATCH 159/162] Update changelog, remove redundant code --- CHANGELOG.md | 1 - Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift | 8 +++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcf23ffea..b02ffdaae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,6 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Enhancements - Adds `grantedEntitlements` so you can grant entitlements from your own backend, which the SDK merges with device and web entitlements. -- Adds `CaseIterable` conformance to `IntegrationAttribute`, so you can list every integration the SDK supports. - Adds `userAttributes` to the `RedemptionInfo` you get from `didRedeemLink`, so you can read the answers someone gave on your web paywall funnel after they redeem in your app. - Adds `SuperwallOptions.devServer` for development builds: with a `superwall dev` server running, paywalls render from your live, local paywall code while configuration, placements, audience evaluation and assignment stay real. Use `.default` on a simulator, which finds the dev server on localhost automatically; on a physical device use `.url(...)` with the Device URL `superwall dev` prints. The dev server also activates test mode, disables preloading, and skips the test mode intro sheet. diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index 692338d81..c101f00df 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -448,7 +448,7 @@ actor WebEntitlementRedeemer { storage.save(response, forType: LatestRedeemResponse.self) - _ = await mergeAndApplyCustomerInfo( + await mergeAndApplyCustomerInfo( webCustomerInfo: response.customerInfo, superwall: superwall ) @@ -516,7 +516,7 @@ actor WebEntitlementRedeemer { private func mergeAndApplyCustomerInfo( webCustomerInfo: CustomerInfo, superwall: Superwall - ) async -> CustomerInfo { + ) async { let mergedCustomerInfo: CustomerInfo if factory.makeHasExternalPurchaseController() { let subscriptionStatus = await MainActor.run { superwall.subscriptionStatus } @@ -536,8 +536,6 @@ actor WebEntitlementRedeemer { await MainActor.run { superwall.customerInfo = mergedCustomerInfo } - - return mergedCustomerInfo } private func updateSubscriptionStatus( @@ -978,7 +976,7 @@ actor WebEntitlementRedeemer { return } - _ = await mergeAndApplyCustomerInfo( + await mergeAndApplyCustomerInfo( webCustomerInfo: response.customerInfo, superwall: superwall ) From 0014faeb9f96a537c234955e232149552233cbb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:59:54 +0200 Subject: [PATCH 160/162] Drop an entitlement past its own expiry even when the device read said nothing Surviving a held status only asked whether the read had authority over the entitlement, so an App Store subscription whose cached expiry had already passed stayed in the status across empty reads. Time passing needs no read to confirm it: a lapsed expiry now drops the entitlement too, while a nil expiry still keeps a lifetime unlock. Co-Authored-By: Claude Fable 5.1 --- .../AutomaticPurchaseController.swift | 25 +++++++---- .../AutomaticPurchaseControllerTests.swift | 43 +++++++++++++++++++ 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift b/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift index 2de4c7ee3..5da6d5b13 100644 --- a/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift +++ b/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift @@ -69,13 +69,21 @@ final class AutomaticPurchaseController { // // Two separate questions. Whether an entitlement holds the status up // needs an expiry the read can be bounded by. Whether it stays in - // the status only needs the read to have no authority over it: a - // lifetime App Store unlock has no expiry, so it can't hold, but an - // empty read said nothing about it either, so it is not dropped - // while another entitlement holds. Only an App Store entitlement - // the read had authority over and did not confirm is dropped — a - // refunded subscription next to a live web one, say. + // the status needs two things: the read had no authority over it or + // confirmed it, and its own expiry hasn't passed. A lifetime App + // Store unlock has no expiry, so it can't hold, but an empty read + // said nothing about it either, so it stays while another + // entitlement holds. A refunded subscription next to a live web one + // is dropped because the read refuted it, and a subscription whose + // cached expiry is already behind us is dropped because the clock + // did — time passing needs no read to confirm it. if case .active(let currentEntitlements) = superwall.assignedSubscriptionStatus { + func isLapsed(_ entitlement: Entitlement) -> Bool { + guard let expiresAt = entitlement.expiresAt else { + return false + } + return expiresAt <= Date() + } func isRefuted(_ entitlement: Entitlement) -> Bool { if purchases.isEmpty || entitlement.store != .appStore { return false @@ -92,11 +100,12 @@ final class AutomaticPurchaseController { } let holdsStatus = currentEntitlements.contains { entitlement in entitlement.isActive - && (entitlement.expiresAt ?? .distantPast) > Date() + && entitlement.expiresAt != nil + && !isLapsed(entitlement) && !isRefuted(entitlement) } if holdsStatus { - let survivors = currentEntitlements.filter { !isRefuted($0) } + let survivors = currentEntitlements.filter { !isRefuted($0) && !isLapsed($0) } if survivors != currentEntitlements { superwall.internallySetSubscriptionStatus( to: .active(survivors), diff --git a/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift b/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift index d9cf15e6d..e0e8c3a0c 100644 --- a/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Purchase Controller/AutomaticPurchaseControllerTests.swift @@ -475,6 +475,49 @@ struct AutomaticPurchaseControllerTests { } } + @Test("An empty device read drops an App Store entitlement whose own expiry has passed") + func testEmptyDeviceRead_lapsedAppStoreBesideHeldEntitlement_dropsLapsed() async { + let superwall = Superwall(dependencyContainer: dependencyContainer) + + // The web subscription holds the status. The App Store subscription's + // cached expiry was yesterday: an empty read says nothing about it, but + // the clock does, so it must not keep granting access beside the web one. + let webEntitlement = stripeEntitlement(expiresAt: Date().addingTimeInterval(30 * 86_400)) + let lapsedEntitlement = Entitlement( + id: "app_store_pro", + type: .serviceLevel, + isActive: true, + productIds: ["annual_product"], + latestProductId: "annual_product", + store: .appStore, + startsAt: Date().addingTimeInterval(-400 * 86_400), + renewedAt: nil, + expiresAt: Date().addingTimeInterval(-86_400), + isLifetime: false, + willRenew: false, + state: nil, + offerType: nil + ) + dependencyContainer.storage.delete(LatestRedeemResponse.self) + await MainActor.run { + superwall.subscriptionStatus = .active([webEntitlement, lapsedEntitlement]) + } + + let controller = makeController() + await controller.syncSubscriptionStatus(withPurchases: [], superwall: superwall) + + let status = await MainActor.run { superwall.subscriptionStatus } + if case .active(let entitlements) = status { + #expect(entitlements.contains(webEntitlement)) + #expect( + !entitlements.contains { $0.id == lapsedEntitlement.id }, + "A subscription past its own expiry must not survive on an empty read" + ) + } else { + Issue.record("A held web entitlement must keep the status active; got \(status)") + } + } + @Test("Inactive purchases cannot refute a nil-store entitlement") func testInactivePurchases_nilStoreStatus_staysActive() async { let superwall = Superwall(dependencyContainer: dependencyContainer) From d4055e6882c5a759f1708fb3a72a7e1f86d67573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:31:13 +0200 Subject: [PATCH 161/162] Resolve the debugger's selected surface the way Present does The preview path looked the refreshed surface up inline while Present went through DevServerPreview.resolveSurface, so the same rule lived in two places. The preview now uses the shared resolver. When the surface is gone the picker title is reset too, instead of naming a surface that no longer exists over an empty preview. Co-Authored-By: Claude Fable 5.1 --- Sources/SuperwallKit/Debug/DebugViewController.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index 56ebfd2c2..fbba1dfc3 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -245,11 +245,17 @@ final class DebugViewController: UIViewController { debugManager.devServer = located // The selected surface was picked from the previous manifest; carry the // selection over to the fresh one so the preview renders what it says now. + // Resolved the same way Present resolves it, so the two can't disagree + // about whether the surface still exists. guard let devSurface = devSurface else { return } - if let refreshed = located.surfaces.first(where: { $0.id == devSurface.id }) { - self.devSurface = refreshed + if let resolved = DevServerPreview.resolveSurface( + previewIdentifier: devSurface.previewIdentifier, + fresh: location, + snapshot: nil + ) { + self.devSurface = resolved.surface return } Logger.debug( @@ -263,6 +269,8 @@ final class DebugViewController: UIViewController { paywallIdentifier = nil paywallDatabaseId = nil } + // The title still named the dropped surface over an empty preview. + previewPickerButton.setTitle("Choose a paywall", for: .normal) } func finishLoadingPreview() async { From 9835f8b948f3e960695771fbd1ff986f97b0f465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:31:50 +0200 Subject: [PATCH 162/162] Say which stores the expiry prune actually settles Web entitlements are merged back from the redeem cache on every assign, so the clock only decides App Store and nil-store records here. The comment read store-agnostic. Co-Authored-By: Claude Fable 5.1 --- .../AutomaticPurchaseController.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift b/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift index 5da6d5b13..56ad7573f 100644 --- a/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift +++ b/Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift @@ -74,9 +74,15 @@ final class AutomaticPurchaseController { // Store unlock has no expiry, so it can't hold, but an empty read // said nothing about it either, so it stays while another // entitlement holds. A refunded subscription next to a live web one - // is dropped because the read refuted it, and a subscription whose - // cached expiry is already behind us is dropped because the clock - // did — time passing needs no read to confirm it. + // is dropped because the read refuted it, and an App Store + // subscription whose cached expiry is already behind us is dropped + // because the clock did — time passing needs no read to confirm it. + // + // The clock only settles App Store and nil-store records here. Web + // entitlements are merged back in from the redeem cache by + // `internallySetSubscriptionStatus`, which is authoritative for + // them, so a lapsed web record comes straight back until the web + // poll says otherwise. if case .active(let currentEntitlements) = superwall.assignedSubscriptionStatus { func isLapsed(_ entitlement: Entitlement) -> Bool { guard let expiresAt = entitlement.expiresAt else {