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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Disabling preloading in the sample app by default

$0.logger = FileLogger("log.txt")
$0.logLevel = checkoutKitLogLevel
}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just for my understanding this means it will default to opening a new webview for unhandled links right?

Copy link
Copy Markdown
Contributor Author

@markmur markmur May 14, 2026

Choose a reason for hiding this comment

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

This is sample app code. The smart default remains the same and will pull the buyer out of the app and into Safari, but the Sample offers an alternative which opens the link in a Safari webview inside of the app.

I'd like to change the smart default to this going foward

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)")")
}
}

extension UIApplication {
fileprivate 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 }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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." : {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
64 changes: 33 additions & 31 deletions platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import WebKit
protocol CheckoutWebViewDelegate: AnyObject {
func checkoutViewDidStartNavigation()
func checkoutViewDidFinishNavigation()
func checkoutViewDidClickLink(url: URL)
func checkoutViewDidFailWithError(error: CheckoutError)
}

Expand Down Expand Up @@ -254,29 +253,53 @@ 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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

canOpenURL here (and at :295) is gated by LSApplicationQueriesSchemes — if a consumer hasn't declared the scheme in Info.plist, this returns false even when the system has a handler. Same shape kiftio flagged on Android (<queries> brittleness on API 30+). Fix: skip the precheck and use UIApplication.shared.open(_:options:completionHandler:) — the completion reports the real result from the system.

return .rejected(reason: "canOpenURL returned false")
}
UIApplication.shared.open(request.url)
return .success
}
}

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")
viewDelegate?.checkoutViewDidClickLink(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)
}
Comment on lines +289 to +302
Copy link
Copy Markdown
Contributor Author

@markmur markmur May 14, 2026

Choose a reason for hiding this comment

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

Note: This an important change. Previously we delegated this to consumers, where we used the "smart default" to open the URL using UIApplication or allowed consumers to override.

The difference now is that it's handled internally. Link open requests come from checkout via the protocol only.

return
}

Expand Down Expand Up @@ -401,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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
Loading
Loading