diff options
Diffstat (limited to '')
-rw-r--r-- | remote/cdp/domains/parent/Page.sys.mjs | 775 |
1 files changed, 775 insertions, 0 deletions
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; + } +} |