Skip to content

Commit 2783484

Browse files
jaxdesmaraisclaudeagedd
authored
Fix Venmo App Switch (#99)
* feat: add returnURLScheme init for Venmo app switch Add `init(webView:returnURLScheme:prefersEphemeralWebBrowserSession:)`, funneled through a single internal designated init that also injects a `URLOpener` for testing. When a returnURLScheme is provided and the Venmo app is installed, configureWebView advertises that scheme to the JS layer as the return URL prefix (getReturnUrlPrefix); otherwise it keeps the internal ASWeb callback scheme. Adds MockURLOpener + tests for the scheme branching, demo Info.plist URL scheme registration, and README/CHANGELOG updates. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: use one effective return scheme across ASWeb and app switch The web SDK reads getReturnUrlPrefix() for both the ASWeb popup and the Venmo app switch, so the return scheme must be consistent everywhere. Previously only getReturnUrlPrefix() switched to the merchant scheme (for Venmo) while the ASWeb callbackURLScheme and the return-URL validation stayed hardcoded to the SDK scheme, which broke the ASWeb popup return (endless spinner). Compute the effective scheme once (merchant scheme when a returnURLScheme is provided and the Venmo app is installed, otherwise the SDK callback scheme) and use it for the user script prefix, the ASWebAuthenticationSession callbackURLScheme, and constructJavaScriptCompletionResult validation. Adds a configurable callbackURLScheme on WebAuthenticationSession and validation tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: refine returnURLScheme README/CHANGELOG wording Also re-sync Podfile.lock checksum with main's merged Podfile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Update Sources/PopupBridge/POPPopupBridge.swift Co-authored-by: agedd <105314544+agedd@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Jax DesMarais-Leder <jdesmarais@paypal.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: agedd <105314544+agedd@users.noreply.github.com>
1 parent a312096 commit 2783484

10 files changed

Lines changed: 221 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# PopupBridge iOS Release Notes
22

3+
## unreleased
4+
* Add `init(webView:returnURLScheme:prefersEphemeralWebBrowserSession:)` for the Venmo app switch flow. When a `returnURLScheme` is provided and the Venmo app is installed, PopupBridge sends that scheme to the web SDK as the return URL prefix (`window.popupBridge.getReturnUrlPrefix()`) so the Venmo app can deep-link back into your app.
5+
36
## 3.0.0 (2025-04-01)
47
* Breaking Changes
58
* Bump minimum supported deployment target to iOS 16+

Demo/Demo/Info.plist

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@
1818
<string>3.0.0</string>
1919
<key>CFBundleVersion</key>
2020
<string>3.0.0</string>
21+
<key>CFBundleURLTypes</key>
22+
<array>
23+
<dict>
24+
<key>CFBundleTypeRole</key>
25+
<string>Editor</string>
26+
<key>CFBundleURLName</key>
27+
<string>com.braintreepayments.Demo</string>
28+
<key>CFBundleURLSchemes</key>
29+
<array>
30+
<string>com.braintreepayments.Demo</string>
31+
</array>
32+
</dict>
33+
</array>
2134
<key>LSApplicationQueriesSchemes</key>
2235
<array>
2336
<string>com.venmo.touch.v2</string>

Demo/Demo/PopupBridgeViewController.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ final class PopupBridgeViewController: UIViewController {
1515
override func viewDidLoad() {
1616
super.viewDidLoad()
1717
setupConstraints()
18-
popupBridge = POPPopupBridge(webView: webView)
18+
popupBridge = POPPopupBridge(webView: webView, returnURLScheme: "com.braintreepayments.Demo")
1919
webView.load(URLRequest(url: URL(string: "https://braintree.github.io/popup-bridge-example/")!))
2020
}
2121

Podfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,6 @@ SPEC REPOS:
1111
SPEC CHECKSUMS:
1212
xcbeautify: df17aa32d769add7af523a8be9b0ef8fb9eb75a8
1313

14-
PODFILE CHECKSUM: f7e2246cbe0f5ad54fc1270994bd0f76a5ed948d
14+
PODFILE CHECKSUM: f41bb7beb906268c485bb81d6560025898b05cb3
1515

1616
COCOAPODS: 1.16.2

PopupBridge.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
451348272D8CAD7C009265C9 /* WebViewScriptHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451348262D8CAD6D009265C9 /* WebViewScriptHandler.swift */; };
1414
4513482B2D8DDA25009265C9 /* XCTestCase+trackForMemoryLeaks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4513482A2D8DDA17009265C9 /* XCTestCase+trackForMemoryLeaks.swift */; };
1515
45AE41D22D7649F800388548 /* MockAnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45AE41D12D7649EE00388548 /* MockAnalyticsService.swift */; };
16+
B7C9D0E12F00000000000002 /* MockURLOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7C9D0E12F00000000000001 /* MockURLOpener.swift */; };
1617
45CD0C2C2D64F08F0072C5A4 /* FPTIBatchData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45CD0C2B2D64F0810072C5A4 /* FPTIBatchData.swift */; };
1718
45CD0C2E2D664D140072C5A4 /* Date+MilisecondTimestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45CD0C2D2D664CF50072C5A4 /* Date+MilisecondTimestamp.swift */; };
1819
45CD0C302D6788A10072C5A4 /* Bundle+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45CD0C2F2D67888F0072C5A4 /* Bundle+Extension.swift */; };
@@ -54,6 +55,7 @@
5455
451348262D8CAD6D009265C9 /* WebViewScriptHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewScriptHandler.swift; sourceTree = "<group>"; };
5556
4513482A2D8DDA17009265C9 /* XCTestCase+trackForMemoryLeaks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+trackForMemoryLeaks.swift"; sourceTree = "<group>"; };
5657
45AE41D12D7649EE00388548 /* MockAnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAnalyticsService.swift; sourceTree = "<group>"; };
58+
B7C9D0E12F00000000000001 /* MockURLOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLOpener.swift; sourceTree = "<group>"; };
5759
45CD0C2B2D64F0810072C5A4 /* FPTIBatchData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FPTIBatchData.swift; sourceTree = "<group>"; };
5860
45CD0C2D2D664CF50072C5A4 /* Date+MilisecondTimestamp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+MilisecondTimestamp.swift"; sourceTree = "<group>"; };
5961
45CD0C2F2D67888F0072C5A4 /* Bundle+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Extension.swift"; sourceTree = "<group>"; };
@@ -223,6 +225,7 @@
223225
45FBAF342D70CFB6000D550B /* AnalyticsService_Test.swift */,
224226
A775A0801DEE4E7E009E67C2 /* Info.plist */,
225227
45AE41D12D7649EE00388548 /* MockAnalyticsService.swift */,
228+
B7C9D0E12F00000000000001 /* MockURLOpener.swift */,
226229
BE2524552A17FB9F00168D77 /* MockScriptMessage.swift */,
227230
45FBAF2E2D701E1D000D550B /* MockSessionable.swift */,
228231
BE2524532A17DFCC00168D77 /* MockUserContentController.swift */,
@@ -472,6 +475,7 @@
472475
BE2524542A17DFCC00168D77 /* MockUserContentController.swift in Sources */,
473476
4513482B2D8DDA25009265C9 /* XCTestCase+trackForMemoryLeaks.swift in Sources */,
474477
45AE41D22D7649F800388548 /* MockAnalyticsService.swift in Sources */,
478+
B7C9D0E12F00000000000002 /* MockURLOpener.swift in Sources */,
475479
BE2524562A17FB9F00168D77 /* MockScriptMessage.swift in Sources */,
476480
BEF9ED212A2A4896005D54AB /* MockWebAuthenticationSession.swift in Sources */,
477481
BE2524522A17DF8200168D77 /* PopupBridge_UnitTests.swift in Sources */,

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,30 @@ You must add the following to the queries schemes allowlist in your app's info.p
4747
</array>
4848
```
4949

50+
### Register your return URL scheme (Venmo app switch)
51+
52+
When using the Venmo app switch flow, initialize PopupBridge with the URL scheme your app is registered to handle:
53+
54+
```swift
55+
popupBridge = POPPopupBridge(webView: webView, returnURLScheme: "your-app-scheme")
56+
```
57+
58+
When the Venmo app is installed, PopupBridge sends this scheme to the web SDK as the return URL prefix (`window.popupBridge.getReturnUrlPrefix()`), so the Venmo app can deep-link back into your app. You must register the same scheme under `CFBundleURLTypes` in your app's `Info.plist`:
59+
60+
```xml
61+
<key>CFBundleURLTypes</key>
62+
<array>
63+
<dict>
64+
<key>CFBundleURLSchemes</key>
65+
<array>
66+
<string>your-app-scheme</string>
67+
</array>
68+
</dict>
69+
</array>
70+
```
71+
72+
Additionally, you must add this scheme to your applications URL types. All 3 cases must match exactly to return to your app as expected after completing the app switch flow.
73+
5074
Sample App
5175
-------
5276

Sources/PopupBridge/POPPopupBridge.swift

Lines changed: 74 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,27 +16,91 @@ public class POPPopupBridge: NSObject, WKScriptMessageHandler {
1616
private let hostName = "popupbridgev1"
1717
private let sessionID = UUID().uuidString.replacingOccurrences(of: "-", with: "")
1818
private let webView: WKWebView
19-
private let application: URLOpener = UIApplication.shared
19+
private let returnURLScheme: String?
20+
private var application: URLOpener = UIApplication.shared
2021

2122
private var webAuthenticationSession: WebAuthenticationSession = WebAuthenticationSession()
2223
private var returnBlock: ((URL) -> Void)? = nil
23-
24+
25+
/// The URL scheme used consistently across the return URL prefix advertised to the JS
26+
/// (`getReturnUrlPrefix()`), the ASWeb `callbackURLScheme`, and the return-URL validation. When a
27+
/// `returnURLScheme` is provided and the Venmo app is installed, the flow app switches into the
28+
/// Venmo app (which returns via the merchant scheme), so the merchant scheme is used everywhere.
29+
/// Otherwise the SDK's internal callback scheme is used (the standard ASWeb popup flow). The web
30+
/// SDK reads `getReturnUrlPrefix()` for both flows, so all three must agree on this one scheme.
31+
private var returnURLPrefixScheme: String {
32+
if let returnURLScheme, application.isVenmoAppInstalled() {
33+
return returnURLScheme
34+
}
35+
return PopupBridgeConstants.callbackURLScheme
36+
}
37+
2438
// MARK: - Initializers
2539

26-
/// Initialize a Popup Bridge.
40+
/// Initialize a Popup Bridge for the standard browser-based (ASWeb) checkout flow.
2741
/// - Parameters:
2842
/// - webView: The web view to add a script message handler to. Do not change the web view's configuration or user content controller after initializing Popup Bridge.
2943
/// - prefersEphemeralWebBrowserSession: A Boolean that, when true, requests that the browser does not share cookies
30-
/// or other browsing data between the authenthication session and the users normal browser session.
44+
/// or other browsing data between the authentication session and the user's normal browser session.
3145
/// Defaults to `true`.
32-
public init(webView: WKWebView, prefersEphemeralWebBrowserSession: Bool = true) {
46+
public convenience init(webView: WKWebView, prefersEphemeralWebBrowserSession: Bool = true) {
47+
self.init(
48+
webView: webView,
49+
webAuthenticationSession: WebAuthenticationSession(),
50+
returnURLScheme: nil,
51+
application: UIApplication.shared,
52+
prefersEphemeralWebBrowserSession: prefersEphemeralWebBrowserSession
53+
)
54+
}
55+
56+
/// Initialize a Popup Bridge with a return URL scheme for app switch flows (e.g. Venmo).
57+
///
58+
/// When a `returnURLScheme` is provided and the Venmo app is installed, the flow app switches into
59+
/// the Venmo app, which deep-links back into the app via this scheme. In that case the scheme is
60+
/// advertised to the JavaScript layer as the return URL prefix (instead of the SDK's internal
61+
/// ASWeb callback scheme).
62+
/// - Parameters:
63+
/// - webView: The web view to add a script message handler to. Do not change the web view's configuration or user content controller after initializing Popup Bridge.
64+
/// - returnURLScheme: The URL scheme registered under `CFBundleURLTypes` in the app's `Info.plist`
65+
/// that the Venmo app uses to deep-link back into the app.
66+
/// - prefersEphemeralWebBrowserSession: A Boolean that, when true, requests that the browser does not share cookies
67+
/// or other browsing data between the authentication session and the user's normal browser session.
68+
/// Defaults to `true`.
69+
public convenience init(webView: WKWebView, returnURLScheme: String, prefersEphemeralWebBrowserSession: Bool = true) {
70+
self.init(
71+
webView: webView,
72+
webAuthenticationSession: WebAuthenticationSession(),
73+
returnURLScheme: returnURLScheme,
74+
application: UIApplication.shared,
75+
prefersEphemeralWebBrowserSession: prefersEphemeralWebBrowserSession
76+
)
77+
}
78+
79+
/// Exposed for testing.
80+
///
81+
/// Designated initializer that injects a custom `WebAuthenticationSession` and `URLOpener` so tests
82+
/// can stub the browser authentication flow and simulate whether the Venmo app is installed. Kept
83+
/// `internal` so the only public entry points are the two initializers above, keeping the internal
84+
/// `URLOpener` out of the public API. `returnURLScheme` must be set before `configureWebView()`
85+
/// runs so the generated user script advertises the correct return URL prefix.
86+
init(
87+
webView: WKWebView,
88+
webAuthenticationSession: WebAuthenticationSession,
89+
returnURLScheme: String? = nil,
90+
application: URLOpener = UIApplication.shared,
91+
prefersEphemeralWebBrowserSession: Bool = true
92+
) {
3393
self.webView = webView
34-
94+
self.webAuthenticationSession = webAuthenticationSession
95+
self.returnURLScheme = returnURLScheme
96+
self.application = application
97+
3598
super.init()
3699

37100
configureWebView()
38-
webAuthenticationSession.prefersEphemeralWebBrowserSession = prefersEphemeralWebBrowserSession
39-
101+
self.webAuthenticationSession.prefersEphemeralWebBrowserSession = prefersEphemeralWebBrowserSession
102+
self.webAuthenticationSession.callbackURLScheme = returnURLPrefixScheme
103+
40104
returnBlock = { [weak self] url in
41105
guard let script = self?.constructJavaScriptCompletionResult(returnURL: url) else {
42106
return
@@ -46,15 +110,6 @@ public class POPPopupBridge: NSObject, WKScriptMessageHandler {
46110
return
47111
}
48112
}
49-
50-
/// Exposed for testing
51-
convenience init(
52-
webView: WKWebView,
53-
webAuthenticationSession: WebAuthenticationSession
54-
) {
55-
self.init(webView: webView)
56-
self.webAuthenticationSession = webAuthenticationSession
57-
}
58113

59114
deinit {
60115
webView.configuration.userContentController.removeAllScriptMessageHandlers()
@@ -69,7 +124,7 @@ public class POPPopupBridge: NSObject, WKScriptMessageHandler {
69124
/// - Returns: JavaScript formatted completion.
70125
func constructJavaScriptCompletionResult(returnURL: URL) -> String? {
71126
guard let urlComponents = URLComponents(url: returnURL, resolvingAgainstBaseURL: false),
72-
urlComponents.scheme?.caseInsensitiveCompare(PopupBridgeConstants.callbackURLScheme) == .orderedSame,
127+
urlComponents.scheme?.caseInsensitiveCompare(returnURLPrefixScheme) == .orderedSame,
73128
urlComponents.host?.caseInsensitiveCompare(hostName) == .orderedSame
74129
else {
75130
return nil
@@ -107,7 +162,7 @@ public class POPPopupBridge: NSObject, WKScriptMessageHandler {
107162
)
108163

109164
let javascript = PopupBridgeUserScript(
110-
scheme: PopupBridgeConstants.callbackURLScheme,
165+
scheme: returnURLPrefixScheme,
111166
scriptMessageHandlerName: messageHandlerName,
112167
host: hostName,
113168
isVenmoInstalled: application.isVenmoAppInstalled()

Sources/PopupBridge/WebAuthenticationSession.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ class WebAuthenticationSession: NSObject {
55

66
var authenticationSession: ASWebAuthenticationSession?
77
var prefersEphemeralWebBrowserSession: Bool = true
8+
var callbackURLScheme: String = PopupBridgeConstants.callbackURLScheme
89

910
func start(
1011
url: URL,
@@ -14,7 +15,7 @@ class WebAuthenticationSession: NSObject {
1415
) {
1516
self.authenticationSession = ASWebAuthenticationSession(
1617
url: url,
17-
callbackURLScheme: PopupBridgeConstants.callbackURLScheme
18+
callbackURLScheme: callbackURLScheme
1819
) { url, error in
1920
if let error = error as? NSError, error.code == ASWebAuthenticationSessionError.canceledLogin.rawValue {
2021
sessionDidCancel()

UnitTests/MockURLOpener.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import Foundation
2+
@testable import PopupBridge
3+
4+
class MockURLOpener: URLOpener {
5+
6+
var venmoInstalled = false
7+
8+
func isVenmoAppInstalled() -> Bool {
9+
venmoInstalled
10+
}
11+
}

UnitTests/PopupBridge_UnitTests.swift

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,4 +373,92 @@ final class PopupBridge_UnitTests: XCTestCase, WKNavigationDelegate {
373373
))
374374
webView.load(URLRequest(url: URL(string: "some-popup-bridge-example")!))
375375
}
376+
377+
// MARK: - returnURLScheme app switch tests
378+
379+
func testUserScript_whenReturnURLSchemeProvidedAndVenmoInstalled_advertisesReturnURLSchemeAsReturnUrlPrefix() {
380+
let mockURLOpener = MockURLOpener()
381+
mockURLOpener.venmoInstalled = true
382+
383+
let webView = WKWebView()
384+
_ = POPPopupBridge(
385+
webView: webView,
386+
webAuthenticationSession: mockWebAuthenticationSession,
387+
returnURLScheme: "my-app-scheme",
388+
application: mockURLOpener
389+
)
390+
391+
let userScript = webView.configuration.userContentController.userScripts[0]
392+
XCTAssertTrue(userScript.source.contains("return 'my-app-scheme://popupbridgev1/';"))
393+
}
394+
395+
func testUserScript_whenReturnURLSchemeProvidedButVenmoNotInstalled_advertisesCallbackScheme() {
396+
let mockURLOpener = MockURLOpener()
397+
mockURLOpener.venmoInstalled = false
398+
399+
let webView = WKWebView()
400+
_ = POPPopupBridge(
401+
webView: webView,
402+
webAuthenticationSession: mockWebAuthenticationSession,
403+
returnURLScheme: "my-app-scheme",
404+
application: mockURLOpener
405+
)
406+
407+
let userScript = webView.configuration.userContentController.userScripts[0]
408+
XCTAssertTrue(userScript.source.contains("return 'sdk.ios.popup-bridge://popupbridgev1/';"))
409+
}
410+
411+
func testUserScript_whenNoReturnURLSchemeAndVenmoInstalled_advertisesCallbackScheme() {
412+
let mockURLOpener = MockURLOpener()
413+
mockURLOpener.venmoInstalled = true
414+
415+
let webView = WKWebView()
416+
_ = POPPopupBridge(
417+
webView: webView,
418+
webAuthenticationSession: mockWebAuthenticationSession,
419+
application: mockURLOpener
420+
)
421+
422+
let userScript = webView.configuration.userContentController.userScripts[0]
423+
XCTAssertTrue(userScript.source.contains("return 'sdk.ios.popup-bridge://popupbridgev1/';"))
424+
}
425+
426+
func testConstructJavaScriptCompletionResult_whenReturnURLSchemeAndVenmoInstalled_acceptsMerchantSchemeReturn() {
427+
let mockURLOpener = MockURLOpener()
428+
mockURLOpener.venmoInstalled = true
429+
430+
let pub = POPPopupBridge(
431+
webView: WKWebView(),
432+
webAuthenticationSession: mockWebAuthenticationSession,
433+
returnURLScheme: "my-app-scheme",
434+
application: mockURLOpener
435+
)
436+
437+
// The return validation keys off the effective (merchant) scheme, so a merchant-scheme return
438+
// from the Venmo app is accepted and completed.
439+
let result = pub.constructJavaScriptCompletionResult(
440+
returnURL: URL(string: "my-app-scheme://popupbridgev1/return?token=abc")!
441+
)
442+
XCTAssertNotNil(result)
443+
XCTAssertTrue(result!.contains("window.popupBridge.onComplete(null,"))
444+
}
445+
446+
func testConstructJavaScriptCompletionResult_whenReturnURLSchemeAndVenmoInstalled_rejectsSDKSchemeReturn() {
447+
let mockURLOpener = MockURLOpener()
448+
mockURLOpener.venmoInstalled = true
449+
450+
let pub = POPPopupBridge(
451+
webView: WKWebView(),
452+
webAuthenticationSession: mockWebAuthenticationSession,
453+
returnURLScheme: "my-app-scheme",
454+
application: mockURLOpener
455+
)
456+
457+
// When the merchant scheme is the effective scheme, the SDK's internal scheme is no longer a
458+
// valid return for this instance.
459+
let result = pub.constructJavaScriptCompletionResult(
460+
returnURL: URL(string: "sdk.ios.popup-bridge://popupbridgev1/return")!
461+
)
462+
XCTAssertNil(result)
463+
}
376464
}

0 commit comments

Comments
 (0)