Skip to content

Commit c958743

Browse files
committed
Adjust deep link handling
- Now we only return true for deep linking if it will definitely be handled by Superwall. There could still be a case where the function returns false but then goes onto handle a deep link if config hasn't been retrieved before. Also if the deep link has been removed in an updated config it could falsely return true. But these are edge cases - Adds tests for deep linking
1 parent 4507e0d commit c958743

5 files changed

Lines changed: 250 additions & 10 deletions

File tree

Examples/Advanced/Advanced/SuperwallAdvancedApp.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ struct SuperwallAdvancedApp: App {
2222

2323
// MARK: - Option 1: Let Superwall handle everything
2424
Superwall.configure(apiKey: apiKey)
25-
25+
2626
// MARK: - Option 2: Use a Purchase Controller with StoreKit
2727
/*
2828
// Step 1 - Create your Purchase Controller

Sources/SuperwallKit/DeepLinkRouter.swift

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,54 @@ final class DeepLinkRouter {
8080
}
8181

8282
/// Stores the deep link until it can be handled.
83+
///
84+
/// Called when `handleDeepLink` is invoked before Superwall configuration completes.
85+
/// The URL is always stored so it can be processed once config loads, but the return
86+
/// value indicates whether Superwall will definitely handle this URL.
87+
///
88+
/// - Note: The URL is always stored regardless of return value because the fresh config
89+
/// might have a `deepLink_open` trigger even if cached config doesn't. This ensures
90+
/// deep links aren't lost during app launch. If the URL isn't a Superwall URL,
91+
/// returning `false` allows other handlers in a handler chain to process it.
92+
///
93+
/// - Parameter url: The deep link URL to store.
94+
/// - Returns: `true` if the URL is a Superwall URL that will be handled, `false` otherwise.
8395
static func storeDeepLink(_ url: URL) -> Bool {
96+
// Always store the URL - the fresh config might have deepLink_open trigger
97+
// even if cached config doesn't
8498
pendingDeepLink = url
85-
return true
99+
100+
// Only return true if we're confident Superwall will handle this URL
101+
return isSuperwallURL(url)
102+
}
103+
104+
/// Checks if the URL is one that Superwall will handle.
105+
private static func isSuperwallURL(_ url: URL) -> Bool {
106+
// Superwall universal links (*.superwall.app/app-link/*)
107+
if url.isSuperwallDeepLink {
108+
return true
109+
}
110+
111+
// Redemption codes
112+
if url.redeemableCode != nil {
113+
return true
114+
}
115+
116+
// Debug/preview URLs
117+
if DebugManager.outcomeForDeepLink(url: url) != nil {
118+
return true
119+
}
120+
121+
// Check cached config for deepLink_open trigger
122+
let cache = Cache()
123+
if let config = cache.read(LatestConfig.self) {
124+
let triggers = ConfigLogic.getTriggersByPlacementName(from: config.triggers)
125+
if triggers[SuperwallEventObjc.deepLink.description] != nil {
126+
return true
127+
}
128+
}
129+
130+
return false
86131
}
87132
}
88133

Sources/SuperwallKit/Superwall.swift

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -913,16 +913,25 @@ public final class Superwall: NSObject, ObservableObject {
913913
return dependencyContainer.deepLinkRouter.route(url: url)
914914
}
915915

916-
/// Handles a deep link sent to your app to open a preview of your paywall.
916+
/// Handles a deep link sent to your app.
917917
///
918-
/// You can preview your paywall on-device before going live by utilizing paywall previews. This uses a deep link to render a
919-
/// preview of a paywall you've configured on the Superwall dashboard on your device. See
920-
/// [In-App Previews](https://docs.superwall.com/docs/in-app-paywall-previews) for
921-
/// more.
918+
/// This method handles several types of deep links:
919+
/// - **Paywall previews**: Preview paywalls on-device before going live. See
920+
/// [In-App Previews](https://docs.superwall.com/docs/in-app-paywall-previews).
921+
/// - **Redemption codes**: Redeem web checkout codes via deep link.
922+
/// - **Superwall universal links**: Links in the format `*.superwall.app/app-link/*`.
923+
/// - **`deepLink_open` trigger**: Any deep link can trigger a paywall if you've configured
924+
/// a `deepLink_open` trigger in your Superwall dashboard.
922925
///
923-
/// - Parameters:
924-
/// - url: The URL of the deep link.
925-
/// - Returns: A `Bool` that is `true` if the deep link was handled. If called before ``Superwall/configure(apiKey:purchaseController:options:completion:)`` completes then it'll always return `true`.
926+
/// This method is designed to work in a handler chain pattern where multiple handlers
927+
/// process deep links. It returns `true` only for URLs that Superwall will handle,
928+
/// allowing other handlers to process non-Superwall URLs.
929+
///
930+
/// - Parameter url: The URL of the deep link.
931+
/// - Returns: `true` if Superwall will handle this deep link, `false` otherwise.
932+
/// When called before ``Superwall/configure(apiKey:purchaseController:options:completion:)``
933+
/// completes, returns `true` only for recognized Superwall URL formats or if cached
934+
/// config contains a `deepLink_open` trigger.
926935
@discardableResult
927936
public static func handleDeepLink(_ url: URL) -> Bool {
928937
if Superwall.isInitialized,

SuperwallKit.xcodeproj/project.pbxproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
00A15C51FC3B450A7B0F8806 /* PaywallViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7569A0893012A88442B7ED36 /* PaywallViewController.swift */; };
1212
00BC6BCF58320BFAC95E7B5F /* PaywallState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EB5A772C1F2ECE6D0E0BD69 /* PaywallState.swift */; };
1313
0175F611A86D1CA915C9EDED /* SuperwallKit_StoreKitTestCertificate.cer in Resources */ = {isa = PBXBuildFile; fileRef = 0D84EBFB7492EE4D10955D82 /* SuperwallKit_StoreKitTestCertificate.cer */; };
14+
01BE837B492223B76A95CB5D /* DeepLinkRouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A9F09187825FB944A3BD8A9 /* DeepLinkRouterTests.swift */; };
1415
01F3133229388B9E69B10E4A /* ASN1Decoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F49804DCB74FEEFA0D3438A9 /* ASN1Decoder.swift */; };
1516
020D985745B77C21039115AC /* UIApplication+ActiveWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74DFC04FC0F3498D1EBE4B6A /* UIApplication+ActiveWindow.swift */; };
1617
023A1FFE88B5881866CBE21E /* InterfaceStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76D61D89D2905A8747E37458 /* InterfaceStyle.swift */; };
@@ -517,6 +518,7 @@
517518
072886BB8C0E08DF414D9162 /* InAppReceiptPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppReceiptPayload.swift; sourceTree = "<group>"; };
518519
07FF7BCB3FA673AAEC8F9154 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
519520
0A716D8F8AA3CD7BBED04F4F /* TriggerAudienceOccurrence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerAudienceOccurrence.swift; sourceTree = "<group>"; };
521+
0A9F09187825FB944A3BD8A9 /* DeepLinkRouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkRouterTests.swift; sourceTree = "<group>"; };
520522
0B31ACE25727649F21DEEBAF /* AttributionPoster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributionPoster.swift; sourceTree = "<group>"; };
521523
0C39015ED9E8F5D9F0F78F1E /* RawWebMessageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RawWebMessageHandler.swift; sourceTree = "<group>"; };
522524
0C95DABA23C6CBEF0AAA63C0 /* PaywallProducts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallProducts.swift; sourceTree = "<group>"; };
@@ -1478,6 +1480,14 @@
14781480
path = Models;
14791481
sourceTree = "<group>";
14801482
};
1483+
3B16D25FCB6991D55E0F63B3 /* DeepLink */ = {
1484+
isa = PBXGroup;
1485+
children = (
1486+
0A9F09187825FB944A3BD8A9 /* DeepLinkRouterTests.swift */,
1487+
);
1488+
path = DeepLink;
1489+
sourceTree = "<group>";
1490+
};
14811491
3BE2C31A01D986D7FED5C4F4 /* Web View */ = {
14821492
isa = PBXGroup;
14831493
children = (
@@ -2322,6 +2332,7 @@
23222332
E338A5AE4BB82E592AE9B9BA /* Analytics */,
23232333
D554340BB6652F5FA1F21FF8 /* Config */,
23242334
38C02C19ED9C9958A7A61FB1 /* Debug */,
2335+
3B16D25FCB6991D55E0F63B3 /* DeepLink */,
23252336
373AFF230833A951B6E5DF36 /* Identity */,
23262337
8DD7B7C5E111EAB0878886B6 /* Logger */,
23272338
4D7656D6A565958F58A644AF /* Misc */,
@@ -2870,6 +2881,7 @@
28702881
37FDB46DD55E649FA10D753C /* CustomerInfoDecodingTests.swift in Sources */,
28712882
654803E77F7CDBF6282D0110 /* Date+IsWithinAnHourBeforeTests.swift in Sources */,
28722883
D91750797BB4947F6975B2B9 /* Date+IsoStringTests.swift in Sources */,
2884+
01BE837B492223B76A95CB5D /* DeepLinkRouterTests.swift in Sources */,
28732885
0CA13E721ADB243882536D4A /* DeviceHelperMock.swift in Sources */,
28742886
9DBDDD10A1EFC7CD3575D9E5 /* DeviceHelperTests.swift in Sources */,
28752887
A03AC977AD8110290DABECBD /* EntitlementPriorityTests.swift in Sources */,
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
//
2+
// DeepLinkRouterTests.swift
3+
// SuperwallKit
4+
//
5+
// Created by Yusuf Tör on 25/11/2025.
6+
//
7+
// swiftlint:disable all
8+
9+
import Testing
10+
@testable import SuperwallKit
11+
import Foundation
12+
13+
struct DeepLinkRouterTests {
14+
// MARK: - Superwall Universal Links
15+
16+
@Test("Returns true for Superwall universal link with superwall.app domain")
17+
func storeDeepLink_superwallUniversalLink_superwallApp() {
18+
let url = URL(string: "https://myapp.superwall.app/app-link/some/path")!
19+
let result = DeepLinkRouter.storeDeepLink(url)
20+
#expect(result == true)
21+
}
22+
23+
@Test("Returns true for Superwall universal link with superwallapp.dev domain")
24+
func storeDeepLink_superwallUniversalLink_superwallappDev() {
25+
let url = URL(string: "https://myapp.superwallapp.dev/app-link/some/path")!
26+
let result = DeepLinkRouter.storeDeepLink(url)
27+
#expect(result == true)
28+
}
29+
30+
@Test("Returns false for superwall.app without app-link path")
31+
func storeDeepLink_superwallDomain_noAppLinkPath() {
32+
let url = URL(string: "https://myapp.superwall.app/other/path")!
33+
let result = DeepLinkRouter.storeDeepLink(url)
34+
#expect(result == false)
35+
}
36+
37+
// MARK: - Redemption Code URLs
38+
39+
@Test("Returns true for URL scheme redemption code")
40+
func storeDeepLink_redemptionCode_urlScheme() {
41+
// Format: scheme://superwall/redeem?code=XXX (host="superwall", path="/redeem")
42+
let url = URL(string: "myapp://superwall/redeem?code=ABC123")!
43+
let result = DeepLinkRouter.storeDeepLink(url)
44+
#expect(result == true)
45+
}
46+
47+
@Test("Returns true for superwall.app redemption code")
48+
func storeDeepLink_redemptionCode_superwallApp() {
49+
let url = URL(string: "https://myapp.superwall.app/app-link/superwall/redeem?code=ABC123")!
50+
let result = DeepLinkRouter.storeDeepLink(url)
51+
#expect(result == true)
52+
}
53+
54+
@Test("Returns true for superwallapp.dev redemption code")
55+
func storeDeepLink_redemptionCode_superwallappDev() {
56+
let url = URL(string: "https://myapp.superwallapp.dev/app-link/superwall/redeem?code=ABC123")!
57+
let result = DeepLinkRouter.storeDeepLink(url)
58+
#expect(result == true)
59+
}
60+
61+
@Test("Returns false for redemption path without code parameter")
62+
func storeDeepLink_redemptionPath_noCode() {
63+
let url = URL(string: "myapp://superwall/redeem")!
64+
let result = DeepLinkRouter.storeDeepLink(url)
65+
#expect(result == false)
66+
}
67+
68+
// MARK: - Debug/Preview URLs
69+
70+
@Test("Returns true for debug URL with superwall_debug and token")
71+
func storeDeepLink_debugUrl_withToken() {
72+
let url = URL(string: "myapp://?superwall_debug=true&token=abc123")!
73+
let result = DeepLinkRouter.storeDeepLink(url)
74+
#expect(result == true)
75+
}
76+
77+
@Test("Returns true for debug URL with paywall_id")
78+
func storeDeepLink_debugUrl_withPaywallId() {
79+
let url = URL(string: "myapp://?superwall_debug=true&token=abc123&paywall_id=pw123")!
80+
let result = DeepLinkRouter.storeDeepLink(url)
81+
#expect(result == true)
82+
}
83+
84+
@Test("Returns false for superwall_debug without token")
85+
func storeDeepLink_debugUrl_noToken() {
86+
let url = URL(string: "myapp://?superwall_debug=true")!
87+
let result = DeepLinkRouter.storeDeepLink(url)
88+
#expect(result == false)
89+
}
90+
91+
@Test("Returns false for superwall_debug=false")
92+
func storeDeepLink_debugUrl_debugFalse() {
93+
let url = URL(string: "myapp://?superwall_debug=false&token=abc123")!
94+
let result = DeepLinkRouter.storeDeepLink(url)
95+
#expect(result == false)
96+
}
97+
98+
// MARK: - Non-Superwall URLs
99+
100+
@Test("Returns false for generic app URL")
101+
func storeDeepLink_genericAppUrl() {
102+
let url = URL(string: "myapp://home")!
103+
let result = DeepLinkRouter.storeDeepLink(url)
104+
#expect(result == false)
105+
}
106+
107+
@Test("Returns false for external website URL")
108+
func storeDeepLink_externalWebsite() {
109+
let url = URL(string: "https://example.com/some/path")!
110+
let result = DeepLinkRouter.storeDeepLink(url)
111+
#expect(result == false)
112+
}
113+
114+
@Test("Returns false for URL with unrelated query params")
115+
func storeDeepLink_unrelatedQueryParams() {
116+
let url = URL(string: "myapp://settings?theme=dark&language=en")!
117+
let result = DeepLinkRouter.storeDeepLink(url)
118+
#expect(result == false)
119+
}
120+
121+
@Test("Returns false for app deep link that looks similar")
122+
func storeDeepLink_similarLookingUrl() {
123+
let url = URL(string: "myapp://superwall/premium")!
124+
let result = DeepLinkRouter.storeDeepLink(url)
125+
#expect(result == false)
126+
}
127+
128+
// MARK: - Cached Config with deepLink_open Trigger
129+
//
130+
// Note: The storeDeepLink function reads from the actual disk cache,
131+
// which makes it difficult to test the cached config scenario in isolation.
132+
// The isSuperwallURL logic for checking cached config is tested implicitly
133+
// through integration tests. The URL format detection tests above cover
134+
// the primary use cases.
135+
136+
// MARK: - URL Extension Tests
137+
138+
@Test("isSuperwallDeepLink returns true for valid format")
139+
func isSuperwallDeepLink_validFormat() {
140+
let url = URL(string: "https://test.superwall.app/app-link/path")!
141+
#expect(url.isSuperwallDeepLink == true)
142+
}
143+
144+
@Test("isSuperwallDeepLink returns false without app-link prefix")
145+
func isSuperwallDeepLink_noAppLinkPrefix() {
146+
let url = URL(string: "https://test.superwall.app/other/path")!
147+
#expect(url.isSuperwallDeepLink == false)
148+
}
149+
150+
@Test("isSuperwallDeepLink returns false for non-superwall domain")
151+
func isSuperwallDeepLink_wrongDomain() {
152+
let url = URL(string: "https://example.com/app-link/path")!
153+
#expect(url.isSuperwallDeepLink == false)
154+
}
155+
156+
@Test("redeemableCode returns code from URL scheme format")
157+
func redeemableCode_urlScheme() {
158+
// Format: scheme://superwall/redeem?code=XXX (host="superwall", path="/redeem")
159+
let url = URL(string: "myapp://superwall/redeem?code=TESTCODE")!
160+
#expect(url.redeemableCode == "TESTCODE")
161+
}
162+
163+
@Test("redeemableCode returns code from universal link format")
164+
func redeemableCode_universalLink() {
165+
let url = URL(string: "https://app.superwall.app/app-link/superwall/redeem?code=TESTCODE")!
166+
#expect(url.redeemableCode == "TESTCODE")
167+
}
168+
169+
@Test("redeemableCode returns nil for non-redemption URL")
170+
func redeemableCode_nonRedemption() {
171+
let url = URL(string: "myapp://home")!
172+
#expect(url.redeemableCode == nil)
173+
}
174+
}

0 commit comments

Comments
 (0)