--- 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,218 @@ let tabListener = {
this.initTabReady();
this.tabReadyPromises.set(nativeTab, deferred);
}
}
return deferred.promise;
},
};
+const allAttrs = new Set(["audible", "favIconUrl", "mutedInfo", "sharingState", "title"]);
+const allProperties = new Set([
+ "audible",
+ "discarded",
+ "favIconUrl",
+ "hidden",
+ "isarticle",
+ "mutedInfo",
+ "pinned",
+ "sharingState",
+ "status",
+ "title",
+]);
+const restricted = new Set(["url", "favIconUrl", "title"]);
+
+class TabsUpdateFilterEventManager extends EventManager {
+ constructor(context, eventName) {
+ let {extension} = context;
+ let {tabManager} = extension;
+
+ let register = (fire, filterProps) => {
+ let filter = {...filterProps};
+ if (filter.urls) {
+ filter.urls = new MatchPatternSet(filter.urls);
+ }
+ let needsModified = true;
+ if (filter.properties) {
+ // Default is to listen for all events.
+ needsModified = filter.properties.some(p => allAttrs.has(p));
+ filter.properties = new Set(filter.properties);
+ } else {
+ filter.properties = allProperties;
+ }
+
+ function sanitize(extension, changeInfo) {
+ let result = {};
+ let nonempty = false;
+ let hasTabs = extension.hasPermission("tabs");
+ for (let prop in changeInfo) {
+ if (hasTabs || !restricted.has(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) {
+ // We check permission first because tab.uri is null if !hasTabPermission.
+ return tab.hasTabPermission && filter.urls.matches(tab.uri);
+ }
+ return true;
+ }
+
+ let fireForTab = (tab, changed) => {
+ if (!matchFilters(tab, changed)) {
+ return;
+ }
+
+ let changeInfo = sanitize(extension, changed);
+ if (changeInfo) {
+ 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.properties.has("favIconUrl")) {
+ needed.push("favIconUrl");
+ }
+ if (changed.includes("muted") && filter.properties.has("mutedInfo")) {
+ needed.push("mutedInfo");
+ }
+ if (changed.includes("soundplaying") && filter.properties.has("audible")) {
+ needed.push("audible");
+ }
+ if (changed.includes("label") && filter.properties.has("title")) {
+ needed.push("title");
+ }
+ if (changed.includes("sharing") && filter.properties.has("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.properties.has("status")) {
+ listeners.set("status", statusListener);
+ }
+ if (needsModified) {
+ listeners.set("TabAttrModified", listener);
+ }
+ if (filter.properties.has("pinned")) {
+ listeners.set("TabPinned", listener);
+ listeners.set("TabUnpinned", listener);
+ }
+ if (filter.properties.has("discarded")) {
+ listeners.set("TabBrowserInserted", listener);
+ listeners.set("TabBrowserDiscarded", listener);
+ }
+ if (filter.properties.has("hidden")) {
+ listeners.set("TabShow", listener);
+ listeners.set("TabHide", listener);
+ }
+
+ for (let [name, listener] of listeners) {
+ windowTracker.addListener(name, listener);
+ }
+
+ if (filter.properties.has("isarticle")) {
+ tabTracker.on("tab-isarticle", isArticleChangeListener);
+ }
+
+ return () => {
+ for (let [name, listener] of listeners) {
+ windowTracker.removeListener(name, listener);
+ }
+
+ if (filter.properties.has("isarticle")) {
+ tabTracker.off("tab-isarticle", isArticleChangeListener);
+ }
+ };
+ };
+
+ super(context, eventName, register);
+ }
+
+ addListener(callback, filter) {
+ let {extension} = this.context;
+ if (filter && filter.urls &&
+ (!extension.hasPermission("tabs") && !extension.hasPermission("activeTab"))) {
+ Cu.reportError("Url filtering in tabs.onUpdated requires \"tabs\" or \"activeTab\" permission.");
+ return false;
+ }
+ return super.addListener(callback, filter);
+ }
+}
+
this.tabs = class extends ExtensionAPI {
static onUpdate(id, manifest) {
if (!manifest.permissions || !manifest.permissions.includes("tabHide")) {
showHiddenTabs(id);
}
}
static onDisable(id) {
@@ -249,127 +452,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. Filtering with urls requires the <code>\"tabs\"</code> or <code>\"activeTab\"</code> permission.",
+ "optional": true,
+ "items": { "type": "string" },
+ "minItems": 1
+ },
+ "properties": {
+ "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,241 @@
+/* -*- 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({
+ manifest: {
+ 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_perm = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
+ browser.test.fail(`received unexpected onUpdated event without tabs permission`);
+ }, {urls: ["*://mochi.test/*"]});
+ },
+ });
+ await ext_perm.startup();
+
+ let ext_ok = ExtensionTestUtils.loadExtension({
+ manifest: {
+ 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/*"]});
+ },
+ });
+ await ext_ok.startup();
+ let ok1 = ext_ok.awaitFinish("onUpdated");
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://mochi.test:8888/");
+ await ok1;
+
+ await ext_ok.unload();
+ await ext_fail.unload();
+ await ext_perm.unload();
+
+ await BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_filter_url_activeTab() {
+ let ext = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["activeTab"],
+ },
+ background() {
+ browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
+ browser.test.fail("should only have notification for activeTab, selectedTab is not activeTab");
+ }, {urls: ["*://mochi.test/*"]});
+ },
+ });
+ await ext.startup();
+
+ let ext2 = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["tabs"],
+ },
+ background() {
+ browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
+ if (changeInfo.status === "complete") {
+ browser.test.notifyPass("onUpdated");
+ }
+ }, {urls: ["*://mochi.test/*"]});
+ },
+ });
+ await ext2.startup();
+ let ok = ext2.awaitFinish("onUpdated");
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://mochi.test:8888/#foreground");
+ await Promise.all([ok]);
+
+ await ext.unload();
+ await ext2.unload();
+ await BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_filter_tabId() {
+ let ext_fail = ExtensionTestUtils.loadExtension({
+ manifest: {
+ 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({
+ manifest: {
+ permissions: ["tabs"],
+ },
+ background() {
+ browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
+ if (changeInfo.status === "complete") {
+ browser.test.notifyPass("onUpdated");
+ }
+ });
+ },
+ });
+ await ext_ok.startup();
+ let ok = ext_ok.awaitFinish("onUpdated");
+
+ let ext_ok2 = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["tabs"],
+ },
+ background() {
+ browser.tabs.onCreated.addListener(tab => {
+ browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
+ if (changeInfo.status === "complete") {
+ browser.test.notifyPass("onUpdated");
+ }
+ }, {tabId: tab.id});
+ browser.test.log(`Tab specific tab listener on tab ${tab.id}`);
+ });
+ },
+ });
+ await ext_ok2.startup();
+ let ok2 = ext_ok2.awaitFinish("onUpdated");
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://mochi.test:8888/");
+ await Promise.all([ok, ok2]);
+
+ await ext_ok.unload();
+ await ext_ok2.unload();
+ await ext_fail.unload();
+
+ await BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_filter_windowId() {
+ let ext_fail = ExtensionTestUtils.loadExtension({
+ manifest: {
+ 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({
+ manifest: {
+ permissions: ["tabs"],
+ },
+ background() {
+ browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
+ if (changeInfo.status === "complete") {
+ browser.test.notifyPass("onUpdated");
+ }
+ }, {windowId: browser.windows.WINDOW_ID_CURRENT});
+ },
+ });
+ await ext_ok.startup();
+ let ok = ext_ok.awaitFinish("onUpdated");
+
+ let ext_ok2 = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["tabs"],
+ },
+ async background() {
+ let window = await browser.windows.getCurrent();
+ browser.test.log(`Window specific tab listener on window ${window.id}`);
+ browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
+ if (changeInfo.status === "complete") {
+ browser.test.notifyPass("onUpdated");
+ }
+ }, {windowId: window.id});
+ browser.test.sendMessage("ready");
+ },
+ });
+ await ext_ok2.startup();
+ await ext_ok2.awaitMessage("ready");
+ let ok2 = ext_ok2.awaitFinish("onUpdated");
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://mochi.test:8888/");
+ await Promise.all([ok, ok2]);
+
+ await ext_ok.unload();
+ await ext_ok2.unload();
+ await ext_fail.unload();
+
+ await BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_filter_property() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ 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)}`);
+ browser.test.assertTrue(!!changeInfo.status, "changeInfo has status");
+ 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");
+ }
+ }, {properties: ["status"]});
+ },
+ });
+ await extension.startup();
+ let ok = extension.awaitFinish("onUpdated");
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://mochi.test:8888/");
+ await ok;
+
+ await extension.unload();
+
+ await BrowserTestUtils.removeTab(tab);
+});
--- a/browser/components/extensions/test/mochitest/test_ext_all_apis.html
+++ b/browser/components/extensions/test/mochitest/test_ext_all_apis.html
@@ -14,16 +14,17 @@
/* exported expectedContentApisTargetSpecific, expectedBackgroundApisTargetSpecific */
let expectedContentApisTargetSpecific = [
];
let expectedBackgroundApisTargetSpecific = [
"tabs.MutedInfoReason",
"tabs.TAB_ID_NONE",
"tabs.TabStatus",
+ "tabs.UpdatePropertyName",
"tabs.WindowType",
"tabs.ZoomSettingsMode",
"tabs.ZoomSettingsScope",
"tabs.connect",
"tabs.create",
"tabs.detectLanguage",
"tabs.duplicate",
"tabs.discard",