summaryrefslogtreecommitdiffstats
path: root/remote/cdp/domains/parent
diff options
context:
space:
mode:
Diffstat (limited to 'remote/cdp/domains/parent')
-rw-r--r--remote/cdp/domains/parent/Browser.sys.mjs40
-rw-r--r--remote/cdp/domains/parent/Emulation.sys.mjs177
-rw-r--r--remote/cdp/domains/parent/Fetch.sys.mjs30
-rw-r--r--remote/cdp/domains/parent/IO.sys.mjs103
-rw-r--r--remote/cdp/domains/parent/Input.sys.mjs168
-rw-r--r--remote/cdp/domains/parent/Network.sys.mjs538
-rw-r--r--remote/cdp/domains/parent/Page.sys.mjs756
-rw-r--r--remote/cdp/domains/parent/Security.sys.mjs58
-rw-r--r--remote/cdp/domains/parent/SystemInfo.sys.mjs48
-rw-r--r--remote/cdp/domains/parent/Target.sys.mjs286
-rw-r--r--remote/cdp/domains/parent/page/DialogHandler.sys.mjs140
11 files changed, 2344 insertions, 0 deletions
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..8cb01b16ac
--- /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..b7eeb75774
--- /dev/null
+++ b/remote/cdp/domains/parent/IO.sys.mjs
@@ -0,0 +1,103 @@
+/* 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";
+import { StreamRegistry } from "chrome://remote/content/cdp/StreamRegistry.sys.mjs";
+
+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).
+ *
+ * @returns {object}
+ * 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);
+
+ if (typeof offset != "undefined") {
+ if (typeof offset != "number") {
+ throw new TypeError(`offset: integer value expected`);
+ }
+
+ await stream.seek(offset);
+ }
+
+ const remainingBytes = await stream.available();
+
+ 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.readBytes(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..4121298d50
--- /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..4d36cf994e
--- /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} options.name
+ * Name of the cookies to remove.
+ * @param {string=} options.url
+ * If specified, deletes all the cookies with the given name
+ * where domain and path match provided URL.
+ * @param {string=} options.domain
+ * If specified, deletes only cookies with the exact domain.
+ * @param {string=} options.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} options.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
+ *
+ * @returns {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>=} options.urls
+ * The list of URLs for which applicable cookies will be fetched.
+ * Defaults to the currently open URL.
+ *
+ * @returns {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} cookie.name
+ * Cookie name.
+ * @param {string} cookie.value
+ * Cookie value.
+ * @param {string=} cookie.domain
+ * Cookie domain.
+ * @param {number=} cookie.expires
+ * Cookie expiration date, session cookie if not set.
+ * @param {boolean=} cookie.httpOnly
+ * True if cookie is http-only.
+ * @param {string=} cookie.path
+ * Cookie path.
+ * @param {string=} cookie.sameSite
+ * Cookie SameSite type.
+ * @param {boolean=} cookie.secure
+ * True if cookie is secure.
+ * @param {string=} cookie.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.
+ *
+ * @returns {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>} options.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
+ *
+ * @returns {Array<string>=}
+ */
+ _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..7b32ad6497
--- /dev/null
+++ b/remote/cdp/domains/parent/Page.sys.mjs
@@ -0,0 +1,756 @@
+/* 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, {
+ SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
+
+ DialogHandler:
+ "chrome://remote/content/cdp/domains/parent/page/DialogHandler.sys.mjs",
+ generateUUID: "chrome://remote/content/shared/UUID.sys.mjs",
+ PollPromise: "chrome://remote/content/shared/Sync.sys.mjs",
+ print: "chrome://remote/content/shared/PDF.sys.mjs",
+ streamRegistry: "chrome://remote/content/cdp/domains/parent/IO.sys.mjs",
+ Stream: "chrome://remote/content/cdp/StreamRegistry.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",
+});
+
+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
+ * @returns {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.
+ networkLessLoaderId = lazy.generateUUID();
+
+ // 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(validURL, 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.
+ *
+ * @returns {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
+ *
+ * @returns {Promise<object>}
+ * Promise which resolves with an object with the following properties
+ * layoutViewport and contentSize
+ */
+ async getLayoutMetrics() {
+ return {
+ layoutViewport: await this.executeInChild("_layoutViewport"),
+ contentSize: await this.executeInChild("_contentRect"),
+ };
+ }
+
+ /**
+ * Returns navigation history for the current page.
+ *
+ * @returns {Promise<object>}
+ * Promise which resolves with an object with the following properties
+ * currentIndex (number) and 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.
+ *
+ * @returns {Promise<{data:string, stream:Stream}>}
+ * Based on the transferMode setting data is a base64-encoded string,
+ * or stream is a 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");
+ }
+
+ 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.paperSizeUnit = Ci.nsIPrintSettings.kPaperSizeInches;
+ printSettings.paperWidth = paperWidth;
+ printSettings.paperHeight = paperHeight;
+
+ // Override any os-specific unwriteable margins
+ printSettings.unwriteableMarginTop = 0;
+ printSettings.unwriteableMarginLeft = 0;
+ printSettings.unwriteableMarginBottom = 0;
+ printSettings.unwriteableMarginRight = 0;
+
+ 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 retval = { data: null, stream: null };
+ const { linkedBrowser } = this.session.target.tab;
+
+ if (transferMode === PDF_TRANSFER_MODES.stream) {
+ // If we are returning a stream, we write the PDF to disk so that we don't
+ // keep (potentially very large) PDFs in memory. We can then stream them
+ // to the client via the returned Stream.
+ //
+ // NOTE: This is a potentially premature optimization -- it might be fine
+ // to keep these PDFs in memory, but we don't have specifics on how CDP is
+ // used in the field so it is possible that leaving the PDFs in memory
+ // could cause a regression.
+ const path = await IOUtils.createUniqueFile(
+ PathUtils.tempDir,
+ "remote-agent.pdf"
+ );
+
+ printSettings.outputDestination =
+ Ci.nsIPrintSettings.kOutputDestinationFile;
+ printSettings.toFileName = path;
+
+ await linkedBrowser.browsingContext.print(printSettings);
+
+ retval.stream = lazy.streamRegistry.add(new lazy.Stream(path));
+ } else {
+ const binaryString = await lazy.print.printToBinaryString(
+ linkedBrowser.browsingContext,
+ printSettings
+ );
+
+ retval.data = btoa(binaryString);
+ }
+
+ 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..ab3987fc66
--- /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/SystemInfo.sys.mjs b/remote/cdp/domains/parent/SystemInfo.sys.mjs
new file mode 100644
index 0000000000..8b5e4a27c2
--- /dev/null
+++ b/remote/cdp/domains/parent/SystemInfo.sys.mjs
@@ -0,0 +1,48 @@
+/* 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 SystemInfo extends Domain {
+ async getProcessInfo() {
+ const procInfo = await ChromeUtils.requestProcInfo();
+
+ // Add child processes
+ const processInfo = procInfo.children.map(proc => ({
+ type: this.#getProcessType(proc.type),
+ id: proc.pid,
+ cpuTime: this.#getCpuTime(proc.cpuTime),
+ }));
+
+ // Add parent process
+ processInfo.unshift({
+ type: "browser",
+ id: procInfo.pid,
+ cpuTime: this.#getCpuTime(procInfo.cpuTime),
+ });
+
+ return processInfo;
+ }
+
+ #getProcessType(type) {
+ // Map internal types to CDP types if applicable
+ switch (type) {
+ case "gpu":
+ return "GPU";
+
+ case "web":
+ case "webIsolated":
+ case "privilegedabout":
+ return "renderer";
+
+ default:
+ return type;
+ }
+ }
+
+ #getCpuTime(cpuTime) {
+ // cpuTime is tracked internally as nanoseconds, CDP is in seconds
+ return cpuTime / 1000 / 1000 / 1000;
+ }
+}
diff --git a/remote/cdp/domains/parent/Target.sys.mjs b/remote/cdp/domains/parent/Target.sys.mjs
new file mode 100644
index 0000000000..7fc67c3226
--- /dev/null
+++ b/remote/cdp/domains/parent/Target.sys.mjs
@@ -0,0 +1,286 @@
+/* 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",
+
+ generateUUID: "chrome://remote/content/shared/UUID.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;
+
+// Default filter from CDP specification
+const defaultFilter = [
+ { type: "browser", exclude: true },
+ { type: "tab", exclude: true },
+ {},
+];
+
+export class Target extends Domain {
+ #browserContextIds;
+ #discoverTargetFilter;
+
+ constructor(session) {
+ super(session);
+
+ this.#browserContextIds = new Set();
+
+ this._onTargetCreated = this._onTargetCreated.bind(this);
+ this._onTargetDestroyed = this._onTargetDestroyed.bind(this);
+ }
+
+ getBrowserContexts() {
+ const browserContextIds =
+ lazy.ContextualIdentityService.getPublicUserContextIds().filter(id =>
+ this.#browserContextIds.has(id)
+ );
+
+ return { browserContextIds };
+ }
+
+ createBrowserContext() {
+ const identity = lazy.ContextualIdentityService.create(
+ "remote-agent-" + browserContextIds++
+ );
+
+ this.#browserContextIds.add(identity.userContextId);
+ return { browserContextId: identity.userContextId };
+ }
+
+ disposeBrowserContext(options = {}) {
+ const { browserContextId } = options;
+
+ lazy.ContextualIdentityService.remove(browserContextId);
+ lazy.ContextualIdentityService.closeContainerTabs(browserContextId);
+
+ this.#browserContextIds.delete(browserContextId);
+ }
+
+ getTargets(options = {}) {
+ const { filter = defaultFilter } = options;
+ const { targetList } = this.session.target;
+
+ this._validateTargetFilter(filter);
+
+ const targetInfos = [...targetList]
+ .filter(target => this._filterIncludesTarget(target, filter))
+ .map(target => this._getTargetInfo(target));
+
+ return {
+ targetInfos,
+ };
+ }
+
+ setDiscoverTargets(options = {}) {
+ const { discover, filter } = options;
+ const { targetList } = this.session.target;
+
+ if (typeof discover !== "boolean") {
+ throw new TypeError("discover: boolean value expected");
+ }
+
+ if (discover === false && filter !== undefined) {
+ throw new Error("filter: should not be present when discover is false");
+ }
+
+ // null filter should not be defaulted
+ const targetFilter = filter === undefined ? defaultFilter : filter;
+ this._validateTargetFilter(targetFilter);
+
+ // Store active filter for filtering in event listeners (targetCreated, targetDestroyed, targetInfoChanged)
+ this.#discoverTargetFilter = targetFilter;
+
+ if (discover) {
+ targetList.on("target-created", this._onTargetCreated);
+ targetList.on("target-destroyed", this._onTargetDestroyed);
+
+ for (const target of targetList) {
+ this._onTargetCreated("target-created", target);
+ }
+ } else {
+ targetList.off("target-created", this._onTargetCreated);
+ targetList.off("target-destroyed", this._onTargetDestroyed);
+ }
+ }
+
+ async createTarget(options = {}) {
+ const { browserContextId, url } = options;
+
+ if (typeof url !== "string") {
+ throw new TypeError("url: string value expected");
+ }
+
+ let validURL;
+ try {
+ validURL = Services.io.newURI(url);
+ } catch (e) {
+ // If we failed to parse given URL, use about:blank instead
+ validURL = Services.io.newURI("about:blank");
+ }
+
+ 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
+ );
+ }
+
+ target.browsingContext.loadURI(validURL, {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+
+ 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,
+ lazy.generateUUID()
+ );
+ 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) {
+ const attached = [...this.session.connection.sessions.values()].some(
+ session => session.target.id === target.id
+ );
+
+ return {
+ targetId: target.id,
+ type: target.type,
+ title: target.title,
+ url: target.url,
+ attached,
+ browserContextId: target.browserContextId,
+ };
+ }
+
+ _filterIncludesTarget(target, filter) {
+ for (const entry of filter) {
+ if ([undefined, target.type].includes(entry.type)) {
+ return !entry.exclude;
+ }
+ }
+
+ return false;
+ }
+
+ _validateTargetFilter(filter) {
+ if (!Array.isArray(filter)) {
+ throw new TypeError("filter: array value expected");
+ }
+
+ for (const entry of filter) {
+ if (entry === null || Array.isArray(entry) || typeof entry !== "object") {
+ throw new TypeError("filter: object values expected in array");
+ }
+
+ if (!["undefined", "string"].includes(typeof entry.type)) {
+ throw new TypeError("filter: type: string value expected");
+ }
+
+ if (!["undefined", "boolean"].includes(typeof entry.exclude)) {
+ throw new TypeError("filter: exclude: boolean value expected");
+ }
+ }
+ }
+
+ _onTargetCreated(eventName, target) {
+ if (!this._filterIncludesTarget(target, this.#discoverTargetFilter)) {
+ return;
+ }
+
+ const targetInfo = this._getTargetInfo(target);
+ this.emit("Target.targetCreated", { targetInfo });
+ }
+
+ _onTargetDestroyed(eventName, target) {
+ if (!this._filterIncludesTarget(target, this.#discoverTargetFilter)) {
+ return;
+ }
+
+ 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..466511c762
--- /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 });
+ }
+}