Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 47 additions & 1 deletion Sources/SuperwallKit/DeepLinkRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -54,6 +64,8 @@ final class DeepLinkRouter {
deepLinkUrl = url
}

captureFirstSessionDeepLink(deepLinkUrl)

Task {
await Superwall.shared.track(InternalSuperwallEvent.DeepLink(url: deepLinkUrl))
}
Expand All @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion Sources/SuperwallKit/Dependencies/DependencyContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion Sources/SuperwallKit/Misc/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ let sdkVersion = """
*/

let sdkVersion = """
4.16.2
4.16.3
"""
14 changes: 14 additions & 0 deletions Sources/SuperwallKit/Storage/Cache/CacheKeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
26 changes: 26 additions & 0 deletions Sources/SuperwallKit/Storage/Storage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Comment on lines +365 to +377

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Repeated disk read on every first-session deep link

queue.sync is used to atomically check-and-write, which is correct. However, once the URL is stored (get() returns non-nil), every subsequent deep link during the first session still enters the sync block and performs a cache.read() disk read before returning false. The existing pattern for similar flags (e.g., _didTrackFirstSeen, _didTrackFirstSession) keeps an in-memory shadow variable that eliminates the disk round-trip on hot paths. Adding a private var _didRecordFirstSessionDeepLink = false (initialized from cache.read(FirstSessionDeepLinkURL.self) != nil in init, and set to true after the first successful write) would make all subsequent calls a pure in-memory check with no disk I/O.

Prompt To Fix With AI
This is a comment left during a code review.
Path: Sources/SuperwallKit/Storage/Storage.swift
Line: 365-377

Comment:
**Repeated disk read on every first-session deep link**

`queue.sync` is used to atomically check-and-write, which is correct. However, once the URL is stored (`get()` returns non-nil), every subsequent deep link during the first session still enters the sync block and performs a `cache.read()` disk read before returning `false`. The existing pattern for similar flags (e.g., `_didTrackFirstSeen`, `_didTrackFirstSession`) keeps an in-memory shadow variable that eliminates the disk round-trip on hot paths. Adding a `private var _didRecordFirstSessionDeepLink = false` (initialized from `cache.read(FirstSessionDeepLinkURL.self) != nil` in `init`, and set to `true` after the first successful write) would make all subsequent calls a pure in-memory check with no disk I/O.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

10 changes: 10 additions & 0 deletions Sources/SuperwallKit/Superwall.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ℹ️ The static handleDeepLink(_:) overload gets this firstDeepLinkUrl doc note, but the deprecated instance handleDeepLink(_:) just above (line 1039) routes through the same capture path and didn't get it. Minor doc inconsistency worth mirroring, since both surfaces trigger the same behavior.

/// 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.
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion SuperwallKit.podspec
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
4 changes: 4 additions & 0 deletions SuperwallKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -647,6 +648,7 @@
1A60698DFEF03837D029E191 /* TestModeEntitlementRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestModeEntitlementRowView.swift; sourceTree = "<group>"; };
1AB5B56470F69FBE1C34EAA8 /* PaywallRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallRequest.swift; sourceTree = "<group>"; };
1AD42859B8BFEC078665FA1E /* StripeStoreProductDiscount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeStoreProductDiscount.swift; sourceTree = "<group>"; };
1AF63F804DD58D34FDF2BAB8 /* FirstSessionDeepLinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstSessionDeepLinkTests.swift; sourceTree = "<group>"; };
1B1A6ADFFB9FA982BF69C134 /* MockSKPaymentTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSKPaymentTransaction.swift; sourceTree = "<group>"; };
1B78FB9232236AF44369EA92 /* AppSessionManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSessionManagerMock.swift; sourceTree = "<group>"; };
1BDB77756A3775FF4ED31C48 /* Email.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Email.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1724,6 +1726,7 @@
isa = PBXGroup;
children = (
0A9F09187825FB944A3BD8A9 /* DeepLinkRouterTests.swift */,
1AF63F804DD58D34FDF2BAB8 /* FirstSessionDeepLinkTests.swift */,
);
path = DeepLink;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
Loading
Loading