Bug 1423725 add show/hide tabs api, r=rpl
authorShane Caraveo <scaraveo@mozilla.com>
Thu, 18 Jan 2018 16:37:18 -0700
changeset 451815 ea7705cc940fd908cfec76cf535629b1fbd78ab8
parent 451814 c7cc4bef1e90774a25df76e6336886b25bc38c9d
child 451816 3af31d84214f7cf4617a6798917ed1a0154f569d
push id8560
push userryanvm@gmail.com
push dateFri, 19 Jan 2018 16:34:00 +0000
treeherdermozilla-beta@4c9965a3b8a0 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrpl
bugs1423725
milestone59.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 1423725 add show/hide tabs api, r=rpl MozReview-Commit-ID: 4z73ZTRE7kN
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_hide.js
browser/components/extensions/test/browser/browser_ext_tabs_sharingState.js
browser/components/extensions/test/browser/head.js
browser/locales/en-US/chrome/browser/browser.properties
modules/libpref/init/all.js
--- a/browser/components/extensions/ext-tabs.js
+++ b/browser/components/extensions/ext-tabs.js
@@ -15,16 +15,21 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyGetter(this, "strBundle", function() {
   return Services.strings.createBundle("chrome://global/locale/extensions.properties");
 });
 
 var {
   ExtensionError,
 } = ExtensionUtils;
 
+const TABHIDE_PREFNAME = "extensions.webextensions.tabhide.enabled";
+
+// WeakMap[Tab -> ExtensionID]
+let hiddenTabs = new WeakMap();
+
 let tabListener = {
   tabReadyInitialized: false,
   tabReadyPromises: new WeakMap(),
   initializingTabs: new WeakSet(),
 
   initTabReady() {
     if (!this.tabReadyInitialized) {
       windowTracker.addListener("progress", this);
@@ -72,16 +77,37 @@ let tabListener = {
         this.tabReadyPromises.set(nativeTab, deferred);
       }
     }
     return deferred.promise;
   },
 };
 
 this.tabs = class extends ExtensionAPI {
+  onShutdown(reason) {
+    if (!this.extension.hasPermission("tabHide")) {
+      return;
+    }
+    if (reason == "ADDON_DISABLE" ||
+        reason == "ADDON_UNINSTALL") {
+      // Show all hidden tabs if a tab managing extension is uninstalled or
+      // disabled.  If a user has more than one, the extensions will need to
+      // self-manage re-hiding tabs.
+      for (let tab of this.extension.tabManager.query()) {
+        let nativeTab = tabTracker.getTab(tab.id);
+        if (hiddenTabs.get(nativeTab) === this.extension.id) {
+          hiddenTabs.delete(nativeTab);
+          if (nativeTab.ownerGlobal) {
+            nativeTab.ownerGlobal.gBrowser.showTab(nativeTab);
+          }
+        }
+      }
+    }
+  }
+
   getAPI(context) {
     let {extension} = context;
 
     let {tabManager} = extension;
 
     function getTabOrActive(tabId) {
       if (tabId !== null) {
         return tabTracker.getTab(tabId);
@@ -270,16 +296,18 @@ this.tabs = class extends ExtensionAPI {
               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");
+              // Always remove the tab from the hiddenTabs map.
+              hiddenTabs.delete(event.originalTarget);
             } 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];
@@ -984,13 +1012,54 @@ this.tabs = class extends ExtensionAPI {
           let tab = await promiseTabWhenReady(tabId);
           if (!tab.isInReaderMode && !tab.isArticle) {
             throw new ExtensionError("The specified tab cannot be placed into reader mode.");
           }
           tab = getTabOrActive(tabId);
 
           tab.linkedBrowser.messageManager.sendAsyncMessage("Reader:ToggleReaderMode");
         },
+
+        show(tabIds) {
+          if (!Services.prefs.getBoolPref(TABHIDE_PREFNAME, false)) {
+            throw new ExtensionError(`tabs.show is currently experimental and must be enabled with the ${TABHIDE_PREFNAME} preference.`);
+          }
+
+          if (!Array.isArray(tabIds)) {
+            tabIds = [tabIds];
+          }
+
+          for (let tabId of tabIds) {
+            let tab = tabTracker.getTab(tabId);
+            if (tab.ownerGlobal) {
+              hiddenTabs.delete(tab);
+              tab.ownerGlobal.gBrowser.showTab(tab);
+            }
+          }
+        },
+
+        hide(tabIds) {
+          if (!Services.prefs.getBoolPref(TABHIDE_PREFNAME, false)) {
+            throw new ExtensionError(`tabs.hide is currently experimental and must be enabled with the ${TABHIDE_PREFNAME} preference.`);
+          }
+
+          if (!Array.isArray(tabIds)) {
+            tabIds = [tabIds];
+          }
+
+          let hidden = [];
+          let tabs = tabIds.map(tabId => tabTracker.getTab(tabId));
+          for (let tab of tabs) {
+            if (tab.ownerGlobal && !tab.hidden) {
+              tab.ownerGlobal.gBrowser.hideTab(tab);
+              if (tab.hidden) {
+                hiddenTabs.set(tab, extension.id);
+                hidden.push(tabTracker.getId(tab));
+              }
+            }
+          }
+          return hidden;
+        },
       },
     };
     return self;
   }
 };
--- a/browser/components/extensions/schemas/tabs.json
+++ b/browser/components/extensions/schemas/tabs.json
@@ -7,17 +7,18 @@
     "namespace": "manifest",
     "types": [
       {
         "$extend": "OptionalPermission",
         "choices": [{
           "type": "string",
           "enum": [
             "activeTab",
-            "tabs"
+            "tabs",
+            "tabHide"
           ]
         }]
       }
     ]
   },
   {
     "namespace": "tabs",
     "description": "Use the <code>browser.tabs</code> API to interact with the browser's tab system. You can use this API to create, modify, and rearrange tabs in the browser.",
@@ -1272,16 +1273,50 @@
               {
                 "type": "string",
                 "name": "status",
                 "description": "Save status: saved, replaced, canceled, not_saved, not_replaced."
               }
             ]
           }
         ]
+      },
+      {
+        "name": "show",
+        "type": "function",
+        "description": "Shows one or more tabs.",
+        "permissions": ["tabHide"],
+        "async": true,
+        "parameters": [
+          {
+            "name": "tabIds",
+            "description": "The TAB ID or list of TAB IDs to show.",
+            "choices": [
+              {"type": "integer", "minimum": 0},
+              {"type": "array", "items": {"type": "integer", "minimum": 0}}
+            ]
+          }
+        ]
+      },
+      {
+        "name": "hide",
+        "type": "function",
+        "description": "Hides one or more tabs. The <code>\"tabHide\"</code> permission is required to hide tabs.  Not all tabs are hidable.  Returns an array of hidden tabs.",
+        "permissions": ["tabHide"],
+        "async": true,
+        "parameters": [
+          {
+            "name": "tabIds",
+            "description": "The TAB ID or list of TAB IDs to hide.",
+            "choices": [
+              {"type": "integer", "minimum": 0},
+              {"type": "array", "items": {"type": "integer", "minimum": 0}}
+            ]
+          }
+        ]
       }
     ],
     "events": [
       {
         "name": "onCreated",
         "type": "function",
         "description": "Fired when a tab is created. Note that the tab's URL may not be set at the time this event fired, but you can listen to onUpdated events to be notified when a URL is set.",
         "parameters": [
--- a/browser/components/extensions/test/browser/browser-common.ini
+++ b/browser/components/extensions/test/browser/browser-common.ini
@@ -148,16 +148,17 @@ skip-if = !e10s
 [browser_ext_tabs_events.js]
 [browser_ext_tabs_executeScript.js]
 [browser_ext_tabs_executeScript_good.js]
 [browser_ext_tabs_executeScript_bad.js]
 [browser_ext_tabs_executeScript_multiple.js]
 [browser_ext_tabs_executeScript_no_create.js]
 [browser_ext_tabs_executeScript_runAt.js]
 [browser_ext_tabs_getCurrent.js]
+[browser_ext_tabs_hide.js]
 [browser_ext_tabs_insertCSS.js]
 [browser_ext_tabs_lastAccessed.js]
 [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]
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_hide.js
@@ -0,0 +1,203 @@
+"use strict";
+
+const {Utils} = Cu.import("resource://gre/modules/sessionstore/Utils.jsm", {});
+const triggeringPrincipal_base64 = Utils.SERIALIZED_SYSTEMPRINCIPAL;
+
+// Ensure the pref prevents API use when the extension has the tabHide permission.
+add_task(async function test_pref_disabled() {
+  async function background() {
+    let tabs = await browser.tabs.query({hidden: false});
+    let ids = tabs.map(tab => tab.id);
+
+    await browser.test.assertRejects(
+      browser.tabs.hide(ids),
+      /tabs.hide is currently experimental/,
+      "Got the expected error when pref not enabled"
+    ).catch(err => {
+      browser.test.notifyFail("pref-test");
+      throw err;
+    });
+
+    browser.test.notifyPass("pref-test");
+  }
+
+  let extdata = {
+    manifest: {permissions: ["tabs", "tabHide"]},
+    background,
+  };
+  let extension = ExtensionTestUtils.loadExtension(extdata);
+  await extension.startup();
+  await extension.awaitFinish("pref-test");
+  await extension.unload();
+});
+
+add_task(async function test_tabs_showhide() {
+  await SpecialPowers.pushPrefEnv({
+    set: [["extensions.webextensions.tabhide.enabled", true]],
+  });
+
+  async function background() {
+    browser.test.onMessage.addListener(async (msg, data) => {
+      switch (msg) {
+        case "hideall": {
+          let tabs = await browser.tabs.query({hidden: false});
+          browser.test.assertEq(tabs.length, 5, "got 5 tabs");
+          let ids = tabs.map(tab => tab.id);
+          browser.test.log(`working with ids ${JSON.stringify(ids)}`);
+
+          let hidden = await browser.tabs.hide(ids);
+          browser.test.assertEq(hidden.length, 3, "hid 3 tabs");
+          tabs = await browser.tabs.query({hidden: true});
+          ids = tabs.map(tab => tab.id);
+          browser.test.assertEq(JSON.stringify(hidden.sort()),
+                                JSON.stringify(ids.sort()), "hidden tabIds match");
+
+          browser.test.sendMessage("hidden", {hidden});
+          break;
+        }
+        case "showall": {
+          let tabs = await browser.tabs.query({hidden: true});
+          for (let tab of tabs) {
+            browser.test.assertTrue(tab.hidden, "tab is hidden");
+          }
+          let ids = tabs.map(tab => tab.id);
+          browser.tabs.show(ids);
+          browser.test.sendMessage("shown");
+          break;
+        }
+      }
+    });
+  }
+
+  let extdata = {
+    manifest: {permissions: ["tabs", "tabHide"]},
+    background,
+  };
+  let extension = ExtensionTestUtils.loadExtension(extdata);
+  await extension.startup();
+
+  let sessData = {
+    windows: [{
+      tabs: [
+        {entries: [{url: "about:blank", triggeringPrincipal_base64}]},
+        {entries: [{url: "https://example.com/", triggeringPrincipal_base64}]},
+        {entries: [{url: "https://mochi.test:8888/", triggeringPrincipal_base64}]},
+      ],
+    }, {
+      tabs: [
+        {entries: [{url: "about:blank", triggeringPrincipal_base64}]},
+        {entries: [{url: "http://test1.example.com/", triggeringPrincipal_base64}]},
+      ],
+    }],
+  };
+
+  // Set up a test session with 2 windows and 5 tabs.
+  let oldState = SessionStore.getBrowserState();
+  let restored = TestUtils.topicObserved("sessionstore-browser-state-restored");
+  SessionStore.setBrowserState(JSON.stringify(sessData));
+  await restored;
+
+  // Attempt to hide all the tabs, however the active tab in each window cannot
+  // be hidden, so the result will be 3 hidden tabs.
+  extension.sendMessage("hideall");
+  await extension.awaitMessage("hidden");
+
+  // We have 2 windows in this session.  Otherwin is the non-current window.
+  // In each window, the first tab will be the selected tab and should not be
+  // hidden.  The rest of the tabs should be hidden at this point.  Hidden
+  // status was already validated inside the extension, this double checks
+  // from chrome code.
+  let otherwin;
+  for (let win of BrowserWindowIterator()) {
+    if (win != window) {
+      otherwin = win;
+    }
+    let tabs = Array.from(win.gBrowser.tabs.values());
+    ok(!tabs[0].hidden, "first tab not hidden");
+    for (let i = 1; i < tabs.length; i++) {
+      ok(tabs[i].hidden, "tab hidden value is correct");
+    }
+  }
+
+  // Test closing the last visible tab, the next tab which is hidden should become
+  // the selectedTab and will be visible.
+  ok(!otherwin.gBrowser.selectedTab.hidden, "selected tab is not hidden");
+  await BrowserTestUtils.removeTab(otherwin.gBrowser.selectedTab);
+  ok(!otherwin.gBrowser.selectedTab.hidden, "tab was unhidden");
+
+  // Showall will unhide any remaining hidden tabs.
+  extension.sendMessage("showall");
+  await extension.awaitMessage("shown");
+
+  // Check from chrome code that all tabs are visible again.
+  for (let win of BrowserWindowIterator()) {
+    let tabs = Array.from(win.gBrowser.tabs.values());
+    for (let i = 0; i < tabs.length; i++) {
+      ok(!tabs[i].hidden, "tab hidden value is correct");
+    }
+  }
+
+  // Close second window.
+  await BrowserTestUtils.closeWindow(otherwin);
+
+  await extension.unload();
+
+  // Restore pre-test state.
+  restored = TestUtils.topicObserved("sessionstore-browser-state-restored");
+  SessionStore.setBrowserState(oldState);
+  await restored;
+});
+
+// Test our shutdown handling.  Currently this means any hidden tabs will be
+// shown when a tabHide extension is shutdown.  We additionally test the
+// tabs.onUpdated listener gets called with hidden state changes.
+add_task(async function test_tabs_shutdown() {
+  await SpecialPowers.pushPrefEnv({
+    set: [["extensions.webextensions.tabhide.enabled", true]],
+  });
+
+  let tabs = [
+    await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.com/", true, true),
+    await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://mochi.test:8888/", true, true),
+  ];
+
+  async function background() {
+    let tabs = await browser.tabs.query({url: "http://example.com/"});
+    let testTab = tabs[0];
+
+    browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
+      if ("hidden" in changeInfo) {
+        browser.test.assertEq(tabId, testTab.id, "correct tab was hidden");
+        browser.test.assertTrue(changeInfo.hidden, "tab is hidden");
+        browser.test.sendMessage("changeInfo");
+      }
+    });
+
+    let hidden = await browser.tabs.hide(testTab.id);
+    browser.test.assertEq(hidden[0], testTab.id, "tab was hidden");
+    tabs = await browser.tabs.query({hidden: true});
+    browser.test.assertEq(tabs[0].id, testTab.id, "tab was hidden");
+    browser.test.sendMessage("ready");
+  }
+
+  let extdata = {
+    manifest: {permissions: ["tabs", "tabHide"]},
+    useAddonManager: "temporary", // For testing onShutdown.
+    background,
+  };
+  let extension = ExtensionTestUtils.loadExtension(extdata);
+  await extension.startup();
+
+  // test onUpdated
+  await Promise.all([
+    extension.awaitMessage("ready"),
+    extension.awaitMessage("changeInfo"),
+  ]);
+  Assert.ok(tabs[0].hidden, "Tab is hidden by extension");
+
+  await extension.unload();
+
+  Assert.ok(!tabs[0].hidden, "Tab is not hidden after unloading extension");
+  await BrowserTestUtils.removeTab(tabs[0]);
+  await BrowserTestUtils.removeTab(tabs[1]);
+});
--- a/browser/components/extensions/test/browser/browser_ext_tabs_sharingState.js
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_sharingState.js
@@ -1,11 +1,15 @@
 "use strict";
 
 add_task(async function test_tabs_mediaIndicators() {
+  await SpecialPowers.pushPrefEnv({
+    set: [["extensions.webextensions.tabhide.enabled", true]],
+  });
+
   let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.com/");
   // setBrowserSharing is called when a request for media icons occurs.  We're
   // just testing that extension tabs get the info and are updated when it is
   // called.
   gBrowser.setBrowserSharing(tab.linkedBrowser, {screen: "Window", microphone: true, camera: true});
 
   async function background() {
     let tabs = await browser.tabs.query({microphone: true});
@@ -20,31 +24,37 @@ add_task(async function test_tabs_mediaI
     browser.test.assertEq(tabs.length, 1, "screen sharing tab was found");
 
     tabs = await browser.tabs.query({screen: "Window"});
     browser.test.assertEq(tabs.length, 1, "screen sharing (window) tab was found");
 
     tabs = await browser.tabs.query({screen: "Screen"});
     browser.test.assertEq(tabs.length, 0, "screen sharing tab was not found");
 
+    // Verify we cannot hide a sharing tab.
+    let hidden = await browser.tabs.hide(testTab.id);
+    browser.test.assertEq(hidden.length, 0, "unable to hide sharing tab");
+    tabs = await browser.tabs.query({hidden: true});
+    browser.test.assertEq(tabs.length, 0, "unable to hide sharing tab");
+
     browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
       if (testTab.id !== tabId) {
         return;
       }
       let state = tab.sharingState;
       browser.test.assertFalse(state.camera, "sharing camera was turned off");
       browser.test.assertFalse(state.microphone, "sharing mic was turned off");
       browser.test.assertFalse(state.screen, "sharing screen was turned off");
       browser.test.notifyPass("done");
     });
     browser.test.sendMessage("ready");
   }
 
   let extdata = {
-    manifest: {permissions: ["tabs"]},
+    manifest: {permissions: ["tabs", "tabHide"]},
     useAddonManager: "temporary",
     background,
   };
   let extension = ExtensionTestUtils.loadExtension(extdata);
   await extension.startup();
 
   // Test that onUpdated is called after the sharing state is changed from
   // chrome code.
--- a/browser/components/extensions/test/browser/head.js
+++ b/browser/components/extensions/test/browser/head.js
@@ -15,17 +15,17 @@
  *          openTabContextMenu closeTabContextMenu
  *          openToolsMenu closeToolsMenu
  *          imageBuffer imageBufferFromDataURI
  *          getListStyleImage getPanelForNode
  *          awaitExtensionPanel awaitPopupResize
  *          promiseContentDimensions alterContent
  *          promisePrefChangeObserved openContextMenuInFrame
  *          promiseAnimationFrame getCustomizableUIPanelID
- *          awaitEvent
+ *          awaitEvent BrowserWindowIterator
  */
 
 // There are shutdown issues for which multiple rejections are left uncaught.
 // This bug should be fixed, but for the moment this directory is whitelisted.
 //
 // NOTE: Entire directory whitelisting should be kept to a minimum. Normally you
 //       should use "expectUncaughtRejection" to flag individual failures.
 const {PromiseTestUtils} = Cu.import("resource://testing-common/PromiseTestUtils.jsm", {});
@@ -479,8 +479,18 @@ function awaitEvent(eventName, id) {
         Management.off(eventName, listener);
         resolve();
       }
     };
 
     Management.on(eventName, listener);
   });
 }
+
+function* BrowserWindowIterator() {
+  let windowsEnum = Services.wm.getEnumerator("navigator:browser");
+  while (windowsEnum.hasMoreElements()) {
+    let currentWindow = windowsEnum.getNext();
+    if (!currentWindow.closed) {
+      yield currentWindow;
+    }
+  }
+}
--- a/browser/locales/en-US/chrome/browser/browser.properties
+++ b/browser/locales/en-US/chrome/browser/browser.properties
@@ -111,16 +111,17 @@ webextPerms.description.management=Monit
 # %S will be replaced with the name of the application
 webextPerms.description.nativeMessaging=Exchange messages with programs other than %S
 webextPerms.description.notifications=Display notifications to you
 webextPerms.description.pkcs11=Provide cryptographic authentication services
 webextPerms.description.privacy=Read and modify privacy settings
 webextPerms.description.proxy=Control browser proxy settings
 webextPerms.description.sessions=Access recently closed tabs
 webextPerms.description.tabs=Access browser tabs
+webextPerms.description.tabHide=Hide and show browser tabs
 webextPerms.description.topSites=Access browsing history
 webextPerms.description.unlimitedStorage=Store unlimited amount of client-side data
 webextPerms.description.webNavigation=Access browser activity during navigation
 
 webextPerms.hostDescription.allUrls=Access your data for all websites
 
 # LOCALIZATION NOTE (webextPerms.hostDescription.wildcard)
 # %S will be replaced by the DNS domain for which a webextension
--- a/modules/libpref/init/all.js
+++ b/modules/libpref/init/all.js
@@ -5021,16 +5021,19 @@ pref("extensions.webextensions.identity.
 pref("extensions.webextensions.themes.enabled", false);
 pref("extensions.webextensions.themes.icons.enabled", false);
 pref("extensions.webextensions.remote", false);
 // Whether or not the moz-extension resource loads are remoted. For debugging
 // purposes only. Setting this to false will break moz-extension URI loading
 // unless other process sandboxing and extension remoting prefs are changed.
 pref("extensions.webextensions.protocol.remote", true);
 
+// Disable tab hiding API by default.
+pref("extensions.webextensions.tabhide.enabled", false);
+
 // Report Site Issue button
 pref("extensions.webcompat-reporter.newIssueEndpoint", "https://webcompat.com/issues/new");
 #if defined(MOZ_DEV_EDITION) || defined(NIGHTLY_BUILD)
 pref("extensions.webcompat-reporter.enabled", true);
 #else
 pref("extensions.webcompat-reporter.enabled", false);
 #endif