From 4b5513634b9f409ba449bfdc4fbbf2f51bb02f2a Mon Sep 17 00:00:00 2001 From: Kris Maglione Date: Tue, 26 Jan 2016 17:06:41 -0800 Subject: [PATCH] Bug 1238311: Part 3 - [webext] Add audible and muted support to browser.tabs API. r=gabor --HG-- extra : commitid : 36za12ltvib extra : rebase_source : 321506a4d0ab68ed6db18536a513584c50231c78 extra : histedit_source : 2787bfcb88593f5e943a77f64a51946e7d011db1 --- browser/components/extensions/ext-tabs.js | 87 ++++--- browser/components/extensions/ext-utils.js | 10 + .../components/extensions/schemas/tabs.json | 9 +- .../extensions/test/browser/browser.ini | 1 + .../test/browser/browser_ext_tabs_audio.js | 218 ++++++++++++++++++ 5 files changed, 287 insertions(+), 38 deletions(-) create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_audio.js diff --git a/browser/components/extensions/ext-tabs.js b/browser/components/extensions/ext-tabs.js index 7c7b0850ea22..3c4ec5339bbf 100644 --- a/browser/components/extensions/ext-tabs.js +++ b/browser/components/extensions/ext-tabs.js @@ -150,29 +150,48 @@ extensions.registerSchemaAPI("tabs", null, (extension, context) => { return [nonempty, result]; } - let listener = event => { - let tab = event.originalTarget; - let window = tab.ownerDocument.defaultView; - let tabId = TabManager.getId(tab); + let fireForBrowser = (browser, changed) => { + let [needed, changeInfo] = sanitize(extension, changed); + if (needed) { + let gBrowser = browser.ownerDocument.defaultView.gBrowser; + let tabElem = gBrowser.getTabForBrowser(browser); - let changeInfo = {}; - let needed = false; + let tab = TabManager.convert(extension, tabElem); + fire(tab.id, changeInfo, tab); + } + }; + + let listener = event => { + let needed = []; if (event.type == "TabAttrModified") { - if (event.detail.changed.indexOf("image") != -1) { - changeInfo.favIconUrl = window.gBrowser.getIcon(tab); - needed = true; + 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"); } } else if (event.type == "TabPinned") { - changeInfo.pinned = true; - needed = true; + needed.push("pinned"); } else if (event.type == "TabUnpinned") { - changeInfo.pinned = false; - needed = true; + needed.push("pinned"); } - [needed, changeInfo] = sanitize(extension, changeInfo); - if (needed) { - fire(tabId, changeInfo, TabManager.convert(extension, tab)); + if (needed.length && !extension.hasPermission("tabs")) { + needed = needed.filter(attr => attr != "url" && attr != "favIconUrl"); + } + + if (needed.length) { + let tab = TabManager.convert(extension, event.originalTarget); + + let changeInfo = {}; + for (let prop of needed) { + changeInfo[prop] = tab[prop]; + } + fire(tab.id, changeInfo, tab); } }; let progressListener = { @@ -193,29 +212,18 @@ extensions.registerSchemaAPI("tabs", null, (extension, context) => { status = "complete"; } - let gBrowser = browser.ownerDocument.defaultView.gBrowser; - let tab = gBrowser.getTabForBrowser(browser); - let tabId = TabManager.getId(tab); - let [needed, changeInfo] = sanitize(extension, {status}); - if (needed) { - fire(tabId, changeInfo, TabManager.convert(extension, tab)); - } + fireForBrowser(browser, {status}); }, onLocationChange(browser, webProgress, request, locationURI, flags) { if (!webProgress.isTopLevel) { return; } - let gBrowser = browser.ownerDocument.defaultView.gBrowser; - let tab = gBrowser.getTabForBrowser(browser); - let tabId = TabManager.getId(tab); - let [needed, changeInfo] = sanitize(extension, { + + fireForBrowser(browser, { status: webProgress.isLoadingDocument ? "loading" : "complete", url: locationURI.spec, }); - if (needed) { - fire(tabId, changeInfo, TabManager.convert(extension, tab)); - } }, }; @@ -333,6 +341,11 @@ extensions.registerSchemaAPI("tabs", null, (extension, context) => { // Not sure what to do here? Which tab should we select? } } + if (updateProperties.muted !== null) { + if (tab.muted != updateProperties.muted) { + tab.toggleMuteAudio(extension.uuid); + } + } if (updateProperties.pinned !== null) { if (updateProperties.pinned) { tabbrowser.pinTab(tab); @@ -340,7 +353,7 @@ extensions.registerSchemaAPI("tabs", null, (extension, context) => { tabbrowser.unpinTab(tab); } } - // FIXME: highlighted/selected, muted, openerTabId + // FIXME: highlighted/selected, openerTabId if (callback) { runSafe(context, callback, TabManager.convert(extension, tab)); @@ -415,6 +428,18 @@ extensions.registerSchemaAPI("tabs", null, (extension, context) => { } } + if (queryInfo.audible !== null) { + if (queryInfo.audible != tab.audible) { + return false; + } + } + + if (queryInfo.muted !== null) { + if (queryInfo.muted != tab.mutedInfo.muted) { + return false; + } + } + if (queryInfo.currentWindow !== null) { let eq = window == currentWindow(context); if (queryInfo.currentWindow != eq) { diff --git a/browser/components/extensions/ext-utils.js b/browser/components/extensions/ext-utils.js index ea35ea439205..8b2374e5c670 100644 --- a/browser/components/extensions/ext-utils.js +++ b/browser/components/extensions/ext-utils.js @@ -423,6 +423,14 @@ ExtensionTabManager.prototype = { convert(tab) { let window = tab.ownerDocument.defaultView; + let mutedInfo = { muted: tab.muted }; + if (tab.muteReason === null) { + mutedInfo.reason = "user"; + } else if (tab.muteReason) { + mutedInfo.reason = "extension"; + mutedInfo.extensionId = tab.muteReason; + } + let result = { id: TabManager.getId(tab), index: tab._tPos, @@ -435,6 +443,8 @@ ExtensionTabManager.prototype = { incognito: PrivateBrowsingUtils.isBrowserPrivate(tab.linkedBrowser), width: tab.linkedBrowser.clientWidth, height: tab.linkedBrowser.clientHeight, + audible: tab.soundPlaying, + mutedInfo, }; if (this.hasTabPermission(tab)) { diff --git a/browser/components/extensions/schemas/tabs.json b/browser/components/extensions/schemas/tabs.json index 03a1c2080b00..442406f2e6a8 100644 --- a/browser/components/extensions/schemas/tabs.json +++ b/browser/components/extensions/schemas/tabs.json @@ -49,8 +49,8 @@ "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": {"unsupported": true, "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": {"unsupported": true, "$ref": "MutedInfo", "optional": true, "description": "Current tab muted state and the reason for the last state change."}, + "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 \"tabs\" permission."}, "title": {"type": "string", "optional": true, "description": "The title of the tab. This property is only present if the extension's manifest includes the \"tabs\" permission."}, "favIconUrl": {"type": "string", "optional": true, "description": "The URL of the tab's favicon. This property is only present if the extension's manifest includes the \"tabs\" permission. It may also be an empty string if the tab is loading."}, @@ -432,13 +432,11 @@ "description": "Whether the tabs are pinned." }, "audible": { - "unsupported": true, "type": "boolean", "optional": true, "description": "Whether the tabs are audible." }, "muted": { - "unsupported": true, "type": "boolean", "optional": true, "description": "Whether the tabs are muted." @@ -593,7 +591,6 @@ "description": "Whether the tab should be pinned." }, "muted": { - "unsupported": true, "type": "boolean", "optional": true, "description": "Whether the tab should be muted." @@ -948,13 +945,11 @@ "description": "The tab's new pinned state." }, "audible": { - "unsupported": true, "type": "boolean", "optional": true, "description": "The tab's new audible state." }, "mutedInfo": { - "unsupported": true, "$ref": "MutedInfo", "optional": true, "description": "The tab's new muted state and the reason for the change." diff --git a/browser/components/extensions/test/browser/browser.ini b/browser/components/extensions/test/browser/browser.ini index f8aeb9c31970..991cb1d4075c 100644 --- a/browser/components/extensions/test/browser/browser.ini +++ b/browser/components/extensions/test/browser/browser.ini @@ -20,6 +20,7 @@ support-files = [browser_ext_popup_api_injection.js] [browser_ext_contextMenus.js] [browser_ext_getViews.js] +[browser_ext_tabs_audio.js] [browser_ext_tabs_executeScript.js] [browser_ext_tabs_executeScript_good.js] [browser_ext_tabs_executeScript_bad.js] diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_audio.js b/browser/components/extensions/test/browser/browser_ext_tabs_audio.js new file mode 100644 index 000000000000..664e88d74cf8 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_audio.js @@ -0,0 +1,218 @@ +/* -*- 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() { + // Wrap API methods in promise-based variants. + let promiseTabs = {}; + Object.keys(browser.tabs).forEach(method => { + promiseTabs[method] = (...args) => { + return new Promise(resolve => { + browser.tabs[method](...args, resolve); + }); + }; + }); + + function promiseUpdated(tabId, attr) { + return new Promise(resolve => { + let onUpdated = (tabId_, changeInfo, tab) => { + if (tabId == tabId_ && attr in changeInfo) { + browser.tabs.onUpdated.removeListener(onUpdated); + + resolve({changeInfo, tab}); + } + }; + browser.tabs.onUpdated.addListener(onUpdated); + }); + } + + let deferred = {}; + browser.test.onMessage.addListener((message, tabId, result) => { + if (message == "change-tab-done" && deferred[tabId]) { + deferred[tabId].resolve(result); + } + }); + + function changeTab(tabId, attr, on) { + return new Promise((resolve, reject) => { + deferred[tabId] = {resolve, reject}; + browser.test.sendMessage("change-tab", tabId, attr, on); + }); + } + + + let windowId; + let tabIds; + promiseTabs.query({ lastFocusedWindow: true }).then(tabs => { + browser.test.assertEq(tabs.length, 3, "We have three tabs"); + + for (let tab of tabs) { + // Note: We want to check that these are actual boolean values, not + // just that they evaluate as false. + browser.test.assertEq(false, tab.mutedInfo.muted, "Tab is not muted"); + browser.test.assertEq(undefined, tab.mutedInfo.reason, "Tab has no muted info reason"); + browser.test.assertEq(false, tab.audible, "Tab is not audible"); + } + + windowId = tabs[0].windowId; + tabIds = [tabs[1].id, tabs[2].id]; + + browser.test.log("Test initial queries for muted and audible return no tabs"); + return Promise.all([ + promiseTabs.query({ windowId, audible: false }), + promiseTabs.query({ windowId, audible: true }), + promiseTabs.query({ windowId, muted: true }), + promiseTabs.query({ windowId, muted: false }), + ]); + }).then(([silent, audible, muted, nonMuted]) => { + browser.test.assertEq(3, silent.length, "Three silent tabs"); + browser.test.assertEq(0, audible.length, "No audible tabs"); + + browser.test.assertEq(0, muted.length, "No muted tabs"); + browser.test.assertEq(3, nonMuted.length, "Three non-muted tabs"); + + browser.test.log("Toggle muted and audible externally on one tab each, and check results"); + return Promise.all([ + promiseUpdated(tabIds[0], "mutedInfo"), + promiseUpdated(tabIds[1], "audible"), + changeTab(tabIds[0], "muted", true), + changeTab(tabIds[1], "audible", true), + ]); + }).then(([muted, audible]) => { + for (let obj of [muted.changeInfo, muted.tab]) { + browser.test.assertEq(true, obj.mutedInfo.muted, "Tab is muted"); + browser.test.assertEq("user", obj.mutedInfo.reason, "Tab was muted by the user"); + } + + browser.test.assertEq(true, audible.changeInfo.audible, "Tab audible state changed"); + browser.test.assertEq(true, audible.tab.audible, "Tab is audible"); + + browser.test.log("Re-check queries. Expect one audible and one muted tab"); + return Promise.all([ + promiseTabs.query({ windowId, audible: false }), + promiseTabs.query({ windowId, audible: true }), + promiseTabs.query({ windowId, muted: true }), + promiseTabs.query({ windowId, muted: false }), + ]); + }).then(([silent, audible, muted, nonMuted]) => { + browser.test.assertEq(2, silent.length, "Two silent tabs"); + browser.test.assertEq(1, audible.length, "One audible tab"); + + browser.test.assertEq(1, muted.length, "One muted tab"); + browser.test.assertEq(2, nonMuted.length, "Two non-muted tabs"); + + browser.test.assertEq(true, muted[0].mutedInfo.muted, "Tab is muted"); + browser.test.assertEq("user", muted[0].mutedInfo.reason, "Tab was muted by the user"); + + browser.test.assertEq(true, audible[0].audible, "Tab is audible"); + + browser.test.log("Toggle muted internally on two tabs, and check results"); + return Promise.all([ + promiseUpdated(tabIds[0], "mutedInfo"), + promiseUpdated(tabIds[1], "mutedInfo"), + promiseTabs.update(tabIds[0], { muted: false }), + promiseTabs.update(tabIds[1], { muted: true }), + ]); + }).then(([unmuted, muted]) => { + for (let obj of [unmuted.changeInfo, unmuted.tab]) { + browser.test.assertEq(false, obj.mutedInfo.muted, "Tab is not muted"); + } + for (let obj of [muted.changeInfo, muted.tab]) { + browser.test.assertEq(true, obj.mutedInfo.muted, "Tab is muted"); + } + + for (let obj of [unmuted.changeInfo, unmuted.tab, muted.changeInfo, muted.tab]) { + browser.test.assertEq("extension", obj.mutedInfo.reason, "Mute state changed by extension"); + + // FIXME: browser.runtime.id is currently broken. + browser.test.assertEq(browser.i18n.getMessage("@@extension_id"), + obj.mutedInfo.extensionId, + "Mute state changed by extension"); + } + + browser.test.log("Test that mutedInfo is preserved by sessionstore"); + return changeTab(tabIds[1], "duplicate").then(promiseTabs.get); + }).then(tab => { + browser.test.assertEq(true, tab.mutedInfo.muted, "Tab is muted"); + + browser.test.assertEq("extension", tab.mutedInfo.reason, "Mute state changed by extension"); + + // FIXME: browser.runtime.id is currently broken. + browser.test.assertEq(browser.i18n.getMessage("@@extension_id"), + tab.mutedInfo.extensionId, + "Mute state changed by extension"); + + browser.test.log("Unmute externally, and check results"); + return Promise.all([ + promiseUpdated(tabIds[1], "mutedInfo"), + changeTab(tabIds[1], "muted", false), + promiseTabs.remove(tab.id), + ]); + }).then(([unmuted]) => { + for (let obj of [unmuted.changeInfo, unmuted.tab]) { + browser.test.assertEq(false, obj.mutedInfo.muted, "Tab is not muted"); + browser.test.assertEq("user", obj.mutedInfo.reason, "Mute state changed by user"); + } + + browser.test.notifyPass("tab-audio"); + }).catch(e => { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("tab-audio"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + }, + + background, + }); + + extension.onMessage("change-tab", (tabId, attr, on) => { + let {TabManager} = Cu.import("resource://gre/modules/Extension.jsm", {}); + + let tab = TabManager.getTab(tabId); + + if (attr == "muted") { + // Ideally we'd simulate a click on the tab audio icon for this, but the + // handler relies on CSS :hover states, which are complicated and fragile + // to simulate. + if (tab.muted != on) { + tab.toggleMuteAudio(); + } + } else if (attr == "audible") { + let browser = tab.linkedBrowser; + if (on) { + browser.audioPlaybackStarted(); + } else { + browser.audioPlaybackStopped(); + } + } else if (attr == "duplicate") { + // This is a bit of a hack. It won't be necessary once we have + // `tabs.duplicate`. + let newTab = gBrowser.duplicateTab(tab); + BrowserTestUtils.waitForEvent(newTab, "SSTabRestored", () => true).then(() => { + extension.sendMessage("change-tab-done", tabId, TabManager.getId(newTab)); + }); + return; + } + + extension.sendMessage("change-tab-done", tabId); + }); + + yield extension.startup(); + + yield extension.awaitFinish("tab-audio"); + + yield extension.unload(); + + yield BrowserTestUtils.removeTab(tab1); + yield BrowserTestUtils.removeTab(tab2); +});