tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

inspector-command.js (17182B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 "use strict";
      6 
      7 loader.lazyRequireGetter(
      8  this,
      9  "getTargetBrowsers",
     10  "resource://devtools/shared/compatibility/compatibility-user-settings.js",
     11  true
     12 );
     13 loader.lazyRequireGetter(
     14  this,
     15  "TARGET_BROWSER_PREF",
     16  "resource://devtools/shared/compatibility/constants.js",
     17  true
     18 );
     19 
     20 const { getSystemInfo } = require("resource://devtools/shared/system.js");
     21 
     22 class InspectorCommand {
     23  constructor({ commands }) {
     24    this.commands = commands;
     25  }
     26 
     27  #cssDeclarationBlockIssuesQueuedDomRulesDeclarations = [];
     28  #cssDeclarationBlockIssuesPendingTimeoutPromise;
     29  #cssDeclarationBlockIssuesTargetBrowsersPromise;
     30 
     31  /**
     32   * Return the list of all current target's inspector fronts
     33   *
     34   * @return {Promise<Array<InspectorFront>>}
     35   */
     36  async getAllInspectorFronts() {
     37    return this.commands.targetCommand.getAllFronts(
     38      [this.commands.targetCommand.TYPES.FRAME],
     39      "inspector"
     40    );
     41  }
     42 
     43  /**
     44   * Search the document for the given string and return all the results.
     45   *
     46   * @param {object} walkerFront
     47   * @param {string} query
     48   *        The string to search for.
     49   * @param {object} options
     50   *        {Boolean} options.reverse - search backwards
     51   * @returns {Array} The list of search results
     52   */
     53  async walkerSearch(walkerFront, query, options = {}) {
     54    const result = await walkerFront.search(query, options);
     55    return result.list.items();
     56  }
     57 
     58  /**
     59   * Incrementally search the top-level document and sub frames for a given string.
     60   * Only one result is sent back at a time. Calling the
     61   * method again with the same query will send the next result.
     62   * If a new query which does not match the current one all is reset and new search
     63   * is kicked off.
     64   *
     65   * @param {string} query
     66   *         The string / selector searched for
     67   * @param {object} options
     68   *        {Boolean} reverse - determines if the search is done backwards
     69   * @returns {object} res
     70   *          {String} res.type
     71   *          {String} res.query - The string / selector searched for
     72   *          {Object} res.node - the current node
     73   *          {Number} res.resultsIndex - The index of the current node
     74   *          {Number} res.resultsLength - The total number of results found.
     75   */
     76  async findNextNode(query, { reverse } = {}) {
     77    const inspectors = await this.getAllInspectorFronts();
     78    const nodes = await Promise.all(
     79      inspectors.map(({ walker }) =>
     80        this.walkerSearch(walker, query, { reverse })
     81      )
     82    );
     83    const results = nodes.flat();
     84 
     85    // If the search query changes
     86    if (this._searchQuery !== query) {
     87      this._searchQuery = query;
     88      this._currentIndex = -1;
     89    }
     90 
     91    if (!results.length) {
     92      return null;
     93    }
     94 
     95    this._currentIndex = reverse
     96      ? this._currentIndex - 1
     97      : this._currentIndex + 1;
     98 
     99    if (this._currentIndex >= results.length) {
    100      this._currentIndex = 0;
    101    }
    102    if (this._currentIndex < 0) {
    103      this._currentIndex = results.length - 1;
    104    }
    105 
    106    return {
    107      node: results[this._currentIndex],
    108      resultsIndex: this._currentIndex,
    109      resultsLength: results.length,
    110    };
    111  }
    112 
    113  /**
    114   * Returns a list of matching results for CSS selector autocompletion.
    115   *
    116   * @param {string} query
    117   *        The selector query being completed
    118   * @param {string} firstPart
    119   *        The exact token being completed out of the query
    120   * @param {string} state
    121   *        One of "pseudo", "id", "tag", "class", "null"
    122   * @return {Array<string>} suggestions
    123   *        The list of suggested CSS selectors
    124   */
    125  async getSuggestionsForQuery(query, firstPart, state) {
    126    // Get all inspectors where we want suggestions from.
    127    const inspectors = await this.getAllInspectorFronts();
    128 
    129    const mergedSuggestions = [];
    130    // Get all of the suggestions.
    131    await Promise.all(
    132      inspectors.map(async ({ walker }) => {
    133        const { suggestions } = await walker.getSuggestionsForQuery(
    134          query,
    135          firstPart,
    136          state
    137        );
    138 
    139        for (const suggestion of suggestions) {
    140          const [value, type] = suggestion;
    141 
    142          // Only add suggestions to final array if it doesn't exist yet.
    143          const existing = mergedSuggestions.some(
    144            ([s, t]) => s == value && t == type
    145          );
    146          if (!existing) {
    147            mergedSuggestions.push([value, type]);
    148          }
    149        }
    150      })
    151    );
    152 
    153    return sortSuggestions(mergedSuggestions);
    154  }
    155 
    156  /**
    157   * Find a nodeFront from an array of selectors. The last item of the array is the selector
    158   * for the element in its owner document, and the previous items are selectors to iframes
    159   * that lead to the frame where the searched node lives in.
    160   *
    161   * For example, with the following markup
    162   * <html>
    163   *  <iframe id="level-1" src="…">
    164   *    <iframe id="level-2" src="…">
    165   *      <h1>Waldo</h1>
    166   *    </iframe>
    167   *  </iframe>
    168   *
    169   * If you want to retrieve the `<h1>` nodeFront, `selectors` would be:
    170   * [
    171   *   "#level-1",
    172   *   "#level-2",
    173   *   "h1",
    174   * ]
    175   *
    176   * @param {Array} selectors
    177   *        An array of CSS selectors to find the target accessible object.
    178   *        Several selectors can be needed if the element is nested in frames
    179   *        and not directly in the root document.
    180   * @param {Integer} timeoutInMs
    181   *        The maximum number of ms the function should run (defaults to 1000).
    182   *        If it exceeds this, the returned promise will resolve with `null`.
    183   * @return {Promise<NodeFront|null>} a promise that resolves when the node front is found
    184   *        for selection using inspector tools. It resolves with the deepest frame document
    185   *        that could be retrieved when the "final" nodeFront couldn't be found in the page.
    186   *        It resolves with `null` when the function runs for more than timeoutInMs.
    187   */
    188  async findNodeFrontFromSelectors(nodeSelectors, timeoutInMs = 1000) {
    189    if (
    190      !nodeSelectors ||
    191      !Array.isArray(nodeSelectors) ||
    192      nodeSelectors.length === 0
    193    ) {
    194      console.warn(
    195        "findNodeFrontFromSelectors expect a non-empty array but got",
    196        nodeSelectors
    197      );
    198      return null;
    199    }
    200 
    201    const { walker } =
    202      await this.commands.targetCommand.targetFront.getFront("inspector");
    203    // Copy the array as we will mutate it
    204    nodeSelectors = [...nodeSelectors];
    205    const querySelectors = async nodeFront => {
    206      const selector = nodeSelectors.shift();
    207      if (!selector) {
    208        return nodeFront;
    209      }
    210      nodeFront = await nodeFront.walkerFront.querySelector(
    211        nodeFront,
    212        selector
    213      );
    214      // It's possible the containing iframe isn't available by the time
    215      // walkerFront.querySelector is called, which causes the re-selected node to be
    216      // unavailable. There also isn't a way for us to know when all iframes on the page
    217      // have been created after a reload. Because of this, we should should bail here.
    218      if (!nodeFront) {
    219        return null;
    220      }
    221 
    222      if (nodeSelectors.length) {
    223        if (!nodeFront.isShadowHost) {
    224          await this.#waitForFrameLoad(nodeFront);
    225        }
    226 
    227        const { nodes } = await walker.children(nodeFront);
    228 
    229        // If there are remaining selectors to process, they will target a document or a
    230        // document-fragment under the current node. Whether the element is a frame or
    231        // a web component, it can only contain one document/document-fragment, so just
    232        // select the first one available.
    233        nodeFront = nodes.find(node => {
    234          const { nodeType } = node;
    235          return (
    236            nodeType === Node.DOCUMENT_FRAGMENT_NODE ||
    237            nodeType === Node.DOCUMENT_NODE
    238          );
    239        });
    240 
    241        // The iframe selector might have matched an element which is not an
    242        // iframe in the new page (or an iframe with no document?). In this
    243        // case, bail out and fallback to the root body element.
    244        if (!nodeFront) {
    245          return null;
    246        }
    247      }
    248      const childrenNodeFront = await querySelectors(nodeFront);
    249      return childrenNodeFront || nodeFront;
    250    };
    251    const rootNodeFront = await walker.getRootNode();
    252 
    253    // Since this is only used for re-setting a selection after a page reloads, we can
    254    // put a timeout, in case there's an iframe that would take too much time to load,
    255    // and prevent the markup view to be populated.
    256    const onTimeout = new Promise(res =>
    257      setTimeout(res, timeoutInMs * getSystemInfo().timeoutMultiplier)
    258    ).then(() => null);
    259    const onQuerySelectors = querySelectors(rootNodeFront);
    260    return Promise.race([onTimeout, onQuerySelectors]);
    261  }
    262 
    263  /**
    264   * Wait for the given NodeFront child document to be loaded.
    265   *
    266   * @param {NodeFront} A nodeFront representing a frame
    267   */
    268  async #waitForFrameLoad(nodeFront) {
    269    const domLoadingPromises = [];
    270 
    271    // if the flag isn't true, we don't know for sure if the iframe will be remote
    272    // or not; when the nodeFront was created, the iframe might still have been loading
    273    // and in such case, its associated window can be an initial document.
    274    // Luckily, once EFT is enabled everywhere we can remove this call and only wait
    275    // for the associated target.
    276    if (!nodeFront.useChildTargetToFetchChildren) {
    277      domLoadingPromises.push(nodeFront.waitForFrameLoad());
    278    }
    279 
    280    const { onResource: onDomInteractiveResource } =
    281      await this.commands.resourceCommand.waitForNextResource(
    282        this.commands.resourceCommand.TYPES.DOCUMENT_EVENT,
    283        {
    284          // We might be in a case where the children document is already loaded (i.e. we
    285          // would already have received the dom-interactive resource), so it's important
    286          // to _not_ ignore existing resource.
    287          predicate: resource =>
    288            resource.name == "dom-interactive" &&
    289            resource.targetFront !== nodeFront.targetFront &&
    290            resource.targetFront.browsingContextID ==
    291              nodeFront.browsingContextID,
    292        }
    293      );
    294    const newTargetResolveValue = Symbol();
    295    domLoadingPromises.push(
    296      onDomInteractiveResource.then(() => newTargetResolveValue)
    297    );
    298 
    299    // Here we wait for any promise to resolve first. `waitForFrameLoad` might throw
    300    // (if the iframe does end up being remote), so we don't want to use `Promise.race`.
    301    const loadResult = await Promise.any(domLoadingPromises);
    302 
    303    // The Node may have `useChildTargetToFetchChildren` set to false because the
    304    // child document was still loading when fetching its form. But it may happen that
    305    // the Node ends up being a remote iframe.
    306    // When this happen we will try to call `waitForFrameLoad` which will throw, but
    307    // we will be notified about the new target.
    308    // This is the special edge case we are trying to handle here.
    309    // We want WalkerFront.children to consider this as an iframe with a dedicated target.
    310    if (loadResult == newTargetResolveValue) {
    311      nodeFront._form.useChildTargetToFetchChildren = true;
    312    }
    313  }
    314 
    315  /**
    316   * Get the full array of selectors from the topmost document, going through
    317   * iframes.
    318   * For example, given the following markup:
    319   *
    320   * <html>
    321   *   <body>
    322   *     <iframe src="...">
    323   *       <html>
    324   *         <body>
    325   *           <h1 id="sub-document-title">Title of sub document</h1>
    326   *         </body>
    327   *       </html>
    328   *     </iframe>
    329   *   </body>
    330   * </html>
    331   *
    332   * If this function is called with the NodeFront for the h1#sub-document-title element,
    333   * it will return something like: ["body > iframe", "#sub-document-title"]
    334   *
    335   * @param {NodeFront} nodeFront: The nodefront to get the selectors for
    336   * @returns {Promise<Array<string>>} A promise that resolves with an array of selectors (strings)
    337   */
    338  async getNodeFrontSelectorsFromTopDocument(nodeFront) {
    339    const selectors = [];
    340 
    341    let currentNode = nodeFront;
    342    while (currentNode) {
    343      // Get the selector for the node inside its document
    344      const selector = await currentNode.getUniqueSelector();
    345      selectors.unshift(selector);
    346 
    347      // Retrieve the node's document/shadowRoot nodeFront so we can get its parent
    348      // (so if we're in an iframe, we'll get the <iframe> node front, and if we're in a
    349      // shadow dom document, we'll get the host).
    350      const rootNode = currentNode.getOwnerRootNodeFront();
    351      currentNode = rootNode?.parentOrHost();
    352    }
    353 
    354    return selectors;
    355  }
    356 
    357  #updateTargetBrowsersCache = async () => {
    358    this.#cssDeclarationBlockIssuesTargetBrowsersPromise = getTargetBrowsers();
    359  };
    360 
    361  /**
    362   *  Get compatibility issues for given domRule declarations
    363   *
    364   * @param {Array<object>} domRuleDeclarations
    365   * @param {string} domRuleDeclarations[].name: Declaration name
    366   * @param {string} domRuleDeclarations[].value: Declaration value
    367   * @returns {Promise<Array<object>>}
    368   */
    369  async getCSSDeclarationBlockIssues(domRuleDeclarations) {
    370    // Filter out custom property declarations as we can't have issue with those and
    371    // they're already ignored on the server.
    372    const nonCustomPropertyDeclarations = domRuleDeclarations.filter(
    373      decl => !decl.isCustomProperty
    374    );
    375    const resultIndex =
    376      this.#cssDeclarationBlockIssuesQueuedDomRulesDeclarations.length;
    377    this.#cssDeclarationBlockIssuesQueuedDomRulesDeclarations.push(
    378      nonCustomPropertyDeclarations
    379    );
    380 
    381    // We're getting the target browsers from RemoteSettings, which can take some time.
    382    // We cache the target browsers to avoid bad performance.
    383    if (!this.#cssDeclarationBlockIssuesTargetBrowsersPromise) {
    384      this.#updateTargetBrowsersCache();
    385      // Update the target browsers cache when the pref in which we store the compat
    386      // panel settings is updated.
    387      Services.prefs.addObserver(
    388        TARGET_BROWSER_PREF,
    389        this.#updateTargetBrowsersCache
    390      );
    391    }
    392 
    393    // This can be a hot path if the rules view has a lot of rules displayed.
    394    // Here we wait before sending the RDP request so we can collect all the domRule declarations
    395    // of "concurrent" calls, and only send a single RDP request.
    396    if (!this.#cssDeclarationBlockIssuesPendingTimeoutPromise) {
    397      // Wait before sending the RDP request so all "concurrent" calls can be handle
    398      // in a single RDP request.
    399      this.#cssDeclarationBlockIssuesPendingTimeoutPromise = new Promise(
    400        resolve => {
    401          setTimeout(() => {
    402            this.#cssDeclarationBlockIssuesPendingTimeoutPromise = null;
    403            this.#batchedGetCSSDeclarationBlockIssues().then(data =>
    404              resolve(data)
    405            );
    406          }, 50);
    407        }
    408      );
    409    }
    410 
    411    const results = await this.#cssDeclarationBlockIssuesPendingTimeoutPromise;
    412    return results?.[resultIndex] || [];
    413  }
    414 
    415  /**
    416   * Get compatibility issues for all queued domRules declarations
    417   *
    418   * @returns {Promise<Array<Array<object>>>}
    419   */
    420  #batchedGetCSSDeclarationBlockIssues = async () => {
    421    const declarations =
    422      this.#cssDeclarationBlockIssuesQueuedDomRulesDeclarations;
    423    this.#cssDeclarationBlockIssuesQueuedDomRulesDeclarations = [];
    424 
    425    const { targetFront } = this.commands.targetCommand;
    426    try {
    427      // The server method isn't dependent on the target (it computes the values from the
    428      // declarations we send, which are just property names and values), so we can always
    429      // use the top-level target front.
    430      const inspectorFront = await targetFront.getFront("inspector");
    431 
    432      const [compatibilityFront, targetBrowsers] = await Promise.all([
    433        inspectorFront.getCompatibilityFront(),
    434        this.#cssDeclarationBlockIssuesTargetBrowsersPromise,
    435      ]);
    436 
    437      const data = await compatibilityFront.getCSSDeclarationBlockIssues(
    438        declarations,
    439        targetBrowsers
    440      );
    441      return data;
    442    } catch (e) {
    443      if (this.destroyed || targetFront.isDestroyed()) {
    444        return [];
    445      }
    446      throw e;
    447    }
    448  };
    449 
    450  destroy() {
    451    Services.prefs.removeObserver(
    452      TARGET_BROWSER_PREF,
    453      this.#updateTargetBrowsersCache
    454    );
    455    this.destroyed = true;
    456  }
    457 }
    458 
    459 // This is a fork of the server sort:
    460 // https://searchfox.org/mozilla-central/rev/46a67b8656ac12b5c180e47bc4055f713d73983b/devtools/server/actors/inspector/walker.js#1447
    461 function sortSuggestions(suggestions) {
    462  const sorted = suggestions.sort((a, b) => {
    463    // Prefixing ids, classes and tags, to group results
    464    const firstA = a[0].substring(0, 1);
    465    const firstB = b[0].substring(0, 1);
    466 
    467    const getSortKeyPrefix = firstLetter => {
    468      if (firstLetter === "#") {
    469        return "2";
    470      }
    471      if (firstLetter === ".") {
    472        return "1";
    473      }
    474      return "0";
    475    };
    476 
    477    const sortA = getSortKeyPrefix(firstA) + a[0];
    478    const sortB = getSortKeyPrefix(firstB) + b[0];
    479 
    480    // String compare
    481    return sortA.localeCompare(sortB);
    482  });
    483  return sorted.slice(0, 25);
    484 }
    485 
    486 module.exports = InspectorCommand;