-
Notifications
You must be signed in to change notification settings - Fork 0
[Swift] Add support for window.open delegation #82
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a73675d
7df30fd
c1f0a50
7a987b5
3d8622c
1393e81
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
|---|---|---|
|
|
@@ -30,7 +30,6 @@ import WebKit | |
| protocol CheckoutWebViewDelegate: AnyObject { | ||
| func checkoutViewDidStartNavigation() | ||
| func checkoutViewDidFinishNavigation() | ||
| func checkoutViewDidClickLink(url: URL) | ||
| func checkoutViewDidFailWithError(error: CheckoutError) | ||
| } | ||
|
|
||
|
|
@@ -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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| } | ||
|
|
||
|
|
@@ -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 | ||
| } | ||
|
|
||
There was a problem hiding this comment.
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