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

Implement BridgeDelegate and BridgeComponent Protocol #6

Merged
merged 29 commits into from
Aug 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
612cfc5
Ignore `*.xcuserstate`.
svara Jul 26, 2023
f2670da
Merge branch 'internal-message' into bridge-delegate
svara Jul 27, 2023
21d3669
Define `BridgeComponent` protocol. Start implementing `BridgeDelegate`.
svara Jul 27, 2023
50aaaf2
Fix broken Bridge test.
svara Jul 31, 2023
e38b09d
Make retrieve component function public.
svara Jul 31, 2023
502c406
Add iOS specific view's lifecycle function to `BridgeDelegate` and `B…
svara Jul 31, 2023
19fb2db
Link `BridgeDelegate`'s bridge when it is set to a `Bridge` instance.
svara Aug 1, 2023
b4db251
Initialize `BridgeComponent` with a destination.
svara Aug 1, 2023
974dd47
Ignore breakpoint files.
svara Aug 2, 2023
4562556
Merge branch 'main' into bridge-delegate
svara Aug 2, 2023
6623961
Only expose a static initializer on `Bridge` that takes a web view in…
svara Aug 3, 2023
cf23734
Expose access to web view through bridge delegate. Implement logic wh…
svara Aug 3, 2023
f34652f
Add two additional lifecycle function to bridge component.
svara Aug 3, 2023
e611f12
Mark destination in bridge delegate as an unowned reference.
svara Aug 3, 2023
dcb1e0d
Merge branch 'main' into bridge-delegate
svara Aug 3, 2023
3e4e5bd
Rename web view activated/deactivated functions following the typical…
svara Aug 4, 2023
adbd2b9
Access webView through the bridge instance.
svara Aug 4, 2023
face82f
Don't expose bridge's js functions publicly.
svara Aug 4, 2023
c263747
Add two more destination lifecycle events to bridge delegate. Update …
svara Aug 4, 2023
45836c9
Remove unused property from `BridgeDestination` protocol.
svara Aug 4, 2023
b2ebd6f
Rename tests.
svara Aug 4, 2023
9b363a8
Update to recommended Xcode settings.
svara Aug 4, 2023
0006649
Set min deployment target to iOS 13.
svara Aug 4, 2023
16c8c21
Increase expectations timeout to 2 seconds.
svara Aug 4, 2023
a06c484
Make `BridgeComponent` an "abstract" class.
svara Aug 4, 2023
c18fc10
Make all functions and properties of `BridgeComponent` public.
svara Aug 4, 2023
506dcae
Make relevant functions and properties of `BridgeComponent` open.
svara Aug 4, 2023
c92caf5
Set min deployment target to iOS 14.
svara Aug 4, 2023
c41a37e
Mark destination inactive only when view disappears.
svara Aug 4, 2023
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ node_modules
*.log

*.xcuserstate

*.xcbkptlist
140 changes: 80 additions & 60 deletions Source/Bridge.swift
Original file line number Diff line number Diff line change
@@ -1,70 +1,66 @@
import Foundation
import WebKit

public protocol BridgeDelegate: AnyObject {
func bridgeDidInitialize()
func bridgeDidReceiveMessage(_ message: Message)
}

public enum BridgeError: Error {
case missingWebView
}

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 send(_ message: Message)
}

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

public var webView: WKWebView? {
didSet {
guard webView != oldValue else { return }
loadIntoWebView()
weak var delegate: BridgeDelegate?
weak var webView: WKWebView?

public static func initialize(_ webView: WKWebView) {
if getBridgeFor(webView) == nil {
initialize(Bridge(webView: webView))
}
}

public weak var delegate: BridgeDelegate?

/// This needs to match whatever the JavaScript file uses
private let bridgeGlobal = "window.nativeBridge"

/// The webkit.messageHandlers name
private let scriptHandlerName = "strada"
init(webView: WKWebView) {
self.webView = webView
loadIntoWebView()
}

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

/// Create a new Bridge object for calling methods on this web view with a delegate
/// for receiving messages
public init(webView: WKWebView? = nil, delegate: BridgeDelegate? = nil) {
self.webView = webView
self.delegate = delegate
loadIntoWebView()
}

// MARK: - API
// MARK: - Internal API

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

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

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

/// Send a message through the bridge to the web application
/// - Parameter message: Message to send
public func send(_ message: Message) {
func send(_ message: Message) {
let internalMessage = InternalMessage(from: message)
callBridgeFunction("send", arguments: [internalMessage.toJSON()])
}
Expand All @@ -77,6 +73,47 @@ public final class Bridge {
// 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
}

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

completion?(result, 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)
}

static func initialize(_ bridge: Bridge) {
instances.append(bridge)
instances.removeAll { $0.webView == nil }
}

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: String, arguments: [Any]) {
let js = JavaScript(object: bridgeGlobal, functionName: function, arguments: arguments)
Expand All @@ -94,7 +131,8 @@ public final class Bridge {
configuration.userContentController.addUserScript(userScript)
}

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

private func makeUserScript() -> WKUserScript? {
Expand All @@ -111,31 +149,8 @@ public final class Bridge {
return nil
}
}

// MARK: - JavaScript Evaluation

/// Evaluates javaScript string directly as passed in sending through the web view
public func evaluate(javaScript: String, completion: CompletionHandler? = nil) {
guard let webView = webView else {
completion?(nil, BridgeError.missingWebView)
return
}

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

completion?(result, 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"])
public func evaluate(function: String, arguments: [Any] = [], completion: CompletionHandler? = nil) {
evaluate(javaScript: JavaScript(functionName: function, arguments: arguments), completion: completion)
}
// MARK: - JavaScript Evaluation

private func evaluate(javaScript: JavaScript, completion: CompletionHandler? = nil) {
do {
Expand All @@ -149,12 +164,17 @@ public final class Bridge {

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

if let message = InternalMessage(scriptMessage: scriptMessage) {
delegate?.bridgeDidReceiveMessage(message.toMessage())
} else {
debugLog("Unhandled message received: \(scriptMessage.body)")
return
}

debugLog("Unhandled message received: \(scriptMessage.body)")
}
}
47 changes: 47 additions & 0 deletions Source/BridgeComponent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import Foundation

protocol BridgingComponent: AnyObject {
static var name: String { get }
var delegate: BridgeDelegate { get }

init(destination: BridgeDestination,
delegate: BridgeDelegate)

func handle(message: Message)
func onViewDidLoad()
func onViewWillAppear()
func onViewDidAppear()
func onViewWillDisappear()
func onViewDidDisappear()
}

open class BridgeComponent: BridgingComponent {
open class var name: String {
fatalError("BridgeComponent subclass must provide a unique 'name'")
}

public unowned let delegate: BridgeDelegate

required public init(destination: BridgeDestination, delegate: BridgeDelegate) {
self.delegate = delegate
}

open func handle(message: Message) {
fatalError("BridgeComponent subclass must handle incoming messages")
}

public func send(message: Message) {
guard let bridge = delegate.bridge else {
debugLog("bridgeMessageFailedToSend: bridge is not available")
return
}

bridge.send(message)
}

open func onViewDidLoad() {}
open func onViewWillAppear() {}
open func onViewDidAppear() {}
open func onViewWillDisappear() {}
open func onViewDidDisappear() {}
}
120 changes: 120 additions & 0 deletions Source/BridgeDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import Foundation
import WebKit

public protocol BridgeDestination: AnyObject {}

public final class BridgeDelegate {
public let location: String
public unowned let destination: BridgeDestination
public var webView: WKWebView? {
bridge?.webView
}

weak var bridge: Bridgable?

public init(location: String,
destination: BridgeDestination,
componentTypes: [BridgeComponent.Type]) {
self.location = location
self.destination = destination
self.componentTypes = componentTypes
}

public func webViewDidBecomeActive(_ webView: WKWebView) {
bridge = Bridge.getBridgeFor(webView)
bridge?.delegate = self

if bridge == nil {
debugLog("bridgeNotInitializedForWebView")
}
}

public func webViewDidBecomeDeactivated() {
bridge?.delegate = nil
bridge = nil
}

// MARK: - Destination lifecycle

public func onViewDidLoad() {
debugLog("bridgeDestinationViewDidLoad: \(location)")
destinationIsActive = true
activeComponents.forEach { $0.onViewDidLoad() }
}

public func onViewWillAppear() {
debugLog("bridgeDestinationViewWillAppear: \(location)")
destinationIsActive = true
activeComponents.forEach { $0.onViewWillAppear() }
}

public func onViewDidAppear() {
debugLog("bridgeDestinationViewDidAppear: \(location)")
destinationIsActive = true
activeComponents.forEach { $0.onViewDidAppear() }
}

public func onViewWillDisappear() {
activeComponents.forEach { $0.onViewWillDisappear() }
debugLog("bridgeDestinationViewWillDisappear: \(location)")
}

public func onViewDidDisappear() {
activeComponents.forEach { $0.onViewDidDisappear() }
destinationIsActive = false
debugLog("bridgeDestinationViewDidDisappear: \(location)")
}

// MARK: Retrieve component by type

public func component<C: BridgeComponent>() -> C? {
return activeComponents.compactMap { $0 as? C }.first
}

// MARK: Internal

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

@discardableResult
func bridgeDidReceiveMessage(_ message: Message) -> Bool {
guard destinationIsActive,
location == message.metadata?.url else {
debugLog("bridgeDidIgnoreMessage: \(message)")
return false
}

debugLog("bridgeDidReceiveMessage: \(message)")
getOrCreateComponent(name: message.component)?.handle(message: message)

return true
}

// MARK: Private

private var initializedComponents: [String: BridgeComponent] = [:]
private var destinationIsActive = false
private let componentTypes: [BridgeComponent.Type]

private var activeComponents: [BridgeComponent] {
return initializedComponents.values.filter { _ in destinationIsActive }
}

private func getOrCreateComponent(name: String) -> BridgeComponent? {
if let component = initializedComponents[name] {
return component
}

guard let componentType = componentTypes.first(where: { $0.name == name }) else {
return nil
}

let component = componentType.init(destination: destination, delegate: self)
initializedComponents[name] = component

return component
}
}

Loading