/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { Domain } from "chrome://remote/content/cdp/domains/Domain.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", DialogHandler: "chrome://remote/content/cdp/domains/parent/page/DialogHandler.sys.mjs", generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", PollPromise: "chrome://remote/content/shared/Sync.sys.mjs", print: "chrome://remote/content/shared/PDF.sys.mjs", streamRegistry: "chrome://remote/content/cdp/domains/parent/IO.sys.mjs", Stream: "chrome://remote/content/cdp/StreamRegistry.sys.mjs", TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", UnsupportedError: "chrome://remote/content/cdp/Error.sys.mjs", windowManager: "chrome://remote/content/shared/WindowManager.sys.mjs", }); const MAX_CANVAS_DIMENSION = 32767; const MAX_CANVAS_AREA = 472907776; const PRINT_MAX_SCALE_VALUE = 2.0; const PRINT_MIN_SCALE_VALUE = 0.1; const PDF_TRANSFER_MODES = { base64: "ReturnAsBase64", stream: "ReturnAsStream", }; const TIMEOUT_SET_HISTORY_INDEX = 1000; export class Page extends Domain { constructor(session) { super(session); this._onDialogLoaded = this._onDialogLoaded.bind(this); this._onRequest = this._onRequest.bind(this); this.enabled = false; this.session.networkObserver.startTrackingBrowserNetwork( this.session.target.browser ); this.session.networkObserver.on("request", this._onRequest); } destructor() { // Flip a flag to avoid to disable the content domain from this.disable() this._isDestroyed = false; this.disable(); this.session.networkObserver.off("request", this._onRequest); this.session.networkObserver.stopTrackingBrowserNetwork( this.session.target.browser ); super.destructor(); } // commands /** * Navigates current page to given URL. * * @param {object} options * @param {string} options.url * destination URL * @param {string=} options.frameId * frame id to navigate (not supported), * if not specified navigate top frame * @param {string=} options.referrer * referred URL (optional) * @param {string=} options.transitionType * intended transition type * @returns {object} * - frameId {string} frame id that has navigated (or failed to) * - errorText {string=} error message if navigation has failed * - loaderId {string} (not supported) */ async navigate(options = {}) { const { url, frameId, referrer, transitionType } = options; if (typeof url != "string") { throw new TypeError("url: string value expected"); } let validURL; try { validURL = Services.io.newURI(url); } catch (e) { throw new Error("Error: Cannot navigate to invalid URL"); } const topFrameId = this.session.browsingContext.id.toString(); if (frameId && frameId != topFrameId) { throw new lazy.UnsupportedError("frameId not supported"); } const hitsNetwork = ["https", "http"].includes(validURL.scheme); let networkLessLoaderId; if (!hitsNetwork) { // This navigation will not hit the network, use a randomly generated id. networkLessLoaderId = lazy.generateUUID(); // Update the content process map of loader ids. await this.executeInChild("_updateLoaderId", { frameId: this.session.browsingContext.id, loaderId: networkLessLoaderId, }); } const currentURI = this.session.browsingContext.currentURI; const isSameDocumentNavigation = // The "host", "query" and "ref" getters can throw if the URLs are not // http/https, so verify first that both currentURI and validURL are // using http/https. hitsNetwork && ["https", "http"].includes(currentURI.scheme) && currentURI.host === validURL.host && currentURI.query === validURL.query && !!validURL.ref; const requestDone = new Promise(resolve => { if (isSameDocumentNavigation) { // Per CDP documentation, same-document navigations should not emit any // loader id (https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-navigate) resolve({}); return; } if (!hitsNetwork) { // This navigation will not hit the network, use a randomly generated id. resolve({ navigationRequestId: networkLessLoaderId }); return; } let navigationRequestId, redirectedRequestId; const _onNavigationRequest = function (_type, _ch, data) { const { url: requestURL, requestId, redirectedFrom = null, isNavigationRequest, } = data; if (!isNavigationRequest) { return; } if (validURL.spec === requestURL) { navigationRequestId = redirectedRequestId = requestId; } else if (redirectedFrom === redirectedRequestId) { redirectedRequestId = requestId; } }; const _onRequestFinished = function (_type, _ch, data) { const { requestId, errorCode } = data; if ( redirectedRequestId !== requestId || errorCode == "NS_BINDING_REDIRECTED" ) { // handle next request in redirection chain return; } this.session.networkObserver.off("request", _onNavigationRequest); this.session.networkObserver.off("requestfinished", _onRequestFinished); resolve({ errorCode, navigationRequestId }); }.bind(this); this.session.networkObserver.on("request", _onNavigationRequest); this.session.networkObserver.on("requestfinished", _onRequestFinished); }); const opts = { loadFlags: transitionToLoadFlag(transitionType), referrerURI: referrer, triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), }; this.session.browsingContext.loadURI(validURL, opts); // clients expect loaderId == requestId for a document navigation request const { navigationRequestId: loaderId, errorCode } = await requestDone; const result = { frameId: topFrameId, loaderId, }; if (errorCode) { result.errorText = errorCode; } return result; } /** * Capture page screenshot. * * @param {object} options * @param {Viewport=} options.clip * Capture the screenshot of a given region only. * @param {string=} options.format * Image compression format. Defaults to "png". * @param {number=} options.quality * Compression quality from range [0..100] (jpeg only). Defaults to 80. * * @returns {string} * Base64-encoded image data. */ async captureScreenshot(options = {}) { const { clip, format = "png", quality = 80 } = options; if (options.fromSurface) { throw new lazy.UnsupportedError("fromSurface not supported"); } let rect; let scale = await this.executeInChild("_devicePixelRatio"); if (clip) { for (const prop of ["x", "y", "width", "height", "scale"]) { if (clip[prop] == undefined) { throw new TypeError(`clip.${prop}: double value expected`); } } const contentRect = await this.executeInChild("_contentRect"); // For invalid scale values default to full page if (clip.scale <= 0) { Object.assign(clip, { x: 0, y: 0, width: contentRect.width, height: contentRect.height, scale: 1, }); } else { if (clip.x < 0 || clip.x > contentRect.width - 1) { clip.x = 0; } if (clip.y < 0 || clip.y > contentRect.height - 1) { clip.y = 0; } if (clip.width <= 0) { clip.width = contentRect.width; } if (clip.height <= 0) { clip.height = contentRect.height; } } rect = new DOMRect(clip.x, clip.y, clip.width, clip.height); scale *= clip.scale; } else { // If no specific clipping region has been specified, // fallback to the layout (fixed) viewport, and the // default pixel ratio. const { pageX, pageY, clientWidth, clientHeight } = await this.executeInChild("_layoutViewport"); rect = new DOMRect(pageX, pageY, clientWidth, clientHeight); } let canvasWidth = rect.width * scale; let canvasHeight = rect.height * scale; // Cap the screenshot size based on maximum allowed canvas sizes. // Using higher dimensions would trigger exceptions in Gecko. // // See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas#Maximum_canvas_size if (canvasWidth > MAX_CANVAS_DIMENSION) { rect.width = Math.floor(MAX_CANVAS_DIMENSION / scale); canvasWidth = rect.width * scale; } if (canvasHeight > MAX_CANVAS_DIMENSION) { rect.height = Math.floor(MAX_CANVAS_DIMENSION / scale); canvasHeight = rect.height * scale; } // If the area is larger, reduce the height to keep the full width. if (canvasWidth * canvasHeight > MAX_CANVAS_AREA) { rect.height = Math.floor(MAX_CANVAS_AREA / (canvasWidth * scale)); canvasHeight = rect.height * scale; } const { browsingContext, window } = this.session.target; const snapshot = await browsingContext.currentWindowGlobal.drawSnapshot( rect, scale, "rgb(255,255,255)" ); const canvas = window.document.createElementNS( "http://www.w3.org/1999/xhtml", "canvas" ); canvas.width = canvasWidth; canvas.height = canvasHeight; const ctx = canvas.getContext("2d"); ctx.drawImage(snapshot, 0, 0); // Bug 1574935 - Huge dimensions can trigger an OOM because multiple copies // of the bitmap will exist in memory. Force the removal of the snapshot // because it is no longer needed. snapshot.close(); const url = canvas.toDataURL(`image/${format}`, quality / 100); if (!url.startsWith(`data:image/${format}`)) { throw new lazy.UnsupportedError(`Unsupported MIME type: image/${format}`); } // only return the base64 encoded data without the data URL prefix const data = url.substring(url.indexOf(",") + 1); return { data }; } async enable() { if (this.enabled) { return; } this.enabled = true; const { browser } = this.session.target; this._dialogHandler = new lazy.DialogHandler(browser); this._dialogHandler.on("dialog-loaded", this._onDialogLoaded); await this.executeInChild("enable"); } async disable() { if (!this.enabled) { return; } this._dialogHandler.destructor(); this._dialogHandler = null; this.enabled = false; if (!this._isDestroyed) { // Only call disable in the content domain if we are not destroying the domain. // If we are destroying the domain, the content domains will be destroyed // independently after firing the remote:destroy event. await this.executeInChild("disable"); } } async bringToFront() { const { tab, window } = this.session.target; // Focus the window, and select the corresponding tab await lazy.windowManager.focusWindow(window); await lazy.TabManager.selectTab(tab); } /** * Return metrics relating to the layouting of the page. * * The returned object contains the following entries: * * layoutViewport: * {number} pageX * Horizontal offset relative to the document (CSS pixels) * {number} pageY * Vertical offset relative to the document (CSS pixels) * {number} clientWidth * Width (CSS pixels), excludes scrollbar if present * {number} clientHeight * Height (CSS pixels), excludes scrollbar if present * * visualViewport: * {number} offsetX * Horizontal offset relative to the layout viewport (CSS pixels) * {number} offsetY * Vertical offset relative to the layout viewport (CSS pixels) * {number} pageX * Horizontal offset relative to the document (CSS pixels) * {number} pageY * Vertical offset relative to the document (CSS pixels) * {number} clientWidth * Width (CSS pixels), excludes scrollbar if present * {number} clientHeight * Height (CSS pixels), excludes scrollbar if present * {number} scale * Scale relative to the ideal viewport (size at width=device-width) * {number} zoom * Page zoom factor (CSS to device independent pixels ratio) * * contentSize: * {number} x * X coordinate * {number} y * Y coordinate * {number} width * Width of scrollable area * {number} height * Height of scrollable area * * @returns {Promise} * Promise which resolves with an object with the following properties * layoutViewport and contentSize */ async getLayoutMetrics() { return { layoutViewport: await this.executeInChild("_layoutViewport"), contentSize: await this.executeInChild("_contentRect"), }; } /** * Returns navigation history for the current page. * * @returns {Promise} * Promise which resolves with an object with the following properties * currentIndex (number) and entries (Array). */ async getNavigationHistory() { const { window } = this.session.target; return new Promise(resolve => { function updateSessionHistory(sessionHistory) { const entries = sessionHistory.entries.map(entry => { return { id: entry.ID, url: entry.url, userTypedURL: entry.originalURI || entry.url, title: entry.title, // TODO: Bug 1609514 transitionType: null, }; }); resolve({ currentIndex: sessionHistory.index, entries, }); } lazy.SessionStore.getSessionHistory( window.gBrowser.selectedTab, updateSessionHistory ); }); } /** * Interact with the currently opened JavaScript dialog (alert, confirm, * prompt) for this page. This will always close the dialog, either accepting * or rejecting it, with the optional prompt filled. * * @param {object} options * @param {boolean=} options.accept * for "confirm", "prompt", "beforeunload" dialogs true will accept * the dialog, false will cancel it. For "alert" dialogs, true or * false closes the dialog in the same way. * @param {string=} options.promptText * for "prompt" dialogs, used to fill the prompt input. */ async handleJavaScriptDialog(options = {}) { const { accept, promptText } = options; if (!this.enabled) { throw new Error("Page domain is not enabled"); } await this._dialogHandler.handleJavaScriptDialog({ accept, promptText }); } /** * Navigates current page to the given history entry. * * @param {object} options * @param {number} options.entryId * Unique id of the entry to navigate to. */ async navigateToHistoryEntry(options = {}) { const { entryId } = options; const index = await this._getIndexForHistoryEntryId(entryId); if (index == null) { throw new Error("No entry with passed id"); } const { window } = this.session.target; window.gBrowser.gotoIndex(index); // On some platforms the requested index isn't set immediately. await lazy.PollPromise( async (resolve, reject) => { const currentIndex = await this._getCurrentHistoryIndex(); if (currentIndex == index) { resolve(); } else { reject(); } }, { timeout: TIMEOUT_SET_HISTORY_INDEX } ); } /** * Print page as PDF. * * @param {object} options * @param {boolean=} options.displayHeaderFooter * Display header and footer. Defaults to false. * @param {string=} options.footerTemplate (not supported) * HTML template for the print footer. * @param {string=} options.headerTemplate (not supported) * HTML template for the print header. Should use the same format * as the footerTemplate. * @param {boolean=} options.ignoreInvalidPageRanges * Whether to silently ignore invalid but successfully parsed page ranges, * such as '3-2'. Defaults to false. * @param {boolean=} options.landscape * Paper orientation. Defaults to false. * @param {number=} options.marginBottom * Bottom margin in inches. Defaults to 1cm (~0.4 inches). * @param {number=} options.marginLeft * Left margin in inches. Defaults to 1cm (~0.4 inches). * @param {number=} options.marginRight * Right margin in inches. Defaults to 1cm (~0.4 inches). * @param {number=} options.marginTop * Top margin in inches. Defaults to 1cm (~0.4 inches). * @param {string=} options.pageRanges (not supported) * Paper ranges to print, e.g., '1-5, 8, 11-13'. * Defaults to the empty string, which means print all pages. * @param {number=} options.paperHeight * Paper height in inches. Defaults to 11 inches. * @param {number=} options.paperWidth * Paper width in inches. Defaults to 8.5 inches. * @param {boolean=} options.preferCSSPageSize * Whether or not to prefer page size as defined by CSS. * Defaults to false, in which case the content will be scaled * to fit the paper size. * @param {boolean=} options.printBackground * Print background graphics. Defaults to false. * @param {number=} options.scale * Scale of the webpage rendering. Defaults to 1. * @param {string=} options.transferMode * Return as base64-encoded string (ReturnAsBase64), * or stream (ReturnAsStream). Defaults to ReturnAsBase64. * * @returns {Promise<{data:string, stream:Stream}>} * Based on the transferMode setting data is a base64-encoded string, * or stream is a Stream. */ async printToPDF(options = {}) { const { displayHeaderFooter = false, // Bug 1601570 - Implement templates for header and footer // headerTemplate = "", // footerTemplate = "", landscape = false, marginBottom = 0.39, marginLeft = 0.39, marginRight = 0.39, marginTop = 0.39, // Bug 1601571 - Implement handling of page ranges // TODO: pageRanges = "", // TODO: ignoreInvalidPageRanges = false, paperHeight = 11.0, paperWidth = 8.5, preferCSSPageSize = false, printBackground = false, scale = 1.0, transferMode = PDF_TRANSFER_MODES.base64, } = options; if (marginBottom < 0) { throw new TypeError("marginBottom is negative"); } if (marginLeft < 0) { throw new TypeError("marginLeft is negative"); } if (marginRight < 0) { throw new TypeError("marginRight is negative"); } if (marginTop < 0) { throw new TypeError("marginTop is negative"); } if (scale < PRINT_MIN_SCALE_VALUE || scale > PRINT_MAX_SCALE_VALUE) { throw new TypeError("scale is outside [0.1 - 2] range"); } if (paperHeight <= 0) { throw new TypeError("paperHeight is zero or negative"); } if (paperWidth <= 0) { throw new TypeError("paperWidth is zero or negative"); } const psService = Cc["@mozilla.org/gfx/printsettings-service;1"].getService( Ci.nsIPrintSettingsService ); const printSettings = psService.createNewPrintSettings(); printSettings.isInitializedFromPrinter = true; printSettings.isInitializedFromPrefs = true; printSettings.outputFormat = Ci.nsIPrintSettings.kOutputFormatPDF; printSettings.printerName = ""; printSettings.printSilent = true; printSettings.paperSizeUnit = Ci.nsIPrintSettings.kPaperSizeInches; printSettings.paperWidth = paperWidth; printSettings.paperHeight = paperHeight; // Override any os-specific unwriteable margins printSettings.unwriteableMarginTop = 0; printSettings.unwriteableMarginLeft = 0; printSettings.unwriteableMarginBottom = 0; printSettings.unwriteableMarginRight = 0; printSettings.marginBottom = marginBottom; printSettings.marginLeft = marginLeft; printSettings.marginRight = marginRight; printSettings.marginTop = marginTop; printSettings.printBGColors = printBackground; printSettings.printBGImages = printBackground; printSettings.scaling = scale; printSettings.shrinkToFit = preferCSSPageSize; if (!displayHeaderFooter) { printSettings.headerStrCenter = ""; printSettings.headerStrLeft = ""; printSettings.headerStrRight = ""; printSettings.footerStrCenter = ""; printSettings.footerStrLeft = ""; printSettings.footerStrRight = ""; } if (landscape) { printSettings.orientation = Ci.nsIPrintSettings.kLandscapeOrientation; } const retval = { data: null, stream: null }; const { linkedBrowser } = this.session.target.tab; if (transferMode === PDF_TRANSFER_MODES.stream) { // If we are returning a stream, we write the PDF to disk so that we don't // keep (potentially very large) PDFs in memory. We can then stream them // to the client via the returned Stream. // // NOTE: This is a potentially premature optimization -- it might be fine // to keep these PDFs in memory, but we don't have specifics on how CDP is // used in the field so it is possible that leaving the PDFs in memory // could cause a regression. const path = await IOUtils.createUniqueFile( PathUtils.tempDir, "remote-agent.pdf" ); printSettings.outputDestination = Ci.nsIPrintSettings.kOutputDestinationFile; printSettings.toFileName = path; await linkedBrowser.browsingContext.print(printSettings); retval.stream = lazy.streamRegistry.add(new lazy.Stream(path)); } else { const binaryString = await lazy.print.printToBinaryString( linkedBrowser.browsingContext, printSettings ); retval.data = btoa(binaryString); } return retval; } /** * Intercept file chooser requests and transfer control to protocol clients. * * When file chooser interception is enabled, * the native file chooser dialog is not shown. * Instead, a protocol event Page.fileChooserOpened is emitted. * * @param {object} options * @param {boolean=} options.enabled * Enabled state of file chooser interception. */ setInterceptFileChooserDialog(options = {}) {} _getCurrentHistoryIndex() { const { window } = this.session.target; return new Promise(resolve => { lazy.SessionStore.getSessionHistory( window.gBrowser.selectedTab, history => { resolve(history.index); } ); }); } _getIndexForHistoryEntryId(id) { const { window } = this.session.target; return new Promise(resolve => { function updateSessionHistory(sessionHistory) { sessionHistory.entries.forEach((entry, index) => { if (entry.ID == id) { resolve(index); } }); resolve(null); } lazy.SessionStore.getSessionHistory( window.gBrowser.selectedTab, updateSessionHistory ); }); } /** * Emit the proper CDP event javascriptDialogOpening when a javascript dialog * opens for the current target. */ _onDialogLoaded(e, data) { const { message, type } = data; // XXX: We rely on the common-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; } }