/* 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"; require("devtools/shared/fronts/styles"); require("devtools/shared/fronts/highlighters"); require("devtools/shared/fronts/layout"); const { SimpleStringFront } = require("devtools/shared/fronts/string"); const { Front, FrontClassWithSpec, custom, preEvent, types } = require("devtools/shared/protocol.js"); const { inspectorSpec, nodeSpec, nodeListSpec, walkerSpec } = require("devtools/shared/specs/inspector"); const promise = require("promise"); const defer = require("devtools/shared/defer"); const { Task } = require("devtools/shared/task"); const { Class } = require("sdk/core/heritage"); const events = require("sdk/event/core"); const object = require("sdk/util/object"); const nodeConstants = require("devtools/shared/dom-node-constants.js"); loader.lazyRequireGetter(this, "CommandUtils", "devtools/client/shared/developer-toolbar", true); const HIDDEN_CLASS = "__fx-devtools-hide-shortcut__"; /** * Convenience API for building a list of attribute modifications * for the `modifyAttributes` request. */ const AttributeModificationList = Class({ initialize: function (node) { this.node = node; this.modifications = []; }, apply: function () { let ret = this.node.modifyAttributes(this.modifications); return ret; }, destroy: function () { this.node = null; this.modification = null; }, setAttributeNS: function (ns, name, value) { this.modifications.push({ attributeNamespace: ns, attributeName: name, newValue: value }); }, setAttribute: function (name, value) { this.setAttributeNS(undefined, name, value); }, removeAttributeNS: function (ns, name) { this.setAttributeNS(ns, name, undefined); }, removeAttribute: function (name) { this.setAttributeNS(undefined, name, undefined); } }); /** * Client side of the node actor. * * Node fronts are strored in a tree that mirrors the DOM tree on the * server, but with a few key differences: * - Not all children will be necessary loaded for each node. * - The order of children isn't guaranteed to be the same as the DOM. * Children are stored in a doubly-linked list, to make addition/removal * and traversal quick. * * Due to the order/incompleteness of the child list, it is safe to use * the parent node from clients, but the `children` request should be used * to traverse children. */ const NodeFront = FrontClassWithSpec(nodeSpec, { initialize: function (conn, form, detail, ctx) { // The parent node this._parent = null; // The first child of this node. this._child = null; // The next sibling of this node. this._next = null; // The previous sibling of this node. this._prev = null; Front.prototype.initialize.call(this, conn, form, detail, ctx); }, /** * Destroy a node front. The node must have been removed from the * ownership tree before this is called, unless the whole walker front * is being destroyed. */ destroy: function () { Front.prototype.destroy.call(this); }, // Update the object given a form representation off the wire. form: function (form, detail, ctx) { if (detail === "actorid") { this.actorID = form; return; } // backward-compatibility: shortValue indicates we are connected to old server if (form.shortValue) { // If the value is not complete, set nodeValue to null, it will be fetched // when calling getNodeValue() form.nodeValue = form.incompleteValue ? null : form.shortValue; } // Shallow copy of the form. We could just store a reference, but // eventually we'll want to update some of the data. this._form = object.merge(form); this._form.attrs = this._form.attrs ? this._form.attrs.slice() : []; if (form.parent) { // Get the owner actor for this actor (the walker), and find the // parent node of this actor from it, creating a standin node if // necessary. let parentNodeFront = ctx.marshallPool().ensureParentFront(form.parent); this.reparent(parentNodeFront); } if (form.inlineTextChild) { this.inlineTextChild = types.getType("domnode").read(form.inlineTextChild, ctx); } else { this.inlineTextChild = undefined; } }, /** * Returns the parent NodeFront for this NodeFront. */ parentNode: function () { return this._parent; }, /** * Process a mutation entry as returned from the walker's `getMutations` * request. Only tries to handle changes of the node's contents * themselves (character data and attribute changes), the walker itself * will keep the ownership tree up to date. */ updateMutation: function (change) { if (change.type === "attributes") { // We'll need to lazily reparse the attributes after this change. this._attrMap = undefined; // Update any already-existing attributes. let found = false; for (let i = 0; i < this.attributes.length; i++) { let attr = this.attributes[i]; if (attr.name == change.attributeName && attr.namespace == change.attributeNamespace) { if (change.newValue !== null) { attr.value = change.newValue; } else { this.attributes.splice(i, 1); } found = true; break; } } // This is a new attribute. The null check is because of Bug 1192270, // in the case of a newly added then removed attribute if (!found && change.newValue !== null) { this.attributes.push({ name: change.attributeName, namespace: change.attributeNamespace, value: change.newValue }); } } else if (change.type === "characterData") { this._form.nodeValue = change.newValue; } else if (change.type === "pseudoClassLock") { this._form.pseudoClassLocks = change.pseudoClassLocks; } else if (change.type === "events") { this._form.hasEventListeners = change.hasEventListeners; } }, // Some accessors to make NodeFront feel more like an nsIDOMNode get id() { return this.getAttribute("id"); }, get nodeType() { return this._form.nodeType; }, get namespaceURI() { return this._form.namespaceURI; }, get nodeName() { return this._form.nodeName; }, get displayName() { let {displayName, nodeName} = this._form; // Keep `nodeName.toLowerCase()` for backward compatibility return displayName || nodeName.toLowerCase(); }, get doctypeString() { return ""; }, get baseURI() { return this._form.baseURI; }, get className() { return this.getAttribute("class") || ""; }, get hasChildren() { return this._form.numChildren > 0; }, get numChildren() { return this._form.numChildren; }, get hasEventListeners() { return this._form.hasEventListeners; }, get isBeforePseudoElement() { return this._form.isBeforePseudoElement; }, get isAfterPseudoElement() { return this._form.isAfterPseudoElement; }, get isPseudoElement() { return this.isBeforePseudoElement || this.isAfterPseudoElement; }, get isAnonymous() { return this._form.isAnonymous; }, get isInHTMLDocument() { return this._form.isInHTMLDocument; }, get tagName() { return this.nodeType === nodeConstants.ELEMENT_NODE ? this.nodeName : null; }, get isDocumentElement() { return !!this._form.isDocumentElement; }, // doctype properties get name() { return this._form.name; }, get publicId() { return this._form.publicId; }, get systemId() { return this._form.systemId; }, getAttribute: function (name) { let attr = this._getAttribute(name); return attr ? attr.value : null; }, hasAttribute: function (name) { this._cacheAttributes(); return (name in this._attrMap); }, get hidden() { let cls = this.getAttribute("class"); return cls && cls.indexOf(HIDDEN_CLASS) > -1; }, get attributes() { return this._form.attrs; }, get pseudoClassLocks() { return this._form.pseudoClassLocks || []; }, hasPseudoClassLock: function (pseudo) { return this.pseudoClassLocks.some(locked => locked === pseudo); }, get isDisplayed() { // The NodeActor's form contains the isDisplayed information as a boolean // starting from FF32. Before that, the property is missing return "isDisplayed" in this._form ? this._form.isDisplayed : true; }, get isTreeDisplayed() { let parent = this; while (parent) { if (!parent.isDisplayed) { return false; } parent = parent.parentNode(); } return true; }, getNodeValue: custom(function () { // backward-compatibility: if nodevalue is null and shortValue is defined, the actual // value of the node needs to be fetched on the server. if (this._form.nodeValue === null && this._form.shortValue) { return this._getNodeValue(); } let str = this._form.nodeValue || ""; return promise.resolve(new SimpleStringFront(str)); }, { impl: "_getNodeValue" }), // Accessors for custom form properties. getFormProperty: function (name) { return this._form.props ? this._form.props[name] : null; }, hasFormProperty: function (name) { return this._form.props ? (name in this._form.props) : null; }, get formProperties() { return this._form.props; }, /** * Return a new AttributeModificationList for this node. */ startModifyingAttributes: function () { return AttributeModificationList(this); }, _cacheAttributes: function () { if (typeof this._attrMap != "undefined") { return; } this._attrMap = {}; for (let attr of this.attributes) { this._attrMap[attr.name] = attr; } }, _getAttribute: function (name) { this._cacheAttributes(); return this._attrMap[name] || undefined; }, /** * Set this node's parent. Note that the children saved in * this tree are unordered and incomplete, so shouldn't be used * instead of a `children` request. */ reparent: function (parent) { if (this._parent === parent) { return; } if (this._parent && this._parent._child === this) { this._parent._child = this._next; } if (this._prev) { this._prev._next = this._next; } if (this._next) { this._next._prev = this._prev; } this._next = null; this._prev = null; this._parent = parent; if (!parent) { // Subtree is disconnected, we're done return; } this._next = parent._child; if (this._next) { this._next._prev = this; } parent._child = this; }, /** * Return all the known children of this node. */ treeChildren: function () { let ret = []; for (let child = this._child; child != null; child = child._next) { ret.push(child); } return ret; }, /** * Do we use a local target? * Useful to know if a rawNode is available or not. * * This will, one day, be removed. External code should * not need to know if the target is remote or not. */ isLocalToBeDeprecated: function () { return !!this.conn._transport._serverConnection; }, /** * Get an nsIDOMNode for the given node front. This only works locally, * and is only intended as a stopgap during the transition to the remote * protocol. If you depend on this you're likely to break soon. */ rawNode: function (rawNode) { if (!this.isLocalToBeDeprecated()) { console.warn("Tried to use rawNode on a remote connection."); return null; } const { DebuggerServer } = require("devtools/server/main"); let actor = DebuggerServer._searchAllConnectionsForActor(this.actorID); if (!actor) { // Can happen if we try to get the raw node for an already-expired // actor. return null; } return actor.rawNode; } }); exports.NodeFront = NodeFront; /** * Client side of a node list as returned by querySelectorAll() */ const NodeListFront = FrontClassWithSpec(nodeListSpec, { initialize: function (client, form) { Front.prototype.initialize.call(this, client, form); }, destroy: function () { Front.prototype.destroy.call(this); }, marshallPool: function () { return this.parent(); }, // Update the object given a form representation off the wire. form: function (json) { this.length = json.length; }, item: custom(function (index) { return this._item(index).then(response => { return response.node; }); }, { impl: "_item" }), items: custom(function (start, end) { return this._items(start, end).then(response => { return response.nodes; }); }, { impl: "_items" }) }); exports.NodeListFront = NodeListFront; /** * Client side of the DOM walker. */ const WalkerFront = FrontClassWithSpec(walkerSpec, { // Set to true if cleanup should be requested after every mutation list. autoCleanup: true, /** * This is kept for backward-compatibility reasons with older remote target. * Targets previous to bug 916443 */ pick: custom(function () { return this._pick().then(response => { return response.node; }); }, {impl: "_pick"}), initialize: function (client, form) { this._createRootNodePromise(); Front.prototype.initialize.call(this, client, form); this._orphaned = new Set(); this._retainedOrphans = new Set(); }, destroy: function () { Front.prototype.destroy.call(this); }, // Update the object given a form representation off the wire. form: function (json) { this.actorID = json.actor; this.rootNode = types.getType("domnode").read(json.root, this); this._rootNodeDeferred.resolve(this.rootNode); // FF42+ the actor starts exposing traits this.traits = json.traits || {}; }, /** * Clients can use walker.rootNode to get the current root node of the * walker, but during a reload the root node might be null. This * method returns a promise that will resolve to the root node when it is * set. */ getRootNode: function () { return this._rootNodeDeferred.promise; }, /** * Create the root node promise, triggering the "new-root" notification * on resolution. */ _createRootNodePromise: function () { this._rootNodeDeferred = defer(); this._rootNodeDeferred.promise.then(() => { events.emit(this, "new-root"); }); }, /** * When reading an actor form off the wire, we want to hook it up to its * parent front. The protocol guarantees that the parent will be seen * by the client in either a previous or the current request. * So if we've already seen this parent return it, otherwise create * a bare-bones stand-in node. The stand-in node will be updated * with a real form by the end of the deserialization. */ ensureParentFront: function (id) { let front = this.get(id); if (front) { return front; } return types.getType("domnode").read({ actor: id }, this, "standin"); }, /** * See the documentation for WalkerActor.prototype.retainNode for * information on retained nodes. * * From the client's perspective, `retainNode` can fail if the node in * question is removed from the ownership tree before the `retainNode` * request reaches the server. This can only happen if the client has * asked the server to release nodes but hasn't gotten a response * yet: Either a `releaseNode` request or a `getMutations` with `cleanup` * set is outstanding. * * If either of those requests is outstanding AND releases the retained * node, this request will fail with noSuchActor, but the ownership tree * will stay in a consistent state. * * Because the protocol guarantees that requests will be processed and * responses received in the order they were sent, we get the right * semantics by setting our local retained flag on the node only AFTER * a SUCCESSFUL retainNode call. */ retainNode: custom(function (node) { return this._retainNode(node).then(() => { node.retained = true; }); }, { impl: "_retainNode", }), unretainNode: custom(function (node) { return this._unretainNode(node).then(() => { node.retained = false; if (this._retainedOrphans.has(node)) { this._retainedOrphans.delete(node); this._releaseFront(node); } }); }, { impl: "_unretainNode" }), releaseNode: custom(function (node, options = {}) { // NodeFront.destroy will destroy children in the ownership tree too, // mimicking what the server will do here. let actorID = node.actorID; this._releaseFront(node, !!options.force); return this._releaseNode({ actorID: actorID }); }, { impl: "_releaseNode" }), findInspectingNode: custom(function () { return this._findInspectingNode().then(response => { return response.node; }); }, { impl: "_findInspectingNode" }), querySelector: custom(function (queryNode, selector) { return this._querySelector(queryNode, selector).then(response => { return response.node; }); }, { impl: "_querySelector" }), getNodeActorFromObjectActor: custom(function (objectActorID) { return this._getNodeActorFromObjectActor(objectActorID).then(response => { return response ? response.node : null; }); }, { impl: "_getNodeActorFromObjectActor" }), getStyleSheetOwnerNode: custom(function (styleSheetActorID) { return this._getStyleSheetOwnerNode(styleSheetActorID).then(response => { return response ? response.node : null; }); }, { impl: "_getStyleSheetOwnerNode" }), getNodeFromActor: custom(function (actorID, path) { return this._getNodeFromActor(actorID, path).then(response => { return response ? response.node : null; }); }, { impl: "_getNodeFromActor" }), /* * Incrementally search the document for a given string. * For modern servers, results will be searched with using the WalkerActor * `search` function (includes tag names, attributes, and text contents). * Only 1 result is sent back, and calling the method again with the same * query will send the next result. When there are no more results to be sent * back, null is sent. * @param {String} query * @param {Object} options * - "reverse": search backwards * - "selectorOnly": treat input as a selector string (don't search text * tags, attributes, etc) */ search: custom(Task.async(function* (query, options = { }) { let nodeList; let searchType; let searchData = this.searchData = this.searchData || { }; let selectorOnly = !!options.selectorOnly; // Backwards compat. Use selector only search if the new // search functionality isn't implemented, or if the caller (tests) // want it. if (selectorOnly || !this.traits.textSearch) { searchType = "selector"; if (this.traits.multiFrameQuerySelectorAll) { nodeList = yield this.multiFrameQuerySelectorAll(query); } else { nodeList = yield this.querySelectorAll(this.rootNode, query); } } else { searchType = "search"; let result = yield this._search(query, options); nodeList = result.list; } // If this is a new search, start at the beginning. if (searchData.query !== query || searchData.selectorOnly !== selectorOnly) { searchData.selectorOnly = selectorOnly; searchData.query = query; searchData.index = -1; } if (!nodeList.length) { return null; } // Move search result cursor and cycle if necessary. searchData.index = options.reverse ? searchData.index - 1 : searchData.index + 1; if (searchData.index >= nodeList.length) { searchData.index = 0; } if (searchData.index < 0) { searchData.index = nodeList.length - 1; } // Send back the single node, along with any relevant search data let node = yield nodeList.item(searchData.index); return { type: searchType, node: node, resultsLength: nodeList.length, resultsIndex: searchData.index, }; }), { impl: "_search" }), _releaseFront: function (node, force) { if (node.retained && !force) { node.reparent(null); this._retainedOrphans.add(node); return; } if (node.retained) { // Forcing a removal. this._retainedOrphans.delete(node); } // Release any children for (let child of node.treeChildren()) { this._releaseFront(child, force); } // All children will have been removed from the node by this point. node.reparent(null); node.destroy(); }, /** * Get any unprocessed mutation records and process them. */ getMutations: custom(function (options = {}) { return this._getMutations(options).then(mutations => { let emitMutations = []; for (let change of mutations) { // The target is only an actorID, get the associated front. let targetID; let targetFront; if (change.type === "newRoot") { // We may receive a new root without receiving any documentUnload // beforehand. Like when opening tools in middle of a document load. if (this.rootNode) { this._createRootNodePromise(); } this.rootNode = types.getType("domnode").read(change.target, this); this._rootNodeDeferred.resolve(this.rootNode); targetID = this.rootNode.actorID; targetFront = this.rootNode; } else { targetID = change.target; targetFront = this.get(targetID); } if (!targetFront) { console.trace("Got a mutation for an unexpected actor: " + targetID + ", please file a bug on bugzilla.mozilla.org!"); continue; } let emittedMutation = object.merge(change, { target: targetFront }); if (change.type === "childList" || change.type === "nativeAnonymousChildList") { // Update the ownership tree according to the mutation record. let addedFronts = []; let removedFronts = []; for (let removed of change.removed) { let removedFront = this.get(removed); if (!removedFront) { console.error("Got a removal of an actor we didn't know about: " + removed); continue; } // Remove from the ownership tree removedFront.reparent(null); // This node is orphaned unless we get it in the 'added' list // eventually. this._orphaned.add(removedFront); removedFronts.push(removedFront); } for (let added of change.added) { let addedFront = this.get(added); if (!addedFront) { console.error("Got an addition of an actor we didn't know " + "about: " + added); continue; } addedFront.reparent(targetFront); // The actor is reconnected to the ownership tree, unorphan // it. this._orphaned.delete(addedFront); addedFronts.push(addedFront); } // Before passing to users, replace the added and removed actor // ids with front in the mutation record. emittedMutation.added = addedFronts; emittedMutation.removed = removedFronts; // If this is coming from a DOM mutation, the actor's numChildren // was passed in. Otherwise, it is simulated from a frame load or // unload, so don't change the front's form. if ("numChildren" in change) { targetFront._form.numChildren = change.numChildren; } } else if (change.type === "frameLoad") { // Nothing we need to do here, except verify that we don't have any // document children, because we should have gotten a documentUnload // first. for (let child of targetFront.treeChildren()) { if (child.nodeType === nodeConstants.DOCUMENT_NODE) { console.trace("Got an unexpected frameLoad in the inspector, " + "please file a bug on bugzilla.mozilla.org!"); } } } else if (change.type === "documentUnload") { if (targetFront === this.rootNode) { this._createRootNodePromise(); } // We try to give fronts instead of actorIDs, but these fronts need // to be destroyed now. emittedMutation.target = targetFront.actorID; emittedMutation.targetParent = targetFront.parentNode(); // Release the document node and all of its children, even retained. this._releaseFront(targetFront, true); } else if (change.type === "unretained") { // Retained orphans were force-released without the intervention of // client (probably a navigated frame). for (let released of change.nodes) { let releasedFront = this.get(released); this._retainedOrphans.delete(released); this._releaseFront(releasedFront, true); } } else { targetFront.updateMutation(change); } // Update the inlineTextChild property of the target for a selected list of // mutation types. if (change.type === "inlineTextChild" || change.type === "childList" || change.type === "nativeAnonymousChildList") { if (change.inlineTextChild) { targetFront.inlineTextChild = types.getType("domnode").read(change.inlineTextChild, this); } else { targetFront.inlineTextChild = undefined; } } emitMutations.push(emittedMutation); } if (options.cleanup) { for (let node of this._orphaned) { // This will move retained nodes to this._retainedOrphans. this._releaseFront(node); } this._orphaned = new Set(); } events.emit(this, "mutations", emitMutations); }); }, { impl: "_getMutations" }), /** * Handle the `new-mutations` notification by fetching the * available mutation records. */ onMutations: preEvent("new-mutations", function () { // Fetch and process the mutations. this.getMutations({cleanup: this.autoCleanup}).catch(() => {}); }), isLocal: function () { return !!this.conn._transport._serverConnection; }, // XXX hack during transition to remote inspector: get a proper NodeFront // for a given local node. Only works locally. frontForRawNode: function (rawNode) { if (!this.isLocal()) { console.warn("Tried to use frontForRawNode on a remote connection."); return null; } const { DebuggerServer } = require("devtools/server/main"); let walkerActor = DebuggerServer._searchAllConnectionsForActor(this.actorID); if (!walkerActor) { throw Error("Could not find client side for actor " + this.actorID); } let nodeActor = walkerActor._ref(rawNode); // Pass the node through a read/write pair to create the client side actor. let nodeType = types.getType("domnode"); let returnNode = nodeType.read( nodeType.write(nodeActor, walkerActor), this); let top = returnNode; let extras = walkerActor.parents(nodeActor, {sameTypeRootTreeItem: true}); for (let extraActor of extras) { top = nodeType.read(nodeType.write(extraActor, walkerActor), this); } if (top !== this.rootNode) { // Imported an already-orphaned node. this._orphaned.add(top); walkerActor._orphaned .add(DebuggerServer._searchAllConnectionsForActor(top.actorID)); } return returnNode; }, removeNode: custom(Task.async(function* (node) { let previousSibling = yield this.previousSibling(node); let nextSibling = yield this._removeNode(node); return { previousSibling: previousSibling, nextSibling: nextSibling, }; }), { impl: "_removeNode" }), }); exports.WalkerFront = WalkerFront; /** * Client side of the inspector actor, which is used to create * inspector-related actors, including the walker. */ var InspectorFront = FrontClassWithSpec(inspectorSpec, { initialize: function (client, tabForm) { Front.prototype.initialize.call(this, client); this.actorID = tabForm.inspectorActor; // XXX: This is the first actor type in its hierarchy to use the protocol // library, so we're going to self-own on the client side for now. this.manage(this); }, destroy: function () { delete this.walker; Front.prototype.destroy.call(this); }, getWalker: custom(function (options = {}) { return this._getWalker(options).then(walker => { this.walker = walker; return walker; }); }, { impl: "_getWalker" }), getPageStyle: custom(function () { return this._getPageStyle().then(pageStyle => { // We need a walker to understand node references from the // node style. if (this.walker) { return pageStyle; } return this.getWalker().then(() => { return pageStyle; }); }); }, { impl: "_getPageStyle" }), pickColorFromPage: custom(Task.async(function* (toolbox, options) { if (toolbox) { // If the eyedropper was already started using the gcli command, hide it so we don't // end up with 2 instances of the eyedropper on the page. let {target} = toolbox; let requisition = yield CommandUtils.createRequisition(target, { environment: CommandUtils.createEnvironment({target}) }); yield requisition.updateExec("eyedropper --hide"); } yield this._pickColorFromPage(options); }), { impl: "_pickColorFromPage" }) }); exports.InspectorFront = InspectorFront;