360 lines
12 KiB
JavaScript
360 lines
12 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/. */
|
|
|
|
"use strict";
|
|
|
|
/**
|
|
* Here's the server side of the remote inspector.
|
|
*
|
|
* The WalkerActor is the client's view of the debuggee's DOM. It's gives
|
|
* the client a tree of NodeActor objects.
|
|
*
|
|
* The walker presents the DOM tree mostly unmodified from the source DOM
|
|
* tree, but with a few key differences:
|
|
*
|
|
* - Empty text nodes are ignored. This is pretty typical of developer
|
|
* tools, but maybe we should reconsider that on the server side.
|
|
* - iframes with documents loaded have the loaded document as the child,
|
|
* the walker provides one big tree for the whole document tree.
|
|
*
|
|
* There are a few ways to get references to NodeActors:
|
|
*
|
|
* - When you first get a WalkerActor reference, it comes with a free
|
|
* reference to the root document's node.
|
|
* - Given a node, you can ask for children, siblings, and parents.
|
|
* - You can issue querySelector and querySelectorAll requests to find
|
|
* other elements.
|
|
* - Requests that return arbitrary nodes from the tree (like querySelector
|
|
* and querySelectorAll) will also return any nodes the client hasn't
|
|
* seen in order to have a complete set of parents.
|
|
*
|
|
* Once you have a NodeFront, you should be able to answer a few questions
|
|
* without further round trips, like the node's name, namespace/tagName,
|
|
* attributes, etc. Other questions (like a text node's full nodeValue)
|
|
* might require another round trip.
|
|
*
|
|
* The protocol guarantees that the client will always know the parent of
|
|
* any node that is returned by the server. This means that some requests
|
|
* (like querySelector) will include the extra nodes needed to satisfy this
|
|
* requirement. The client keeps track of this parent relationship, so the
|
|
* node fronts form a tree that is a subset of the actual DOM tree.
|
|
*
|
|
*
|
|
* We maintain this guarantee to support the ability to release subtrees on
|
|
* the client - when a node is disconnected from the DOM tree we want to be
|
|
* able to free the client objects for all the children nodes.
|
|
*
|
|
* So to be able to answer "all the children of a given node that we have
|
|
* seen on the client side", we guarantee that every time we've seen a node,
|
|
* we connect it up through its parents.
|
|
*/
|
|
|
|
const { Actor } = require("resource://devtools/shared/protocol.js");
|
|
const {
|
|
inspectorSpec,
|
|
} = require("resource://devtools/shared/specs/inspector.js");
|
|
|
|
const { setTimeout } = ChromeUtils.importESModule(
|
|
"resource://gre/modules/Timer.sys.mjs",
|
|
{ global: "contextual" }
|
|
);
|
|
const {
|
|
LongStringActor,
|
|
} = require("resource://devtools/server/actors/string.js");
|
|
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
"InspectorActorUtils",
|
|
"resource://devtools/server/actors/inspector/utils.js"
|
|
);
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
"WalkerActor",
|
|
"resource://devtools/server/actors/inspector/walker.js",
|
|
true
|
|
);
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
"EyeDropper",
|
|
"resource://devtools/server/actors/highlighters/eye-dropper.js",
|
|
true
|
|
);
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
"PageStyleActor",
|
|
"resource://devtools/server/actors/page-style.js",
|
|
true
|
|
);
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
["CustomHighlighterActor", "isTypeRegistered", "HighlighterEnvironment"],
|
|
"resource://devtools/server/actors/highlighters.js",
|
|
true
|
|
);
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
"CompatibilityActor",
|
|
"resource://devtools/server/actors/compatibility/compatibility.js",
|
|
true
|
|
);
|
|
|
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
|
|
|
|
/**
|
|
* Server side of the inspector actor, which is used to create
|
|
* inspector-related actors, including the walker.
|
|
*/
|
|
class InspectorActor extends Actor {
|
|
constructor(conn, targetActor) {
|
|
super(conn, inspectorSpec);
|
|
this.targetActor = targetActor;
|
|
|
|
this._onColorPicked = this._onColorPicked.bind(this);
|
|
this._onColorPickCanceled = this._onColorPickCanceled.bind(this);
|
|
this.destroyEyeDropper = this.destroyEyeDropper.bind(this);
|
|
}
|
|
|
|
highlightersState = {
|
|
fadingViewportSizeHiglighter: null,
|
|
};
|
|
|
|
destroy() {
|
|
super.destroy();
|
|
this.destroyEyeDropper();
|
|
|
|
this._compatibility = null;
|
|
this._pageStylePromise = null;
|
|
this._walkerPromise = null;
|
|
this.walker = null;
|
|
this.targetActor = null;
|
|
}
|
|
|
|
get window() {
|
|
return this.targetActor.window;
|
|
}
|
|
|
|
getWalker(options = {}) {
|
|
if (this._walkerPromise) {
|
|
return this._walkerPromise;
|
|
}
|
|
|
|
this._walkerPromise = new Promise(resolve => {
|
|
const domReady = () => {
|
|
const targetActor = this.targetActor;
|
|
this.walker = new WalkerActor(this.conn, targetActor, options);
|
|
this.manage(this.walker);
|
|
this.walker.once("destroyed", () => {
|
|
this._walkerPromise = null;
|
|
this._pageStylePromise = null;
|
|
});
|
|
resolve(this.walker);
|
|
};
|
|
|
|
if (this.window.document.readyState === "loading") {
|
|
// Expose an abort controller for DOMContentLoaded to remove the
|
|
// listener unconditionally, even if the race hits the timeout.
|
|
const abortController = new AbortController();
|
|
Promise.race([
|
|
new Promise(r => {
|
|
this.window.addEventListener("DOMContentLoaded", r, {
|
|
capture: true,
|
|
once: true,
|
|
signal: abortController.signal,
|
|
});
|
|
}),
|
|
// The DOMContentLoaded event will never be emitted on documents stuck
|
|
// in the loading state, for instance if document.write was called
|
|
// without calling document.close.
|
|
// TODO: It is not clear why we are waiting for the event overall, see
|
|
// Bug 1766279 to actually stop listening to the event altogether.
|
|
new Promise(r => setTimeout(r, 500)),
|
|
])
|
|
.then(domReady)
|
|
.finally(() => abortController.abort());
|
|
} else {
|
|
domReady();
|
|
}
|
|
});
|
|
|
|
return this._walkerPromise;
|
|
}
|
|
|
|
getPageStyle() {
|
|
if (this._pageStylePromise) {
|
|
return this._pageStylePromise;
|
|
}
|
|
|
|
this._pageStylePromise = this.getWalker().then(() => {
|
|
const pageStyle = new PageStyleActor(this);
|
|
this.manage(pageStyle);
|
|
return pageStyle;
|
|
});
|
|
return this._pageStylePromise;
|
|
}
|
|
|
|
getCompatibility() {
|
|
if (this._compatibility) {
|
|
return this._compatibility;
|
|
}
|
|
|
|
this._compatibility = new CompatibilityActor(this);
|
|
this.manage(this._compatibility);
|
|
return this._compatibility;
|
|
}
|
|
|
|
/**
|
|
* If consumers need to display several highlighters at the same time or
|
|
* different types of highlighters, then this method should be used, passing
|
|
* the type name of the highlighter needed as argument.
|
|
* A new instance will be created everytime the method is called, so it's up
|
|
* to the consumer to release it when it is not needed anymore
|
|
*
|
|
* @param {String} type The type of highlighter to create
|
|
* @return {Highlighter} The highlighter actor instance or null if the
|
|
* typeName passed doesn't match any available highlighter
|
|
*/
|
|
async getHighlighterByType(typeName) {
|
|
if (isTypeRegistered(typeName)) {
|
|
const highlighterActor = new CustomHighlighterActor(this, typeName);
|
|
if (highlighterActor.instance.isReady) {
|
|
await highlighterActor.instance.isReady;
|
|
}
|
|
|
|
return highlighterActor;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get the node's image data if any (for canvas and img nodes).
|
|
* Returns an imageData object with the actual data being a LongStringActor
|
|
* and a size json object.
|
|
* The image data is transmitted as a base64 encoded png data-uri.
|
|
* The method rejects if the node isn't an image or if the image is missing
|
|
*
|
|
* Accepts a maxDim request parameter to resize images that are larger. This
|
|
* is important as the resizing occurs server-side so that image-data being
|
|
* transfered in the longstring back to the client will be that much smaller
|
|
*/
|
|
getImageDataFromURL(url, maxDim) {
|
|
const img = new this.window.Image();
|
|
img.src = url;
|
|
|
|
// imageToImageData waits for the image to load.
|
|
return InspectorActorUtils.imageToImageData(img, maxDim).then(imageData => {
|
|
return {
|
|
data: new LongStringActor(this.conn, imageData.data),
|
|
size: imageData.size,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resolve a URL to its absolute form, in the scope of a given content window.
|
|
* @param {String} url.
|
|
* @param {NodeActor} node If provided, the owner window of this node will be
|
|
* used to resolve the URL. Otherwise, the top-level content window will be
|
|
* used instead.
|
|
* @return {String} url.
|
|
*/
|
|
resolveRelativeURL(url, node) {
|
|
const document = InspectorActorUtils.isNodeDead(node)
|
|
? this.window.document
|
|
: InspectorActorUtils.nodeDocument(node.rawNode);
|
|
|
|
if (!document) {
|
|
return url;
|
|
}
|
|
|
|
const baseURI = Services.io.newURI(document.baseURI);
|
|
return Services.io.newURI(url, null, baseURI).spec;
|
|
}
|
|
|
|
/**
|
|
* Create an instance of the eye-dropper highlighter and store it on this._eyeDropper.
|
|
* Note that for now, a new instance is created every time to deal with page navigation.
|
|
*/
|
|
createEyeDropper() {
|
|
this.destroyEyeDropper();
|
|
this._highlighterEnv = new HighlighterEnvironment();
|
|
this._highlighterEnv.initFromTargetActor(this.targetActor);
|
|
this._eyeDropper = new EyeDropper(this._highlighterEnv);
|
|
return this._eyeDropper.isReady;
|
|
}
|
|
|
|
/**
|
|
* Destroy the current eye-dropper highlighter instance.
|
|
*/
|
|
destroyEyeDropper() {
|
|
if (this._eyeDropper) {
|
|
this.cancelPickColorFromPage();
|
|
this._eyeDropper.destroy();
|
|
this._eyeDropper = null;
|
|
this._highlighterEnv.destroy();
|
|
this._highlighterEnv = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pick a color from the page using the eye-dropper. This method doesn't return anything
|
|
* but will cause events to be sent to the front when a color is picked or when the user
|
|
* cancels the picker.
|
|
* @param {Object} options
|
|
*/
|
|
async pickColorFromPage(options) {
|
|
await this.createEyeDropper();
|
|
this._eyeDropper.show(this.window.document.documentElement, options);
|
|
this._eyeDropper.once("selected", this._onColorPicked);
|
|
this._eyeDropper.once("canceled", this._onColorPickCanceled);
|
|
this.targetActor.once("will-navigate", this.destroyEyeDropper);
|
|
}
|
|
|
|
/**
|
|
* After the pickColorFromPage method is called, the only way to dismiss the eye-dropper
|
|
* highlighter is for the user to click in the page and select a color. If you need to
|
|
* dismiss the eye-dropper programatically instead, use this method.
|
|
*/
|
|
cancelPickColorFromPage() {
|
|
if (this._eyeDropper) {
|
|
this._eyeDropper.hide();
|
|
this._eyeDropper.off("selected", this._onColorPicked);
|
|
this._eyeDropper.off("canceled", this._onColorPickCanceled);
|
|
this.targetActor.off("will-navigate", this.destroyEyeDropper);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if the current document supports highlighters using a canvasFrame anonymous
|
|
* content container.
|
|
* It is impossible to detect the feature programmatically as some document types simply
|
|
* don't render the canvasFrame without throwing any error.
|
|
*/
|
|
supportsHighlighters() {
|
|
const doc = this.targetActor.window.document;
|
|
const ns = doc.documentElement.namespaceURI;
|
|
|
|
// XUL documents do not support insertAnonymousContent().
|
|
if (ns === XUL_NS) {
|
|
return false;
|
|
}
|
|
|
|
// SVG documents do not render the canvasFrame (see Bug 1157592).
|
|
if (ns === SVG_NS) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
_onColorPicked(color) {
|
|
this.emit("color-picked", color);
|
|
}
|
|
|
|
_onColorPickCanceled() {
|
|
this.emit("color-pick-canceled");
|
|
}
|
|
}
|
|
|
|
exports.InspectorActor = InspectorActor;
|