diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutBridge.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutBridge.swift index 1cc839dc..3a15f69d 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutBridge.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutBridge.swift @@ -1,5 +1,6 @@ import WebKit +@MainActor protocol CheckoutBridgeProtocol { @discardableResult @MainActor static func sendResponse(_ webView: WKWebView, messageBody: String) async -> Bool } diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift index 2109d7c3..43ef9b36 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift @@ -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) @@ -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 diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index 08140113..956027b4 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -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)? @@ -94,7 +97,6 @@ class CheckoutWebView: WKWebView { deinit { OSLogger.shared.debug("De-allocating webview") - detachBridge() } @available(*, unavailable) @@ -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 } @@ -132,6 +138,43 @@ 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 { @@ -139,13 +182,13 @@ extension CheckoutWebView: WKScriptMessageHandler { } 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 diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift index c478be82..d612f809 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift @@ -1,6 +1,7 @@ import UIKit import WebKit +@MainActor class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControllerDelegate { var onCancel: (() -> Void)? var onFail: ((CheckoutError) -> Void)? diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/ConfettiCannon.swift b/platforms/swift/Sources/ShopifyCheckoutKit/ConfettiCannon.swift index 36d4e0b2..df33f0ad 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/ConfettiCannon.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/ConfettiCannon.swift @@ -1,5 +1,6 @@ import UIKit +@MainActor enum ConfettiCannon { static func fire(in view: UIView) { let layerName = "shopify-confetti" diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/ProgressBarView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/ProgressBarView.swift index be5121a6..c9513617 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/ProgressBarView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/ProgressBarView.swift @@ -1,5 +1,6 @@ import UIKit +@MainActor class ProgressBarView: UIView { lazy var progressBar: UIProgressView = { let progressBar = UIProgressView(progressViewStyle: .bar) diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift index ce871ea3..5ee84e63 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift @@ -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) @@ -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) diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutBridgeTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutBridgeTests.swift index 5b3b2eed..287451d8 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutBridgeTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutBridgeTests.swift @@ -2,6 +2,7 @@ import WebKit import XCTest +@MainActor class CheckoutBridgeTests: XCTestCase { func testApplicationNameDelegatesToUserAgent() { XCTAssertEqual( @@ -50,6 +51,7 @@ class CheckoutBridgeTests: XCTestCase { } } +@MainActor private class MockWebView: WKWebView { var evaluatedScript: String? diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutViewControllerTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutViewControllerTests.swift index 867fb287..9a631634 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutViewControllerTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutViewControllerTests.swift @@ -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" } @@ -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() { @@ -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") } @@ -103,6 +108,7 @@ class CheckoutViewDelegateTests: XCTestCase { } } +@MainActor protocol Dismissible: AnyObject { func dismiss(animated flag: Bool, completion: (() -> Void)?) } diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift index a57581a0..0d9bed63 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift @@ -2,6 +2,7 @@ import WebKit import XCTest +@MainActor class TestableCheckoutWebViewController: CheckoutWebViewController { var dismissCalled = false var dismissAnimated: Bool = false @@ -13,6 +14,7 @@ class TestableCheckoutWebViewController: CheckoutWebViewController { } } +@MainActor class CheckoutWebViewControllerTests: XCTestCase { private let url = URL(string: "http://shopify1.shopify.com/checkouts/cn/123")! diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index 86156868..42d0c5e8 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -3,12 +3,14 @@ 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 @@ -16,9 +18,9 @@ class CheckoutWebViewTests: XCTestCase { MockCheckoutBridge.reset() } - override func tearDown() { + override func tearDown() async throws { view.viewDelegate = nil - super.tearDown() + try await super.tearDown() } func testCorrectlyConfiguresWebview() { @@ -26,6 +28,15 @@ class CheckoutWebViewTests: XCTestCase { 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") @@ -503,6 +514,7 @@ class CheckoutWebViewTests: XCTestCase { } } +@MainActor class MockCheckoutBridge: CheckoutBridgeProtocol { static var sendResponseCalled = false static var sendResponseCount = 0 diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/ShopifyCheckoutKitTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/ShopifyCheckoutKitTests.swift index 6f0a62fd..f4558f3f 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/ShopifyCheckoutKitTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/ShopifyCheckoutKitTests.swift @@ -1,6 +1,7 @@ @testable import ShopifyCheckoutKit import XCTest +@MainActor class ShopifyCheckoutKitTests: XCTestCase { func test_version_whenAccessed_shouldExist() { XCTAssertFalse(ShopifyCheckoutKit.version.isEmpty) diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/SwiftUITests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/SwiftUITests.swift index 2e76ef8b..a091378c 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/SwiftUITests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/SwiftUITests.swift @@ -1,6 +1,7 @@ @testable import ShopifyCheckoutKit import XCTest +@MainActor class CheckoutViewControllerTests: XCTestCase { var checkoutURL: URL! var checkoutViewController: CheckoutViewController! @@ -16,6 +17,7 @@ class CheckoutViewControllerTests: XCTestCase { } } +@MainActor class ShopifyCheckoutTests: XCTestCase { var checkoutURL: URL! var shopifyCheckout: ShopifyCheckout! @@ -58,6 +60,7 @@ class ShopifyCheckoutTests: XCTestCase { } } +@MainActor class CheckoutConfigurableTests: XCTestCase { var checkoutURL: URL! var shopifyCheckout: ShopifyCheckout! diff --git a/platforms/swift/api/ShopifyCheckoutKit.json b/platforms/swift/api/ShopifyCheckoutKit.json index 6c5c1931..49d87b3c 100644 --- a/platforms/swift/api/ShopifyCheckoutKit.json +++ b/platforms/swift/api/ShopifyCheckoutKit.json @@ -1212,7 +1212,6 @@ "mangledName": "$s18ShopifyCheckoutKit0B14ViewControllerC8checkout8delegate6clientAC10Foundation3URLV_AA0B8Delegate_pSgAA0B21CommunicationProtocol_pSgtcfc", "moduleName": "ShopifyCheckoutKit", "declAttributes": [ - "Preconcurrency", "Custom" ], "init_kind": "Designated" @@ -1286,7 +1285,6 @@ "moduleName": "ShopifyCheckoutKit", "isInternal": true, "declAttributes": [ - "Preconcurrency", "Custom" ], "init_kind": "Designated" @@ -1396,7 +1394,6 @@ "declAttributes": [ "Dynamic", "ObjC", - "Preconcurrency", "Available", "Override", "Custom" @@ -1431,7 +1428,6 @@ "declAttributes": [ "Dynamic", "ObjC", - "Preconcurrency", "Override", "Custom" ], @@ -1487,7 +1483,6 @@ "declAttributes": [ "Dynamic", "ObjC", - "Preconcurrency", "Override", "Custom" ], @@ -1499,7 +1494,6 @@ "mangledName": "$s18ShopifyCheckoutKit0B14ViewControllerC", "moduleName": "ShopifyCheckoutKit", "declAttributes": [ - "Preconcurrency", "Custom", "ObjC" ], @@ -2094,6 +2088,9 @@ "moduleName": "ShopifyCheckoutKit", "genericSig": "", "protocolReq": true, + "declAttributes": [ + "Custom" + ], "reqNewWitnessTableEntry": true, "funcSelfKind": "NonMutating" }, @@ -2120,6 +2117,9 @@ "moduleName": "ShopifyCheckoutKit", "genericSig": "", "protocolReq": true, + "declAttributes": [ + "Custom" + ], "reqNewWitnessTableEntry": true, "funcSelfKind": "NonMutating" }, @@ -2146,6 +2146,9 @@ "moduleName": "ShopifyCheckoutKit", "genericSig": "", "protocolReq": true, + "declAttributes": [ + "Custom" + ], "reqNewWitnessTableEntry": true, "funcSelfKind": "NonMutating" }, @@ -2172,6 +2175,9 @@ "moduleName": "ShopifyCheckoutKit", "genericSig": "", "protocolReq": true, + "declAttributes": [ + "Custom" + ], "reqNewWitnessTableEntry": true, "funcSelfKind": "NonMutating" }, @@ -2206,6 +2212,9 @@ "moduleName": "ShopifyCheckoutKit", "genericSig": "", "protocolReq": true, + "declAttributes": [ + "Custom" + ], "reqNewWitnessTableEntry": true, "funcSelfKind": "NonMutating" }, @@ -2232,7 +2241,8 @@ "moduleName": "ShopifyCheckoutKit", "genericSig": "", "declAttributes": [ - "DiscardableResult" + "DiscardableResult", + "Custom" ], "isFromExtension": true, "funcSelfKind": "NonMutating" @@ -2260,7 +2270,8 @@ "moduleName": "ShopifyCheckoutKit", "genericSig": "", "declAttributes": [ - "DiscardableResult" + "DiscardableResult", + "Custom" ], "isFromExtension": true, "funcSelfKind": "NonMutating" @@ -2288,7 +2299,8 @@ "moduleName": "ShopifyCheckoutKit", "genericSig": "", "declAttributes": [ - "DiscardableResult" + "DiscardableResult", + "Custom" ], "isFromExtension": true, "funcSelfKind": "NonMutating" @@ -2316,7 +2328,8 @@ "moduleName": "ShopifyCheckoutKit", "genericSig": "", "declAttributes": [ - "DiscardableResult" + "DiscardableResult", + "Custom" ], "isFromExtension": true, "funcSelfKind": "NonMutating" @@ -2352,7 +2365,8 @@ "moduleName": "ShopifyCheckoutKit", "genericSig": "", "declAttributes": [ - "DiscardableResult" + "DiscardableResult", + "Custom" ], "isFromExtension": true, "funcSelfKind": "NonMutating" @@ -2362,6 +2376,9 @@ "usr": "s:18ShopifyCheckoutKit0B12ConfigurableP", "mangledName": "$s18ShopifyCheckoutKit0B12ConfigurableP", "moduleName": "ShopifyCheckoutKit", + "declAttributes": [ + "Custom" + ], "conformances": [ { "kind": "Conformance", @@ -5499,7 +5516,8 @@ "mangledName": "$s18ShopifyCheckoutKit7present8checkout4from8delegate6clientAA0B14ViewControllerC10Foundation3URLV_So06UIViewJ0CAA0B8Delegate_pSgAA0B21CommunicationProtocol_pSgtF", "moduleName": "ShopifyCheckoutKit", "declAttributes": [ - "DiscardableResult" + "DiscardableResult", + "Custom" ], "funcSelfKind": "NonMutating" }, @@ -5569,7 +5587,8 @@ "moduleName": "ShopifyCheckoutKit", "isInternal": true, "declAttributes": [ - "DiscardableResult" + "DiscardableResult", + "Custom" ], "funcSelfKind": "NonMutating" },