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
@@ -1,5 +1,6 @@
import WebKit

@MainActor
protocol CheckoutBridgeProtocol {
@discardableResult @MainActor static func sendResponse(_ webView: WKWebView, messageBody: String) async -> Bool
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import SwiftUI
import UIKit

@MainActor
public class CheckoutViewController: UINavigationController {
public init(checkout url: URL, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil) {
let rootViewController = CheckoutWebViewController(checkoutURL: url, delegate: delegate, client: client, entryPoint: nil)
Expand Down Expand Up @@ -80,6 +81,7 @@ public struct ShopifyCheckout: UIViewControllerRepresentable, CheckoutConfigurab
}
}

@MainActor
public protocol CheckoutConfigurable {
func backgroundColor(_ color: UIColor) -> Self
func colorScheme(_ colorScheme: ShopifyCheckoutKit.Configuration.ColorScheme) -> Self
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,21 @@
import UIKit
import WebKit

@MainActor
protocol CheckoutWebViewDelegate: AnyObject {
func checkoutViewDidStartNavigation()
func checkoutViewDidFinishNavigation()
func checkoutViewDidFailWithError(error: CheckoutError)
}

@MainActor
class CheckoutWebView: WKWebView {
var timer: Date?

var checkoutBridge: CheckoutBridgeProtocol.Type = CheckoutBridge.self

var isBridgeAttached = false
private var bridgeRegistration: ScriptMessageHandlerRegistration?

var client: (any CheckoutCommunicationProtocol)?

Expand Down Expand Up @@ -94,7 +97,6 @@ class CheckoutWebView: WKWebView {

deinit {
OSLogger.shared.debug("De-allocating webview")

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.

See PR body for why this line was removed and why we have a bridgeRegistration

detachBridge()
}

@available(*, unavailable)
Expand All @@ -104,16 +106,20 @@ class CheckoutWebView: WKWebView {

private func connectBridge() {
OSLogger.shared.debug("Bridging communication to checkout")
configuration.userContentController
.add(MessageHandler(delegate: self), name: CheckoutBridge.messageHandler)

bridgeRegistration = ScriptMessageHandlerRegistration(
userContentController: configuration.userContentController,
name: CheckoutBridge.messageHandler,
handler: MessageHandler(delegate: self)
)
isBridgeAttached = true
}

public func detachBridge() {
guard isBridgeAttached else { return }

OSLogger.shared.debug("Detaching bridge")
configuration.userContentController
.removeScriptMessageHandler(forName: CheckoutBridge.messageHandler)
bridgeRegistration?.detach()
bridgeRegistration = nil
isBridgeAttached = false
}

Expand All @@ -132,20 +138,57 @@ class CheckoutWebView: WKWebView {
}
}

/// Holds a WebKit script-message registration outside CheckoutWebView deinit.
///
/// Swift 6.0 does not support isolated deinit, so this token captures the
/// WebKit registration while on the main actor and schedules fallback teardown
/// back onto the main actor from deinit. Replace this with an isolated deinit
/// on CheckoutWebView once Swift 6.2+ is the minimum supported compiler.
private final class ScriptMessageHandlerRegistration {
private let userContentController: WKUserContentController
private let name: String
private var isAttached = true

@MainActor
init(userContentController: WKUserContentController, name: String, handler: WKScriptMessageHandler) {
self.userContentController = userContentController
self.name = name
userContentController.add(handler, name: name)
}

@MainActor
func detach() {
guard isAttached else { return }

userContentController.removeScriptMessageHandler(forName: name)
isAttached = false
}

deinit {
guard isAttached else { return }

let userContentController = userContentController
let name = name
Task { @MainActor in
userContentController.removeScriptMessageHandler(forName: name)
}
}
}

extension CheckoutWebView: WKScriptMessageHandler {
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
guard let body = message.body as? String else {
return
}

if let response = CheckoutProtocol.acknowledgeReady(body) {
Task {
Task { @MainActor in
await checkoutBridge.sendResponse(self, messageBody: response)
}
return
}

Task {
Task { @MainActor in
let composedClient = ComposedCheckoutCommunicationClient(
merchant: client,
defaults: defaultClientBindings
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import UIKit
import WebKit

@MainActor
class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControllerDelegate {
var onCancel: (() -> Void)?
var onFail: ((CheckoutError) -> Void)?
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import UIKit

@MainActor
enum ConfettiCannon {
static func fire(in view: UIView) {
let layerName = "shopify-confetti"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import UIKit

@MainActor
class ProgressBarView: UIView {
lazy var progressBar: UIProgressView = {
let progressBar = UIProgressView(progressViewStyle: .bar)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public func configure(_ block: (inout Configuration) -> Void) {
block(&configuration)
}

@MainActor
@discardableResult
public func present(checkout url: URL, from: UIViewController, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil) -> CheckoutViewController {
let decorated = CheckoutProtocol.url(for: url)
Expand All @@ -26,6 +27,7 @@ public func present(checkout url: URL, from: UIViewController, delegate: (any Ch
return viewController
}

@MainActor
@discardableResult
package func present(checkout url: URL, from: UIViewController, entryPoint: MetaData.EntryPoint, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil) -> CheckoutViewController {
let decorated = CheckoutProtocol.url(for: url)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import WebKit
import XCTest

@MainActor
class CheckoutBridgeTests: XCTestCase {
func testApplicationNameDelegatesToUserAgent() {
XCTAssertEqual(
Expand Down Expand Up @@ -50,6 +51,7 @@ class CheckoutBridgeTests: XCTestCase {
}
}

@MainActor
private class MockWebView: WKWebView {
var evaluatedScript: String?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
import WebKit
import XCTest

@MainActor
class CheckoutViewDelegateTests: XCTestCase {
private var customTitle: String?
private let checkoutURL = URL(string: "https://checkout-sdk.myshopify.com")!
private var viewController: MockCheckoutWebViewController!
private var navigationController: UINavigationController!

override func setUp() {
override func setUp() async throws {
try await super.setUp()
ShopifyCheckoutKit.configure {
$0.title = customTitle ?? "Checkout"
}
Expand All @@ -19,9 +21,9 @@ class CheckoutViewDelegateTests: XCTestCase {
navigationController = UINavigationController(rootViewController: viewController)
}

override func tearDown() {
override func tearDown() async throws {
customTitle = nil
super.tearDown()
try await super.tearDown()
}

func testTitleIsSetToCheckout() {
Expand All @@ -30,7 +32,10 @@ class CheckoutViewDelegateTests: XCTestCase {

func testTitleCanBeCustomized() {
customTitle = "Custom title"
setUp()
ShopifyCheckoutKit.configure { $0.title = customTitle ?? "Checkout" }
viewController = MockCheckoutWebViewController(
checkoutURL: checkoutURL
)
XCTAssertEqual(viewController.title, "Custom title")
}

Expand Down Expand Up @@ -103,6 +108,7 @@ class CheckoutViewDelegateTests: XCTestCase {
}
}

@MainActor
protocol Dismissible: AnyObject {
func dismiss(animated flag: Bool, completion: (() -> Void)?)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import WebKit
import XCTest

@MainActor
class TestableCheckoutWebViewController: CheckoutWebViewController {
var dismissCalled = false
var dismissAnimated: Bool = false
Expand All @@ -13,6 +14,7 @@ class TestableCheckoutWebViewController: CheckoutWebViewController {
}
}

@MainActor
class CheckoutWebViewControllerTests: XCTestCase {
private let url = URL(string: "http://shopify1.shopify.com/checkouts/cn/123")!

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,40 @@ import ShopifyCheckoutProtocol
import WebKit
import XCTest

@MainActor
class CheckoutWebViewTests: XCTestCase {
private var view: CheckoutWebView!
private var mockDelegate: MockCheckoutWebViewDelegate!
private var url = URL(string: "http://shopify1.shopify.com/checkouts/cn/123")!

override func setUp() {
override func setUp() async throws {
try await super.setUp()
view = CheckoutWebView.for(checkout: url)
mockDelegate = MockCheckoutWebViewDelegate()
view.viewDelegate = mockDelegate
view.checkoutBridge = MockCheckoutBridge.self
MockCheckoutBridge.reset()
}

override func tearDown() {
override func tearDown() async throws {
view.viewDelegate = nil
super.tearDown()
try await super.tearDown()
}

func testCorrectlyConfiguresWebview() {
XCTAssertEqual(view.configuration.applicationNameForUserAgent, CheckoutBridge.applicationName)
XCTAssertTrue(view.configuration.allowsInlineMediaPlayback)
}

func testDetachBridgeIsIdempotent() {
XCTAssertTrue(view.isBridgeAttached)

view.detachBridge()
view.detachBridge()

XCTAssertFalse(view.isBridgeAttached)
}

func testHTTPSLinkIsAllowed() throws {
let link = try XCTUnwrap(URL(string: "https://www.shopify.com/legal/privacy/app-users"))
let received = expectation(description: "policy decided")
Expand Down Expand Up @@ -503,6 +514,7 @@ class CheckoutWebViewTests: XCTestCase {
}
}

@MainActor
class MockCheckoutBridge: CheckoutBridgeProtocol {
static var sendResponseCalled = false
static var sendResponseCount = 0
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
@testable import ShopifyCheckoutKit
import XCTest

@MainActor
class ShopifyCheckoutKitTests: XCTestCase {
func test_version_whenAccessed_shouldExist() {
XCTAssertFalse(ShopifyCheckoutKit.version.isEmpty)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
@testable import ShopifyCheckoutKit
import XCTest

@MainActor
class CheckoutViewControllerTests: XCTestCase {
var checkoutURL: URL!
var checkoutViewController: CheckoutViewController!
Expand All @@ -16,6 +17,7 @@ class CheckoutViewControllerTests: XCTestCase {
}
}

@MainActor
class ShopifyCheckoutTests: XCTestCase {
var checkoutURL: URL!
var shopifyCheckout: ShopifyCheckout!
Expand Down Expand Up @@ -58,6 +60,7 @@ class ShopifyCheckoutTests: XCTestCase {
}
}

@MainActor
class CheckoutConfigurableTests: XCTestCase {
var checkoutURL: URL!
var shopifyCheckout: ShopifyCheckout!
Expand Down
Loading
Loading