Bug 1423725 add show/hide tabs api, r?rpl draft
authorShane Caraveo <scaraveo@mozilla.com>
Thu, 18 Jan 2018 16:37:18 -0700
changeset 722404 1a0a2f439964ea520af160bccd998c19e25c5d99
parent 722403 ad8f7420a88fa2dbb70ca1688ca02ce6e68118ef
child 746614 562d604bcd66088d087ab9012784c06cc3364abf
push id96149
push usermixedpuppy@gmail.com
push dateThu, 18 Jan 2018 23:37:40 +0000
reviewersrpl
bugs1423725
milestone59.0a1
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
@@ -5018,16 +5018,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