Bug 1553384, update find extension api to support out-of-process iframes. r=mixedpuppy
authorNeil Deakin <neil@mozilla.com>
Tue, 17 Sep 2019 23:31:34 +0000
changeset 493763 3977d67dc67ae062a7a4982153339fccac4a50a8
parent 493762 46f35eae82f67e0f3d0af5435d01cdca4d531198
child 493764 21aa598103758f173b1f0e0a65731089567234d1
push id36589
push usernerli@mozilla.com
push dateWed, 18 Sep 2019 21:49:27 +0000
treeherdermozilla-central@21aff209f5a9 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmixedpuppy
bugs1553384
milestone71.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1553384, update find extension api to support out-of-process iframes. r=mixedpuppy Differential Revision: https://phabricator.services.mozilla.com/D41229
browser/components/extensions/parent/ext-find.js
browser/components/extensions/test/browser/browser_ext_find.js
toolkit/actors/ExtFindChild.jsm
toolkit/components/extensions/FindContent.jsm
toolkit/modules/ActorManagerParent.jsm
--- a/browser/components/extensions/parent/ext-find.js
+++ b/browser/components/extensions/parent/ext-find.js
@@ -10,32 +10,169 @@
 ChromeUtils.defineModuleGetter(
   this,
   "PrivateBrowsingUtils",
   "resource://gre/modules/PrivateBrowsingUtils.jsm"
 );
 
 var { ExtensionError } = ExtensionUtils;
 
+// A mapping of top-level ExtFind actors to arrays of results in each subframe.
+let findResults = new WeakMap();
+
+function getActorForBrowsingContext(browsingContext) {
+  let windowGlobal = browsingContext.currentWindowGlobal;
+  return windowGlobal ? windowGlobal.getActor("ExtFind") : null;
+}
+
+function getTopLevelActor(browser) {
+  return getActorForBrowsingContext(browser.browsingContext);
+}
+
+function gatherActors(browsingContext) {
+  let list = [];
+
+  let actor = getActorForBrowsingContext(browsingContext);
+  if (actor) {
+    list.push({ actor, result: null });
+  }
+
+  let children = browsingContext.getChildren();
+  for (let child of children) {
+    list.push(...gatherActors(child));
+  }
+
+  return list;
+}
+
+function mergeFindResults(params, list) {
+  let finalResult = {
+    count: 0,
+  };
+
+  if (params.includeRangeData) {
+    finalResult.rangeData = [];
+  }
+  if (params.includeRectData) {
+    finalResult.rectData = [];
+  }
+
+  let currentFramePos = -1;
+  for (let item of list) {
+    if (item.result.count == 0) {
+      continue;
+    }
+
+    // The framePos is incremented for each different document that has matches.
+    currentFramePos++;
+
+    finalResult.count += item.result.count;
+    if (params.includeRangeData && item.result.rangeData) {
+      for (let range of item.result.rangeData) {
+        range.framePos = currentFramePos;
+      }
+
+      finalResult.rangeData.push(...item.result.rangeData);
+    }
+
+    if (params.includeRectData && item.result.rectData) {
+      finalResult.rectData.push(...item.result.rectData);
+    }
+  }
+
+  return finalResult;
+}
+
+function sendMessageToAllActors(browser, message, params) {
+  for (let { actor } of gatherActors(browser.browsingContext)) {
+    actor.sendAsyncMessage("ext-Finder:" + message, params);
+  }
+}
+
+async function getFindResultsForActor(findContext, message, params) {
+  findContext.result = await findContext.actor.sendQuery(
+    "ext-Finder:" + message,
+    params
+  );
+  return findContext;
+}
+
+function queryAllActors(browser, message, params) {
+  let promises = [];
+  for (let findContext of gatherActors(browser.browsingContext)) {
+    promises.push(getFindResultsForActor(findContext, message, params));
+  }
+  return Promise.all(promises);
+}
+
+async function collectFindResults(browser, findResults, params) {
+  let results = await queryAllActors(browser, "CollectResults", params);
+  findResults.set(getTopLevelActor(browser), results);
+  return mergeFindResults(params, results);
+}
+
+async function runHighlight(browser, params) {
+  let hasResults = false;
+  let foundResults = false;
+  let list = findResults.get(getTopLevelActor(browser));
+  if (!list) {
+    return Promise.reject({ message: "no search results to highlight" });
+  }
+
+  let highlightPromises = [];
+
+  let index = params.rangeIndex;
+  for (let c = 0; c < list.length; c++) {
+    if (list[c].result.count) {
+      hasResults = true;
+    }
+
+    let actor = list[c].actor;
+    if (!foundResults && index < list[c].result.count) {
+      foundResults = true;
+      params.rangeIndex = index;
+      highlightPromises.push(
+        actor.sendQuery("ext-Finder:HighlightResults", params)
+      );
+    } else {
+      highlightPromises.push(
+        actor.sendQuery("ext-Finder:ClearHighlighting", params)
+      );
+    }
+
+    index -= list[c].result.count;
+  }
+
+  let responses = await Promise.all(highlightPromises);
+  if (hasResults) {
+    if (responses.includes("OutOfRange") || index >= 0) {
+      return Promise.reject({ message: "index supplied was out of range" });
+    } else if (responses.includes("Success")) {
+      return;
+    }
+  }
+
+  return Promise.reject({ message: "no search results to highlight" });
+}
+
 /**
  * runFindOperation
  * Utility for `find` and `highlightResults`.
  *
  * @param {BaseContext} context - context the find operation runs in.
  * @param {object} params - params to pass to message sender.
  * @param {string} message - identifying component of message name.
  *
  * @returns {Promise} a promise that will be resolved or rejected based on the
  *          data received by the message listener.
  */
 function runFindOperation(context, params, message) {
   let { tabId } = params;
   let tab = tabId ? tabTracker.getTab(tabId) : tabTracker.activeTab;
   let browser = tab.linkedBrowser;
-  let mm = browser.messageManager;
   tabId = tabId || tabTracker.getId(tab);
   if (
     !context.privateBrowsingAllowed &&
     PrivateBrowsingUtils.isBrowserPrivate(browser)
   ) {
     return Promise.reject({ message: `Unable to search: ${tabId}` });
   }
   // We disallow find in about: urls.
@@ -44,40 +181,23 @@ function runFindOperation(context, param
     (["about", "chrome", "resource"].includes(
       tab.linkedBrowser.currentURI.scheme
     ) &&
       tab.linkedBrowser.currentURI.spec != "about:blank")
   ) {
     return Promise.reject({ message: `Unable to search: ${tabId}` });
   }
 
-  return new Promise((resolve, reject) => {
-    mm.addMessageListener(
-      `ext-Finder:${message}Finished`,
-      function messageListener(message) {
-        mm.removeMessageListener(
-          `ext-Finder:${message}Finished`,
-          messageListener
-        );
-        switch (message.data) {
-          case "Success":
-            resolve();
-            break;
-          case "OutOfRange":
-            reject({ message: "index supplied was out of range" });
-            break;
-          case "NoResults":
-            reject({ message: "no search results to highlight" });
-            break;
-        }
-        resolve(message.data);
-      }
-    );
-    mm.sendAsyncMessage(`ext-Finder:${message}`, params);
-  });
+  if (message == "HighlightResults") {
+    return runHighlight(browser, params);
+  } else if (message == "CollectResults") {
+    // Remove prior highlights before starting a new find operation.
+    findResults.delete(getTopLevelActor(browser));
+    return collectFindResults(browser, findResults, params);
+  }
 }
 
 this.find = class extends ExtensionAPI {
   getAPI(context) {
     return {
       find: {
         /**
          * browser.find.find
@@ -134,16 +254,14 @@ this.find = class extends ExtensionAPI {
         removeHighlighting(tabId) {
           let tab = tabId ? tabTracker.getTab(tabId) : tabTracker.activeTab;
           if (
             !context.privateBrowsingAllowed &&
             PrivateBrowsingUtils.isBrowserPrivate(tab.linkedBrowser)
           ) {
             throw new ExtensionError(`Invalid tab ID: ${tabId}`);
           }
-          tab.linkedBrowser.messageManager.sendAsyncMessage(
-            "ext-Finder:clearHighlighting"
-          );
+          sendMessageToAllActors(tab.linkedBrowser, "ClearHighlighting", {});
         },
       },
     };
   }
 };
--- a/browser/components/extensions/test/browser/browser_ext_find.js
+++ b/browser/components/extensions/test/browser/browser_ext_find.js
@@ -1,45 +1,41 @@
 /* global browser */
 "use strict";
 
 function frameScript() {
-  let frame = this.content.frames[0].frames[1];
-  let docShell = frame.docShell;
+  let docShell = content.docShell;
   let controller = docShell
     .QueryInterface(Ci.nsIInterfaceRequestor)
     .getInterface(Ci.nsISelectionDisplay)
     .QueryInterface(Ci.nsISelectionController);
   let selection = controller.getSelection(controller.SELECTION_FIND);
+  if (!selection.rangeCount) {
+    return {
+      text: "",
+    };
+  }
+
   let range = selection.getRangeAt(0);
   let scope = {};
   ChromeUtils.import("resource://gre/modules/FindContent.jsm", scope);
   let highlighter = new scope.FindContent(docShell).highlighter;
-  let r1 = frame.parent.frameElement.getBoundingClientRect();
-  let f1 = highlighter._getFrameElementOffsets(frame.parent);
-  let r2 = frame.frameElement.getBoundingClientRect();
-  let f2 = highlighter._getFrameElementOffsets(frame);
+  let r1 = content.parent.frameElement.getBoundingClientRect();
+  let f1 = highlighter._getFrameElementOffsets(content.parent);
+  let r2 = content.frameElement.getBoundingClientRect();
+  let f2 = highlighter._getFrameElementOffsets(content);
   let r3 = range.getBoundingClientRect();
   let rect = {
     top: r1.top + r2.top + r3.top + f1.y + f2.y,
     left: r1.left + r2.left + r3.left + f1.x + f2.x,
   };
-  this.sendAsyncMessage("test:find:selectionTest", {
+  return {
     text: selection.toString(),
     rect,
-  });
-}
-
-function waitForMessage(messageManager, topic) {
-  return new Promise(resolve => {
-    messageManager.addMessageListener(topic, function messageListener(message) {
-      messageManager.removeMessageListener(topic, messageListener);
-      resolve(message);
-    });
-  });
+  };
 }
 
 add_task(async function testFind() {
   async function background() {
     function awaitLoad(tabId, url) {
       return new Promise(resolve => {
         browser.tabs.onUpdated.addListener(function listener(
           tabId_,
@@ -246,46 +242,114 @@ add_task(async function testFind() {
     background,
   });
 
   await extension.startup();
   let rectData = await extension.awaitMessage("test:find:WebExtensionFinished");
   let { top, left } = rectData[5].rectsAndTexts.rectList[0];
   await extension.unload();
 
-  let { selectedBrowser } = gBrowser;
-
-  let frameScriptUrl = `data:,(${frameScript}).call(this)`;
-  selectedBrowser.messageManager.loadFrameScript(frameScriptUrl, false, true);
-  let message = await waitForMessage(
-    selectedBrowser.messageManager,
-    "test:find:selectionTest"
+  let subFrameBrowsingContext = gBrowser.selectedBrowser.browsingContext
+    .getChildren()[0]
+    .getChildren()[1];
+  let result = await SpecialPowers.spawn(
+    subFrameBrowsingContext,
+    [],
+    frameScript
   );
 
   info("Test that text was highlighted properly.");
   is(
-    message.data.text,
+    result.text,
     "bananA",
-    `The text that was highlighted: - Expected: bananA, Actual: ${
-      message.data.text
-    }`
+    `The text that was highlighted: - Expected: bananA, Actual: ${result.text}`
   );
 
   info(
     "Test that rectangle data returned from the search matches the highlighted result."
   );
   is(
-    message.data.rect.top,
+    result.rect.top,
     top,
-    `rect.top: - Expected: ${message.data.rect.top}, Actual: ${top}`
+    `rect.top: - Expected: ${result.rect.top}, Actual: ${top}`
   );
   is(
-    message.data.rect.left,
+    result.rect.left,
     left,
-    `rect.left: - Expected: ${message.data.rect.left}, Actual: ${left}`
+    `rect.left: - Expected: ${result.rect.left}, Actual: ${left}`
+  );
+});
+
+add_task(async function testRemoveHighlighting() {
+  async function background() {
+    function awaitLoad(tabId, url) {
+      return new Promise(resolve => {
+        browser.tabs.onUpdated.addListener(function listener(
+          tabId_,
+          changed,
+          tab
+        ) {
+          if (
+            tabId == tabId_ &&
+            changed.status == "complete" &&
+            tab.url == url
+          ) {
+            browser.tabs.onUpdated.removeListener(listener);
+            resolve();
+          }
+        });
+      });
+    }
+
+    let url =
+      "http://example.com/browser/browser/components/extensions/test/browser/file_find_frames.html";
+    let tab = await browser.tabs.update({ url });
+    await awaitLoad(tab.id, url);
+
+    let data = await browser.find.find("banana", { includeRangeData: true });
+
+    browser.test.log("Test that `data.count` is the expected value.");
+    browser.test.assertEq(
+      6,
+      data.count,
+      "The value returned from `data.count`"
+    );
+
+    await browser.find.highlightResults({ rangeIndex: 5 });
+
+    browser.find.removeHighlighting();
+
+    browser.test.sendMessage("test:find:WebExtensionFinished");
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      permissions: ["find", "tabs"],
+    },
+    background,
+  });
+
+  await extension.startup();
+  await extension.awaitMessage("test:find:WebExtensionFinished");
+  await extension.unload();
+
+  let subFrameBrowsingContext = gBrowser.selectedBrowser.browsingContext
+    .getChildren()[0]
+    .getChildren()[1];
+  let result = await SpecialPowers.spawn(
+    subFrameBrowsingContext,
+    [],
+    frameScript
+  );
+
+  info("Test that highlight was cleared properly.");
+  is(
+    result.text,
+    "",
+    `The text that was highlighted: - Expected: '', Actual: ${result.text}`
   );
 });
 
 add_task(async function testAboutFind() {
   async function background() {
     await browser.test.assertRejects(
       browser.find.find("banana"),
       /Unable to search:/,
--- a/toolkit/actors/ExtFindChild.jsm
+++ b/toolkit/actors/ExtFindChild.jsm
@@ -1,41 +1,34 @@
 /* vim: set ts=2 sw=2 sts=2 et tw=80: */
 /* 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";
 
 var EXPORTED_SYMBOLS = ["ExtFindChild"];
 
-const { ActorChild } = ChromeUtils.import(
-  "resource://gre/modules/ActorChild.jsm"
-);
-
 ChromeUtils.defineModuleGetter(
   this,
   "FindContent",
   "resource://gre/modules/FindContent.jsm"
 );
 
-class ExtFindChild extends ActorChild {
-  async receiveMessage(message) {
+class ExtFindChild extends JSWindowActorChild {
+  receiveMessage(message) {
     if (!this._findContent) {
-      this._findContent = new FindContent(this.mm.docShell);
+      this._findContent = new FindContent(this.docShell);
     }
 
-    let data;
     switch (message.name) {
       case "ext-Finder:CollectResults":
         this.finderInited = true;
-        data = await this._findContent.findRanges(message.data);
-        this.mm.sendAsyncMessage("ext-Finder:CollectResultsFinished", data);
-        break;
+        return this._findContent.findRanges(message.data);
       case "ext-Finder:HighlightResults":
-        data = this._findContent.highlightResults(message.data);
-        this.mm.sendAsyncMessage("ext-Finder:HighlightResultsFinished", data);
-        break;
-      case "ext-Finder:clearHighlighting":
+        return this._findContent.highlightResults(message.data);
+      case "ext-Finder:ClearHighlighting":
         this._findContent.highlighter.highlight(false);
         break;
     }
+
+    return null;
   }
 }
--- a/toolkit/components/extensions/FindContent.jsm
+++ b/toolkit/components/extensions/FindContent.jsm
@@ -4,57 +4,44 @@
  * 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";
 
 var EXPORTED_SYMBOLS = ["FindContent"];
 
 /* exported FindContent */
 
+ChromeUtils.defineModuleGetter(
+  this,
+  "FinderIterator",
+  "resource://gre/modules/FinderIterator.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+  this,
+  "FinderHighlighter",
+  "resource://gre/modules/FinderHighlighter.jsm"
+);
+
 class FindContent {
   constructor(docShell) {
     const { Finder } = ChromeUtils.import("resource://gre/modules/Finder.jsm");
     this.finder = new Finder(docShell);
   }
 
   get iterator() {
     if (!this._iterator) {
-      const { FinderIterator } = ChromeUtils.import(
-        "resource://gre/modules/FinderIterator.jsm"
-      );
-      this._iterator = Object.assign({}, FinderIterator);
-
-      // Native FinderIterator._collectFrames skips frames if they are scrolled out
-      // of viewport.  Override with method that doesn't do that.
-      this._iterator._collectFrames = window => {
-        let frames = [];
-        if (!("frames" in window) || !window.frames.length) {
-          return frames;
-        }
-
-        for (let i = 0, l = window.frames.length; i < l; ++i) {
-          let frame = window.frames[i];
-          if (!frame || !frame.frameElement) {
-            continue;
-          }
-          frames.push(frame, ...this._iterator._collectFrames(frame));
-        }
-
-        return frames;
-      };
+      this._iterator = new FinderIterator();
     }
     return this._iterator;
   }
 
   get highlighter() {
     if (!this._highlighter) {
-      const { FinderHighlighter } = ChromeUtils.import(
-        "resource://gre/modules/FinderHighlighter.jsm"
-      );
-      this._highlighter = new FinderHighlighter(this.finder);
+      this._highlighter = new FinderHighlighter(this.finder, true);
     }
     return this._highlighter;
   }
 
   /**
    * findRanges
    *
    * Performs a search which will cache found ranges in `iterator._previousRanges`.  Cached
@@ -85,17 +72,17 @@ class FindContent {
 
       // Cast `caseSensitive` and `entireWord` to boolean, otherwise _iterator.start will throw.
       let iteratorPromise = this.iterator.start({
         word: queryphrase,
         caseSensitive: !!caseSensitive,
         entireWord: !!entireWord,
         finder: this.finder,
         listener: this.finder,
-        useSubFrames: true,
+        useSubFrames: false,
       });
 
       iteratorPromise.then(() => {
         let rangeData;
         let rectData;
         if (includeRangeData) {
           rangeData = this._serializeRangeData();
         }
@@ -123,17 +110,16 @@ class FindContent {
    * @returns {array} - serializable range data.
    */
   _serializeRangeData() {
     let ranges = this.iterator._previousRanges;
 
     let rangeData = [];
     let nodeCountWin = 0;
     let lastDoc;
-    let framePos = -1;
     let walker;
     let node;
 
     for (let range of ranges) {
       let startContainer = range.startContainer;
       let doc = startContainer.ownerDocument;
 
       if (lastDoc !== doc) {
@@ -142,21 +128,21 @@ class FindContent {
           doc.defaultView.NodeFilter.SHOW_TEXT,
           null,
           false
         );
         // Get first node.
         node = walker.nextNode();
         // Reset node count.
         nodeCountWin = 0;
-        framePos++;
       }
       lastDoc = doc;
 
-      let data = { framePos, text: range.toString() };
+      // The framePos will be set by the parent process later.
+      let data = { framePos: 0, text: range.toString() };
       rangeData.push(data);
 
       if (node != range.startContainer) {
         node = walker.nextNode();
         while (node) {
           nodeCountWin++;
           if (node == range.startContainer) {
             break;
--- a/toolkit/modules/ActorManagerParent.jsm
+++ b/toolkit/modules/ActorManagerParent.jsm
@@ -158,16 +158,29 @@ let ACTORS = {
         MozUpdateDateTimePicker: {},
         MozCloseDateTimePicker: {},
       },
     },
 
     allFrames: true,
   },
 
+  ExtFind: {
+    child: {
+      moduleURI: "resource://gre/actors/ExtFindChild.jsm",
+      messages: [
+        "ext-Finder:CollectResults",
+        "ext-Finder:HighlightResults",
+        "ext-Finder:ClearHighlighting",
+      ],
+    },
+
+    allFrames: true,
+  },
+
   FindBar: {
     parent: {
       moduleURI: "resource://gre/actors/FindBarParent.jsm",
       messages: ["Findbar:Keypress", "Findbar:Mouseup"],
     },
     child: {
       moduleURI: "resource://gre/actors/FindBarChild.jsm",
       events: {
@@ -269,27 +282,16 @@ let ACTORS = {
 let LEGACY_ACTORS = {
   Controllers: {
     child: {
       module: "resource://gre/actors/ControllersChild.jsm",
       messages: ["ControllerCommands:Do", "ControllerCommands:DoWithParams"],
     },
   },
 
-  ExtFind: {
-    child: {
-      module: "resource://gre/actors/ExtFindChild.jsm",
-      messages: [
-        "ext-Finder:CollectResults",
-        "ext-Finder:HighlightResults",
-        "ext-Finder:clearHighlighting",
-      ],
-    },
-  },
-
   FormSubmit: {
     child: {
       module: "resource://gre/actors/FormSubmitChild.jsm",
       allFrames: true,
       events: {
         DOMFormBeforeSubmit: {},
       },
     },