453 lines
13 KiB
JavaScript
453 lines
13 KiB
JavaScript
/* 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,
|
|
};
|
|
}
|
|
}
|