diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js index a1f20289aef7..4a18eb3640aa 100644 --- a/browser/app/profile/firefox.js +++ b/browser/app/profile/firefox.js @@ -403,6 +403,8 @@ pref("permissions.desktop-notification.postPrompt.enabled", true); pref("permissions.desktop-notification.postPrompt.enabled", false); #endif +pref("permissions.fullscreen.allowed", false); + pref("permissions.postPrompt.animate", true); // This is primarily meant to be enabled for studies. diff --git a/browser/base/content/browser-addons.js b/browser/base/content/browser-addons.js index 8711567033d4..316dfde761c7 100644 --- a/browser/base/content/browser-addons.js +++ b/browser/base/content/browser-addons.js @@ -443,7 +443,7 @@ var gXPInstallObserver = { PopupNotifications.getNotification(id, browser) ).filter(notification => notification != null); - PopupNotifications.remove(notifications); + PopupNotifications.remove(notifications, true); }, observe(aSubject, aTopic, aData) { diff --git a/browser/base/content/browser-fullScreenAndPointerLock.js b/browser/base/content/browser-fullScreenAndPointerLock.js index 71c564ef5827..88cb3406c3b2 100644 --- a/browser/base/content/browser-fullScreenAndPointerLock.js +++ b/browser/base/content/browser-fullScreenAndPointerLock.js @@ -6,6 +6,8 @@ // This file is loaded into the browser window scope. /* eslint-env mozilla/browser-window */ +ChromeUtils.import("resource:///modules/PermissionUI.jsm", this); + var PointerlockFsWarning = { _element: null, _origin: null, @@ -246,7 +248,19 @@ var FullScreen = { "DOMFullscreen:Painted", ], + _permissionNotificationIDs: Object.values(PermissionUI) + .filter(value => value.prototype && value.prototype.notificationID) + .map(value => value.prototype.notificationID) + // Additionally include webRTC permission prompt which does not use PermissionUI + .concat(["webRTC-shareDevices"]), + init() { + XPCOMUtils.defineLazyPreferenceGetter( + this, + "permissionsFullScreenAllowed", + "permissions.fullscreen.allowed" + ); + // called when we go into full screen, even if initiated by a web page script window.addEventListener("fullscreen", this, true); window.addEventListener("willenterfullscreen", this, true); @@ -385,11 +399,6 @@ var FullScreen = { browser = event.target.ownerGlobal.docShell.chromeEventHandler; } - // Addon installation should be cancelled when entering fullscreen for security and usability reasons. - // Installation prompts in fullscreen can trick the user into installing unwanted addons. - // In fullscreen the notification box does not have a clear visual association with its parent anymore. - gXPInstallObserver.removeAllNotifications(browser); - TelemetryStopwatch.start("FULLSCREEN_CHANGE_MS"); this.enterDomFullscreen(browser); break; @@ -401,6 +410,18 @@ var FullScreen = { } }, + _handlePermPromptShow() { + if ( + !FullScreen.permissionsFullScreenAllowed && + window.fullScreen && + PopupNotifications.getNotification( + this._permissionNotificationIDs + ).filter(n => !n.dismissed).length > 0 + ) { + this.exitDomFullScreen(); + } + }, + receiveMessage(aMessage) { let browser = aMessage.target; switch (aMessage.name) { @@ -465,6 +486,14 @@ var FullScreen = { return; } + // Remove permission prompts when entering full-screen. + if (!FullScreen.permissionsFullScreenAllowed) { + let notifications = PopupNotifications.getNotification( + this._permissionNotificationIDs + ).filter(n => !n.dismissed); + PopupNotifications.remove(notifications, true); + } + document.documentElement.setAttribute("inDOMFullscreen", true); if (gFindBarInitialized) { @@ -478,6 +507,17 @@ var FullScreen = { // If a fullscreen window loses focus, we show a warning when the // fullscreen window is refocused. window.addEventListener("activate", this); + + // Addon installation should be cancelled when entering fullscreen for security and usability reasons. + // Installation prompts in fullscreen can trick the user into installing unwanted addons. + // In fullscreen the notification box does not have a clear visual association with its parent anymore. + gXPInstallObserver.removeAllNotifications(aBrowser); + + PopupNotifications.panel.addEventListener( + "popupshowing", + () => this._handlePermPromptShow(), + true + ); }, cleanup() { @@ -490,6 +530,11 @@ var FullScreen = { }, cleanupDomFullscreen() { + PopupNotifications.panel.removeEventListener( + "popupshowing", + () => this._handlePermPromptShow(), + true + ); window.messageManager.broadcastAsyncMessage("DOMFullscreen:CleanUp"); PointerlockFsWarning.close(); diff --git a/browser/base/content/test/fullscreen/browser.ini b/browser/base/content/test/fullscreen/browser.ini index abe2ac2b9338..76bd91785306 100644 --- a/browser/base/content/test/fullscreen/browser.ini +++ b/browser/base/content/test/fullscreen/browser.ini @@ -3,3 +3,5 @@ support-files = head.js [browser_bug1557041.js] skip-if = os == 'linux' # Bug 1561973 +[browser_fullscreen_permissions_prompt.js] +skip-if = debug && os == 'macos' # Bug 1568570 \ No newline at end of file diff --git a/browser/base/content/test/fullscreen/browser_fullscreen_permissions_prompt.js b/browser/base/content/test/fullscreen/browser_fullscreen_permissions_prompt.js new file mode 100644 index 000000000000..e84e47cc9cb2 --- /dev/null +++ b/browser/base/content/test/fullscreen/browser_fullscreen_permissions_prompt.js @@ -0,0 +1,156 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// This test tends to trigger a race in the fullscreen time telemetry, +// where the fullscreen enter and fullscreen exit events (which use the +// same histogram ID) overlap. That causes TelemetryStopwatch to log an +// error. +SimpleTest.ignoreAllUncaughtExceptions(true); + +SimpleTest.requestCompleteLog(); + +async function requestNotificationPermission(browser) { + return ContentTask.spawn(browser, null, () => { + return content.Notification.requestPermission(); + }); +} + +async function requestCameraPermission(browser) { + return ContentTask.spawn(browser, null, () => { + return new Promise(resolve => { + content.navigator.mediaDevices + .getUserMedia({ video: true, fake: true }) + .catch(resolve(false)) + .then(resolve(true)); + }); + }); +} + +add_task(async function test_fullscreen_closes_permissionui_prompt() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["dom.webnotifications.requireuserinteraction", false], + ["permissions.fullscreen.allowed", false], + ], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com" + ); + let browser = tab.linkedBrowser; + + let popupShown, requestResult, popupHidden; + + popupShown = BrowserTestUtils.waitForEvent( + window.PopupNotifications.panel, + "popupshown" + ); + + info("Requesting notification permission"); + requestResult = requestNotificationPermission(browser); + await popupShown; + + info("Entering DOM full-screen"); + popupHidden = BrowserTestUtils.waitForEvent( + window.PopupNotifications.panel, + "popuphidden" + ); + + await changeFullscreen(browser, true); + + await popupHidden; + + is( + await requestResult, + "default", + "Expect permission request to be cancelled" + ); + + await changeFullscreen(browser, false); + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_fullscreen_closes_webrtc_permission_prompt() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.navigator.permission.fake", true], + ["media.navigator.permission.force", true], + ["permissions.fullscreen.allowed", false], + ], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com" + ); + let browser = tab.linkedBrowser; + let popupShown, requestResult, popupHidden; + + popupShown = BrowserTestUtils.waitForEvent( + window.PopupNotifications.panel, + "popupshown" + ); + + info("Requesting camera permission"); + requestResult = requestCameraPermission(browser); + + await popupShown; + + info("Entering DOM full-screen"); + popupHidden = BrowserTestUtils.waitForEvent( + window.PopupNotifications.panel, + "popuphidden" + ); + await changeFullscreen(browser, true); + + await popupHidden; + + is( + await requestResult, + false, + "Expect webrtc permission request to be cancelled" + ); + + await changeFullscreen(browser, false); + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_permission_prompt_closes_fullscreen() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["dom.webnotifications.requireuserinteraction", false], + ["permissions.fullscreen.allowed", false], + ], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com" + ); + let browser = tab.linkedBrowser; + info("Entering DOM full-screen"); + await changeFullscreen(browser, true); + + let popupShown = BrowserTestUtils.waitForEvent( + window.PopupNotifications.panel, + "popupshown" + ); + let fullScreenExit = waitForFullScreenState(browser, false); + + info("Requesting notification permission"); + requestNotificationPermission(browser); + await popupShown; + + info("Waiting for full-screen exit"); + await fullScreenExit; + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/base/content/test/fullscreen/head.js b/browser/base/content/test/fullscreen/head.js index 13faf9568bc7..c5b6e0c6d3a2 100644 --- a/browser/base/content/test/fullscreen/head.js +++ b/browser/base/content/test/fullscreen/head.js @@ -6,10 +6,29 @@ const { ContentTask } = ChromeUtils.import( ); function waitForFullScreenState(browser, state) { - let eventName = state - ? "MozDOMFullscreen:Entered" - : "MozDOMFullscreen:Exited"; - return BrowserTestUtils.waitForEvent(browser.ownerGlobal, eventName); + return new Promise(resolve => { + let eventReceived = false; + window.messageManager.addMessageListener( + "DOMFullscreen:Painted", + function listener() { + if (!eventReceived) { + return; + } + window.messageManager.removeMessageListener( + "DOMFullscreen:Painted", + listener + ); + resolve(); + } + ); + window.addEventListener( + `MozDOMFullscreen:${state ? "Entered" : "Exited"}`, + () => { + eventReceived = true; + }, + { once: true } + ); + }); } /** @@ -18,9 +37,17 @@ function waitForFullScreenState(browser, state) { * @param {Boolean} fullscreenState - true to enter fullscreen, false to leave * @returns {Promise} - Resolves once fullscreen change is applied */ -function changeFullscreen(browser, fullScreenState) { +async function changeFullscreen(browser, fullScreenState) { + await new Promise(resolve => + SimpleTest.waitForFocus(resolve, browser.ownerGlobal) + ); let fullScreenChange = waitForFullScreenState(browser, fullScreenState); - ContentTask.spawn(browser, fullScreenState, state => { + ContentTask.spawn(browser, fullScreenState, async state => { + // Wait for document focus before requesting full-screen + await ContentTaskUtils.waitForCondition( + () => docShell.isActive && content.document.hasFocus(), + "Waiting for document focus" + ); if (state) { content.document.body.requestFullscreen(); } else { diff --git a/browser/modules/PermissionUI.jsm b/browser/modules/PermissionUI.jsm index 4bfc594c26eb..0ea620a58dde 100644 --- a/browser/modules/PermissionUI.jsm +++ b/browser/modules/PermissionUI.jsm @@ -625,7 +625,7 @@ var PermissionPromptPrototype = { options.hideClose = true; } - options.eventCallback = (topic, nextRemovalReason) => { + options.eventCallback = (topic, nextRemovalReason, isCancel) => { // When the docshell of the browser is aboout to be swapped to another one, // the "swapping" event is called. Returning true causes the notification // to be moved to the new browser. @@ -653,6 +653,9 @@ var PermissionPromptPrototype = { nextRemovalReason ); } + if (isCancel) { + this.cancel(); + } this.onAfterShow(); } return false; diff --git a/browser/modules/webrtcUI.jsm b/browser/modules/webrtcUI.jsm index 69d61c2be384..7deebe389d3a 100644 --- a/browser/modules/webrtcUI.jsm +++ b/browser/modules/webrtcUI.jsm @@ -572,7 +572,7 @@ function prompt(aBrowser, aRequest) { name: getHostOrExtensionName(principal.URI), persistent: true, hideClose: true, - eventCallback(aTopic, aNewBrowser) { + eventCallback(aTopic, aNewBrowser, isCancel) { if (aTopic == "swapping") { return true; } @@ -602,6 +602,11 @@ function prompt(aBrowser, aRequest) { } } + // If the notification has been cancelled (e.g. due to entering full-screen), also cancel the webRTC request + if (aTopic == "removed" && notification && isCancel) { + denyRequest(notification.browser, aRequest); + } + if (aTopic != "showing") { return false; } diff --git a/toolkit/modules/PopupNotifications.jsm b/toolkit/modules/PopupNotifications.jsm index 08650640b4e5..f30d13719d53 100644 --- a/toolkit/modules/PopupNotifications.jsm +++ b/toolkit/modules/PopupNotifications.jsm @@ -346,20 +346,24 @@ PopupNotifications.prototype = { }, /** - * Retrieve a Notification object associated with the browser/ID pair. - * @param id - * The Notification ID to search for. - * @param browser + * Retrieve one or many Notification object/s associated with the browser/ID pair. + * @param {string|string[]} id + * The Notification ID or an array of IDs to search for. + * @param [browser] * The browser whose notifications should be searched. If null, the * currently selected browser's notifications will be searched. * - * @returns the corresponding Notification object, or null if no such + * @returns {Notification|Notification[]|null} If passed a single id, returns the corresponding Notification object, or null if no such * notification exists. + * If passed an id array, returns an array of Notification objects which match the ids. */ getNotification: function PopupNotifications_getNotification(id, browser) { let notifications = this._getNotificationsForBrowser( browser || this.tabbrowser.selectedBrowser ); + if (Array.isArray(id)) { + return notifications.filter(x => id.includes(x.id)); + } return notifications.find(x => x.id == id) || null; }, @@ -732,15 +736,18 @@ PopupNotifications.prototype = { /** * Removes one or many Notifications. * @param {Notification|Notification[]} notification - The Notification object/s to remove. + * @param {Boolean} [isCancel] - Whether to signal, in the notification event, that removal + * should be treated as cancel. This is currently used to cancel permission requests + * when their Notifications are removed. */ - remove: function PopupNotifications_remove(notification) { + remove: function PopupNotifications_remove(notification, isCancel = false) { let notificationArray = Array.isArray(notification) ? notification : [notification]; let activeBrowser; notificationArray.forEach(n => { - this._remove(n); + this._remove(n, isCancel); if (!activeBrowser && this._isActiveBrowser(n.browser)) { activeBrowser = n.browser; } @@ -796,7 +803,10 @@ PopupNotifications.prototype = { : []; }, - _remove: function PopupNotifications_removeHelper(notification) { + _remove: function PopupNotifications_removeHelper( + notification, + isCancel = false + ) { // This notification may already be removed, in which case let's just fail // silently. let notifications = this._getNotificationsForBrowser(notification.browser); @@ -818,7 +828,8 @@ PopupNotifications.prototype = { this._fireCallback( notification, NOTIFICATION_EVENT_REMOVED, - this.nextRemovalReason + this.nextRemovalReason, + isCancel ); },