devtools/startup/DevToolsShim.jsm
author Sebastian Hengst <archaeopteryx@coole-files.de>
Wed, 14 Apr 2021 11:29:47 +0200
changeset 575819 14541ee6ce5e7c9f273e83c0ad3b2ba1c46b02c5
parent 573969 b725fa7eb45fd383029d8a7b267811b52b06cb11
permissions -rw-r--r--
Merge autoland to mozilla-central. a=merge

/* 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 } = ChromeUtils.import("resource://gre/modules/Services.jsm");

const { XPCOMUtils } = ChromeUtils.import(
  "resource://gre/modules/XPCOMUtils.jsm"
);
XPCOMUtils.defineLazyGetter(this, "DevToolsStartup", () => {
  return Cc["@mozilla.org/devtools/startup-clh;1"].getService(
    Ci.nsICommandLineHandler
  ).wrappedJSObject;
});

// We don't want to spend time initializing the full loader here so we create
// our own lazy require.
XPCOMUtils.defineLazyGetter(this, "Telemetry", function() {
  const { require } = ChromeUtils.import(
    "resource://devtools/shared/Loader.jsm"
  );
  // eslint-disable-next-line no-shadow
  const Telemetry = require("devtools/client/shared/telemetry");

  return Telemetry;
});

const DEVTOOLS_ENABLED_PREF = "devtools.enabled";
const DEVTOOLS_POLICY_DISABLED_PREF = "devtools.policy.disabled";

const EXPORTED_SYMBOLS = ["DevToolsShim"];

function removeItem(array, callback) {
  const index = array.findIndex(callback);
  if (index >= 0) {
    array.splice(index, 1);
  }
}

/**
 * DevToolsShim is a singleton that provides a set of helpers to interact with DevTools,
 * that work whether Devtools are enabled or not.
 *
 * It can be used to start listening to devtools events before DevTools are ready. As soon
 * as DevTools are enabled, the DevToolsShim will forward all the requests received until
 * then to the real DevTools instance.
 */
const DevToolsShim = {
  _gDevTools: null,
  listeners: [],

  get telemetry() {
    if (!this._telemetry) {
      this._telemetry = new Telemetry();
      this._telemetry.setEventRecordingEnabled(true);
    }
    return this._telemetry;
  },

  /**
   * Returns true if DevTools are enabled for the current profile. If devtools are not
   * enabled, initializing DevTools will open the onboarding page. Some entry points
   * should no-op in this case.
   */
  isEnabled: function() {
    const enabled = Services.prefs.getBoolPref(DEVTOOLS_ENABLED_PREF);
    return enabled && !this.isDisabledByPolicy();
  },

  /**
   * Returns true if the devtools are completely disabled and can not be enabled. All
   * entry points should return without throwing, initDevTools should never be called.
   */
  isDisabledByPolicy: function() {
    return Services.prefs.getBoolPref(DEVTOOLS_POLICY_DISABLED_PREF, false);
  },

  /**
   * Check if DevTools have already been initialized.
   *
   * @return {Boolean} true if DevTools are initialized.
   */
  isInitialized: function() {
    return !!this._gDevTools;
  },

  /**
   * Returns the array of the existing toolboxes. This method is part of the compatibility
   * layer for webextensions.
   *
   * @return {Array<Toolbox>}
   *   An array of toolboxes.
   */
  getToolboxes: function() {
    if (this.isInitialized()) {
      return this._gDevTools.getToolboxes();
    }

    return [];
  },

  /**
   * Register an instance of gDevTools. Should be called by DevTools during startup.
   *
   * @param {DevTools} a devtools instance (from client/framework/devtools)
   */
  register: function(gDevTools) {
    this._gDevTools = gDevTools;
    this._onDevToolsRegistered();
    this._gDevTools.emit("devtools-registered");
  },

  /**
   * Unregister the current instance of gDevTools. Should be called by DevTools during
   * shutdown.
   */
  unregister: function() {
    if (this.isInitialized()) {
      this._gDevTools.emit("devtools-unregistered");
      this._gDevTools = null;
    }
  },

  /**
   * The following methods can be called before DevTools are initialized:
   * - on
   * - off
   *
   * If DevTools are not initialized when calling the method, DevToolsShim will call the
   * appropriate method as soon as a gDevTools instance is registered.
   */

  /**
   * This method is used by browser/components/extensions/ext-devtools.js for the events:
   * - toolbox-created
   * - toolbox-destroyed
   */
  on: function(event, listener) {
    if (this.isInitialized()) {
      this._gDevTools.on(event, listener);
    } else {
      this.listeners.push([event, listener]);
    }
  },

  /**
   * This method is currently only used by devtools code, but is kept here for consistency
   * with on().
   */
  off: function(event, listener) {
    if (this.isInitialized()) {
      this._gDevTools.off(event, listener);
    } else {
      removeItem(this.listeners, ([e, l]) => e === event && l === listener);
    }
  },

  /**
   * Called from SessionStore.jsm in mozilla-central when saving the current state.
   *
   * @param {Object} state
   *                 A SessionStore state object that gets modified by reference
   */
  saveDevToolsSession: function(state) {
    if (!this.isInitialized()) {
      return;
    }

    this._gDevTools.saveDevToolsSession(state);
  },

  /**
   * Called from SessionStore.jsm in mozilla-central when restoring a previous session.
   * Will always be called, even if the session does not contain DevTools related items.
   */
  restoreDevToolsSession: function(session) {
    if (!this.isEnabled()) {
      return;
    }

    const { browserConsole, browserToolbox } = session;
    const hasDevToolsData = browserConsole || browserToolbox;
    if (!hasDevToolsData) {
      // Do not initialize DevTools unless there is DevTools specific data in the session.
      return;
    }

    this.initDevTools("SessionRestore");
    this._gDevTools.restoreDevToolsSession(session);
  },

  isDevToolsUser() {
    return DevToolsStartup.isDevToolsUser();
  },

  /**
   * Called from nsContextMenu.js in mozilla-central when using the Inspect Accessibility
   * context menu item.
   *
   * @param {XULTab} tab
   *        The browser tab on which inspect accessibility was used.
   * @param {ElementIdentifier} domReference
   *        Identifier generated by ContentDOMReference. It is a unique pair of
   *        BrowsingContext ID and a numeric ID.
   * @return {Promise} a promise that resolves when the accessible node is selected in the
   *         accessibility inspector or that resolves immediately if DevTools are not
   *         enabled.
   */
  inspectA11Y: function(tab, domReference) {
    if (!this.isEnabled()) {
      if (!this.isDisabledByPolicy()) {
        DevToolsStartup.openInstallPage("ContextMenu");
      }
      return Promise.resolve();
    }

    // Record the timing at which this event started in order to compute later in
    // gDevTools.showToolbox, the complete time it takes to open the toolbox.
    // i.e. especially take `DevToolsStartup.initDevTools` into account.
    const startTime = Cu.now();

    this.initDevTools("ContextMenu");

    return this._gDevTools.inspectA11Y(tab, domReference, startTime);
  },

  /**
   * Called from nsContextMenu.js in mozilla-central when using the Inspect Element
   * context menu item.
   *
   * @param {XULTab} tab
   *        The browser tab on which inspect node was used.
   * @param {ElementIdentifier} domReference
   *        Identifier generated by ContentDOMReference. It is a unique pair of
   *        BrowsingContext ID and a numeric ID.
   * @return {Promise} a promise that resolves when the node is selected in the inspector
   *         markup view or that resolves immediately if DevTools are not enabled.
   */
  inspectNode: function(tab, domReference) {
    if (!this.isEnabled()) {
      if (!this.isDisabledByPolicy()) {
        DevToolsStartup.openInstallPage("ContextMenu");
      }
      return Promise.resolve();
    }

    // Record the timing at which this event started in order to compute later in
    // gDevTools.showToolbox, the complete time it takes to open the toolbox.
    // i.e. especially take `DevToolsStartup.initDevTools` into account.
    const startTime = Cu.now();

    this.initDevTools("ContextMenu");

    return this._gDevTools.inspectNode(tab, domReference, startTime);
  },

  _onDevToolsRegistered: function() {
    // Register all pending event listeners on the real gDevTools object.
    for (const [event, listener] of this.listeners) {
      this._gDevTools.on(event, listener);
    }

    this.listeners = [];
  },

  /**
   * Initialize DevTools via DevToolsStartup if needed. This method throws if DevTools are
   * not enabled.. If the entry point is supposed to trigger the onboarding, call it
   * explicitly via DevToolsStartup.openInstallPage().
   *
   * @param {String} reason
   *        optional, if provided should be a valid entry point for DEVTOOLS_ENTRY_POINT
   *        in toolkit/components/telemetry/Histograms.json
   */
  initDevTools: function(reason) {
    if (!this.isEnabled()) {
      throw new Error("DevTools are not enabled and can not be initialized.");
    }

    if (reason) {
      const window = Services.wm.getMostRecentWindow("navigator:browser");

      this.telemetry.addEventProperty(
        window,
        "open",
        "tools",
        null,
        "shortcut",
        ""
      );
      this.telemetry.addEventProperty(
        window,
        "open",
        "tools",
        null,
        "entrypoint",
        reason
      );
    }

    if (!this.isInitialized()) {
      DevToolsStartup.initDevTools(reason);
    }
  },
};

/**
 * Compatibility layer for webextensions.
 *
 * Those methods are called only after a DevTools webextension was loaded in DevTools,
 * therefore DevTools should always be available when they are called.
 */
const webExtensionsMethods = [
  "createCommandsForTabForWebExtension",
  "getTheme",
  "openBrowserConsole",
];

/**
 * Compatibility layer for other third parties.
 */
const otherToolMethods = [
  // gDevTools.showToolboxForTab is used by wptrunner to start devtools
  // https://github.com/web-platform-tests/wpt
  "showToolboxForTab",
];

for (const method of [...webExtensionsMethods, ...otherToolMethods]) {
  DevToolsShim[method] = function() {
    if (!this.isEnabled()) {
      throw new Error(
        "Could not call a DevToolsShim webextension method ('" +
          method +
          "'): DevTools are not initialized."
      );
    }

    this.initDevTools();
    return this._gDevTools[method].apply(this._gDevTools, arguments);
  };
}