diff options
Diffstat (limited to 'devtools/client/fronts')
64 files changed, 9133 insertions, 0 deletions
diff --git a/devtools/client/fronts/accessibility.js b/devtools/client/fronts/accessibility.js new file mode 100644 index 0000000000..fd7025919c --- /dev/null +++ b/devtools/client/fronts/accessibility.js @@ -0,0 +1,592 @@ +/* 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("devtools/shared/protocol.js"); +const { + accessibleSpec, + accessibleWalkerSpec, + accessibilitySpec, + parentAccessibilitySpec, + simulatorSpec, +} = require("devtools/shared/specs/accessibility"); +const events = require("devtools/shared/event-emitter"); +const Services = require("Services"); +const BROWSER_TOOLBOX_FISSION_ENABLED = Services.prefs.getBoolPref( + "devtools.browsertoolbox.fission", + false +); + +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 remoteFrame() { + return BROWSER_TOOLBOX_FISSION_ENABLED && this._form.remoteFrame; + } + + 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.remoteFrame) { + 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, remoteFrame } = 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 (!remoteFrame) { + 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 remoteFrame properties. + delete snapshot.contentDOMReference; + delete snapshot.remoteFrame; + 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. BROWSER_TOOLBOX_FISSION_ENABLED is false, the top + * level document is bound by current target's document. Otherwise, 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); + if (!BROWSER_TOOLBOX_FISSION_ENABLED) { + // Do not try to get the ancestry across the remote frame hierarchy. + return ancestry; + } + + 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 + */ + async audit({ types, onProgress }) { + 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 or there's nothing to report, 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) { + 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.remoteFrame) { + 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..8722164646 --- /dev/null +++ b/devtools/client/fronts/addon/addons.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 { addonsSpec } = require("devtools/shared/specs/addon/addons"); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); + +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..e8064d3691 --- /dev/null +++ b/devtools/client/fronts/addon/webextension-inspected-window.js @@ -0,0 +1,97 @@ +/* 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("devtools/shared/specs/addon/webextension-inspected-window"); + +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); + +const { + getAdHocFrontOrPrimitiveGrip, +} = require("devtools/client/fronts/object"); + +/** + * 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"; + } + + /** + * Evaluate the provided javascript code in a target window. + * + * @param {Object} webExtensionCallerInfo - The addonId and the url (the addon base url + * or the url of the actual caller filename and lineNumber) used to log useful + * debugging information in the produced error logs and eval stack trace. + * @param {String} expression - The expression to evaluate. + * @param {Object} options - An option object. Check the actor method definition to see + * what properties it can hold (minus the `consoleFront` property which is defined + * below). + * @param {WebConsoleFront} options.consoleFront - An optional webconsole front. When + * set, the result will be either a primitive, a LongStringFront or an + * ObjectFront, and the WebConsoleActor corresponding to the console front will + * be used to generate those, which is needed if we want to handle ObjectFronts + * on the client. + */ + async eval(webExtensionCallerInfo, expression, options = {}) { + const { consoleFront } = options; + + if (consoleFront) { + options.evalResultAsGrip = true; + options.toolboxConsoleActorID = consoleFront.actor; + delete options.consoleFront; + } + + const response = await super.eval( + webExtensionCallerInfo, + expression, + options + ); + + // If no consoleFront was provided, we can directly return the response. + if (!consoleFront) { + return response; + } + + if ( + !response.hasOwnProperty("exceptionInfo") && + !response.hasOwnProperty("valueGrip") + ) { + throw new Error( + "Response does not have `exceptionInfo` or `valueGrip` property" + ); + } + + if (response.exceptionInfo) { + console.error( + response.exceptionInfo.description, + ...(response.exceptionInfo.details || []) + ); + return response; + } + + // On the server, the valueGrip is created from the toolbox webconsole actor. + // If we want since the ObjectFront connection is inherited from the parent front, we + // need to set the console front as the parent front. + return getAdHocFrontOrPrimitiveGrip( + response.valueGrip, + consoleFront || this + ); + } +} + +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..4bd452a1cb --- /dev/null +++ b/devtools/client/fronts/animation.js @@ -0,0 +1,213 @@ +/* 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("devtools/shared/protocol"); +const { + animationPlayerSpec, + animationsSpec, +} = require("devtools/shared/specs/animation"); + +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), + }; + } + + /** + * 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..b3ef8b4195 --- /dev/null +++ b/devtools/client/fronts/array-buffer.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 { arrayBufferSpec } = require("devtools/shared/specs/array-buffer"); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); + +/** + * 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/breakpoint-list.js b/devtools/client/fronts/breakpoint-list.js new file mode 100644 index 0000000000..8832a401b7 --- /dev/null +++ b/devtools/client/fronts/breakpoint-list.js @@ -0,0 +1,15 @@ +/* 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("devtools/shared/protocol"); +const { breakpointListSpec } = require("devtools/shared/specs/breakpoint-list"); + +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..272bd802cf --- /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("devtools/shared/protocol"); +const { changesSpec } = require("devtools/shared/specs/changes"); + +/** + * 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..84f7511317 --- /dev/null +++ b/devtools/client/fronts/compatibility.js @@ -0,0 +1,16 @@ +/* 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("devtools/shared/protocol"); +const { compatibilitySpec } = require("devtools/shared/specs/compatibility"); + +class CompatibilityFront extends FrontClassWithSpec(compatibilitySpec) {} + +exports.CompatibilityFront = CompatibilityFront; +registerFront(CompatibilityFront); diff --git a/devtools/client/fronts/content-viewer.js b/devtools/client/fronts/content-viewer.js new file mode 100644 index 0000000000..a92c3f27c0 --- /dev/null +++ b/devtools/client/fronts/content-viewer.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 { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); +const { contentViewerSpec } = require("devtools/shared/specs/content-viewer"); + +/** + * The corresponding Front object for the ContentViewer actor. + */ +class ContentViewerFront extends FrontClassWithSpec(contentViewerSpec) { + 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 = "contentViewerActor"; + } +} + +exports.ContentViewerFront = ContentViewerFront; +registerFront(ContentViewerFront); diff --git a/devtools/client/fronts/css-properties.js b/devtools/client/fronts/css-properties.js new file mode 100644 index 0000000000..3c229770c3 --- /dev/null +++ b/devtools/client/fronts/css-properties.js @@ -0,0 +1,317 @@ +/* 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("devtools/shared/protocol"); +const { cssPropertiesSpec } = require("devtools/shared/specs/css-properties"); + +loader.lazyRequireGetter( + this, + "cssColors", + "devtools/shared/css/color-db", + true +); +loader.lazyRequireGetter( + this, + "CSS_PROPERTIES_DB", + "devtools/shared/css/properties-db", + true +); +loader.lazyRequireGetter( + this, + "CSS_TYPES", + "devtools/shared/css/constants", + 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 FIRST_CHAR = ["[_a-z]", NON_ASCII, ESCAPE].join("|"); +var TRAILING_CHAR = ["[_a-z0-9-]", NON_ASCII, ESCAPE].join("|"); +var IS_VARIABLE_TOKEN = new RegExp( + `^--(${FIRST_CHAR})(${TRAILING_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; + + // supported feature + this.cssColor4ColorFunction = hasFeature( + db.supportedFeature, + "css-color-4-color-function" + ); + + this.isKnown = this.isKnown.bind(this); + this.isInherited = this.isInherited.bind(this); + this.supportsType = this.supportsType.bind(this); + this.supportsCssColor4ColorFunction = this.supportsCssColor4ColorFunction.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 []; + }, + + /** + * Checking for the css-color-4 color function support. + * + * @return {Boolean} Return true if the server supports css-color-4 color function. + */ + supportsCssColor4ColorFunction() { + return this.cssColor4ColorFunction; + }, +}; + +/** + * Check that this is a CSS variable. + * + * @param {String} input + * @return {Boolean} + */ +function isCssVariable(input) { + return !!input.match(IS_VARIABLE_TOKEN); +} + +/** + * Query the feature supporting status in the featureSet. + * + * @param {Hashmap} featureSet the feature set hashmap + * @param {String} feature the feature name string + * @return {Boolean} has the feature or not + */ +function hasFeature(featureSet, feature) { + if (feature in featureSet) { + return featureSet[feature]; + } + return false; +} + +/** + * 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); + + // If there is no supportedFeature in db, create an empty one. + if (!db.supportedFeature) { + db.supportedFeature = {}; + } + + 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/moz.build b/devtools/client/fronts/descriptors/moz.build new file mode 100644 index 0000000000..bf297b3dcb --- /dev/null +++ b/devtools/client/fronts/descriptors/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( + "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..0d0602920b --- /dev/null +++ b/devtools/client/fronts/descriptors/process.js @@ -0,0 +1,112 @@ +/* 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("devtools/shared/specs/descriptors/process"); +const { + BrowsingContextTargetFront, +} = require("devtools/client/fronts/targets/browsing-context"); +const { + ContentProcessTargetFront, +} = require("devtools/client/fronts/targets/content-process"); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); + +class ProcessDescriptorFront extends FrontClassWithSpec(processDescriptorSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + this.isParent = false; + this._processTargetFront = null; + this._targetFrontPromise = null; + this._client = client; + } + + form(json) { + this.id = json.id; + this.isParent = json.isParent; + 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 + // BrowsingContextTargetFront on the client side. + front = new BrowsingContextTargetFront(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; + } + + 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); + await targetFront.attach(); + } 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..a3e649bfd2 --- /dev/null +++ b/devtools/client/fronts/descriptors/tab.js @@ -0,0 +1,263 @@ +/* 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 Services = require("Services"); +const { tabDescriptorSpec } = require("devtools/shared/specs/descriptors/tab"); + +loader.lazyRequireGetter( + this, + "gDevTools", + "devtools/client/framework/devtools", + true +); +loader.lazyRequireGetter( + this, + "BrowsingContextTargetFront", + "devtools/client/fronts/targets/browsing-context", + true +); +loader.lazyRequireGetter( + this, + "TargetFactory", + "devtools/client/framework/target", + true +); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); + +/** + * 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 FrontClassWithSpec(tabDescriptorSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + this._client = client; + + // 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; + + // Flipped when creating dedicated tab targets for DevTools WebExtensions. + // See toolkit/components/extensions/ExtensionParent.jsm . + this.isDevToolsExtensionContext = false; + + this._onTargetDestroyed = this._onTargetDestroyed.bind(this); + this._handleTabEvent = this._handleTabEvent.bind(this); + } + + form(json) { + this.actorID = json.actor; + this._form = json; + this.traits = json.traits || {}; + } + + destroy() { + if (this.isLocalTab) { + this._teardownLocalTabListeners(); + } + super.destroy(); + } + + setLocalTab(localTab) { + this._localTab = localTab; + this._setupLocalTabListeners(); + } + + 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 + ); + } + + get isZombieTab() { + return this._form.isZombieTab; + } + + get outerWindowID() { + return this._form.outerWindowID; + } + + 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 BrowsingContextTargetFront(this._client, null, this); + + if (this.isLocalTab) { + front.shouldCloseClient = true; + } + + // 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); + front.on("target-destroyed", this._onTargetDestroyed); + 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); + } + } + } + + async getTarget() { + 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._createTabTarget(targetForm); + await targetFront.attach(); + } catch (e) { + console.log( + `Request to connect to TabDescriptor "${this.id}" failed: ${e}` + ); + } + this._targetFront = targetFront; + this._targetFrontPromise = null; + return targetFront; + })(); + return this._targetFrontPromise; + } + + /** + * Handle tabs events. + */ + async _handleTabEvent(event) { + switch (event.type) { + case "TabClose": + // Always destroy the toolbox opened for this local tab target. + // Toolboxes are no longer destroyed on target destruction. + // When the toolbox is in a Window Host, it won't be removed from the + // DOM when the tab is closed. + const toolbox = gDevTools.getToolbox(this._targetFront); + // A few tests are using TargetFactory.forTab, but aren't spawning any + // toolbox. In this case, the toobox won't destroy the target, so we + // do it from here. But ultimately, the target should destroy itself + // from the actor side anyway. + 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() { + // Responsive design does a crazy dance around tabs and triggers + // remotenesschange events. But we should ignore them as at the end + // the content doesn't change its remoteness. + if (this.localTab.isResponsiveDesignMode) { + return; + } + + // The front that was created for DevTools page extension does not have corresponding toolbox. + if (this.isDevToolsExtensionContext) { + return; + } + + const toolbox = gDevTools.getToolbox(this._targetFront); + + const targetSwitchingEnabled = Services.prefs.getBoolPref( + "devtools.target-switching.enabled", + false + ); + + // When target switching is enabled, everything is handled by the TargetList + // In a near future, this client side code should be replaced by actor code, + // notifying about new tab targets. + if (targetSwitchingEnabled) { + this.emit("remoteness-change", this._targetFront); + return; + } + + // Otherwise, if we don't support target switching, ensure the toolbox is destroyed. + // We need to wait for the toolbox destruction because the TargetFactory memoized the targets, + // and only cleans up the cache after the target is destroyed via toolbox destruction. + await toolbox.destroy(); + + // Fetch the new target for this tab + const newTarget = await TargetFactory.forTab(this.localTab, null); + + gDevTools.showToolbox(newTarget); + } +} + +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..4ed8cc53c5 --- /dev/null +++ b/devtools/client/fronts/descriptors/webextension.js @@ -0,0 +1,139 @@ +/* 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("devtools/shared/specs/descriptors/webextension"); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); +loader.lazyRequireGetter( + this, + "BrowsingContextTargetFront", + "devtools/client/fronts/targets/browsing-context", + true +); + +class WebExtensionDescriptorFront extends FrontClassWithSpec( + webExtensionDescriptorSpec +) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + this.client = client; + this.traits = {}; + } + + 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 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 isWebExtension() { + return this._form.isWebExtension; + } + + get manifestURL() { + return this._form.manifestURL; + } + + get name() { + return this._form.name; + } + + get temporarilyInstalled() { + return this._form.temporarilyInstalled; + } + + get url() { + return this._form.url; + } + + get warnings() { + return this._form.warnings; + } + + _createWebExtensionTarget(form) { + const front = new BrowsingContextTargetFront(this.conn, null, this); + front.form(form); + this.manage(front); + return front; + } + + /** + * Retrieve the BrowsingContextTargetFront 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); + await targetFront.attach(); + } 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..3e0ed5ed30 --- /dev/null +++ b/devtools/client/fronts/descriptors/worker.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 { Ci } = require("chrome"); +const { + workerDescriptorSpec, +} = require("devtools/shared/specs/descriptors/worker"); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); +const { TargetMixin } = require("devtools/client/fronts/targets/target-mixin"); + +class WorkerDescriptorFront extends TargetMixin( + FrontClassWithSpec(workerDescriptorSpec) +) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + this.traits = {}; + + // The actor sends a "close" event, which is translated to "worker-close" by + // the specification in order to not conflict with Target's "close" event. + // This event is similar to tabDetached and means that the worker is destroyed. + // So that we should destroy the target in order to significate that the target + // is no longer debuggable. + this.once("worker-close", this.destroy.bind(this)); + } + + 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; + } + + get name() { + return this.url.split("/").pop(); + } + + 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; + } + + async attach() { + // 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; + } + + const response = await super.attach(); + + if (this.isServiceWorker) { + this.registration = await this._getRegistrationIfActive(); + if (this.registration) { + await this.registration.preventShutdown(); + } + } + + this._url = response.url; + + if (this.isDestroyedOrBeingDestroyed()) { + return; + } + + const workerTargetForm = await super.getTarget(); + + // Set the console actor ID on the form to expose it to Target.attachConsole + // Set the ThreadActor on the target form so it is accessible by getFront + this.targetForm.consoleActor = workerTargetForm.consoleActor; + this.targetForm.threadActor = workerTargetForm.threadActor; + + if (this.isDestroyedOrBeingDestroyed()) { + return; + } + + await this.attachConsole(); + })(); + return this._attach; + } + + async detach() { + let response; + try { + response = 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"); + } + + return response; + } + + 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..b8101c10ad --- /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("devtools/shared/specs/device"); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); + +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/eventsource.js b/devtools/client/fronts/eventsource.js new file mode 100644 index 0000000000..0325be1a99 --- /dev/null +++ b/devtools/client/fronts/eventsource.js @@ -0,0 +1,73 @@ +/* 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("devtools/shared/protocol"); +const { eventSourceSpec } = require("devtools/shared/specs/eventsource"); + +/** + * A EventSourceFront is used as a front end for the EventSourceActor that is + * created on the server, hiding implementation details. + */ +class EventSourceFront extends FrontClassWithSpec(eventSourceSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + this._onEventSourceConnectionClosed = this._onEventSourceConnectionClosed.bind( + this + ); + this._onEventReceived = this._onEventReceived.bind(this); + + // Attribute name from which to retrieve the actorID + // out of the target actor's form + this.formAttributeName = "eventSourceActor"; + + this.on( + "serverEventSourceConnectionClosed", + this._onEventSourceConnectionClosed + ); + this.on("serverEventReceived", this._onEventReceived); + } + + /** + * Close the EventSourceFront. + */ + destroy() { + this.off("serverEventSourceConnectionClosed"); + this.off("serverEventReceived"); + return super.destroy(); + } + + /** + * The "eventSourceConnectionClosed" message type handler. We redirect any message to + * the UI for displaying. + * + * @private + * @param number httpChannelId + */ + async _onEventSourceConnectionClosed(httpChannelId) { + this.emit("eventSourceConnectionClosed", httpChannelId); + } + + /** + * The "eventReceived" message type handler. We redirect any message to + * the UI for displaying. + * + * @private + * @param string httpChannelId + * Channel ID of the eventSource connection. + * @param object data + * The data received from the server. + */ + async _onEventReceived(httpChannelId, data) { + this.emit("eventReceived", httpChannelId, data); + } +} + +exports.EventSourceFront = EventSourceFront; +registerFront(EventSourceFront); diff --git a/devtools/client/fronts/frame.js b/devtools/client/fronts/frame.js new file mode 100644 index 0000000000..d194bbb2c3 --- /dev/null +++ b/devtools/client/fronts/frame.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 { frameSpec } = require("devtools/shared/specs/frame"); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); + +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; + } + + getWebConsoleFront() { + return this.targetFront.getFront("console"); + } +} + +module.exports = FrameFront; +registerFront(FrameFront); diff --git a/devtools/client/fronts/framerate.js b/devtools/client/fronts/framerate.js new file mode 100644 index 0000000000..385d0b3622 --- /dev/null +++ b/devtools/client/fronts/framerate.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 { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); +const { framerateSpec } = require("devtools/shared/specs/framerate"); + +/** + * The corresponding Front object for the FramerateActor. + */ +class FramerateFront extends FrontClassWithSpec(framerateSpec) { + 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 = "framerateActor"; + } +} + +exports.FramerateFront = FramerateFront; +registerFront(FramerateFront); diff --git a/devtools/client/fronts/highlighters.js b/devtools/client/fronts/highlighters.js new file mode 100644 index 0000000000..2f15d94128 --- /dev/null +++ b/devtools/client/fronts/highlighters.js @@ -0,0 +1,49 @@ +/* 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("devtools/shared/protocol"); +const { customHighlighterSpec } = require("devtools/shared/specs/highlighters"); +const { safeAsyncMethod } = require("devtools/shared/async-utils"); + +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..d55f83bd67 --- /dev/null +++ b/devtools/client/fronts/inspector.js @@ -0,0 +1,215 @@ +/* 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 Services = require("Services"); +const Telemetry = require("devtools/client/shared/telemetry"); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol.js"); +const { inspectorSpec } = require("devtools/shared/specs/inspector"); + +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(); + } + + // async initialization + async initialize() { + if (this.initialized) { + return this.initialized; + } + + 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() { + this._compatibility = 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) { + await super.pickColorFromPage(options); + 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 BrowsingContextTarget. + // Get the target for this remote frame element + const { descriptorFront } = this.targetFront; + + // Tab and Process Descriptors expose a Watcher, which should be used to + // fetch the node's target. + let target; + if (descriptorFront && descriptorFront.traits.watcher) { + const watcherFront = await descriptorFront.getWatcher(); + target = await watcherFront.getBrowsingContextTarget(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..14b96b7842 --- /dev/null +++ b/devtools/client/fronts/inspector/rule-rewriter.js @@ -0,0 +1,750 @@ +/* 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 promise = require("promise"); +const { getCSSLexer } = require("devtools/shared/css/lexer"); +const { + COMMENT_PARSING_HEURISTIC_BYPASS_CHAR, + escapeCSSComment, + parseNamedDeclarations, + unescapeCSSComment, +} = require("devtools/shared/css/parsing-utils"); + +loader.lazyRequireGetter( + this, + ["getIndentationFromPrefs", "getIndentationFromString"], + "devtools/shared/indentation", + 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: function(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: function(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: function(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: function(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 > 0) { + 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: function(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: function(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: function(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: function(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: function(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. + */ + getDefaultIndentation: async function() { + if (!this.rule.parentStyleSheet) { + return null; + } + + if (this.rule.parentStyleSheet.resourceId) { + 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); + } + + return this.rule.parentStyleSheet.guessIndentation(); + }, + + /** + * 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 > 0) { + 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: function(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: function(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: function(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: function(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: function() { + 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: function() { + 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..ca1cddb400 --- /dev/null +++ b/devtools/client/fronts/layout.js @@ -0,0 +1,169 @@ +/* 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("devtools/shared/protocol"); +const { + flexboxSpec, + flexItemSpec, + gridSpec, + layoutSpec, +} = require("devtools/shared/specs/layout"); + +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) { + /** + * Get the WalkerFront instance that owns this LayoutFront. + */ + get walkerFront() { + return this.parentFront; + } + + getAllGrids() { + 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..9c643ffb4e --- /dev/null +++ b/devtools/client/fronts/manifest.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 { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); +const { manifestSpec } = require("devtools/shared/specs/manifest"); + +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/media-rule.js b/devtools/client/fronts/media-rule.js new file mode 100644 index 0000000000..48f23aa61e --- /dev/null +++ b/devtools/client/fronts/media-rule.js @@ -0,0 +1,54 @@ +/* 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("devtools/shared/protocol"); +const { mediaRuleSpec } = require("devtools/shared/specs/media-rule"); + +/** + * Corresponding client-side front for a MediaRuleActor. + */ +class MediaRuleFront extends FrontClassWithSpec(mediaRuleSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + this._onMatchesChange = this._onMatchesChange.bind(this); + this.on("matches-change", this._onMatchesChange); + } + + _onMatchesChange(matches) { + this._form.matches = matches; + } + + form(form) { + this.actorID = form.actor; + this._form = form; + } + + get mediaText() { + return this._form.mediaText; + } + get conditionText() { + return this._form.conditionText; + } + get matches() { + return this._form.matches; + } + get line() { + return this._form.line || -1; + } + get column() { + return this._form.column || -1; + } + get parentStyleSheet() { + return this.conn.getFrontByID(this._form.parentStyleSheet); + } +} + +exports.MediaRuleFront = MediaRuleFront; +registerFront(MediaRuleFront); diff --git a/devtools/client/fronts/memory.js b/devtools/client/fronts/memory.js new file mode 100644 index 0000000000..aa2bda6ec9 --- /dev/null +++ b/devtools/client/fronts/memory.js @@ -0,0 +1,117 @@ +/* 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("devtools/shared/specs/memory"); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); + +loader.lazyRequireGetter( + this, + "FileUtils", + "resource://gre/modules/FileUtils.jsm", + true +); +loader.lazyRequireGetter( + this, + "HeapSnapshotFileUtils", + "devtools/shared/heapsnapshot/HeapSnapshotFileUtils" +); + +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 FileUtils.File(outFilePath); + const outFileStream = 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); + + 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..1f808c21d7 --- /dev/null +++ b/devtools/client/fronts/moz.build @@ -0,0 +1,60 @@ +# -*- 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", + "breakpoint-list.js", + "changes.js", + "compatibility.js", + "content-viewer.js", + "css-properties.js", + "device.js", + "eventsource.js", + "frame.js", + "framerate.js", + "highlighters.js", + "inspector.js", + "layout.js", + "manifest.js", + "media-rule.js", + "memory.js", + "network-content.js", + "network-parent.js", + "node.js", + "object.js", + "page-style.js", + "perf.js", + "performance-recording.js", + "performance.js", + "preference.js", + "property-iterator.js", + "reflow.js", + "responsive.js", + "root.js", + "screenshot.js", + "source.js", + "storage.js", + "string.js", + "style-rule.js", + "style-sheet.js", + "style-sheets.js", + "symbol-iterator.js", + "thread.js", + "walker.js", + "watcher.js", + "webconsole.js", + "websocket.js", +) diff --git a/devtools/client/fronts/network-content.js b/devtools/client/fronts/network-content.js new file mode 100644 index 0000000000..fee47c7429 --- /dev/null +++ b/devtools/client/fronts/network-content.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("devtools/shared/protocol"); +const { networkContentSpec } = require("devtools/shared/specs/network-content"); + +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..7b918e5509 --- /dev/null +++ b/devtools/client/fronts/network-parent.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("devtools/shared/protocol"); +const { networkParentSpec } = require("devtools/shared/specs/network-parent"); + +/** + * 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..261e2aaf9c --- /dev/null +++ b/devtools/client/fronts/node.js @@ -0,0 +1,545 @@ +/* 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 promise = require("promise"); +const { + FrontClassWithSpec, + types, + registerFront, +} = require("devtools/shared/protocol.js"); +const { nodeSpec, nodeListSpec } = require("devtools/shared/specs/node"); +const { SimpleStringFront } = require("devtools/client/fronts/string"); +const Services = require("Services"); + +loader.lazyRequireGetter( + this, + "nodeConstants", + "devtools/shared/dom-node-constants" +); + +const BROWSER_TOOLBOX_FISSION_ENABLED = Services.prefs.getBoolPref( + "devtools.browsertoolbox.fission", + false +); + +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; + } + + /** + * 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 parentNodeFront = ctx + .marshallPool() + .ensureDOMNodeFront(form.parent); + this.reparent(parentNodeFront); + } + + if (form.host) { + this.host = ctx.marshallPool().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; + } + + /** + * 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 className() { + return this.getAttribute("class") || ""; + } + + get hasChildren() { + return this._form.numChildren > 0; + } + get numChildren() { + return this._form.numChildren; + } + get remoteFrame() { + return BROWSER_TOOLBOX_FISSION_ENABLED && this._form.remoteFrame; + } + 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("devtools/server/devtools-server"); + 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 connectToRemoteFrame() { + if (!this.remoteFrame) { + console.warn("Tried to open remote connection to an invalid frame."); + return null; + } + if (this._remoteFrameTarget && !this._remoteFrameTarget.isDestroyed()) { + return this._remoteFrameTarget; + } + + // Get the target for this remote frame element + this._remoteFrameTarget = await this.targetFront.getBrowsingContextTarget( + this._form.browsingContextID + ); + return this._remoteFrameTarget; + } +} + +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..774538ec85 --- /dev/null +++ b/devtools/client/fronts/object.js @@ -0,0 +1,441 @@ +/* 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("devtools/shared/specs/object"); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); +const { LongStringFront } = require("devtools/client/fronts/string"); + +/** + * 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 names of a function's formal parameters. + */ + getParameterNames() { + if (this._grip.class !== "Function") { + console.error("getParameterNames is only valid for function grips."); + return null; + } + return super.parameterNames(); + } + + /** + * Request the names of the properties defined on the object and not its + * prototype. + */ + getOwnPropertyNames() { + return super.ownPropertyNames(); + } + + /** + * 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 ( + !["Map", "WeakMap", "Set", "WeakSet", "Storage"].includes( + this._grip.class + ) + ) { + console.error( + "enumEntries is only valid for Map/Set/Storage-like grips." + ); + 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; + } + + /** + * 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 display string of the object. + */ + getDisplayString() { + return super.displayString(); + } + + /** + * 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 ResourceWatcher) + 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 + ); + } + } + } + } +} + +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..7157065452 --- /dev/null +++ b/devtools/client/fronts/page-style.js @@ -0,0 +1,121 @@ +/* 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("devtools/shared/protocol"); +const { pageStyleSpec } = require("devtools/shared/specs/page-style"); + +/** + * 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..1a9b3ae9f6 --- /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("devtools/shared/protocol"); +const { perfSpec } = require("devtools/shared/specs/perf"); + +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/performance-recording.js b/devtools/client/fronts/performance-recording.js new file mode 100644 index 0000000000..93015e8df1 --- /dev/null +++ b/devtools/client/fronts/performance-recording.js @@ -0,0 +1,171 @@ +/* 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("devtools/shared/protocol"); +const { + performanceRecordingSpec, +} = require("devtools/shared/specs/performance-recording"); + +loader.lazyRequireGetter( + this, + "PerformanceIO", + "devtools/client/performance/modules/io" +); +loader.lazyRequireGetter( + this, + "PerformanceRecordingCommon", + "devtools/shared/performance/recording-common", + true +); +loader.lazyRequireGetter( + this, + "RecordingUtils", + "devtools/shared/performance/recording-utils" +); + +/** + * This can be used on older Profiler implementations, but the methods cannot + * be changed -- you must introduce a new method, and detect the server. + */ +class PerformanceRecordingFront extends FrontClassWithSpec( + performanceRecordingSpec +) { + form(form) { + this.actorID = form.actor; + this._form = form; + this._configuration = form.configuration; + this._startingBufferStatus = form.startingBufferStatus; + this._console = form.console; + this._label = form.label; + this._startTime = form.startTime; + this._localStartTime = form.localStartTime; + this._recording = form.recording; + this._completed = form.completed; + this._duration = form.duration; + + if (form.finalizedData) { + this._profile = form.profile; + this._systemHost = form.systemHost; + this._systemClient = form.systemClient; + } + + // Sort again on the client side if we're using realtime markers and the recording + // just finished. This is because GC/Compositing markers can come into the array out + // of order with the other markers, leading to strange collapsing in waterfall view. + if (this._completed && !this._markersSorted) { + this._markers = this._markers.sort((a, b) => a.start > b.start); + this._markersSorted = true; + } + } + + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + this._markers = []; + this._frames = []; + this._memory = []; + this._ticks = []; + this._allocations = { sites: [], timestamps: [], frames: [], sizes: [] }; + } + + /** + * Saves the current recording to a file. + * + * @param nsIFile file + * The file to stream the data into. + */ + exportRecording(file) { + const recordingData = this.getAllData(); + return PerformanceIO.saveRecordingToFile(recordingData, file); + } + + /** + * Fired whenever the PerformanceFront emits markers, memory or ticks. + */ + _addTimelineData(eventName, data) { + const config = this.getConfiguration(); + + switch (eventName) { + // Accumulate timeline markers into an array. Furthermore, the timestamps + // do not have a zero epoch, so offset all of them by the start time. + case "markers": { + if (!config.withMarkers) { + break; + } + const { markers } = data; + RecordingUtils.offsetMarkerTimes(markers, this._startTime); + RecordingUtils.pushAll(this._markers, markers); + break; + } + // Accumulate stack frames into an array. + case "frames": { + if (!config.withMarkers) { + break; + } + const { frames } = data; + RecordingUtils.pushAll(this._frames, frames); + break; + } + // Accumulate memory measurements into an array. Furthermore, the timestamp + // does not have a zero epoch, so offset it by the actor's start time. + case "memory": { + if (!config.withMemory) { + break; + } + const { delta, measurement } = data; + this._memory.push({ + delta: delta - this._startTime, + value: measurement.total / 1024 / 1024, + }); + break; + } + // Save the accumulated refresh driver ticks. + case "ticks": { + if (!config.withTicks) { + break; + } + const { timestamps } = data; + this._ticks = timestamps; + break; + } + // Accumulate allocation sites into an array. + case "allocations": { + if (!config.withAllocations) { + break; + } + const { + allocations: sites, + allocationsTimestamps: timestamps, + allocationSizes: sizes, + frames, + } = data; + + RecordingUtils.offsetAndScaleTimestamps(timestamps, this._startTime); + RecordingUtils.pushAll(this._allocations.sites, sites); + RecordingUtils.pushAll(this._allocations.timestamps, timestamps); + RecordingUtils.pushAll(this._allocations.frames, frames); + RecordingUtils.pushAll(this._allocations.sizes, sizes); + break; + } + } + } + + toString() { + return "[object PerformanceRecordingFront]"; + } +} + +// PerformanceRecordingFront also needs to inherit from PerformanceRecordingCommon +// but as ES classes don't support multiple inheritance, we are overriding the +// prototype with PerformanceRecordingCommon methods. +Object.defineProperties( + PerformanceRecordingFront.prototype, + Object.getOwnPropertyDescriptors(PerformanceRecordingCommon) +); + +exports.PerformanceRecordingFront = PerformanceRecordingFront; +registerFront(PerformanceRecordingFront); diff --git a/devtools/client/fronts/performance.js b/devtools/client/fronts/performance.js new file mode 100644 index 0000000000..4233efa52e --- /dev/null +++ b/devtools/client/fronts/performance.js @@ -0,0 +1,167 @@ +/* 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 { Cu } = require("chrome"); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); +const { + PerformanceRecordingFront, +} = require("devtools/client/fronts/performance-recording"); +const { performanceSpec } = require("devtools/shared/specs/performance"); + +loader.lazyRequireGetter( + this, + "PerformanceIO", + "devtools/client/performance/modules/io" +); +loader.lazyRequireGetter(this, "getSystemInfo", "devtools/shared/system", true); + +class PerformanceFront extends FrontClassWithSpec(performanceSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + this._queuedRecordings = []; + this._onRecordingStartedEvent = this._onRecordingStartedEvent.bind(this); + this.flushQueuedRecordings = this.flushQueuedRecordings.bind(this); + + this.before("profiler-status", this._onProfilerStatus.bind(this)); + this.before("timeline-data", this._onTimelineEvent.bind(this)); + this.on("recording-started", this._onRecordingStartedEvent); + + // Attribute name from which to retrieve the actorID out of the target actor's form + this.formAttributeName = "performanceActor"; + } + + async initialize() { + await this.connect(); + } + + /** + * Conenct to the server, and handle once-off tasks like storing traits + * or system info. + */ + async connect() { + const systemClient = await getSystemInfo(); + const { traits } = await super.connect({ systemClient }); + this._traits = traits; + + return this._traits; + } + + /** + * Called when the "recording-started" event comes from the PerformanceFront. + * this is only used to queue up observed recordings before the performance tool can + * handle them, which will only occur when `console.profile()` recordings are started + * before the tool loads. + */ + async _onRecordingStartedEvent(recording) { + this._queuedRecordings.push(recording); + } + + flushQueuedRecordings() { + this.off("recording-started", this._onPerformanceFrontEvent); + const recordings = this._queuedRecordings; + this._queuedRecordings = []; + return recordings; + } + + get traits() { + if (!this._traits) { + Cu.reportError( + "Cannot access traits of PerformanceFront before " + + "calling `connect()`." + ); + } + return this._traits; + } + + /** + * Pass in a PerformanceRecording and get a normalized value from 0 to 1 of how much + * of this recording's lifetime remains without being overwritten. + * + * @param {PerformanceRecording} recording + * @return {number?} + */ + getBufferUsageForRecording(recording) { + if (!recording.isRecording()) { + return void 0; + } + const { + position: currentPosition, + totalSize, + generation: currentGeneration, + } = this._currentBufferStatus; + const { + position: origPosition, + generation: origGeneration, + } = recording.getStartingBufferStatus(); + + const normalizedCurrent = + totalSize * (currentGeneration - origGeneration) + currentPosition; + const percent = (normalizedCurrent - origPosition) / totalSize; + + // Clamp between 0 and 1; can get negative percentage values when a new + // recording starts and the currentBufferStatus has not yet been updated. Rather + // than fetching another status update, just clamp to 0, and this will be updated + // on the next profiler-status event. + if (percent < 0) { + return 0; + } else if (percent > 1) { + return 1; + } + + return percent; + } + + /** + * Loads a recording from a file. + * + * @param {nsIFile} file + * The file to import the data from. + * @return {Promise<PerformanceRecordingFront>} + */ + importRecording(file) { + return PerformanceIO.loadRecordingFromFile(file).then(recordingData => { + const model = new PerformanceRecordingFront(); + model._imported = true; + model._label = recordingData.label || ""; + model._duration = recordingData.duration; + model._markers = recordingData.markers; + model._frames = recordingData.frames; + model._memory = recordingData.memory; + model._ticks = recordingData.ticks; + model._allocations = recordingData.allocations; + model._profile = recordingData.profile; + model._configuration = recordingData.configuration || {}; + model._systemHost = recordingData.systemHost; + model._systemClient = recordingData.systemClient; + return model; + }); + } + + /** + * Store profiler status when the position has been update so we can + * calculate recording's buffer percentage usage after emitting the event. + */ + _onProfilerStatus(data) { + this._currentBufferStatus = data; + } + + /** + * For all PerformanceRecordings that are recording, and needing realtime markers, + * apply the timeline data to the front PerformanceRecording (so we only have one event + * for each timeline data chunk as they could be shared amongst several recordings). + */ + _onTimelineEvent(type, data, recordings) { + for (const recording of recordings) { + recording._addTimelineData(type, data); + } + } +} + +exports.PerformanceFront = PerformanceFront; +registerFront(PerformanceFront); diff --git a/devtools/client/fronts/preference.js b/devtools/client/fronts/preference.js new file mode 100644 index 0000000000..ce77d0b248 --- /dev/null +++ b/devtools/client/fronts/preference.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 { preferenceSpec } = require("devtools/shared/specs/preference"); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); + +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/property-iterator.js b/devtools/client/fronts/property-iterator.js new file mode 100644 index 0000000000..dcbdf3e22e --- /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("devtools/shared/protocol"); +const { + propertyIteratorSpec, +} = require("devtools/shared/specs/property-iterator"); +const { + getAdHocFrontOrPrimitiveGrip, +} = require("devtools/client/fronts/object"); + +/** + * 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..1104e2f9ca --- /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("devtools/shared/specs/reflow"); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); + +/** + * 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..3eb9a625ef --- /dev/null +++ b/devtools/client/fronts/responsive.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 { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); +const { responsiveSpec } = require("devtools/shared/specs/responsive"); + +/** + * 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..4d04941333 --- /dev/null +++ b/devtools/client/fronts/root.js @@ -0,0 +1,325 @@ +/* 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 { Ci } = require("chrome"); +const { rootSpec } = require("devtools/shared/specs/root"); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); + +loader.lazyRequireGetter(this, "getFront", "devtools/shared/protocol", 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.isParent) { + return []; + } + const front = await processDescriptorFront.getTarget(); + if (!front) { + return []; + } + const response = await front.listWorkers(); + return response.workers; + }) + ); + + 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: + * - outerWindowID: used to match tabs in parent process + * - tabId: used to match tabs in child processes + * - tab: a reference to xul:tab element + * If nothing is specified, returns the actor for the currently + * selected tab. + */ + async getTab(filter) { + const packet = {}; + if (filter) { + if (typeof filter.outerWindowID == "number") { + packet.outerWindowID = filter.outerWindowID; + } else if (typeof filter.tabId == "number") { + packet.tabId = filter.tabId; + } else if ("tab" in filter) { + const browser = filter.tab.linkedBrowser; + if (browser.frameLoader.remoteTab) { + // Tabs in child process + packet.tabId = browser.frameLoader.remoteTab.tabId; + } else { + // <xul:browser> or <iframe mozbrowser> tabs in parent process + packet.outerWindowID = + browser.browsingContext.currentWindowGlobal.outerWindowId; + } + } 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); + + // 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.js b/devtools/client/fronts/screenshot.js new file mode 100644 index 0000000000..55fa9e4ef3 --- /dev/null +++ b/devtools/client/fronts/screenshot.js @@ -0,0 +1,29 @@ +/* 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("devtools/shared/specs/screenshot"); +const saveScreenshot = require("devtools/client/shared/save-screenshot"); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); + +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"; + } + + async captureAndSave(window, args) { + const screenshot = await this.capture(args); + return saveScreenshot(window, args, screenshot); + } +} + +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..c433f95f97 --- /dev/null +++ b/devtools/client/fronts/source.js @@ -0,0 +1,100 @@ +/* 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("devtools/shared/specs/source"); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); +const { ArrayBufferFront } = require("devtools/client/fronts/array-buffer"); + +/** + * 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: 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..390682e2a2 --- /dev/null +++ b/devtools/client/fronts/storage.js @@ -0,0 +1,35 @@ +/* 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("devtools/shared/protocol"); +const { childSpecs, storageSpec } = require("devtools/shared/specs/storage"); + +for (const childSpec of Object.values(childSpecs)) { + class ChildStorageFront extends FrontClassWithSpec(childSpec) { + form(form) { + this.actorID = form.actor; + this.hosts = form.hosts; + this.traits = form.traits || {}; + return null; + } + } + registerFront(ChildStorageFront); +} + +class StorageFront extends FrontClassWithSpec(storageSpec) { + 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 = "storageActor"; + } +} + +exports.StorageFront = StorageFront; +registerFront(StorageFront); diff --git a/devtools/client/fronts/string.js b/devtools/client/fronts/string.js new file mode 100644 index 0000000000..f9b42e260c --- /dev/null +++ b/devtools/client/fronts/string.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 { DevToolsServer } = require("devtools/server/devtools-server"); +const { + longStringSpec, + SimpleStringFront, +} = require("devtools/shared/specs/string"); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); + +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..fa4a03841c --- /dev/null +++ b/devtools/client/fronts/style-rule.js @@ -0,0 +1,335 @@ +/* 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("devtools/shared/protocol"); +const { styleRuleSpec } = require("devtools/shared/specs/style-rule"); +const promise = require("promise"); + +loader.lazyRequireGetter( + this, + "RuleRewriter", + "devtools/client/fronts/inspector/rule-rewriter" +); + +/** + * 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 || {}; + if (this._mediaText) { + this._mediaText = null; + } + } + + /** + * Ensure _form is updated when location-changed is emitted. + */ + _locationChangedPre(line, column) { + this._clearOriginalLocation(); + 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 media() { + return this._form.media; + } + get mediaText() { + if (!this._form.media) { + return null; + } + if (this._mediaText) { + return this._mediaText; + } + this._mediaText = this.media.join(", "); + return this._mediaText; + } + + get parentRule() { + return this.conn.getFrontByID(this._form.parentRule); + } + + get parentStyleSheet() { + const resourceWatcher = this.parentFront.resourceWatcher; + if (resourceWatcher) { + return resourceWatcher.getResourceById( + resourceWatcher.TYPES.STYLESHEET, + this._form.parentStyleSheet + ); + } + + return this.conn.getFrontByID(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, + }; + } + + _clearOriginalLocation() { + this._originalLocation = null; + } + + getOriginalLocation() { + if (this._originalLocation) { + return promise.resolve(this._originalLocation); + } + const parentSheet = this.parentStyleSheet; + if (!parentSheet) { + // This rule doesn't belong to a stylesheet so it is an inline style. + // Inline styles do not have any mediaText so we can return early. + return promise.resolve(this.location); + } + return parentSheet + .getOriginalLocation(this.line, this.column) + .then(({ fromSourceMap, source, line, column }) => { + const location = { + href: source, + line: line, + column: column, + mediaText: this.mediaText, + }; + if (fromSourceMap === false) { + location.source = this.parentStyleSheet; + } + if (!source) { + location.href = this.href; + } + this._originalLocation = location; + return location; + }); + } + + 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-sheet.js b/devtools/client/fronts/style-sheet.js new file mode 100644 index 0000000000..7f95e09ddb --- /dev/null +++ b/devtools/client/fronts/style-sheet.js @@ -0,0 +1,96 @@ +/* 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("devtools/shared/protocol"); +const { styleSheetSpec } = require("devtools/shared/specs/style-sheet"); +const promise = require("promise"); + +loader.lazyRequireGetter( + this, + ["getIndentationFromPrefs", "getIndentationFromString"], + "devtools/shared/indentation", + true +); + +/** + * StyleSheetFront is the client-side counterpart to a StyleSheetActor. + */ +class StyleSheetFront extends FrontClassWithSpec(styleSheetSpec) { + constructor(conn, targetFront, parentFront) { + super(conn, targetFront, parentFront); + + this._onPropertyChange = this._onPropertyChange.bind(this); + this.on("property-change", this._onPropertyChange); + } + + destroy() { + this.off("property-change", this._onPropertyChange); + super.destroy(); + } + + _onPropertyChange(property, value) { + this[property] = value; + } + + form(form) { + this.actorID = form.actor; + this.href = form.href; + this.nodeHref = form.nodeHref; + this.disabled = form.disabled; + this.title = form.title; + this.system = form.system; + this.styleSheetIndex = form.styleSheetIndex; + this.ruleCount = form.ruleCount; + this.sourceMapURL = form.sourceMapURL; + this._sourceMapBaseURL = form.sourceMapBaseURL; + } + + get isSystem() { + return this.system; + } + + get sourceMapBaseURL() { + // Handle backward-compat for servers that don't return sourceMapBaseURL. + if (this._sourceMapBaseURL === undefined) { + return this.href || this.nodeHref; + } + + return this._sourceMapBaseURL; + } + + set sourceMapBaseURL(sourceMapBaseURL) { + this._sourceMapBaseURL = sourceMapBaseURL; + } + + /** + * Get the indentation to use for edits to this style sheet. + * + * @return {Promise} A promise that will resolve to a string that + * should be used to indent a block in this style sheet. + */ + guessIndentation() { + const prefIndent = getIndentationFromPrefs(); + if (prefIndent) { + const { indentUnit, indentWithTabs } = prefIndent; + return promise.resolve(indentWithTabs ? "\t" : " ".repeat(indentUnit)); + } + + return async function() { + const longStr = await this.getText(); + const source = await longStr.string(); + + const { indentUnit, indentWithTabs } = getIndentationFromString(source); + + return indentWithTabs ? "\t" : " ".repeat(indentUnit); + }.bind(this)(); + } +} + +exports.StyleSheetFront = StyleSheetFront; +registerFront(StyleSheetFront); diff --git a/devtools/client/fronts/style-sheets.js b/devtools/client/fronts/style-sheets.js new file mode 100644 index 0000000000..bf8d3a9db5 --- /dev/null +++ b/devtools/client/fronts/style-sheets.js @@ -0,0 +1,35 @@ +/* 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("devtools/shared/protocol"); +const { styleSheetsSpec } = require("devtools/shared/specs/style-sheets"); + +/** + * 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..749deca7d5 --- /dev/null +++ b/devtools/client/fronts/symbol-iterator.js @@ -0,0 +1,54 @@ +/* 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("devtools/shared/protocol"); +const { symbolIteratorSpec } = require("devtools/shared/specs/symbol-iterator"); +const { + getAdHocFrontOrPrimitiveGrip, +} = require("devtools/client/fronts/object"); + +/** + * 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/targets/browsing-context.js b/devtools/client/fronts/targets/browsing-context.js new file mode 100644 index 0000000000..117ed076a1 --- /dev/null +++ b/devtools/client/fronts/targets/browsing-context.js @@ -0,0 +1,141 @@ +/* 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 { + browsingContextTargetSpec, +} = require("devtools/shared/specs/targets/browsing-context"); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); +const { TargetMixin } = require("devtools/client/fronts/targets/target-mixin"); + +class BrowsingContextTargetFront extends TargetMixin( + FrontClassWithSpec(browsingContextTargetSpec) +) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + // Cache the value of some target properties that are being returned by `attach` + // request and then keep them up-to-date in `reconfigure` request. + this.configureOptions = { + javascriptEnabled: null, + }; + + this._onTabNavigated = this._onTabNavigated.bind(this); + this._onFrameUpdate = this._onFrameUpdate.bind(this); + } + + form(json) { + this.actorID = json.actor; + this.browsingContextID = json.browsingContextID; + + // 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; + this._title = json.title; + this._url = 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.nativeConsoleAPI = packet.nativeConsoleAPI; + 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._url = packet.url; + this._title = packet.title; + } + + // 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); + } + } + + async attach() { + if (this._attach) { + return this._attach; + } + this._attach = (async () => { + // All Browsing context inherited target emit a few event that are being + // translated on the target class. Listen for them before attaching as they + // can start firing on attach call. + this.on("tabNavigated", this._onTabNavigated); + this.on("frameUpdate", this._onFrameUpdate); + + const response = await super.attach(); + + this.targetForm.threadActor = response.threadActor; + this.configureOptions.javascriptEnabled = response.javascriptEnabled; + this.traits = response.traits || {}; + + // xpcshell tests from devtools/server/tests/xpcshell/ are implementing + // fake BrowsingContextTargetActor which do not expose any console actor. + if (this.targetForm.consoleActor) { + await this.attachConsole(); + } + })(); + return this._attach; + } + + async reconfigure({ options }) { + const response = await super.reconfigure({ options }); + + if (typeof options.javascriptEnabled != "undefined") { + this.configureOptions.javascriptEnabled = options.javascriptEnabled; + } + + return response; + } + + async detach() { + try { + await super.detach(); + } catch (e) { + this.logDetachError(e, "browsing context"); + } + + // Remove listeners set in attach + this.off("tabNavigated", this._onTabNavigated); + this.off("frameUpdate", this._onFrameUpdate); + } + + destroy() { + const promise = super.destroy(); + + // As detach isn't necessarily called on target's destroy + // (it isn't for local tabs), ensure removing listeners set in attach. + this.off("tabNavigated", this._onTabNavigated); + this.off("frameUpdate", this._onFrameUpdate); + + return promise; + } +} + +exports.BrowsingContextTargetFront = BrowsingContextTargetFront; +registerFront(exports.BrowsingContextTargetFront); diff --git a/devtools/client/fronts/targets/content-process.js b/devtools/client/fronts/targets/content-process.js new file mode 100644 index 0000000000..65b9eafe91 --- /dev/null +++ b/devtools/client/fronts/targets/content-process.js @@ -0,0 +1,54 @@ +/* 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("devtools/shared/specs/targets/content-process"); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); +const { TargetMixin } = require("devtools/client/fronts/targets/target-mixin"); + +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; + } + + get name() { + return `Content Process (pid ${this.processID})`; + } + + attach() { + // All target actors have a console actor to attach. + // All but xpcshell test actors... which is using a ContentProcessTargetActor + if (this.targetForm.consoleActor) { + return this.attachConsole(); + } + return Promise.resolve(); + } + + 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..420b99e433 --- /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( + "browsing-context.js", + "content-process.js", + "target-mixin.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..937eabdbd1 --- /dev/null +++ b/devtools/client/fronts/targets/target-mixin.js @@ -0,0 +1,750 @@ +/* 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", "devtools/shared/protocol", true); +loader.lazyRequireGetter( + this, + "getThreadOptions", + "devtools/client/shared/thread-utils", + 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 BrowsingContextTarget: + * - 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); + + this._forceChrome = false; + + this.destroy = this.destroy.bind(this); + + this.threadFront = null; + + // This flag will be set to true from: + // - TabDescriptorFront getTarget(), for local tab targets + // - targetFromURL(), for local targets (about:debugging) + // - initToolbox(), for some test-only targets + this.shouldCloseClient = false; + + this._client = client; + + // Cache of already created targed-scoped fronts + // [typeName:string => Front instance] + this.fronts = new Map(); + + // `resource-available-form` events can be emitted by target actors before the + // ResourceWatcher could add event listeners. The target front will cache those + // events until the ResourceWatcher has added the listeners. + this._resourceCache = []; + this._onResourceAvailable = this._onResourceAvailable.bind(this); + // In order to avoid destroying the `_resourceCache`, we need to call `super.on()` + // instead of `this.on()`. + super.on("resource-available-form", this._onResourceAvailable); + + this._addListeners(); + } + + on(eventName, listener) { + if (eventName === "resource-available-form" && this._resourceCache) { + this.off("resource-available-form", this._onResourceAvailable); + for (const cache of this._resourceCache) { + listener(cache); + } + this._resourceCache = null; + } + + 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 the descriptor front for this target. + * + * TODO: Should be removed. This is misleading as only the top level target should have a descriptor. + * This will return null for targets created by the Watcher actor and will still be defined + * by targets created by RootActor methods (listSomething methods). + */ + get descriptorFront() { + if (this.isDestroyed()) { + // If the target was already destroyed, parentFront will be null. + return null; + } + + if (this.parentFront.typeName.endsWith("Descriptor")) { + return this.parentFront; + } + return null; + } + + get targetType() { + return this._targetType; + } + + get isTopLevel() { + return this._isTopLevel; + } + + setTargetType(type) { + this._targetType = type; + } + + setIsTopLevel(isTopLevel) { + this._isTopLevel = isTopLevel; + } + + /** + * Get the top level WatcherFront for this target. + * + * The targets should all ultimately be managed by a unique Watcher actor, + * created from the unique Descriptor actor which is passed to the Toolbox. + * For now, the top level target is still created by the top level Descriptor, + * but it is also meant to be created by the Watcher. + * + * @return {TargetMixin} the parent target. + */ + getWatcherFront() { + // All additional frame targets are spawn by the WatcherActor and are managed by it. + if (this.parentFront.typeName == "watcher") { + return this.parentFront; + } + + // Otherwise, for top level targets, the parent front is a Descriptor, from which we can retrieve the Watcher. + // TODO: top level target should also be exposed by the Watcher actor, like any target. + if ( + this.parentFront.typeName.endsWith("Descriptor") && + this.parentFront.traits && + this.parentFront.traits.watcher + ) { + return this.parentFront.getWatcher(); + } + + // For WebExtension, the descriptor doesn't expose a watcher yet (See Bug 1675456). + return null; + } + + /** + * Get the immediate parent target for this target. + * + * @return {TargetMixin} the parent target. + */ + async getParentTarget() { + // We now support frames watching via watchTargets for Tab and Process descriptors. + const watcherFront = await this.getWatcherFront(); + if (watcherFront) { + // Safety check, in theory all watcher should support frames. We should be able + // to remove this as part of Bug 1680280. + if (watcherFront.traits.frame) { + // Retrieve the Watcher, which manage all the targets and should already have a reference to + // to the parent target. + return watcherFront.getParentBrowsingContextTarget( + this.browsingContextID + ); + } + return null; + } + + if (this.parentFront.getParentTarget) { + return this.parentFront.getParentTarget(); + } + + // Other targets, like WebExtensions, don't have a Watcher yet, nor do expose `getParentTarget`. + // We can't fetch parent target yet for these targets. + return null; + } + + /** + * Get the target for the given Browsing Context ID. + * + * @return {TargetMixin} the requested target. + */ + async getBrowsingContextTarget(browsingContextID) { + // Tab and Process Descriptors expose a Watcher, which is creating the + // targets and should be used to fetch any. + const watcherFront = await this.getWatcherFront(); + if (watcherFront) { + // Safety check, in theory all watcher should support frames. + if (watcherFront.traits.frame) { + return watcherFront.getBrowsingContextTarget(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 getBrowsingContextTarget 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 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 isLocalTab() { + return !!this.descriptorFront?.isLocalTab; + } + + get localTab() { + return this.descriptorFront?.localTab || null; + } + + // Get a promise of the RootActor's form + get root() { + return this.client.mainRoot.rootForm; + } + + // Get a Front for a target-scoped actor. + // i.e. an actor served by RootActor.listTabs or RootActorActor.getTab requests + async getFront(typeName) { + 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 we are debugging content document + // or if we are debugging chrome stuff. + // Allows to controls which features are available against + // a chrome or a content document. + get chrome() { + return ( + this.isAddon || + this.isContentProcess || + this.isParentProcess || + this.isWindowTarget || + this._forceChrome + ); + } + + forceChrome() { + this._forceChrome = true; + } + + // Tells us if the related actor implements BrowsingContextTargetActor + // interface and requires to call `attach` request before being used and + // `detach` during cleanup. + get isBrowsingContext() { + return this.typeName === "browsingContextTarget"; + } + + get name() { + if (this.isAddon || this.isContentProcess) { + return this.targetForm.name; + } + return this.title; + } + + get title() { + return this._title || this.url; + } + + get url() { + return this._url; + } + + get isAddon() { + return this.isLegacyAddon || this.isWebExtension; + } + + get isWorkerTarget() { + // XXX Remove the check on `workerDescriptor` as part of Bug 1667404. + return ( + this.typeName === "workerTarget" || this.typeName === "workerDescriptor" + ); + } + + get isLegacyAddon() { + return !!( + this.targetForm && + this.targetForm.actor && + this.targetForm.actor.match(/conn\d+\.addon(Target)?\d+/) + ); + } + + 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+/) + ); + } + + get isWindowTarget() { + return !!( + this.targetForm && + this.targetForm.actor && + this.targetForm.actor.match(/conn\d+\.chromeWindowTarget\d+/) + ); + } + + get isMultiProcess() { + return !this.window; + } + + 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; + } + } + + // Attach the console actor + async attachConsole() { + const consoleFront = await this.getFront("console"); + + if (this.isDestroyedOrBeingDestroyed()) { + return; + } + + // Calling startListeners will populate the traits as it's the first request we + // make to the front. + await consoleFront.startListeners([]); + + this._onInspectObject = packet => this.emit("inspect-object", packet); + this.removeOnInspectObjectListener = consoleFront.on( + "inspectObject", + this._onInspectObject + ); + } + + /** + * 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 {TargetList} targetList + * @returns {Promise} A promise that resolves once the thread is attached and resumed. + */ + attachAndInitThread(targetList) { + if (this._onThreadInitialized) { + return this._onThreadInitialized; + } + + this._onThreadInitialized = this._attachAndInitThread(targetList); + 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 {TargetList} targetList + * @returns {Promise} A promise that resolves once the thread is attached and resumed. + */ + async _attachAndInitThread(targetList) { + // If the target is destroyed or soon will be, don't go further + if (this.isDestroyedOrBeingDestroyed()) { + return; + } + + // WorkerTargetFront don't have an attach function as the related console and thread + // actors are created right away (from devtools/server/startup/worker.js) + if (this.attach) { + await this.attach(); + } + + const isBrowserToolbox = targetList.targetFront.isParentProcess; + const isNonTopLevelFrameTarget = + !this.isTopLevel && this.targetType === targetList.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; + } + + const options = await getThreadOptions(); + // If the target is destroyed or soon will be, don't go further + if (this.isDestroyedOrBeingDestroyed()) { + return; + } + const threadFront = await this.attachThread(options); + + // @backward-compat { version 86 } ThreadActor.attach no longer pause the thread, + // so that we no longer have to resume. + // Once 86 is in release, we can remove the rest of this method. + if (this.getTrait("noPauseOnThreadActorAttach")) { + return; + } + try { + if (this.isDestroyedOrBeingDestroyed() || threadFront.isDestroyed()) { + return; + } + await threadFront.resume(); + } catch (ex) { + if (ex.error === "wrongOrder") { + targetList.emit("target-thread-wrong-order-on-resume"); + } else { + throw ex; + } + } + } + + 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"); + if ( + this.isDestroyedOrBeingDestroyed() || + this.threadFront.isDestroyed() + ) { + return this.threadFront; + } + + await this.threadFront.attach(options); + + return this.threadFront; + } + + /** + * Setup listeners. + */ + _addListeners() { + this.client.on("closed", this.destroy); + + // `tabDetached` is sent by all target targets types: frame, process and workers. + // This is sent when the target is destroyed: + // * the target context destroys itself (the tab closes for ex, or the worker shuts down) + // in this case, it may be the connector that send this event in the name of the target actor + // * the target actor is destroyed, but the target context stays up and running (for ex, when we call Watcher.unwatchTargets) + // * the DevToolsServerConnection closes (client closes the connection) + this.on("tabDetached", this.destroy); + } + + /** + * Teardown listeners. + */ + _removeListeners() { + // Remove listeners set in _addListeners + if (this.client) { + this.client.off("closed", this.destroy); + } + this.off("tabDetached", this.destroy); + + // Remove listeners set in attachConsole + if (this.removeOnInspectObjectListener) { + this.removeOnInspectObjectListener(); + this.removeOnInspectObjectListener = null; + } + } + + 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() { + // Before taking any action, notify listeners that destruction is imminent. + this.emit("close"); + + // 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._removeListeners(); + + this.threadFront = null; + + if (this.shouldCloseClient) { + try { + await this._client.close(); + } catch (e) { + // Ignore any errors while closing, since there is not much that can be done + // at this point. + console.warn("Error while closing client:", e); + } + + // 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. + } else 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); + } + } + + // 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"); + + // 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; + + // All target front subclasses set this variable in their `attach` method. + // None of them overload destroy, so clean this up from here. + this._attach = null; + + this._title = null; + this._url = null; + } + + _onResourceAvailable(resources) { + if (this._resourceCache) { + this._resourceCache.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.traits.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.traits.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/worker.js b/devtools/client/fronts/targets/worker.js new file mode 100644 index 0000000000..80c875fa5c --- /dev/null +++ b/devtools/client/fronts/targets/worker.js @@ -0,0 +1,29 @@ +/* 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("devtools/shared/specs/targets/worker"); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); +const { TargetMixin } = require("devtools/client/fronts/targets/target-mixin"); + +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.js b/devtools/client/fronts/thread.js new file mode 100644 index 0000000000..a597cd9b5a --- /dev/null +++ b/devtools/client/fronts/thread.js @@ -0,0 +1,307 @@ +/* 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("devtools/client/constants"); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); + +const { threadSpec } = require("devtools/shared/specs/thread"); + +loader.lazyRequireGetter( + this, + "ObjectFront", + "devtools/client/fronts/object", + true +); +loader.lazyRequireGetter(this, "FrameFront", "devtools/client/fronts/frame"); +loader.lazyRequireGetter( + this, + "SourceFront", + "devtools/client/fronts/source", + 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. + // @backward-compat { version 86 } ThreadActor.attach no longer pauses the thread, + // so that the default state is "attached" by default. + if (this.targetFront.getTrait("noPauseOnThreadActorAttach")) { + this._state = "attached"; + } else { + this._state = "paused"; + } + 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; + } + + getWebconsoleFront() { + return this.targetFront.getFront("console"); + } + + _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 }; + } + + /** + * attach to the thread actor. + */ + async attach(options) { + const noPauseOnThreadActorAttach = this.targetFront.getTrait( + "noPauseOnThreadActorAttach" + ); + const onPaused = noPauseOnThreadActorAttach ? null : this.once("paused"); + await super.attach(options); + // @backward-compat { version 86 } ThreadActor.attach no longer pause the thread, + // so that we shouldn't wait for the paused event, + // since it won't be emitted anymore. + if (!noPauseOnThreadActorAttach) { + await onPaused; + } + } + + /** + * 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/walker.js b/devtools/client/fronts/walker.js new file mode 100644 index 0000000000..a0f6d71041 --- /dev/null +++ b/devtools/client/fronts/walker.js @@ -0,0 +1,551 @@ +/* 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("devtools/shared/protocol.js"); +const { walkerSpec } = require("devtools/shared/specs/walker"); +const { safeAsyncMethod } = require("devtools/shared/async-utils"); + +/** + * 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 ResourceWatcher + // 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: 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; + } + + /* + * Incrementally search the document for a given string. + * For modern servers, results will be searched with using the WalkerActor + * `search` function (includes tag names, attributes, and text contents). + * Only 1 result is sent back, and calling the method again with the same + * query will send the next result. When there are no more results to be sent + * back, null is sent. + * @param {String} query + * @param {Object} options + * - "reverse": search backwards + */ + async search(query, options = {}) { + const searchData = (this.searchData = this.searchData || {}); + const result = await super.search(query, options); + const nodeList = result.list; + + // If this is a new search, start at the beginning. + if (searchData.query !== query) { + searchData.query = query; + searchData.index = -1; + } + + if (!nodeList.length) { + return null; + } + + // Move search result cursor and cycle if necessary. + searchData.index = options.reverse + ? searchData.index - 1 + : searchData.index + 1; + if (searchData.index >= nodeList.length) { + searchData.index = 0; + } + if (searchData.index < 0) { + searchData.index = nodeList.length - 1; + } + + // Send back the single node, along with any relevant search data + const node = await nodeList.item(searchData.index); + return { + type: "search", + node: node, + resultsLength: nodeList.length, + resultsIndex: searchData.index, + }; + } + + _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" || + change.type === "nativeAnonymousChildList" + ) { + // 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" || + change.type === "nativeAnonymousChildList" + ) { + if (change.inlineTextChild) { + targetFront.inlineTextChild = types + .getType("domnode") + .read(change.inlineTextChild, this); + } else { + targetFront.inlineTextChild = undefined; + } + } + + emitMutations.push(emittedMutation); + } + + if (options.cleanup) { + for (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: previousSibling, + nextSibling: nextSibling, + }; + } + + async children(node, options) { + if (!node.remoteFrame) { + return super.children(node, options); + } + const remoteTarget = await node.connectToRemoteFrame(); + const walker = (await remoteTarget.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); + } + + /** + * Evaluate the cross iframes query selectors for the current walker front. + * + * @param {Array} selectors + * An array of CSS selectors to find the target accessible object. + * Several selectors can be needed if the element is nested in frames + * and not directly in the root document. + * @return {Promise} a promise that resolves when the node front is found for + * selection using inspector tools. + */ + async findNodeFront(nodeSelectors) { + const querySelectors = async nodeFront => { + const selector = nodeSelectors.shift(); + if (!selector) { + return nodeFront; + } + nodeFront = await this.querySelector(nodeFront, selector); + + // It's possible the containing iframe isn't available by the time + // this.querySelector is called, which causes the re-selected node to be + // unavailable. There also isn't a way for us to know when all iframes on the page + // have been created after a reload. Because of this, we should should bail here. + if (!nodeFront) { + return null; + } + + if (nodeSelectors.length > 0) { + await nodeFront.waitForFrameLoad(); + + const { nodes } = await this.children(nodeFront); + + // If there are remaining selectors to process, they will target a document or a + // document-fragment under the current node. Whether the element is a frame or + // a web component, it can only contain one document/document-fragment, so just + // select the first one available. + nodeFront = nodes.find(node => { + const { nodeType } = node; + return ( + nodeType === Node.DOCUMENT_FRAGMENT_NODE || + nodeType === Node.DOCUMENT_NODE + ); + }); + } + return querySelectors(nodeFront) || nodeFront; + }; + const nodeFront = await this.getRootNode(); + + // If rootSelectors are [frameSelector1, ..., frameSelectorN, rootSelector] + // we expect that [frameSelector1, ..., frameSelectorN] will also be in + // nodeSelectors. + // Otherwise it means the nodeSelectors target a node outside of this walker + // and we should return null. + const rootFrontSelectors = await nodeFront.getAllSelectors(); + for (let i = 0; i < rootFrontSelectors.length - 1; i++) { + if (rootFrontSelectors[i] !== nodeSelectors[i]) { + return null; + } + } + + // The query will start from the walker's rootNode, remove all the + // "frameSelectors". + nodeSelectors.splice(0, rootFrontSelectors.length - 1); + + return querySelectors(nodeFront); + } + + _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); + } + + /** + * 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..239d3047a0 --- /dev/null +++ b/devtools/client/fronts/watcher.js @@ -0,0 +1,113 @@ +/* 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("devtools/shared/specs/watcher"); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); + +loader.lazyRequireGetter( + this, + "BrowsingContextTargetFront", + "devtools/client/fronts/targets/browsing-context", + true +); +loader.lazyRequireGetter( + this, + "ContentProcessTargetFront", + "devtools/client/fronts/targets/content-process", + true +); +loader.lazyRequireGetter( + this, + "WorkerTargetFront", + "devtools/client/fronts/targets/worker", + 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 BrowsingContextTargetFront(this.conn, null, this); + } + front.actorID = form.actor; + front.form(form); + this.manage(front); + this.emit("target-available", front); + } + + _onTargetDestroyed(form) { + const front = this.getActorByID(form.actor); + this.emit("target-destroyed", front); + } + + /** + * Retrieve the already existing BrowsingContextTargetFront for the parent + * BrowsingContext of the given BrowsingContext ID. + */ + async getParentBrowsingContextTarget(browsingContextID) { + const id = await this.getParentBrowsingContextID(browsingContextID); + if (!id) { + return null; + } + return this.getBrowsingContextTarget(id); + } + + /** + * For a given BrowsingContext ID, return the already existing BrowsingContextTargetFront + */ + async getBrowsingContextTarget(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 browsing context target for the provided id, the + // browsing context might not be the topmost browsing context of a given + // process. For now we only create targets for the top browsing context 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.getBrowsingContextTarget(parentBrowsingContextID); + } + + return null; + } +} +registerFront(WatcherFront); diff --git a/devtools/client/fronts/webconsole.js b/devtools/client/fronts/webconsole.js new file mode 100644 index 0000000000..6b0e705810 --- /dev/null +++ b/devtools/client/fronts/webconsole.js @@ -0,0 +1,507 @@ +/* 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 DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const { LongStringFront } = require("devtools/client/fronts/string"); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); +const { webconsoleSpec } = require("devtools/shared/specs/webconsole"); +const { + getAdHocFrontOrPrimitiveGrip, +} = require("devtools/client/fronts/object"); + +/** + * 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.traits = {}; + this._longStrings = {}; + this.events = []; + + // Attribute name from which to retrieve the actorID out of the target actor's form + this.formAttributeName = "consoleActor"; + + this.pendingEvaluationResults = new Map(); + this.onEvaluationResult = this.onEvaluationResult.bind(this); + this._onNetworkEventUpdate = this._onNetworkEventUpdate.bind(this); + + this.on("evaluationResult", this.onEvaluationResult); + 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); + } + + /** + * Evaluate a JavaScript expression asynchronously. + * + * @param {String} string: The code you want to evaluate. + * @param {Object} opts: Options for evaluation: + * + * - {String} frameActor: a FrameActor ID. The FA holds a reference to + * a Debugger.Frame. This option allows you to evaluate the string in + * the frame of the given FA. + * + * - {String} url: the url to evaluate the script as. Defaults to + * "debugger eval code". + * + * - {String} selectedNodeActor: the NodeActor ID of the current + * selection in the Inspector, if such a selection + * exists. This is used by helper functions that can + * reference the currently selected node in the Inspector, like $0. + * + * - {String} selectedObjectActor: the actorID of a given objectActor. + * This is used by context menu entries to get a reference to an object, in order + * to perform some operation on it (copy it, store it as a global variable, …). + * + * - {Integer} innerWindowID: An optional window id to be used for the evaluation, + * instead of the regular webConsoleActor.evalWindow. + * This is used by functions that may want to evaluate in a different window (for + * example a non-remote iframe), like getting the elements of a given document. + * + * @return {Promise}: A promise that resolves with the response. + */ + async evaluateJSAsync(string, opts = {}) { + const options = { + text: string, + frameActor: opts.frameActor, + url: opts.url, + selectedNodeActor: opts.selectedNodeActor, + selectedObjectActor: opts.selectedObjectActor, + innerWindowID: opts.innerWindowID, + mapped: opts.mapped, + eager: opts.eager, + }; + + this._pendingAsyncEvaluation = super.evaluateJSAsync(options); + const { resultID } = await this._pendingAsyncEvaluation; + this._pendingAsyncEvaluation = null; + + return new Promise((resolve, reject) => { + // Null check this in case the client has been detached while sending + // the one way request + if (this.pendingEvaluationResults) { + this.pendingEvaluationResults.set(resultID, resp => { + if (resp.error) { + reject(resp); + } else { + if (resp.result) { + resp.result = getAdHocFrontOrPrimitiveGrip(resp.result, this); + } + + if (resp.helperResult?.object) { + resp.helperResult.object = getAdHocFrontOrPrimitiveGrip( + resp.helperResult.object, + this + ); + } + + if (resp.exception) { + resp.exception = getAdHocFrontOrPrimitiveGrip( + resp.exception, + this + ); + } + + if (resp.exceptionMessage) { + resp.exceptionMessage = getAdHocFrontOrPrimitiveGrip( + resp.exceptionMessage, + this + ); + } + + resolve(resp); + } + }); + } + }); + } + + /** + * Handler for the actors's unsolicited evaluationResult packet. + */ + async onEvaluationResult(packet) { + // In some cases, the evaluationResult event can be received before the initial call + // to evaluationJSAsync completes. So make sure to wait for the corresponding promise + // before handling the event. + await this._pendingAsyncEvaluation; + + // Find the associated callback based on this ID, and fire it. + // In a sync evaluation, this would have already been called in + // direct response to the client.request function. + const onResponse = this.pendingEvaluationResults.get(packet.resultID); + if (onResponse) { + onResponse(packet); + this.pendingEvaluationResults.delete(packet.resultID); + } else { + DevToolsUtils.reportException( + "onEvaluationResult", + "No response handler for an evaluateJSAsync result (resultID: " + + packet.resultID + + ")" + ); + } + } + + 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); + } + + /** + * Start the given Web Console listeners. + * TODO: remove once the front is retrieved via getFront, and we use form() + * + * @see this.LISTENERS + * @param array listeners + * Array of listeners you want to start. See this.LISTENERS for + * known listeners. + * @return request + * Request object that implements both Promise and EventEmitter interfaces + */ + async startListeners(listeners) { + const response = await super.startListeners(listeners); + this.hasNativeConsoleAPI = response.nativeConsoleAPI; + this.traits = response.traits; + return response; + } + + /** + * Return an instance of LongStringFront for the given long string grip. + * + * @param object grip + * The long string grip returned by the protocol. + * @return {LongStringFront} the front for the given long string grip. + */ + longString(grip) { + if (grip.actor in this._longStrings) { + return this._longStrings[grip.actor]; + } + + const front = new LongStringFront(this._client, this.targetFront, this); + front.form(grip); + this.manage(front); + this._longStrings[grip.actor] = front; + return front; + } + + /** + * Fetches the full text of a LongString. + * + * @param object | string stringGrip + * The long string grip containing the corresponding actor. + * If you pass in a plain string (by accident or because you're lazy), + * then a promise of the same string is simply returned. + * @return object Promise + * A promise that is resolved when the full string contents + * are available, or rejected if something goes wrong. + */ + async getString(stringGrip) { + // Make sure this is a long string. + if (typeof stringGrip !== "object" || stringGrip.type !== "longString") { + // Go home string, you're drunk. + return stringGrip; + } + + // Fetch the long string only once. + if (stringGrip._fullText) { + return stringGrip._fullText; + } + + const { initial, length } = stringGrip; + const longStringFront = this.longString(stringGrip); + + try { + const response = await longStringFront.substring(initial.length, length); + return initial + response; + } catch (e) { + DevToolsUtils.reportException("getString", e.message); + throw e; + } + } + + /** + * 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; + + this.off("evaluationResult", this.onEvaluationResult); + this._longStrings = null; + this.pendingEvaluationResults.clear(); + this.pendingEvaluationResults = null; + return super.destroy(); + } +} + +exports.WebConsoleFront = WebConsoleFront; +registerFront(WebConsoleFront); diff --git a/devtools/client/fronts/websocket.js b/devtools/client/fronts/websocket.js new file mode 100644 index 0000000000..8aa60e9b38 --- /dev/null +++ b/devtools/client/fronts/websocket.js @@ -0,0 +1,115 @@ +/* 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("devtools/shared/protocol"); +const { webSocketSpec } = require("devtools/shared/specs/websocket"); + +/** + * A WebSocketFront is used as a front end for the WebSocketActor that is + * created on the server, hiding implementation details. + */ +class WebSocketFront extends FrontClassWithSpec(webSocketSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + + this._onWebSocketOpened = this._onWebSocketOpened.bind(this); + this._onWebSocketClosed = this._onWebSocketClosed.bind(this); + this._onFrameSent = this._onFrameSent.bind(this); + this._onFrameReceived = this._onFrameReceived.bind(this); + + // Attribute name from which to retrieve the actorID + // out of the target actor's form + this.formAttributeName = "webSocketActor"; + + this.on("serverWebSocketOpened", this._onWebSocketOpened); + this.on("serverWebSocketClosed", this._onWebSocketClosed); + this.on("serverFrameSent", this._onFrameSent); + this.on("serverFrameReceived", this._onFrameReceived); + } + + /** + * Close the WebSocketFront. + * + */ + destroy() { + this.off("serverWebSocketOpened"); + this.off("serverWebSocketClosed"); + this.off("serverFrameSent"); + this.off("serverFrameReceived"); + return super.destroy(); + } + + /** + * The "webSocketOpened" message type handler. We redirect any message to + * the UI for displaying. + * + * @private + * @param number httpChannelId + * Channel ID of the websocket connection. + * @param string effectiveURI + * URI of the page. + * @param string protocols + * WebSocket procotols. + * @param string extensions + */ + async _onWebSocketOpened(httpChannelId, effectiveURI, protocols, extensions) { + this.emit( + "webSocketOpened", + httpChannelId, + effectiveURI, + protocols, + extensions + ); + } + + /** + * The "webSocketClosed" message type handler. We redirect any message to + * the UI for displaying. + * + * @private + * @param number httpChannelId + * @param boolean wasClean + * @param number code + * @param string reason + */ + async _onWebSocketClosed(httpChannelId, wasClean, code, reason) { + this.emit("webSocketClosed", httpChannelId, wasClean, code, reason); + } + + /** + * The "frameReceived" message type handler. We redirect any message to + * the UI for displaying. + * + * @private + * @param string httpChannelId + * Channel ID of the websocket connection. + * @param object data + * The data received from the server. + */ + async _onFrameReceived(httpChannelId, data) { + this.emit("frameReceived", httpChannelId, data); + } + + /** + * The "frameSent" message type handler. We redirect any message to + * the UI for displaying. + * + * @private + * @param string httpChannelId + * Channel ID of the websocket connection. + * @param object data + * The data received from the server. + */ + async _onFrameSent(httpChannelId, data) { + this.emit("frameSent", httpChannelId, data); + } +} + +exports.WebSocketFront = WebSocketFront; +registerFront(WebSocketFront); 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..5c71065867 --- /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("devtools/shared/specs/worker/push-subscription"); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); + +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..6dbe836962 --- /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("devtools/shared/specs/worker/service-worker-registration"); +const { + FrontClassWithSpec, + registerFront, + types, +} = require("devtools/shared/protocol"); + +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..39480f95a3 --- /dev/null +++ b/devtools/client/fronts/worker/service-worker.js @@ -0,0 +1,63 @@ +/* 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("devtools/shared/specs/worker/service-worker"); +const { + FrontClassWithSpec, + registerFront, +} = require("devtools/shared/protocol"); +const { Ci } = require("chrome"); +const { LocalizationHelper } = require("devtools/shared/l10n"); + +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); |