Bug 1238314: [webext] Implement tabs API `openerTabId` properties. r?billm
MozReview-Commit-ID: 8PLsV8h4gRR
--- a/browser/components/extensions/ext-tabs.js
+++ b/browser/components/extensions/ext-tabs.js
@@ -392,26 +392,35 @@ extensions.registerSchemaAPI("tabs", nul
AllWindowEvents.removeListener("progress", progressListener);
AllWindowEvents.removeListener("TabAttrModified", listener);
AllWindowEvents.removeListener("TabPinned", listener);
AllWindowEvents.removeListener("TabUnpinned", listener);
};
}).api(),
create: function(createProperties) {
- return new Promise(resolve => {
+ return new Promise((resolve, reject) => {
function createInWindow(window) {
let url;
if (createProperties.url !== null) {
url = context.uri.resolve(createProperties.url);
} else {
url = window.BROWSER_NEW_TAB_URL;
}
- let tab = window.gBrowser.addTab(url);
+ let ownerTab;
+ if (createProperties.openerTabId !== null) {
+ ownerTab = TabManager.getTab(createProperties.openerTabId);
+ if (ownerTab.ownerDocument.defaultView !== window) {
+ return reject({message: "Opener tab must be in the same window as the tab being created"});
+ }
+ }
+
+ let relatedToCurrent = ownerTab == window.gBrowser.selectedTab;
+ let tab = window.gBrowser.addTab(url, {ownerTab, relatedToCurrent});
let active = true;
if (createProperties.active !== null) {
active = createProperties.active;
}
if (active) {
window.gBrowser.selectedTab = tab;
}
@@ -478,17 +487,26 @@ extensions.registerSchemaAPI("tabs", nul
}
if (updateProperties.pinned !== null) {
if (updateProperties.pinned) {
tabbrowser.pinTab(tab);
} else {
tabbrowser.unpinTab(tab);
}
}
- // FIXME: highlighted/selected, openerTabId
+
+ if (updateProperties.openerTabId !== null) {
+ let opener = TabManager.getTab(updateProperties.openerTabId);
+ if (opener.ownerDocument !== tab.ownerDocument) {
+ return Promise.reject({message: "Opener tab must be in the same window as the tab being updated"});
+ }
+ TabManager.setOpener(tab, opener);
+ }
+
+ // FIXME: highlighted/selected
return Promise.resolve(TabManager.convert(extension, tab));
},
reload: function(tabId, reloadProperties) {
let tab = tabId !== null ? TabManager.getTab(tabId) : TabManager.activeTab;
let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
if (reloadProperties && reloadProperties.bypassCache) {
--- a/browser/components/extensions/ext-utils.js
+++ b/browser/components/extensions/ext-utils.js
@@ -442,16 +442,21 @@ ExtensionTabManager.prototype = {
status: TabManager.getStatus(tab),
incognito: PrivateBrowsingUtils.isBrowserPrivate(tab.linkedBrowser),
width: tab.linkedBrowser.clientWidth,
height: tab.linkedBrowser.clientHeight,
audible: tab.soundPlaying,
mutedInfo,
};
+ let opener = TabManager.getOpener(tab);
+ if (opener != null) {
+ result.openerTabId = opener;
+ }
+
if (this.hasTabPermission(tab)) {
result.url = tab.linkedBrowser.currentURI.spec;
if (tab.linkedBrowser.contentTitle) {
result.title = tab.linkedBrowser.contentTitle;
}
let icon = window.gBrowser.getIcon(tab);
if (icon) {
result.favIconUrl = icon;
@@ -464,17 +469,22 @@ ExtensionTabManager.prototype = {
getTabs(window) {
return Array.from(window.gBrowser.tabs, tab => this.convert(tab));
},
};
// Manages global mappings between XUL tabs and extension tab IDs.
global.TabManager = {
+ // A mapping of <tab> elements to their IDs.
+ // WeakMap[<tab> -> number]
_tabs: new WeakMap(),
+ // A mapping of <tab> elements to the IDs of their openers.
+ // WeakMap[<tab> -> number]
+ _tabOpeners: new WeakMap(),
_nextId: 1,
_initialized: false,
// We begin listening for TabOpen and TabClose events once we've started
// assigning IDs to tabs, so that we can remap the IDs of tabs which are moved
// between windows.
initListener() {
if (this._initialized) {
@@ -561,16 +571,51 @@ global.TabManager = {
get activeTab() {
let window = WindowManager.topWindow;
if (window && window.gBrowser) {
return window.gBrowser.selectedTab;
}
return null;
},
+ /**
+ * Returns the ID of the tab which opened this tab, if the opener tab still
+ * exists. This generally corresponds to the `.owner` property of a tab
+ * element but, unlike that property, is not cleared after an unrelated tab is
+ * opened.
+ *
+ * @param {<tab>} tab The tab for which to find an owner.
+ * @returns {number|null}
+ */
+ getOpener(tab) {
+ let opener = tab.owner || this._tabOpeners.get(tab);
+ if (opener && opener.parentNode && opener.ownerDocument === tab.ownerDocument) {
+ // If this came from the `.owner` property, make sure we cache the value
+ // so it persists after that property is cleared.
+ this._tabOpeners.set(tab, opener);
+ return this.getId(opener);
+ }
+ return null;
+ },
+
+ /**
+ * Sets the opener of `tab` to the ID `openerTab`. Both tabs must be in the
+ * same window, or this function will throw a type error.
+ *
+ * @param {<tab>} tab The tab for which to set the owner.
+ * @param {<tab>} openerTab The opener of <tab>.
+ */
+ setOpener(tab, openerTab) {
+ if (tab.ownerDocument !== openerTab.ownerDocument) {
+ throw new TypeError("Tab must be in the same window as its opener");
+ }
+ tab.owner = openerTab;
+ this._tabOpeners.set(tab, openerTab);
+ },
+
getStatus(tab) {
return tab.getAttribute("busy") == "true" ? "loading" : "complete";
},
convert(extension, tab) {
return TabManager.for(extension).convert(tab);
},
};
--- a/browser/components/extensions/schemas/tabs.json
+++ b/browser/components/extensions/schemas/tabs.json
@@ -54,17 +54,17 @@
},
{
"id": "Tab",
"type": "object",
"properties": {
"id": {"type": "integer", "minimum": -1, "optional": true, "description": "The ID of the tab. Tab IDs are unique within a browser session. Under some circumstances a Tab may not be assigned an ID, for example when querying foreign tabs using the $(ref:sessions) API, in which case a session ID may be present. Tab ID can also be set to $(ref:tabs.TAB_ID_NONE) for apps and devtools windows."},
"index": {"type": "integer", "minimum": -1, "description": "The zero-based index of the tab within its window."},
"windowId": {"type": "integer", "minimum": 0, "description": "The ID of the window the tab is contained within."},
- "openerTabId": {"unsupported": true, "type": "integer", "minimum": 0, "optional": true, "description": "The ID of the tab that opened this tab, if any. This property is only present if the opener tab still exists."},
+ "openerTabId": {"type": "integer", "minimum": 0, "optional": true, "description": "The ID of the tab that opened this tab, if any. This property is only present if the opener tab still exists."},
"selected": {"type": "boolean", "description": "Whether the tab is selected.", "deprecated": "Please use $(ref:tabs.Tab.highlighted).", "unsupported": true},
"highlighted": {"type": "boolean", "description": "Whether the tab is highlighted."},
"active": {"type": "boolean", "description": "Whether the tab is active in its window. (Does not necessarily mean the window is focused.)"},
"pinned": {"type": "boolean", "description": "Whether the tab is pinned."},
"audible": {"type": "boolean", "optional": true, "description": "Whether the tab has produced sound over the past couple of seconds (but it might not be heard if also muted). Equivalent to whether the speaker audio indicator is showing."},
"mutedInfo": {"$ref": "MutedInfo", "optional": true, "description": "Current tab muted state and the reason for the last state change."},
"url": {"type": "string", "optional": true, "description": "The URL the tab is displaying. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission."},
"title": {"type": "string", "optional": true, "description": "The title of the tab. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission."},
@@ -379,17 +379,16 @@
"description": "Whether the tab should become the selected tab in the window. Defaults to <var>true</var>"
},
"pinned": {
"type": "boolean",
"optional": true,
"description": "Whether the tab should be pinned. Defaults to <var>false</var>"
},
"openerTabId": {
- "unsupported": true,
"type": "integer",
"minimum": 0,
"optional": true,
"description": "The ID of the tab that opened this tab. If specified, the opener tab must be in the same window as the newly created tab."
}
}
},
{
@@ -615,17 +614,16 @@
"description": "Whether the tab should be pinned."
},
"muted": {
"type": "boolean",
"optional": true,
"description": "Whether the tab should be muted."
},
"openerTabId": {
- "unsupported": true,
"type": "integer",
"minimum": 0,
"optional": true,
"description": "The ID of the tab that opened this tab. If specified, the opener tab must be in the same window as this tab."
}
}
},
{
--- a/browser/components/extensions/test/browser/browser.ini
+++ b/browser/components/extensions/test/browser/browser.ini
@@ -30,16 +30,17 @@ support-files =
[browser_ext_tabs_executeScript_bad.js]
[browser_ext_tabs_insertCSS.js]
[browser_ext_tabs_query.js]
[browser_ext_tabs_getCurrent.js]
[browser_ext_tabs_create.js]
[browser_ext_tabs_duplicate.js]
[browser_ext_tabs_update.js]
[browser_ext_tabs_onUpdated.js]
+[browser_ext_tabs_opener.js]
[browser_ext_tabs_sendMessage.js]
[browser_ext_tabs_move.js]
[browser_ext_tabs_move_window.js]
[browser_ext_windows_create_tabId.js]
[browser_ext_windows_update.js]
[browser_ext_contentscript_connect.js]
[browser_ext_tab_runtimeConnect.js]
[browser_ext_webNavigation_getFrames.js]
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_opener.js
@@ -0,0 +1,80 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(function* () {
+ let tab1 = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank?1");
+ let tab2 = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank?2");
+
+ gBrowser.selectedTab = tab1;
+
+ function background() {
+ let activeTab;
+ let tabId;
+ let tabIds;
+ browser.tabs.query({lastFocusedWindow: true}).then(tabs => {
+ browser.test.assertEq(tabs.length, 3, "We have three tabs");
+
+ browser.test.assertTrue(tabs[1].active, "Tab 1 is active");
+ activeTab = tabs[1];
+
+ tabIds = tabs.map(tab => tab.id);
+
+ return browser.tabs.create({openerTabId: activeTab.id, active: false});
+ }).then(tab => {
+ browser.test.assertEq(activeTab.id, tab.openerTabId, "Tab opener ID is correct");
+ browser.test.assertEq(activeTab.index + 1, tab.index, "Tab was inserted after the related current tab");
+
+ tabId = tab.id;
+ return browser.tabs.get(tabId);
+ }).then(tab => {
+ browser.test.assertEq(activeTab.id, tab.openerTabId, "Tab opener ID is still correct");
+
+ return browser.tabs.update(tabId, {openerTabId: tabIds[0]});
+ }).then(tab => {
+ browser.test.assertEq(tabIds[0], tab.openerTabId, "Updated tab opener ID is correct");
+
+ return browser.tabs.get(tabId);
+ }).then(tab => {
+ browser.test.assertEq(tabIds[0], tab.openerTabId, "Updated tab opener ID is still correct");
+
+ return browser.tabs.create({openerTabId: tabId, active: false});
+ }).then(tab => {
+ browser.test.assertEq(tabId, tab.openerTabId, "New tab opener ID is correct");
+ browser.test.assertEq(tabIds.length + 1, tab.index, "New tab was not inserted after the unrelated current tab");
+
+ let promise = browser.tabs.remove(tabId);
+
+ tabId = tab.id;
+ return promise;
+ }).then(() => {
+ return browser.tabs.get(tabId);
+ }).then(tab => {
+ browser.test.assertEq(undefined, tab.openerTabId, "Tab opener ID was cleared after opener tab closed");
+
+ return browser.tabs.remove(tabId);
+ }).then(() => {
+ browser.test.notifyPass("tab-opener");
+ }).catch(e => {
+ browser.test.fail(`${e} :: ${e.stack}`);
+ browser.test.notifyFail("tab-opener");
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ "permissions": ["tabs"],
+ },
+
+ background,
+ });
+
+ yield extension.startup();
+
+ yield extension.awaitFinish("tab-opener");
+
+ yield extension.unload();
+
+ yield BrowserTestUtils.removeTab(tab1);
+ yield BrowserTestUtils.removeTab(tab2);
+});