/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ // This file is loaded into the browser window scope. /* eslint-env mozilla/browser-window */ var PointerlockFsWarning = { _element: null, _origin: null, /** * Timeout object for managing timeout request. If it is started when * the previous call hasn't finished, it would automatically cancelled * the previous one. */ Timeout: class { constructor(func, delay) { this._id = 0; this._func = func; this._delay = delay; } start() { this.cancel(); this._id = setTimeout(() => this._handle(), this._delay); } cancel() { if (this._id) { clearTimeout(this._id); this._id = 0; } } _handle() { this._id = 0; this._func(); } get delay() { return this._delay; } }, showPointerLock(aOrigin) { if (!document.fullscreen) { let timeout = Services.prefs.getIntPref( "pointer-lock-api.warning.timeout" ); this.show(aOrigin, "pointerlock-warning", timeout, 0); } }, showFullScreen(aOrigin) { let timeout = Services.prefs.getIntPref("full-screen-api.warning.timeout"); let delay = Services.prefs.getIntPref("full-screen-api.warning.delay"); this.show(aOrigin, "fullscreen-warning", timeout, delay); }, // Shows a warning that the site has entered fullscreen or // pointer lock for a short duration. show(aOrigin, elementId, timeout, delay) { if (!this._element) { this._element = document.getElementById(elementId); // Setup event listeners this._element.addEventListener("transitionend", this); window.addEventListener("mousemove", this, true); // The timeout to hide the warning box after a while. this._timeoutHide = new this.Timeout(() => { this._state = "hidden"; }, timeout); // The timeout to show the warning box when the pointer is at the top this._timeoutShow = new this.Timeout(() => { this._state = "ontop"; this._timeoutHide.start(); }, delay); } // Set the strings on the warning UI. if (aOrigin) { this._origin = aOrigin; } let uri = Services.io.newURI(this._origin); let host = null; // Make an exception for PDF.js - we'll show "This document" instead. if (this._origin != "resource://pdf.js") { try { host = uri.host; } catch (e) {} } let textElem = this._element.querySelector( ".pointerlockfswarning-domain-text" ); if (!host) { textElem.hidden = true; } else { textElem.removeAttribute("hidden"); // Document's principal's URI has a host. Display a warning including it. let { DownloadUtils } = ChromeUtils.import( "resource://gre/modules/DownloadUtils.jsm" ); let displayHost = DownloadUtils.getURIHost(uri.spec)[0]; let l10nString = { "fullscreen-warning": "fullscreen-warning-domain", "pointerlock-warning": "pointerlock-warning-domain", }[elementId]; document.l10n.setAttributes(textElem, l10nString, { domain: displayHost, }); } this._element.dataset.identity = gIdentityHandler.pointerlockFsWarningClassName; // User should be allowed to explicitly disable // the prompt if they really want. if (this._timeoutHide.delay <= 0) { return; } // Explicitly set the last state to hidden to avoid the warning // box being hidden immediately because of mousemove. this._state = "onscreen"; this._lastState = "hidden"; this._timeoutHide.start(); }, /** * Close the full screen or pointerlock warning. * @param {('fullscreen-warning'|'pointerlock-warning')} elementId - Id of the * warning element to close. If the id does not match the currently shown * warning this is a no-op. */ close(elementId) { if (!elementId) { throw new Error("Must pass id of warning element to close"); } if (!this._element || this._element.id != elementId) { return; } // Cancel any pending timeout this._timeoutHide.cancel(); this._timeoutShow.cancel(); // Reset state of the warning box this._state = "hidden"; // Reset state of the text so we don't persist or retranslate it. this._element .querySelector(".pointerlockfswarning-domain-text") .removeAttribute("data-l10n-id"); this._element.hidden = true; // Remove all event listeners this._element.removeEventListener("transitionend", this); window.removeEventListener("mousemove", this, true); // Clear fields this._element = null; this._timeoutHide = null; this._timeoutShow = null; // Ensure focus switches away from the (now hidden) warning box. // If the user clicked buttons in the warning box, it would have // been focused, and any key events would be directed at the (now // hidden) chrome document instead of the target document. gBrowser.selectedBrowser.focus(); }, // State could be one of "onscreen", "ontop", "hiding", and // "hidden". Setting the state to "onscreen" and "ontop" takes // effect immediately, while setting it to "hidden" actually // turns the state to "hiding" before the transition finishes. _lastState: null, _STATES: ["hidden", "ontop", "onscreen"], get _state() { for (let state of this._STATES) { if (this._element.hasAttribute(state)) { return state; } } return "hiding"; }, set _state(newState) { let currentState = this._state; if (currentState == newState) { return; } if (currentState != "hiding") { this._lastState = currentState; this._element.removeAttribute(currentState); } if (newState != "hidden") { if (currentState != "hidden") { this._element.setAttribute(newState, true); } else { // When the previous state is hidden, the display was none, // thus no box was constructed. We need to wait for the new // display value taking effect first, otherwise, there won't // be any transition. Since requestAnimationFrame callback is // generally triggered before any style flush and layout, we // should wait for the second animation frame. requestAnimationFrame(() => { requestAnimationFrame(() => { if (this._element) { this._element.setAttribute(newState, true); } }); }); } } }, handleEvent(event) { switch (event.type) { case "mousemove": { let state = this._state; if (state == "hidden") { // If the warning box is currently hidden, show it after // a short delay if the pointer is at the top. if (event.clientY != 0) { this._timeoutShow.cancel(); } else if (this._timeoutShow.delay >= 0) { this._timeoutShow.start(); } } else { let elemRect = this._element.getBoundingClientRect(); if (state == "hiding" && this._lastState != "hidden") { // If we are on the hiding transition, and the pointer // moved near the box, restore to the previous state. if (event.clientY <= elemRect.bottom + 50) { this._state = this._lastState; this._timeoutHide.start(); } } else if (state == "ontop" || this._lastState != "hidden") { // State being "ontop" or the previous state not being // "hidden" indicates this current warning box is shown // in response to user's action. Hide it immediately when // the pointer leaves that area. if (event.clientY > elemRect.bottom + 50) { this._state = "hidden"; this._timeoutHide.cancel(); } } } break; } case "transitionend": { if (this._state == "hiding") { this._element.hidden = true; } break; } } }, }; var PointerLock = { entered(originNoSuffix) { PointerlockFsWarning.showPointerLock(originNoSuffix); }, exited() { PointerlockFsWarning.close("pointerlock-warning"); }, }; var FullScreen = { init() { XPCOMUtils.defineLazyPreferenceGetter( this, "permissionsFullScreenAllowed", "permissions.fullscreen.allowed" ); // Called when the Firefox window go into fullscreen. addEventListener("fullscreen", this, true); // Called only when fullscreen is requested // by the parent (eg: via the browser-menu). // Should not be called when the request comes from // the content. addEventListener("willenterfullscreen", this, true); addEventListener("willexitfullscreen", this, true); addEventListener("MacFullscreenMenubarRevealUpdate", this, true); if (window.fullScreen) { this.toggle(); } }, uninit() { this.cleanup(); }, willToggle(aWillEnterFullscreen) { if (aWillEnterFullscreen) { document.documentElement.setAttribute("inFullscreen", true); } else { document.documentElement.removeAttribute("inFullscreen"); } }, get fullScreenToggler() { delete this.fullScreenToggler; return (this.fullScreenToggler = document.getElementById( "fullscr-toggler" )); }, toggle() { var enterFS = window.fullScreen; // Toggle the View:FullScreen command, which controls elements like the // fullscreen menuitem, and menubars. let fullscreenCommand = document.getElementById("View:FullScreen"); if (enterFS) { fullscreenCommand.setAttribute("checked", enterFS); } else { fullscreenCommand.removeAttribute("checked"); } if (AppConstants.platform == "macosx") { // Make sure the menu items are adjusted. document.getElementById("enterFullScreenItem").hidden = enterFS; document.getElementById("exitFullScreenItem").hidden = !enterFS; this.shiftMacToolbarDown(0); } let fstoggler = this.fullScreenToggler; fstoggler.addEventListener("mouseover", this._expandCallback); fstoggler.addEventListener("dragenter", this._expandCallback); fstoggler.addEventListener("touchmove", this._expandCallback, { passive: true, }); if (enterFS) { gNavToolbox.setAttribute("inFullscreen", true); document.documentElement.setAttribute("inFullscreen", true); let alwaysUsesNativeFullscreen = AppConstants.platform == "macosx" && Services.prefs.getBoolPref("full-screen-api.macos-native-full-screen"); if ( (alwaysUsesNativeFullscreen || !document.fullscreenElement) && AppConstants.platform == "macosx" ) { document.documentElement.setAttribute("macOSNativeFullscreen", true); } } else { gNavToolbox.removeAttribute("inFullscreen"); document.documentElement.removeAttribute("inFullscreen"); document.documentElement.removeAttribute("macOSNativeFullscreen"); } if (!document.fullscreenElement) { this._updateToolbars(enterFS); } if (enterFS) { document.addEventListener("keypress", this._keyToggleCallback); document.addEventListener("popupshown", this._setPopupOpen); document.addEventListener("popuphidden", this._setPopupOpen); gURLBar.controller.addQueryListener(this); // In DOM fullscreen mode, we hide toolbars with CSS if (!document.fullscreenElement) { this.hideNavToolbox(true); } } else { this.showNavToolbox(false); // This is needed if they use the context menu to quit fullscreen this._isPopupOpen = false; this.cleanup(); } }, exitDomFullScreen() { if (document.fullscreen) { document.exitFullscreen(); } }, /** * Shifts the browser toolbar down when it is moused over on macOS in * fullscreen. * @param {number} shiftSize * A distance, in pixels, by which to shift the browser toolbar down. */ shiftMacToolbarDown(shiftSize) { if (typeof shiftSize !== "number") { console.error("Tried to shift the toolbar by a non-numeric distance."); return; } // shiftSize is sent from Cocoa widget code as a very precise double. We // don't need that kind of precision in our CSS. shiftSize = shiftSize.toFixed(2); let toolbox = document.getElementById("navigator-toolbox"); if (shiftSize > 0) { toolbox.style.setProperty("transform", `translateY(${shiftSize}px)`); toolbox.style.setProperty("z-index", "2"); } else { toolbox.style.removeProperty("transform"); toolbox.style.removeProperty("z-index"); } }, handleEvent(event) { switch (event.type) { case "willenterfullscreen": this.willToggle(true); break; case "willexitfullscreen": this.willToggle(false); break; case "fullscreen": this.toggle(); break; case "MacFullscreenMenubarRevealUpdate": this.shiftMacToolbarDown(event.detail); break; } }, _logWarningPermissionPromptFS(actionStringKey) { let consoleMsg = Cc["@mozilla.org/scripterror;1"].createInstance( Ci.nsIScriptError ); let message = gBrowserBundle.GetStringFromName( `permissions.fullscreen.${actionStringKey}` ); consoleMsg.initWithWindowID( message, gBrowser.currentURI.spec, null, 0, 0, Ci.nsIScriptError.warningFlag, "FullScreen", gBrowser.selectedBrowser.innerWindowID ); Services.console.logMessage(consoleMsg); }, _handlePermPromptShow() { if ( !FullScreen.permissionsFullScreenAllowed && window.fullScreen && PopupNotifications.getNotification( this._permissionNotificationIDs ).filter(n => !n.dismissed).length ) { this.exitDomFullScreen(); this._logWarningPermissionPromptFS("fullScreenCanceled"); } }, enterDomFullscreen(aBrowser, aActor) { if (!document.fullscreenElement) { aActor.requestOrigin = null; return; } // If we have a current pointerlock warning shown then hide it // before transition. PointerlockFsWarning.close("pointerlock-warning"); // If it is a remote browser, send a message to ask the content // to enter fullscreen state. We don't need to do so if it is an // in-process browser, since all related document should have // entered fullscreen state at this point. // Additionally, in Fission world, we may need to notify the // frames in the middle (content frames that embbed the oop iframe where // the element requesting fullscreen lives) to enter fullscreen // first. // This should be done before the active tab check below to ensure // that the content document handles the pending request. Doing so // before the check is fine since we also check the activeness of // the requesting document in content-side handling code. if (this._isRemoteBrowser(aBrowser)) { // The cached message recipient in actor is used for fullscreen state // cleanup, we should not use it while entering fullscreen. let [targetActor, inProcessBC] = this._getNextMsgRecipientActor( aActor, false /* aUseCache */ ); if (!targetActor) { // If there is no appropriate actor to send the message we have // no way to complete the transition and should abort by exiting // fullscreen. this._abortEnterFullscreen(); return; } // Record that the actor is waiting for its child to enter // fullscreen so that if it dies we can abort. targetActor.waitingForChildEnterFullscreen = true; targetActor.sendAsyncMessage("DOMFullscreen:Entered", { remoteFrameBC: inProcessBC, }); if (inProcessBC) { // We aren't messaging the request origin yet, skip this time. return; } } // If we've received a fullscreen notification, we have to ensure that the // element that's requesting fullscreen belongs to the browser that's currently // active. If not, we exit fullscreen since the "full-screen document" isn't // actually visible now. if ( !aBrowser || gBrowser.selectedBrowser != aBrowser || // The top-level window has lost focus since the request to enter // full-screen was made. Cancel full-screen. Services.focus.activeWindow != window ) { this._abortEnterFullscreen(); 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); if (notifications.length) { this._logWarningPermissionPromptFS("promptCanceled"); } } document.documentElement.setAttribute("inDOMFullscreen", true); if (gFindBarInitialized) { gFindBar.close(true); } // Exit DOM full-screen mode when switching to a different tab. gBrowser.tabContainer.addEventListener("TabSelect", this.exitDomFullScreen); // Addon installation should be cancelled when entering DOM 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. if (gXPInstallObserver.removeAllNotifications(aBrowser)) { // If notifications have been removed, log a warning to the website console gXPInstallObserver.logWarningFullScreenInstallBlocked(); } PopupNotifications.panel.addEventListener( "popupshowing", () => this._handlePermPromptShow(), true ); }, cleanup() { if (!window.fullScreen) { MousePosTracker.removeListener(this); document.removeEventListener("keypress", this._keyToggleCallback); document.removeEventListener("popupshown", this._setPopupOpen); document.removeEventListener("popuphidden", this._setPopupOpen); gURLBar.controller.removeQueryListener(this); } }, /** * Clean up full screen, starting from the request origin's first ancestor * frame that is OOP. * * If there are OOP ancestor frames, we notify the first of those and then bail to * be called again in that process when it has dealt with the change. This is * repeated until all ancestor processes have been updated. Once that has happened * we remove our handlers and attributes and notify the request origin to complete * the cleanup. */ cleanupDomFullscreen(aActor) { let needToWaitForChildExit = false; // Use the message recipient cached in the actor if possible, especially for // the case that actor is destroyed, which we are unable to find it by // walking up the browsing context tree. let [target, inProcessBC] = this._getNextMsgRecipientActor( aActor, true /* aUseCache */ ); if (target) { needToWaitForChildExit = true; // Record that the actor is waiting for its child to exit fullscreen so // that if it dies we can continue cleanup. target.waitingForChildExitFullscreen = true; target.sendAsyncMessage("DOMFullscreen:CleanUp", { remoteFrameBC: inProcessBC, }); if (inProcessBC) { return needToWaitForChildExit; } } PopupNotifications.panel.removeEventListener( "popupshowing", () => this._handlePermPromptShow(), true ); PointerlockFsWarning.close("fullscreen-warning"); gBrowser.tabContainer.removeEventListener( "TabSelect", this.exitDomFullScreen ); document.documentElement.removeAttribute("inDOMFullscreen"); return needToWaitForChildExit; }, _abortEnterFullscreen() { // This function is called synchronously in fullscreen change, so // we have to avoid calling exitFullscreen synchronously here. // // This could reject if we're not currently in fullscreen // so just ignore rejection. setTimeout(() => document.exitFullscreen().catch(() => {}), 0); if (TelemetryStopwatch.running("FULLSCREEN_CHANGE_MS")) { // Cancel the stopwatch for any fullscreen change to avoid // errors if it is started again. TelemetryStopwatch.cancel("FULLSCREEN_CHANGE_MS"); } }, /** * Search for the first ancestor of aActor that lives in a different process. * If found, that ancestor actor and the browsing context for its child which * was in process are returned. Otherwise [request origin, null]. * * * @param {JSWindowActorParent} aActor * The actor that called this function. * @param {bool} aUseCache * Use the recipient cached in the aActor if available. * * @return {[JSWindowActorParent, BrowsingContext]} * The parent actor which should be sent the next msg and the * in process browsing context which is its child. Will be * [null, null] if there is no OOP parent actor and request origin * is unset. [null, null] is also returned if the intended actor or * the calling actor has been destroyed or its associated * WindowContext is in BFCache. */ _getNextMsgRecipientActor(aActor, aUseCache) { // Walk up the cached nextMsgRecipient to find the next available actor if // any. if (aUseCache && aActor.nextMsgRecipient) { let nextMsgRecipient = aActor.nextMsgRecipient; while (nextMsgRecipient) { let [actor] = nextMsgRecipient; if ( !actor.hasBeenDestroyed() && actor.windowContext && !actor.windowContext.isInBFCache ) { return nextMsgRecipient; } nextMsgRecipient = actor.nextMsgRecipient; } } if (aActor.hasBeenDestroyed()) { return [null, null]; } let childBC = aActor.browsingContext; let parentBC = childBC.parent; // Walk up the browsing context tree from aActor's browsing context // to find the first ancestor browsing context that's in a different process. while (parentBC) { if (!childBC.currentWindowGlobal || !parentBC.currentWindowGlobal) { break; } let childPid = childBC.currentWindowGlobal.osPid; let parentPid = parentBC.currentWindowGlobal.osPid; if (childPid == parentPid) { childBC = parentBC; parentBC = childBC.parent; } else { break; } } let target = null; let inProcessBC = null; if (parentBC && parentBC.currentWindowGlobal) { target = parentBC.currentWindowGlobal.getActor("DOMFullscreen"); inProcessBC = childBC; aActor.nextMsgRecipient = [target, inProcessBC]; } else { target = aActor.requestOrigin; } if ( !target || target.hasBeenDestroyed() || target.windowContext?.isInBFCache ) { return [null, null]; } return [target, inProcessBC]; }, _isRemoteBrowser(aBrowser) { return gMultiProcessBrowser && aBrowser.getAttribute("remote") == "true"; }, getMouseTargetRect() { return this._mouseTargetRect; }, // Event callbacks _expandCallback() { FullScreen.showNavToolbox(); }, onMouseEnter() { this.hideNavToolbox(); }, _keyToggleCallback(aEvent) { // if we can use the keyboard (eg Ctrl+L or Ctrl+E) to open the toolbars, we // should provide a way to collapse them too. if (aEvent.keyCode == aEvent.DOM_VK_ESCAPE) { FullScreen.hideNavToolbox(); } else if (aEvent.keyCode == aEvent.DOM_VK_F6) { // F6 is another shortcut to the address bar, but its not covered in OpenLocation() FullScreen.showNavToolbox(); } }, // Checks whether we are allowed to collapse the chrome _isPopupOpen: false, _isChromeCollapsed: false, _setPopupOpen(aEvent) { // Popups should only veto chrome collapsing if they were opened when the chrome was not collapsed. // Otherwise, they would not affect chrome and the user would expect the chrome to go away. // e.g. we wouldn't want the autoscroll icon firing this event, so when the user // toggles chrome when moving mouse to the top, it doesn't go away again. let target = aEvent.originalTarget; if (target.localName == "tooltip") { return; } if ( aEvent.type == "popupshown" && !FullScreen._isChromeCollapsed && target.getAttribute("nopreventnavboxhide") != "true" ) { FullScreen._isPopupOpen = true; } else if (aEvent.type == "popuphidden") { FullScreen._isPopupOpen = false; // Try again to hide toolbar when we close the popup. FullScreen.hideNavToolbox(true); } }, // UrlbarController listener method onViewOpen() { if (!this._isChromeCollapsed) { this._isPopupOpen = true; } }, // UrlbarController listener method onViewClose() { this._isPopupOpen = false; this.hideNavToolbox(true); }, get navToolboxHidden() { return this._isChromeCollapsed; }, // Autohide helpers for the context menu item updateAutohideMenuitem(aItem) { aItem.setAttribute( "checked", Services.prefs.getBoolPref("browser.fullscreen.autohide") ); }, setAutohide() { Services.prefs.setBoolPref( "browser.fullscreen.autohide", !Services.prefs.getBoolPref("browser.fullscreen.autohide") ); // Try again to hide toolbar when we change the pref. FullScreen.hideNavToolbox(true); }, showNavToolbox(trackMouse = true) { if (BrowserHandler.kiosk) { return; } this.fullScreenToggler.hidden = true; gNavToolbox.removeAttribute("fullscreenShouldAnimate"); gNavToolbox.style.marginTop = ""; if (!this._isChromeCollapsed) { return; } // Track whether mouse is near the toolbox if (trackMouse) { let rect = gBrowser.tabpanels.getBoundingClientRect(); this._mouseTargetRect = { top: rect.top + 50, bottom: rect.bottom, left: rect.left, right: rect.right, }; MousePosTracker.addListener(this); } this._isChromeCollapsed = false; Services.obs.notifyObservers(null, "fullscreen-nav-toolbox", "shown"); }, hideNavToolbox(aAnimate = false) { if (this._isChromeCollapsed) { return; } if (!Services.prefs.getBoolPref("browser.fullscreen.autohide")) { return; } // a popup menu is open in chrome: don't collapse chrome if (this._isPopupOpen) { return; } // a textbox in chrome is focused (location bar anyone?): don't collapse chrome // unless we are kiosk mode let focused = document.commandDispatcher.focusedElement; if ( focused && focused.ownerDocument == document && focused.localName == "input" && !BrowserHandler.kiosk ) { // But try collapse the chrome again when anything happens which can make // it lose the focus. We cannot listen on "blur" event on focused here // because that event can be triggered by "mousedown", and hiding chrome // would cause the content to move. This combination may split a single // click into two actionless halves. let retryHideNavToolbox = () => { // Wait for at least a frame to give it a chance to be passed down to // the content. requestAnimationFrame(() => { setTimeout(() => { // In the meantime, it's possible that we exited fullscreen somehow, // so only hide the toolbox if we're still in fullscreen mode. if (window.fullScreen) { this.hideNavToolbox(aAnimate); } }, 0); }); window.removeEventListener("keydown", retryHideNavToolbox); window.removeEventListener("click", retryHideNavToolbox); }; window.addEventListener("keydown", retryHideNavToolbox); window.addEventListener("click", retryHideNavToolbox); return; } if (!BrowserHandler.kiosk) { this.fullScreenToggler.hidden = false; } if ( aAnimate && window.matchMedia("(prefers-reduced-motion: no-preference)").matches && !BrowserHandler.kiosk ) { gNavToolbox.setAttribute("fullscreenShouldAnimate", true); } gNavToolbox.style.marginTop = -gNavToolbox.getBoundingClientRect().height + "px"; this._isChromeCollapsed = true; Services.obs.notifyObservers(null, "fullscreen-nav-toolbox", "hidden"); MousePosTracker.removeListener(this); }, _updateToolbars(aEnterFS) { for (let el of document.querySelectorAll( "toolbar[fullscreentoolbar=true]" )) { // Set the inFullscreen attribute to allow specific styling // in fullscreen mode if (aEnterFS) { el.setAttribute("inFullscreen", true); } else { el.removeAttribute("inFullscreen"); } } ToolbarIconColor.inferFromText("fullscreen", aEnterFS); }, }; XPCOMUtils.defineLazyGetter(FullScreen, "_permissionNotificationIDs", () => { let { PermissionUI } = ChromeUtils.import( "resource:///modules/PermissionUI.jsm" ); return ( 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"]) ); });