summaryrefslogtreecommitdiffstats
path: root/remote/domains/parent
diff options
context:
space:
mode:
Diffstat (limited to 'remote/domains/parent')
-rw-r--r--remote/domains/parent/Browser.jsm34
-rw-r--r--remote/domains/parent/Emulation.jsm176
-rw-r--r--remote/domains/parent/IO.jsm122
-rw-r--r--remote/domains/parent/Input.jsm172
-rw-r--r--remote/domains/parent/Network.jsm512
-rw-r--r--remote/domains/parent/Page.jsm747
-rw-r--r--remote/domains/parent/Security.jsm55
-rw-r--r--remote/domains/parent/Target.jsm199
-rw-r--r--remote/domains/parent/page/DialogHandler.jsm117
9 files changed, 2134 insertions, 0 deletions
diff --git a/remote/domains/parent/Browser.jsm b/remote/domains/parent/Browser.jsm
new file mode 100644
index 0000000000..9e496e52f6
--- /dev/null
+++ b/remote/domains/parent/Browser.jsm
@@ -0,0 +1,34 @@
+/* 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/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["Browser"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { Domain } = ChromeUtils.import(
+ "chrome://remote/content/domains/Domain.jsm"
+);
+
+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 {
+ protocolVersion: "1.3",
+ product: (isHeadless ? "Headless " : "") + "Firefox",
+ revision: "1",
+ userAgent,
+ jsVersion: "1.8.5",
+ };
+ }
+
+ close() {
+ Services.startup.quit(Ci.nsIAppStartup.eAttemptQuit);
+ }
+}
diff --git a/remote/domains/parent/Emulation.jsm b/remote/domains/parent/Emulation.jsm
new file mode 100644
index 0000000000..36172d3c44
--- /dev/null
+++ b/remote/domains/parent/Emulation.jsm
@@ -0,0 +1,176 @@
+/* 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/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["Emulation"];
+
+const { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+
+const { Domain } = ChromeUtils.import(
+ "chrome://remote/content/domains/Domain.jsm"
+);
+
+const MAX_WINDOW_SIZE = 10000000;
+
+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;
+
+ await this.executeInChild("_setDPPXOverride", 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 == 0) {
+ 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 = 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/domains/parent/IO.jsm b/remote/domains/parent/IO.jsm
new file mode 100644
index 0000000000..c6213cf862
--- /dev/null
+++ b/remote/domains/parent/IO.jsm
@@ -0,0 +1,122 @@
+/* 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/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["IO", "streamRegistry"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ OS: "resource://gre/modules/osfile.jsm",
+});
+
+const { Domain } = ChromeUtils.import(
+ "chrome://remote/content/domains/Domain.jsm"
+);
+const { StreamRegistry } = ChromeUtils.import(
+ "chrome://remote/content/StreamRegistry.jsm"
+);
+
+const DEFAULT_CHUNK_SIZE = 10 * 1024 * 1024;
+
+// Global singleton for managing open streams
+const streamRegistry = new StreamRegistry();
+
+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, 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/domains/parent/Input.jsm b/remote/domains/parent/Input.jsm
new file mode 100644
index 0000000000..85e8ef966c
--- /dev/null
+++ b/remote/domains/parent/Input.jsm
@@ -0,0 +1,172 @@
+/* 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/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["Input"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+const { Domain } = ChromeUtils.import(
+ "chrome://remote/content/domains/Domain.jsm"
+);
+
+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);
+ }
+
+ // Temporary workaround to handle certain native key bindings than cannot
+ // be synthesized with EventUtils: dispatch editor command directly
+ if (domType == "keydown") {
+ switch (Services.appinfo.OS) {
+ case "Linux":
+ if (modifiers == ctrl && key == "Backspace") {
+ await this.executeInChild(
+ "_doDocShellCommand",
+ "cmd_deleteWordBackward"
+ );
+ }
+ break;
+ case "Darwin":
+ if (modifiers == meta && key == "Backspace") {
+ await this.executeInChild(
+ "_doDocShellCommand",
+ "cmd_deleteToBeginningOfLine"
+ );
+ }
+ }
+ }
+
+ // TODO in case of workaround for native key bindings: wait for input event?
+ await this.executeInChild("_waitForContentEvent", eventId);
+ }
+
+ async dispatchMouseEvent({ type, button, x, y, modifiers, clickCount }) {
+ const { alt, ctrl, meta, shift } = Input.Modifier;
+
+ if (type == "mousePressed") {
+ type = "mousedown";
+ } else if (type == "mouseReleased") {
+ type = "mouseup";
+ } else if (type == "mouseMoved") {
+ type = "mousemove";
+ } else {
+ throw new Error(`Mouse type is not supported: ${type}`);
+ }
+
+ if (type === "mousedown" && button === "right") {
+ type = "contextmenu";
+ }
+ const buttonID = Input.Button[button] || Input.Button.left;
+ const { browser } = this.session.target;
+ const currentWindow = browser.ownerGlobal;
+ const EventUtils = this._getEventUtils(currentWindow);
+ EventUtils.synthesizeMouse(browser, x, y, {
+ type,
+ button: buttonID,
+ clickCount: clickCount || 1,
+ altKey: !!(modifiers & alt),
+ ctrlKey: !!(modifiers & ctrl),
+ metaKey: !!(modifiers & meta),
+ shiftKey: !!(modifiers & shift),
+ });
+ }
+
+ /**
+ * 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/domains/parent/Network.jsm b/remote/domains/parent/Network.jsm
new file mode 100644
index 0000000000..16850f397d
--- /dev/null
+++ b/remote/domains/parent/Network.jsm
@@ -0,0 +1,512 @@
+/* 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/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["Network"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
+
+const { Domain } = ChromeUtils.import(
+ "chrome://remote/content/domains/Domain.jsm"
+);
+
+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",
+};
+
+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 == 0) {
+ 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 = {}) {
+ // Bug 1605354 - Add support for options.urls
+ const urls = [this.session.target.url];
+
+ 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;
+ }
+
+ cookies.push(_buildCookie(cookie));
+ }
+ }
+
+ 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 == 0) {
+ 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 request = {
+ url: httpChannel.URI.spec,
+ urlFragment: undefined,
+ 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.spec,
+ 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 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/domains/parent/Page.jsm b/remote/domains/parent/Page.jsm
new file mode 100644
index 0000000000..c7b943f323
--- /dev/null
+++ b/remote/domains/parent/Page.jsm
@@ -0,0 +1,747 @@
+/* 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/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["Page"];
+
+var { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ SessionStore: "resource:///modules/sessionstore/SessionStore.jsm",
+});
+
+const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
+const { clearInterval, setInterval } = ChromeUtils.import(
+ "resource://gre/modules/Timer.jsm"
+);
+
+const { DialogHandler } = ChromeUtils.import(
+ "chrome://remote/content/domains/parent/page/DialogHandler.jsm"
+);
+const { Domain } = ChromeUtils.import(
+ "chrome://remote/content/domains/Domain.jsm"
+);
+const { UnsupportedError } = ChromeUtils.import(
+ "chrome://remote/content/Error.jsm"
+);
+const { streamRegistry } = ChromeUtils.import(
+ "chrome://remote/content/domains/parent/IO.jsm"
+);
+const { PollPromise } = ChromeUtils.import("chrome://remote/content/Sync.jsm");
+const { TabManager } = ChromeUtils.import(
+ "chrome://remote/content/TabManager.jsm"
+);
+const { WindowManager } = ChromeUtils.import(
+ "chrome://remote/content/WindowManager.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;
+
+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 UnsupportedError("frameId not supported");
+ }
+
+ const requestDone = new Promise(resolve => {
+ if (!["https", "http"].includes(validURL.scheme)) {
+ resolve({});
+ 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 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 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 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 WindowManager.focus(window);
+ 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,
+ });
+ }
+
+ 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}
+ * - {Boolean} 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.
+ * - {String} promptText: for "prompt" dialogs, used to fill the prompt
+ * input.
+ */
+ async handleJavaScriptDialog({ accept, promptText }) {
+ 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 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 = OS.Path.join(OS.Constants.Path.tmpDir, "remote-agent.pdf");
+ const { file, path: filePath } = await OS.File.openUnique(basePath);
+ await file.close();
+
+ const psService = Cc["@mozilla.org/gfx/printsettings-service;1"].getService(
+ Ci.nsIPrintSettingsService
+ );
+
+ const printSettings = psService.newPrintSettings;
+ printSettings.isInitializedFromPrinter = true;
+ printSettings.isInitializedFromPrefs = true;
+ printSettings.outputFormat = Ci.nsIPrintSettings.kOutputFormatPDF;
+ printSettings.printerName = "";
+ printSettings.printSilent = true;
+ printSettings.printToFile = true;
+ printSettings.showPrintProgress = false;
+ 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.print(linkedBrowser.outerWindowID, 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 = setInterval(async () => {
+ const fileInfo = await OS.File.stat(filePath);
+ if (lastSize > 0 && fileInfo.size == lastSize) {
+ clearInterval(timerId);
+ resolve();
+ }
+ lastSize = fileInfo.size;
+ }, DELAY_CHECK_FILE_COMPLETELY_WRITTEN);
+ });
+
+ const fp = await OS.File.open(filePath);
+
+ const retval = { data: null, stream: null };
+ if (transferMode == PDF_TRANSFER_MODES.stream) {
+ retval.stream = streamRegistry.add(fp);
+ } else {
+ // return all data as a base64 encoded string
+ let bytes;
+ try {
+ bytes = await fp.read();
+ } finally {
+ fp.close();
+ await 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 => {
+ 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);
+ }
+
+ 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/domains/parent/Security.jsm b/remote/domains/parent/Security.jsm
new file mode 100644
index 0000000000..5114dd1069
--- /dev/null
+++ b/remote/domains/parent/Security.jsm
@@ -0,0 +1,55 @@
+/* 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/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["Security"];
+
+const { Domain } = ChromeUtils.import(
+ "chrome://remote/content/domains/Domain.jsm"
+);
+
+const { Preferences } = ChromeUtils.import(
+ "resource://gre/modules/Preferences.jsm"
+);
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyServiceGetters(this, {
+ 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";
+
+class Security extends Domain {
+ destructor() {
+ this.setIgnoreCertificateErrors({ ignore: false });
+ }
+
+ setIgnoreCertificateErrors({ ignore }) {
+ if (ignore) {
+ // make it possible to register certificate overrides for domains
+ // that use HSTS or HPKP
+ Preferences.set(HSTS_PRELOAD_LIST_PREF, false);
+ Preferences.set(CERT_PINNING_ENFORCEMENT_PREF, 0);
+ } else {
+ Preferences.reset(HSTS_PRELOAD_LIST_PREF);
+ Preferences.reset(CERT_PINNING_ENFORCEMENT_PREF);
+
+ // clear collected HSTS and HPKP state
+ sss.clearAll();
+ sss.clearPreloads();
+ }
+
+ certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyData(
+ ignore
+ );
+ }
+}
diff --git a/remote/domains/parent/Target.jsm b/remote/domains/parent/Target.jsm
new file mode 100644
index 0000000000..0d572cd517
--- /dev/null
+++ b/remote/domains/parent/Target.jsm
@@ -0,0 +1,199 @@
+/* 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/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["Target"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "UUIDGen",
+ "@mozilla.org/uuid-generator;1",
+ "nsIUUIDGenerator"
+);
+
+const { ContextualIdentityService } = ChromeUtils.import(
+ "resource://gre/modules/ContextualIdentityService.jsm"
+);
+const { Domain } = ChromeUtils.import(
+ "chrome://remote/content/domains/Domain.jsm"
+);
+const { MainProcessTarget } = ChromeUtils.import(
+ "chrome://remote/content/targets/MainProcessTarget.jsm"
+);
+const { TabManager } = ChromeUtils.import(
+ "chrome://remote/content/TabManager.jsm"
+);
+const { TabSession } = ChromeUtils.import(
+ "chrome://remote/content/sessions/TabSession.jsm"
+);
+const { WindowManager } = ChromeUtils.import(
+ "chrome://remote/content/WindowManager.jsm"
+);
+
+let browserContextIds = 1;
+
+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 = ContextualIdentityService.create(
+ "remote-agent-" + browserContextIds++
+ );
+ return { browserContextId: identity.userContextId };
+ }
+
+ disposeBrowserContext({ browserContextId }) {
+ ContextualIdentityService.remove(browserContextId);
+ ContextualIdentityService.closeContainerTabs(browserContextId);
+ }
+
+ getTargets() {
+ const { targets } = this.session.target;
+
+ const targetInfos = [];
+ for (const target of targets) {
+ if (target instanceof MainProcessTarget) {
+ continue;
+ }
+
+ targetInfos.push(this._getTargetInfo(target));
+ }
+
+ return { targetInfos };
+ }
+
+ setDiscoverTargets({ discover }) {
+ const { targets } = this.session.target;
+ if (discover) {
+ targets.on("target-created", this._onTargetCreated);
+ targets.on("target-destroyed", this._onTargetDestroyed);
+ } else {
+ targets.off("target-created", this._onTargetCreated);
+ targets.off("target-destroyed", this._onTargetDestroyed);
+ }
+ for (const target of targets) {
+ this._onTargetCreated("target-created", target);
+ }
+ }
+
+ async createTarget({ browserContextId }) {
+ const { targets } = this.session.target;
+ const onTarget = targets.once("target-created");
+ const tab = TabManager.addTab({ userContextId: browserContextId });
+ const target = await onTarget;
+ if (tab.linkedBrowser != target.browser) {
+ throw new Error(
+ "Unexpected tab opened: " + tab.linkedBrowser.currentURI.spec
+ );
+ }
+ return { targetId: target.id };
+ }
+
+ closeTarget({ targetId }) {
+ const { targets } = this.session.target;
+ const target = targets.getById(targetId);
+
+ if (!target) {
+ throw new Error(`Unable to find target with id '${targetId}'`);
+ }
+
+ TabManager.removeTab(target.tab);
+ }
+
+ async activateTarget({ targetId }) {
+ const { targets, window } = this.session.target;
+ const target = targets.getById(targetId);
+
+ if (!target) {
+ throw new Error(`Unable to find target with id '${targetId}'`);
+ }
+
+ // Focus the window, and select the corresponding tab
+ await WindowManager.focus(window);
+ TabManager.selectTab(target.tab);
+ }
+
+ attachToTarget({ targetId }) {
+ const { targets } = this.session.target;
+ const target = targets.getById(targetId);
+
+ if (!target) {
+ throw new Error(`Unable to find target with id '${targetId}'`);
+ }
+
+ const tabSession = new TabSession(
+ this.session.connection,
+ target,
+ UUIDGen.generateUUID()
+ .toString()
+ .slice(1, -1)
+ );
+ this.session.connection.registerSession(tabSession);
+
+ this._emitAttachedToTarget(target, tabSession);
+
+ return {
+ sessionId: tabSession.id,
+ };
+ }
+
+ setAutoAttach() {}
+
+ sendMessageToTarget({ sessionId, message }) {
+ 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/domains/parent/page/DialogHandler.jsm b/remote/domains/parent/page/DialogHandler.jsm
new file mode 100644
index 0000000000..26027a90db
--- /dev/null
+++ b/remote/domains/parent/page/DialogHandler.jsm
@@ -0,0 +1,117 @@
+/* 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/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["DialogHandler"];
+
+const { EventEmitter } = ChromeUtils.import(
+ "resource://gre/modules/EventEmitter.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+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
+ */
+class DialogHandler {
+ constructor(browser) {
+ EventEmitter.decorate(this);
+ this._dialog = null;
+ this._browser = browser;
+
+ this._onTabDialogLoaded = this._onTabDialogLoaded.bind(this);
+
+ Services.obs.addObserver(this._onTabDialogLoaded, "tabmodal-dialog-loaded");
+ }
+
+ destructor() {
+ this._dialog = null;
+ this._pageTarget = null;
+
+ 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.onButtonClick(0);
+ } else {
+ this._dialog.onButtonClick(1);
+ }
+
+ 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);
+ }
+ }
+
+ _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 });
+ }
+}