/* 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 { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", }); const { LOAD_FLAGS_BYPASS_CACHE, LOAD_FLAGS_BYPASS_PROXY, LOAD_FLAGS_NONE } = Ci.nsIWebNavigation; export class Page extends ContentProcessDomain { constructor(session) { super(session); this.enabled = false; this.lifecycleEnabled = false; // script id => { source, worldName } this.scriptsToEvaluateOnLoad = new Map(); this.worldsToEvaluateOnLoad = new Set(); // This map is used to keep a reference to the loader id for // those Page events, which do not directly rely on // Network events. This might be a temporary solution until // the Network observer could be queried for that. But right // now this lives in the parent process. this.frameIdToLoaderId = new Map(); this._onFrameAttached = this._onFrameAttached.bind(this); this._onFrameDetached = this._onFrameDetached.bind(this); this._onFrameNavigated = this._onFrameNavigated.bind(this); this._onScriptLoaded = this._onScriptLoaded.bind(this); this.session.contextObserver.on("script-loaded", this._onScriptLoaded); } destructor() { this.setLifecycleEventsEnabled({ enabled: false }); this.session.contextObserver.off("script-loaded", this._onScriptLoaded); this.disable(); super.destructor(); } // commands async enable() { if (!this.enabled) { this.session.contextObserver.on("frame-attached", this._onFrameAttached); this.session.contextObserver.on("frame-detached", this._onFrameDetached); this.session.contextObserver.on( "frame-navigated", this._onFrameNavigated ); this.chromeEventHandler.addEventListener("readystatechange", this, { mozSystemGroup: true, capture: true, }); this.chromeEventHandler.addEventListener("pagehide", this, { mozSystemGroup: true, }); this.chromeEventHandler.addEventListener("unload", this, { mozSystemGroup: true, capture: true, }); this.chromeEventHandler.addEventListener("DOMContentLoaded", this, { mozSystemGroup: true, }); this.chromeEventHandler.addEventListener("hashchange", this, { mozSystemGroup: true, capture: true, }); this.chromeEventHandler.addEventListener("load", this, { mozSystemGroup: true, capture: true, }); this.chromeEventHandler.addEventListener("pageshow", this, { mozSystemGroup: true, }); this.enabled = true; } } disable() { if (this.enabled) { this.session.contextObserver.off("frame-attached", this._onFrameAttached); this.session.contextObserver.off("frame-detached", this._onFrameDetached); this.session.contextObserver.off( "frame-navigated", this._onFrameNavigated ); this.chromeEventHandler.removeEventListener("readystatechange", this, { mozSystemGroup: true, capture: true, }); this.chromeEventHandler.removeEventListener("pagehide", this, { mozSystemGroup: true, }); this.chromeEventHandler.removeEventListener("unload", this, { mozSystemGroup: true, capture: true, }); this.chromeEventHandler.removeEventListener("DOMContentLoaded", this, { mozSystemGroup: true, }); this.chromeEventHandler.removeEventListener("hashchange", this, { mozSystemGroup: true, capture: true, }); this.chromeEventHandler.removeEventListener("load", this, { mozSystemGroup: true, capture: true, }); this.chromeEventHandler.removeEventListener("pageshow", this, { mozSystemGroup: true, }); this.enabled = false; } } async reload(options = {}) { const { ignoreCache } = options; let flags = LOAD_FLAGS_NONE; if (ignoreCache) { flags |= LOAD_FLAGS_BYPASS_CACHE; flags |= LOAD_FLAGS_BYPASS_PROXY; } this.docShell.reload(flags); } getFrameTree() { const getFrames = context => { const frameTree = { frame: this._getFrameDetails({ context }), }; if (context.children.length) { const frames = []; for (const childContext of context.children) { frames.push(getFrames(childContext)); } frameTree.childFrames = frames; } return frameTree; }; return { frameTree: getFrames(this.docShell.browsingContext), }; } /** * Enqueues given script to be evaluated in every frame upon creation * * If `worldName` is specified, creates an execution context with the given name * and evaluates given script in it. * * At this time, queued scripts do not get evaluated, hence `source` is marked as * "unsupported". * * @param {object} options * @param {string} options.source (not supported) * @param {string=} options.worldName * @returns {string} Page.ScriptIdentifier */ addScriptToEvaluateOnNewDocument(options = {}) { const { source, worldName } = options; if (worldName) { this.worldsToEvaluateOnLoad.add(worldName); } const identifier = lazy.generateUUID(); this.scriptsToEvaluateOnLoad.set(identifier, { worldName, source }); return { identifier }; } /** * Creates an isolated world for the given frame. * * Really it just creates an execution context with label "isolated". * * @param {object} options * @param {string} options.frameId * Id of the frame in which the isolated world should be created. * @param {string=} options.worldName * An optional name which is reported in the Execution Context. * @param {boolean=} options.grantUniversalAccess (not supported) * This is a powerful option, use with caution. * * @returns {number} Runtime.ExecutionContextId * Execution context of the isolated world. */ createIsolatedWorld(options = {}) { const { frameId, worldName } = options; if (typeof frameId != "string") { throw new TypeError("frameId: string value expected"); } if (!["undefined", "string"].includes(typeof worldName)) { throw new TypeError("worldName: string value expected"); } const Runtime = this.session.domains.get("Runtime"); const contexts = Runtime._getContextsForFrame(frameId); if (!contexts.length) { throw new Error("No frame for given id found"); } const defaultContext = Runtime._getDefaultContextForWindow( contexts[0].windowId ); const window = defaultContext.window; const executionContextId = Runtime._onContextCreated("context-created", { windowId: window.windowGlobalChild.innerWindowId, window, isDefault: false, contextName: worldName, contextType: "isolated", }); return { executionContextId }; } /** * Controls whether page will emit lifecycle events. * * @param {object} options * @param {boolean} options.enabled * If true, starts emitting lifecycle events. */ setLifecycleEventsEnabled(options = {}) { const { enabled } = options; this.lifecycleEnabled = enabled; } url() { return this.content.location.href; } _onFrameAttached(name, { frameId, window }) { const bc = BrowsingContext.get(frameId); // Don't emit for top-level browsing contexts if (!bc.parent) { return; } // TODO: Use a unique identifier for frames (bug 1605359) this.emit("Page.frameAttached", { frameId: frameId.toString(), parentFrameId: bc.parent.id.toString(), stack: null, }); // Usually both events are emitted when the "pagehide" event is received. // But this wont happen for a new window or frame when the initial // about:blank page has already loaded, and is being replaced with the // final document. if (!window.document.isInitialDocument) { this.emit("Page.frameStartedLoading", { frameId: frameId.toString() }); const loaderId = this.frameIdToLoaderId.get(frameId); const timestamp = Date.now() / 1000; this.emitLifecycleEvent(frameId, loaderId, "init", timestamp); } } _onFrameDetached(name, { frameId }) { const bc = BrowsingContext.get(frameId); // Don't emit for top-level browsing contexts if (!bc.parent) { return; } // TODO: Use a unique identifier for frames (bug 1605359) this.emit("Page.frameDetached", { frameId: frameId.toString() }); } _onFrameNavigated(name, { frameId }) { const bc = BrowsingContext.get(frameId); this.emit("Page.frameNavigated", { frame: this._getFrameDetails({ context: bc }), }); } /** * @param {string} name * The event name. * @param {object=} options * @param {number} options.windowId * The inner window id of the window the script has been loaded for. * @param {Window} options.window * The window object of the document. */ _onScriptLoaded(name, options = {}) { const { windowId, window } = options; const Runtime = this.session.domains.get("Runtime"); for (const world of this.worldsToEvaluateOnLoad) { Runtime._onContextCreated("context-created", { windowId, window, isDefault: false, contextName: world, contextType: "isolated", }); } // TODO evaluate each onNewDoc script in the appropriate world } emitLifecycleEvent(frameId, loaderId, name, timestamp) { if (this.lifecycleEnabled) { this.emit("Page.lifecycleEvent", { frameId: frameId.toString(), loaderId, name, timestamp, }); } } handleEvent({ type, target }) { const timestamp = Date.now() / 1000; // Some events such as "hashchange" use the window as the target, while // others have a document. const win = Window.isInstance(target) ? target : target.defaultView; const frameId = win.docShell.browsingContext.id; const isFrame = !!win.docShell.browsingContext.parent; const loaderId = this.frameIdToLoaderId.get(frameId); const url = win.location.href; switch (type) { case "DOMContentLoaded": if (!isFrame) { this.emit("Page.domContentEventFired", { timestamp }); } this.emitLifecycleEvent( frameId, loaderId, "DOMContentLoaded", timestamp ); break; case "hashchange": this.emit("Page.navigatedWithinDocument", { frameId: frameId.toString(), url, }); break; case "pagehide": // Maybe better to bound to "unload" once we can register for this event this.emit("Page.frameStartedLoading", { frameId: frameId.toString() }); this.emitLifecycleEvent(frameId, loaderId, "init", timestamp); break; case "load": if (!isFrame) { this.emit("Page.loadEventFired", { timestamp }); } this.emitLifecycleEvent(frameId, loaderId, "load", timestamp); // XXX this should most likely be sent differently this.emit("Page.frameStoppedLoading", { frameId: frameId.toString() }); break; case "readystatechange": if (this.content.document.readyState === "loading") { this.emitLifecycleEvent(frameId, loaderId, "init", timestamp); } } } _updateLoaderId(data) { const { frameId, loaderId } = data; this.frameIdToLoaderId.set(frameId, loaderId); } _contentRect() { const docEl = this.content.document.documentElement; return { x: 0, y: 0, width: docEl.scrollWidth, height: docEl.scrollHeight, }; } _devicePixelRatio() { return ( this.content.browsingContext.overrideDPPX || this.content.devicePixelRatio ); } _getFrameDetails({ context, id }) { const bc = context || BrowsingContext.get(id); const frame = bc.embedderElement; return { id: bc.id.toString(), parentId: bc.parent?.id.toString(), loaderId: this.frameIdToLoaderId.get(bc.id), url: bc.docShell.domWindow.location.href, name: frame?.id || frame?.name, securityOrigin: null, mimeType: null, }; } _getScrollbarSize() { const scrollbarHeight = {}; const scrollbarWidth = {}; this.content.windowUtils.getScrollbarSize( false, scrollbarWidth, scrollbarHeight ); return { width: scrollbarWidth.value, height: scrollbarHeight.value, }; } _layoutViewport() { const scrollbarSize = this._getScrollbarSize(); return { pageX: this.content.pageXOffset, pageY: this.content.pageYOffset, clientWidth: this.content.innerWidth - scrollbarSize.width, clientHeight: this.content.innerHeight - scrollbarSize.height, }; } }