Bug 1238314: Part 2 - Implement browser.tabs openerTabId functionality. r=aswan
MozReview-Commit-ID: L4ycNoQDfa
--- a/browser/components/extensions/ext-tabs.js
+++ b/browser/components/extensions/ext-tabs.js
@@ -358,16 +358,24 @@ this.tabs = class extends ExtensionAPI {
}
// Make sure things like about:blank and data: URIs never inherit,
// and instead always get a NullPrincipal.
options.disallowInheritPrincipal = true;
tabListener.initTabReady();
let currentTab = window.gBrowser.selectedTab;
+
+ if (createProperties.openerTabId !== null) {
+ options.ownerTab = tabTracker.getTab(createProperties.openerTabId);
+ if (options.ownerTab.ownerGlobal !== window) {
+ return Promise.reject({message: "Opener tab must be in the same window as the tab being created"});
+ }
+ }
+
let nativeTab = window.gBrowser.addTab(url || window.BROWSER_NEW_TAB_URL, options);
let active = true;
if (createProperties.active !== null) {
active = createProperties.active;
}
if (active) {
window.gBrowser.selectedTab = nativeTab;
@@ -441,17 +449,23 @@ this.tabs = class extends ExtensionAPI {
}
if (updateProperties.pinned !== null) {
if (updateProperties.pinned) {
tabbrowser.pinTab(nativeTab);
} else {
tabbrowser.unpinTab(nativeTab);
}
}
- // FIXME: highlighted/selected, openerTabId
+ if (updateProperties.openerTabId !== null) {
+ let opener = tabTracker.getTab(updateProperties.openerTabId);
+ if (opener.ownerDocument !== nativeTab.ownerDocument) {
+ return Promise.reject({message: "Opener tab must be in the same window as the tab being updated"});
+ }
+ tabTracker.setOpener(nativeTab, opener);
+ }
return tabManager.convert(nativeTab);
},
async reload(tabId, reloadProperties) {
let nativeTab = getTabOrActive(tabId);
let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
--- a/browser/components/extensions/ext-utils.js
+++ b/browser/components/extensions/ext-utils.js
@@ -211,16 +211,47 @@ class TabTracker extends TabTrackerBase
}
if (default_ !== undefined) {
return default_;
}
throw new ExtensionError(`Invalid tab ID: ${tabId}`);
}
/**
+ * 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 {Element} tab The tab for which to find an owner.
+ * @returns {number|null}
+ */
+ getOpenerId(tab) {
+ let opener = tab.openerTab;
+ if (opener && opener.parentNode && opener.ownerDocument == tab.ownerDocument) {
+ 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 {Element} tab The tab for which to set the owner.
+ * @param {Element} openerTab The opener of <tab>.
+ */
+ setOpener(tab, openerTab) {
+ if (tab.ownerDocument !== openerTab.ownerDocument) {
+ throw new Error("Tab must be in the same window as its opener");
+ }
+ tab.openerTab = openerTab;
+ }
+
+ /**
* @param {Event} event
* The DOM Event to handle.
* @private
*/
handleEvent(event) {
let nativeTab = event.target;
switch (event.type) {
@@ -491,16 +522,20 @@ class Tab extends TabBase {
// height.
return super.frameLoader || {lazyWidth: 0, lazyHeight: 0};
}
get cookieStoreId() {
return getCookieStoreIdForTab(this, this.nativeTab);
}
+ get openerTabId() {
+ return tabTracker.getOpenerId(this.nativeTab);
+ }
+
get height() {
return this.frameLoader.lazyHeight;
}
get index() {
return this.nativeTab._tPos;
}
--- 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", "optional": true, "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. Works as an alias of active"},
"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."},
"lastAccessed": {"type": "integer", "optional": true, "description": "The last time the tab was accessed as the number of milliseconds since epoch."},
"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, "permissions": ["tabs"], "description": "The URL the tab is displaying. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission."},
@@ -480,17 +480,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."
},
"cookieStoreId": {
"type": "string",
"optional": true,
@@ -619,16 +618,22 @@
"optional": true,
"minimum": 0,
"description": "The position of the tabs within their windows."
},
"cookieStoreId": {
"type": "string",
"optional": true,
"description": "The CookieStoreId used for the tab."
+ },
+ "openerTabId": {
+ "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."
}
}
},
{
"type": "function",
"name": "callback",
"parameters": [
{
@@ -728,17 +733,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-common.ini
+++ b/browser/components/extensions/test/browser/browser-common.ini
@@ -129,16 +129,17 @@ skip-if = debug || asan # Bug 1354681
[browser_ext_tabs_lastAccessed.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_opener.js]
[browser_ext_tabs_printPreview.js]
[browser_ext_tabs_query.js]
[browser_ext_tabs_reload.js]
[browser_ext_tabs_reload_bypass_cache.js]
[browser_ext_tabs_sendMessage.js]
[browser_ext_tabs_cookieStoreId.js]
[browser_ext_tabs_update.js]
[browser_ext_tabs_zoom.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_opener.js
@@ -0,0 +1,78 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function() {
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank?1");
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank?2");
+
+ gBrowser.selectedTab = tab1;
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ "permissions": ["tabs"],
+ },
+
+ background() {
+ let activeTab;
+ let tabId;
+ let tabIds;
+ browser.tabs.query({lastFocusedWindow: true}).then(tabs => {
+ browser.test.assertEq(3, tabs.length, "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, 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");
+ });
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitFinish("tab-opener");
+
+ await extension.unload();
+
+ await BrowserTestUtils.removeTab(tab1);
+ await BrowserTestUtils.removeTab(tab2);
+});
--- a/toolkit/components/extensions/ExtensionTabs.jsm
+++ b/toolkit/components/extensions/ExtensionTabs.jsm
@@ -311,16 +311,25 @@ class TabBase {
* @readonly
* @abstract
*/
get cookieStoreId() {
throw new Error("Not implemented");
}
/**
+ * @property {integer} openerTabId
+ * Returns the ID of the tab which opened this one.
+ * @readonly
+ */
+ get openerTabId() {
+ return null;
+ }
+
+ /**
* @property {integer} height
* Returns the pixel height of the visible area of the tab.
* @readonly
* @abstract
*/
get height() {
throw new Error("Not implemented");
}
@@ -446,19 +455,19 @@ class TabBase {
* than an exact value match, and will do so in the future.
* @param {MatchPattern} [queryInfo.url]
* Requires the tab's URL to match the given MatchPattern object.
*
* @returns {boolean}
* True if the tab matches the query.
*/
matches(queryInfo) {
- const PROPS = ["active", "audible", "cookieStoreId", "highlighted", "index", "pinned", "status", "title"];
+ const PROPS = ["active", "audible", "cookieStoreId", "highlighted", "index", "openerTabId", "pinned", "status", "title"];
- if (PROPS.some(prop => queryInfo[prop] !== null && queryInfo[prop] !== this[prop])) {
+ if (PROPS.some(prop => queryInfo[prop] != null && queryInfo[prop] !== this[prop])) {
return false;
}
if (queryInfo.muted !== null) {
if (queryInfo.muted !== this.mutedInfo.muted) {
return false;
}
}
@@ -499,16 +508,21 @@ class TabBase {
// If the tab has not been fully layed-out yet, fallback to the geometry
// from a different tab (usually the currently active tab).
if (fallbackTab && (!result.width || !result.height)) {
result.width = fallbackTab.width;
result.height = fallbackTab.height;
}
+ let opener = this.openerTabId;
+ if (opener) {
+ result.openerTabId = opener;
+ }
+
if (this.extension.hasPermission("cookies")) {
result.cookieStoreId = this.cookieStoreId;
}
if (this.hasTabPermission) {
for (let prop of ["url", "title", "favIconUrl"]) {
// We use the underscored variants here to avoid the redundant
// permissions checks imposed on the public properties.