Skip to content

Commit

Permalink
docs
Browse files Browse the repository at this point in the history
  • Loading branch information
Shane Osbourne committed Apr 22, 2023
1 parent 3a829df commit f4ffc54
Show file tree
Hide file tree
Showing 4 changed files with 126 additions and 34 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//
// Created by shane osbourne on 22/04/2023.
//

import Foundation
import WebKit
import Combine
import ContentScopeScripts
import UserScript
import Common
import os.log

public final class SpecialPagesUserScript: NSObject, StaticUserScript, UserScriptMessaging {

public let broker = UserScriptMessageBroker(context: "specialPages")

public let messageNames: [String] = [
"specialPages"
]

public var requiresRunInPageContentWorld: Bool {
return true
}

public static var injectionTime: WKUserScriptInjectionTime { .atDocumentEnd }
public static var forMainFrameOnly: Bool { true }
public static var source: String = ""
public static var script: WKUserScript = SpecialPagesUserScript.makeWKUserScript()
}

@available(macOS 11, *)
extension SpecialPagesUserScript: WKScriptMessageHandlerWithReply {
public func userContentController(_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage,
replyHandler: @escaping (Any?, String?) -> Void) {
let action = broker.messageHandlerFor(message)
do {
try broker.execute(action: action, original: message) { json in
replyHandler(json, nil)
}
} catch let error {
// forward uncaught errors to the client
replyHandler(nil, error.localizedDescription)
}
}
}

// MARK: - Fallback for macOS 10.15
extension SpecialPagesUserScript: WKScriptMessageHandler {
public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
// secure messaging not supported for ContentScopeScripts
}
}
29 changes: 22 additions & 7 deletions Sources/UserScript/UserScriptMessaging.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,20 @@ public protocol UserScriptMessagingSubFeature {
/// The first part, `Any` represents the 'params' field of either RequestMessage or
/// NotificationMessage and can be easily converted into a type of your choosing.
///
/// The second part is the original WKScriptMessage
///
/// The `replyHandler` part accepts any Encodable value - so you can call it with values
/// directly and they will be serialized into the `result` field of [MessageResponse](https://duckduckgo.github.io/content-scope-scripts/classes/Messaging_Schema.MessageResponse.html#result)
///
/// ```swift
/// func hello(params: Any, replyHandler: @escaping MessageReplyHandler) throws -> Void {
/// func hello(params: Any, original: WKScriptMessage, replyHandler: @escaping MessageReplyHandler) throws -> Void {
/// if let myData: MyData = DecodableHelper.decode(params) {
/// let myResponse = MyResponse()
/// replyHandler(myResponse)
/// }
/// }
/// ```
typealias Handler = (_ params: Any, _ originl: UserScriptMessage, _ replyHandler: @escaping MessageReplyHandler) throws -> Void
typealias Handler = (_ params: Any, _ original: WKScriptMessage, _ replyHandler: @escaping MessageReplyHandler) throws -> Void

/// This gives a feature the opportunity to select it's own handler on a
/// call-by-call basis. The 'method' key is present on `RequestMessage` & `NotificationMessage`
Expand All @@ -57,6 +59,16 @@ public protocol UserScriptMessagingSubFeature {
/// This allows the feature to be selective about which domains/origins it accepts messages from
var allowedOrigins: AllowedOrigins { get }

/// The top-level name of the feature. For example, if Duck Player was delivered through C-S-S, the
/// "featureName" would still be "duckPlayer" and the "context" would be related to the shared UserScript, in this
/// case that's "contentScopeScripts"
///
/// For example:
/// context: "contentScopeScripts"
/// featureName: "duckPlayer"
/// method: "setUserValues"
/// params: "..."
/// id: "abc"
///
var featureName: String { get }
}
Expand All @@ -71,6 +83,8 @@ extension UserScriptMessaging {
}
}

/// The message broker just holds references to instances and distributes messages
/// to them. There would be exactly 1 `UserScriptMessageBroker` per UserScript
public final class UserScriptMessageBroker: NSObject, UserScriptMessageEncryption {

public let encrypter: UserScriptEncrypter
Expand All @@ -83,7 +97,7 @@ public final class UserScriptMessageBroker: NSObject, UserScriptMessageEncryptio
public let context: String

/// We determine which feature should receive a given message
/// based on this. For example, if the mapping is...
/// based on this
var callbacks: [String: UserScriptMessagingSubFeature] = [:]

public init(context: String,
Expand All @@ -109,8 +123,8 @@ public final class UserScriptMessageBroker: NSObject, UserScriptMessageEncryptio
/// Convert incoming messages, into an Action.
///
/// Conditions for `error`:
/// - does contain `featureName`, `context` or `method`
/// - delegate not found based on `featureName`
/// - does not contain `featureName`, `context` or `method`
/// - delegate not found for `featureName`
/// - origin not supported, due to a feature's configuration
/// - delegate failed to provide a handler
///
Expand Down Expand Up @@ -147,12 +161,13 @@ public final class UserScriptMessageBroker: NSObject, UserScriptMessageEncryptio
return .error(message: "the incoming message is ignored because the feature `\(featureName)` couldn't provide a handler for method `\(method)`")
}

// just send empty params if absent
/// just send empty params if absent
var methodParams: Any = [:] as Any
if let params = dict["params"] {
methodParams = params
}

/// id the incoming message had an 'id' field, it requires a response
if let id = dict["id"] as? String {
let incoming = RequestMessage(context: context, featureName: featureName, id: id, method: method, params: methodParams)
return .respond(handler: handler, request: incoming)
Expand All @@ -163,7 +178,7 @@ public final class UserScriptMessageBroker: NSObject, UserScriptMessageEncryptio
}

/// Perform the side-effect described in an action
public func execute(action: Action, original: UserScriptMessage, completion: @escaping (String) -> Void) throws {
public func execute(action: Action, original: WKScriptMessage, completion: @escaping (String) -> Void) throws {
switch action {
/// for `notify` we just need to execute the handler and continue
/// we **do not** forward any errors to the client
Expand Down
26 changes: 25 additions & 1 deletion Sources/UserScript/UserScriptMessagingSchema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,33 @@ public struct SubscriptionEvent {
public let featureName: String
public let subscriptionName: String
public let params: Encodable

public func toJSON() -> String? {

// I added this because I couldn't figure out the generic/recursive Encodable
let dictionary: [String: Encodable] = [
"context": self.context,
"featureName": self.featureName,
"subscriptionName": self.subscriptionName,
"params": self.params
]

return GenericJsonOutput.toJSON(dict: dictionary)
}

public static func toJS(context: String, featureName: String, subscriptionName: String, params: Encodable) -> String? {

let res = SubscriptionEvent(context: context, featureName: featureName, subscriptionName: subscriptionName, params: params)
guard let json = res.toJSON() else {
assertionFailure("Could not convert a SubscriptionEvent to JSON")
return nil
}

return "window.\(res.subscriptionName)?.(\(json));"
}
}

// helper types
// helper types for nested JSON output (please remove if there's a better way
struct GenericJsonOutput: Encodable {
let dict: [String: Encodable]

Expand Down
52 changes: 26 additions & 26 deletions Tests/UserScriptTests/UserScriptMessagingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ class UserScriptMessagingTests: XCTestCase {
/// that the client is expecting a response. This test ensures that
/// a 'result' keys exists on the response, as per [MessageResponse](https://duckduckgo.github.io/content-scope-scripts/classes/Messaging_Schema.MessageResponse.html)
func testDelegateRespondsWithResult() async {
let (testee, action) = setupWith(message: [
let (testee, action, original) = setupWith(message: [
"context": "any_context",
"featureName": TestDelegate.featureName,
"featureName": "fooBarFeature",
"method": "responseExample",
"id": "abcdef01623456",
"params": [
Expand All @@ -41,15 +41,15 @@ class UserScriptMessagingTests: XCTestCase {
let expectation = XCTestExpectation(description: "replyHandler called")

do {
try testee.execute(action: action) { json in
try testee.execute(action: action, original: original) { json in
// convert back from json to test the e2e flow
if let data = json.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data),
let dict = obj as? [String: Any],
let result = dict["result"] as? [String: Any] {

XCTAssertEqual(dict["context"] as? String, testee.context)
XCTAssertEqual(dict["featureName"] as? String, TestDelegate.featureName)
XCTAssertEqual(dict["featureName"] as? String, "fooBarFeature")
XCTAssertEqual(dict["id"] as? String, "abcdef01623456")

// here we care that the data was returned inside 'result'
Expand All @@ -70,9 +70,9 @@ class UserScriptMessagingTests: XCTestCase {
/// Note: the incoming message has no `id` field. This is what makes it a 'notification'
func testDelegateHandlesNotification() async {

let (testee, action) = setupWith(message: [
let (testee, action, original) = setupWith(message: [
"context": "any_context",
"featureName": TestDelegate.featureName,
"featureName": "fooBarFeature",
"method": "notifyExample",
"params": [
"name": "kittie"
Expand All @@ -83,7 +83,7 @@ class UserScriptMessagingTests: XCTestCase {
let expectation = XCTestExpectation(description: "replyHandler called")

do {
try testee.execute(action: action) { json in
try testee.execute(action: action, original: original) { json in
// in a notification, we send back an empty object to prevent the promise from rejecting on the client
XCTAssertEqual("{}", json)
expectation.fulfill()
Expand All @@ -98,9 +98,9 @@ class UserScriptMessagingTests: XCTestCase {
/// it can use the error-response to close the loop between request/response.
func testDelegateRespondsWithErrorResponse() async {

let (testee, action) = setupWith(message: [
let (testee, action, original) = setupWith(message: [
"context": "any_context",
"featureName": TestDelegate.featureName,
"featureName": "fooBarFeature",
"method": "errorExample",
"id": "abcdef01623456",
"params": [
Expand All @@ -111,14 +111,14 @@ class UserScriptMessagingTests: XCTestCase {
let expectation = XCTestExpectation(description: "replyHandler called")

do {
try testee.execute(action: action) { json in
try testee.execute(action: action, original: original) { json in
if let data = json.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data),
let dict = obj as? [String: Any],
let error = dict["error"] as? [String: Any] {

XCTAssertEqual(dict["context"] as? String, testee.context)
XCTAssertEqual(dict["featureName"] as? String, TestDelegate.featureName)
XCTAssertEqual(dict["featureName"] as? String, "fooBarFeature")
XCTAssertEqual(dict["id"] as? String, "abcdef01623456")

// we care that 'error.message' is reflected to the client
Expand All @@ -134,7 +134,7 @@ class UserScriptMessagingTests: XCTestCase {

/// Ensure that an error is thrown if the feature was not registered
func testThrowsOnMissingFeature() async {
let (testee, action) = setupWith(message: [
let (testee, action, original) = setupWith(message: [
"context": "any_context",
"featureName": "this_feature_doesnt_exist",
"method": "an_unknown_method_name_but_no_id",
Expand All @@ -144,7 +144,7 @@ class UserScriptMessagingTests: XCTestCase {
])

do {
try testee.execute(action: action) { _ in
try testee.execute(action: action, original: original) { _ in
XCTFail("unreachable")
}
} catch let error {
Expand All @@ -155,17 +155,17 @@ class UserScriptMessagingTests: XCTestCase {
/// Ensure that an error is thrown if a feature was found, but it wasn't able to select a handler for the
/// particular incoming 'method'
func testThrowsOnMissingMethod() async {
let (testee, action) = setupWith(message: [
let (testee, action, original) = setupWith(message: [
"context": "any_context",
"featureName": TestDelegate.featureName,
"featureName": "fooBarFeature",
"method": "an_unknown_method_name_but_no_id",
"params": [
"foo": "bar"
]
])

do {
try testee.execute(action: action) { _ in
try testee.execute(action: action, original: original) { _ in
XCTFail("unreachable")
}
} catch let error {
Expand All @@ -177,16 +177,16 @@ class UserScriptMessagingTests: XCTestCase {
/// on the webkit implementation (because the webkit MessageHandler must of been correct), this test is
/// more about compliance with the shared/documented types
func testThrowsOnMissingContext() async {
let (testee, action) = setupWith(message: [
"featureName": TestDelegate.featureName,
let (testee, action, original) = setupWith(message: [
"featureName": "fooBarFeature",
"method": "an_unknown_method_name_but_no_id",
"params": [
"foo": "bar"
]
])

do {
try testee.execute(action: action) { _ in
try testee.execute(action: action, original: original) { _ in
XCTFail("unreachable")
}
} catch let error {
Expand All @@ -200,27 +200,27 @@ class UserScriptMessagingTests: XCTestCase {
///
/// - Parameter message: The incoming message
///
func setupWith(message: [String: Any]) -> (UserScriptMessageBroker, UserScriptMessageBroker.Action) {
func setupWith(message: [String: Any]) -> (UserScriptMessageBroker, UserScriptMessageBroker.Action, WKScriptMessage) {
// create the instance of ContentScopeMessaging
let testee = UserScriptMessageBroker(context: message["context"] as? String ?? "default")

// register a feature for a given name
testee.registerSubFeatureFor(name: TestDelegate.featureName, delegate: TestDelegate())
testee.registerSubFeatureFor(name: "fooBarFeature", delegate: TestDelegate())

// Mock the call from the webview.
let msg1 = MockMsg(name: testee.context, body: message)

// get the handler action
let action = testee.messageHandlerFor(msg1)

return (testee, action)
return (testee, action, msg1)
}

/// An example of how to conform to `ContentScopeScriptsSubFeature`
/// It contains 3 examples that are typical of a feature that needs to
/// communicate to a UserScript
struct TestDelegate: UserScriptMessagingSubFeature {
static let featureName = "fooBarFeature"
var featureName = "fooBarFeature"

/// This feature will accept messages from .all - meaning every origin
///
Expand All @@ -244,20 +244,20 @@ struct TestDelegate: UserScriptMessagingSubFeature {
}

/// An example that represents handling a [NotificationMessage](https://duckduckgo.github.io/content-scope-scripts/classes/Messaging_Schema.NotificationMessage.html)
func notifyExample(params: Any, replyHandler: @escaping MessageReplyHandler) throws {
func notifyExample(params: Any, original: WKScriptMessage, replyHandler: @escaping MessageReplyHandler) throws {
print("not replying...")
}

/// An example that represents throwing an exception from a handler
///
/// Note: if this happens as part of a 'request', the error string will be forwarded onto the client side JS
func errorExample(params: Any, replyHandler: @escaping MessageReplyHandler) throws {
func errorExample(params: Any, original: WKScriptMessage, replyHandler: @escaping MessageReplyHandler) throws {
let error = NSError(domain: "MyHandler", code: 0, userInfo: [NSLocalizedDescriptionKey: "Some Error"])
throw error
}

/// An example of how a handler can reply with any Encodable data type
func responseExample(params: Any, replyHandler: @escaping MessageReplyHandler) throws {
func responseExample(params: Any, original: WKScriptMessage, replyHandler: @escaping MessageReplyHandler) throws {
let person = Person(name: "Kittie")
replyHandler(person)
}
Expand Down

0 comments on commit f4ffc54

Please sign in to comment.