Skip to content

Commit

Permalink
Merge branch 'main' into incorporate-stimulus-polywork
Browse files Browse the repository at this point in the history
* main: (66 commits)
  Expose, by making `didReceive(message:)` and all of component's view lifecycle functions public. This allows to use them for testing.
  Add example component tests.
  Define `BridgingDelegate` to  enable component testing by mocking the component's delegate.
  Add reply(with:) function to `BridgeDelegate` to avoid accessing the bridge directly from within the BridgeComponent.
  Expose `Message` and `Message.Metadata`'s initializers.
  Bump min iOS version in package to iOS 14.
  Refactor logging using the logger with appropriate log levels.
  Add `debugLoggingEnabled` flag to config.
  Define enabled and disabled loggers.
  Move user agent substring generation from `Bridge` to `Strada`.
  Move json encoder/decoder configurations to a config inside a Strada namespace.
  Tweak `Message` file structure.
  Rename Message's functions related to Encodable types.
  Rename reply(to:) functions.
  Rename Message's decodedJsonData() to decodedDataObject().
  Tweak Message's replacing with an encodable object function. Add tests for replyTo with a Codable.
  Add option to reply with a Encodable type.
  Improve decoder related `Message` tests.
  Add `BridgeComponent` tests.
  Add option to reply to an event.
  ...

# Conflicts:
#	Source/strada.js
  • Loading branch information
jayohms committed Sep 12, 2023
2 parents 60e8569 + 84d6b06 commit 184ea65
Show file tree
Hide file tree
Showing 34 changed files with 1,927 additions and 182 deletions.
13 changes: 0 additions & 13 deletions .github/workflows/main.yml

This file was deleted.

27 changes: 27 additions & 0 deletions .github/workflows/run_tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Run tests

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

on:
push:
branches:
- main
pull_request:

env:
XCODEBUILD_DESTINATION_IOS: 'platform=iOS Simulator,name=iPhone 14 Pro'

jobs:
build-and-test:
runs-on: macos-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3

- name: Select Xcode
run: sudo xcode-select -switch /Applications/Xcode_14.2.app && /usr/bin/xcodebuild -version

- name: Run Tests
run: xcodebuild test -project Strada.xcodeproj -scheme Strada -destination "${{ env.XCODEBUILD_DESTINATION_IOS }}" -resultBundlePath TestResults
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
build
node_modules
*.log

*.xcuserstate

*.xcbkptlist
20 changes: 20 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
Copyright (c) 2022 37signals, LLC

Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:

The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import PackageDescription
let package = Package(
name: "Strada",
platforms: [
.iOS(.v12)
.iOS(.v14)
],
products: [
.library(
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,16 @@ In most case though, you'll be replying to an existing message, in which you can
```swift
let message: Message = // a message received earlier through delegate
bridge.reply(to: message, with: data)
```
```

## Contributing

Strada iOS is open-source software, freely distributable under the terms of an [MIT-style license](LICENSE). The [source code is hosted on GitHub](https://github.com/hotwired/strada-ios). Development is sponsored by [37signals](https://37signals.com/).

We welcome contributions in the form of bug reports, pull requests, or thoughtful discussions in the [GitHub issue tracker](https://github.com/hotwired/strada-ios/issues).

Please note that this project is released with a [Contributor Code of Conduct](docs/CONDUCT.md). By participating in this project you agree to abide by its terms.

---------

© 2022 37signals, LLC
178 changes: 103 additions & 75 deletions Source/Bridge.swift
Original file line number Diff line number Diff line change
@@ -1,84 +1,123 @@
import Foundation
import WebKit

public protocol BridgeDelegate: class {
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 reply(with 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) {
callBridgeFunction("register", arguments: [component])
func register(component: String) {
callBridgeFunction(.register, arguments: [component])
}

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

/// Unregister support for a single component
/// - Parameter component: Component name
public func unregister(component: String) {
callBridgeFunction("unregister", arguments: [component])
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) {
callBridgeFunction("send", arguments: [message.toJSON()])
func reply(with message: Message) {
logger.debug("bridgeWillReplyWithMessage: \(String(describing: message))")
let internalMessage = InternalMessage(from: message)
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
/// - Parameter data: Data to send with reply
public func reply(to message: Message, with data: MessageData) {
let replyMessage = message.replacing(data: data)
callBridgeFunction("send", arguments: [replyMessage.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
// /// - Parameter data: Data to send with reply
// public func reply(to message: Message, with data: MessageData) {
// 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 {
logger.error("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 }
}

private func callBridgeFunction(_ function: String, arguments: [Any]) {
let js = JavaScript(object: bridgeGlobal, functionName: function, arguments: arguments)
// 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]) {
let js = JavaScript(object: bridgeGlobal, functionName: function.rawValue, arguments: arguments)
evaluate(javaScript: js)
}

Expand All @@ -93,7 +132,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 @@ -110,50 +150,38 @@ 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 {
evaluate(javaScript: try javaScript.toString(), completion: completion)
} catch {
debugLog("Error evaluating JavaScript: \(javaScript), error: \(error)")
logger.error("Error evaluating JavaScript: \(String(describing: javaScript)), error: \(error)")
completion?(nil, error)
}
}

private enum JavaScriptBridgeFunction: String {
case register
case unregister
case replyWith
}
}

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 = Message(scriptMessage: scriptMessage) {
delegate?.bridgeDidReceiveMessage(message)
} else {
debugLog("Unhandled message received: \(scriptMessage.body)")
return
}

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

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

0 comments on commit 184ea65

Please sign in to comment.