diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7b166be..d06c822 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,25 +2,25 @@ name: Build on: [pull_request, workflow_dispatch] jobs: cocoapods: - name: CocoaPods (Xcode 15.1) - runs-on: macOS-13 + name: CocoaPods (Xcode 16.2.0) + runs-on: macOS-15-xlarge steps: - name: Check out repository uses: actions/checkout@v2 - - name: Use Xcode 15.1 - run: sudo xcode-select -switch /Applications/Xcode_15.1.app + - name: Use Xcode 16.2.0 + run: sudo xcode-select -switch /Applications/Xcode_16.2.0.app - name: Install CocoaPod dependencies run: pod install - name: Run pod lib lint run: pod lib lint spm: - name: SPM (Xcode 15.1) - runs-on: macOS-13 + name: SPM (Xcode 16.2.0) + runs-on: macOS-15-xlarge steps: - name: Check out repository uses: actions/checkout@v2 - - name: Use Xcode 15.1 - run: sudo xcode-select -switch /Applications/Xcode_15.1.app + - name: Use Xcode 16.2.0 + run: sudo xcode-select -switch /Applications/Xcode_16.2.0.app - name: Use current branch run: sed -i '' 's/branch = .*/branch = \"'"$GITHUB_HEAD_REF"'\";/' SampleApps/SPMTest/SPMTest.xcodeproj/project.pbxproj - name: Run swift package resolve diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5c6c5d..9f95b5b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,15 +8,15 @@ on: jobs: release: name: Release - runs-on: macOS-13 + runs-on: macOS-15-xlarge steps: - name: Check out repository uses: actions/checkout@v2 with: fetch-depth: 0 - - name: Use Xcode 15.1 - run: sudo xcode-select -switch /Applications/Xcode_15.1.app + - name: Use Xcode 16.2.0 + run: sudo xcode-select -switch /Applications/Xcode_16.2.0.app - name: Check for unreleased section in changelog run: grep "## unreleased" CHANGELOG.md || (echo "::error::No unreleased section found in CHANGELOG"; exit 1) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bcfcbfd..af6d72d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,26 +2,26 @@ name: Tests on: [pull_request, workflow_dispatch] jobs: unit_test_job: - name: Unit (Xcode 15.1) - runs-on: macOS-13 + name: Unit (Xcode 16.2.0) + runs-on: macOS-15-xlarge steps: - name: Checkout repository uses: actions/checkout@v2 - - name: Use Xcode 15.1 - run: sudo xcode-select -switch /Applications/Xcode_15.1.app + - name: Use Xcode 16.2.0 + run: sudo xcode-select -switch /Applications/Xcode_16.2.0.app - name: Install CocoaPod dependencies run: pod install - name: Run Unit Tests - run: set -o pipefail && xcodebuild -workspace 'PopupBridge.xcworkspace' -sdk 'iphonesimulator' -configuration 'Debug' -scheme 'UnitTests' -destination 'name=iPhone 14,platform=iOS Simulator' test | ./Pods/xcbeautify/xcbeautify + run: set -o pipefail && xcodebuild -workspace 'PopupBridge.xcworkspace' -sdk 'iphonesimulator' -configuration 'Debug' -scheme 'UnitTests' -destination 'name=iPhone 16,platform=iOS Simulator' test | ./Pods/xcbeautify/xcbeautify ui_test_job: - name: UI (Xcode 15.1) - runs-on: macOS-13 + name: UI (Xcode 16.2.0) + runs-on: macOS-15-xlarge steps: - name: Checkout repository uses: actions/checkout@v2 - - name: Use Xcode 15.1 - run: sudo xcode-select -switch /Applications/Xcode_15.1.app + - name: Use Xcode 16.2.0 + run: sudo xcode-select -switch /Applications/Xcode_16.2.0.app - name: Install CocoaPod dependencies run: pod install - name: Run UI Tests - run: set -o pipefail && xcodebuild -workspace 'PopupBridge.xcworkspace' -sdk 'iphonesimulator' -configuration 'Release' -scheme 'UITests' -destination 'name=iPhone 14,platform=iOS Simulator' test | ./Pods/xcbeautify/xcbeautify + run: set -o pipefail && xcodebuild -workspace 'PopupBridge.xcworkspace' -sdk 'iphonesimulator' -configuration 'Release' -scheme 'UITests' -destination 'name=iPhone 16,platform=iOS Simulator' test | ./Pods/xcbeautify/xcbeautify diff --git a/CHANGELOG.md b/CHANGELOG.md index fea5f34..1cbc04f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # PopupBridge iOS Release Notes +## unreleased +* Breaking Changes + * Bump minimum supported deployment target to iOS 16+ + * Require Xcode 16.2.0+ and Swift 5.10+ +* Add the `prefersEphemeralWebBrowserSession` property to the `POPPopupBridge` initializer, which specifies whether to request a private authentication session from the browser. +* Add validation to check if the Venmo app is installed on the device. + ## 2.2.0 (2025-02-05) * Require Xcode 15.0+ and Swift 5.9+ (per [App Store requirements](https://developer.apple.com/news/?id=khzvxn8a)) diff --git a/Demo/Demo.xcodeproj/project.pbxproj b/Demo/Demo.xcodeproj/project.pbxproj index ec6dfb6..f033cc3 100644 --- a/Demo/Demo.xcodeproj/project.pbxproj +++ b/Demo/Demo.xcodeproj/project.pbxproj @@ -7,14 +7,12 @@ objects = { /* Begin PBXBuildFile section */ + 45150AC32D4D48670071E385 /* PopupBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45150AC22D4D48670071E385 /* PopupBridgeViewController.swift */; }; + 45150B0C2D4D56F00071E385 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45150B0B2D4D56F00071E385 /* AppDelegate.swift */; }; + 45150B0E2D4D572D0071E385 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45150B0D2D4D572D0071E385 /* SceneDelegate.swift */; }; 8018D88525B7891E00D877F3 /* PopupBridge.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8018D88425B7891E00D877F3 /* PopupBridge.framework */; }; 8018D88625B7891E00D877F3 /* PopupBridge.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 8018D88425B7891E00D877F3 /* PopupBridge.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 806D558625AFD733005ED326 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 806D558525AFD733005ED326 /* AppDelegate.m */; }; - 806D558925AFD733005ED326 /* SceneDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 806D558825AFD733005ED326 /* SceneDelegate.m */; }; - 806D558C25AFD733005ED326 /* POPViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 806D558B25AFD733005ED326 /* POPViewController.m */; }; - 806D558F25AFD733005ED326 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 806D558D25AFD733005ED326 /* Main.storyboard */; }; 806D559125AFD735005ED326 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 806D559025AFD735005ED326 /* Assets.xcassets */; }; - 806D559725AFD735005ED326 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 806D559625AFD735005ED326 /* main.m */; }; BEF9ED1D2A2A29B5005D54AB /* PopupBridge_DemoUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEF9ED1C2A2A29B5005D54AB /* PopupBridge_DemoUITests.swift */; }; /* End PBXBuildFile section */ @@ -43,18 +41,13 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 45150AC22D4D48670071E385 /* PopupBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupBridgeViewController.swift; sourceTree = ""; }; + 45150B0B2D4D56F00071E385 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 45150B0D2D4D572D0071E385 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 8018D88425B7891E00D877F3 /* PopupBridge.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = PopupBridge.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 806D558125AFD733005ED326 /* Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Demo.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 806D558425AFD733005ED326 /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; - 806D558525AFD733005ED326 /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; - 806D558725AFD733005ED326 /* SceneDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SceneDelegate.h; sourceTree = ""; }; - 806D558825AFD733005ED326 /* SceneDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SceneDelegate.m; sourceTree = ""; }; - 806D558A25AFD733005ED326 /* POPViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = POPViewController.h; sourceTree = ""; }; - 806D558B25AFD733005ED326 /* POPViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = POPViewController.m; sourceTree = ""; }; - 806D558E25AFD733005ED326 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 806D559025AFD735005ED326 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 806D559525AFD735005ED326 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 806D559625AFD735005ED326 /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; 806D55B425AFD877005ED326 /* UITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 806D55B825AFD877005ED326 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; BEF9ED1C2A2A29B5005D54AB /* PopupBridge_DemoUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupBridge_DemoUITests.swift; sourceTree = ""; }; @@ -101,16 +94,11 @@ 806D558325AFD733005ED326 /* Demo */ = { isa = PBXGroup; children = ( - 806D558425AFD733005ED326 /* AppDelegate.h */, - 806D558525AFD733005ED326 /* AppDelegate.m */, - 806D558725AFD733005ED326 /* SceneDelegate.h */, - 806D558825AFD733005ED326 /* SceneDelegate.m */, - 806D558A25AFD733005ED326 /* POPViewController.h */, - 806D558B25AFD733005ED326 /* POPViewController.m */, - 806D558D25AFD733005ED326 /* Main.storyboard */, + 45150AC22D4D48670071E385 /* PopupBridgeViewController.swift */, + 45150B0B2D4D56F00071E385 /* AppDelegate.swift */, 806D559025AFD735005ED326 /* Assets.xcassets */, 806D559525AFD735005ED326 /* Info.plist */, - 806D559625AFD735005ED326 /* main.m */, + 45150B0D2D4D572D0071E385 /* SceneDelegate.swift */, ); path = Demo; sourceTree = ""; @@ -181,6 +169,7 @@ TargetAttributes = { 806D558025AFD733005ED326 = { CreatedOnToolsVersion = 12.3; + LastSwiftMigration = 1620; }; 806D55B325AFD877005ED326 = { CreatedOnToolsVersion = 12.3; @@ -214,7 +203,6 @@ buildActionMask = 2147483647; files = ( 806D559125AFD735005ED326 /* Assets.xcassets in Resources */, - 806D558F25AFD733005ED326 /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -232,10 +220,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 806D558C25AFD733005ED326 /* POPViewController.m in Sources */, - 806D558625AFD733005ED326 /* AppDelegate.m in Sources */, - 806D559725AFD735005ED326 /* main.m in Sources */, - 806D558925AFD733005ED326 /* SceneDelegate.m in Sources */, + 45150AC32D4D48670071E385 /* PopupBridgeViewController.swift in Sources */, + 45150B0C2D4D56F00071E385 /* AppDelegate.swift in Sources */, + 45150B0E2D4D572D0071E385 /* SceneDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -257,17 +244,6 @@ }; /* End PBXTargetDependency section */ -/* Begin PBXVariantGroup section */ - 806D558D25AFD733005ED326 /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 806D558E25AFD733005ED326 /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - /* Begin XCBuildConfiguration section */ 806D559825AFD735005ED326 /* Debug */ = { isa = XCBuildConfiguration; @@ -320,7 +296,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.3; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -373,7 +349,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.3; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -386,15 +362,19 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 43253H4X22; INFOPLIST_FILE = Demo/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.braintreepayments.Demo; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -404,15 +384,18 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 43253H4X22; INFOPLIST_FILE = Demo/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.braintreepayments.Demo; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; diff --git a/Demo/Demo/AppDelegate.h b/Demo/Demo/AppDelegate.h deleted file mode 100644 index a5a8b38..0000000 --- a/Demo/Demo/AppDelegate.h +++ /dev/null @@ -1,7 +0,0 @@ -#import - -@interface AppDelegate : UIResponder - -@property (strong, nonatomic) UIWindow *window; - -@end diff --git a/Demo/Demo/AppDelegate.m b/Demo/Demo/AppDelegate.m deleted file mode 100644 index ae9c511..0000000 --- a/Demo/Demo/AppDelegate.m +++ /dev/null @@ -1,33 +0,0 @@ -#import "AppDelegate.h" -#import - -@interface AppDelegate () - -@end - -@implementation AppDelegate - - -- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { - return YES; -} - - -#pragma mark - UISceneSession lifecycle - - -- (UISceneConfiguration *)application:(UIApplication *)application configurationForConnectingSceneSession:(UISceneSession *)connectingSceneSession options:(UISceneConnectionOptions *)options { - // Called when a new scene session is being created. - // Use this method to select a configuration to create the new scene with. - return [[UISceneConfiguration alloc] initWithName:@"Default Configuration" sessionRole:connectingSceneSession.role]; -} - - -- (void)application:(UIApplication *)application didDiscardSceneSessions:(NSSet *)sceneSessions { - // Called when the user discards a scene session. - // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. - // Use this method to release any resources that were specific to the discarded scenes, as they will not return. -} - - -@end diff --git a/Demo/Demo/AppDelegate.swift b/Demo/Demo/AppDelegate.swift new file mode 100644 index 0000000..461eecd --- /dev/null +++ b/Demo/Demo/AppDelegate.swift @@ -0,0 +1,24 @@ +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + // Called when the user discards a scene session. + // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. + // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + } +} diff --git a/Demo/Demo/Base.lproj/Main.storyboard b/Demo/Demo/Base.lproj/Main.storyboard deleted file mode 100644 index c1c8012..0000000 --- a/Demo/Demo/Base.lproj/Main.storyboard +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Demo/Demo/Info.plist b/Demo/Demo/Info.plist index 9001e28..002b07d 100644 --- a/Demo/Demo/Info.plist +++ b/Demo/Demo/Info.plist @@ -32,9 +32,7 @@ UISceneConfigurationName Default Configuration UISceneDelegateClassName - SceneDelegate - UISceneStoryboardFile - Main + $(PRODUCT_MODULE_NAME).SceneDelegate @@ -43,8 +41,6 @@ UILaunchStoryboardName LaunchScreen - UIMainStoryboardFile - Main UIRequiredDeviceCapabilities armv7 @@ -62,5 +58,9 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + LSApplicationQueriesSchemes + + com.venmo.touch.v2 + diff --git a/Demo/Demo/POPViewController.h b/Demo/Demo/POPViewController.h deleted file mode 100644 index 39f7615..0000000 --- a/Demo/Demo/POPViewController.h +++ /dev/null @@ -1,5 +0,0 @@ -@import UIKit; - -@interface POPViewController : UIViewController - -@end diff --git a/Demo/Demo/POPViewController.m b/Demo/Demo/POPViewController.m deleted file mode 100644 index b099b48..0000000 --- a/Demo/Demo/POPViewController.m +++ /dev/null @@ -1,34 +0,0 @@ -#import "POPViewController.h" -#import -#import - -@interface POPViewController () -@property (nonatomic, strong) WKWebView *webView; -@property (nonatomic, strong) POPPopupBridge *popupBridge; -@property (nonatomic, strong) UIActivityIndicatorView *activityIndicatorView; -@end - -@implementation POPViewController - -- (void)viewDidLoad -{ - [super viewDidLoad]; - - self.webView = [[WKWebView alloc] initWithFrame:CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height)]; - - // 2. Create Popup Bridge. - self.popupBridge = [[POPPopupBridge alloc] initWithWebView:self.webView]; - - [self.view addSubview:self.webView]; - [self.webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"https://braintree.github.io/popup-bridge-example/"]]]; -} - -- (void)popupBridge:(POPPopupBridge *)bridge requestsPresentationOfViewController:(UIViewController *)viewController { - [self presentViewController:viewController animated:YES completion:nil]; -} - -- (void)popupBridge:(POPPopupBridge *)bridge requestsDismissalOfViewController:(UIViewController *)viewController { - [viewController dismissViewControllerAnimated:YES completion:nil]; -} - -@end diff --git a/Demo/Demo/PopupBridgeViewController.swift b/Demo/Demo/PopupBridgeViewController.swift new file mode 100644 index 0000000..cc5c745 --- /dev/null +++ b/Demo/Demo/PopupBridgeViewController.swift @@ -0,0 +1,35 @@ +import PopupBridge +import UIKit +import WebKit + +final class PopupBridgeViewController: UIViewController { + + // MARK: - Private Properties + + private let webView = WKWebView() + + private var popupBridge: POPPopupBridge? + + // MARK: - Internal Methods + + override func viewDidLoad() { + super.viewDidLoad() + setupConstraints() + popupBridge = POPPopupBridge(webView: webView) + webView.load(URLRequest(url: URL(string: "https://braintree.github.io/popup-bridge-example/")!)) + } + + // MARK: - Private Methods + + private func setupConstraints() { + view.addSubview(webView) + webView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: view.topAnchor), + webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } +} diff --git a/Demo/Demo/SceneDelegate.h b/Demo/Demo/SceneDelegate.h deleted file mode 100644 index 9d3a8ba..0000000 --- a/Demo/Demo/SceneDelegate.h +++ /dev/null @@ -1,7 +0,0 @@ -#import - -@interface SceneDelegate : UIResponder - -@property (strong, nonatomic) UIWindow * window; - -@end diff --git a/Demo/Demo/SceneDelegate.m b/Demo/Demo/SceneDelegate.m deleted file mode 100644 index 1f8d4c6..0000000 --- a/Demo/Demo/SceneDelegate.m +++ /dev/null @@ -1,50 +0,0 @@ -#import "SceneDelegate.h" -#import - -@interface SceneDelegate () - -@end - -@implementation SceneDelegate - -- (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)connectionOptions { - // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. - // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. - // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). -} - - -- (void)sceneDidDisconnect:(UIScene *)scene { - // Called as the scene is being released by the system. - // This occurs shortly after the scene enters the background, or when its session is discarded. - // Release any resources associated with this scene that can be re-created the next time the scene connects. - // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). -} - - -- (void)sceneDidBecomeActive:(UIScene *)scene { - // Called when the scene has moved from an inactive state to an active state. - // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. -} - - -- (void)sceneWillResignActive:(UIScene *)scene { - // Called when the scene will move from an active state to an inactive state. - // This may occur due to temporary interruptions (ex. an incoming phone call). -} - - -- (void)sceneWillEnterForeground:(UIScene *)scene { - // Called as the scene transitions from the background to the foreground. - // Use this method to undo the changes made on entering the background. -} - - -- (void)sceneDidEnterBackground:(UIScene *)scene { - // Called as the scene transitions from the foreground to the background. - // Use this method to save data, release shared resources, and store enough scene-specific state information - // to restore the scene back to its current state. -} - - -@end diff --git a/Demo/Demo/SceneDelegate.swift b/Demo/Demo/SceneDelegate.swift new file mode 100644 index 0000000..fec4473 --- /dev/null +++ b/Demo/Demo/SceneDelegate.swift @@ -0,0 +1,45 @@ +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. + // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. + // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). + guard let windowScene = (scene as? UIWindowScene) else { return } + + window = UIWindow(windowScene: windowScene) + window?.rootViewController = PopupBridgeViewController() + window?.makeKeyAndVisible() + } + + func sceneDidDisconnect(_ scene: UIScene) { + // Called as the scene is being released by the system. + // This occurs shortly after the scene enters the background, or when its session is discarded. + // Release any resources associated with this scene that can be re-created the next time the scene connects. + // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // Called when the scene has moved from an inactive state to an active state. + // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + } + + func sceneWillResignActive(_ scene: UIScene) { + // Called when the scene will move from an active state to an inactive state. + // This may occur due to temporary interruptions (ex. an incoming phone call). + } + + func sceneWillEnterForeground(_ scene: UIScene) { + // Called as the scene transitions from the background to the foreground. + // Use this method to undo the changes made on entering the background. + } + + func sceneDidEnterBackground(_ scene: UIScene) { + // Called as the scene transitions from the foreground to the background. + // Use this method to save data, release shared resources, and store enough scene-specific state information + // to restore the scene back to its current state. + } +} diff --git a/Demo/Demo/main.m b/Demo/Demo/main.m deleted file mode 100644 index dba295e..0000000 --- a/Demo/Demo/main.m +++ /dev/null @@ -1,11 +0,0 @@ -#import -#import "AppDelegate.h" - -int main(int argc, char * argv[]) { - NSString * appDelegateClassName; - @autoreleasepool { - // Setup code that might create autoreleased objects goes here. - appDelegateClassName = NSStringFromClass([AppDelegate class]); - } - return UIApplicationMain(argc, argv, nil, appDelegateClassName); -} diff --git a/Demo/UITests/PopupBridge_DemoUITests.swift b/Demo/UITests/PopupBridge_DemoUITests.swift index ed084c2..f640f18 100644 --- a/Demo/UITests/PopupBridge_DemoUITests.swift +++ b/Demo/UITests/PopupBridge_DemoUITests.swift @@ -36,7 +36,7 @@ final class PopupBridge_DemoUITests: XCTestCase { waitForElement(text, timeout: 10) doNotLikeLink.tap() - waitForElement(app.staticTexts["You did not like any of our colors"], timeout: 10) + waitForElement(app.staticTexts["You did not like any of our colors"]) } func testClickingSafariCancel_returnsCancel() { @@ -51,12 +51,12 @@ final class PopupBridge_DemoUITests: XCTestCase { waitForElement(cancelButton) cancelButton.tap() - waitForElement(app.staticTexts["You did not choose a color"], timeout: 10) + waitForElement(app.staticTexts["You did not choose a color"]) } // MARK: - Helpers - func waitForElement(_ element: XCUIElement, timeout: TimeInterval = 5) { + func waitForElement(_ element: XCUIElement, timeout: TimeInterval = 15) { expectation(for: NSPredicate(format: "exists ==1"), evaluatedWith: element) waitForExpectations(timeout: timeout) } diff --git a/Package.swift b/Package.swift index d4612be..c17ddac 100644 --- a/Package.swift +++ b/Package.swift @@ -1,10 +1,10 @@ -// swift-tools-version:5.9 +// swift-tools-version:5.10 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "PopupBridge", - platforms: [.iOS(.v14)], + platforms: [.iOS(.v16)], products: [ .library( name: "PopupBridge", diff --git a/Podfile b/Podfile index 6e4148a..d93d78e 100644 --- a/Podfile +++ b/Podfile @@ -20,4 +20,4 @@ post_install do |installer| end end end -end \ No newline at end of file +end diff --git a/Podfile.lock b/Podfile.lock index 2d487da..0e549a7 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -11,6 +11,6 @@ SPEC REPOS: SPEC CHECKSUMS: xcbeautify: df17aa32d769add7af523a8be9b0ef8fb9eb75a8 -PODFILE CHECKSUM: 024901ddc2333bc0d6dc62af1768885f98b22da1 +PODFILE CHECKSUM: f7e2246cbe0f5ad54fc1270994bd0f76a5ed948d -COCOAPODS: 1.12.1 +COCOAPODS: 1.16.2 diff --git a/PopupBridge.podspec b/PopupBridge.podspec index 7e84a80..18afc01 100644 --- a/PopupBridge.podspec +++ b/PopupBridge.podspec @@ -18,8 +18,8 @@ Use cases for PopupBridge: s.author = { 'Braintree' => 'code@getbraintree.com' } s.source = { :git => 'https://github.com/braintree/popup-bridge-ios.git', :tag => s.version.to_s } - s.ios.deployment_target = '14.0' - s.swift_version = "5.9" + s.ios.deployment_target = '16.0' + s.swift_version = "5.10" s.source_files = 'Sources/PopupBridge/**/*.swift' s.resource_bundle = { "PopupBridge_PrivacyInfo" => "Sources/PopupBridge/PrivacyInfo.xcprivacy" } diff --git a/PopupBridge.xcodeproj/project.pbxproj b/PopupBridge.xcodeproj/project.pbxproj index 69af621..fa4b373 100644 --- a/PopupBridge.xcodeproj/project.pbxproj +++ b/PopupBridge.xcodeproj/project.pbxproj @@ -7,6 +7,19 @@ objects = { /* Begin PBXBuildFile section */ + 451348272D8CAD7C009265C9 /* WebViewScriptHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451348262D8CAD6D009265C9 /* WebViewScriptHandler.swift */; }; + 4513482B2D8DDA25009265C9 /* XCTestCase+trackForMemoryLeaks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4513482A2D8DDA17009265C9 /* XCTestCase+trackForMemoryLeaks.swift */; }; + 45AE41D22D7649F800388548 /* MockAnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45AE41D12D7649EE00388548 /* MockAnalyticsService.swift */; }; + 45CD0C2C2D64F08F0072C5A4 /* FPTIBatchData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45CD0C2B2D64F0810072C5A4 /* FPTIBatchData.swift */; }; + 45CD0C2E2D664D140072C5A4 /* Date+MilisecondTimestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45CD0C2D2D664CF50072C5A4 /* Date+MilisecondTimestamp.swift */; }; + 45CD0C302D6788A10072C5A4 /* Bundle+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45CD0C2F2D67888F0072C5A4 /* Bundle+Extension.swift */; }; + 45CD0C322D6793FB0072C5A4 /* PopupBridgeAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45CD0C312D6793F90072C5A4 /* PopupBridgeAnalytics.swift */; }; + 45FBAF272D6F9CCF000D550B /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45FBAF262D6F9CC6000D550B /* AnalyticsService.swift */; }; + 45FBAF292D701AFE000D550B /* Sessionable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45FBAF282D701AF7000D550B /* Sessionable.swift */; }; + 45FBAF2B2D701B12000D550B /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45FBAF2A2D701B0D000D550B /* NetworkError.swift */; }; + 45FBAF2F2D701E24000D550B /* MockSessionable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45FBAF2E2D701E1D000D550B /* MockSessionable.swift */; }; + 45FBAF352D70CFB6000D550B /* AnalyticsService_Test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45FBAF342D70CFB6000D550B /* AnalyticsService_Test.swift */; }; + 45FC74C22D8347B500E50035 /* UIApplication+URLOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45FC74C12D8347AA00E50035 /* UIApplication+URLOpener.swift */; }; 62D5EC522B9F753100D09C5D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 62D5EC512B9F753100D09C5D /* PrivacyInfo.xcprivacy */; }; 79DB9F7F53319F206CDE119E /* Pods_UnitTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 28245E4F1AC5126D54985D88 /* Pods_UnitTests.framework */; }; 800A09D82995F143003ED16E /* POPPopupBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800A09D72995F143003ED16E /* POPPopupBridge.swift */; }; @@ -26,6 +39,19 @@ /* Begin PBXFileReference section */ 28245E4F1AC5126D54985D88 /* Pods_UnitTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_UnitTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 451348262D8CAD6D009265C9 /* WebViewScriptHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewScriptHandler.swift; sourceTree = ""; }; + 4513482A2D8DDA17009265C9 /* XCTestCase+trackForMemoryLeaks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+trackForMemoryLeaks.swift"; sourceTree = ""; }; + 45AE41D12D7649EE00388548 /* MockAnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAnalyticsService.swift; sourceTree = ""; }; + 45CD0C2B2D64F0810072C5A4 /* FPTIBatchData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FPTIBatchData.swift; sourceTree = ""; }; + 45CD0C2D2D664CF50072C5A4 /* Date+MilisecondTimestamp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+MilisecondTimestamp.swift"; sourceTree = ""; }; + 45CD0C2F2D67888F0072C5A4 /* Bundle+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Extension.swift"; sourceTree = ""; }; + 45CD0C312D6793F90072C5A4 /* PopupBridgeAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupBridgeAnalytics.swift; sourceTree = ""; }; + 45FBAF262D6F9CC6000D550B /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = ""; }; + 45FBAF282D701AF7000D550B /* Sessionable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sessionable.swift; sourceTree = ""; }; + 45FBAF2A2D701B0D000D550B /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = ""; }; + 45FBAF2E2D701E1D000D550B /* MockSessionable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSessionable.swift; sourceTree = ""; }; + 45FBAF342D70CFB6000D550B /* AnalyticsService_Test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService_Test.swift; sourceTree = ""; }; + 45FC74C12D8347AA00E50035 /* UIApplication+URLOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+URLOpener.swift"; sourceTree = ""; }; 4EF7C7DDAB0B99FF28DD6541 /* Pods-UnitTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-UnitTests.debug.xcconfig"; path = "Target Support Files/Pods-UnitTests/Pods-UnitTests.debug.xcconfig"; sourceTree = ""; }; 6003F58D195388D20070C39A /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; 6003F58F195388D20070C39A /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; }; @@ -86,6 +112,16 @@ path = Pods; sourceTree = ""; }; + 45FC74C32D84922F00E50035 /* Analytics */ = { + isa = PBXGroup; + children = ( + 45FBAF262D6F9CC6000D550B /* AnalyticsService.swift */, + 45CD0C2B2D64F0810072C5A4 /* FPTIBatchData.swift */, + 45CD0C312D6793F90072C5A4 /* PopupBridgeAnalytics.swift */, + ); + path = Analytics; + sourceTree = ""; + }; 6003F581195388D10070C39A = { isa = PBXGroup; children = ( @@ -143,11 +179,15 @@ A775A07D1DEE4E7E009E67C2 /* UnitTests */ = { isa = PBXGroup; children = ( + 45FBAF342D70CFB6000D550B /* AnalyticsService_Test.swift */, A775A0801DEE4E7E009E67C2 /* Info.plist */, + 45AE41D12D7649EE00388548 /* MockAnalyticsService.swift */, BE2524552A17FB9F00168D77 /* MockScriptMessage.swift */, + 45FBAF2E2D701E1D000D550B /* MockSessionable.swift */, BE2524532A17DFCC00168D77 /* MockUserContentController.swift */, BEF9ED202A2A4896005D54AB /* MockWebAuthenticationSession.swift */, BE2524512A17DF8200168D77 /* PopupBridge_UnitTests.swift */, + 4513482A2D8DDA17009265C9 /* XCTestCase+trackForMemoryLeaks.swift */, ); path = UnitTests; sourceTree = ""; @@ -155,14 +195,21 @@ A775A08C1DEE4EF0009E67C2 /* PopupBridge */ = { isa = PBXGroup; children = ( + 45FC74C32D84922F00E50035 /* Analytics */, + 45CD0C2F2D67888F0072C5A4 /* Bundle+Extension.swift */, + 45CD0C2D2D664CF50072C5A4 /* Date+MilisecondTimestamp.swift */, + 45FBAF2A2D701B0D000D550B /* NetworkError.swift */, 800A09D72995F143003ED16E /* POPPopupBridge.swift */, A79330F01DF0F98F00EE479D /* PopupBridge-Framework-Info.plist */, + BEF9ED222A2A6A2C005D54AB /* PopupBridgeConstants.swift */, 8079D7192996F6C200A2E336 /* PopupBridgeUserScript.swift */, + 62D5EC512B9F753100D09C5D /* PrivacyInfo.xcprivacy */, + 45FBAF282D701AF7000D550B /* Sessionable.swift */, + 45FC74C12D8347AA00E50035 /* UIApplication+URLOpener.swift */, 800E789E29E09A2A00D1B0FC /* URLDetailsPayload.swift */, BE8E37B52A17B79E00181FDA /* WebAuthenticationSession.swift */, 800E789C29E0958A00D1B0FC /* WebViewMessage.swift */, - BEF9ED222A2A6A2C005D54AB /* PopupBridgeConstants.swift */, - 62D5EC512B9F753100D09C5D /* PrivacyInfo.xcprivacy */, + 451348262D8CAD6D009265C9 /* WebViewScriptHandler.swift */, ); path = PopupBridge; sourceTree = ""; @@ -305,10 +352,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 45FBAF2F2D701E24000D550B /* MockSessionable.swift in Sources */, BE2524542A17DFCC00168D77 /* MockUserContentController.swift in Sources */, + 4513482B2D8DDA25009265C9 /* XCTestCase+trackForMemoryLeaks.swift in Sources */, + 45AE41D22D7649F800388548 /* MockAnalyticsService.swift in Sources */, BE2524562A17FB9F00168D77 /* MockScriptMessage.swift in Sources */, BEF9ED212A2A4896005D54AB /* MockWebAuthenticationSession.swift in Sources */, BE2524522A17DF8200168D77 /* PopupBridge_UnitTests.swift in Sources */, + 45FBAF352D70CFB6000D550B /* AnalyticsService_Test.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -316,10 +367,19 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 45FBAF292D701AFE000D550B /* Sessionable.swift in Sources */, + 45FBAF2B2D701B12000D550B /* NetworkError.swift in Sources */, 800E789F29E09A2A00D1B0FC /* URLDetailsPayload.swift in Sources */, + 45CD0C302D6788A10072C5A4 /* Bundle+Extension.swift in Sources */, + 45FBAF272D6F9CCF000D550B /* AnalyticsService.swift in Sources */, BEF9ED232A2A6A2C005D54AB /* PopupBridgeConstants.swift in Sources */, + 451348272D8CAD7C009265C9 /* WebViewScriptHandler.swift in Sources */, + 45FC74C22D8347B500E50035 /* UIApplication+URLOpener.swift in Sources */, 800A09D82995F143003ED16E /* POPPopupBridge.swift in Sources */, BE8E37B62A17B79E00181FDA /* WebAuthenticationSession.swift in Sources */, + 45CD0C2C2D64F08F0072C5A4 /* FPTIBatchData.swift in Sources */, + 45CD0C322D6793FB0072C5A4 /* PopupBridgeAnalytics.swift in Sources */, + 45CD0C2E2D664D140072C5A4 /* Date+MilisecondTimestamp.swift in Sources */, 800E789D29E0958A00D1B0FC /* WebViewMessage.swift in Sources */, 8079D71A2996F6C200A2E336 /* PopupBridgeUserScript.swift in Sources */, ); @@ -376,7 +436,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; @@ -424,7 +484,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; @@ -445,7 +505,7 @@ ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_NO_COMMON_BLOCKS = YES; INFOPLIST_FILE = UnitTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_ENABLE_DEBUG_INFO = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.braintreepayments.PopupBridge-Tests"; @@ -471,7 +531,7 @@ ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_NO_COMMON_BLOCKS = YES; INFOPLIST_FILE = UnitTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_BUNDLE_IDENTIFIER = "com.braintreepayments.PopupBridge-Tests"; @@ -501,7 +561,7 @@ GCC_NO_COMMON_BLOCKS = YES; INFOPLIST_FILE = "$(SRCROOT)/Sources/PopupBridge/PopupBridge-Framework-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_ENABLE_DEBUG_INFO = YES; PRODUCT_BUNDLE_IDENTIFIER = com.braintreepayments.PopupBridge; @@ -537,7 +597,7 @@ GCC_NO_COMMON_BLOCKS = YES; INFOPLIST_FILE = "$(SRCROOT)/Sources/PopupBridge/PopupBridge-Framework-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_BUNDLE_IDENTIFIER = com.braintreepayments.PopupBridge; diff --git a/README.md b/README.md index beb6ebe..887696a 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ See the [Frequently Asked Questions](#frequently-asked-questions) to learn more Requirements ------------ -- iOS 14.0+ -- Xcode 15.0+ -- Swift 5.9+, or Objective-C +- iOS 16.0+ +- Xcode 16.2.0+ +- Swift 5.10+, or Objective-C Installation ------------ @@ -37,11 +37,28 @@ To integrate using Swift Package Manager, select File > Swift Packages > Add Pac If you look at your app target, you will see that `PopupBridge` is automatically linked as a framework to your app (see General > Frameworks, Libraries, and Embedded Content). +### Allowlist Venmo URL scheme (Venmo integrations only) +You must add the following to the queries schemes allowlist in your app's info.plist: + +``` xml +LSApplicationQueriesSchemes + + com.venmo.touch.v2 + +``` + Sample App ------- To run the sample app, clone the repo, open `PopupBridge.xcworkspace` and run the `Demo` app target. +Supported Payment Methods +------- + +- [PayPal SDK (v5 only, v6+ not currently supported)](https://developer.paypal.com/sdk/js/configuration/) +- [PayPal (via Braintree)](https://developer.paypal.com/braintree/docs/guides/paypal/overview) +- [Venmo (via Braintree)](https://developer.paypal.com/braintree/docs/guides/venmo/overview) + Quick Start ----------- @@ -66,65 +83,151 @@ Quick Start } } ``` + +PayPal Example +-------------- + +```html + + + + + + + + + + +
+ +``` -1. Use PopupBridge from the web page by writing some JavaScript: - - ```javascript - var url = 'http://localhost:3099/popup'; // or whatever the page is that you want to open in a popup +```js +// From https://developer.paypal.com/braintree/docs/guides/paypal/client-side/javascript/v3/ + +if (!window.popupBridge) { + throw new Error("Popup Bridge is is not installed!"); +} + +// Create a client. +braintree.client.create({ + authorization: CLIENT_AUTHORIZATION +}).then(function (clientInstance) { + // Create a PayPal Checkout component. + return braintree.paypalCheckout.create({ + client: clientInstance + }); +}).then(function (paypalCheckoutInstance) { + // Load the PayPal JS SDK (see Load the PayPal JS SDK section) + paypalCheckoutInstance.loadPayPalSDK().then(function () { + // The PayPal script is now loaded on the page and + // window.paypal.Buttons is now available to use + + // render the PayPal button (see Render the PayPal Button section) + }); +}).catch(function (err) { + // Handle component creation error +}); +``` - if (window.popupBridge) { - // Open the popup in a browser, and give it the deep link back to the app - popupBridge.open(url + '?popupBridgeReturnUrlPrefix=' + popupBridge.getReturnUrlPrefix()); +Venmo Example +------------- - // Optional: define a callback to process results of interaction with the popup - popupBridge.onComplete = function (err, payload) { - if (err) { - console.error('PopupBridge onComplete Error:', err); - } else if (!err && !payload) { - console.log('User closed popup.'); - } else { - alert('Your favorite color is ' + payload.queryItems.color); - } - }; - } else { - var popup = window.open(url); +```html + - window.addEventListener('message', function (event) { - var color = JSON.parse(event.data).color; + + + + + - if (color) { - popup.close(); - alert('Your favorite color is ' + color); - } - }); - } - ``` + +
+ +``` -1. Redirect back to the app inside of the popup: +```js +// From https://developer.paypal.com/braintree/docs/guides/venmo/client-side/javascript/v3/ - ```html -

What is your favorite color?

+if (!window.popupBridge) { + throw new Error("Popup Bridge is is not installed!"); +} else { + // If using PopupBridge, set the deepLinkReturnUrl so that the Venmo app + // knows where to return after authorization. This ensures the app resumes + // correctly from the WebView. + // Note: When integrating with Venmo via WebView and PopupBridge, it's important to set the deepLinkReturnUrl + // so the user is redirected back to the correct context in your app after completing the flow in the Venmo app. + createOptions.deepLinkReturnUrl = window.popupBridge.getReturnUrlPrefix(); - Red - Green - Blue + window.popupBridge.onComplete = (err, payload) => { + console.log('Popup Bridge completed'); - - - ``` + }); +} +function handleVenmoError(err) { + // ... +} + +function handleVenmoSuccess(payload) { + // ... +} +``` + Frequently Asked Questions -------------------------- @@ -182,7 +285,8 @@ This SDK abides by our Client SDK Deprecation Policy. For more information on th | Major version number | Status | Released | Deprecated | Unsupported | | -------------------- | ------ | -------- | ---------- | ----------- | -| 2.x.x | Active | October 2023 | TBA | TBA | +| 3.x.x | Active | April 2025 | TBA | TBA | +| 2.x.x | Inactive | October 2023 | April 2025 | April 2026 | | 1.x.x | Inactive | 2016 | October 2024 | October 2025 | ## Author diff --git a/SampleApps/SPMTest/SPMTest.xcodeproj/project.pbxproj b/SampleApps/SPMTest/SPMTest.xcodeproj/project.pbxproj index b5429bd..75cd51f 100644 --- a/SampleApps/SPMTest/SPMTest.xcodeproj/project.pbxproj +++ b/SampleApps/SPMTest/SPMTest.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 52; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -212,7 +212,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.3; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -267,7 +267,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.3; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -285,6 +285,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 43253H4X22; INFOPLIST_FILE = SPMTest/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -304,6 +305,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 43253H4X22; INFOPLIST_FILE = SPMTest/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Sources/PopupBridge/Analytics/AnalyticsService.swift b/Sources/PopupBridge/Analytics/AnalyticsService.swift new file mode 100644 index 0000000..c5ad439 --- /dev/null +++ b/Sources/PopupBridge/Analytics/AnalyticsService.swift @@ -0,0 +1,62 @@ +import Foundation + +protocol AnalyticsServiceable { + func sendAnalyticsEvent(_ eventName: String, sessionID: String) +} + +final class AnalyticsService: AnalyticsServiceable { + + // MARK: - Private Properties + + /// The FPTI URL to post all analytic events. + private let url = URL(string: "https://api.paypal.com/v1/tracking/batch/events")! + private let session: Sessionable + + // MARK: - Initializer + + init(session: Sessionable = URLSession.shared) { + self.session = session + } + + // MARK: - Internal Methods + + func sendAnalyticsEvent(_ eventName: String, sessionID: String) { + Task(priority: .background) { + await performEventRequest(eventName, sessionID: sessionID) + } + } + + func performEventRequest(_ eventName: String, sessionID: String) async { + let body = createAnalyticsEvent(eventName: eventName, sessionID: sessionID) + do { + try await post(url: url, body: body) + } catch { + NSLog("[PopupBridge SDK] Failed to send analytics: %@", error.localizedDescription) + } + } + + // MARK: - Private Methods + + /// Constructs POST params to be sent to FPTI + private func createAnalyticsEvent(eventName: String, sessionID: String) -> FPTIBatchData { + let batchMetadata = FPTIBatchData.Metadata(sessionID: sessionID) + let event = FPTIBatchData.Event(eventName: eventName) + return FPTIBatchData(metadata: batchMetadata, events: [event]) + } + + private func post(url: URL, body: FPTIBatchData) async throws { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.allHTTPHeaderFields = ["Content-Type": "application/json"] + + let encodedBody = try JSONEncoder().encode(body) + request.httpBody = encodedBody + + let (_, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + throw NetworkError.invalidResponse + } + } +} diff --git a/Sources/PopupBridge/Analytics/FPTIBatchData.swift b/Sources/PopupBridge/Analytics/FPTIBatchData.swift new file mode 100644 index 0000000..41d04e3 --- /dev/null +++ b/Sources/PopupBridge/Analytics/FPTIBatchData.swift @@ -0,0 +1,111 @@ +import UIKit + +struct FPTIBatchData: Codable { + + let events: [EventsContainer] + + init(metadata: Metadata, events fptiEvents: [Event]) { + self.events = [ + EventsContainer( + metadata: metadata, + fptiEvents: fptiEvents + ) + ] + } + + struct EventsContainer: Codable { + + let metadata: Metadata + let fptiEvents: [Event] + + enum CodingKeys: String, CodingKey { + case metadata = "batch_params" + case fptiEvents = "event_params" + } + } + + /// Encapsulates a single event by it's name and timestamp. + struct Event: Codable { + + let eventName: String + + let timestamp: String = String(Date().utcTimestampMilliseconds) + + let tenantName: String = "Braintree" + + enum CodingKeys: String, CodingKey { + case eventName = "event_name" + case timestamp = "t" + case tenantName = "tenant_name" + } + } + + /// The FPTI tags/metadata applicable to all events in the batch upload. + struct Metadata: Codable { + + let appID: String = Bundle.main.infoDictionary?[kCFBundleIdentifierKey as String] as? String ?? "N/A" + + let appName: String = Bundle.main.infoDictionary?[kCFBundleNameKey as String] as? String ?? "N/A" + + let clientSDKVersion: String = Bundle.clientSDKVersion + + let clientOS: String = UIDevice.current.systemName + " " + UIDevice.current.systemVersion + + let component: String = "popupbridgesdk" + + let deviceManufacturer: String = "Apple" + + let deviceModel: String = { + var systemInfo = utsname() + uname(&systemInfo) + let machineMirror = Mirror(reflecting: systemInfo.machine) + let identifier = machineMirror.children.reduce("") { identifier, element in + guard let value = element.value as? Int8, value != 0 else { return identifier } + return identifier + String(UnicodeScalar(UInt8(value))) + } + return identifier + }() + + let eventSource: String = "mobile-native" + + let isSimulator: Bool = { + #if targetEnvironment(simulator) + true + #else + false + #endif + }() + + let merchantAppVersion: String = Bundle.main.infoDictionary?[kCFBundleVersionKey as String] as? String ?? "N/A" + + let packageManager: String = { + #if COCOAPODS + "CocoaPods" + #elseif SWIFT_PACKAGE + "Swift Package Manager" + #else + "Carthage or Other" + #endif + }() + + let platform: String = "iOS" + + let sessionID: String + + enum CodingKeys: String, CodingKey { + case appID = "app_id" + case appName = "app_name" + case clientSDKVersion = "c_sdk_ver" + case clientOS = "client_os" + case component = "comp" + case deviceManufacturer = "device_manufacturer" + case deviceModel = "mobile_device_model" + case eventSource = "event_source" + case isSimulator = "is_simulator" + case merchantAppVersion = "mapv" + case packageManager = "ios_package_manager" + case platform + case sessionID = "session_id" + } + } +} diff --git a/Sources/PopupBridge/Analytics/PopupBridgeAnalytics.swift b/Sources/PopupBridge/Analytics/PopupBridgeAnalytics.swift new file mode 100644 index 0000000..1870416 --- /dev/null +++ b/Sources/PopupBridge/Analytics/PopupBridgeAnalytics.swift @@ -0,0 +1,8 @@ +enum PopupBridgeAnalytics { + + static let started = "popup-bridge:started" + static let succeeded = "popup-bridge:succeeded" + static let failed = "popup-bridge:failed" + static let canceled = "popup-bridge:canceled" +} + diff --git a/Sources/PopupBridge/Bundle+Extension.swift b/Sources/PopupBridge/Bundle+Extension.swift new file mode 100644 index 0000000..d920bd5 --- /dev/null +++ b/Sources/PopupBridge/Bundle+Extension.swift @@ -0,0 +1,8 @@ +import Foundation + +extension Bundle { + + static var clientSDKVersion: String { + Bundle(identifier: "com.braintreepayments.PopupBridge")?.infoDictionary?["CFBundleShortVersionString"] as? String ?? "N/A" + } +} diff --git a/Sources/PopupBridge/Date+MilisecondTimestamp.swift b/Sources/PopupBridge/Date+MilisecondTimestamp.swift new file mode 100644 index 0000000..c239de9 --- /dev/null +++ b/Sources/PopupBridge/Date+MilisecondTimestamp.swift @@ -0,0 +1,8 @@ +import Foundation + +extension Date { + + var utcTimestampMilliseconds: Int { + Int(round(timeIntervalSince1970 * 1000)) + } +} diff --git a/Sources/PopupBridge/NetworkError.swift b/Sources/PopupBridge/NetworkError.swift new file mode 100644 index 0000000..ab07a1b --- /dev/null +++ b/Sources/PopupBridge/NetworkError.swift @@ -0,0 +1,5 @@ +import Foundation + +enum NetworkError: Error { + case invalidResponse +} diff --git a/Sources/PopupBridge/POPPopupBridge.swift b/Sources/PopupBridge/POPPopupBridge.swift index 43d9b16..ee58d4f 100644 --- a/Sources/PopupBridge/POPPopupBridge.swift +++ b/Sources/PopupBridge/POPPopupBridge.swift @@ -8,13 +8,17 @@ public class POPPopupBridge: NSObject, WKScriptMessageHandler { /// Exposed for testing var returnedWithURL: Bool = false + static var analyticsService: AnalyticsServiceable = AnalyticsService() + // MARK: - Private Properties private let messageHandlerName = "POPPopupBridge" - private let hostName = "popupbridgev1" + private let hostName = "popupbridgev1" + private let sessionID = UUID().uuidString.replacingOccurrences(of: "-", with: "") private let webView: WKWebView - private var webAuthenticationSession: WebAuthenticationSession = WebAuthenticationSession() + private let application: URLOpener = UIApplication.shared + private var webAuthenticationSession: WebAuthenticationSession = WebAuthenticationSession() private var returnBlock: ((URL) -> Void)? = nil // MARK: - Initializers @@ -22,29 +26,42 @@ public class POPPopupBridge: NSObject, WKScriptMessageHandler { /// Initialize a Popup Bridge. /// - Parameters: /// - 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. - public init(webView: WKWebView) { + /// - prefersEphemeralWebBrowserSession: A Boolean that, when true, requests that the browser does not share cookies + /// or other browsing data between the authenthication session and the user’s normal browser session. + /// Defaults to `true`. + public init(webView: WKWebView, prefersEphemeralWebBrowserSession: Bool = true) { self.webView = webView super.init() - + + Self.analyticsService.sendAnalyticsEvent(PopupBridgeAnalytics.started, sessionID: sessionID) + configureWebView() + webAuthenticationSession.prefersEphemeralWebBrowserSession = prefersEphemeralWebBrowserSession - returnBlock = { url in - guard let script = self.constructJavaScriptCompletionResult(returnURL: url) else { + returnBlock = { [weak self] url in + guard let script = self?.constructJavaScriptCompletionResult(returnURL: url) else { return } - self.injectWebView(webView: webView, withJavaScript: script) + self?.injectWebView(webView: webView, withJavaScript: script) return } } /// Exposed for testing - convenience init(webView: WKWebView, webAuthenticationSession: WebAuthenticationSession) { + convenience init( + webView: WKWebView, + webAuthenticationSession: WebAuthenticationSession + ) { self.init(webView: webView) self.webAuthenticationSession = webAuthenticationSession } + deinit { + webView.configuration.userContentController.removeAllScriptMessageHandlers() + } + // MARK: - Internal Methods /// Exposed for testing @@ -72,10 +89,12 @@ public class POPPopupBridge: NSObject, WKScriptMessageHandler { if let payloadData = try? JSONEncoder().encode(payload), let payload = String(data: payloadData, encoding: .utf8) { + Self.analyticsService.sendAnalyticsEvent(PopupBridgeAnalytics.succeeded, sessionID: sessionID) return "window.popupBridge.onComplete(null, \(payload));" } else { let errorMessage = "Failed to parse query items from return URL." let errorResponse = "new Error(\"\(errorMessage)\")" + Self.analyticsService.sendAnalyticsEvent(PopupBridgeAnalytics.failed, sessionID: sessionID) return "window.popupBridge.onComplete(\(errorResponse), null);" } } @@ -83,12 +102,16 @@ public class POPPopupBridge: NSObject, WKScriptMessageHandler { /// Injects custom JavaScript into the merchant's webpage. /// - Parameter scheme: the url scheme provided by the merchant private func configureWebView() { - webView.configuration.userContentController.add(self, name: messageHandlerName) + webView.configuration.userContentController.add( + WebViewScriptHandler(proxy: self), + name: messageHandlerName + ) let javascript = PopupBridgeUserScript( scheme: PopupBridgeConstants.callbackURLScheme, scriptMessageHandlerName: messageHandlerName, - host: hostName + host: hostName, + isVenmoInstalled: application.isVenmoAppInstalled() ).rawJavascript let script = WKUserScript( @@ -135,7 +158,9 @@ public class POPPopupBridge: NSObject, WKScriptMessageHandler { window.popupBridge.onComplete(null, null);\ } """ - + + Self.analyticsService.sendAnalyticsEvent(PopupBridgeAnalytics.canceled, sessionID: sessionID) + injectWebView(webView: webView, withJavaScript: script) return } @@ -149,13 +174,8 @@ public class POPPopupBridge: NSObject, WKScriptMessageHandler { extension POPPopupBridge: ASWebAuthenticationPresentationContextProviding { public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - if #available(iOS 15, *) { - let firstScene = UIApplication.shared.connectedScenes.first as? UIWindowScene - let window = firstScene?.windows.first { $0.isKeyWindow } - return window ?? ASPresentationAnchor() - } else { - let window = UIApplication.shared.windows.first { $0.isKeyWindow } - return window ?? ASPresentationAnchor() - } + let firstScene = UIApplication.shared.connectedScenes.first as? UIWindowScene + let window = firstScene?.windows.first { $0.isKeyWindow } + return window ?? ASPresentationAnchor() } } diff --git a/Sources/PopupBridge/PopupBridgeUserScript.swift b/Sources/PopupBridge/PopupBridgeUserScript.swift index 2888dd7..97001f5 100644 --- a/Sources/PopupBridge/PopupBridgeUserScript.swift +++ b/Sources/PopupBridge/PopupBridgeUserScript.swift @@ -5,6 +5,7 @@ struct PopupBridgeUserScript { let scheme: String let scriptMessageHandlerName: String let host: String + let isVenmoInstalled: Bool var rawJavascript: String { """ @@ -15,6 +16,8 @@ struct PopupBridgeUserScript { return '\(scheme)://\(host)/'; }; + window.popupBridge.isVenmoInstalled = \(isVenmoInstalled); + window.popupBridge.open = function open(url) { window.webkit.messageHandlers.\(scriptMessageHandlerName) .postMessage({ diff --git a/Sources/PopupBridge/Sessionable.swift b/Sources/PopupBridge/Sessionable.swift new file mode 100644 index 0000000..f6909df --- /dev/null +++ b/Sources/PopupBridge/Sessionable.swift @@ -0,0 +1,14 @@ +import Foundation + +protocol Sessionable { + func data(for request: URLRequest, delegate: (any URLSessionTaskDelegate)?) async throws -> (Data, URLResponse) +} + +extension Sessionable { + + func data(for request: URLRequest, delegate: (any URLSessionTaskDelegate)? = nil) async throws -> (Data, URLResponse) { + try await data(for: request, delegate: delegate) + } +} + +extension URLSession: Sessionable { } diff --git a/Sources/PopupBridge/UIApplication+URLOpener.swift b/Sources/PopupBridge/UIApplication+URLOpener.swift new file mode 100644 index 0000000..5373785 --- /dev/null +++ b/Sources/PopupBridge/UIApplication+URLOpener.swift @@ -0,0 +1,17 @@ +import UIKit + +protocol URLOpener { + + func isVenmoAppInstalled() -> Bool +} + +extension UIApplication: URLOpener { + + /// Indicates whether the Venmo App is installed. + func isVenmoAppInstalled() -> Bool { + guard let venmoURL = URL(string: "com.venmo.touch.v2://") else { + return false + } + return canOpenURL(venmoURL) + } +} diff --git a/Sources/PopupBridge/WebAuthenticationSession.swift b/Sources/PopupBridge/WebAuthenticationSession.swift index 5df2ab5..bb0f7d0 100644 --- a/Sources/PopupBridge/WebAuthenticationSession.swift +++ b/Sources/PopupBridge/WebAuthenticationSession.swift @@ -4,6 +4,7 @@ import AuthenticationServices class WebAuthenticationSession: NSObject { var authenticationSession: ASWebAuthenticationSession? + var prefersEphemeralWebBrowserSession: Bool = true func start( url: URL, @@ -21,8 +22,7 @@ class WebAuthenticationSession: NSObject { sessionDidComplete(url, error) } } - - authenticationSession?.prefersEphemeralWebBrowserSession = true + authenticationSession?.prefersEphemeralWebBrowserSession = prefersEphemeralWebBrowserSession authenticationSession?.presentationContextProvider = context authenticationSession?.start() diff --git a/Sources/PopupBridge/WebViewScriptHandler.swift b/Sources/PopupBridge/WebViewScriptHandler.swift new file mode 100644 index 0000000..0977302 --- /dev/null +++ b/Sources/PopupBridge/WebViewScriptHandler.swift @@ -0,0 +1,18 @@ +import Foundation +import WebKit + +final class WebViewScriptHandler: NSObject { + + weak var proxy: WKScriptMessageHandler? + + init(proxy: WKScriptMessageHandler) { + self.proxy = proxy + } +} + +extension WebViewScriptHandler: WKScriptMessageHandler { + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + proxy?.userContentController(userContentController, didReceive: message) + } +} diff --git a/UnitTests/AnalyticsService_Test.swift b/UnitTests/AnalyticsService_Test.swift new file mode 100644 index 0000000..1d2aea3 --- /dev/null +++ b/UnitTests/AnalyticsService_Test.swift @@ -0,0 +1,48 @@ +@testable import PopupBridge +import XCTest + +class AnalyticsService_Test: XCTestCase { + + var sut: AnalyticsService! + var mockSession: MockSession! + + let eventName = "some-event-name" + let sessionID = "some-session-id" + let testURL = URL(string: "https://api.paypal.com/v1/tracking/batch/events")! + + override func setUp() { + super.setUp() + mockSession = MockSession() + sut = AnalyticsService(session: mockSession) + } + + override func tearDown() { + sut = nil + mockSession = nil + super.tearDown() + } + + func testPerformEventRequest_handleSuccess() async { + mockSession.response = (data: Data(), response: HTTPURLResponse(url: testURL, statusCode: 200, httpVersion: nil, headerFields: nil)!) + + await sut.performEventRequest(eventName, sessionID: sessionID) + + let decodedResponse = decodeData(mockSession.requestedBody!) + + XCTAssertEqual(mockSession.requestedURL, testURL) + XCTAssertEqual(mockSession.requestHttpMethod, "POST") + XCTAssertEqual(mockSession.requestAllHTTPHeaderFields, ["Content-Type": "application/json"]) + XCTAssertEqual(decodedResponse?.events.first?.fptiEvents.first?.eventName, eventName) + XCTAssertEqual(decodedResponse?.events.first?.metadata.sessionID, sessionID) + } + + func decodeData(_ data: Data) -> FPTIBatchData? { + let decoder = JSONDecoder() + do { + let event = try decoder.decode(FPTIBatchData.self, from: data) + return event + } catch { + return nil + } + } +} diff --git a/UnitTests/MockAnalyticsService.swift b/UnitTests/MockAnalyticsService.swift new file mode 100644 index 0000000..036eb43 --- /dev/null +++ b/UnitTests/MockAnalyticsService.swift @@ -0,0 +1,15 @@ +import Foundation +@testable import PopupBridge + +class MockAnalyticsService: AnalyticsServiceable { + + var lastEventName: String? + var lastSessionID: String? + var eventCount = 0 + + func sendAnalyticsEvent(_ eventName: String, sessionID: String) { + lastEventName = eventName + lastSessionID = sessionID + eventCount += 1 + } +} diff --git a/UnitTests/MockSessionable.swift b/UnitTests/MockSessionable.swift new file mode 100644 index 0000000..860689e --- /dev/null +++ b/UnitTests/MockSessionable.swift @@ -0,0 +1,28 @@ +import Foundation +@testable import PopupBridge + +class MockSession: Sessionable { + + var requestedURL: URL? + var requestedBody: Data? + var requestHttpMethod: String? + var requestAllHTTPHeaderFields: [String: String] = [:] + var response: (data: Data, response: URLResponse)? + var error: Error? + + func data(for request: URLRequest, delegate: (any URLSessionTaskDelegate)?) async throws -> (Data, URLResponse) { + + requestedURL = request.url + requestedBody = request.httpBody + requestHttpMethod = request.httpMethod + requestAllHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] + + if let error { + throw error + } else if let response { + return response + } else { + throw NetworkError.invalidResponse + } + } +} diff --git a/UnitTests/MockWebAuthenticationSession.swift b/UnitTests/MockWebAuthenticationSession.swift index 4f8b539..62608d7 100644 --- a/UnitTests/MockWebAuthenticationSession.swift +++ b/UnitTests/MockWebAuthenticationSession.swift @@ -5,6 +5,7 @@ import AuthenticationServices class MockWebAuthenticationSession: WebAuthenticationSession { var cannedResponseURL: URL? var cannedErrorResponse: Error? + var shouldCancel: Bool = false override func start( url: URL, @@ -12,6 +13,10 @@ class MockWebAuthenticationSession: WebAuthenticationSession { sessionDidComplete: @escaping (URL?, Error?) -> Void, sessionDidCancel: @escaping () -> Void ) { - sessionDidComplete(cannedResponseURL, cannedErrorResponse) + if shouldCancel { + sessionDidCancel() + } else { + sessionDidComplete(cannedResponseURL, cannedErrorResponse) + } } } diff --git a/UnitTests/PopupBridge_UnitTests.swift b/UnitTests/PopupBridge_UnitTests.swift index 08db566..226b4de 100644 --- a/UnitTests/PopupBridge_UnitTests.swift +++ b/UnitTests/PopupBridge_UnitTests.swift @@ -7,6 +7,7 @@ final class PopupBridge_UnitTests: XCTestCase, WKNavigationDelegate { let scriptMessageHandlerName: String = "POPPopupBridge" let returnURL: String = "com.braintreepayments.popupbridgeexample" let mockWebAuthenticationSession = MockWebAuthenticationSession() + let mockAnalyticsService = MockAnalyticsService() var webViewReadyBlock: (Void)? @@ -33,9 +34,9 @@ final class PopupBridge_UnitTests: XCTestCase, WKNavigationDelegate { configuration.userContentController = mockUserContentController let webView = WKWebView(frame: CGRect(), configuration: configuration) - let pub = POPPopupBridge(webView: webView) + let _ = POPPopupBridge(webView: webView) - XCTAssertEqual(mockUserContentController.scriptMessageHandler as? POPPopupBridge, pub) + XCTAssertNotNil(mockUserContentController.scriptMessageHandler as? WebViewScriptHandler) XCTAssertEqual(mockUserContentController.name, scriptMessageHandlerName) } @@ -52,7 +53,10 @@ final class PopupBridge_UnitTests: XCTestCase, WKNavigationDelegate { configuration.userContentController = mockUserContentController let webView = WKWebView(frame: CGRect(), configuration: configuration) - let pub = POPPopupBridge(webView: webView, webAuthenticationSession: mockWebAuthenticationSession) + let pub = POPPopupBridge( + webView: webView, + webAuthenticationSession: mockWebAuthenticationSession + ) mockWebAuthenticationSession.cannedResponseURL = URL(string: "http://example.com/?hello=world") pub.userContentController(WKUserContentController(), didReceive: stubMessage) @@ -135,14 +139,20 @@ final class PopupBridge_UnitTests: XCTestCase, WKNavigationDelegate { configuration.userContentController = mockUserContentController let webView = WKWebView(frame: CGRect(), configuration: configuration) - let pub = POPPopupBridge(webView: webView, webAuthenticationSession: mockWebAuthenticationSession) + let pub = POPPopupBridge( + webView: webView, + webAuthenticationSession: mockWebAuthenticationSession + ) let mockURL = URL(string: "sdk.ios.popup-bridge://popupbridgev1/return?something=foo&other=bar")! mockWebAuthenticationSession.cannedResponseURL = mockURL let expectedResult = "window.popupBridge.onComplete(null, {\"path\":\"\\/return\",\"queryItems\":{\"other\":\"bar\",\"something\":\"foo\"}});" let result = pub.constructJavaScriptCompletionResult(returnURL: mockURL) - XCTAssertEqual(result, expectedResult) + let expectedJSON = extractJSON(from: expectedResult) + let actualJSON = extractJSON(from: result!) + + XCTAssertEqual(actualJSON, expectedJSON) } func testConstructJavaScriptCompletionResult_whenReturnURLHasNoQueryParams_passesPayloadWithNoQueryItemsToWebView() { @@ -158,14 +168,20 @@ final class PopupBridge_UnitTests: XCTestCase, WKNavigationDelegate { configuration.userContentController = mockUserContentController let webView = WKWebView(frame: CGRect(), configuration: configuration) - let pub = POPPopupBridge(webView: webView, webAuthenticationSession: mockWebAuthenticationSession) + let pub = POPPopupBridge( + webView: webView, + webAuthenticationSession: mockWebAuthenticationSession + ) let mockURL = URL(string: "sdk.ios.popup-bridge://popupbridgev1/return")! mockWebAuthenticationSession.cannedResponseURL = mockURL let expectedResult = "window.popupBridge.onComplete(null, {\"path\":\"\\/return\",\"queryItems\":{}});" let result = pub.constructJavaScriptCompletionResult(returnURL: mockURL) - XCTAssertEqual(result, expectedResult) + let expectedJSON = extractJSON(from: expectedResult) + let actualJSON = extractJSON(from: result!) + + XCTAssertEqual(actualJSON, expectedJSON) } func testConstructJavaScriptCompletionResult_whenReturnURLHasURLFragment_passesPayloadWithHashToWebView() { @@ -175,7 +191,10 @@ final class PopupBridge_UnitTests: XCTestCase, WKNavigationDelegate { stubMessage.body = stubMessageBody stubMessage.name = stubMessageName - let pub = POPPopupBridge(webView: WKWebView(), webAuthenticationSession: mockWebAuthenticationSession) + let pub = POPPopupBridge( + webView: WKWebView(), + webAuthenticationSession: mockWebAuthenticationSession + ) pub.userContentController(WKUserContentController(), didReceive: stubMessage) @@ -185,7 +204,10 @@ final class PopupBridge_UnitTests: XCTestCase, WKNavigationDelegate { let expectedResult = "window.popupBridge.onComplete(null, {\"path\":\"\\/return\",\"hash\":\"something=foo&other=bar\",\"queryItems\":{}});" let result = pub.constructJavaScriptCompletionResult(returnURL: mockURL) - XCTAssertEqual(result, expectedResult) + let expectedJSON = extractJSON(from: expectedResult) + let actualJSON = extractJSON(from: result!) + + XCTAssertEqual(actualJSON, expectedJSON) } func testConstructJavaScriptCompletionResult_whenReturnURLHasNoURLFragment_passesPayloadWithNilHashToWebView() { @@ -195,7 +217,10 @@ final class PopupBridge_UnitTests: XCTestCase, WKNavigationDelegate { stubMessage.body = stubMessageBody stubMessage.name = stubMessageName - let pub = POPPopupBridge(webView: WKWebView(), webAuthenticationSession: mockWebAuthenticationSession) + let pub = POPPopupBridge( + webView: WKWebView(), + webAuthenticationSession: mockWebAuthenticationSession + ) pub.userContentController(WKUserContentController(), didReceive: stubMessage) @@ -205,7 +230,10 @@ final class PopupBridge_UnitTests: XCTestCase, WKNavigationDelegate { let expectedResult = "window.popupBridge.onComplete(null, {\"path\":\"\\/return\",\"queryItems\":{}});" let result = pub.constructJavaScriptCompletionResult(returnURL: mockURL) - XCTAssertEqual(result, expectedResult) + let expectedJSON = extractJSON(from: expectedResult) + let actualJSON = extractJSON(from: result!) + + XCTAssertEqual(actualJSON, expectedJSON) } func testConstructJavaScriptCompletionResult_whenReturnURLDoesNotMatchScheme_returnsFalseAndDoesNotCallOnComplete() { @@ -243,4 +271,106 @@ final class PopupBridge_UnitTests: XCTestCase, WKNavigationDelegate { webViewReadyBlock } } + + private func extractJSON(from jsString: String) -> [String: String]? { + let pattern = "window\\.popupBridge\\.onComplete\\(null, (.*)\\);" + let regex = try? NSRegularExpression(pattern: pattern, options: []) + + if let match = regex?.firstMatch(in: jsString, options: [], range: NSRange(jsString.startIndex..., in: jsString)), + let jsonRange = Range(match.range(at: 1), in: jsString) { + let jsonString = String(jsString[jsonRange]) + + if let data = jsonString.data(using: .utf8) { + return (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: String] + } + } + + return nil + } + + func testInit_sendsAnalytics() { + XCTAssertEqual(mockAnalyticsService.eventCount, 0) + POPPopupBridge.analyticsService = mockAnalyticsService + + let _ = POPPopupBridge( + webView: WKWebView(), + webAuthenticationSession: mockWebAuthenticationSession + ) + + XCTAssertEqual(mockAnalyticsService.eventCount, 1) + XCTAssertEqual(mockAnalyticsService.lastEventName, PopupBridgeAnalytics.started) + XCTAssertNotNil(mockAnalyticsService.lastSessionID) + } + + func testConstructJavaScriptCompletionResult_whenReturnURL_sendsAnalytics() { + XCTAssertEqual(mockAnalyticsService.eventCount, 0) + let configuration = WKWebViewConfiguration() + let mockUserContentController = MockUserContentController() + + configuration.userContentController = mockUserContentController + + let webView = WKWebView(frame: CGRect(), configuration: configuration) + POPPopupBridge.analyticsService = mockAnalyticsService + let pub = POPPopupBridge( + webView: webView, + webAuthenticationSession: mockWebAuthenticationSession + ) + let mockURL = URL(string: "sdk.ios.popup-bridge://popupbridgev1/return?something=foo&other=bar")! + mockWebAuthenticationSession.cannedResponseURL = mockURL + + let expectedResult = "window.popupBridge.onComplete(null, {\"path\":\"\\/return\",\"queryItems\":{\"other\":\"bar\",\"something\":\"foo\"}});" + let result = pub.constructJavaScriptCompletionResult(returnURL: mockURL) + + let expectedJSON = extractJSON(from: expectedResult) + let actualJSON = extractJSON(from: result!) + + XCTAssertEqual(actualJSON, expectedJSON) + XCTAssertEqual(mockAnalyticsService.eventCount, 2) + XCTAssertEqual(mockAnalyticsService.lastEventName, PopupBridgeAnalytics.succeeded) + XCTAssertNotNil(mockAnalyticsService.lastSessionID) + } + + func testPopupBridge_whenCancelButtonTappedOnSafariViewController_sendsAnalytics() { + XCTAssertEqual(mockAnalyticsService.eventCount, 0) + let stubMessageBody: [String: String] = ["url": "http://example.com/?hello=world"] + let stubMessageName = scriptMessageHandlerName + let stubMessage = MockScriptMessage() + stubMessage.body = stubMessageBody + stubMessage.name = stubMessageName + + let configuration = WKWebViewConfiguration() + let mockUserContentController = MockUserContentController() + + configuration.userContentController = mockUserContentController + + POPPopupBridge.analyticsService = mockAnalyticsService + let webView = WKWebView(frame: CGRect(), configuration: configuration) + let pub = POPPopupBridge( + webView: webView, + webAuthenticationSession: mockWebAuthenticationSession + ) + mockWebAuthenticationSession.shouldCancel = true + pub.userContentController(WKUserContentController(), didReceive: stubMessage) + + webView.evaluateJavaScript(""" + "if (typeof window.popupBridge.onCancel === 'function') {" + " window.popupBridge.onCancel();" + "} else {" + " window.popupBridge.onComplete(null, null);" + "}" + """) + + XCTAssertEqual(mockAnalyticsService.eventCount, 2) + XCTAssertEqual(mockAnalyticsService.lastEventName, PopupBridgeAnalytics.canceled) + XCTAssertNotNil(mockAnalyticsService.lastSessionID) + } + + func testPOPPopupBridge_shouldNotLeak() { + let webView = WKWebView() + trackForMemoryLeak(instance: POPPopupBridge( + webView: webView, + webAuthenticationSession: mockWebAuthenticationSession + )) + webView.load(URLRequest(url: URL(string: "some-popup-bridge-example")!)) + } } diff --git a/UnitTests/XCTestCase+trackForMemoryLeaks.swift b/UnitTests/XCTestCase+trackForMemoryLeaks.swift new file mode 100644 index 0000000..12c0766 --- /dev/null +++ b/UnitTests/XCTestCase+trackForMemoryLeaks.swift @@ -0,0 +1,22 @@ +import Foundation +import XCTest + +extension XCTestCase { + + @discardableResult + func trackForMemoryLeak( + instance: T, + file: StaticString = #filePath, + line: UInt = #line + ) -> T { + addTeardownBlock { [weak instance] in + XCTAssertNil( + instance, + "Potential memory leak on \(String(describing: instance))", + file: file, + line: line + ) + } + return instance + } +} diff --git a/V3_MIGRATION.md b/V3_MIGRATION.md new file mode 100644 index 0000000..4599de1 --- /dev/null +++ b/V3_MIGRATION.md @@ -0,0 +1,18 @@ +# PopupBridge iOS v3 Migration Guide + +See the [CHANGELOG](/CHANGELOG.md) for a complete list of changes. This migration guide outlines the basics for updating your client integration from v2 to v3. + +## Supported Versions + +v3 supports a minimum deployment target of iOS 16+. It requires Xcode 16.2.0+ and Swift 5.10+. If your application contains Objective-C code, the `Enable Modules` build setting must be set to `YES`. + +## Venmo + +### Allowlist Venmo URL scheme +You must add the following to the queries schemes allowlist in your app's info.plist: + +``` xml +LSApplicationQueriesSchemes + + com.venmo.touch.v2 +