Skip to content

Commit

Permalink
Merge pull request #29 from hotwired/async-javascript-take-2
Browse files Browse the repository at this point in the history
Async javascript take 2
  • Loading branch information
joemasilotti authored Mar 1, 2024
2 parents d8c1bcf + 5ef73bc commit 0b4b6c0
Show file tree
Hide file tree
Showing 18 changed files with 452 additions and 173 deletions.
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
build
node_modules
*.log

*.xcuserstate

*.xcbkptlist
.swiftpm
xcuserdata
145 changes: 83 additions & 62 deletions Source/Bridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,20 @@ public enum BridgeError: Error {
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 throws
func register(components: [String]) async throws
func unregister(component: String) async throws
func reply(with message: Message) async throws
}

/// `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

public typealias InitializationCompletionHandler = () -> Void
weak var delegate: BridgeDelegate?
weak var webView: WKWebView?

public static func initialize(_ webView: WKWebView) {
if getBridgeFor(webView) == nil {
initialize(Bridge(webView: webView))
Expand All @@ -33,39 +32,39 @@ public final class Bridge: Bridgable {
self.webView = webView
loadIntoWebView()
}

deinit {
webView?.configuration.userContentController.removeScriptMessageHandler(forName: scriptHandlerName)
}


// MARK: - Internal API

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

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

/// Unregister support for a single component
/// - Parameter component: Component name
func unregister(component: String) {
callBridgeFunction(.unregister, arguments: [component])
@MainActor
func unregister(component: String) async throws {
try 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) {
@MainActor
func reply(with message: Message) async throws {
logger.debug("bridgeWillReplyWithMessage: \(String(describing: message))")
let internalMessage = InternalMessage(from: message)
callBridgeFunction(.replyWith, arguments: [internalMessage.toJSON()])
try await callBridgeFunction(.replyWith, arguments: [internalMessage.toJSON()])
}

// /// Convenience method to reply to a previously received message. Data will be replaced,
// /// while id, component, and event will remain the same
// /// - Parameter message: Message to reply to
Expand All @@ -74,28 +73,27 @@ 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) {
guard let webView = webView else {
completion?(nil, BridgeError.missingWebView)
return
@discardableResult
@MainActor
func evaluate(javaScript: String) async throws -> Any? {
guard let webView else {
throw BridgeError.missingWebView
}

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

completion?(result, error)

do {
return try await webView.evaluateJavaScriptAsync(javaScript)
} catch {
logger.error("Error evaluating JavaScript: \(error)")
throw 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)
@MainActor
func evaluate(function: String, arguments: [Any] = []) async throws -> Any? {
try await evaluate(javaScript: JavaScript(functionName: function, arguments: arguments).toString())
}

static func initialize(_ bridge: Bridge) {
Expand All @@ -106,23 +104,24 @@ public final class Bridge: Bridgable {
static func getBridgeFor(_ webView: WKWebView) -> Bridge? {
return instances.first { $0.webView == webView }
}

// MARK: Private

private static var instances: [Bridge] = []
/// This needs to match whatever the JavaScript file uses
private let bridgeGlobal = "window.nativeBridge"

/// The webkit.messageHandlers name
private let scriptHandlerName = "strada"

private func callBridgeFunction(_ function: JavaScriptBridgeFunction, arguments: [Any]) {

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

// MARK: - Configuration

/// Configure the bridge in the provided web view
private func loadIntoWebView() {
guard let configuration = webView?.configuration else { return }
Expand All @@ -131,17 +130,18 @@ public final class Bridge: Bridgable {
if let userScript = makeUserScript() {
configuration.userContentController.addUserScript(userScript)
}

let scriptMessageHandler = ScriptMessageHandler(delegate: self)
configuration.userContentController.add(scriptMessageHandler, name: scriptHandlerName)
}

private func makeUserScript() -> WKUserScript? {
guard
let path = PathLoader().pathFor(name: "strada", fileType: "js") else {
return nil
let path = PathLoader().pathFor(name: "strada", fileType: "js")
else {
return nil
}

do {
let source = try String(contentsOfFile: path)
return WKUserScript(source: source, injectionTime: .atDocumentStart, forMainFrameOnly: true)
Expand All @@ -150,18 +150,20 @@ public final class Bridge: Bridgable {
return nil
}
}

// MARK: - JavaScript Evaluation

private func evaluate(javaScript: JavaScript, completion: CompletionHandler? = nil) {

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

private enum JavaScriptBridgeFunction: String {
case register
case unregister
Expand All @@ -170,18 +172,37 @@ public final class Bridge: Bridgable {
}

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

if let message = InternalMessage(scriptMessage: scriptMessage) {
delegate?.bridgeDidReceiveMessage(message.toMessage())
return
}

logger.warning("Unhandled message received: \(String(describing: scriptMessage.body))")
}
}

private extension WKWebView {
/// NOTE: The async version crashes the app with `Fatal error: Unexpectedly found nil while implicitly unwrapping an Optional value`
/// in case the function doesn't return anything.
/// This is a workaround. See https://forums.developer.apple.com/forums/thread/701553 for more details.
@discardableResult
@MainActor
func evaluateJavaScriptAsync(_ javaScriptString: String) async throws -> Any? {
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Any?, Error>) in
evaluateJavaScript(javaScriptString) { data, error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: data)
}
}
}
}
}
Loading

0 comments on commit 0b4b6c0

Please sign in to comment.