From a73675d3109ab1204b313458dfb84484ec09aa29 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Wed, 13 May 2026 14:40:12 +0100 Subject: [PATCH 1/6] Default support for window.open delegation in Swift kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds default handling of the UCP window.open delegation. When checkout fires ec.window.open_request, the kit opens the URL via UIApplication and responds with success; if canOpenURL returns false, the kit replies with window_open_rejected_error per spec. The kit advertises window.open in the ec_delegate URL query param and echoes the intersection of merchant-requested ∩ kit-supported delegations in the ec.ready response. Consumers can override the default behaviour via Client.onWindowOpenRequest(_:). --- .../Sources/App/AppDelegate.swift | 2 +- .../Sources/CheckoutProtocolBridge.swift | 27 --- .../Sources/CheckoutProtocolClient.swift | 80 ++++++-- .../Sources/Localizable.xcstrings | 4 + .../Sources/Scenes/Cart/CartView.swift | 9 +- .../Sources/Scenes/SettingsView.swift | 23 +++ .../CheckoutCommunicationProtocol.swift | 5 + .../ShopifyCheckoutKit/CheckoutWebView.swift | 45 ++++- .../CheckoutWebViewController.swift | 6 - .../CheckoutWebViewTests.swift | 174 +++++++++++++----- .../Mocks/MockCheckoutWebViewDelegate.swift | 5 - .../CheckoutProtocol+URL.swift | 11 +- .../CheckoutProtocol.swift | 2 + .../ShopifyCheckoutProtocol/Client.swift | 18 +- .../ShopifyCheckoutProtocol/Codec.swift | 61 ++++-- .../ShopifyCheckoutProtocol/Descriptors.swift | 13 ++ .../JSONRPCMessage.swift | 1 + .../ShopifyCheckoutProtocol/UCPMessage.swift | 4 +- .../ShopifyCheckoutProtocol/WindowOpen.swift | 97 ++++++++++ .../CheckoutProtocolURLTests.swift | 79 ++++++++ .../ClientTests.swift | 84 +++++++++ .../CodecDecodeTests.swift | 39 +++- .../CodecEncodeTests.swift | 81 +++++++- .../Fixtures/window_open_request.json | 8 + 24 files changed, 737 insertions(+), 141 deletions(-) delete mode 100644 platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/CheckoutProtocolBridge.swift create mode 100644 protocol/languages/swift/Sources/ShopifyCheckoutProtocol/WindowOpen.swift create mode 100644 protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CheckoutProtocolURLTests.swift create mode 100644 protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/Fixtures/window_open_request.json diff --git a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/App/AppDelegate.swift b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/App/AppDelegate.swift index dd51fdfc..587cba86 100644 --- a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/App/AppDelegate.swift +++ b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/App/AppDelegate.swift @@ -48,7 +48,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ShopifyCheckoutKit.configure { $0.colorScheme = .automatic $0.tintColor = ColorPalette.primaryColor - $0.preloading.enabled = true + $0.preloading.enabled = false $0.logger = FileLogger("log.txt") $0.logLevel = checkoutKitLogLevel } diff --git a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/CheckoutProtocolBridge.swift b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/CheckoutProtocolBridge.swift deleted file mode 100644 index 442069cd..00000000 --- a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/CheckoutProtocolBridge.swift +++ /dev/null @@ -1,27 +0,0 @@ -/* - MIT License - - Copyright 2023 - Present, Shopify Inc. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -import ShopifyCheckoutKit -import ShopifyCheckoutProtocol - -extension CheckoutProtocol.Client: @retroactive CheckoutCommunicationProtocol {} diff --git a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/CheckoutProtocolClient.swift b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/CheckoutProtocolClient.swift index b7b8558d..d7aa11c4 100644 --- a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/CheckoutProtocolClient.swift +++ b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/CheckoutProtocolClient.swift @@ -21,27 +21,69 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import SafariServices import ShopifyCheckoutProtocol +import UIKit -enum CheckoutProtocolClient { - static let shared = CheckoutProtocol.Client() - .on(CheckoutProtocol.start) { checkout in - print("[UCP] ec.start: \(checkout.id)") - } - .on(CheckoutProtocol.complete) { checkout in - print("[UCP] ec.complete: \(checkout.order?.id ?? "unknown")") - CartManager.shared.resetCart() - } - .on(CheckoutProtocol.lineItemsChange) { checkout in - print("[UCP] ec.line_items.change: \(checkout.id)") - } - .on(CheckoutProtocol.messagesChange) { checkout in - print("[UCP] ec.messages.change: \(checkout.id)") - } - .on(CheckoutProtocol.totalsChange) { checkout in - print("[UCP] ec.totals.change: \(checkout.id)") +extension CheckoutProtocol.Client { + @MainActor + static func with(windowOpen: WindowOpenHandlerOption) -> CheckoutProtocol.Client { + let base = CheckoutProtocol.Client() + .on(CheckoutProtocol.start) { checkout in + print("[UCP] ec.start: \(checkout.id)") + } + .on(CheckoutProtocol.complete) { checkout in + print("[UCP] ec.complete: \(checkout.order?.id ?? "unknown")") + CartManager.shared.resetCart() + } + .on(CheckoutProtocol.lineItemsChange) { checkout in + print("[UCP] ec.line_items.change: \(checkout.id)") + } + .on(CheckoutProtocol.messagesChange) { checkout in + print("[UCP] ec.messages.change: \(checkout.id)") + } + .on(CheckoutProtocol.totalsChange) { checkout in + print("[UCP] ec.totals.change: \(checkout.id)") + } + .on(CheckoutProtocol.error) { error in + print("[UCP] ec.error: \(error.messages.first?.content ?? "(no message)")") + } + + switch windowOpen { + case .default: + return base + case .safariViewController: + return base.on(CheckoutProtocol.windowOpen) { request in + let scheme = request.url.scheme?.lowercased() + + print("[UCP] ec.window_open (\(scheme ?? ""))") + + guard scheme == "http" || scheme == "https" else { + return .rejected(reason: "unsupported URL scheme") + } + + guard let presenter = UIApplication.shared.foregroundActiveWindow?.topMostViewController() else { + return .rejected(reason: "no presenter available") + } + + let safari = SFSafariViewController(url: request.url) + presenter.present(safari, animated: true) + return .success + } } - .on(CheckoutProtocol.error) { error in - print("[UCP] ec.error: \(error.messages.first?.content ?? "(no message)")") + } +} + +private extension UIApplication { + var foregroundActiveWindow: UIWindow? { + let activeScenes = connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive } + + if #available(iOS 15.0, *) { + return activeScenes.compactMap(\.keyWindow).first + } else { + return activeScenes.flatMap(\.windows).first { $0.isKeyWindow } } + } } diff --git a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Localizable.xcstrings b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Localizable.xcstrings index e6b4e3e0..77e4a912 100644 --- a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Localizable.xcstrings +++ b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Localizable.xcstrings @@ -186,6 +186,10 @@ }, "Version" : { + }, + "Window open handler" : { + "comment" : "A label for the selection of the window open handler in the settings.", + "isCommentAutoGenerated" : true }, "Your cart is empty." : { diff --git a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Scenes/Cart/CartView.swift b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Scenes/Cart/CartView.swift index 21671a0b..82dfa7dc 100644 --- a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Scenes/Cart/CartView.swift +++ b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Scenes/Cart/CartView.swift @@ -37,11 +37,16 @@ struct CartView: View { @ObservedObject var cartManager: CartManager = .shared - private let client = CheckoutProtocolClient.shared - @AppStorage(AppStorageKeys.applePayStyle.rawValue) var applePayStyle: ApplePayStyleOption = .automatic + @AppStorage(AppStorageKeys.windowOpenHandler.rawValue) + var windowOpenHandler: WindowOpenHandlerOption = .default + + private var client: CheckoutProtocol.Client { + .with(windowOpen: windowOpenHandler) + } + var body: some View { if let lines = cartManager.cart?.lines.nodes { ZStack(alignment: .bottom) { diff --git a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Scenes/SettingsView.swift b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Scenes/SettingsView.swift index 4fe8e631..7ec6554e 100644 --- a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Scenes/SettingsView.swift +++ b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/Scenes/SettingsView.swift @@ -31,6 +31,19 @@ enum AppStorageKeys: String { case checkoutKitLogLevel case buyerIdentityMode case applePayStyle + case windowOpenHandler +} + +enum WindowOpenHandlerOption: String, CaseIterable { + case `default` + case safariViewController + + var title: String { + switch self { + case .default: return "Default (UIApplication.open)" + case .safariViewController: return "SFSafariViewController" + } + } } struct SettingsView: View { @@ -48,6 +61,9 @@ struct SettingsView: View { @AppStorage(AppStorageKeys.applePayStyle.rawValue) var applePayStyle: ApplePayStyleOption = .automatic + @AppStorage(AppStorageKeys.windowOpenHandler.rawValue) + var windowOpenHandler: WindowOpenHandlerOption = .default + @State private var preloadingEnabled = ShopifyCheckoutKit.configuration.preloading.enabled @State private var logs: [String?] = LogReader.shared.readLogs() ?? [] @State private var selectedColorScheme = ShopifyCheckoutKit.configuration.colorScheme @@ -61,6 +77,13 @@ struct SettingsView: View { .onChange(of: preloadingEnabled) { newValue in ShopifyCheckoutKit.configuration.preloading.enabled = newValue } + + Picker("Window open handler", selection: $windowOpenHandler) { + ForEach(WindowOpenHandlerOption.allCases, id: \.self) { option in + Text(option.title).tag(option) + } + } + .pickerStyle(.menu) } Section( diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutCommunicationProtocol.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutCommunicationProtocol.swift index bc47f183..8b9e267a 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutCommunicationProtocol.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutCommunicationProtocol.swift @@ -21,8 +21,13 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +#if !COCOAPODS + import ShopifyCheckoutProtocol +#endif import Foundation public protocol CheckoutCommunicationProtocol: Sendable { func process(_ message: String) async -> String? } + +extension CheckoutProtocol.Client: CheckoutCommunicationProtocol {} diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index 672073e2..59a64f61 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -30,7 +30,6 @@ import WebKit protocol CheckoutWebViewDelegate: AnyObject { func checkoutViewDidStartNavigation() func checkoutViewDidFinishNavigation() - func checkoutViewDidClickLink(url: URL) func checkoutViewDidFailWithError(error: CheckoutError) } @@ -254,16 +253,29 @@ extension CheckoutWebView: WKScriptMessageHandler { return } - guard let client else { - return - } - Task { - if let response = await client.process(body) { + if let response = await client?.process(body) { + checkoutBridge.sendResponse(self, messageBody: response) + return + } + + if let response = await CheckoutWebView.defaultsClient.process(body) { checkoutBridge.sendResponse(self, messageBody: response) } } } + + /// Kit-owned client that handles delegations the consumer did not register. + /// Today the only default is `window.open`, which falls back to + /// `UIApplication.shared.open(...)` after a `canOpenURL` check. + static let defaultsClient = CheckoutProtocol.Client() + .on(CheckoutProtocol.windowOpen) { request in + guard UIApplication.shared.canOpenURL(request.url) else { + return .rejected(reason: "canOpenURL returned false") + } + UIApplication.shared.open(request.url) + return .success + } } extension CheckoutWebView: WKNavigationDelegate { @@ -275,7 +287,7 @@ extension CheckoutWebView: WKNavigationDelegate { if isExternalLink(action) || CheckoutURL(from: url).isDeepLink() { OSLogger.shared.debug("External or deep link clicked: \(url.absoluteString) - request intercepted") - viewDelegate?.checkoutViewDidClickLink(url: removeExternalParam(url)) + dispatchWindowOpenRequest(url: removeExternalParam(url)) decisionHandler(.cancel) return } @@ -283,6 +295,25 @@ extension CheckoutWebView: WKNavigationDelegate { decisionHandler(.allow) } + private func dispatchWindowOpenRequest(url: URL) { + let envelope: [String: Any] = [ + "jsonrpc": "2.0", + "id": UUID().uuidString, + "method": "ec.window.open_request", + "params": ["url": url.absoluteString] + ] + + guard + let data = try? JSONSerialization.data(withJSONObject: envelope), + let message = String(data: data, encoding: .utf8) + else { return } + + Task { @MainActor [client] in + if let consumer = client, await consumer.process(message) != nil { return } + _ = await CheckoutWebView.defaultsClient.process(message) + } + } + func webView(_: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { if let response = navigationResponse.response as? HTTPURLResponse { decisionHandler(handleResponse(response)) diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift index a76f11d3..547bd73c 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift @@ -247,12 +247,6 @@ extension CheckoutWebViewController: CheckoutWebViewDelegate { return isRecoverableError() && isWithinRetryLimit && error.isRecoverable } - func checkoutViewDidClickLink(url: URL) { - if UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) - } - } - private func isRecoverableError() -> Bool { return !CheckoutURL(from: checkoutURL).isMultipassURL() } diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index 7f1a6ae8..ae22e750 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -68,88 +68,103 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertFalse(recovery.isOpaque) } - func testEmailContactLinkDelegation() throws { + func testEmailContactLinkDispatchesWindowOpen() throws { let link = try XCTUnwrap(URL(string: "mailto:contact@shopify.com")) - - let delegate = MockCheckoutWebViewDelegate() - let didClickLinkExpectation = expectation( - description: "checkoutViewDidClickLink was called" - ) - delegate.didClickLinkExpectation = didClickLinkExpectation - view.viewDelegate = delegate + let received = TestExpectation() + var capturedURL: URL? + view.client = CheckoutProtocol.Client() + .on(CheckoutProtocol.windowOpen) { request in + capturedURL = request.url + received.fulfill() + return .success + } view.webView(view, decidePolicyFor: MockNavigationAction(url: link)) { policy in XCTAssertEqual(policy, .cancel) } - wait(for: [didClickLinkExpectation], timeout: 1) + XCTAssertEqual(XCTWaiter().wait(for: [received], timeout: 2.0), .completed) + XCTAssertEqual(capturedURL, link) } - func testPhoneContactLinkDelegation() throws { + func testPhoneContactLinkDispatchesWindowOpen() throws { let link = try XCTUnwrap(URL(string: "tel:1234567890")) - - let delegate = MockCheckoutWebViewDelegate() - let didClickLinkExpectation = expectation( - description: "checkoutViewDidClickLink was called" - ) - delegate.didClickLinkExpectation = didClickLinkExpectation - view.viewDelegate = delegate + let received = TestExpectation() + var capturedURL: URL? + view.client = CheckoutProtocol.Client() + .on(CheckoutProtocol.windowOpen) { request in + capturedURL = request.url + received.fulfill() + return .success + } view.webView(view, decidePolicyFor: MockNavigationAction(url: link)) { policy in XCTAssertEqual(policy, .cancel) } - wait(for: [didClickLinkExpectation], timeout: 1) + XCTAssertEqual(XCTWaiter().wait(for: [received], timeout: 2.0), .completed) + XCTAssertEqual(capturedURL, link) } - func testURLLinkDelegation() throws { + func testURLLinkDispatchesWindowOpen() throws { let link = try XCTUnwrap(URL(string: "https://www.shopify.com/legal/privacy/app-users")) - - let delegate = MockCheckoutWebViewDelegate() - let didClickLinkExpectation = expectation( - description: "checkoutViewDidClickLink was called" - ) - delegate.didClickLinkExpectation = didClickLinkExpectation - view.viewDelegate = delegate + let received = TestExpectation() + var capturedURL: URL? + view.client = CheckoutProtocol.Client() + .on(CheckoutProtocol.windowOpen) { request in + capturedURL = request.url + received.fulfill() + return .success + } view.webView(view, decidePolicyFor: MockExternalNavigationAction(url: link)) { policy in XCTAssertEqual(policy, .cancel) } - wait(for: [didClickLinkExpectation], timeout: 1) + XCTAssertEqual(XCTWaiter().wait(for: [received], timeout: 2.0), .completed) + XCTAssertEqual(capturedURL, link) } - func testCheckoutDidClickLinkWasCalledForDeepLink() throws { + func testDeepLinkDispatchesWindowOpen() throws { let link = try XCTUnwrap(URL(string: "shopify://app/privacy")) - let delegate = MockCheckoutWebViewDelegate() - let didClickLinkExpectation = expectation( - description: "checkoutViewDidClickLink was called" - ) - delegate.didClickLinkExpectation = didClickLinkExpectation - view.viewDelegate = delegate + let received = TestExpectation() + var capturedURL: URL? + view.client = CheckoutProtocol.Client() + .on(CheckoutProtocol.windowOpen) { request in + capturedURL = request.url + received.fulfill() + return .success + } view.webView(view, decidePolicyFor: MockExternalNavigationAction(url: link)) { policy in XCTAssertEqual(policy, .cancel) } - wait(for: [didClickLinkExpectation], timeout: 1) + XCTAssertEqual(XCTWaiter().wait(for: [received], timeout: 2.0), .completed) + XCTAssertEqual(capturedURL, link) } - func testURLLinkDelegationWithExternalParam() throws { + func testURLLinkWithExternalParamDispatchesWindowOpenWithoutParam() throws { let link = try XCTUnwrap(URL(string: "https://www.shopify.com/legal/privacy/app-users?open_externally=true")) - - let delegate = MockCheckoutWebViewDelegate() - let didClickLinkExpectation = expectation( - description: "checkoutViewDidClickLink was called" - ) - delegate.didClickLinkExpectation = didClickLinkExpectation - view.viewDelegate = delegate + let received = TestExpectation() + var capturedURL: URL? + view.client = CheckoutProtocol.Client() + .on(CheckoutProtocol.windowOpen) { request in + capturedURL = request.url + received.fulfill() + return .success + } view.webView(view, decidePolicyFor: MockExternalNavigationAction(url: link, navigationType: .other)) { policy in XCTAssertEqual(policy, .cancel) } - wait(for: [didClickLinkExpectation], timeout: 1) + XCTAssertEqual(XCTWaiter().wait(for: [received], timeout: 2.0), .completed) + let components = URLComponents(url: try XCTUnwrap(capturedURL), resolvingAgainstBaseURL: false) + XCTAssertNil(components?.queryItems?.first(where: { $0.name == "open_externally" }), + "open_externally query item should be stripped") + XCTAssertEqual(capturedURL?.path, "/legal/privacy/app-users") + XCTAssertEqual(capturedURL?.host, "www.shopify.com") } func test403responseOnCheckoutURLCodeDelegation() throws { @@ -498,6 +513,77 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertTrue(MockCheckoutBridge.sendResponseCalled) } + + // MARK: - ec.window.open_request + + func testWindowOpenRequestUsesConsumerOverride() throws { + let id = "req-window-1" + let body = #"{"jsonrpc":"2.0","method":"ec.window.open_request","id":"\#(id)","params":{"url":"https://example.com/terms"}}"# + let received = TestExpectation() + view.client = CheckoutProtocol.Client() + .on(CheckoutProtocol.windowOpen) { _ in + received.fulfill() + return .rejected(reason: "consumer override") + } + let message = MockScriptMessage(body: body) + + view.userContentController(WKUserContentController(), didReceive: message) + + let result = XCTWaiter().wait(for: [received], timeout: 2.0) + XCTAssertEqual(result, .completed) + + let exp = expectation(description: "response sent") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { exp.fulfill() } + wait(for: [exp], timeout: 2.0) + + let response = try XCTUnwrap(MockCheckoutBridge.lastResponseBody) + let parsed = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + XCTAssertEqual(parsed["id"] as? String, id) + let resultBody = try XCTUnwrap(parsed["result"] as? [String: Any]) + let ucp = try XCTUnwrap(resultBody["ucp"] as? [String: Any]) + XCTAssertEqual(ucp["status"] as? String, "error") + let messages = try XCTUnwrap(resultBody["messages"] as? [[String: Any]]) + XCTAssertEqual(messages.first?["content"] as? String, "consumer override") + XCTAssertEqual(messages.first?["code"] as? String, "window_open_rejected_error") + } + + func testWindowOpenRequestFallsBackToDefaultHandler() throws { + let body = #"{"jsonrpc":"2.0","method":"ec.window.open_request","id":"req-window-1","params":{"url":"unhandled-scheme://nowhere"}}"# + view.client = nil + let message = MockScriptMessage(body: body) + + view.userContentController(WKUserContentController(), didReceive: message) + + let exp = expectation(description: "default handler responds") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { exp.fulfill() } + wait(for: [exp], timeout: 2.0) + + XCTAssertTrue(MockCheckoutBridge.sendResponseCalled) + let response = try XCTUnwrap(MockCheckoutBridge.lastResponseBody) + let parsed = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + XCTAssertEqual(parsed["id"] as? String, "req-window-1") + let resultBody = try XCTUnwrap(parsed["result"] as? [String: Any]) + let ucp = try XCTUnwrap(resultBody["ucp"] as? [String: Any]) + XCTAssertEqual(ucp["status"] as? String, "error", + "Default handler should reject schemes that canOpenURL refuses") + } + + func testWindowOpenRequestIgnoresMalformedBody() { + view.client = nil + let message = MockScriptMessage(body: #"{"jsonrpc":"2.0","method":"ec.window.open_request","id":"r","params":{}}"#) + + view.userContentController(WKUserContentController(), didReceive: message) + + let exp = expectation(description: "process drains") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { exp.fulfill() } + wait(for: [exp], timeout: 2.0) + + XCTAssertFalse(MockCheckoutBridge.sendResponseCalled) + } +} + +private final class TestExpectation: XCTestExpectation { + init() { super.init(description: "received") } } class LoadedRequestObservableWebView: CheckoutWebView { diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/Mocks/MockCheckoutWebViewDelegate.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/Mocks/MockCheckoutWebViewDelegate.swift index cb8b0c94..85a29645 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/Mocks/MockCheckoutWebViewDelegate.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/Mocks/MockCheckoutWebViewDelegate.swift @@ -29,7 +29,6 @@ class MockCheckoutWebViewDelegate: CheckoutWebViewDelegate { var didStartNavigationExpectation: XCTestExpectation? var didFinishNavigationExpectation: XCTestExpectation? - var didClickLinkExpectation: XCTestExpectation? var didFailWithErrorExpectation: XCTestExpectation? func checkoutViewDidStartNavigation() { @@ -40,10 +39,6 @@ class MockCheckoutWebViewDelegate: CheckoutWebViewDelegate { didFinishNavigationExpectation?.fulfill() } - func checkoutViewDidClickLink(url _: URL) { - didClickLinkExpectation?.fulfill() - } - func checkoutViewDidFailWithError(error: CheckoutError) { errorReceived = error didFailWithErrorExpectation?.fulfill() diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol+URL.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol+URL.swift index f3560720..92a2f518 100644 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol+URL.swift +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol+URL.swift @@ -26,14 +26,21 @@ import Foundation extension CheckoutProtocol { /// Returns the given checkout URL with the query parameters required to /// initiate the Embedded Checkout Protocol handshake (`ec_version`, - /// `ec_color_scheme`). - public static func url(for url: URL, colorScheme: String) -> URL { + /// `ec_color_scheme`, `ec_delegate`). + public static func url( + for url: URL, + colorScheme: String, + delegations: [String] = defaultDelegations + ) -> URL { guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url } var queryItems = components.queryItems ?? [] queryItems.append(URLQueryItem(name: "ec_version", value: specVersion)) queryItems.append(URLQueryItem(name: "ec_color_scheme", value: colorScheme)) + if !delegations.isEmpty { + queryItems.append(URLQueryItem(name: "ec_delegate", value: delegations.joined(separator: ","))) + } components.queryItems = queryItems return components.url ?? url } diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol.swift index 61a85501..601de446 100644 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol.swift +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol.swift @@ -24,6 +24,8 @@ public enum CheckoutProtocol { public static let specVersion = "2026-04-08" + public static let defaultDelegations: [String] = ["window.open"] + static let buyerChange = NotificationDescriptor(method: "ec.buyer.change") public static let complete = NotificationDescriptor(method: "ec.complete") public static let error = NotificationDescriptor(method: "ec.error") diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Client.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Client.swift index 7088c6d2..8a2b95ff 100644 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Client.swift +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Client.swift @@ -27,6 +27,7 @@ extension CheckoutProtocol { public struct Client: Sendable, MutableCopyable { private var notificationHandlers: [String: @MainActor @Sendable (any EventPayload) -> Void] private var delegationEntries: [String: DelegationEntry] + var delegations: [String] { delegationEntries.values.map(\.delegation) } @@ -52,13 +53,13 @@ extension CheckoutProtocol { @discardableResult public func on( _ descriptor: DelegationDescriptor, - perform: @escaping @MainActor (P) async -> R + perform: @escaping @MainActor @Sendable (P) async -> R ) -> Client { return copy { $0.delegationEntries[descriptor.method] = DelegationEntry( delegation: descriptor.delegation, - handler: { id, checkout in - guard let payload = checkout as? P else { return nil } + handler: { id, params in + guard let payload = descriptor.decode(params) else { return nil } let result = await perform(payload) return CheckoutProtocol.encodeResponse(id: id, result: result) } @@ -70,16 +71,17 @@ extension CheckoutProtocol { let decoded = CheckoutProtocol.decode(jsonRpc: message) switch decoded { - case let .ready(id, _): - return CheckoutProtocol.encodeReadyResponse(id: id) + case let .ready(id, requested): + let accepted = requested.filter(Set(delegations).contains) + return CheckoutProtocol.encodeReadyResponse(id: id, acceptedDelegations: accepted) case let .notification(method, payload): await notificationHandlers[method]?(payload) return nil - case let .request(id, method, checkout): + case let .request(id, method, params): if let entry = delegationEntries[method] { - return await entry.handler(id, checkout) + return await entry.handler(id, params) } return nil @@ -91,6 +93,6 @@ extension CheckoutProtocol { struct DelegationEntry { let delegation: String - let handler: @MainActor @Sendable (String, Checkout) async -> String? + let handler: @MainActor @Sendable (String, Data) async -> String? } } diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Codec.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Codec.swift index 8cf038d3..da475f8e 100644 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Codec.swift +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Codec.swift @@ -25,11 +25,15 @@ import Foundation extension CheckoutProtocol { /// Returns an `ec.ready` response if the given message is an `ec.ready` request, - /// otherwise `nil`. Lets the kit acknowledge the handshake without surfacing it - /// to consumers. - public static func acknowledgeReady(_ message: String) -> String? { - guard case let .ready(id, _) = decode(jsonRpc: message) else { return nil } - return encodeReadyResponse(id: id) + /// otherwise `nil`. The response echoes the intersection of the merchant's + /// requested delegations with `supportedDelegations` under a `delegate` array. + public static func acknowledgeReady( + _ message: String, + supportedDelegations: [String] = CheckoutProtocol.defaultDelegations + ) -> String? { + guard case let .ready(id, requested) = decode(jsonRpc: message) else { return nil } + let accepted = requested.filter(Set(supportedDelegations).contains) + return encodeReadyResponse(id: id, acceptedDelegations: accepted) } } @@ -52,15 +56,30 @@ extension CheckoutProtocol { return .notification(method: request.method, payload: error) } - guard let checkout = request.params?.checkout else { - return .unknown(method: request.method, rawParams: jsonRpc) + if let id = request.id { + return .request( + id: id, + method: request.method, + params: extractParamsData(from: data) + ) } - if let id = request.id { - return .request(id: id, method: request.method, checkout: checkout) + if let checkout = request.params?.checkout { + return .notification(method: request.method, payload: checkout) } - return .notification(method: request.method, payload: checkout) + return .unknown(method: request.method, rawParams: jsonRpc) + } + + private static func extractParamsData(from envelope: Data) -> Data { + guard + let object = try? JSONSerialization.jsonObject(with: envelope) as? [String: Any], + let params = object["params"], + let data = try? JSONSerialization.data(withJSONObject: params) + else { + return Data("{}".utf8) + } + return data } static func encodeResponse(id: String, result: some Encodable) -> String { @@ -71,8 +90,11 @@ extension CheckoutProtocol { return String(data: data, encoding: .utf8) ?? "{}" } - static func encodeReadyResponse(id: String) -> String { - let result = ReadyResult(ucp: UCPSuccess(version: specVersion)) + static func encodeReadyResponse(id: String, acceptedDelegations: [String]) -> String { + let result = UCPSuccessResult( + ucp: UCPSuccess(version: specVersion), + delegate: acceptedDelegations.isEmpty ? nil : acceptedDelegations + ) return encodeResponse(id: id, result: result) } } @@ -83,11 +105,22 @@ private struct JSONRPCResponse: Encodable { let result: R } -private struct ReadyResult: Encodable { +struct UCPSuccessResult: Encodable { let ucp: UCPSuccess + let delegate: [String]? + + init(ucp: UCPSuccess, delegate: [String]? = nil) { + self.ucp = ucp + self.delegate = delegate + } } -private struct UCPSuccess: Encodable { +struct UCPSuccess: Encodable { let version: String let status = "success" } + +struct UCPError: Encodable { + let version: String + let status = "error" +} diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Descriptors.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Descriptors.swift index b1da61fe..7684b384 100644 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Descriptors.swift +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Descriptors.swift @@ -21,6 +21,8 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import Foundation + /// Marker protocol that constrains which types can be used as descriptor payloads. /// This must be a protocol (not a typealias for `Decodable & Sendable`) so that /// conformance is explicit — preventing arbitrary types like `[String]` or `Int` @@ -43,4 +45,15 @@ public struct NotificationDescriptor: Sendable { public struct DelegationDescriptor: Sendable { public let method: String public let delegation: String + let decode: @Sendable (Data) -> Payload? + + public init( + method: String, + delegation: String, + decode: @escaping @Sendable (Data) -> Payload? + ) { + self.method = method + self.delegation = delegation + self.decode = decode + } } diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/JSONRPCMessage.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/JSONRPCMessage.swift index e01a9d66..b1b469a5 100644 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/JSONRPCMessage.swift +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/JSONRPCMessage.swift @@ -34,4 +34,5 @@ struct JSONRPCParams: Decodable { let checkout: Checkout? let delegate: [String]? let error: ErrorResponse? + let url: String? } diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/UCPMessage.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/UCPMessage.swift index c20eb3b2..3bebdf8a 100644 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/UCPMessage.swift +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/UCPMessage.swift @@ -21,9 +21,11 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import Foundation + enum UCPMessage { case notification(method: String, payload: any EventPayload & Sendable) - case request(id: String, method: String, checkout: Checkout) + case request(id: String, method: String, params: Data) case ready(id: String, delegations: [String]) case unknown(method: String, rawParams: String) } diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/WindowOpen.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/WindowOpen.swift new file mode 100644 index 00000000..f214339e --- /dev/null +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/WindowOpen.swift @@ -0,0 +1,97 @@ +/* + MIT License + + Copyright 2023 - Present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import Foundation + +public struct WindowOpenRequest: EventPayload { + public let url: URL + + public init(url: URL) { + self.url = url + } + + private enum CodingKeys: String, CodingKey { + case url + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let raw = try container.decode(String.self, forKey: .url) + guard !raw.isEmpty, let parsed = URL(string: raw) else { + throw DecodingError.dataCorruptedError( + forKey: .url, + in: container, + debugDescription: "Invalid URL string: '\(raw)'" + ) + } + url = parsed + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(url.absoluteString, forKey: .url) + } +} + +public enum WindowOpenResult: ResponsePayload { + case success + case rejected(reason: String?) + + public func encode(to encoder: Encoder) throws { + switch self { + case .success: + try UCPSuccessResult( + ucp: UCPSuccess(version: CheckoutProtocol.specVersion) + ).encode(to: encoder) + case let .rejected(reason): + try WindowOpenRejectedBody( + ucp: UCPError(version: CheckoutProtocol.specVersion), + messages: [ + WindowOpenRejectedMessage(content: reason ?? "Window open rejected") + ] + ).encode(to: encoder) + } + } +} + +extension CheckoutProtocol { + public static let windowOpen = DelegationDescriptor( + method: "ec.window.open_request", + delegation: "window.open", + decode: { params in + try? JSONDecoder().decode(WindowOpenRequest.self, from: params) + } + ) +} + +private struct WindowOpenRejectedBody: Encodable { + let ucp: UCPError + let messages: [WindowOpenRejectedMessage] +} + +private struct WindowOpenRejectedMessage: Encodable { + let type = "error" + let code = "window_open_rejected_error" + let content: String + let severity = "unrecoverable" +} diff --git a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CheckoutProtocolURLTests.swift b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CheckoutProtocolURLTests.swift new file mode 100644 index 00000000..55a625ef --- /dev/null +++ b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CheckoutProtocolURLTests.swift @@ -0,0 +1,79 @@ +/* + MIT License + + Copyright 2023 - Present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import Foundation +@testable import ShopifyCheckoutProtocol +import Testing + +@Suite("CheckoutProtocol URL Tests") +struct CheckoutProtocolURLTests { + @Test func appendsVersionAndColorScheme() throws { + let input = URL(string: "https://shop.example.com/checkout")! + let result = CheckoutProtocol.url(for: input, colorScheme: "automatic") + + let components = try #require(URLComponents(url: result, resolvingAgainstBaseURL: false)) + let items = try #require(components.queryItems) + #expect(items.contains(URLQueryItem(name: "ec_version", value: CheckoutProtocol.specVersion))) + #expect(items.contains(URLQueryItem(name: "ec_color_scheme", value: "automatic"))) + } + + @Test func appendsDefaultDelegate() throws { + let input = URL(string: "https://shop.example.com/checkout")! + let result = CheckoutProtocol.url(for: input, colorScheme: "light") + + let components = try #require(URLComponents(url: result, resolvingAgainstBaseURL: false)) + let items = try #require(components.queryItems) + #expect(items.contains(URLQueryItem(name: "ec_delegate", value: "window.open"))) + } + + @Test func joinsMultipleDelegationsWithComma() throws { + let input = URL(string: "https://shop.example.com/checkout")! + let result = CheckoutProtocol.url( + for: input, + colorScheme: "light", + delegations: ["window.open", "payment.credential"] + ) + + let components = try #require(URLComponents(url: result, resolvingAgainstBaseURL: false)) + let items = try #require(components.queryItems) + #expect(items.contains(URLQueryItem(name: "ec_delegate", value: "window.open,payment.credential"))) + } + + @Test func omitsDelegateWhenEmpty() throws { + let input = URL(string: "https://shop.example.com/checkout")! + let result = CheckoutProtocol.url(for: input, colorScheme: "light", delegations: []) + + let components = try #require(URLComponents(url: result, resolvingAgainstBaseURL: false)) + let items = try #require(components.queryItems) + #expect(!items.contains(where: { $0.name == "ec_delegate" })) + } + + @Test func preservesExistingQueryItems() throws { + let input = URL(string: "https://shop.example.com/checkout?cart=abc")! + let result = CheckoutProtocol.url(for: input, colorScheme: "light") + + let components = try #require(URLComponents(url: result, resolvingAgainstBaseURL: false)) + let items = try #require(components.queryItems) + #expect(items.contains(URLQueryItem(name: "cart", value: "abc"))) + } +} diff --git a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/ClientTests.swift b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/ClientTests.swift index 255a9314..5e7ec0f6 100644 --- a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/ClientTests.swift +++ b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/ClientTests.swift @@ -95,6 +95,90 @@ struct ClientTests { #expect(response == nil) } + @Test @MainActor func windowOpenRequestDispatchesToRegisteredHandler() async throws { + let request = #""" + {"jsonrpc":"2.0","id":"req-window-1","method":"ec.window.open_request","params":{"url":"https://example.com/terms"}} + """# + + var receivedURL: URL? + let client = CheckoutProtocol.Client() + .on(CheckoutProtocol.windowOpen) { payload in + receivedURL = payload.url + return .success + } + + let response = try #require(await client.process(request)) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + + #expect(parsed["id"] as? String == "req-window-1") + let result = try #require(parsed["result"] as? [String: Any]) + let ucp = try #require(result["ucp"] as? [String: Any]) + #expect(ucp["status"] as? String == "success") + #expect(receivedURL == URL(string: "https://example.com/terms")) + } + + @Test @MainActor func windowOpenRequestEncodesRejectedResult() async throws { + let request = #""" + {"jsonrpc":"2.0","id":"req-window-1","method":"ec.window.open_request","params":{"url":"https://example.com"}} + """# + + let client = CheckoutProtocol.Client() + .on(CheckoutProtocol.windowOpen) { _ in + .rejected(reason: "no presenter available") + } + + let response = try #require(await client.process(request)) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + + let result = try #require(parsed["result"] as? [String: Any]) + let ucp = try #require(result["ucp"] as? [String: Any]) + #expect(ucp["status"] as? String == "error") + + let messages = try #require(result["messages"] as? [[String: Any]]) + #expect(messages[0]["content"] as? String == "no presenter available") + } + + @Test @MainActor func windowOpenRequestReturnsNilWhenHandlerNotRegistered() async { + let client = CheckoutProtocol.Client() + let request = #""" + {"jsonrpc":"2.0","id":"req-window-1","method":"ec.window.open_request","params":{"url":"https://example.com"}} + """# + + let response = await client.process(request) + #expect(response == nil) + } + + @Test @MainActor func windowOpenRequestLastHandlerWins() async throws { + let request = #""" + {"jsonrpc":"2.0","id":"req-window-1","method":"ec.window.open_request","params":{"url":"https://example.com"}} + """# + + let client = CheckoutProtocol.Client() + .on(CheckoutProtocol.windowOpen) { _ in .rejected(reason: "first") } + .on(CheckoutProtocol.windowOpen) { _ in .success } + + let response = try #require(await client.process(request)) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + let result = try #require(parsed["result"] as? [String: Any]) + let ucp = try #require(result["ucp"] as? [String: Any]) + #expect(ucp["status"] as? String == "success") + } + + @Test @MainActor func windowOpenRequestAdvertisesDelegationInReadyResponse() async throws { + let ready = #""" + {"jsonrpc":"2.0","id":"ready-1","method":"ec.ready","params":{"delegate":["window.open"]}} + """# + + let client = CheckoutProtocol.Client() + .on(CheckoutProtocol.windowOpen) { _ in .success } + + let response = try #require(await client.process(ready)) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + let result = try #require(parsed["result"] as? [String: Any]) + let delegate = try #require(result["delegate"] as? [String]) + #expect(delegate == ["window.open"]) + } + @Test @MainActor func readyReturnsResponse() async throws { let client = CheckoutProtocol.Client() diff --git a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecDecodeTests.swift b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecDecodeTests.swift index 58d7ce53..6c369774 100644 --- a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecDecodeTests.swift +++ b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecDecodeTests.swift @@ -62,19 +62,50 @@ struct CodecDecodeTests { #expect(error.messages.first?.content == "Boom.") } - @Test func decodesRequest() throws { + @Test func decodesRequestCarriesRawParams() throws { let json = try fixtureString("request") let message = CheckoutProtocol.decode(jsonRpc: json) - guard case let .request(id, method, checkout) = message else { + guard case let .request(id, method, params) = message else { Issue.record("Expected .request, got \(message)") return } #expect(id == "req-456") #expect(method == "ec.payment.credential_request") - #expect(checkout.id == "checkout-789") - #expect(checkout.currency == "CAD") + + let parsed = try #require( + JSONSerialization.jsonObject(with: params) as? [String: Any] + ) + let checkout = try #require(parsed["checkout"] as? [String: Any]) + #expect(checkout["id"] as? String == "checkout-789") + #expect(checkout["currency"] as? String == "CAD") + } + + @Test func decodesWindowOpenRequest() throws { + let json = try fixtureString("window_open_request") + let message = CheckoutProtocol.decode(jsonRpc: json) + + guard case let .request(id, method, params) = message else { + Issue.record("Expected .request, got \(message)") + return + } + + #expect(id == "req-window-1") + #expect(method == "ec.window.open_request") + + let payload = try #require(CheckoutProtocol.windowOpen.decode(params)) + #expect(payload.url == URL(string: "https://example.com/terms")) + } + + @Test func windowOpenDescriptorRejectsEmptyURL() { + let params = Data(#"{"url":""}"#.utf8) + #expect(CheckoutProtocol.windowOpen.decode(params) == nil) + } + + @Test func windowOpenDescriptorRejectsMissingURL() { + let params = Data("{}".utf8) + #expect(CheckoutProtocol.windowOpen.decode(params) == nil) } @Test func decodesUnknownMethod() { diff --git a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecEncodeTests.swift b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecEncodeTests.swift index 6a6199a3..2fd94db7 100644 --- a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecEncodeTests.swift +++ b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecEncodeTests.swift @@ -51,7 +51,7 @@ struct CodecEncodeTests { } @Test func encodesReadyResponseWithResultEnvelope() throws { - let json = CheckoutProtocol.encodeReadyResponse(id: "ready-1") + let json = CheckoutProtocol.encodeReadyResponse(id: "ready-1", acceptedDelegations: []) let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) #expect(parsed["jsonrpc"] as? String == "2.0") @@ -63,6 +63,16 @@ struct CodecEncodeTests { let ucp = try #require(result["ucp"] as? [String: Any]) #expect(ucp["version"] as? String == CheckoutProtocol.specVersion) #expect(ucp["status"] as? String == "success") + #expect(result["delegate"] == nil, "Empty delegate list must be omitted") + } + + @Test func encodesReadyResponseEchoesAcceptedDelegations() throws { + let json = CheckoutProtocol.encodeReadyResponse(id: "ready-1", acceptedDelegations: ["window.open"]) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + + let result = try #require(parsed["result"] as? [String: Any]) + let delegate = try #require(result["delegate"] as? [String]) + #expect(delegate == ["window.open"]) } @Test func acknowledgeReadyReturnsResponseForReadyMessage() throws { @@ -80,6 +90,31 @@ struct CodecEncodeTests { #expect(ucp["status"] as? String == "success") } + @Test func acknowledgeReadyEchoesIntersectionWithSupportedDelegations() throws { + let message = #""" + {"jsonrpc":"2.0","id":"ready-1","method":"ec.ready","params":{"delegate":["payment.credential","window.open","fulfillment.address_change"]}} + """# + + let response = try #require(CheckoutProtocol.acknowledgeReady(message, supportedDelegations: ["window.open"])) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + + let result = try #require(parsed["result"] as? [String: Any]) + let delegate = try #require(result["delegate"] as? [String]) + #expect(delegate == ["window.open"]) + } + + @Test func acknowledgeReadyOmitsDelegateWhenNoIntersection() throws { + let message = #""" + {"jsonrpc":"2.0","id":"ready-1","method":"ec.ready","params":{"delegate":["payment.credential"]}} + """# + + let response = try #require(CheckoutProtocol.acknowledgeReady(message, supportedDelegations: ["window.open"])) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + + let result = try #require(parsed["result"] as? [String: Any]) + #expect(result["delegate"] == nil) + } + @Test func acknowledgeReadyReturnsNilForNonReadyMessage() { let message = #""" {"jsonrpc":"2.0","method":"ec.start","params":{"checkout":{"id":"c"}}} @@ -91,4 +126,48 @@ struct CodecEncodeTests { @Test func acknowledgeReadyReturnsNilForMalformedJSON() { #expect(CheckoutProtocol.acknowledgeReady("not json") == nil) } + + @Test func windowOpenResultEncodesSuccessBody() throws { + let json = CheckoutProtocol.encodeResponse(id: "req-window-1", result: WindowOpenResult.success) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + + #expect(parsed["jsonrpc"] as? String == "2.0") + #expect(parsed["id"] as? String == "req-window-1") + let result = try #require(parsed["result"] as? [String: Any]) + let ucp = try #require(result["ucp"] as? [String: Any]) + #expect(ucp["status"] as? String == "success") + #expect(ucp["version"] as? String == CheckoutProtocol.specVersion) + } + + @Test func windowOpenResultEncodesRejectedBody() throws { + let json = CheckoutProtocol.encodeResponse( + id: "req-window-1", + result: WindowOpenResult.rejected(reason: "canOpenURL returned false") + ) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + + #expect(parsed["id"] as? String == "req-window-1") + let result = try #require(parsed["result"] as? [String: Any]) + let ucp = try #require(result["ucp"] as? [String: Any]) + #expect(ucp["status"] as? String == "error") + + let messages = try #require(result["messages"] as? [[String: Any]]) + #expect(messages.count == 1) + #expect(messages[0]["type"] as? String == "error") + #expect(messages[0]["code"] as? String == "window_open_rejected_error") + #expect(messages[0]["severity"] as? String == "unrecoverable") + #expect(messages[0]["content"] as? String == "canOpenURL returned false") + } + + @Test func windowOpenResultEncodesRejectedWithNilReason() throws { + let json = CheckoutProtocol.encodeResponse( + id: "req-window-1", + result: WindowOpenResult.rejected(reason: nil) + ) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + + let result = try #require(parsed["result"] as? [String: Any]) + let messages = try #require(result["messages"] as? [[String: Any]]) + #expect(messages[0]["content"] as? String != "", "Content is required per message_error schema") + } } diff --git a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/Fixtures/window_open_request.json b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/Fixtures/window_open_request.json new file mode 100644 index 00000000..b2d70a74 --- /dev/null +++ b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/Fixtures/window_open_request.json @@ -0,0 +1,8 @@ +{ + "jsonrpc": "2.0", + "method": "ec.window.open_request", + "id": "req-window-1", + "params": { + "url": "https://example.com/terms" + } +} From 7df30fd1c5b62723466231c2de7f3abf7f33ddce Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Wed, 13 May 2026 16:56:24 +0100 Subject: [PATCH 2/6] Fix CI: format, lint, and Swift 6 sendability in tests - SwiftFormat: hoist try, wrap arguments/function bodies, extension access control - Replace force-unwraps in CheckoutProtocolURLTests with try #require - Use CapturedURLHolder reference to avoid Swift 6 captured-var warnings in link-dispatch tests - Use XCTestCase expectation+wait pattern for deterministic main-runloop draining --- .../Sources/CheckoutProtocolClient.swift | 4 +- .../CheckoutWebViewTests.swift | 121 ++++++++++-------- .../CheckoutProtocolURLTests.swift | 10 +- 3 files changed, 74 insertions(+), 61 deletions(-) diff --git a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/CheckoutProtocolClient.swift b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/CheckoutProtocolClient.swift index d7aa11c4..171b5951 100644 --- a/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/CheckoutProtocolClient.swift +++ b/platforms/swift/Samples/MobileBuyIntegration/MobileBuyIntegration/Sources/CheckoutProtocolClient.swift @@ -74,8 +74,8 @@ extension CheckoutProtocol.Client { } } -private extension UIApplication { - var foregroundActiveWindow: UIWindow? { +extension UIApplication { + fileprivate var foregroundActiveWindow: UIWindow? { let activeScenes = connectedScenes .compactMap { $0 as? UIWindowScene } .filter { $0.activationState == .foregroundActive } diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index ae22e750..fa137bc4 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -34,6 +34,7 @@ class CheckoutWebViewTests: XCTestCase { override func setUp() { ShopifyCheckoutKit.configuration.preloading.enabled = true + CheckoutWebView.invalidate() view = CheckoutWebView.for(checkout: url) mockDelegate = MockCheckoutWebViewDelegate() view.viewDelegate = mockDelegate @@ -41,6 +42,12 @@ class CheckoutWebViewTests: XCTestCase { MockCheckoutBridge.reset() } + override func tearDown() { + view.viewDelegate = nil + CheckoutWebView.invalidate() + super.tearDown() + } + private func createRecoveryAgent() -> CheckoutWebView { recovery = CheckoutWebView.for(checkout: url, recovery: true) mockDelegate = MockCheckoutWebViewDelegate() @@ -68,13 +75,14 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertFalse(recovery.isOpaque) } - func testEmailContactLinkDispatchesWindowOpen() throws { + @MainActor + func testEmailContactLinkDispatchesWindowOpen() async throws { let link = try XCTUnwrap(URL(string: "mailto:contact@shopify.com")) - let received = TestExpectation() - var capturedURL: URL? + let holder = CapturedURLHolder() + let received = expectation(description: "windowOpen handler fired") view.client = CheckoutProtocol.Client() .on(CheckoutProtocol.windowOpen) { request in - capturedURL = request.url + holder.url = request.url received.fulfill() return .success } @@ -83,17 +91,18 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertEqual(policy, .cancel) } - XCTAssertEqual(XCTWaiter().wait(for: [received], timeout: 2.0), .completed) - XCTAssertEqual(capturedURL, link) + await fulfillment(of: [received], timeout: 5.0) + XCTAssertEqual(holder.url, link) } - func testPhoneContactLinkDispatchesWindowOpen() throws { + @MainActor + func testPhoneContactLinkDispatchesWindowOpen() async throws { let link = try XCTUnwrap(URL(string: "tel:1234567890")) - let received = TestExpectation() - var capturedURL: URL? + let holder = CapturedURLHolder() + let received = expectation(description: "windowOpen handler fired") view.client = CheckoutProtocol.Client() .on(CheckoutProtocol.windowOpen) { request in - capturedURL = request.url + holder.url = request.url received.fulfill() return .success } @@ -102,17 +111,18 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertEqual(policy, .cancel) } - XCTAssertEqual(XCTWaiter().wait(for: [received], timeout: 2.0), .completed) - XCTAssertEqual(capturedURL, link) + await fulfillment(of: [received], timeout: 5.0) + XCTAssertEqual(holder.url, link) } - func testURLLinkDispatchesWindowOpen() throws { + @MainActor + func testURLLinkDispatchesWindowOpen() async throws { let link = try XCTUnwrap(URL(string: "https://www.shopify.com/legal/privacy/app-users")) - let received = TestExpectation() - var capturedURL: URL? + let holder = CapturedURLHolder() + let received = expectation(description: "windowOpen handler fired") view.client = CheckoutProtocol.Client() .on(CheckoutProtocol.windowOpen) { request in - capturedURL = request.url + holder.url = request.url received.fulfill() return .success } @@ -121,17 +131,18 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertEqual(policy, .cancel) } - XCTAssertEqual(XCTWaiter().wait(for: [received], timeout: 2.0), .completed) - XCTAssertEqual(capturedURL, link) + await fulfillment(of: [received], timeout: 5.0) + XCTAssertEqual(holder.url, link) } - func testDeepLinkDispatchesWindowOpen() throws { + @MainActor + func testDeepLinkDispatchesWindowOpen() async throws { let link = try XCTUnwrap(URL(string: "shopify://app/privacy")) - let received = TestExpectation() - var capturedURL: URL? + let holder = CapturedURLHolder() + let received = expectation(description: "windowOpen handler fired") view.client = CheckoutProtocol.Client() .on(CheckoutProtocol.windowOpen) { request in - capturedURL = request.url + holder.url = request.url received.fulfill() return .success } @@ -140,17 +151,18 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertEqual(policy, .cancel) } - XCTAssertEqual(XCTWaiter().wait(for: [received], timeout: 2.0), .completed) - XCTAssertEqual(capturedURL, link) + await fulfillment(of: [received], timeout: 5.0) + XCTAssertEqual(holder.url, link) } - func testURLLinkWithExternalParamDispatchesWindowOpenWithoutParam() throws { + @MainActor + func testURLLinkWithExternalParamDispatchesWindowOpenWithoutParam() async throws { let link = try XCTUnwrap(URL(string: "https://www.shopify.com/legal/privacy/app-users?open_externally=true")) - let received = TestExpectation() - var capturedURL: URL? + let holder = CapturedURLHolder() + let received = expectation(description: "windowOpen handler fired") view.client = CheckoutProtocol.Client() .on(CheckoutProtocol.windowOpen) { request in - capturedURL = request.url + holder.url = request.url received.fulfill() return .success } @@ -159,12 +171,15 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertEqual(policy, .cancel) } - XCTAssertEqual(XCTWaiter().wait(for: [received], timeout: 2.0), .completed) - let components = URLComponents(url: try XCTUnwrap(capturedURL), resolvingAgainstBaseURL: false) - XCTAssertNil(components?.queryItems?.first(where: { $0.name == "open_externally" }), - "open_externally query item should be stripped") - XCTAssertEqual(capturedURL?.path, "/legal/privacy/app-users") - XCTAssertEqual(capturedURL?.host, "www.shopify.com") + await fulfillment(of: [received], timeout: 5.0) + let captured = try XCTUnwrap(holder.url) + let components = URLComponents(url: captured, resolvingAgainstBaseURL: false) + XCTAssertNil( + components?.queryItems?.first(where: { $0.name == "open_externally" }), + "open_externally query item should be stripped" + ) + XCTAssertEqual(captured.path, "/legal/privacy/app-users") + XCTAssertEqual(captured.host, "www.shopify.com") } func test403responseOnCheckoutURLCodeDelegation() throws { @@ -516,10 +531,11 @@ class CheckoutWebViewTests: XCTestCase { // MARK: - ec.window.open_request - func testWindowOpenRequestUsesConsumerOverride() throws { + @MainActor + func testWindowOpenRequestUsesConsumerOverride() async throws { let id = "req-window-1" let body = #"{"jsonrpc":"2.0","method":"ec.window.open_request","id":"\#(id)","params":{"url":"https://example.com/terms"}}"# - let received = TestExpectation() + let received = expectation(description: "received") view.client = CheckoutProtocol.Client() .on(CheckoutProtocol.windowOpen) { _ in received.fulfill() @@ -529,12 +545,8 @@ class CheckoutWebViewTests: XCTestCase { view.userContentController(WKUserContentController(), didReceive: message) - let result = XCTWaiter().wait(for: [received], timeout: 2.0) - XCTAssertEqual(result, .completed) - - let exp = expectation(description: "response sent") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { exp.fulfill() } - wait(for: [exp], timeout: 2.0) + await fulfillment(of: [received], timeout: 2.0) + try await Task.sleep(nanoseconds: 200_000_000) let response = try XCTUnwrap(MockCheckoutBridge.lastResponseBody) let parsed = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) @@ -547,16 +559,15 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertEqual(messages.first?["code"] as? String, "window_open_rejected_error") } - func testWindowOpenRequestFallsBackToDefaultHandler() throws { + @MainActor + func testWindowOpenRequestFallsBackToDefaultHandler() async throws { let body = #"{"jsonrpc":"2.0","method":"ec.window.open_request","id":"req-window-1","params":{"url":"unhandled-scheme://nowhere"}}"# view.client = nil let message = MockScriptMessage(body: body) view.userContentController(WKUserContentController(), didReceive: message) - let exp = expectation(description: "default handler responds") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { exp.fulfill() } - wait(for: [exp], timeout: 2.0) + try await Task.sleep(nanoseconds: 500_000_000) XCTAssertTrue(MockCheckoutBridge.sendResponseCalled) let response = try XCTUnwrap(MockCheckoutBridge.lastResponseBody) @@ -564,26 +575,28 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertEqual(parsed["id"] as? String, "req-window-1") let resultBody = try XCTUnwrap(parsed["result"] as? [String: Any]) let ucp = try XCTUnwrap(resultBody["ucp"] as? [String: Any]) - XCTAssertEqual(ucp["status"] as? String, "error", - "Default handler should reject schemes that canOpenURL refuses") + XCTAssertEqual( + ucp["status"] as? String, + "error", + "Default handler should reject schemes that canOpenURL refuses" + ) } - func testWindowOpenRequestIgnoresMalformedBody() { + @MainActor + func testWindowOpenRequestIgnoresMalformedBody() async throws { view.client = nil let message = MockScriptMessage(body: #"{"jsonrpc":"2.0","method":"ec.window.open_request","id":"r","params":{}}"#) view.userContentController(WKUserContentController(), didReceive: message) - let exp = expectation(description: "process drains") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { exp.fulfill() } - wait(for: [exp], timeout: 2.0) + try await Task.sleep(nanoseconds: 300_000_000) XCTAssertFalse(MockCheckoutBridge.sendResponseCalled) } } -private final class TestExpectation: XCTestExpectation { - init() { super.init(description: "received") } +private final class CapturedURLHolder { + var url: URL? } class LoadedRequestObservableWebView: CheckoutWebView { diff --git a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CheckoutProtocolURLTests.swift b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CheckoutProtocolURLTests.swift index 55a625ef..76a0b5dc 100644 --- a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CheckoutProtocolURLTests.swift +++ b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CheckoutProtocolURLTests.swift @@ -28,7 +28,7 @@ import Testing @Suite("CheckoutProtocol URL Tests") struct CheckoutProtocolURLTests { @Test func appendsVersionAndColorScheme() throws { - let input = URL(string: "https://shop.example.com/checkout")! + let input = try #require(URL(string: "https://shop.example.com/checkout")) let result = CheckoutProtocol.url(for: input, colorScheme: "automatic") let components = try #require(URLComponents(url: result, resolvingAgainstBaseURL: false)) @@ -38,7 +38,7 @@ struct CheckoutProtocolURLTests { } @Test func appendsDefaultDelegate() throws { - let input = URL(string: "https://shop.example.com/checkout")! + let input = try #require(URL(string: "https://shop.example.com/checkout")) let result = CheckoutProtocol.url(for: input, colorScheme: "light") let components = try #require(URLComponents(url: result, resolvingAgainstBaseURL: false)) @@ -47,7 +47,7 @@ struct CheckoutProtocolURLTests { } @Test func joinsMultipleDelegationsWithComma() throws { - let input = URL(string: "https://shop.example.com/checkout")! + let input = try #require(URL(string: "https://shop.example.com/checkout")) let result = CheckoutProtocol.url( for: input, colorScheme: "light", @@ -60,7 +60,7 @@ struct CheckoutProtocolURLTests { } @Test func omitsDelegateWhenEmpty() throws { - let input = URL(string: "https://shop.example.com/checkout")! + let input = try #require(URL(string: "https://shop.example.com/checkout")) let result = CheckoutProtocol.url(for: input, colorScheme: "light", delegations: []) let components = try #require(URLComponents(url: result, resolvingAgainstBaseURL: false)) @@ -69,7 +69,7 @@ struct CheckoutProtocolURLTests { } @Test func preservesExistingQueryItems() throws { - let input = URL(string: "https://shop.example.com/checkout?cart=abc")! + let input = try #require(URL(string: "https://shop.example.com/checkout?cart=abc")) let result = CheckoutProtocol.url(for: input, colorScheme: "light") let components = try #require(URLComponents(url: result, resolvingAgainstBaseURL: false)) From c1f0a50596703b50f34e843dc94c9c828ea32570 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Wed, 13 May 2026 18:34:18 +0100 Subject: [PATCH 3/6] Bump timeouts to fix flakiness --- .../ShopifyCheckoutKitTests/CheckoutWebViewTests.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index fa137bc4..a6647953 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -91,7 +91,7 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertEqual(policy, .cancel) } - await fulfillment(of: [received], timeout: 5.0) + await fulfillment(of: [received], timeout: 15.0) XCTAssertEqual(holder.url, link) } @@ -111,7 +111,7 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertEqual(policy, .cancel) } - await fulfillment(of: [received], timeout: 5.0) + await fulfillment(of: [received], timeout: 15.0) XCTAssertEqual(holder.url, link) } @@ -131,7 +131,7 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertEqual(policy, .cancel) } - await fulfillment(of: [received], timeout: 5.0) + await fulfillment(of: [received], timeout: 15.0) XCTAssertEqual(holder.url, link) } @@ -151,7 +151,7 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertEqual(policy, .cancel) } - await fulfillment(of: [received], timeout: 5.0) + await fulfillment(of: [received], timeout: 15.0) XCTAssertEqual(holder.url, link) } @@ -171,7 +171,7 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertEqual(policy, .cancel) } - await fulfillment(of: [received], timeout: 5.0) + await fulfillment(of: [received], timeout: 15.0) let captured = try XCTUnwrap(holder.url) let components = URLComponents(url: captured, resolvingAgainstBaseURL: false) XCTAssertNil( From 7a987b55ba7799193e1001b323c7a49ba60e85e8 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Thu, 14 May 2026 11:09:40 +0100 Subject: [PATCH 4/6] Remove code comment --- .../Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index a6647953..a1482a31 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -529,8 +529,6 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertTrue(MockCheckoutBridge.sendResponseCalled) } - // MARK: - ec.window.open_request - @MainActor func testWindowOpenRequestUsesConsumerOverride() async throws { let id = "req-window-1" From 3d8622c7f07f2ddc9299b63a3d5eca7de2ed5148 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Thu, 14 May 2026 13:08:47 +0100 Subject: [PATCH 5/6] Remove external link handling, add UIApplication intercept for non-https --- .../ShopifyCheckoutKit/CheckoutWebView.swift | 59 +++++-------------- 1 file changed, 15 insertions(+), 44 deletions(-) diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index 59a64f61..fcba9431 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -280,40 +280,32 @@ extension CheckoutWebView: WKScriptMessageHandler { extension CheckoutWebView: WKNavigationDelegate { func webView(_: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + // Handle rare cases where the url is nil guard let url = action.request.url else { decisionHandler(.allow) return } - if isExternalLink(action) || CheckoutURL(from: url).isDeepLink() { - OSLogger.shared.debug("External or deep link clicked: \(url.absoluteString) - request intercepted") - dispatchWindowOpenRequest(url: removeExternalParam(url)) - decisionHandler(.cancel) + // Handle non-HTTP links triggered on external surfaces by opening them with UIApplication + // Scenarios include: + // - mailto:, tel: etc + // - Deep links on offsite payment sites + // + if CheckoutURL(from: url).isDeepLink() { + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + OSLogger.shared.debug("Deep link intercepted: \(url.absoluteString) - allowed") + return decisionHandler(.allow) + } else { + OSLogger.shared.debug("Deep link intercepted: \(url.absoluteString) - rejected") + return decisionHandler(.cancel) + } return } decisionHandler(.allow) } - private func dispatchWindowOpenRequest(url: URL) { - let envelope: [String: Any] = [ - "jsonrpc": "2.0", - "id": UUID().uuidString, - "method": "ec.window.open_request", - "params": ["url": url.absoluteString] - ] - - guard - let data = try? JSONSerialization.data(withJSONObject: envelope), - let message = String(data: data, encoding: .utf8) - else { return } - - Task { @MainActor [client] in - if let consumer = client, await consumer.process(message) != nil { return } - _ = await CheckoutWebView.defaultsClient.process(message) - } - } - func webView(_: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { if let response = navigationResponse.response as? HTTPURLResponse { decisionHandler(handleResponse(response)) @@ -432,27 +424,6 @@ extension CheckoutWebView: WKNavigationDelegate { ) } - private func isExternalLink(_ action: WKNavigationAction) -> Bool { - if action.navigationType == .linkActivated && action.targetFrame == nil { - return true - } - - guard let url = action.request.url else { return false } - guard let url = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false } - - guard let openExternally = url.queryItems?.first(where: { $0.name == "open_externally" })?.value else { return false } - - return openExternally.lowercased() == "true" || openExternally == "1" - } - - private func removeExternalParam(_ url: URL) -> URL { - guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return url - } - urlComponents.queryItems = urlComponents.queryItems?.filter { !($0.name == "open_externally") } - return urlComponents.url ?? url - } - private func isCheckout(url: URL?) -> Bool { return self.url == url } From 1393e819150738caf77f1302b43f89844a7f66e7 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Thu, 14 May 2026 16:24:15 +0100 Subject: [PATCH 6/6] Fix CI --- .../CheckoutWebViewTests.swift | 143 ++++-------------- 1 file changed, 33 insertions(+), 110 deletions(-) diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index a1482a31..1a30425a 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -75,111 +75,40 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertFalse(recovery.isOpaque) } - @MainActor - func testEmailContactLinkDispatchesWindowOpen() async throws { - let link = try XCTUnwrap(URL(string: "mailto:contact@shopify.com")) - let holder = CapturedURLHolder() - let received = expectation(description: "windowOpen handler fired") - view.client = CheckoutProtocol.Client() - .on(CheckoutProtocol.windowOpen) { request in - holder.url = request.url - received.fulfill() - return .success - } - - view.webView(view, decidePolicyFor: MockNavigationAction(url: link)) { policy in - XCTAssertEqual(policy, .cancel) - } - - await fulfillment(of: [received], timeout: 15.0) - XCTAssertEqual(holder.url, link) - } - - @MainActor - func testPhoneContactLinkDispatchesWindowOpen() async throws { - let link = try XCTUnwrap(URL(string: "tel:1234567890")) - let holder = CapturedURLHolder() - let received = expectation(description: "windowOpen handler fired") - view.client = CheckoutProtocol.Client() - .on(CheckoutProtocol.windowOpen) { request in - holder.url = request.url - received.fulfill() - return .success - } - - view.webView(view, decidePolicyFor: MockNavigationAction(url: link)) { policy in - XCTAssertEqual(policy, .cancel) - } - - await fulfillment(of: [received], timeout: 15.0) - XCTAssertEqual(holder.url, link) - } - - @MainActor - func testURLLinkDispatchesWindowOpen() async throws { + func testHTTPSLinkIsAllowed() throws { let link = try XCTUnwrap(URL(string: "https://www.shopify.com/legal/privacy/app-users")) - let holder = CapturedURLHolder() - let received = expectation(description: "windowOpen handler fired") - view.client = CheckoutProtocol.Client() - .on(CheckoutProtocol.windowOpen) { request in - holder.url = request.url - received.fulfill() - return .success - } + let received = expectation(description: "policy decided") view.webView(view, decidePolicyFor: MockExternalNavigationAction(url: link)) { policy in - XCTAssertEqual(policy, .cancel) + XCTAssertEqual(policy, .allow) + received.fulfill() } - await fulfillment(of: [received], timeout: 15.0) - XCTAssertEqual(holder.url, link) + wait(for: [received], timeout: 2.0) } - @MainActor - func testDeepLinkDispatchesWindowOpen() async throws { - let link = try XCTUnwrap(URL(string: "shopify://app/privacy")) - let holder = CapturedURLHolder() - let received = expectation(description: "windowOpen handler fired") - view.client = CheckoutProtocol.Client() - .on(CheckoutProtocol.windowOpen) { request in - holder.url = request.url - received.fulfill() - return .success - } + func testDeepLinkIsCancelledWhenUIApplicationCannotOpen() throws { + let link = try XCTUnwrap(URL(string: "unhandled-scheme://nowhere")) + let received = expectation(description: "policy decided") view.webView(view, decidePolicyFor: MockExternalNavigationAction(url: link)) { policy in - XCTAssertEqual(policy, .cancel) + XCTAssertEqual(policy, .cancel, "Schemes that canOpenURL refuses should be cancelled") + received.fulfill() } - await fulfillment(of: [received], timeout: 15.0) - XCTAssertEqual(holder.url, link) + wait(for: [received], timeout: 2.0) } - @MainActor - func testURLLinkWithExternalParamDispatchesWindowOpenWithoutParam() async throws { - let link = try XCTUnwrap(URL(string: "https://www.shopify.com/legal/privacy/app-users?open_externally=true")) - let holder = CapturedURLHolder() - let received = expectation(description: "windowOpen handler fired") - view.client = CheckoutProtocol.Client() - .on(CheckoutProtocol.windowOpen) { request in - holder.url = request.url - received.fulfill() - return .success - } + func testHTTPSubframeRequestIsAllowed() throws { + let link = try XCTUnwrap(URL(string: "https://shopify1.shopify.com/checkouts/cn/123")) + let received = expectation(description: "policy decided") - view.webView(view, decidePolicyFor: MockExternalNavigationAction(url: link, navigationType: .other)) { policy in - XCTAssertEqual(policy, .cancel) + view.webView(view, decidePolicyFor: MockNavigationAction(url: link)) { policy in + XCTAssertEqual(policy, .allow) + received.fulfill() } - await fulfillment(of: [received], timeout: 15.0) - let captured = try XCTUnwrap(holder.url) - let components = URLComponents(url: captured, resolvingAgainstBaseURL: false) - XCTAssertNil( - components?.queryItems?.first(where: { $0.name == "open_externally" }), - "open_externally query item should be stripped" - ) - XCTAssertEqual(captured.path, "/legal/privacy/app-users") - XCTAssertEqual(captured.host, "www.shopify.com") + wait(for: [received], timeout: 2.0) } func test403responseOnCheckoutURLCodeDelegation() throws { @@ -533,18 +462,17 @@ class CheckoutWebViewTests: XCTestCase { func testWindowOpenRequestUsesConsumerOverride() async throws { let id = "req-window-1" let body = #"{"jsonrpc":"2.0","method":"ec.window.open_request","id":"\#(id)","params":{"url":"https://example.com/terms"}}"# - let received = expectation(description: "received") + let responseSent = expectation(description: "response sent") + MockCheckoutBridge.sendResponseExpectation = responseSent view.client = CheckoutProtocol.Client() .on(CheckoutProtocol.windowOpen) { _ in - received.fulfill() - return .rejected(reason: "consumer override") + .rejected(reason: "consumer override") } let message = MockScriptMessage(body: body) view.userContentController(WKUserContentController(), didReceive: message) - await fulfillment(of: [received], timeout: 2.0) - try await Task.sleep(nanoseconds: 200_000_000) + await fulfillment(of: [responseSent], timeout: 2.0) let response = try XCTUnwrap(MockCheckoutBridge.lastResponseBody) let parsed = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) @@ -558,17 +486,11 @@ class CheckoutWebViewTests: XCTestCase { } @MainActor - func testWindowOpenRequestFallsBackToDefaultHandler() async throws { + func testDefaultsClientRejectsUnopenableScheme() async throws { let body = #"{"jsonrpc":"2.0","method":"ec.window.open_request","id":"req-window-1","params":{"url":"unhandled-scheme://nowhere"}}"# - view.client = nil - let message = MockScriptMessage(body: body) - - view.userContentController(WKUserContentController(), didReceive: message) - try await Task.sleep(nanoseconds: 500_000_000) - - XCTAssertTrue(MockCheckoutBridge.sendResponseCalled) - let response = try XCTUnwrap(MockCheckoutBridge.lastResponseBody) + let raw = await CheckoutWebView.defaultsClient.process(body) + let response = try XCTUnwrap(raw) let parsed = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) XCTAssertEqual(parsed["id"] as? String, "req-window-1") let resultBody = try XCTUnwrap(parsed["result"] as? [String: Any]) @@ -581,22 +503,20 @@ class CheckoutWebViewTests: XCTestCase { } @MainActor - func testWindowOpenRequestIgnoresMalformedBody() async throws { + func testWindowOpenRequestIgnoresMalformedBody() async { view.client = nil + let notFired = expectation(description: "sendResponse must not fire") + notFired.isInverted = true + MockCheckoutBridge.sendResponseExpectation = notFired let message = MockScriptMessage(body: #"{"jsonrpc":"2.0","method":"ec.window.open_request","id":"r","params":{}}"#) view.userContentController(WKUserContentController(), didReceive: message) - try await Task.sleep(nanoseconds: 300_000_000) - + await fulfillment(of: [notFired], timeout: 1.0) XCTAssertFalse(MockCheckoutBridge.sendResponseCalled) } } -private final class CapturedURLHolder { - var url: URL? -} - class LoadedRequestObservableWebView: CheckoutWebView { var lastLoadedURLRequest: URLRequest? var lastInstrumentationPayload: InstrumentationPayload? @@ -616,12 +536,14 @@ class MockCheckoutBridge: CheckoutBridgeProtocol { static var sendMessageCalled = false static var sendResponseCalled = false static var lastResponseBody: String? + static var sendResponseExpectation: XCTestExpectation? static func reset() { instrumentCalled = false sendMessageCalled = false sendResponseCalled = false lastResponseBody = nil + sendResponseExpectation = nil } static func instrument(_: WKWebView, _: InstrumentationPayload) { @@ -635,5 +557,6 @@ class MockCheckoutBridge: CheckoutBridgeProtocol { static func sendResponse(_: WKWebView, messageBody: String) { sendResponseCalled = true lastResponseBody = messageBody + sendResponseExpectation?.fulfill() } }