diff options
Diffstat (limited to '')
25 files changed, 4610 insertions, 0 deletions
diff --git a/remote/cdp/domains/ContentProcessDomain.sys.mjs b/remote/cdp/domains/ContentProcessDomain.sys.mjs new file mode 100644 index 0000000000..fefe6aece5 --- /dev/null +++ b/remote/cdp/domains/ContentProcessDomain.sys.mjs @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +export class ContentProcessDomain extends Domain { + destructor() { + super.destructor(); + } + + // helpers + + get content() { + return this.session.content; + } + + get docShell() { + return this.session.docShell; + } + + get chromeEventHandler() { + return this.docShell.chromeEventHandler; + } +} diff --git a/remote/cdp/domains/ContentProcessDomains.sys.mjs b/remote/cdp/domains/ContentProcessDomains.sys.mjs new file mode 100644 index 0000000000..a434dc0067 --- /dev/null +++ b/remote/cdp/domains/ContentProcessDomains.sys.mjs @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export const ContentProcessDomains = {}; + +// eslint-disable-next-line mozilla/lazy-getter-object-name +ChromeUtils.defineESModuleGetters(ContentProcessDomains, { + DOM: "chrome://remote/content/cdp/domains/content/DOM.sys.mjs", + Emulation: "chrome://remote/content/cdp/domains/content/Emulation.sys.mjs", + Input: "chrome://remote/content/cdp/domains/content/Input.sys.mjs", + Log: "chrome://remote/content/cdp/domains/content/Log.sys.mjs", + Network: "chrome://remote/content/cdp/domains/content/Network.sys.mjs", + Page: "chrome://remote/content/cdp/domains/content/Page.sys.mjs", + Performance: + "chrome://remote/content/cdp/domains/content/Performance.sys.mjs", + Runtime: "chrome://remote/content/cdp/domains/content/Runtime.sys.mjs", + Security: "chrome://remote/content/cdp/domains/content/Security.sys.mjs", +}); diff --git a/remote/cdp/domains/Domain.sys.mjs b/remote/cdp/domains/Domain.sys.mjs new file mode 100644 index 0000000000..e20f9aadbd --- /dev/null +++ b/remote/cdp/domains/Domain.sys.mjs @@ -0,0 +1,71 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export class Domain { + constructor(session) { + this.session = session; + this.name = this.constructor.name; + + this.eventListeners_ = new Set(); + this._requestCounter = 0; + } + + destructor() {} + + emit(eventName, params = {}) { + for (const listener of this.eventListeners_) { + try { + if (isEventHandler(listener)) { + listener.onEvent(eventName, params); + } else { + listener.call(this, eventName, params); + } + } catch (e) { + console.error(e); + } + } + } + + /** + * Execute the provided method in the child domain that has the same domain + * name. eg. calling this.executeInChild from domains/parent/Input.jsm will + * attempt to execute the method in domains/content/Input.jsm. + * + * This can only be called from parent domains managed by a TabSession. + * + * @param {String} method + * Name of the method to call on the child domain. + * @param {Object} params + * Optional parameters. Must be serializable. + */ + executeInChild(method, params) { + if (!this.session.executeInChild) { + throw new Error( + "executeInChild can only be used in Domains managed by a TabSession" + ); + } + this._requestCounter++; + const id = this.name + "-" + this._requestCounter; + return this.session.executeInChild(id, this.name, method, params); + } + + addEventListener(listener) { + if (typeof listener != "function" && !isEventHandler(listener)) { + throw new TypeError(); + } + this.eventListeners_.add(listener); + } + + // static + + static implements(command) { + return command && typeof this.prototype[command] == "function"; + } +} + +function isEventHandler(listener) { + return ( + listener && "onEvent" in listener && typeof listener.onEvent == "function" + ); +} diff --git a/remote/cdp/domains/DomainCache.sys.mjs b/remote/cdp/domains/DomainCache.sys.mjs new file mode 100644 index 0000000000..b4651cbe68 --- /dev/null +++ b/remote/cdp/domains/DomainCache.sys.mjs @@ -0,0 +1,113 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Domain: "chrome://remote/content/cdp/domains/Domain.sys.mjs", + UnknownMethodError: "chrome://remote/content/cdp/Error.sys.mjs", +}); + +/** + * Lazy domain instance cache. + * + * Domains are loaded into each target's realm, and consequently + * there exists one domain cache per realm. Domains are preregistered + * with this cache and then constructed lazily upon request. + * + * @param {Session} session + * Session that domains should be associated with as they + * are constructed. + * @param {Map.<string, string>} modules + * Table defining JS modules available to this domain cache. + * This should be a mapping between domain name + * and JS module path passed to ChromeUtils.import. + */ +export class DomainCache { + constructor(session, modules) { + this.session = session; + this.modules = modules; + this.instances = new Map(); + } + + /** Test if domain supports method. */ + domainSupportsMethod(name, method) { + const domain = this.modules[name]; + if (domain) { + return domain.implements(method); + } + return false; + } + + /** + * Gets the current instance of the domain, or creates a new one, + * and associates it with the predefined session. + * + * @throws {UnknownMethodError} + * If domain is not preregistered with this domain cache. + */ + get(name) { + let inst = this.instances.get(name); + if (!inst) { + const Cls = this.modules[name]; + if (!Cls) { + throw new lazy.UnknownMethodError(name); + } + if (!isConstructor(Cls)) { + throw new TypeError("Domain cannot be constructed"); + } + + inst = new Cls(this.session); + if (!(inst instanceof lazy.Domain)) { + throw new TypeError("Instance not a domain"); + } + + inst.addEventListener(this.session); + + this.instances.set(name, inst); + } + + return inst; + } + + /** + * Tells if a Domain of the given name is available + */ + has(name) { + return name in this.modules; + } + + get size() { + return this.instances.size; + } + + /** + * Execute the given command (function) of a given domain with the given parameters. + * If the command doesn't exists, it will throw. + * It returns the returned value of the command, which is most likely a promise. + */ + execute(domain, command, params) { + if (!this.domainSupportsMethod(domain, command)) { + throw new lazy.UnknownMethodError(domain, command); + } + const inst = this.get(domain); + return inst[command](params); + } + + /** Calls destructor on each domain and clears the cache. */ + clear() { + for (const inst of this.instances.values()) { + inst.destructor(); + } + this.instances.clear(); + } + + toString() { + return `[object DomainCache ${this.size}]`; + } +} + +function isConstructor(obj) { + return !!obj.prototype && !!obj.prototype.constructor.name; +} diff --git a/remote/cdp/domains/ParentProcessDomains.sys.mjs b/remote/cdp/domains/ParentProcessDomains.sys.mjs new file mode 100644 index 0000000000..48121980c1 --- /dev/null +++ b/remote/cdp/domains/ParentProcessDomains.sys.mjs @@ -0,0 +1,18 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export const ParentProcessDomains = {}; + +// eslint-disable-next-line mozilla/lazy-getter-object-name +ChromeUtils.defineESModuleGetters(ParentProcessDomains, { + Browser: "chrome://remote/content/cdp/domains/parent/Browser.sys.mjs", + Emulation: "chrome://remote/content/cdp/domains/parent/Emulation.sys.mjs", + Fetch: "chrome://remote/content/cdp/domains/parent/Fetch.sys.mjs", + Input: "chrome://remote/content/cdp/domains/parent/Input.sys.mjs", + IO: "chrome://remote/content/cdp/domains/parent/IO.sys.mjs", + Network: "chrome://remote/content/cdp/domains/parent/Network.sys.mjs", + Page: "chrome://remote/content/cdp/domains/parent/Page.sys.mjs", + Security: "chrome://remote/content/cdp/domains/parent/Security.sys.mjs", + Target: "chrome://remote/content/cdp/domains/parent/Target.sys.mjs", +}); diff --git a/remote/cdp/domains/content/DOM.sys.mjs b/remote/cdp/domains/content/DOM.sys.mjs new file mode 100644 index 0000000000..8f920756e6 --- /dev/null +++ b/remote/cdp/domains/content/DOM.sys.mjs @@ -0,0 +1,246 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +export class DOM extends ContentProcessDomain { + constructor(session) { + super(session); + this.enabled = false; + } + + destructor() { + this.disable(); + } + + // commands + + async enable() { + if (!this.enabled) { + this.enabled = true; + } + } + + /** + * Describes node given its id. + * + * Does not require domain to be enabled. Does not start tracking any objects. + * + * @param {Object} options + * @param {number=} options.backendNodeId [not supported] + * Identifier of the backend node. + * @param {number=} options.depth [not supported] + * The maximum depth at which children should be retrieved, defaults to 1. + * Use -1 for the entire subtree or provide an integer larger than 0. + * @param {number=} options.nodeId [not supported] + * Identifier of the node. + * @param {string} options.objectId + * JavaScript object id of the node wrapper. + * @param {boolean=} options.pierce [not supported] + * Whether or not iframes and shadow roots should be traversed + * when returning the subtree, defaults to false. + * + * @return {DOM.Node} + * Node description. + */ + describeNode(options = {}) { + const { objectId } = options; + + // Until nodeId/backendNodeId is supported force usage of the objectId + if (!["string"].includes(typeof objectId)) { + throw new TypeError("objectId: string value expected"); + } + + const Runtime = this.session.domains.get("Runtime"); + const debuggerObj = Runtime._getRemoteObject(objectId); + if (!debuggerObj) { + throw new Error("Could not find object with given id"); + } + + if (typeof debuggerObj.nodeId == "undefined") { + throw new Error("Object id doesn't reference a Node"); + } + + const unsafeObj = debuggerObj.unsafeDereference(); + + const attributes = []; + if (unsafeObj.attributes) { + // Flatten the list of attributes for name and value + for (const attribute of unsafeObj.attributes) { + attributes.push(attribute.name, attribute.value); + } + } + + let context = this.docShell.browsingContext; + if (HTMLIFrameElement.isInstance(unsafeObj)) { + context = unsafeObj.contentWindow.docShell.browsingContext; + } + + const node = { + nodeId: debuggerObj.nodeId, + backendNodeId: debuggerObj.nodeId, + nodeType: unsafeObj.nodeType, + nodeName: unsafeObj.nodeName, + localName: unsafeObj.localName, + nodeValue: unsafeObj.nodeValue ? unsafeObj.nodeValue.toString() : "", + childNodeCount: unsafeObj.childElementCount, + attributes: attributes.length ? attributes : undefined, + frameId: context.id.toString(), + }; + + return { node }; + } + + disable() { + if (this.enabled) { + this.enabled = false; + } + } + + getContentQuads(options = {}) { + const { objectId } = options; + const Runtime = this.session.domains.get("Runtime"); + const debuggerObj = Runtime._getRemoteObject(objectId); + if (!debuggerObj) { + throw new Error(`Cannot find object with id: ${objectId}`); + } + const unsafeObject = debuggerObj.unsafeDereference(); + if (!unsafeObject.getBoxQuads) { + throw new Error("RemoteObject is not a node"); + } + let quads = unsafeObject.getBoxQuads({ relativeTo: this.content.document }); + quads = quads.map(quad => { + return [ + quad.p1.x, + quad.p1.y, + quad.p2.x, + quad.p2.y, + quad.p3.x, + quad.p3.y, + quad.p4.x, + quad.p4.y, + ].map(Math.round); + }); + return { quads }; + } + + getBoxModel(options = {}) { + const { objectId } = options; + const Runtime = this.session.domains.get("Runtime"); + const debuggerObj = Runtime._getRemoteObject(objectId); + if (!debuggerObj) { + throw new Error(`Cannot find object with id: ${objectId}`); + } + const unsafeObject = debuggerObj.unsafeDereference(); + const bounding = unsafeObject.getBoundingClientRect(); + const model = { + width: Math.round(bounding.width), + height: Math.round(bounding.height), + }; + for (const box of ["content", "padding", "border", "margin"]) { + const quads = unsafeObject.getBoxQuads({ + box, + relativeTo: this.content.document, + }); + + // getBoxQuads may return more than one element. In this case we have to compute the bounding box + // of all these boxes. + let bounding = { + p1: { x: Infinity, y: Infinity }, + p2: { x: -Infinity, y: Infinity }, + p3: { x: -Infinity, y: -Infinity }, + p4: { x: Infinity, y: -Infinity }, + }; + quads.forEach(quad => { + bounding = { + p1: { + x: Math.min(bounding.p1.x, quad.p1.x), + y: Math.min(bounding.p1.y, quad.p1.y), + }, + p2: { + x: Math.max(bounding.p2.x, quad.p2.x), + y: Math.min(bounding.p2.y, quad.p2.y), + }, + p3: { + x: Math.max(bounding.p3.x, quad.p3.x), + y: Math.max(bounding.p3.y, quad.p3.y), + }, + p4: { + x: Math.min(bounding.p4.x, quad.p4.x), + y: Math.max(bounding.p4.y, quad.p4.y), + }, + }; + }); + + model[box] = [ + bounding.p1.x, + bounding.p1.y, + bounding.p2.x, + bounding.p2.y, + bounding.p3.x, + bounding.p3.y, + bounding.p4.x, + bounding.p4.y, + ].map(Math.round); + } + return { + model, + }; + } + + /** + * Resolves the JavaScript node object for a given NodeId or BackendNodeId. + * + * @param {Object} options + * @param {number} options.backendNodeId [required for now] + * Backend identifier of the node to resolve. + * @param {number=} options.executionContextId + * Execution context in which to resolve the node. + * @param {number=} options.nodeId [not supported] + * Id of the node to resolve. + * @param {string=} options.objectGroup [not supported] + * Symbolic group name that can be used to release multiple objects. + * + * @return {Runtime.RemoteObject} + * JavaScript object wrapper for given node. + */ + resolveNode(options = {}) { + const { backendNodeId, executionContextId } = options; + + // Until nodeId is supported force usage of the backendNodeId + // Bug 1625417 - CDP expects the id as number + if (!["string"].includes(typeof backendNodeId)) { + throw new TypeError("backendNodeId: string value expected"); + } + if (!["undefined", "number"].includes(typeof executionContextId)) { + throw new TypeError("executionContextId: integer value expected"); + } + + const Runtime = this.session.domains.get("Runtime"); + + // Retrieve the node to resolve, and its context + const debuggerObj = Runtime._getRemoteObjectByNodeId(backendNodeId); + + if (!debuggerObj) { + throw new Error(`No node with given id found`); + } + + // If execution context isn't specified use the default one for the node + let context; + if (typeof executionContextId != "undefined") { + context = Runtime.contexts.get(executionContextId); + if (!context) { + throw new Error(`Node with given id does not belong to the document`); + } + } else { + context = Runtime._getDefaultContextForWindow(); + } + + Runtime._setRemoteObject(debuggerObj, context); + + return { + object: Runtime._serializeRemoteObject(debuggerObj, context.id), + }; + } +} diff --git a/remote/cdp/domains/content/Emulation.sys.mjs b/remote/cdp/domains/content/Emulation.sys.mjs new file mode 100644 index 0000000000..41bb0c76ea --- /dev/null +++ b/remote/cdp/domains/content/Emulation.sys.mjs @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AnimationFramePromise: "chrome://remote/content/shared/Sync.sys.mjs", +}); + +export class Emulation extends ContentProcessDomain { + // commands + + /** + * Internal methods: the following methods are not part of CDP; + * note the _ prefix. + */ + + /** + * Waits until the viewport has reached the new dimensions. + */ + async _awaitViewportDimensions({ width, height }) { + const win = this.content; + let resized; + + // Updates for background tabs are throttled, and we also we have to make + // sure that the new browser dimensions have been received by the content + // process. As such wait for the next animation frame. + await lazy.AnimationFramePromise(win); + + const checkBrowserSize = () => { + if (win.innerWidth === width && win.innerHeight === height) { + resized(); + } + }; + + return new Promise(resolve => { + resized = resolve; + + win.addEventListener("resize", checkBrowserSize); + + // Trigger a layout flush in case none happened yet. + checkBrowserSize(); + }).finally(() => { + win.removeEventListener("resize", checkBrowserSize); + }); + } +} diff --git a/remote/cdp/domains/content/Input.sys.mjs b/remote/cdp/domains/content/Input.sys.mjs new file mode 100644 index 0000000000..e7a6ffc709 --- /dev/null +++ b/remote/cdp/domains/content/Input.sys.mjs @@ -0,0 +1,57 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +export class Input extends ContentProcessDomain { + constructor(session) { + super(session); + + // Internal id used to track existing event handlers. + this._eventId = 0; + + // Map of event id -> event handler promise. + this._eventPromises = new Map(); + } + + /** + * Internal methods: the following methods are not part of CDP; + * note the _ prefix. + */ + + /** + * Add an event listener in the content page for the provided eventName. + * This method will return a unique handler id that can be used to wait + * for the event. + * + * Example usage from a parent process domain: + * + * const id = await this.executeInChild("_addContentEventListener", "click"); + * // do something that triggers a click in content + * await this.executeInChild("_waitForContentEvent", id); + */ + _addContentEventListener(eventName) { + const eventPromise = new Promise(resolve => { + this.chromeEventHandler.addEventListener(eventName, resolve, { + mozSystemGroup: true, + once: true, + }); + }); + this._eventId++; + this._eventPromises.set(this._eventId, eventPromise); + return this._eventId; + } + + /** + * Wait for an event listener added via `addContentEventListener` to be fired. + */ + async _waitForContentEvent(eventId) { + const eventPromise = this._eventPromises.get(eventId); + if (!eventPromise) { + throw new Error("No event promise found for id " + eventId); + } + await eventPromise; + this._eventPromises.delete(eventId); + } +} diff --git a/remote/cdp/domains/content/Log.sys.mjs b/remote/cdp/domains/content/Log.sys.mjs new file mode 100644 index 0000000000..f8c561d429 --- /dev/null +++ b/remote/cdp/domains/content/Log.sys.mjs @@ -0,0 +1,86 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +const CONSOLE_MESSAGE_LEVEL_MAP = { + [Ci.nsIConsoleMessage.debug]: "verbose", + [Ci.nsIConsoleMessage.info]: "info", + [Ci.nsIConsoleMessage.warn]: "warning", + [Ci.nsIConsoleMessage.error]: "error", +}; + +export class Log extends ContentProcessDomain { + constructor(session) { + super(session); + this.enabled = false; + } + + destructor() { + this.disable(); + + super.destructor(); + } + + enable() { + if (!this.enabled) { + this.enabled = true; + + Services.console.registerListener(this); + } + } + + disable() { + if (this.enabled) { + this.enabled = false; + + Services.console.unregisterListener(this); + } + } + + _getLogCategory(category) { + if (category.startsWith("CORS")) { + return "network"; + } else if (category.includes("javascript")) { + return "javascript"; + } + + return "other"; + } + + // nsIObserver + + /** + * Takes all script error messages that do not have an exception attached, + * and emits a "Log.entryAdded" event. + * + * @param {nsIConsoleMessage} message + * Message originating from the nsIConsoleService. + */ + observe(message) { + if (message instanceof Ci.nsIScriptError && !message.hasException) { + let url; + if (message.sourceName !== "debugger eval code") { + url = message.sourceName; + } + + const entry = { + source: this._getLogCategory(message.category), + level: CONSOLE_MESSAGE_LEVEL_MAP[message.logLevel], + text: message.errorMessage, + timestamp: message.timeStamp, + url, + lineNumber: message.lineNumber, + }; + + this.emit("Log.entryAdded", { entry }); + } + } + + // XPCOM + + get QueryInterface() { + return ChromeUtils.generateQI(["nsIConsoleListener"]); + } +} diff --git a/remote/cdp/domains/content/Network.sys.mjs b/remote/cdp/domains/content/Network.sys.mjs new file mode 100644 index 0000000000..91dc44cf46 --- /dev/null +++ b/remote/cdp/domains/content/Network.sys.mjs @@ -0,0 +1,18 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +export class Network extends ContentProcessDomain { + // commands + + /** + * Internal methods: the following methods are not part of CDP; + * note the _ prefix. + */ + + _updateLoadFlags(flags) { + this.docShell.defaultLoadFlags = flags; + } +} diff --git a/remote/cdp/domains/content/Page.sys.mjs b/remote/cdp/domains/content/Page.sys.mjs new file mode 100644 index 0000000000..5f833a9102 --- /dev/null +++ b/remote/cdp/domains/content/Page.sys.mjs @@ -0,0 +1,451 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +const { + LOAD_FLAGS_BYPASS_CACHE, + LOAD_FLAGS_BYPASS_PROXY, + LOAD_FLAGS_NONE, +} = Ci.nsIWebNavigation; + +export class Page extends ContentProcessDomain { + constructor(session) { + super(session); + + this.enabled = false; + this.lifecycleEnabled = false; + // script id => { source, worldName } + this.scriptsToEvaluateOnLoad = new Map(); + this.worldsToEvaluateOnLoad = new Set(); + + // This map is used to keep a reference to the loader id for + // those Page events, which do not directly rely on + // Network events. This might be a temporary solution until + // the Network observer could be queried for that. But right + // now this lives in the parent process. + this.frameIdToLoaderId = new Map(); + + this._onFrameAttached = this._onFrameAttached.bind(this); + this._onFrameDetached = this._onFrameDetached.bind(this); + this._onFrameNavigated = this._onFrameNavigated.bind(this); + this._onScriptLoaded = this._onScriptLoaded.bind(this); + + this.session.contextObserver.on("script-loaded", this._onScriptLoaded); + } + + destructor() { + this.setLifecycleEventsEnabled({ enabled: false }); + this.session.contextObserver.off("script-loaded", this._onScriptLoaded); + this.disable(); + + super.destructor(); + } + + // commands + + async enable() { + if (!this.enabled) { + this.session.contextObserver.on("frame-attached", this._onFrameAttached); + this.session.contextObserver.on("frame-detached", this._onFrameDetached); + this.session.contextObserver.on( + "frame-navigated", + this._onFrameNavigated + ); + + this.chromeEventHandler.addEventListener("readystatechange", this, { + mozSystemGroup: true, + capture: true, + }); + this.chromeEventHandler.addEventListener("pagehide", this, { + mozSystemGroup: true, + }); + this.chromeEventHandler.addEventListener("unload", this, { + mozSystemGroup: true, + capture: true, + }); + this.chromeEventHandler.addEventListener("DOMContentLoaded", this, { + mozSystemGroup: true, + }); + this.chromeEventHandler.addEventListener("hashchange", this, { + mozSystemGroup: true, + capture: true, + }); + this.chromeEventHandler.addEventListener("load", this, { + mozSystemGroup: true, + capture: true, + }); + this.chromeEventHandler.addEventListener("pageshow", this, { + mozSystemGroup: true, + }); + + this.enabled = true; + } + } + + disable() { + if (this.enabled) { + this.session.contextObserver.off("frame-attached", this._onFrameAttached); + this.session.contextObserver.off("frame-detached", this._onFrameDetached); + this.session.contextObserver.off( + "frame-navigated", + this._onFrameNavigated + ); + + this.chromeEventHandler.removeEventListener("readystatechange", this, { + mozSystemGroup: true, + capture: true, + }); + this.chromeEventHandler.removeEventListener("pagehide", this, { + mozSystemGroup: true, + }); + this.chromeEventHandler.removeEventListener("unload", this, { + mozSystemGroup: true, + capture: true, + }); + this.chromeEventHandler.removeEventListener("DOMContentLoaded", this, { + mozSystemGroup: true, + }); + this.chromeEventHandler.removeEventListener("hashchange", this, { + mozSystemGroup: true, + capture: true, + }); + this.chromeEventHandler.removeEventListener("load", this, { + mozSystemGroup: true, + capture: true, + }); + this.chromeEventHandler.removeEventListener("pageshow", this, { + mozSystemGroup: true, + }); + this.enabled = false; + } + } + + async reload(options = {}) { + const { ignoreCache } = options; + let flags = LOAD_FLAGS_NONE; + if (ignoreCache) { + flags |= LOAD_FLAGS_BYPASS_CACHE; + flags |= LOAD_FLAGS_BYPASS_PROXY; + } + this.docShell.reload(flags); + } + + getFrameTree() { + const getFrames = context => { + const frameTree = { + frame: this._getFrameDetails({ context }), + }; + + if (context.children.length) { + const frames = []; + for (const childContext of context.children) { + frames.push(getFrames(childContext)); + } + frameTree.childFrames = frames; + } + + return frameTree; + }; + + return { + frameTree: getFrames(this.docShell.browsingContext), + }; + } + + /** + * Enqueues given script to be evaluated in every frame upon creation + * + * If `worldName` is specified, creates an execution context with the given name + * and evaluates given script in it. + * + * At this time, queued scripts do not get evaluated, hence `source` is marked as + * "unsupported". + * + * @param {Object} options + * @param {string} options.source (not supported) + * @param {string=} options.worldName + * @return {string} Page.ScriptIdentifier + */ + addScriptToEvaluateOnNewDocument(options = {}) { + const { source, worldName } = options; + if (worldName) { + this.worldsToEvaluateOnLoad.add(worldName); + } + const identifier = Services.uuid + .generateUUID() + .toString() + .slice(1, -1); + this.scriptsToEvaluateOnLoad.set(identifier, { worldName, source }); + + return { identifier }; + } + + /** + * Creates an isolated world for the given frame. + * + * Really it just creates an execution context with label "isolated". + * + * @param {Object} options + * @param {string} options.frameId + * Id of the frame in which the isolated world should be created. + * @param {string=} options.worldName + * An optional name which is reported in the Execution Context. + * @param {boolean=} options.grantUniversalAccess (not supported) + * This is a powerful option, use with caution. + * + * @return {number} Runtime.ExecutionContextId + * Execution context of the isolated world. + */ + createIsolatedWorld(options = {}) { + const { frameId, worldName } = options; + + if (typeof frameId != "string") { + throw new TypeError("frameId: string value expected"); + } + + if (!["undefined", "string"].includes(typeof worldName)) { + throw new TypeError("worldName: string value expected"); + } + + const Runtime = this.session.domains.get("Runtime"); + const contexts = Runtime._getContextsForFrame(frameId); + if (!contexts.length) { + throw new Error("No frame for given id found"); + } + + const defaultContext = Runtime._getDefaultContextForWindow( + contexts[0].windowId + ); + const window = defaultContext.window; + + const executionContextId = Runtime._onContextCreated("context-created", { + windowId: window.windowGlobalChild.innerWindowId, + window, + isDefault: false, + contextName: worldName, + contextType: "isolated", + }); + + return { executionContextId }; + } + + /** + * Controls whether page will emit lifecycle events. + * + * @param {Object} options + * @param {boolean} options.enabled + * If true, starts emitting lifecycle events. + */ + setLifecycleEventsEnabled(options = {}) { + const { enabled } = options; + + this.lifecycleEnabled = enabled; + } + + url() { + return this.content.location.href; + } + + _onFrameAttached(name, { frameId, window }) { + const bc = BrowsingContext.get(frameId); + + // Don't emit for top-level browsing contexts + if (!bc.parent) { + return; + } + + // TODO: Use a unique identifier for frames (bug 1605359) + this.emit("Page.frameAttached", { + frameId: frameId.toString(), + parentFrameId: bc.parent.id.toString(), + stack: null, + }); + + // Usually both events are emitted when the "pagehide" event is received. + // But this wont happen for a new window or frame when the initial + // about:blank page has already loaded, and is being replaced with the + // final document. + if (!window.document.isInitialDocument) { + this.emit("Page.frameStartedLoading", { frameId: frameId.toString() }); + + const loaderId = this.frameIdToLoaderId.get(frameId); + const timestamp = Date.now() / 1000; + this.emitLifecycleEvent(frameId, loaderId, "init", timestamp); + } + } + + _onFrameDetached(name, { frameId }) { + const bc = BrowsingContext.get(frameId); + + // Don't emit for top-level browsing contexts + if (!bc.parent) { + return; + } + + // TODO: Use a unique identifier for frames (bug 1605359) + this.emit("Page.frameDetached", { frameId: frameId.toString() }); + } + + _onFrameNavigated(name, { frameId }) { + const bc = BrowsingContext.get(frameId); + + this.emit("Page.frameNavigated", { + frame: this._getFrameDetails({ context: bc }), + }); + } + + /** + * @param {Object=} options + * @param {number} options.windowId + * The inner window id of the window the script has been loaded for. + * @param {Window} options.window + * The window object of the document. + */ + _onScriptLoaded(name, options = {}) { + const { windowId, window } = options; + + const Runtime = this.session.domains.get("Runtime"); + for (const world of this.worldsToEvaluateOnLoad) { + Runtime._onContextCreated("context-created", { + windowId, + window, + isDefault: false, + contextName: world, + contextType: "isolated", + }); + } + // TODO evaluate each onNewDoc script in the appropriate world + } + + emitLifecycleEvent(frameId, loaderId, name, timestamp) { + if (this.lifecycleEnabled) { + this.emit("Page.lifecycleEvent", { + frameId: frameId.toString(), + loaderId, + name, + timestamp, + }); + } + } + + handleEvent({ type, target }) { + const timestamp = Date.now() / 1000; + + // Some events such as "hashchange" use the window as the target, while + // others have a document. + const win = Window.isInstance(target) ? target : target.defaultView; + const frameId = win.docShell.browsingContext.id; + const isFrame = !!win.docShell.browsingContext.parent; + const loaderId = this.frameIdToLoaderId.get(frameId); + const url = win.location.href; + + switch (type) { + case "DOMContentLoaded": + if (!isFrame) { + this.emit("Page.domContentEventFired", { timestamp }); + } + this.emitLifecycleEvent( + frameId, + loaderId, + "DOMContentLoaded", + timestamp + ); + break; + + case "hashchange": + this.emit("Page.navigatedWithinDocument", { + frameId: frameId.toString(), + url, + }); + break; + + case "pagehide": + // Maybe better to bound to "unload" once we can register for this event + this.emit("Page.frameStartedLoading", { frameId: frameId.toString() }); + this.emitLifecycleEvent(frameId, loaderId, "init", timestamp); + break; + + case "load": + if (!isFrame) { + this.emit("Page.loadEventFired", { timestamp }); + } + this.emitLifecycleEvent(frameId, loaderId, "load", timestamp); + + // XXX this should most likely be sent differently + this.emit("Page.frameStoppedLoading", { frameId: frameId.toString() }); + break; + + case "readystatechange": + if (this.content.document.readyState === "loading") { + this.emitLifecycleEvent(frameId, loaderId, "init", timestamp); + } + } + } + + _updateLoaderId(data) { + const { frameId, loaderId } = data; + + this.frameIdToLoaderId.set(frameId, loaderId); + } + + _contentRect() { + const docEl = this.content.document.documentElement; + + return { + x: 0, + y: 0, + width: docEl.scrollWidth, + height: docEl.scrollHeight, + }; + } + + _devicePixelRatio() { + return ( + this.content.browsingContext.overrideDPPX || this.content.devicePixelRatio + ); + } + + _getFrameDetails({ context, id }) { + const bc = context || BrowsingContext.get(id); + const frame = bc.embedderElement; + + return { + id: bc.id.toString(), + parentId: bc.parent?.id.toString(), + loaderId: this.frameIdToLoaderId.get(bc.id), + url: bc.docShell.domWindow.location.href, + name: frame?.id || frame?.name, + securityOrigin: null, + mimeType: null, + }; + } + + _getScrollbarSize() { + const scrollbarHeight = {}; + const scrollbarWidth = {}; + + this.content.windowUtils.getScrollbarSize( + false, + scrollbarWidth, + scrollbarHeight + ); + + return { + width: scrollbarWidth.value, + height: scrollbarHeight.value, + }; + } + + _layoutViewport() { + const scrollbarSize = this._getScrollbarSize(); + + return { + pageX: this.content.pageXOffset, + pageY: this.content.pageYOffset, + clientWidth: this.content.innerWidth - scrollbarSize.width, + clientHeight: this.content.innerHeight - scrollbarSize.height, + }; + } +} diff --git a/remote/cdp/domains/content/Performance.sys.mjs b/remote/cdp/domains/content/Performance.sys.mjs new file mode 100644 index 0000000000..e5726725b5 --- /dev/null +++ b/remote/cdp/domains/content/Performance.sys.mjs @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +export class Performance extends ContentProcessDomain { + constructor(session) { + super(session); + this.enabled = false; + } + + destructor() { + this.disable(); + + super.destructor(); + } + + // commands + + async enable() { + if (!this.enabled) { + this.enabled = true; + } + } + + disable() { + if (this.enabled) { + this.enabled = false; + } + } +} diff --git a/remote/cdp/domains/content/Runtime.sys.mjs b/remote/cdp/domains/content/Runtime.sys.mjs new file mode 100644 index 0000000000..0f3ab16d2a --- /dev/null +++ b/remote/cdp/domains/content/Runtime.sys.mjs @@ -0,0 +1,629 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { addDebuggerToGlobal } from "resource://gre/modules/jsdebugger.sys.mjs"; + +import { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + executeSoon: "chrome://remote/content/shared/Sync.sys.mjs", + ExecutionContext: + "chrome://remote/content/cdp/domains/content/runtime/ExecutionContext.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "ConsoleAPIStorage", () => { + return Cc["@mozilla.org/consoleAPI-storage;1"].getService( + Ci.nsIConsoleAPIStorage + ); +}); + +// Import the `Debugger` constructor in the current scope +// eslint-disable-next-line mozilla/reject-globalThis-modification +addDebuggerToGlobal(globalThis); + +const CONSOLE_API_LEVEL_MAP = { + warn: "warning", +}; + +class SetMap extends Map { + constructor() { + super(); + this._count = 1; + } + // Every key in the map is associated with a Set. + // The first time `key` is used `obj.set(key, value)` maps `key` to + // to `Set(value)`. Subsequent calls add more values to the Set for `key`. + // Note that `obj.get(key)` will return undefined if there's no such key, + // as in a regular Map. + set(key, value) { + const innerSet = this.get(key); + if (innerSet) { + innerSet.add(value); + } else { + super.set(key, new Set([value])); + } + this._count++; + return this; + } + // used as ExecutionContext id + get count() { + return this._count; + } +} + +export class Runtime extends ContentProcessDomain { + constructor(session) { + super(session); + this.enabled = false; + + // Map of all the ExecutionContext instances: + // [id (Number) => ExecutionContext instance] + this.contexts = new Map(); + // [innerWindowId (Number) => Set of ExecutionContext instances] + this.innerWindowIdToContexts = new SetMap(); + + this._onContextCreated = this._onContextCreated.bind(this); + this._onContextDestroyed = this._onContextDestroyed.bind(this); + + // TODO Bug 1602083 + this.session.contextObserver.on("context-created", this._onContextCreated); + this.session.contextObserver.on( + "context-destroyed", + this._onContextDestroyed + ); + } + + destructor() { + this.disable(); + + this.session.contextObserver.off("context-created", this._onContextCreated); + this.session.contextObserver.off( + "context-destroyed", + this._onContextDestroyed + ); + + super.destructor(); + } + + // commands + + async enable() { + if (!this.enabled) { + this.enabled = true; + + Services.console.registerListener(this); + this.onConsoleLogEvent = this.onConsoleLogEvent.bind(this); + lazy.ConsoleAPIStorage.addLogEventListener( + this.onConsoleLogEvent, + Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal) + ); + + // Spin the event loop in order to send the `executionContextCreated` event right + // after we replied to `enable` request. + lazy.executeSoon(() => { + this._onContextCreated("context-created", { + windowId: this.content.windowGlobalChild.innerWindowId, + window: this.content, + isDefault: true, + }); + }); + } + } + + disable() { + if (this.enabled) { + this.enabled = false; + + Services.console.unregisterListener(this); + lazy.ConsoleAPIStorage.removeLogEventListener(this.onConsoleLogEvent); + } + } + + releaseObject(options = {}) { + const { objectId } = options; + + let context = null; + for (const ctx of this.contexts.values()) { + if (ctx.hasRemoteObject(objectId)) { + context = ctx; + break; + } + } + if (!context) { + throw new Error( + `Unable to get execution context by object ID: ${objectId}` + ); + } + context.releaseObject(objectId); + } + + /** + * Calls function with given declaration on the given object. + * + * Object group of the result is inherited from the target object. + * + * @param {Object} options + * @param {string} options.functionDeclaration + * Declaration of the function to call. + * @param {Array.<Object>=} options.arguments + * Call arguments. All call arguments must belong to the same + * JavaScript world as the target object. + * @param {boolean=} options.awaitPromise + * Whether execution should `await` for resulting value + * and return once awaited promise is resolved. + * @param {number=} options.executionContextId + * Specifies execution context which global object will be used + * to call function on. Either executionContextId or objectId + * should be specified. + * @param {string=} options.objectId + * Identifier of the object to call function on. + * Either objectId or executionContextId should be specified. + * @param {boolean=} options.returnByValue + * Whether the result is expected to be a JSON object + * which should be sent by value. + * + * @return {Object.<RemoteObject, ExceptionDetails>} + */ + callFunctionOn(options = {}) { + if (typeof options.functionDeclaration != "string") { + throw new TypeError("functionDeclaration: string value expected"); + } + if ( + typeof options.arguments != "undefined" && + !Array.isArray(options.arguments) + ) { + throw new TypeError("arguments: array value expected"); + } + if (!["undefined", "boolean"].includes(typeof options.awaitPromise)) { + throw new TypeError("awaitPromise: boolean value expected"); + } + if (!["undefined", "number"].includes(typeof options.executionContextId)) { + throw new TypeError("executionContextId: number value expected"); + } + if (!["undefined", "string"].includes(typeof options.objectId)) { + throw new TypeError("objectId: string value expected"); + } + if (!["undefined", "boolean"].includes(typeof options.returnByValue)) { + throw new TypeError("returnByValue: boolean value expected"); + } + + if ( + typeof options.executionContextId == "undefined" && + typeof options.objectId == "undefined" + ) { + throw new Error( + "Either objectId or executionContextId must be specified" + ); + } + + let context = null; + // When an `objectId` is passed, we want to execute the function of a given object + // So we first have to find its ExecutionContext + if (options.objectId) { + for (const ctx of this.contexts.values()) { + if (ctx.hasRemoteObject(options.objectId)) { + context = ctx; + break; + } + } + if (!context) { + throw new Error( + `Unable to get the context for object with id: ${options.objectId}` + ); + } + } else { + context = this.contexts.get(options.executionContextId); + if (!context) { + throw new Error("Cannot find context with specified id"); + } + } + + return context.callFunctionOn( + options.functionDeclaration, + options.arguments, + options.returnByValue, + options.awaitPromise, + options.objectId + ); + } + + /** + * Evaluate expression on global object. + * + * @param {Object} options + * @param {string} options.expression + * Expression to evaluate. + * @param {boolean=} options.awaitPromise + * Whether execution should `await` for resulting value + * and return once awaited promise is resolved. + * @param {number=} options.contextId + * Specifies in which execution context to perform evaluation. + * If the parameter is omitted the evaluation will be performed + * in the context of the inspected page. + * @param {boolean=} options.returnByValue + * Whether the result is expected to be a JSON object + * that should be sent by value. Defaults to false. + * @param {boolean=} options.userGesture [unsupported] + * Whether execution should be treated as initiated by user in the UI. + * + * @return {Object<RemoteObject, exceptionDetails>} + * The evaluation result, and optionally exception details. + */ + evaluate(options = {}) { + const { + expression, + awaitPromise = false, + contextId, + returnByValue = false, + } = options; + + if (typeof expression != "string") { + throw new Error("expression: string value expected"); + } + if (!["undefined", "boolean"].includes(typeof options.awaitPromise)) { + throw new TypeError("awaitPromise: boolean value expected"); + } + if (typeof returnByValue != "boolean") { + throw new Error("returnByValue: boolean value expected"); + } + + let context; + if (typeof contextId != "undefined") { + context = this.contexts.get(contextId); + if (!context) { + throw new Error("Cannot find context with specified id"); + } + } else { + context = this._getDefaultContextForWindow(); + } + + return context.evaluate(expression, awaitPromise, returnByValue); + } + + getProperties(options = {}) { + const { objectId, ownProperties } = options; + + for (const ctx of this.contexts.values()) { + const debuggerObj = ctx.getRemoteObject(objectId); + if (debuggerObj) { + return ctx.getProperties({ objectId, ownProperties }); + } + } + return null; + } + + /** + * Internal methods: the following methods are not part of CDP; + * note the _ prefix. + */ + + get _debugger() { + if (this.__debugger) { + return this.__debugger; + } + this.__debugger = new Debugger(); + return this.__debugger; + } + + _buildStackTrace(stack) { + const callFrames = []; + + while ( + stack && + stack.source !== "debugger eval code" && + !stack.source.startsWith("chrome://") + ) { + callFrames.push({ + functionName: stack.functionDisplayName, + scriptId: stack.sourceId.toString(), + url: stack.source, + lineNumber: stack.line - 1, + columnNumber: stack.column - 1, + }); + stack = stack.parent || stack.asyncParent; + } + + return { + callFrames, + }; + } + + _getRemoteObject(objectId) { + for (const ctx of this.contexts.values()) { + const debuggerObj = ctx.getRemoteObject(objectId); + if (debuggerObj) { + return debuggerObj; + } + } + return null; + } + + _serializeRemoteObject(debuggerObj, executionContextId) { + const ctx = this.contexts.get(executionContextId); + return ctx._toRemoteObject(debuggerObj); + } + + _getRemoteObjectByNodeId(nodeId, executionContextId) { + let debuggerObj = null; + + if (typeof executionContextId != "undefined") { + const ctx = this.contexts.get(executionContextId); + debuggerObj = ctx.getRemoteObjectByNodeId(nodeId); + } else { + for (const ctx of this.contexts.values()) { + const obj = ctx.getRemoteObjectByNodeId(nodeId); + if (obj) { + debuggerObj = obj; + break; + } + } + } + + return debuggerObj; + } + + _setRemoteObject(debuggerObj, context) { + return context.setRemoteObject(debuggerObj); + } + + _getDefaultContextForWindow(innerWindowId) { + if (!innerWindowId) { + innerWindowId = this.content.windowGlobalChild.innerWindowId; + } + const curContexts = this.innerWindowIdToContexts.get(innerWindowId); + if (curContexts) { + for (const ctx of curContexts) { + if (ctx.isDefault) { + return ctx; + } + } + } + return null; + } + + _getContextsForFrame(frameId) { + const frameContexts = []; + for (const ctx of this.contexts.values()) { + if (ctx.frameId == frameId) { + frameContexts.push(ctx); + } + } + return frameContexts; + } + + _emitConsoleAPICalled(payload) { + // Filter out messages that aren't coming from a valid inner window, or from + // a different browser tab. Also messages of type "time", which are not + // getting reported by Chrome. + const curBrowserId = this.session.browsingContext.browserId; + const win = Services.wm.getCurrentInnerWindowWithId(payload.innerWindowId); + if ( + !win || + BrowsingContext.getFromWindow(win).browserId != curBrowserId || + payload.type === "time" + ) { + return; + } + + const context = this._getDefaultContextForWindow(); + this.emit("Runtime.consoleAPICalled", { + args: payload.arguments.map(arg => context._toRemoteObject(arg)), + executionContextId: context?.id || 0, + timestamp: payload.timestamp, + type: payload.type, + stackTrace: this._buildStackTrace(payload.stack), + }); + } + + _emitExceptionThrown(payload) { + // Filter out messages that aren't coming from a valid inner window, or from + // a different browser tab. Also messages of type "time", which are not + // getting reported by Chrome. + const curBrowserId = this.session.browsingContext.browserId; + const win = Services.wm.getCurrentInnerWindowWithId(payload.innerWindowId); + if (!win || BrowsingContext.getFromWindow(win).browserId != curBrowserId) { + return; + } + + const context = this._getDefaultContextForWindow(); + this.emit("Runtime.exceptionThrown", { + timestamp: payload.timestamp, + exceptionDetails: { + // Temporary placeholder to return a number. + exceptionId: 0, + text: payload.text, + lineNumber: payload.lineNumber, + columnNumber: payload.columnNumber, + url: payload.url, + stackTrace: this._buildStackTrace(payload.stack), + executionContextId: context?.id || undefined, + }, + }); + } + + /** + * Helper method in order to instantiate the ExecutionContext for a given + * DOM Window as well as emitting the related + * `Runtime.executionContextCreated` event + * + * @param {string} name + * Event name + * @param {Object=} options + * @param {number} options.windowId + * The inner window id of the newly instantiated document. + * @param {Window} options.window + * The window object of the newly instantiated document. + * @param {string=} options.contextName + * Human-readable name to describe the execution context. + * @param {boolean=} options.isDefault + * Whether the execution context is the default one. + * @param {string=} options.contextType + * "default" or "isolated" + * + * @return {number} ID of created context + * + */ + _onContextCreated(name, options = {}) { + const { + windowId, + window, + contextName = "", + isDefault = true, + contextType = "default", + } = options; + + if (windowId === undefined) { + throw new Error("windowId is required"); + } + + // allow only one default context per inner window + if (isDefault && this.innerWindowIdToContexts.has(windowId)) { + for (const ctx of this.innerWindowIdToContexts.get(windowId)) { + if (ctx.isDefault) { + return null; + } + } + } + + const context = new lazy.ExecutionContext( + this._debugger, + window, + this.innerWindowIdToContexts.count, + isDefault + ); + this.contexts.set(context.id, context); + this.innerWindowIdToContexts.set(windowId, context); + + if (this.enabled) { + this.emit("Runtime.executionContextCreated", { + context: { + id: context.id, + origin: window.origin, + name: contextName, + auxData: { + isDefault, + frameId: context.frameId, + type: contextType, + }, + }, + }); + } + + return context.id; + } + + /** + * Helper method to destroy the ExecutionContext of the given id. Also emit + * the related `Runtime.executionContextDestroyed` and + * `Runtime.executionContextsCleared` events. + * ContextObserver will call this method with either `id` or `frameId` argument + * being set. + * + * @param {string} name + * Event name + * @param {Object=} options + * @param {number} id + * The execution context id to destroy. + * @param {number} windowId + * The inner-window id of the execution context to destroy. + * @param {number} frameId + * The frame id of execution context to destroy. + * Either `id` or `frameId` or `windowId` is passed. + */ + _onContextDestroyed(name, { id, frameId, windowId }) { + let contexts; + if ([id, frameId, windowId].filter(id => !!id).length > 1) { + throw new Error("Expects only *one* of id, frameId, windowId"); + } + + if (id) { + contexts = [this.contexts.get(id)]; + } else if (frameId) { + contexts = this._getContextsForFrame(frameId); + } else { + contexts = this.innerWindowIdToContexts.get(windowId) || []; + } + + for (const ctx of contexts) { + const isFrame = !!BrowsingContext.get(ctx.frameId).parent; + + ctx.destructor(); + this.contexts.delete(ctx.id); + this.innerWindowIdToContexts.get(ctx.windowId).delete(ctx); + + if (this.enabled) { + this.emit("Runtime.executionContextDestroyed", { + executionContextId: ctx.id, + }); + } + + if (this.innerWindowIdToContexts.get(ctx.windowId).size == 0) { + this.innerWindowIdToContexts.delete(ctx.windowId); + // Only emit when all the exeuction contexts were cleared for the + // current browser / target, which means it should only be emitted + // for a top-level browsing context reference. + if (this.enabled && !isFrame) { + this.emit("Runtime.executionContextsCleared"); + } + } + } + } + + onConsoleLogEvent(message) { + let entry = fromConsoleAPI(message); + this._emitConsoleAPICalled(entry); + } + + // nsIObserver + + /** + * Takes a console message belonging to the current window and emits a + * "exceptionThrown" event if it's a Javascript error, otherwise a + * "consoleAPICalled" event. + * + * @param {nsIConsoleMessage} message + * Console message. + */ + observe(subject, topic, data) { + if (subject instanceof Ci.nsIScriptError && subject.hasException) { + let entry = fromScriptError(subject); + this._emitExceptionThrown(entry); + } + } + + // XPCOM + + get QueryInterface() { + return ChromeUtils.generateQI(["nsIConsoleListener"]); + } +} + +function fromConsoleAPI(message) { + // From sendConsoleAPIMessage (toolkit/modules/Console.sys.mjs) + return { + arguments: message.arguments, + innerWindowId: message.innerID, + // TODO: Fetch the stack (Bug 1679981) + stack: undefined, + timestamp: message.timeStamp, + type: CONSOLE_API_LEVEL_MAP[message.level] || message.level, + }; +} + +function fromScriptError(error) { + // From dom/bindings/nsIScriptError.idl + return { + innerWindowId: error.innerWindowID, + columnNumber: error.columnNumber - 1, + lineNumber: error.lineNumber - 1, + stack: error.stack, + text: error.errorMessage, + timestamp: error.timeStamp, + url: error.sourceName, + }; +} diff --git a/remote/cdp/domains/content/Security.sys.mjs b/remote/cdp/domains/content/Security.sys.mjs new file mode 100644 index 0000000000..7d21d386b4 --- /dev/null +++ b/remote/cdp/domains/content/Security.sys.mjs @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; + +export class Security extends ContentProcessDomain { + constructor(session) { + super(session); + this.enabled = false; + } + + destructor() { + this.disable(); + + super.destructor(); + } + + // commands + + async enable() { + if (!this.enabled) { + this.enabled = true; + } + } + + disable() { + if (this.enabled) { + this.enabled = false; + } + } +} diff --git a/remote/cdp/domains/content/runtime/ExecutionContext.sys.mjs b/remote/cdp/domains/content/runtime/ExecutionContext.sys.mjs new file mode 100644 index 0000000000..8e5a496770 --- /dev/null +++ b/remote/cdp/domains/content/runtime/ExecutionContext.sys.mjs @@ -0,0 +1,530 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const TYPED_ARRAY_CLASSES = [ + "Uint8Array", + "Uint8ClampedArray", + "Uint16Array", + "Uint32Array", + "Int8Array", + "Int16Array", + "Int32Array", + "Float32Array", + "Float64Array", +]; + +function uuid() { + return Services.uuid + .generateUUID() + .toString() + .slice(1, -1); +} + +/** + * This class represent a debuggable context onto which we can evaluate Javascript. + * This is typically a document, but it could also be a worker, an add-on, ... or + * any kind of context involving JS scripts. + * + * @param {Debugger} dbg + * A Debugger instance that we can use to inspect the given global. + * @param {GlobalObject} debuggee + * The debuggable context's global object. This is typically the document window + * object. But it can also be any global object, like a worker global scope object. + */ +export class ExecutionContext { + constructor(dbg, debuggee, id, isDefault) { + this._debugger = dbg; + this._debuggee = this._debugger.addDebuggee(debuggee); + + // Here, we assume that debuggee is a window object and we will propably have + // to adapt that once we cover workers or contexts that aren't a document. + this.window = debuggee; + this.windowId = this.window.windowGlobalChild.innerWindowId; + this.id = id; + this.frameId = this.window.browsingContext.id.toString(); + this.isDefault = isDefault; + + // objectId => Debugger.Object + this._remoteObjects = new Map(); + } + + destructor() { + this._debugger.removeDebuggee(this._debuggee); + } + + get browsingContext() { + return this.window.browsingContext; + } + + hasRemoteObject(objectId) { + return this._remoteObjects.has(objectId); + } + + getRemoteObject(objectId) { + return this._remoteObjects.get(objectId); + } + + getRemoteObjectByNodeId(nodeId) { + for (const value of this._remoteObjects.values()) { + if (value.nodeId == nodeId) { + return value; + } + } + + return null; + } + + releaseObject(objectId) { + return this._remoteObjects.delete(objectId); + } + + /** + * Add a new debuggerObj to the object cache. + * + * Whenever an object is returned as reference, a new entry is added + * to the internal object cache. It means the same underlying object or node + * can be represented via multiple references. + */ + setRemoteObject(debuggerObj) { + const objectId = uuid(); + + // TODO: Wrap Symbol into an object, + // which would allow us to set the objectId. + if (typeof debuggerObj == "object") { + debuggerObj.objectId = objectId; + } + + // For node objects add an unique identifier. + if ( + debuggerObj instanceof Debugger.Object && + Node.isInstance(debuggerObj.unsafeDereference()) + ) { + debuggerObj.nodeId = uuid(); + // We do not differentiate between backendNodeId and nodeId (yet) + debuggerObj.backendNodeId = debuggerObj.nodeId; + } + + this._remoteObjects.set(objectId, debuggerObj); + + return objectId; + } + + /** + * Evaluate a Javascript expression. + * + * @param {String} expression + * The JS expression to evaluate against the JS context. + * @param {boolean} options.awaitPromise + * Whether execution should `await` for resulting value + * and return once awaited promise is resolved. + * @param {boolean} returnByValue + * Whether the result is expected to be a JSON object + * that should be sent by value. + * + * @return {Object} A multi-form object depending if the execution + * succeed or failed. If the expression failed to evaluate, + * it will return an object with an `exceptionDetails` attribute + * matching the `ExceptionDetails` CDP type. Otherwise it will + * return an object with `result` attribute whose type is + * `RemoteObject` CDP type. + */ + async evaluate(expression, awaitPromise, returnByValue) { + let rv = this._debuggee.executeInGlobal(expression); + if (!rv) { + return { + exceptionDetails: { + text: "Evaluation terminated!", + }, + }; + } + + if (rv.throw) { + return this._returnError(rv.throw); + } + + let result = rv.return; + + if (result && result.isPromise && awaitPromise) { + if (result.promiseState === "fulfilled") { + result = result.promiseValue; + } else if (result.promiseState === "rejected") { + return this._returnError(result.promiseReason); + } else { + try { + const promiseResult = await result.unsafeDereference(); + result = this._debuggee.makeDebuggeeValue(promiseResult); + } catch (e) { + // The promise has been rejected + return this._returnError(e); + } + } + } + + if (returnByValue) { + result = this._toRemoteObjectByValue(result); + } else { + result = this._toRemoteObject(result); + } + + return { result }; + } + + /** + * Given a Debugger.Object reference for an Exception, return a JSON object + * describing the exception by following CDP ExceptionDetails specification. + */ + _returnError(exception) { + if ( + this._debuggee.executeInGlobalWithBindings("exception instanceof Error", { + exception, + }).return + ) { + const text = this._debuggee.executeInGlobalWithBindings( + "exception.message", + { exception } + ).return; + return { + exceptionDetails: { + text, + }, + }; + } + + // If that isn't an Error, consider the exception as a JS value + return { + exceptionDetails: { + exception: this._toRemoteObject(exception), + }, + }; + } + + async callFunctionOn( + functionDeclaration, + callArguments = [], + returnByValue = false, + awaitPromise = false, + objectId = null + ) { + // Map the given objectId to a JS reference. + let thisArg = null; + if (objectId) { + thisArg = this.getRemoteObject(objectId); + if (!thisArg) { + throw new Error(`Unable to get target object with id: ${objectId}`); + } + } + + // First evaluate the function + const fun = this._debuggee.executeInGlobal("(" + functionDeclaration + ")"); + if (!fun) { + return { + exceptionDetails: { + text: "Evaluation terminated!", + }, + }; + } + if (fun.throw) { + return this._returnError(fun.throw); + } + + // Then map all input arguments, which are matching CDP's CallArguments type, + // into JS values + const args = callArguments.map(arg => this._fromCallArgument(arg)); + + // Finally, call the function with these arguments + const rv = fun.return.apply(thisArg, args); + if (rv.throw) { + return this._returnError(rv.throw); + } + + let result = rv.return; + + if (result && result.isPromise && awaitPromise) { + if (result.promiseState === "fulfilled") { + result = result.promiseValue; + } else if (result.promiseState === "rejected") { + return this._returnError(result.promiseReason); + } else { + try { + const promiseResult = await result.unsafeDereference(); + result = this._debuggee.makeDebuggeeValue(promiseResult); + } catch (e) { + // The promise has been rejected + return this._returnError(e); + } + } + } + + if (returnByValue) { + result = this._toRemoteObjectByValue(result); + } else { + result = this._toRemoteObject(result); + } + + return { result }; + } + + getProperties({ objectId, ownProperties }) { + let debuggerObj = this.getRemoteObject(objectId); + if (!debuggerObj) { + throw new Error("Could not find object with given id"); + } + + const result = []; + const serializeObject = (debuggerObj, isOwn) => { + for (const propertyName of debuggerObj.getOwnPropertyNames()) { + const descriptor = debuggerObj.getOwnPropertyDescriptor(propertyName); + result.push({ + name: propertyName, + + configurable: descriptor.configurable, + enumerable: descriptor.enumerable, + writable: descriptor.writable, + value: this._toRemoteObject(descriptor.value), + get: descriptor.get + ? this._toRemoteObject(descriptor.get) + : undefined, + set: descriptor.set + ? this._toRemoteObject(descriptor.set) + : undefined, + + isOwn, + }); + } + }; + + // When `ownProperties` is set to true, we only iterate over own properties. + // Otherwise, we also iterate over propreties inherited from the prototype chain. + serializeObject(debuggerObj, true); + + if (!ownProperties) { + while (true) { + debuggerObj = debuggerObj.proto; + if (!debuggerObj) { + break; + } + serializeObject(debuggerObj, false); + } + } + + return { + result, + }; + } + + /** + * Given a CDP `CallArgument`, return a JS value that represent this argument. + * Note that `CallArgument` is actually very similar to `RemoteObject` + */ + _fromCallArgument(arg) { + if (arg.objectId) { + if (!this.hasRemoteObject(arg.objectId)) { + throw new Error("Could not find object with given id"); + } + return this.getRemoteObject(arg.objectId); + } + + if (arg.unserializableValue) { + switch (arg.unserializableValue) { + case "-0": + return -0; + case "Infinity": + return Infinity; + case "-Infinity": + return -Infinity; + case "NaN": + return NaN; + default: + if (/^\d+n$/.test(arg.unserializableValue)) { + // eslint-disable-next-line no-undef + return BigInt(arg.unserializableValue.slice(0, -1)); + } + throw new Error("Couldn't parse value object in call argument"); + } + } + + return this._deserialize(arg.value); + } + + /** + * Given a JS value, create a copy of it within the debugee compartment. + */ + _deserialize(obj) { + if (typeof obj !== "object") { + return obj; + } + const result = this._debuggee.executeInGlobalWithBindings( + "JSON.parse(obj)", + { obj: JSON.stringify(obj) } + ); + if (result.throw) { + throw new Error("Unable to deserialize object"); + } + return result.return; + } + + /** + * Given a `Debugger.Object` object, return a JSON-serializable description of it + * matching `RemoteObject` CDP type. + * + * @param {Debugger.Object} debuggerObj + * The object to serialize + * @return {RemoteObject} + * The serialized description of the given object + */ + _toRemoteObject(debuggerObj) { + const result = {}; + + // First handle all non-primitive values which are going to be wrapped by the + // Debugger API into Debugger.Object instances + if (debuggerObj instanceof Debugger.Object) { + const rawObj = debuggerObj.unsafeDereference(); + + result.objectId = this.setRemoteObject(debuggerObj); + result.type = typeof rawObj; + + // Map the Debugger API `class` attribute to CDP `subtype` + const cls = debuggerObj.class; + if (debuggerObj.isProxy) { + result.subtype = "proxy"; + } else if (cls == "Array") { + result.subtype = "array"; + } else if (cls == "RegExp") { + result.subtype = "regexp"; + } else if (cls == "Date") { + result.subtype = "date"; + } else if (cls == "Map") { + result.subtype = "map"; + } else if (cls == "Set") { + result.subtype = "set"; + } else if (cls == "WeakMap") { + result.subtype = "weakmap"; + } else if (cls == "WeakSet") { + result.subtype = "weakset"; + } else if (cls == "Error") { + result.subtype = "error"; + } else if (cls == "Promise") { + result.subtype = "promise"; + } else if (TYPED_ARRAY_CLASSES.includes(cls)) { + result.subtype = "typedarray"; + } else if (Node.isInstance(rawObj)) { + result.subtype = "node"; + result.className = ChromeUtils.getClassName(rawObj); + result.description = rawObj.localName || rawObj.nodeName; + if (rawObj.id) { + result.description += `#${rawObj.id}`; + } + } + return result; + } + + // Now, handle all values that Debugger API isn't wrapping into Debugger.API. + // This is all the primitive JS types. + result.type = typeof debuggerObj; + + // Symbol and BigInt are primitive values but aren't serializable. + // CDP expects them to be considered as objects, with an objectId to later inspect + // them. + if (result.type == "symbol") { + result.description = debuggerObj.toString(); + result.objectId = this.setRemoteObject(debuggerObj); + + return result; + } + + // A few primitive type can't be serialized and CDP has special case for them + if (Object.is(debuggerObj, NaN)) { + result.unserializableValue = "NaN"; + } else if (Object.is(debuggerObj, -0)) { + result.unserializableValue = "-0"; + } else if (Object.is(debuggerObj, Infinity)) { + result.unserializableValue = "Infinity"; + } else if (Object.is(debuggerObj, -Infinity)) { + result.unserializableValue = "-Infinity"; + } else if (result.type == "bigint") { + result.unserializableValue = `${debuggerObj}n`; + } + + if (result.unserializableValue) { + result.description = result.unserializableValue; + return result; + } + + // Otherwise, we serialize the primitive values as-is via `value` attribute + result.value = debuggerObj; + + // null is special as it has a dedicated subtype + if (debuggerObj === null) { + result.subtype = "null"; + } + + return result; + } + + /** + * Given a `Debugger.Object` object, return a JSON-serializable description of it + * matching `RemoteObject` CDP type. + * + * @param {Debugger.Object} debuggerObj + * The object to serialize + * @return {RemoteObject} + * The serialized description of the given object + */ + _toRemoteObjectByValue(debuggerObj) { + const type = typeof debuggerObj; + + if (type == "undefined") { + return { type }; + } + + let unserializableValue; + if (Object.is(debuggerObj, -0)) { + unserializableValue = "-0"; + } else if (Object.is(debuggerObj, NaN)) { + unserializableValue = "NaN"; + } else if (Object.is(debuggerObj, Infinity)) { + unserializableValue = "Infinity"; + } else if (Object.is(debuggerObj, -Infinity)) { + unserializableValue = "-Infinity"; + } else if (typeof debuggerObj == "bigint") { + unserializableValue = `${debuggerObj}n`; + } + + if (unserializableValue) { + return { + type, + unserializableValue, + description: unserializableValue, + }; + } + + const value = this._serialize(debuggerObj); + return { + type: typeof value, + value, + description: value != null ? value.toString() : value, + }; + } + + /** + * Convert a given `Debugger.Object` to an object. + * + * @param {Debugger.Object} obj + * The object to convert + * + * @return {Object} + * The converted object + */ + _serialize(debuggerObj) { + const result = this._debuggee.executeInGlobalWithBindings( + "JSON.stringify(e)", + { e: debuggerObj } + ); + if (result.throw) { + throw new Error("Object is not serializable"); + } + + return JSON.parse(result.return); + } +} diff --git a/remote/cdp/domains/parent/Browser.sys.mjs b/remote/cdp/domains/parent/Browser.sys.mjs new file mode 100644 index 0000000000..ecf93d4d8d --- /dev/null +++ b/remote/cdp/domains/parent/Browser.sys.mjs @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +export class Browser extends Domain { + getVersion() { + const { isHeadless } = Cc["@mozilla.org/gfx/info;1"].getService( + Ci.nsIGfxInfo + ); + const { userAgent } = Cc[ + "@mozilla.org/network/protocol;1?name=http" + ].getService(Ci.nsIHttpProtocolHandler); + return { + jsVersion: Services.appinfo.version, + protocolVersion: "1.3", + product: + (isHeadless ? "Headless" : "") + + `${Services.appinfo.name}/${Services.appinfo.version}`, + revision: Services.appinfo.sourceURL.split("/").pop(), + userAgent, + }; + } + + close() { + // Notify all windows that an application quit has been requested. + const cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance( + Ci.nsISupportsPRBool + ); + Services.obs.notifyObservers(cancelQuit, "quit-application-requested"); + + // If the shutdown of the application is prevented force quit it instead. + const mode = cancelQuit.data + ? Ci.nsIAppStartup.eForceQuit + : Ci.nsIAppStartup.eAttemptQuit; + + Services.startup.quit(mode); + } +} diff --git a/remote/cdp/domains/parent/Emulation.sys.mjs b/remote/cdp/domains/parent/Emulation.sys.mjs new file mode 100644 index 0000000000..f5ce827f0e --- /dev/null +++ b/remote/cdp/domains/parent/Emulation.sys.mjs @@ -0,0 +1,177 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyModuleGetters(lazy, { + NetUtil: "resource://gre/modules/NetUtil.jsm", +}); + +const MAX_WINDOW_SIZE = 10000000; + +export class Emulation extends Domain { + destructor() { + this.setUserAgentOverride({ userAgent: "", platform: "" }); + + super.destructor(); + } + + /** + * Overrides the values of device screen dimensions. + * + * Values as modified are: + * - window.screen.width + * - window.screen.height + * - window.innerWidth + * - window.innerHeight + * - "device-width"/"device-height"-related CSS media query results + * + * @param {Object} options + * @param {number} options.width + * Overriding width value in pixels. 0 disables the override. + * @param {number} options.height + * Overriding height value in pixels. 0 disables the override. + * @param {number} options.deviceScaleFactor + * Overriding device scale factor value. 0 disables the override. + * @param {number} options.mobile [not supported] + * Whether to emulate a mobile device. This includes viewport meta tag, + * overlay scrollbars, text autosizing and more. + * @param {number} options.screenOrientation + * Screen orientation override [not supported] + */ + async setDeviceMetricsOverride(options = {}) { + const { width, height, deviceScaleFactor } = options; + + if ( + width < 0 || + width > MAX_WINDOW_SIZE || + height < 0 || + height > MAX_WINDOW_SIZE + ) { + throw new TypeError( + `Width and height values must be positive, not greater than ${MAX_WINDOW_SIZE}` + ); + } + + if (typeof deviceScaleFactor != "number") { + throw new TypeError("deviceScaleFactor: number expected"); + } + + if (deviceScaleFactor < 0) { + throw new TypeError("deviceScaleFactor: must be positive"); + } + + const { tab } = this.session.target; + const { linkedBrowser: browser } = tab; + + const { browsingContext } = this.session.target; + browsingContext.overrideDPPX = deviceScaleFactor; + + // With a value of 0 the current size is used + const { layoutViewport } = await this.session.execute( + this.session.id, + "Page", + "getLayoutMetrics" + ); + + const targetWidth = width > 0 ? width : layoutViewport.clientWidth; + const targetHeight = height > 0 ? height : layoutViewport.clientHeight; + + browser.style.setProperty("min-width", targetWidth + "px"); + browser.style.setProperty("max-width", targetWidth + "px"); + browser.style.setProperty("min-height", targetHeight + "px"); + browser.style.setProperty("max-height", targetHeight + "px"); + + // Wait until the viewport has been resized + await this.executeInChild("_awaitViewportDimensions", { + width: targetWidth, + height: targetHeight, + }); + } + + /** + * Enables touch on platforms which do not support them. + * + * @param {Object} options + * @param {boolean} options.enabled + * Whether the touch event emulation should be enabled. + * @param {number=} options.maxTouchPoints [not yet supported] + * Maximum touch points supported. Defaults to one. + */ + async setTouchEmulationEnabled(options = {}) { + const { enabled } = options; + + if (typeof enabled != "boolean") { + throw new TypeError( + "Invalid parameters (enabled: boolean value expected)" + ); + } + + const { browsingContext } = this.session.target; + if (enabled) { + browsingContext.touchEventsOverride = "enabled"; + } else { + browsingContext.touchEventsOverride = "none"; + } + } + + /** + * Allows overriding user agent with the given string. + * + * @param {Object} options + * @param {string} options.userAgent + * User agent to use. + * @param {string=} options.acceptLanguage [not yet supported] + * Browser langugage to emulate. + * @param {string=} options.platform + * The platform navigator.platform should return. + */ + async setUserAgentOverride(options = {}) { + const { userAgent, platform } = options; + + if (typeof userAgent != "string") { + throw new TypeError( + "Invalid parameters (userAgent: string value expected)" + ); + } + + if (!["undefined", "string"].includes(typeof platform)) { + throw new TypeError("platform: string value expected"); + } + + const { browsingContext } = this.session.target; + + if (!userAgent.length) { + browsingContext.customUserAgent = null; + } else if (this._isValidHTTPRequestHeaderValue(userAgent)) { + browsingContext.customUserAgent = userAgent; + } else { + throw new TypeError("Invalid characters found in userAgent"); + } + + if (platform?.length > 0) { + browsingContext.customPlatform = platform; + } else { + browsingContext.customPlatform = null; + } + } + + _isValidHTTPRequestHeaderValue(value) { + try { + const channel = lazy.NetUtil.newChannel({ + uri: "http://localhost", + loadUsingSystemPrincipal: true, + }); + channel.QueryInterface(Ci.nsIHttpChannel); + channel.setRequestHeader("X-check", value, false); + return true; + } catch (e) { + return false; + } + } +} diff --git a/remote/cdp/domains/parent/Fetch.sys.mjs b/remote/cdp/domains/parent/Fetch.sys.mjs new file mode 100644 index 0000000000..39e6965ccd --- /dev/null +++ b/remote/cdp/domains/parent/Fetch.sys.mjs @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +// Note: For now this domain has only been added so that clients using CDP +// (like Selenium) don't break when trying to disable Fetch events. + +export class Fetch extends Domain { + constructor(session) { + super(session); + + this.enabled = false; + } + + destructor() { + this.disable(); + + super.destructor(); + } + + disable() { + if (!this.enabled) { + return; + } + + this.enabled = false; + } +} diff --git a/remote/cdp/domains/parent/IO.sys.mjs b/remote/cdp/domains/parent/IO.sys.mjs new file mode 100644 index 0000000000..f0877d017f --- /dev/null +++ b/remote/cdp/domains/parent/IO.sys.mjs @@ -0,0 +1,114 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; +import { StreamRegistry } from "chrome://remote/content/cdp/StreamRegistry.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyModuleGetters(lazy, { + OS: "resource://gre/modules/osfile.jsm", +}); + +const DEFAULT_CHUNK_SIZE = 10 * 1024 * 1024; + +// Global singleton for managing open streams +export const streamRegistry = new StreamRegistry(); + +export class IO extends Domain { + // commands + + /** + * Close the stream, discard any temporary backing storage. + * + * @param {Object} options + * @param {string} options.handle + * Handle of the stream to close. + */ + async close(options = {}) { + const { handle } = options; + + if (typeof handle != "string") { + throw new TypeError(`handle: string value expected`); + } + + await streamRegistry.remove(handle); + } + + /** + * Read a chunk of the stream. + * + * @param {Object} options + * @param {string} options.handle + * Handle of the stream to read. + * @param {number=} options.offset + * Seek to the specified offset before reading. If not specificed, + * proceed with offset following the last read. + * Some types of streams may only support sequential reads. + * @param {number=} options.size + * Maximum number of bytes to read (left upon the agent + * discretion if not specified). + * + * @return {string, boolean, boolean} + * Data that were read, including flags for base64-encoded, and end-of-file reached. + */ + async read(options = {}) { + const { handle, offset, size } = options; + + if (typeof handle != "string") { + throw new TypeError(`handle: string value expected`); + } + + const stream = streamRegistry.get(handle); + const fileInfo = await stream.stat(); + + if (typeof offset != "undefined") { + if (typeof offset != "number") { + throw new TypeError(`offset: integer value expected`); + } + + // To keep compatibility with Chrome clip invalid offsets + const seekTo = Math.max(0, Math.min(offset, fileInfo.size)); + await stream.setPosition(seekTo, lazy.OS.File.POS_START); + } + + const curPos = await stream.getPosition(); + const remainingBytes = fileInfo.size - curPos; + + let chunkSize; + if (typeof size != "undefined") { + if (typeof size != "number") { + throw new TypeError(`size: integer value expected`); + } + + // Chromium currently crashes for negative sizes (https://bit.ly/2P6h0Fv), + // but might behave similar to the offset and clip invalid values + chunkSize = Math.max(0, Math.min(size, remainingBytes)); + } else { + chunkSize = Math.min(DEFAULT_CHUNK_SIZE, remainingBytes); + } + + const bytes = await stream.read(chunkSize); + // Each UCS2 character has an upper byte of 0 and a lower byte matching + // the binary data. Using a loop here prevents us from hitting the browser's + // internal `arguments.length` limit. + const ARGS_MAX = 262144; + const stringData = []; + for (let i = 0; i < bytes.length; i += ARGS_MAX) { + let argsChunk = Math.min(bytes.length, i + ARGS_MAX); + stringData.push( + String.fromCharCode.apply(null, bytes.slice(i, argsChunk)) + ); + } + const data = btoa(stringData.join("")); + + return { + data, + base64Encoded: true, + eof: remainingBytes - bytes.length == 0, + }; + } +} diff --git a/remote/cdp/domains/parent/Input.sys.mjs b/remote/cdp/domains/parent/Input.sys.mjs new file mode 100644 index 0000000000..b3b7fd253b --- /dev/null +++ b/remote/cdp/domains/parent/Input.sys.mjs @@ -0,0 +1,168 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +export class Input extends Domain { + // commands + + /** + * Simulate key events. + * + * @param {Object} options + * - autoRepeat (not supported) + * - code (not supported) + * - key + * - isKeypad (not supported) + * - location (not supported) + * - modifiers + * - text (not supported) + * - type + * - unmodifiedText (not supported) + * - windowsVirtualKeyCode + * - nativeVirtualKeyCode (not supported) + * - keyIdentifier (not supported) + * - isSystemKey (not supported) + */ + async dispatchKeyEvent(options = {}) { + // missing code, text, unmodifiedText, autorepeat, location, iskeypad + const { key, modifiers, type, windowsVirtualKeyCode } = options; + const { alt, ctrl, meta, shift } = Input.Modifier; + + let domType; + if (type == "keyDown" || type == "rawKeyDown") { + // 'rawKeyDown' is passed as type by puppeteer for all non-text keydown events: + // See https://github.com/GoogleChrome/puppeteer/blob/2d99d85976dcb28cc6e3bad4b6a00cd61a67a2cf/lib/Input.js#L52 + // For now we simply map rawKeyDown to keydown. + domType = "keydown"; + } else if (type == "keyUp" || type == "char") { + // 'char' is fired as a single key event. Behind the scenes it will trigger keydown, + // keypress and keyup. `domType` will only be used as the event to wait for. + domType = "keyup"; + } else { + throw new Error(`Unknown key event type ${type}`); + } + + const { browser } = this.session.target; + const browserWindow = browser.ownerGlobal; + + const EventUtils = this._getEventUtils(browserWindow); + const eventId = await this.executeInChild( + "_addContentEventListener", + domType + ); + + if (type == "char") { + // type == "char" is used when doing `await page.keyboard.type( 'I’m a list' );` + // the ’ character will be calling dispatchKeyEvent only once with type=char. + EventUtils.synthesizeKey(key, {}, browserWindow); + } else { + // Non printable keys should be prefixed with `KEY_` + const eventUtilsKey = key.length == 1 ? key : "KEY_" + key; + const eventInfo = { + keyCode: windowsVirtualKeyCode, + type: domType, + altKey: !!(modifiers & alt), + ctrlKey: !!(modifiers & ctrl), + metaKey: !!(modifiers & meta), + shiftKey: !!(modifiers & shift), + }; + EventUtils.synthesizeKey(eventUtilsKey, eventInfo, browserWindow); + } + + await this.executeInChild("_waitForContentEvent", eventId); + } + + /** + * Simulate mouse events. + * + * @param {Object} options + * @param {string} options.type + * @param {number} options.x + * @param {number} options.y + * @param {number} options.modifiers + * @param {number} options.timestamp [Not Supported] + * @param {string} options.button + * @param {number} options.buttons [Not Supported] + * @param {string} options.clickCount + * @param {number} options.deltaX [Not Supported] + * @param {number} options.deltaY [Not Supported] + * @param {string} options.pointerType [Not Supported] + */ + async dispatchMouseEvent(options = {}) { + const { button, clickCount, modifiers, type, x, y } = options; + const { alt, ctrl, meta, shift } = Input.Modifier; + + let domType; + if (type === "mousePressed") { + domType = "mousedown"; + } else if (type === "mouseReleased") { + domType = "mouseup"; + } else if (type === "mouseMoved") { + domType = "mousemove"; + } else { + throw new Error(`Mouse type is not supported: ${type}`); + } + + if (domType === "mousedown" && button === "right") { + domType = "contextmenu"; + } + + const buttonID = Input.Button[button] || Input.Button.left; + const { browser } = this.session.target; + const currentWindow = browser.ownerGlobal; + + const EventUtils = this._getEventUtils(currentWindow); + const eventId = await this.executeInChild( + "_addContentEventListener", + domType + ); + + EventUtils.synthesizeMouse(browser, x, y, { + type: domType, + button: buttonID, + clickCount: clickCount || 1, + altKey: !!(modifiers & alt), + ctrlKey: !!(modifiers & ctrl), + metaKey: !!(modifiers & meta), + shiftKey: !!(modifiers & shift), + }); + + await this.executeInChild("_waitForContentEvent", eventId); + } + + /** + * Memoized EventUtils getter. + */ + _getEventUtils(win) { + if (!this._eventUtils) { + this._eventUtils = { + window: win, + parent: win, + _EU_Ci: Ci, + _EU_Cc: Cc, + }; + Services.scriptloader.loadSubScript( + "chrome://remote/content/external/EventUtils.js", + this._eventUtils + ); + } + return this._eventUtils; + } +} + +Input.Button = { + left: 0, + middle: 1, + right: 2, + back: 3, + forward: 4, +}; + +Input.Modifier = { + alt: 1, + ctrl: 2, + meta: 4, + shift: 8, +}; diff --git a/remote/cdp/domains/parent/Network.sys.mjs b/remote/cdp/domains/parent/Network.sys.mjs new file mode 100644 index 0000000000..bccebe7aed --- /dev/null +++ b/remote/cdp/domains/parent/Network.sys.mjs @@ -0,0 +1,538 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +const MAX_COOKIE_EXPIRY = Number.MAX_SAFE_INTEGER; + +const LOAD_CAUSE_STRINGS = { + [Ci.nsIContentPolicy.TYPE_INVALID]: "Invalid", + [Ci.nsIContentPolicy.TYPE_OTHER]: "Other", + [Ci.nsIContentPolicy.TYPE_SCRIPT]: "Script", + [Ci.nsIContentPolicy.TYPE_IMAGE]: "Img", + [Ci.nsIContentPolicy.TYPE_STYLESHEET]: "Stylesheet", + [Ci.nsIContentPolicy.TYPE_OBJECT]: "Object", + [Ci.nsIContentPolicy.TYPE_DOCUMENT]: "Document", + [Ci.nsIContentPolicy.TYPE_SUBDOCUMENT]: "Subdocument", + [Ci.nsIContentPolicy.TYPE_PING]: "Ping", + [Ci.nsIContentPolicy.TYPE_XMLHTTPREQUEST]: "Xhr", + [Ci.nsIContentPolicy.TYPE_OBJECT_SUBREQUEST]: "ObjectSubdoc", + [Ci.nsIContentPolicy.TYPE_DTD]: "Dtd", + [Ci.nsIContentPolicy.TYPE_FONT]: "Font", + [Ci.nsIContentPolicy.TYPE_MEDIA]: "Media", + [Ci.nsIContentPolicy.TYPE_WEBSOCKET]: "Websocket", + [Ci.nsIContentPolicy.TYPE_CSP_REPORT]: "Csp", + [Ci.nsIContentPolicy.TYPE_XSLT]: "Xslt", + [Ci.nsIContentPolicy.TYPE_BEACON]: "Beacon", + [Ci.nsIContentPolicy.TYPE_FETCH]: "Fetch", + [Ci.nsIContentPolicy.TYPE_IMAGESET]: "Imageset", + [Ci.nsIContentPolicy.TYPE_WEB_MANIFEST]: "WebManifest", + [Ci.nsIContentPolicy.TYPE_WEB_IDENTITY]: "Webidentity", +}; + +export class Network extends Domain { + constructor(session) { + super(session); + this.enabled = false; + + this._onRequest = this._onRequest.bind(this); + this._onResponse = this._onResponse.bind(this); + } + + destructor() { + this.disable(); + + super.destructor(); + } + + enable() { + if (this.enabled) { + return; + } + this.enabled = true; + this.session.networkObserver.startTrackingBrowserNetwork( + this.session.target.browser + ); + this.session.networkObserver.on("request", this._onRequest); + this.session.networkObserver.on("response", this._onResponse); + } + + disable() { + if (!this.enabled) { + return; + } + this.session.networkObserver.stopTrackingBrowserNetwork( + this.session.target.browser + ); + this.session.networkObserver.off("request", this._onRequest); + this.session.networkObserver.off("response", this._onResponse); + this.enabled = false; + } + + /** + * Deletes browser cookies with matching name and url or domain/path pair. + * + * @param {Object} options + * @param {string} name + * Name of the cookies to remove. + * @param {string=} url + * If specified, deletes all the cookies with the given name + * where domain and path match provided URL. + * @param {string=} domain + * If specified, deletes only cookies with the exact domain. + * @param {string=} path + * If specified, deletes only cookies with the exact path. + */ + async deleteCookies(options = {}) { + const { domain, name, path = "/", url } = options; + + if (typeof name != "string") { + throw new TypeError("name: string value expected"); + } + + if (!url && !domain) { + throw new TypeError( + "At least one of the url and domain needs to be specified" + ); + } + + // Retrieve host. Check domain first because it has precedence. + let hostname = domain || ""; + if (!hostname.length) { + const cookieURL = new URL(url); + if (!["http:", "https:"].includes(cookieURL.protocol)) { + throw new TypeError("An http or https url must be specified"); + } + hostname = cookieURL.hostname; + } + + const cookiesFound = Services.cookies.getCookiesWithOriginAttributes( + JSON.stringify({}), + hostname + ); + + for (const cookie of cookiesFound) { + if (cookie.name == name && cookie.path.startsWith(path)) { + Services.cookies.remove( + cookie.host, + cookie.name, + cookie.path, + cookie.originAttributes + ); + } + } + } + + /** + * Activates emulation of network conditions. + * + * @param {Object} options + * @param {boolean} offline + * True to emulate internet disconnection. + */ + emulateNetworkConditions(options = {}) { + const { offline } = options; + + if (typeof offline != "boolean") { + throw new TypeError("offline: boolean value expected"); + } + + Services.io.offline = offline; + } + + /** + * Returns all browser cookies. + * + * Depending on the backend support, will return detailed cookie information in the cookies field. + * + * @param {Object} options + * + * @return {Array<Cookie>} + * Array of cookie objects. + */ + async getAllCookies(options = {}) { + const cookies = []; + for (const cookie of Services.cookies.cookies) { + cookies.push(_buildCookie(cookie)); + } + + return { cookies }; + } + + /** + * Returns all browser cookies for the current URL. + * + * @param {Object} options + * @param {Array<string>=} urls + * The list of URLs for which applicable cookies will be fetched. + * Defaults to the currently open URL. + * + * @return {Array<Cookie>} + * Array of cookie objects. + */ + async getCookies(options = {}) { + const { urls = this._getDefaultUrls() } = options; + + if (!Array.isArray(urls)) { + throw new TypeError("urls: array expected"); + } + + for (const [index, url] of urls.entries()) { + if (typeof url !== "string") { + throw new TypeError(`urls: string value expected at index ${index}`); + } + } + + const cookies = []; + for (let url of urls) { + url = new URL(url); + + const secureProtocol = ["https:", "wss:"].includes(url.protocol); + + const cookiesFound = Services.cookies.getCookiesWithOriginAttributes( + JSON.stringify({}), + url.hostname + ); + + for (const cookie of cookiesFound) { + // Ignore secure cookies for non-secure protocols + if (cookie.isSecure && !secureProtocol) { + continue; + } + + // Ignore cookies which do not match the given path + if (!url.pathname.startsWith(cookie.path)) { + continue; + } + + const builtCookie = _buildCookie(cookie); + const duplicateCookie = cookies.some(value => { + return ( + value.name === builtCookie.name && + value.path === builtCookie.path && + value.domain === builtCookie.domain + ); + }); + + if (duplicateCookie) { + continue; + } + + cookies.push(builtCookie); + } + } + + return { cookies }; + } + + /** + * Sets a cookie with the given cookie data. + * + * Note that it may overwrite equivalent cookies if they exist. + * + * @param {Object} cookie + * @param {string} name + * Cookie name. + * @param {string} value + * Cookie value. + * @param {string=} domain + * Cookie domain. + * @param {number=} expires + * Cookie expiration date, session cookie if not set. + * @param {boolean=} httpOnly + * True if cookie is http-only. + * @param {string=} path + * Cookie path. + * @param {string=} sameSite + * Cookie SameSite type. + * @param {boolean=} secure + * True if cookie is secure. + * @param {string=} url + * The request-URI to associate with the setting of the cookie. + * This value can affect the default domain and path values of the + * created cookie. + * + * @return {boolean} + * True if successfully set cookie. + */ + setCookie(cookie) { + if (typeof cookie.name != "string") { + throw new TypeError("name: string value expected"); + } + + if (typeof cookie.value != "string") { + throw new TypeError("value: string value expected"); + } + + if ( + typeof cookie.url == "undefined" && + typeof cookie.domain == "undefined" + ) { + throw new TypeError( + "At least one of the url and domain needs to be specified" + ); + } + + // Retrieve host. Check domain first because it has precedence. + let hostname = cookie.domain || ""; + let cookieURL; + let schemeType = Ci.nsICookie.SCHEME_UNSET; + if (!hostname.length) { + try { + cookieURL = new URL(cookie.url); + } catch (e) { + return { success: false }; + } + + if (!["http:", "https:"].includes(cookieURL.protocol)) { + throw new TypeError(`Invalid protocol ${cookieURL.protocol}`); + } + + if (cookieURL.protocol == "https:") { + cookie.secure = true; + schemeType = Ci.nsICookie.SCHEME_HTTPS; + } else { + schemeType = Ci.nsICookie.SCHEME_HTTP; + } + + hostname = cookieURL.hostname; + } + + if (typeof cookie.path == "undefined") { + cookie.path = "/"; + } + + let isSession = false; + if (typeof cookie.expires == "undefined") { + isSession = true; + cookie.expires = MAX_COOKIE_EXPIRY; + } + + const sameSiteMap = new Map([ + ["None", Ci.nsICookie.SAMESITE_NONE], + ["Lax", Ci.nsICookie.SAMESITE_LAX], + ["Strict", Ci.nsICookie.SAMESITE_STRICT], + ]); + + let success = true; + try { + Services.cookies.add( + hostname, + cookie.path, + cookie.name, + cookie.value, + cookie.secure, + cookie.httpOnly || false, + isSession, + cookie.expires, + {} /* originAttributes */, + sameSiteMap.get(cookie.sameSite), + schemeType + ); + } catch (e) { + success = false; + } + + return { success }; + } + + /** + * Sets given cookies. + * + * @param {Object} options + * @param {Array.<Cookie>} cookies + * Cookies to be set. + */ + setCookies(options = {}) { + const { cookies } = options; + + if (!Array.isArray(cookies)) { + throw new TypeError("Invalid parameters (cookies: array expected)"); + } + + cookies.forEach(cookie => { + const { success } = this.setCookie(cookie); + if (!success) { + throw new Error("Invalid cookie fields"); + } + }); + } + + /** + * Toggles ignoring cache for each request. If true, cache will not be used. + * + * @param {Object} options + * @param {boolean} options.cacheDisabled + * Cache disabled state. + */ + async setCacheDisabled(options = {}) { + const { cacheDisabled = false } = options; + + const { INHIBIT_CACHING, LOAD_BYPASS_CACHE, LOAD_NORMAL } = Ci.nsIRequest; + + let loadFlags = LOAD_NORMAL; + if (cacheDisabled) { + loadFlags = LOAD_BYPASS_CACHE | INHIBIT_CACHING; + } + + await this.executeInChild("_updateLoadFlags", loadFlags); + } + + /** + * Allows overriding user agent with the given string. + * + * Redirected to Emulation.setUserAgentOverride. + */ + setUserAgentOverride(options = {}) { + const { id } = this.session; + this.session.execute(id, "Emulation", "setUserAgentOverride", options); + } + + _onRequest(eventName, httpChannel, data) { + const wrappedChannel = ChannelWrapper.get(httpChannel); + const urlFragment = httpChannel.URI.hasRef + ? "#" + httpChannel.URI.ref + : undefined; + + const request = { + url: httpChannel.URI.specIgnoringRef, + urlFragment, + method: httpChannel.requestMethod, + headers: headersAsObject(data.headers), + postData: undefined, + hasPostData: false, + mixedContentType: undefined, + initialPriority: undefined, + referrerPolicy: undefined, + isLinkPreload: false, + }; + this.emit("Network.requestWillBeSent", { + requestId: data.requestId, + loaderId: data.loaderId, + documentURL: + wrappedChannel.documentURL || httpChannel.URI.specIgnoringRef, + request, + timestamp: Date.now() / 1000, + wallTime: undefined, + initiator: undefined, + redirectResponse: undefined, + type: LOAD_CAUSE_STRINGS[data.cause] || "unknown", + frameId: data.frameId.toString(), + hasUserGesture: undefined, + }); + } + + _onResponse(eventName, httpChannel, data) { + const wrappedChannel = ChannelWrapper.get(httpChannel); + const headers = headersAsObject(data.headers); + + this.emit("Network.responseReceived", { + requestId: data.requestId, + loaderId: data.loaderId, + timestamp: Date.now() / 1000, + type: LOAD_CAUSE_STRINGS[data.cause] || "unknown", + response: { + url: httpChannel.URI.spec, + status: data.status, + statusText: data.statusText, + headers, + mimeType: wrappedChannel.contentType, + requestHeaders: headersAsObject(data.requestHeaders), + connectionReused: undefined, + connectionId: undefined, + remoteIPAddress: data.remoteIPAddress, + remotePort: data.remotePort, + fromDiskCache: data.fromCache, + encodedDataLength: undefined, + protocol: httpChannel.protocolVersion, + securityDetails: data.securityDetails, + // unknown, neutral, insecure, secure, info, insecure-broken + securityState: "unknown", + }, + frameId: data.frameId.toString(), + }); + } + + /** + * Creates an array of all Urls in the page context + * + * @param {Array<string>=} urls + */ + _getDefaultUrls() { + const urls = this.session.target.browsingContext + .getAllBrowsingContextsInSubtree() + .map(context => context.currentURI.spec); + + return urls; + } +} + +/** + * Creates a CDP Network.Cookie from our internal cookie values + * + * @param {nsICookie} cookie + * + * @returns {Network.Cookie} + * A CDP Cookie + */ +function _buildCookie(cookie) { + const data = { + name: cookie.name, + value: cookie.value, + domain: cookie.host, + path: cookie.path, + expires: cookie.isSession ? -1 : cookie.expiry, + // The size is the combined length of both the cookie name and value + size: cookie.name.length + cookie.value.length, + httpOnly: cookie.isHttpOnly, + secure: cookie.isSecure, + session: cookie.isSession, + }; + + if (cookie.sameSite) { + const sameSiteMap = new Map([ + [Ci.nsICookie.SAMESITE_LAX, "Lax"], + [Ci.nsICookie.SAMESITE_STRICT, "Strict"], + ]); + + data.sameSite = sameSiteMap.get(cookie.sameSite); + } + + return data; +} + +/** + * Given a array of possibly repeating header names, merge the values for + * duplicate headers into a comma-separated list, or in some cases a + * newline-separated list. + * + * e.g. { "Cache-Control": "no-cache,no-store" } + * + * Based on + * https://hg.mozilla.org/mozilla-central/file/56c09d42f411246e407fe30418c27e67a6a44d29/netwerk/protocol/http/nsHttpHeaderArray.h + * + * @param {Array} headers + * Array of {name, value} + * @returns {Object} + * Object where each key is a header name. + */ +function headersAsObject(headers) { + const rv = {}; + headers.forEach(({ name, value }) => { + name = name.toLowerCase(); + if (rv[name]) { + const separator = [ + "set-cookie", + "www-authenticate", + "proxy-authenticate", + ].includes(name) + ? "\n" + : ","; + rv[name] += `${separator}${value}`; + } else { + rv[name] = value; + } + }); + return rv; +} diff --git a/remote/cdp/domains/parent/Page.sys.mjs b/remote/cdp/domains/parent/Page.sys.mjs new file mode 100644 index 0000000000..fd2a747721 --- /dev/null +++ b/remote/cdp/domains/parent/Page.sys.mjs @@ -0,0 +1,775 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + clearInterval: "resource://gre/modules/Timer.sys.mjs", + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", + setInterval: "resource://gre/modules/Timer.sys.mjs", + + DialogHandler: + "chrome://remote/content/cdp/domains/parent/page/DialogHandler.sys.mjs", + PollPromise: "chrome://remote/content/shared/Sync.sys.mjs", + streamRegistry: "chrome://remote/content/cdp/domains/parent/IO.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", + UnsupportedError: "chrome://remote/content/cdp/Error.sys.mjs", + windowManager: "chrome://remote/content/shared/WindowManager.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + OS: "resource://gre/modules/osfile.jsm", +}); + +const MAX_CANVAS_DIMENSION = 32767; +const MAX_CANVAS_AREA = 472907776; + +const PRINT_MAX_SCALE_VALUE = 2.0; +const PRINT_MIN_SCALE_VALUE = 0.1; + +const PDF_TRANSFER_MODES = { + base64: "ReturnAsBase64", + stream: "ReturnAsStream", +}; + +const TIMEOUT_SET_HISTORY_INDEX = 1000; + +export class Page extends Domain { + constructor(session) { + super(session); + + this._onDialogLoaded = this._onDialogLoaded.bind(this); + this._onRequest = this._onRequest.bind(this); + + this.enabled = false; + + this.session.networkObserver.startTrackingBrowserNetwork( + this.session.target.browser + ); + this.session.networkObserver.on("request", this._onRequest); + } + + destructor() { + // Flip a flag to avoid to disable the content domain from this.disable() + this._isDestroyed = false; + this.disable(); + + this.session.networkObserver.off("request", this._onRequest); + this.session.networkObserver.stopTrackingBrowserNetwork( + this.session.target.browser + ); + super.destructor(); + } + + // commands + + /** + * Navigates current page to given URL. + * + * @param {Object} options + * @param {string} options.url + * destination URL + * @param {string=} options.frameId + * frame id to navigate (not supported), + * if not specified navigate top frame + * @param {string=} options.referrer + * referred URL (optional) + * @param {string=} options.transitionType + * intended transition type + * @return {Object} + * - frameId {string} frame id that has navigated (or failed to) + * - errorText {string=} error message if navigation has failed + * - loaderId {string} (not supported) + */ + async navigate(options = {}) { + const { url, frameId, referrer, transitionType } = options; + if (typeof url != "string") { + throw new TypeError("url: string value expected"); + } + let validURL; + try { + validURL = Services.io.newURI(url); + } catch (e) { + throw new Error("Error: Cannot navigate to invalid URL"); + } + const topFrameId = this.session.browsingContext.id.toString(); + if (frameId && frameId != topFrameId) { + throw new lazy.UnsupportedError("frameId not supported"); + } + + const hitsNetwork = ["https", "http"].includes(validURL.scheme); + let networkLessLoaderId; + if (!hitsNetwork) { + // This navigation will not hit the network, use a randomly generated id. + const uuid = Services.uuid.generateUUID().toString(); + networkLessLoaderId = uuid.substring(1, uuid.length - 1); + + // Update the content process map of loader ids. + await this.executeInChild("_updateLoaderId", { + frameId: this.session.browsingContext.id, + loaderId: networkLessLoaderId, + }); + } + + const currentURI = this.session.browsingContext.currentURI; + + const isSameDocumentNavigation = + // The "host", "query" and "ref" getters can throw if the URLs are not + // http/https, so verify first that both currentURI and validURL are + // using http/https. + hitsNetwork && + ["https", "http"].includes(currentURI.scheme) && + currentURI.host === validURL.host && + currentURI.query === validURL.query && + !!validURL.ref; + + const requestDone = new Promise(resolve => { + if (isSameDocumentNavigation) { + // Per CDP documentation, same-document navigations should not emit any + // loader id (https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-navigate) + resolve({}); + return; + } + + if (!hitsNetwork) { + // This navigation will not hit the network, use a randomly generated id. + resolve({ navigationRequestId: networkLessLoaderId }); + return; + } + let navigationRequestId, redirectedRequestId; + const _onNavigationRequest = function(_type, _ch, data) { + const { + url: requestURL, + requestId, + redirectedFrom = null, + isNavigationRequest, + } = data; + if (!isNavigationRequest) { + return; + } + if (validURL.spec === requestURL) { + navigationRequestId = redirectedRequestId = requestId; + } else if (redirectedFrom === redirectedRequestId) { + redirectedRequestId = requestId; + } + }; + + const _onRequestFinished = function(_type, _ch, data) { + const { requestId, errorCode } = data; + if ( + redirectedRequestId !== requestId || + errorCode == "NS_BINDING_REDIRECTED" + ) { + // handle next request in redirection chain + return; + } + this.session.networkObserver.off("request", _onNavigationRequest); + this.session.networkObserver.off("requestfinished", _onRequestFinished); + resolve({ errorCode, navigationRequestId }); + }.bind(this); + + this.session.networkObserver.on("request", _onNavigationRequest); + this.session.networkObserver.on("requestfinished", _onRequestFinished); + }); + + const opts = { + loadFlags: transitionToLoadFlag(transitionType), + referrerURI: referrer, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }; + this.session.browsingContext.loadURI(url, opts); + // clients expect loaderId == requestId for a document navigation request + const { navigationRequestId: loaderId, errorCode } = await requestDone; + const result = { + frameId: topFrameId, + loaderId, + }; + if (errorCode) { + result.errorText = errorCode; + } + return result; + } + + /** + * Capture page screenshot. + * + * @param {Object} options + * @param {Viewport=} options.clip + * Capture the screenshot of a given region only. + * @param {string=} options.format + * Image compression format. Defaults to "png". + * @param {number=} options.quality + * Compression quality from range [0..100] (jpeg only). Defaults to 80. + * + * @return {string} + * Base64-encoded image data. + */ + async captureScreenshot(options = {}) { + const { clip, format = "png", quality = 80 } = options; + + if (options.fromSurface) { + throw new lazy.UnsupportedError("fromSurface not supported"); + } + + let rect; + let scale = await this.executeInChild("_devicePixelRatio"); + + if (clip) { + for (const prop of ["x", "y", "width", "height", "scale"]) { + if (clip[prop] == undefined) { + throw new TypeError(`clip.${prop}: double value expected`); + } + } + + const contentRect = await this.executeInChild("_contentRect"); + + // For invalid scale values default to full page + if (clip.scale <= 0) { + Object.assign(clip, { + x: 0, + y: 0, + width: contentRect.width, + height: contentRect.height, + scale: 1, + }); + } else { + if (clip.x < 0 || clip.x > contentRect.width - 1) { + clip.x = 0; + } + if (clip.y < 0 || clip.y > contentRect.height - 1) { + clip.y = 0; + } + if (clip.width <= 0) { + clip.width = contentRect.width; + } + if (clip.height <= 0) { + clip.height = contentRect.height; + } + } + + rect = new DOMRect(clip.x, clip.y, clip.width, clip.height); + scale *= clip.scale; + } else { + // If no specific clipping region has been specified, + // fallback to the layout (fixed) viewport, and the + // default pixel ratio. + const { + pageX, + pageY, + clientWidth, + clientHeight, + } = await this.executeInChild("_layoutViewport"); + + rect = new DOMRect(pageX, pageY, clientWidth, clientHeight); + } + + let canvasWidth = rect.width * scale; + let canvasHeight = rect.height * scale; + + // Cap the screenshot size based on maximum allowed canvas sizes. + // Using higher dimensions would trigger exceptions in Gecko. + // + // See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas#Maximum_canvas_size + if (canvasWidth > MAX_CANVAS_DIMENSION) { + rect.width = Math.floor(MAX_CANVAS_DIMENSION / scale); + canvasWidth = rect.width * scale; + } + if (canvasHeight > MAX_CANVAS_DIMENSION) { + rect.height = Math.floor(MAX_CANVAS_DIMENSION / scale); + canvasHeight = rect.height * scale; + } + // If the area is larger, reduce the height to keep the full width. + if (canvasWidth * canvasHeight > MAX_CANVAS_AREA) { + rect.height = Math.floor(MAX_CANVAS_AREA / (canvasWidth * scale)); + canvasHeight = rect.height * scale; + } + + const { browsingContext, window } = this.session.target; + const snapshot = await browsingContext.currentWindowGlobal.drawSnapshot( + rect, + scale, + "rgb(255,255,255)" + ); + + const canvas = window.document.createElementNS( + "http://www.w3.org/1999/xhtml", + "canvas" + ); + canvas.width = canvasWidth; + canvas.height = canvasHeight; + + const ctx = canvas.getContext("2d"); + ctx.drawImage(snapshot, 0, 0); + + // Bug 1574935 - Huge dimensions can trigger an OOM because multiple copies + // of the bitmap will exist in memory. Force the removal of the snapshot + // because it is no longer needed. + snapshot.close(); + + const url = canvas.toDataURL(`image/${format}`, quality / 100); + if (!url.startsWith(`data:image/${format}`)) { + throw new lazy.UnsupportedError(`Unsupported MIME type: image/${format}`); + } + + // only return the base64 encoded data without the data URL prefix + const data = url.substring(url.indexOf(",") + 1); + + return { data }; + } + + async enable() { + if (this.enabled) { + return; + } + + this.enabled = true; + + const { browser } = this.session.target; + this._dialogHandler = new lazy.DialogHandler(browser); + this._dialogHandler.on("dialog-loaded", this._onDialogLoaded); + await this.executeInChild("enable"); + } + + async disable() { + if (!this.enabled) { + return; + } + + this._dialogHandler.destructor(); + this._dialogHandler = null; + this.enabled = false; + + if (!this._isDestroyed) { + // Only call disable in the content domain if we are not destroying the domain. + // If we are destroying the domain, the content domains will be destroyed + // independently after firing the remote:destroy event. + await this.executeInChild("disable"); + } + } + + async bringToFront() { + const { tab, window } = this.session.target; + + // Focus the window, and select the corresponding tab + await lazy.windowManager.focusWindow(window); + await lazy.TabManager.selectTab(tab); + } + + /** + * Return metrics relating to the layouting of the page. + * + * The returned object contains the following entries: + * + * layoutViewport: + * {number} pageX + * Horizontal offset relative to the document (CSS pixels) + * {number} pageY + * Vertical offset relative to the document (CSS pixels) + * {number} clientWidth + * Width (CSS pixels), excludes scrollbar if present + * {number} clientHeight + * Height (CSS pixels), excludes scrollbar if present + * + * visualViewport: + * {number} offsetX + * Horizontal offset relative to the layout viewport (CSS pixels) + * {number} offsetY + * Vertical offset relative to the layout viewport (CSS pixels) + * {number} pageX + * Horizontal offset relative to the document (CSS pixels) + * {number} pageY + * Vertical offset relative to the document (CSS pixels) + * {number} clientWidth + * Width (CSS pixels), excludes scrollbar if present + * {number} clientHeight + * Height (CSS pixels), excludes scrollbar if present + * {number} scale + * Scale relative to the ideal viewport (size at width=device-width) + * {number} zoom + * Page zoom factor (CSS to device independent pixels ratio) + * + * contentSize: + * {number} x + * X coordinate + * {number} y + * Y coordinate + * {number} width + * Width of scrollable area + * {number} height + * Height of scrollable area + * + * @return {Promise} + * @resolves {layoutViewport, visualViewport, contentSize} + */ + async getLayoutMetrics() { + return { + layoutViewport: await this.executeInChild("_layoutViewport"), + contentSize: await this.executeInChild("_contentRect"), + }; + } + + /** + * Returns navigation history for the current page. + * + * @return {currentIndex:number, entries:Array<NavigationEntry>} + */ + async getNavigationHistory() { + const { window } = this.session.target; + + return new Promise(resolve => { + function updateSessionHistory(sessionHistory) { + const entries = sessionHistory.entries.map(entry => { + return { + id: entry.ID, + url: entry.url, + userTypedURL: entry.originalURI || entry.url, + title: entry.title, + // TODO: Bug 1609514 + transitionType: null, + }; + }); + + resolve({ + currentIndex: sessionHistory.index, + entries, + }); + } + + lazy.SessionStore.getSessionHistory( + window.gBrowser.selectedTab, + updateSessionHistory + ); + }); + } + + /** + * Interact with the currently opened JavaScript dialog (alert, confirm, + * prompt) for this page. This will always close the dialog, either accepting + * or rejecting it, with the optional prompt filled. + * + * @param {Object} options + * @param {boolean=} options.accept + * for "confirm", "prompt", "beforeunload" dialogs true will accept + * the dialog, false will cancel it. For "alert" dialogs, true or + * false closes the dialog in the same way. + * @param {string=} options.promptText + * for "prompt" dialogs, used to fill the prompt input. + */ + async handleJavaScriptDialog(options = {}) { + const { accept, promptText } = options; + + if (!this.enabled) { + throw new Error("Page domain is not enabled"); + } + await this._dialogHandler.handleJavaScriptDialog({ accept, promptText }); + } + + /** + * Navigates current page to the given history entry. + * + * @param {Object} options + * @param {number} options.entryId + * Unique id of the entry to navigate to. + */ + async navigateToHistoryEntry(options = {}) { + const { entryId } = options; + + const index = await this._getIndexForHistoryEntryId(entryId); + + if (index == null) { + throw new Error("No entry with passed id"); + } + + const { window } = this.session.target; + window.gBrowser.gotoIndex(index); + + // On some platforms the requested index isn't set immediately. + await lazy.PollPromise( + async (resolve, reject) => { + const currentIndex = await this._getCurrentHistoryIndex(); + if (currentIndex == index) { + resolve(); + } else { + reject(); + } + }, + { timeout: TIMEOUT_SET_HISTORY_INDEX } + ); + } + + /** + * Print page as PDF. + * + * @param {Object} options + * @param {boolean=} options.displayHeaderFooter + * Display header and footer. Defaults to false. + * @param {string=} options.footerTemplate (not supported) + * HTML template for the print footer. + * @param {string=} options.headerTemplate (not supported) + * HTML template for the print header. Should use the same format + * as the footerTemplate. + * @param {boolean=} options.ignoreInvalidPageRanges + * Whether to silently ignore invalid but successfully parsed page ranges, + * such as '3-2'. Defaults to false. + * @param {boolean=} options.landscape + * Paper orientation. Defaults to false. + * @param {number=} options.marginBottom + * Bottom margin in inches. Defaults to 1cm (~0.4 inches). + * @param {number=} options.marginLeft + * Left margin in inches. Defaults to 1cm (~0.4 inches). + * @param {number=} options.marginRight + * Right margin in inches. Defaults to 1cm (~0.4 inches). + * @param {number=} options.marginTop + * Top margin in inches. Defaults to 1cm (~0.4 inches). + * @param {string=} options.pageRanges (not supported) + * Paper ranges to print, e.g., '1-5, 8, 11-13'. + * Defaults to the empty string, which means print all pages. + * @param {number=} options.paperHeight + * Paper height in inches. Defaults to 11 inches. + * @param {number=} options.paperWidth + * Paper width in inches. Defaults to 8.5 inches. + * @param {boolean=} options.preferCSSPageSize + * Whether or not to prefer page size as defined by CSS. + * Defaults to false, in which case the content will be scaled + * to fit the paper size. + * @param {boolean=} options.printBackground + * Print background graphics. Defaults to false. + * @param {number=} options.scale + * Scale of the webpage rendering. Defaults to 1. + * @param {string=} options.transferMode + * Return as base64-encoded string (ReturnAsBase64), + * or stream (ReturnAsStream). Defaults to ReturnAsBase64. + * + * @return {Promise<{data:string, stream:string}> + * Based on the transferMode setting data is a base64-encoded string, + * or stream is a handle to a OS.File stream. + */ + async printToPDF(options = {}) { + const { + displayHeaderFooter = false, + // Bug 1601570 - Implement templates for header and footer + // headerTemplate = "", + // footerTemplate = "", + landscape = false, + marginBottom = 0.39, + marginLeft = 0.39, + marginRight = 0.39, + marginTop = 0.39, + // Bug 1601571 - Implement handling of page ranges + // TODO: pageRanges = "", + // TODO: ignoreInvalidPageRanges = false, + paperHeight = 11.0, + paperWidth = 8.5, + preferCSSPageSize = false, + printBackground = false, + scale = 1.0, + transferMode = PDF_TRANSFER_MODES.base64, + } = options; + + if (marginBottom < 0) { + throw new TypeError("marginBottom is negative"); + } + if (marginLeft < 0) { + throw new TypeError("marginLeft is negative"); + } + if (marginRight < 0) { + throw new TypeError("marginRight is negative"); + } + if (marginTop < 0) { + throw new TypeError("marginTop is negative"); + } + if (scale < PRINT_MIN_SCALE_VALUE || scale > PRINT_MAX_SCALE_VALUE) { + throw new TypeError("scale is outside [0.1 - 2] range"); + } + if (paperHeight <= 0) { + throw new TypeError("paperHeight is zero or negative"); + } + if (paperWidth <= 0) { + throw new TypeError("paperWidth is zero or negative"); + } + + // Create a unique filename for the temporary PDF file + const basePath = lazy.OS.Path.join( + lazy.OS.Constants.Path.tmpDir, + "remote-agent.pdf" + ); + const { file, path: filePath } = await lazy.OS.File.openUnique(basePath); + await file.close(); + + const psService = Cc["@mozilla.org/gfx/printsettings-service;1"].getService( + Ci.nsIPrintSettingsService + ); + + const printSettings = psService.createNewPrintSettings(); + printSettings.isInitializedFromPrinter = true; + printSettings.isInitializedFromPrefs = true; + printSettings.outputFormat = Ci.nsIPrintSettings.kOutputFormatPDF; + printSettings.printerName = ""; + printSettings.printSilent = true; + printSettings.outputDestination = + Ci.nsIPrintSettings.kOutputDestinationFile; + printSettings.toFileName = filePath; + + printSettings.paperSizeUnit = Ci.nsIPrintSettings.kPaperSizeInches; + printSettings.paperWidth = paperWidth; + printSettings.paperHeight = paperHeight; + + printSettings.marginBottom = marginBottom; + printSettings.marginLeft = marginLeft; + printSettings.marginRight = marginRight; + printSettings.marginTop = marginTop; + + printSettings.printBGColors = printBackground; + printSettings.printBGImages = printBackground; + printSettings.scaling = scale; + printSettings.shrinkToFit = preferCSSPageSize; + + if (!displayHeaderFooter) { + printSettings.headerStrCenter = ""; + printSettings.headerStrLeft = ""; + printSettings.headerStrRight = ""; + printSettings.footerStrCenter = ""; + printSettings.footerStrLeft = ""; + printSettings.footerStrRight = ""; + } + + if (landscape) { + printSettings.orientation = Ci.nsIPrintSettings.kLandscapeOrientation; + } + + const { linkedBrowser } = this.session.target.tab; + + await linkedBrowser.browsingContext.print(printSettings); + + // Bug 1603739 - With e10s enabled the promise returned by print() resolves + // too early, which means the file hasn't been completely written. + await new Promise(resolve => { + const DELAY_CHECK_FILE_COMPLETELY_WRITTEN = 100; + + let lastSize = 0; + const timerId = lazy.setInterval(async () => { + const fileInfo = await lazy.OS.File.stat(filePath); + if (lastSize > 0 && fileInfo.size == lastSize) { + lazy.clearInterval(timerId); + resolve(); + } + lastSize = fileInfo.size; + }, DELAY_CHECK_FILE_COMPLETELY_WRITTEN); + }); + + const fp = await lazy.OS.File.open(filePath); + + const retval = { data: null, stream: null }; + if (transferMode == PDF_TRANSFER_MODES.stream) { + retval.stream = lazy.streamRegistry.add(fp); + } else { + // return all data as a base64 encoded string + let bytes; + try { + bytes = await fp.read(); + } finally { + fp.close(); + await lazy.OS.File.remove(filePath); + } + + // Each UCS2 character has an upper byte of 0 and a lower byte matching + // the binary data + retval.data = btoa(String.fromCharCode.apply(null, bytes)); + } + + return retval; + } + + /** + * Intercept file chooser requests and transfer control to protocol clients. + * + * When file chooser interception is enabled, + * the native file chooser dialog is not shown. + * Instead, a protocol event Page.fileChooserOpened is emitted. + * + * @param {Object} options + * @param {boolean=} options.enabled + * Enabled state of file chooser interception. + */ + setInterceptFileChooserDialog(options = {}) {} + + _getCurrentHistoryIndex() { + const { window } = this.session.target; + + return new Promise(resolve => { + lazy.SessionStore.getSessionHistory( + window.gBrowser.selectedTab, + history => { + resolve(history.index); + } + ); + }); + } + + _getIndexForHistoryEntryId(id) { + const { window } = this.session.target; + + return new Promise(resolve => { + function updateSessionHistory(sessionHistory) { + sessionHistory.entries.forEach((entry, index) => { + if (entry.ID == id) { + resolve(index); + } + }); + + resolve(null); + } + + lazy.SessionStore.getSessionHistory( + window.gBrowser.selectedTab, + updateSessionHistory + ); + }); + } + + /** + * Emit the proper CDP event javascriptDialogOpening when a javascript dialog + * opens for the current target. + */ + _onDialogLoaded(e, data) { + const { message, type } = data; + // XXX: We rely on the tabmodal-dialog-loaded event (see DialogHandler.jsm) + // which is inconsistent with the name "javascriptDialogOpening". + // For correctness we should rely on an event fired _before_ the prompt is + // visible, such as DOMWillOpenModalDialog. However the payload of this + // event does not contain enough data to populate javascriptDialogOpening. + // + // Since the event is fired asynchronously, this should not have an impact + // on the actual tests relying on this API. + this.emit("Page.javascriptDialogOpening", { message, type }); + } + + /** + * Handles HTTP request to propagate loaderId to events emitted from + * content process + */ + _onRequest(_type, _ch, data) { + if (!data.loaderId) { + return; + } + this.executeInChild("_updateLoaderId", { + loaderId: data.loaderId, + frameId: data.frameId, + }); + } +} + +function transitionToLoadFlag(transitionType) { + switch (transitionType) { + case "reload": + return Ci.nsIWebNavigation.LOAD_FLAGS_IS_REFRESH; + case "link": + default: + return Ci.nsIWebNavigation.LOAD_FLAGS_IS_LINK; + } +} diff --git a/remote/cdp/domains/parent/Security.sys.mjs b/remote/cdp/domains/parent/Security.sys.mjs new file mode 100644 index 0000000000..73967d73cb --- /dev/null +++ b/remote/cdp/domains/parent/Security.sys.mjs @@ -0,0 +1,58 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + sss: ["@mozilla.org/ssservice;1", "nsISiteSecurityService"], + certOverrideService: [ + "@mozilla.org/security/certoverride;1", + "nsICertOverrideService", + ], +}); + +const CERT_PINNING_ENFORCEMENT_PREF = "security.cert_pinning.enforcement_level"; +const HSTS_PRELOAD_LIST_PREF = "network.stricttransportsecurity.preloadlist"; + +export class Security extends Domain { + destructor() { + this.setIgnoreCertificateErrors({ ignore: false }); + } + + /** + * Enable/disable whether all certificate errors should be ignored + * + * @param {Object} options + * @param {boolean=} options.ignore + * if true, all certificate errors will be ignored. + */ + setIgnoreCertificateErrors(options = {}) { + const { ignore } = options; + + if (ignore) { + // make it possible to register certificate overrides for domains + // that use HSTS or HPKP + lazy.Preferences.set(HSTS_PRELOAD_LIST_PREF, false); + lazy.Preferences.set(CERT_PINNING_ENFORCEMENT_PREF, 0); + } else { + lazy.Preferences.reset(HSTS_PRELOAD_LIST_PREF); + lazy.Preferences.reset(CERT_PINNING_ENFORCEMENT_PREF); + + // clear collected HSTS and HPKP state + lazy.sss.clearAll(); + } + + lazy.certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyData( + ignore + ); + } +} diff --git a/remote/cdp/domains/parent/Target.sys.mjs b/remote/cdp/domains/parent/Target.sys.mjs new file mode 100644 index 0000000000..5f0229ce4d --- /dev/null +++ b/remote/cdp/domains/parent/Target.sys.mjs @@ -0,0 +1,193 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ContextualIdentityService: + "resource://gre/modules/ContextualIdentityService.sys.mjs", + MainProcessTarget: + "chrome://remote/content/cdp/targets/MainProcessTarget.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", + TabSession: "chrome://remote/content/cdp/sessions/TabSession.sys.mjs", + windowManager: "chrome://remote/content/shared/WindowManager.sys.mjs", +}); + +let browserContextIds = 1; + +export class Target extends Domain { + constructor(session) { + super(session); + + this._onTargetCreated = this._onTargetCreated.bind(this); + this._onTargetDestroyed = this._onTargetDestroyed.bind(this); + } + + getBrowserContexts() { + return { + browserContextIds: [], + }; + } + + createBrowserContext() { + const identity = lazy.ContextualIdentityService.create( + "remote-agent-" + browserContextIds++ + ); + return { browserContextId: identity.userContextId }; + } + + disposeBrowserContext(options = {}) { + const { browserContextId } = options; + + lazy.ContextualIdentityService.remove(browserContextId); + lazy.ContextualIdentityService.closeContainerTabs(browserContextId); + } + + getTargets() { + const { targetList } = this.session.target; + + const targetInfos = []; + for (const target of targetList) { + if (target instanceof lazy.MainProcessTarget) { + continue; + } + + targetInfos.push(this._getTargetInfo(target)); + } + + return { targetInfos }; + } + + setDiscoverTargets(options = {}) { + const { discover } = options; + const { targetList } = this.session.target; + if (discover) { + targetList.on("target-created", this._onTargetCreated); + targetList.on("target-destroyed", this._onTargetDestroyed); + } else { + targetList.off("target-created", this._onTargetCreated); + targetList.off("target-destroyed", this._onTargetDestroyed); + } + for (const target of targetList) { + this._onTargetCreated("target-created", target); + } + } + + async createTarget(options = {}) { + const { browserContextId } = options; + const { targetList, window } = this.session.target; + const onTarget = targetList.once("target-created"); + const tab = await lazy.TabManager.addTab({ + focus: true, + userContextId: browserContextId, + window, + }); + const target = await onTarget; + if (tab.linkedBrowser != target.browser) { + throw new Error( + "Unexpected tab opened: " + tab.linkedBrowser.currentURI.spec + ); + } + return { targetId: target.id }; + } + + async closeTarget(options = {}) { + const { targetId } = options; + const { targetList } = this.session.target; + const target = targetList.getById(targetId); + + if (!target) { + throw new Error(`Unable to find target with id '${targetId}'`); + } + + await lazy.TabManager.removeTab(target.tab); + } + + async activateTarget(options = {}) { + const { targetId } = options; + const { targetList, window } = this.session.target; + const target = targetList.getById(targetId); + + if (!target) { + throw new Error(`Unable to find target with id '${targetId}'`); + } + + // Focus the window, and select the corresponding tab + await lazy.windowManager.focusWindow(window); + await lazy.TabManager.selectTab(target.tab); + } + + attachToTarget(options = {}) { + const { targetId } = options; + const { targetList } = this.session.target; + const target = targetList.getById(targetId); + + if (!target) { + throw new Error(`Unable to find target with id '${targetId}'`); + } + + const tabSession = new lazy.TabSession( + this.session.connection, + target, + Services.uuid + .generateUUID() + .toString() + .slice(1, -1) + ); + this.session.connection.registerSession(tabSession); + + this._emitAttachedToTarget(target, tabSession); + + return { + sessionId: tabSession.id, + }; + } + + setAutoAttach() {} + + sendMessageToTarget(options = {}) { + const { sessionId, message } = options; + const { connection } = this.session; + connection.sendMessageToTarget(sessionId, message); + } + + /** + * Internal methods: the following methods are not part of CDP; + * note the _ prefix. + */ + + _emitAttachedToTarget(target, tabSession) { + const targetInfo = this._getTargetInfo(target); + this.emit("Target.attachedToTarget", { + targetInfo, + sessionId: tabSession.id, + waitingForDebugger: false, + }); + } + + _getTargetInfo(target) { + return { + targetId: target.id, + type: target.type, + title: target.title, + url: target.url, + // TODO: Correctly determine if target is attached (bug 1680780) + attached: target.id == this.session.target.id, + browserContextId: target.browserContextId, + }; + } + + _onTargetCreated(eventName, target) { + const targetInfo = this._getTargetInfo(target); + this.emit("Target.targetCreated", { targetInfo }); + } + + _onTargetDestroyed(eventName, target) { + this.emit("Target.targetDestroyed", { + targetId: target.id, + }); + } +} diff --git a/remote/cdp/domains/parent/page/DialogHandler.sys.mjs b/remote/cdp/domains/parent/page/DialogHandler.sys.mjs new file mode 100644 index 0000000000..a142165b4b --- /dev/null +++ b/remote/cdp/domains/parent/page/DialogHandler.sys.mjs @@ -0,0 +1,140 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", +}); + +const DIALOG_TYPES = { + ALERT: "alert", + BEFOREUNLOAD: "beforeunload", + CONFIRM: "confirm", + PROMPT: "prompt", +}; + +/** + * Helper dedicated to detect and interact with browser dialogs such as `alert`, + * `confirm` etc. The current implementation only supports tabmodal dialogs, + * not full window dialogs. + * + * Emits "dialog-loaded" when a javascript dialog is opened for the current + * browser. + * + * @param {BrowserElement} browser + */ +export class DialogHandler { + constructor(browser) { + lazy.EventEmitter.decorate(this); + this._dialog = null; + this._browser = browser; + + this._onCommonDialogLoaded = this._onCommonDialogLoaded.bind(this); + this._onTabDialogLoaded = this._onTabDialogLoaded.bind(this); + + Services.obs.addObserver( + this._onCommonDialogLoaded, + "common-dialog-loaded" + ); + Services.obs.addObserver(this._onTabDialogLoaded, "tabmodal-dialog-loaded"); + } + + destructor() { + this._dialog = null; + this._pageTarget = null; + + Services.obs.removeObserver( + this._onCommonDialogLoaded, + "common-dialog-loaded" + ); + Services.obs.removeObserver( + this._onTabDialogLoaded, + "tabmodal-dialog-loaded" + ); + } + + async handleJavaScriptDialog({ accept, promptText }) { + if (!this._dialog) { + throw new Error("No dialog available for handleJavaScriptDialog"); + } + + const type = this._getDialogType(); + if (promptText && type === "prompt") { + this._dialog.ui.loginTextbox.value = promptText; + } + + const onDialogClosed = new Promise(r => { + this._browser.addEventListener("DOMModalDialogClosed", r, { + once: true, + }); + }); + + // 0 corresponds to the OK callback, 1 to the CANCEL callback. + if (accept) { + this._dialog.ui.button0.click(); + } else { + this._dialog.ui.button1.click(); + } + + await onDialogClosed; + + // Resetting dialog to null here might be racy and lead to errors if the + // content page is triggering several prompts in a row. + // See Bug 1569578. + this._dialog = null; + } + + _getDialogType() { + const { inPermitUnload, promptType } = this._dialog.args; + + if (inPermitUnload) { + return DIALOG_TYPES.BEFOREUNLOAD; + } + + switch (promptType) { + case "alert": + return DIALOG_TYPES.ALERT; + case "confirm": + return DIALOG_TYPES.CONFIRM; + case "prompt": + return DIALOG_TYPES.PROMPT; + default: + throw new Error("Unsupported dialog type: " + promptType); + } + } + + _onCommonDialogLoaded(dialogWindow) { + const dialogs = this._browser.tabDialogBox.getContentDialogManager() + .dialogs; + const dialog = dialogs.find(d => d.frameContentWindow === dialogWindow); + + if (!dialog) { + // The dialog is not for the current tab. + return; + } + + this._dialog = dialogWindow.Dialog; + const message = this._dialog.args.text; + const type = this._getDialogType(); + + this.emit("dialog-loaded", { message, type }); + } + + _onTabDialogLoaded(promptContainer) { + const prompts = this._browser.tabModalPromptBox.listPrompts(); + const prompt = prompts.find(p => p.ui.promptContainer === promptContainer); + + if (!prompt) { + // The dialog is not for the current tab. + return; + } + + this._dialog = prompt; + const message = this._dialog.args.text; + const type = this._getDialogType(); + + this.emit("dialog-loaded", { message, type }); + } +} |