diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js b/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js index 5b3cd9dc346b..367d8176853f 100644 --- a/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js @@ -1345,6 +1345,1096 @@ return parseJSONPointer(fromPointer); } + /** + * @description + * + * A wrapper for messaging on Windows. + * + * This requires 3 methods to be available, see {@link WindowsMessagingConfig} for details + * + * @example + * + * ```javascript + * [[include:packages/messaging/lib/examples/windows.example.js]]``` + * + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + + /** + * An implementation of {@link MessagingTransport} for Windows + * + * All messages go through `window.chrome.webview` APIs + * + * @implements {MessagingTransport} + */ + class WindowsMessagingTransport { + config + + /** + * @param {WindowsMessagingConfig} config + * @param {import('../index.js').MessagingContext} messagingContext + * @internal + */ + constructor (config, messagingContext) { + this.messagingContext = messagingContext; + this.config = config; + this.globals = { + window, + JSONparse: window.JSON.parse, + JSONstringify: window.JSON.stringify, + Promise: window.Promise, + Error: window.Error, + String: window.String + }; + for (const [methodName, fn] of Object.entries(this.config.methods)) { + if (typeof fn !== 'function') { + throw new Error('cannot create WindowsMessagingTransport, missing the method: ' + methodName) + } + } + } + + /** + * @param {import('../index.js').NotificationMessage} msg + */ + notify (msg) { + const data = this.globals.JSONparse(this.globals.JSONstringify(msg.params || {})); + const notification = WindowsNotification.fromNotification(msg, data); + this.config.methods.postMessage(notification); + } + + /** + * @param {import('../index.js').RequestMessage} msg + * @param {{signal?: AbortSignal}} opts + * @return {Promise} + */ + request (msg, opts = {}) { + // convert the message to window-specific naming + const data = this.globals.JSONparse(this.globals.JSONstringify(msg.params || {})); + const outgoing = WindowsRequestMessage.fromRequest(msg, data); + + // send the message + this.config.methods.postMessage(outgoing); + + // compare incoming messages against the `msg.id` + const comparator = (eventData) => { + return eventData.featureName === msg.featureName && + eventData.context === msg.context && + eventData.id === msg.id + }; + + /** + * @param data + * @return {data is import('../index.js').MessageResponse} + */ + function isMessageResponse (data) { + if ('result' in data) return true + if ('error' in data) return true + return false + } + + // now wait for a matching message + return new this.globals.Promise((resolve, reject) => { + try { + this._subscribe(comparator, opts, (value, unsubscribe) => { + unsubscribe(); + + if (!isMessageResponse(value)) { + console.warn('unknown response type', value); + return reject(new this.globals.Error('unknown response')) + } + + if (value.result) { + return resolve(value.result) + } + + const message = this.globals.String(value.error?.message || 'unknown error'); + reject(new this.globals.Error(message)); + }); + } catch (e) { + reject(e); + } + }) + } + + /** + * @param {import('../index.js').Subscription} msg + * @param {(value: unknown | undefined) => void} callback + */ + subscribe (msg, callback) { + // compare incoming messages against the `msg.subscriptionName` + const comparator = (eventData) => { + return eventData.featureName === msg.featureName && + eventData.context === msg.context && + eventData.subscriptionName === msg.subscriptionName + }; + + // only forward the 'params' from a SubscriptionEvent + const cb = (eventData) => { + return callback(eventData.params) + }; + + // now listen for matching incoming messages. + return this._subscribe(comparator, {}, cb) + } + + /** + * @typedef {import('../index.js').MessageResponse | import('../index.js').SubscriptionEvent} Incoming + */ + /** + * @param {(eventData: any) => boolean} comparator + * @param {{signal?: AbortSignal}} options + * @param {(value: Incoming, unsubscribe: (()=>void)) => void} callback + * @internal + */ + _subscribe (comparator, options, callback) { + // if already aborted, reject immediately + if (options?.signal?.aborted) { + throw new DOMException('Aborted', 'AbortError') + } + /** @type {(()=>void) | undefined} */ + // eslint-disable-next-line prefer-const + let teardown; + + /** + * @param {MessageEvent} event + */ + const idHandler = (event) => { + if (this.messagingContext.env === 'production') { + if (event.origin !== null && event.origin !== undefined) { + console.warn('ignoring because evt.origin is not `null` or `undefined`'); + return + } + } + if (!event.data) { + console.warn('data absent from message'); + return + } + if (comparator(event.data)) { + if (!teardown) throw new Error('unreachable') + callback(event.data, teardown); + } + }; + + // what to do if this promise is aborted + const abortHandler = () => { + teardown?.(); + throw new DOMException('Aborted', 'AbortError') + }; + + // console.log('DEBUG: handler setup', { config, comparator }) + // eslint-disable-next-line no-undef + this.config.methods.addEventListener('message', idHandler); + options?.signal?.addEventListener('abort', abortHandler); + + teardown = () => { + // console.log('DEBUG: handler teardown', { config, comparator }) + // eslint-disable-next-line no-undef + this.config.methods.removeEventListener('message', idHandler); + options?.signal?.removeEventListener('abort', abortHandler); + }; + + return () => { + teardown?.(); + } + } + } + + /** + * To construct this configuration object, you need access to 3 methods + * + * - `postMessage` + * - `addEventListener` + * - `removeEventListener` + * + * These would normally be available on Windows via the following: + * + * - `window.chrome.webview.postMessage` + * - `window.chrome.webview.addEventListener` + * - `window.chrome.webview.removeEventListener` + * + * Depending on where the script is running, we may want to restrict access to those globals. On the native + * side those handlers `window.chrome.webview` handlers might be deleted and replaces with in-scope variables, such as: + * + * ```ts + * [[include:packages/messaging/lib/examples/windows.example.js]]``` + * + */ + class WindowsMessagingConfig { + /** + * @param {object} params + * @param {WindowsInteropMethods} params.methods + * @internal + */ + constructor (params) { + /** + * The methods required for communication + */ + this.methods = params.methods; + /** + * @type {'windows'} + */ + this.platform = 'windows'; + } + } + + /** + * This data type represents a message sent to the Windows + * platform via `window.chrome.webview.postMessage`. + * + * **NOTE**: This is sent when a response is *not* expected + */ + class WindowsNotification { + /** + * @param {object} params + * @param {string} params.Feature + * @param {string} params.SubFeatureName + * @param {string} params.Name + * @param {Record} [params.Data] + * @internal + */ + constructor (params) { + /** + * Alias for: {@link NotificationMessage.context} + */ + this.Feature = params.Feature; + /** + * Alias for: {@link NotificationMessage.featureName} + */ + this.SubFeatureName = params.SubFeatureName; + /** + * Alias for: {@link NotificationMessage.method} + */ + this.Name = params.Name; + /** + * Alias for: {@link NotificationMessage.params} + */ + this.Data = params.Data; + } + + /** + * Helper to convert a {@link NotificationMessage} to a format that Windows can support + * @param {NotificationMessage} notification + * @returns {WindowsNotification} + */ + static fromNotification (notification, data) { + /** @type {WindowsNotification} */ + const output = { + Data: data, + Feature: notification.context, + SubFeatureName: notification.featureName, + Name: notification.method + }; + return output + } + } + + /** + * This data type represents a message sent to the Windows + * platform via `window.chrome.webview.postMessage` when it + * expects a response + */ + class WindowsRequestMessage { + /** + * @param {object} params + * @param {string} params.Feature + * @param {string} params.SubFeatureName + * @param {string} params.Name + * @param {Record} [params.Data] + * @param {string} [params.Id] + * @internal + */ + constructor (params) { + this.Feature = params.Feature; + this.SubFeatureName = params.SubFeatureName; + this.Name = params.Name; + this.Data = params.Data; + this.Id = params.Id; + } + + /** + * Helper to convert a {@link RequestMessage} to a format that Windows can support + * @param {RequestMessage} msg + * @param {Record} data + * @returns {WindowsRequestMessage} + */ + static fromRequest (msg, data) { + /** @type {WindowsRequestMessage} */ + const output = { + Data: data, + Feature: msg.context, + SubFeatureName: msg.featureName, + Name: msg.method, + Id: msg.id + }; + return output + } + } + + /** + * @module Messaging Schema + * + * @description + * These are all the shared data types used throughout. Transports receive these types and + * can choose how to deliver the message to their respective native platforms. + * + * - Notifications via {@link NotificationMessage} + * - Request -> Response via {@link RequestMessage} and {@link MessageResponse} + * - Subscriptions via {@link Subscription} + * + * Note: For backwards compatibility, some platforms may alter the data shape within the transport. + */ + + /** + * This is the format of an outgoing message. + * + * - See {@link MessageResponse} for what's expected in a response + * + * **NOTE**: + * - Windows will alter this before it's sent, see: {@link Messaging.WindowsRequestMessage} + */ + class RequestMessage { + /** + * @param {object} params + * @param {string} params.context + * @param {string} params.featureName + * @param {string} params.method + * @param {string} params.id + * @param {Record} [params.params] + * @internal + */ + constructor (params) { + /** + * The global context for this message. For example, something like `contentScopeScripts` or `specialPages` + * @type {string} + */ + this.context = params.context; + /** + * The name of the sub-feature, such as `duckPlayer` or `clickToLoad` + * @type {string} + */ + this.featureName = params.featureName; + /** + * The name of the handler to be executed on the native side + */ + this.method = params.method; + /** + * The `id` that native sides can use when sending back a response + */ + this.id = params.id; + /** + * Optional data payload - must be a plain key/value object + */ + this.params = params.params; + } + } + + /** + * **NOTE**: + * - Windows will alter this before it's sent, see: {@link Messaging.WindowsNotification} + */ + class NotificationMessage { + /** + * @param {object} params + * @param {string} params.context + * @param {string} params.featureName + * @param {string} params.method + * @param {Record} [params.params] + * @internal + */ + constructor (params) { + /** + * The global context for this message. For example, something like `contentScopeScripts` or `specialPages` + */ + this.context = params.context; + /** + * The name of the sub-feature, such as `duckPlayer` or `clickToLoad` + */ + this.featureName = params.featureName; + /** + * The name of the handler to be executed on the native side + */ + this.method = params.method; + /** + * An optional payload + */ + this.params = params.params; + } + } + + class Subscription { + /** + * @param {object} params + * @param {string} params.context + * @param {string} params.featureName + * @param {string} params.subscriptionName + * @internal + */ + constructor (params) { + this.context = params.context; + this.featureName = params.featureName; + this.subscriptionName = params.subscriptionName; + } + } + + /** + * @param {RequestMessage} request + * @param {Record} data + * @return {data is MessageResponse} + */ + function isResponseFor (request, data) { + if ('result' in data) { + return data.featureName === request.featureName && + data.context === request.context && + data.id === request.id + } + if ('error' in data) { + if ('message' in data.error) { + return true + } + } + return false + } + + /** + * @param {Subscription} sub + * @param {Record} data + * @return {data is SubscriptionEvent} + */ + function isSubscriptionEventFor (sub, data) { + if ('subscriptionName' in data) { + return data.featureName === sub.featureName && + data.context === sub.context && + data.subscriptionName === sub.subscriptionName + } + + return false + } + + /** + * + * @description + * + * A wrapper for messaging on WebKit platforms. It supports modern WebKit messageHandlers + * along with encryption for older versions (like macOS Catalina) + * + * Note: If you wish to support Catalina then you'll need to implement the native + * part of the message handling, see {@link WebkitMessagingTransport} for details. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + + /** + * @example + * On macOS 11+, this will just call through to `window.webkit.messageHandlers.x.postMessage` + * + * Eg: for a `foo` message defined in Swift that accepted the payload `{"bar": "baz"}`, the following + * would occur: + * + * ```js + * const json = await window.webkit.messageHandlers.foo.postMessage({ bar: "baz" }); + * const response = JSON.parse(json) + * ``` + * + * @example + * On macOS 10 however, the process is a little more involved. A method will be appended to `window` + * that allows the response to be delivered there instead. It's not exactly this, but you can visualize the flow + * as being something along the lines of: + * + * ```js + * // add the window method + * window["_0123456"] = (response) => { + * // decrypt `response` and deliver the result to the caller here + * // then remove the temporary method + * delete window['_0123456'] + * }; + * + * // send the data + `messageHanding` values + * window.webkit.messageHandlers.foo.postMessage({ + * bar: "baz", + * messagingHandling: { + * methodName: "_0123456", + * secret: "super-secret", + * key: [1, 2, 45, 2], + * iv: [34, 4, 43], + * } + * }); + * + * // later in swift, the following JavaScript snippet will be executed + * (() => { + * window['_0123456']({ + * ciphertext: [12, 13, 4], + * tag: [3, 5, 67, 56] + * }) + * })() + * ``` + * @implements {MessagingTransport} + */ + class WebkitMessagingTransport { + /** @type {WebkitMessagingConfig} */ + config + /** @internal */ + globals + + /** + * @param {WebkitMessagingConfig} config + * @param {import('../index.js').MessagingContext} messagingContext + */ + constructor (config, messagingContext) { + this.messagingContext = messagingContext; + this.config = config; + this.globals = captureGlobals(); + if (!this.config.hasModernWebkitAPI) { + this.captureWebkitHandlers(this.config.webkitMessageHandlerNames); + } + } + + /** + * Sends message to the webkit layer (fire and forget) + * @param {String} handler + * @param {*} data + * @internal + */ + wkSend (handler, data = {}) { + if (!(handler in this.globals.window.webkit.messageHandlers)) { + throw new MissingHandler(`Missing webkit handler: '${handler}'`, handler) + } + if (!this.config.hasModernWebkitAPI) { + const outgoing = { + ...data, + messageHandling: { + ...data.messageHandling, + secret: this.config.secret + } + }; + if (!(handler in this.globals.capturedWebkitHandlers)) { + throw new MissingHandler(`cannot continue, method ${handler} not captured on macos < 11`, handler) + } else { + return this.globals.capturedWebkitHandlers[handler](outgoing) + } + } + return this.globals.window.webkit.messageHandlers[handler].postMessage?.(data) + } + + /** + * Sends message to the webkit layer and waits for the specified response + * @param {String} handler + * @param {import('../index.js').RequestMessage} data + * @returns {Promise<*>} + * @internal + */ + async wkSendAndWait (handler, data) { + if (this.config.hasModernWebkitAPI) { + const response = await this.wkSend(handler, data); + return this.globals.JSONparse(response || '{}') + } + + try { + const randMethodName = this.createRandMethodName(); + const key = await this.createRandKey(); + const iv = this.createRandIv(); + + const { + ciphertext, + tag + } = await new this.globals.Promise((/** @type {any} */ resolve) => { + this.generateRandomMethod(randMethodName, resolve); + + // @ts-expect-error - this is a carve-out for catalina that will be removed soon + data.messageHandling = new SecureMessagingParams({ + methodName: randMethodName, + secret: this.config.secret, + key: this.globals.Arrayfrom(key), + iv: this.globals.Arrayfrom(iv) + }); + this.wkSend(handler, data); + }); + + const cipher = new this.globals.Uint8Array([...ciphertext, ...tag]); + const decrypted = await this.decrypt(cipher, key, iv); + return this.globals.JSONparse(decrypted || '{}') + } catch (e) { + // re-throw when the error is just a 'MissingHandler' + if (e instanceof MissingHandler) { + throw e + } else { + console.error('decryption failed', e); + console.error(e); + return { error: e } + } + } + } + + /** + * @param {import('../index.js').NotificationMessage} msg + */ + notify (msg) { + this.wkSend(msg.context, msg); + } + + /** + * @param {import('../index.js').RequestMessage} msg + */ + async request (msg) { + const data = await this.wkSendAndWait(msg.context, msg); + + if (isResponseFor(msg, data)) { + if (data.result) { + return data.result || {} + } + // forward the error if one was given explicity + if (data.error) { + throw new Error(data.error.message) + } + } + + throw new Error('an unknown error occurred') + } + + /** + * Generate a random method name and adds it to the global scope + * The native layer will use this method to send the response + * @param {string | number} randomMethodName + * @param {Function} callback + * @internal + */ + generateRandomMethod (randomMethodName, callback) { + this.globals.ObjectDefineProperty(this.globals.window, randomMethodName, { + enumerable: false, + // configurable, To allow for deletion later + configurable: true, + writable: false, + /** + * @param {any[]} args + */ + value: (...args) => { + // eslint-disable-next-line n/no-callback-literal + callback(...args); + delete this.globals.window[randomMethodName]; + } + }); + } + + /** + * @internal + * @return {string} + */ + randomString () { + return '' + this.globals.getRandomValues(new this.globals.Uint32Array(1))[0] + } + + /** + * @internal + * @return {string} + */ + createRandMethodName () { + return '_' + this.randomString() + } + + /** + * @type {{name: string, length: number}} + * @internal + */ + algoObj = { + name: 'AES-GCM', + length: 256 + } + + /** + * @returns {Promise} + * @internal + */ + async createRandKey () { + const key = await this.globals.generateKey(this.algoObj, true, ['encrypt', 'decrypt']); + const exportedKey = await this.globals.exportKey('raw', key); + return new this.globals.Uint8Array(exportedKey) + } + + /** + * @returns {Uint8Array} + * @internal + */ + createRandIv () { + return this.globals.getRandomValues(new this.globals.Uint8Array(12)) + } + + /** + * @param {BufferSource} ciphertext + * @param {BufferSource} key + * @param {Uint8Array} iv + * @returns {Promise} + * @internal + */ + async decrypt (ciphertext, key, iv) { + const cryptoKey = await this.globals.importKey('raw', key, 'AES-GCM', false, ['decrypt']); + const algo = { + name: 'AES-GCM', + iv + }; + + const decrypted = await this.globals.decrypt(algo, cryptoKey, ciphertext); + + const dec = new this.globals.TextDecoder(); + return dec.decode(decrypted) + } + + /** + * When required (such as on macos 10.x), capture the `postMessage` method on + * each webkit messageHandler + * + * @param {string[]} handlerNames + */ + captureWebkitHandlers (handlerNames) { + const handlers = window.webkit.messageHandlers; + if (!handlers) throw new MissingHandler('window.webkit.messageHandlers was absent', 'all') + for (const webkitMessageHandlerName of handlerNames) { + if (typeof handlers[webkitMessageHandlerName]?.postMessage === 'function') { + /** + * `bind` is used here to ensure future calls to the captured + * `postMessage` have the correct `this` context + */ + const original = handlers[webkitMessageHandlerName]; + const bound = handlers[webkitMessageHandlerName].postMessage?.bind(original); + this.globals.capturedWebkitHandlers[webkitMessageHandlerName] = bound; + delete handlers[webkitMessageHandlerName].postMessage; + } + } + } + + /** + * @param {import('../index.js').Subscription} msg + * @param {(value: unknown) => void} callback + */ + subscribe (msg, callback) { + // for now, bail if there's already a handler setup for this subscription + if (msg.subscriptionName in this.globals.window) { + throw new this.globals.Error(`A subscription with the name ${msg.subscriptionName} already exists`) + } + this.globals.ObjectDefineProperty(this.globals.window, msg.subscriptionName, { + enumerable: false, + configurable: true, + writable: false, + value: (data) => { + if (data && isSubscriptionEventFor(msg, data)) { + callback(data.params); + } else { + console.warn('Received a message that did not match the subscription', data); + } + } + }); + return () => { + this.globals.ReflectDeleteProperty(this.globals.window, msg.subscriptionName); + } + } + } + + /** + * Use this configuration to create an instance of {@link Messaging} for WebKit platforms + * + * We support modern WebKit environments *and* macOS Catalina. + * + * Please see {@link WebkitMessagingTransport} for details on how messages are sent/received + * + * @example Webkit Messaging + * + * ```javascript + * [[include:packages/messaging/lib/examples/webkit.example.js]]``` + */ + class WebkitMessagingConfig { + /** + * @param {object} params + * @param {boolean} params.hasModernWebkitAPI + * @param {string[]} params.webkitMessageHandlerNames + * @param {string} params.secret + * @internal + */ + constructor (params) { + /** + * Whether or not the current WebKit Platform supports secure messaging + * by default (eg: macOS 11+) + */ + this.hasModernWebkitAPI = params.hasModernWebkitAPI; + /** + * A list of WebKit message handler names that a user script can send. + * + * For example, if the native platform can receive messages through this: + * + * ```js + * window.webkit.messageHandlers.foo.postMessage('...') + * ``` + * + * then, this property would be: + * + * ```js + * webkitMessageHandlerNames: ['foo'] + * ``` + */ + this.webkitMessageHandlerNames = params.webkitMessageHandlerNames; + /** + * A string provided by native platforms to be sent with future outgoing + * messages. + */ + this.secret = params.secret; + } + } + + /** + * This is the additional payload that gets appended to outgoing messages. + * It's used in the Swift side to encrypt the response that comes back + */ + class SecureMessagingParams { + /** + * @param {object} params + * @param {string} params.methodName + * @param {string} params.secret + * @param {number[]} params.key + * @param {number[]} params.iv + */ + constructor (params) { + /** + * The method that's been appended to `window` to be called later + */ + this.methodName = params.methodName; + /** + * The secret used to ensure message sender validity + */ + this.secret = params.secret; + /** + * The CipherKey as number[] + */ + this.key = params.key; + /** + * The Initial Vector as number[] + */ + this.iv = params.iv; + } + } + + /** + * Capture some globals used for messaging handling to prevent page + * scripts from tampering with this + */ + function captureGlobals () { + // Create base with null prototype + return { + window, + // Methods must be bound to their interface, otherwise they throw Illegal invocation + encrypt: window.crypto.subtle.encrypt.bind(window.crypto.subtle), + decrypt: window.crypto.subtle.decrypt.bind(window.crypto.subtle), + generateKey: window.crypto.subtle.generateKey.bind(window.crypto.subtle), + exportKey: window.crypto.subtle.exportKey.bind(window.crypto.subtle), + importKey: window.crypto.subtle.importKey.bind(window.crypto.subtle), + getRandomValues: window.crypto.getRandomValues.bind(window.crypto), + TextEncoder, + TextDecoder, + Uint8Array, + Uint16Array, + Uint32Array, + JSONstringify: window.JSON.stringify, + JSONparse: window.JSON.parse, + Arrayfrom: window.Array.from, + Promise: window.Promise, + Error: window.Error, + ReflectDeleteProperty: window.Reflect.deleteProperty.bind(window.Reflect), + ObjectDefineProperty: window.Object.defineProperty, + addEventListener: window.addEventListener.bind(window), + /** @type {Record} */ + capturedWebkitHandlers: {} + } + } + + /** + * @module Messaging + * @category Libraries + * @description + * + * An abstraction for communications between JavaScript and host platforms. + * + * 1) First you construct your platform-specific configuration (eg: {@link WebkitMessagingConfig}) + * 2) Then use that to get an instance of the Messaging utility which allows + * you to send and receive data in a unified way + * 3) Each platform implements {@link MessagingTransport} along with its own Configuration + * - For example, to learn what configuration is required for Webkit, see: {@link WebkitMessagingConfig} + * - Or, to learn about how messages are sent and received in Webkit, see {@link WebkitMessagingTransport} + * + * ## Links + * Please see the following links for examples + * + * - Windows: {@link WindowsMessagingConfig} + * - Webkit: {@link WebkitMessagingConfig} + * - Schema: {@link "Messaging Schema"} + * + */ + + /** + * Common options/config that are *not* transport specific. + */ + class MessagingContext { + /** + * @param {object} params + * @param {string} params.context + * @param {string} params.featureName + * @param {"production" | "development"} params.env + * @internal + */ + constructor (params) { + this.context = params.context; + this.featureName = params.featureName; + this.env = params.env; + } + } + + /** + * + */ + class Messaging { + /** + * @param {MessagingContext} messagingContext + * @param {WebkitMessagingConfig | WindowsMessagingConfig | TestTransportConfig} config + */ + constructor (messagingContext, config) { + this.messagingContext = messagingContext; + this.transport = getTransport(config, this.messagingContext); + } + + /** + * Send a 'fire-and-forget' message. + * @throws {MissingHandler} + * + * @example + * + * ```ts + * const messaging = new Messaging(config) + * messaging.notify("foo", {bar: "baz"}) + * ``` + * @param {string} name + * @param {Record} [data] + */ + notify (name, data = {}) { + const message = new NotificationMessage({ + context: this.messagingContext.context, + featureName: this.messagingContext.featureName, + method: name, + params: data + }); + this.transport.notify(message); + } + + /** + * Send a request, and wait for a response + * @throws {MissingHandler} + * + * @example + * ``` + * const messaging = new Messaging(config) + * const response = await messaging.request("foo", {bar: "baz"}) + * ``` + * + * @param {string} name + * @param {Record} [data] + * @return {Promise} + */ + request (name, data = {}) { + const id = name + '.response'; + const message = new RequestMessage({ + context: this.messagingContext.context, + featureName: this.messagingContext.featureName, + method: name, + params: data, + id + }); + return this.transport.request(message) + } + + /** + * @param {string} name + * @param {(value: unknown) => void} callback + * @return {() => void} + */ + subscribe (name, callback) { + const msg = new Subscription({ + context: this.messagingContext.context, + featureName: this.messagingContext.featureName, + subscriptionName: name + }); + return this.transport.subscribe(msg, callback) + } + } + + /** + * Use this to create testing transport on the fly. + * It's useful for debugging, and for enabling scripts to run in + * other environments - for example, testing in a browser without the need + * for a full integration + * + * ```js + * [[include:packages/messaging/lib/examples/test.example.js]]``` + */ + class TestTransportConfig { + /** + * @param {MessagingTransport} impl + */ + constructor (impl) { + this.impl = impl; + } + } + + /** + * @implements {MessagingTransport} + */ + class TestTransport { + /** + * @param {TestTransportConfig} config + * @param {MessagingContext} messagingContext + */ + constructor (config, messagingContext) { + this.config = config; + this.messagingContext = messagingContext; + } + + notify (msg) { + return this.config.impl.notify(msg) + } + + request (msg) { + return this.config.impl.request(msg) + } + + subscribe (msg, callback) { + return this.config.impl.subscribe(msg, callback) + } + } + + /** + * @param {WebkitMessagingConfig | WindowsMessagingConfig | TestTransportConfig} config + * @param {MessagingContext} messagingContext + * @returns {MessagingTransport} + */ + function getTransport (config, messagingContext) { + if (config instanceof WebkitMessagingConfig) { + return new WebkitMessagingTransport(config, messagingContext) + } + if (config instanceof WindowsMessagingConfig) { + return new WindowsMessagingTransport(config, messagingContext) + } + if (config instanceof TestTransportConfig) { + return new TestTransport(config, messagingContext) + } + throw new Error('unreachable') + } + + /** + * Thrown when a handler cannot be found + */ + class MissingHandler extends Error { + /** + * @param {string} message + * @param {string} handlerName + */ + constructor (message, handlerName) { + super(message); + this.handlerName = handlerName; + } + } + /** * @typedef {object} AssetConfig * @property {string} regularFontUrl @@ -1368,6 +2458,8 @@ #documentOriginIsTracker /** @type {Record | undefined} */ #bundledfeatureSettings + /** @type {MessagingContext} */ + #messagingContext /** @type {{ debug?: boolean, featureSettings?: Record, assets?: AssetConfig | undefined, site: Site } | null} */ #args @@ -1422,6 +2514,19 @@ return this.#bundledConfig } + /** + * @returns {MessagingContext} + */ + get messagingContext () { + if (this.#messagingContext) return this.#messagingContext + this.#messagingContext = new MessagingContext({ + context: 'contentScopeScripts', + featureName: this.name, + env: this.isDebug ? 'development' : 'production' + }); + return this.#messagingContext + } + /** * Get the value of a config setting. * If the value is not set, return the default value. @@ -6821,6 +7926,156 @@ return { config, sharedStrings } } + /** + * Workaround defining MessagingTransport locally because "import()" is not working in `@implements` + * @typedef {import('@duckduckgo/messaging').MessagingTransport} MessagingTransport + */ + + /** + * A temporary implementation of {@link MessagingTransport} to communicate with Android and Extension. + * It wraps the current messaging system that calls `sendMessage` + * + * @implements {MessagingTransport} + * @deprecated - Use this only to communicate with Android and the Extension while support to {@link Messaging} + * is not ready and we need to use `sendMessage()`. + */ + class ClickToLoadMessagingTransport { + /** + * Queue of callbacks to be called with messages sent from the Platform. + * This is used to connect requests with responses and to trigger subscriptions callbacks. + */ + _queue = new Set() + + constructor () { + this.globals = { + window, + JSONparse: window.JSON.parse, + JSONstringify: window.JSON.stringify, + Promise: window.Promise, + Error: window.Error, + String: window.String + }; + } + + /** + * Callback for update() handler. This connects messages sent from the Platform + * with callback functions in the _queue. + * @param {any} response + */ + onResponse (response) { + this._queue.forEach((subscription) => subscription(response)); + } + + /** + * @param {import('@duckduckgo/messaging').NotificationMessage} msg + */ + notify (msg) { + let params = msg.params; + + // Unwrap 'setYoutubePreviewsEnabled' params to match expected payload + // for sendMessage() + if (msg.method === 'setYoutubePreviewsEnabled') { + params = msg.params?.youtubePreviewsEnabled; + } + // Unwrap 'updateYouTubeCTLAddedFlag' params to match expected payload + // for sendMessage() + if (msg.method === 'updateYouTubeCTLAddedFlag') { + params = msg.params?.youTubeCTLAddedFlag; + } + + sendMessage(msg.method, params); + } + + /** + * @param {import('@duckduckgo/messaging').RequestMessage} req + * @return {Promise} + */ + request (req) { + let comparator = (eventData) => { + return eventData.responseMessageType === req.method + }; + let params = req.params; + + // Adapts request for 'getYouTubeVideoDetails' by identifying the correct + // response for each request and updating params to expect current + // implementation specifications. + if (req.method === 'getYouTubeVideoDetails') { + comparator = (eventData) => { + return ( + eventData.responseMessageType === req.method && + eventData.response && + eventData.response.videoURL === req.params?.videoURL + ) + }; + params = req.params?.videoURL; + } + + sendMessage(req.method, params); + + return new this.globals.Promise((resolve) => { + this._subscribe(comparator, (msgRes, unsubscribe) => { + unsubscribe(); + + resolve(msgRes.response); + }); + }) + } + + /** + * @param {import('@duckduckgo/messaging').Subscription} msg + * @param {(value: unknown | undefined) => void} callback + */ + subscribe (msg, callback) { + const comparator = (eventData) => { + return ( + eventData.messageType === msg.subscriptionName || + eventData.responseMessageType === msg.subscriptionName + ) + }; + + // only forward the 'params' ('response' in current format), to match expected + // callback from a SubscriptionEvent + const cb = (eventData) => { + return callback(eventData.response) + }; + return this._subscribe(comparator, cb) + } + + /** + * @param {(eventData: any) => boolean} comparator + * @param {(value: any, unsubscribe: (()=>void)) => void} callback + * @internal + */ + _subscribe (comparator, callback) { + /** @type {(()=>void) | undefined} */ + // eslint-disable-next-line prefer-const + let teardown; + + /** + * @param {MessageEvent} event + */ + const idHandler = (event) => { + if (!event) { + console.warn('no message available'); + return + } + if (comparator(event)) { + if (!teardown) throw new this.globals.Error('unreachable') + callback(event, teardown); + } + }; + this._queue.add(idHandler); + + teardown = () => { + this._queue.delete(idHandler); + }; + + return () => { + teardown?.(); + } + } + } + /** * The following code is originally from https://github.com/mozilla-extensions/secure-proxy/blob/db4d1b0e2bfe0abae416bf04241916f9e4768fd2/src/commons/template.js */ @@ -7212,6 +8467,9 @@ let config = null; let sharedStrings = null; let styles = null; + // Used to choose between extension/desktop flow or mobile apps flow. + // Updated on ClickToLoad.init() + let isMobileApp; // TODO: Remove these redundant data structures and refactor the related code. // There should be no need to have the entity configuration stored in two @@ -7236,9 +8494,20 @@ let afterPageLoadResolver; const afterPageLoad = new Promise(resolve => { afterPageLoadResolver = resolve; }); - // Used to choose between extension/desktop flow or mobile apps flow. - // Updated on ClickToLoad.init() - let isMobileApp; + // Messaging layer for Click to Load. The messaging instance is initialized in + // ClickToLoad.init() and updated here to be used outside ClickToLoad class + // we need a module scoped reference. + /** @type {import("@duckduckgo/messaging").Messaging} */ + let _messagingModuleScope; + const ctl = { + /** + * @return {import("@duckduckgo/messaging").Messaging} + */ + get messaging () { + if (!_messagingModuleScope) throw new Error('Messaging not initialized') + return _messagingModuleScope + } + }; /********************************************************* * Widget Replacement logic @@ -7556,6 +8825,7 @@ const handleClick = e => { // Ensure that the click is created by a user event & prevent double clicks from adding more animations if (e.isTrusted && !clicked) { + e.stopPropagation(); this.isUnblocked = true; clicked = true; let isLogin = false; @@ -7563,7 +8833,9 @@ if (this.replaceSettings.type === 'loginButton') { isLogin = true; } - window.addEventListener('ddg-ctp-unblockClickToLoadContent-complete', () => { + const action = this.entity === 'Youtube' ? 'block-ctl-yt' : 'block-ctl-fb'; + // eslint-disable-next-line promise/prefer-await-to-then + unblockClickToLoadContent({ entity: this.entity, action, isLogin }).then(() => { const parent = replacementElement.parentNode; // The placeholder was removed from the DOM while we loaded @@ -7641,9 +8913,7 @@ if (onError) { fbElement.addEventListener('error', onError, { once: true }); } - }, { once: true }); - const action = this.entity === 'Youtube' ? 'block-ctl-yt' : 'block-ctl-fb'; - unblockClickToLoadContent({ entity: this.entity, action, isLogin }); + }); } }; // If this is a login button, show modal if needed @@ -7710,7 +8980,7 @@ // from the DOM after they are collapsed. As a workaround, have the iframe // load a benign data URI, so that it's uncollapsed, before removing it from // the DOM. See https://crbug.com/1428971 - const originalSrc = elementToReplace.src; + const originalSrc = elementToReplace.src || elementToReplace.getAttribute('data-src'); elementToReplace.src = 'data:text/plain;charset=utf-8;base64,' + btoa('https://crbug.com/1428971'); @@ -7803,14 +9073,14 @@ // YouTube if (widget.replaceSettings.type === 'youtube-video') { - sendMessage('updateYouTubeCTLAddedFlag', true); + ctl.messaging.notify('updateYouTubeCTLAddedFlag', { youTubeCTLAddedFlag: true }); replaceYouTubeCTL(trackingElement, widget); // Subscribe to changes to youtubePreviewsEnabled setting // and update the CTL state - window.addEventListener( - 'ddg-settings-youtubePreviewsEnabled', - (/** @type CustomEvent */ { detail: value }) => { + ctl.messaging.subscribe( + 'setYoutubePreviewsEnabled', + ({ value }) => { isYoutubePreviewsEnabled = value; replaceYouTubeCTL(trackingElement, widget); } @@ -7864,7 +9134,7 @@ dataKey: 'yt-preview-toggle', // data-key attribute for button label: widget.replaceSettings.previewToggleText, // Text to be presented with toggle size: isMobileApp ? 'lg' : 'md', - onClick: () => sendMessage('setYoutubePreviewsEnabled', true) // Toggle click callback + onClick: () => ctl.messaging.notify('setYoutubePreviewsEnabled', { youtubePreviewsEnabled: true }) // Toggle click callback }, withFeedback: { label: sharedStrings.shareFeedback, @@ -8030,9 +9300,10 @@ * the page. * @param {unblockClickToLoadContentRequest} message * @see {@link ddg-ctp-unblockClickToLoadContent-complete} for the response handler. + * @returns {Promise} */ function unblockClickToLoadContent (message) { - sendMessage('unblockClickToLoadContent', message); + return ctl.messaging.request('unblockClickToLoadContent', message) } /** @@ -8041,9 +9312,10 @@ * shown. * @param {string} entity */ - function runLogin (entity) { + async function runLogin (entity) { const action = entity === 'Youtube' ? 'block-ctl-yt' : 'block-ctl-fb'; - unblockClickToLoadContent({ entity, action, isLogin: true }); + await unblockClickToLoadContent({ entity, action, isLogin: true }); + // Communicate with surrogate to run login originalWindowDispatchEvent( createCustomEvent('ddg-ctp-run-login', { detail: { @@ -8054,8 +9326,8 @@ } /** - * Close the login dialog and abort. Called after the user clicks to cancel - * after the warning dialog is shown. + * Close the login dialog and communicate with the surrogate to abort. + * Called after the user clicks to cancel after the warning dialog is shown. * @param {string} entity */ function cancelModal (entity) { @@ -8069,11 +9341,7 @@ } function openShareFeedbackPage () { - sendMessage('openShareFeedbackPage', ''); - } - - function getYouTubeVideoDetails (videoURL) { - sendMessage('getYouTubeVideoDetails', videoURL); + ctl.messaging.notify('openShareFeedbackPage'); } /********************************************************* @@ -8714,7 +9982,7 @@ ); previewToggle.addEventListener( 'click', - () => makeModal(widget.entity, () => sendMessage('setYoutubePreviewsEnabled', true), widget.entity) + () => makeModal(widget.entity, () => ctl.messaging.notify('setYoutubePreviewsEnabled', { youtubePreviewsEnabled: true }), widget.entity) ); bottomRow.appendChild(previewToggle); @@ -8831,7 +10099,7 @@ ); previewToggle.addEventListener( 'click', - () => sendMessage('setYoutubePreviewsEnabled', false) + () => ctl.messaging.notify('setYoutubePreviewsEnabled', { youtubePreviewsEnabled: false }) ); /** Preview Info Text */ @@ -8863,12 +10131,10 @@ // We use .then() instead of await here to show the placeholder right away // while the YouTube endpoint takes it time to respond. const videoURL = originalElement.src || originalElement.getAttribute('data-src'); - getYouTubeVideoDetails(videoURL); - window.addEventListener('ddg-ctp-youTubeVideoDetails', - (/** @type {CustomEvent} */ { - detail: { videoURL: videoURLResp, status, title, previewImage } - }) => { - if (videoURLResp !== videoURL) { return } + ctl.messaging.request('getYouTubeVideoDetails', { videoURL }) + // eslint-disable-next-line promise/prefer-await-to-then + .then(({ videoURL: videoURLResp, status, title, previewImage }) => { + if (!status || videoURLResp !== videoURL) { return } if (status === 'success') { titleElement.innerText = title; titleElement.title = title; @@ -8877,8 +10143,7 @@ } widget.autoplay = true; } - } - ); + }); /** Share Feedback Link */ const feedbackRow = makeShareFeedbackRow(); @@ -8887,48 +10152,17 @@ return { youTubePreview, shadowRoot } } - // Convention is that each function should be named the same as the sendMessage - // method we are calling into eg. calling `sendMessage('getClickToLoadState')` - // will result in a response routed to `updateHandlers.getClickToLoadState()`. - const messageResponseHandlers = { - getClickToLoadState (response) { - devMode = response.devMode; - isYoutubePreviewsEnabled = response.youtubePreviewsEnabled; - - // Mark the feature as ready, to allow placeholder replacements to - // start. - readyToDisplayPlaceholdersResolver(); - }, - setYoutubePreviewsEnabled (response) { - if (response?.messageType && typeof response?.value === 'boolean') { - originalWindowDispatchEvent( - createCustomEvent( - response.messageType, { detail: response.value } - ) - ); - } - }, - getYouTubeVideoDetails (response) { - if (response?.status && typeof response.videoURL === 'string') { - originalWindowDispatchEvent( - createCustomEvent( - 'ddg-ctp-youTubeVideoDetails', - { detail: response } - ) - ); - } - }, - unblockClickToLoadContent () { - originalWindowDispatchEvent( - createCustomEvent('ddg-ctp-unblockClickToLoadContent-complete') - ); - } - }; - - const knownMessageResponseType = Object.prototype.hasOwnProperty.bind(messageResponseHandlers); - class ClickToLoad extends ContentFeature { async init (args) { + /** + * Bail if no messaging backend - this is a debugging feature to ensure we don't + * accidentally enabled this + */ + if (!this.messaging) { + throw new Error('Cannot operate click to load without a messaging backend') + } + _messagingModuleScope = this.messaging; + const websiteOwner = args?.site?.parentEntity; const settings = args?.featureSettings?.clickToLoad || {}; const locale = args?.locale || 'en'; @@ -8976,8 +10210,8 @@ entityData[entity] = currentEntityData; } - // Listen for events from "surrogate" scripts. - addEventListener('ddg-ctp', (/** @type {CustomEvent} */ event) => { + // Listen for window events from "surrogate" scripts. + window.addEventListener('ddg-ctp', (/** @type {CustomEvent} */ event) => { if (!('detail' in event)) return const entity = event.detail?.entity; @@ -8997,12 +10231,22 @@ } } }); + // Listen to message from Platform letting CTL know that we're ready to + // replace elements in the page + // eslint-disable-next-line promise/prefer-await-to-then + this.messaging.subscribe( + 'displayClickToLoadPlaceholders', + // TODO: Pass `message.options.ruleAction` through, that way only + // content corresponding to the entity for that ruleAction need to + // be replaced with a placeholder. + () => replaceClickToLoadElements() + ); // Request the current state of Click to Load from the platform. // Note: When the response is received, the response handler resolves // the readyToDisplayPlaceholders Promise. - sendMessage('getClickToLoadState'); - await readyToDisplayPlaceholders; + const clickToLoadState = await this.messaging.request('getClickToLoadState'); + this.onClickToLoadState(clickToLoadState); // Then wait for the page to finish loading, and resolve the // afterPageLoad Promise. @@ -9030,6 +10274,12 @@ }, 0); } + /** + * This is only called by the current integration between Android and Extension and is now + * used to connect only these Platforms responses with the temporary implementation of + * ClickToLoadMessagingTransport that wraps this communication. + * This can be removed once they have their own Messaging integration. + */ update (message) { // TODO: Once all Click to Load messages include the feature property, drop // messages that don't include the feature property too. @@ -9038,20 +10288,49 @@ const messageType = message?.messageType; if (!messageType) return - // Message responses. - if (messageType === 'response') { - const messageResponseType = message?.responseMessageType; - if (messageResponseType && knownMessageResponseType(messageResponseType)) { - return messageResponseHandlers[messageResponseType](message.response) - } + if (!this._clickToLoadMessagingTransport) { + throw new Error('_clickToLoadMessagingTransport not ready. Cannot operate click to load without a messaging backend') } - // Other known update messages. - if (messageType === 'displayClickToLoadPlaceholders') { - // TODO: Pass `message.options.ruleAction` through, that way only - // content corresponding to the entity for that ruleAction need to - // be replaced with a placeholder. - return replaceClickToLoadElements() + // Send to Messaging layer the response or subscription message received + // from the Platform. + return this._clickToLoadMessagingTransport.onResponse(message) + } + + /** + * Update Click to Load internal state + * @param {Object} state Click to Load state response from the Platform + * @param {boolean} state.devMode Developer or Production environment + * @param {boolean} state.youtubePreviewsEnabled YouTube Click to Load - YT Previews enabled flag + */ + onClickToLoadState (state) { + devMode = state.devMode; + isYoutubePreviewsEnabled = state.youtubePreviewsEnabled; + + // Mark the feature as ready, to allow placeholder + // replacements to start. + readyToDisplayPlaceholdersResolver(); + } + + // Messaging layer between Click to Load and the Platform + get messaging () { + if (this._messaging) return this._messaging + + if (this.platform.name === 'android' || this.platform.name === 'extension' || this.platform.name === 'macos') { + this._clickToLoadMessagingTransport = new ClickToLoadMessagingTransport(); + const config = new TestTransportConfig(this._clickToLoadMessagingTransport); + this._messaging = new Messaging(this.messagingContext, config); + return this._messaging + } else if (this.platform.name === 'ios') { + const config = new WebkitMessagingConfig({ + secret: '', + hasModernWebkitAPI: true, + webkitMessageHandlerNames: ['contentScopeScripts'] + }); + this._messaging = new Messaging(this.messagingContext, config); + return this._messaging + } else { + throw new Error('Messaging not supported yet on platform: ' + this.name) } } } diff --git a/package-lock.json b/package-lock.json index bf98600aba81..faa3b0341ff0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@duckduckgo/autoconsent": "^4.1.1", "@duckduckgo/autofill": "github:duckduckgo/duckduckgo-autofill#7.1.0", - "@duckduckgo/content-scope-scripts": "github:duckduckgo/content-scope-scripts#4.21.0", + "@duckduckgo/content-scope-scripts": "github:duckduckgo/content-scope-scripts#4.21.1", "@duckduckgo/privacy-dashboard": "github:duckduckgo/privacy-dashboard#1.4.0", "@duckduckgo/privacy-reference-tests": "github:duckduckgo/privacy-reference-tests#1685458917" }, @@ -68,7 +68,7 @@ "license": "Apache-2.0" }, "node_modules/@duckduckgo/content-scope-scripts": { - "resolved": "git+ssh://git@github.com/duckduckgo/content-scope-scripts.git#a275d9836eba2662e4a96080911d142059d331ef", + "resolved": "git+ssh://git@github.com/duckduckgo/content-scope-scripts.git#260329ad8021115235d64ddd76c24629f93a7212", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -287,9 +287,9 @@ } }, "node_modules/acorn": { - "version": "8.8.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", - "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.9.0.tgz", + "integrity": "sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==", "dev": true, "bin": { "acorn": "bin/acorn" diff --git a/package.json b/package.json index bd15450a7b72..3f9bed9d89da 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "dependencies": { "@duckduckgo/autoconsent": "^4.1.1", "@duckduckgo/autofill": "github:duckduckgo/duckduckgo-autofill#7.1.0", - "@duckduckgo/content-scope-scripts": "github:duckduckgo/content-scope-scripts#4.21.0", + "@duckduckgo/content-scope-scripts": "github:duckduckgo/content-scope-scripts#4.21.1", "@duckduckgo/privacy-dashboard": "github:duckduckgo/privacy-dashboard#1.4.0", "@duckduckgo/privacy-reference-tests": "github:duckduckgo/privacy-reference-tests#1685458917" }