diff options
Diffstat (limited to '')
-rw-r--r-- | remote/cdp/domains/parent/Browser.sys.mjs | 40 | ||||
-rw-r--r-- | remote/cdp/domains/parent/Emulation.sys.mjs | 177 | ||||
-rw-r--r-- | remote/cdp/domains/parent/Fetch.sys.mjs | 30 | ||||
-rw-r--r-- | remote/cdp/domains/parent/IO.sys.mjs | 114 | ||||
-rw-r--r-- | remote/cdp/domains/parent/Input.sys.mjs | 168 | ||||
-rw-r--r-- | remote/cdp/domains/parent/Network.sys.mjs | 538 | ||||
-rw-r--r-- | remote/cdp/domains/parent/Page.sys.mjs | 775 | ||||
-rw-r--r-- | remote/cdp/domains/parent/Security.sys.mjs | 58 | ||||
-rw-r--r-- | remote/cdp/domains/parent/Target.sys.mjs | 193 | ||||
-rw-r--r-- | remote/cdp/domains/parent/page/DialogHandler.sys.mjs | 140 |
10 files changed, 2233 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..f5ce827f0e --- /dev/null +++ b/remote/cdp/domains/parent/Emulation.sys.mjs @@ -0,0 +1,177 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyModuleGetters(lazy, { + NetUtil: "resource://gre/modules/NetUtil.jsm", +}); + +const MAX_WINDOW_SIZE = 10000000; + +export class Emulation extends Domain { + destructor() { + this.setUserAgentOverride({ userAgent: "", platform: "" }); + + super.destructor(); + } + + /** + * Overrides the values of device screen dimensions. + * + * Values as modified are: + * - window.screen.width + * - window.screen.height + * - window.innerWidth + * - window.innerHeight + * - "device-width"/"device-height"-related CSS media query results + * + * @param {Object} options + * @param {number} options.width + * Overriding width value in pixels. 0 disables the override. + * @param {number} options.height + * Overriding height value in pixels. 0 disables the override. + * @param {number} options.deviceScaleFactor + * Overriding device scale factor value. 0 disables the override. + * @param {number} options.mobile [not supported] + * Whether to emulate a mobile device. This includes viewport meta tag, + * overlay scrollbars, text autosizing and more. + * @param {number} options.screenOrientation + * Screen orientation override [not supported] + */ + async setDeviceMetricsOverride(options = {}) { + const { width, height, deviceScaleFactor } = options; + + if ( + width < 0 || + width > MAX_WINDOW_SIZE || + height < 0 || + height > MAX_WINDOW_SIZE + ) { + throw new TypeError( + `Width and height values must be positive, not greater than ${MAX_WINDOW_SIZE}` + ); + } + + if (typeof deviceScaleFactor != "number") { + throw new TypeError("deviceScaleFactor: number expected"); + } + + if (deviceScaleFactor < 0) { + throw new TypeError("deviceScaleFactor: must be positive"); + } + + const { tab } = this.session.target; + const { linkedBrowser: browser } = tab; + + const { browsingContext } = this.session.target; + browsingContext.overrideDPPX = deviceScaleFactor; + + // With a value of 0 the current size is used + const { layoutViewport } = await this.session.execute( + this.session.id, + "Page", + "getLayoutMetrics" + ); + + const targetWidth = width > 0 ? width : layoutViewport.clientWidth; + const targetHeight = height > 0 ? height : layoutViewport.clientHeight; + + browser.style.setProperty("min-width", targetWidth + "px"); + browser.style.setProperty("max-width", targetWidth + "px"); + browser.style.setProperty("min-height", targetHeight + "px"); + browser.style.setProperty("max-height", targetHeight + "px"); + + // Wait until the viewport has been resized + await this.executeInChild("_awaitViewportDimensions", { + width: targetWidth, + height: targetHeight, + }); + } + + /** + * Enables touch on platforms which do not support them. + * + * @param {Object} options + * @param {boolean} options.enabled + * Whether the touch event emulation should be enabled. + * @param {number=} options.maxTouchPoints [not yet supported] + * Maximum touch points supported. Defaults to one. + */ + async setTouchEmulationEnabled(options = {}) { + const { enabled } = options; + + if (typeof enabled != "boolean") { + throw new TypeError( + "Invalid parameters (enabled: boolean value expected)" + ); + } + + const { browsingContext } = this.session.target; + if (enabled) { + browsingContext.touchEventsOverride = "enabled"; + } else { + browsingContext.touchEventsOverride = "none"; + } + } + + /** + * Allows overriding user agent with the given string. + * + * @param {Object} options + * @param {string} options.userAgent + * User agent to use. + * @param {string=} options.acceptLanguage [not yet supported] + * Browser langugage to emulate. + * @param {string=} options.platform + * The platform navigator.platform should return. + */ + async setUserAgentOverride(options = {}) { + const { userAgent, platform } = options; + + if (typeof userAgent != "string") { + throw new TypeError( + "Invalid parameters (userAgent: string value expected)" + ); + } + + if (!["undefined", "string"].includes(typeof platform)) { + throw new TypeError("platform: string value expected"); + } + + const { browsingContext } = this.session.target; + + if (!userAgent.length) { + browsingContext.customUserAgent = null; + } else if (this._isValidHTTPRequestHeaderValue(userAgent)) { + browsingContext.customUserAgent = userAgent; + } else { + throw new TypeError("Invalid characters found in userAgent"); + } + + if (platform?.length > 0) { + browsingContext.customPlatform = platform; + } else { + browsingContext.customPlatform = null; + } + } + + _isValidHTTPRequestHeaderValue(value) { + try { + const channel = lazy.NetUtil.newChannel({ + uri: "http://localhost", + loadUsingSystemPrincipal: true, + }); + channel.QueryInterface(Ci.nsIHttpChannel); + channel.setRequestHeader("X-check", value, false); + return true; + } catch (e) { + return false; + } + } +} diff --git a/remote/cdp/domains/parent/Fetch.sys.mjs b/remote/cdp/domains/parent/Fetch.sys.mjs new file mode 100644 index 0000000000..39e6965ccd --- /dev/null +++ b/remote/cdp/domains/parent/Fetch.sys.mjs @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +// Note: For now this domain has only been added so that clients using CDP +// (like Selenium) don't break when trying to disable Fetch events. + +export class Fetch extends Domain { + constructor(session) { + super(session); + + this.enabled = false; + } + + destructor() { + this.disable(); + + super.destructor(); + } + + disable() { + if (!this.enabled) { + return; + } + + this.enabled = false; + } +} diff --git a/remote/cdp/domains/parent/IO.sys.mjs b/remote/cdp/domains/parent/IO.sys.mjs new file mode 100644 index 0000000000..f0877d017f --- /dev/null +++ b/remote/cdp/domains/parent/IO.sys.mjs @@ -0,0 +1,114 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; +import { StreamRegistry } from "chrome://remote/content/cdp/StreamRegistry.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyModuleGetters(lazy, { + OS: "resource://gre/modules/osfile.jsm", +}); + +const DEFAULT_CHUNK_SIZE = 10 * 1024 * 1024; + +// Global singleton for managing open streams +export const streamRegistry = new StreamRegistry(); + +export class IO extends Domain { + // commands + + /** + * Close the stream, discard any temporary backing storage. + * + * @param {Object} options + * @param {string} options.handle + * Handle of the stream to close. + */ + async close(options = {}) { + const { handle } = options; + + if (typeof handle != "string") { + throw new TypeError(`handle: string value expected`); + } + + await streamRegistry.remove(handle); + } + + /** + * Read a chunk of the stream. + * + * @param {Object} options + * @param {string} options.handle + * Handle of the stream to read. + * @param {number=} options.offset + * Seek to the specified offset before reading. If not specificed, + * proceed with offset following the last read. + * Some types of streams may only support sequential reads. + * @param {number=} options.size + * Maximum number of bytes to read (left upon the agent + * discretion if not specified). + * + * @return {string, boolean, boolean} + * Data that were read, including flags for base64-encoded, and end-of-file reached. + */ + async read(options = {}) { + const { handle, offset, size } = options; + + if (typeof handle != "string") { + throw new TypeError(`handle: string value expected`); + } + + const stream = streamRegistry.get(handle); + const fileInfo = await stream.stat(); + + if (typeof offset != "undefined") { + if (typeof offset != "number") { + throw new TypeError(`offset: integer value expected`); + } + + // To keep compatibility with Chrome clip invalid offsets + const seekTo = Math.max(0, Math.min(offset, fileInfo.size)); + await stream.setPosition(seekTo, lazy.OS.File.POS_START); + } + + const curPos = await stream.getPosition(); + const remainingBytes = fileInfo.size - curPos; + + let chunkSize; + if (typeof size != "undefined") { + if (typeof size != "number") { + throw new TypeError(`size: integer value expected`); + } + + // Chromium currently crashes for negative sizes (https://bit.ly/2P6h0Fv), + // but might behave similar to the offset and clip invalid values + chunkSize = Math.max(0, Math.min(size, remainingBytes)); + } else { + chunkSize = Math.min(DEFAULT_CHUNK_SIZE, remainingBytes); + } + + const bytes = await stream.read(chunkSize); + // Each UCS2 character has an upper byte of 0 and a lower byte matching + // the binary data. Using a loop here prevents us from hitting the browser's + // internal `arguments.length` limit. + const ARGS_MAX = 262144; + const stringData = []; + for (let i = 0; i < bytes.length; i += ARGS_MAX) { + let argsChunk = Math.min(bytes.length, i + ARGS_MAX); + stringData.push( + String.fromCharCode.apply(null, bytes.slice(i, argsChunk)) + ); + } + const data = btoa(stringData.join("")); + + return { + data, + base64Encoded: true, + eof: remainingBytes - bytes.length == 0, + }; + } +} diff --git a/remote/cdp/domains/parent/Input.sys.mjs b/remote/cdp/domains/parent/Input.sys.mjs new file mode 100644 index 0000000000..b3b7fd253b --- /dev/null +++ b/remote/cdp/domains/parent/Input.sys.mjs @@ -0,0 +1,168 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +export class Input extends Domain { + // commands + + /** + * Simulate key events. + * + * @param {Object} options + * - autoRepeat (not supported) + * - code (not supported) + * - key + * - isKeypad (not supported) + * - location (not supported) + * - modifiers + * - text (not supported) + * - type + * - unmodifiedText (not supported) + * - windowsVirtualKeyCode + * - nativeVirtualKeyCode (not supported) + * - keyIdentifier (not supported) + * - isSystemKey (not supported) + */ + async dispatchKeyEvent(options = {}) { + // missing code, text, unmodifiedText, autorepeat, location, iskeypad + const { key, modifiers, type, windowsVirtualKeyCode } = options; + const { alt, ctrl, meta, shift } = Input.Modifier; + + let domType; + if (type == "keyDown" || type == "rawKeyDown") { + // 'rawKeyDown' is passed as type by puppeteer for all non-text keydown events: + // See https://github.com/GoogleChrome/puppeteer/blob/2d99d85976dcb28cc6e3bad4b6a00cd61a67a2cf/lib/Input.js#L52 + // For now we simply map rawKeyDown to keydown. + domType = "keydown"; + } else if (type == "keyUp" || type == "char") { + // 'char' is fired as a single key event. Behind the scenes it will trigger keydown, + // keypress and keyup. `domType` will only be used as the event to wait for. + domType = "keyup"; + } else { + throw new Error(`Unknown key event type ${type}`); + } + + const { browser } = this.session.target; + const browserWindow = browser.ownerGlobal; + + const EventUtils = this._getEventUtils(browserWindow); + const eventId = await this.executeInChild( + "_addContentEventListener", + domType + ); + + if (type == "char") { + // type == "char" is used when doing `await page.keyboard.type( 'I’m a list' );` + // the ’ character will be calling dispatchKeyEvent only once with type=char. + EventUtils.synthesizeKey(key, {}, browserWindow); + } else { + // Non printable keys should be prefixed with `KEY_` + const eventUtilsKey = key.length == 1 ? key : "KEY_" + key; + const eventInfo = { + keyCode: windowsVirtualKeyCode, + type: domType, + altKey: !!(modifiers & alt), + ctrlKey: !!(modifiers & ctrl), + metaKey: !!(modifiers & meta), + shiftKey: !!(modifiers & shift), + }; + EventUtils.synthesizeKey(eventUtilsKey, eventInfo, browserWindow); + } + + await this.executeInChild("_waitForContentEvent", eventId); + } + + /** + * Simulate mouse events. + * + * @param {Object} options + * @param {string} options.type + * @param {number} options.x + * @param {number} options.y + * @param {number} options.modifiers + * @param {number} options.timestamp [Not Supported] + * @param {string} options.button + * @param {number} options.buttons [Not Supported] + * @param {string} options.clickCount + * @param {number} options.deltaX [Not Supported] + * @param {number} options.deltaY [Not Supported] + * @param {string} options.pointerType [Not Supported] + */ + async dispatchMouseEvent(options = {}) { + const { button, clickCount, modifiers, type, x, y } = options; + const { alt, ctrl, meta, shift } = Input.Modifier; + + let domType; + if (type === "mousePressed") { + domType = "mousedown"; + } else if (type === "mouseReleased") { + domType = "mouseup"; + } else if (type === "mouseMoved") { + domType = "mousemove"; + } else { + throw new Error(`Mouse type is not supported: ${type}`); + } + + if (domType === "mousedown" && button === "right") { + domType = "contextmenu"; + } + + const buttonID = Input.Button[button] || Input.Button.left; + const { browser } = this.session.target; + const currentWindow = browser.ownerGlobal; + + const EventUtils = this._getEventUtils(currentWindow); + const eventId = await this.executeInChild( + "_addContentEventListener", + domType + ); + + EventUtils.synthesizeMouse(browser, x, y, { + type: domType, + button: buttonID, + clickCount: clickCount || 1, + altKey: !!(modifiers & alt), + ctrlKey: !!(modifiers & ctrl), + metaKey: !!(modifiers & meta), + shiftKey: !!(modifiers & shift), + }); + + await this.executeInChild("_waitForContentEvent", eventId); + } + + /** + * Memoized EventUtils getter. + */ + _getEventUtils(win) { + if (!this._eventUtils) { + this._eventUtils = { + window: win, + parent: win, + _EU_Ci: Ci, + _EU_Cc: Cc, + }; + Services.scriptloader.loadSubScript( + "chrome://remote/content/external/EventUtils.js", + this._eventUtils + ); + } + return this._eventUtils; + } +} + +Input.Button = { + left: 0, + middle: 1, + right: 2, + back: 3, + forward: 4, +}; + +Input.Modifier = { + alt: 1, + ctrl: 2, + meta: 4, + shift: 8, +}; diff --git a/remote/cdp/domains/parent/Network.sys.mjs b/remote/cdp/domains/parent/Network.sys.mjs new file mode 100644 index 0000000000..bccebe7aed --- /dev/null +++ b/remote/cdp/domains/parent/Network.sys.mjs @@ -0,0 +1,538 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +const MAX_COOKIE_EXPIRY = Number.MAX_SAFE_INTEGER; + +const LOAD_CAUSE_STRINGS = { + [Ci.nsIContentPolicy.TYPE_INVALID]: "Invalid", + [Ci.nsIContentPolicy.TYPE_OTHER]: "Other", + [Ci.nsIContentPolicy.TYPE_SCRIPT]: "Script", + [Ci.nsIContentPolicy.TYPE_IMAGE]: "Img", + [Ci.nsIContentPolicy.TYPE_STYLESHEET]: "Stylesheet", + [Ci.nsIContentPolicy.TYPE_OBJECT]: "Object", + [Ci.nsIContentPolicy.TYPE_DOCUMENT]: "Document", + [Ci.nsIContentPolicy.TYPE_SUBDOCUMENT]: "Subdocument", + [Ci.nsIContentPolicy.TYPE_PING]: "Ping", + [Ci.nsIContentPolicy.TYPE_XMLHTTPREQUEST]: "Xhr", + [Ci.nsIContentPolicy.TYPE_OBJECT_SUBREQUEST]: "ObjectSubdoc", + [Ci.nsIContentPolicy.TYPE_DTD]: "Dtd", + [Ci.nsIContentPolicy.TYPE_FONT]: "Font", + [Ci.nsIContentPolicy.TYPE_MEDIA]: "Media", + [Ci.nsIContentPolicy.TYPE_WEBSOCKET]: "Websocket", + [Ci.nsIContentPolicy.TYPE_CSP_REPORT]: "Csp", + [Ci.nsIContentPolicy.TYPE_XSLT]: "Xslt", + [Ci.nsIContentPolicy.TYPE_BEACON]: "Beacon", + [Ci.nsIContentPolicy.TYPE_FETCH]: "Fetch", + [Ci.nsIContentPolicy.TYPE_IMAGESET]: "Imageset", + [Ci.nsIContentPolicy.TYPE_WEB_MANIFEST]: "WebManifest", + [Ci.nsIContentPolicy.TYPE_WEB_IDENTITY]: "Webidentity", +}; + +export class Network extends Domain { + constructor(session) { + super(session); + this.enabled = false; + + this._onRequest = this._onRequest.bind(this); + this._onResponse = this._onResponse.bind(this); + } + + destructor() { + this.disable(); + + super.destructor(); + } + + enable() { + if (this.enabled) { + return; + } + this.enabled = true; + this.session.networkObserver.startTrackingBrowserNetwork( + this.session.target.browser + ); + this.session.networkObserver.on("request", this._onRequest); + this.session.networkObserver.on("response", this._onResponse); + } + + disable() { + if (!this.enabled) { + return; + } + this.session.networkObserver.stopTrackingBrowserNetwork( + this.session.target.browser + ); + this.session.networkObserver.off("request", this._onRequest); + this.session.networkObserver.off("response", this._onResponse); + this.enabled = false; + } + + /** + * Deletes browser cookies with matching name and url or domain/path pair. + * + * @param {Object} options + * @param {string} name + * Name of the cookies to remove. + * @param {string=} url + * If specified, deletes all the cookies with the given name + * where domain and path match provided URL. + * @param {string=} domain + * If specified, deletes only cookies with the exact domain. + * @param {string=} path + * If specified, deletes only cookies with the exact path. + */ + async deleteCookies(options = {}) { + const { domain, name, path = "/", url } = options; + + if (typeof name != "string") { + throw new TypeError("name: string value expected"); + } + + if (!url && !domain) { + throw new TypeError( + "At least one of the url and domain needs to be specified" + ); + } + + // Retrieve host. Check domain first because it has precedence. + let hostname = domain || ""; + if (!hostname.length) { + const cookieURL = new URL(url); + if (!["http:", "https:"].includes(cookieURL.protocol)) { + throw new TypeError("An http or https url must be specified"); + } + hostname = cookieURL.hostname; + } + + const cookiesFound = Services.cookies.getCookiesWithOriginAttributes( + JSON.stringify({}), + hostname + ); + + for (const cookie of cookiesFound) { + if (cookie.name == name && cookie.path.startsWith(path)) { + Services.cookies.remove( + cookie.host, + cookie.name, + cookie.path, + cookie.originAttributes + ); + } + } + } + + /** + * Activates emulation of network conditions. + * + * @param {Object} options + * @param {boolean} offline + * True to emulate internet disconnection. + */ + emulateNetworkConditions(options = {}) { + const { offline } = options; + + if (typeof offline != "boolean") { + throw new TypeError("offline: boolean value expected"); + } + + Services.io.offline = offline; + } + + /** + * Returns all browser cookies. + * + * Depending on the backend support, will return detailed cookie information in the cookies field. + * + * @param {Object} options + * + * @return {Array<Cookie>} + * Array of cookie objects. + */ + async getAllCookies(options = {}) { + const cookies = []; + for (const cookie of Services.cookies.cookies) { + cookies.push(_buildCookie(cookie)); + } + + return { cookies }; + } + + /** + * Returns all browser cookies for the current URL. + * + * @param {Object} options + * @param {Array<string>=} urls + * The list of URLs for which applicable cookies will be fetched. + * Defaults to the currently open URL. + * + * @return {Array<Cookie>} + * Array of cookie objects. + */ + async getCookies(options = {}) { + const { urls = this._getDefaultUrls() } = options; + + if (!Array.isArray(urls)) { + throw new TypeError("urls: array expected"); + } + + for (const [index, url] of urls.entries()) { + if (typeof url !== "string") { + throw new TypeError(`urls: string value expected at index ${index}`); + } + } + + const cookies = []; + for (let url of urls) { + url = new URL(url); + + const secureProtocol = ["https:", "wss:"].includes(url.protocol); + + const cookiesFound = Services.cookies.getCookiesWithOriginAttributes( + JSON.stringify({}), + url.hostname + ); + + for (const cookie of cookiesFound) { + // Ignore secure cookies for non-secure protocols + if (cookie.isSecure && !secureProtocol) { + continue; + } + + // Ignore cookies which do not match the given path + if (!url.pathname.startsWith(cookie.path)) { + continue; + } + + const builtCookie = _buildCookie(cookie); + const duplicateCookie = cookies.some(value => { + return ( + value.name === builtCookie.name && + value.path === builtCookie.path && + value.domain === builtCookie.domain + ); + }); + + if (duplicateCookie) { + continue; + } + + cookies.push(builtCookie); + } + } + + return { cookies }; + } + + /** + * Sets a cookie with the given cookie data. + * + * Note that it may overwrite equivalent cookies if they exist. + * + * @param {Object} cookie + * @param {string} name + * Cookie name. + * @param {string} value + * Cookie value. + * @param {string=} domain + * Cookie domain. + * @param {number=} expires + * Cookie expiration date, session cookie if not set. + * @param {boolean=} httpOnly + * True if cookie is http-only. + * @param {string=} path + * Cookie path. + * @param {string=} sameSite + * Cookie SameSite type. + * @param {boolean=} secure + * True if cookie is secure. + * @param {string=} url + * The request-URI to associate with the setting of the cookie. + * This value can affect the default domain and path values of the + * created cookie. + * + * @return {boolean} + * True if successfully set cookie. + */ + setCookie(cookie) { + if (typeof cookie.name != "string") { + throw new TypeError("name: string value expected"); + } + + if (typeof cookie.value != "string") { + throw new TypeError("value: string value expected"); + } + + if ( + typeof cookie.url == "undefined" && + typeof cookie.domain == "undefined" + ) { + throw new TypeError( + "At least one of the url and domain needs to be specified" + ); + } + + // Retrieve host. Check domain first because it has precedence. + let hostname = cookie.domain || ""; + let cookieURL; + let schemeType = Ci.nsICookie.SCHEME_UNSET; + if (!hostname.length) { + try { + cookieURL = new URL(cookie.url); + } catch (e) { + return { success: false }; + } + + if (!["http:", "https:"].includes(cookieURL.protocol)) { + throw new TypeError(`Invalid protocol ${cookieURL.protocol}`); + } + + if (cookieURL.protocol == "https:") { + cookie.secure = true; + schemeType = Ci.nsICookie.SCHEME_HTTPS; + } else { + schemeType = Ci.nsICookie.SCHEME_HTTP; + } + + hostname = cookieURL.hostname; + } + + if (typeof cookie.path == "undefined") { + cookie.path = "/"; + } + + let isSession = false; + if (typeof cookie.expires == "undefined") { + isSession = true; + cookie.expires = MAX_COOKIE_EXPIRY; + } + + const sameSiteMap = new Map([ + ["None", Ci.nsICookie.SAMESITE_NONE], + ["Lax", Ci.nsICookie.SAMESITE_LAX], + ["Strict", Ci.nsICookie.SAMESITE_STRICT], + ]); + + let success = true; + try { + Services.cookies.add( + hostname, + cookie.path, + cookie.name, + cookie.value, + cookie.secure, + cookie.httpOnly || false, + isSession, + cookie.expires, + {} /* originAttributes */, + sameSiteMap.get(cookie.sameSite), + schemeType + ); + } catch (e) { + success = false; + } + + return { success }; + } + + /** + * Sets given cookies. + * + * @param {Object} options + * @param {Array.<Cookie>} cookies + * Cookies to be set. + */ + setCookies(options = {}) { + const { cookies } = options; + + if (!Array.isArray(cookies)) { + throw new TypeError("Invalid parameters (cookies: array expected)"); + } + + cookies.forEach(cookie => { + const { success } = this.setCookie(cookie); + if (!success) { + throw new Error("Invalid cookie fields"); + } + }); + } + + /** + * Toggles ignoring cache for each request. If true, cache will not be used. + * + * @param {Object} options + * @param {boolean} options.cacheDisabled + * Cache disabled state. + */ + async setCacheDisabled(options = {}) { + const { cacheDisabled = false } = options; + + const { INHIBIT_CACHING, LOAD_BYPASS_CACHE, LOAD_NORMAL } = Ci.nsIRequest; + + let loadFlags = LOAD_NORMAL; + if (cacheDisabled) { + loadFlags = LOAD_BYPASS_CACHE | INHIBIT_CACHING; + } + + await this.executeInChild("_updateLoadFlags", loadFlags); + } + + /** + * Allows overriding user agent with the given string. + * + * Redirected to Emulation.setUserAgentOverride. + */ + setUserAgentOverride(options = {}) { + const { id } = this.session; + this.session.execute(id, "Emulation", "setUserAgentOverride", options); + } + + _onRequest(eventName, httpChannel, data) { + const wrappedChannel = ChannelWrapper.get(httpChannel); + const urlFragment = httpChannel.URI.hasRef + ? "#" + httpChannel.URI.ref + : undefined; + + const request = { + url: httpChannel.URI.specIgnoringRef, + urlFragment, + method: httpChannel.requestMethod, + headers: headersAsObject(data.headers), + postData: undefined, + hasPostData: false, + mixedContentType: undefined, + initialPriority: undefined, + referrerPolicy: undefined, + isLinkPreload: false, + }; + this.emit("Network.requestWillBeSent", { + requestId: data.requestId, + loaderId: data.loaderId, + documentURL: + wrappedChannel.documentURL || httpChannel.URI.specIgnoringRef, + request, + timestamp: Date.now() / 1000, + wallTime: undefined, + initiator: undefined, + redirectResponse: undefined, + type: LOAD_CAUSE_STRINGS[data.cause] || "unknown", + frameId: data.frameId.toString(), + hasUserGesture: undefined, + }); + } + + _onResponse(eventName, httpChannel, data) { + const wrappedChannel = ChannelWrapper.get(httpChannel); + const headers = headersAsObject(data.headers); + + this.emit("Network.responseReceived", { + requestId: data.requestId, + loaderId: data.loaderId, + timestamp: Date.now() / 1000, + type: LOAD_CAUSE_STRINGS[data.cause] || "unknown", + response: { + url: httpChannel.URI.spec, + status: data.status, + statusText: data.statusText, + headers, + mimeType: wrappedChannel.contentType, + requestHeaders: headersAsObject(data.requestHeaders), + connectionReused: undefined, + connectionId: undefined, + remoteIPAddress: data.remoteIPAddress, + remotePort: data.remotePort, + fromDiskCache: data.fromCache, + encodedDataLength: undefined, + protocol: httpChannel.protocolVersion, + securityDetails: data.securityDetails, + // unknown, neutral, insecure, secure, info, insecure-broken + securityState: "unknown", + }, + frameId: data.frameId.toString(), + }); + } + + /** + * Creates an array of all Urls in the page context + * + * @param {Array<string>=} urls + */ + _getDefaultUrls() { + const urls = this.session.target.browsingContext + .getAllBrowsingContextsInSubtree() + .map(context => context.currentURI.spec); + + return urls; + } +} + +/** + * Creates a CDP Network.Cookie from our internal cookie values + * + * @param {nsICookie} cookie + * + * @returns {Network.Cookie} + * A CDP Cookie + */ +function _buildCookie(cookie) { + const data = { + name: cookie.name, + value: cookie.value, + domain: cookie.host, + path: cookie.path, + expires: cookie.isSession ? -1 : cookie.expiry, + // The size is the combined length of both the cookie name and value + size: cookie.name.length + cookie.value.length, + httpOnly: cookie.isHttpOnly, + secure: cookie.isSecure, + session: cookie.isSession, + }; + + if (cookie.sameSite) { + const sameSiteMap = new Map([ + [Ci.nsICookie.SAMESITE_LAX, "Lax"], + [Ci.nsICookie.SAMESITE_STRICT, "Strict"], + ]); + + data.sameSite = sameSiteMap.get(cookie.sameSite); + } + + return data; +} + +/** + * Given a array of possibly repeating header names, merge the values for + * duplicate headers into a comma-separated list, or in some cases a + * newline-separated list. + * + * e.g. { "Cache-Control": "no-cache,no-store" } + * + * Based on + * https://hg.mozilla.org/mozilla-central/file/56c09d42f411246e407fe30418c27e67a6a44d29/netwerk/protocol/http/nsHttpHeaderArray.h + * + * @param {Array} headers + * Array of {name, value} + * @returns {Object} + * Object where each key is a header name. + */ +function headersAsObject(headers) { + const rv = {}; + headers.forEach(({ name, value }) => { + name = name.toLowerCase(); + if (rv[name]) { + const separator = [ + "set-cookie", + "www-authenticate", + "proxy-authenticate", + ].includes(name) + ? "\n" + : ","; + rv[name] += `${separator}${value}`; + } else { + rv[name] = value; + } + }); + return rv; +} diff --git a/remote/cdp/domains/parent/Page.sys.mjs b/remote/cdp/domains/parent/Page.sys.mjs new file mode 100644 index 0000000000..fd2a747721 --- /dev/null +++ b/remote/cdp/domains/parent/Page.sys.mjs @@ -0,0 +1,775 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + clearInterval: "resource://gre/modules/Timer.sys.mjs", + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", + setInterval: "resource://gre/modules/Timer.sys.mjs", + + DialogHandler: + "chrome://remote/content/cdp/domains/parent/page/DialogHandler.sys.mjs", + PollPromise: "chrome://remote/content/shared/Sync.sys.mjs", + streamRegistry: "chrome://remote/content/cdp/domains/parent/IO.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", + UnsupportedError: "chrome://remote/content/cdp/Error.sys.mjs", + windowManager: "chrome://remote/content/shared/WindowManager.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + OS: "resource://gre/modules/osfile.jsm", +}); + +const MAX_CANVAS_DIMENSION = 32767; +const MAX_CANVAS_AREA = 472907776; + +const PRINT_MAX_SCALE_VALUE = 2.0; +const PRINT_MIN_SCALE_VALUE = 0.1; + +const PDF_TRANSFER_MODES = { + base64: "ReturnAsBase64", + stream: "ReturnAsStream", +}; + +const TIMEOUT_SET_HISTORY_INDEX = 1000; + +export class Page extends Domain { + constructor(session) { + super(session); + + this._onDialogLoaded = this._onDialogLoaded.bind(this); + this._onRequest = this._onRequest.bind(this); + + this.enabled = false; + + this.session.networkObserver.startTrackingBrowserNetwork( + this.session.target.browser + ); + this.session.networkObserver.on("request", this._onRequest); + } + + destructor() { + // Flip a flag to avoid to disable the content domain from this.disable() + this._isDestroyed = false; + this.disable(); + + this.session.networkObserver.off("request", this._onRequest); + this.session.networkObserver.stopTrackingBrowserNetwork( + this.session.target.browser + ); + super.destructor(); + } + + // commands + + /** + * Navigates current page to given URL. + * + * @param {Object} options + * @param {string} options.url + * destination URL + * @param {string=} options.frameId + * frame id to navigate (not supported), + * if not specified navigate top frame + * @param {string=} options.referrer + * referred URL (optional) + * @param {string=} options.transitionType + * intended transition type + * @return {Object} + * - frameId {string} frame id that has navigated (or failed to) + * - errorText {string=} error message if navigation has failed + * - loaderId {string} (not supported) + */ + async navigate(options = {}) { + const { url, frameId, referrer, transitionType } = options; + if (typeof url != "string") { + throw new TypeError("url: string value expected"); + } + let validURL; + try { + validURL = Services.io.newURI(url); + } catch (e) { + throw new Error("Error: Cannot navigate to invalid URL"); + } + const topFrameId = this.session.browsingContext.id.toString(); + if (frameId && frameId != topFrameId) { + throw new lazy.UnsupportedError("frameId not supported"); + } + + const hitsNetwork = ["https", "http"].includes(validURL.scheme); + let networkLessLoaderId; + if (!hitsNetwork) { + // This navigation will not hit the network, use a randomly generated id. + const uuid = Services.uuid.generateUUID().toString(); + networkLessLoaderId = uuid.substring(1, uuid.length - 1); + + // Update the content process map of loader ids. + await this.executeInChild("_updateLoaderId", { + frameId: this.session.browsingContext.id, + loaderId: networkLessLoaderId, + }); + } + + const currentURI = this.session.browsingContext.currentURI; + + const isSameDocumentNavigation = + // The "host", "query" and "ref" getters can throw if the URLs are not + // http/https, so verify first that both currentURI and validURL are + // using http/https. + hitsNetwork && + ["https", "http"].includes(currentURI.scheme) && + currentURI.host === validURL.host && + currentURI.query === validURL.query && + !!validURL.ref; + + const requestDone = new Promise(resolve => { + if (isSameDocumentNavigation) { + // Per CDP documentation, same-document navigations should not emit any + // loader id (https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-navigate) + resolve({}); + return; + } + + if (!hitsNetwork) { + // This navigation will not hit the network, use a randomly generated id. + resolve({ navigationRequestId: networkLessLoaderId }); + return; + } + let navigationRequestId, redirectedRequestId; + const _onNavigationRequest = function(_type, _ch, data) { + const { + url: requestURL, + requestId, + redirectedFrom = null, + isNavigationRequest, + } = data; + if (!isNavigationRequest) { + return; + } + if (validURL.spec === requestURL) { + navigationRequestId = redirectedRequestId = requestId; + } else if (redirectedFrom === redirectedRequestId) { + redirectedRequestId = requestId; + } + }; + + const _onRequestFinished = function(_type, _ch, data) { + const { requestId, errorCode } = data; + if ( + redirectedRequestId !== requestId || + errorCode == "NS_BINDING_REDIRECTED" + ) { + // handle next request in redirection chain + return; + } + this.session.networkObserver.off("request", _onNavigationRequest); + this.session.networkObserver.off("requestfinished", _onRequestFinished); + resolve({ errorCode, navigationRequestId }); + }.bind(this); + + this.session.networkObserver.on("request", _onNavigationRequest); + this.session.networkObserver.on("requestfinished", _onRequestFinished); + }); + + const opts = { + loadFlags: transitionToLoadFlag(transitionType), + referrerURI: referrer, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }; + this.session.browsingContext.loadURI(url, opts); + // clients expect loaderId == requestId for a document navigation request + const { navigationRequestId: loaderId, errorCode } = await requestDone; + const result = { + frameId: topFrameId, + loaderId, + }; + if (errorCode) { + result.errorText = errorCode; + } + return result; + } + + /** + * Capture page screenshot. + * + * @param {Object} options + * @param {Viewport=} options.clip + * Capture the screenshot of a given region only. + * @param {string=} options.format + * Image compression format. Defaults to "png". + * @param {number=} options.quality + * Compression quality from range [0..100] (jpeg only). Defaults to 80. + * + * @return {string} + * Base64-encoded image data. + */ + async captureScreenshot(options = {}) { + const { clip, format = "png", quality = 80 } = options; + + if (options.fromSurface) { + throw new lazy.UnsupportedError("fromSurface not supported"); + } + + let rect; + let scale = await this.executeInChild("_devicePixelRatio"); + + if (clip) { + for (const prop of ["x", "y", "width", "height", "scale"]) { + if (clip[prop] == undefined) { + throw new TypeError(`clip.${prop}: double value expected`); + } + } + + const contentRect = await this.executeInChild("_contentRect"); + + // For invalid scale values default to full page + if (clip.scale <= 0) { + Object.assign(clip, { + x: 0, + y: 0, + width: contentRect.width, + height: contentRect.height, + scale: 1, + }); + } else { + if (clip.x < 0 || clip.x > contentRect.width - 1) { + clip.x = 0; + } + if (clip.y < 0 || clip.y > contentRect.height - 1) { + clip.y = 0; + } + if (clip.width <= 0) { + clip.width = contentRect.width; + } + if (clip.height <= 0) { + clip.height = contentRect.height; + } + } + + rect = new DOMRect(clip.x, clip.y, clip.width, clip.height); + scale *= clip.scale; + } else { + // If no specific clipping region has been specified, + // fallback to the layout (fixed) viewport, and the + // default pixel ratio. + const { + pageX, + pageY, + clientWidth, + clientHeight, + } = await this.executeInChild("_layoutViewport"); + + rect = new DOMRect(pageX, pageY, clientWidth, clientHeight); + } + + let canvasWidth = rect.width * scale; + let canvasHeight = rect.height * scale; + + // Cap the screenshot size based on maximum allowed canvas sizes. + // Using higher dimensions would trigger exceptions in Gecko. + // + // See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas#Maximum_canvas_size + if (canvasWidth > MAX_CANVAS_DIMENSION) { + rect.width = Math.floor(MAX_CANVAS_DIMENSION / scale); + canvasWidth = rect.width * scale; + } + if (canvasHeight > MAX_CANVAS_DIMENSION) { + rect.height = Math.floor(MAX_CANVAS_DIMENSION / scale); + canvasHeight = rect.height * scale; + } + // If the area is larger, reduce the height to keep the full width. + if (canvasWidth * canvasHeight > MAX_CANVAS_AREA) { + rect.height = Math.floor(MAX_CANVAS_AREA / (canvasWidth * scale)); + canvasHeight = rect.height * scale; + } + + const { browsingContext, window } = this.session.target; + const snapshot = await browsingContext.currentWindowGlobal.drawSnapshot( + rect, + scale, + "rgb(255,255,255)" + ); + + const canvas = window.document.createElementNS( + "http://www.w3.org/1999/xhtml", + "canvas" + ); + canvas.width = canvasWidth; + canvas.height = canvasHeight; + + const ctx = canvas.getContext("2d"); + ctx.drawImage(snapshot, 0, 0); + + // Bug 1574935 - Huge dimensions can trigger an OOM because multiple copies + // of the bitmap will exist in memory. Force the removal of the snapshot + // because it is no longer needed. + snapshot.close(); + + const url = canvas.toDataURL(`image/${format}`, quality / 100); + if (!url.startsWith(`data:image/${format}`)) { + throw new lazy.UnsupportedError(`Unsupported MIME type: image/${format}`); + } + + // only return the base64 encoded data without the data URL prefix + const data = url.substring(url.indexOf(",") + 1); + + return { data }; + } + + async enable() { + if (this.enabled) { + return; + } + + this.enabled = true; + + const { browser } = this.session.target; + this._dialogHandler = new lazy.DialogHandler(browser); + this._dialogHandler.on("dialog-loaded", this._onDialogLoaded); + await this.executeInChild("enable"); + } + + async disable() { + if (!this.enabled) { + return; + } + + this._dialogHandler.destructor(); + this._dialogHandler = null; + this.enabled = false; + + if (!this._isDestroyed) { + // Only call disable in the content domain if we are not destroying the domain. + // If we are destroying the domain, the content domains will be destroyed + // independently after firing the remote:destroy event. + await this.executeInChild("disable"); + } + } + + async bringToFront() { + const { tab, window } = this.session.target; + + // Focus the window, and select the corresponding tab + await lazy.windowManager.focusWindow(window); + await lazy.TabManager.selectTab(tab); + } + + /** + * Return metrics relating to the layouting of the page. + * + * The returned object contains the following entries: + * + * layoutViewport: + * {number} pageX + * Horizontal offset relative to the document (CSS pixels) + * {number} pageY + * Vertical offset relative to the document (CSS pixels) + * {number} clientWidth + * Width (CSS pixels), excludes scrollbar if present + * {number} clientHeight + * Height (CSS pixels), excludes scrollbar if present + * + * visualViewport: + * {number} offsetX + * Horizontal offset relative to the layout viewport (CSS pixels) + * {number} offsetY + * Vertical offset relative to the layout viewport (CSS pixels) + * {number} pageX + * Horizontal offset relative to the document (CSS pixels) + * {number} pageY + * Vertical offset relative to the document (CSS pixels) + * {number} clientWidth + * Width (CSS pixels), excludes scrollbar if present + * {number} clientHeight + * Height (CSS pixels), excludes scrollbar if present + * {number} scale + * Scale relative to the ideal viewport (size at width=device-width) + * {number} zoom + * Page zoom factor (CSS to device independent pixels ratio) + * + * contentSize: + * {number} x + * X coordinate + * {number} y + * Y coordinate + * {number} width + * Width of scrollable area + * {number} height + * Height of scrollable area + * + * @return {Promise} + * @resolves {layoutViewport, visualViewport, contentSize} + */ + async getLayoutMetrics() { + return { + layoutViewport: await this.executeInChild("_layoutViewport"), + contentSize: await this.executeInChild("_contentRect"), + }; + } + + /** + * Returns navigation history for the current page. + * + * @return {currentIndex:number, entries:Array<NavigationEntry>} + */ + async getNavigationHistory() { + const { window } = this.session.target; + + return new Promise(resolve => { + function updateSessionHistory(sessionHistory) { + const entries = sessionHistory.entries.map(entry => { + return { + id: entry.ID, + url: entry.url, + userTypedURL: entry.originalURI || entry.url, + title: entry.title, + // TODO: Bug 1609514 + transitionType: null, + }; + }); + + resolve({ + currentIndex: sessionHistory.index, + entries, + }); + } + + lazy.SessionStore.getSessionHistory( + window.gBrowser.selectedTab, + updateSessionHistory + ); + }); + } + + /** + * Interact with the currently opened JavaScript dialog (alert, confirm, + * prompt) for this page. This will always close the dialog, either accepting + * or rejecting it, with the optional prompt filled. + * + * @param {Object} options + * @param {boolean=} options.accept + * for "confirm", "prompt", "beforeunload" dialogs true will accept + * the dialog, false will cancel it. For "alert" dialogs, true or + * false closes the dialog in the same way. + * @param {string=} options.promptText + * for "prompt" dialogs, used to fill the prompt input. + */ + async handleJavaScriptDialog(options = {}) { + const { accept, promptText } = options; + + if (!this.enabled) { + throw new Error("Page domain is not enabled"); + } + await this._dialogHandler.handleJavaScriptDialog({ accept, promptText }); + } + + /** + * Navigates current page to the given history entry. + * + * @param {Object} options + * @param {number} options.entryId + * Unique id of the entry to navigate to. + */ + async navigateToHistoryEntry(options = {}) { + const { entryId } = options; + + const index = await this._getIndexForHistoryEntryId(entryId); + + if (index == null) { + throw new Error("No entry with passed id"); + } + + const { window } = this.session.target; + window.gBrowser.gotoIndex(index); + + // On some platforms the requested index isn't set immediately. + await lazy.PollPromise( + async (resolve, reject) => { + const currentIndex = await this._getCurrentHistoryIndex(); + if (currentIndex == index) { + resolve(); + } else { + reject(); + } + }, + { timeout: TIMEOUT_SET_HISTORY_INDEX } + ); + } + + /** + * Print page as PDF. + * + * @param {Object} options + * @param {boolean=} options.displayHeaderFooter + * Display header and footer. Defaults to false. + * @param {string=} options.footerTemplate (not supported) + * HTML template for the print footer. + * @param {string=} options.headerTemplate (not supported) + * HTML template for the print header. Should use the same format + * as the footerTemplate. + * @param {boolean=} options.ignoreInvalidPageRanges + * Whether to silently ignore invalid but successfully parsed page ranges, + * such as '3-2'. Defaults to false. + * @param {boolean=} options.landscape + * Paper orientation. Defaults to false. + * @param {number=} options.marginBottom + * Bottom margin in inches. Defaults to 1cm (~0.4 inches). + * @param {number=} options.marginLeft + * Left margin in inches. Defaults to 1cm (~0.4 inches). + * @param {number=} options.marginRight + * Right margin in inches. Defaults to 1cm (~0.4 inches). + * @param {number=} options.marginTop + * Top margin in inches. Defaults to 1cm (~0.4 inches). + * @param {string=} options.pageRanges (not supported) + * Paper ranges to print, e.g., '1-5, 8, 11-13'. + * Defaults to the empty string, which means print all pages. + * @param {number=} options.paperHeight + * Paper height in inches. Defaults to 11 inches. + * @param {number=} options.paperWidth + * Paper width in inches. Defaults to 8.5 inches. + * @param {boolean=} options.preferCSSPageSize + * Whether or not to prefer page size as defined by CSS. + * Defaults to false, in which case the content will be scaled + * to fit the paper size. + * @param {boolean=} options.printBackground + * Print background graphics. Defaults to false. + * @param {number=} options.scale + * Scale of the webpage rendering. Defaults to 1. + * @param {string=} options.transferMode + * Return as base64-encoded string (ReturnAsBase64), + * or stream (ReturnAsStream). Defaults to ReturnAsBase64. + * + * @return {Promise<{data:string, stream:string}> + * Based on the transferMode setting data is a base64-encoded string, + * or stream is a handle to a OS.File stream. + */ + async printToPDF(options = {}) { + const { + displayHeaderFooter = false, + // Bug 1601570 - Implement templates for header and footer + // headerTemplate = "", + // footerTemplate = "", + landscape = false, + marginBottom = 0.39, + marginLeft = 0.39, + marginRight = 0.39, + marginTop = 0.39, + // Bug 1601571 - Implement handling of page ranges + // TODO: pageRanges = "", + // TODO: ignoreInvalidPageRanges = false, + paperHeight = 11.0, + paperWidth = 8.5, + preferCSSPageSize = false, + printBackground = false, + scale = 1.0, + transferMode = PDF_TRANSFER_MODES.base64, + } = options; + + if (marginBottom < 0) { + throw new TypeError("marginBottom is negative"); + } + if (marginLeft < 0) { + throw new TypeError("marginLeft is negative"); + } + if (marginRight < 0) { + throw new TypeError("marginRight is negative"); + } + if (marginTop < 0) { + throw new TypeError("marginTop is negative"); + } + if (scale < PRINT_MIN_SCALE_VALUE || scale > PRINT_MAX_SCALE_VALUE) { + throw new TypeError("scale is outside [0.1 - 2] range"); + } + if (paperHeight <= 0) { + throw new TypeError("paperHeight is zero or negative"); + } + if (paperWidth <= 0) { + throw new TypeError("paperWidth is zero or negative"); + } + + // Create a unique filename for the temporary PDF file + const basePath = lazy.OS.Path.join( + lazy.OS.Constants.Path.tmpDir, + "remote-agent.pdf" + ); + const { file, path: filePath } = await lazy.OS.File.openUnique(basePath); + await file.close(); + + const psService = Cc["@mozilla.org/gfx/printsettings-service;1"].getService( + Ci.nsIPrintSettingsService + ); + + const printSettings = psService.createNewPrintSettings(); + printSettings.isInitializedFromPrinter = true; + printSettings.isInitializedFromPrefs = true; + printSettings.outputFormat = Ci.nsIPrintSettings.kOutputFormatPDF; + printSettings.printerName = ""; + printSettings.printSilent = true; + printSettings.outputDestination = + Ci.nsIPrintSettings.kOutputDestinationFile; + printSettings.toFileName = filePath; + + printSettings.paperSizeUnit = Ci.nsIPrintSettings.kPaperSizeInches; + printSettings.paperWidth = paperWidth; + printSettings.paperHeight = paperHeight; + + printSettings.marginBottom = marginBottom; + printSettings.marginLeft = marginLeft; + printSettings.marginRight = marginRight; + printSettings.marginTop = marginTop; + + printSettings.printBGColors = printBackground; + printSettings.printBGImages = printBackground; + printSettings.scaling = scale; + printSettings.shrinkToFit = preferCSSPageSize; + + if (!displayHeaderFooter) { + printSettings.headerStrCenter = ""; + printSettings.headerStrLeft = ""; + printSettings.headerStrRight = ""; + printSettings.footerStrCenter = ""; + printSettings.footerStrLeft = ""; + printSettings.footerStrRight = ""; + } + + if (landscape) { + printSettings.orientation = Ci.nsIPrintSettings.kLandscapeOrientation; + } + + const { linkedBrowser } = this.session.target.tab; + + await linkedBrowser.browsingContext.print(printSettings); + + // Bug 1603739 - With e10s enabled the promise returned by print() resolves + // too early, which means the file hasn't been completely written. + await new Promise(resolve => { + const DELAY_CHECK_FILE_COMPLETELY_WRITTEN = 100; + + let lastSize = 0; + const timerId = lazy.setInterval(async () => { + const fileInfo = await lazy.OS.File.stat(filePath); + if (lastSize > 0 && fileInfo.size == lastSize) { + lazy.clearInterval(timerId); + resolve(); + } + lastSize = fileInfo.size; + }, DELAY_CHECK_FILE_COMPLETELY_WRITTEN); + }); + + const fp = await lazy.OS.File.open(filePath); + + const retval = { data: null, stream: null }; + if (transferMode == PDF_TRANSFER_MODES.stream) { + retval.stream = lazy.streamRegistry.add(fp); + } else { + // return all data as a base64 encoded string + let bytes; + try { + bytes = await fp.read(); + } finally { + fp.close(); + await lazy.OS.File.remove(filePath); + } + + // Each UCS2 character has an upper byte of 0 and a lower byte matching + // the binary data + retval.data = btoa(String.fromCharCode.apply(null, bytes)); + } + + return retval; + } + + /** + * Intercept file chooser requests and transfer control to protocol clients. + * + * When file chooser interception is enabled, + * the native file chooser dialog is not shown. + * Instead, a protocol event Page.fileChooserOpened is emitted. + * + * @param {Object} options + * @param {boolean=} options.enabled + * Enabled state of file chooser interception. + */ + setInterceptFileChooserDialog(options = {}) {} + + _getCurrentHistoryIndex() { + const { window } = this.session.target; + + return new Promise(resolve => { + lazy.SessionStore.getSessionHistory( + window.gBrowser.selectedTab, + history => { + resolve(history.index); + } + ); + }); + } + + _getIndexForHistoryEntryId(id) { + const { window } = this.session.target; + + return new Promise(resolve => { + function updateSessionHistory(sessionHistory) { + sessionHistory.entries.forEach((entry, index) => { + if (entry.ID == id) { + resolve(index); + } + }); + + resolve(null); + } + + lazy.SessionStore.getSessionHistory( + window.gBrowser.selectedTab, + updateSessionHistory + ); + }); + } + + /** + * Emit the proper CDP event javascriptDialogOpening when a javascript dialog + * opens for the current target. + */ + _onDialogLoaded(e, data) { + const { message, type } = data; + // XXX: We rely on the tabmodal-dialog-loaded event (see DialogHandler.jsm) + // which is inconsistent with the name "javascriptDialogOpening". + // For correctness we should rely on an event fired _before_ the prompt is + // visible, such as DOMWillOpenModalDialog. However the payload of this + // event does not contain enough data to populate javascriptDialogOpening. + // + // Since the event is fired asynchronously, this should not have an impact + // on the actual tests relying on this API. + this.emit("Page.javascriptDialogOpening", { message, type }); + } + + /** + * Handles HTTP request to propagate loaderId to events emitted from + * content process + */ + _onRequest(_type, _ch, data) { + if (!data.loaderId) { + return; + } + this.executeInChild("_updateLoaderId", { + loaderId: data.loaderId, + frameId: data.frameId, + }); + } +} + +function transitionToLoadFlag(transitionType) { + switch (transitionType) { + case "reload": + return Ci.nsIWebNavigation.LOAD_FLAGS_IS_REFRESH; + case "link": + default: + return Ci.nsIWebNavigation.LOAD_FLAGS_IS_LINK; + } +} diff --git a/remote/cdp/domains/parent/Security.sys.mjs b/remote/cdp/domains/parent/Security.sys.mjs new file mode 100644 index 0000000000..73967d73cb --- /dev/null +++ b/remote/cdp/domains/parent/Security.sys.mjs @@ -0,0 +1,58 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + sss: ["@mozilla.org/ssservice;1", "nsISiteSecurityService"], + certOverrideService: [ + "@mozilla.org/security/certoverride;1", + "nsICertOverrideService", + ], +}); + +const CERT_PINNING_ENFORCEMENT_PREF = "security.cert_pinning.enforcement_level"; +const HSTS_PRELOAD_LIST_PREF = "network.stricttransportsecurity.preloadlist"; + +export class Security extends Domain { + destructor() { + this.setIgnoreCertificateErrors({ ignore: false }); + } + + /** + * Enable/disable whether all certificate errors should be ignored + * + * @param {Object} options + * @param {boolean=} options.ignore + * if true, all certificate errors will be ignored. + */ + setIgnoreCertificateErrors(options = {}) { + const { ignore } = options; + + if (ignore) { + // make it possible to register certificate overrides for domains + // that use HSTS or HPKP + lazy.Preferences.set(HSTS_PRELOAD_LIST_PREF, false); + lazy.Preferences.set(CERT_PINNING_ENFORCEMENT_PREF, 0); + } else { + lazy.Preferences.reset(HSTS_PRELOAD_LIST_PREF); + lazy.Preferences.reset(CERT_PINNING_ENFORCEMENT_PREF); + + // clear collected HSTS and HPKP state + lazy.sss.clearAll(); + } + + lazy.certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyData( + ignore + ); + } +} diff --git a/remote/cdp/domains/parent/Target.sys.mjs b/remote/cdp/domains/parent/Target.sys.mjs new file mode 100644 index 0000000000..5f0229ce4d --- /dev/null +++ b/remote/cdp/domains/parent/Target.sys.mjs @@ -0,0 +1,193 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ContextualIdentityService: + "resource://gre/modules/ContextualIdentityService.sys.mjs", + MainProcessTarget: + "chrome://remote/content/cdp/targets/MainProcessTarget.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", + TabSession: "chrome://remote/content/cdp/sessions/TabSession.sys.mjs", + windowManager: "chrome://remote/content/shared/WindowManager.sys.mjs", +}); + +let browserContextIds = 1; + +export class Target extends Domain { + constructor(session) { + super(session); + + this._onTargetCreated = this._onTargetCreated.bind(this); + this._onTargetDestroyed = this._onTargetDestroyed.bind(this); + } + + getBrowserContexts() { + return { + browserContextIds: [], + }; + } + + createBrowserContext() { + const identity = lazy.ContextualIdentityService.create( + "remote-agent-" + browserContextIds++ + ); + return { browserContextId: identity.userContextId }; + } + + disposeBrowserContext(options = {}) { + const { browserContextId } = options; + + lazy.ContextualIdentityService.remove(browserContextId); + lazy.ContextualIdentityService.closeContainerTabs(browserContextId); + } + + getTargets() { + const { targetList } = this.session.target; + + const targetInfos = []; + for (const target of targetList) { + if (target instanceof lazy.MainProcessTarget) { + continue; + } + + targetInfos.push(this._getTargetInfo(target)); + } + + return { targetInfos }; + } + + setDiscoverTargets(options = {}) { + const { discover } = options; + const { targetList } = this.session.target; + if (discover) { + targetList.on("target-created", this._onTargetCreated); + targetList.on("target-destroyed", this._onTargetDestroyed); + } else { + targetList.off("target-created", this._onTargetCreated); + targetList.off("target-destroyed", this._onTargetDestroyed); + } + for (const target of targetList) { + this._onTargetCreated("target-created", target); + } + } + + async createTarget(options = {}) { + const { browserContextId } = options; + const { targetList, window } = this.session.target; + const onTarget = targetList.once("target-created"); + const tab = await lazy.TabManager.addTab({ + focus: true, + userContextId: browserContextId, + window, + }); + const target = await onTarget; + if (tab.linkedBrowser != target.browser) { + throw new Error( + "Unexpected tab opened: " + tab.linkedBrowser.currentURI.spec + ); + } + return { targetId: target.id }; + } + + async closeTarget(options = {}) { + const { targetId } = options; + const { targetList } = this.session.target; + const target = targetList.getById(targetId); + + if (!target) { + throw new Error(`Unable to find target with id '${targetId}'`); + } + + await lazy.TabManager.removeTab(target.tab); + } + + async activateTarget(options = {}) { + const { targetId } = options; + const { targetList, window } = this.session.target; + const target = targetList.getById(targetId); + + if (!target) { + throw new Error(`Unable to find target with id '${targetId}'`); + } + + // Focus the window, and select the corresponding tab + await lazy.windowManager.focusWindow(window); + await lazy.TabManager.selectTab(target.tab); + } + + attachToTarget(options = {}) { + const { targetId } = options; + const { targetList } = this.session.target; + const target = targetList.getById(targetId); + + if (!target) { + throw new Error(`Unable to find target with id '${targetId}'`); + } + + const tabSession = new lazy.TabSession( + this.session.connection, + target, + Services.uuid + .generateUUID() + .toString() + .slice(1, -1) + ); + this.session.connection.registerSession(tabSession); + + this._emitAttachedToTarget(target, tabSession); + + return { + sessionId: tabSession.id, + }; + } + + setAutoAttach() {} + + sendMessageToTarget(options = {}) { + const { sessionId, message } = options; + const { connection } = this.session; + connection.sendMessageToTarget(sessionId, message); + } + + /** + * Internal methods: the following methods are not part of CDP; + * note the _ prefix. + */ + + _emitAttachedToTarget(target, tabSession) { + const targetInfo = this._getTargetInfo(target); + this.emit("Target.attachedToTarget", { + targetInfo, + sessionId: tabSession.id, + waitingForDebugger: false, + }); + } + + _getTargetInfo(target) { + return { + targetId: target.id, + type: target.type, + title: target.title, + url: target.url, + // TODO: Correctly determine if target is attached (bug 1680780) + attached: target.id == this.session.target.id, + browserContextId: target.browserContextId, + }; + } + + _onTargetCreated(eventName, target) { + const targetInfo = this._getTargetInfo(target); + this.emit("Target.targetCreated", { targetInfo }); + } + + _onTargetDestroyed(eventName, target) { + this.emit("Target.targetDestroyed", { + targetId: target.id, + }); + } +} diff --git a/remote/cdp/domains/parent/page/DialogHandler.sys.mjs b/remote/cdp/domains/parent/page/DialogHandler.sys.mjs new file mode 100644 index 0000000000..a142165b4b --- /dev/null +++ b/remote/cdp/domains/parent/page/DialogHandler.sys.mjs @@ -0,0 +1,140 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", +}); + +const DIALOG_TYPES = { + ALERT: "alert", + BEFOREUNLOAD: "beforeunload", + CONFIRM: "confirm", + PROMPT: "prompt", +}; + +/** + * Helper dedicated to detect and interact with browser dialogs such as `alert`, + * `confirm` etc. The current implementation only supports tabmodal dialogs, + * not full window dialogs. + * + * Emits "dialog-loaded" when a javascript dialog is opened for the current + * browser. + * + * @param {BrowserElement} browser + */ +export class DialogHandler { + constructor(browser) { + lazy.EventEmitter.decorate(this); + this._dialog = null; + this._browser = browser; + + this._onCommonDialogLoaded = this._onCommonDialogLoaded.bind(this); + this._onTabDialogLoaded = this._onTabDialogLoaded.bind(this); + + Services.obs.addObserver( + this._onCommonDialogLoaded, + "common-dialog-loaded" + ); + Services.obs.addObserver(this._onTabDialogLoaded, "tabmodal-dialog-loaded"); + } + + destructor() { + this._dialog = null; + this._pageTarget = null; + + Services.obs.removeObserver( + this._onCommonDialogLoaded, + "common-dialog-loaded" + ); + Services.obs.removeObserver( + this._onTabDialogLoaded, + "tabmodal-dialog-loaded" + ); + } + + async handleJavaScriptDialog({ accept, promptText }) { + if (!this._dialog) { + throw new Error("No dialog available for handleJavaScriptDialog"); + } + + const type = this._getDialogType(); + if (promptText && type === "prompt") { + this._dialog.ui.loginTextbox.value = promptText; + } + + const onDialogClosed = new Promise(r => { + this._browser.addEventListener("DOMModalDialogClosed", r, { + once: true, + }); + }); + + // 0 corresponds to the OK callback, 1 to the CANCEL callback. + if (accept) { + this._dialog.ui.button0.click(); + } else { + this._dialog.ui.button1.click(); + } + + await onDialogClosed; + + // Resetting dialog to null here might be racy and lead to errors if the + // content page is triggering several prompts in a row. + // See Bug 1569578. + this._dialog = null; + } + + _getDialogType() { + const { inPermitUnload, promptType } = this._dialog.args; + + if (inPermitUnload) { + return DIALOG_TYPES.BEFOREUNLOAD; + } + + switch (promptType) { + case "alert": + return DIALOG_TYPES.ALERT; + case "confirm": + return DIALOG_TYPES.CONFIRM; + case "prompt": + return DIALOG_TYPES.PROMPT; + default: + throw new Error("Unsupported dialog type: " + promptType); + } + } + + _onCommonDialogLoaded(dialogWindow) { + const dialogs = this._browser.tabDialogBox.getContentDialogManager() + .dialogs; + const dialog = dialogs.find(d => d.frameContentWindow === dialogWindow); + + if (!dialog) { + // The dialog is not for the current tab. + return; + } + + this._dialog = dialogWindow.Dialog; + const message = this._dialog.args.text; + const type = this._getDialogType(); + + this.emit("dialog-loaded", { message, type }); + } + + _onTabDialogLoaded(promptContainer) { + const prompts = this._browser.tabModalPromptBox.listPrompts(); + const prompt = prompts.find(p => p.ui.promptContainer === promptContainer); + + if (!prompt) { + // The dialog is not for the current tab. + return; + } + + this._dialog = prompt; + const message = this._dialog.args.text; + const type = this._getDialogType(); + + this.emit("dialog-loaded", { message, type }); + } +} |