--- 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