diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /devtools/client/fronts | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/fronts')
64 files changed, 8569 insertions, 0 deletions
diff --git a/devtools/client/fronts/accessibility.js b/devtools/client/fronts/accessibility.js new file mode 100644 index 0000000000..3a60856164 --- /dev/null +++ b/devtools/client/fronts/accessibility.js @@ -0,0 +1,583 @@ +/* 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"; + +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + accessibleSpec, + accessibleWalkerSpec, + accessibilitySpec, + parentAccessibilitySpec, + simulatorSpec, +} = require("resource://devtools/shared/specs/accessibility.js"); +const events = require("resource://devtools/shared/event-emitter.js"); + +class AccessibleFront extends FrontClassWithSpec(accessibleSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + this.before("audited", this.audited.bind(this)); + this.before("name-change", this.nameChange.bind(this)); + this.before("value-change", this.valueChange.bind(this)); + this.before("description-change", this.descriptionChange.bind(this)); + this.before("shortcut-change", this.shortcutChange.bind(this)); + this.before("reorder", this.reorder.bind(this)); + this.before("text-change", this.textChange.bind(this)); + this.before("index-in-parent-change", this.indexInParentChange.bind(this)); + this.before("states-change", this.statesChange.bind(this)); + this.before("actions-change", this.actionsChange.bind(this)); + this.before("attributes-change", this.attributesChange.bind(this)); + } + + marshallPool() { + return this.getParent(); + } + + get useChildTargetToFetchChildren() { + return this._form.useChildTargetToFetchChildren; + } + + get role() { + return this._form.role; + } + + get name() { + return this._form.name; + } + + get value() { + return this._form.value; + } + + get description() { + return this._form.description; + } + + get keyboardShortcut() { + return this._form.keyboardShortcut; + } + + get childCount() { + return this._form.childCount; + } + + get domNodeType() { + return this._form.domNodeType; + } + + get indexInParent() { + return this._form.indexInParent; + } + + get states() { + return this._form.states; + } + + get actions() { + return this._form.actions; + } + + get attributes() { + return this._form.attributes; + } + + get checks() { + return this._form.checks; + } + + form(form) { + this.actorID = form.actor; + this._form = this._form || {}; + Object.assign(this._form, form); + } + + nameChange(name, parent) { + this._form.name = name; + // Name change event affects the tree rendering, we fire this event on + // accessibility walker as the point of interaction for UI. + const accessibilityWalkerFront = this.getParent(); + if (accessibilityWalkerFront) { + events.emit(accessibilityWalkerFront, "name-change", this, parent); + } + } + + valueChange(value) { + this._form.value = value; + } + + descriptionChange(description) { + this._form.description = description; + } + + shortcutChange(keyboardShortcut) { + this._form.keyboardShortcut = keyboardShortcut; + } + + reorder(childCount) { + this._form.childCount = childCount; + // Reorder event affects the tree rendering, we fire this event on + // accessibility walker as the point of interaction for UI. + const accessibilityWalkerFront = this.getParent(); + if (accessibilityWalkerFront) { + events.emit(accessibilityWalkerFront, "reorder", this); + } + } + + textChange() { + // Text event affects the tree rendering, we fire this event on + // accessibility walker as the point of interaction for UI. + const accessibilityWalkerFront = this.getParent(); + if (accessibilityWalkerFront) { + events.emit(accessibilityWalkerFront, "text-change", this); + } + } + + indexInParentChange(indexInParent) { + this._form.indexInParent = indexInParent; + } + + statesChange(states) { + this._form.states = states; + } + + actionsChange(actions) { + this._form.actions = actions; + } + + attributesChange(attributes) { + this._form.attributes = attributes; + } + + audited(checks) { + this._form.checks = this._form.checks || {}; + Object.assign(this._form.checks, checks); + } + + hydrate() { + return super.hydrate().then(properties => { + Object.assign(this._form, properties); + }); + } + + async children() { + if (!this.useChildTargetToFetchChildren) { + return super.children(); + } + + const { walker: domWalkerFront } = await this.targetFront.getFront( + "inspector" + ); + const node = await domWalkerFront.getNodeFromActor(this.actorID, [ + "rawAccessible", + "DOMNode", + ]); + // We are using DOM inspector/walker API here because we want to keep both + // the accessiblity tree and the DOM tree in sync. This is necessary for + // several features that the accessibility panel provides such as inspecting + // a corresponding DOM node or any other functionality that requires DOM + // node ancestries to be resolved all the way up to the top level document. + const { + nodes: [documentNodeFront], + } = await domWalkerFront.children(node); + const accessibilityFront = await documentNodeFront.targetFront.getFront( + "accessibility" + ); + + return accessibilityFront.accessibleWalkerFront.children(); + } + + /** + * Helper function that helps with building a complete snapshot of + * accessibility tree starting at the level of current accessible front. It + * accumulates subtrees from possible out of process frames that are children + * of the current accessible front. + * @param {JSON} snapshot + * Snapshot of the current accessible front or one of its in process + * children when recursing. + * + * @return {JSON} + * Complete snapshot of current accessible front. + */ + async _accumulateSnapshot(snapshot) { + const { childCount, useChildTargetToFetchChildren } = snapshot; + // No children, we are done. + if (childCount === 0) { + return snapshot; + } + + // If current accessible is not a remote frame, continue accumulating inside + // its children. + if (!useChildTargetToFetchChildren) { + const childSnapshots = []; + for (const childSnapshot of snapshot.children) { + childSnapshots.push(this._accumulateSnapshot(childSnapshot)); + } + await Promise.all(childSnapshots); + return snapshot; + } + + // When we have a remote frame, we need to obtain an accessible front for a + // remote frame document and retrieve its snapshot. + const inspectorFront = await this.targetFront.getFront("inspector"); + const frameNodeFront = + await inspectorFront.getNodeActorFromContentDomReference( + snapshot.contentDOMReference + ); + // Remove contentDOMReference and useChildTargetToFetchChildren properties. + delete snapshot.contentDOMReference; + delete snapshot.useChildTargetToFetchChildren; + if (!frameNodeFront) { + return snapshot; + } + + // Remote frame lives in the same process as the current accessible + // front we can retrieve the accessible front directly. + const frameAccessibleFront = await this.parentFront.getAccessibleFor( + frameNodeFront + ); + if (!frameAccessibleFront) { + return snapshot; + } + + const [docAccessibleFront] = await frameAccessibleFront.children(); + const childSnapshot = await docAccessibleFront.snapshot(); + snapshot.children.push(childSnapshot); + + return snapshot; + } + + /** + * Retrieves a complete JSON snapshot for an accessible subtree of a given + * accessible front (inclduing OOP frames). + */ + async snapshot() { + const snapshot = await super.snapshot(); + await this._accumulateSnapshot(snapshot); + return snapshot; + } +} + +class AccessibleWalkerFront extends FrontClassWithSpec(accessibleWalkerSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + this.documentReady = this.documentReady.bind(this); + this.on("document-ready", this.documentReady); + } + + destroy() { + this.off("document-ready", this.documentReady); + super.destroy(); + } + + form(json) { + this.actorID = json.actor; + } + + documentReady() { + if (this.targetFront.isTopLevel) { + this.emit("top-level-document-ready"); + } + } + + pick(doFocus) { + if (doFocus) { + return this.pickAndFocus(); + } + + return super.pick(); + } + + /** + * Get the accessible object ancestry starting from the given accessible to + * the top level document. The top level document is in the top level content process. + * @param {Object} accessible + * Accessible front to determine the ancestry for. + * + * @return {Array} ancestry + * List of ancestry objects which consist of an accessible with its + * children. + */ + async getAncestry(accessible) { + const ancestry = await super.getAncestry(accessible); + + const parentTarget = await this.targetFront.getParentTarget(); + if (!parentTarget) { + return ancestry; + } + + // Get an accessible front for the parent frame. We go through the + // inspector's walker to keep both inspector and accessibility trees in + // sync. + const { walker: domWalkerFront } = await this.targetFront.getFront( + "inspector" + ); + const frameNodeFront = (await domWalkerFront.getRootNode()).parentNode(); + const accessibilityFront = await parentTarget.getFront("accessibility"); + const { accessibleWalkerFront } = accessibilityFront; + const frameAccessibleFront = await accessibleWalkerFront.getAccessibleFor( + frameNodeFront + ); + + if (!frameAccessibleFront) { + // Most likely we are inside a hidden frame. + return Promise.reject( + `Can't get the ancestry for an accessible front ${accessible.actorID}. It is in the detached tree.` + ); + } + + // Compose the final ancestry out of ancestry for the given accessible in + // the current process and recursively get the ancestry for the frame + // accessible. + ancestry.push( + { + accessible: frameAccessibleFront, + children: await frameAccessibleFront.children(), + }, + ...(await accessibleWalkerFront.getAncestry(frameAccessibleFront)) + ); + + return ancestry; + } + + /** + * Run an accessibility audit for a document that accessibility walker is + * responsible for (in process). In addition to plainly running an audit (in + * cases when the document is in the OOP frame), this method also updates + * relative ancestries of audited accessible objects all the way up to the top + * level document for the toolbox. + * @param {Object} options + * - {Array} types + * types of the accessibility issues to audit for + * - {Function} onProgress + * callback function for a progress audit-event + * - {Boolean} retrieveAncestries (defaults to true) + * Set to false to _not_ retrieve ancestries of audited accessible objects. + * This is used when a specific document is selected in the iframe picker + * and we want to treat it as the root of the accessibility panel tree. + */ + async audit({ types, onProgress, retrieveAncestries = true }) { + const onAudit = new Promise(resolve => { + const auditEventHandler = ({ type, ancestries, progress }) => { + switch (type) { + case "error": + this.off("audit-event", auditEventHandler); + resolve({ error: true }); + break; + case "completed": + this.off("audit-event", auditEventHandler); + resolve({ ancestries }); + break; + case "progress": + onProgress(progress); + break; + default: + break; + } + }; + + this.on("audit-event", auditEventHandler); + super.startAudit({ types }); + }); + + const audit = await onAudit; + // If audit resulted in an error, if there's nothing to report or if the callsite + // explicitly asked to not retrieve ancestries, we are done. + // (no need to check for ancestry across the remote frame hierarchy). + // See also https://bugzilla.mozilla.org/show_bug.cgi?id=1641551 why the rest of + // the code path is only supported when content toolbox fission is enabled. + if (audit.error || audit.ancestries.length === 0 || !retrieveAncestries) { + return audit; + } + + const parentTarget = await this.targetFront.getParentTarget(); + // If there is no parent target, we do not need to update ancestries as we + // are in the top level document. + if (!parentTarget) { + return audit; + } + + // Retrieve an ancestry (cross process) for a current root document and make + // audit report ancestries relative to it. + const [docAccessibleFront] = await this.children(); + let docAccessibleAncestry; + try { + docAccessibleAncestry = await this.getAncestry(docAccessibleFront); + } catch (e) { + // We are in a detached subtree. We do not consider this an error, instead + // we need to ignore the audit for this frame and return an empty report. + return { ancestries: [] }; + } + for (const ancestry of audit.ancestries) { + // Compose the final ancestries out of the ones in the audit report + // relative to this document and the ancestry of the document itself + // (cross process). + ancestry.push(...docAccessibleAncestry); + } + + return audit; + } + + /** + * A helper wrapper function to show tabbing order overlay for a given target. + * The only additional work done is resolving domnode front from a + * ContentDOMReference received from a remote target. + * + * @param {Object} startElm + * domnode front to be used as the starting point for generating the + * tabbing order. + * @param {Number} startIndex + * Starting index for the tabbing order. + */ + async _showTabbingOrder(startElm, startIndex) { + const { contentDOMReference, index } = await super.showTabbingOrder( + startElm, + startIndex + ); + let elm; + if (contentDOMReference) { + const inspectorFront = await this.targetFront.getFront("inspector"); + elm = await inspectorFront.getNodeActorFromContentDomReference( + contentDOMReference + ); + } + + return { elm, index }; + } + + /** + * Show tabbing order overlay for a given target. + * + * @param {Object} startElm + * domnode front to be used as the starting point for generating the + * tabbing order. + * @param {Number} startIndex + * Starting index for the tabbing order. + * + * @return {JSON} + * Tabbing order information for the last element in the tabbing + * order. It includes a domnode front and a tabbing index. If we are + * at the end of the tabbing order for the top level content document, + * the domnode front will be null. If focus manager discovered a + * remote IFRAME, then the domnode front is for the IFRAME itself. + */ + async showTabbingOrder(startElm, startIndex) { + let { elm: currentElm, index: currentIndex } = await this._showTabbingOrder( + startElm, + startIndex + ); + + // If no remote frames were found, currentElm will be null. + while (currentElm) { + // Safety check to ensure that the currentElm is a remote frame. + if (currentElm.useChildTargetToFetchChildren) { + const { walker: domWalkerFront } = + await currentElm.targetFront.getFront("inspector"); + const { + nodes: [childDocumentNodeFront], + } = await domWalkerFront.children(currentElm); + const { accessibleWalkerFront } = + await childDocumentNodeFront.targetFront.getFront("accessibility"); + // Show tabbing order in the remote target, while updating the tabbing + // index. + ({ index: currentIndex } = await accessibleWalkerFront.showTabbingOrder( + childDocumentNodeFront, + currentIndex + )); + } + + // Finished with the remote frame, continue in tabbing order, from the + // remote frame. + ({ elm: currentElm, index: currentIndex } = await this._showTabbingOrder( + currentElm, + currentIndex + )); + } + + return { elm: currentElm, index: currentIndex }; + } +} + +class AccessibilityFront extends FrontClassWithSpec(accessibilitySpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + this.before("init", this.init.bind(this)); + this.before("shutdown", this.shutdown.bind(this)); + + // Attribute name from which to retrieve the actorID out of the target + // actor's form + this.formAttributeName = "accessibilityActor"; + } + + async initialize() { + this.accessibleWalkerFront = await super.getWalker(); + this.simulatorFront = await super.getSimulator(); + const { enabled } = await super.bootstrap(); + this.enabled = enabled; + + try { + this._traits = await this.getTraits(); + } catch (e) { + // @backward-compat { version 84 } getTraits isn't available on older server. + this._traits = {}; + } + } + + get traits() { + return this._traits; + } + + init() { + this.enabled = true; + } + + shutdown() { + this.enabled = false; + } +} + +class ParentAccessibilityFront extends FrontClassWithSpec( + parentAccessibilitySpec +) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + this.before("can-be-enabled-change", this.canBeEnabled.bind(this)); + this.before("can-be-disabled-change", this.canBeDisabled.bind(this)); + + // Attribute name from which to retrieve the actorID out of the target + // actor's form + this.formAttributeName = "parentAccessibilityActor"; + } + + async initialize() { + ({ canBeEnabled: this.canBeEnabled, canBeDisabled: this.canBeDisabled } = + await super.bootstrap()); + } + + canBeEnabled(canBeEnabled) { + this.canBeEnabled = canBeEnabled; + } + + canBeDisabled(canBeDisabled) { + this.canBeDisabled = canBeDisabled; + } +} + +const SimulatorFront = FrontClassWithSpec(simulatorSpec); + +exports.AccessibleFront = AccessibleFront; +registerFront(AccessibleFront); +exports.AccessibleWalkerFront = AccessibleWalkerFront; +registerFront(AccessibleWalkerFront); +exports.AccessibilityFront = AccessibilityFront; +registerFront(AccessibilityFront); +exports.ParentAccessibilityFront = ParentAccessibilityFront; +registerFront(ParentAccessibilityFront); +exports.SimulatorFront = SimulatorFront; +registerFront(SimulatorFront); diff --git a/devtools/client/fronts/addon/addons.js b/devtools/client/fronts/addon/addons.js new file mode 100644 index 0000000000..84d495b850 --- /dev/null +++ b/devtools/client/fronts/addon/addons.js @@ -0,0 +1,25 @@ +/* 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"; + +const { + addonsSpec, +} = require("resource://devtools/shared/specs/addon/addons.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); + +class AddonsFront extends FrontClassWithSpec(addonsSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + // Attribute name from which to retrieve the actorID out of the target actor's form + this.formAttributeName = "addonsActor"; + } +} + +exports.AddonsFront = AddonsFront; +registerFront(AddonsFront); diff --git a/devtools/client/fronts/addon/moz.build b/devtools/client/fronts/addon/moz.build new file mode 100644 index 0000000000..e382173641 --- /dev/null +++ b/devtools/client/fronts/addon/moz.build @@ -0,0 +1,10 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + "addons.js", + "webextension-inspected-window.js", +) diff --git a/devtools/client/fronts/addon/webextension-inspected-window.js b/devtools/client/fronts/addon/webextension-inspected-window.js new file mode 100644 index 0000000000..cf0674dfa8 --- /dev/null +++ b/devtools/client/fronts/addon/webextension-inspected-window.js @@ -0,0 +1,31 @@ +/* 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"; + +const { + webExtensionInspectedWindowSpec, +} = require("resource://devtools/shared/specs/addon/webextension-inspected-window.js"); + +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); + +/** + * The corresponding Front object for the WebExtensionInspectedWindowActor. + */ +class WebExtensionInspectedWindowFront extends FrontClassWithSpec( + webExtensionInspectedWindowSpec +) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + // Attribute name from which to retrieve the actorID out of the target actor's form + this.formAttributeName = "webExtensionInspectedWindowActor"; + } +} + +exports.WebExtensionInspectedWindowFront = WebExtensionInspectedWindowFront; +registerFront(WebExtensionInspectedWindowFront); diff --git a/devtools/client/fronts/animation.js b/devtools/client/fronts/animation.js new file mode 100644 index 0000000000..7dd17fc6e5 --- /dev/null +++ b/devtools/client/fronts/animation.js @@ -0,0 +1,214 @@ +/* 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"; + +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + animationPlayerSpec, + animationsSpec, +} = require("resource://devtools/shared/specs/animation.js"); + +class AnimationPlayerFront extends FrontClassWithSpec(animationPlayerSpec) { + constructor(conn, targetFront, parentFront) { + super(conn, targetFront, parentFront); + + this.state = {}; + this.before("changed", this.onChanged.bind(this)); + } + + form(form) { + this._form = form; + this.state = this.initialState; + } + + /** + * If the AnimationsActor was given a reference to the WalkerActor previously + * then calling this getter will return the animation target NodeFront. + */ + get animationTargetNodeFront() { + if (!this._form.animationTargetNodeActorID) { + return null; + } + + return this.conn.getFrontByID(this._form.animationTargetNodeActorID); + } + + /** + * Getter for the initial state of the player. Up to date states can be + * retrieved by calling the getCurrentState method. + */ + get initialState() { + return { + type: this._form.type, + startTime: this._form.startTime, + currentTime: this._form.currentTime, + playState: this._form.playState, + playbackRate: this._form.playbackRate, + name: this._form.name, + duration: this._form.duration, + delay: this._form.delay, + endDelay: this._form.endDelay, + iterationCount: this._form.iterationCount, + iterationStart: this._form.iterationStart, + easing: this._form.easing, + fill: this._form.fill, + direction: this._form.direction, + animationTimingFunction: this._form.animationTimingFunction, + isRunningOnCompositor: this._form.isRunningOnCompositor, + propertyState: this._form.propertyState, + documentCurrentTime: this._form.documentCurrentTime, + createdTime: this._form.createdTime, + currentTimeAtCreated: this._form.currentTimeAtCreated, + absoluteValues: this.calculateAbsoluteValues(this._form), + properties: this._form.properties, + }; + } + + /** + * Executed when the AnimationPlayerActor emits a "changed" event. Used to + * update the local knowledge of the state. + */ + onChanged(partialState) { + const { state } = this.reconstructState(partialState); + this.state = state; + } + + /** + * Refresh the current state of this animation on the client from information + * found on the server. Doesn't return anything, just stores the new state. + */ + async refreshState() { + const data = await this.getCurrentState(); + if (this.currentStateHasChanged) { + this.state = data; + } + } + + /** + * getCurrentState interceptor re-constructs incomplete states since the actor + * only sends the values that have changed. + */ + getCurrentState() { + this.currentStateHasChanged = false; + return super.getCurrentState().then(partialData => { + const { state, hasChanged } = this.reconstructState(partialData); + this.currentStateHasChanged = hasChanged; + return state; + }); + } + + reconstructState(data) { + let hasChanged = false; + + for (const key in this.state) { + if (typeof data[key] === "undefined") { + data[key] = this.state[key]; + } else if (data[key] !== this.state[key]) { + hasChanged = true; + } + } + + data.absoluteValues = this.calculateAbsoluteValues(data); + return { state: data, hasChanged }; + } + + calculateAbsoluteValues(data) { + const { + createdTime, + currentTime, + currentTimeAtCreated, + delay, + duration, + endDelay = 0, + fill, + iterationCount, + playbackRate, + } = data; + + const toRate = v => v / Math.abs(playbackRate); + const isPositivePlaybackRate = playbackRate > 0; + let absoluteDelay = 0; + let absoluteEndDelay = 0; + let isDelayFilled = false; + let isEndDelayFilled = false; + + if (isPositivePlaybackRate) { + absoluteDelay = toRate(delay); + absoluteEndDelay = toRate(endDelay); + isDelayFilled = fill === "both" || fill === "backwards"; + isEndDelayFilled = fill === "both" || fill === "forwards"; + } else { + absoluteDelay = toRate(endDelay); + absoluteEndDelay = toRate(delay); + isDelayFilled = fill === "both" || fill === "forwards"; + isEndDelayFilled = fill === "both" || fill === "backwards"; + } + + let endTime = 0; + + if (duration === Infinity) { + // Set endTime so as to enable the scrubber with keeping the consinstency of UI + // even the duration was Infinity. In case of delay is longer than zero, handle + // the graph duration as double of the delay amount. In case of no delay, handle + // the duration as 1ms which is short enough so as to make the scrubber movable + // and the limited duration is prioritized. + endTime = absoluteDelay > 0 ? absoluteDelay * 2 : 1; + } else { + endTime = + absoluteDelay + + toRate(duration * (iterationCount || 1)) + + absoluteEndDelay; + } + + const absoluteCreatedTime = isPositivePlaybackRate + ? createdTime + : createdTime - endTime; + const absoluteCurrentTimeAtCreated = isPositivePlaybackRate + ? currentTimeAtCreated + : endTime - currentTimeAtCreated; + const animationCurrentTime = isPositivePlaybackRate + ? currentTime + : endTime - currentTime; + const absoluteCurrentTime = + absoluteCreatedTime + toRate(animationCurrentTime); + const absoluteStartTime = absoluteCreatedTime + Math.min(absoluteDelay, 0); + const absoluteStartTimeAtCreated = + absoluteCreatedTime + absoluteCurrentTimeAtCreated; + // To show whole graph with endDelay, we add negative endDelay amount to endTime. + const endTimeWithNegativeEndDelay = endTime - Math.min(absoluteEndDelay, 0); + const absoluteEndTime = absoluteCreatedTime + endTimeWithNegativeEndDelay; + + return { + createdTime: absoluteCreatedTime, + currentTime: absoluteCurrentTime, + currentTimeAtCreated: absoluteCurrentTimeAtCreated, + delay: absoluteDelay, + endDelay: absoluteEndDelay, + endTime: absoluteEndTime, + isDelayFilled, + isEndDelayFilled, + startTime: absoluteStartTime, + startTimeAtCreated: absoluteStartTimeAtCreated, + }; + } +} + +exports.AnimationPlayerFront = AnimationPlayerFront; +registerFront(AnimationPlayerFront); + +class AnimationsFront extends FrontClassWithSpec(animationsSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + // Attribute name from which to retrieve the actorID out of the target actor's form + this.formAttributeName = "animationsActor"; + } +} + +exports.AnimationsFront = AnimationsFront; +registerFront(AnimationsFront); diff --git a/devtools/client/fronts/array-buffer.js b/devtools/client/fronts/array-buffer.js new file mode 100644 index 0000000000..c7741ffe3f --- /dev/null +++ b/devtools/client/fronts/array-buffer.js @@ -0,0 +1,26 @@ +/* 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"; + +const { + arrayBufferSpec, +} = require("resource://devtools/shared/specs/array-buffer.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); + +/** + * A ArrayBufferClient provides a way to access ArrayBuffer from the + * devtools server. + */ +class ArrayBufferFront extends FrontClassWithSpec(arrayBufferSpec) { + form(json) { + this.length = json.length; + } +} + +exports.ArrayBufferFront = ArrayBufferFront; +registerFront(ArrayBufferFront); diff --git a/devtools/client/fronts/blackboxing.js b/devtools/client/fronts/blackboxing.js new file mode 100644 index 0000000000..ab26c484cc --- /dev/null +++ b/devtools/client/fronts/blackboxing.js @@ -0,0 +1,17 @@ +/* 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"; + +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + blackboxingSpec, +} = require("resource://devtools/shared/specs/blackboxing.js"); + +class BlackboxingFront extends FrontClassWithSpec(blackboxingSpec) {} + +registerFront(BlackboxingFront); diff --git a/devtools/client/fronts/breakpoint-list.js b/devtools/client/fronts/breakpoint-list.js new file mode 100644 index 0000000000..c8fc57226b --- /dev/null +++ b/devtools/client/fronts/breakpoint-list.js @@ -0,0 +1,17 @@ +/* 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"; + +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + breakpointListSpec, +} = require("resource://devtools/shared/specs/breakpoint-list.js"); + +class BreakpointListFront extends FrontClassWithSpec(breakpointListSpec) {} + +registerFront(BreakpointListFront); diff --git a/devtools/client/fronts/changes.js b/devtools/client/fronts/changes.js new file mode 100644 index 0000000000..4737156dcf --- /dev/null +++ b/devtools/client/fronts/changes.js @@ -0,0 +1,33 @@ +/* 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"; + +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { changesSpec } = require("resource://devtools/shared/specs/changes.js"); + +/** + * ChangesFront, the front object for the ChangesActor + */ +class ChangesFront extends FrontClassWithSpec(changesSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + // Attribute name from which to retrieve the actorID out of the target actor's form + this.formAttributeName = "changesActor"; + } + + async initialize() { + // Ensure the corresponding ChangesActor is immediately available and ready to track + // changes by calling a method on it. Actors are lazy and won't be created until + // actually used. + await super.start(); + } +} + +exports.ChangesFront = ChangesFront; +registerFront(ChangesFront); diff --git a/devtools/client/fronts/compatibility.js b/devtools/client/fronts/compatibility.js new file mode 100644 index 0000000000..292411e6af --- /dev/null +++ b/devtools/client/fronts/compatibility.js @@ -0,0 +1,18 @@ +/* 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"; + +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + compatibilitySpec, +} = require("resource://devtools/shared/specs/compatibility.js"); + +class CompatibilityFront extends FrontClassWithSpec(compatibilitySpec) {} + +exports.CompatibilityFront = CompatibilityFront; +registerFront(CompatibilityFront); diff --git a/devtools/client/fronts/css-properties.js b/devtools/client/fronts/css-properties.js new file mode 100644 index 0000000000..6c16df4cea --- /dev/null +++ b/devtools/client/fronts/css-properties.js @@ -0,0 +1,278 @@ +/* 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"; + +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + cssPropertiesSpec, +} = require("resource://devtools/shared/specs/css-properties.js"); + +loader.lazyRequireGetter( + this, + "cssColors", + "resource://devtools/shared/css/color-db.js", + true +); +loader.lazyRequireGetter( + this, + "CSS_PROPERTIES_DB", + "resource://devtools/shared/css/properties-db.js", + true +); +loader.lazyRequireGetter( + this, + "CSS_TYPES", + "resource://devtools/shared/css/constants.js", + true +); + +/** + * Build up a regular expression that matches a CSS variable token. This is an + * ident token that starts with two dashes "--". + * + * https://www.w3.org/TR/css-syntax-3/#ident-token-diagram + */ +var NON_ASCII = "[^\\x00-\\x7F]"; +var ESCAPE = "\\\\[^\n\r]"; +var VALID_CHAR = ["[_a-z0-9-]", NON_ASCII, ESCAPE].join("|"); +var IS_VARIABLE_TOKEN = new RegExp(`^--(${VALID_CHAR})*$`, "i"); + +/** + * The CssProperties front provides a mechanism to have a one-time asynchronous + * load of a CSS properties database. This is then fed into the CssProperties + * interface that provides synchronous methods for finding out what CSS + * properties the current server supports. + */ +class CssPropertiesFront extends FrontClassWithSpec(cssPropertiesSpec) { + constructor(client, targetFront) { + super(client, targetFront); + + // Attribute name from which to retrieve the actorID out of the target actor's form + this.formAttributeName = "cssPropertiesActor"; + } + + async initialize() { + const db = await super.getCSSDatabase(); + this.cssProperties = new CssProperties(normalizeCssData(db)); + } + + destroy() { + this.cssProperties = null; + super.destroy(); + } +} + +/** + * Ask questions to a CSS database. This class does not care how the database + * gets loaded in, only the questions that you can ask to it. + * Prototype functions are bound to 'this' so they can be passed around as helper + * functions. + * + * @param {Object} db + * A database of CSS properties + * @param {Object} inheritedList + * The key is the property name, the value is whether or not + * that property is inherited. + */ +function CssProperties(db) { + this.properties = db.properties; + this.pseudoElements = db.pseudoElements; + + this.isKnown = this.isKnown.bind(this); + this.isInherited = this.isInherited.bind(this); + this.supportsType = this.supportsType.bind(this); +} + +CssProperties.prototype = { + /** + * Checks to see if the property is known by the browser. This function has + * `this` already bound so that it can be passed around by reference. + * + * @param {String} property The property name to be checked. + * @return {Boolean} + */ + isKnown(property) { + // Custom Property Names (aka CSS Variables) are case-sensitive; do not lowercase. + property = property.startsWith("--") ? property : property.toLowerCase(); + return !!this.properties[property] || isCssVariable(property); + }, + + /** + * Checks to see if the property is an inherited one. + * + * @param {String} property The property name to be checked. + * @return {Boolean} + */ + isInherited(property) { + return ( + (this.properties[property] && this.properties[property].isInherited) || + isCssVariable(property) + ); + }, + + /** + * Checks if the property supports the given CSS type. + * + * @param {String} property The property to be checked. + * @param {String} type One of the values from InspectorPropertyType. + * @return {Boolean} + */ + supportsType(property, type) { + const id = CSS_TYPES[type]; + return ( + this.properties[property] && + (this.properties[property].supports.includes(type) || + this.properties[property].supports.includes(id)) + ); + }, + + /** + * Gets the CSS values for a given property name. + * + * @param {String} property The property to use. + * @return {Array} An array of strings. + */ + getValues(property) { + return this.properties[property] ? this.properties[property].values : []; + }, + + /** + * Gets the CSS property names. + * + * @return {Array} An array of strings. + */ + getNames(property) { + return Object.keys(this.properties); + }, + + /** + * Return a list of subproperties for the given property. If |name| + * does not name a valid property, an empty array is returned. If + * the property is not a shorthand property, then array containing + * just the property itself is returned. + * + * @param {String} name The property to query + * @return {Array} An array of subproperty names. + */ + getSubproperties(name) { + // Custom Property Names (aka CSS Variables) are case-sensitive; do not lowercase. + name = name.startsWith("--") ? name : name.toLowerCase(); + if (this.isKnown(name)) { + if (this.properties[name] && this.properties[name].subproperties) { + return this.properties[name].subproperties; + } + return [name]; + } + return []; + }, +}; + +/** + * Check that this is a CSS variable. + * + * @param {String} input + * @return {Boolean} + */ +function isCssVariable(input) { + return !!input.match(IS_VARIABLE_TOKEN); +} + +/** + * Get a client-side CssProperties. This is useful for dependencies in tests, or parts + * of the codebase that don't particularly need to match every known CSS property on + * the target. + * @return {CssProperties} + */ +function getClientCssProperties() { + return new CssProperties(normalizeCssData(CSS_PROPERTIES_DB)); +} + +/** + * Even if the target has the cssProperties actor, the returned data may not be in the + * same shape or have all of the data we need. This normalizes the data and fills in + * any missing information like color values. + * + * @return {Object} The normalized CSS database. + */ +function normalizeCssData(db) { + // If there is a `from` attributes, it means that it comes from RDP + // and it is not the client CSS_PROPERTIES_DB object. + // (prevent comparing to CSS_PROPERTIES_DB to avoid loading client database) + if (typeof db.from == "string") { + const missingSupports = !db.properties.color.supports; + const missingValues = !db.properties.color.values; + const missingSubproperties = !db.properties.background.subproperties; + const missingIsInherited = !db.properties.font.isInherited; + + const missingSomething = + missingSupports || + missingValues || + missingSubproperties || + missingIsInherited; + + if (missingSomething) { + for (const name in db.properties) { + // Skip the current property if we can't find it in CSS_PROPERTIES_DB. + if (typeof CSS_PROPERTIES_DB.properties[name] !== "object") { + continue; + } + + // Add "supports" information to the css properties if it's missing. + if (missingSupports) { + db.properties[name].supports = + CSS_PROPERTIES_DB.properties[name].supports; + } + // Add "values" information to the css properties if it's missing. + if (missingValues) { + db.properties[name].values = + CSS_PROPERTIES_DB.properties[name].values; + } + // Add "subproperties" information to the css properties if it's missing. + if (missingSubproperties) { + db.properties[name].subproperties = + CSS_PROPERTIES_DB.properties[name].subproperties; + } + // Add "isInherited" information to the css properties if it's missing. + if (missingIsInherited) { + db.properties[name].isInherited = + CSS_PROPERTIES_DB.properties[name].isInherited; + } + } + } + } + + reattachCssColorValues(db); + + return db; +} + +/** + * Color values are omitted to save on space. Add them back here. + * @param {Object} The CSS database. + */ +function reattachCssColorValues(db) { + if (db.properties.color.values[0] === "COLOR") { + const colors = Object.keys(cssColors); + + for (const name in db.properties) { + const property = db.properties[name]; + // "values" can be undefined if {name} was not found in CSS_PROPERTIES_DB. + if (property.values && property.values[0] === "COLOR") { + property.values.shift(); + property.values = property.values.concat(colors).sort(); + } + } + } +} + +module.exports = { + CssPropertiesFront, + getClientCssProperties, + isCssVariable, +}; +registerFront(CssPropertiesFront); diff --git a/devtools/client/fronts/descriptors/descriptor-mixin.js b/devtools/client/fronts/descriptors/descriptor-mixin.js new file mode 100644 index 0000000000..ac38e104b5 --- /dev/null +++ b/devtools/client/fronts/descriptors/descriptor-mixin.js @@ -0,0 +1,60 @@ +/* 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"; + +/** + * A Descriptor represents a debuggable context. It can be a browser tab, a tab on + * a remote device, like a tab on Firefox for Android. But it can also be an add-on, + * as well as firefox parent process, or just one of its content process. + * It can be very similar to a Target. The key difference is the lifecycle of these two classes. + * The descriptor is meant to be always alive and meaningful/usable until the end of the RDP connection. + * Typically a Tab Descriptor will describe the tab and not the one document currently loaded in this tab, + * while the Target, will describe this one document and a new Target may be created on each navigation. + */ +function DescriptorMixin(parentClass) { + class Descriptor extends parentClass { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + this._client = client; + + // Pass a true value in order to distinguish this event reception + // from any manual destroy caused by the frontend + this.on( + "descriptor-destroyed", + this.destroy.bind(this, { isServerDestroyEvent: true }) + ); + } + + get client() { + return this._client; + } + + async destroy({ isServerDestroyEvent } = {}) { + if (this.isDestroyed()) { + return; + } + // This workaround is mostly done for Workers, as WorkerDescriptor + // extends the Target class, which causes some issue down the road: + // In Target.destroy, we call WorkerDescriptorActor.detach *before* calling super.destroy(), + // and so hold on before calling Front.destroy() which would reject all pending requests, including detach(). + // When calling detach, the server will emit "descriptor-destroyed", which will call Target.destroy again, + // but will still be blocked on detach resolution and won't call Front.destroy, and won't reject pending requests either. + // + // So call Front.baseFrontClassDestroyed manually from here, so that we ensure rejecting the pending detach request + // and unblock Target.destroy resolution. + // + // Here is the inheritance chain for WorkerDescriptor: + // WorkerDescriptor -> Descriptor (from descriptor-mixin.js) -> Target (from target-mixin.js) -> Front (protocol.js) -> Pool (protocol.js) -> EventEmitter + if (isServerDestroyEvent) { + this.baseFrontClassDestroy(); + } + + await super.destroy(); + } + } + return Descriptor; +} +exports.DescriptorMixin = DescriptorMixin; diff --git a/devtools/client/fronts/descriptors/descriptor-types.js b/devtools/client/fronts/descriptors/descriptor-types.js new file mode 100644 index 0000000000..00164509ab --- /dev/null +++ b/devtools/client/fronts/descriptors/descriptor-types.js @@ -0,0 +1,17 @@ +/* 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"; + +/** + * List of all descriptor types. + * + * This will be expose via `descriptorType` on all descriptor fronts. + */ +module.exports = { + PROCESS: "process", + WORKER: "worker", + TAB: "tab", + EXTENSION: "extension", +}; diff --git a/devtools/client/fronts/descriptors/moz.build b/devtools/client/fronts/descriptors/moz.build new file mode 100644 index 0000000000..665a0f252a --- /dev/null +++ b/devtools/client/fronts/descriptors/moz.build @@ -0,0 +1,14 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + "descriptor-mixin.js", + "descriptor-types.js", + "process.js", + "tab.js", + "webextension.js", + "worker.js", +) diff --git a/devtools/client/fronts/descriptors/process.js b/devtools/client/fronts/descriptors/process.js new file mode 100644 index 0000000000..6efc1bd4d7 --- /dev/null +++ b/devtools/client/fronts/descriptors/process.js @@ -0,0 +1,142 @@ +/* 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"; + +const { + processDescriptorSpec, +} = require("resource://devtools/shared/specs/descriptors/process.js"); +const { + WindowGlobalTargetFront, +} = require("resource://devtools/client/fronts/targets/window-global.js"); +const { + ContentProcessTargetFront, +} = require("resource://devtools/client/fronts/targets/content-process.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + DescriptorMixin, +} = require("resource://devtools/client/fronts/descriptors/descriptor-mixin.js"); +const DESCRIPTOR_TYPES = require("resource://devtools/client/fronts/descriptors/descriptor-types.js"); + +class ProcessDescriptorFront extends DescriptorMixin( + FrontClassWithSpec(processDescriptorSpec) +) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + this._isParent = false; + this._processTargetFront = null; + this._targetFrontPromise = null; + } + + descriptorType = DESCRIPTOR_TYPES.PROCESS; + + form(json) { + this.id = json.id; + this._isParent = json.isParent; + this._isWindowlessParent = json.isWindowlessParent; + this.traits = json.traits || {}; + } + + async _createProcessTargetFront(form) { + let front = null; + // the request to getTarget may return a ContentProcessTargetActor or a + // ParentProcessTargetActor. In most cases getProcess(0) will return the + // main process target actor, which is a ParentProcessTargetActor, but + // not in xpcshell, which uses a ContentProcessTargetActor. So select + // the right front based on the actor ID. + if (form.actor.includes("parentProcessTarget")) { + // ParentProcessTargetActor doesn't have a specific front, instead it uses + // WindowGlobalTargetFront on the client side. + front = new WindowGlobalTargetFront(this._client, null, this); + } else { + front = new ContentProcessTargetFront(this._client, null, this); + } + // As these fronts aren't instantiated by protocol.js, we have to set their actor ID + // manually like that: + front.actorID = form.actor; + front.form(form); + + // @backward-compat { version 84 } Older server don't send the processID in the form + if (!front.processID) { + front.processID = this.id; + } + + this.manage(front); + return front; + } + + /** + * This flag should be true for parent process descriptors of a regular + * browser instance, where you can expect the target to be associated with a + * window global. + * + * This will typically be true for the descriptor used by the Browser Toolbox + * or the Browser Console opened against a regular Firefox instance. + * + * On the contrary this will be false for parent process descriptors created + * for xpcshell debugging or for background task debugging. + */ + get isBrowserProcessDescriptor() { + return this._isParent && !this._isWindowlessParent; + } + + get isParentProcessDescriptor() { + return this._isParent; + } + + get isProcessDescriptor() { + return true; + } + + getCachedTarget() { + return this._processTargetFront; + } + + async getTarget() { + // Only return the cached Target if it is still alive. + if (this._processTargetFront && !this._processTargetFront.isDestroyed()) { + return this._processTargetFront; + } + // Otherwise, ensure that we don't try to spawn more than one Target by + // returning the pending promise + if (this._targetFrontPromise) { + return this._targetFrontPromise; + } + this._targetFrontPromise = (async () => { + let targetFront = null; + try { + const targetForm = await super.getTarget(); + targetFront = await this._createProcessTargetFront(targetForm); + } catch (e) { + // This is likely to happen if we get a lot of events which drop previous + // processes. + console.log( + `Request to connect to ProcessDescriptor "${this.id}" failed: ${e}` + ); + } + // Save the reference to the target only after the call to attach + // so that getTarget always returns the attached target in case of concurrent calls + this._processTargetFront = targetFront; + // clear the promise if we are finished so that we can re-connect if + // necessary + this._targetFrontPromise = null; + return targetFront; + })(); + return this._targetFrontPromise; + } + + destroy() { + if (this._processTargetFront) { + this._processTargetFront.destroy(); + this._processTargetFront = null; + } + this._targetFrontPromise = null; + super.destroy(); + } +} + +exports.ProcessDescriptorFront = ProcessDescriptorFront; +registerFront(ProcessDescriptorFront); diff --git a/devtools/client/fronts/descriptors/tab.js b/devtools/client/fronts/descriptors/tab.js new file mode 100644 index 0000000000..097aed7674 --- /dev/null +++ b/devtools/client/fronts/descriptors/tab.js @@ -0,0 +1,330 @@ +/* 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"; + +const { + tabDescriptorSpec, +} = require("resource://devtools/shared/specs/descriptors/tab.js"); +const DESCRIPTOR_TYPES = require("resource://devtools/client/fronts/descriptors/descriptor-types.js"); + +loader.lazyRequireGetter( + this, + "gDevTools", + "resource://devtools/client/framework/devtools.js", + true +); +loader.lazyRequireGetter( + this, + "WindowGlobalTargetFront", + "resource://devtools/client/fronts/targets/window-global.js", + true +); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + DescriptorMixin, +} = require("resource://devtools/client/fronts/descriptors/descriptor-mixin.js"); + +const POPUP_DEBUG_PREF = "devtools.popups.debug"; + +/** + * DescriptorFront for tab targets. + * + * @fires remoteness-change + * Fired only for target switching, when the debugged tab is a local tab. + * TODO: This event could move to the server in order to support + * remoteness change for remote debugging. + */ +class TabDescriptorFront extends DescriptorMixin( + FrontClassWithSpec(tabDescriptorSpec) +) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + // The tab descriptor can be configured to create either local tab targets + // (eg, regular tab toolbox) or browsing context targets (eg tab remote + // debugging). + this._localTab = null; + + // Flag to prevent the server from trying to spawn targets by the watcher actor. + this._disableTargetSwitching = false; + + this._onTargetDestroyed = this._onTargetDestroyed.bind(this); + this._handleTabEvent = this._handleTabEvent.bind(this); + + // When the target is created from the server side, + // it is not created via TabDescriptor.getTarget. + // Instead, it is retrieved by the TargetCommand which + // will call TabDescriptor.setTarget from TargetCommand.onTargetAvailable + if (this.isServerTargetSwitchingEnabled()) { + this._targetFrontPromise = new Promise( + r => (this._resolveTargetFrontPromise = r) + ); + } + } + + descriptorType = DESCRIPTOR_TYPES.TAB; + + form(json) { + this.actorID = json.actor; + this._form = json; + this.traits = json.traits || {}; + } + + /** + * Destroy the front. + * + * @param Boolean If true, it means that we destroy the front when receiving the descriptor-destroyed + * event from the server. + */ + destroy({ isServerDestroyEvent = false } = {}) { + if (this.isDestroyed()) { + return; + } + + // The descriptor may be destroyed first by the frontend. + // When closing the tab, the toolbox document is almost immediately removed from the DOM. + // The `unload` event fires and toolbox destroys itself, as well as its related client. + // + // In such case, we emit the descriptor-destroyed event + if (!isServerDestroyEvent) { + this.emit("descriptor-destroyed"); + } + if (this.isLocalTab) { + this._teardownLocalTabListeners(); + } + super.destroy(); + } + + getWatcher() { + const isPopupDebuggingEnabled = Services.prefs.getBoolPref( + POPUP_DEBUG_PREF, + false + ); + return super.getWatcher({ + isServerTargetSwitchingEnabled: this.isServerTargetSwitchingEnabled(), + isPopupDebuggingEnabled, + }); + } + + setLocalTab(localTab) { + this._localTab = localTab; + this._setupLocalTabListeners(); + } + + get isTabDescriptor() { + return true; + } + + get isLocalTab() { + return !!this._localTab; + } + + get localTab() { + return this._localTab; + } + + _setupLocalTabListeners() { + this.localTab.addEventListener("TabClose", this._handleTabEvent); + this.localTab.addEventListener("TabRemotenessChange", this._handleTabEvent); + } + + _teardownLocalTabListeners() { + this.localTab.removeEventListener("TabClose", this._handleTabEvent); + this.localTab.removeEventListener( + "TabRemotenessChange", + this._handleTabEvent + ); + } + + isServerTargetSwitchingEnabled() { + return !this._disableTargetSwitching; + } + + /** + * Called by CommandsFactory, when the WebExtension codebase instantiates + * a commands. We have to flag the TabDescriptor for them as they don't support + * target switching and gets severely broken when enabling server target which + * introduce target switching for all navigations and reloads + */ + setIsForWebExtension() { + this.disableTargetSwitching(); + } + + /** + * Method used by the WebExtension which still need to disable server side targets, + * and also a few xpcshell tests which are using legacy API and don't support watcher actor. + */ + disableTargetSwitching() { + this._disableTargetSwitching = true; + // Delete these two attributes which have to be set early from the constructor, + // but we don't know yet if target switch should be disabled. + delete this._targetFrontPromise; + delete this._resolveTargetFrontPromise; + } + + get isZombieTab() { + return this._form.isZombieTab; + } + + get browserId() { + return this._form.browserId; + } + + get selected() { + return this._form.selected; + } + + get title() { + return this._form.title; + } + + get url() { + return this._form.url; + } + + get favicon() { + // Note: the favicon is not part of the default form() payload, it will be + // added in `retrieveFavicon`. + return this._form.favicon; + } + + _createTabTarget(form) { + const front = new WindowGlobalTargetFront(this._client, null, this); + + // As these fronts aren't instantiated by protocol.js, we have to set their actor ID + // manually like that: + front.actorID = form.actor; + front.form(form); + this.manage(front); + return front; + } + + _onTargetDestroyed() { + // Clear the cached targetFront when the target is destroyed. + // Note that we are also checking that _targetFront has a valid actorID + // in getTarget, this acts as an additional security to avoid races. + this._targetFront = null; + } + + /** + * Safely retrieves the favicon via getFavicon() and populates this._form.favicon. + * + * We could let callers explicitly retrieve the favicon instead of inserting it in the + * form dynamically. + */ + async retrieveFavicon() { + try { + this._form.favicon = await this.getFavicon(); + } catch (e) { + // We might request the data for a tab which is going to be destroyed. + // In this case the TargetFront will be destroyed. Otherwise log an error. + if (!this.isDestroyed()) { + console.error("Failed to retrieve the favicon for " + this.url, e); + } + } + } + + /** + * Top-level targets created on the server will not be created and managed + * by a descriptor front. Instead they are created by the Watcher actor. + * On the client side we manually re-establish a link between the descriptor + * and the new top-level target. + */ + setTarget(targetFront) { + // Completely ignore the previous target. + // We might nullify the _targetFront unexpectely due to previous target + // being destroyed after the new is created + if (this._targetFront) { + this._targetFront.off("target-destroyed", this._onTargetDestroyed); + } + this._targetFront = targetFront; + + targetFront.on("target-destroyed", this._onTargetDestroyed); + + if (this.isServerTargetSwitchingEnabled()) { + this._resolveTargetFrontPromise(targetFront); + + // Set a new promise in order to: + // 1) Avoid leaking the targetFront we just resolved into the previous promise. + // 2) Never return an empty target from `getTarget` + // + // About the second point: + // There is a race condition where we call `onTargetDestroyed` (which clears `this.targetFront`) + // a bit before calling `setTarget`. So that `this.targetFront` could be null, + // while we now a new target will eventually come when calling `setTarget`. + // Setting a new promise will help wait for the next target while `_targetFront` is null. + // Note that `getTarget` first look into `_targetFront` before checking for `_targetFrontPromise`. + this._targetFrontPromise = new Promise( + r => (this._resolveTargetFrontPromise = r) + ); + } + } + getCachedTarget() { + return this._targetFront; + } + async getTarget() { + if (this._targetFront && !this._targetFront.isDestroyed()) { + return this._targetFront; + } + + if (this._targetFrontPromise) { + return this._targetFrontPromise; + } + + this._targetFrontPromise = (async () => { + let newTargetFront = null; + try { + const targetForm = await super.getTarget(); + newTargetFront = this._createTabTarget(targetForm); + this.setTarget(newTargetFront); + } catch (e) { + console.log( + `Request to connect to TabDescriptor "${this.id}" failed: ${e}` + ); + } + + this._targetFrontPromise = null; + return newTargetFront; + })(); + return this._targetFrontPromise; + } + + /** + * Handle tabs events. + */ + async _handleTabEvent(event) { + switch (event.type) { + case "TabClose": + // Always destroy the toolbox opened for this local tab descriptor. + // When the toolbox is in a Window Host, it won't be removed from the + // DOM when the tab is closed. + const toolbox = gDevTools.getToolboxForDescriptorFront(this); + if (toolbox) { + // Toolbox.destroy will call target.destroy eventually. + await toolbox.destroy(); + } + break; + case "TabRemotenessChange": + this._onRemotenessChange(); + break; + } + } + + /** + * Automatically respawn the toolbox when the tab changes between being + * loaded within the parent process and loaded from a content process. + * Process change can go in both ways. + */ + async _onRemotenessChange() { + // In a near future, this client side code should be replaced by actor code, + // notifying about new tab targets. + this.emit("remoteness-change", this._targetFront); + } +} + +exports.TabDescriptorFront = TabDescriptorFront; +registerFront(TabDescriptorFront); diff --git a/devtools/client/fronts/descriptors/webextension.js b/devtools/client/fronts/descriptors/webextension.js new file mode 100644 index 0000000000..5db86ee5f8 --- /dev/null +++ b/devtools/client/fronts/descriptors/webextension.js @@ -0,0 +1,173 @@ +/* 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"; + +const { + webExtensionDescriptorSpec, +} = require("resource://devtools/shared/specs/descriptors/webextension.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + DescriptorMixin, +} = require("resource://devtools/client/fronts/descriptors/descriptor-mixin.js"); +const DESCRIPTOR_TYPES = require("resource://devtools/client/fronts/descriptors/descriptor-types.js"); +loader.lazyRequireGetter( + this, + "WindowGlobalTargetFront", + "resource://devtools/client/fronts/targets/window-global.js", + true +); + +class WebExtensionDescriptorFront extends DescriptorMixin( + FrontClassWithSpec(webExtensionDescriptorSpec) +) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + this.traits = {}; + } + + descriptorType = DESCRIPTOR_TYPES.EXTENSION; + + form(json) { + this.actorID = json.actor; + + // Do not use `form` name to avoid colliding with protocol.js's `form` method + this._form = json; + this.traits = json.traits || {}; + } + + get backgroundScriptStatus() { + return this._form.backgroundScriptStatus; + } + + get debuggable() { + return this._form.debuggable; + } + + get hidden() { + return this._form.hidden; + } + + get iconDataURL() { + return this._form.iconDataURL; + } + + get iconURL() { + return this._form.iconURL; + } + + get id() { + return this._form.id; + } + + get isSystem() { + return this._form.isSystem; + } + + get isWebExtensionDescriptor() { + return true; + } + + get isWebExtension() { + return this._form.isWebExtension; + } + + get manifestURL() { + return this._form.manifestURL; + } + + get name() { + return this._form.name; + } + + get persistentBackgroundScript() { + return this._form.persistentBackgroundScript; + } + + get temporarilyInstalled() { + return this._form.temporarilyInstalled; + } + + get url() { + return this._form.url; + } + + get warnings() { + return this._form.warnings; + } + + isServerTargetSwitchingEnabled() { + // For now, we don't expose any target out of the WatcherActor. + // And the top level target is still manually instantiated by the descriptor. + // We most likely need to wait for full enabling of EFT before being able to spawn + // the extension target from the server side as doing this would most likely break + // the iframe dropdown. It would break it as spawning the targets from the server + // would probably mean getting rid of the usage of WindowGlobalTargetActor._setWindow + // and instead spawn one target per extension document. + // That, instead of having a unique target for all the documents. + return false; + } + + getWatcher() { + return super.getWatcher({ + isServerTargetSwitchingEnabled: this.isServerTargetSwitchingEnabled(), + }); + } + + _createWebExtensionTarget(form) { + const front = new WindowGlobalTargetFront(this.conn, null, this); + front.form(form); + this.manage(front); + return front; + } + + /** + * Retrieve the WindowGlobalTargetFront representing a + * WebExtensionTargetActor if this addon is a webextension. + * + * WebExtensionDescriptors will be created for any type of addon type + * (webextension, search plugin, themes). Only webextensions can be targets. + * This method will throw for other addon types. + * + * TODO: We should filter out non-webextension & non-debuggable addons on the + * server to avoid the isWebExtension check here. See Bug 1644355. + */ + async getTarget() { + if (!this.isWebExtension) { + throw new Error( + "Tried to create a target for an addon which is not a webextension: " + + this.actorID + ); + } + + if (this._targetFront && !this._targetFront.isDestroyed()) { + return this._targetFront; + } + + if (this._targetFrontPromise) { + return this._targetFrontPromise; + } + + this._targetFrontPromise = (async () => { + let targetFront = null; + try { + const targetForm = await super.getTarget(); + targetFront = this._createWebExtensionTarget(targetForm); + } catch (e) { + console.log( + `Request to connect to WebExtensionDescriptor "${this.id}" failed: ${e}` + ); + } + this._targetFront = targetFront; + this._targetFrontPromise = null; + return targetFront; + })(); + return this._targetFrontPromise; + } +} + +exports.WebExtensionDescriptorFront = WebExtensionDescriptorFront; +registerFront(WebExtensionDescriptorFront); diff --git a/devtools/client/fronts/descriptors/worker.js b/devtools/client/fronts/descriptors/worker.js new file mode 100644 index 0000000000..47f016f15f --- /dev/null +++ b/devtools/client/fronts/descriptors/worker.js @@ -0,0 +1,146 @@ +/* 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"; + +const { + workerDescriptorSpec, +} = require("resource://devtools/shared/specs/descriptors/worker.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + TargetMixin, +} = require("resource://devtools/client/fronts/targets/target-mixin.js"); +const { + DescriptorMixin, +} = require("resource://devtools/client/fronts/descriptors/descriptor-mixin.js"); +const DESCRIPTOR_TYPES = require("resource://devtools/client/fronts/descriptors/descriptor-types.js"); + +class WorkerDescriptorFront extends DescriptorMixin( + TargetMixin(FrontClassWithSpec(workerDescriptorSpec)) +) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + this.traits = {}; + } + + descriptorType = DESCRIPTOR_TYPES.WORKER; + + form(json) { + this.actorID = json.actor; + this.id = json.id; + + // Save the full form for Target class usage. + // Do not use `form` name to avoid colliding with protocol.js's `form` method + this.targetForm = json; + this._url = json.url; + this.type = json.type; + this.scope = json.scope; + this.fetch = json.fetch; + this.traits = json.traits; + } + + get name() { + // this._url is nullified in TargetMixin#destroy. + if (!this.url) { + return null; + } + + return this.url.split("/").pop(); + } + + get isWorkerDescriptor() { + return true; + } + + get isDedicatedWorker() { + return this.type === Ci.nsIWorkerDebugger.TYPE_DEDICATED; + } + + get isSharedWorker() { + return this.type === Ci.nsIWorkerDebugger.TYPE_SHARED; + } + + get isServiceWorker() { + return this.type === Ci.nsIWorkerDebugger.TYPE_SERVICE; + } + + // For now, WorkerDescriptor is morphed into a WorkerTarget when calling this method. + // Ideally, we would split this into two distinct classes. + async morphWorkerDescriptorIntoWorkerTarget() { + // temporary, will be moved once we have a target actor + return this.getTarget(); + } + + async getTarget() { + if (this._attach) { + return this._attach; + } + + this._attach = (async () => { + if (this.isDestroyedOrBeingDestroyed()) { + return this; + } + + if (this.isServiceWorker) { + this.registration = await this._getRegistrationIfActive(); + if (this.registration) { + await this.registration.preventShutdown(); + } + } + + if (this.isDestroyedOrBeingDestroyed()) { + return this; + } + + const workerTargetForm = await super.getTarget(); + + // Set the console and thread actor IDs on the form so it is accessible by TargetMixin.getFront + this.targetForm.consoleActor = workerTargetForm.consoleActor; + this.targetForm.threadActor = workerTargetForm.threadActor; + this.targetForm.tracerActor = workerTargetForm.tracerActor; + + if (this.isDestroyedOrBeingDestroyed()) { + return this; + } + + return this; + })(); + return this._attach; + } + + async detach() { + try { + await super.detach(); + + if (this.registration) { + // Bug 1644772 - Sometimes, the Browser Toolbox fails opening with a connection timeout + // with an exception related to this call to allowShutdown and its usage of detachDebugger API. + await this.registration.allowShutdown(); + this.registration = null; + } + } catch (e) { + this.logDetachError(e, "worker"); + } + } + + async _getRegistrationIfActive() { + const { registrations } = + await this.client.mainRoot.listServiceWorkerRegistrations(); + return registrations.find(({ activeWorker }) => { + return activeWorker && this.id === activeWorker.id; + }); + } + + reconfigure() { + // Toolbox and options panel are calling this method but Worker Target can't be + // reconfigured. So we ignore this call here. + return Promise.resolve(); + } +} + +exports.WorkerDescriptorFront = WorkerDescriptorFront; +registerFront(exports.WorkerDescriptorFront); diff --git a/devtools/client/fronts/device.js b/devtools/client/fronts/device.js new file mode 100644 index 0000000000..47f57c30b8 --- /dev/null +++ b/devtools/client/fronts/device.js @@ -0,0 +1,23 @@ +/* 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"; + +const { deviceSpec } = require("resource://devtools/shared/specs/device.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); + +class DeviceFront extends FrontClassWithSpec(deviceSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + // Attribute name from which to retrieve the actorID out of the target actor's form + this.formAttributeName = "deviceActor"; + } +} + +exports.DeviceFront = DeviceFront; +registerFront(DeviceFront); diff --git a/devtools/client/fronts/frame.js b/devtools/client/fronts/frame.js new file mode 100644 index 0000000000..525f0d1170 --- /dev/null +++ b/devtools/client/fronts/frame.js @@ -0,0 +1,27 @@ +/* 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"; + +const { frameSpec } = require("resource://devtools/shared/specs/frame.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); + +class FrameFront extends FrontClassWithSpec(frameSpec) { + form(json) { + this.displayName = json.displayName; + this.arguments = json.arguments; + this.type = json.type; + this.where = json.where; + this.this = json.this; + this.data = json; + this.asyncCause = json.asyncCause; + this.state = json.state; + } +} + +module.exports = FrameFront; +registerFront(FrameFront); diff --git a/devtools/client/fronts/highlighters.js b/devtools/client/fronts/highlighters.js new file mode 100644 index 0000000000..b1e159e5fc --- /dev/null +++ b/devtools/client/fronts/highlighters.js @@ -0,0 +1,53 @@ +/* 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"; + +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + customHighlighterSpec, +} = require("resource://devtools/shared/specs/highlighters.js"); +const { + safeAsyncMethod, +} = require("resource://devtools/shared/async-utils.js"); + +class CustomHighlighterFront extends FrontClassWithSpec(customHighlighterSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + // show/hide requests can be triggered while DevTools are closing. + this.show = safeAsyncMethod(this.show.bind(this), () => this.isDestroyed()); + this.hide = safeAsyncMethod(this.hide.bind(this), () => this.isDestroyed()); + + this._isShown = false; + } + + show(...args) { + this._isShown = true; + return super.show(...args); + } + + hide() { + this._isShown = false; + return super.hide(); + } + + isShown() { + return this._isShown; + } + + destroy() { + if (this.isDestroyed()) { + return; + } + super.finalize(); // oneway call, doesn't expect a response. + super.destroy(); + } +} + +exports.CustomHighlighterFront = CustomHighlighterFront; +registerFront(CustomHighlighterFront); diff --git a/devtools/client/fronts/inspector.js b/devtools/client/fronts/inspector.js new file mode 100644 index 0000000000..116993078b --- /dev/null +++ b/devtools/client/fronts/inspector.js @@ -0,0 +1,282 @@ +/* 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"; + +const Telemetry = require("resource://devtools/client/shared/telemetry.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + inspectorSpec, +} = require("resource://devtools/shared/specs/inspector.js"); + +loader.lazyRequireGetter( + this, + "captureScreenshot", + "resource://devtools/client/shared/screenshot.js", + true +); + +const TELEMETRY_EYEDROPPER_OPENED = "DEVTOOLS_EYEDROPPER_OPENED_COUNT"; +const TELEMETRY_EYEDROPPER_OPENED_MENU = + "DEVTOOLS_MENU_EYEDROPPER_OPENED_COUNT"; +const SHOW_ALL_ANONYMOUS_CONTENT_PREF = + "devtools.inspector.showAllAnonymousContent"; + +const telemetry = new Telemetry(); + +/** + * Client side of the inspector actor, which is used to create + * inspector-related actors, including the walker. + */ +class InspectorFront extends FrontClassWithSpec(inspectorSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + this._client = client; + this._highlighters = new Map(); + + // Attribute name from which to retrieve the actorID out of the target actor's form + this.formAttributeName = "inspectorActor"; + + // Map of highlighter types to unsettled promises to create a highlighter of that type + this._pendingGetHighlighterMap = new Map(); + + this.noopStylesheetListener = () => {}; + } + + // async initialization + async initialize() { + if (this.initialized) { + return this.initialized; + } + + // Watch STYLESHEET resources to fill the ResourceCommand cache. + // StyleRule front's `get parentStyleSheet()` will query the cache to + // retrieve the resource corresponding to the parent stylesheet of a rule. + const { resourceCommand } = this.targetFront.commands; + // Backup resourceCommand, targetFront.commands might be null in `destroy`. + this.resourceCommand = resourceCommand; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: this.noopStylesheetListener, + }); + + // Bail out if the inspector is closed while watchResources was pending + if (this.isDestroyed()) { + return null; + } + + this.initialized = await Promise.all([ + this._getWalker(), + this._getPageStyle(), + ]); + + return this.initialized; + } + + async _getWalker() { + const showAllAnonymousContent = Services.prefs.getBoolPref( + SHOW_ALL_ANONYMOUS_CONTENT_PREF + ); + this.walker = await this.getWalker({ + showAllAnonymousContent, + }); + + // We need to reparent the RootNode of remote iframe Walkers + // so that their parent is the NodeFront of the <iframe> + // element, coming from another process/target/WalkerFront. + await this.walker.reparentRemoteFrame(); + } + + hasHighlighter(type) { + return this._highlighters.has(type); + } + + async _getPageStyle() { + this.pageStyle = await super.getPageStyle(); + } + + async getCompatibilityFront() { + if (!this._compatibility) { + this._compatibility = await super.getCompatibility(); + } + + return this._compatibility; + } + + destroy() { + if (this.isDestroyed()) { + return; + } + this._compatibility = null; + + const { resourceCommand } = this; + resourceCommand.unwatchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: this.noopStylesheetListener, + }); + this.resourceCommand = null; + + this.walker = null; + + // CustomHighlighter fronts are managed by InspectorFront and so will be + // automatically destroyed. But we have to clear the `_highlighters` + // Map as well as explicitly call `finalize` request on all of them. + this.destroyHighlighters(); + super.destroy(); + } + + destroyHighlighters() { + for (const type of this._highlighters.keys()) { + if (this._highlighters.has(type)) { + const highlighter = this._highlighters.get(type); + if (!highlighter.isDestroyed()) { + highlighter.finalize(); + } + this._highlighters.delete(type); + } + } + } + + async getHighlighterByType(typeName) { + let highlighter = null; + try { + highlighter = await super.getHighlighterByType(typeName); + } catch (_) { + throw new Error( + "The target doesn't support " + + `creating highlighters by types or ${typeName} is unknown` + ); + } + return highlighter; + } + + getKnownHighlighter(type) { + return this._highlighters.get(type); + } + + /** + * Return a highlighter instance of the given type. + * If an instance was previously created, return it. Else, create and return a new one. + * + * Store a promise for the request to create a new highlighter. If another request + * comes in before that promise is resolved, wait for it to resolve and return the + * highlighter instance it resolved with instead of creating a new request. + * + * @param {String} type + * Highlighter type + * @return {Promise} + * Promise which resolves with a highlighter instance of the given type + */ + async getOrCreateHighlighterByType(type) { + let front = this._highlighters.get(type); + let pendingGetHighlighter = this._pendingGetHighlighterMap.get(type); + + if (!front && !pendingGetHighlighter) { + pendingGetHighlighter = (async () => { + const highlighter = await this.getHighlighterByType(type); + this._highlighters.set(type, highlighter); + this._pendingGetHighlighterMap.delete(type); + return highlighter; + })(); + + this._pendingGetHighlighterMap.set(type, pendingGetHighlighter); + } + + if (pendingGetHighlighter) { + front = await pendingGetHighlighter; + } + + return front; + } + + async pickColorFromPage(options) { + let screenshot = null; + + // @backward-compat { version 87 } ScreenshotContentActor was only added in 87. + // When connecting to older server, the eyedropper will use drawWindow + // to retrieve the screenshot of the page (that's a decent fallback, + // even if it doesn't handle remote frames). + if (this.targetFront.hasActor("screenshotContent")) { + try { + // We use the screenshot actors as it can retrieve an image of the current viewport, + // handling remote frame if need be. + const { data } = await captureScreenshot(this.targetFront, { + browsingContextID: this.targetFront.browsingContextID, + disableFlash: true, + ignoreDprForFileScale: true, + }); + screenshot = data; + } catch (e) { + // We simply log the error and still call pickColorFromPage as it will default to + // use drawWindow in order to get the screenshot of the page (that's a decent + // fallback, even if it doesn't handle remote frames). + console.error( + "Error occured when taking a screenshot for the eyedropper", + e + ); + } + } + + await super.pickColorFromPage({ + ...options, + screenshot, + }); + + if (options?.fromMenu) { + telemetry.getHistogramById(TELEMETRY_EYEDROPPER_OPENED_MENU).add(true); + } else { + telemetry.getHistogramById(TELEMETRY_EYEDROPPER_OPENED).add(true); + } + } + + /** + * Given a node grip, return a NodeFront on the right context. + * + * @param {Object} grip: The node grip. + * @returns {Promise<NodeFront|null>} A promise that resolves with a NodeFront or null + * if the NodeFront couldn't be created/retrieved. + */ + async getNodeFrontFromNodeGrip(grip) { + return this.getNodeActorFromContentDomReference(grip.contentDomReference); + } + + async getNodeActorFromContentDomReference(contentDomReference) { + const { browsingContextId } = contentDomReference; + // If the contentDomReference lives in the same browsing context id than the + // current one, we can directly use the current walker. + if (this.targetFront.browsingContextID === browsingContextId) { + return this.walker.getNodeActorFromContentDomReference( + contentDomReference + ); + } + + // If the contentDomReference has a different browsing context than the current one, + // we are either in Fission or in the Multiprocess Browser Toolbox, so we need to + // retrieve the walker of the WindowGlobalTarget. + // Get the target for this remote frame element + + // Tab and Process Descriptors expose a Watcher, which should be used to + // fetch the node's target. + let target; + const { watcherFront } = this.targetFront.commands; + if (watcherFront) { + target = await watcherFront.getWindowGlobalTarget(browsingContextId); + } else { + // For descriptors which don't expose a watcher (e.g. WebExtension) + // we used to call RootActor::getBrowsingContextDescriptor, but it was + // removed in FF77. + // Support for watcher in WebExtension descriptors is Bug 1644341. + throw new Error( + `Unable to call getNodeActorFromContentDomReference for ${this.targetFront.actorID}` + ); + } + const { walker } = await target.getFront("inspector"); + return walker.getNodeActorFromContentDomReference(contentDomReference); + } +} + +exports.InspectorFront = InspectorFront; +registerFront(InspectorFront); diff --git a/devtools/client/fronts/inspector/moz.build b/devtools/client/fronts/inspector/moz.build new file mode 100644 index 0000000000..de635f5947 --- /dev/null +++ b/devtools/client/fronts/inspector/moz.build @@ -0,0 +1,9 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + "rule-rewriter.js", +) diff --git a/devtools/client/fronts/inspector/rule-rewriter.js b/devtools/client/fronts/inspector/rule-rewriter.js new file mode 100644 index 0000000000..30d1cf88d2 --- /dev/null +++ b/devtools/client/fronts/inspector/rule-rewriter.js @@ -0,0 +1,745 @@ +/* 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/. */ + +// This file holds various CSS parsing and rewriting utilities. +// Some entry points of note are: +// parseDeclarations - parse a CSS rule into declarations +// RuleRewriter - rewrite CSS rule text +// parsePseudoClassesAndAttributes - parse selector and extract +// pseudo-classes +// parseSingleValue - parse a single CSS property value + +"use strict"; + +const { getCSSLexer } = require("resource://devtools/shared/css/lexer.js"); +const { + COMMENT_PARSING_HEURISTIC_BYPASS_CHAR, + escapeCSSComment, + parseNamedDeclarations, + unescapeCSSComment, +} = require("resource://devtools/shared/css/parsing-utils.js"); + +loader.lazyRequireGetter( + this, + ["getIndentationFromPrefs", "getIndentationFromString"], + "resource://devtools/shared/indentation.js", + true +); + +// Used to test whether a newline appears anywhere in some text. +const NEWLINE_RX = /[\r\n]/; +// Used to test whether a bit of text starts an empty comment, either +// an "ordinary" /* ... */ comment, or a "heuristic bypass" comment +// like /*! ... */. +const EMPTY_COMMENT_START_RX = /^\/\*!?[ \r\n\t\f]*$/; +// Used to test whether a bit of text ends an empty comment. +const EMPTY_COMMENT_END_RX = /^[ \r\n\t\f]*\*\//; +// Used to test whether a string starts with a blank line. +const BLANK_LINE_RX = /^[ \t]*(?:\r\n|\n|\r|\f|$)/; + +/** + * Return an object that can be used to rewrite declarations in some + * source text. The source text and parsing are handled in the same + * way as @see parseNamedDeclarations, with |parseComments| being true. + * Rewriting is done by calling one of the modification functions like + * setPropertyEnabled. The returned object has the same interface + * as @see RuleModificationList. + * + * An example showing how to disable the 3rd property in a rule: + * + * let rewriter = new RuleRewriter(isCssPropertyKnown, ruleActor, + * ruleActor.authoredText); + * rewriter.setPropertyEnabled(3, "color", false); + * rewriter.apply().then(() => { ... the change is made ... }); + * + * The exported rewriting methods are |renameProperty|, |setPropertyEnabled|, + * |createProperty|, |setProperty|, and |removeProperty|. The |apply| + * method can be used to send the edited text to the StyleRuleActor; + * |getDefaultIndentation| is useful for the methods requiring a + * default indentation value; and |getResult| is useful for testing. + * + * Additionally, editing will set the |changedDeclarations| property + * on this object. This property has the same form as the |changed| + * property of the object returned by |getResult|. + * + * @param {Function} isCssPropertyKnown + * A function to check if the CSS property is known. This is either an + * internal server function or from the CssPropertiesFront. + * that are supported by the server. Note that if Bug 1222047 + * is completed then isCssPropertyKnown will not need to be passed in. + * The CssProperty front will be able to obtained directly from the + * RuleRewriter. + * @param {StyleRuleFront} rule The style rule to use. Note that this + * is only needed by the |apply| and |getDefaultIndentation| methods; + * and in particular for testing it can be |null|. + * @param {String} inputString The CSS source text to parse and modify. + * @return {Object} an object that can be used to rewrite the input text. + */ +function RuleRewriter(isCssPropertyKnown, rule, inputString) { + this.rule = rule; + this.isCssPropertyKnown = isCssPropertyKnown; + // The RuleRewriter sends CSS rules as text to the server, but with this modifications + // array, it also sends the list of changes so the server doesn't have to re-parse the + // rule if it needs to track what changed. + this.modifications = []; + + // Keep track of which any declarations we had to rewrite while + // performing the requested action. + this.changedDeclarations = {}; + + // If not null, a promise that must be wait upon before |apply| can + // do its work. + this.editPromise = null; + + // If the |defaultIndentation| property is set, then it is used; + // otherwise the RuleRewriter will try to compute the default + // indentation based on the style sheet's text. This override + // facility is for testing. + this.defaultIndentation = null; + + this.startInitialization(inputString); +} + +RuleRewriter.prototype = { + /** + * An internal function to initialize the rewriter with a given + * input string. + * + * @param {String} inputString the input to use + */ + startInitialization(inputString) { + this.inputString = inputString; + // Whether there are any newlines in the input text. + this.hasNewLine = /[\r\n]/.test(this.inputString); + // The declarations. + this.declarations = parseNamedDeclarations( + this.isCssPropertyKnown, + this.inputString, + true + ); + this.decl = null; + this.result = null; + }, + + /** + * An internal function to complete initialization and set some + * properties for further processing. + * + * @param {Number} index The index of the property to modify + */ + completeInitialization(index) { + if (index < 0) { + throw new Error("Invalid index " + index + ". Expected positive integer"); + } + // |decl| is the declaration to be rewritten, or null if there is no + // declaration corresponding to |index|. + // |result| is used to accumulate the result text. + if (index < this.declarations.length) { + this.decl = this.declarations[index]; + this.result = this.inputString.substring(0, this.decl.offsets[0]); + } else { + this.decl = null; + this.result = this.inputString; + } + }, + + /** + * A helper function to compute the indentation of some text. This + * examines the rule's existing text to guess the indentation to use; + * unlike |getDefaultIndentation|, which examines the entire style + * sheet. + * + * @param {String} string the input text + * @param {Number} offset the offset at which to compute the indentation + * @return {String} the indentation at the indicated position + */ + getIndentation(string, offset) { + let originalOffset = offset; + for (--offset; offset >= 0; --offset) { + const c = string[offset]; + if (c === "\r" || c === "\n" || c === "\f") { + return string.substring(offset + 1, originalOffset); + } + if (c !== " " && c !== "\t") { + // Found some non-whitespace character before we found a newline + // -- let's reset the starting point and keep going, as we saw + // something on the line before the declaration. + originalOffset = offset; + } + } + // Ran off the end. + return ""; + }, + + /** + * Modify a property value to ensure it is "lexically safe" for + * insertion into a style sheet. This function doesn't attempt to + * ensure that the resulting text is a valid value for the given + * property; but rather just that inserting the text into the style + * sheet will not cause unwanted changes to other rules or + * declarations. + * + * @param {String} text The input text. This should include the trailing ";". + * @return {Array} An array of the form [anySanitized, text], where + * |anySanitized| is a boolean that indicates + * whether anything substantive has changed; and + * where |text| is the text that has been rewritten + * to be "lexically safe". + */ + sanitizePropertyValue(text) { + // Start by stripping any trailing ";". This is done here to + // avoid the case where the user types "url(" (which is turned + // into "url(;" by the rule view before coming here), being turned + // into "url(;)" by this code -- due to the way "url(...)" is + // parsed as a single token. + text = text.replace(/;$/, ""); + const lexer = getCSSLexer(text); + + let result = ""; + let previousOffset = 0; + const parenStack = []; + let anySanitized = false; + + // Push a closing paren on the stack. + const pushParen = (token, closer) => { + result = + result + + text.substring(previousOffset, token.startOffset) + + text.substring(token.startOffset, token.endOffset); + // We set the location of the paren in a funny way, to handle + // the case where we've seen a function token, where the paren + // appears at the end. + parenStack.push({ closer, offset: result.length - 1 }); + previousOffset = token.endOffset; + }; + + // Pop a closing paren from the stack. + const popSomeParens = closer => { + while (parenStack.length) { + const paren = parenStack.pop(); + + if (paren.closer === closer) { + return true; + } + + // Found a non-matching closing paren, so quote it. Note that + // these are processed in reverse order. + result = + result.substring(0, paren.offset) + + "\\" + + result.substring(paren.offset); + anySanitized = true; + } + return false; + }; + + while (true) { + const token = lexer.nextToken(); + if (!token) { + break; + } + + if (token.tokenType === "symbol") { + switch (token.text) { + case ";": + // We simply drop the ";" here. This lets us cope with + // declarations that don't have a ";" and also other + // termination. The caller handles adding the ";" again. + result += text.substring(previousOffset, token.startOffset); + previousOffset = token.endOffset; + break; + + case "{": + pushParen(token, "}"); + break; + + case "(": + pushParen(token, ")"); + break; + + case "[": + pushParen(token, "]"); + break; + + case "}": + case ")": + case "]": + // Did we find an unmatched close bracket? + if (!popSomeParens(token.text)) { + // Copy out text from |previousOffset|. + result += text.substring(previousOffset, token.startOffset); + // Quote the offending symbol. + result += "\\" + token.text; + previousOffset = token.endOffset; + anySanitized = true; + } + break; + } + } else if (token.tokenType === "function") { + pushParen(token, ")"); + } + } + + // Fix up any unmatched parens. + popSomeParens(null); + + // Copy out any remaining text, then any needed terminators. + result += text.substring(previousOffset, text.length); + const eofFixup = lexer.performEOFFixup("", true); + if (eofFixup) { + anySanitized = true; + result += eofFixup; + } + return [anySanitized, result]; + }, + + /** + * Start at |index| and skip whitespace + * backward in |string|. Return the index of the first + * non-whitespace character, or -1 if the entire string was + * whitespace. + * @param {String} string the input string + * @param {Number} index the index at which to start + * @return {Number} index of the first non-whitespace character, or -1 + */ + skipWhitespaceBackward(string, index) { + for ( + --index; + index >= 0 && (string[index] === " " || string[index] === "\t"); + --index + ) { + // Nothing. + } + return index; + }, + + /** + * Terminate a given declaration, if needed. + * + * @param {Number} index The index of the rule to possibly + * terminate. It might be invalid, so this + * function must check for that. + */ + maybeTerminateDecl(index) { + if ( + index < 0 || + index >= this.declarations.length || + // No need to rewrite declarations in comments. + "commentOffsets" in this.declarations[index] + ) { + return; + } + + const termDecl = this.declarations[index]; + let endIndex = termDecl.offsets[1]; + // Due to an oddity of the lexer, we might have gotten a bit of + // extra whitespace in a trailing bad_url token -- so be sure to + // skip that as well. + endIndex = this.skipWhitespaceBackward(this.result, endIndex) + 1; + + const trailingText = this.result.substring(endIndex); + if (termDecl.terminator) { + // Insert the terminator just at the end of the declaration, + // before any trailing whitespace. + this.result = + this.result.substring(0, endIndex) + termDecl.terminator + trailingText; + // In a couple of cases, we may have had to add something to + // terminate the declaration, but the termination did not + // actually affect the property's value -- and at this spot, we + // only care about reporting value changes. In particular, we + // might have added a plain ";", or we might have terminated a + // comment with "*/;". Neither of these affect the value. + if (termDecl.terminator !== ";" && termDecl.terminator !== "*/;") { + this.changedDeclarations[index] = + termDecl.value + termDecl.terminator.slice(0, -1); + } + } + // If the rule generally has newlines, but this particular + // declaration doesn't have a trailing newline, insert one now. + // Maybe this style is too weird to bother with. + if (this.hasNewLine && !NEWLINE_RX.test(trailingText)) { + this.result += "\n"; + } + }, + + /** + * Sanitize the given property value and return the sanitized form. + * If the property is rewritten during sanitization, make a note in + * |changedDeclarations|. + * + * @param {String} text The property text. + * @param {Number} index The index of the property. + * @return {String} The sanitized text. + */ + sanitizeText(text, index) { + const [anySanitized, sanitizedText] = this.sanitizePropertyValue(text); + if (anySanitized) { + this.changedDeclarations[index] = sanitizedText; + } + return sanitizedText; + }, + + /** + * Rename a declaration. + * + * @param {Number} index index of the property in the rule. + * @param {String} name current name of the property + * @param {String} newName new name of the property + */ + renameProperty(index, name, newName) { + this.completeInitialization(index); + this.result += CSS.escape(newName); + // We could conceivably compute the name offsets instead so we + // could preserve white space and comments on the LHS of the ":". + this.completeCopying(this.decl.colonOffsets[0]); + this.modifications.push({ type: "set", index, name, newName }); + }, + + /** + * Enable or disable a declaration + * + * @param {Number} index index of the property in the rule. + * @param {String} name current name of the property + * @param {Boolean} isEnabled true if the property should be enabled; + * false if it should be disabled + */ + setPropertyEnabled(index, name, isEnabled) { + this.completeInitialization(index); + const decl = this.decl; + const priority = decl.priority; + let copyOffset = decl.offsets[1]; + if (isEnabled) { + // Enable it. First see if the comment start can be deleted. + const commentStart = decl.commentOffsets[0]; + if (EMPTY_COMMENT_START_RX.test(this.result.substring(commentStart))) { + this.result = this.result.substring(0, commentStart); + } else { + this.result += "*/ "; + } + + // Insert the name and value separately, so we can report + // sanitization changes properly. + const commentNamePart = this.inputString.substring( + decl.offsets[0], + decl.colonOffsets[1] + ); + this.result += unescapeCSSComment(commentNamePart); + + // When uncommenting, we must be sure to sanitize the text, to + // avoid things like /* decl: }; */, which will be accepted as + // a property but which would break the entire style sheet. + let newText = this.inputString.substring( + decl.colonOffsets[1], + decl.offsets[1] + ); + newText = cssTrimRight(unescapeCSSComment(newText)); + this.result += this.sanitizeText(newText, index) + ";"; + + // See if the comment end can be deleted. + const trailingText = this.inputString.substring(decl.offsets[1]); + if (EMPTY_COMMENT_END_RX.test(trailingText)) { + copyOffset = decl.commentOffsets[1]; + } else { + this.result += " /*"; + } + } else { + // Disable it. Note that we use our special comment syntax + // here. + const declText = this.inputString.substring( + decl.offsets[0], + decl.offsets[1] + ); + this.result += + "/*" + + COMMENT_PARSING_HEURISTIC_BYPASS_CHAR + + " " + + escapeCSSComment(declText) + + " */"; + } + this.completeCopying(copyOffset); + + if (isEnabled) { + this.modifications.push({ + type: "set", + index, + name, + value: decl.value, + priority, + }); + } else { + this.modifications.push({ type: "disable", index, name }); + } + }, + + /** + * Return a promise that will be resolved to the default indentation + * of the rule. This is a helper for internalCreateProperty. + * + * @return {Promise} a promise that will be resolved to a string + * that holds the default indentation that should be used + * for edits to the rule. + */ + async getDefaultIndentation() { + if (!this.rule.parentStyleSheet) { + return null; + } + + const prefIndent = getIndentationFromPrefs(); + if (prefIndent) { + const { indentUnit, indentWithTabs } = prefIndent; + return indentWithTabs ? "\t" : " ".repeat(indentUnit); + } + + const styleSheetsFront = await this.rule.targetFront.getFront( + "stylesheets" + ); + const { str: source } = await styleSheetsFront.getText( + this.rule.parentStyleSheet.resourceId + ); + const { indentUnit, indentWithTabs } = getIndentationFromString(source); + return indentWithTabs ? "\t" : " ".repeat(indentUnit); + }, + + /** + * An internal function to create a new declaration. This does all + * the work of |createProperty|. + * + * @param {Number} index index of the property in the rule. + * @param {String} name name of the new property + * @param {String} value value of the new property + * @param {String} priority priority of the new property; either + * the empty string or "important" + * @param {Boolean} enabled True if the new property should be + * enabled, false if disabled + * @return {Promise} a promise that is resolved when the edit has + * completed + */ + async internalCreateProperty(index, name, value, priority, enabled) { + this.completeInitialization(index); + let newIndentation = ""; + if (this.hasNewLine) { + if (this.declarations.length) { + newIndentation = this.getIndentation( + this.inputString, + this.declarations[0].offsets[0] + ); + } else if (this.defaultIndentation) { + newIndentation = this.defaultIndentation; + } else { + newIndentation = await this.getDefaultIndentation(); + } + } + + this.maybeTerminateDecl(index - 1); + + // If we generally have newlines, and if skipping whitespace + // backward stops at a newline, then insert our text before that + // whitespace. This ensures the indentation we computed is what + // is actually used. + let savedWhitespace = ""; + if (this.hasNewLine) { + const wsOffset = this.skipWhitespaceBackward( + this.result, + this.result.length + ); + if (this.result[wsOffset] === "\r" || this.result[wsOffset] === "\n") { + savedWhitespace = this.result.substring(wsOffset + 1); + this.result = this.result.substring(0, wsOffset + 1); + } + } + + let newText = CSS.escape(name) + ": " + this.sanitizeText(value, index); + if (priority === "important") { + newText += " !important"; + } + newText += ";"; + + if (!enabled) { + newText = + "/*" + + COMMENT_PARSING_HEURISTIC_BYPASS_CHAR + + " " + + escapeCSSComment(newText) + + " */"; + } + + this.result += newIndentation + newText; + if (this.hasNewLine) { + this.result += "\n"; + } + this.result += savedWhitespace; + + if (this.decl) { + // Still want to copy in the declaration previously at this + // index. + this.completeCopying(this.decl.offsets[0]); + } + }, + + /** + * Create a new declaration. + * + * @param {Number} index index of the property in the rule. + * @param {String} name name of the new property + * @param {String} value value of the new property + * @param {String} priority priority of the new property; either + * the empty string or "important" + * @param {Boolean} enabled True if the new property should be + * enabled, false if disabled + */ + createProperty(index, name, value, priority, enabled) { + this.editPromise = this.internalCreateProperty( + index, + name, + value, + priority, + enabled + ); + // Log the modification only if the created property is enabled. + if (enabled) { + this.modifications.push({ type: "set", index, name, value, priority }); + } + }, + + /** + * Set a declaration's value. + * + * @param {Number} index index of the property in the rule. + * This can be -1 in the case where + * the rule does not support setRuleText; + * generally for setting properties + * on an element's style. + * @param {String} name the property's name + * @param {String} value the property's value + * @param {String} priority the property's priority, either the empty + * string or "important" + */ + setProperty(index, name, value, priority) { + this.completeInitialization(index); + // We might see a "set" on a previously non-existent property; in + // that case, act like "create". + if (!this.decl) { + this.createProperty(index, name, value, priority, true); + return; + } + + // Note that this assumes that "set" never operates on disabled + // properties. + this.result += + this.inputString.substring( + this.decl.offsets[0], + this.decl.colonOffsets[1] + ) + this.sanitizeText(value, index); + + if (priority === "important") { + this.result += " !important"; + } + this.result += ";"; + this.completeCopying(this.decl.offsets[1]); + this.modifications.push({ type: "set", index, name, value, priority }); + }, + + /** + * Remove a declaration. + * + * @param {Number} index index of the property in the rule. + * @param {String} name the name of the property to remove + */ + removeProperty(index, name) { + this.completeInitialization(index); + + // If asked to remove a property that does not exist, bail out. + if (!this.decl) { + return; + } + + // If the property is disabled, then first enable it, and then + // delete it. We take this approach because we want to remove the + // entire comment if possible; but the logic for dealing with + // comments is hairy and already implemented in + // setPropertyEnabled. + if (this.decl.commentOffsets) { + this.setPropertyEnabled(index, name, true); + this.startInitialization(this.result); + this.completeInitialization(index); + } + + let copyOffset = this.decl.offsets[1]; + // Maybe removing this rule left us with a completely blank + // line. In this case, we'll delete the whole thing. We only + // bother with this if we're looking at sources that already + // have a newline somewhere. + if (this.hasNewLine) { + const nlOffset = this.skipWhitespaceBackward( + this.result, + this.decl.offsets[0] + ); + if ( + nlOffset < 0 || + this.result[nlOffset] === "\r" || + this.result[nlOffset] === "\n" + ) { + const trailingText = this.inputString.substring(copyOffset); + const match = BLANK_LINE_RX.exec(trailingText); + if (match) { + this.result = this.result.substring(0, nlOffset + 1); + copyOffset += match[0].length; + } + } + } + this.completeCopying(copyOffset); + this.modifications.push({ type: "remove", index, name }); + }, + + /** + * An internal function to copy any trailing text to the output + * string. + * + * @param {Number} copyOffset Offset into |inputString| of the + * final text to copy to the output string. + */ + completeCopying(copyOffset) { + // Add the trailing text. + this.result += this.inputString.substring(copyOffset); + }, + + /** + * Apply the modifications in this object to the associated rule. + * + * @return {Promise} A promise which will be resolved when the modifications + * are complete. + */ + apply() { + return Promise.resolve(this.editPromise).then(() => { + return this.rule.setRuleText(this.result, this.modifications); + }); + }, + + /** + * Get the result of the rewriting. This is used for testing. + * + * @return {object} an object of the form {changed: object, text: string} + * |changed| is an object where each key is + * the index of a property whose value had to be + * rewritten during the sanitization process, and + * whose value is the new text of the property. + * |text| is the rewritten text of the rule. + */ + getResult() { + return { changed: this.changedDeclarations, text: this.result }; + }, +}; + +/** + * Like trimRight, but only trims CSS-allowed whitespace. + */ +function cssTrimRight(str) { + const match = /^(.*?)[ \t\r\n\f]*$/.exec(str); + if (match) { + return match[1]; + } + return str; +} + +module.exports = RuleRewriter; diff --git a/devtools/client/fronts/layout.js b/devtools/client/fronts/layout.js new file mode 100644 index 0000000000..6f84032ba6 --- /dev/null +++ b/devtools/client/fronts/layout.js @@ -0,0 +1,184 @@ +/* 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"; + +const { + safeAsyncMethod, +} = require("resource://devtools/shared/async-utils.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + flexboxSpec, + flexItemSpec, + gridSpec, + layoutSpec, +} = require("resource://devtools/shared/specs/layout.js"); + +class FlexboxFront extends FrontClassWithSpec(flexboxSpec) { + form(form) { + this._form = form; + } + + /** + * In some cases, the FlexboxActor already knows the NodeActor ID of the node where the + * flexbox is located. In such cases, this getter returns the NodeFront for it. + */ + get containerNodeFront() { + if (!this._form.containerNodeActorID) { + return null; + } + + return this.conn.getFrontByID(this._form.containerNodeActorID); + } + + /** + * Get the WalkerFront instance that owns this FlexboxFront. + */ + get walkerFront() { + return this.parentFront.walkerFront; + } + + /** + * Get the computed style properties for the flex container. + */ + get properties() { + return this._form.properties; + } +} + +class FlexItemFront extends FrontClassWithSpec(flexItemSpec) { + form(form) { + this._form = form; + } + + /** + * Get the flex item sizing data. + */ + get flexItemSizing() { + return this._form.flexItemSizing; + } + + /** + * In some cases, the FlexItemActor already knows the NodeActor ID of the node where the + * flex item is located. In such cases, this getter returns the NodeFront for it. + */ + get nodeFront() { + if (!this._form.nodeActorID) { + return null; + } + + return this.conn.getFrontByID(this._form.nodeActorID); + } + + /** + * Get the WalkerFront instance that owns this FlexItemFront. + */ + get walkerFront() { + return this.parentFront.walkerFront; + } + + /** + * Get the computed style properties for the flex item. + */ + get computedStyle() { + return this._form.computedStyle; + } + + /** + * Get the style properties for the flex item. + */ + get properties() { + return this._form.properties; + } +} + +class GridFront extends FrontClassWithSpec(gridSpec) { + form(form) { + this._form = form; + } + + /** + * In some cases, the GridActor already knows the NodeActor ID of the node where the + * grid is located. In such cases, this getter returns the NodeFront for it. + */ + get containerNodeFront() { + if (!this._form.containerNodeActorID) { + return null; + } + + return this.conn.getFrontByID(this._form.containerNodeActorID); + } + + /** + * Get the WalkerFront instance that owns this GridFront. + */ + get walkerFront() { + return this.parentFront.walkerFront; + } + + /** + * Get the text direction of the grid container. + */ + get direction() { + return this._form.direction; + } + + /** + * Getter for the grid fragments data. + */ + get gridFragments() { + return this._form.gridFragments; + } + + /** + * Get whether or not the grid is a subgrid. + */ + get isSubgrid() { + return !!this._form.isSubgrid; + } + + /** + * Get the writing mode of the grid container. + */ + get writingMode() { + return this._form.writingMode; + } +} + +class LayoutFront extends FrontClassWithSpec(layoutSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + this.getAllGrids = safeAsyncMethod( + this.getAllGrids.bind(this), + () => this.isDestroyed(), + [] + ); + } + /** + * Get the WalkerFront instance that owns this LayoutFront. + */ + get walkerFront() { + return this.parentFront; + } + + getAllGrids() { + if (!this.walkerFront.rootNode) { + return []; + } + return this.getGrids(this.walkerFront.rootNode); + } +} + +exports.FlexboxFront = FlexboxFront; +registerFront(FlexboxFront); +exports.FlexItemFront = FlexItemFront; +registerFront(FlexItemFront); +exports.GridFront = GridFront; +registerFront(GridFront); +exports.LayoutFront = LayoutFront; +registerFront(LayoutFront); diff --git a/devtools/client/fronts/manifest.js b/devtools/client/fronts/manifest.js new file mode 100644 index 0000000000..0cd720d278 --- /dev/null +++ b/devtools/client/fronts/manifest.js @@ -0,0 +1,25 @@ +/* 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"; + +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + manifestSpec, +} = require("resource://devtools/shared/specs/manifest.js"); + +class ManifestFront extends FrontClassWithSpec(manifestSpec) { + constructor(client) { + super(client); + + // attribute name from which to retrieve the actorID out of the target actor's form + this.formAttributeName = "manifestActor"; + } +} + +exports.ManifestFront = ManifestFront; +registerFront(ManifestFront); diff --git a/devtools/client/fronts/memory.js b/devtools/client/fronts/memory.js new file mode 100644 index 0000000000..80ba417b67 --- /dev/null +++ b/devtools/client/fronts/memory.js @@ -0,0 +1,116 @@ +/* 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"; + +const { memorySpec } = require("resource://devtools/shared/specs/memory.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", +}); +loader.lazyRequireGetter( + this, + "HeapSnapshotFileUtils", + "resource://devtools/shared/heapsnapshot/HeapSnapshotFileUtils.js" +); + +class MemoryFront extends FrontClassWithSpec(memorySpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + this._client = client; + this.heapSnapshotFileActorID = null; + + // Attribute name from which to retrieve the actorID out of the target actor's form + this.formAttributeName = "memoryActor"; + } + + /** + * Save a heap snapshot, transfer it from the server to the client if the + * server and client do not share a file system, and return the local file + * path to the heap snapshot. + * + * Note that this is safe to call for actors inside sandoxed child processes, + * as we jump through the correct IPDL hoops. + * + * @params Boolean options.forceCopy + * Always force a bulk data copy of the saved heap snapshot, even when + * the server and client share a file system. + * + * @params {Object|undefined} options.boundaries + * The boundaries for the heap snapshot. See + * ChromeUtils.webidl for more details. + * + * @returns Promise<String> + */ + async saveHeapSnapshot(options = {}) { + const snapshotId = await super.saveHeapSnapshot(options.boundaries); + + if ( + !options.forceCopy && + (await HeapSnapshotFileUtils.haveHeapSnapshotTempFile(snapshotId)) + ) { + return HeapSnapshotFileUtils.getHeapSnapshotTempFilePath(snapshotId); + } + + return this.transferHeapSnapshot(snapshotId); + } + + /** + * Given that we have taken a heap snapshot with the given id, transfer the + * heap snapshot file to the client. The path to the client's local file is + * returned. + * + * @param {String} snapshotId + * + * @returns Promise<String> + */ + async transferHeapSnapshot(snapshotId) { + if (!this.heapSnapshotFileActorID) { + const form = await this._client.mainRoot.rootForm; + this.heapSnapshotFileActorID = form.heapSnapshotFileActor; + } + + try { + const request = this._client.request({ + to: this.heapSnapshotFileActorID, + type: "transferHeapSnapshot", + snapshotId, + }); + + const outFilePath = + HeapSnapshotFileUtils.getNewUniqueHeapSnapshotTempFilePath(); + const outFile = new lazy.FileUtils.File(outFilePath); + const outFileStream = lazy.FileUtils.openSafeFileOutputStream(outFile); + + // This request is a bulk request. That's why the result of the request is + // an object with the `copyTo` function that can transfer the data to + // another stream. + // See devtools/shared/transport/transport.js to know more about this mode. + const { copyTo } = await request; + await copyTo(outFileStream); + + lazy.FileUtils.closeSafeFileOutputStream(outFileStream); + return outFilePath; + } catch (e) { + if (e.error) { + // This isn't a real error, rather this is a message coming from the + // server. So let's throw a real error instead. + throw new Error( + `The server's actor threw an error: (${e.error}) ${e.message}` + ); + } + + // Otherwise, rethrow the error + throw e; + } + } +} + +exports.MemoryFront = MemoryFront; +registerFront(MemoryFront); diff --git a/devtools/client/fronts/moz.build b/devtools/client/fronts/moz.build new file mode 100644 index 0000000000..8b56870104 --- /dev/null +++ b/devtools/client/fronts/moz.build @@ -0,0 +1,58 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DIRS += [ + "addon", + "descriptors", + "inspector", + "targets", + "worker", +] + +DevToolsModules( + "accessibility.js", + "animation.js", + "array-buffer.js", + "blackboxing.js", + "breakpoint-list.js", + "changes.js", + "compatibility.js", + "css-properties.js", + "device.js", + "frame.js", + "highlighters.js", + "inspector.js", + "layout.js", + "manifest.js", + "memory.js", + "network-content.js", + "network-parent.js", + "node.js", + "object.js", + "page-style.js", + "perf.js", + "preference.js", + "private-properties-iterator.js", + "property-iterator.js", + "reflow.js", + "responsive.js", + "root.js", + "screenshot-content.js", + "screenshot.js", + "source.js", + "storage.js", + "string.js", + "style-rule.js", + "style-sheets.js", + "symbol-iterator.js", + "target-configuration.js", + "thread-configuration.js", + "thread.js", + "tracer.js", + "walker.js", + "watcher.js", + "webconsole.js", +) diff --git a/devtools/client/fronts/network-content.js b/devtools/client/fronts/network-content.js new file mode 100644 index 0000000000..43b4154c56 --- /dev/null +++ b/devtools/client/fronts/network-content.js @@ -0,0 +1,24 @@ +/* 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"; + +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + networkContentSpec, +} = require("resource://devtools/shared/specs/network-content.js"); + +class NetworkContentFront extends FrontClassWithSpec(networkContentSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + // Attribute name from which to retrieve the actorID out of the target actor's form + this.formAttributeName = "networkContentActor"; + } +} + +exports.NetworkContentFront = NetworkContentFront; +registerFront(NetworkContentFront); diff --git a/devtools/client/fronts/network-parent.js b/devtools/client/fronts/network-parent.js new file mode 100644 index 0000000000..c2b68449b5 --- /dev/null +++ b/devtools/client/fronts/network-parent.js @@ -0,0 +1,27 @@ +/* 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"; + +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + networkParentSpec, +} = require("resource://devtools/shared/specs/network-parent.js"); + +/** + * Client side for the network actor, used for managing network requests. + */ + +class NetworkParentFront extends FrontClassWithSpec(networkParentSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + // Attribute name from which to retrieve the actorID out of the target actor's form + this.formAttributeName = "networkParentActor"; + } +} + +registerFront(NetworkParentFront); diff --git a/devtools/client/fronts/node.js b/devtools/client/fronts/node.js new file mode 100644 index 0000000000..ef3497a92b --- /dev/null +++ b/devtools/client/fronts/node.js @@ -0,0 +1,629 @@ +/* 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"; + +const { + FrontClassWithSpec, + types, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + nodeSpec, + nodeListSpec, +} = require("resource://devtools/shared/specs/node.js"); +const { + SimpleStringFront, +} = require("resource://devtools/client/fronts/string.js"); + +loader.lazyRequireGetter( + this, + "nodeConstants", + "resource://devtools/shared/dom-node-constants.js" +); + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "browserToolboxScope", + "devtools.browsertoolbox.scope" +); + +const BROWSER_TOOLBOX_SCOPE_EVERYTHING = "everything"; + +const HIDDEN_CLASS = "__fx-devtools-hide-shortcut__"; + +/** + * Client side of a node list as returned by querySelectorAll() + */ +class NodeListFront extends FrontClassWithSpec(nodeListSpec) { + marshallPool() { + return this.getParent(); + } + + // Update the object given a form representation off the wire. + form(json) { + this.length = json.length; + } + + item(index) { + return super.item(index).then(response => { + return response.node; + }); + } + + items(start, end) { + return super.items(start, end).then(response => { + return response.nodes; + }); + } +} + +exports.NodeListFront = NodeListFront; +registerFront(NodeListFront); + +/** + * Convenience API for building a list of attribute modifications + * for the `modifyAttributes` request. + */ +class AttributeModificationList { + constructor(node) { + this.node = node; + this.modifications = []; + } + + apply() { + const ret = this.node.modifyAttributes(this.modifications); + return ret; + } + + destroy() { + this.node = null; + this.modification = null; + } + + setAttributeNS(ns, name, value) { + this.modifications.push({ + attributeNamespace: ns, + attributeName: name, + newValue: value, + }); + } + + setAttribute(name, value) { + this.setAttributeNS(undefined, name, value); + } + + removeAttributeNS(ns, name) { + this.setAttributeNS(ns, name, undefined); + } + + removeAttribute(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. + */ +class NodeFront extends FrontClassWithSpec(nodeSpec) { + constructor(conn, targetFront, parentFront) { + super(conn, targetFront, parentFront); + // 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; + // Store the flag to use it after destroy, where targetFront is set to null. + this._hasParentProcessTarget = targetFront.isParentProcess; + } + + /** + * 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() { + super.destroy(); + } + + // Update the object given a form representation off the wire. + form(form, ctx) { + // 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; + } + + this.traits = form.traits || {}; + + // 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.assign({}, 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. + const owner = ctx.marshallPool(); + if (typeof owner.ensureDOMNodeFront === "function") { + const parentNodeFront = owner.ensureDOMNodeFront(form.parent); + this.reparent(parentNodeFront); + } + } + + if (form.host) { + const owner = ctx.marshallPool(); + if (typeof owner.ensureDOMNodeFront === "function") { + this.host = owner.ensureDOMNodeFront(form.host); + } + } + + if (form.inlineTextChild) { + this.inlineTextChild = types + .getType("domnode") + .read(form.inlineTextChild, ctx); + } else { + this.inlineTextChild = undefined; + } + } + + /** + * Returns the parent NodeFront for this NodeFront. + */ + parentNode() { + return this._parent; + } + + /** + * Returns the NodeFront corresponding to the parentNode of this NodeFront, or the + * NodeFront corresponding to the host element for shadowRoot elements. + */ + parentOrHost() { + return this.isShadowRoot ? this.host : this._parent; + } + + /** + * Returns the owner DocumentElement|ShadowRootElement NodeFront for this NodeFront, + * or null if such element can't be found. + * + * @returns {NodeFront|null} + */ + getOwnerRootNodeFront() { + let currentNode = this; + while (currentNode) { + if ( + currentNode.isShadowRoot || + currentNode.nodeType === Node.DOCUMENT_NODE + ) { + return currentNode; + } + + currentNode = currentNode.parentNode(); + } + + return null; + } + + /** + * 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(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++) { + const 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; + } else if (change.type === "mutationBreakpoint") { + this._form.mutationBreakpoints = change.mutationBreakpoints; + } + } + + // Some accessors to make NodeFront feel more like a Node + + 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() { + const { displayName, nodeName } = this._form; + + // Keep `nodeName.toLowerCase()` for backward compatibility + return displayName || nodeName.toLowerCase(); + } + get doctypeString() { + return ( + "<!DOCTYPE " + + this._form.name + + (this._form.publicId ? ' PUBLIC "' + this._form.publicId + '"' : "") + + (this._form.systemId ? ' "' + this._form.systemId + '"' : "") + + ">" + ); + } + + get baseURI() { + return this._form.baseURI; + } + + get browsingContextID() { + return this._form.browsingContextID; + } + + get className() { + return this.getAttribute("class") || ""; + } + + // Check if the node has children but the current DevTools session is unable + // to retrieve them. + // Typically: a <frame> or <browser> element which loads a document in another + // process, but the toolbox' configuration prevents to inspect it (eg the + // parent-process only Browser Toolbox). + get childrenUnavailable() { + return ( + // If form.useChildTargetToFetchChildren is true, it means the node HAS + // children in another target. + // Note: useChildTargetToFetchChildren might be undefined, force + // conversion to boolean. See Bug 1783613 to try and improve this. + !!this._form.useChildTargetToFetchChildren && + // But if useChildTargetToFetchChildren is false, it means the client + // configuration prevents from displaying such children. + // This is the only case where children are considered as unavailable: + // they exist, but can't be retrieved by configuration. + !this.useChildTargetToFetchChildren + ); + } + get hasChildren() { + return this.numChildren > 0; + } + get numChildren() { + if (this.childrenUnavailable) { + return 0; + } + + return this._form.numChildren; + } + get useChildTargetToFetchChildren() { + if ( + this._hasParentProcessTarget && + browserToolboxScope != BROWSER_TOOLBOX_SCOPE_EVERYTHING + ) { + return false; + } + + return !!this._form.useChildTargetToFetchChildren; + } + get hasEventListeners() { + return this._form.hasEventListeners; + } + + get isMarkerPseudoElement() { + return this._form.isMarkerPseudoElement; + } + get isBeforePseudoElement() { + return this._form.isBeforePseudoElement; + } + get isAfterPseudoElement() { + return this._form.isAfterPseudoElement; + } + get isPseudoElement() { + return ( + this.isBeforePseudoElement || + this.isAfterPseudoElement || + this.isMarkerPseudoElement + ); + } + 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; + } + + get isTopLevelDocument() { + return this._form.isTopLevelDocument; + } + + get isShadowRoot() { + return this._form.isShadowRoot; + } + + get shadowRootMode() { + return this._form.shadowRootMode; + } + + get isShadowHost() { + return this._form.isShadowHost; + } + + get customElementLocation() { + return this._form.customElementLocation; + } + + get isDirectShadowHostChild() { + return this._form.isDirectShadowHostChild; + } + + // doctype properties + get name() { + return this._form.name; + } + get publicId() { + return this._form.publicId; + } + get systemId() { + return this._form.systemId; + } + + getAttribute(name) { + const attr = this._getAttribute(name); + return attr ? attr.value : null; + } + hasAttribute(name) { + this._cacheAttributes(); + return name in this._attrMap; + } + + get hidden() { + const cls = this.getAttribute("class"); + return cls && cls.indexOf(HIDDEN_CLASS) > -1; + } + + get attributes() { + return this._form.attrs; + } + + get mutationBreakpoints() { + return this._form.mutationBreakpoints; + } + + get pseudoClassLocks() { + return this._form.pseudoClassLocks || []; + } + hasPseudoClassLock(pseudo) { + return this.pseudoClassLocks.some(locked => locked === pseudo); + } + + get displayType() { + return this._form.displayType; + } + + get isDisplayed() { + return this._form.isDisplayed; + } + + get isScrollable() { + return this._form.isScrollable; + } + + get causesOverflow() { + return this._form.causesOverflow; + } + + get isTreeDisplayed() { + let parent = this; + while (parent) { + if (!parent.isDisplayed) { + return false; + } + parent = parent.parentNode(); + } + return true; + } + + get inspectorFront() { + return this.parentFront.parentFront; + } + + get walkerFront() { + return this.parentFront; + } + + getNodeValue() { + // 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 super.getNodeValue(); + } + + const str = this._form.nodeValue || ""; + return Promise.resolve(new SimpleStringFront(str)); + } + + /** + * Return a new AttributeModificationList for this node. + */ + startModifyingAttributes() { + return new AttributeModificationList(this); + } + + _cacheAttributes() { + if (typeof this._attrMap != "undefined") { + return; + } + this._attrMap = {}; + for (const attr of this.attributes) { + this._attrMap[attr.name] = attr; + } + } + + _getAttribute(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(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() { + const 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() { + return !!this.conn._transport._serverConnection; + } + + /** + * Get a Node 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(rawNode) { + if (!this.isLocalToBeDeprecated()) { + console.warn("Tried to use rawNode on a remote connection."); + return null; + } + const { + DevToolsServer, + } = require("resource://devtools/server/devtools-server.js"); + const actor = DevToolsServer.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; + } + + async connectToFrame() { + if (!this.useChildTargetToFetchChildren) { + console.warn("Tried to open connection to an invalid frame."); + return null; + } + if ( + this._childBrowsingContextTarget && + !this._childBrowsingContextTarget.isDestroyed() + ) { + return this._childBrowsingContextTarget; + } + + // Get the target for this frame element + this._childBrowsingContextTarget = + await this.targetFront.getWindowGlobalTarget( + this._form.browsingContextID + ); + + // Bug 1776250: When the target is destroyed, we need to easily find the + // parent node front so that we can update its frontend container in the + // markup-view. + this._childBrowsingContextTarget.setParentNodeFront(this); + + return this._childBrowsingContextTarget; + } +} + +exports.NodeFront = NodeFront; +registerFront(NodeFront); diff --git a/devtools/client/fronts/object.js b/devtools/client/fronts/object.js new file mode 100644 index 0000000000..fa1f1d8ba1 --- /dev/null +++ b/devtools/client/fronts/object.js @@ -0,0 +1,465 @@ +/* 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"; + +const { objectSpec } = require("resource://devtools/shared/specs/object.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + LongStringFront, +} = require("resource://devtools/client/fronts/string.js"); + +const SUPPORT_ENUM_ENTRIES_SET = new Set([ + "Headers", + "Map", + "WeakMap", + "Set", + "WeakSet", + "Storage", + "URLSearchParams", + "FormData", + "MIDIInputMap", + "MIDIOutputMap", +]); + +/** + * A ObjectFront is used as a front end for the ObjectActor that is + * created on the server, hiding implementation details. + */ +class ObjectFront extends FrontClassWithSpec(objectSpec) { + constructor(conn = null, targetFront = null, parentFront = null, data) { + if (!parentFront) { + throw new Error("ObjectFront require a parent front"); + } + + super(conn, targetFront, parentFront); + + this._grip = data; + this.actorID = this._grip.actor; + this.valid = true; + + parentFront.manage(this); + } + + skipDestroy() { + // Object fronts are simple fronts, they don't need to be cleaned up on + // toolbox destroy. `conn` is a DebuggerClient instance, check the + // `isToolboxDestroy` flag to skip the destroy. + return this.conn && this.conn.isToolboxDestroy; + } + + getGrip() { + return this._grip; + } + + get isFrozen() { + return this._grip.frozen; + } + + get isSealed() { + return this._grip.sealed; + } + + get isExtensible() { + return this._grip.extensible; + } + + /** + * Request the prototype and own properties of the object. + */ + async getPrototypeAndProperties() { + const result = await super.prototypeAndProperties(); + + if (result.prototype) { + result.prototype = getAdHocFrontOrPrimitiveGrip(result.prototype, this); + } + + // The result packet can have multiple properties that hold grips which we may need + // to turn into fronts. + const gripKeys = ["value", "getterValue", "get", "set"]; + + if (result.ownProperties) { + Object.entries(result.ownProperties).forEach(([key, descriptor]) => { + if (descriptor) { + for (const gripKey of gripKeys) { + if (descriptor.hasOwnProperty(gripKey)) { + result.ownProperties[key][gripKey] = getAdHocFrontOrPrimitiveGrip( + descriptor[gripKey], + this + ); + } + } + } + }); + } + + if (result.safeGetterValues) { + Object.entries(result.safeGetterValues).forEach(([key, descriptor]) => { + if (descriptor) { + for (const gripKey of gripKeys) { + if (descriptor.hasOwnProperty(gripKey)) { + result.safeGetterValues[key][gripKey] = + getAdHocFrontOrPrimitiveGrip(descriptor[gripKey], this); + } + } + } + }); + } + + if (result.ownSymbols) { + result.ownSymbols.forEach((descriptor, i, arr) => { + if (descriptor) { + for (const gripKey of gripKeys) { + if (descriptor.hasOwnProperty(gripKey)) { + arr[i][gripKey] = getAdHocFrontOrPrimitiveGrip( + descriptor[gripKey], + this + ); + } + } + } + }); + } + + return result; + } + + /** + * Request a PropertyIteratorFront instance to ease listing + * properties for this object. + * + * @param options Object + * A dictionary object with various boolean attributes: + * - ignoreIndexedProperties Boolean + * If true, filters out Array items. + * e.g. properties names between `0` and `object.length`. + * - ignoreNonIndexedProperties Boolean + * If true, filters out items that aren't array items + * e.g. properties names that are not a number between `0` + * and `object.length`. + * - sort Boolean + * If true, the iterator will sort the properties by name + * before dispatching them. + */ + enumProperties(options) { + return super.enumProperties(options); + } + + /** + * Request a PropertyIteratorFront instance to enumerate entries in a + * Map/Set-like object. + */ + enumEntries() { + if (!SUPPORT_ENUM_ENTRIES_SET.has(this._grip.class)) { + console.error( + `enumEntries can't be called for "${ + this._grip.class + }" grips. Supported grips are: ${[...SUPPORT_ENUM_ENTRIES_SET].join( + ", " + )}.` + ); + return null; + } + return super.enumEntries(); + } + + /** + * Request a SymbolIteratorFront instance to enumerate symbols in an object. + */ + enumSymbols() { + if (this._grip.type !== "object") { + console.error("enumSymbols is only valid for objects grips."); + return null; + } + return super.enumSymbols(); + } + + /** + * Request the property descriptor of the object's specified property. + * + * @param name string The name of the requested property. + */ + getProperty(name) { + return super.property(name); + } + + /** + * Request the value of the object's specified property. + * + * @param name string The name of the requested property. + * @param receiverId string|null The actorId of the receiver to be used for getters. + */ + async getPropertyValue(name, receiverId) { + const response = await super.propertyValue(name, receiverId); + + if (response.value) { + const { value } = response; + if (value.return) { + response.value.return = getAdHocFrontOrPrimitiveGrip( + value.return, + this + ); + } + + if (value.throw) { + response.value.throw = getAdHocFrontOrPrimitiveGrip(value.throw, this); + } + } + return response; + } + + /** + * Get the body of a custom formatted object. + */ + async customFormatterBody() { + const result = await super.customFormatterBody(); + + if (!result?.customFormatterBody) { + return result; + } + + const createFrontsInJsonMl = item => { + if (Array.isArray(item)) { + return item.map(i => createFrontsInJsonMl(i)); + } + return getAdHocFrontOrPrimitiveGrip(item, this); + }; + + result.customFormatterBody = createFrontsInJsonMl( + result.customFormatterBody + ); + + return result; + } + + /** + * Request the prototype of the object. + */ + async getPrototype() { + const result = await super.prototype(); + + if (!result.prototype) { + return result; + } + + result.prototype = getAdHocFrontOrPrimitiveGrip(result.prototype, this); + + return result; + } + + /** + * Request the state of a promise. + */ + async getPromiseState() { + if (this._grip.class !== "Promise") { + console.error("getPromiseState is only valid for promise grips."); + return null; + } + + let response, promiseState; + try { + response = await super.promiseState(); + promiseState = response.promiseState; + } catch (error) { + // @backward-compat { version 85 } On older server, the promiseState request didn't + // didn't exist (bug 1552648). The promise state was directly included in the grip. + if (error.message.includes("unrecognizedPacketType")) { + promiseState = this._grip.promiseState; + response = { promiseState }; + } else { + throw error; + } + } + + const { value, reason } = promiseState; + + if (value) { + promiseState.value = getAdHocFrontOrPrimitiveGrip(value, this); + } + + if (reason) { + promiseState.reason = getAdHocFrontOrPrimitiveGrip(reason, this); + } + + return response; + } + + /** + * Request the target and handler internal slots of a proxy. + */ + async getProxySlots() { + if (this._grip.class !== "Proxy") { + console.error("getProxySlots is only valid for proxy grips."); + return null; + } + + const response = await super.proxySlots(); + const { proxyHandler, proxyTarget } = response; + + if (proxyHandler) { + response.proxyHandler = getAdHocFrontOrPrimitiveGrip(proxyHandler, this); + } + + if (proxyTarget) { + response.proxyTarget = getAdHocFrontOrPrimitiveGrip(proxyTarget, this); + } + + return response; + } + + get isSyntaxError() { + return this._grip.preview && this._grip.preview.name == "SyntaxError"; + } +} + +/** + * When we are asking the server for the value of a given variable, we might get different + * type of objects: + * - a primitive (string, number, null, false, boolean) + * - a long string + * - an "object" (i.e. not primitive nor long string) + * + * Each of those type need a different front, or none: + * - a primitive does not allow further interaction with the server, so we don't need + * to have a dedicated front. + * - a long string needs a longStringFront to be able to retrieve the full string. + * - an object need an objectFront to retrieve properties, symbols and prototype. + * + * In the case an ObjectFront is created, we also check if the object has properties + * that should be turned into fronts as well. + * + * @param {String|Number|Object} options: The packet returned by the server. + * @param {Front} parentFront + * + * @returns {Number|String|Object|LongStringFront|ObjectFront} + */ +function getAdHocFrontOrPrimitiveGrip(packet, parentFront) { + // We only want to try to create a front when it makes sense, i.e when it has an + // actorID, unless: + // - it's a Symbol (See Bug 1600299) + // - it's a mapEntry (the preview.key and preview.value properties can hold actors) + // - or it is already a front (happens when we are using the legacy listeners in the ResourceCommand) + const isPacketAnObject = packet && typeof packet === "object"; + const isFront = !!packet.typeName; + if ( + !isPacketAnObject || + packet.type == "symbol" || + (packet.type !== "mapEntry" && !packet.actor) || + isFront + ) { + return packet; + } + + const { conn } = parentFront; + // If the parent front is a target, consider it as the target to use for all objects + const targetFront = parentFront.isTargetFront + ? parentFront + : parentFront.targetFront; + + // We may have already created a front for this object actor since some actor (e.g. the + // thread actor) cache the object actors they create. + const existingFront = conn.getFrontByID(packet.actor); + if (existingFront) { + return existingFront; + } + + const { type } = packet; + + if (type === "longString") { + const longStringFront = new LongStringFront(conn, targetFront, parentFront); + longStringFront.form(packet); + parentFront.manage(longStringFront); + return longStringFront; + } + + if (type === "mapEntry" && packet.preview) { + const { key, value } = packet.preview; + packet.preview.key = getAdHocFrontOrPrimitiveGrip( + key, + parentFront, + targetFront + ); + packet.preview.value = getAdHocFrontOrPrimitiveGrip( + value, + parentFront, + targetFront + ); + return packet; + } + + const objectFront = new ObjectFront(conn, targetFront, parentFront, packet); + createChildFronts(objectFront, packet); + return objectFront; +} + +/** + * Create child fronts of the passed object front given a packet. Those child fronts are + * usually mapping actors of the packet sub-properties (preview items, promise fullfilled + * values, …). + * + * @param {ObjectFront} objectFront + * @param {String|Number|Object} packet: The packet returned by the server + */ +function createChildFronts(objectFront, packet) { + if (packet.preview) { + const { message, entries } = packet.preview; + + // The message could be a longString. + if (packet.preview.message) { + packet.preview.message = getAdHocFrontOrPrimitiveGrip( + message, + objectFront + ); + } + + // Handle Map/WeakMap preview entries (the preview might be directly used if has all the + // items needed, i.e. if the Map has less than 10 items). + if (entries && Array.isArray(entries)) { + packet.preview.entries = entries.map(([key, value]) => [ + getAdHocFrontOrPrimitiveGrip(key, objectFront), + getAdHocFrontOrPrimitiveGrip(value, objectFront), + ]); + } + } + + if (packet && typeof packet.ownProperties === "object") { + for (const [name, descriptor] of Object.entries(packet.ownProperties)) { + // The descriptor can have multiple properties that hold grips which we may need + // to turn into fronts. + const gripKeys = ["value", "getterValue", "get", "set"]; + for (const key of gripKeys) { + if ( + descriptor && + typeof descriptor === "object" && + descriptor.hasOwnProperty(key) + ) { + packet.ownProperties[name][key] = getAdHocFrontOrPrimitiveGrip( + descriptor[key], + objectFront + ); + } + } + } + } + + // Handle custom formatters + if (packet && packet.useCustomFormatter && Array.isArray(packet.header)) { + const createFrontsInJsonMl = item => { + if (Array.isArray(item)) { + return item.map(i => createFrontsInJsonMl(i)); + } + return getAdHocFrontOrPrimitiveGrip(item, objectFront); + }; + + packet.header = createFrontsInJsonMl(packet.header); + } +} + +registerFront(ObjectFront); + +exports.ObjectFront = ObjectFront; +exports.getAdHocFrontOrPrimitiveGrip = getAdHocFrontOrPrimitiveGrip; diff --git a/devtools/client/fronts/page-style.js b/devtools/client/fronts/page-style.js new file mode 100644 index 0000000000..dfe9f593e4 --- /dev/null +++ b/devtools/client/fronts/page-style.js @@ -0,0 +1,123 @@ +/* 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"; + +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + pageStyleSpec, +} = require("resource://devtools/shared/specs/page-style.js"); + +/** + * PageStyleFront, the front object for the PageStyleActor + */ +class PageStyleFront extends FrontClassWithSpec(pageStyleSpec) { + _attributesCache = new Map(); + + constructor(conn, targetFront, parentFront) { + super(conn, targetFront, parentFront); + this.inspector = this.getParent(); + + this._clearAttributesCache = this._clearAttributesCache.bind(this); + this.on("stylesheet-updated", this._clearAttributesCache); + this.walker.on("new-mutations", this._clearAttributesCache); + } + + form(form) { + this._form = form; + } + + get walker() { + return this.inspector.walker; + } + + get supportsFontStretchLevel4() { + return this._form.traits && this._form.traits.fontStretchLevel4; + } + + get supportsFontStyleLevel4() { + return this._form.traits && this._form.traits.fontStyleLevel4; + } + + get supportsFontVariations() { + return this._form.traits && this._form.traits.fontVariations; + } + + get supportsFontWeightLevel4() { + return this._form.traits && this._form.traits.fontWeightLevel4; + } + + getMatchedSelectors(node, property, options) { + return super.getMatchedSelectors(node, property, options).then(ret => { + return ret.matched; + }); + } + + async getApplied(node, options = {}) { + const ret = await super.getApplied(node, options); + return ret.entries; + } + + addNewRule(node, pseudoClasses) { + return super.addNewRule(node, pseudoClasses).then(ret => { + return ret.entries[0]; + }); + } + + /** + * Get an array of existing attribute values in a node document, given an attribute type. + * + * @param {String} search: A string to filter attribute value on. + * @param {String} attributeType: The type of attribute we want to retrieve the values. + * @param {Element} node: The element we want to get possible attributes for. This will + * be used to get the document where the search is happening. + * @returns {Array<String>} An array of strings + */ + async getAttributesInOwnerDocument(search, attributeType, node) { + if (!attributeType) { + throw new Error("`type` should not be empty"); + } + + if (!search) { + return []; + } + + const lcFilter = search.toLowerCase(); + + // If the new filter includes the string that was used on our last trip to the server, + // we can filter the cached results instead of calling the server again. + if ( + this._attributesCache && + this._attributesCache.has(attributeType) && + search.startsWith(this._attributesCache.get(attributeType).search) + ) { + const cachedResults = this._attributesCache + .get(attributeType) + .results.filter(item => item.toLowerCase().startsWith(lcFilter)); + this.emitForTests( + "getAttributesInOwnerDocument-cache-hit", + cachedResults + ); + return cachedResults; + } + + const results = await super.getAttributesInOwnerDocument( + search, + attributeType, + node + ); + this._attributesCache.set(attributeType, { search, results }); + return results; + } + + _clearAttributesCache() { + this._attributesCache.clear(); + } +} + +exports.PageStyleFront = PageStyleFront; +registerFront(PageStyleFront); diff --git a/devtools/client/fronts/perf.js b/devtools/client/fronts/perf.js new file mode 100644 index 0000000000..9ca0fb8b9d --- /dev/null +++ b/devtools/client/fronts/perf.js @@ -0,0 +1,22 @@ +/* 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"; + +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { perfSpec } = require("resource://devtools/shared/specs/perf.js"); + +class PerfFront extends FrontClassWithSpec(perfSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + // Attribute name from which to retrieve the actorID out of the target actor's form + this.formAttributeName = "perfActor"; + } +} + +registerFront(PerfFront); diff --git a/devtools/client/fronts/preference.js b/devtools/client/fronts/preference.js new file mode 100644 index 0000000000..e2c498ba4b --- /dev/null +++ b/devtools/client/fronts/preference.js @@ -0,0 +1,33 @@ +/* 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"; + +const { + preferenceSpec, +} = require("resource://devtools/shared/specs/preference.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); + +class PreferenceFront extends FrontClassWithSpec(preferenceSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + // Attribute name from which to retrieve the actorID out of the target actor's form + this.formAttributeName = "preferenceActor"; + } + + async getTraits() { + if (!this._traits) { + this._traits = await super.getTraits(); + } + + return this._traits; + } +} + +exports.PreferenceFront = PreferenceFront; +registerFront(PreferenceFront); diff --git a/devtools/client/fronts/private-properties-iterator.js b/devtools/client/fronts/private-properties-iterator.js new file mode 100644 index 0000000000..6b7bd3c14a --- /dev/null +++ b/devtools/client/fronts/private-properties-iterator.js @@ -0,0 +1,60 @@ +/* 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"; + +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + privatePropertiesIteratorSpec, +} = require("resource://devtools/shared/specs/private-properties-iterator.js"); +const { + getAdHocFrontOrPrimitiveGrip, +} = require("resource://devtools/client/fronts/object.js"); + +class PrivatePropertiesIteratorFront extends FrontClassWithSpec( + privatePropertiesIteratorSpec +) { + form(data) { + this.actorID = data.actor; + this.count = data.count; + } + + async slice(start, count) { + const result = await super.slice({ start, count }); + return this._onResult(result); + } + + async all() { + const result = await super.all(); + return this._onResult(result); + } + + _onResult(result) { + if (!result.privateProperties) { + return result; + } + + // The result packet can have multiple properties that hold grips which we may need + // to turn into fronts. + const gripKeys = ["value", "getterValue", "get", "set"]; + + result.privateProperties.forEach((item, i) => { + if (item?.descriptor) { + for (const gripKey of gripKeys) { + if (item.descriptor.hasOwnProperty(gripKey)) { + result.privateProperties[i].descriptor[gripKey] = + getAdHocFrontOrPrimitiveGrip(item.descriptor[gripKey], this); + } + } + } + }); + return result; + } +} + +exports.PrivatePropertiesIteratorFront = PrivatePropertiesIteratorFront; +registerFront(PrivatePropertiesIteratorFront); diff --git a/devtools/client/fronts/property-iterator.js b/devtools/client/fronts/property-iterator.js new file mode 100644 index 0000000000..6348e435f9 --- /dev/null +++ b/devtools/client/fronts/property-iterator.js @@ -0,0 +1,67 @@ +/* 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"; + +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + propertyIteratorSpec, +} = require("resource://devtools/shared/specs/property-iterator.js"); +const { + getAdHocFrontOrPrimitiveGrip, +} = require("resource://devtools/client/fronts/object.js"); + +/** + * A PropertyIteratorFront provides a way to access to property names and + * values of an object efficiently, slice by slice. + * Note that the properties can be sorted in the backend, + * this is controled while creating the PropertyIteratorFront + * from ObjectFront.enumProperties. + */ +class PropertyIteratorFront extends FrontClassWithSpec(propertyIteratorSpec) { + form(data) { + this.actorID = data.actor; + this.count = data.count; + } + + async slice(start, count) { + const result = await super.slice({ start, count }); + return this._onResult(result); + } + + async all() { + const result = await super.all(); + return this._onResult(result); + } + + _onResult(result) { + if (!result.ownProperties) { + return result; + } + + // The result packet can have multiple properties that hold grips which we may need + // to turn into fronts. + const gripKeys = ["value", "getterValue", "get", "set"]; + + Object.entries(result.ownProperties).forEach(([key, descriptor]) => { + if (descriptor) { + for (const gripKey of gripKeys) { + if (descriptor.hasOwnProperty(gripKey)) { + result.ownProperties[key][gripKey] = getAdHocFrontOrPrimitiveGrip( + descriptor[gripKey], + this + ); + } + } + } + }); + return result; + } +} + +exports.PropertyIteratorFront = PropertyIteratorFront; +registerFront(PropertyIteratorFront); diff --git a/devtools/client/fronts/reflow.js b/devtools/client/fronts/reflow.js new file mode 100644 index 0000000000..027e09e9a0 --- /dev/null +++ b/devtools/client/fronts/reflow.js @@ -0,0 +1,31 @@ +/* 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"; + +const { reflowSpec } = require("resource://devtools/shared/specs/reflow.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); + +/** + * Usage example of the reflow front: + * + * let front = await target.getFront("reflow"); + * front.on("reflows", this._onReflows); + * front.start(); + * // now wait for events to come + */ +class ReflowFront extends FrontClassWithSpec(reflowSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + // Attribute name from which to retrieve the actorID out of the target actor's form + this.formAttributeName = "reflowActor"; + } +} + +exports.ReflowFront = ReflowFront; +registerFront(ReflowFront); diff --git a/devtools/client/fronts/responsive.js b/devtools/client/fronts/responsive.js new file mode 100644 index 0000000000..c6f991c8a6 --- /dev/null +++ b/devtools/client/fronts/responsive.js @@ -0,0 +1,28 @@ +/* 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"; + +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + responsiveSpec, +} = require("resource://devtools/shared/specs/responsive.js"); + +/** + * The corresponding Front object for the Responsive actor. + */ +class ResponsiveFront extends FrontClassWithSpec(responsiveSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + // Attribute name from which to retrieve the actorID out of the target actor's form + this.formAttributeName = "responsiveActor"; + } +} + +exports.ResponsiveFront = ResponsiveFront; +registerFront(ResponsiveFront); diff --git a/devtools/client/fronts/root.js b/devtools/client/fronts/root.js new file mode 100644 index 0000000000..92d780ea24 --- /dev/null +++ b/devtools/client/fronts/root.js @@ -0,0 +1,332 @@ +/* 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"; + +const { rootSpec } = require("resource://devtools/shared/specs/root.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); + +loader.lazyRequireGetter( + this, + "getFront", + "resource://devtools/shared/protocol.js", + true +); + +class RootFront extends FrontClassWithSpec(rootSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + // Cache root form as this will always be the same value. + Object.defineProperty(this, "rootForm", { + get() { + delete this.rootForm; + this.rootForm = this.getRoot(); + return this.rootForm; + }, + configurable: true, + }); + + // Cache of already created global scoped fronts + // [typeName:string => Front instance] + this.fronts = new Map(); + + this._client = client; + } + + form(form) { + // Root Front is a special Front. It is the only one to set its actor ID manually + // out of the form object returned by RootActor.sayHello which is called when calling + // DevToolsClient.connect(). + this.actorID = form.from; + + this.applicationType = form.applicationType; + this.traits = form.traits; + } + /** + * Retrieve all service worker registrations with their corresponding workers. + * @param {Array} [workerTargets] (optional) + * Array containing the result of a call to `listAllWorkerTargets`. + * (this exists to avoid duplication of calls to that method) + * @return {Object[]} result - An Array of Objects with the following format + * - {result[].registration} - The registration front + * - {result[].workers} Array of form-like objects for service workers + */ + async listAllServiceWorkers(workerTargets) { + const result = []; + const { registrations } = await this.listServiceWorkerRegistrations(); + const allWorkers = workerTargets + ? workerTargets + : await this.listAllWorkerTargets(); + + for (const registrationFront of registrations) { + // workers from the registration, ordered from most recent to older + const workers = [ + registrationFront.activeWorker, + registrationFront.waitingWorker, + registrationFront.installingWorker, + registrationFront.evaluatingWorker, + ] + // filter out non-existing workers + .filter(w => !!w) + // build a worker object with its WorkerDescriptorFront + .map(workerFront => { + const workerDescriptorFront = allWorkers.find( + targetFront => targetFront.id === workerFront.id + ); + + return { + id: workerFront.id, + name: workerFront.url, + state: workerFront.state, + stateText: workerFront.stateText, + url: workerFront.url, + workerDescriptorFront, + }; + }); + + // TODO: return only the worker targets. See Bug 1620605 + result.push({ + registration: registrationFront, + workers, + }); + } + + return result; + } + + /** + * Retrieve all service worker registrations as well as workers from the parent and + * content processes. Listing service workers involves merging information coming from + * registrations and workers, this method will combine this information to present a + * unified array of serviceWorkers. If you are only interested in other workers, use + * listWorkers. + * + * @return {Object} + * - {Array} service + * array of form-like objects for serviceworkers + * - {Array} shared + * Array of WorkerTargetActor forms, containing shared workers. + * - {Array} other + * Array of WorkerTargetActor forms, containing other workers. + */ + async listAllWorkers() { + const allWorkers = await this.listAllWorkerTargets(); + const serviceWorkers = await this.listAllServiceWorkers(allWorkers); + + // NOTE: listAllServiceWorkers() now returns all the workers belonging to + // a registration. To preserve the usual behavior at about:debugging, + // in which we show only the most recent one, we grab the first + // worker in the array only. + const result = { + service: serviceWorkers + .map(({ registration, workers }) => { + return workers.slice(0, 1).map(worker => { + return Object.assign(worker, { + registrationFront: registration, + fetch: registration.fetch, + }); + }); + }) + .flat(), + shared: [], + other: [], + }; + + allWorkers.forEach(front => { + const worker = { + id: front.id, + url: front.url, + name: front.url, + workerDescriptorFront: front, + }; + + switch (front.type) { + case Ci.nsIWorkerDebugger.TYPE_SERVICE: + // do nothing, since we already fetched them in `serviceWorkers` + break; + case Ci.nsIWorkerDebugger.TYPE_SHARED: + result.shared.push(worker); + break; + default: + result.other.push(worker); + } + }); + + return result; + } + + /** Get the target fronts for all worker threads running in any process. */ + async listAllWorkerTargets() { + const listParentWorkers = async () => { + const { workers } = await this.listWorkers(); + return workers; + }; + const listChildWorkers = async () => { + const processes = await this.listProcesses(); + const processWorkers = await Promise.all( + processes.map(async processDescriptorFront => { + // Ignore parent process + if (processDescriptorFront.isParentProcessDescriptor) { + return []; + } + try { + const front = await processDescriptorFront.getTarget(); + if (!front) { + return []; + } + const response = await front.listWorkers(); + return response.workers; + } catch (e) { + if (e.message.includes("Connection closed")) { + return []; + } + throw e; + } + }) + ); + + return processWorkers.flat(); + }; + + const [parentWorkers, childWorkers] = await Promise.all([ + listParentWorkers(), + listChildWorkers(), + ]); + + return parentWorkers.concat(childWorkers); + } + + /** + * Fetch the ProcessDescriptorFront for the main process. + * + * `getProcess` requests allows to fetch the descriptor for any process and + * the main process is having the process ID zero. + */ + getMainProcess() { + return this.getProcess(0); + } + + /** + * Fetch the tab descriptor for the currently selected tab, or for a specific + * tab given as first parameter. + * + * @param [optional] object filter + * A dictionary object with following optional attributes: + * - browserId: use to match any tab + * - tab: a reference to xul:tab element (used for local tab debugging) + * - isWebExtension: an optional boolean to flag TabDescriptors + * If nothing is specified, returns the actor for the currently + * selected tab. + */ + async getTab(filter) { + const packet = {}; + if (filter) { + if (typeof filter.browserId == "number") { + packet.browserId = filter.browserId; + } else if ("tab" in filter) { + const browser = filter.tab.linkedBrowser; + packet.browserId = browser.browserId; + } else { + // Throw if a filter object have been passed but without + // any clearly idenfified filter. + throw new Error("Unsupported argument given to getTab request"); + } + } + + const descriptorFront = await super.getTab(packet); + + // Will flag TabDescriptor used by WebExtension codebase. + if (filter?.isWebExtension) { + descriptorFront.setIsForWebExtension(true); + } + + // If the tab is a local tab, forward it to the descriptor. + if (filter?.tab?.tagName == "tab") { + // Ignore the fake `tab` object we receive, where there is only a + // `linkedBrowser` attribute, but this isn't a real <tab> element. + // devtools/client/framework/test/browser_toolbox_target.js is passing such + // a fake tab. + descriptorFront.setLocalTab(filter.tab); + } + + return descriptorFront; + } + + /** + * Fetch the target front for a given add-on. + * This is just an helper on top of `listAddons` request. + * + * @param object filter + * A dictionary object with following attribute: + * - id: used to match the add-on to connect to. + */ + async getAddon({ id }) { + const addons = await this.listAddons(); + const webextensionDescriptorFront = addons.find(addon => addon.id === id); + return webextensionDescriptorFront; + } + + /** + * Fetch the target front for a given worker. + * This is just an helper on top of `listAllWorkers` request. + * + * @param id + */ + async getWorker(id) { + const { service, shared, other } = await this.listAllWorkers(); + const worker = [...service, ...shared, ...other].find(w => w.id === id); + if (!worker) { + return null; + } + return worker.workerDescriptorFront || worker.registrationFront; + } + + /** + * Test request that returns the object passed as first argument. + * + * `echo` is special as all the property of the given object have to be passed + * on the packet object. That's not something that can be achieve by requester helper. + */ + + echo(packet) { + packet.type = "echo"; + return this.request(packet); + } + + /* + * This function returns a protocol.js Front for any root actor. + * i.e. the one directly served from RootActor.listTabs or getRoot. + * + * @param String typeName + * The type name used in protocol.js's spec for this actor. + */ + async getFront(typeName) { + let front = this.fronts.get(typeName); + if (front) { + return front; + } + const rootForm = await this.rootForm; + front = getFront(this._client, typeName, rootForm); + this.fronts.set(typeName, front); + return front; + } + + /* + * This function returns true if the root actor has a registered global actor + * with a given name. + * @param {String} actorName + * The name of a global actor. + * + * @return {Boolean} + */ + async hasActor(actorName) { + const rootForm = await this.rootForm; + return !!rootForm[actorName + "Actor"]; + } +} +exports.RootFront = RootFront; +registerFront(RootFront); diff --git a/devtools/client/fronts/screenshot-content.js b/devtools/client/fronts/screenshot-content.js new file mode 100644 index 0000000000..a1b8849a16 --- /dev/null +++ b/devtools/client/fronts/screenshot-content.js @@ -0,0 +1,25 @@ +/* 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"; + +const { + screenshotContentSpec, +} = require("resource://devtools/shared/specs/screenshot-content.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); + +class ScreenshotContentFront extends FrontClassWithSpec(screenshotContentSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + // Attribute name from which to retrieve the actorID out of the target actor's form + this.formAttributeName = "screenshotContentActor"; + } +} + +exports.ScreenshotContentFront = ScreenshotContentFront; +registerFront(ScreenshotContentFront); diff --git a/devtools/client/fronts/screenshot.js b/devtools/client/fronts/screenshot.js new file mode 100644 index 0000000000..cbceec319e --- /dev/null +++ b/devtools/client/fronts/screenshot.js @@ -0,0 +1,25 @@ +/* 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"; + +const { + screenshotSpec, +} = require("resource://devtools/shared/specs/screenshot.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); + +class ScreenshotFront extends FrontClassWithSpec(screenshotSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + // Attribute name from which to retrieve the actorID out of the target actor's form + this.formAttributeName = "screenshotActor"; + } +} + +exports.ScreenshotFront = ScreenshotFront; +registerFront(ScreenshotFront); diff --git a/devtools/client/fronts/source.js b/devtools/client/fronts/source.js new file mode 100644 index 0000000000..d8a195bbb2 --- /dev/null +++ b/devtools/client/fronts/source.js @@ -0,0 +1,102 @@ +/* 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"; + +const { sourceSpec } = require("resource://devtools/shared/specs/source.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + ArrayBufferFront, +} = require("resource://devtools/client/fronts/array-buffer.js"); + +/** + * A SourceFront provides a way to access the source text of a script. + * + * @param client DevToolsClient + * The DevTools Client instance. + * @param form Object + * The form sent across the remote debugging protocol. + */ +class SourceFront extends FrontClassWithSpec(sourceSpec) { + constructor(client, form) { + super(client); + if (form) { + this._url = form.url; + // this is here for the time being, until the source front is managed + // via protocol.js marshalling + this.actorID = form.actor; + } + } + + form(json) { + this._url = json.url; + } + + get actor() { + return this.actorID; + } + + get url() { + return this._url; + } + + // Alias for source.blackbox to avoid changing protocol.js packets + blackBox(range) { + return this.blackbox(range); + } + + // Alias for source.unblackbox to avoid changing protocol.js packets + unblackBox() { + return this.unblackbox(); + } + + /** + * Get a Front for either an ArrayBuffer or LongString + * for this SourceFront's source. + */ + async source() { + const response = await super.source(); + return this._onSourceResponse(response); + } + + _onSourceResponse(response) { + const { contentType, source } = response; + if (source instanceof ArrayBufferFront) { + return source.slice(0, source.length).then(function (resp) { + if (resp.error) { + return resp; + } + // Keeping str as a string, ArrayBuffer/Uint8Array will not survive + // setIn/mergeIn operations. + const str = atob(resp.encoded); + const newResponse = { + source: { + binary: str, + toString: () => "[wasm]", + }, + contentType, + }; + return newResponse; + }); + } + + return source.substring(0, source.length).then(function (resp) { + if (resp.error) { + return resp; + } + + const newResponse = { + source: resp, + contentType, + }; + return newResponse; + }); + } +} + +exports.SourceFront = SourceFront; +registerFront(SourceFront); diff --git a/devtools/client/fronts/storage.js b/devtools/client/fronts/storage.js new file mode 100644 index 0000000000..7637471126 --- /dev/null +++ b/devtools/client/fronts/storage.js @@ -0,0 +1,55 @@ +/* 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"; + +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { childSpecs } = require("resource://devtools/shared/specs/storage.js"); + +for (const childSpec of Object.values(childSpecs)) { + class ChildStorageFront extends FrontClassWithSpec(childSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + this.on("single-store-update", this._onStoreUpdate.bind(this)); + } + + form(form) { + this.actorID = form.actor; + this.hosts = form.hosts; + this.traits = form.traits || {}; + return null; + } + + // Update the storage fronts `hosts` properties with potential new hosts and remove the deleted ones + async _onStoreUpdate({ changed, added, deleted }) { + // `resourceKey` comes from the storage resource and is set by the legacy listener + // -or- the resource transformer. + const { resourceKey } = this; + if (added) { + for (const host in added[resourceKey]) { + if (!this.hosts[host]) { + this.hosts[host] = added[resourceKey][host]; + } + } + } + if (deleted) { + // While addition have to be added immediately, before ui.js receive single-store-update event + // Deletions have to be removed after ui.js processed single-store-update. + // + // Unfortunately it makes some tests to fail, for ex: browser_storage_cookies_delete_all.js + // + //setTimeout(()=> { + // for (const host in deleted[resourceKey]) { + // delete this.hosts[host]; + // } + //}, 2000); + } + } + } + registerFront(ChildStorageFront); +} diff --git a/devtools/client/fronts/string.js b/devtools/client/fronts/string.js new file mode 100644 index 0000000000..0a05d20c3e --- /dev/null +++ b/devtools/client/fronts/string.js @@ -0,0 +1,62 @@ +/* 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"; + +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); +const { + longStringSpec, + SimpleStringFront, +} = require("resource://devtools/shared/specs/string.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); + +class LongStringFront extends FrontClassWithSpec(longStringSpec) { + destroy() { + this.initial = null; + this.length = null; + this.strPromise = null; + super.destroy(); + } + + form(data) { + this.actorID = data.actor; + this.initial = data.initial; + this.length = data.length; + + this._grip = data; + } + + // We expose the grip so consumers (e.g. ObjectInspector) that handle webconsole + // evaluations (which can return primitive, object fronts or longString front), + // can directly call this without further check. + getGrip() { + return this._grip; + } + + string() { + if (!this.strPromise) { + const promiseRest = thusFar => { + if (thusFar.length === this.length) { + return Promise.resolve(thusFar); + } + return this.substring( + thusFar.length, + thusFar.length + DevToolsServer.LONG_STRING_READ_LENGTH + ).then(next => promiseRest(thusFar + next)); + }; + + this.strPromise = promiseRest(this.initial); + } + return this.strPromise; + } +} + +exports.LongStringFront = LongStringFront; +registerFront(LongStringFront); +exports.SimpleStringFront = SimpleStringFront; +registerFront(SimpleStringFront); diff --git a/devtools/client/fronts/style-rule.js b/devtools/client/fronts/style-rule.js new file mode 100644 index 0000000000..afcfb54693 --- /dev/null +++ b/devtools/client/fronts/style-rule.js @@ -0,0 +1,281 @@ +/* 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"; + +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + styleRuleSpec, +} = require("resource://devtools/shared/specs/style-rule.js"); + +loader.lazyRequireGetter( + this, + "RuleRewriter", + "resource://devtools/client/fronts/inspector/rule-rewriter.js" +); + +/** + * StyleRuleFront, the front for the StyleRule actor. + */ +class StyleRuleFront extends FrontClassWithSpec(styleRuleSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + this.before("location-changed", this._locationChangedPre.bind(this)); + } + + form(form) { + this.actorID = form.actor; + this._form = form; + this.traits = form.traits || {}; + } + + /** + * Ensure _form is updated when location-changed is emitted. + */ + _locationChangedPre(line, column) { + this._form.line = line; + this._form.column = column; + } + + /** + * Return a new RuleModificationList or RuleRewriter for this node. + * A RuleRewriter will be returned when the rule's canSetRuleText + * trait is true; otherwise a RuleModificationList will be + * returned. + * + * @param {CssPropertiesFront} cssProperties + * This is needed by the RuleRewriter. + * @return {RuleModificationList} + */ + startModifyingProperties(cssProperties) { + if (this.canSetRuleText) { + return new RuleRewriter(cssProperties.isKnown, this, this.authoredText); + } + return new RuleModificationList(this); + } + + get type() { + return this._form.type; + } + get line() { + return this._form.line || -1; + } + get column() { + return this._form.column || -1; + } + get cssText() { + return this._form.cssText; + } + get authoredText() { + return typeof this._form.authoredText === "string" + ? this._form.authoredText + : this._form.cssText; + } + get declarations() { + return this._form.declarations || []; + } + get keyText() { + return this._form.keyText; + } + get name() { + return this._form.name; + } + get selectors() { + return this._form.selectors; + } + + get parentStyleSheet() { + const resourceCommand = this.targetFront.commands.resourceCommand; + return resourceCommand.getResourceById( + resourceCommand.TYPES.STYLESHEET, + this._form.parentStyleSheet + ); + } + + get element() { + return this.conn.getFrontByID(this._form.element); + } + + get href() { + if (this._form.href) { + return this._form.href; + } + const sheet = this.parentStyleSheet; + return sheet ? sheet.href : ""; + } + + get nodeHref() { + const sheet = this.parentStyleSheet; + return sheet ? sheet.nodeHref : ""; + } + + get canSetRuleText() { + return this._form.traits && this._form.traits.canSetRuleText; + } + + get location() { + return { + source: this.parentStyleSheet, + href: this.href, + line: this.line, + column: this.column, + }; + } + + get ancestorData() { + return this._form.ancestorData; + } + + async modifySelector(node, value) { + const response = await super.modifySelector( + node, + value, + this.canSetRuleText + ); + + if (response.ruleProps) { + response.ruleProps = response.ruleProps.entries[0]; + } + return response; + } + + setRuleText(newText, modifications) { + this._form.authoredText = newText; + return super.setRuleText(newText, modifications); + } +} + +exports.StyleRuleFront = StyleRuleFront; +registerFront(StyleRuleFront); + +/** + * Convenience API for building a list of attribute modifications + * for the `modifyProperties` request. A RuleModificationList holds a + * list of modifications that will be applied to a StyleRuleActor. + * The modifications are processed in the order in which they are + * added to the RuleModificationList. + * + * Objects of this type expose the same API as @see RuleRewriter. + * This lets the inspector use (mostly) the same code, regardless of + * whether the server implements setRuleText. + */ +class RuleModificationList { + /** + * Initialize a RuleModificationList. + * @param {StyleRuleFront} rule the associated rule + */ + constructor(rule) { + this.rule = rule; + this.modifications = []; + } + + /** + * Apply the modifications in this object to the associated rule. + * + * @return {Promise} A promise which will be resolved when the modifications + * are complete; @see StyleRuleActor.modifyProperties. + */ + apply() { + return this.rule.modifyProperties(this.modifications); + } + + /** + * Add a "set" entry to the modification list. + * + * @param {Number} index index of the property in the rule. + * This can be -1 in the case where + * the rule does not support setRuleText; + * generally for setting properties + * on an element's style. + * @param {String} name the property's name + * @param {String} value the property's value + * @param {String} priority the property's priority, either the empty + * string or "important" + */ + setProperty(index, name, value, priority) { + this.modifications.push({ type: "set", index, name, value, priority }); + } + + /** + * Add a "remove" entry to the modification list. + * + * @param {Number} index index of the property in the rule. + * This can be -1 in the case where + * the rule does not support setRuleText; + * generally for setting properties + * on an element's style. + * @param {String} name the name of the property to remove + */ + removeProperty(index, name) { + this.modifications.push({ type: "remove", index, name }); + } + + /** + * Rename a property. This implementation acts like + * |removeProperty|, because |setRuleText| is not available. + * + * @param {Number} index index of the property in the rule. + * This can be -1 in the case where + * the rule does not support setRuleText; + * generally for setting properties + * on an element's style. + * @param {String} name current name of the property + * + * This parameter is also passed, but as it is not used in this + * implementation, it is omitted. It is documented here as this + * code also defined the interface implemented by @see RuleRewriter. + * @param {String} newName new name of the property + */ + renameProperty(index, name) { + this.removeProperty(index, name); + } + + /** + * Enable or disable a property. This implementation acts like + * a no-op when enabling, because |setRuleText| is not available. + * + * @param {Number} index index of the property in the rule. + * This can be -1 in the case where + * the rule does not support setRuleText; + * generally for setting properties + * on an element's style. + * @param {String} name current name of the property + * @param {Boolean} isEnabled true if the property should be enabled; + * false if it should be disabled + */ + setPropertyEnabled(index, name, isEnabled) { + if (!isEnabled) { + this.modifications.push({ type: "disable", index, name }); + } + } + + /** + * Create a new property. This implementation does nothing, because + * |setRuleText| is not available. + * + * These parameters are passed, but as they are not used in this + * implementation, they are omitted. They are documented here as + * this code also defined the interface implemented by @see + * RuleRewriter. + * + * @param {Number} index index of the property in the rule. + * This can be -1 in the case where + * the rule does not support setRuleText; + * generally for setting properties + * on an element's style. + * @param {String} name name of the new property + * @param {String} value value of the new property + * @param {String} priority priority of the new property; either + * the empty string or "important" + * @param {Boolean} enabled True if the new property should be + * enabled, false if disabled + */ + createProperty() { + // Nothing. + } +} diff --git a/devtools/client/fronts/style-sheets.js b/devtools/client/fronts/style-sheets.js new file mode 100644 index 0000000000..de6010aa74 --- /dev/null +++ b/devtools/client/fronts/style-sheets.js @@ -0,0 +1,37 @@ +/* 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"; + +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + styleSheetsSpec, +} = require("resource://devtools/shared/specs/style-sheets.js"); + +/** + * The corresponding Front object for the StyleSheetsActor. + */ +class StyleSheetsFront extends FrontClassWithSpec(styleSheetsSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + // Attribute name from which to retrieve the actorID out of the target actor's form + this.formAttributeName = "styleSheetsActor"; + } + + async getTraits() { + if (this._traits) { + return this._traits; + } + const { traits } = await super.getTraits(); + this._traits = traits; + return this._traits; + } +} + +exports.StyleSheetsFront = StyleSheetsFront; +registerFront(StyleSheetsFront); diff --git a/devtools/client/fronts/symbol-iterator.js b/devtools/client/fronts/symbol-iterator.js new file mode 100644 index 0000000000..1f6cd2581c --- /dev/null +++ b/devtools/client/fronts/symbol-iterator.js @@ -0,0 +1,56 @@ +/* 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"; + +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + symbolIteratorSpec, +} = require("resource://devtools/shared/specs/symbol-iterator.js"); +const { + getAdHocFrontOrPrimitiveGrip, +} = require("resource://devtools/client/fronts/object.js"); + +/** + * A SymbolIteratorFront is used as a front end for the SymbolIterator that is + * created on the server, hiding implementation details. + */ +class SymbolIteratorFront extends FrontClassWithSpec(symbolIteratorSpec) { + form(data) { + this.actorID = data.actor; + this.count = data.count; + } + + async slice(start, count) { + const result = await super.slice({ start, count }); + return this._onResult(result); + } + + async all() { + const result = await super.all(); + return this._onResult(result); + } + + _onResult(result) { + if (!result.ownSymbols) { + return result; + } + + result.ownSymbols.forEach((item, i) => { + if (item?.descriptor) { + result.ownSymbols[i].descriptor.value = getAdHocFrontOrPrimitiveGrip( + item.descriptor.value, + this + ); + } + }); + return result; + } +} + +exports.SymbolIteratorFront = SymbolIteratorFront; +registerFront(SymbolIteratorFront); diff --git a/devtools/client/fronts/target-configuration.js b/devtools/client/fronts/target-configuration.js new file mode 100644 index 0000000000..33ed2cd593 --- /dev/null +++ b/devtools/client/fronts/target-configuration.js @@ -0,0 +1,33 @@ +/* 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"; + +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + targetConfigurationSpec, +} = require("resource://devtools/shared/specs/target-configuration.js"); + +/** + * The TargetConfigurationFront/Actor should be used to populate the DevTools server + * with settings read from the client side but which impact the server. + * For instance, "disable cache" is a feature toggled via DevTools UI (client), + * but which should be communicated to the targets (server). + * + * See the TargetConfigurationActor for a list of supported configuration options. + */ +class TargetConfigurationFront extends FrontClassWithSpec( + targetConfigurationSpec +) { + form(json) { + // Read the initial configuration. + this.initialConfiguration = json.configuration; + this.traits = json.traits; + } +} + +registerFront(TargetConfigurationFront); diff --git a/devtools/client/fronts/targets/content-process.js b/devtools/client/fronts/targets/content-process.js new file mode 100644 index 0000000000..7629cb8449 --- /dev/null +++ b/devtools/client/fronts/targets/content-process.js @@ -0,0 +1,57 @@ +/* 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"; + +const { + contentProcessTargetSpec, +} = require("resource://devtools/shared/specs/targets/content-process.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + TargetMixin, +} = require("resource://devtools/client/fronts/targets/target-mixin.js"); + +class ContentProcessTargetFront extends TargetMixin( + FrontClassWithSpec(contentProcessTargetSpec) +) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + this.traits = {}; + } + + form(json) { + this.actorID = json.actor; + this.processID = json.processID; + + // Save the full form for Target class usage. + // Do not use `form` name to avoid colliding with protocol.js's `form` method + this.targetForm = json; + + this.remoteType = json.remoteType; + this.isXpcShellTarget = json.isXpcShellTarget; + } + + get name() { + // @backward-compat { version 87 } We now have `remoteType` attribute. + if (this.remoteType) { + return `(pid ${this.processID}) ${this.remoteType.replace( + "webIsolated=", + "" + )}`; + } + return `(pid ${this.processID}) Content Process`; + } + + reconfigure() { + // Toolbox and options panel are calling this method but Worker Target can't be + // reconfigured. So we ignore this call here. + return Promise.resolve(); + } +} + +exports.ContentProcessTargetFront = ContentProcessTargetFront; +registerFront(exports.ContentProcessTargetFront); diff --git a/devtools/client/fronts/targets/moz.build b/devtools/client/fronts/targets/moz.build new file mode 100644 index 0000000000..82b82f24a7 --- /dev/null +++ b/devtools/client/fronts/targets/moz.build @@ -0,0 +1,12 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + "content-process.js", + "target-mixin.js", + "window-global.js", + "worker.js", +) diff --git a/devtools/client/fronts/targets/target-mixin.js b/devtools/client/fronts/targets/target-mixin.js new file mode 100644 index 0000000000..157e83e73a --- /dev/null +++ b/devtools/client/fronts/targets/target-mixin.js @@ -0,0 +1,630 @@ +/* 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"; + +loader.lazyRequireGetter( + this, + "getFront", + "resource://devtools/shared/protocol.js", + true +); +loader.lazyRequireGetter( + this, + "getThreadOptions", + "resource://devtools/client/shared/thread-utils.js", + true +); + +/** + * A Target represents a debuggable context. It can be a browser tab, a tab on + * a remote device, like a tab on Firefox for Android. But it can also be an add-on, + * as well as firefox parent process, or just one of its content process. + * A Target is related to a given TargetActor, for which we derive this class. + * + * Providing a generalized abstraction of a web-page or web-browser (available + * either locally or remotely) is beyond the scope of this class (and maybe + * also beyond the scope of this universe) However Target does attempt to + * abstract some common events and read-only properties common to many Tools. + * + * Supported read-only properties: + * - name, url + * + * Target extends EventEmitter and provides support for the following events: + * - close: The target window has been closed. All tools attached to this + * target should close. This event is not currently cancelable. + * + * Optional events only dispatched by WindowGlobalTarget: + * - will-navigate: The target window will navigate to a different URL + * - navigate: The target window has navigated to a different URL + */ +function TargetMixin(parentClass) { + class Target extends parentClass { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + // TargetCommand._onTargetAvailable will set this public attribute. + // This is a reference to the related `commands` object and helps all fronts + // easily call any command method. Without this bit of magic, Fronts wouldn't + // be able to interact with any commands while it is frequently useful. + this.commands = null; + + this.destroy = this.destroy.bind(this); + + this.threadFront = null; + + this._client = client; + + // Cache of already created targed-scoped fronts + // [typeName:string => Front instance] + this.fronts = new Map(); + + // `resource-available-form` and `resource-updated-form` events can be emitted + // by target actors before the ResourceCommand could add event listeners. + // The target front will cache those events until the ResourceCommand has + // added the listeners. + this._resourceCache = {}; + + // In order to avoid destroying the `_resourceCache[event]`, we need to call `super.on()` + // instead of `this.on()`. + const offResourceAvailable = super.on( + "resource-available-form", + this._onResourceEvent.bind(this, "resource-available-form") + ); + const offResourceUpdated = super.on( + "resource-updated-form", + this._onResourceEvent.bind(this, "resource-updated-form") + ); + + this._offResourceEvent = new Map([ + ["resource-available-form", offResourceAvailable], + ["resource-updated-form", offResourceUpdated], + ]); + + // Expose a promise that is resolved once the target front is usable + // i.e. once attachAndInitThread has been called and resolved. + this.initialized = new Promise(resolve => { + this._onInitialized = resolve; + }); + } + + on(eventName, listener) { + if (this._offResourceEvent && this._offResourceEvent.has(eventName)) { + // If a callsite sets an event listener for resource-(available|update)-form: + + // we want to remove the listener we set here in the constructor… + const off = this._offResourceEvent.get(eventName); + this._offResourceEvent.delete(eventName); + off(); + + // …and call the new listener with the resources that were put in the cache. + if (this._resourceCache[eventName]) { + for (const cache of this._resourceCache[eventName]) { + listener(cache); + } + delete this._resourceCache[eventName]; + } + } + + return super.on(eventName, listener); + } + + /** + * Boolean flag to help distinguish Target Fronts from other Fronts. + * As we are using a Mixin, we can't easily distinguish these fronts via instanceof(). + */ + get isTargetFront() { + return true; + } + + get targetType() { + return this._targetType; + } + + get isTopLevel() { + // We can't use `getTrait` here as this might be called from a destroyed target (e.g. + // from an onTargetDestroyed callback that was triggered by a legacy listener), which + // means `this.client` would be null, which would make `getTrait` throw (See Bug 1714974) + if (!this.targetForm.hasOwnProperty("isTopLevelTarget")) { + return !!this._isTopLevel; + } + + return this.targetForm.isTopLevelTarget; + } + + setTargetType(type) { + this._targetType = type; + } + + setIsTopLevel(isTopLevel) { + if (!this.getTrait("supportsTopLevelTargetFlag")) { + this._isTopLevel = isTopLevel; + } + } + + /** + * Get the immediate parent target for this target. + * + * @return {TargetMixin} the parent target. + */ + async getParentTarget() { + return this.commands.targetCommand.getParentTarget(this); + } + + /** + * Returns a Promise that resolves to a boolean indicating if the provided target is + * an ancestor of this instance. + * + * @param {TargetFront} target: The possible ancestor target. + * @returns Promise<Boolean> + */ + async isTargetAnAncestor(target) { + const parentTargetFront = await this.getParentTarget(); + if (!parentTargetFront) { + return false; + } + + if (parentTargetFront == target) { + return true; + } + + return parentTargetFront.isTargetAnAncestor(target); + } + + /** + * Get the target for the given Browsing Context ID. + * + * @return {TargetMixin} the requested target. + */ + async getWindowGlobalTarget(browsingContextID) { + // Just for sanity as commands attribute is set late from TargetCommand._onTargetAvailable + // but ideally target front should be used before this happens. + if (!this.commands) { + return null; + } + // Tab and Process Descriptors expose a Watcher, which is creating the + // targets and should be used to fetch any. + const { watcherFront } = this.commands; + if (watcherFront) { + // Safety check, in theory all watcher should support frames. + if (watcherFront.traits.frame) { + return watcherFront.getWindowGlobalTarget(browsingContextID); + } + return null; + } + + // For descriptors which don't expose a watcher (e.g. WebExtension) + // we used to call RootActor::getBrowsingContextDescriptor, but it was + // removed in FF77. + // Support for watcher in WebExtension descriptors is Bug 1644341. + throw new Error( + `Unable to call getWindowGlobalTarget for ${this.actorID}` + ); + } + + /** + * Returns a boolean indicating whether or not the specific actor + * type exists. + * + * @param {String} actorName + * @return {Boolean} + */ + hasActor(actorName) { + if (this.targetForm) { + return !!this.targetForm[actorName + "Actor"]; + } + return false; + } + + /** + * Returns a trait from the target actor if it exists, + * if not it will fallback to that on the root actor. + * + * @param {String} traitName + * @return {Mixed} + */ + getTrait(traitName) { + // If the targeted actor exposes traits and has a defined value for this + // traits, override the root actor traits + if (this.targetForm.traits && traitName in this.targetForm.traits) { + return this.targetForm.traits[traitName]; + } + + return this.client.traits[traitName]; + } + + // Get a Front for a target-scoped actor. + // i.e. an actor served by RootActor.listTabs or RootActorActor.getTab requests + async getFront(typeName) { + if (this.isDestroyed()) { + throw new Error( + "Target already destroyed, unable to fetch children fronts" + ); + } + let front = this.fronts.get(typeName); + if (front) { + // XXX: This is typically the kind of spot where switching to + // `isDestroyed()` is complicated, because `front` is not necessarily a + // Front... + const isFrontInitializing = typeof front.then === "function"; + const isFrontAlive = !isFrontInitializing && !front.isDestroyed(); + if (isFrontInitializing || isFrontAlive) { + return front; + } + } + + front = getFront(this.client, typeName, this.targetForm, this); + this.fronts.set(typeName, front); + // replace the placeholder with the instance of the front once it has loaded + front = await front; + this.fronts.set(typeName, front); + return front; + } + + getCachedFront(typeName) { + // do not wait for async fronts; + const front = this.fronts.get(typeName); + // ensure that the front is a front, and not async front + if (front?.actorID) { + return front; + } + return null; + } + + get client() { + return this._client; + } + + // Tells us if the related actor implements WindowGlobalTargetActor + // interface and requires to call `attach` request before being used and + // `detach` during cleanup. + get isBrowsingContext() { + return this.typeName === "windowGlobalTarget"; + } + + get name() { + if (this.isWebExtension || this.isContentProcess) { + return this.targetForm.name; + } + return this.title; + } + + get title() { + return this._title || this.url; + } + + get url() { + return this._url; + } + + get isWorkerTarget() { + // XXX Remove the check on `workerDescriptor` as part of Bug 1667404. + return ( + this.typeName === "workerTarget" || this.typeName === "workerDescriptor" + ); + } + + get isWebExtension() { + return !!( + this.targetForm && + this.targetForm.actor && + (this.targetForm.actor.match(/conn\d+\.webExtension(Target)?\d+/) || + this.targetForm.actor.match(/child\d+\/webExtension(Target)?\d+/)) + ); + } + + get isContentProcess() { + // browser content toolbox's form will be of the form: + // server0.conn0.content-process0/contentProcessTarget7 + // while xpcshell debugging will be: + // server1.conn0.contentProcessTarget7 + return !!( + this.targetForm && + this.targetForm.actor && + this.targetForm.actor.match( + /conn\d+\.(content-process\d+\/)?contentProcessTarget\d+/ + ) + ); + } + + get isParentProcess() { + return !!( + this.targetForm && + this.targetForm.actor && + this.targetForm.actor.match(/conn\d+\.parentProcessTarget\d+/) + ); + } + + getExtensionPathName(url) { + // Return the url if the target is not a webextension. + if (!this.isWebExtension) { + throw new Error("Target is not a WebExtension"); + } + + try { + const parsedURL = new URL(url); + // Only moz-extension URL should be shortened into the URL pathname. + if (parsedURL.protocol !== "moz-extension:") { + return url; + } + return parsedURL.pathname; + } catch (e) { + // Return the url if unable to resolve the pathname. + return url; + } + } + + /** + * This method attaches the target and then attaches its related thread, sending it + * the options it needs (e.g. breakpoints, pause on exception setting, …). + * This function can be called multiple times, it will only perform the actual + * initialization process once; on subsequent call the original promise (_onThreadInitialized) + * will be returned. + * + * @param {TargetCommand} targetCommand + * @returns {Promise} A promise that resolves once the thread is attached and resumed. + */ + attachAndInitThread(targetCommand) { + if (this._onThreadInitialized) { + return this._onThreadInitialized; + } + + this._onThreadInitialized = this._attachAndInitThread(targetCommand); + // Resolve the `initialized` promise, while ignoring errors + // The empty function passed to catch will avoid spawning a new possibly rejected promise + this._onThreadInitialized.catch(() => {}).then(this._onInitialized); + return this._onThreadInitialized; + } + + /** + * This method attach the target and then attach its related thread, sending it the + * options it needs (e.g. breakpoints, pause on exception setting, …) + * + * @private + * @param {TargetCommand} targetCommand + * @returns {Promise} A promise that resolves once the thread is attached and resumed. + */ + async _attachAndInitThread(targetCommand) { + // If the target is destroyed or soon will be, don't go further + if (this.isDestroyedOrBeingDestroyed()) { + return; + } + + // The current class we have is actually the WorkerDescriptorFront, + // which will morph into a target by fetching the underlying target's form. + // Ideally, worker targets would be spawn by the server, and we would no longer + // have the hybrid descriptor/target class which brings lots of complexity and confusion. + // To be removed in bug 1651522. + if (this.morphWorkerDescriptorIntoWorkerTarget) { + await this.morphWorkerDescriptorIntoWorkerTarget(); + } + + const isBrowserToolbox = + targetCommand.descriptorFront.isBrowserProcessDescriptor; + const isNonTopLevelFrameTarget = + !this.isTopLevel && this.targetType === targetCommand.TYPES.FRAME; + + if (isBrowserToolbox && isNonTopLevelFrameTarget) { + // In the BrowserToolbox, non-top-level frame targets are already + // debugged via content-process targets. + // Do not attach the thread here, as it was already done by the + // corresponding content-process target. + return; + } + + // Avoid attaching any thread actor in the browser console or in + // webextension commands in order to avoid triggering any type of + // breakpoint. + if (targetCommand.descriptorFront.doNotAttachThreadActor) { + return; + } + + const options = await getThreadOptions(); + // If the target is destroyed or soon will be, don't go further + if (this.isDestroyedOrBeingDestroyed()) { + return; + } + await this.attachThread(options); + } + + async attachThread(options = {}) { + if (!this.targetForm || !this.targetForm.threadActor) { + throw new Error( + "TargetMixin sub class should set targetForm.threadActor before calling " + + "attachThread" + ); + } + this.threadFront = await this.getFront("thread"); + + // Avoid attaching if the thread actor was already attached on target creation from the server side. + // This doesn't include: + // * targets that aren't yet supported by the Watcher (like web extensions), + // * workers, which still use a unique codepath for thread actor attach + // * all targets when connecting to an older server + // If all targets are supported by watcher actor, and workers no longer use + // its unique attach sequence, we can assume the thread front is always attached. + const isAttached = await this.threadFront.isAttached(); + + const isDestroyed = + this.isDestroyedOrBeingDestroyed() || this.threadFront.isDestroyed(); + if (!isAttached && !isDestroyed) { + await this.threadFront.attach(options); + } + + return this.threadFront; + } + + isDestroyedOrBeingDestroyed() { + return this.isDestroyed() || this._destroyer; + } + + /** + * Target is not alive anymore. + */ + destroy() { + // If several things call destroy then we give them all the same + // destruction promise so we're sure to destroy only once + if (this._destroyer) { + return this._destroyer; + } + + // This pattern allows to immediately return the destroyer promise. + // See Bug 1602727 for more details. + let destroyerResolve; + this._destroyer = new Promise(r => (destroyerResolve = r)); + this._destroyTarget().then(destroyerResolve); + + return this._destroyer; + } + + async _destroyTarget() { + // If the target is being attached, try to wait until it's done, to prevent having + // pending connection to the server when the toolbox is destroyed. + if (this._onThreadInitialized) { + try { + await this._onThreadInitialized; + } catch (e) { + // We might still get into cases where attaching fails (e.g. the worker we're + // trying to attach to is already closed). Since the target is being destroyed, + // we don't need to do anything special here. + } + } + + for (let [name, front] of this.fronts) { + try { + // If a Front with an async initialize method is still being instantiated, + // we should wait for completion before trying to destroy it. + if (front instanceof Promise) { + front = await front; + } + front.destroy(); + } catch (e) { + console.warn("Error while destroying front:", name, e); + } + } + this.fronts.clear(); + + this.threadFront = null; + this._offResourceEvent = null; + + // This event should be emitted before calling super.destroy(), because + // super.destroy() will remove all event listeners attached to this front. + this.emit("target-destroyed"); + + // Not all targets supports attach/detach. For example content process doesn't. + // Also ensure that the front is still active before trying to do the request. + if (this.detach && !this.isDestroyed()) { + // The client was handed to us, so we are not responsible for closing + // it. We just need to detach from the tab, if already attached. + // |detach| may fail if the connection is already dead, so proceed with + // cleanup directly after this. + try { + await this.detach(); + } catch (e) { + this.logDetachError(e); + } + } + + // Do that very last in order to let a chance to dispatch `detach` requests. + super.destroy(); + + this._cleanup(); + } + + /** + * Detach can fail under regular circumstances, if the target was already + * destroyed on the server side. All target fronts should handle detach + * error logging in similar ways so this might be used by subclasses + * with custom detach() implementations. + * + * @param {Error} e + * The real error object. + * @param {String} targetType + * The type of the target front ("worker", "browsing-context", ...) + */ + logDetachError(e, targetType) { + const ignoredError = + e?.message.includes("noSuchActor") || + e?.message.includes("Connection closed"); + + // Silence exceptions for already destroyed actors and fronts: + // - "noSuchActor" errors from the server + // - "Connection closed" errors from the client, when purging requests + if (ignoredError) { + return; + } + + // Properly log any other error. + const message = targetType + ? `Error while detaching the ${targetType} target:` + : "Error while detaching target:"; + console.warn(message, e); + } + + /** + * Clean up references to what this target points to. + */ + _cleanup() { + this.threadFront = null; + this._client = null; + + this._title = null; + this._url = null; + } + + _onResourceEvent(eventName, resources) { + if (!this._resourceCache[eventName]) { + this._resourceCache[eventName] = []; + } + this._resourceCache[eventName].push(resources); + } + + toString() { + const id = this.targetForm ? this.targetForm.actor : null; + return `Target:${id}`; + } + + dumpPools() { + // NOTE: dumpPools is defined in the Thread actor to avoid + // adding it to multiple target specs and actors. + return this.threadFront.dumpPools(); + } + + /** + * Log an error of some kind to the tab's console. + * + * @param {String} text + * The text to log. + * @param {String} category + * The category of the message. @see nsIScriptError. + * @returns {Promise} + */ + logErrorInPage(text, category) { + if (this.getTrait("logInPage")) { + const errorFlag = 0; + return this.logInPage({ text, category, flags: errorFlag }); + } + return Promise.resolve(); + } + + /** + * Log a warning of some kind to the tab's console. + * + * @param {String} text + * The text to log. + * @param {String} category + * The category of the message. @see nsIScriptError. + * @returns {Promise} + */ + logWarningInPage(text, category) { + if (this.getTrait("logInPage")) { + const warningFlag = 1; + return this.logInPage({ text, category, flags: warningFlag }); + } + return Promise.resolve(); + } + } + return Target; +} +exports.TargetMixin = TargetMixin; diff --git a/devtools/client/fronts/targets/window-global.js b/devtools/client/fronts/targets/window-global.js new file mode 100644 index 0000000000..8bf69383e0 --- /dev/null +++ b/devtools/client/fronts/targets/window-global.js @@ -0,0 +1,172 @@ +/* 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"; + +const { + windowGlobalTargetSpec, +} = require("resource://devtools/shared/specs/targets/window-global.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + TargetMixin, +} = require("resource://devtools/client/fronts/targets/target-mixin.js"); + +class WindowGlobalTargetFront extends TargetMixin( + FrontClassWithSpec(windowGlobalTargetSpec) +) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + // For targets which support the Watcher and configuration actor, the status + // for the `javascriptEnabled` setting will be available on the configuration + // front, and the target will only be used to read the initial value from older + // servers. + // Note: this property is marked as private but is accessed by the + // TargetCommand to provide the "isJavascriptEnabled" wrapper. It should NOT be + // used anywhere else. + this._javascriptEnabled = null; + + // If this target was retrieved via NodeFront connectToFrame, keep a + // reference to the parent NodeFront. + this._parentNodeFront = null; + + this._onTabNavigated = this._onTabNavigated.bind(this); + this._onFrameUpdate = this._onFrameUpdate.bind(this); + + this.on("tabNavigated", this._onTabNavigated); + this.on("frameUpdate", this._onFrameUpdate); + } + + form(json) { + this.actorID = json.actor; + this.browsingContextID = json.browsingContextID; + this.innerWindowId = json.innerWindowId; + this.processID = json.processID; + + // Save the full form for Target class usage. + // Do not use `form` name to avoid colliding with protocol.js's `form` method + this.targetForm = json; + + this.outerWindowID = json.outerWindowID; + this.favicon = json.favicon; + + // Initial value for the page title and url. Since the WindowGlobalTargetActor can + // be created very early, those might not represent the actual value we'd want to + // display for the user (e.g. the <title> might not have been parsed yet, and the + // url could still be about:blank, which is what the platform uses at the very start + // of a navigation to a new location). + // Those values are set again from the targetCommand when receiving DOCUMENT_EVENT + // resource, at which point both values should be in their expected form. + this.setTitle(json.title); + this.setUrl(json.url); + } + + /** + * Event listener for `frameUpdate` event. + */ + _onFrameUpdate(packet) { + this.emit("frame-update", packet); + } + + /** + * Event listener for `tabNavigated` event. + */ + _onTabNavigated(packet) { + const event = Object.create(null); + event.url = packet.url; + event.title = packet.title; + event.isFrameSwitching = packet.isFrameSwitching; + + // Keep the title unmodified when a developer toolbox switches frame + // for a tab (Bug 1261687), but always update the title when the target + // is a WebExtension (where the addon name is always included in the title + // and the url is supposed to be updated every time the selected frame changes). + if (!packet.isFrameSwitching || this.isWebExtension) { + this.setTitle(packet.title); + this.setUrl(packet.url); + } + + // Send any stored event payload (DOMWindow or nsIRequest) for backwards + // compatibility with non-remotable tools. + if (packet.state == "start") { + this.emit("will-navigate", event); + } else { + this.emit("navigate", event); + } + } + + getParentNodeFront() { + return this._parentNodeFront; + } + + setParentNodeFront(nodeFront) { + this._parentNodeFront = nodeFront; + } + + /** + * Set the targetFront url. + * + * @param {string} url + */ + setUrl(url) { + this._url = url; + } + + /** + * Set the targetFront title. + * + * @param {string} title + */ + setTitle(title) { + this._title = title; + } + + async detach() { + // When calling this.destroy() at the end of this method, + // we will end up calling detach again from TargetMixin.destroy. + // Avoid invalid loops and do not try to resolve only once the previous call to detach + // is done as it would do async infinite loop that never resolves. + if (this._isDetaching) { + return; + } + this._isDetaching = true; + + // Remove listeners set in constructor + this.off("tabNavigated", this._onTabNavigated); + this.off("frameUpdate", this._onFrameUpdate); + + try { + await super.detach(); + } catch (e) { + this.logDetachError(e, "browsing context"); + } + + // Detach will destroy the target actor, but the server won't emit any + // target-destroyed-form in such manual, client side destruction. + // So that we have to manually destroy the associated front on the client + // + // If detach was called by TargetFrontMixin.destroy, avoid recalling it from it + // as it would do an async infinite loop which would never resolve. + if (!this.isDestroyedOrBeingDestroyed()) { + this.destroy(); + } + } + + destroy() { + const promise = super.destroy(); + this._parentNodeFront = null; + + // As detach isn't necessarily called on target's destroy + // (it isn't for local tabs), ensure removing listeners set in constructor. + this.off("tabNavigated", this._onTabNavigated); + this.off("frameUpdate", this._onFrameUpdate); + + return promise; + } +} + +exports.WindowGlobalTargetFront = WindowGlobalTargetFront; +registerFront(exports.WindowGlobalTargetFront); diff --git a/devtools/client/fronts/targets/worker.js b/devtools/client/fronts/targets/worker.js new file mode 100644 index 0000000000..3ca053d468 --- /dev/null +++ b/devtools/client/fronts/targets/worker.js @@ -0,0 +1,33 @@ +/* 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"; + +const { + workerTargetSpec, +} = require("resource://devtools/shared/specs/targets/worker.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + TargetMixin, +} = require("resource://devtools/client/fronts/targets/target-mixin.js"); + +class WorkerTargetFront extends TargetMixin( + FrontClassWithSpec(workerTargetSpec) +) { + form(json) { + this.actorID = json.actor; + + // Save the full form for Target class usage. + // Do not use `form` name to avoid colliding with protocol.js's `form` method + this.targetForm = json; + + this._title = json.title; + this._url = json.url; + } +} + +exports.WorkerTargetFront = WorkerTargetFront; +registerFront(exports.WorkerTargetFront); diff --git a/devtools/client/fronts/thread-configuration.js b/devtools/client/fronts/thread-configuration.js new file mode 100644 index 0000000000..c6b7171c70 --- /dev/null +++ b/devtools/client/fronts/thread-configuration.js @@ -0,0 +1,25 @@ +/* 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"; + +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + threadConfigurationSpec, +} = require("resource://devtools/shared/specs/thread-configuration.js"); + +/** + * The ThreadConfigurationFront/Actor should be used to maintain thread settings + * sent from the client for the thread actor. + * + * See the ThreadConfigurationActor for a list of supported configuration options. + */ +class ThreadConfigurationFront extends FrontClassWithSpec( + threadConfigurationSpec +) {} + +registerFront(ThreadConfigurationFront); diff --git a/devtools/client/fronts/thread.js b/devtools/client/fronts/thread.js new file mode 100644 index 0000000000..f5d05aa868 --- /dev/null +++ b/devtools/client/fronts/thread.js @@ -0,0 +1,285 @@ +/* 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"; + +const { ThreadStateTypes } = require("resource://devtools/client/constants.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); + +const { threadSpec } = require("resource://devtools/shared/specs/thread.js"); + +loader.lazyRequireGetter( + this, + "ObjectFront", + "resource://devtools/client/fronts/object.js", + true +); +loader.lazyRequireGetter( + this, + "FrameFront", + "resource://devtools/client/fronts/frame.js" +); +loader.lazyRequireGetter( + this, + "SourceFront", + "resource://devtools/client/fronts/source.js", + true +); + +/** + * Creates a thread front for the remote debugging protocol server. This client + * is a front to the thread actor created in the server side, hiding the + * protocol details in a traditional JavaScript API. + * + * @param client DevToolsClient + * @param actor string + * The actor ID for this thread. + */ +class ThreadFront extends FrontClassWithSpec(threadSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + this.client = client; + this._pauseGrips = {}; + this._threadGrips = {}; + // Note that this isn't matching ThreadActor state field. + // ThreadFront is only using two values: paused or attached. + this._state = "attached"; + + this._beforePaused = this._beforePaused.bind(this); + this._beforeResumed = this._beforeResumed.bind(this); + this.before("paused", this._beforePaused); + this.before("resumed", this._beforeResumed); + this.targetFront.on("will-navigate", this._onWillNavigate.bind(this)); + // Attribute name from which to retrieve the actorID out of the target actor's form + this.formAttributeName = "threadActor"; + } + + get state() { + return this._state; + } + + get paused() { + return this._state === "paused"; + } + + get actor() { + return this.actorID; + } + + _assertPaused(command) { + if (!this.paused) { + throw Error( + command + " command sent while not paused. Currently " + this._state + ); + } + } + + getFrames(start, count) { + return super.frames(start, count); + } + + /** + * Resume a paused thread. If the optional limit parameter is present, then + * the thread will also pause when that limit is reached. + * + * @param [optional] object limit + * An object with a type property set to the appropriate limit (next, + * step, or finish) per the remote debugging protocol specification. + * Use null to specify no limit. + */ + async _doResume(resumeLimit, frameActorID) { + this._assertPaused("resume"); + + // Put the client in a tentative "resuming" state so we can prevent + // further requests that should only be sent in the paused state. + this._previousState = this._state; + this._state = "resuming"; + try { + await super.resume(resumeLimit, frameActorID); + } catch (e) { + if (this._state == "resuming") { + // There was an error resuming, update the state to the new one + // reported by the server, if given (only on wrongState), otherwise + // reset back to the previous state. + if (e.state) { + this._state = ThreadStateTypes[e.state]; + } else { + this._state = this._previousState; + } + } + } + + delete this._previousState; + } + + /** + * Resume a paused thread. + */ + resume() { + return this._doResume(null); + } + + /** + * Resume then pause without stepping. + * + */ + resumeThenPause() { + return this._doResume({ type: "break" }); + } + + /** + * Step over a function call. + */ + stepOver(frameActorID) { + return this._doResume({ type: "next" }, frameActorID); + } + + /** + * Step into a function call. + */ + stepIn(frameActorID) { + return this._doResume({ type: "step" }, frameActorID); + } + + /** + * Step out of a function call. + */ + stepOut(frameActorID) { + return this._doResume({ type: "finish" }, frameActorID); + } + + /** + * Restart selected frame. + */ + restart(frameActorID) { + return this._doResume({ type: "restart" }, frameActorID); + } + + /** + * Immediately interrupt a running thread. + */ + interrupt() { + return this._doInterrupt(null); + } + + /** + * Pause execution right before the next JavaScript bytecode is executed. + */ + breakOnNext() { + return this._doInterrupt("onNext"); + } + + /** + * Interrupt a running thread. + */ + _doInterrupt(when) { + return super.interrupt(when); + } + + /** + * Request the loaded sources for the current thread. + */ + async getSources() { + let sources = []; + try { + sources = await super.sources(); + } catch (e) { + // we may have closed the connection + console.log(`getSources failed. Connection may have closed: ${e}`); + } + return { sources }; + } + + /** + * Return a ObjectFront object for the given object grip. + * + * @param grip object + * A pause-lifetime object grip returned by the protocol. + */ + pauseGrip(grip) { + if (grip.actor in this._pauseGrips) { + return this._pauseGrips[grip.actor]; + } + + const objectFront = new ObjectFront( + this.conn, + this.targetFront, + this, + grip + ); + this._pauseGrips[grip.actor] = objectFront; + return objectFront; + } + + /** + * Clear and invalidate all the grip fronts from the given cache. + * + * @param gripCacheName + * The property name of the grip cache we want to clear. + */ + _clearObjectFronts(gripCacheName) { + for (const id in this[gripCacheName]) { + this[gripCacheName][id].valid = false; + } + this[gripCacheName] = {}; + } + + /** + * Invalidate pause-lifetime grip clients and clear the list of current grip + * clients. + */ + _clearPauseGrips() { + this._clearObjectFronts("_pauseGrips"); + } + + _beforePaused(packet) { + this._state = "paused"; + this._onThreadState(packet); + } + + _beforeResumed() { + this._state = "attached"; + this._onThreadState(null); + this.unmanageChildren(FrameFront); + } + + _onWillNavigate() { + this.unmanageChildren(SourceFront); + } + + /** + * Handle thread state change by doing necessary cleanup + */ + _onThreadState(packet) { + // The debugger UI may not be initialized yet so we want to keep + // the packet around so it knows what to pause state to display + // when it's initialized + this._lastPausePacket = packet; + this._clearPauseGrips(); + } + + getLastPausePacket() { + return this._lastPausePacket; + } + + /** + * Return an instance of SourceFront for the given source actor form. + */ + source(form) { + if (form.actor in this._threadGrips) { + return this._threadGrips[form.actor]; + } + + const sourceFront = new SourceFront(this.client, form); + this.manage(sourceFront); + this._threadGrips[form.actor] = sourceFront; + return sourceFront; + } +} + +exports.ThreadFront = ThreadFront; +registerFront(ThreadFront); diff --git a/devtools/client/fronts/tracer.js b/devtools/client/fronts/tracer.js new file mode 100644 index 0000000000..d5df948be3 --- /dev/null +++ b/devtools/client/fronts/tracer.js @@ -0,0 +1,22 @@ +/* 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"; + +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { tracerSpec } = require("resource://devtools/shared/specs/tracer.js"); + +class TracerFront extends FrontClassWithSpec(tracerSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + // Attribute name from which to retrieve the actorID out of the target actor's form + this.formAttributeName = "tracerActor"; + } +} + +registerFront(TracerFront); diff --git a/devtools/client/fronts/walker.js b/devtools/client/fronts/walker.js new file mode 100644 index 0000000000..5feae2a343 --- /dev/null +++ b/devtools/client/fronts/walker.js @@ -0,0 +1,461 @@ +/* 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"; + +const { + FrontClassWithSpec, + types, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { walkerSpec } = require("resource://devtools/shared/specs/walker.js"); +const { + safeAsyncMethod, +} = require("resource://devtools/shared/async-utils.js"); + +/** + * Client side of the DOM walker. + */ +class WalkerFront extends FrontClassWithSpec(walkerSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + this._isPicking = false; + this._orphaned = new Set(); + this._retainedOrphans = new Set(); + + // Set to true if cleanup should be requested after every mutation list. + this.autoCleanup = true; + + this._rootNodePromise = new Promise( + r => (this._rootNodePromiseResolve = r) + ); + + this._onRootNodeAvailable = this._onRootNodeAvailable.bind(this); + this._onRootNodeDestroyed = this._onRootNodeDestroyed.bind(this); + + // pick/cancelPick requests can be triggered while the Walker is being destroyed. + this.pick = safeAsyncMethod(this.pick.bind(this), () => this.isDestroyed()); + this.cancelPick = safeAsyncMethod(this.cancelPick.bind(this), () => + this.isDestroyed() + ); + + this.before("new-mutations", this.onMutations.bind(this)); + + // Those events will be emitted if watchRootNode was called on the + // corresponding WalkerActor, which should be handled by the ResourceCommand + // as long as a consumer is watching for root-node resources. + // This should be fixed by using watchResources directly from the walker + // front, either with the ROOT_NODE resource, or with the DOCUMENT_EVENT + // resource. See Bug 1663973. + this.on("root-available", this._onRootNodeAvailable); + this.on("root-destroyed", this._onRootNodeDestroyed); + } + + // Update the object given a form representation off the wire. + form(json) { + this.actorID = json.actor; + + // The rootNode property should usually be provided via watchRootNode. + // However tests are currently using the walker front without explicitly + // calling watchRootNode, so we keep this assignment as a fallback. + this.rootNode = types.getType("domnode").read(json.root, this); + + 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. + */ + async getRootNode() { + let rootNode = this.rootNode; + if (!rootNode) { + rootNode = await this._rootNodePromise; + } + + return rootNode; + } + + /** + * When reading an actor form off the wire, we want to hook it up to its + * parent or host 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. + */ + ensureDOMNodeFront(id) { + const front = this.getActorByID(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. + */ + async retainNode(node) { + await super.retainNode(node); + node.retained = true; + } + + async unretainNode(node) { + await super.unretainNode(node); + node.retained = false; + if (this._retainedOrphans.has(node)) { + this._retainedOrphans.delete(node); + this._releaseFront(node); + } + } + + releaseNode(node, options = {}) { + // NodeFront.destroy will destroy children in the ownership tree too, + // mimicking what the server will do here. + const actorID = node.actorID; + this._releaseFront(node, !!options.force); + return super.releaseNode({ actorID }); + } + + async findInspectingNode() { + const response = await super.findInspectingNode(); + return response.node; + } + + async querySelector(queryNode, selector) { + const response = await super.querySelector(queryNode, selector); + return response.node; + } + + async getNodeActorFromWindowID(windowID) { + const response = await super.getNodeActorFromWindowID(windowID); + return response ? response.node : null; + } + + async getNodeActorFromContentDomReference(contentDomReference) { + const response = await super.getNodeActorFromContentDomReference( + contentDomReference + ); + return response ? response.node : null; + } + + async getStyleSheetOwnerNode(styleSheetActorID) { + const response = await super.getStyleSheetOwnerNode(styleSheetActorID); + return response ? response.node : null; + } + + async getNodeFromActor(actorID, path) { + const response = await super.getNodeFromActor(actorID, path); + return response ? response.node : null; + } + + _releaseFront(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 (const 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. + */ + // eslint-disable-next-line complexity + async getMutations(options = {}) { + const mutations = await super.getMutations(options); + const emitMutations = []; + for (const change of mutations) { + // The target is only an actorID, get the associated front. + const targetID = change.target; + const targetFront = this.getActorByID(targetID); + + if (!targetFront) { + console.warn( + "Got a mutation for an unexpected actor: " + + targetID + + ", please file a bug on bugzilla.mozilla.org!" + ); + console.trace(); + continue; + } + + const emittedMutation = Object.assign(change, { target: targetFront }); + + if (change.type === "childList") { + // Update the ownership tree according to the mutation record. + const addedFronts = []; + const removedFronts = []; + for (const removed of change.removed) { + const removedFront = this.getActorByID(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 (const added of change.added) { + const addedFront = this.getActorByID(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 === "shadowRootAttached") { + targetFront._form.isShadowHost = true; + } else if (change.type === "customElementDefined") { + targetFront._form.customElementLocation = change.customElementLocation; + } else if (change.type === "unretained") { + // Retained orphans were force-released without the intervention of + // client (probably a navigated frame). + for (const released of change.nodes) { + const releasedFront = this.getActorByID(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 === "shadowRootAttached" + ) { + if (change.inlineTextChild) { + targetFront.inlineTextChild = types + .getType("domnode") + .read(change.inlineTextChild, this); + } else { + targetFront.inlineTextChild = undefined; + } + } + + emitMutations.push(emittedMutation); + } + + if (options.cleanup) { + for (const node of this._orphaned) { + // This will move retained nodes to this._retainedOrphans. + this._releaseFront(node); + } + this._orphaned = new Set(); + } + + this.emit("mutations", emitMutations); + } + + /** + * Handle the `new-mutations` notification by fetching the + * available mutation records. + */ + onMutations() { + // Fetch and process the mutations. + this.getMutations({ cleanup: this.autoCleanup }).catch(() => {}); + } + + isLocal() { + return !!this.conn._transport._serverConnection; + } + + async removeNode(node) { + const previousSibling = await this.previousSibling(node); + const nextSibling = await super.removeNode(node); + return { + previousSibling, + nextSibling, + }; + } + + async children(node, options) { + if (!node.useChildTargetToFetchChildren) { + return super.children(node, options); + } + const target = await node.connectToFrame(); + + // We had several issues in the past where `connectToFrame` was returning the same + // target as the owner document one, which led to the inspector being broken. + // Ultimately, we shouldn't get to this point (fix should happen in connectToFrame or + // on the server, e.g. for Bug 1752342), but at least this will serve as a safe guard + // so we don't freeze/crash the inspector. + if ( + target == this.targetFront && + Services.prefs.getBoolPref( + "devtools.testing.bypass-walker-children-iframe-guard", + false + ) !== true + ) { + console.warn("connectToFrame returned an unexpected target"); + return { + nodes: [], + hasFirst: true, + hasLast: true, + }; + } + + const walker = (await target.getFront("inspector")).walker; + + // Finally retrieve the NodeFront of the remote frame's document + const documentNode = await walker.getRootNode(); + + // Force reparenting through the remote frame boundary. + documentNode.reparent(node); + + // And return the same kind of response `walker.children` returns + return { + nodes: [documentNode], + hasFirst: true, + hasLast: true, + }; + } + + /** + * Ensure that the RootNode of this Walker has the right parent NodeFront. + * + * This method does nothing if we are on the top level target's WalkerFront, + * as the RootNode won't have any parent. + * + * Otherwise, if we are in an iframe's WalkerFront, we would expect the parent + * of the RootNode (i.e. the NodeFront for the document loaded within the iframe) + * to be the <iframe>'s NodeFront. Because of fission, the two NodeFront may refer + * to DOM Element running in distinct processes and so the NodeFront comes from + * two distinct Targets and two distinct WalkerFront. + * This is why we need this manual "reparent" code to do the glue between the + * two documents. + */ + async reparentRemoteFrame() { + const parentTarget = await this.targetFront.getParentTarget(); + if (!parentTarget) { + return; + } + // Don't reparent if we are on the top target + if (parentTarget == this.targetFront) { + return; + } + // Get the NodeFront for the embedder element + // i.e. the <iframe> element which is hosting the document that + const parentWalker = (await parentTarget.getFront("inspector")).walker; + // As this <iframe> most likely runs in another process, we have to get it through the parent + // target's WalkerFront. + const parentNode = ( + await parentWalker.getEmbedderElement(this.targetFront.browsingContextID) + ).node; + + // Finally, set this embedder element's node front as the + const documentNode = await this.getRootNode(); + documentNode.reparent(parentNode); + } + + _onRootNodeAvailable(rootNode) { + if (rootNode.isTopLevelDocument) { + this.rootNode = rootNode; + this._rootNodePromiseResolve(this.rootNode); + } + } + + _onRootNodeDestroyed(rootNode) { + if (rootNode.isTopLevelDocument) { + this._rootNodePromise = new Promise( + r => (this._rootNodePromiseResolve = r) + ); + this.rootNode = null; + } + } + + /** + * Start the element picker on the debuggee target. + * @param {Boolean} doFocus - Optionally focus the content area once the picker is + * activated. + */ + pick(doFocus) { + if (this._isPicking) { + return Promise.resolve(); + } + + this._isPicking = true; + + return super.pick( + doFocus, + this.targetFront.commands.descriptorFront.isLocalTab + ); + } + + /** + * Stop the element picker. + */ + cancelPick() { + if (!this._isPicking) { + return Promise.resolve(); + } + + this._isPicking = false; + return super.cancelPick(); + } +} + +exports.WalkerFront = WalkerFront; +registerFront(WalkerFront); diff --git a/devtools/client/fronts/watcher.js b/devtools/client/fronts/watcher.js new file mode 100644 index 0000000000..1a7499561a --- /dev/null +++ b/devtools/client/fronts/watcher.js @@ -0,0 +1,202 @@ +/* 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"; + +const { watcherSpec } = require("resource://devtools/shared/specs/watcher.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); + +loader.lazyRequireGetter( + this, + "WindowGlobalTargetFront", + "resource://devtools/client/fronts/targets/window-global.js", + true +); +loader.lazyRequireGetter( + this, + "ContentProcessTargetFront", + "resource://devtools/client/fronts/targets/content-process.js", + true +); +loader.lazyRequireGetter( + this, + "WorkerTargetFront", + "resource://devtools/client/fronts/targets/worker.js", + true +); + +class WatcherFront extends FrontClassWithSpec(watcherSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + this._onTargetAvailable = this._onTargetAvailable.bind(this); + this._onTargetDestroyed = this._onTargetDestroyed.bind(this); + + // Convert form, which is just JSON object to Fronts for these two events + this.on("target-available-form", this._onTargetAvailable); + this.on("target-destroyed-form", this._onTargetDestroyed); + } + + form(json) { + this.actorID = json.actor; + this.traits = json.traits; + } + + _onTargetAvailable(form) { + let front; + if (form.actor.includes("/contentProcessTarget")) { + front = new ContentProcessTargetFront(this.conn, null, this); + } else if (form.actor.includes("/workerTarget")) { + front = new WorkerTargetFront(this.conn, null, this); + } else { + front = new WindowGlobalTargetFront(this.conn, null, this); + } + front.actorID = form.actor; + front.form(form); + this.manage(front); + this.emit("target-available", front); + } + + _onTargetDestroyed(form, options = {}) { + const front = this._getTargetFront(form); + + // When server side target switching is off, + // the watcher may notify us about the top level target destruction a bit late. + // The descriptor (`this.parentFront`) already switched to the new target. + // Missing `target-destroyed` isn't critical when target switching is off + // as `TargetCommand.switchToTarget` will end calling `TargetCommandonTargetDestroyed` for all + // existing targets. + // https://searchfox.org/mozilla-central/rev/af8e5d37fd56be90ccddae2203e7b875d3f3ae87/devtools/shared/commands/target/target-command.js#166-173 + if (front) { + this.emit("target-destroyed", front, options); + } + } + + _getTargetFront(form) { + let front = this.getActorByID(form.actor); + // For top level target, the target will be a child of the descriptor front, + // which happens to be the parent front of the watcher. + if (!front) { + front = this.parentFront.getActorByID(form.actor); + } + return front; + } + + /** + * Retrieve the already existing WindowGlobalTargetFront for the parent + * BrowsingContext of the given BrowsingContext ID. + */ + async getParentWindowGlobalTarget(browsingContextID) { + const id = await this.getParentBrowsingContextID(browsingContextID); + if (!id) { + return null; + } + return this.getWindowGlobalTarget(id); + } + + /** + * Memoized getter for the "blackboxing" actor + */ + async getBlackboxingActor() { + if (!this._blackboxingActor) { + this._blackboxingActor = await super.getBlackboxingActor(); + } + return this._blackboxingActor; + } + /** + * Memoized getter for the "breakpoint-list" actor + */ + async getBreakpointListActor() { + if (!this._breakpointListActor) { + this._breakpointListActor = await super.getBreakpointListActor(); + } + return this._breakpointListActor; + } + + /** + * Memoized getter for the "target-configuration" actor + */ + async getTargetConfigurationActor() { + if (!this._targetConfigurationActor) { + this._targetConfigurationActor = + await super.getTargetConfigurationActor(); + } + return this._targetConfigurationActor; + } + + /** + * Memoized getter for the "thread-configuration" actor + */ + async getThreadConfigurationActor() { + if (!this._threadConfigurationActor) { + this._threadConfigurationActor = + await super.getThreadConfigurationActor(); + } + return this._threadConfigurationActor; + } + + /** + * For a given BrowsingContext ID, return the already existing WindowGlobalTargetFront + */ + async getWindowGlobalTarget(id) { + // First scan the watcher children as the watcher manages all the targets + for (const front of this.poolChildren()) { + if (front.browsingContextID == id) { + return front; + } + } + // But the top level target will be created by the Descriptor.getTarget() method + // and so be hosted in the Descriptor's pool. + // The parent front of the WatcherActor happens to be the Descriptor Actor. + // This code could go away or be simplified if the Descriptor starts fetch all + // the targets, including the top level one via the Watcher. i.e. drop Descriptor.getTarget(). + const topLevelTarget = await this.parentFront.getTarget(); + if (topLevelTarget?.browsingContextID == id) { + return topLevelTarget; + } + + // If we could not find a window global target for the provided id, the + // window global might not be the topmost one of a given process (isProcessRoot == true). + // For now we only create targets for the top window global of each process, + // so we recursively check the parent browsing context ids + // until we find a valid target. + const parentBrowsingContextID = await this.getParentBrowsingContextID(id); + if (parentBrowsingContextID && parentBrowsingContextID !== id) { + return this.getWindowGlobalTarget(parentBrowsingContextID); + } + + return null; + } + + getWindowGlobalTargetByInnerWindowId(innerWindowId) { + for (const front of this.poolChildren()) { + if (front.innerWindowId == innerWindowId) { + return front; + } + } + // Use getCachedTarget in order to have a fully synchronous method + // as the callsite in ResourceCommand benefit from being synchronous. + // Here we care only about already existing resource and do not need to + // wait for the next target to come. + const topLevelTarget = this.parentFront.getCachedTarget(); + if (topLevelTarget?.innerWindowId == innerWindowId) { + return topLevelTarget; + } + console.error("Unable to find target with innerWindowId:" + innerWindowId); + return null; + } + + /** + * Memoized getter for the "networkParent" actor + */ + async getNetworkParentActor() { + if (!this._networkParentActor) { + this._networkParentActor = await super.getNetworkParentActor(); + } + return this._networkParentActor; + } +} +registerFront(WatcherFront); diff --git a/devtools/client/fronts/webconsole.js b/devtools/client/fronts/webconsole.js new file mode 100644 index 0000000000..e71b04841f --- /dev/null +++ b/devtools/client/fronts/webconsole.js @@ -0,0 +1,315 @@ +/* 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"; + +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { + webconsoleSpec, +} = require("resource://devtools/shared/specs/webconsole.js"); +const { + getAdHocFrontOrPrimitiveGrip, +} = require("resource://devtools/client/fronts/object.js"); + +/** + * A WebConsoleFront is used as a front end for the WebConsoleActor that is + * created on the server, hiding implementation details. + * + * @param object client + * The DevToolsClient instance we live for. + */ +class WebConsoleFront extends FrontClassWithSpec(webconsoleSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + this._client = client; + this.events = []; + + // Attribute name from which to retrieve the actorID out of the target actor's form + this.formAttributeName = "consoleActor"; + + this._onNetworkEventUpdate = this._onNetworkEventUpdate.bind(this); + + this.before("consoleAPICall", this.beforeConsoleAPICall); + this.before("pageError", this.beforePageError); + this.before("serverNetworkEvent", this.beforeServerNetworkEvent); + + this._client.on("networkEventUpdate", this._onNetworkEventUpdate); + } + + get actor() { + return this.actorID; + } + + /** + * The "networkEventUpdate" message type handler. We redirect any message to + * the UI for displaying. + * + * @private + * @param object packet + * The message received from the server. + */ + _onNetworkEventUpdate(packet) { + this.emit("serverNetworkUpdateEvent", packet); + } + + beforeServerNetworkEvent(packet) { + // The stacktrace info needs to be sent before + // the network event. + this.emit("serverNetworkStackTrace", packet); + } + + beforeConsoleAPICall(packet) { + if (packet.message && Array.isArray(packet.message.arguments)) { + // We might need to create fronts for each of the message arguments. + packet.message.arguments = packet.message.arguments.map(arg => + getAdHocFrontOrPrimitiveGrip(arg, this) + ); + } + return packet; + } + + beforePageError(packet) { + if (packet?.pageError?.errorMessage) { + packet.pageError.errorMessage = getAdHocFrontOrPrimitiveGrip( + packet.pageError.errorMessage, + this + ); + } + + if (packet?.pageError?.exception) { + packet.pageError.exception = getAdHocFrontOrPrimitiveGrip( + packet.pageError.exception, + this + ); + } + return packet; + } + + async getCachedMessages(messageTypes) { + const response = await super.getCachedMessages(messageTypes); + if (Array.isArray(response.messages)) { + response.messages = response.messages.map(packet => { + if (Array.isArray(packet?.message?.arguments)) { + // We might need to create fronts for each of the message arguments. + packet.message.arguments = packet.message.arguments.map(arg => + getAdHocFrontOrPrimitiveGrip(arg, this) + ); + } + + if (packet.pageError?.exception) { + packet.pageError.exception = getAdHocFrontOrPrimitiveGrip( + packet.pageError.exception, + this + ); + } + + return packet; + }); + } + return response; + } + + /** + * Retrieve the request headers from the given NetworkEventActor. + * + * @param string actor + * The NetworkEventActor ID. + * @param function onResponse + * The function invoked when the response is received. + * @return request + * Request object that implements both Promise and EventEmitter interfaces + */ + getRequestHeaders(actor, onResponse) { + const packet = { + to: actor, + type: "getRequestHeaders", + }; + return this._client.request(packet, onResponse); + } + + /** + * Retrieve the request cookies from the given NetworkEventActor. + * + * @param string actor + * The NetworkEventActor ID. + * @param function onResponse + * The function invoked when the response is received. + * @return request + * Request object that implements both Promise and EventEmitter interfaces + */ + getRequestCookies(actor, onResponse) { + const packet = { + to: actor, + type: "getRequestCookies", + }; + return this._client.request(packet, onResponse); + } + + /** + * Retrieve the request post data from the given NetworkEventActor. + * + * @param string actor + * The NetworkEventActor ID. + * @param function onResponse + * The function invoked when the response is received. + * @return request + * Request object that implements both Promise and EventEmitter interfaces + */ + getRequestPostData(actor, onResponse) { + const packet = { + to: actor, + type: "getRequestPostData", + }; + return this._client.request(packet, onResponse); + } + + /** + * Retrieve the response headers from the given NetworkEventActor. + * + * @param string actor + * The NetworkEventActor ID. + * @param function onResponse + * The function invoked when the response is received. + * @return request + * Request object that implements both Promise and EventEmitter interfaces + */ + getResponseHeaders(actor, onResponse) { + const packet = { + to: actor, + type: "getResponseHeaders", + }; + return this._client.request(packet, onResponse); + } + + /** + * Retrieve the response cookies from the given NetworkEventActor. + * + * @param string actor + * The NetworkEventActor ID. + * @param function onResponse + * The function invoked when the response is received. + * @return request + * Request object that implements both Promise and EventEmitter interfaces + */ + getResponseCookies(actor, onResponse) { + const packet = { + to: actor, + type: "getResponseCookies", + }; + return this._client.request(packet, onResponse); + } + + /** + * Retrieve the response content from the given NetworkEventActor. + * + * @param string actor + * The NetworkEventActor ID. + * @param function onResponse + * The function invoked when the response is received. + * @return request + * Request object that implements both Promise and EventEmitter interfaces + */ + getResponseContent(actor, onResponse) { + const packet = { + to: actor, + type: "getResponseContent", + }; + return this._client.request(packet, onResponse); + } + + /** + * Retrieve the response cache from the given NetworkEventActor + * + * @param string actor + * The NetworkEventActor ID. + * @param function onResponse + * The function invoked when the response is received. + * @return request + * Request object that implements both Promise and EventEmitter interfaces. + */ + getResponseCache(actor, onResponse) { + const packet = { + to: actor, + type: "getResponseCache", + }; + return this._client.request(packet, onResponse); + } + + /** + * Retrieve the timing information for the given NetworkEventActor. + * + * @param string actor + * The NetworkEventActor ID. + * @param function onResponse + * The function invoked when the response is received. + * @return request + * Request object that implements both Promise and EventEmitter interfaces + */ + getEventTimings(actor, onResponse) { + const packet = { + to: actor, + type: "getEventTimings", + }; + return this._client.request(packet, onResponse); + } + + /** + * Retrieve the security information for the given NetworkEventActor. + * + * @param string actor + * The NetworkEventActor ID. + * @param function onResponse + * The function invoked when the response is received. + * @return request + * Request object that implements both Promise and EventEmitter interfaces + */ + getSecurityInfo(actor, onResponse) { + const packet = { + to: actor, + type: "getSecurityInfo", + }; + return this._client.request(packet, onResponse); + } + + /** + * Retrieve the stack-trace information for the given NetworkEventActor. + * + * @param string actor + * The NetworkEventActor ID. + * @param function onResponse + * The function invoked when the stack-trace is received. + * @return request + * Request object that implements both Promise and EventEmitter interfaces + */ + getStackTrace(actor, onResponse) { + const packet = { + to: actor, + type: "getStackTrace", + }; + return this._client.request(packet, onResponse); + } + + /** + * Close the WebConsoleFront. + * + */ + destroy() { + if (!this._client) { + return null; + } + + this._client.off("networkEventUpdate", this._onNetworkEventUpdate); + // This will make future calls to this function harmless because of the early return + // at the top of the function. + this._client = null; + + return super.destroy(); + } +} + +exports.WebConsoleFront = WebConsoleFront; +registerFront(WebConsoleFront); diff --git a/devtools/client/fronts/worker/moz.build b/devtools/client/fronts/worker/moz.build new file mode 100644 index 0000000000..dae0e2d606 --- /dev/null +++ b/devtools/client/fronts/worker/moz.build @@ -0,0 +1,11 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + "push-subscription.js", + "service-worker-registration.js", + "service-worker.js", +) diff --git a/devtools/client/fronts/worker/push-subscription.js b/devtools/client/fronts/worker/push-subscription.js new file mode 100644 index 0000000000..790b32350e --- /dev/null +++ b/devtools/client/fronts/worker/push-subscription.js @@ -0,0 +1,30 @@ +/* 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"; + +const { + pushSubscriptionSpec, +} = require("resource://devtools/shared/specs/worker/push-subscription.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); + +class PushSubscriptionFront extends FrontClassWithSpec(pushSubscriptionSpec) { + get endpoint() { + return this._form.endpoint; + } + + get quota() { + return this._form.quota; + } + + form(form) { + this.actorID = form.actor; + this._form = form; + } +} + +exports.PushSubscriptionFront = PushSubscriptionFront; +registerFront(PushSubscriptionFront); diff --git a/devtools/client/fronts/worker/service-worker-registration.js b/devtools/client/fronts/worker/service-worker-registration.js new file mode 100644 index 0000000000..57e2bc400b --- /dev/null +++ b/devtools/client/fronts/worker/service-worker-registration.js @@ -0,0 +1,79 @@ +/* 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"; + +const { + serviceWorkerRegistrationSpec, +} = require("resource://devtools/shared/specs/worker/service-worker-registration.js"); +const { + FrontClassWithSpec, + registerFront, + types, +} = require("resource://devtools/shared/protocol.js"); + +class ServiceWorkerRegistrationFront extends FrontClassWithSpec( + serviceWorkerRegistrationSpec +) { + get active() { + return this._form.active; + } + + get fetch() { + return this._form.fetch; + } + + get id() { + return this.url; + } + + get lastUpdateTime() { + return this._form.lastUpdateTime; + } + + get scope() { + return this._form.scope; + } + + get type() { + return this._form.type; + } + + get url() { + return this._form.url; + } + + get evaluatingWorker() { + return this._getServiceWorker("evaluatingWorker"); + } + + get activeWorker() { + return this._getServiceWorker("activeWorker"); + } + + get installingWorker() { + return this._getServiceWorker("installingWorker"); + } + + get waitingWorker() { + return this._getServiceWorker("waitingWorker"); + } + + _getServiceWorker(type) { + const workerForm = this._form[type]; + if (!workerForm) { + return null; + } + return types.getType("serviceWorker").read(workerForm, this); + } + + form(form) { + this.actorID = form.actor; + this._form = form; + // @backward-compat { version 70 } ServiceWorkerRegistration actor now exposes traits + this.traits = form.traits || {}; + } +} + +exports.ServiceWorkerRegistrationFront = ServiceWorkerRegistrationFront; +registerFront(ServiceWorkerRegistrationFront); diff --git a/devtools/client/fronts/worker/service-worker.js b/devtools/client/fronts/worker/service-worker.js new file mode 100644 index 0000000000..604eb388e5 --- /dev/null +++ b/devtools/client/fronts/worker/service-worker.js @@ -0,0 +1,62 @@ +/* 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"; + +const { + serviceWorkerSpec, +} = require("resource://devtools/shared/specs/worker/service-worker.js"); +const { + FrontClassWithSpec, + registerFront, +} = require("resource://devtools/shared/protocol.js"); +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); + +const L10N = new LocalizationHelper( + "devtools/client/locales/debugger.properties" +); + +class ServiceWorkerFront extends FrontClassWithSpec(serviceWorkerSpec) { + get fetch() { + return this._form.fetch; + } + + get url() { + return this._form.url; + } + + get state() { + return this._form.state; + } + + get stateText() { + switch (this.state) { + case Ci.nsIServiceWorkerInfo.STATE_PARSED: + return L10N.getStr("serviceWorkerInfo.parsed"); + case Ci.nsIServiceWorkerInfo.STATE_INSTALLING: + return L10N.getStr("serviceWorkerInfo.installing"); + case Ci.nsIServiceWorkerInfo.STATE_INSTALLED: + return L10N.getStr("serviceWorkerInfo.installed"); + case Ci.nsIServiceWorkerInfo.STATE_ACTIVATING: + return L10N.getStr("serviceWorkerInfo.activating"); + case Ci.nsIServiceWorkerInfo.STATE_ACTIVATED: + return L10N.getStr("serviceWorkerInfo.activated"); + case Ci.nsIServiceWorkerInfo.STATE_REDUNDANT: + return L10N.getStr("serviceWorkerInfo.redundant"); + default: + return L10N.getStr("serviceWorkerInfo.unknown"); + } + } + + get id() { + return this._form.id; + } + + form(form) { + this.actorID = form.actor; + this._form = form; + } +} + +exports.ServiceWorkerFront = ServiceWorkerFront; +registerFront(ServiceWorkerFront); |