From bbd5dfc9e816554c0f0fba5e0e849eada1ec99a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:34:49 +0200 Subject: [PATCH] Capture first-session deep link as firstDeepLinkUrl user attribute App Store custom product pages (iOS 18+) can each specify a deep link that's delivered on the first app open after install. When Superwall.handleDeepLink routes a deep link during the first app session, the SDK now stores it install-scoped and merges a firstDeepLinkUrl user attribute so campaign results can be broken down and audiences filtered by the product page that drove the install. The stored URL survives identity resets and is re-applied to the new user after reset(), mirroring Apple Search Ads and MMP install attribution. Redemption-code deep links are excluded, consistent with them not producing a deepLink_open event. Closes SW-5241. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 6 + Sources/SuperwallKit/DeepLinkRouter.swift | 48 ++++- .../Dependencies/DependencyContainer.swift | 4 +- Sources/SuperwallKit/Misc/Constants.swift | 2 +- .../Storage/Cache/CacheKeys.swift | 14 ++ Sources/SuperwallKit/Storage/Storage.swift | 26 +++ Sources/SuperwallKit/Superwall.swift | 10 + SuperwallKit.podspec | 2 +- SuperwallKit.xcodeproj/project.pbxproj | 4 + .../DeepLink/FirstSessionDeepLinkTests.swift | 200 ++++++++++++++++++ 10 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 Tests/SuperwallKitTests/DeepLink/FirstSessionDeepLinkTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d06cfe70fa..471a46e646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. +## 4.16.3 + +### Enhancements + +- Saves the first deep link received during the first app session as the `firstDeepLinkUrl` user attribute. If you assign deep links to your App Store custom product pages, you can use this attribute to break down results and filter audiences by the product page that drove the install. Requires `Superwall.handleDeepLink(_:)` to be set up. + ## 4.16.2 ### Enhancements diff --git a/Sources/SuperwallKit/DeepLinkRouter.swift b/Sources/SuperwallKit/DeepLinkRouter.swift index 39191cf33c..3b3c846686 100644 --- a/Sources/SuperwallKit/DeepLinkRouter.swift +++ b/Sources/SuperwallKit/DeepLinkRouter.swift @@ -10,19 +10,29 @@ import Foundation import Combine final class DeepLinkRouter { + /// The user attribute key holding the first deep link URL received + /// during the first app session after install. + static let firstDeepLinkUrlAttributeKey = "firstDeepLinkUrl" + private unowned let webEntitlementRedeemer: WebEntitlementRedeemer private unowned let debugManager: DebugManager private unowned let configManager: ConfigManager + private unowned let storage: Storage + private unowned let identityManager: IdentityManager private static var pendingDeepLink: URL? init( webEntitlementRedeemer: WebEntitlementRedeemer, debugManager: DebugManager, - configManager: ConfigManager + configManager: ConfigManager, + storage: Storage, + identityManager: IdentityManager ) { self.webEntitlementRedeemer = webEntitlementRedeemer self.debugManager = debugManager self.configManager = configManager + self.storage = storage + self.identityManager = identityManager listenToConfig() } @@ -54,6 +64,8 @@ final class DeepLinkRouter { deepLinkUrl = url } + captureFirstSessionDeepLink(deepLinkUrl) + Task { await Superwall.shared.track(InternalSuperwallEvent.DeepLink(url: deepLinkUrl)) } @@ -76,6 +88,40 @@ final class DeepLinkRouter { return false } + /// Captures the first deep link received during the first app session + /// as a user attribute. + /// + /// App Store custom product pages can each specify a deep link, which is + /// delivered on the first app open after install. Storing it as a user + /// attribute means campaign results can be broken down by custom product + /// page. + private func captureFirstSessionDeepLink(_ url: URL) { + if storage.recordFirstSessionDeepLink(url) { + identityManager.mergeUserAttributes( + [Self.firstDeepLinkUrlAttributeKey: url.absoluteString] + ) + } + } + + /// Re-applies the stored first-session deep link to the current user's + /// attributes. Called from `reset(duringIdentify:)` after user files are + /// wiped — the deep link is install-scoped, so the new user identity + /// inherits it, mirroring Apple Search Ads and MMP install attribution. + /// + /// - Warning: This must not read `identityManager.userAttributes`: + /// `identify()` triggers the reset from the identity serial queue and the + /// attributes getter is `queue.sync` on that same queue, which would + /// deadlock. The merge is async and idempotent, so it's applied + /// unconditionally. + func reapplyFirstSessionDeepLinkAttribute() { + guard let urlString = storage.get(FirstSessionDeepLinkURL.self) else { + return + } + identityManager.mergeUserAttributes( + [Self.firstDeepLinkUrlAttributeKey: urlString] + ) + } + private func listenToConfig() { configManager.configState .subscribe( diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index ba96227197..d234c651f2 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -190,7 +190,9 @@ final class DependencyContainer { deepLinkRouter = DeepLinkRouter( webEntitlementRedeemer: webEntitlementRedeemer, debugManager: debugManager, - configManager: configManager + configManager: configManager, + storage: storage, + identityManager: identityManager ) purchaseManager = PurchaseManager( storeKitVersion: options.storeKitVersion, diff --git a/Sources/SuperwallKit/Misc/Constants.swift b/Sources/SuperwallKit/Misc/Constants.swift index 20f05d6af4..d238a7fd6f 100644 --- a/Sources/SuperwallKit/Misc/Constants.swift +++ b/Sources/SuperwallKit/Misc/Constants.swift @@ -18,5 +18,5 @@ let sdkVersion = """ */ let sdkVersion = """ -4.16.2 +4.16.3 """ diff --git a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift index daab59c395..19f7ce1ce9 100644 --- a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift +++ b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift @@ -113,6 +113,20 @@ enum DidTrackFirstSession: Storable { typealias Value = Bool } +/// The first deep link URL received during the first app session after +/// install. App Store custom product pages can each specify a deep link +/// that's delivered on the first app open, so this is stored for product +/// page attribution. Install-scoped: survives identity reset so it can be +/// re-applied to a new user's attributes, and is only cleared by app +/// uninstall. +enum FirstSessionDeepLinkURL: Storable { + static var key: String { + "store.firstSessionDeepLinkUrl" + } + static var directory: SearchPathDirectory = .appSpecificDocuments + typealias Value = String +} + enum DidCleanUserAttributes: Storable { static var key: String { "store.didCleanUserAttributes" diff --git a/Sources/SuperwallKit/Storage/Storage.swift b/Sources/SuperwallKit/Storage/Storage.swift index 895fa38c47..4b365a2116 100644 --- a/Sources/SuperwallKit/Storage/Storage.swift +++ b/Sources/SuperwallKit/Storage/Storage.swift @@ -349,3 +349,29 @@ class Storage { return cache.delete(keyType) } } + +// MARK: - First Session Deep Link +extension Storage { + /// Records the first deep link received during the first app session. + /// + /// App Store custom product pages can each specify a deep link that's + /// delivered on the first app open after install. Storing it allows + /// results to be broken down by product page. Only the first deep link + /// received during the first app session is recorded. The stored value + /// is install-scoped, surviving identity resets. + /// + /// - Returns: `true` if this call recorded the URL, `false` if the first + /// app session has already ended or a deep link was already recorded. + func recordFirstSessionDeepLink(_ url: URL) -> Bool { + queue.sync { + if _didTrackFirstSession { + return false + } + if get(FirstSessionDeepLinkURL.self) != nil { + return false + } + save(url.absoluteString, forType: FirstSessionDeepLinkURL.self) + return true + } + } +} diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 6148926d61..9e4634cde3 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -1050,6 +1050,11 @@ public final class Superwall: NSObject, ObservableObject { /// - **`deepLink_open` trigger**: Any deep link can trigger a paywall if you've configured /// a `deepLink_open` trigger in your Superwall dashboard. /// + /// The first deep link received during the first app session after install is + /// also saved as the `firstDeepLinkUrl` user attribute. If you assign deep links + /// to your App Store custom product pages, you can use this attribute to break + /// down results and filter audiences by the product page that drove the install. + /// /// This method is designed to work in a handler chain pattern where multiple handlers /// process deep links. It returns `true` only for URLs that Superwall will handle, /// allowing other handlers to process non-Superwall URLs. @@ -1111,6 +1116,11 @@ public final class Superwall: NSObject, ObservableObject { // logout after that would otherwise leave the new user without attributes. dependencyContainer.mmpAttributionManager.reapplyCachedAcquisitionAttributes() + // The first-session deep link is install-scoped too. Re-apply it so the + // new user keeps the `firstDeepLinkUrl` attribute used for product page + // attribution. + dependencyContainer.deepLinkRouter.reapplyFirstSessionDeepLinkAttribute() + dependencyContainer.paywallManager.resetCache() presentationItems.reset() Task { diff --git a/SuperwallKit.podspec b/SuperwallKit.podspec index d18d4169e7..3c72f185dc 100644 --- a/SuperwallKit.podspec +++ b/SuperwallKit.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "SuperwallKit" - s.version = "4.16.2" + s.version = "4.16.3" 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 54b3fe0fc1..350bb6dc20 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -463,6 +463,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 */; }; + D09F053C8458BD4DC5D8C385 /* FirstSessionDeepLinkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AF63F804DD58D34FDF2BAB8 /* FirstSessionDeepLinkTests.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 */; }; @@ -647,6 +648,7 @@ 1A60698DFEF03837D029E191 /* TestModeEntitlementRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestModeEntitlementRowView.swift; sourceTree = ""; }; 1AB5B56470F69FBE1C34EAA8 /* PaywallRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallRequest.swift; sourceTree = ""; }; 1AD42859B8BFEC078665FA1E /* StripeStoreProductDiscount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeStoreProductDiscount.swift; sourceTree = ""; }; + 1AF63F804DD58D34FDF2BAB8 /* FirstSessionDeepLinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstSessionDeepLinkTests.swift; sourceTree = ""; }; 1B1A6ADFFB9FA982BF69C134 /* MockSKPaymentTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSKPaymentTransaction.swift; sourceTree = ""; }; 1B78FB9232236AF44369EA92 /* AppSessionManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSessionManagerMock.swift; sourceTree = ""; }; 1BDB77756A3775FF4ED31C48 /* Email.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Email.swift; sourceTree = ""; }; @@ -1724,6 +1726,7 @@ isa = PBXGroup; children = ( 0A9F09187825FB944A3BD8A9 /* DeepLinkRouterTests.swift */, + 1AF63F804DD58D34FDF2BAB8 /* FirstSessionDeepLinkTests.swift */, ); path = DeepLink; sourceTree = ""; @@ -3271,6 +3274,7 @@ CE43399599386456DCCD1FDC /* FakeLocationAuthorizationStatusTests.swift in Sources */, 1F9AE04458B942D070FC4832 /* FakeTrackingAuthorizationStatusTests.swift in Sources */, AC7D527612F631AAADC7D225 /* FileManagerMigratorTests.swift in Sources */, + D09F053C8458BD4DC5D8C385 /* FirstSessionDeepLinkTests.swift in Sources */, D4B5A9708204AB6D58904733 /* GetPaywallVcOperatorTests.swift in Sources */, 64B962A8B59ACE514B0E48AE /* GetPresenterOperatorTests.swift in Sources */, AF4AD928FACF9056E00D5920 /* HandleTriggerResultOperatorTests.swift in Sources */, diff --git a/Tests/SuperwallKitTests/DeepLink/FirstSessionDeepLinkTests.swift b/Tests/SuperwallKitTests/DeepLink/FirstSessionDeepLinkTests.swift new file mode 100644 index 0000000000..9c08739c83 --- /dev/null +++ b/Tests/SuperwallKitTests/DeepLink/FirstSessionDeepLinkTests.swift @@ -0,0 +1,200 @@ +// +// FirstSessionDeepLinkTests.swift +// SuperwallKit +// +// Tests for capturing the first deep link of the first app session +// as a user attribute for product page attribution. +// +// swiftlint:disable all + +import Testing +import Foundation +@testable import SuperwallKit + +// MARK: - Storage gating + +@Suite +struct FirstSessionDeepLinkStorageTests { + private func makeStorage(cache: Cache = CacheMock()) -> Storage { + Storage( + factory: StorageMock.DeviceInfoFactoryMock(), + cache: cache + ) + } + + @Test("Records the deep link during the first app session") + func record_firstSession_recordsAndReturnsTrue() throws { + let storage = makeStorage() + let url = try #require(URL(string: "myapp://home?promo=summer")) + + let didRecord = storage.recordFirstSessionDeepLink(url) + + #expect(didRecord == true) + #expect(storage.get(FirstSessionDeepLinkURL.self) == url.absoluteString) + } + + @Test("Keeps the first recorded deep link when a second one arrives") + func record_secondLink_keepsFirst() throws { + let storage = makeStorage() + let firstUrl = try #require(URL(string: "myapp://home?promo=first")) + let secondUrl = try #require(URL(string: "myapp://home?promo=second")) + + #expect(storage.recordFirstSessionDeepLink(firstUrl) == true) + #expect(storage.recordFirstSessionDeepLink(secondUrl) == false) + + #expect(storage.get(FirstSessionDeepLinkURL.self) == firstUrl.absoluteString) + } + + @Test("Doesn't record when the first app session ended on a previous launch") + func record_notFirstSession_returnsFalse() throws { + let cache = CacheMock() + cache.write(true, forType: DidTrackFirstSession.self) + let storage = makeStorage(cache: cache) + let url = try #require(URL(string: "myapp://home?promo=summer")) + + let didRecord = storage.recordFirstSessionDeepLink(url) + + #expect(didRecord == false) + #expect(storage.get(FirstSessionDeepLinkURL.self) == nil) + } + + @Test("Doesn't record after the first session has been tracked") + func record_afterFirstSessionTracked_returnsFalse() throws { + let storage = makeStorage() + storage.recordFirstSessionTracked() + let url = try #require(URL(string: "myapp://home?promo=summer")) + + let didRecord = storage.recordFirstSessionDeepLink(url) + + #expect(didRecord == false) + #expect(storage.get(FirstSessionDeepLinkURL.self) == nil) + } +} + +// MARK: - Router capture + +@Suite(.serialized) +struct FirstSessionDeepLinkRouterTests { + let dependencyContainer: DependencyContainer + let storage: Storage + let router: DeepLinkRouter + + init() { + dependencyContainer = DependencyContainer() + storage = Storage( + factory: StorageMock.DeviceInfoFactoryMock(), + cache: CacheMock() + ) + router = DeepLinkRouter( + webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, + debugManager: dependencyContainer.debugManager, + configManager: dependencyContainer.configManager, + storage: storage, + identityManager: dependencyContainer.identityManager + ) + + // Remove any attribute persisted to disk by previous test runs. + let removal: [String: Any?] = [DeepLinkRouter.firstDeepLinkUrlAttributeKey: nil] + dependencyContainer.identityManager.mergeUserAttributes(removal) + } + + private var storedAttribute: String? { + dependencyContainer.identityManager + .userAttributes[DeepLinkRouter.firstDeepLinkUrlAttributeKey] as? String + } + + @Test("Routing a deep link during the first session sets the firstDeepLinkUrl user attribute") + func route_firstSession_setsUserAttribute() async throws { + let url = try #require(URL(string: "myapp://home?promo=\(UUID().uuidString)")) + + router.route(url: url) + try await Task.sleep(nanoseconds: 500_000_000) + + #expect(storage.get(FirstSessionDeepLinkURL.self) == url.absoluteString) + #expect(storedAttribute == url.absoluteString) + } + + @Test("Only the first deep link of the first session is captured") + func route_secondLink_keepsFirstAttribute() async throws { + let firstUrl = try #require(URL(string: "myapp://home?promo=\(UUID().uuidString)")) + let secondUrl = try #require(URL(string: "myapp://home?promo=\(UUID().uuidString)")) + + router.route(url: firstUrl) + router.route(url: secondUrl) + try await Task.sleep(nanoseconds: 500_000_000) + + #expect(storage.get(FirstSessionDeepLinkURL.self) == firstUrl.absoluteString) + #expect(storedAttribute == firstUrl.absoluteString) + } + + @Test("Superwall app-link deep links are captured in their mapped form") + func route_superwallAppLink_capturesMappedUrl() async throws { + let promo = UUID().uuidString + let url = try #require( + URL(string: "https://myapp.superwall.app/app-link/home?promo=\(promo)") + ) + + router.route(url: url) + try await Task.sleep(nanoseconds: 500_000_000) + + // The mapped form is captured, consistent with the deepLink_open event. + #expect(storage.get(FirstSessionDeepLinkURL.self) == "myapp://home?promo=\(promo)") + #expect(storedAttribute == "myapp://home?promo=\(promo)") + } + + @Test("Redemption code deep links aren't captured for attribution") + func route_redemptionLink_doesNotCapture() async throws { + let superwall = Superwall(dependencyContainer: dependencyContainer) + let options = dependencyContainer.makeSuperwallOptions() + options.paywalls.shouldShowWebPurchaseConfirmationAlert = false + let mockNetwork = NetworkMock( + options: options, + factory: dependencyContainer + ) + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: storage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + let router = DeepLinkRouter( + webEntitlementRedeemer: redeemer, + debugManager: dependencyContainer.debugManager, + configManager: dependencyContainer.configManager, + storage: storage, + identityManager: dependencyContainer.identityManager + ) + let url = try #require(URL(string: "myapp://superwall/redeem?code=TESTCODE")) + + router.route(url: url) + + #expect(storage.get(FirstSessionDeepLinkURL.self) == nil) + + // Let the fire-and-forget redemption task finish against the mock network + // before the redeemer deallocates. + try await Task.sleep(nanoseconds: 500_000_000) + } + + @Test("Re-applies the stored deep link to user attributes after a reset") + func reapply_appliesStoredLink() async throws { + let urlString = "myapp://home?promo=\(UUID().uuidString)" + storage.save(urlString, forType: FirstSessionDeepLinkURL.self) + + router.reapplyFirstSessionDeepLinkAttribute() + try await Task.sleep(nanoseconds: 500_000_000) + + #expect(storedAttribute == urlString) + } + + @Test("Reapply is a no-op when no deep link was stored") + func reapply_noStoredLink_doesNotSetAttribute() async throws { + router.reapplyFirstSessionDeepLinkAttribute() + try await Task.sleep(nanoseconds: 300_000_000) + + #expect(storedAttribute == nil) + } +}