Skip to content
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

Async javascript #26

Closed
wants to merge 4 commits into from
Closed
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
83 changes: 47 additions & 36 deletions Source/Bridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,17 @@ protocol Bridgable: AnyObject {
var delegate: BridgeDelegate? { get set }
var webView: WKWebView? { get }

func register(component: String)
func register(components: [String])
func unregister(component: String)
func reply(with message: Message)
func register(component: String) async
func register(components: [String]) async
func unregister(component: String) async
func reply(with message: Message) async
}

/// `Bridge` is the object for configuring a web view and
/// the channel for sending/receiving messages
public final class Bridge: Bridgable {
typealias CompletionHandler = (_ result: Any?, _ error: Error?) -> Void

weak var delegate: BridgeDelegate?
weak var webView: WKWebView?

Expand All @@ -42,28 +42,28 @@ public final class Bridge: Bridgable {

/// Register a single component
/// - Parameter component: Name of a component to register support for
func register(component: String) {
callBridgeFunction(.register, arguments: [component])
func register(component: String) async {
await callBridgeFunction(.register, arguments: [component])
}

/// Register multiple components
/// - Parameter components: Array of component names to register
func register(components: [String]) {
callBridgeFunction(.register, arguments: [components])
func register(components: [String]) async {
await callBridgeFunction(.register, arguments: [components])
}

/// Unregister support for a single component
/// - Parameter component: Component name
func unregister(component: String) {
callBridgeFunction(.unregister, arguments: [component])
func unregister(component: String) async {
await callBridgeFunction(.unregister, arguments: [component])
}

/// Send a message through the bridge to the web application
/// - Parameter message: Message to send
func reply(with message: Message) {
func reply(with message: Message) async {
logger.debug("bridgeWillReplyWithMessage: \(String(describing: message))")
let internalMessage = InternalMessage(from: message)
callBridgeFunction(.replyWith, arguments: [internalMessage.toJSON()])
await callBridgeFunction(.replyWith, arguments: [internalMessage.toJSON()])
}

// /// Convenience method to reply to a previously received message. Data will be replaced,
Expand All @@ -74,28 +74,30 @@ public final class Bridge: Bridgable {
// let replyMessage = message.replacing(data: data)
// callBridgeFunction("send", arguments: [replyMessage.toJSON()])
// }

/// Evaluates javaScript string directly as passed in sending through the web view
func evaluate(javaScript: String, completion: CompletionHandler? = nil) {
@MainActor
@discardableResult
func evaluate(javaScript: String) async -> JavaScriptResult {
guard let webView = webView else {
completion?(nil, BridgeError.missingWebView)
return
return JavaScriptResult(result: nil, error: BridgeError.missingWebView)
}

webView.evaluateJavaScript(javaScript) { result, error in
if let error = error {
logger.error("Error evaluating JavaScript: \(error)")
}

completion?(result, error)

var result: Any? = nil
var error: Error? = nil
do {
result = try await webView.evaluateJavaScript(javaScript)
} catch let e {
logger.error("Error evaluating JavaScript: \(e)")
error = e
}

return JavaScriptResult(result: result, error: error)
}

/// Evaluates a JavaScript function with optional arguments by encoding the arguments
/// Function should not include the parens
/// Usage: evaluate(function: "console.log", arguments: ["test"])
func evaluate(function: String, arguments: [Any] = [], completion: CompletionHandler? = nil) {
evaluate(javaScript: JavaScript(functionName: function, arguments: arguments), completion: completion)
func evaluate(function: String, arguments: [Any] = []) async -> JavaScriptResult {
await evaluate(javaScript: JavaScript(functionName: function, arguments: arguments))
}

static func initialize(_ bridge: Bridge) {
Expand All @@ -116,9 +118,9 @@ public final class Bridge: Bridgable {
/// The webkit.messageHandlers name
private let scriptHandlerName = "strada"

private func callBridgeFunction(_ function: JavaScriptBridgeFunction, arguments: [Any]) {
private func callBridgeFunction(_ function: JavaScriptBridgeFunction, arguments: [Any]) async {
let js = JavaScript(object: bridgeGlobal, functionName: function.rawValue, arguments: arguments)
evaluate(javaScript: js)
await evaluate(javaScript: js)
}

// MARK: - Configuration
Expand Down Expand Up @@ -153,15 +155,16 @@ public final class Bridge: Bridgable {

// MARK: - JavaScript Evaluation

private func evaluate(javaScript: JavaScript, completion: CompletionHandler? = nil) {
@discardableResult
private func evaluate(javaScript: JavaScript) async -> JavaScriptResult {
do {
evaluate(javaScript: try javaScript.toString(), completion: completion)
return await evaluate(javaScript: try javaScript.toString())
} catch {
logger.error("Error evaluating JavaScript: \(String(describing: javaScript)), error: \(error)")
completion?(nil, error)
return JavaScriptResult(result: nil, error: error)
}
}

private enum JavaScriptBridgeFunction: String {
case register
case unregister
Expand All @@ -170,10 +173,11 @@ public final class Bridge: Bridgable {
}

extension Bridge: ScriptMessageHandlerDelegate {
func scriptMessageHandlerDidReceiveMessage(_ scriptMessage: WKScriptMessage) {
@MainActor
func scriptMessageHandlerDidReceiveMessage(_ scriptMessage: WKScriptMessage) async {
if let event = scriptMessage.body as? String,
event == "ready" {
delegate?.bridgeDidInitialize()
await delegate?.bridgeDidInitialize()
return
}

Expand All @@ -185,3 +189,10 @@ extension Bridge: ScriptMessageHandlerDelegate {
logger.warning("Unhandled message received: \(String(describing: scriptMessage.body))")
}
}

extension Bridge {
struct JavaScriptResult {
let result: Any?
let error: Error?
}
}
16 changes: 8 additions & 8 deletions Source/BridgeComponent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ open class BridgeComponent: BridgingComponent {
///
/// - Parameter message: The message to be replied with.
/// - Returns: `true` if the reply was successful, `false` if the bridge is not available.
public func reply(with message: Message) -> Bool {
return delegate.reply(with: message)
public func reply(with message: Message) async -> Bool {
await delegate.reply(with: message)
}

@discardableResult
Expand All @@ -61,13 +61,13 @@ open class BridgeComponent: BridgingComponent {
///
/// - Parameter event: The `event` for which a reply should be sent.
/// - Returns: `true` if the reply was successful, `false` if the event message was not received.
public func reply(to event: String) -> Bool {
public func reply(to event: String) async -> Bool {
guard let message = receivedMessage(for: event) else {
logger.warning("bridgeMessageFailedToReply: message for event \(event) was not received")
return false
}

return reply(with: message)
return await reply(with: message)
}

@discardableResult
Expand All @@ -79,15 +79,15 @@ open class BridgeComponent: BridgingComponent {
/// - event: The `event` for which a reply should be sent.
/// - jsonData: The `jsonData` to be included in the reply message.
/// - Returns: `true` if the reply was successful, `false` if the event message was not received.
public func reply(to event: String, with jsonData: String) -> Bool {
public func reply(to event: String, with jsonData: String) async -> Bool {
guard let message = receivedMessage(for: event) else {
logger.warning("bridgeMessageFailedToReply: message for event \(event) was not received")
return false
}

let messageReply = message.replacing(jsonData: jsonData)

return reply(with: messageReply)
return await reply(with: messageReply)
}

@discardableResult
Expand All @@ -100,15 +100,15 @@ open class BridgeComponent: BridgingComponent {
/// - event: The `event` for which a reply should be sent.
/// - data: An instance conforming to `Encodable` to be included as `jsonData` in the reply message.
/// - Returns: `true` if the reply was successful, `false` if the event message was not received.
public func reply<T: Encodable>(to event: String, with data: T) -> Bool {
public func reply<T: Encodable>(to event: String, with data: T) async -> Bool {
guard let message = receivedMessage(for: event) else {
logger.warning("bridgeMessageFailedToReply: message for event \(event) was not received")
return false
}

let messageReply = message.replacing(data: data)

return reply(with: messageReply)
return await reply(with: messageReply)
}

/// Returns the last received message for a given `event`, if available.
Expand Down
14 changes: 7 additions & 7 deletions Source/BridgeDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ public protocol BridgingDelegate: AnyObject {

func webViewDidBecomeActive(_ webView: WKWebView)
func webViewDidBecomeDeactivated()
func reply(with message: Message) -> Bool
func reply(with message: Message) async -> Bool

func onViewDidLoad()
func onViewWillAppear()
func onViewDidAppear()
Expand All @@ -20,7 +20,7 @@ public protocol BridgingDelegate: AnyObject {

func component<C: BridgeComponent>() -> C?

func bridgeDidInitialize()
func bridgeDidInitialize() async
func bridgeDidReceiveMessage(_ message: Message) -> Bool
}

Expand Down Expand Up @@ -60,13 +60,13 @@ public final class BridgeDelegate: BridgingDelegate {
///
/// - Parameter message: The message to be replied with.
/// - Returns: `true` if the reply was successful, `false` if the bridge is not available.
public func reply(with message: Message) -> Bool {
public func reply(with message: Message) async -> Bool {
guard let bridge else {
logger.warning("bridgeMessageFailedToReply: bridge is not available")
return false
}

bridge.reply(with: message)
await bridge.reply(with: message)
return true
}

Expand Down Expand Up @@ -109,9 +109,9 @@ public final class BridgeDelegate: BridgingDelegate {

// MARK: Internal use

public func bridgeDidInitialize() {
public func bridgeDidInitialize() async {
let componentNames = componentTypes.map { $0.name }
bridge?.register(components: componentNames)
await bridge?.register(components: componentNames)
}

@discardableResult
Expand Down
25 changes: 25 additions & 0 deletions Source/Message.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,28 @@ extension Message {
}
}
}

extension Message {
/// Using `Equatable`'s default implementation is bound to give us false positives
/// since two `Message`s may have semantically equal, but textually different, `jsonData`.
///
/// For example, the following `jsonData` should be considered equal.
///
/// ```
/// lhs.jsonData = "{\"title\":\"Page-title\",\"subtitle\":\"Page-subtitle\",\"action_name\":\"go\"}")"
///
/// rhs.jsonData = "{\"action_name\":\"go\",\"title\":\"Page-title\",\"subtitle\":\"Page-subtitle\"}")"
/// ```
///
/// - Parameters:
/// - lhs: a message
/// - rhs: another message
/// - Returns: true if they're semantically equal
public static func == (lhs: Self, rhs: Self) -> Bool {
return lhs.id == rhs.id &&
lhs.component == rhs.component &&
lhs.event == rhs.event &&
lhs.metadata == rhs.metadata &&
lhs.jsonData.jsonObject() as? [String: AnyHashable] == rhs.jsonData.jsonObject() as? [String: AnyHashable]
}
}
4 changes: 2 additions & 2 deletions Source/ScriptMessageHandler.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import WebKit

protocol ScriptMessageHandlerDelegate: AnyObject {
func scriptMessageHandlerDidReceiveMessage(_ scriptMessage: WKScriptMessage)
func scriptMessageHandlerDidReceiveMessage(_ scriptMessage: WKScriptMessage) async
}

// Avoids retain cycle caused by WKUserContentController
Expand All @@ -13,6 +13,6 @@ final class ScriptMessageHandler: NSObject, WKScriptMessageHandler {
}

func userContentController(_ userContentController: WKUserContentController, didReceive scriptMessage: WKScriptMessage) {
delegate?.scriptMessageHandlerDidReceiveMessage(scriptMessage)
Task { await delegate?.scriptMessageHandlerDidReceiveMessage(scriptMessage) }
}
}
36 changes: 18 additions & 18 deletions Tests/BridgeComponentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,60 +44,60 @@ class BridgeComponentTest: XCTestCase {

// MARK: reply(to:)

func test_replyToReceivedMessageSucceeds() {
let success = component.reply(to: "connect")
func test_replyToReceivedMessageSucceeds() async {
let success = await component.reply(to: "connect")

XCTAssertTrue(success)
XCTAssertTrue(delegate.replyWithMessageWasCalled)
XCTAssertEqual(delegate.replyWithMessageArg, message)
}

func test_replyToReceivedMessageWithACodableObjectSucceeds() {
func test_replyToReceivedMessageWithACodableObjectSucceeds() async {
let messageData = MessageData(title: "hey", subtitle: "", actionName: "tap")
let newJsonData = "{\"title\":\"hey\",\"subtitle\":\"\",\"actionName\":\"tap\"}"
let newMessage = message.replacing(jsonData: newJsonData)

let success = component.reply(to: "connect", with: messageData)
let success = await component.reply(to: "connect", with: messageData)

XCTAssertTrue(success)
XCTAssertTrue(delegate.replyWithMessageWasCalled)
XCTAssertEqual(delegate.replyWithMessageArg, newMessage)
}

func test_replyToMessageNotReceivedWithACodableObjectIgnoresTheReply() {
func test_replyToMessageNotReceivedWithACodableObjectIgnoresTheReply() async {
let messageData = MessageData(title: "hey", subtitle: "", actionName: "tap")

let success = component.reply(to: "disconnect", with: messageData)
let success = await component.reply(to: "disconnect", with: messageData)

XCTAssertFalse(success)
XCTAssertFalse(delegate.replyWithMessageWasCalled)
XCTAssertNil(delegate.replyWithMessageArg)
}

func test_replyToMessageNotReceivedIgnoresTheReply() {
let success = component.reply(to: "disconnect")
func test_replyToMessageNotReceivedIgnoresTheReply() async {
let success = await component.reply(to: "disconnect")

XCTAssertFalse(success)
XCTAssertFalse(delegate.replyWithMessageWasCalled)
XCTAssertNil(delegate.replyWithMessageArg)
}

func test_replyToMessageNotReceivedWithJsonDataIgnoresTheReply() {
let success = component.reply(to: "disconnect", with: "{\"title\":\"Page-title\"}")
func test_replyToMessageNotReceivedWithJsonDataIgnoresTheReply() async {
let success = await component.reply(to: "disconnect", with: "{\"title\":\"Page-title\"}")

XCTAssertFalse(success)
XCTAssertFalse(delegate.replyWithMessageWasCalled)
XCTAssertNil(delegate.replyWithMessageArg)
}

// MARK: reply(with:)

func test_replyWithSucceedsWhenBridgeIsSet() {
func test_replyWithSucceedsWhenBridgeIsSet() async {
let newJsonData = "{\"title\":\"Page-title\"}"
let newMessage = message.replacing(jsonData: newJsonData)

let success = component.reply(with: newMessage)
let success = await component.reply(with: newMessage)

XCTAssertTrue(success)
XCTAssertTrue(delegate.replyWithMessageWasCalled)
XCTAssertEqual(delegate.replyWithMessageArg, newMessage)
Expand Down
Loading