Bug 1329507 add filtering to tabs.onUpdated, r?kmag draft
authorShane Caraveo <scaraveo@mozilla.com>
Mon, 05 Mar 2018 18:14:22 -0600
changeset 763470 2b4897837c003a7dd2d677cebf1162a3a82835b4
parent 763109 51200c0fdaddb2749549a82596da5323a4cbd499
push id101460
push usermixedpuppy@gmail.com
push dateTue, 06 Mar 2018 00:15:27 +0000
reviewerskmag
bugs1329507
milestone60.0a1
Bug 1329507 add filtering to tabs.onUpdated, r?kmag Add filtering of urls, properties, window and tab id to onUpdated events to help reduce the quantity of update events that are dispatched. MozReview-Commit-ID: J8Rh9uEt1gW
browser/components/extensions/ext-tabs.js
browser/components/extensions/schemas/tabs.json
browser/components/extensions/test/browser/browser-common.ini
browser/components/extensions/test/browser/browser_ext_tabs_onUpdated_filter.js
--- a/browser/components/extensions/ext-tabs.js
+++ b/browser/components/extensions/ext-tabs.js
@@ -1,14 +1,15 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 "use strict";
 
 // The ext-* files are imported into the same scopes.
 /* import-globals-from ext-browser.js */
+/* import-globals-from ../../../toolkit/components/extensions/ext-tabs-base.js */
 
 ChromeUtils.defineModuleGetter(this, "PrivateBrowsingUtils",
                                "resource://gre/modules/PrivateBrowsingUtils.jsm");
 ChromeUtils.defineModuleGetter(this, "PromiseUtils",
                                "resource://gre/modules/PromiseUtils.jsm");
 ChromeUtils.defineModuleGetter(this, "Services",
                                "resource://gre/modules/Services.jsm");
 ChromeUtils.defineModuleGetter(this, "SessionStore",
@@ -92,16 +93,208 @@ let tabListener = {
         this.initTabReady();
         this.tabReadyPromises.set(nativeTab, deferred);
       }
     }
     return deferred.promise;
   },
 };
 
+const allAttrs = new Set(["audible", "favIconUrl", "mutedInfo", "sharingState", "title"]);
+const allProperties = [
+  "audible",
+  "discarded",
+  "favIconUrl",
+  "hidden",
+  "isarticle",
+  "mutedInfo",
+  "pinned",
+  "sharingState",
+  "status",
+  "title",
+];
+
+class TabsUpdateFilterEventManager extends EventManager {
+  constructor(context, eventName) {
+    let {extension} = context;
+    let {tabManager} = extension;
+
+    let register = (fire, filterProps) => {
+      const restricted = ["url", "favIconUrl", "title"];
+
+      let filter = {...filterProps};
+      if (filter.urls) {
+        filter.urls = new MatchPatternSet(filter.urls);
+      }
+      let needsModified = true;
+      if (!filter.propertyNames) {
+        // Default is to listen for all events.
+        filter.propertyNames = allProperties;
+      } else {
+        needsModified = filter.propertyNames.some(p => allAttrs.has(p));
+      }
+
+      function sanitize(extension, changeInfo) {
+        let result = {};
+        let nonempty = false;
+        let hasTabs = extension.hasPermission("tabs");
+        for (let prop in changeInfo) {
+          if (hasTabs || !restricted.includes(prop)) {
+            nonempty = true;
+            result[prop] = changeInfo[prop];
+          }
+        }
+        return [nonempty, result];
+      }
+
+      function getWindowID(windowId) {
+        if (windowId == WINDOW_ID_CURRENT) {
+          return windowTracker.getId(windowTracker.topWindow);
+        }
+        return windowId;
+      }
+
+      function matchFilters(tab, changed) {
+        if (!filterProps) {
+          return true;
+        }
+        if (filter.tabId != null && tab.id != filter.tabId) {
+          return false;
+        }
+        if (filter.windowId != null && tab.windowId != getWindowID(filter.windowId)) {
+          return false;
+        }
+        if (filter.urls) {
+          let nativeTab = tabTracker.getTab(tab.id);
+          return filter.urls.matches(nativeTab.linkedBrowser.currentURI);
+        }
+        return true;
+      }
+
+      let fireForTab = (tab, changed) => {
+        if (!matchFilters(tab, changed)) {
+          return;
+        }
+
+        let [needed, changeInfo] = sanitize(extension, changed);
+        if (needed) {
+          fire.async(tab.id, changeInfo, tab.convert());
+        }
+      };
+
+      let listener = event => {
+        let needed = [];
+        if (event.type == "TabAttrModified") {
+          let changed = event.detail.changed;
+          if (changed.includes("image") && filter.propertyNames.includes("favIconUrl")) {
+            needed.push("favIconUrl");
+          }
+          if (changed.includes("muted") && filter.propertyNames.includes("mutedInfo")) {
+            needed.push("mutedInfo");
+          }
+          if (changed.includes("soundplaying") && filter.propertyNames.includes("audible")) {
+            needed.push("audible");
+          }
+          if (changed.includes("label") && filter.propertyNames.includes("title")) {
+            needed.push("title");
+          }
+          if (changed.includes("sharing") && filter.propertyNames.includes("sharingState")) {
+            needed.push("sharingState");
+          }
+        } else if (event.type == "TabPinned") {
+          needed.push("pinned");
+        } else if (event.type == "TabUnpinned") {
+          needed.push("pinned");
+        } else if (event.type == "TabBrowserInserted" &&
+                   !event.detail.insertedOnTabCreation) {
+          needed.push("discarded");
+        } else if (event.type == "TabBrowserDiscarded") {
+          needed.push("discarded");
+        } else if (event.type == "TabShow") {
+          needed.push("hidden");
+        } else if (event.type == "TabHide") {
+          needed.push("hidden");
+        }
+
+        let tab = tabManager.getWrapper(event.originalTarget);
+
+        let changeInfo = {};
+        for (let prop of needed) {
+          changeInfo[prop] = tab[prop];
+        }
+
+        fireForTab(tab, changeInfo);
+      };
+
+      let statusListener = ({browser, status, url}) => {
+        let {gBrowser} = browser.ownerGlobal;
+        let tabElem = gBrowser.getTabForBrowser(browser);
+        if (tabElem) {
+          let changed = {status};
+          if (url) {
+            changed.url = url;
+          }
+
+          fireForTab(tabManager.wrapTab(tabElem), changed);
+        }
+      };
+
+      let isArticleChangeListener = (messageName, message) => {
+        let {gBrowser} = message.target.ownerGlobal;
+        let nativeTab = gBrowser.getTabForBrowser(message.target);
+
+        if (nativeTab) {
+          let tab = tabManager.getWrapper(nativeTab);
+          fireForTab(tab, {isArticle: message.data.isArticle});
+        }
+      };
+
+      let listeners = new Map();
+      if (filter.propertyNames.includes("status")) {
+        listeners.set("status", statusListener);
+      }
+      if (needsModified) {
+        listeners.set("TabAttrModified", listener);
+      }
+      if (filter.propertyNames.includes("pinned")) {
+        listeners.set("TabPinned", listener);
+        listeners.set("TabUnpinned", listener);
+      }
+      if (filter.propertyNames.includes("discarded")) {
+        listeners.set("TabBrowserInserted", listener);
+        listeners.set("TabBrowserDiscarded", listener);
+      }
+      if (filter.propertyNames.includes("hidden")) {
+        listeners.set("TabShow", listener);
+        listeners.set("TabHide", listener);
+      }
+
+      for (let [name, listener] of listeners) {
+        windowTracker.addListener(name, listener);
+      }
+
+      if (filter.propertyNames.includes("isarticle")) {
+        tabTracker.on("tab-isarticle", isArticleChangeListener);
+      }
+
+      return () => {
+        for (let [name, listener] of listeners) {
+          windowTracker.removeListener(name, listener);
+        }
+
+        if (filter.propertyNames.includes("isarticle")) {
+          tabTracker.off("tab-isarticle", isArticleChangeListener);
+        }
+      };
+    };
+
+    super(context, eventName, register);
+  }
+}
+
 this.tabs = class extends ExtensionAPI {
   static onUpdate(id, manifest) {
     if (!manifest.permissions || !manifest.permissions.includes("tabHide")) {
       showHiddenTabs(id);
     }
   }
 
   static onDisable(id) {
@@ -249,127 +442,17 @@ this.tabs = class extends ExtensionAPI {
           windowTracker.addListener("TabMove", moveListener);
           windowTracker.addListener("TabOpen", openListener);
           return () => {
             windowTracker.removeListener("TabMove", moveListener);
             windowTracker.removeListener("TabOpen", openListener);
           };
         }).api(),
 
-        onUpdated: new EventManager(context, "tabs.onUpdated", fire => {
-          const restricted = ["url", "favIconUrl", "title"];
-
-          function sanitize(extension, changeInfo) {
-            let result = {};
-            let nonempty = false;
-            for (let prop in changeInfo) {
-              if (extension.hasPermission("tabs") || !restricted.includes(prop)) {
-                nonempty = true;
-                result[prop] = changeInfo[prop];
-              }
-            }
-            return [nonempty, result];
-          }
-
-          let fireForTab = (tab, changed) => {
-            let [needed, changeInfo] = sanitize(extension, changed);
-            if (needed) {
-              fire.async(tab.id, changeInfo, tab.convert());
-            }
-          };
-
-          let listener = event => {
-            let needed = [];
-            if (event.type == "TabAttrModified") {
-              let changed = event.detail.changed;
-              if (changed.includes("image")) {
-                needed.push("favIconUrl");
-              }
-              if (changed.includes("muted")) {
-                needed.push("mutedInfo");
-              }
-              if (changed.includes("soundplaying")) {
-                needed.push("audible");
-              }
-              if (changed.includes("label")) {
-                needed.push("title");
-              }
-              if (changed.includes("sharing")) {
-                needed.push("sharingState");
-              }
-            } else if (event.type == "TabPinned") {
-              needed.push("pinned");
-            } else if (event.type == "TabUnpinned") {
-              needed.push("pinned");
-            } else if (event.type == "TabBrowserInserted" &&
-                       !event.detail.insertedOnTabCreation) {
-              needed.push("discarded");
-            } else if (event.type == "TabBrowserDiscarded") {
-              needed.push("discarded");
-            } else if (event.type == "TabShow") {
-              needed.push("hidden");
-            } else if (event.type == "TabHide") {
-              needed.push("hidden");
-            }
-
-            let tab = tabManager.getWrapper(event.originalTarget);
-            let changeInfo = {};
-            for (let prop of needed) {
-              changeInfo[prop] = tab[prop];
-            }
-
-            fireForTab(tab, changeInfo);
-          };
-
-          let statusListener = ({browser, status, url}) => {
-            let {gBrowser} = browser.ownerGlobal;
-            let tabElem = gBrowser.getTabForBrowser(browser);
-            if (tabElem) {
-              let changed = {status};
-              if (url) {
-                changed.url = url;
-              }
-
-              fireForTab(tabManager.wrapTab(tabElem), changed);
-            }
-          };
-
-          let isArticleChangeListener = (messageName, message) => {
-            let {gBrowser} = message.target.ownerGlobal;
-            let nativeTab = gBrowser.getTabForBrowser(message.target);
-
-            if (nativeTab) {
-              let tab = tabManager.getWrapper(nativeTab);
-              fireForTab(tab, {isArticle: message.data.isArticle});
-            }
-          };
-
-          windowTracker.addListener("status", statusListener);
-          windowTracker.addListener("TabAttrModified", listener);
-          windowTracker.addListener("TabPinned", listener);
-          windowTracker.addListener("TabUnpinned", listener);
-          windowTracker.addListener("TabBrowserInserted", listener);
-          windowTracker.addListener("TabBrowserDiscarded", listener);
-          windowTracker.addListener("TabShow", listener);
-          windowTracker.addListener("TabHide", listener);
-
-          tabTracker.on("tab-isarticle", isArticleChangeListener);
-
-          return () => {
-            windowTracker.removeListener("status", statusListener);
-            windowTracker.removeListener("TabAttrModified", listener);
-            windowTracker.removeListener("TabPinned", listener);
-            windowTracker.removeListener("TabUnpinned", listener);
-            windowTracker.removeListener("TabBrowserInserted", listener);
-            windowTracker.removeListener("TabBrowserDiscarded", listener);
-            windowTracker.removeListener("TabShow", listener);
-            windowTracker.removeListener("TabHide", listener);
-            tabTracker.off("tab-isarticle", isArticleChangeListener);
-          };
-        }).api(),
+        onUpdated: new TabsUpdateFilterEventManager(context, "tabs.onUpdated").api(),
 
         create(createProperties) {
           return new Promise((resolve, reject) => {
             let window = createProperties.windowId !== null ?
               windowTracker.getWindow(createProperties.windowId, context) :
               windowTracker.topNormalWindow;
 
             if (!window.gBrowser) {
--- a/browser/components/extensions/schemas/tabs.json
+++ b/browser/components/extensions/schemas/tabs.json
@@ -283,16 +283,56 @@
         "enum": ["loading", "complete"],
         "description": "Whether the tabs have completed loading."
       },
       {
         "id": "WindowType",
         "type": "string",
         "enum": ["normal", "popup", "panel", "app", "devtools"],
         "description": "The type of window."
+      },
+      {
+        "id": "UpdatePropertyName",
+        "type": "string",
+        "enum": [
+          "audible",
+          "discarded",
+          "favIconUrl",
+          "hidden",
+          "isarticle",
+          "mutedInfo",
+          "pinned",
+          "sharingState",
+          "status",
+          "title"
+        ],
+        "description": "Event names supported in onUpdated."
+      },
+      {
+        "id": "UpdateFilter",
+        "type": "object",
+        "description": "An object describing filters to apply to tabs.onUpdated events.",
+        "properties": {
+          "urls": {
+            "type": "array",
+            "description": "A list of URLs or URL patterns. Events that cannot match any of the URLs will be filtered out.",
+            "optional": true,
+            "items": { "type": "string" },
+            "minItems": 1
+          },
+          "propertyNames": {
+            "type": "array",
+            "optional": true,
+            "description": "A list of property names. Events that do not match any of the names will be filtered out.",
+            "items": { "$ref": "UpdatePropertyName" },
+            "minItems": 1
+          },
+          "tabId": { "type": "integer", "optional": true },
+          "windowId": { "type": "integer", "optional": true }
+        }
       }
     ],
     "properties": {
       "TAB_ID_NONE": {
         "value": -1,
         "description": "An ID which represents the absence of a browser tab."
       }
     },
@@ -1395,16 +1435,24 @@
               }
             }
           },
           {
             "$ref": "Tab",
             "name": "tab",
             "description": "Gives the state of the tab that was updated."
           }
+        ],
+        "extraParameters": [
+          {
+            "$ref": "UpdateFilter",
+            "name": "filter",
+            "optional": true,
+            "description": "A set of filters that restricts the events that will be sent to this listener."
+          }
         ]
       },
       {
         "name": "onMoved",
         "type": "function",
         "description": "Fired when a tab is moved within a window. Only one move event is fired, representing the tab the user directly moved. Move events are not fired for the other tabs that must move in response. This event is not fired when a tab is moved between windows. For that, see $(ref:tabs.onDetached).",
         "parameters": [
           {"type": "integer", "name": "tabId", "minimum": 0},
--- a/browser/components/extensions/test/browser/browser-common.ini
+++ b/browser/components/extensions/test/browser/browser-common.ini
@@ -171,16 +171,17 @@ skip-if = !e10s
 [browser_ext_tabs_lazy.js]
 [browser_ext_tabs_removeCSS.js]
 [browser_ext_tabs_move_array.js]
 [browser_ext_tabs_move_window.js]
 [browser_ext_tabs_move_window_multiple.js]
 [browser_ext_tabs_move_window_pinned.js]
 [browser_ext_tabs_onHighlighted.js]
 [browser_ext_tabs_onUpdated.js]
+[browser_ext_tabs_onUpdated_filter.js]
 [browser_ext_tabs_opener.js]
 [browser_ext_tabs_printPreview.js]
 [browser_ext_tabs_query.js]
 [browser_ext_tabs_readerMode.js]
 [browser_ext_tabs_reload.js]
 [browser_ext_tabs_reload_bypass_cache.js]
 [browser_ext_tabs_saveAsPDF.js]
 skip-if = os == 'mac' # Save as PDF not supported on Mac OS X
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_onUpdated_filter.js
@@ -0,0 +1,149 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function test_filter_url() {
+  let ext_fail = ExtensionTestUtils.loadExtension({
+    permissions: "tabs",
+    background() {
+      browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
+        browser.test.fail(`received unexpected onUpdated event ${JSON.stringify(changeInfo)}`);
+      }, {urls: ["*://*.mozilla.org/*"]});
+    },
+  });
+  await ext_fail.startup();
+
+  let ext_ok = ExtensionTestUtils.loadExtension({
+    permissions: "tabs",
+    background() {
+      browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
+        browser.test.log(`got onUpdated ${JSON.stringify(changeInfo)}`);
+        if (changeInfo.status === "complete") {
+          browser.test.notifyPass("onUpdated");
+        }
+      }, {urls: ["*://mochi.test/*"]});
+      browser.test.sendMessage("ready");
+    },
+  });
+  await ext_ok.startup();
+  await ext_ok.awaitMessage("ready");
+  let ok = ext_ok.awaitFinish("onUpdated");
+
+  let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://mochi.test:8888/");
+  await ok;
+
+  await ext_ok.unload();
+  await ext_fail.unload();
+
+  await BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_filter_tabId() {
+  let ext_fail = ExtensionTestUtils.loadExtension({
+    permissions: "tabs",
+    background() {
+      browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
+        browser.test.fail(`received unexpected onUpdated event ${JSON.stringify(changeInfo)}`);
+      }, {tabId: 12345});
+    },
+  });
+  await ext_fail.startup();
+
+  let ext_ok = ExtensionTestUtils.loadExtension({
+    permissions: "tabs",
+    background() {
+      browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
+        if (changeInfo.status === "complete") {
+          browser.test.notifyPass("onUpdated");
+        }
+      });
+      browser.test.sendMessage("ready");
+    },
+  });
+  await ext_ok.startup();
+  await ext_ok.awaitMessage("ready");
+  let ok = ext_ok.awaitFinish("onUpdated");
+
+  let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://mochi.test:8888/");
+  await ok;
+
+  await ext_ok.unload();
+  await ext_fail.unload();
+
+  await BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_filter_windowId() {
+  let ext_fail = ExtensionTestUtils.loadExtension({
+    permissions: "tabs",
+    background() {
+      browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
+        browser.test.fail(`received unexpected onUpdated event ${JSON.stringify(changeInfo)}`);
+      }, {windowId: 12345});
+    },
+  });
+  await ext_fail.startup();
+
+  let ext_ok = ExtensionTestUtils.loadExtension({
+    permissions: "tabs",
+    background() {
+      browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
+        if (changeInfo.status === "complete") {
+          browser.test.notifyPass("onUpdated");
+        }
+      }, {windowId: browser.windows.WINDOW_ID_CURRENT});
+      browser.test.sendMessage("ready");
+    },
+  });
+  await ext_ok.startup();
+  await ext_ok.awaitMessage("ready");
+  let ok = ext_ok.awaitFinish("onUpdated");
+
+  let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://mochi.test:8888/");
+  await ok;
+
+  await ext_ok.unload();
+  await ext_fail.unload();
+
+  await BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_filter_property() {
+  let extension = ExtensionTestUtils.loadExtension({
+    permissions: "tabs",
+    background() {
+      // We expect only status updates, anything else is a failure.
+      let properties = new Set([
+        "audible",
+        "discarded",
+        "favIconUrl",
+        "hidden",
+        "isarticle",
+        "mutedInfo",
+        "pinned",
+        "sharingState",
+        "title",
+      ]);
+      browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
+        browser.test.log(`got onUpdated ${JSON.stringify(changeInfo)}`);
+        if (Object.keys(changeInfo).some(p => properties.has(p))) {
+          browser.test.fail(`received unexpected onUpdated event ${JSON.stringify(changeInfo)}`);
+        }
+        if (changeInfo.status === "complete") {
+          browser.test.notifyPass("onUpdated");
+        }
+      }, {propertyNames: ["status"]});
+      browser.test.sendMessage("ready");
+    },
+  });
+  await extension.startup();
+  await extension.awaitMessage("ready");
+  let ok = extension.awaitFinish("onUpdated");
+
+  let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://mochi.test:8888/");
+  await ok;
+
+  await extension.unload();
+
+  await BrowserTestUtils.removeTab(tab);
+});