/* 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} */ 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; } await new Promise(resolve => { // Bug 1603739 - With e10s enabled the WebProgressListener states // STOP too early, which means the file hasn't been completely written. const waitForFileWritten = () => { 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 printProgressListener = { onStateChange(webProgress, request, flags, status) { if ( flags & Ci.nsIWebProgressListener.STATE_STOP && flags & Ci.nsIWebProgressListener.STATE_IS_NETWORK ) { waitForFileWritten(); } }, QueryInterface: ChromeUtils.generateQI([Ci.nsIWebProgressListener]), }; const { tab } = this.session.target; tab.linkedBrowser.print( tab.linkedBrowser.outerWindowID, printSettings, printProgressListener ); }); 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; } }