diff options
Diffstat (limited to '')
59 files changed, 11243 insertions, 0 deletions
diff --git a/toolkit/actors/AboutHttpsOnlyErrorChild.sys.mjs b/toolkit/actors/AboutHttpsOnlyErrorChild.sys.mjs new file mode 100644 index 0000000000..20313d30c0 --- /dev/null +++ b/toolkit/actors/AboutHttpsOnlyErrorChild.sys.mjs @@ -0,0 +1,83 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { RemotePageChild } from "resource://gre/actors/RemotePageChild.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "@mozilla.org/network/serialization-helper;1", + "nsISerializationHelper" +); + +export class AboutHttpsOnlyErrorChild extends RemotePageChild { + actorCreated() { + super.actorCreated(); + + // If you add a new function, remember to add it to RemotePageAccessManager.sys.mjs + // to allow content-privileged about:httpsonlyerror to use it. + const exportableFunctions = [ + "RPMTryPingSecureWWWLink", + "RPMOpenSecureWWWLink", + ]; + this.exportFunctions(exportableFunctions); + } + + RPMTryPingSecureWWWLink() { + // try if the page can be reached with www prefix + // if so send message to the parent to send message to the error page to display + // suggestion button for www + + const httpsOnlySuggestionPref = Services.prefs.getBoolPref( + "dom.security.https_only_mode_error_page_user_suggestions" + ); + + // only check if pref is true otherwise return + if (!httpsOnlySuggestionPref) { + return; + } + + // get the host url without the path with www in front + const wwwURL = "https://www." + this.contentWindow.location.host; + fetch(wwwURL, { + credentials: "omit", + cache: "no-store", + }) + .then(data => { + if (data.status === 200) { + this.contentWindow.dispatchEvent( + new this.contentWindow.CustomEvent("pingSecureWWWLinkSuccess") + ); + } + }) + .catch(() => { + dump("No secure www suggestion possible for " + wwwURL); + }); + } + + RPMOpenSecureWWWLink() { + // if user wants to visit suggested secure www page: visit page with www prefix and delete errorpage from history + const context = this.manager.browsingContext; + const docShell = context.docShell; + const httpChannel = docShell.failedChannel.QueryInterface( + Ci.nsIHttpChannel + ); + const webNav = docShell.QueryInterface(Ci.nsIWebNavigation); + const triggeringPrincipal = + docShell.failedChannel.loadInfo.triggeringPrincipal; + const oldURI = httpChannel.URI; + const newWWWURI = oldURI + .mutate() + .setHost("www." + oldURI.host) + .finalize(); + + webNav.loadURI(newWWWURI, { + triggeringPrincipal, + loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY, + }); + } +} diff --git a/toolkit/actors/AboutHttpsOnlyErrorParent.sys.mjs b/toolkit/actors/AboutHttpsOnlyErrorParent.sys.mjs new file mode 100644 index 0000000000..a7a4b23cfa --- /dev/null +++ b/toolkit/actors/AboutHttpsOnlyErrorParent.sys.mjs @@ -0,0 +1,48 @@ +/* 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/. */ + +const { HomePage } = ChromeUtils.import("resource:///modules/HomePage.jsm"); +import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs"; + +export class AboutHttpsOnlyErrorParent extends JSWindowActorParent { + get browser() { + return this.browsingContext.top.embedderElement; + } + + receiveMessage(aMessage) { + switch (aMessage.name) { + case "goBack": + this.goBackFromErrorPage(this.browser); + break; + } + } + + goBackFromErrorPage(aBrowser) { + if (!aBrowser.canGoBack) { + // If the unsafe page is the first or the only one in history, go to the + // start page. + aBrowser.fixupAndLoadURIString( + this.getDefaultHomePage(aBrowser.ownerGlobal), + { + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + } + ); + } else { + aBrowser.goBack(); + } + } + + getDefaultHomePage(win) { + if (PrivateBrowsingUtils.isWindowPrivate(win)) { + return win.BROWSER_NEW_TAB_URL || "about:blank"; + } + let url = HomePage.getDefault(); + // If url is a pipe-delimited set of pages, just take the first one. + if (url.includes("|")) { + url = url.split("|")[0]; + } + return url; + } +} diff --git a/toolkit/actors/AudioPlaybackChild.sys.mjs b/toolkit/actors/AudioPlaybackChild.sys.mjs new file mode 100644 index 0000000000..a392a73464 --- /dev/null +++ b/toolkit/actors/AudioPlaybackChild.sys.mjs @@ -0,0 +1,20 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +export class AudioPlaybackChild extends JSWindowActorChild { + observe(subject, topic, data) { + if (topic === "audio-playback") { + let name = "AudioPlayback:"; + if (data === "activeMediaBlockStart") { + name += "ActiveMediaBlockStart"; + } else if (data === "activeMediaBlockStop") { + name += "ActiveMediaBlockStop"; + } else { + name += data === "active" ? "Start" : "Stop"; + } + this.sendAsyncMessage(name); + } + } +} diff --git a/toolkit/actors/AudioPlaybackParent.sys.mjs b/toolkit/actors/AudioPlaybackParent.sys.mjs new file mode 100644 index 0000000000..db682fd90b --- /dev/null +++ b/toolkit/actors/AudioPlaybackParent.sys.mjs @@ -0,0 +1,42 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +export class AudioPlaybackParent extends JSWindowActorParent { + constructor() { + super(); + this._hasAudioPlayback = false; + this._hasBlockMedia = false; + } + receiveMessage(aMessage) { + const browser = this.browsingContext.top.embedderElement; + switch (aMessage.name) { + case "AudioPlayback:Start": + this._hasAudioPlayback = true; + browser.audioPlaybackStarted(); + break; + case "AudioPlayback:Stop": + this._hasAudioPlayback = false; + browser.audioPlaybackStopped(); + break; + case "AudioPlayback:ActiveMediaBlockStart": + this._hasBlockMedia = true; + browser.activeMediaBlockStarted(); + break; + case "AudioPlayback:ActiveMediaBlockStop": + this._hasBlockMedia = false; + browser.activeMediaBlockStopped(); + break; + } + } + didDestroy() { + const browser = this.browsingContext.top.embedderElement; + if (browser && this._hasAudioPlayback) { + browser.audioPlaybackStopped(); + } + if (browser && this._hasBlockMedia) { + browser.activeMediaBlockStopped(); + } + } +} diff --git a/toolkit/actors/AutoCompleteChild.sys.mjs b/toolkit/actors/AutoCompleteChild.sys.mjs new file mode 100644 index 0000000000..a405834ca9 --- /dev/null +++ b/toolkit/actors/AutoCompleteChild.sys.mjs @@ -0,0 +1,197 @@ +/* -*- 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/. */ + +/* eslint no-unused-vars: ["error", {args: "none"}] */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ContentDOMReference: "resource://gre/modules/ContentDOMReference.sys.mjs", + LayoutUtils: "resource://gre/modules/LayoutUtils.sys.mjs", + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", +}); + +let autoCompleteListeners = new Set(); + +export class AutoCompleteChild extends JSWindowActorChild { + constructor() { + super(); + + this._input = null; + this._popupOpen = false; + } + + static addPopupStateListener(listener) { + autoCompleteListeners.add(listener); + } + + static removePopupStateListener(listener) { + autoCompleteListeners.delete(listener); + } + + receiveMessage(message) { + switch (message.name) { + case "FormAutoComplete:HandleEnter": { + this.selectedIndex = message.data.selectedIndex; + + let controller = Cc[ + "@mozilla.org/autocomplete/controller;1" + ].getService(Ci.nsIAutoCompleteController); + controller.handleEnter(message.data.isPopupSelection); + break; + } + + case "FormAutoComplete:PopupClosed": { + this._popupOpen = false; + this.notifyListeners(message.name, message.data); + break; + } + + case "FormAutoComplete:PopupOpened": { + this._popupOpen = true; + this.notifyListeners(message.name, message.data); + break; + } + + case "FormAutoComplete:Focus": { + // XXX See bug 1582722 + // Before bug 1573836, the messages here didn't match + // ("FormAutoComplete:Focus" versus "FormAutoComplete:RequestFocus") + // so this was never called. However this._input is actually a + // nsIAutoCompleteInput, which doesn't have a focus() method, so it + // wouldn't have worked anyway. So for now, I have just disabled this. + /* + if (this._input) { + this._input.focus(); + } + */ + break; + } + } + } + + notifyListeners(messageName, data) { + for (let listener of autoCompleteListeners) { + try { + listener.popupStateChanged(messageName, data, this.contentWindow); + } catch (ex) { + console.error(ex); + } + } + } + + get input() { + return this._input; + } + + set selectedIndex(index) { + this.sendAsyncMessage("FormAutoComplete:SetSelectedIndex", { index }); + } + + get selectedIndex() { + // selectedIndex getter must be synchronous because we need the + // correct value when the controller is in controller::HandleEnter. + // We can't easily just let the parent inform us the new value every + // time it changes because not every action that can change the + // selectedIndex is trivial to catch (e.g. moving the mouse over the + // list). + let selectedIndexResult = Services.cpmm.sendSyncMessage( + "FormAutoComplete:GetSelectedIndex", + { + browsingContext: this.browsingContext, + } + ); + + if ( + selectedIndexResult.length != 1 || + !Number.isInteger(selectedIndexResult[0]) + ) { + throw new Error("Invalid autocomplete selectedIndex"); + } + return selectedIndexResult[0]; + } + + get popupOpen() { + return this._popupOpen; + } + + openAutocompletePopup(input, element) { + if (this._popupOpen || !input) { + return; + } + + let rect = lazy.LayoutUtils.getElementBoundingScreenRect(element); + let window = element.ownerGlobal; + let dir = window.getComputedStyle(element).direction; + let results = this.getResultsFromController(input); + let formOrigin = lazy.LoginHelper.getLoginOrigin( + element.ownerDocument.documentURI + ); + let inputElementIdentifier = lazy.ContentDOMReference.get(element); + + this.sendAsyncMessage("FormAutoComplete:MaybeOpenPopup", { + results, + rect, + dir, + inputElementIdentifier, + formOrigin, + }); + + this._input = input; + } + + closePopup() { + // We set this here instead of just waiting for the + // PopupClosed message to do it so that we don't end + // up in a state where the content thinks that a popup + // is open when it isn't (or soon won't be). + this._popupOpen = false; + this.sendAsyncMessage("FormAutoComplete:ClosePopup", {}); + } + + invalidate() { + if (this._popupOpen) { + let results = this.getResultsFromController(this._input); + this.sendAsyncMessage("FormAutoComplete:Invalidate", { results }); + } + } + + selectBy(reverse, page) { + Services.cpmm.sendSyncMessage("FormAutoComplete:SelectBy", { + browsingContext: this.browsingContext, + reverse, + page, + }); + } + + getResultsFromController(inputField) { + let results = []; + + if (!inputField) { + return results; + } + + let controller = inputField.controller; + if (!(controller instanceof Ci.nsIAutoCompleteController)) { + return results; + } + + for (let i = 0; i < controller.matchCount; ++i) { + let result = {}; + result.value = controller.getValueAt(i); + result.label = controller.getLabelAt(i); + result.comment = controller.getCommentAt(i); + result.style = controller.getStyleAt(i); + result.image = controller.getImageAt(i); + results.push(result); + } + + return results; + } +} + +AutoCompleteChild.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIAutoCompletePopup", +]); diff --git a/toolkit/actors/AutoCompleteParent.sys.mjs b/toolkit/actors/AutoCompleteParent.sys.mjs new file mode 100644 index 0000000000..82ebf22dcf --- /dev/null +++ b/toolkit/actors/AutoCompleteParent.sys.mjs @@ -0,0 +1,516 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyModuleGetters(lazy, { + GeckoViewAutocomplete: "resource://gre/modules/GeckoViewAutocomplete.jsm", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "DELEGATE_AUTOCOMPLETE", + "toolkit.autocomplete.delegate", + false +); + +ChromeUtils.defineESModuleGetters(lazy, { + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +const PREF_SECURITY_DELAY = "security.notification_enable_delay"; + +// Stores the actor that has the active popup, used by formfill +let currentActor = null; + +let autoCompleteListeners = new Set(); + +function compareContext(message) { + if ( + !currentActor || + (currentActor.browsingContext != message.data.browsingContext && + currentActor.browsingContext.top != message.data.browsingContext) + ) { + return false; + } + + return true; +} + +// These are two synchronous messages sent by the child. +// The browsingContext within the message data is either the one that has +// the active autocomplete popup or the top-level of the one that has +// the active autocomplete popup. +Services.ppmm.addMessageListener( + "FormAutoComplete:GetSelectedIndex", + message => { + if (compareContext(message)) { + let actor = currentActor; + if (actor && actor.openedPopup) { + return actor.openedPopup.selectedIndex; + } + } + + return -1; + } +); + +Services.ppmm.addMessageListener("FormAutoComplete:SelectBy", message => { + if (compareContext(message)) { + let actor = currentActor; + if (actor && actor.openedPopup) { + actor.openedPopup.selectBy(message.data.reverse, message.data.page); + } + } +}); + +// AutoCompleteResultView is an abstraction around a list of results. +// It implements enough of nsIAutoCompleteController and +// nsIAutoCompleteInput to make the richlistbox popup work. Since only +// one autocomplete popup should be open at a time, this is a singleton. +var AutoCompleteResultView = { + // nsISupports + QueryInterface: ChromeUtils.generateQI([ + "nsIAutoCompleteController", + "nsIAutoCompleteInput", + ]), + + // Private variables + results: [], + + // The AutoCompleteParent currently showing results or null otherwise. + currentActor: null, + + // nsIAutoCompleteController + get matchCount() { + return this.results.length; + }, + + getValueAt(index) { + return this.results[index].value; + }, + + getFinalCompleteValueAt(index) { + return this.results[index].value; + }, + + getLabelAt(index) { + // Backwardly-used by richlist autocomplete - see getCommentAt. + // The label is used for secondary information. + return this.results[index].comment; + }, + + getCommentAt(index) { + // The richlist autocomplete popup uses comment for its main + // display of an item, which is why we're returning the label + // here instead. + return this.results[index].label; + }, + + getStyleAt(index) { + return this.results[index].style; + }, + + getImageAt(index) { + return this.results[index].image; + }, + + handleEnter(aIsPopupSelection) { + if (this.currentActor) { + this.currentActor.handleEnter(aIsPopupSelection); + } + }, + + stopSearch() {}, + + searchString: "", + + // nsIAutoCompleteInput + get controller() { + return this; + }, + + get popup() { + return null; + }, + + _focus() { + if (this.currentActor) { + this.currentActor.requestFocus(); + } + }, + + // Internal JS-only API + clearResults() { + this.currentActor = null; + this.results = []; + }, + + setResults(actor, results) { + this.currentActor = actor; + this.results = results; + }, +}; + +export class AutoCompleteParent extends JSWindowActorParent { + didDestroy() { + if (this.openedPopup) { + this.openedPopup.closePopup(); + } + } + + static getCurrentActor() { + return currentActor; + } + + static addPopupStateListener(listener) { + autoCompleteListeners.add(listener); + } + + static removePopupStateListener(listener) { + autoCompleteListeners.delete(listener); + } + + handleEvent(evt) { + switch (evt.type) { + case "popupshowing": { + this.sendAsyncMessage("FormAutoComplete:PopupOpened", {}); + break; + } + + case "popuphidden": { + let selectedIndex = this.openedPopup.selectedIndex; + let selectedRowComment = + selectedIndex != -1 + ? AutoCompleteResultView.getCommentAt(selectedIndex) + : ""; + let selectedRowStyle = + selectedIndex != -1 + ? AutoCompleteResultView.getStyleAt(selectedIndex) + : ""; + this.sendAsyncMessage("FormAutoComplete:PopupClosed", { + selectedRowComment, + selectedRowStyle, + }); + AutoCompleteResultView.clearResults(); + // adjustHeight clears the height from the popup so that + // we don't have a big shrink effect if we closed with a + // large list, and then open on a small one. + this.openedPopup.adjustHeight(); + this.openedPopup = null; + currentActor = null; + evt.target.removeEventListener("popuphidden", this); + evt.target.removeEventListener("popupshowing", this); + break; + } + } + } + + showPopupWithResults({ rect, dir, results }) { + if (!results.length || this.openedPopup) { + // We shouldn't ever be showing an empty popup, and if we + // already have a popup open, the old one needs to close before + // we consider opening a new one. + return; + } + + let browser = this.browsingContext.top.embedderElement; + let window = browser.ownerGlobal; + // Also check window top in case this is a sidebar. + if ( + Services.focus.activeWindow !== window.top && + Services.focus.focusedWindow.top !== window.top + ) { + // We were sent a message from a window or tab that went into the + // background, so we'll ignore it for now. + return; + } + + // Non-empty result styles + let resultStyles = new Set(results.map(r => r.style).filter(r => !!r)); + currentActor = this; + this.openedPopup = browser.autoCompletePopup; + // the layout varies according to different result type + this.openedPopup.setAttribute("resultstyles", [...resultStyles].join(" ")); + this.openedPopup.hidden = false; + // don't allow the popup to become overly narrow + this.openedPopup.style.setProperty( + "--panel-width", + Math.max(100, rect.width) + "px" + ); + this.openedPopup.style.direction = dir; + + AutoCompleteResultView.setResults(this, results); + this.openedPopup.view = AutoCompleteResultView; + this.openedPopup.selectedIndex = -1; + + // Reset fields that were set from the last time the search popup was open + this.openedPopup.mInput = AutoCompleteResultView; + // Temporarily increase the maxRows as we don't want to show + // the scrollbar in login or form autofill popups. + if ( + resultStyles.size && + (resultStyles.has("autofill-profile") || resultStyles.has("loginsFooter")) + ) { + this.openedPopup._normalMaxRows = this.openedPopup.maxRows; + this.openedPopup.mInput.maxRows = 10; + } + browser.constrainPopup(this.openedPopup); + this.openedPopup.addEventListener("popuphidden", this); + this.openedPopup.addEventListener("popupshowing", this); + this.openedPopup.openPopupAtScreenRect( + "after_start", + rect.left, + rect.top, + rect.width, + rect.height, + false, + false + ); + this.openedPopup.invalidate(); + this._maybeRecordTelemetryEvents(results); + + // This is a temporary solution. We should replace it with + // proper meta information about the popup once such field + // becomes available. + let isCreditCard = results.some(result => + result?.comment?.includes("cc-number") + ); + + if (isCreditCard) { + this.delayPopupInput(); + } + } + + /** + * @param {object[]} results - Non-empty array of autocomplete results. + */ + _maybeRecordTelemetryEvents(results) { + let actor = + this.browsingContext.currentWindowGlobal.getActor("LoginManager"); + actor.maybeRecordPasswordGenerationShownTelemetryEvent(results); + + // Assume the result with the start time (loginsFooter) is last. + let lastResult = results[results.length - 1]; + if (lastResult.style != "loginsFooter") { + return; + } + + // The comment field of `loginsFooter` results have many additional pieces of + // information for telemetry purposes. After bug 1555209, this information + // can be passed to the parent process outside of nsIAutoCompleteResult APIs + // so we won't need this hack. + let rawExtraData = JSON.parse(lastResult.comment).telemetryEventData; + if (!rawExtraData.searchStartTimeMS) { + throw new Error("Invalid autocomplete search start time"); + } + + if (rawExtraData.stringLength > 1) { + // To reduce event volume, only record for lengths 0 and 1. + return; + } + + let duration = + Services.telemetry.msSystemNow() - rawExtraData.searchStartTimeMS; + delete rawExtraData.searchStartTimeMS; + + // Add counts by result style to rawExtraData. + results.reduce((accumulated, r) => { + // Ignore learn more as it is only added after importable logins. + // Do not track generic items in the telemetry. + if (r.style === "importableLearnMore" || r.style === "generic") { + return accumulated; + } + + // Keys can be a maximum of 15 characters and values must be strings. + // Also treat both "loginWithOrigin" and "login" as "login" as extra_keys + // is limited to 10. + let truncatedStyle = r.style.substring( + 0, + r.style === "loginWithOrigin" ? 5 : 15 + ); + accumulated[truncatedStyle] = (accumulated[truncatedStyle] || 0) + 1; + return accumulated; + }, rawExtraData); + + // Convert extra values to strings since recordEvent requires that. + let extraStrings = Object.fromEntries( + Object.entries(rawExtraData).map(([key, val]) => { + let stringVal = ""; + if (typeof val == "boolean") { + stringVal += val ? "1" : "0"; + } else { + stringVal += val; + } + return [key, stringVal]; + }) + ); + + Services.telemetry.recordEvent( + "form_autocomplete", + "show", + "logins", + // Convert to a string + duration + "", + extraStrings + ); + } + + invalidate(results) { + if (!this.openedPopup) { + return; + } + + if (!results.length) { + this.closePopup(); + } else { + AutoCompleteResultView.setResults(this, results); + this.openedPopup.invalidate(); + this._maybeRecordTelemetryEvents(results); + } + } + + closePopup() { + if (this.openedPopup) { + // Note that hidePopup() closes the popup immediately, + // so popuphiding or popuphidden events will be fired + // and handled during this call. + this.openedPopup.hidePopup(); + } + } + + receiveMessage(message) { + let browser = this.browsingContext.top.embedderElement; + + if ( + !browser || + (!lazy.DELEGATE_AUTOCOMPLETE && !browser.autoCompletePopup) + ) { + // If there is no browser or popup, just make sure that the popup has been closed. + if (this.openedPopup) { + this.openedPopup.closePopup(); + } + + // Returning false to pacify ESLint, but this return value is + // ignored by the messaging infrastructure. + return false; + } + + switch (message.name) { + case "FormAutoComplete:SetSelectedIndex": { + let { index } = message.data; + if (this.openedPopup) { + this.openedPopup.selectedIndex = index; + } + break; + } + + case "FormAutoComplete:MaybeOpenPopup": { + let { results, rect, dir, inputElementIdentifier, formOrigin } = + message.data; + if (lazy.DELEGATE_AUTOCOMPLETE) { + lazy.GeckoViewAutocomplete.delegateSelection({ + browsingContext: this.browsingContext, + options: results, + inputElementIdentifier, + formOrigin, + }); + } else { + this.showPopupWithResults({ results, rect, dir }); + this.notifyListeners(); + } + break; + } + + case "FormAutoComplete:Invalidate": { + let { results } = message.data; + this.invalidate(results); + break; + } + + case "FormAutoComplete:ClosePopup": { + if (lazy.DELEGATE_AUTOCOMPLETE) { + lazy.GeckoViewAutocomplete.delegateDismiss(); + break; + } + this.closePopup(); + break; + } + } + // Returning false to pacify ESLint, but this return value is + // ignored by the messaging infrastructure. + return false; + } + + // Imposes a brief period during which the popup will not respond to + // a click, so as to reduce the chances of a successful clickjacking + // attempt + delayPopupInput() { + if (!this.openedPopup) { + return; + } + const popupDelay = Services.prefs.getIntPref(PREF_SECURITY_DELAY); + + // Mochitests set this to 0, and many will fail on integration + // if we make the popup items inactive, even briefly. + if (!popupDelay) { + return; + } + + const items = Array.from( + this.openedPopup.getElementsByTagName("richlistitem") + ); + items.forEach(item => (item.disabled = true)); + + lazy.setTimeout( + () => items.forEach(item => (item.disabled = false)), + popupDelay + ); + } + + notifyListeners() { + let window = this.browsingContext.top.embedderElement.ownerGlobal; + for (let listener of autoCompleteListeners) { + try { + listener(window); + } catch (ex) { + console.error(ex); + } + } + } + + /** + * Despite its name, this handleEnter is only called when the user clicks on + * one of the items in the popup since the popup is rendered in the parent process. + * The real controller's handleEnter is called directly in the content process + * for other methods of completing a selection (e.g. using the tab or enter + * keys) since the field with focus is in that process. + * @param {boolean} aIsPopupSelection + */ + handleEnter(aIsPopupSelection) { + if (this.openedPopup) { + this.sendAsyncMessage("FormAutoComplete:HandleEnter", { + selectedIndex: this.openedPopup.selectedIndex, + isPopupSelection: aIsPopupSelection, + }); + } + } + + stopSearch() {} + + /** + * Sends a message to the browser that is requesting the input + * that the open popup should be focused. + */ + requestFocus() { + // Bug 1582722 - See the response in AutoCompleteChild.jsm for why this disabled. + /* + if (this.openedPopup) { + this.sendAsyncMessage("FormAutoComplete:Focus"); + } + */ + } +} diff --git a/toolkit/actors/AutoScrollChild.sys.mjs b/toolkit/actors/AutoScrollChild.sys.mjs new file mode 100644 index 0000000000..88fa078c28 --- /dev/null +++ b/toolkit/actors/AutoScrollChild.sys.mjs @@ -0,0 +1,445 @@ +/* -*- 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", +}); + +export class AutoScrollChild extends JSWindowActorChild { + constructor() { + super(); + + this._scrollable = null; + this._scrolldir = ""; + this._startX = null; + this._startY = null; + this._screenX = null; + this._screenY = null; + this._lastFrame = null; + this._autoscrollHandledByApz = false; + this._scrollId = null; + + this.observer = new AutoScrollObserver(this); + this.autoscrollLoop = this.autoscrollLoop.bind(this); + } + + isAutoscrollBlocker(event) { + let mmPaste = Services.prefs.getBoolPref("middlemouse.paste"); + let mmScrollbarPosition = Services.prefs.getBoolPref( + "middlemouse.scrollbarPosition" + ); + let node = event.originalTarget; + let content = node.ownerGlobal; + + // If the node is in editable document or content, we don't want to start + // autoscroll. + if (mmPaste) { + if (node.ownerDocument?.designMode == "on") { + return true; + } + const element = + node.nodeType === content.Node.ELEMENT_NODE ? node : node.parentElement; + if (element.isContentEditable) { + return true; + } + } + + // Don't start if we're on a link. + let [href] = lazy.BrowserUtils.hrefAndLinkNodeForClickEvent(event); + if (href) { + return true; + } + + // Or if we're pasting into an input field of sorts. + let closestInput = mmPaste && node.closest("input,textarea"); + if ( + content.HTMLInputElement.isInstance(closestInput) || + content.HTMLTextAreaElement.isInstance(closestInput) + ) { + return true; + } + + // Or if we're on a scrollbar or XUL <tree> + if ( + (mmScrollbarPosition && + content.XULElement.isInstance( + node.closest("scrollbar,scrollcorner") + )) || + content.XULElement.isInstance(node.closest("treechildren")) + ) { + return true; + } + return false; + } + + isScrollableElement(aNode) { + let content = aNode.ownerGlobal; + if (content.HTMLElement.isInstance(aNode)) { + return !content.HTMLSelectElement.isInstance(aNode) || aNode.multiple; + } + + return content.XULElement.isInstance(aNode); + } + + computeWindowScrollDirection(global) { + if (!global.scrollbars.visible) { + return null; + } + if (global.scrollMaxX != global.scrollMinX) { + return global.scrollMaxY != global.scrollMinY ? "NSEW" : "EW"; + } + if (global.scrollMaxY != global.scrollMinY) { + return "NS"; + } + return null; + } + + computeNodeScrollDirection(node) { + if (!this.isScrollableElement(node)) { + return null; + } + + let global = node.ownerGlobal; + + // this is a list of overflow property values that allow scrolling + const scrollingAllowed = ["scroll", "auto"]; + + let cs = global.getComputedStyle(node); + let overflowx = cs.getPropertyValue("overflow-x"); + let overflowy = cs.getPropertyValue("overflow-y"); + // we already discarded non-multiline selects so allow vertical + // scroll for multiline ones directly without checking for a + // overflow property + let scrollVert = + node.scrollTopMax && + (global.HTMLSelectElement.isInstance(node) || + scrollingAllowed.includes(overflowy)); + + // do not allow horizontal scrolling for select elements, it leads + // to visual artifacts and is not the expected behavior anyway + if ( + !global.HTMLSelectElement.isInstance(node) && + node.scrollLeftMin != node.scrollLeftMax && + scrollingAllowed.includes(overflowx) + ) { + return scrollVert ? "NSEW" : "EW"; + } + + if (scrollVert) { + return "NS"; + } + + return null; + } + + findNearestScrollableElement(aNode) { + // go upward in the DOM and find any parent element that has a overflow + // area and can therefore be scrolled + this._scrollable = null; + for (let node = aNode; node; node = node.flattenedTreeParentNode) { + // do not use overflow based autoscroll for <html> and <body> + // Elements or non-html/non-xul elements such as svg or Document nodes + // also make sure to skip select elements that are not multiline + let direction = this.computeNodeScrollDirection(node); + if (direction) { + this._scrolldir = direction; + this._scrollable = node; + break; + } + } + + if (!this._scrollable) { + let direction = this.computeWindowScrollDirection(aNode.ownerGlobal); + if (direction) { + this._scrolldir = direction; + this._scrollable = aNode.ownerGlobal; + } else if (aNode.ownerGlobal.frameElement) { + // Note, in case of out of process iframes frameElement is null, and + // a caller is supposed to communicate to iframe's parent on its own to + // support cross process scrolling. + this.findNearestScrollableElement(aNode.ownerGlobal.frameElement); + } + } + } + + async startScroll(event) { + this.findNearestScrollableElement(event.originalTarget); + if (!this._scrollable) { + this.sendAsyncMessage("Autoscroll:MaybeStartInParent", { + browsingContextId: this.browsingContext.id, + screenX: event.screenX, + screenY: event.screenY, + }); + return; + } + + let content = event.originalTarget.ownerGlobal; + + // In some configurations like Print Preview, content.performance + // (which we use below) is null. Autoscrolling is broken in Print + // Preview anyways (see bug 1393494), so just don't start it at all. + if (!content.performance) { + return; + } + + let domUtils = content.windowUtils; + let scrollable = this._scrollable; + if (scrollable instanceof Ci.nsIDOMWindow) { + // getViewId() needs an element to operate on. + scrollable = scrollable.document.documentElement; + } + this._scrollId = null; + try { + this._scrollId = domUtils.getViewId(scrollable); + } catch (e) { + // No view ID - leave this._scrollId as null. Receiving side will check. + } + let presShellId = domUtils.getPresShellId(); + let { autoscrollEnabled, usingApz } = await this.sendQuery( + "Autoscroll:Start", + { + scrolldir: this._scrolldir, + screenXDevPx: event.screenX * content.devicePixelRatio, + screenYDevPx: event.screenY * content.devicePixelRatio, + scrollId: this._scrollId, + presShellId, + browsingContext: this.browsingContext, + } + ); + if (!autoscrollEnabled) { + this._scrollable = null; + return; + } + + Services.els.addSystemEventListener(this.document, "mousemove", this, true); + Services.els.addSystemEventListener(this.document, "mouseup", this, true); + this.document.addEventListener("pagehide", this, true); + + this._startX = event.screenX; + this._startY = event.screenY; + this._screenX = event.screenX; + this._screenY = event.screenY; + this._scrollErrorX = 0; + this._scrollErrorY = 0; + this._autoscrollHandledByApz = usingApz; + + if (!usingApz) { + // If the browser didn't hand the autoscroll off to APZ, + // scroll here in the main thread. + this.startMainThreadScroll(); + } else { + // Even if the browser did hand the autoscroll to APZ, + // APZ might reject it in which case it will notify us + // and we need to take over. + Services.obs.addObserver(this.observer, "autoscroll-rejected-by-apz"); + } + + if (Cu.isInAutomation) { + Services.obs.notifyObservers(content, "autoscroll-start"); + } + } + + startMainThreadScroll() { + let content = this.document.defaultView; + this._lastFrame = content.performance.now(); + content.requestAnimationFrame(this.autoscrollLoop); + } + + stopScroll() { + if (this._scrollable) { + this._scrollable.mozScrollSnap(); + this._scrollable = null; + + Services.els.removeSystemEventListener( + this.document, + "mousemove", + this, + true + ); + Services.els.removeSystemEventListener( + this.document, + "mouseup", + this, + true + ); + this.document.removeEventListener("pagehide", this, true); + if (this._autoscrollHandledByApz) { + Services.obs.removeObserver( + this.observer, + "autoscroll-rejected-by-apz" + ); + } + } + } + + accelerate(curr, start) { + const speed = 12; + var val = (curr - start) / speed; + + if (val > 1) { + return val * Math.sqrt(val) - 1; + } + if (val < -1) { + return val * Math.sqrt(-val) + 1; + } + return 0; + } + + roundToZero(num) { + if (num > 0) { + return Math.floor(num); + } + return Math.ceil(num); + } + + autoscrollLoop(timestamp) { + if (!this._scrollable) { + // Scrolling has been canceled + return; + } + + // avoid long jumps when the browser hangs for more than + // |maxTimeDelta| ms + const maxTimeDelta = 100; + var timeDelta = Math.min(maxTimeDelta, timestamp - this._lastFrame); + // we used to scroll |accelerate()| pixels every 20ms (50fps) + var timeCompensation = timeDelta / 20; + this._lastFrame = timestamp; + + var actualScrollX = 0; + var actualScrollY = 0; + // don't bother scrolling vertically when the scrolldir is only horizontal + // and the other way around + if (this._scrolldir != "EW") { + var y = this.accelerate(this._screenY, this._startY) * timeCompensation; + var desiredScrollY = this._scrollErrorY + y; + actualScrollY = this.roundToZero(desiredScrollY); + this._scrollErrorY = desiredScrollY - actualScrollY; + } + if (this._scrolldir != "NS") { + var x = this.accelerate(this._screenX, this._startX) * timeCompensation; + var desiredScrollX = this._scrollErrorX + x; + actualScrollX = this.roundToZero(desiredScrollX); + this._scrollErrorX = desiredScrollX - actualScrollX; + } + + this._scrollable.scrollBy({ + left: actualScrollX, + top: actualScrollY, + behavior: "instant", + }); + + this._scrollable.ownerGlobal.requestAnimationFrame(this.autoscrollLoop); + } + + canStartAutoScrollWith(event) { + if ( + !event.isTrusted || + event.defaultPrevented || + event.button !== 1 || + event.clickEventPrevented() + ) { + return false; + } + + for (const modifier of ["shift", "alt", "ctrl", "meta"]) { + if ( + event[modifier + "Key"] && + Services.prefs.getBoolPref( + `general.autoscroll.prevent_to_start.${modifier}Key`, + false + ) + ) { + return false; + } + } + if ( + event.getModifierState("OS") && + Services.prefs.getBoolPref("general.autoscroll.prevent_to_start.osKey") + ) { + return false; + } + return true; + } + + handleEvent(event) { + switch (event.type) { + case "mousemove": + this._screenX = event.screenX; + this._screenY = event.screenY; + break; + case "mousedown": + if ( + this.canStartAutoScrollWith(event) && + !this._scrollable && + !this.isAutoscrollBlocker(event) + ) { + this.startScroll(event); + } + // fallthrough + case "mouseup": + if (this._scrollable) { + // Middle mouse click event shouldn't be fired in web content for + // compatibility with Chrome. + event.preventClickEvent(); + } + break; + case "pagehide": + if (this._scrollable) { + var doc = this._scrollable.ownerDocument || this._scrollable.document; + if (doc == event.target) { + this.sendAsyncMessage("Autoscroll:Cancel"); + this.stopScroll(); + } + } + break; + } + } + + receiveMessage(msg) { + let data = msg.data; + switch (msg.name) { + case "Autoscroll:MaybeStart": + for (let child of this.browsingContext.children) { + if (data.browsingContextId == child.id) { + this.startScroll({ + screenX: data.screenX, + screenY: data.screenY, + originalTarget: child.embedderElement, + }); + break; + } + } + break; + case "Autoscroll:Stop": { + this.stopScroll(); + break; + } + } + } + + rejectedByApz(data) { + // The caller passes in the scroll id via 'data'. + if (data == this._scrollId) { + this._autoscrollHandledByApz = false; + this.startMainThreadScroll(); + Services.obs.removeObserver(this.observer, "autoscroll-rejected-by-apz"); + } + } +} + +class AutoScrollObserver { + constructor(actor) { + this.actor = actor; + } + + observe(subject, topic, data) { + if (topic === "autoscroll-rejected-by-apz") { + this.actor.rejectedByApz(data); + } + } +} diff --git a/toolkit/actors/AutoScrollParent.sys.mjs b/toolkit/actors/AutoScrollParent.sys.mjs new file mode 100644 index 0000000000..1f9f780902 --- /dev/null +++ b/toolkit/actors/AutoScrollParent.sys.mjs @@ -0,0 +1,48 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +export class AutoScrollParent extends JSWindowActorParent { + receiveMessage(msg) { + let browser = this.manager.browsingContext.top.embedderElement; + if (!browser) { + return null; + } + + // If another tab is activated, we shouldn't start autoscroll requested + // for the previous active window if and only if the browser is a remote + // browser. This is required for web apps which don't prevent default of + // middle click after opening a new window. If the active tab is our + // documents like about:*, we don't need this check since our documents + // should do it correctly. + const requestedInForegroundTab = browser.isRemoteBrowser + ? Services.focus.focusedElement == browser + : true; + + let data = msg.data; + switch (msg.name) { + case "Autoscroll:Start": + // Don't start autoscroll if the tab has already been a background tab. + if (!requestedInForegroundTab) { + return Promise.resolve({ autoscrollEnabled: false, usingAPZ: false }); + } + return Promise.resolve(browser.startScroll(data)); + case "Autoscroll:MaybeStartInParent": + // Don't start autoscroll if the tab has already been a background tab. + if (!requestedInForegroundTab) { + return Promise.resolve({ autoscrollEnabled: false, usingAPZ: false }); + } + let parent = this.browsingContext.parent; + if (parent) { + let actor = parent.currentWindowGlobal.getActor("AutoScroll"); + actor.sendAsyncMessage("Autoscroll:MaybeStart", data); + } + break; + case "Autoscroll:Cancel": + browser.cancelScroll(); + break; + } + return null; + } +} diff --git a/toolkit/actors/AutoplayChild.sys.mjs b/toolkit/actors/AutoplayChild.sys.mjs new file mode 100644 index 0000000000..87fa966cb1 --- /dev/null +++ b/toolkit/actors/AutoplayChild.sys.mjs @@ -0,0 +1,10 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +export class AutoplayChild extends JSWindowActorChild { + handleEvent(event) { + this.sendAsyncMessage("GloballyAutoplayBlocked", {}); + } +} diff --git a/toolkit/actors/AutoplayParent.sys.mjs b/toolkit/actors/AutoplayParent.sys.mjs new file mode 100644 index 0000000000..3e9f807b3a --- /dev/null +++ b/toolkit/actors/AutoplayParent.sys.mjs @@ -0,0 +1,17 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +export class AutoplayParent extends JSWindowActorParent { + receiveMessage(aMessage) { + let topBrowsingContext = this.manager.browsingContext.top; + let browser = topBrowsingContext.embedderElement; + let document = browser.ownerDocument; + let event = document.createEvent("CustomEvent"); + event.initCustomEvent("GloballyAutoplayBlocked", true, false, { + url: this.documentURI, + }); + browser.dispatchEvent(event); + } +} diff --git a/toolkit/actors/BackgroundThumbnailsChild.sys.mjs b/toolkit/actors/BackgroundThumbnailsChild.sys.mjs new file mode 100644 index 0000000000..3af51b3b80 --- /dev/null +++ b/toolkit/actors/BackgroundThumbnailsChild.sys.mjs @@ -0,0 +1,102 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PageThumbUtils: "resource://gre/modules/PageThumbUtils.sys.mjs", +}); + +// NOTE: Copied from nsSandboxFlags.h +/** + * This flag prevents content from creating new auxiliary browsing contexts, + * e.g. using the target attribute, or the window.open() method. + */ +const SANDBOXED_AUXILIARY_NAVIGATION = 0x2; + +export class BackgroundThumbnailsChild extends JSWindowActorChild { + receiveMessage(message) { + switch (message.name) { + case "Browser:Thumbnail:ContentInfo": { + if ( + message.data.isImage || + this.contentWindow.ImageDocument.isInstance(this.document) + ) { + // To avoid sending additional messages between processes, we return + // the image data directly with the size info. + return lazy.PageThumbUtils.createImageThumbnailCanvas( + this.contentWindow, + this.document.location, + message.data.targetWidth, + message.data.backgroundColor + ); + } + + let [width, height] = lazy.PageThumbUtils.getContentSize( + this.contentWindow + ); + return { width, height }; + } + + case "Browser:Thumbnail:LoadURL": { + let docShell = this.docShell.QueryInterface(Ci.nsIWebNavigation); + + // We want a low network priority for this service - lower than b/g tabs + // etc - so set it to the lowest priority available. + docShell + .QueryInterface(Ci.nsIDocumentLoader) + .loadGroup.QueryInterface(Ci.nsISupportsPriority).priority = + Ci.nsISupportsPriority.PRIORITY_LOWEST; + + docShell.allowMedia = false; + docShell.allowPlugins = false; + docShell.allowContentRetargeting = false; + let defaultFlags = + Ci.nsIRequest.LOAD_ANONYMOUS | + Ci.nsIRequest.LOAD_BYPASS_CACHE | + Ci.nsIRequest.INHIBIT_CACHING | + Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_HISTORY; + docShell.defaultLoadFlags = defaultFlags; + this.browsingContext.sandboxFlags |= SANDBOXED_AUXILIARY_NAVIGATION; + docShell.useTrackingProtection = true; + + // Get the document to force a content viewer to be created, otherwise + // the first load can fail. + if (!this.document) { + return false; + } + + let loadURIOptions = { + // Bug 1498603 verify usages of systemPrincipal here + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_STOP_CONTENT, + }; + try { + docShell.loadURI( + Services.io.newURI(message.data.url), + loadURIOptions + ); + } catch (ex) { + return false; + } + + return true; + } + } + + return undefined; + } + + handleEvent(event) { + if (event.type == "DOMDocElementInserted") { + // Arrange to prevent (most) popup dialogs for this window - popups done + // in the parent (eg, auth) aren't prevented, but alert() etc are. + // disableDialogs only works on the current inner window, so it has + // to be called every page load, but before scripts run. + this.contentWindow.windowUtils.disableDialogs(); + } + } +} diff --git a/toolkit/actors/BrowserElementChild.sys.mjs b/toolkit/actors/BrowserElementChild.sys.mjs new file mode 100644 index 0000000000..97e1327821 --- /dev/null +++ b/toolkit/actors/BrowserElementChild.sys.mjs @@ -0,0 +1,35 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +export class BrowserElementChild extends JSWindowActorChild { + handleEvent(event) { + if ( + event.type == "DOMWindowClose" && + !this.manager.browsingContext.parent + ) { + this.sendAsyncMessage("DOMWindowClose", {}); + } + } + + receiveMessage(message) { + switch (message.name) { + case "EnterModalState": { + this.contentWindow.windowUtils.enterModalState(); + break; + } + + case "LeaveModalState": { + if ( + !message.data.forceLeave && + !this.contentWindow.windowUtils.isInModalState() + ) { + break; + } + this.contentWindow.windowUtils.leaveModalState(); + break; + } + } + } +} diff --git a/toolkit/actors/BrowserElementParent.sys.mjs b/toolkit/actors/BrowserElementParent.sys.mjs new file mode 100644 index 0000000000..c3cb0991a3 --- /dev/null +++ b/toolkit/actors/BrowserElementParent.sys.mjs @@ -0,0 +1,36 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +/** + * The BrowserElementParent is for performing actions on one or more subframes of + * a <xul:browser> from the browser element binding. + */ +export class BrowserElementParent extends JSWindowActorParent { + receiveMessage(message) { + switch (message.name) { + case "DOMWindowClose": { + // This message is sent whenever window.close() is called within a window + // that had originally been opened via window.open. Double-check that this is + // coming from a top-level frame, and then dispatch the DOMWindowClose event + // on the browser so that the front-end code can do the right thing with the + // request to close. + if (!this.manager.browsingContext.parent) { + let browser = this.manager.browsingContext.embedderElement; + let win = browser.ownerGlobal; + // If this is a non-remote browser, the DOMWindowClose event will bubble + // up naturally, and doesn't need to be re-dispatched. + if (browser.isRemoteBrowser) { + browser.dispatchEvent( + new win.CustomEvent("DOMWindowClose", { + bubbles: true, + }) + ); + } + } + break; + } + } + } +} diff --git a/toolkit/actors/ClipboardReadPasteChild.sys.mjs b/toolkit/actors/ClipboardReadPasteChild.sys.mjs new file mode 100644 index 0000000000..e8b113bd68 --- /dev/null +++ b/toolkit/actors/ClipboardReadPasteChild.sys.mjs @@ -0,0 +1,39 @@ +/* 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/. */ + +/** + * Propagates "MozClipboardReadPaste" events from a content process to the + * chrome process. + * Receives messages from the chrome process. + */ +export class ClipboardReadPasteChild extends JSWindowActorChild { + constructor() { + super(); + } + + // EventListener interface. + handleEvent(aEvent) { + if (aEvent.type == "MozClipboardReadPaste" && aEvent.isTrusted) { + this.sendAsyncMessage("ClipboardReadPaste:ShowMenupopup", {}); + } + } + + // For JSWindowActorChild. + receiveMessage(value) { + switch (value.name) { + case "ClipboardReadPaste:PasteMenuItemClicked": { + this.contentWindow.navigator.clipboard.onUserReactedToPasteMenuPopup( + true + ); + break; + } + case "ClipboardReadPaste:PasteMenuItemDismissed": { + this.contentWindow.navigator.clipboard.onUserReactedToPasteMenuPopup( + false + ); + break; + } + } + } +} diff --git a/toolkit/actors/ClipboardReadPasteParent.sys.mjs b/toolkit/actors/ClipboardReadPasteParent.sys.mjs new file mode 100644 index 0000000000..a0b37f1041 --- /dev/null +++ b/toolkit/actors/ClipboardReadPasteParent.sys.mjs @@ -0,0 +1,194 @@ +/* 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/. */ + +const kMenuPopupId = "clipboardReadPasteMenuPopup"; + +// Exchanges messages with the child actor and handles events from the +// pasteMenuHandler. +export class ClipboardReadPasteParent extends JSWindowActorParent { + constructor() { + super(); + + this._menupopup = null; + this._menuitem = null; + this._delayTimer = null; + this._pasteMenuItemClicked = false; + this._lastBeepTime = 0; + } + + didDestroy() { + if (this._menupopup) { + this._menupopup.hidePopup(true); + } + } + + // EventListener interface. + handleEvent(aEvent) { + switch (aEvent.type) { + case "command": { + this.onCommand(); + break; + } + case "popuphiding": { + this.onPopupHiding(); + break; + } + case "keydown": { + this.onKeyDown(aEvent); + break; + } + } + } + + onCommand() { + this._pasteMenuItemClicked = true; + this.sendAsyncMessage("ClipboardReadPaste:PasteMenuItemClicked"); + } + + onPopupHiding() { + // Remove the listeners before potentially sending the async message + // below, because that might throw. + this._removeMenupopupEventListeners(); + this._clearDelayTimer(); + this._stopWatchingForSpammyActivation(); + + if (this._pasteMenuItemClicked) { + // A message was already sent. Reset the state to handle further + // click/dismiss events properly. + this._pasteMenuItemClicked = false; + } else { + this.sendAsyncMessage("ClipboardReadPaste:PasteMenuItemDismissed"); + } + } + + onKeyDown(aEvent) { + if (!this._menuitem.disabled) { + return; + } + + let accesskey = this._menuitem.getAttribute("accesskey"); + if ( + aEvent.key == accesskey.toLowerCase() || + aEvent.key == accesskey.toUpperCase() + ) { + if (Date.now() - this._lastBeepTime > 1000) { + Cc["@mozilla.org/sound;1"].getService(Ci.nsISound).beep(); + this._lastBeepTime = Date.now(); + } + this._refreshDelayTimer(); + } + } + + // For JSWindowActorParent. + receiveMessage(value) { + if (value.name == "ClipboardReadPaste:ShowMenupopup") { + if (!this._menupopup) { + this._menupopup = this._getMenupopup(); + this._menuitem = this._menupopup.firstElementChild; + } + + this._addMenupopupEventListeners(); + + const browser = this.browsingContext.top.embedderElement; + const window = browser.ownerGlobal; + const windowUtils = window.windowUtils; + + let mouseXInCSSPixels = {}; + let mouseYInCSSPixels = {}; + windowUtils.getLastOverWindowPointerLocationInCSSPixels( + mouseXInCSSPixels, + mouseYInCSSPixels + ); + + this._menuitem.disabled = true; + this._startWatchingForSpammyActivation(); + // `openPopup` is a no-op if the popup is already opened. + // That property is used when `navigator.clipboard.readText()` or + // `navigator.clipboard.read()`is called from two different frames, e.g. + // an iframe and the top level frame. In that scenario, the two frames + // correspond to different `navigator.clipboard` instances. When + // `readText()` or `read()` is called from both frames, an actor pair is + // instantiated for each of them. Both actor parents will call `openPopup` + // on the same `_menupopup` object. If the popup is already open, + // `openPopup` is a no-op. When the popup is clicked or dismissed both + // actor parents will receive the corresponding event. + this._menupopup.openPopup( + null, + "overlap" /* options */, + mouseXInCSSPixels.value, + mouseYInCSSPixels.value, + true /* isContextMenu */ + ); + + this._refreshDelayTimer(); + } + } + + _addMenupopupEventListeners() { + this._menupopup.addEventListener("command", this); + this._menupopup.addEventListener("popuphiding", this); + } + + _removeMenupopupEventListeners() { + this._menupopup.removeEventListener("command", this); + this._menupopup.removeEventListener("popuphiding", this); + } + + _createMenupopup(aChromeDoc) { + let menuitem = aChromeDoc.createXULElement("menuitem"); + menuitem.id = "clipboardReadPasteMenuItem"; + menuitem.setAttribute("data-l10n-id", "text-action-paste"); + + let menupopup = aChromeDoc.createXULElement("menupopup"); + menupopup.id = kMenuPopupId; + menupopup.appendChild(menuitem); + return menupopup; + } + + _getMenupopup() { + let browser = this.browsingContext.top.embedderElement; + let window = browser.ownerGlobal; + let chromeDoc = window.document; + + let menupopup = chromeDoc.getElementById(kMenuPopupId); + if (menupopup == null) { + menupopup = this._createMenupopup(chromeDoc); + const parent = + chromeDoc.querySelector("popupset") || chromeDoc.documentElement; + parent.appendChild(menupopup); + } + + return menupopup; + } + + _startWatchingForSpammyActivation() { + let doc = this._menuitem.ownerDocument; + Services.els.addSystemEventListener(doc, "keydown", this, true); + } + + _stopWatchingForSpammyActivation() { + let doc = this._menuitem.ownerDocument; + Services.els.removeSystemEventListener(doc, "keydown", this, true); + } + + _clearDelayTimer() { + if (this._delayTimer) { + let window = this._menuitem.ownerGlobal; + window.clearTimeout(this._delayTimer); + this._delayTimer = null; + } + } + + _refreshDelayTimer() { + this._clearDelayTimer(); + + let window = this._menuitem.ownerGlobal; + let delay = Services.prefs.getIntPref("security.dialog_enable_delay"); + this._delayTimer = window.setTimeout(() => { + this._menuitem.disabled = false; + this._stopWatchingForSpammyActivation(); + this._delayTimer = null; + }, delay); + } +} diff --git a/toolkit/actors/ContentMetaChild.sys.mjs b/toolkit/actors/ContentMetaChild.sys.mjs new file mode 100644 index 0000000000..929d92db47 --- /dev/null +++ b/toolkit/actors/ContentMetaChild.sys.mjs @@ -0,0 +1,199 @@ +/* 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/. */ + +// Debounce time in milliseconds - this should be long enough to account for +// sync script tags that could appear between desired meta tags +const TIMEOUT_DELAY = 1000; + +const ACCEPTED_PROTOCOLS = ["http:", "https:"]; + +// Possible description tags, listed in order from least favourable to most favourable +const DESCRIPTION_RULES = [ + "twitter:description", + "description", + "og:description", +]; + +// Possible image tags, listed in order from least favourable to most favourable +const PREVIEW_IMAGE_RULES = [ + "thumbnail", + "twitter:image", + "og:image", + "og:image:url", + "og:image:secure_url", +]; + +/* + * Checks if the incoming meta tag has a greater score than the current best + * score by checking the index of the meta tag in the list of rules provided. + * + * @param {Array} aRules + * The list of rules for a given type of meta tag + * @param {String} aTag + * The name or property of the incoming meta tag + * @param {String} aEntry + * The current best entry for the given meta tag + * + * @returns {Boolean} true if the incoming meta tag is better than the current + * best meta tag of that same kind, false otherwise + */ +function shouldExtractMetadata(aRules, aTag, aEntry) { + return aRules.indexOf(aTag) > aEntry.currMaxScore; +} + +/* + * Ensure that the preview image URL is safe and valid before storing + * + * @param {URL} aURL + * A URL object that needs to be checked for valid principal and protocol + * + * @returns {Boolean} true if the preview URL is safe and can be stored, false otherwise + */ +function checkLoadURIStr(aURL) { + if (!ACCEPTED_PROTOCOLS.includes(aURL.protocol)) { + return false; + } + try { + let ssm = Services.scriptSecurityManager; + let principal = ssm.createNullPrincipal({}); + ssm.checkLoadURIStrWithPrincipal( + principal, + aURL.href, + ssm.DISALLOW_INHERIT_PRINCIPAL + ); + } catch (e) { + return false; + } + return true; +} + +/* + * This listens to DOMMetaAdded events and collects relevant metadata about the + * meta tag received. Then, it sends the metadata gathered from the meta tags + * and the url of the page as it's payload to be inserted into moz_places. + */ +export class ContentMetaChild extends JSWindowActorChild { + constructor() { + super(); + + // Store a mapping of the best description and preview + // image collected so far for a given URL. + this.metaTags = new Map(); + } + + didDestroy() { + for (let entry of this.metaTags.values()) { + entry.timeout.cancel(); + } + } + + handleEvent(event) { + switch (event.type) { + case "DOMContentLoaded": + const metaTags = this.contentWindow.document.querySelectorAll("meta"); + for (let metaTag of metaTags) { + this.onMetaTag(metaTag); + } + break; + case "DOMMetaAdded": + this.onMetaTag(event.originalTarget); + break; + default: + } + } + + onMetaTag(metaTag) { + const window = metaTag.ownerGlobal; + + // If there's no meta tag, ignore this. Also verify that the window + // matches just to be safe. + if (!metaTag || !metaTag.ownerDocument || window != this.contentWindow) { + return; + } + + const url = metaTag.ownerDocument.documentURI; + + let name = metaTag.name; + let prop = metaTag.getAttributeNS(null, "property"); + if (!name && !prop) { + return; + } + + let tag = name || prop; + + const entry = this.metaTags.get(url) || { + description: { value: null, currMaxScore: -1 }, + image: { value: null, currMaxScore: -1 }, + timeout: null, + }; + + // Malformed meta tag - do not store it + const content = metaTag.getAttributeNS(null, "content"); + if (!content) { + return; + } + + if (shouldExtractMetadata(DESCRIPTION_RULES, tag, entry.description)) { + // Extract the description + entry.description.value = content; + entry.description.currMaxScore = DESCRIPTION_RULES.indexOf(tag); + } else if (shouldExtractMetadata(PREVIEW_IMAGE_RULES, tag, entry.image)) { + // Extract the preview image + let value; + try { + value = new URL(content, url); + } catch (e) { + return; + } + if (value && checkLoadURIStr(value)) { + entry.image.value = value.href; + entry.image.currMaxScore = PREVIEW_IMAGE_RULES.indexOf(tag); + } + } else { + // We don't care about other meta tags + return; + } + + if (!this.metaTags.has(url)) { + this.metaTags.set(url, entry); + } + + if (entry.timeout) { + entry.timeout.delay = TIMEOUT_DELAY; + } else { + // We want to debounce incoming meta tags until we're certain we have the + // best one for description and preview image, and only store that one + entry.timeout = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + entry.timeout.initWithCallback( + () => { + entry.timeout = null; + this.metaTags.delete(url); + // We try to cancel the timers when we get destroyed, but if + // there's a race, catch it: + if (!this.manager || this.manager.isClosed) { + return; + } + + // Save description and preview image to moz_places + this.sendAsyncMessage("Meta:SetPageInfo", { + url, + description: entry.description.value, + previewImageURL: entry.image.value, + }); + + // Telemetry for recording the size of page metadata + let metadataSize = entry.description.value + ? entry.description.value.length + : 0; + metadataSize += entry.image.value ? entry.image.value.length : 0; + Services.telemetry + .getHistogramById("PAGE_METADATA_SIZE") + .add(metadataSize); + }, + TIMEOUT_DELAY, + Ci.nsITimer.TYPE_ONE_SHOT + ); + } + } +} diff --git a/toolkit/actors/ContentMetaParent.sys.mjs b/toolkit/actors/ContentMetaParent.sys.mjs new file mode 100644 index 0000000000..4cdc6c3c85 --- /dev/null +++ b/toolkit/actors/ContentMetaParent.sys.mjs @@ -0,0 +1,23 @@ +/* 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/. */ + +export class ContentMetaParent extends JSWindowActorParent { + receiveMessage(message) { + if (message.name == "Meta:SetPageInfo") { + let browser = this.manager.browsingContext.top.embedderElement; + if (browser) { + let event = new browser.ownerGlobal.CustomEvent("pageinfo", { + bubbles: true, + cancelable: false, + detail: { + url: message.data.url, + description: message.data.description, + previewImageURL: message.data.previewImageURL, + }, + }); + browser.dispatchEvent(event); + } + } + } +} diff --git a/toolkit/actors/ControllersChild.sys.mjs b/toolkit/actors/ControllersChild.sys.mjs new file mode 100644 index 0000000000..d975c1f431 --- /dev/null +++ b/toolkit/actors/ControllersChild.sys.mjs @@ -0,0 +1,63 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +export class ControllersChild extends JSWindowActorChild { + receiveMessage(message) { + switch (message.name) { + case "ControllerCommands:Do": + if (this.docShell && this.docShell.isCommandEnabled(message.data)) { + this.docShell.doCommand(message.data); + } + break; + + case "ControllerCommands:DoWithParams": + var data = message.data; + if (this.docShell && this.docShell.isCommandEnabled(data.cmd)) { + var params = Cu.createCommandParams(); + let substituteXY = false; + let x = 0; + let y = 0; + if ( + data.cmd == "cmd_lookUpDictionary" && + "x" in data.params && + "y" in data.params && + data.params.x.type == "long" && + data.params.y.type == "long" + ) { + substituteXY = true; + x = parseInt(data.params.x.value); + y = parseInt(data.params.y.value); + + let rect = + this.contentWindow.windowUtils.convertFromParentProcessWidgetToLocal( + x, + y, + 1, + 1 + ); + x = Math.round(rect.x); + y = Math.round(rect.y); + } + + for (var name in data.params) { + var value = data.params[name]; + if (value.type == "long") { + if (substituteXY && name === "x") { + params.setLongValue(name, x); + } else if (substituteXY && name === "y") { + params.setLongValue(name, y); + } else { + params.setLongValue(name, parseInt(value.value)); + } + } else { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + } + this.docShell.doCommandWithParams(data.cmd, params); + } + break; + } + } +} diff --git a/toolkit/actors/ControllersParent.sys.mjs b/toolkit/actors/ControllersParent.sys.mjs new file mode 100644 index 0000000000..05ea166112 --- /dev/null +++ b/toolkit/actors/ControllersParent.sys.mjs @@ -0,0 +1,90 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +export class ControllersParent extends JSWindowActorParent { + constructor() { + super(); + + // A map of commands that have had their enabled/disabled state assigned. The + // value of each key will be true if enabled, and false if disabled. + this.supportedCommands = {}; + } + + get browser() { + return this.browsingContext.top.embedderElement; + } + + // Update the set of enabled and disabled commands. + enableDisableCommands(aAction, aEnabledCommands, aDisabledCommands) { + // Clear the list first + this.supportedCommands = {}; + + for (let command of aEnabledCommands) { + this.supportedCommands[command] = true; + } + + for (let command of aDisabledCommands) { + this.supportedCommands[command] = false; + } + + let browser = this.browser; + if (browser) { + browser.ownerGlobal.updateCommands(aAction); + } + } + + isCommandEnabled(aCommand) { + return this.supportedCommands[aCommand] || false; + } + + supportsCommand(aCommand) { + return aCommand in this.supportedCommands; + } + + doCommand(aCommand) { + this.sendAsyncMessage("ControllerCommands:Do", aCommand); + } + + getCommandStateWithParams(aCommand, aCommandParams) { + throw Components.Exception("Not implemented", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + doCommandWithParams(aCommand, aCommandParams) { + let cmd = { + cmd: aCommand, + params: null, + }; + if (aCommand == "cmd_lookUpDictionary") { + cmd.params = { + x: { + type: "long", + value: aCommandParams.getLongValue("x"), + }, + y: { + type: "long", + value: aCommandParams.getLongValue("y"), + }, + }; + } else { + throw Components.Exception( + "Not implemented", + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + this.sendAsyncMessage("ControllerCommands:DoWithParams", cmd); + } + + getSupportedCommands() { + throw Components.Exception("Not implemented", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + onEvent() {} +} + +ControllersParent.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIBrowserController", + "nsIController", + "nsICommandController", +]); diff --git a/toolkit/actors/DateTimePickerChild.sys.mjs b/toolkit/actors/DateTimePickerChild.sys.mjs new file mode 100644 index 0000000000..9ef55af435 --- /dev/null +++ b/toolkit/actors/DateTimePickerChild.sys.mjs @@ -0,0 +1,204 @@ +/* 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/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + LayoutUtils: "resource://gre/modules/LayoutUtils.sys.mjs", +}); + +/** + * DateTimePickerChild is the communication channel between the input box + * (content) for date/time input types and its picker (chrome). + */ +export class DateTimePickerChild extends JSWindowActorChild { + /** + * On init, just listen for the event to open the picker, once the picker is + * opened, we'll listen for update and close events. + */ + constructor() { + super(); + + this._inputElement = null; + } + + /** + * Cleanup function called when picker is closed. + */ + close() { + this.removeListeners(this._inputElement); + let dateTimeBoxElement = this._inputElement.dateTimeBoxElement; + if (!dateTimeBoxElement) { + this._inputElement = null; + return; + } + + // dateTimeBoxElement is within UA Widget Shadow DOM. + // An event dispatch to it can't be accessed by document. + let win = this._inputElement.ownerGlobal; + dateTimeBoxElement.dispatchEvent( + new win.CustomEvent("MozSetDateTimePickerState", { detail: false }) + ); + + this._inputElement = null; + } + + /** + * Called after picker is opened to start listening for input box update + * events. + */ + addListeners(aElement) { + aElement.ownerGlobal.addEventListener("pagehide", this); + } + + /** + * Stop listeneing for events when picker is closed. + */ + removeListeners(aElement) { + aElement.ownerGlobal.removeEventListener("pagehide", this); + } + + /** + * Helper function that returns the CSS direction property of the element. + */ + getComputedDirection(aElement) { + return aElement.ownerGlobal + .getComputedStyle(aElement) + .getPropertyValue("direction"); + } + + /** + * Helper function that returns the rect of the element, which is the position + * relative to the left/top of the content area. + */ + getBoundingContentRect(aElement) { + return lazy.LayoutUtils.getElementBoundingScreenRect(aElement); + } + + getTimePickerPref() { + return Services.prefs.getBoolPref("dom.forms.datetime.timepicker"); + } + + /** + * nsIMessageListener. + */ + receiveMessage(aMessage) { + switch (aMessage.name) { + case "FormDateTime:PickerClosed": { + if (!this._inputElement) { + return; + } + + this.close(); + break; + } + case "FormDateTime:PickerValueChanged": { + if (!this._inputElement) { + return; + } + + let dateTimeBoxElement = this._inputElement.dateTimeBoxElement; + if (!dateTimeBoxElement) { + return; + } + + let win = this._inputElement.ownerGlobal; + + // dateTimeBoxElement is within UA Widget Shadow DOM. + // An event dispatch to it can't be accessed by document. + dateTimeBoxElement.dispatchEvent( + new win.CustomEvent("MozPickerValueChanged", { + detail: Cu.cloneInto(aMessage.data, win), + }) + ); + break; + } + default: + break; + } + } + + /** + * nsIDOMEventListener, for chrome events sent by the input element and other + * DOM events. + */ + handleEvent(aEvent) { + switch (aEvent.type) { + case "MozOpenDateTimePicker": { + // Time picker is disabled when preffed off + if ( + !aEvent.originalTarget.ownerGlobal.HTMLInputElement.isInstance( + aEvent.originalTarget + ) || + (aEvent.originalTarget.type == "time" && !this.getTimePickerPref()) + ) { + return; + } + + if (this._inputElement) { + // This happens when we're trying to open a picker when another picker + // is still open. We ignore this request to let the first picker + // close gracefully. + return; + } + + this._inputElement = aEvent.originalTarget; + + let dateTimeBoxElement = this._inputElement.dateTimeBoxElement; + if (!dateTimeBoxElement) { + throw new Error("How do we get this event without a UA Widget?"); + } + + // dateTimeBoxElement is within UA Widget Shadow DOM. + // An event dispatch to it can't be accessed by document, because + // the event is not composed. + let win = this._inputElement.ownerGlobal; + dateTimeBoxElement.dispatchEvent( + new win.CustomEvent("MozSetDateTimePickerState", { detail: true }) + ); + + this.addListeners(this._inputElement); + + let value = this._inputElement.getDateTimeInputBoxValue(); + this.sendAsyncMessage("FormDateTime:OpenPicker", { + rect: this.getBoundingContentRect(this._inputElement), + dir: this.getComputedDirection(this._inputElement), + type: this._inputElement.type, + detail: { + // Pass partial value if it's available, otherwise pass input + // element's value. + value: Object.keys(value).length ? value : this._inputElement.value, + min: this._inputElement.getMinimum(), + max: this._inputElement.getMaximum(), + step: this._inputElement.getStep(), + stepBase: this._inputElement.getStepBase(), + }, + }); + break; + } + case "MozUpdateDateTimePicker": { + let value = this._inputElement.getDateTimeInputBoxValue(); + value.type = this._inputElement.type; + this.sendAsyncMessage("FormDateTime:UpdatePicker", { value }); + break; + } + case "MozCloseDateTimePicker": { + this.sendAsyncMessage("FormDateTime:ClosePicker", {}); + this.close(); + break; + } + case "pagehide": { + if ( + this._inputElement && + this._inputElement.ownerDocument == aEvent.target + ) { + this.sendAsyncMessage("FormDateTime:ClosePicker", {}); + this.close(); + } + break; + } + default: + break; + } + } +} diff --git a/toolkit/actors/DateTimePickerParent.sys.mjs b/toolkit/actors/DateTimePickerParent.sys.mjs new file mode 100644 index 0000000000..ba78a39ffb --- /dev/null +++ b/toolkit/actors/DateTimePickerParent.sys.mjs @@ -0,0 +1,157 @@ +/* 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/. */ + +const DEBUG = false; +function debug(aStr) { + if (DEBUG) { + dump("-*- DateTimePickerParent: " + aStr + "\n"); + } +} + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + DateTimePickerPanel: "resource://gre/modules/DateTimePickerPanel.sys.mjs", +}); + +/* + * DateTimePickerParent receives message from content side (input box) and + * is reposible for opening, closing and updating the picker. Similarly, + * DateTimePickerParent listens for picker's events and notifies the content + * side (input box) about them. + */ +export class DateTimePickerParent extends JSWindowActorParent { + receiveMessage(aMessage) { + debug("receiveMessage: " + aMessage.name); + switch (aMessage.name) { + case "FormDateTime:OpenPicker": { + this.showPicker(aMessage.data); + break; + } + case "FormDateTime:ClosePicker": { + if (!this._picker) { + return; + } + this.close(); + break; + } + case "FormDateTime:UpdatePicker": { + if (!this._picker) { + return; + } + this._picker.setPopupValue(aMessage.data); + break; + } + default: + break; + } + } + + handleEvent(aEvent) { + debug("handleEvent: " + aEvent.type); + switch (aEvent.type) { + case "DateTimePickerValueCleared": { + this.sendAsyncMessage("FormDateTime:PickerValueChanged", null); + break; + } + case "DateTimePickerValueChanged": { + this.sendAsyncMessage("FormDateTime:PickerValueChanged", aEvent.detail); + break; + } + case "popuphidden": { + this.sendAsyncMessage("FormDateTime:PickerClosed", {}); + this.close(); + break; + } + default: + break; + } + } + + // Get picker from browser and show it anchored to the input box. + showPicker(aData) { + let rect = aData.rect; + let type = aData.type; + let detail = aData.detail; + + debug("Opening picker with details: " + JSON.stringify(detail)); + let topBC = this.browsingContext.top; + let window = topBC.topChromeWindow; + if (Services.focus.activeWindow != window) { + debug("Not in the active window"); + return; + } + + { + let browser = topBC.embedderElement; + if ( + browser && + browser.ownerGlobal.gBrowser && + browser.ownerGlobal.gBrowser.selectedBrowser != browser + ) { + debug("In background tab"); + return; + } + } + + let doc = window.document; + let panel = doc.getElementById("DateTimePickerPanel"); + if (!panel) { + panel = doc.createXULElement("panel"); + panel.id = "DateTimePickerPanel"; + panel.setAttribute("type", "arrow"); + panel.setAttribute("orient", "vertical"); + panel.setAttribute("ignorekeys", "true"); + panel.setAttribute("noautofocus", "true"); + // This ensures that clicks on the anchored input box are never consumed. + panel.setAttribute("consumeoutsideclicks", "never"); + panel.setAttribute("level", "parent"); + panel.setAttribute("tabspecific", "true"); + let container = + doc.getElementById("mainPopupSet") || + doc.querySelector("popupset") || + doc.documentElement.appendChild(doc.createXULElement("popupset")); + container.appendChild(panel); + } + this._oldFocus = doc.activeElement; + this._picker = new lazy.DateTimePickerPanel(panel); + this._picker.openPicker(type, rect, detail); + this.addPickerListeners(); + } + + // Close the picker and do some cleanup. + close() { + this._picker.closePicker(); + // Restore focus to where it was before the picker opened. + this._oldFocus?.focus(); + this._oldFocus = null; + this.removePickerListeners(); + this._picker = null; + } + + // Listen to picker's event. + addPickerListeners() { + if (!this._picker) { + return; + } + this._picker.element.addEventListener("popuphidden", this); + this._picker.element.addEventListener("DateTimePickerValueChanged", this); + this._picker.element.addEventListener("DateTimePickerValueCleared", this); + } + + // Stop listening to picker's event. + removePickerListeners() { + if (!this._picker) { + return; + } + this._picker.element.removeEventListener("popuphidden", this); + this._picker.element.removeEventListener( + "DateTimePickerValueChanged", + this + ); + this._picker.element.removeEventListener( + "DateTimePickerValueCleared", + this + ); + } +} diff --git a/toolkit/actors/ExtFindChild.sys.mjs b/toolkit/actors/ExtFindChild.sys.mjs new file mode 100644 index 0000000000..dc6d296d88 --- /dev/null +++ b/toolkit/actors/ExtFindChild.sys.mjs @@ -0,0 +1,31 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FindContent: "resource://gre/modules/FindContent.sys.mjs", +}); + +export class ExtFindChild extends JSWindowActorChild { + receiveMessage(message) { + if (!this._findContent) { + this._findContent = new lazy.FindContent(this.docShell); + } + + switch (message.name) { + case "ext-Finder:CollectResults": + this.finderInited = true; + return this._findContent.findRanges(message.data); + case "ext-Finder:HighlightResults": + return this._findContent.highlightResults(message.data); + case "ext-Finder:ClearHighlighting": + this._findContent.highlighter.highlight(false); + break; + } + + return null; + } +} diff --git a/toolkit/actors/FindBarChild.sys.mjs b/toolkit/actors/FindBarChild.sys.mjs new file mode 100644 index 0000000000..a82615a253 --- /dev/null +++ b/toolkit/actors/FindBarChild.sys.mjs @@ -0,0 +1,157 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", +}); + +export class FindBarChild extends JSWindowActorChild { + constructor() { + super(); + + this._findKey = null; + + XPCOMUtils.defineLazyProxy( + this, + "FindBarContent", + () => { + const { FindBarContent } = ChromeUtils.importESModule( + "resource://gre/modules/FindBarContent.sys.mjs" + ); + return new FindBarContent(this); + }, + { inQuickFind: false, inPassThrough: false } + ); + } + + receiveMessage(msg) { + if (msg.name == "Findbar:UpdateState") { + let { FindBarContent } = this; + FindBarContent.updateState(msg.data); + } + } + + /** + * Check whether this key event will start the findbar in the parent, + * in which case we should pass any further key events to the parent to avoid + * them being lost. + * @param aEvent the key event to check. + */ + eventMatchesFindShortcut(aEvent) { + if (!this._findKey) { + this._findKey = Services.cpmm.sharedData.get("Findbar:Shortcut"); + if (!this._findKey) { + return false; + } + } + for (let k in this._findKey) { + if (this._findKey[k] != aEvent[k]) { + return false; + } + } + return true; + } + + handleEvent(event) { + if (event.type == "keypress") { + this.onKeypress(event); + } + } + + onKeypress(event) { + let { FindBarContent } = this; + + if (!FindBarContent.inPassThrough && this.eventMatchesFindShortcut(event)) { + return FindBarContent.start(event); + } + + // disable FAYT in about:blank to prevent FAYT opening unexpectedly. + let location = this.document.location.href; + if (location == "about:blank") { + return null; + } + + if ( + event.ctrlKey || + event.altKey || + event.metaKey || + event.defaultPrevented || + !lazy.BrowserUtils.mimeTypeIsTextBased(this.document.contentType) || + !lazy.BrowserUtils.canFindInPage(location) + ) { + return null; + } + + if (FindBarContent.inPassThrough || FindBarContent.inQuickFind) { + return FindBarContent.onKeypress(event); + } + + if (event.charCode && this.shouldFastFind(event.target)) { + let key = String.fromCharCode(event.charCode); + if ((key == "/" || key == "'") && FindBarChild.manualFAYT) { + return FindBarContent.startQuickFind(event); + } + if (key != " " && FindBarChild.findAsYouType) { + return FindBarContent.startQuickFind(event, true); + } + } + return null; + } + + /** + * Return true if we should FAYT for this node: + * + * @param elt + * The element that is focused + */ + shouldFastFind(elt) { + if (elt) { + let win = elt.ownerGlobal; + if (win.HTMLInputElement.isInstance(elt) && elt.mozIsTextField(false)) { + return false; + } + + if (elt.isContentEditable || win.document.designMode == "on") { + return false; + } + + if ( + win.HTMLTextAreaElement.isInstance(elt) || + win.HTMLSelectElement.isInstance(elt) || + win.HTMLObjectElement.isInstance(elt) || + win.HTMLEmbedElement.isInstance(elt) + ) { + return false; + } + + if ( + (win.HTMLIFrameElement.isInstance(elt) && elt.mozbrowser) || + win.XULFrameElement.isInstance(elt) + ) { + // If we're targeting a mozbrowser iframe or an embedded XULFrameElement + // (e.g. about:addons extensions inline options page), do not activate + // fast find. + return false; + } + } + + return true; + } +} + +XPCOMUtils.defineLazyPreferenceGetter( + FindBarChild, + "findAsYouType", + "accessibility.typeaheadfind" +); +XPCOMUtils.defineLazyPreferenceGetter( + FindBarChild, + "manualFAYT", + "accessibility.typeaheadfind.manual" +); diff --git a/toolkit/actors/FindBarParent.sys.mjs b/toolkit/actors/FindBarParent.sys.mjs new file mode 100644 index 0000000000..eaf42b634e --- /dev/null +++ b/toolkit/actors/FindBarParent.sys.mjs @@ -0,0 +1,47 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +// Map of browser elements to findbars. +let findbars = new WeakMap(); + +export class FindBarParent extends JSWindowActorParent { + setFindbar(browser, findbar) { + if (findbar) { + findbars.set(browser, findbar); + } else { + findbars.delete(browser, findbar); + } + } + + receiveMessage(message) { + let browser = this.manager.browsingContext.top.embedderElement; + if (!browser) { + return; + } + + let respondToMessage = () => { + let findBar = findbars.get(browser); + if (!findBar) { + return; + } + + switch (message.name) { + case "Findbar:Keypress": + findBar._onBrowserKeypress(message.data); + break; + case "Findbar:Mouseup": + findBar.onMouseUp(); + break; + } + }; + + let findPromise = browser.ownerGlobal.gFindBarPromise; + if (findPromise) { + findPromise.then(respondToMessage); + } else { + respondToMessage(); + } + } +} diff --git a/toolkit/actors/FinderChild.sys.mjs b/toolkit/actors/FinderChild.sys.mjs new file mode 100644 index 0000000000..6a245cd606 --- /dev/null +++ b/toolkit/actors/FinderChild.sys.mjs @@ -0,0 +1,129 @@ +// -*- indent-tabs-mode: nil; js-indent-level: 2 -*- +// vim: set ts=2 sw=2 sts=2 et tw=80: */ +// 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/. + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Finder: "resource://gre/modules/Finder.sys.mjs", +}); + +export class FinderChild extends JSWindowActorChild { + get finder() { + if (!this._finder) { + this._finder = new lazy.Finder(this.docShell); + } + return this._finder; + } + + receiveMessage(aMessage) { + let data = aMessage.data; + + switch (aMessage.name) { + case "Finder:CaseSensitive": + this.finder.caseSensitive = data.caseSensitive; + break; + + case "Finder:MatchDiacritics": + this.finder.matchDiacritics = data.matchDiacritics; + break; + + case "Finder:EntireWord": + this.finder.entireWord = data.entireWord; + break; + + case "Finder:SetSearchStringToSelection": { + return new Promise(resolve => { + resolve(this.finder.setSearchStringToSelection()); + }); + } + + case "Finder:GetInitialSelection": { + return new Promise(resolve => { + resolve(this.finder.getActiveSelectionText()); + }); + } + + case "Finder:Find": + return this.finder.find(data); + + case "Finder:Highlight": + return this.finder + .highlight( + data.highlight, + data.searchString, + data.linksOnly, + data.useSubFrames + ) + .then(result => { + if (result) { + result.browsingContextId = this.browsingContext.id; + } + return result; + }); + + case "Finder:UpdateHighlightAndMatchCount": + return this.finder.updateHighlightAndMatchCount(data).then(result => { + if (result) { + result.browsingContextId = this.browsingContext.id; + } + return result; + }); + + case "Finder:HighlightAllChange": + this.finder.onHighlightAllChange(data.highlightAll); + break; + + case "Finder:EnableSelection": + this.finder.enableSelection(); + break; + + case "Finder:RemoveSelection": + this.finder.removeSelection(data.keepHighlight); + break; + + case "Finder:FocusContent": + this.finder.focusContent(); + break; + + case "Finder:FindbarClose": + this.finder.onFindbarClose(); + break; + + case "Finder:FindbarOpen": + this.finder.onFindbarOpen(); + break; + + case "Finder:KeyPress": + var KeyboardEvent = this.finder._getWindow().KeyboardEvent; + this.finder.keyPress(new KeyboardEvent("keypress", data)); + break; + + case "Finder:MatchesCount": + return this.finder + .requestMatchesCount( + data.searchString, + data.linksOnly, + data.useSubFrames + ) + .then(result => { + if (result) { + result.browsingContextId = this.browsingContext.id; + } + return result; + }); + + case "Finder:ModalHighlightChange": + this.finder.onModalHighlightChange(data.useModalHighlight); + break; + + case "Finder:EnableMarkTesting": + this.finder.highlighter.enableTesting(data.enable); + break; + } + + return null; + } +} diff --git a/toolkit/actors/InlineSpellCheckerChild.sys.mjs b/toolkit/actors/InlineSpellCheckerChild.sys.mjs new file mode 100644 index 0000000000..c8262c749a --- /dev/null +++ b/toolkit/actors/InlineSpellCheckerChild.sys.mjs @@ -0,0 +1,38 @@ +/* -*- mode: js; indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + InlineSpellCheckerContent: + "resource://gre/modules/InlineSpellCheckerContent.sys.mjs", +}); + +export class InlineSpellCheckerChild extends JSWindowActorChild { + receiveMessage(msg) { + switch (msg.name) { + case "InlineSpellChecker:selectDictionaries": + lazy.InlineSpellCheckerContent.selectDictionaries(msg.data.localeCodes); + break; + + case "InlineSpellChecker:replaceMisspelling": + lazy.InlineSpellCheckerContent.replaceMisspelling(msg.data.suggestion); + break; + + case "InlineSpellChecker:toggleEnabled": + lazy.InlineSpellCheckerContent.toggleEnabled(); + break; + + case "InlineSpellChecker:recheck": + lazy.InlineSpellCheckerContent.recheck(); + break; + + case "InlineSpellChecker:uninit": + lazy.InlineSpellCheckerContent.uninitContextMenu(); + break; + } + } +} diff --git a/toolkit/actors/InlineSpellCheckerParent.sys.mjs b/toolkit/actors/InlineSpellCheckerParent.sys.mjs new file mode 100644 index 0000000000..ff5f55724e --- /dev/null +++ b/toolkit/actors/InlineSpellCheckerParent.sys.mjs @@ -0,0 +1,50 @@ +/* -*- mode: js; indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +export class InlineSpellCheckerParent extends JSWindowActorParent { + selectDictionaries({ localeCodes }) { + this.sendAsyncMessage("InlineSpellChecker:selectDictionaries", { + localeCodes, + }); + } + + replaceMisspelling({ suggestion }) { + this.sendAsyncMessage("InlineSpellChecker:replaceMisspelling", { + suggestion, + }); + } + + toggleEnabled() { + this.sendAsyncMessage("InlineSpellChecker:toggleEnabled", {}); + } + + recheckSpelling() { + this.sendAsyncMessage("InlineSpellChecker:recheck", {}); + } + + uninit() { + // This method gets called by InlineSpellChecker when the context menu + // goes away and the InlineSpellChecker instance is still alive. + // Stop referencing it and tidy the child end of us. + this.sendAsyncMessage("InlineSpellChecker:uninit", {}); + } + + _destructionObservers = new Set(); + registerDestructionObserver(obj) { + this._destructionObservers.add(obj); + } + + unregisterDestructionObserver(obj) { + this._destructionObservers.delete(obj); + } + + didDestroy() { + for (let obs of this._destructionObservers) { + obs.actorDestroyed(this); + } + this._destructionObservers = null; + } +} diff --git a/toolkit/actors/KeyPressEventModelCheckerChild.sys.mjs b/toolkit/actors/KeyPressEventModelCheckerChild.sys.mjs new file mode 100644 index 0000000000..8b4fe82f17 --- /dev/null +++ b/toolkit/actors/KeyPressEventModelCheckerChild.sys.mjs @@ -0,0 +1,107 @@ +/* -*- mode: js; indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +export class KeyPressEventModelCheckerChild extends JSWindowActorChild { + // Currently, the event is dispatched only when the document becomes editable + // because of contenteditable. If you need to add new editor which is in + // designMode, you need to change MaybeDispatchCheckKeyPressEventModelEvent() + // of Document. + handleEvent(aEvent) { + if (!AppConstants.DEBUG) { + // Stop propagation in opt build to save the propagation cost. + // However, the event is necessary for running test_bug1514940.html. + // Therefore, we need to keep propagating it at least on debug build. + aEvent.stopImmediatePropagation(); + } + + // Currently, even if we set Document.KEYPRESS_EVENT_MODEL_CONFLATED + // here, conflated model isn't used forcibly. If you need it, you need + // to change WidgetKeyboardEvent, dom::KeyboardEvent and PresShell. + let model = Document.KEYPRESS_EVENT_MODEL_DEFAULT; + if ( + this._isOldOfficeOnlineServer(aEvent.target) || + this._isOldConfluence(aEvent.target.ownerGlobal) + ) { + model = Document.KEYPRESS_EVENT_MODEL_SPLIT; + } + aEvent.target.setKeyPressEventModel(model); + } + + _isOldOfficeOnlineServer(aDocument) { + let editingElement = aDocument.getElementById( + "WACViewPanel_EditingElement" + ); + // If it's not Office Online Server, don't include it into the telemetry + // because we just need to collect percentage of old version in all loaded + // Office Online Server instances. + if (!editingElement) { + return false; + } + let isOldVersion = !editingElement.classList.contains( + "WACViewPanel_DisableLegacyKeyCodeAndCharCode" + ); + Services.telemetry.keyedScalarAdd( + "dom.event.office_online_load_count", + isOldVersion ? "old" : "new", + 1 + ); + return isOldVersion; + } + + _isOldConfluence(aWindow) { + if (!aWindow) { + return false; + } + // aWindow should be an editor window in <iframe>. However, we don't know + // whether it can be without <iframe>. Anyway, there should be tinyMCE + // object in the parent window or in the window. + let tinyMCEObject; + // First, try to retrieve tinyMCE object from parent window. + try { + tinyMCEObject = ChromeUtils.waiveXrays(aWindow.parent).tinyMCE; + } catch (e) { + // Ignore the exception for now. + } + // Next, if there is no tinyMCE object in the parent window, let's check + // the window. + if (!tinyMCEObject) { + try { + tinyMCEObject = ChromeUtils.waiveXrays(aWindow).tinyMCE; + } catch (e) { + // Fallthrough to return false below. + } + // If we couldn't find tinyMCE object, let's assume that it's not + // Confluence instance. + if (!tinyMCEObject) { + return false; + } + } + // If there is tinyMCE object, we can assume that we loaded Confluence + // instance. So, let's check the version whether it allows conflated + // keypress event model. + try { + let { author, version } = + new tinyMCEObject.plugins.CursorTargetPlugin().getInfo(); + // If it's not Confluence, don't include it into the telemetry because + // we just need to collect percentage of old version in all loaded + // Confluence instances. + if (author !== "Atlassian") { + return false; + } + let isOldVersion = version === "1.0"; + Services.telemetry.keyedScalarAdd( + "dom.event.confluence_load_count", + isOldVersion ? "old" : "new", + 1 + ); + return isOldVersion; + } catch (e) { + return false; + } + } +} diff --git a/toolkit/actors/NetErrorChild.sys.mjs b/toolkit/actors/NetErrorChild.sys.mjs new file mode 100644 index 0000000000..671eb22baa --- /dev/null +++ b/toolkit/actors/NetErrorChild.sys.mjs @@ -0,0 +1,244 @@ +/* -*- 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", +}); + +import { RemotePageChild } from "resource://gre/actors/RemotePageChild.sys.mjs"; + +export class NetErrorChild extends RemotePageChild { + actorCreated() { + super.actorCreated(); + + // If you add a new function, remember to add it to RemotePageAccessManager.sys.mjs + // to allow content-privileged about:neterror or about:certerror to use it. + const exportableFunctions = [ + "RPMGetAppBuildID", + "RPMGetInnerMostURI", + "RPMAddToHistogram", + "RPMRecordTelemetryEvent", + "RPMCheckAlternateHostAvailable", + "RPMGetHttpResponseHeader", + "RPMIsTRROnlyFailure", + "RPMIsFirefox", + "RPMIsNativeFallbackFailure", + "RPMOpenPreferences", + "RPMGetTRRSkipReason", + "RPMGetTRRDomain", + "RPMIsSiteSpecificTRRError", + "RPMSetTRRDisabledLoadFlags", + "RPMGetCurrentTRRMode", + ]; + this.exportFunctions(exportableFunctions); + } + + getFailedCertChain(docShell) { + let securityInfo = + docShell.failedChannel && docShell.failedChannel.securityInfo; + if (!securityInfo) { + return []; + } + return securityInfo.failedCertChain.map(cert => cert.getBase64DERString()); + } + + handleEvent(aEvent) { + // Documents have a null ownerDocument. + let doc = aEvent.originalTarget.ownerDocument || aEvent.originalTarget; + + switch (aEvent.type) { + case "click": + let elem = aEvent.originalTarget; + if (elem.id == "viewCertificate") { + // Call through the superclass to avoid the security check. + this.sendAsyncMessage("Browser:CertExceptionError", { + location: doc.location.href, + elementId: elem.id, + failedCertChain: this.getFailedCertChain(doc.defaultView.docShell), + }); + } + break; + } + } + + RPMGetInnerMostURI(uriString) { + let uri = Services.io.newURI(uriString); + if (uri instanceof Ci.nsINestedURI) { + uri = uri.QueryInterface(Ci.nsINestedURI).innermostURI; + } + + return uri.spec; + } + + RPMGetAppBuildID() { + return Services.appinfo.appBuildID; + } + + RPMAddToHistogram(histID, bin) { + Services.telemetry.getHistogramById(histID).add(bin); + } + + RPMRecordTelemetryEvent(category, event, object, value, extra) { + Services.telemetry.recordEvent(category, event, object, value, extra); + } + + RPMCheckAlternateHostAvailable() { + const host = this.contentWindow.location.host.trim(); + + // Adapted from UrlbarUtils::looksLikeSingleWordHost + // https://searchfox.org/mozilla-central/rev/a26af613a476fafe6c3eba05a81bef63dff3c9f1/browser/components/urlbar/UrlbarUtils.sys.mjs#893 + const REGEXP_SINGLE_WORD = /^[^\s@:/?#]+(:\d+)?$/; + if (!REGEXP_SINGLE_WORD.test(host)) { + return; + } + + let info = Services.uriFixup.forceHttpFixup( + this.contentWindow.location.href + ); + + if (!info.fixupCreatedAlternateURI && !info.fixupChangedProtocol) { + return; + } + + let { displayHost, displaySpec, pathQueryRef } = info.fixedURI; + + if (pathQueryRef.endsWith("/")) { + pathQueryRef = pathQueryRef.slice(0, pathQueryRef.length - 1); + } + + let weakDoc = Cu.getWeakReference(this.contentWindow.document); + let onLookupCompleteListener = { + onLookupComplete(request, record, status) { + let doc = weakDoc.get(); + if (!doc || !Components.isSuccessCode(status)) { + return; + } + + let link = doc.createElement("a"); + link.href = displaySpec; + link.setAttribute("data-l10n-name", "website"); + + let span = doc.createElement("span"); + span.appendChild(link); + doc.l10n.setAttributes(span, "neterror-dns-not-found-with-suggestion", { + hostAndPath: displayHost + pathQueryRef, + }); + + const shortDesc = doc.getElementById("errorShortDesc"); + shortDesc.textContent += " "; + shortDesc.appendChild(span); + }, + }; + + Services.uriFixup.checkHost( + info.fixedURI, + onLookupCompleteListener, + this.document.nodePrincipal.originAttributes + ); + } + + // Get the header from the http response of the failed channel. This function + // is used in the 'about:neterror' page. + RPMGetHttpResponseHeader(responseHeader) { + let channel = this.contentWindow.docShell.failedChannel; + if (!channel) { + return ""; + } + + let httpChannel = channel.QueryInterface(Ci.nsIHttpChannel); + if (!httpChannel) { + return ""; + } + + try { + return httpChannel.getResponseHeader(responseHeader); + } catch (e) {} + + return ""; + } + + RPMIsTRROnlyFailure() { + // We will only show this in Firefox because the options may direct users to settings only available on Firefox Desktop + let channel = this.contentWindow?.docShell?.failedChannel?.QueryInterface( + Ci.nsIHttpChannelInternal + ); + if (!channel) { + return false; + } + return channel.effectiveTRRMode == Ci.nsIRequest.TRR_ONLY_MODE; + } + + RPMIsFirefox() { + return lazy.AppInfo.isFirefox; + } + + _getTRRSkipReason() { + let channel = this.contentWindow?.docShell?.failedChannel?.QueryInterface( + Ci.nsIHttpChannelInternal + ); + return channel?.trrSkipReason ?? Ci.nsITRRSkipReason.TRR_UNSET; + } + + RPMIsNativeFallbackFailure() { + if (!this.contentWindow?.navigator.onLine) { + return false; + } + + let skipReason = this._getTRRSkipReason(); + + if ( + Services.dns.currentTrrMode === Ci.nsIDNSService.MODE_TRRFIRST && + skipReason === Ci.nsITRRSkipReason.TRR_NOT_CONFIRMED + ) { + return true; + } + + const warningReasons = new Set([ + Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_GOOGLE_SAFESEARCH, + Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_YOUTUBE_SAFESEARCH, + Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_ZSCALER_CANARY, + Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_CANARY, + Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_MODIFIED_ROOTS, + Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_PARENTAL_CONTROLS, + Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_THIRD_PARTY_ROOTS, + Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_ENTERPRISE_POLICY, + Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_VPN, + Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_PROXY, + Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_NRPT, + ]); + + return ( + Services.dns.currentTrrMode === Ci.nsIDNSService.MODE_NATIVEONLY && + warningReasons.has(skipReason) + ); + } + + RPMGetTRRSkipReason() { + let skipReason = this._getTRRSkipReason(); + return Services.dns.getTRRSkipReasonName(skipReason); + } + + RPMGetTRRDomain() { + return Services.dns.trrDomain; + } + + RPMIsSiteSpecificTRRError() { + let skipReason = this._getTRRSkipReason(); + switch (skipReason) { + case Ci.nsITRRSkipReason.TRR_NXDOMAIN: + case Ci.nsITRRSkipReason.TRR_RCODE_FAIL: + case Ci.nsITRRSkipReason.TRR_NO_ANSWERS: + return true; + } + return false; + } + + RPMSetTRRDisabledLoadFlags() { + this.contentWindow.docShell.browsingContext.defaultLoadFlags |= + Ci.nsIRequest.LOAD_TRR_DISABLED_MODE; + } +} diff --git a/toolkit/actors/NetErrorParent.sys.mjs b/toolkit/actors/NetErrorParent.sys.mjs new file mode 100644 index 0000000000..7d1f0f3f0f --- /dev/null +++ b/toolkit/actors/NetErrorParent.sys.mjs @@ -0,0 +1,353 @@ +/* -*- 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/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs"; +import { TelemetryController } from "resource://gre/modules/TelemetryController.sys.mjs"; + +const PREF_SSL_IMPACT_ROOTS = [ + "security.tls.version.", + "security.ssl3.", + "security.tls13.", +]; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", +}); + +ChromeUtils.defineModuleGetter( + lazy, + "HomePage", + "resource:///modules/HomePage.jsm" +); + +class CaptivePortalObserver { + constructor(actor) { + this.actor = actor; + Services.obs.addObserver(this, "captive-portal-login-abort"); + Services.obs.addObserver(this, "captive-portal-login-success"); + } + + stop() { + Services.obs.removeObserver(this, "captive-portal-login-abort"); + Services.obs.removeObserver(this, "captive-portal-login-success"); + } + + observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "captive-portal-login-abort": + case "captive-portal-login-success": + // Send a message to the content when a captive portal is freed + // so that error pages can refresh themselves. + this.actor.sendAsyncMessage("AboutNetErrorCaptivePortalFreed"); + break; + } + } +} + +export class NetErrorParent extends JSWindowActorParent { + constructor() { + super(); + this.captivePortalObserver = new CaptivePortalObserver(this); + } + + didDestroy() { + if (this.captivePortalObserver) { + this.captivePortalObserver.stop(); + } + } + + get browser() { + return this.browsingContext.top.embedderElement; + } + + hasChangedCertPrefs() { + let prefSSLImpact = PREF_SSL_IMPACT_ROOTS.reduce((prefs, root) => { + return prefs.concat(Services.prefs.getChildList(root)); + }, []); + for (let prefName of prefSSLImpact) { + if (Services.prefs.prefHasUserValue(prefName)) { + return true; + } + } + + return false; + } + + async ReportBlockingError(bcID, scheme, host, port, path, xfoAndCspInfo) { + // For reporting X-Frame-Options error and CSP: frame-ancestors errors, We + // are collecting 4 pieces of information. + // 1. The X-Frame-Options in the response header. + // 2. The CSP: frame-ancestors in the response header. + // 3. The URI of the frame who triggers this error. + // 4. The top-level URI which loads the frame. + // + // We will exclude the query strings from the reporting URIs. + // + // More details about the data we send can be found in + // https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/telemetry/data/xfocsp-error-report-ping.html + // + + let topBC = BrowsingContext.get(bcID).top; + let topURI = topBC.currentWindowGlobal.documentURI; + + // Get the URLs without query strings. + let frame_uri = `${scheme}://${host}${port == -1 ? "" : ":" + port}${path}`; + let top_uri = `${topURI.scheme}://${topURI.hostPort}${topURI.filePath}`; + + TelemetryController.submitExternalPing( + "xfocsp-error-report", + { + ...xfoAndCspInfo, + frame_hostname: host, + top_hostname: topURI.host, + frame_uri, + top_uri, + }, + { addClientId: false, addEnvironment: false } + ); + } + + /** + * Return the default start page for the cases when the user's own homepage is + * infected, so we can get them somewhere safe. + */ + getDefaultHomePage(win) { + let url; + if ( + !PrivateBrowsingUtils.isWindowPrivate(win) && + AppConstants.MOZ_BUILD_APP == "browser" + ) { + url = lazy.HomePage.getDefault(); + } + url ||= win.BROWSER_NEW_TAB_URL || "about:blank"; + + // If url is a pipe-delimited set of pages, just take the first one. + if (url.includes("|")) { + url = url.split("|")[0]; + } + return url; + } + + /** + * Re-direct the browser to the previous page or a known-safe page if no + * previous page is found in history. This function is used when the user + * browses to a secure page with certificate issues and is presented with + * about:certerror. The "Go Back" button should take the user to the previous + * or a default start page so that even when their own homepage is on a server + * that has certificate errors, we can get them somewhere safe. + */ + goBackFromErrorPage(browser) { + if (!browser.canGoBack) { + // If the unsafe page is the first or the only one in history, go to the + // start page. + browser.fixupAndLoadURIString( + this.getDefaultHomePage(browser.ownerGlobal), + { + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + } + ); + } else { + browser.goBack(); + } + } + + /** + * This function does a canary request to a reliable, maintained endpoint, in + * order to help network code detect a system-wide man-in-the-middle. + */ + primeMitm(browser) { + // If we already have a mitm canary issuer stored, then don't bother with the + // extra request. This will be cleared on every update ping. + if (Services.prefs.getStringPref("security.pki.mitm_canary_issuer", null)) { + return; + } + + let url = Services.prefs.getStringPref( + "security.certerrors.mitm.priming.endpoint" + ); + let request = new XMLHttpRequest({ mozAnon: true }); + request.open("HEAD", url); + request.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE; + request.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING; + + request.addEventListener("error", event => { + // Make sure the user is still on the cert error page. + if (!browser.documentURI.spec.startsWith("about:certerror")) { + return; + } + + let secInfo = request.channel.securityInfo; + if (secInfo.errorCodeString != "SEC_ERROR_UNKNOWN_ISSUER") { + return; + } + + // When we get to this point there's already something deeply wrong, it's very likely + // that there is indeed a system-wide MitM. + if (secInfo.serverCert && secInfo.serverCert.issuerName) { + // Grab the issuer of the certificate used in the exchange and store it so that our + // network-level MitM detection code has a comparison baseline. + Services.prefs.setStringPref( + "security.pki.mitm_canary_issuer", + secInfo.serverCert.issuerName + ); + + // MitM issues are sometimes caused by software not registering their root certs in the + // Firefox root store. We might opt for using third party roots from the system root store. + if ( + Services.prefs.getBoolPref( + "security.certerrors.mitm.auto_enable_enterprise_roots" + ) + ) { + if ( + !Services.prefs.getBoolPref("security.enterprise_roots.enabled") + ) { + // Loading enterprise roots happens on a background thread, so wait for import to finish. + lazy.BrowserUtils.promiseObserved( + "psm:enterprise-certs-imported" + ).then(() => { + if (browser.documentURI.spec.startsWith("about:certerror")) { + browser.reload(); + } + }); + + Services.prefs.setBoolPref( + "security.enterprise_roots.enabled", + true + ); + // Record that this pref was automatically set. + Services.prefs.setBoolPref( + "security.enterprise_roots.auto-enabled", + true + ); + } + } else { + // Need to reload the page to make sure network code picks up the canary issuer pref. + browser.reload(); + } + } + }); + + request.send(null); + } + + displayOfflineSupportPage(supportPageSlug) { + const AVAILABLE_PAGES = ["connection-not-secure", "time-errors"]; + if (!AVAILABLE_PAGES.includes(supportPageSlug)) { + console.log( + `[Not supported] Offline support is not yet available for ${supportPageSlug} errors.` + ); + return; + } + + let offlinePagePath = `chrome://global/content/neterror/supportpages/${supportPageSlug}.html`; + let triggeringPrincipal = + Services.scriptSecurityManager.getSystemPrincipal(); + this.browser.loadURI(Services.io.newURI(offlinePagePath), { + triggeringPrincipal, + }); + } + + receiveMessage(message) { + switch (message.name) { + case "Browser:EnableOnlineMode": + // Reset network state and refresh the page. + Services.io.offline = false; + this.browser.reload(); + break; + case "Browser:OpenCaptivePortalPage": + this.browser.ownerGlobal.CaptivePortalWatcher.ensureCaptivePortalTab(); + break; + case "Browser:PrimeMitm": + this.primeMitm(this.browser); + break; + case "Browser:ResetEnterpriseRootsPref": + Services.prefs.clearUserPref("security.enterprise_roots.enabled"); + Services.prefs.clearUserPref("security.enterprise_roots.auto-enabled"); + break; + case "Browser:ResetSSLPreferences": + let prefSSLImpact = PREF_SSL_IMPACT_ROOTS.reduce((prefs, root) => { + return prefs.concat(Services.prefs.getChildList(root)); + }, []); + for (let prefName of prefSSLImpact) { + Services.prefs.clearUserPref(prefName); + } + this.browser.reload(); + break; + case "Browser:SSLErrorGoBack": + this.goBackFromErrorPage(this.browser); + break; + case "Browser:SSLErrorReportTelemetry": + let reportStatus = message.data.reportStatus; + Services.telemetry + .getHistogramById("TLS_ERROR_REPORT_UI") + .add(reportStatus); + break; + case "GetChangedCertPrefs": + let hasChangedCertPrefs = this.hasChangedCertPrefs(); + this.sendAsyncMessage("HasChangedCertPrefs", { + hasChangedCertPrefs, + }); + break; + case "ReportBlockingError": + this.ReportBlockingError( + this.browsingContext.id, + message.data.scheme, + message.data.host, + message.data.port, + message.data.path, + message.data.xfoAndCspInfo + ); + break; + case "DisplayOfflineSupportPage": + this.displayOfflineSupportPage(message.data.supportPageSlug); + break; + case "Browser:CertExceptionError": + switch (message.data.elementId) { + case "viewCertificate": { + let certs = message.data.failedCertChain.map(certBase64 => + encodeURIComponent(certBase64) + ); + let certsStringURL = certs.map(elem => `cert=${elem}`); + certsStringURL = certsStringURL.join("&"); + let url = `about:certificate?${certsStringURL}`; + + let window = this.browser.ownerGlobal; + if (AppConstants.MOZ_BUILD_APP === "browser") { + window.switchToTabHavingURI(url, true, {}); + } else { + window.open(url, "_blank"); + } + break; + } + } + break; + case "Browser:AddTRRExcludedDomain": + let domain = message.data.hostname; + let excludedDomains = Services.prefs.getStringPref( + "network.trr.excluded-domains" + ); + excludedDomains += `, ${domain}`; + Services.prefs.setStringPref( + "network.trr.excluded-domains", + excludedDomains + ); + break; + case "OpenTRRPreferences": + let browser = this.browsingContext.top.embedderElement; + if (!browser) { + break; + } + + let win = browser.ownerGlobal; + win.openPreferences("privacy-doh"); + break; + } + } +} diff --git a/toolkit/actors/PictureInPictureChild.sys.mjs b/toolkit/actors/PictureInPictureChild.sys.mjs new file mode 100644 index 0000000000..ffaabbd5da --- /dev/null +++ b/toolkit/actors/PictureInPictureChild.sys.mjs @@ -0,0 +1,3152 @@ +/* -*- 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ContentDOMReference: "resource://gre/modules/ContentDOMReference.sys.mjs", + DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", + KEYBOARD_CONTROLS: "resource://gre/modules/PictureInPictureControls.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + Rect: "resource://gre/modules/Geometry.sys.mjs", + TOGGLE_POLICIES: "resource://gre/modules/PictureInPictureControls.sys.mjs", + TOGGLE_POLICY_STRINGS: + "resource://gre/modules/PictureInPictureControls.sys.mjs", +}); + +import { WebVTT } from "resource://gre/modules/vtt.sys.mjs"; +import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "DISPLAY_TEXT_TRACKS_PREF", + "media.videocontrols.picture-in-picture.display-text-tracks.enabled", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "IMPROVED_CONTROLS_ENABLED_PREF", + "media.videocontrols.picture-in-picture.improved-video-controls.enabled", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "MIN_VIDEO_LENGTH", + "media.videocontrols.picture-in-picture.video-toggle.min-video-secs", + 45 +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "PIP_TOGGLE_ALWAYS_SHOW", + "media.videocontrols.picture-in-picture.video-toggle.always-show", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "PIP_URLBAR_BUTTON", + "media.videocontrols.picture-in-picture.urlbar-button.enabled", + false +); + +const PIP_ENABLED_PREF = "media.videocontrols.picture-in-picture.enabled"; +const TOGGLE_ENABLED_PREF = + "media.videocontrols.picture-in-picture.video-toggle.enabled"; +const TOGGLE_FIRST_SEEN_PREF = + "media.videocontrols.picture-in-picture.video-toggle.first-seen-secs"; +const TOGGLE_FIRST_TIME_DURATION_DAYS = 28; +const TOGGLE_HAS_USED_PREF = + "media.videocontrols.picture-in-picture.video-toggle.has-used"; +const TOGGLE_TESTING_PREF = + "media.videocontrols.picture-in-picture.video-toggle.testing"; +const TOGGLE_VISIBILITY_THRESHOLD_PREF = + "media.videocontrols.picture-in-picture.video-toggle.visibility-threshold"; +const TEXT_TRACK_FONT_SIZE = + "media.videocontrols.picture-in-picture.display-text-tracks.size"; + +const MOUSEMOVE_PROCESSING_DELAY_MS = 50; +const TOGGLE_HIDING_TIMEOUT_MS = 3000; +// If you change this, also change VideoControlsWidget.SEEK_TIME_SECS: +const SEEK_TIME_SECS = 5; +const EMPTIED_TIMEOUT_MS = 1000; + +// The ToggleChild does not want to capture events from the PiP +// windows themselves. This set contains all currently open PiP +// players' content windows +var gPlayerContents = new WeakSet(); + +// To make it easier to write tests, we have a process-global +// WeakSet of all <video> elements that are being tracked for +// mouseover +var gWeakIntersectingVideosForTesting = new WeakSet(); + +// Overrides are expected to stay constant for the lifetime of a +// content process, so we set this as a lazy process global. +// See PictureInPictureToggleChild.getSiteOverrides for a +// sense of what the return types are. +XPCOMUtils.defineLazyGetter(lazy, "gSiteOverrides", () => { + return PictureInPictureToggleChild.getSiteOverrides(); +}); + +XPCOMUtils.defineLazyGetter(lazy, "logConsole", () => { + return console.createInstance({ + prefix: "PictureInPictureChild", + maxLogLevel: Services.prefs.getBoolPref( + "media.videocontrols.picture-in-picture.log", + false + ) + ? "Debug" + : "Error", + }); +}); + +/** + * Creates and returns an instance of the PictureInPictureChildVideoWrapper class responsible + * for applying site-specific wrapper methods around the original video. + * + * The Picture-In-Picture add-on can use this to provide site-specific wrappers for + * sites that require special massaging to control. + * @param {Object} pipChild reference to PictureInPictureChild class calling this function + * @param {Element} originatingVideo + * The <video> element to wrap. + * @returns {PictureInPictureChildVideoWrapper} instance of PictureInPictureChildVideoWrapper + */ +function applyWrapper(pipChild, originatingVideo) { + let originatingDoc = originatingVideo.ownerDocument; + let originatingDocumentURI = originatingDoc.documentURI; + + let overrides = lazy.gSiteOverrides.find(([matcher]) => { + return matcher.matches(originatingDocumentURI); + }); + + // gSiteOverrides is a list of tuples where the first element is the MatchPattern + // for a supported site and the second is the actual overrides object for it. + let wrapperPath = overrides ? overrides[1].videoWrapperScriptPath : null; + return new PictureInPictureChildVideoWrapper( + wrapperPath, + originatingVideo, + pipChild + ); +} + +export class PictureInPictureLauncherChild extends JSWindowActorChild { + handleEvent(event) { + switch (event.type) { + case "MozTogglePictureInPicture": { + if (event.isTrusted) { + this.togglePictureInPicture({ + video: event.target, + reason: event.detail?.reason, + }); + } + break; + } + } + } + + receiveMessage(message) { + switch (message.name) { + case "PictureInPicture:KeyToggle": { + this.keyToggle(); + break; + } + } + } + + /** + * Tells the parent to open a Picture-in-Picture window hosting + * a clone of the passed video. If we know about a pre-existing + * Picture-in-Picture window existing, this tells the parent to + * close it before opening the new one. + * + * @param {Object} pipObject An object containing the video and reason + * for toggling the PiP video + * + * @return {Promise} + * @resolves {undefined} Once the new Picture-in-Picture window + * has been requested. + */ + async togglePictureInPicture(pipObject) { + let { video, reason } = pipObject; + if (video.isCloningElementVisually) { + // The only way we could have entered here for the same video is if + // we are toggling via the context menu or via the urlbar button, + // since we hide the inline Picture-in-Picture toggle when a video + // is being displayed in Picture-in-Picture. Turn off PiP in this case + const stopPipEvent = new this.contentWindow.CustomEvent( + "MozStopPictureInPicture", + { + bubbles: true, + detail: { reason }, + } + ); + video.dispatchEvent(stopPipEvent); + return; + } + + if (!PictureInPictureChild.videoWrapper) { + PictureInPictureChild.videoWrapper = applyWrapper( + PictureInPictureChild, + video + ); + } + + let timestamp = undefined; + let scrubberPosition = undefined; + + if (lazy.IMPROVED_CONTROLS_ENABLED_PREF) { + timestamp = PictureInPictureChild.videoWrapper.formatTimestamp( + PictureInPictureChild.videoWrapper.getCurrentTime(video), + PictureInPictureChild.videoWrapper.getDuration(video) + ); + + // Scrubber is hidden if undefined, so only set it to something else + // if the timestamp is not undefined. + scrubberPosition = + timestamp === undefined + ? undefined + : PictureInPictureChild.videoWrapper.getCurrentTime(video) / + PictureInPictureChild.videoWrapper.getDuration(video); + } + + // All other requests to toggle PiP should open a new PiP + // window + const videoRef = lazy.ContentDOMReference.get(video); + this.sendAsyncMessage("PictureInPicture:Request", { + isMuted: PictureInPictureChild.videoIsMuted(video), + playing: PictureInPictureChild.videoIsPlaying(video), + videoHeight: video.videoHeight, + videoWidth: video.videoWidth, + videoRef, + ccEnabled: lazy.DISPLAY_TEXT_TRACKS_PREF, + webVTTSubtitles: !!video.textTracks?.length, + scrubberPosition, + timestamp, + }); + + let args = { + firstTimeToggle: (!Services.prefs.getBoolPref( + TOGGLE_HAS_USED_PREF + )).toString(), + }; + + Services.telemetry.recordEvent( + "pictureinpicture", + "opened_method", + reason, + null, + args + ); + } + + /** + * The keyboard was used to attempt to open Picture-in-Picture. If a video is focused, + * select that video. Otherwise find the first playing video, or if none, the largest + * dimension video. We suspect this heuristic will handle most cases, though we + * might refine this later on. Note that we assume that this method will only be + * called for the focused document. + */ + keyToggle() { + let doc = this.document; + if (doc) { + let video = doc.activeElement; + if (!HTMLVideoElement.isInstance(video)) { + let listOfVideos = [...doc.querySelectorAll("video")].filter( + video => !isNaN(video.duration) + ); + // Get the first non-paused video, otherwise the longest video. This + // fallback is designed to skip over "preview"-style videos on sidebars. + video = + listOfVideos.filter(v => !v.paused)[0] || + listOfVideos.sort((a, b) => b.duration - a.duration)[0]; + } + if (video) { + this.togglePictureInPicture({ video, reason: "shortcut" }); + } + } + } +} + +/** + * The PictureInPictureToggleChild is responsible for displaying the overlaid + * Picture-in-Picture toggle over top of <video> elements that the mouse is + * hovering. + */ +export class PictureInPictureToggleChild extends JSWindowActorChild { + constructor() { + super(); + // We need to maintain some state about various things related to the + // Picture-in-Picture toggles - however, for now, the same + // PictureInPictureToggleChild might be re-used for different documents. + // We keep the state stashed inside of this WeakMap, keyed on the document + // itself. + this.weakDocStates = new WeakMap(); + this.toggleEnabled = + Services.prefs.getBoolPref(TOGGLE_ENABLED_PREF) && + Services.prefs.getBoolPref(PIP_ENABLED_PREF); + this.toggleTesting = Services.prefs.getBoolPref(TOGGLE_TESTING_PREF, false); + + // Bug 1570744 - JSWindowActorChild's cannot be used as nsIObserver's + // directly, so we create a new function here instead to act as our + // nsIObserver, which forwards the notification to the observe method. + this.observerFunction = (subject, topic, data) => { + this.observe(subject, topic, data); + }; + Services.prefs.addObserver(TOGGLE_ENABLED_PREF, this.observerFunction); + Services.prefs.addObserver(PIP_ENABLED_PREF, this.observerFunction); + Services.prefs.addObserver(TOGGLE_FIRST_SEEN_PREF, this.observerFunction); + Services.cpmm.sharedData.addEventListener("change", this); + + this.eligiblePipVideos = new WeakSet(); + this.trackingVideos = new WeakSet(); + } + + receiveMessage(message) { + switch (message.name) { + case "PictureInPicture:UrlbarToggle": { + this.urlbarToggle(); + break; + } + } + return null; + } + + didDestroy() { + this.stopTrackingMouseOverVideos(); + Services.prefs.removeObserver(TOGGLE_ENABLED_PREF, this.observerFunction); + Services.prefs.removeObserver(PIP_ENABLED_PREF, this.observerFunction); + Services.prefs.removeObserver( + TOGGLE_FIRST_SEEN_PREF, + this.observerFunction + ); + Services.cpmm.sharedData.removeEventListener("change", this); + + // remove the observer on the <video> element + let state = this.docState; + if (state?.intersectionObserver) { + state.intersectionObserver.disconnect(); + } + + // ensure the sandbox created by the video is destroyed + this.videoWrapper?.destroy(); + this.videoWrapper = null; + + for (let video of ChromeUtils.nondeterministicGetWeakSetKeys( + this.eligiblePipVideos + )) { + video.removeEventListener("emptied", this); + video.removeEventListener("loadedmetadata", this); + video.removeEventListener("durationchange", this); + } + + for (let video of ChromeUtils.nondeterministicGetWeakSetKeys( + this.trackingVideos + )) { + video.removeEventListener("emptied", this); + video.removeEventListener("loadedmetadata", this); + video.removeEventListener("durationchange", this); + } + + // ensure we don't access the state + this.isDestroyed = true; + } + + observe(subject, topic, data) { + if (topic != "nsPref:changed") { + return; + } + + this.toggleEnabled = + Services.prefs.getBoolPref(TOGGLE_ENABLED_PREF) && + Services.prefs.getBoolPref(PIP_ENABLED_PREF); + + if (this.toggleEnabled) { + // We have enabled the Picture-in-Picture toggle, so we need to make + // sure we register all of the videos that might already be on the page. + this.contentWindow.requestIdleCallback(() => { + let videos = this.document.querySelectorAll("video"); + for (let video of videos) { + this.registerVideo(video); + } + }); + } + + switch (data) { + case TOGGLE_FIRST_SEEN_PREF: + const firstSeenSeconds = Services.prefs.getIntPref( + TOGGLE_FIRST_SEEN_PREF + ); + if (!firstSeenSeconds || firstSeenSeconds < 0) { + return; + } + this.changeToIconIfDurationEnd(firstSeenSeconds); + break; + } + } + + /** + * Returns the state for the current document referred to via + * this.document. If no such state exists, creates it, stores it + * and returns it. + */ + get docState() { + if (this.isDestroyed || !this.document) { + return false; + } + + let state = this.weakDocStates.get(this.document); + + let visibilityThresholdPref = Services.prefs.getFloatPref( + TOGGLE_VISIBILITY_THRESHOLD_PREF, + "1.0" + ); + + if (!state) { + state = { + // A reference to the IntersectionObserver that's monitoring for videos + // to become visible. + intersectionObserver: null, + // A WeakSet of videos that are supposedly visible, according to the + // IntersectionObserver. + weakVisibleVideos: new WeakSet(), + // The number of videos that are supposedly visible, according to the + // IntersectionObserver + visibleVideosCount: 0, + // The DeferredTask that we'll arm every time a mousemove event occurs + // on a page where we have one or more visible videos. + mousemoveDeferredTask: null, + // A weak reference to the last video we displayed the toggle over. + weakOverVideo: null, + // True if the user is in the midst of clicking the toggle. + isClickingToggle: false, + // Set to the original target element on pointerdown if the user is clicking + // the toggle - this way, we can determine if a "click" event will need to be + // suppressed ("click" events don't fire if a "mouseup" occurs on a different + // element from the "pointerdown" / "mousedown" event). + clickedElement: null, + // This is a DeferredTask to hide the toggle after a period of mouse + // inactivity. + hideToggleDeferredTask: null, + // If we reach a point where we're tracking videos for mouse movements, + // then this will be true. If there are no videos worth tracking, then + // this is false. + isTrackingVideos: false, + togglePolicy: lazy.TOGGLE_POLICIES.DEFAULT, + toggleVisibilityThreshold: visibilityThresholdPref, + // The documentURI that has been checked with toggle policies and + // visibility thresholds for this document. Note that the documentURI + // might change for a document via the history API, so we remember + // the last checked documentURI to determine if we need to check again. + checkedPolicyDocumentURI: null, + }; + this.weakDocStates.set(this.document, state); + } + + return state; + } + + /** + * Returns the video that the user was last hovering with the mouse if it + * still exists. + * + * @return {Element} the <video> element that the user was last hovering, + * or null if there was no such <video>, or the <video> no longer exists. + */ + getWeakOverVideo() { + let { weakOverVideo } = this.docState; + if (weakOverVideo) { + // Bug 800957 - Accessing weakrefs at the wrong time can cause us to + // throw NS_ERROR_XPC_BAD_CONVERT_NATIVE + try { + return weakOverVideo.get(); + } catch (e) { + return null; + } + } + return null; + } + + handleEvent(event) { + if (!event.isTrusted) { + // We don't care about synthesized events that might be coming from + // content JS. + return; + } + + // Don't capture events from Picture-in-Picture content windows + if (gPlayerContents.has(this.contentWindow)) { + return; + } + + switch (event.type) { + case "touchstart": { + // Even if this is a touch event, there may be subsequent click events. + // Suppress those events after selecting the toggle to prevent playback changes + // when opening the Picture-in-Picture window. + if (this.docState.isClickingToggle) { + event.stopImmediatePropagation(); + event.preventDefault(); + } + break; + } + case "change": { + const { changedKeys } = event; + if (changedKeys.includes("PictureInPicture:SiteOverrides")) { + // For now we only update our cache if the site overrides change. + // the user will need to refresh the page for changes to apply. + try { + lazy.gSiteOverrides = + PictureInPictureToggleChild.getSiteOverrides(); + } catch (e) { + // Ignore resulting TypeError if gSiteOverrides is still unloaded + if (!(e instanceof TypeError)) { + throw e; + } + } + } + break; + } + case "UAWidgetSetupOrChange": { + if ( + this.toggleEnabled && + this.contentWindow.HTMLVideoElement.isInstance(event.target) && + event.target.ownerDocument == this.document + ) { + this.registerVideo(event.target); + } + break; + } + case "contextmenu": { + if (this.toggleEnabled) { + this.checkContextMenu(event); + } + break; + } + case "mouseout": { + this.onMouseOut(event); + break; + } + case "click": + if (event.detail == 0) { + let shadowRoot = event.originalTarget.containingShadowRoot; + let toggle = this.getToggleElement(shadowRoot); + if (event.originalTarget == toggle) { + this.startPictureInPicture(event, shadowRoot.host, toggle); + return; + } + } + // fall through + case "mousedown": + case "pointerup": + case "mouseup": { + this.onMouseButtonEvent(event); + break; + } + case "pointerdown": { + this.onPointerDown(event); + break; + } + case "mousemove": { + this.onMouseMove(event); + break; + } + case "pageshow": { + this.onPageShow(event); + break; + } + case "pagehide": { + this.onPageHide(event); + break; + } + case "durationchange": + // Intentional fall-through + case "emptied": + // Intentional fall-through + case "loadedmetadata": { + this.updatePipVideoEligibility(event.target); + break; + } + } + } + + /** + * Adds a <video> to the IntersectionObserver so that we know when it becomes + * visible. + * + * @param {Element} video The <video> element to register. + */ + registerVideo(video) { + let state = this.docState; + if (!state.intersectionObserver) { + let fn = this.onIntersection.bind(this); + state.intersectionObserver = new this.contentWindow.IntersectionObserver( + fn, + { + threshold: [0.0, 0.5], + } + ); + } + + state.intersectionObserver.observe(video); + + if (!lazy.PIP_URLBAR_BUTTON) { + return; + } + + video.addEventListener("emptied", this); + video.addEventListener("loadedmetadata", this); + video.addEventListener("durationchange", this); + + this.trackingVideos.add(video); + + this.updatePipVideoEligibility(video); + } + + updatePipVideoEligibility(video) { + let isEligible = this.isVideoPiPEligible(video); + if (isEligible) { + if (!this.eligiblePipVideos.has(video)) { + this.eligiblePipVideos.add(video); + + let mutationObserver = new this.contentWindow.MutationObserver( + mutationList => { + this.handleEligiblePipVideoMutation(mutationList); + } + ); + mutationObserver.observe(video.parentElement, { childList: true }); + } + } else if (this.eligiblePipVideos.has(video)) { + this.eligiblePipVideos.delete(video); + } + + let videos = ChromeUtils.nondeterministicGetWeakSetKeys( + this.eligiblePipVideos + ); + + this.sendAsyncMessage("PictureInPicture:UpdateEligiblePipVideoCount", { + pipCount: videos.length, + pipDisabledCount: videos.reduce( + (accumulator, currentVal) => + accumulator + + (currentVal.getAttribute("disablePictureInPicture") === "true" + ? 1 + : 0), + 0 + ), + }); + } + + handleEligiblePipVideoMutation(mutationList) { + for (let mutationRecord of mutationList) { + let video = mutationRecord.removedNodes[0]; + this.eligiblePipVideos.delete(video); + } + + let videos = ChromeUtils.nondeterministicGetWeakSetKeys( + this.eligiblePipVideos + ); + + this.sendAsyncMessage("PictureInPicture:UpdateEligiblePipVideoCount", { + pipCount: videos.length, + pipDisabledCount: videos.reduce( + (accumulator, currentVal) => + accumulator + + (currentVal.getAttribute("disablePictureInPicture") === "true" + ? 1 + : 0), + 0 + ), + }); + } + + urlbarToggle() { + let video = ChromeUtils.nondeterministicGetWeakSetKeys( + this.eligiblePipVideos + )[0]; + if (video) { + let pipEvent = new this.contentWindow.CustomEvent( + "MozTogglePictureInPicture", + { + bubbles: true, + detail: { reason: "urlBar" }, + } + ); + video.dispatchEvent(pipEvent); + } + } + + isVideoPiPEligible(video) { + if (lazy.PIP_TOGGLE_ALWAYS_SHOW) { + return true; + } + + if (isNaN(video.duration) || video.duration < lazy.MIN_VIDEO_LENGTH) { + return false; + } + + const MIN_VIDEO_DIMENSION = 140; // pixels + if ( + video.clientWidth < MIN_VIDEO_DIMENSION || + video.clientHeight < MIN_VIDEO_DIMENSION + ) { + return false; + } + + return true; + } + + /** + * Changes from the first-time toggle to the icon toggle if the Nimbus variable `displayDuration`'s + * end date is reached when hovering over a video. The end date is calculated according to the timestamp + * indicating when the PiP toggle was first seen. + * @param {Number} firstSeenStartSeconds the timestamp in seconds indicating when the PiP toggle was first seen + */ + changeToIconIfDurationEnd(firstSeenStartSeconds) { + const { displayDuration } = + lazy.NimbusFeatures.pictureinpicture.getAllVariables({ + defaultValues: { + displayDuration: TOGGLE_FIRST_TIME_DURATION_DAYS, + }, + }); + if (!displayDuration || displayDuration < 0) { + return; + } + + let daysInSeconds = displayDuration * 24 * 60 * 60; + let firstSeenEndSeconds = daysInSeconds + firstSeenStartSeconds; + let currentDateSeconds = Math.round(Date.now() / 1000); + + lazy.logConsole.debug( + "Toggle duration experiment - first time toggle seen on:", + new Date(firstSeenStartSeconds * 1000).toLocaleDateString() + ); + lazy.logConsole.debug( + "Toggle duration experiment - first time toggle will change on:", + new Date(firstSeenEndSeconds * 1000).toLocaleDateString() + ); + lazy.logConsole.debug( + "Toggle duration experiment - current date:", + new Date(currentDateSeconds * 1000).toLocaleDateString() + ); + + if (currentDateSeconds >= firstSeenEndSeconds) { + this.sendAsyncMessage("PictureInPicture:SetHasUsed", { + hasUsed: true, + }); + } + } + + /** + * Called by the IntersectionObserver callback once a video becomes visible. + * This adds some fine-grained checking to ensure that a sufficient amount of + * the video is visible before we consider showing the toggles on it. For now, + * that means that the entirety of the video must be in the viewport. + * + * @param {IntersectionEntry} intersectionEntry An IntersectionEntry passed to + * the IntersectionObserver callback. + * @return bool Whether or not we should start tracking mousemove events for + * this registered video. + */ + worthTracking(intersectionEntry) { + return intersectionEntry.isIntersecting; + } + + /** + * Called by the IntersectionObserver once a video crosses one of the + * thresholds dictated by the IntersectionObserver configuration. + * + * @param {Array<IntersectionEntry>} A collection of one or more + * IntersectionEntry's for <video> elements that might have entered or exited + * the viewport. + */ + onIntersection(entries) { + // The IntersectionObserver will also fire when a previously intersecting + // element is removed from the DOM. We know, however, that the node is + // still alive and referrable from the WeakSet because the + // IntersectionObserverEntry holds a strong reference to the video. + let state = this.docState; + if (!state) { + return; + } + let oldVisibleVideosCount = state.visibleVideosCount; + for (let entry of entries) { + let video = entry.target; + if (this.worthTracking(entry)) { + if (!state.weakVisibleVideos.has(video)) { + state.weakVisibleVideos.add(video); + state.visibleVideosCount++; + if (this.toggleTesting) { + gWeakIntersectingVideosForTesting.add(video); + } + } + } else if (state.weakVisibleVideos.has(video)) { + state.weakVisibleVideos.delete(video); + state.visibleVideosCount--; + if (this.toggleTesting) { + gWeakIntersectingVideosForTesting.delete(video); + } + } + } + + // For testing, especially in debug or asan builds, we might not + // run this idle callback within an acceptable time. While we're + // testing, we'll bypass the idle callback performance optimization + // and run our callbacks as soon as possible during the next idle + // period. + if (!oldVisibleVideosCount && state.visibleVideosCount) { + if (this.toggleTesting || !this.contentWindow) { + this.beginTrackingMouseOverVideos(); + } else { + this.contentWindow.requestIdleCallback(() => { + this.beginTrackingMouseOverVideos(); + }); + } + } else if (oldVisibleVideosCount && !state.visibleVideosCount) { + if (this.toggleTesting || !this.contentWindow) { + this.stopTrackingMouseOverVideos(); + } else { + this.contentWindow.requestIdleCallback(() => { + this.stopTrackingMouseOverVideos(); + }); + } + } + } + + addMouseButtonListeners() { + // We want to try to cancel the mouse events from continuing + // on into content if the user has clicked on the toggle, so + // we don't use the mozSystemGroup here, and add the listener + // to the parent target of the window, which in this case, + // is the windowRoot. Since this event listener is attached to + // part of the outer window, we need to also remove it in a + // pagehide event listener in the event that the page unloads + // before stopTrackingMouseOverVideos fires. + this.contentWindow.windowRoot.addEventListener("pointerdown", this, { + capture: true, + }); + this.contentWindow.windowRoot.addEventListener("mousedown", this, { + capture: true, + }); + this.contentWindow.windowRoot.addEventListener("mouseup", this, { + capture: true, + }); + this.contentWindow.windowRoot.addEventListener("pointerup", this, { + capture: true, + }); + this.contentWindow.windowRoot.addEventListener("click", this, { + capture: true, + }); + this.contentWindow.windowRoot.addEventListener("mouseout", this, { + capture: true, + }); + this.contentWindow.windowRoot.addEventListener("touchstart", this, { + capture: true, + }); + } + + removeMouseButtonListeners() { + // This can be null when closing the tab, but the event + // listeners should be removed in that case already. + if (!this.contentWindow || !this.contentWindow.windowRoot) { + return; + } + + this.contentWindow.windowRoot.removeEventListener("pointerdown", this, { + capture: true, + }); + this.contentWindow.windowRoot.removeEventListener("mousedown", this, { + capture: true, + }); + this.contentWindow.windowRoot.removeEventListener("mouseup", this, { + capture: true, + }); + this.contentWindow.windowRoot.removeEventListener("pointerup", this, { + capture: true, + }); + this.contentWindow.windowRoot.removeEventListener("click", this, { + capture: true, + }); + this.contentWindow.windowRoot.removeEventListener("mouseout", this, { + capture: true, + }); + this.contentWindow.windowRoot.removeEventListener("touchstart", this, { + capture: true, + }); + } + + /** + * One of the challenges of displaying this toggle is that many sites put + * things over top of <video> elements, like custom controls, or images, or + * all manner of things that might intercept mouseevents that would normally + * fire directly on the <video>. In order to properly detect when the mouse + * is over top of one of the <video> elements in this situation, we currently + * add a mousemove event handler to the entire document, and stash the most + * recent mousemove that fires. At periodic intervals, that stashed mousemove + * event is checked to see if it's hovering over one of our registered + * <video> elements. + * + * This sort of thing will not be necessary once bug 1539652 is fixed. + */ + beginTrackingMouseOverVideos() { + let state = this.docState; + if (!state.mousemoveDeferredTask) { + state.mousemoveDeferredTask = new lazy.DeferredTask(() => { + this.checkLastMouseMove(); + }, MOUSEMOVE_PROCESSING_DELAY_MS); + } + this.document.addEventListener("mousemove", this, { + mozSystemGroup: true, + capture: true, + }); + this.contentWindow.addEventListener("pageshow", this, { + mozSystemGroup: true, + }); + this.contentWindow.addEventListener("pagehide", this, { + mozSystemGroup: true, + }); + this.addMouseButtonListeners(); + state.isTrackingVideos = true; + } + + /** + * If we no longer have any interesting videos in the viewport, we deregister + * the mousemove and click listeners, and also remove any toggles that might + * be on the page still. + */ + stopTrackingMouseOverVideos() { + let state = this.docState; + // We initialize `mousemoveDeferredTask` in `beginTrackingMouseOverVideos`. + // If it doesn't exist, that can't have happened. Nothing else ever sets + // this value (though we arm/disarm in various places). So we don't need + // to do anything else here and can return early. + if (!state.mousemoveDeferredTask) { + return; + } + state.mousemoveDeferredTask.disarm(); + this.document.removeEventListener("mousemove", this, { + mozSystemGroup: true, + capture: true, + }); + if (this.contentWindow) { + this.contentWindow.removeEventListener("pageshow", this, { + mozSystemGroup: true, + }); + this.contentWindow.removeEventListener("pagehide", this, { + mozSystemGroup: true, + }); + } + this.removeMouseButtonListeners(); + let oldOverVideo = this.getWeakOverVideo(); + if (oldOverVideo) { + this.onMouseLeaveVideo(oldOverVideo); + } + state.isTrackingVideos = false; + } + + /** + * This pageshow event handler will get called if and when we complete a tab + * tear out or in. If we happened to be tracking videos before the tear + * occurred, we re-add the mouse event listeners so that they're attached to + * the right WindowRoot. + * + * @param {Event} event The pageshow event fired when completing a tab tear + * out or in. + */ + onPageShow(event) { + let state = this.docState; + if (state.isTrackingVideos) { + this.addMouseButtonListeners(); + } + } + + /** + * This pagehide event handler will get called if and when we start a tab + * tear out or in. If we happened to be tracking videos before the tear + * occurred, we remove the mouse event listeners. We'll re-add them when the + * pageshow event fires. + * + * @param {Event} event The pagehide event fired when starting a tab tear + * out or in. + */ + onPageHide(event) { + let state = this.docState; + if (state.isTrackingVideos) { + this.removeMouseButtonListeners(); + } + } + + /** + * If we're tracking <video> elements, this pointerdown event handler is run anytime + * a pointerdown occurs on the document. This function is responsible for checking + * if the user clicked on the Picture-in-Picture toggle. It does this by first + * checking if the video is visible beneath the point that was clicked. Then + * it tests whether or not the pointerdown occurred within the rectangle of the + * toggle. If so, the event's propagation is stopped, and Picture-in-Picture is + * triggered. + * + * @param {Event} event The mousemove event. + */ + onPointerDown(event) { + // The toggle ignores non-primary mouse clicks. + if (event.button != 0) { + return; + } + + let video = this.getWeakOverVideo(); + if (!video) { + return; + } + + let shadowRoot = video.openOrClosedShadowRoot; + if (!shadowRoot) { + return; + } + + let state = this.docState; + + let overVideo = (() => { + let { clientX, clientY } = event; + let winUtils = this.contentWindow.windowUtils; + // We use winUtils.nodesFromRect instead of document.elementsFromPoint, + // since document.elementsFromPoint always flushes layout. The 1's in that + // function call are for the size of the rect that we want, which is 1x1. + // + // We pass the aOnlyVisible boolean argument to check that the video isn't + // occluded by anything visible at the point of mousedown. If it is, we'll + // ignore the mousedown. + let elements = winUtils.nodesFromRect( + clientX, + clientY, + 1, + 1, + 1, + 1, + true, + false, + /* aOnlyVisible = */ true, + state.toggleVisibilityThreshold + ); + + for (let element of elements) { + if (element == video || element.containingShadowRoot == shadowRoot) { + return true; + } + } + + return false; + })(); + + if (!overVideo) { + return; + } + + let toggle = this.getToggleElement(shadowRoot); + if (this.isMouseOverToggle(toggle, event)) { + state.isClickingToggle = true; + state.clickedElement = Cu.getWeakReference(event.originalTarget); + event.stopImmediatePropagation(); + + this.startPictureInPicture(event, video, toggle); + } + } + + startPictureInPicture(event, video, toggle) { + Services.telemetry.keyedScalarAdd( + "pictureinpicture.opened_method", + "toggle", + 1 + ); + + let pipEvent = new this.contentWindow.CustomEvent( + "MozTogglePictureInPicture", + { + bubbles: true, + detail: { reason: "toggle" }, + } + ); + video.dispatchEvent(pipEvent); + + // Since we've initiated Picture-in-Picture, we can go ahead and + // hide the toggle now. + this.onMouseLeaveVideo(video); + } + + /** + * Called for mousedown, pointerup, mouseup and click events. If we + * detected that the user is clicking on the Picture-in-Picture toggle, + * these events are cancelled in the capture-phase before they reach + * content. The state for suppressing these events is cleared on the + * click event (unless the mouseup occurs on a different element from + * the mousedown, in which case, the state is cleared on mouseup). + * + * @param {Event} event A mousedown, pointerup, mouseup or click event. + */ + onMouseButtonEvent(event) { + // The toggle ignores non-primary mouse clicks. + if (event.button != 0) { + return; + } + + let state = this.docState; + if (state.isClickingToggle) { + event.stopImmediatePropagation(); + + // If this is a mouseup event, check to see if we have a record of what + // the original target was on pointerdown. If so, and if it doesn't match + // the mouseup original target, that means we won't get a click event, and + // we can clear the "clicking the toggle" state right away. + // + // Otherwise, we wait for the click event to do that. + let isMouseUpOnOtherElement = + event.type == "mouseup" && + (!state.clickedElement || + state.clickedElement.get() != event.originalTarget); + + if ( + isMouseUpOnOtherElement || + event.type == "click" || + // pointerup event still triggers after a touchstart event. We just need to detect + // the pointer type and determine if we got to this part of the code through a touch event. + event.pointerType == "touch" + ) { + // The click is complete, so now we reset the state so that + // we stop suppressing these events. + state.isClickingToggle = false; + state.clickedElement = null; + } + } + } + + /** + * Called on mouseout events to determine whether or not the mouse has + * exited the window. + * + * @param {Event} event The mouseout event. + */ + onMouseOut(event) { + if (!event.relatedTarget) { + // For mouseout events, if there's no relatedTarget (which normally + // maps to the element that the mouse entered into) then this means that + // we left the window. + let video = this.getWeakOverVideo(); + if (!video) { + return; + } + + this.onMouseLeaveVideo(video); + } + } + + /** + * Called for each mousemove event when we're tracking those events to + * determine if the cursor is hovering over a <video>. + * + * @param {Event} event The mousemove event. + */ + onMouseMove(event) { + let state = this.docState; + + if (state.hideToggleDeferredTask) { + state.hideToggleDeferredTask.disarm(); + state.hideToggleDeferredTask.arm(); + } + + state.lastMouseMoveEvent = event; + state.mousemoveDeferredTask.arm(); + } + + /** + * Called by the DeferredTask after MOUSEMOVE_PROCESSING_DELAY_MS + * milliseconds. Checked to see if that mousemove happens to be overtop of + * any interesting <video> elements that we want to display the toggle + * on. If so, puts the toggle on that video. + */ + checkLastMouseMove() { + let state = this.docState; + let event = state.lastMouseMoveEvent; + let { clientX, clientY } = event; + lazy.logConsole.debug("Visible videos count:", state.visibleVideosCount); + lazy.logConsole.debug("Tracking videos:", state.isTrackingVideos); + let winUtils = this.contentWindow.windowUtils; + // We use winUtils.nodesFromRect instead of document.elementsFromPoint, + // since document.elementsFromPoint always flushes layout. The 1's in that + // function call are for the size of the rect that we want, which is 1x1. + let elements = winUtils.nodesFromRect( + clientX, + clientY, + 1, + 1, + 1, + 1, + true, + false, + /* aOnlyVisible = */ true + ); + + for (let element of elements) { + lazy.logConsole.debug("Element id under cursor:", element.id); + lazy.logConsole.debug( + "Node name of an element under cursor:", + element.nodeName + ); + lazy.logConsole.debug( + "Supported <video> element:", + state.weakVisibleVideos.has(element) + ); + lazy.logConsole.debug( + "PiP window is open:", + element.isCloningElementVisually + ); + + // Check for hovering over the video controls or so too, not only + // directly over the video. + for (let el = element; el; el = el.containingShadowRoot?.host) { + if (state.weakVisibleVideos.has(el) && !el.isCloningElementVisually) { + lazy.logConsole.debug("Found supported element"); + this.onMouseOverVideo(el, event); + return; + } + } + } + + let oldOverVideo = this.getWeakOverVideo(); + if (oldOverVideo) { + this.onMouseLeaveVideo(oldOverVideo); + } + } + + /** + * Called once it has been determined that the mouse is overtop of a video + * that is in the viewport. + * + * @param {Element} video The video the mouse is over. + */ + onMouseOverVideo(video, event) { + let oldOverVideo = this.getWeakOverVideo(); + let shadowRoot = video.openOrClosedShadowRoot; + + if (shadowRoot.firstChild && video != oldOverVideo) { + if (video.getTransformToViewport().a == -1) { + shadowRoot.firstChild.setAttribute("flipped", true); + } else { + shadowRoot.firstChild.removeAttribute("flipped"); + } + } + + // It seems from automated testing that if it's still very early on in the + // lifecycle of a <video> element, it might not yet have a shadowRoot, + // in which case, we can bail out here early. + if (!shadowRoot) { + if (oldOverVideo) { + // We also clear the hover state on the old video we were hovering, + // if there was one. + this.onMouseLeaveVideo(oldOverVideo); + } + + return; + } + + let state = this.docState; + let toggle = this.getToggleElement(shadowRoot); + let controlsOverlay = shadowRoot.querySelector(".controlsOverlay"); + + if (state.checkedPolicyDocumentURI != this.document.documentURI) { + state.togglePolicy = lazy.TOGGLE_POLICIES.DEFAULT; + // We cache the matchers process-wide. We'll skip this while running tests to make that + // easier. + let siteOverrides = this.toggleTesting + ? PictureInPictureToggleChild.getSiteOverrides() + : lazy.gSiteOverrides; + + let visibilityThresholdPref = Services.prefs.getFloatPref( + TOGGLE_VISIBILITY_THRESHOLD_PREF, + "1.0" + ); + + if (!this.videoWrapper) { + this.videoWrapper = applyWrapper(this, video); + } + + // Do we have any toggle overrides? If so, try to apply them. + for (let [override, { policy, visibilityThreshold }] of siteOverrides) { + if ( + (policy || visibilityThreshold) && + override.matches(this.document.documentURI) + ) { + state.togglePolicy = this.videoWrapper?.shouldHideToggle(video) + ? lazy.TOGGLE_POLICIES.HIDDEN + : policy || lazy.TOGGLE_POLICIES.DEFAULT; + state.toggleVisibilityThreshold = + visibilityThreshold || visibilityThresholdPref; + break; + } + } + + state.checkedPolicyDocumentURI = this.document.documentURI; + } + + // The built-in <video> controls are along the bottom, which would overlap the + // toggle if the override is set to BOTTOM, so we ignore overrides that set + // a policy of BOTTOM for <video> elements with controls. + if ( + state.togglePolicy != lazy.TOGGLE_POLICIES.DEFAULT && + !(state.togglePolicy == lazy.TOGGLE_POLICIES.BOTTOM && video.controls) + ) { + toggle.setAttribute( + "policy", + lazy.TOGGLE_POLICY_STRINGS[state.togglePolicy] + ); + } else { + toggle.removeAttribute("policy"); + } + + // nimbusExperimentVariables will be defaultValues when the experiment is disabled + const nimbusExperimentVariables = + lazy.NimbusFeatures.pictureinpicture.getAllVariables({ + defaultValues: { + oldToggle: true, + title: null, + message: false, + showIconOnly: false, + displayDuration: TOGGLE_FIRST_TIME_DURATION_DAYS, + }, + }); + + /** + * If a Nimbus variable exists for the first-time PiP toggle design, + * override the old design via a classname "experiment". + */ + if (!nimbusExperimentVariables.oldToggle) { + let controlsContainer = shadowRoot.querySelector(".controlsContainer"); + let pipWrapper = shadowRoot.querySelector(".pip-wrapper"); + + controlsContainer.classList.add("experiment"); + pipWrapper.classList.add("experiment"); + } else { + let controlsContainer = shadowRoot.querySelector(".controlsContainer"); + let pipWrapper = shadowRoot.querySelector(".pip-wrapper"); + + controlsContainer.classList.remove("experiment"); + pipWrapper.classList.remove("experiment"); + } + + if (nimbusExperimentVariables.title) { + let pipExplainer = shadowRoot.querySelector(".pip-explainer"); + let pipLabel = shadowRoot.querySelector(".pip-label"); + + if (pipExplainer && nimbusExperimentVariables.message) { + pipExplainer.innerText = nimbusExperimentVariables.message; + } + pipLabel.innerText = nimbusExperimentVariables.title; + } else if (nimbusExperimentVariables.showIconOnly) { + // We only want to show the PiP icon in this experiment scenario + let pipExpanded = shadowRoot.querySelector(".pip-expanded"); + pipExpanded.style.display = "none"; + let pipSmall = shadowRoot.querySelector(".pip-small"); + pipSmall.style.opacity = "1"; + + let pipIcon = shadowRoot.querySelectorAll(".pip-icon")[1]; + pipIcon.style.display = "block"; + } + + controlsOverlay.removeAttribute("hidetoggle"); + + // The hideToggleDeferredTask we create here is for automatically hiding + // the toggle after a period of no mousemove activity for + // TOGGLE_HIDING_TIMEOUT_MS. If the mouse moves, then the DeferredTask + // timer is reset. + // + // We disable the toggle hiding timeout during testing to reduce + // non-determinism from timers when testing the toggle. + if (!state.hideToggleDeferredTask && !this.toggleTesting) { + state.hideToggleDeferredTask = new lazy.DeferredTask(() => { + controlsOverlay.setAttribute("hidetoggle", true); + }, TOGGLE_HIDING_TIMEOUT_MS); + } + + if (oldOverVideo) { + if (oldOverVideo == video) { + // If we're still hovering the old video, we might have entered or + // exited the toggle region. + this.checkHoverToggle(toggle, event); + return; + } + + // We had an old video that we were hovering, and we're not hovering + // it anymore. Let's leave it. + this.onMouseLeaveVideo(oldOverVideo); + } + + state.weakOverVideo = Cu.getWeakReference(video); + controlsOverlay.classList.add("hovering"); + + if ( + state.togglePolicy != lazy.TOGGLE_POLICIES.HIDDEN && + !toggle.hasAttribute("hidden") + ) { + Services.telemetry.scalarAdd("pictureinpicture.saw_toggle", 1); + const hasUsedPiP = Services.prefs.getBoolPref(TOGGLE_HAS_USED_PREF); + let args = { + firstTime: (!hasUsedPiP).toString(), + }; + Services.telemetry.recordEvent( + "pictureinpicture", + "saw_toggle", + "toggle", + null, + args + ); + // only record if this is the first time seeing the toggle + if (!hasUsedPiP) { + lazy.NimbusFeatures.pictureinpicture.recordExposureEvent(); + + const firstSeenSeconds = Services.prefs.getIntPref( + TOGGLE_FIRST_SEEN_PREF, + 0 + ); + + if (!firstSeenSeconds || firstSeenSeconds < 0) { + let firstTimePiPStartDate = Math.round(Date.now() / 1000); + this.sendAsyncMessage("PictureInPicture:SetFirstSeen", { + dateSeconds: firstTimePiPStartDate, + }); + } else if (nimbusExperimentVariables.displayDuration) { + this.changeToIconIfDurationEnd(firstSeenSeconds); + } + } + } + + // Now that we're hovering the video, we'll check to see if we're + // hovering the toggle too. + this.checkHoverToggle(toggle, event); + } + + /** + * Checks if a mouse event is happening over a toggle element. If it is, + * sets the hovering class on it. Otherwise, it clears the hovering + * class. + * + * @param {Element} toggle The Picture-in-Picture toggle to check. + * @param {MouseEvent} event A MouseEvent to test. + */ + checkHoverToggle(toggle, event) { + toggle.classList.toggle("hovering", this.isMouseOverToggle(toggle, event)); + } + + /** + * Called once it has been determined that the mouse is no longer overlapping + * a video that we'd previously called onMouseOverVideo with. + * + * @param {Element} video The video that the mouse left. + */ + onMouseLeaveVideo(video) { + let state = this.docState; + let shadowRoot = video.openOrClosedShadowRoot; + + if (shadowRoot) { + let controlsOverlay = shadowRoot.querySelector(".controlsOverlay"); + let toggle = this.getToggleElement(shadowRoot); + controlsOverlay.classList.remove("hovering"); + toggle.classList.remove("hovering"); + } + + state.weakOverVideo = null; + + if (!this.toggleTesting) { + state.hideToggleDeferredTask.disarm(); + state.mousemoveDeferredTask.disarm(); + } + + state.hideToggleDeferredTask = null; + } + + /** + * Given a reference to a Picture-in-Picture toggle element, determines + * if a MouseEvent event is occurring within its bounds. + * + * @param {Element} toggle The Picture-in-Picture toggle. + * @param {MouseEvent} event A MouseEvent to test. + * + * @return {Boolean} + */ + isMouseOverToggle(toggle, event) { + let toggleRect = + toggle.ownerGlobal.windowUtils.getBoundsWithoutFlushing(toggle); + + // The way the toggle is currently implemented with + // absolute positioning, the root toggle element bounds don't actually + // contain all of the toggle child element bounds. Until we find a way to + // sort that out, we workaround the issue by having each clickable child + // elements of the toggle have a clicklable class, and then compute the + // smallest rect that contains all of their bounding rects and use that + // as the hitbox. + toggleRect = lazy.Rect.fromRect(toggleRect); + let clickableChildren = toggle.querySelectorAll(".clickable"); + for (let child of clickableChildren) { + let childRect = lazy.Rect.fromRect( + child.ownerGlobal.windowUtils.getBoundsWithoutFlushing(child) + ); + toggleRect.expandToContain(childRect); + } + + // If the toggle has no dimensions, we're definitely not over it. + if (!toggleRect.width || !toggleRect.height) { + return false; + } + + let { clientX, clientY } = event; + + return ( + clientX >= toggleRect.left && + clientX <= toggleRect.right && + clientY >= toggleRect.top && + clientY <= toggleRect.bottom + ); + } + + /** + * Checks a contextmenu event to see if the mouse is currently over the + * Picture-in-Picture toggle. If so, sends a message to the parent process + * to open up the Picture-in-Picture toggle context menu. + * + * @param {MouseEvent} event A contextmenu event. + */ + checkContextMenu(event) { + let video = this.getWeakOverVideo(); + if (!video) { + return; + } + + let shadowRoot = video.openOrClosedShadowRoot; + if (!shadowRoot) { + return; + } + + let toggle = this.getToggleElement(shadowRoot); + if (this.isMouseOverToggle(toggle, event)) { + let devicePixelRatio = toggle.ownerGlobal.devicePixelRatio; + this.sendAsyncMessage("PictureInPicture:OpenToggleContextMenu", { + screenXDevPx: event.screenX * devicePixelRatio, + screenYDevPx: event.screenY * devicePixelRatio, + mozInputSource: event.mozInputSource, + }); + event.stopImmediatePropagation(); + event.preventDefault(); + } + } + + /** + * Returns the appropriate root element for the Picture-in-Picture toggle, + * depending on whether or not we're using the experimental toggle preference. + * + * @param {Element} shadowRoot The shadowRoot of the video element. + * @returns {Element} The toggle element. + */ + getToggleElement(shadowRoot) { + return shadowRoot.getElementById("pictureInPictureToggle"); + } + + /** + * This is a test-only function that returns true if a video is being tracked + * for mouseover events after having intersected the viewport. + */ + static isTracking(video) { + return gWeakIntersectingVideosForTesting.has(video); + } + + /** + * Gets any Picture-in-Picture site-specific overrides stored in the + * sharedData struct, and returns them as an Array of two-element Arrays, + * where the first element is a MatchPattern and the second element is an + * object of the form { policy, disabledKeyboardControls } (where each property + * may be missing or undefined). + * + * @returns {Array<Array<2>>} Array of 2-element Arrays where the first element + * is a MatchPattern and the second element is an object with optional policy + * and/or disabledKeyboardControls properties. + */ + static getSiteOverrides() { + let result = []; + let patterns = Services.cpmm.sharedData.get( + "PictureInPicture:SiteOverrides" + ); + for (let pattern in patterns) { + let matcher = new MatchPattern(pattern); + result.push([matcher, patterns[pattern]]); + } + return result; + } +} + +export class PictureInPictureChild extends JSWindowActorChild { + #subtitlesEnabled = false; + // A weak reference to this PiP window's video element + weakVideo = null; + + // A weak reference to this PiP window's content window + weakPlayerContent = null; + + // A reference to current WebVTT track currently displayed on the content window + _currentWebVTTTrack = null; + + observerFunction = null; + + observe(subject, topic, data) { + if (topic != "nsPref:changed") { + return; + } + + switch (data) { + case "media.videocontrols.picture-in-picture.display-text-tracks.enabled": { + const originatingVideo = this.getWeakVideo(); + let isTextTrackPrefEnabled = Services.prefs.getBoolPref( + "media.videocontrols.picture-in-picture.display-text-tracks.enabled" + ); + + // Enable or disable text track support + if (isTextTrackPrefEnabled) { + this.setupTextTracks(originatingVideo); + } else { + this.removeTextTracks(originatingVideo); + } + break; + } + } + } + + /** + * Creates a link element with a reference to the css stylesheet needed + * for text tracks responsive styling. + * @returns {Element} the link element containing text tracks stylesheet. + */ + createTextTracksStyleSheet() { + let headStyleElement = this.document.createElement("link"); + headStyleElement.setAttribute("rel", "stylesheet"); + headStyleElement.setAttribute( + "href", + "chrome://global/skin/pictureinpicture/texttracks.css" + ); + headStyleElement.setAttribute("type", "text/css"); + return headStyleElement; + } + + /** + * Sets up Picture-in-Picture to support displaying text tracks from WebVTT + * or if WebVTT isn't supported we will register the caption change mutation observer if + * the site wrapper exists. + * + * If the originating video supports WebVTT, try to read the + * active track and cues. Display any active cues on the pip window + * right away if applicable. + * + * @param originatingVideo {Element|null} + * The <video> being displayed in Picture-in-Picture mode, or null if that <video> no longer exists. + */ + setupTextTracks(originatingVideo) { + const isWebVTTSupported = !!originatingVideo.textTracks?.length; + + if (!isWebVTTSupported) { + this.setUpCaptionChangeListener(originatingVideo); + return; + } + + // Verify active track for originating video + this.setActiveTextTrack(originatingVideo.textTracks); + + if (!this._currentWebVTTTrack) { + // If WebVTT track is invalid, try using a video wrapper + this.setUpCaptionChangeListener(originatingVideo); + return; + } + + // Listen for changes in tracks and active cues + originatingVideo.textTracks.addEventListener("change", this); + this._currentWebVTTTrack.addEventListener("cuechange", this.onCueChange); + + const cues = this._currentWebVTTTrack.activeCues; + this.updateWebVTTTextTracksDisplay(cues); + } + + /** + * Toggle the visibility of the subtitles in the PiP window + */ + toggleTextTracks() { + let textTracks = this.document.getElementById("texttracks"); + textTracks.style.display = + textTracks.style.display === "none" ? "" : "none"; + } + + /** + * Removes existing text tracks on the Picture in Picture window. + * + * If the originating video supports WebVTT, clear references to active + * tracks and cues. No longer listen for any track or cue changes. + * + * @param originatingVideo {Element|null} + * The <video> being displayed in Picture-in-Picture mode, or null if that <video> no longer exists. + */ + removeTextTracks(originatingVideo) { + const isWebVTTSupported = !!originatingVideo.textTracks; + + if (!isWebVTTSupported) { + return; + } + + // No longer listen for changes to tracks and active cues + originatingVideo.textTracks.removeEventListener("change", this); + this._currentWebVTTTrack?.removeEventListener( + "cuechange", + this.onCueChange + ); + this._currentWebVTTTrack = null; + this.updateWebVTTTextTracksDisplay(null); + } + + /** + * Moves the text tracks container position above the pip window's video controls + * if their positions visually overlap. Since pip controls are within the parent + * process, we determine if pip video controls and text tracks visually overlap by + * comparing their relative positions with DOMRect. + * + * If overlap is found, set attribute "overlap-video-controls" to move text tracks + * and define a new relative bottom position according to pip window size and the + * position of video controls. + * @param {Object} data args needed to determine if text tracks must be moved + */ + moveTextTracks(data) { + const { + isFullscreen, + isVideoControlsShowing, + playerBottomControlsDOMRect, + isScrubberShowing, + } = data; + let textTracks = this.document.getElementById("texttracks"); + const originatingWindow = this.getWeakVideo().ownerGlobal; + const isReducedMotionEnabled = originatingWindow.matchMedia( + "(prefers-reduced-motion: reduce)" + ).matches; + const textTracksFontScale = this.document + .querySelector(":root") + .style.getPropertyValue("--font-scale"); + + if (isFullscreen || isReducedMotionEnabled) { + textTracks.removeAttribute("overlap-video-controls"); + return; + } + + if (isVideoControlsShowing) { + let playerVideoRect = textTracks.parentElement.getBoundingClientRect(); + let isOverlap = + playerVideoRect.bottom - textTracksFontScale * playerVideoRect.height > + playerBottomControlsDOMRect.top; + + if (isOverlap) { + const root = this.document.querySelector(":root"); + if (isScrubberShowing) { + root.style.setProperty("--player-controls-scrubber-height", "30px"); + } else { + root.style.setProperty("--player-controls-scrubber-height", "0px"); + } + textTracks.setAttribute("overlap-video-controls", true); + } else { + textTracks.removeAttribute("overlap-video-controls"); + } + } else { + textTracks.removeAttribute("overlap-video-controls"); + } + } + + /** + * Updates the text content for the container that holds and displays text tracks + * on the pip window. + * @param textTrackCues {TextTrackCueList|null} + * Collection of TextTrackCue objects containing text displayed, or null if there is no cue to display. + */ + updateWebVTTTextTracksDisplay(textTrackCues) { + let pipWindowTracksContainer = this.document.getElementById("texttracks"); + let playerVideo = this.document.getElementById("playervideo"); + let playerVideoWindow = playerVideo.ownerGlobal; + + // To prevent overlap with previous cues, clear all text from the pip window + pipWindowTracksContainer.replaceChildren(); + + if (!textTrackCues) { + return; + } + + if (!this.isSubtitlesEnabled) { + this.isSubtitlesEnabled = true; + this.sendAsyncMessage("PictureInPicture:EnableSubtitlesButton"); + } + + let allCuesArray = [...textTrackCues]; + // Re-order cues + this.getOrderedWebVTTCues(allCuesArray); + // Parse through WebVTT cue using vtt.js to ensure + // semantic markup like <b> and <i> tags are rendered. + allCuesArray.forEach(cue => { + let text = cue.text; + // Trim extra newlines and whitespaces + const re = /(\s*\n{2,}\s*)/g; + text = text.trim(); + text = text.replace(re, "\n"); + let cueTextNode = WebVTT.convertCueToDOMTree(playerVideoWindow, text); + let cueDiv = this.document.createElement("div"); + cueDiv.appendChild(cueTextNode); + pipWindowTracksContainer.appendChild(cueDiv); + }); + } + + /** + * Re-orders list of multiple active cues to ensure cues are rendered in the correct order. + * How cues are ordered depends on the VTTCue.line value of the cue. + * + * If line is string "auto", we want to reverse the order of cues. + * Cues are read from top to bottom in a vtt file, but are inserted into a video from bottom to top. + * Ensure this order is followed. + * + * If line is an integer or percentage, we want to order cues according to numeric value. + * Assumptions: + * 1) all active cues are numeric + * 2) all active cues are in range 0..100 + * 3) all actives cue are horizontal (no VTTCue.vertical) + * 4) all active cues with VTTCue.line integer have VTTCue.snapToLines = true + * 5) all active cues with VTTCue.line percentage have VTTCue.snapToLines = false + * + * vtt.jsm currently sets snapToLines to false if line is a percentage value, but + * cues are still ordered by line. In most cases, snapToLines is set to true by default, + * unless intentionally overridden. + * @param allCuesArray {Array<VTTCue>} array of active cues + */ + getOrderedWebVTTCues(allCuesArray) { + if (!allCuesArray || allCuesArray.length <= 1) { + return; + } + + let allCuesHaveNumericLines = allCuesArray.find(cue => cue.line !== "auto"); + + if (allCuesHaveNumericLines) { + allCuesArray.sort((cue1, cue2) => cue1.line - cue2.line); + } else if (allCuesArray.length >= 2) { + allCuesArray.reverse(); + } + } + + /** + * Returns a reference to the PiP's <video> element being displayed in Picture-in-Picture + * mode. + * + * @return {Element} The <video> being displayed in Picture-in-Picture mode, or null + * if that <video> no longer exists. + */ + getWeakVideo() { + if (this.weakVideo) { + // Bug 800957 - Accessing weakrefs at the wrong time can cause us to + // throw NS_ERROR_XPC_BAD_CONVERT_NATIVE + try { + return this.weakVideo.get(); + } catch (e) { + return null; + } + } + return null; + } + + /** + * Returns a reference to the inner window of the about:blank document that is + * cloning the originating <video> in the always-on-top player <xul:browser>. + * + * @return {Window} The inner window of the about:blank player <xul:browser>, or + * null if that window has been closed. + */ + getWeakPlayerContent() { + if (this.weakPlayerContent) { + // Bug 800957 - Accessing weakrefs at the wrong time can cause us to + // throw NS_ERROR_XPC_BAD_CONVERT_NATIVE + try { + return this.weakPlayerContent.get(); + } catch (e) { + return null; + } + } + return null; + } + + /** + * Returns true if the passed video happens to be the one that this + * content process is running in a Picture-in-Picture window. + * + * @param {Element} video The <video> element to check. + * + * @return {Boolean} + */ + inPictureInPicture(video) { + return this.getWeakVideo() === video; + } + + static videoIsPlaying(video) { + return !!(!video.paused && !video.ended && video.readyState > 2); + } + + static videoIsMuted(video) { + return this.videoWrapper.isMuted(video); + } + + handleEvent(event) { + switch (event.type) { + case "MozStopPictureInPicture": { + if (event.isTrusted && event.target === this.getWeakVideo()) { + const reason = event.detail?.reason || "videoElRemove"; + this.closePictureInPicture({ reason }); + } + break; + } + case "pagehide": { + // The originating video's content document has unloaded, + // so close Picture-in-Picture. + this.closePictureInPicture({ reason: "pagehide" }); + break; + } + case "MozDOMFullscreen:Request": { + this.closePictureInPicture({ reason: "fullscreen" }); + break; + } + case "play": { + this.sendAsyncMessage("PictureInPicture:Playing"); + break; + } + case "pause": { + this.sendAsyncMessage("PictureInPicture:Paused"); + break; + } + case "volumechange": { + let video = this.getWeakVideo(); + + // Just double-checking that we received the event for the right + // video element. + if (video !== event.target) { + lazy.logConsole.error( + "PictureInPictureChild received volumechange for " + + "the wrong video!" + ); + return; + } + + if (this.constructor.videoIsMuted(video)) { + this.sendAsyncMessage("PictureInPicture:Muting"); + } else { + this.sendAsyncMessage("PictureInPicture:Unmuting"); + } + break; + } + case "resize": { + let video = event.target; + if (this.inPictureInPicture(video)) { + this.sendAsyncMessage("PictureInPicture:Resize", { + videoHeight: video.videoHeight, + videoWidth: video.videoWidth, + }); + } + this.setupTextTracks(video); + break; + } + case "emptied": { + this.isSubtitlesEnabled = false; + if (this.emptiedTimeout) { + clearTimeout(this.emptiedTimeout); + this.emptiedTimeout = null; + } + let video = this.getWeakVideo(); + // We may want to keep the pip window open if the video + // is still in DOM. But if video src is no longer defined, + // close Picture-in-Picture. + this.emptiedTimeout = setTimeout(() => { + if (!video || !video.src) { + this.closePictureInPicture({ reason: "videoElEmptied" }); + } + }, EMPTIED_TIMEOUT_MS); + break; + } + case "change": { + // Clear currently stored track data (webvtt support) before reading + // a new track. + if (this._currentWebVTTTrack) { + this._currentWebVTTTrack.removeEventListener( + "cuechange", + this.onCueChange + ); + this._currentWebVTTTrack = null; + } + + const tracks = event.target; + this.setActiveTextTrack(tracks); + const isCurrentTrackAvailable = this._currentWebVTTTrack; + + // If tracks are disabled or invalid while change occurs, + // remove text tracks from the pip window and stop here. + if (!isCurrentTrackAvailable || !tracks.length) { + this.updateWebVTTTextTracksDisplay(null); + return; + } + + this._currentWebVTTTrack.addEventListener( + "cuechange", + this.onCueChange + ); + const cues = this._currentWebVTTTrack.activeCues; + this.updateWebVTTTextTracksDisplay(cues); + break; + } + case "timeupdate": + case "durationchange": { + let video = this.getWeakVideo(); + let currentTime = this.videoWrapper.getCurrentTime(video); + let duration = this.videoWrapper.getDuration(video); + let scrubberPosition = currentTime === 0 ? 0 : currentTime / duration; + let timestamp = this.videoWrapper.formatTimestamp( + currentTime, + duration + ); + // There's no point in sending this message unless we have a + // reasonable timestamp. + if (timestamp !== undefined && lazy.IMPROVED_CONTROLS_ENABLED_PREF) { + this.sendAsyncMessage( + "PictureInPicture:SetTimestampAndScrubberPosition", + { + scrubberPosition, + timestamp, + } + ); + } + break; + } + } + } + + /** + * Tells the parent to close a pre-existing Picture-in-Picture + * window. + * + * @return {Promise} + * + * @resolves {undefined} Once the pre-existing Picture-in-Picture + * window has unloaded. + */ + async closePictureInPicture({ reason }) { + let video = this.getWeakVideo(); + if (video) { + this.untrackOriginatingVideo(video); + } + this.sendAsyncMessage("PictureInPicture:Close", { + reason, + }); + + let playerContent = this.getWeakPlayerContent(); + if (playerContent) { + if (!playerContent.closed) { + await new Promise(resolve => { + playerContent.addEventListener("unload", resolve, { + once: true, + }); + }); + } + // Nothing should be holding a reference to the Picture-in-Picture + // player window content at this point, but just in case, we'll + // clear the weak reference directly so nothing else can get a hold + // of it from this angle. + this.weakPlayerContent = null; + } + } + + receiveMessage(message) { + switch (message.name) { + case "PictureInPicture:SetupPlayer": { + const { videoRef } = message.data; + this.setupPlayer(videoRef); + break; + } + case "PictureInPicture:Play": { + this.play(); + break; + } + case "PictureInPicture:Pause": { + if (message.data && message.data.reason == "pip-closed") { + let video = this.getWeakVideo(); + + // Currently in Firefox srcObjects are MediaStreams. However, by spec a srcObject + // can be either a MediaStream, MediaSource or Blob. In case of future changes + // we do not want to pause MediaStream srcObjects and we want to maintain current + // behavior for non-MediaStream srcObjects. + if (video && MediaStream.isInstance(video.srcObject)) { + break; + } + } + this.pause(); + break; + } + case "PictureInPicture:Mute": { + this.mute(); + break; + } + case "PictureInPicture:Unmute": { + this.unmute(); + break; + } + case "PictureInPicture:SeekForward": + case "PictureInPicture:SeekBackward": { + let selectedTime; + let video = this.getWeakVideo(); + let currentTime = this.videoWrapper.getCurrentTime(video); + if (message.name == "PictureInPicture:SeekBackward") { + selectedTime = currentTime - SEEK_TIME_SECS; + selectedTime = selectedTime >= 0 ? selectedTime : 0; + } else { + const maxtime = this.videoWrapper.getDuration(video); + selectedTime = currentTime + SEEK_TIME_SECS; + selectedTime = selectedTime <= maxtime ? selectedTime : maxtime; + } + this.videoWrapper.setCurrentTime(video, selectedTime); + break; + } + case "PictureInPicture:KeyDown": { + this.keyDown(message.data); + break; + } + case "PictureInPicture:EnterFullscreen": + case "PictureInPicture:ExitFullscreen": { + let textTracks = this.document.getElementById("texttracks"); + if (textTracks) { + this.moveTextTracks(message.data); + } + break; + } + case "PictureInPicture:ShowVideoControls": + case "PictureInPicture:HideVideoControls": { + let textTracks = this.document.getElementById("texttracks"); + if (textTracks) { + this.moveTextTracks(message.data); + } + break; + } + case "PictureInPicture:ToggleTextTracks": { + this.toggleTextTracks(); + break; + } + case "PictureInPicture:ChangeFontSizeTextTracks": { + this.setTextTrackFontSize(); + break; + } + case "PictureInPicture:SetVideoTime": { + const { scrubberPosition, wasPlaying } = message.data; + this.setVideoTime(scrubberPosition, wasPlaying); + break; + } + } + } + + /** + * Set the current time of the video based of the position of the scrubber + * @param {Number} scrubberPosition A number between 0 and 1 representing the position of the scrubber + */ + setVideoTime(scrubberPosition, wasPlaying) { + const video = this.getWeakVideo(); + let duration = this.videoWrapper.getDuration(video); + let currentTime = scrubberPosition * duration; + this.videoWrapper.setCurrentTime(video, currentTime, wasPlaying); + } + + /** + * @returns {boolean} true if a textTrack with mode "hidden" should be treated as "showing" + */ + shouldShowHiddenTextTracks() { + const video = this.getWeakVideo(); + if (!video) { + return false; + } + const { documentURI } = video.ownerDocument; + if (!documentURI) { + return false; + } + for (let [override, { showHiddenTextTracks }] of lazy.gSiteOverrides) { + if (override.matches(documentURI) && showHiddenTextTracks) { + return true; + } + } + return false; + } + + /** + * Updates this._currentWebVTTTrack if an active track is found + * for the originating video. + * @param {TextTrackList} textTrackList list of text tracks + */ + setActiveTextTrack(textTrackList) { + this._currentWebVTTTrack = null; + + for (let i = 0; i < textTrackList.length; i++) { + let track = textTrackList[i]; + let isCCText = track.kind === "subtitles" || track.kind === "captions"; + let shouldShowTrack = + track.mode === "showing" || + (track.mode === "hidden" && this.shouldShowHiddenTextTracks()); + if (isCCText && shouldShowTrack && track.cues) { + this._currentWebVTTTrack = track; + break; + } + } + } + + /** + * Set the font size on the PiP window using the current font size value from + * the "media.videocontrols.picture-in-picture.display-text-tracks.size" pref + */ + setTextTrackFontSize() { + const fontSize = Services.prefs.getStringPref( + TEXT_TRACK_FONT_SIZE, + "medium" + ); + const root = this.document.querySelector(":root"); + if (fontSize === "small") { + root.style.setProperty("--font-scale", "0.03"); + } else if (fontSize === "large") { + root.style.setProperty("--font-scale", "0.09"); + } else { + root.style.setProperty("--font-scale", "0.06"); + } + } + + /** + * Keeps an eye on the originating video's document. If it ever + * goes away, this will cause the Picture-in-Picture window for any + * of its content to go away as well. + */ + trackOriginatingVideo(originatingVideo) { + this.observerFunction = (subject, topic, data) => { + this.observe(subject, topic, data); + }; + Services.prefs.addObserver( + "media.videocontrols.picture-in-picture.display-text-tracks.enabled", + this.observerFunction + ); + + let originatingWindow = originatingVideo.ownerGlobal; + if (originatingWindow) { + originatingWindow.addEventListener("pagehide", this); + originatingVideo.addEventListener("play", this); + originatingVideo.addEventListener("pause", this); + originatingVideo.addEventListener("volumechange", this); + originatingVideo.addEventListener("resize", this); + originatingVideo.addEventListener("emptied", this); + originatingVideo.addEventListener("timeupdate", this); + + if (lazy.DISPLAY_TEXT_TRACKS_PREF) { + this.setupTextTracks(originatingVideo); + } + + let chromeEventHandler = originatingWindow.docShell.chromeEventHandler; + chromeEventHandler.addEventListener( + "MozDOMFullscreen:Request", + this, + true + ); + chromeEventHandler.addEventListener( + "MozStopPictureInPicture", + this, + true + ); + } + } + + setUpCaptionChangeListener(originatingVideo) { + if (this.videoWrapper) { + this.videoWrapper.setCaptionContainerObserver(originatingVideo, this); + } + } + + /** + * Stops tracking the originating video's document. This should + * happen once the Picture-in-Picture window goes away (or is about + * to go away), and we no longer care about hearing when the originating + * window's document unloads. + */ + untrackOriginatingVideo(originatingVideo) { + Services.prefs.removeObserver( + "media.videocontrols.picture-in-picture.display-text-tracks.enabled", + this.observerFunction + ); + + let originatingWindow = originatingVideo.ownerGlobal; + if (originatingWindow) { + originatingWindow.removeEventListener("pagehide", this); + originatingVideo.removeEventListener("play", this); + originatingVideo.removeEventListener("pause", this); + originatingVideo.removeEventListener("volumechange", this); + originatingVideo.removeEventListener("resize", this); + originatingVideo.removeEventListener("emptied", this); + originatingVideo.removeEventListener("timeupdate", this); + + if (lazy.DISPLAY_TEXT_TRACKS_PREF) { + this.removeTextTracks(originatingVideo); + } + + let chromeEventHandler = originatingWindow.docShell.chromeEventHandler; + chromeEventHandler.removeEventListener( + "MozDOMFullscreen:Request", + this, + true + ); + chromeEventHandler.removeEventListener( + "MozStopPictureInPicture", + this, + true + ); + } + } + + /** + * Runs in an instance of PictureInPictureChild for the + * player window's content, and not the originating video + * content. Sets up the player so that it clones the originating + * video. If anything goes wrong during set up, a message is + * sent to the parent to close the Picture-in-Picture window. + * + * @param videoRef {ContentDOMReference} + * A reference to the video element that a Picture-in-Picture window + * is being created for + * @return {Promise} + * @resolves {undefined} Once the player window has been set up + * properly, or a pre-existing Picture-in-Picture window has gone + * away due to an unexpected error. + */ + async setupPlayer(videoRef) { + const video = await lazy.ContentDOMReference.resolve(videoRef); + + this.weakVideo = Cu.getWeakReference(video); + let originatingVideo = this.getWeakVideo(); + if (!originatingVideo) { + // If the video element has gone away before we've had a chance to set up + // Picture-in-Picture for it, tell the parent to close the Picture-in-Picture + // window. + await this.closePictureInPicture({ reason: "setupFailure" }); + return; + } + + this.videoWrapper = applyWrapper(this, originatingVideo); + + let loadPromise = new Promise(resolve => { + this.contentWindow.addEventListener("load", resolve, { + once: true, + mozSystemGroup: true, + capture: true, + }); + }); + this.contentWindow.location.reload(); + await loadPromise; + + // We're committed to adding the video to this window now. Ensure we track + // the content window before we do so, so that the toggle actor can + // distinguish this new video we're creating from web-controlled ones. + this.weakPlayerContent = Cu.getWeakReference(this.contentWindow); + gPlayerContents.add(this.contentWindow); + + let doc = this.document; + let playerVideo = doc.createElement("video"); + playerVideo.id = "playervideo"; + let textTracks = doc.createElement("div"); + + doc.body.style.overflow = "hidden"; + doc.body.style.margin = "0"; + + // Force the player video to assume maximum height and width of the + // containing window + playerVideo.style.height = "100vh"; + playerVideo.style.width = "100vw"; + playerVideo.style.backgroundColor = "#000"; + + // Load text tracks container in the content process so that + // we can load text tracks without having to constantly + // access the parent process. + textTracks.id = "texttracks"; + // When starting pip, player controls are expected to appear. + textTracks.setAttribute("overlap-video-controls", true); + doc.body.appendChild(playerVideo); + doc.body.appendChild(textTracks); + // Load text tracks stylesheet + let textTracksStyleSheet = this.createTextTracksStyleSheet(); + doc.head.appendChild(textTracksStyleSheet); + + this.setTextTrackFontSize(); + + originatingVideo.cloneElementVisually(playerVideo); + + let shadowRoot = originatingVideo.openOrClosedShadowRoot; + if (originatingVideo.getTransformToViewport().a == -1) { + shadowRoot.firstChild.setAttribute("flipped", true); + playerVideo.style.transform = "scaleX(-1)"; + } + + this.onCueChange = this.onCueChange.bind(this); + this.trackOriginatingVideo(originatingVideo); + + // A request to open PIP implies that the user intends to be interacting + // with the page, even if they open PIP by some means outside of the page + // itself (e.g., the keyboard shortcut or the page action button). So we + // manually record that the document has been activated via user gesture + // to make sure the video can be played regardless of autoplay permissions. + originatingVideo.ownerDocument.notifyUserGestureActivation(); + + this.contentWindow.addEventListener( + "unload", + () => { + let video = this.getWeakVideo(); + if (video) { + this.untrackOriginatingVideo(video); + video.stopCloningElementVisually(); + } + this.weakVideo = null; + }, + { once: true } + ); + } + + play() { + let video = this.getWeakVideo(); + if (video && this.videoWrapper) { + this.videoWrapper.play(video); + } + } + + pause() { + let video = this.getWeakVideo(); + if (video && this.videoWrapper) { + this.videoWrapper.pause(video); + } + } + + mute() { + let video = this.getWeakVideo(); + if (video && this.videoWrapper) { + this.videoWrapper.setMuted(video, true); + } + } + + unmute() { + let video = this.getWeakVideo(); + if (video && this.videoWrapper) { + this.videoWrapper.setMuted(video, false); + } + } + + onCueChange(e) { + if (!lazy.DISPLAY_TEXT_TRACKS_PREF) { + this.updateWebVTTTextTracksDisplay(null); + } else { + const cues = this._currentWebVTTTrack.activeCues; + this.updateWebVTTTextTracksDisplay(cues); + } + } + + /** + * This checks if a given keybinding has been disabled for the specific site + * currently being viewed. + */ + isKeyDisabled(key) { + const video = this.getWeakVideo(); + if (!video) { + return false; + } + const { documentURI } = video.ownerDocument; + if (!documentURI) { + return true; + } + for (let [override, { disabledKeyboardControls }] of lazy.gSiteOverrides) { + if ( + disabledKeyboardControls !== undefined && + override.matches(documentURI) + ) { + if (disabledKeyboardControls === lazy.KEYBOARD_CONTROLS.ALL) { + return true; + } + return !!(disabledKeyboardControls & key); + } + } + return false; + } + + /** + * This reuses the keyHandler logic in the VideoControlsWidget + * https://searchfox.org/mozilla-central/rev/cfd1cc461f1efe0d66c2fdc17c024a203d5a2fd8/toolkit/content/widgets/videocontrols.js#1687-1810. + * There are future plans to eventually combine the two implementations. + */ + /* eslint-disable complexity */ + keyDown({ altKey, shiftKey, metaKey, ctrlKey, keyCode }) { + let video = this.getWeakVideo(); + if (!video) { + return; + } + + var keystroke = ""; + if (altKey) { + keystroke += "alt-"; + } + if (shiftKey) { + keystroke += "shift-"; + } + if (this.contentWindow.navigator.platform.startsWith("Mac")) { + if (metaKey) { + keystroke += "accel-"; + } + if (ctrlKey) { + keystroke += "control-"; + } + } else { + if (metaKey) { + keystroke += "meta-"; + } + if (ctrlKey) { + keystroke += "accel-"; + } + } + + switch (keyCode) { + case this.contentWindow.KeyEvent.DOM_VK_UP: + keystroke += "upArrow"; + break; + case this.contentWindow.KeyEvent.DOM_VK_DOWN: + keystroke += "downArrow"; + break; + case this.contentWindow.KeyEvent.DOM_VK_LEFT: + keystroke += "leftArrow"; + break; + case this.contentWindow.KeyEvent.DOM_VK_RIGHT: + keystroke += "rightArrow"; + break; + case this.contentWindow.KeyEvent.DOM_VK_HOME: + keystroke += "home"; + break; + case this.contentWindow.KeyEvent.DOM_VK_END: + keystroke += "end"; + break; + case this.contentWindow.KeyEvent.DOM_VK_SPACE: + keystroke += "space"; + break; + case this.contentWindow.KeyEvent.DOM_VK_W: + keystroke += "w"; + break; + } + + const isVideoStreaming = this.videoWrapper.isLive(video); + var oldval, newval; + + try { + switch (keystroke) { + case "space" /* Toggle Play / Pause */: + if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.PLAY_PAUSE)) { + return; + } + + if ( + this.videoWrapper.getPaused(video) || + this.videoWrapper.getEnded(video) + ) { + this.videoWrapper.play(video); + } else { + this.videoWrapper.pause(video); + } + + break; + case "accel-w" /* Close video */: + if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.CLOSE)) { + return; + } + this.pause(); + this.closePictureInPicture({ reason: "closePlayerShortcut" }); + break; + case "downArrow" /* Volume decrease */: + if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.VOLUME)) { + return; + } + oldval = this.videoWrapper.getVolume(video); + this.videoWrapper.setVolume(video, oldval < 0.1 ? 0 : oldval - 0.1); + this.videoWrapper.setMuted(video, false); + break; + case "upArrow" /* Volume increase */: + if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.VOLUME)) { + return; + } + oldval = this.videoWrapper.getVolume(video); + this.videoWrapper.setVolume(video, oldval > 0.9 ? 1 : oldval + 0.1); + this.videoWrapper.setMuted(video, false); + break; + case "accel-downArrow" /* Mute */: + if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.MUTE_UNMUTE)) { + return; + } + this.videoWrapper.setMuted(video, true); + break; + case "accel-upArrow" /* Unmute */: + if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.MUTE_UNMUTE)) { + return; + } + this.videoWrapper.setMuted(video, false); + break; + case "leftArrow": /* Seek back 5 seconds */ + case "accel-leftArrow" /* Seek back 10% */: + if ( + this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.SEEK) || + (isVideoStreaming && + this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.LIVE_SEEK)) + ) { + return; + } + + oldval = this.videoWrapper.getCurrentTime(video); + if (keystroke == "leftArrow") { + newval = oldval - SEEK_TIME_SECS; + } else { + newval = oldval - this.videoWrapper.getDuration(video) / 10; + } + this.videoWrapper.setCurrentTime(video, newval >= 0 ? newval : 0); + break; + case "rightArrow": /* Seek forward 5 seconds */ + case "accel-rightArrow" /* Seek forward 10% */: + if ( + this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.SEEK) || + (isVideoStreaming && + this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.LIVE_SEEK)) + ) { + return; + } + + oldval = this.videoWrapper.getCurrentTime(video); + var maxtime = this.videoWrapper.getDuration(video); + if (keystroke == "rightArrow") { + newval = oldval + SEEK_TIME_SECS; + } else { + newval = oldval + maxtime / 10; + } + let selectedTime = newval <= maxtime ? newval : maxtime; + this.videoWrapper.setCurrentTime(video, selectedTime); + break; + case "home" /* Seek to beginning */: + if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.SEEK)) { + return; + } + if (!isVideoStreaming) { + this.videoWrapper.setCurrentTime(video, 0); + } + break; + case "end" /* Seek to end */: + if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.SEEK)) { + return; + } + + let duration = this.videoWrapper.getDuration(video); + if ( + !isVideoStreaming && + this.videoWrapper.getCurrentTime(video) != duration + ) { + this.videoWrapper.setCurrentTime(video, duration); + } + break; + default: + } + } catch (e) { + /* ignore any exception from setting video.currentTime */ + } + } + + get isSubtitlesEnabled() { + return this.#subtitlesEnabled; + } + + set isSubtitlesEnabled(val) { + if (val) { + Services.telemetry.recordEvent( + "pictureinpicture", + "subtitles_shown", + "subtitles", + null, + { + webVTTSubtitles: (!!this.getWeakVideo().textTracks + ?.length).toString(), + } + ); + } else { + this.sendAsyncMessage("PictureInPicture:DisableSubtitlesButton"); + } + this.#subtitlesEnabled = val; + } +} + +/** + * The PictureInPictureChildVideoWrapper class handles providing a path to a script that + * defines a "site wrapper" for the original <video> (or other controls API provided + * by the site) to command it. + * + * This "site wrapper" provided to PictureInPictureChildVideoWrapper is a script file that + * defines a class called `PictureInPictureVideoWrapper` and exports it. These scripts can + * be found under "browser/extensions/pictureinpicture/video-wrappers" as part of the + * Picture-In-Picture addon. + * + * Site wrappers need to adhere to a specific interface to work properly with + * PictureInPictureChildVideoWrapper: + * + * - The "site wrapper" script must export a class called "PictureInPictureVideoWrapper" + * - Method names on a site wrapper class should match its caller's name + * (i.e: PictureInPictureChildVideoWrapper.play will only call `play` on a site-wrapper, if available) + */ +class PictureInPictureChildVideoWrapper { + #sandbox; + #siteWrapper; + #PictureInPictureChild; + + /** + * Create a wrapper for the original <video> + * + * @param {String|null} videoWrapperScriptPath + * Path to a wrapper script from the Picture-in-Picture addon. If a wrapper isn't + * provided to the class, then we fallback on a default implementation for + * commanding the original <video>. + * @param {HTMLVideoElement} video + * The original <video> we want to create a wrapper class for. + * @param {Object} pipChild + * Reference to PictureInPictureChild class calling this function. + */ + constructor(videoWrapperScriptPath, video, pipChild) { + this.#sandbox = videoWrapperScriptPath + ? this.#createSandbox(videoWrapperScriptPath, video) + : null; + this.#PictureInPictureChild = pipChild; + } + + /** + * Handles calling methods defined on the site wrapper class to perform video + * controls operations on the source video. If the method doesn't exist, + * or if an error is thrown while calling it, use a fallback implementation. + * + * @param {String} methodInfo.name + * The method name to call. + * @param {Array} methodInfo.args + * Arguments to pass to the site wrapper method being called. + * @param {Function} methodInfo.fallback + * A fallback function that's invoked when a method doesn't exist on the site + * wrapper class or an error is thrown while calling a method + * @param {Function} methodInfo.validateReturnVal + * Validates whether or not the return value of the wrapper method is correct. + * If this isn't provided or if it evaluates false for a return value, then + * return null. + * + * @returns The expected output of the wrapper function. + */ + #callWrapperMethod({ name, args = [], fallback = () => {}, validateRetVal }) { + try { + const wrappedMethod = this.#siteWrapper?.[name]; + if (typeof wrappedMethod === "function") { + let retVal = wrappedMethod.call(this.#siteWrapper, ...args); + + if (!validateRetVal) { + lazy.logConsole.error( + `No return value validator was provided for method ${name}(). Returning null.` + ); + return null; + } + + if (!validateRetVal(retVal)) { + lazy.logConsole.error( + `Calling method ${name}() returned an unexpected value: ${retVal}. Returning null.` + ); + return null; + } + + return retVal; + } + } catch (e) { + lazy.logConsole.error( + `There was an error while calling ${name}(): `, + e.message + ); + } + + return fallback(); + } + + /** + * Creates a sandbox with Xray vision to execute content code in an unprivileged + * context. This way, privileged code (PictureInPictureChild) can call into the + * sandbox to perform video controls operations on the originating video + * (content code) and still be protected from direct access by it. + * + * @param {String} videoWrapperScriptPath + * Path to a wrapper script from the Picture-in-Picture addon. + * @param {HTMLVideoElement} video + * The source video element whose window to create a sandbox for. + */ + #createSandbox(videoWrapperScriptPath, video) { + const addonPolicy = WebExtensionPolicy.getByID( + "pictureinpicture@mozilla.org" + ); + let wrapperScriptUrl = addonPolicy.getURL(videoWrapperScriptPath); + let originatingWin = video.ownerGlobal; + let originatingDoc = video.ownerDocument; + + let sandbox = Cu.Sandbox([originatingDoc.nodePrincipal], { + sandboxName: "Picture-in-Picture video wrapper sandbox", + sandboxPrototype: originatingWin, + sameZoneAs: originatingWin, + wantXrays: false, + }); + + try { + Services.scriptloader.loadSubScript(wrapperScriptUrl, sandbox); + } catch (e) { + Cu.nukeSandbox(sandbox); + lazy.logConsole.error( + "Error loading wrapper script for Picture-in-Picture", + e + ); + return null; + } + + // The prototype of the wrapper class instantiated from the sandbox with Xray + // vision is `Object` and not actually `PictureInPictureVideoWrapper`. But we + // need to be able to access methods defined on this class to perform site-specific + // video control operations otherwise we fallback to a default implementation. + // Because of this, we need to "waive Xray vision" by adding `.wrappedObject` to the + // end. + this.#siteWrapper = new sandbox.PictureInPictureVideoWrapper( + video + ).wrappedJSObject; + + return sandbox; + } + + #isBoolean(val) { + return typeof val === "boolean"; + } + + #isNumber(val) { + return typeof val === "number"; + } + + /** + * Destroys the sandbox for the site wrapper class + */ + destroy() { + if (this.#sandbox) { + Cu.nukeSandbox(this.#sandbox); + } + } + + /** + * Function to display the captions on the PiP window + * @param text The captions to be shown on the PiP window + */ + updatePiPTextTracks(text) { + if (!this.#PictureInPictureChild.isSubtitlesEnabled && text) { + this.#PictureInPictureChild.isSubtitlesEnabled = true; + this.#PictureInPictureChild.sendAsyncMessage( + "PictureInPicture:EnableSubtitlesButton" + ); + } + let pipWindowTracksContainer = + this.#PictureInPictureChild.document.getElementById("texttracks"); + pipWindowTracksContainer.textContent = text; + } + + /* Video methods to be used for video controls from the PiP window. */ + + /** + * OVERRIDABLE - calls the play() method defined in the site wrapper script. Runs a fallback implementation + * if the method does not exist or if an error is thrown while calling it. This method is meant to handle video + * behaviour when a video is played. + * @param {HTMLVideoElement} video + * The originating video source element + */ + play(video) { + return this.#callWrapperMethod({ + name: "play", + args: [video], + fallback: () => video.play(), + validateRetVal: retVal => retVal == null, + }); + } + + /** + * OVERRIDABLE - calls the pause() method defined in the site wrapper script. Runs a fallback implementation + * if the method does not exist or if an error is thrown while calling it. This method is meant to handle video + * behaviour when a video is paused. + * @param {HTMLVideoElement} video + * The originating video source element + */ + pause(video) { + return this.#callWrapperMethod({ + name: "pause", + args: [video], + fallback: () => video.pause(), + validateRetVal: retVal => retVal == null, + }); + } + + /** + * OVERRIDABLE - calls the getPaused() method defined in the site wrapper script. Runs a fallback implementation + * if the method does not exist or if an error is thrown while calling it. This method is meant to determine if + * a video is paused or not. + * @param {HTMLVideoElement} video + * The originating video source element + * @returns {Boolean} Boolean value true if paused, or false if video is still playing + */ + getPaused(video) { + return this.#callWrapperMethod({ + name: "getPaused", + args: [video], + fallback: () => video.paused, + validateRetVal: retVal => this.#isBoolean(retVal), + }); + } + + /** + * OVERRIDABLE - calls the getEnded() method defined in the site wrapper script. Runs a fallback implementation + * if the method does not exist or if an error is thrown while calling it. This method is meant to determine if + * video playback or streaming has stopped. + * @param {HTMLVideoElement} video + * The originating video source element + * @returns {Boolean} Boolean value true if the video has ended, or false if still playing + */ + getEnded(video) { + return this.#callWrapperMethod({ + name: "getEnded", + args: [video], + fallback: () => video.ended, + validateRetVal: retVal => this.#isBoolean(retVal), + }); + } + + /** + * OVERRIDABLE - calls the getDuration() method defined in the site wrapper script. Runs a fallback implementation + * if the method does not exist or if an error is thrown while calling it. This method is meant to get the current + * duration of a video in seconds. + * @param {HTMLVideoElement} video + * The originating video source element + * @returns {Number} Duration of the video in seconds + */ + getDuration(video) { + return this.#callWrapperMethod({ + name: "getDuration", + args: [video], + fallback: () => video.duration, + validateRetVal: retVal => this.#isNumber(retVal), + }); + } + + /** + * OVERRIDABLE - calls the getCurrentTime() method defined in the site wrapper script. Runs a fallback implementation + * if the method does not exist or if an error is thrown while calling it. This method is meant to get the current + * time of a video in seconds. + * @param {HTMLVideoElement} video + * The originating video source element + * @returns {Number} Current time of the video in seconds + */ + getCurrentTime(video) { + return this.#callWrapperMethod({ + name: "getCurrentTime", + args: [video], + fallback: () => video.currentTime, + validateRetVal: retVal => this.#isNumber(retVal), + }); + } + + /** + * OVERRIDABLE - calls the setCurrentTime() method defined in the site wrapper script. Runs a fallback implementation + * if the method does not exist or if an error is thrown while calling it. This method is meant to set the current + * time of a video. + * @param {HTMLVideoElement} video + * The originating video source element + * @param {Number} position + * The current playback time of the video + * @param {Boolean} wasPlaying + * True if the video was playing before seeking else false + */ + setCurrentTime(video, position, wasPlaying) { + return this.#callWrapperMethod({ + name: "setCurrentTime", + args: [video, position, wasPlaying], + fallback: () => { + video.currentTime = position; + }, + validateRetVal: retVal => retVal == null, + }); + } + + /** + * Return hours, minutes, and seconds from seconds + * @param {Number} aSeconds + * The time in seconds + * @returns {String} Timestamp string + **/ + timeFromSeconds(aSeconds) { + aSeconds = isNaN(aSeconds) ? 0 : Math.round(aSeconds); + let seconds = Math.floor(aSeconds % 60), + minutes = Math.floor((aSeconds / 60) % 60), + hours = Math.floor(aSeconds / 3600); + seconds = seconds < 10 ? "0" + seconds : seconds; + minutes = hours > 0 && minutes < 10 ? "0" + minutes : minutes; + return aSeconds < 3600 + ? `${minutes}:${seconds}` + : `${hours}:${minutes}:${seconds}`; + } + + /** + * Format a timestamp from current time and total duration, + * output as a string in the form '0:00 / 0:00' + * @param {Number} aCurrentTime + * The current time in seconds + * @param {Number} aDuration + * The total duration in seconds + * @returns {String} Formatted timestamp + **/ + formatTimestamp(aCurrentTime, aDuration) { + // We can't format numbers that can't be represented as decimal digits. + if (!Number.isFinite(aCurrentTime) || !Number.isFinite(aDuration)) { + return undefined; + } + + return `${this.timeFromSeconds(aCurrentTime)} / ${this.timeFromSeconds( + aDuration + )}`; + } + + /** + * OVERRIDABLE - calls the getVolume() method defined in the site wrapper script. Runs a fallback implementation + * if the method does not exist or if an error is thrown while calling it. This method is meant to get the volume + * value of a video. + * @param {HTMLVideoElement} video + * The originating video source element + * @returns {Number} Volume of the video between 0 (muted) and 1 (loudest) + */ + getVolume(video) { + return this.#callWrapperMethod({ + name: "getVolume", + args: [video], + fallback: () => video.volume, + validateRetVal: retVal => this.#isNumber(retVal), + }); + } + + /** + * OVERRIDABLE - calls the setVolume() method defined in the site wrapper script. Runs a fallback implementation + * if the method does not exist or if an error is thrown while calling it. This method is meant to set the volume + * value of a video. + * @param {HTMLVideoElement} video + * The originating video source element + * @param {Number} volume + * Value between 0 (muted) and 1 (loudest) + */ + setVolume(video, volume) { + return this.#callWrapperMethod({ + name: "setVolume", + args: [video, volume], + fallback: () => { + video.volume = volume; + }, + validateRetVal: retVal => retVal == null, + }); + } + + /** + * OVERRIDABLE - calls the isMuted() method defined in the site wrapper script. Runs a fallback implementation + * if the method does not exist or if an error is thrown while calling it. This method is meant to get the mute + * state a video. + * @param {HTMLVideoElement} video + * The originating video source element + * @param {Boolean} shouldMute + * Boolean value true to mute the video, or false to unmute the video + */ + isMuted(video) { + return this.#callWrapperMethod({ + name: "isMuted", + args: [video], + fallback: () => video.muted, + validateRetVal: retVal => this.#isBoolean(retVal), + }); + } + + /** + * OVERRIDABLE - calls the setMuted() method defined in the site wrapper script. Runs a fallback implementation + * if the method does not exist or if an error is thrown while calling it. This method is meant to mute or unmute + * a video. + * @param {HTMLVideoElement} video + * The originating video source element + * @param {Boolean} shouldMute + * Boolean value true to mute the video, or false to unmute the video + */ + setMuted(video, shouldMute) { + return this.#callWrapperMethod({ + name: "setMuted", + args: [video, shouldMute], + fallback: () => { + video.muted = shouldMute; + }, + validateRetVal: retVal => retVal == null, + }); + } + + /** + * OVERRIDABLE - calls the setCaptionContainerObserver() method defined in the site wrapper script. Runs a fallback implementation + * if the method does not exist or if an error is thrown while calling it. This method is meant to listen for any cue changes in a + * video's caption container and execute a callback function responsible for updating the pip window's text tracks container whenever + * a cue change is triggered {@see updatePiPTextTracks()}. + * @param {HTMLVideoElement} video + * The originating video source element + * @param {Function} callback + * The callback function to be executed when cue changes are detected + */ + setCaptionContainerObserver(video, callback) { + return this.#callWrapperMethod({ + name: "setCaptionContainerObserver", + args: [ + video, + text => { + this.updatePiPTextTracks(text); + }, + ], + fallback: () => {}, + validateRetVal: retVal => retVal == null, + }); + } + + /** + * OVERRIDABLE - calls the shouldHideToggle() method defined in the site wrapper script. Runs a fallback implementation + * if the method does not exist or if an error is thrown while calling it. This method is meant to determine if the pip toggle + * for a video should be hidden by the site wrapper. + * @param {HTMLVideoElement} video + * The originating video source element + * @returns {Boolean} Boolean value true if the pip toggle should be hidden by the site wrapper, or false if it should not + */ + shouldHideToggle(video) { + return this.#callWrapperMethod({ + name: "shouldHideToggle", + args: [video], + fallback: () => false, + validateRetVal: retVal => this.#isBoolean(retVal), + }); + } + + /** + * OVERRIDABLE - calls the isLive() method defined in the site wrapper script. Runs a fallback implementation + * if the method does not exist or if an error is thrown while calling it. This method is meant to get if the + * video is a live stream. + * @param {HTMLVideoElement} video + * The originating video source element + */ + isLive(video) { + return this.#callWrapperMethod({ + name: "isLive", + args: [video], + fallback: () => video.duration === Infinity, + validateRetVal: retVal => this.#isBoolean(retVal), + }); + } +} diff --git a/toolkit/actors/PopupBlockingChild.sys.mjs b/toolkit/actors/PopupBlockingChild.sys.mjs new file mode 100644 index 0000000000..053f9683c5 --- /dev/null +++ b/toolkit/actors/PopupBlockingChild.sys.mjs @@ -0,0 +1,147 @@ +/* -*- 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/. */ + +/* eslint no-unused-vars: ["error", {args: "none"}] */ + +// The maximum number of popup information we'll send to the parent. +const MAX_SENT_POPUPS = 15; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +export class PopupBlockingChild extends JSWindowActorChild { + constructor() { + super(); + this.weakDocStates = new WeakMap(); + } + + /** + * Returns the state for the current document referred to via + * this.document. If no such state exists, creates it, stores it + * and returns it. + */ + get docState() { + let state = this.weakDocStates.get(this.document); + if (!state) { + state = { + popupData: [], + }; + this.weakDocStates.set(this.document, state); + } + + return state; + } + + receiveMessage(msg) { + switch (msg.name) { + case "UnblockPopup": { + let i = msg.data.index; + let state = this.docState; + let popupData = state.popupData[i]; + if (popupData) { + let dwi = popupData.requestingWindow; + + // If we have a requesting window and the requesting document is + // still the current document, open the popup. + if (dwi && dwi.document == popupData.requestingDocument) { + dwi.open( + popupData.popupWindowURISpec, + popupData.popupWindowName, + popupData.popupWindowFeatures + ); + } + } + break; + } + + case "GetBlockedPopupList": { + let state = this.docState; + let length = Math.min(state.popupData.length, MAX_SENT_POPUPS); + + let result = []; + + for (let i = 0; i < length; ++i) { + let popup = state.popupData[i]; + + let popupWindowURISpec = popup.popupWindowURISpec; + + if (this.contentWindow.location.href == popupWindowURISpec) { + popupWindowURISpec = "<self>"; + } else { + // Limit 500 chars to be sent because the URI will be cropped + // by the UI anyway, and data: URIs can be significantly larger. + popupWindowURISpec = popupWindowURISpec.substring(0, 500); + } + + result.push({ + popupWindowURISpec, + }); + } + + return result; + } + } + + return null; + } + + handleEvent(event) { + switch (event.type) { + case "DOMPopupBlocked": + this.onPopupBlocked(event); + break; + case "pageshow": { + this.onPageShow(event); + break; + } + } + } + + onPopupBlocked(event) { + if (event.target != this.document) { + return; + } + + let state = this.docState; + + // Avoid spamming the parent process with too many blocked popups. + if (state.popupData.length >= PopupBlockingChild.maxReportedPopups) { + return; + } + + let popup = { + popupWindowURISpec: event.popupWindowURI + ? event.popupWindowURI.spec + : "about:blank", + popupWindowFeatures: event.popupWindowFeatures, + popupWindowName: event.popupWindowName, + requestingWindow: event.requestingWindow, + requestingDocument: event.requestingWindow.document, + }; + + state.popupData.push(popup); + this.updateBlockedPopups(true); + } + + onPageShow(event) { + if (event.target != this.document) { + return; + } + + this.updateBlockedPopups(false); + } + + updateBlockedPopups(shouldNotify) { + this.sendAsyncMessage("UpdateBlockedPopups", { + shouldNotify, + count: this.docState.popupData.length, + }); + } +} + +XPCOMUtils.defineLazyPreferenceGetter( + PopupBlockingChild, + "maxReportedPopups", + "privacy.popups.maxReported" +); diff --git a/toolkit/actors/PopupBlockingParent.sys.mjs b/toolkit/actors/PopupBlockingParent.sys.mjs new file mode 100644 index 0000000000..adb49a1ba2 --- /dev/null +++ b/toolkit/actors/PopupBlockingParent.sys.mjs @@ -0,0 +1,268 @@ +/* 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 class manages all popup blocking operations on a <xul:browser>, including + * notifying the UI about updates to the blocked popups, and allowing popups to + * be unblocked. + */ +export class PopupBlocker { + constructor(browser) { + this._browser = browser; + this._allBlockedPopupCounts = new WeakMap(); + this._shouldShowNotification = false; + } + + /** + * Returns whether or not there are new blocked popups for the associated + * <xul:browser> that the user might need to be notified about. + */ + get shouldShowNotification() { + return this._shouldShowNotification; + } + + /** + * Should be called by the UI when the user has been notified about blocked + * popups for the associated <xul:browser>. + */ + didShowNotification() { + this._shouldShowNotification = false; + } + + /** + * Synchronously returns the most recent count of blocked popups for + * the associated <xul:browser>. + * + * @return {Number} + * The total number of blocked popups for this <xul:browser>. + */ + getBlockedPopupCount() { + let totalBlockedPopups = 0; + + let contextsToVisit = [this._browser.browsingContext]; + while (contextsToVisit.length) { + let currentBC = contextsToVisit.pop(); + let windowGlobal = currentBC.currentWindowGlobal; + + if (!windowGlobal) { + continue; + } + + let popupCountForGlobal = + this._allBlockedPopupCounts.get(windowGlobal) || 0; + totalBlockedPopups += popupCountForGlobal; + contextsToVisit.push(...currentBC.children); + } + + return totalBlockedPopups; + } + + /** + * Asynchronously retrieve information about the popups that have + * been blocked for the associated <xul:browser>. This information + * can be used to unblock those popups. + * + * @return {Promise} + * @resolves {Array} + * When the blocked popup information has been gathered, + * resolves with an Array of Objects with the following properties: + * + * browsingContext {BrowsingContext} + * The BrowsingContext that the popup was blocked for. + * + * innerWindowId {Number} + * The inner window ID for the blocked popup. This is used to differentiate + * popups that were blocked from one page load to the next. + * + * popupWindowURISpec {String} + * A string representing part or all of the URI that tried to be opened in a + * popup. + */ + async getBlockedPopups() { + let contextsToVisit = [this._browser.browsingContext]; + let result = []; + while (contextsToVisit.length) { + let currentBC = contextsToVisit.pop(); + let windowGlobal = currentBC.currentWindowGlobal; + + if (!windowGlobal) { + continue; + } + + let popupCountForGlobal = + this._allBlockedPopupCounts.get(windowGlobal) || 0; + if (popupCountForGlobal) { + let actor = windowGlobal.getActor("PopupBlocking"); + let popups = await actor.sendQuery("GetBlockedPopupList"); + + for (let popup of popups) { + if (!popup.popupWindowURISpec) { + continue; + } + + result.push({ + browsingContext: currentBC, + innerWindowId: windowGlobal.innerWindowId, + popupWindowURISpec: popup.popupWindowURISpec, + }); + } + } + + contextsToVisit.push(...currentBC.children); + } + + return result; + } + + /** + * Unblocks a popup that had been blocked. The information passed should + * come from the list of blocked popups returned via getBlockedPopups(). + * + * Unblocking a popup causes that popup to open. + * + * @param browsingContext {BrowsingContext} + * The BrowsingContext that the popup was blocked for. + * + * @param innerWindowId {Number} + * The inner window ID for the blocked popup. This is used to differentiate popups + * that were blocked from one page load to the next. + * + * @param popupIndex {Number} + * The index of the entry in the Array returned by getBlockedPopups(). + */ + unblockPopup(browsingContext, innerWindowId, popupIndex) { + let popupFrame = browsingContext.top.embedderElement; + let popupBrowser = popupFrame.outerBrowser + ? popupFrame.outerBrowser + : popupFrame; + + if (this._browser != popupBrowser) { + throw new Error( + "Attempting to unblock popup in a BrowsingContext no longer hosted in this browser." + ); + } + + let windowGlobal = browsingContext.currentWindowGlobal; + + if (!windowGlobal || windowGlobal.innerWindowId != innerWindowId) { + // The inner window has moved on since the user clicked on + // the blocked popups dropdown, so we'll just exit silently. + return; + } + + let actor = browsingContext.currentWindowGlobal.getActor("PopupBlocking"); + actor.sendAsyncMessage("UnblockPopup", { index: popupIndex }); + } + + /** + * Goes through the most recent list of blocked popups for the associated + * <xul:browser> and unblocks all of them. Unblocking a popup causes the popup + * to open. + */ + async unblockAllPopups() { + let popups = await this.getBlockedPopups(); + for (let i = 0; i < popups.length; ++i) { + let popup = popups[i]; + this.unblockPopup(popup.browsingContext, popup.innerWindowId, i); + } + } + + /** + * Fires a DOMUpdateBlockedPopups chrome-only event so that the UI can + * update itself to represent the current state of popup blocking for + * the associated <xul:browser>. + */ + updateBlockedPopupsUI() { + let event = this._browser.ownerDocument.createEvent("Events"); + event.initEvent("DOMUpdateBlockedPopups", true, true); + this._browser.dispatchEvent(event); + } + + /** Private methods **/ + + /** + * Updates the current popup count for a particular BrowsingContext based + * on messages from the underlying process. + * + * This should only be called by a PopupBlockingParent instance. + * + * @param browsingContext {BrowsingContext} + * The BrowsingContext to update the internal blocked popup count for. + * + * @param blockedPopupData {Object} + * An Object representing information about how many popups are blocked + * for the BrowsingContext. The Object has the following properties: + * + * count {Number} + * The total number of blocked popups for the BrowsingContext. + * + * shouldNotify {Boolean} + * Whether or not the list of blocked popups has changed in such a way that + * the UI should be updated about it. + */ + _updateBlockedPopupEntries(browsingContext, blockedPopupData) { + let windowGlobal = browsingContext.currentWindowGlobal; + let { count, shouldNotify } = blockedPopupData; + + if (!this.shouldShowNotification && shouldNotify) { + this._shouldShowNotification = true; + } + + if (windowGlobal) { + this._allBlockedPopupCounts.set(windowGlobal, count); + } + + this.updateBlockedPopupsUI(); + } +} + +/** + * To keep things properly encapsulated, these should only be instantiated via + * the PopupBlocker class for a particular <xul:browser>. + * + * Instantiated for a WindowGlobalParent for a BrowsingContext in one of two cases: + * + * 1. One or more popups have been blocked for the underlying frame represented + * by the WindowGlobalParent. + * + * 2. Something in the parent process is querying a frame for information about + * any popups that may have been blocked inside of it. + */ +export class PopupBlockingParent extends JSWindowActorParent { + didDestroy() { + this.updatePopupCountForBrowser({ count: 0, shouldNotify: false }); + } + + receiveMessage(message) { + if (message.name == "UpdateBlockedPopups") { + this.updatePopupCountForBrowser({ + count: message.data.count, + shouldNotify: message.data.shouldNotify, + }); + } + } + + /** + * Updates the PopupBlocker for the <xul:browser> associated with this + * PopupBlockingParent with the most recent count of blocked popups. + * + * @param data {Object} + * An Object with the following properties: + * + * count {Number}: + * The number of blocked popups for the underlying document. + * + * shouldNotify {Boolean}: + * Whether or not the list of blocked popups has changed in such a way that + * the UI should be updated about it. + */ + updatePopupCountForBrowser(data) { + let browser = this.browsingContext.top.embedderElement; + if (!browser) { + return; + } + + browser.popupBlocker._updateBlockedPopupEntries(this.browsingContext, data); + } +} diff --git a/toolkit/actors/PrintingChild.sys.mjs b/toolkit/actors/PrintingChild.sys.mjs new file mode 100644 index 0000000000..4ebfaf9faf --- /dev/null +++ b/toolkit/actors/PrintingChild.sys.mjs @@ -0,0 +1,260 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", + ReaderMode: "resource://gre/modules/ReaderMode.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +let gPendingPreviewsMap = new Map(); + +export class PrintingChild extends JSWindowActorChild { + actorCreated() { + // When the print preview page is loaded, the actor will change, so update + // the state/progress listener to the new actor. + let listener = gPendingPreviewsMap.get(this.browsingContext.id); + if (listener) { + listener.actor = this; + } + this.contentWindow.addEventListener("scroll", this); + } + + didDestroy() { + this._scrollTask?.disarm(); + this.contentWindow?.removeEventListener("scroll", this); + } + + handleEvent(event) { + switch (event.type) { + case "PrintingError": { + let win = event.target.defaultView; + let wbp = win.getInterface(Ci.nsIWebBrowserPrint); + let nsresult = event.detail; + this.sendAsyncMessage("Printing:Error", { + isPrinting: wbp.doingPrint, + nsresult, + }); + break; + } + + case "scroll": + if (!this._scrollTask) { + this._scrollTask = new lazy.DeferredTask( + () => this.updateCurrentPage(), + 16, + 16 + ); + } + this._scrollTask.arm(); + break; + } + } + + receiveMessage(message) { + let data = message.data; + switch (message.name) { + case "Printing:Preview:Navigate": { + this.navigate(data.navType, data.pageNum); + break; + } + + case "Printing:Preview:ParseDocument": { + return this.parseDocument( + data.URL, + Services.wm.getOuterWindowWithId(data.windowID) + ); + } + } + + return undefined; + } + + async parseDocument(URL, contentWindow) { + // The document in 'contentWindow' will be simplified and the resulting nodes + // will be inserted into this.contentWindow. + let thisWindow = this.contentWindow; + + // By using ReaderMode primitives, we parse given document and place the + // resulting JS object into the DOM of current browser. + let article; + try { + article = await lazy.ReaderMode.parseDocument(contentWindow.document); + } catch (ex) { + console.error(ex); + } + + await new Promise(resolve => { + // We make use of a web progress listener in order to know when the content we inject + // into the DOM has finished rendering. If our layout engine is still painting, we + // will wait for MozAfterPaint event to be fired. + let actor = thisWindow.windowGlobalChild.getActor("Printing"); + let webProgressListener = { + onStateChange(webProgress, req, flags, status) { + if (flags & Ci.nsIWebProgressListener.STATE_STOP) { + webProgress.removeProgressListener(webProgressListener); + let domUtils = contentWindow.windowUtils; + // Here we tell the parent that we have parsed the document successfully + // using ReaderMode primitives and we are able to enter on preview mode. + if (domUtils.isMozAfterPaintPending) { + let onPaint = function () { + contentWindow.removeEventListener("MozAfterPaint", onPaint); + actor.sendAsyncMessage("Printing:Preview:ReaderModeReady"); + resolve(); + }; + contentWindow.addEventListener("MozAfterPaint", onPaint); + // This timer is needed for when display list invalidation doesn't invalidate. + lazy.setTimeout(() => { + contentWindow.removeEventListener("MozAfterPaint", onPaint); + actor.sendAsyncMessage("Printing:Preview:ReaderModeReady"); + resolve(); + }, 100); + } else { + actor.sendAsyncMessage("Printing:Preview:ReaderModeReady"); + resolve(); + } + } + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + "nsIObserver", + ]), + }; + + // Here we QI the docShell into a nsIWebProgress passing our web progress listener in. + let webProgress = thisWindow.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + webProgress.addProgressListener( + webProgressListener, + Ci.nsIWebProgress.NOTIFY_STATE_REQUEST + ); + + let document = thisWindow.document; + document.head.innerHTML = ""; + + // Set base URI of document. Print preview code will read this value to + // populate the URL field in print settings so that it doesn't show + // "about:blank" as its URI. + let headBaseElement = document.createElement("base"); + headBaseElement.setAttribute("href", URL); + document.head.appendChild(headBaseElement); + + // Create link element referencing aboutReader.css and append it to head + let headStyleElement = document.createElement("link"); + headStyleElement.setAttribute("rel", "stylesheet"); + headStyleElement.setAttribute( + "href", + "chrome://global/skin/aboutReader.css" + ); + headStyleElement.setAttribute("type", "text/css"); + document.head.appendChild(headStyleElement); + + // Create link element referencing simplifyMode.css and append it to head + headStyleElement = document.createElement("link"); + headStyleElement.setAttribute("rel", "stylesheet"); + headStyleElement.setAttribute( + "href", + "chrome://global/content/simplifyMode.css" + ); + headStyleElement.setAttribute("type", "text/css"); + document.head.appendChild(headStyleElement); + + document.body.innerHTML = ""; + + // Create container div (main element) and append it to body + let containerElement = document.createElement("div"); + containerElement.setAttribute("class", "container"); + document.body.appendChild(containerElement); + + // Reader Mode might return null if there's a failure when parsing the document. + // We'll render the error message for the Simplify Page document when that happens. + if (article) { + // Set title of document + document.title = article.title; + + // Create header div and append it to container + let headerElement = document.createElement("div"); + headerElement.setAttribute("class", "reader-header"); + headerElement.setAttribute("class", "header"); + containerElement.appendChild(headerElement); + + // Jam the article's title and byline into header div + let titleElement = document.createElement("h1"); + titleElement.setAttribute("class", "reader-title"); + titleElement.textContent = article.title; + headerElement.appendChild(titleElement); + + let bylineElement = document.createElement("div"); + bylineElement.setAttribute("class", "reader-credits credits"); + bylineElement.textContent = article.byline; + headerElement.appendChild(bylineElement); + + // Display header element + headerElement.style.display = "block"; + + // Create content div and append it to container + let contentElement = document.createElement("div"); + contentElement.setAttribute("class", "content"); + containerElement.appendChild(contentElement); + + // Jam the article's content into content div + let readerContent = document.createElement("div"); + readerContent.setAttribute("class", "moz-reader-content"); + contentElement.appendChild(readerContent); + + let articleUri = Services.io.newURI(article.url); + let parserUtils = Cc["@mozilla.org/parserutils;1"].getService( + Ci.nsIParserUtils + ); + let contentFragment = parserUtils.parseFragment( + article.content, + Ci.nsIParserUtils.SanitizerDropForms | + Ci.nsIParserUtils.SanitizerAllowStyle, + false, + articleUri, + readerContent + ); + + readerContent.appendChild(contentFragment); + + // Display reader content element + readerContent.style.display = "block"; + } else { + const l10n = new Localization(["toolkit/about/aboutReader.ftl"], true); + const errorMessage = l10n.formatValueSync("about-reader-load-error"); + + document.title = errorMessage; + + // Create reader message div and append it to body + let readerMessageElement = document.createElement("div"); + readerMessageElement.setAttribute("class", "reader-message"); + readerMessageElement.textContent = errorMessage; + containerElement.appendChild(readerMessageElement); + + // Display reader message element + readerMessageElement.style.display = "block"; + } + }); + } + + updateCurrentPage() { + let cv = this.docShell.contentViewer; + cv.QueryInterface(Ci.nsIWebBrowserPrint); + this.sendAsyncMessage("Printing:Preview:CurrentPage", { + currentPage: cv.printPreviewCurrentPageNumber, + }); + } + + navigate(navType, pageNum) { + let cv = this.docShell.contentViewer; + cv.QueryInterface(Ci.nsIWebBrowserPrint); + cv.printPreviewScrollToPage(navType, pageNum); + } +} diff --git a/toolkit/actors/PrintingParent.sys.mjs b/toolkit/actors/PrintingParent.sys.mjs new file mode 100644 index 0000000000..31b8c6bbc7 --- /dev/null +++ b/toolkit/actors/PrintingParent.sys.mjs @@ -0,0 +1,22 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +export class PrintingParent extends JSWindowActorParent { + receiveMessage(message) { + let browser = this.browsingContext.top.embedderElement; + + if (message.name == "Printing:Error") { + browser.ownerGlobal.PrintUtils._displayPrintingError( + message.data.nsresult, + message.data.isPrinting, + browser + ); + } else if (message.name == "Printing:Preview:CurrentPage") { + browser.setAttribute("current-page", message.data.currentPage); + } + + return undefined; + } +} diff --git a/toolkit/actors/PrintingSelectionChild.sys.mjs b/toolkit/actors/PrintingSelectionChild.sys.mjs new file mode 100644 index 0000000000..c8dfe39449 --- /dev/null +++ b/toolkit/actors/PrintingSelectionChild.sys.mjs @@ -0,0 +1,20 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +export class PrintingSelectionChild extends JSWindowActorChild { + receiveMessage(message) { + switch (message.name) { + case "PrintingSelection:HasSelection": + return this.hasSelection(); + } + + return undefined; + } + + hasSelection() { + let selection = this.browsingContext.associatedWindow?.getSelection(); + return selection && selection.type == "Range"; + } +} diff --git a/toolkit/actors/PurgeSessionHistoryChild.sys.mjs b/toolkit/actors/PurgeSessionHistoryChild.sys.mjs new file mode 100644 index 0000000000..32de7929a0 --- /dev/null +++ b/toolkit/actors/PurgeSessionHistoryChild.sys.mjs @@ -0,0 +1,35 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +export class PurgeSessionHistoryChild extends JSWindowActorChild { + receiveMessage(message) { + if (message.name != "Browser:PurgeSessionHistory") { + return; + } + let sessionHistory = this.docShell.QueryInterface( + Ci.nsIWebNavigation + ).sessionHistory; + if (!sessionHistory) { + return; + } + + // place the entry at current index at the end of the history list, so it won't get removed + if (sessionHistory.index < sessionHistory.count - 1) { + let legacy = sessionHistory.legacySHistory; + let indexEntry = legacy.getEntryAtIndex(sessionHistory.index); + indexEntry.QueryInterface(Ci.nsISHEntry); + legacy.addEntry(indexEntry, true); + } + + let purge = sessionHistory.count; + if (this.document.location.href != "about:blank") { + --purge; // Don't remove the page the user's staring at from shistory + } + + if (purge > 0) { + sessionHistory.legacySHistory.purgeHistory(purge); + } + } +} diff --git a/toolkit/actors/RemotePageChild.sys.mjs b/toolkit/actors/RemotePageChild.sys.mjs new file mode 100644 index 0000000000..48cd4d0df9 --- /dev/null +++ b/toolkit/actors/RemotePageChild.sys.mjs @@ -0,0 +1,219 @@ +/* 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/. */ + +/** + * RemotePageChild is a base class for an unprivileged internal page, typically + * an about: page. A specific implementation should subclass the RemotePageChild + * actor with a more specific actor for that page. Typically, the child is not + * needed, but the parent actor will respond to messages and provide results + * directly to the page. + */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + AsyncPrefs: "resource://gre/modules/AsyncPrefs.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + RemotePageAccessManager: + "resource://gre/modules/RemotePageAccessManager.sys.mjs", +}); + +export class RemotePageChild extends JSWindowActorChild { + actorCreated() { + this.listeners = new Map(); + this.exportBaseFunctions(); + } + + exportBaseFunctions() { + const exportableFunctions = [ + "RPMSendAsyncMessage", + "RPMSendQuery", + "RPMAddMessageListener", + "RPMRemoveMessageListener", + "RPMGetIntPref", + "RPMGetStringPref", + "RPMGetBoolPref", + "RPMSetBoolPref", + "RPMGetFormatURLPref", + "RPMIsWindowPrivate", + ]; + + this.exportFunctions(exportableFunctions); + } + + /** + * Exports a list of functions to be accessible by the privileged page. + * Subclasses may call this function to add functions that are specific + * to a page. When the page calls a function, a function with the same + * name is called within the child actor. + * + * Only functions that appear in the whitelist in the + * RemotePageAccessManager for that page will be exported. + * + * @param array of function names. + */ + exportFunctions(functions) { + let document = this.document; + let principal = document.nodePrincipal; + + // If there is no content principal, don't export any functions. + if (!principal) { + return; + } + + let window = this.contentWindow; + + for (let fnname of functions) { + let allowAccess = lazy.RemotePageAccessManager.checkAllowAccessToFeature( + principal, + fnname, + document + ); + + if (allowAccess) { + // Wrap each function in an access checking function. + function accessCheckedFn(...args) { + this.checkAllowAccess(fnname, args[0]); + return this[fnname](...args); + } + + Cu.exportFunction(accessCheckedFn.bind(this), window, { + defineAs: fnname, + }); + } + } + } + + handleEvent() { + // Do nothing. The DOMDocElementInserted event is just used to create + // the actor. + } + + receiveMessage(messagedata) { + let message = { + name: messagedata.name, + data: messagedata.data, + }; + + let listeners = this.listeners.get(message.name); + if (!listeners) { + return; + } + + let clonedMessage = Cu.cloneInto(message, this.contentWindow); + for (let listener of listeners.values()) { + try { + listener(clonedMessage); + } catch (e) { + console.error(e); + } + } + } + + wrapPromise(promise) { + return new this.contentWindow.Promise((resolve, reject) => + promise.then(resolve, reject) + ); + } + + /** + * Returns true if a feature cannot be accessed by the current page. + * Throws an exception if the feature may not be accessed. + + * @param aDocument child process document to call from + * @param aFeature to feature to check access to + * @param aValue value that must be included with that feature's whitelist + * @returns true if access is allowed or throws an exception otherwise + */ + checkAllowAccess(aFeature, aValue) { + let doc = this.document; + if (!lazy.RemotePageAccessManager.checkAllowAccess(doc, aFeature, aValue)) { + throw new Error( + "RemotePageAccessManager does not allow access to " + aFeature + ); + } + + return true; + } + + addPage(aUrl, aFunctionMap) { + lazy.RemotePageAccessManager.addPage(aUrl, aFunctionMap); + } + + // Implementation of functions that are exported into the page. + + RPMSendAsyncMessage(aName, aData = null) { + this.sendAsyncMessage(aName, aData); + } + + RPMSendQuery(aName, aData = null) { + return this.wrapPromise( + new Promise(resolve => { + this.sendQuery(aName, aData).then(result => { + resolve(Cu.cloneInto(result, this.contentWindow)); + }); + }) + ); + } + + /** + * Adds a listener for messages. Many callbacks can be registered for the + * same message if necessary. An attempt to register the same callback for the + * same message twice will be ignored. When called the callback is passed an + * object with these properties: + * name: The message name + * data: Any data sent with the message + */ + RPMAddMessageListener(aName, aCallback) { + if (!this.listeners.has(aName)) { + this.listeners.set(aName, new Set([aCallback])); + } else { + this.listeners.get(aName).add(aCallback); + } + } + + /** + * Removes a listener for messages. + */ + RPMRemoveMessageListener(aName, aCallback) { + if (!this.listeners.has(aName)) { + return; + } + + this.listeners.get(aName).delete(aCallback); + } + + RPMGetIntPref(aPref, defaultValue) { + // Only call with a default value if it's defined, to be able to throw + // errors for non-existent prefs. + if (defaultValue !== undefined) { + return Services.prefs.getIntPref(aPref, defaultValue); + } + return Services.prefs.getIntPref(aPref); + } + + RPMGetStringPref(aPref) { + return Services.prefs.getStringPref(aPref); + } + + RPMGetBoolPref(aPref, defaultValue) { + // Only call with a default value if it's defined, to be able to throw + // errors for non-existent prefs. + if (defaultValue !== undefined) { + return Services.prefs.getBoolPref(aPref, defaultValue); + } + return Services.prefs.getBoolPref(aPref); + } + + RPMSetBoolPref(aPref, aVal) { + return this.wrapPromise(lazy.AsyncPrefs.set(aPref, aVal)); + } + + RPMGetFormatURLPref(aFormatURL) { + return Services.urlFormatter.formatURLPref(aFormatURL); + } + + RPMIsWindowPrivate() { + return lazy.PrivateBrowsingUtils.isContentWindowPrivate(this.contentWindow); + } +} diff --git a/toolkit/actors/SelectChild.sys.mjs b/toolkit/actors/SelectChild.sys.mjs new file mode 100644 index 0000000000..add2024093 --- /dev/null +++ b/toolkit/actors/SelectChild.sys.mjs @@ -0,0 +1,470 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", + LayoutUtils: "resource://gre/modules/LayoutUtils.sys.mjs", +}); + +const kStateActive = 0x00000001; // ElementState::ACTIVE +const kStateHover = 0x00000004; // ElementState::HOVER + +// Duplicated in SelectParent.jsm +// Please keep these lists in sync. +const SUPPORTED_OPTION_OPTGROUP_PROPERTIES = [ + "direction", + "color", + "background-color", + "text-shadow", + "text-transform", + "font-family", + "font-weight", + "font-size", + "font-style", +]; + +const SUPPORTED_SELECT_PROPERTIES = [ + ...SUPPORTED_OPTION_OPTGROUP_PROPERTIES, + "scrollbar-width", + "scrollbar-color", +]; + +// A process global state for whether or not content thinks +// that a <select> dropdown is open or not. This is managed +// entirely within this module, and is read-only accessible +// via SelectContentHelper.open. +var gOpen = false; + +export var SelectContentHelper = function (aElement, aOptions, aActor) { + this.element = aElement; + this.initialSelection = aElement[aElement.selectedIndex] || null; + this.actor = aActor; + this.closedWithClickOn = false; + this.isOpenedViaTouch = aOptions.isOpenedViaTouch; + this._closeAfterBlur = true; + this._pseudoStylesSetup = false; + this._lockedDescendants = null; + this.init(); + this.showDropDown(); + this._updateTimer = new lazy.DeferredTask(this._update.bind(this), 0); +}; + +Object.defineProperty(SelectContentHelper, "open", { + get() { + return gOpen; + }, +}); + +SelectContentHelper.prototype = { + init() { + let win = this.element.ownerGlobal; + win.addEventListener("pagehide", this, { mozSystemGroup: true }); + this.element.addEventListener("blur", this, { mozSystemGroup: true }); + this.element.addEventListener("transitionend", this, { + mozSystemGroup: true, + }); + let MutationObserver = this.element.ownerGlobal.MutationObserver; + this.mut = new MutationObserver(mutations => { + // Something changed the <select> while it was open, so + // we'll poke a DeferredTask to update the parent sometime + // in the very near future. + this._updateTimer.arm(); + }); + this.mut.observe(this.element, { + childList: true, + subtree: true, + attributes: true, + }); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "disablePopupAutohide", + "ui.popup.disable_autohide", + false + ); + }, + + uninit() { + this.element.openInParentProcess = false; + let win = this.element.ownerGlobal; + win.removeEventListener("pagehide", this, { mozSystemGroup: true }); + this.element.removeEventListener("blur", this, { mozSystemGroup: true }); + this.element.removeEventListener("transitionend", this, { + mozSystemGroup: true, + }); + this.element = null; + this.actor = null; + this.mut.disconnect(); + this._updateTimer.disarm(); + this._updateTimer = null; + gOpen = false; + }, + + showDropDown() { + this.element.openInParentProcess = true; + this._setupPseudoClassStyles(); + let rect = this._getBoundingContentRect(); + let computedStyles = getComputedStyles(this.element); + let options = this._buildOptionList(); + let defaultStyles = this.element.ownerGlobal.getDefaultComputedStyle( + this.element + ); + this.actor.sendAsyncMessage("Forms:ShowDropDown", { + isOpenedViaTouch: this.isOpenedViaTouch, + options, + rect, + custom: !this.element.nodePrincipal.isSystemPrincipal, + selectedIndex: this.element.selectedIndex, + isDarkBackground: ChromeUtils.isDarkBackground(this.element), + style: supportedStyles(computedStyles, SUPPORTED_SELECT_PROPERTIES), + defaultStyle: supportedStyles(defaultStyles, SUPPORTED_SELECT_PROPERTIES), + }); + this._clearPseudoClassStyles(); + gOpen = true; + }, + + _setupPseudoClassStyles() { + if (this._pseudoStylesSetup) { + throw new Error("pseudo styles must not be set up yet"); + } + // Do all of the things that change style at once, before we read + // any styles. + this._pseudoStylesSetup = true; + InspectorUtils.addPseudoClassLock(this.element, ":focus"); + let lockedDescendants = (this._lockedDescendants = + this.element.querySelectorAll(":checked")); + for (let child of lockedDescendants) { + // Selected options have the :checked pseudo-class, which + // we want to disable before calculating the computed + // styles since the user agent styles alter the styling + // based on :checked. + InspectorUtils.addPseudoClassLock(child, ":checked", false); + } + }, + + _clearPseudoClassStyles() { + if (!this._pseudoStylesSetup) { + throw new Error("pseudo styles must be set up already"); + } + // Undo all of the things that change style at once, after we're + // done reading styles. + InspectorUtils.clearPseudoClassLocks(this.element); + let lockedDescendants = this._lockedDescendants; + for (let child of lockedDescendants) { + InspectorUtils.clearPseudoClassLocks(child); + } + this._lockedDescendants = null; + this._pseudoStylesSetup = false; + }, + + _getBoundingContentRect() { + return lazy.LayoutUtils.getElementBoundingScreenRect(this.element); + }, + + _buildOptionList() { + if (!this._pseudoStylesSetup) { + throw new Error("pseudo styles must be set up"); + } + let uniqueStyles = []; + let options = buildOptionListForChildren(this.element, uniqueStyles); + return { options, uniqueStyles }; + }, + + _update() { + // The <select> was updated while the dropdown was open. + // Let's send up a new list of options. + // Technically we might not need to set this pseudo-class + // during _update() since the element should organically + // have :focus, though it is here for belt-and-suspenders. + this._setupPseudoClassStyles(); + let computedStyles = getComputedStyles(this.element); + let defaultStyles = this.element.ownerGlobal.getDefaultComputedStyle( + this.element + ); + this.actor.sendAsyncMessage("Forms:UpdateDropDown", { + options: this._buildOptionList(), + custom: !this.element.nodePrincipal.isSystemPrincipal, + selectedIndex: this.element.selectedIndex, + isDarkBackground: ChromeUtils.isDarkBackground(this.element), + style: supportedStyles(computedStyles, SUPPORTED_SELECT_PROPERTIES), + defaultStyle: supportedStyles(defaultStyles, SUPPORTED_SELECT_PROPERTIES), + }); + this._clearPseudoClassStyles(); + }, + + dispatchMouseEvent(win, target, eventName) { + let mouseEvent = new win.MouseEvent(eventName, { + view: win, + bubbles: true, + cancelable: true, + composed: true, + }); + target.dispatchEvent(mouseEvent); + }, + + receiveMessage(message) { + switch (message.name) { + case "Forms:SelectDropDownItem": + this.element.selectedIndex = message.data.value; + this.closedWithClickOn = !message.data.closedWithEnter; + break; + + case "Forms:DismissedDropDown": { + if (!this.element) { + return; + } + + let win = this.element.ownerGlobal; + + // Running arbitrary script below (dispatching events for example) can + // close us, but we should still send events consistently. + let element = this.element; + + let selectedOption = element.item(element.selectedIndex); + + // For ordering of events, we're using non-e10s as our guide here, + // since the spec isn't exactly clear. In non-e10s: + // - If the user clicks on an element in the dropdown, we fire + // mousedown, mouseup, input, change, and click events. + // - If the user uses the keyboard to select an element in the + // dropdown, we only fire input and change events. + // - If the user pressed ESC key or clicks outside the dropdown, + // we fire nothing as the selected option is unchanged. + if (this.closedWithClickOn) { + this.dispatchMouseEvent(win, selectedOption, "mousedown"); + this.dispatchMouseEvent(win, selectedOption, "mouseup"); + } + + // Clear active document no matter user selects via keyboard or mouse + InspectorUtils.removeContentState( + element, + kStateActive, + /* aClearActiveDocument */ true + ); + + // Fire input and change events when selected option changes + if (this.initialSelection !== selectedOption) { + let inputEvent = new win.Event("input", { + bubbles: true, + composed: true, + }); + + let changeEvent = new win.Event("change", { + bubbles: true, + }); + + let handlingUserInput = win.windowUtils.setHandlingUserInput(true); + try { + element.dispatchEvent(inputEvent); + element.dispatchEvent(changeEvent); + } finally { + handlingUserInput.destruct(); + } + } + + // Fire click event + if (this.closedWithClickOn) { + this.dispatchMouseEvent(win, selectedOption, "click"); + } + + this.uninit(); + break; + } + + case "Forms:MouseOver": + InspectorUtils.setContentState(this.element, kStateHover); + break; + + case "Forms:MouseOut": + InspectorUtils.removeContentState(this.element, kStateHover); + break; + + case "Forms:MouseUp": + let win = this.element.ownerGlobal; + if (message.data.onAnchor) { + this.dispatchMouseEvent(win, this.element, "mouseup"); + } + InspectorUtils.removeContentState(this.element, kStateActive); + if (message.data.onAnchor) { + this.dispatchMouseEvent(win, this.element, "click"); + } + break; + + case "Forms:SearchFocused": + this._closeAfterBlur = false; + break; + + case "Forms:BlurDropDown-Pong": + if (!this._closeAfterBlur || !gOpen) { + return; + } + this.actor.sendAsyncMessage("Forms:HideDropDown", {}); + this.uninit(); + break; + } + }, + + handleEvent(event) { + switch (event.type) { + case "pagehide": + if (this.element.ownerDocument === event.target) { + this.actor.sendAsyncMessage("Forms:HideDropDown", {}); + this.uninit(); + } + break; + case "blur": { + if (this.element !== event.target || this.disablePopupAutohide) { + break; + } + this._closeAfterBlur = true; + // Send a ping-pong message to make sure that we wait for + // enough cycles to pass from the potential focusing of the + // search box to disable closing-after-blur. + this.actor.sendAsyncMessage("Forms:BlurDropDown-Ping", {}); + break; + } + case "mozhidedropdown": + if (this.element === event.target) { + this.actor.sendAsyncMessage("Forms:HideDropDown", {}); + this.uninit(); + } + break; + case "transitionend": + if ( + this.element === event.target && + SUPPORTED_SELECT_PROPERTIES.includes(event.propertyName) + ) { + this._updateTimer.arm(); + } + break; + } + }, +}; + +function getComputedStyles(element) { + return element.ownerGlobal.getComputedStyle(element); +} + +function supportedStyles(cs, supportedProps) { + let styles = {}; + for (let property of supportedProps) { + styles[property] = cs.getPropertyValue(property); + } + return styles; +} + +function supportedStylesEqual(styles, otherStyles) { + for (let property in styles) { + if (styles[property] !== otherStyles[property]) { + return false; + } + } + return true; +} + +function uniqueStylesIndex(cs, uniqueStyles) { + let styles = supportedStyles(cs, SUPPORTED_OPTION_OPTGROUP_PROPERTIES); + for (let i = uniqueStyles.length; i--; ) { + if (supportedStylesEqual(uniqueStyles[i], styles)) { + return i; + } + } + uniqueStyles.push(styles); + return uniqueStyles.length - 1; +} + +function buildOptionListForChildren(node, uniqueStyles) { + let result = []; + + for (let child of node.children) { + let className = ChromeUtils.getClassName(child); + let isOption = className == "HTMLOptionElement"; + let isOptGroup = className == "HTMLOptGroupElement"; + if (!isOption && !isOptGroup) { + continue; + } + if (child.hidden) { + continue; + } + + // The option code-path should match HTMLOptionElement::GetRenderedLabel. + let textContent = isOptGroup + ? child.getAttribute("label") + : child.label || child.text; + if (textContent == null) { + textContent = ""; + } + + let cs = getComputedStyles(child); + let info = { + index: child.index, + isOptGroup, + textContent, + disabled: child.disabled, + display: cs.display, + tooltip: child.title, + children: isOptGroup + ? buildOptionListForChildren(child, uniqueStyles) + : [], + // Most options have the same style. In order to reduce the size of the + // IPC message, coalesce them in uniqueStyles. + styleIndex: uniqueStylesIndex(cs, uniqueStyles), + }; + result.push(info); + } + return result; +} + +// Hold the instance of SelectContentHelper created +// when the dropdown list is opened. This variable helps +// re-route the received message from SelectChild to SelectContentHelper object. +let currentSelectContentHelper = new WeakMap(); + +export class SelectChild extends JSWindowActorChild { + handleEvent(event) { + if (SelectContentHelper.open) { + // The SelectContentHelper object handles captured + // events when the <select> popup is open. + let contentHelper = currentSelectContentHelper.get(this); + if (contentHelper) { + contentHelper.handleEvent(event); + } + return; + } + + switch (event.type) { + case "mozshowdropdown": { + let contentHelper = new SelectContentHelper( + event.target, + { isOpenedViaTouch: false }, + this + ); + currentSelectContentHelper.set(this, contentHelper); + break; + } + + case "mozshowdropdown-sourcetouch": { + let contentHelper = new SelectContentHelper( + event.target, + { isOpenedViaTouch: true }, + this + ); + currentSelectContentHelper.set(this, contentHelper); + break; + } + } + } + + receiveMessage(message) { + let contentHelper = currentSelectContentHelper.get(this); + if (contentHelper) { + contentHelper.receiveMessage(message); + } + } +} diff --git a/toolkit/actors/SelectParent.sys.mjs b/toolkit/actors/SelectParent.sys.mjs new file mode 100644 index 0000000000..7513940cab --- /dev/null +++ b/toolkit/actors/SelectParent.sys.mjs @@ -0,0 +1,805 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "DOM_FORMS_SELECTSEARCH", + "dom.forms.selectSearch", + false +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "CUSTOM_STYLING_ENABLED", + "dom.forms.select.customstyling", + false +); + +// Maximum number of rows to display in the select dropdown. +const MAX_ROWS = 20; + +// Minimum elements required to show select search +const SEARCH_MINIMUM_ELEMENTS = 40; + +// The properties that we should respect only when the item is not active. +const PROPERTIES_RESET_WHEN_ACTIVE = [ + "color", + "background-color", + "text-shadow", +]; + +// Duplicated in SelectChild.jsm +// Please keep these lists in sync. +const SUPPORTED_OPTION_OPTGROUP_PROPERTIES = [ + "direction", + "color", + "background-color", + "text-shadow", + "text-transform", + "font-family", + "font-weight", + "font-size", + "font-style", +]; + +const SUPPORTED_SELECT_PROPERTIES = [ + ...SUPPORTED_OPTION_OPTGROUP_PROPERTIES, + "scrollbar-width", + "scrollbar-color", +]; + +export var SelectParentHelper = { + /** + * `populate` takes the `menulist` element and a list of `items` and generates + * a popup list of options. + * + * If `CUSTOM_STYLING_ENABLED` is set to `true`, the function will also + * style the select and its popup trying to prevent the text + * and background to end up in the same color. + * + * All `ua*` variables represent the color values for the default colors + * for their respective form elements used by the user agent. + * The `select*` variables represent the color values defined for the + * particular <select> element. + * + * The `customoptionstyling` attribute controls the application of + * `-moz-appearance` on the elements and is disabled if the element is + * defining its own background-color. + * + * @param {Element} menulist + * @param {Array<Element>} items + * @param {Array<Object>} uniqueItemStyles + * @param {Number} selectedIndex + * @param {Number} zoom + * @param {Boolean} custom + * @param {Boolean} isDarkBackground + * @param {Object} uaStyle + * @param {Object} selectStyle + */ + populate( + menulist, + items, + uniqueItemStyles, + selectedIndex, + zoom, + custom, + isDarkBackground, + uaStyle, + selectStyle + ) { + let doc = menulist.ownerDocument; + + // Clear the current contents of the popup + let menupopup = menulist.menupopup; + menupopup.textContent = ""; + + let stylesheet = menulist.querySelector("#ContentSelectDropdownStylesheet"); + if (stylesheet) { + stylesheet.remove(); + } + + menupopup.setAttribute("style", ""); + menupopup.style.colorScheme = isDarkBackground ? "dark" : "light"; + menupopup.style.direction = selectStyle.direction; + + stylesheet = doc.createElementNS("http://www.w3.org/1999/xhtml", "style"); + stylesheet.setAttribute("id", "ContentSelectDropdownStylesheet"); + stylesheet.hidden = true; + stylesheet = menulist.appendChild(stylesheet); + + let sheet = stylesheet.sheet; + + if (!custom) { + selectStyle = uaStyle; + } + + if (selectStyle["background-color"] == "rgba(0, 0, 0, 0)") { + selectStyle["background-color"] = uaStyle["background-color"]; + } + + if (selectStyle.color == selectStyle["background-color"]) { + selectStyle.color = uaStyle.color; + } + + // We ensure that we set the content background if the color changes as + // well, to prevent contrast issues. + let selectBackgroundSet = + selectStyle["background-color"] != uaStyle["background-color"] || + selectStyle.color != uaStyle.color; + + if (custom) { + if (selectStyle["text-shadow"] != "none") { + sheet.insertRule( + `#ContentSelectDropdown > menupopup > :is(menuitem, menucaption)[_moz-menuactive="true"] { + text-shadow: none; + }`, + 0 + ); + } + + for (let property of SUPPORTED_SELECT_PROPERTIES) { + let shouldSkip = (function () { + if (property == "direction") { + // Handled elsewhere. + return true; + } + if (!selectStyle[property]) { + return true; + } + if (property == "background-color") { + // This also depends on whether "color" is set. + return !selectBackgroundSet; + } + return selectStyle[property] == uaStyle[property]; + })(); + + if (shouldSkip) { + continue; + } + let value = selectStyle[property]; + if (property == "scrollbar-width") { + // This needs to actually apply to the relevant scrollbox, because + // scrollbar-width doesn't inherit. + property = "--content-select-scrollbar-width"; + } + if (property == "color") { + property = "--panel-color"; + } + menupopup.style.setProperty(property, value); + } + // Some webpages set the <select> backgroundColor to transparent, + // but they don't intend to change the popup to transparent. + // So we remove the backgroundColor and turn it into an image instead. + if (selectBackgroundSet) { + // We intentionally use the parsed color to prevent color + // values like `url(..)` being injected into the + // `background-image` property. + let parsedColor = menupopup.style.backgroundColor; + menupopup.style.setProperty( + "--content-select-background-image", + `linear-gradient(${parsedColor}, ${parsedColor})` + ); + // Always drop the background color to avoid messing with the custom + // shadow on Windows 10 styling. + menupopup.style.backgroundColor = ""; + // If the background is set, we also make sure we set the color, to + // prevent contrast issues. + menupopup.style.setProperty("--panel-color", selectStyle.color); + + sheet.insertRule( + `#ContentSelectDropdown > menupopup > :is(menuitem, menucaption):not([_moz-menuactive="true"]) { + color: inherit; + }`, + 0 + ); + } + } + + for (let i = 0, len = uniqueItemStyles.length; i < len; ++i) { + sheet.insertRule( + `#ContentSelectDropdown .ContentSelectDropdown-item-${i} {}`, + 0 + ); + let style = uniqueItemStyles[i]; + let rule = sheet.cssRules[0].style; + rule.direction = style.direction; + rule.fontSize = zoom * parseFloat(style["font-size"], 10) + "px"; + + if (!custom) { + continue; + } + let optionBackgroundIsTransparent = + style["background-color"] == "rgba(0, 0, 0, 0)"; + let optionBackgroundSet = + !optionBackgroundIsTransparent || style.color != selectStyle.color; + + if (optionBackgroundIsTransparent && style.color != selectStyle.color) { + style["background-color"] = selectStyle["background-color"]; + } + + if (style.color == style["background-color"]) { + style.color = selectStyle.color; + } + + let inactiveRule = null; + for (const property of SUPPORTED_OPTION_OPTGROUP_PROPERTIES) { + let shouldSkip = (function () { + if (property == "direction" || property == "font-size") { + // Handled elsewhere. + return true; + } + if (!style[property]) { + return true; + } + if (property == "background-color" || property == "color") { + // This also depends on whether "color" is set. + return !optionBackgroundSet; + } + return style[property] == selectStyle[property]; + })(); + if (shouldSkip) { + continue; + } + if (PROPERTIES_RESET_WHEN_ACTIVE.includes(property)) { + if (!inactiveRule) { + sheet.insertRule( + `#ContentSelectDropdown .ContentSelectDropdown-item-${i}:not([_moz-menuactive="true"]) {}`, + 0 + ); + inactiveRule = sheet.cssRules[0].style; + } + inactiveRule[property] = style[property]; + } else { + rule[property] = style[property]; + } + } + style.customStyling = selectBackgroundSet || optionBackgroundSet; + } + + // We only set the `customoptionstyling` if the background has been + // manually set. This prevents the overlap between moz-appearance and + // background-color. `color` and `text-shadow` do not interfere with it. + if (custom && selectBackgroundSet) { + menulist.menupopup.setAttribute("customoptionstyling", "true"); + } else { + menulist.menupopup.removeAttribute("customoptionstyling"); + } + + this._currentZoom = zoom; + this._currentMenulist = menulist; + this.populateChildren(menulist, items, uniqueItemStyles, selectedIndex); + }, + + open(browser, menulist, rect, isOpenedViaTouch, selectParentActor) { + this._actor = selectParentActor; + menulist.hidden = false; + this._currentBrowser = browser; + this._closedWithEnter = false; + this._selectRect = rect; + this._registerListeners(menulist.menupopup); + + // Set the maximum height to show exactly MAX_ROWS items. + let menupopup = menulist.menupopup; + let firstItem = menupopup.firstElementChild; + while (firstItem && firstItem.hidden) { + firstItem = firstItem.nextElementSibling; + } + + let win = menulist.ownerGlobal; + if (firstItem) { + let itemHeight = firstItem.getBoundingClientRect().height; + + // Include the padding and border on the popup. + let cs = win.getComputedStyle(menupopup); + let bpHeight = + parseFloat(cs.borderTopWidth) + + parseFloat(cs.borderBottomWidth) + + parseFloat(cs.paddingTop) + + parseFloat(cs.paddingBottom); + menupopup.style.maxHeight = itemHeight * MAX_ROWS + bpHeight + "px"; + } + + menupopup.classList.toggle("isOpenedViaTouch", isOpenedViaTouch); + + if (browser) { + browser.constrainPopup(menupopup); + } else { + menupopup.setConstraintRect(new win.DOMRect(0, 0, 0, 0)); + } + menupopup.openPopupAtScreenRect( + AppConstants.platform == "macosx" ? "selection" : "after_start", + rect.left, + rect.top, + rect.width, + rect.height, + false, + false + ); + }, + + hide(menulist, browser) { + if (this._currentBrowser == browser) { + menulist.menupopup.hidePopup(); + } + }, + + handleEvent(event) { + switch (event.type) { + case "mouseup": + function inRect(rect, x, y) { + return ( + x >= rect.left && + x <= rect.left + rect.width && + y >= rect.top && + y <= rect.top + rect.height + ); + } + + let x = event.screenX, + y = event.screenY; + let onAnchor = + !inRect(this._currentMenulist.menupopup.getOuterScreenRect(), x, y) && + inRect(this._selectRect, x, y) && + this._currentMenulist.menupopup.state == "open"; + this._actor.sendAsyncMessage("Forms:MouseUp", { onAnchor }); + break; + + case "mouseover": + if ( + !event.relatedTarget || + !this._currentMenulist.contains(event.relatedTarget) + ) { + this._actor.sendAsyncMessage("Forms:MouseOver", {}); + } + break; + + case "mouseout": + if ( + !event.relatedTarget || + !this._currentMenulist.contains(event.relatedTarget) + ) { + this._actor.sendAsyncMessage("Forms:MouseOut", {}); + } + break; + + case "keydown": + if (event.keyCode == event.DOM_VK_RETURN) { + this._closedWithEnter = true; + } + break; + + case "command": + if (event.target.hasAttribute("value")) { + this._actor.sendAsyncMessage("Forms:SelectDropDownItem", { + value: event.target.value, + closedWithEnter: this._closedWithEnter, + }); + } + break; + + case "fullscreen": + case "FullscreenWarningOnScreen": + if (this._currentMenulist) { + this._currentMenulist.menupopup.hidePopup(); + } + break; + + case "popuphidden": + this._actor.sendAsyncMessage("Forms:DismissedDropDown", {}); + let popup = event.target; + this._unregisterListeners(popup); + popup.parentNode.hidden = true; + this._currentBrowser = null; + this._currentMenulist = null; + this._selectRect = null; + this._currentZoom = 1; + this._actor = null; + break; + } + }, + + receiveMessage(browser, msg) { + // Sanity check - we'd better know what the currently opened menulist is, + // and what browser it belongs to... + if (!this._currentMenulist || this._currentBrowser != browser) { + return; + } + + if (msg.name == "Forms:UpdateDropDown") { + let scrollBox = this._currentMenulist.menupopup.scrollBox.scrollbox; + let scrollTop = scrollBox.scrollTop; + + let options = msg.data.options; + let selectedIndex = msg.data.selectedIndex; + this.populate( + this._currentMenulist, + options.options, + options.uniqueStyles, + selectedIndex, + this._currentZoom, + msg.data.custom && lazy.CUSTOM_STYLING_ENABLED, + msg.data.isDarkBackground, + msg.data.defaultStyle, + msg.data.style + ); + + // Restore scroll position to what it was prior to the update. + scrollBox.scrollTop = scrollTop; + } else if (msg.name == "Forms:BlurDropDown-Ping") { + this._actor.sendAsyncMessage("Forms:BlurDropDown-Pong", {}); + } + }, + + _registerListeners(popup) { + popup.addEventListener("command", this); + popup.addEventListener("popuphidden", this); + popup.addEventListener("mouseover", this); + popup.addEventListener("mouseout", this); + popup.ownerGlobal.addEventListener("mouseup", this, true); + popup.ownerGlobal.addEventListener("keydown", this, true); + popup.ownerGlobal.addEventListener("fullscreen", this, true); + popup.ownerGlobal.addEventListener("FullscreenWarningOnScreen", this, true); + }, + + _unregisterListeners(popup) { + popup.removeEventListener("command", this); + popup.removeEventListener("popuphidden", this); + popup.removeEventListener("mouseover", this); + popup.removeEventListener("mouseout", this); + popup.ownerGlobal.removeEventListener("mouseup", this, true); + popup.ownerGlobal.removeEventListener("keydown", this, true); + popup.ownerGlobal.removeEventListener("fullscreen", this, true); + popup.ownerGlobal.removeEventListener( + "FullscreenWarningOnScreen", + this, + true + ); + }, + + /** + * `populateChildren` creates all <menuitem> elements for the popup menu + * based on the list of <option> elements from the <select> element. + * + * It attempts to intelligently add per-item CSS rules if the single + * item values differ from the parent menu values and attempting to avoid + * ending up with the same color of text and background. + * + * @param {Element} menulist + * @param {Array<Element>} options + * @param {Array<Object>} uniqueOptionStyles + * @param {Number} selectedIndex + * @param {Element} parentElement + * @param {Boolean} isGroupDisabled + * @param {Boolean} addSearch + * @param {Number} nthChildIndex + * @returns {Number} + */ + populateChildren( + menulist, + options, + uniqueOptionStyles, + selectedIndex, + parentElement = null, + isGroupDisabled = false, + addSearch = true, + nthChildIndex = 1 + ) { + let element = menulist.menupopup; + + let ariaOwns = ""; + for (let option of options) { + let isOptGroup = option.isOptGroup; + let item = element.ownerDocument.createXULElement( + isOptGroup ? "menucaption" : "menuitem" + ); + if (isOptGroup) { + item.setAttribute("role", "group"); + } + item.setAttribute("label", option.textContent); + item.className = `ContentSelectDropdown-item-${option.styleIndex}`; + item.hidden = + option.display == "none" || (parentElement && parentElement.hidden); + // Keep track of which options are hidden by page content, so we can avoid + // showing them on search input. + item.hiddenByContent = item.hidden; + item.setAttribute("tooltiptext", option.tooltip); + + if (uniqueOptionStyles[option.styleIndex].customStyling) { + item.setAttribute("customoptionstyling", "true"); + } else { + item.removeAttribute("customoptionstyling"); + } + + if (parentElement) { + // In the menupopup, the optgroup is a sibling of its contained options. + // For accessibility, we want to preserve the hierarchy such that the + // options are inside the optgroup. We do this using aria-owns on the + // parent. + item.id = "ContentSelectDropdownOption" + nthChildIndex; + item.setAttribute("aria-level", "2"); + ariaOwns += item.id + " "; + } + + element.appendChild(item); + nthChildIndex++; + + // A disabled optgroup disables all of its child options. + let isDisabled = isGroupDisabled || option.disabled; + if (isDisabled) { + item.setAttribute("disabled", "true"); + } + + if (isOptGroup) { + nthChildIndex = this.populateChildren( + menulist, + option.children, + uniqueOptionStyles, + selectedIndex, + item, + isDisabled, + false, + nthChildIndex + ); + } else { + if (option.index == selectedIndex) { + // We expect the parent element of the popup to be a <xul:menulist> that + // has the popuponly attribute set to "true". This is necessary in order + // for a <xul:menupopup> to act like a proper <html:select> dropdown, as + // the <xul:menulist> does things like remember state and set the + // _moz-menuactive attribute on the selected <xul:menuitem>. + menulist.selectedItem = item; + + // It's hack time. In the event that we've re-populated the menulist due + // to a mutation in the <select> in content, that means that the -moz_activemenu + // may have been removed from the selected item. Since that's normally only + // set for the initially selected on popupshowing for the menulist, and we + // don't want to close and re-open the popup, we manually set it here. + menulist.activeChild = item; + } + + item.setAttribute("value", option.index); + + if (parentElement) { + item.classList.add("contentSelectDropdown-ingroup"); + } + } + } + + if (parentElement && ariaOwns) { + parentElement.setAttribute("aria-owns", ariaOwns); + } + + // Check if search pref is enabled, if this is the first time iterating through + // the dropdown, and if the list is long enough for a search element to be added. + if ( + lazy.DOM_FORMS_SELECTSEARCH && + addSearch && + element.childElementCount > SEARCH_MINIMUM_ELEMENTS + ) { + // Add a search text field as the first element of the dropdown + let searchbox = element.ownerDocument.createXULElement("search-textbox"); + searchbox.className = "contentSelectDropdown-searchbox"; + searchbox.addEventListener("input", this.onSearchInput); + searchbox.addEventListener("focus", this.onSearchFocus.bind(this)); + searchbox.addEventListener("blur", this.onSearchBlur); + searchbox.addEventListener("command", this.onSearchInput); + + // Handle special keys for exiting search + searchbox.addEventListener( + "keydown", + event => { + this.onSearchKeydown(event, menulist); + }, + true + ); + + element.insertBefore(searchbox, element.children[0]); + } + + return nthChildIndex; + }, + + onSearchKeydown(event, menulist) { + if (event.defaultPrevented) { + return; + } + + let searchbox = event.currentTarget; + switch (event.key) { + case "Escape": + searchbox.parentElement.hidePopup(); + break; + case "ArrowDown": + case "Enter": + case "Tab": + searchbox.blur(); + if ( + searchbox.nextElementSibling.localName == "menuitem" && + !searchbox.nextElementSibling.hidden + ) { + menulist.activeChild = searchbox.nextElementSibling; + } else { + let currentOption = searchbox.nextElementSibling; + while ( + currentOption && + (currentOption.localName != "menuitem" || currentOption.hidden) + ) { + currentOption = currentOption.nextElementSibling; + } + if (currentOption) { + menulist.activeChild = currentOption; + } else { + searchbox.focus(); + } + } + break; + default: + return; + } + event.preventDefault(); + }, + + onSearchInput(event) { + let searchObj = event.currentTarget; + + // Get input from search field, set to all lower case for comparison + let input = searchObj.value.toLowerCase(); + // Get all items in dropdown (could be options or optgroups) + let menupopup = searchObj.parentElement; + let menuItems = menupopup.querySelectorAll("menuitem, menucaption"); + + // Flag used to detect any group headers with no visible options. + // These group headers should be hidden. + let allHidden = true; + // Keep a reference to the previous group header (menucaption) to go back + // and set to hidden if all options within are hidden. + let prevCaption = null; + + for (let currentItem of menuItems) { + // Make sure we don't show any options that were hidden by page content + if (!currentItem.hiddenByContent) { + // Get label and tooltip (title) from option and change to + // lower case for comparison + let itemLabel = currentItem.getAttribute("label").toLowerCase(); + let itemTooltip = currentItem.getAttribute("title").toLowerCase(); + + // If search input is empty, all options should be shown + if (!input) { + currentItem.hidden = false; + } else if (currentItem.localName == "menucaption") { + if (prevCaption != null) { + prevCaption.hidden = allHidden; + } + prevCaption = currentItem; + allHidden = true; + } else { + if ( + !currentItem.classList.contains("contentSelectDropdown-ingroup") && + currentItem.previousElementSibling.classList.contains( + "contentSelectDropdown-ingroup" + ) + ) { + if (prevCaption != null) { + prevCaption.hidden = allHidden; + } + prevCaption = null; + allHidden = true; + } + if (itemLabel.includes(input) || itemTooltip.includes(input)) { + currentItem.hidden = false; + allHidden = false; + } else { + currentItem.hidden = true; + } + } + if (prevCaption != null) { + prevCaption.hidden = allHidden; + } + } + } + }, + + onSearchFocus(event) { + let menupopup = event.target.closest("menupopup"); + menupopup.parentElement.activeChild = null; + menupopup.setAttribute("ignorekeys", "true"); + this._actor.sendAsyncMessage("Forms:SearchFocused", {}); + }, + + onSearchBlur(event) { + let menupopup = event.target.closest("menupopup"); + menupopup.setAttribute( + "ignorekeys", + AppConstants.platform == "win" ? "shortcuts" : "false" + ); + }, +}; + +export class SelectParent extends JSWindowActorParent { + get relevantBrowser() { + return this.browsingContext.top.embedderElement; + } + + get _document() { + return this.browsingContext.topChromeWindow.document; + } + + get _menulist() { + return this._document.getElementById("ContentSelectDropdown"); + } + + _createMenulist() { + let document = this._document; + let menulist = document.createXULElement("menulist"); + menulist.setAttribute("id", "ContentSelectDropdown"); + menulist.setAttribute("popuponly", "true"); + menulist.setAttribute("hidden", "true"); + + let popup = menulist.appendChild(document.createXULElement("menupopup")); + popup.setAttribute("id", "ContentSelectDropdownPopup"); + popup.setAttribute("activateontab", "true"); + popup.setAttribute("position", "after_start"); + popup.setAttribute("level", "parent"); + if (AppConstants.platform == "win") { + popup.setAttribute("consumeoutsideclicks", "false"); + popup.setAttribute("ignorekeys", "shortcuts"); + } + + let container = + document.getElementById("mainPopupSet") || + document.querySelector("popupset") || + document.documentElement.appendChild( + document.createXULElement("popupset") + ); + + container.appendChild(menulist); + return menulist; + } + + receiveMessage(message) { + switch (message.name) { + case "Forms:ShowDropDown": { + let menulist = this._menulist || this._createMenulist(); + + let data = message.data; + + SelectParentHelper.populate( + menulist, + data.options.options, + data.options.uniqueStyles, + data.selectedIndex, + // We only want to apply the full zoom. The text zoom is already + // applied in the font-size. + this.browsingContext.fullZoom, + data.custom && lazy.CUSTOM_STYLING_ENABLED, + data.isDarkBackground, + data.defaultStyle, + data.style + ); + SelectParentHelper.open( + this.relevantBrowser, + menulist, + data.rect, + data.isOpenedViaTouch, + this + ); + break; + } + + case "Forms:HideDropDown": { + SelectParentHelper.hide(this._menulist, this.relevantBrowser); + break; + } + + default: + SelectParentHelper.receiveMessage(this.relevantBrowser, message); + } + } +} diff --git a/toolkit/actors/TestProcessActorChild.jsm b/toolkit/actors/TestProcessActorChild.jsm new file mode 100644 index 0000000000..20b7382624 --- /dev/null +++ b/toolkit/actors/TestProcessActorChild.jsm @@ -0,0 +1,59 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["TestProcessActorChild"]; + +class TestProcessActorChild extends JSProcessActorChild { + constructor() { + super(); + this.sawActorCreated = false; + } + + actorCreated() { + this.sawActorCreated = true; + } + + receiveMessage(aMessage) { + switch (aMessage.name) { + case "toChild": + aMessage.data.toChild = true; + this.sendAsyncMessage("toParent", aMessage.data); + break; + case "asyncAdd": + let { a, b } = aMessage.data; + return new Promise(resolve => { + resolve({ result: a + b }); + }); + case "error": + return Promise.reject(new SyntaxError(aMessage.data.message)); + case "exception": + return Promise.reject( + Components.Exception(aMessage.data.message, aMessage.data.result) + ); + case "done": + this.done(aMessage.data); + break; + } + + return undefined; + } + + observe(subject, topic, data) { + this.lastObserved = { subject, topic, data }; + } + + show() { + return "TestProcessActorChild"; + } + + didDestroy() { + Services.obs.notifyObservers( + this, + "test-js-content-actor-diddestroy", + true + ); + } +} diff --git a/toolkit/actors/TestProcessActorChild.sys.mjs b/toolkit/actors/TestProcessActorChild.sys.mjs new file mode 100644 index 0000000000..b9631ff9d9 --- /dev/null +++ b/toolkit/actors/TestProcessActorChild.sys.mjs @@ -0,0 +1,56 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +export class TestProcessActorChild extends JSProcessActorChild { + constructor() { + super(); + this.sawActorCreated = false; + } + + actorCreated() { + this.sawActorCreated = true; + } + + receiveMessage(aMessage) { + switch (aMessage.name) { + case "toChild": + aMessage.data.toChild = true; + this.sendAsyncMessage("toParent", aMessage.data); + break; + case "asyncAdd": + let { a, b } = aMessage.data; + return new Promise(resolve => { + resolve({ result: a + b }); + }); + case "error": + return Promise.reject(new SyntaxError(aMessage.data.message)); + case "exception": + return Promise.reject( + Components.Exception(aMessage.data.message, aMessage.data.result) + ); + case "done": + this.done(aMessage.data); + break; + } + + return undefined; + } + + observe(subject, topic, data) { + this.lastObserved = { subject, topic, data }; + } + + show() { + return "TestProcessActorChild"; + } + + didDestroy() { + Services.obs.notifyObservers( + this, + "test-js-content-actor-diddestroy", + true + ); + } +} diff --git a/toolkit/actors/TestProcessActorParent.jsm b/toolkit/actors/TestProcessActorParent.jsm new file mode 100644 index 0000000000..dfa1fc1df0 --- /dev/null +++ b/toolkit/actors/TestProcessActorParent.jsm @@ -0,0 +1,41 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["TestProcessActorParent"]; + +class TestProcessActorParent extends JSProcessActorParent { + constructor() { + super(); + this.wrappedJSObject = this; + this.sawActorCreated = false; + } + + actorCreated() { + this.sawActorCreated = true; + } + + receiveMessage(aMessage) { + switch (aMessage.name) { + case "init": + aMessage.data.initial = true; + this.sendAsyncMessage("toChild", aMessage.data); + break; + case "toParent": + aMessage.data.toParent = true; + this.sendAsyncMessage("done", aMessage.data); + break; + case "asyncMul": + let { a, b } = aMessage.data; + return { result: a * b }; + } + + return undefined; + } + + show() { + return "TestProcessActorParent"; + } +} diff --git a/toolkit/actors/TestProcessActorParent.sys.mjs b/toolkit/actors/TestProcessActorParent.sys.mjs new file mode 100644 index 0000000000..c86bd8ad84 --- /dev/null +++ b/toolkit/actors/TestProcessActorParent.sys.mjs @@ -0,0 +1,38 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +export class TestProcessActorParent extends JSProcessActorParent { + constructor() { + super(); + this.wrappedJSObject = this; + this.sawActorCreated = false; + } + + actorCreated() { + this.sawActorCreated = true; + } + + receiveMessage(aMessage) { + switch (aMessage.name) { + case "init": + aMessage.data.initial = true; + this.sendAsyncMessage("toChild", aMessage.data); + break; + case "toParent": + aMessage.data.toParent = true; + this.sendAsyncMessage("done", aMessage.data); + break; + case "asyncMul": + let { a, b } = aMessage.data; + return { result: a * b }; + } + + return undefined; + } + + show() { + return "TestProcessActorParent"; + } +} diff --git a/toolkit/actors/TestWindowChild.jsm b/toolkit/actors/TestWindowChild.jsm new file mode 100644 index 0000000000..127c03fa0e --- /dev/null +++ b/toolkit/actors/TestWindowChild.jsm @@ -0,0 +1,102 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["TestWindowChild"]; + +var docShellThunks = new Map(); + +class TestWindowChild extends JSWindowActorChild { + constructor() { + super(); + this.sawActorCreated = false; + + try { + void this.contentWindow; + } catch (e) { + this.uninitializedGetterError = e; + } + } + + actorCreated() { + this.sawActorCreated = true; + } + + receiveMessage(aMessage) { + switch (aMessage.name) { + case "toChild": + aMessage.data.toChild = true; + this.sendAsyncMessage("toParent", aMessage.data); + break; + case "asyncAdd": + let { a, b } = aMessage.data; + return new Promise(resolve => { + resolve({ result: a + b }); + }); + case "error": + return Promise.reject(new SyntaxError(aMessage.data.message)); + case "exception": + return Promise.reject( + Components.Exception(aMessage.data.message, aMessage.data.result) + ); + case "done": + this.done(aMessage.data); + break; + case "noncloneReply": + // Return a value which is non-cloneable, like a WindowProxy. + return this.contentWindow; + case "storeActor": + docShellThunks.set(this.docShell, this); + break; + case "checkActor": { + let actor = docShellThunks.get(this.docShell); + docShellThunks.delete(this.docShell); + + let contentWindow; + let error; + try { + contentWindow = actor.contentWindow; + } catch (e) { + error = e; + } + if (error) { + return { + status: "error", + errorType: error.name, + }; + } + return { + status: "success", + valueIsNull: contentWindow === null, + }; + } + } + + return undefined; + } + + handleEvent(aEvent) { + this.sendAsyncMessage("event", { type: aEvent.type }); + } + + observe(subject, topic, data) { + switch (topic) { + case "audio-playback": + this.done({ subject, topic, data }); + break; + default: + this.lastObserved = { subject, topic, data }; + break; + } + } + + show() { + return "TestWindowChild"; + } + + didDestroy() { + Services.obs.notifyObservers(this, "test-js-window-actor-diddestroy", true); + } +} diff --git a/toolkit/actors/TestWindowChild.sys.mjs b/toolkit/actors/TestWindowChild.sys.mjs new file mode 100644 index 0000000000..5df64c932c --- /dev/null +++ b/toolkit/actors/TestWindowChild.sys.mjs @@ -0,0 +1,99 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +var docShellThunks = new Map(); + +export class TestWindowChild extends JSWindowActorChild { + constructor() { + super(); + this.sawActorCreated = false; + + try { + void this.contentWindow; + } catch (e) { + this.uninitializedGetterError = e; + } + } + + actorCreated() { + this.sawActorCreated = true; + } + + receiveMessage(aMessage) { + switch (aMessage.name) { + case "toChild": + aMessage.data.toChild = true; + this.sendAsyncMessage("toParent", aMessage.data); + break; + case "asyncAdd": + let { a, b } = aMessage.data; + return new Promise(resolve => { + resolve({ result: a + b }); + }); + case "error": + return Promise.reject(new SyntaxError(aMessage.data.message)); + case "exception": + return Promise.reject( + Components.Exception(aMessage.data.message, aMessage.data.result) + ); + case "done": + this.done(aMessage.data); + break; + case "noncloneReply": + // Return a value which is non-cloneable, like a WindowProxy. + return this.contentWindow; + case "storeActor": + docShellThunks.set(this.docShell, this); + break; + case "checkActor": { + let actor = docShellThunks.get(this.docShell); + docShellThunks.delete(this.docShell); + + let contentWindow; + let error; + try { + contentWindow = actor.contentWindow; + } catch (e) { + error = e; + } + if (error) { + return { + status: "error", + errorType: error.name, + }; + } + return { + status: "success", + valueIsNull: contentWindow === null, + }; + } + } + + return undefined; + } + + handleEvent(aEvent) { + this.sendAsyncMessage("event", { type: aEvent.type }); + } + + observe(subject, topic, data) { + switch (topic) { + case "audio-playback": + this.done({ subject, topic, data }); + break; + default: + this.lastObserved = { subject, topic, data }; + break; + } + } + + show() { + return "TestWindowChild"; + } + + didDestroy() { + Services.obs.notifyObservers(this, "test-js-window-actor-diddestroy", true); + } +} diff --git a/toolkit/actors/TestWindowParent.jsm b/toolkit/actors/TestWindowParent.jsm new file mode 100644 index 0000000000..5c368d3321 --- /dev/null +++ b/toolkit/actors/TestWindowParent.jsm @@ -0,0 +1,49 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["TestWindowParent"]; + +class TestWindowParent extends JSWindowActorParent { + constructor() { + super(); + this.wrappedJSObject = this; + this.sawActorCreated = false; + } + + actorCreated() { + this.sawActorCreated = true; + } + + receiveMessage(aMessage) { + switch (aMessage.name) { + case "init": + aMessage.data.initial = true; + this.sendAsyncMessage("toChild", aMessage.data); + break; + case "toParent": + aMessage.data.toParent = true; + this.sendAsyncMessage("done", aMessage.data); + break; + case "asyncMul": + let { a, b } = aMessage.data; + return { result: a * b }; + + case "event": + Services.obs.notifyObservers( + this, + "test-js-window-actor-parent-event", + aMessage.data.type + ); + break; + } + + return undefined; + } + + show() { + return "TestWindowParent"; + } +} diff --git a/toolkit/actors/TestWindowParent.sys.mjs b/toolkit/actors/TestWindowParent.sys.mjs new file mode 100644 index 0000000000..d7d0c2b547 --- /dev/null +++ b/toolkit/actors/TestWindowParent.sys.mjs @@ -0,0 +1,46 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +export class TestWindowParent extends JSWindowActorParent { + constructor() { + super(); + this.wrappedJSObject = this; + this.sawActorCreated = false; + } + + actorCreated() { + this.sawActorCreated = true; + } + + receiveMessage(aMessage) { + switch (aMessage.name) { + case "init": + aMessage.data.initial = true; + this.sendAsyncMessage("toChild", aMessage.data); + break; + case "toParent": + aMessage.data.toParent = true; + this.sendAsyncMessage("done", aMessage.data); + break; + case "asyncMul": + let { a, b } = aMessage.data; + return { result: a * b }; + + case "event": + Services.obs.notifyObservers( + this, + "test-js-window-actor-parent-event", + aMessage.data.type + ); + break; + } + + return undefined; + } + + show() { + return "TestWindowParent"; + } +} diff --git a/toolkit/actors/ThumbnailsChild.sys.mjs b/toolkit/actors/ThumbnailsChild.sys.mjs new file mode 100644 index 0000000000..9ab6fc3b08 --- /dev/null +++ b/toolkit/actors/ThumbnailsChild.sys.mjs @@ -0,0 +1,60 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PageThumbUtils: "resource://gre/modules/PageThumbUtils.sys.mjs", +}); + +export class ThumbnailsChild extends JSWindowActorChild { + receiveMessage(message) { + switch (message.name) { + case "Browser:Thumbnail:ContentInfo": { + let [width, height] = lazy.PageThumbUtils.getContentSize( + this.contentWindow + ); + return { width, height }; + } + case "Browser:Thumbnail:CheckState": { + /** + * Remote isSafeForCapture request handler for PageThumbs. + */ + return new Promise(resolve => + Services.tm.idleDispatchToMainThread(() => { + if (!this.manager) { + // If we have no manager, our actor has been destroyed, which + // means we can't respond, and trying to touch + // `this.contentWindow` or `this.browsingContext` will throw. + // The `sendQuery` call in the parent will already have been + // rejected when the actor was destroyed, so there's no need to + // reject our promise or log an additional error. + return; + } + + let result = lazy.PageThumbUtils.shouldStoreContentThumbnail( + this.contentWindow, + this.browsingContext.docShell + ); + resolve(result); + }) + ); + } + case "Browser:Thumbnail:GetOriginalURL": { + /** + * Remote GetOriginalURL request handler for PageThumbs. + */ + let channel = this.browsingContext.docShell.currentDocumentChannel; + let channelError = lazy.PageThumbUtils.isChannelErrorResponse(channel); + let originalURL; + try { + originalURL = channel.originalURI.spec; + } catch (ex) {} + return { channelError, originalURL }; + } + } + return undefined; + } +} diff --git a/toolkit/actors/UAWidgetsChild.sys.mjs b/toolkit/actors/UAWidgetsChild.sys.mjs new file mode 100644 index 0000000000..6f4244ffe9 --- /dev/null +++ b/toolkit/actors/UAWidgetsChild.sys.mjs @@ -0,0 +1,236 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +export class UAWidgetsChild extends JSWindowActorChild { + constructor() { + super(); + + this.widgets = new WeakMap(); + this.prefsCache = new Map(); + this.observedPrefs = []; + + // Bug 1570744 - JSWindowActorChild's cannot be used as nsIObserver's + // directly, so we create a new function here instead to act as our + // nsIObserver, which forwards the notification to the observe method. + this.observerFunction = (subject, topic, data) => { + this.observe(subject, topic, data); + }; + } + + didDestroy() { + for (let pref in this.observedPrefs) { + Services.prefs.removeObserver(pref, this.observerFunction); + } + } + + unwrap(obj) { + return Cu.isXrayWrapper(obj) ? obj.wrappedJSObject : obj; + } + + handleEvent(aEvent) { + switch (aEvent.type) { + case "UAWidgetSetupOrChange": + this.setupOrNotifyWidget(aEvent.target); + break; + case "UAWidgetTeardown": + this.teardownWidget(aEvent.target); + break; + } + } + + setupOrNotifyWidget(aElement) { + if (!this.widgets.has(aElement)) { + this.setupWidget(aElement); + return; + } + + let { widget } = this.widgets.get(aElement); + + if (typeof widget.onchange == "function") { + if ( + this.unwrap(aElement.openOrClosedShadowRoot) != + this.unwrap(widget.shadowRoot) + ) { + console.error( + "Getting a UAWidgetSetupOrChange event without the ShadowRoot. " + + "Torn down already?" + ); + return; + } + try { + widget.onchange(); + } catch (ex) { + console.error(ex); + } + } + } + + setupWidget(aElement) { + let uri; + let widgetName; + // Use prefKeys to optionally send a list of preferences to forward to + // the UAWidget. The UAWidget will receive those preferences as key-value + // pairs as the second argument to its constructor. Updates to those prefs + // can be observed by implementing an optional onPrefChange method for the + // UAWidget that receives the changed pref name as the first argument, and + // the updated value as the second. + let prefKeys = []; + switch (aElement.localName) { + case "video": + case "audio": + uri = "chrome://global/content/elements/videocontrols.js"; + widgetName = "VideoControlsWidget"; + prefKeys = [ + "media.videocontrols.picture-in-picture.enabled", + "media.videocontrols.picture-in-picture.video-toggle.enabled", + "media.videocontrols.picture-in-picture.video-toggle.always-show", + "media.videocontrols.picture-in-picture.video-toggle.min-video-secs", + "media.videocontrols.picture-in-picture.video-toggle.position", + "media.videocontrols.picture-in-picture.video-toggle.has-used", + "media.videocontrols.keyboard-tab-to-all-controls", + "media.videocontrols.picture-in-picture.respect-disablePictureInPicture", + ]; + break; + case "input": + uri = "chrome://global/content/elements/datetimebox.js"; + widgetName = "DateTimeBoxWidget"; + break; + case "marquee": + uri = "chrome://global/content/elements/marquee.js"; + widgetName = "MarqueeWidget"; + break; + case "img": + uri = "chrome://global/content/elements/textrecognition.js"; + widgetName = "TextRecognitionWidget"; + } + + if (!uri || !widgetName) { + console.error( + "Getting a UAWidgetSetupOrChange event on undefined element." + ); + return; + } + + let shadowRoot = aElement.openOrClosedShadowRoot; + if (!shadowRoot) { + console.error( + "Getting a UAWidgetSetupOrChange event without the Shadow Root. " + + "Torn down already?" + ); + return; + } + + let isSystemPrincipal = aElement.nodePrincipal.isSystemPrincipal; + let sandbox = isSystemPrincipal + ? Object.create(null) + : Cu.getUAWidgetScope(aElement.nodePrincipal); + + if (!sandbox[widgetName]) { + Services.scriptloader.loadSubScript(uri, sandbox); + } + + let prefs = Cu.cloneInto( + this.getPrefsForUAWidget(widgetName, prefKeys), + sandbox + ); + + let widget = new sandbox[widgetName](shadowRoot, prefs); + if (!isSystemPrincipal) { + widget = widget.wrappedJSObject; + } + if (this.unwrap(widget.shadowRoot) != this.unwrap(shadowRoot)) { + console.error("Widgets should expose their shadow root."); + } + this.widgets.set(aElement, { widget, widgetName }); + try { + widget.onsetup(); + } catch (ex) { + console.error(ex); + } + } + + teardownWidget(aElement) { + if (!this.widgets.has(aElement)) { + return; + } + let { widget } = this.widgets.get(aElement); + if (typeof widget.teardown == "function") { + try { + widget.teardown(); + } catch (ex) { + console.error(ex); + } + } + this.widgets.delete(aElement); + } + + getPrefsForUAWidget(aWidgetName, aPrefKeys) { + let result = this.prefsCache.get(aWidgetName); + if (result) { + return result; + } + + result = {}; + for (let key of aPrefKeys) { + result[key] = this.getPref(key); + this.observePref(key); + } + + this.prefsCache.set(aWidgetName, result); + return result; + } + + observePref(prefKey) { + Services.prefs.addObserver(prefKey, this.observerFunction); + this.observedPrefs.push(prefKey); + } + + getPref(prefKey) { + switch (Services.prefs.getPrefType(prefKey)) { + case Ci.nsIPrefBranch.PREF_BOOL: { + return Services.prefs.getBoolPref(prefKey); + } + case Ci.nsIPrefBranch.PREF_INT: { + return Services.prefs.getIntPref(prefKey); + } + case Ci.nsIPrefBranch.PREF_STRING: { + return Services.prefs.getStringPref(prefKey); + } + } + + return undefined; + } + + observe(subject, topic, data) { + if (topic == "nsPref:changed") { + for (let [widgetName, prefCache] of this.prefsCache) { + if (prefCache.hasOwnProperty(data)) { + let newValue = this.getPref(data); + prefCache[data] = newValue; + + this.notifyWidgetsOnPrefChange(widgetName, data, newValue); + } + } + } + } + + notifyWidgetsOnPrefChange(nameOfWidgetToNotify, prefKey, newValue) { + let elements = ChromeUtils.nondeterministicGetWeakMapKeys(this.widgets); + for (let element of elements) { + if (!Cu.isDeadWrapper(element) && element.isConnected) { + let { widgetName, widget } = this.widgets.get(element); + if (widgetName == nameOfWidgetToNotify) { + if (typeof widget.onPrefChange == "function") { + try { + widget.onPrefChange(prefKey, newValue); + } catch (ex) { + console.error(ex); + } + } + } + } + } + } +} diff --git a/toolkit/actors/UnselectedTabHoverChild.sys.mjs b/toolkit/actors/UnselectedTabHoverChild.sys.mjs new file mode 100644 index 0000000000..1cf1ddb9fd --- /dev/null +++ b/toolkit/actors/UnselectedTabHoverChild.sys.mjs @@ -0,0 +1,20 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +export class UnselectedTabHoverChild extends JSWindowActorChild { + receiveMessage(message) { + Services.obs.notifyObservers( + this.contentWindow, + "unselected-tab-hover", + message.data.hovered + ); + } + + handleEvent(event) { + this.sendAsyncMessage("UnselectedTabHover:Toggle", { + enable: event.type == "UnselectedTabHover:Enable", + }); + } +} diff --git a/toolkit/actors/UnselectedTabHoverParent.sys.mjs b/toolkit/actors/UnselectedTabHoverParent.sys.mjs new file mode 100644 index 0000000000..797cbd8af2 --- /dev/null +++ b/toolkit/actors/UnselectedTabHoverParent.sys.mjs @@ -0,0 +1,15 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +export class UnselectedTabHoverParent extends JSWindowActorParent { + receiveMessage(message) { + const topBrowsingContext = this.manager.browsingContext.top; + const browser = topBrowsingContext.embedderElement; + if (!browser) { + return; + } + browser.shouldHandleUnselectedTabHover = message.data.enable; + } +} diff --git a/toolkit/actors/ViewSourceChild.sys.mjs b/toolkit/actors/ViewSourceChild.sys.mjs new file mode 100644 index 0000000000..4c573865b7 --- /dev/null +++ b/toolkit/actors/ViewSourceChild.sys.mjs @@ -0,0 +1,346 @@ +/* -*- 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ViewSourcePageChild: "resource://gre/actors/ViewSourcePageChild.sys.mjs", +}); + +export class ViewSourceChild extends JSWindowActorChild { + receiveMessage(message) { + let data = message.data; + switch (message.name) { + case "ViewSource:LoadSource": + this.viewSource(data.URL, data.outerWindowID, data.lineNumber); + break; + case "ViewSource:LoadSourceWithSelection": + this.viewSourceWithSelection( + data.URL, + data.drawSelection, + data.baseURI + ); + break; + case "ViewSource:GetSelection": + let selectionDetails; + try { + selectionDetails = this.getSelection(this.document.ownerGlobal); + } catch (e) {} + return selectionDetails; + } + + return undefined; + } + + /** + * Called when the parent sends a message to view some source code. + * + * @param URL (required) + * The URL string of the source to be shown. + * @param outerWindowID (optional) + * The outerWindowID of the content window that has hosted + * the document, in case we want to retrieve it from the network + * cache. + * @param lineNumber (optional) + * The line number to focus as soon as the source has finished + * loading. + */ + viewSource(URL, outerWindowID, lineNumber) { + let otherDocShell; + let forceEncodingDetection = false; + + if (outerWindowID) { + let contentWindow = Services.wm.getOuterWindowWithId(outerWindowID); + if (contentWindow) { + otherDocShell = contentWindow.docShell; + + forceEncodingDetection = contentWindow.windowUtils.docCharsetIsForced; + } + } + + this.loadSource(URL, otherDocShell, lineNumber, forceEncodingDetection); + } + + /** + * Loads a view source selection showing the given view-source url and + * highlight the selection. + * + * @param uri view-source uri to show + * @param drawSelection true to highlight the selection + * @param baseURI base URI of the original document + */ + viewSourceWithSelection(uri, drawSelection, baseURI) { + // This isn't ideal, but set a global in the view source page actor + // that indicates that a selection should be drawn. It will be read + // when by the page's pageshow listener. This should work as the + // view source page is always loaded in the same process. + lazy.ViewSourcePageChild.setNeedsDrawSelection(drawSelection); + + // all our content is held by the data:URI and URIs are internally stored as utf-8 (see nsIURI.idl) + let loadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + let webNav = this.docShell.QueryInterface(Ci.nsIWebNavigation); + let loadURIOptions = { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + loadFlags, + baseURI: Services.io.newURI(baseURI), + }; + webNav.fixupAndLoadURIString(uri, loadURIOptions); + } + + /** + * Common utility function used by both the current and deprecated APIs + * for loading source. + * + * @param URL (required) + * The URL string of the source to be shown. + * @param otherDocShell (optional) + * The docshell of the content window that is hosting the document. + * @param lineNumber (optional) + * The line number to focus as soon as the source has finished + * loading. + * @param forceEncodingDetection (optional) + * Force autodetection of the character encoding. + */ + loadSource(URL, otherDocShell, lineNumber, forceEncodingDetection) { + const viewSrcURL = "view-source:" + URL; + + if (forceEncodingDetection) { + this.docShell.forceEncodingDetection(); + } + + if (lineNumber) { + lazy.ViewSourcePageChild.setInitialLineNumber(lineNumber); + } + + if (!otherDocShell) { + this.loadSourceFromURL(viewSrcURL); + return; + } + + try { + let pageLoader = this.docShell.QueryInterface(Ci.nsIWebPageDescriptor); + pageLoader.loadPageAsViewSource(otherDocShell, viewSrcURL); + } catch (e) { + // We were not able to load the source from the network cache. + this.loadSourceFromURL(viewSrcURL); + } + } + + /** + * Load some URL in the browser. + * + * @param URL + * The URL string to load. + */ + loadSourceFromURL(URL) { + let loadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + let webNav = this.docShell.QueryInterface(Ci.nsIWebNavigation); + let loadURIOptions = { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + loadFlags, + }; + webNav.fixupAndLoadURIString(URL, loadURIOptions); + } + + /** + * A helper to get a path like FIXptr, but with an array instead of the + * "tumbler" notation. + * See FIXptr: http://lists.w3.org/Archives/Public/www-xml-linking-comments/2001AprJun/att-0074/01-NOTE-FIXptr-20010425.htm + */ + getPath(ancestor, node) { + var n = node; + var p = n.parentNode; + if (n == ancestor || !p) { + return null; + } + var path = []; + if (!path) { + return null; + } + do { + for (var i = 0; i < p.childNodes.length; i++) { + if (p.childNodes.item(i) == n) { + path.push(i); + break; + } + } + n = p; + p = n.parentNode; + } while (n != ancestor && p); + return path; + } + + getSelection(global) { + const { content } = global; + + // These are markers used to delimit the selection during processing. They + // are removed from the final rendering. + // We use noncharacter Unicode codepoints to minimize the risk of clashing + // with anything that might legitimately be present in the document. + // U+FDD0..FDEF <noncharacters> + const MARK_SELECTION_START = "\uFDD0"; + const MARK_SELECTION_END = "\uFDEF"; + + var focusedWindow = Services.focus.focusedWindow || content; + var selection = focusedWindow.getSelection(); + + var range = selection.getRangeAt(0); + var ancestorContainer = range.commonAncestorContainer; + var doc = ancestorContainer.ownerDocument; + + var startContainer = range.startContainer; + var endContainer = range.endContainer; + var startOffset = range.startOffset; + var endOffset = range.endOffset; + + // let the ancestor be an element + var Node = doc.defaultView.Node; + if ( + ancestorContainer.nodeType == Node.TEXT_NODE || + ancestorContainer.nodeType == Node.CDATA_SECTION_NODE + ) { + ancestorContainer = ancestorContainer.parentNode; + } + + // for selectAll, let's use the entire document, including <html>...</html> + // @see nsDocumentViewer::SelectAll() for how selectAll is implemented + try { + if (ancestorContainer == doc.body) { + ancestorContainer = doc.documentElement; + } + } catch (e) {} + + // each path is a "child sequence" (a.k.a. "tumbler") that + // descends from the ancestor down to the boundary point + var startPath = this.getPath(ancestorContainer, startContainer); + var endPath = this.getPath(ancestorContainer, endContainer); + + // clone the fragment of interest and reset everything to be relative to it + // note: it is with the clone that we operate/munge from now on. Also note + // that we clone into a data document to prevent images in the fragment from + // loading and the like. The use of importNode here, as opposed to adoptNode, + // is _very_ important. + // XXXbz wish there were a less hacky way to create an untrusted document here + var isHTML = doc.createElement("div").tagName == "DIV"; + var dataDoc = isHTML + ? ancestorContainer.ownerDocument.implementation.createHTMLDocument("") + : ancestorContainer.ownerDocument.implementation.createDocument( + "", + "", + null + ); + ancestorContainer = dataDoc.importNode(ancestorContainer, true); + startContainer = ancestorContainer; + endContainer = ancestorContainer; + + // Only bother with the selection if it can be remapped. Don't mess with + // leaf elements (such as <isindex>) that secretly use anynomous content + // for their display appearance. + var canDrawSelection = ancestorContainer.hasChildNodes(); + var tmpNode; + if (canDrawSelection) { + var i; + for (i = startPath ? startPath.length - 1 : -1; i >= 0; i--) { + startContainer = startContainer.childNodes.item(startPath[i]); + } + for (i = endPath ? endPath.length - 1 : -1; i >= 0; i--) { + endContainer = endContainer.childNodes.item(endPath[i]); + } + + // add special markers to record the extent of the selection + // note: |startOffset| and |endOffset| are interpreted either as + // offsets in the text data or as child indices (see the Range spec) + // (here, munging the end point first to keep the start point safe...) + if ( + endContainer.nodeType == Node.TEXT_NODE || + endContainer.nodeType == Node.CDATA_SECTION_NODE + ) { + // do some extra tweaks to try to avoid the view-source output to look like + // ...<tag>]... or ...]</tag>... (where ']' marks the end of the selection). + // To get a neat output, the idea here is to remap the end point from: + // 1. ...<tag>]... to ...]<tag>... + // 2. ...]</tag>... to ...</tag>]... + if ( + (endOffset > 0 && endOffset < endContainer.data.length) || + !endContainer.parentNode || + !endContainer.parentNode.parentNode + ) { + endContainer.insertData(endOffset, MARK_SELECTION_END); + } else { + tmpNode = dataDoc.createTextNode(MARK_SELECTION_END); + endContainer = endContainer.parentNode; + if (endOffset === 0) { + endContainer.parentNode.insertBefore(tmpNode, endContainer); + } else { + endContainer.parentNode.insertBefore( + tmpNode, + endContainer.nextSibling + ); + } + } + } else { + tmpNode = dataDoc.createTextNode(MARK_SELECTION_END); + endContainer.insertBefore( + tmpNode, + endContainer.childNodes.item(endOffset) + ); + } + + if ( + startContainer.nodeType == Node.TEXT_NODE || + startContainer.nodeType == Node.CDATA_SECTION_NODE + ) { + // do some extra tweaks to try to avoid the view-source output to look like + // ...<tag>[... or ...[</tag>... (where '[' marks the start of the selection). + // To get a neat output, the idea here is to remap the start point from: + // 1. ...<tag>[... to ...[<tag>... + // 2. ...[</tag>... to ...</tag>[... + if ( + (startOffset > 0 && startOffset < startContainer.data.length) || + !startContainer.parentNode || + !startContainer.parentNode.parentNode || + startContainer != startContainer.parentNode.lastChild + ) { + startContainer.insertData(startOffset, MARK_SELECTION_START); + } else { + tmpNode = dataDoc.createTextNode(MARK_SELECTION_START); + startContainer = startContainer.parentNode; + if (startOffset === 0) { + startContainer.parentNode.insertBefore(tmpNode, startContainer); + } else { + startContainer.parentNode.insertBefore( + tmpNode, + startContainer.nextSibling + ); + } + } + } else { + tmpNode = dataDoc.createTextNode(MARK_SELECTION_START); + startContainer.insertBefore( + tmpNode, + startContainer.childNodes.item(startOffset) + ); + } + } + + // now extract and display the syntax highlighted source + tmpNode = dataDoc.createElementNS("http://www.w3.org/1999/xhtml", "div"); + tmpNode.appendChild(ancestorContainer); + + return { + URL: + (isHTML + ? "view-source:data:text/html;charset=utf-8," + : "view-source:data:application/xml;charset=utf-8,") + + encodeURIComponent(tmpNode.innerHTML), + drawSelection: canDrawSelection, + baseURI: doc.baseURI, + }; + } + + get wrapLongLines() { + return Services.prefs.getBoolPref("view_source.wrap_long_lines"); + } +} diff --git a/toolkit/actors/ViewSourcePageChild.sys.mjs b/toolkit/actors/ViewSourcePageChild.sys.mjs new file mode 100644 index 0000000000..2f5c53cc2e --- /dev/null +++ b/toolkit/actors/ViewSourcePageChild.sys.mjs @@ -0,0 +1,474 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const BUNDLE_URL = "chrome://global/locale/viewSource.properties"; + +// These are markers used to delimit the selection during processing. They +// are removed from the final rendering. +// We use noncharacter Unicode codepoints to minimize the risk of clashing +// with anything that might legitimately be present in the document. +// U+FDD0..FDEF <noncharacters> +const MARK_SELECTION_START = "\uFDD0"; +const MARK_SELECTION_END = "\uFDEF"; + +/** + * When showing selection source, chrome will construct a page fragment to + * show, and then instruct content to draw a selection after load. This is + * set true when there is a pending request to draw selection. + */ +let gNeedsDrawSelection = false; + +/** + * Start at a specific line number. + */ +let gInitialLineNumber = -1; + +export class ViewSourcePageChild extends JSWindowActorChild { + constructor() { + super(); + + XPCOMUtils.defineLazyGetter(this, "bundle", function () { + return Services.strings.createBundle(BUNDLE_URL); + }); + } + + static setNeedsDrawSelection(value) { + gNeedsDrawSelection = value; + } + + static setInitialLineNumber(value) { + gInitialLineNumber = value; + } + + receiveMessage(msg) { + switch (msg.name) { + case "ViewSource:GoToLine": + this.goToLine(msg.data.lineNumber); + break; + case "ViewSource:IsWrapping": + return this.isWrapping; + case "ViewSource:IsSyntaxHighlighting": + return this.isSyntaxHighlighting; + case "ViewSource:ToggleWrapping": + this.toggleWrapping(); + break; + case "ViewSource:ToggleSyntaxHighlighting": + this.toggleSyntaxHighlighting(); + break; + } + return undefined; + } + + /** + * Any events should get handled here, and should get dispatched to + * a specific function for the event type. + */ + handleEvent(event) { + switch (event.type) { + case "pageshow": + this.onPageShow(event); + break; + case "click": + this.onClick(event); + break; + } + } + + /** + * A shortcut to the nsISelectionController for the content. + */ + get selectionController() { + return this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsISelectionDisplay) + .QueryInterface(Ci.nsISelectionController); + } + + /** + * A shortcut to the nsIWebBrowserFind for the content. + */ + get webBrowserFind() { + return this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebBrowserFind); + } + + /** + * This handler is for click events from: + * * error page content, which can show up if the user attempts to view the + * source of an attack page. + */ + onClick(event) { + let target = event.originalTarget; + + // Don't trust synthetic events + if (!event.isTrusted || event.target.localName != "button") { + return; + } + + let errorDoc = target.ownerDocument; + + if (/^about:blocked/.test(errorDoc.documentURI)) { + // The event came from a button on a malware/phishing block page + + if (target == errorDoc.getElementById("goBackButton")) { + // Instead of loading some safe page, just close the window + this.sendAsyncMessage("ViewSource:Close"); + } + } + } + + /** + * Handler for the pageshow event. + * + * @param event + * The pageshow event being handled. + */ + onPageShow(event) { + // If we need to draw the selection, wait until an actual view source page + // has loaded, instead of about:blank. + if ( + gNeedsDrawSelection && + this.document.documentURI.startsWith("view-source:") + ) { + gNeedsDrawSelection = false; + this.drawSelection(); + } + + if (gInitialLineNumber >= 0) { + this.goToLine(gInitialLineNumber); + gInitialLineNumber = -1; + } + } + + /** + * Attempts to go to a particular line in the source code being + * shown. If it succeeds in finding the line, it will fire a + * "ViewSource:GoToLine:Success" message, passing up an object + * with the lineNumber we just went to. If it cannot find the line, + * it will fire a "ViewSource:GoToLine:Failed" message. + * + * @param lineNumber + * The line number to attempt to go to. + */ + goToLine(lineNumber) { + let body = this.document.body; + + // The source document is made up of a number of pre elements with + // id attributes in the format <pre id="line123">, meaning that + // the first line in the pre element is number 123. + // Do binary search to find the pre element containing the line. + // However, in the plain text case, we have only one pre without an + // attribute, so assume it begins on line 1. + let pre; + for (let lbound = 0, ubound = body.childNodes.length; ; ) { + let middle = (lbound + ubound) >> 1; + pre = body.childNodes[middle]; + + let firstLine = pre.id ? parseInt(pre.id.substring(4)) : 1; + + if (lbound == ubound - 1) { + break; + } + + if (lineNumber >= firstLine) { + lbound = middle; + } else { + ubound = middle; + } + } + + let result = {}; + let found = this.findLocation(pre, lineNumber, null, -1, false, result); + + if (!found) { + this.sendAsyncMessage("ViewSource:GoToLine:Failed"); + return; + } + + let selection = this.document.defaultView.getSelection(); + selection.removeAllRanges(); + + // In our case, the range's startOffset is after "\n" on the previous line. + // Tune the selection at the beginning of the next line and do some tweaking + // to position the focusNode and the caret at the beginning of the line. + selection.interlinePosition = true; + + selection.addRange(result.range); + + if (!selection.isCollapsed) { + selection.collapseToEnd(); + + let offset = result.range.startOffset; + let node = result.range.startContainer; + if (offset < node.data.length) { + // The same text node spans across the "\n", just focus where we were. + selection.extend(node, offset); + } else { + // There is another tag just after the "\n", hook there. We need + // to focus a safe point because there are edgy cases such as + // <span>...\n</span><span>...</span> vs. + // <span>...\n<span>...</span></span><span>...</span> + node = node.nextSibling + ? node.nextSibling + : node.parentNode.nextSibling; + selection.extend(node, 0); + } + } + + let selCon = this.selectionController; + selCon.setDisplaySelection(Ci.nsISelectionController.SELECTION_ON); + selCon.setCaretVisibilityDuringSelection(true); + + // Scroll the beginning of the line into view. + selCon.scrollSelectionIntoView( + Ci.nsISelectionController.SELECTION_NORMAL, + Ci.nsISelectionController.SELECTION_FOCUS_REGION, + true + ); + + this.sendAsyncMessage("ViewSource:GoToLine:Success", { lineNumber }); + } + + /** + * Some old code from the original view source implementation. Original + * documentation follows: + * + * "Loops through the text lines in the pre element. The arguments are either + * (pre, line) or (node, offset, interlinePosition). result is an out + * argument. If (pre, line) are specified (and node == null), result.range is + * a range spanning the specified line. If the (node, offset, + * interlinePosition) are specified, result.line and result.col are the line + * and column number of the specified offset in the specified node relative to + * the whole file." + */ + findLocation(pre, lineNumber, node, offset, interlinePosition, result) { + if (node && !pre) { + // Look upwards to find the current pre element. + // eslint-disable-next-line no-empty + for (pre = node; pre.nodeName != "PRE"; pre = pre.parentNode) {} + } + + // The source document is made up of a number of pre elements with + // id attributes in the format <pre id="line123">, meaning that + // the first line in the pre element is number 123. + // However, in the plain text case, there is only one <pre> without an id, + // so assume line 1. + let curLine = pre.id ? parseInt(pre.id.substring(4)) : 1; + + // Walk through each of the text nodes and count newlines. + let treewalker = this.document.createTreeWalker( + pre, + NodeFilter.SHOW_TEXT, + null + ); + + // The column number of the first character in the current text node. + let firstCol = 1; + + let found = false; + for ( + let textNode = treewalker.firstChild(); + textNode && !found; + textNode = treewalker.nextNode() + ) { + // \r is not a valid character in the DOM, so we only check for \n. + let lineArray = textNode.data.split(/\n/); + let lastLineInNode = curLine + lineArray.length - 1; + + // Check if we can skip the text node without further inspection. + if (node ? textNode != node : lastLineInNode < lineNumber) { + if (lineArray.length > 1) { + firstCol = 1; + } + firstCol += lineArray[lineArray.length - 1].length; + curLine = lastLineInNode; + continue; + } + + // curPos is the offset within the current text node of the first + // character in the current line. + for ( + var i = 0, curPos = 0; + i < lineArray.length; + curPos += lineArray[i++].length + 1 + ) { + if (i > 0) { + curLine++; + } + + if (node) { + if (offset >= curPos && offset <= curPos + lineArray[i].length) { + // If we are right after the \n of a line and interlinePosition is + // false, the caret looks as if it were at the end of the previous + // line, so we display that line and column instead. + + if (i > 0 && offset == curPos && !interlinePosition) { + result.line = curLine - 1; + var prevPos = curPos - lineArray[i - 1].length; + result.col = (i == 1 ? firstCol : 1) + offset - prevPos; + } else { + result.line = curLine; + result.col = (i == 0 ? firstCol : 1) + offset - curPos; + } + found = true; + + break; + } + } else if (curLine == lineNumber && !("range" in result)) { + result.range = this.document.createRange(); + result.range.setStart(textNode, curPos); + + // This will always be overridden later, except when we look for + // the very last line in the file (this is the only line that does + // not end with \n). + result.range.setEndAfter(pre.lastChild); + } else if (curLine == lineNumber + 1) { + result.range.setEnd(textNode, curPos - 1); + found = true; + break; + } + } + } + + return found || "range" in result; + } + + /** + * @return {boolean} whether the "wrap" class exists on the document body. + */ + get isWrapping() { + return this.document.body.classList.contains("wrap"); + } + + /** + * @return {boolean} whether the "highlight" class exists on the document body. + */ + get isSyntaxHighlighting() { + return this.document.body.classList.contains("highlight"); + } + + /** + * Toggles the "wrap" class on the document body, which sets whether + * or not long lines are wrapped. Notifies parent to update the pref. + */ + toggleWrapping() { + let body = this.document.body; + let state = body.classList.toggle("wrap"); + this.sendAsyncMessage("ViewSource:StoreWrapping", { state }); + } + + /** + * Toggles the "highlight" class on the document body, which sets whether + * or not syntax highlighting is displayed. Notifies parent to update the + * pref. + */ + toggleSyntaxHighlighting() { + let body = this.document.body; + let state = body.classList.toggle("highlight"); + this.sendAsyncMessage("ViewSource:StoreSyntaxHighlighting", { state }); + } + + /** + * Using special markers left in the serialized source, this helper makes the + * underlying markup of the selected fragment to automatically appear as + * selected on the inflated view-source DOM. + */ + drawSelection() { + this.document.title = this.bundle.GetStringFromName( + "viewSelectionSourceTitle" + ); + + // find the special selection markers that we added earlier, and + // draw the selection between the two... + var findService = null; + try { + // get the find service which stores the global find state + findService = Cc["@mozilla.org/find/find_service;1"].getService( + Ci.nsIFindService + ); + } catch (e) {} + if (!findService) { + return; + } + + // cache the current global find state + var matchCase = findService.matchCase; + var entireWord = findService.entireWord; + var wrapFind = findService.wrapFind; + var findBackwards = findService.findBackwards; + var searchString = findService.searchString; + var replaceString = findService.replaceString; + + // setup our find instance + var findInst = this.webBrowserFind; + findInst.matchCase = true; + findInst.entireWord = false; + findInst.wrapFind = true; + findInst.findBackwards = false; + + // ...lookup the start mark + findInst.searchString = MARK_SELECTION_START; + var startLength = MARK_SELECTION_START.length; + findInst.findNext(); + + var selection = this.document.defaultView.getSelection(); + if (!selection.rangeCount) { + return; + } + + var range = selection.getRangeAt(0); + + var startContainer = range.startContainer; + var startOffset = range.startOffset; + + // ...lookup the end mark + findInst.searchString = MARK_SELECTION_END; + var endLength = MARK_SELECTION_END.length; + findInst.findNext(); + + var endContainer = selection.anchorNode; + var endOffset = selection.anchorOffset; + + // reset the selection that find has left + selection.removeAllRanges(); + + // delete the special markers now... + endContainer.deleteData(endOffset, endLength); + startContainer.deleteData(startOffset, startLength); + if (startContainer == endContainer) { + endOffset -= startLength; + } // has shrunk if on same text node... + range.setEnd(endContainer, endOffset); + + // show the selection and scroll it into view + selection.addRange(range); + // the default behavior of the selection is to scroll at the end of + // the selection, whereas in this situation, it is more user-friendly + // to scroll at the beginning. So we override the default behavior here + try { + this.selectionController.scrollSelectionIntoView( + Ci.nsISelectionController.SELECTION_NORMAL, + Ci.nsISelectionController.SELECTION_ANCHOR_REGION, + true + ); + } catch (e) {} + + // restore the current find state + findService.matchCase = matchCase; + findService.entireWord = entireWord; + findService.wrapFind = wrapFind; + findService.findBackwards = findBackwards; + findService.searchString = searchString; + findService.replaceString = replaceString; + + findInst.matchCase = matchCase; + findInst.entireWord = entireWord; + findInst.wrapFind = wrapFind; + findInst.findBackwards = findBackwards; + findInst.searchString = searchString; + } +} diff --git a/toolkit/actors/ViewSourcePageParent.sys.mjs b/toolkit/actors/ViewSourcePageParent.sys.mjs new file mode 100644 index 0000000000..cc45b248cc --- /dev/null +++ b/toolkit/actors/ViewSourcePageParent.sys.mjs @@ -0,0 +1,165 @@ +// -*- 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/. */ + +const BUNDLE_URL = "chrome://global/locale/viewSource.properties"; + +/** + * ViewSourcePageParent manages the view source <browser> from the chrome side. + */ +export class ViewSourcePageParent extends JSWindowActorParent { + constructor() { + super(); + + /** + * Holds the value of the last line found via the "Go to line" + * command, to pre-populate the prompt the next time it is + * opened. + */ + this.lastLineFound = null; + } + + /** + * Anything added to the messages array will get handled here, and should + * get dispatched to a specific function for the message name. + */ + receiveMessage(message) { + let data = message.data; + + switch (message.name) { + case "ViewSource:PromptAndGoToLine": + this.promptAndGoToLine(); + break; + case "ViewSource:GoToLine:Success": + this.onGoToLineSuccess(data.lineNumber); + break; + case "ViewSource:GoToLine:Failed": + this.onGoToLineFailed(); + break; + case "ViewSource:StoreWrapping": + this.storeWrapping(data.state); + break; + case "ViewSource:StoreSyntaxHighlighting": + this.storeSyntaxHighlighting(data.state); + break; + } + } + + /** + * A getter for the view source string bundle. + */ + get bundle() { + if (this._bundle) { + return this._bundle; + } + return (this._bundle = Services.strings.createBundle(BUNDLE_URL)); + } + + /** + * Opens the "Go to line" prompt for a user to hop to a particular line + * of the source code they're viewing. This will keep prompting until the + * user either cancels out of the prompt, or enters a valid line number. + */ + promptAndGoToLine() { + let input = { value: this.lastLineFound }; + let window = Services.wm.getMostRecentWindow(null); + + let ok = Services.prompt.prompt( + window, + this.bundle.GetStringFromName("goToLineTitle"), + this.bundle.GetStringFromName("goToLineText"), + input, + null, + { value: 0 } + ); + + if (!ok) { + return; + } + + let line = parseInt(input.value, 10); + + if (!(line > 0)) { + Services.prompt.alert( + window, + this.bundle.GetStringFromName("invalidInputTitle"), + this.bundle.GetStringFromName("invalidInputText") + ); + this.promptAndGoToLine(); + } else { + this.goToLine(line); + } + } + + /** + * Go to a particular line of the source code. This act is asynchronous. + * + * @param lineNumber + * The line number to try to go to to. + */ + goToLine(lineNumber) { + this.sendAsyncMessage("ViewSource:GoToLine", { lineNumber }); + } + + /** + * Called when the frame script reports that a line was successfully gotten + * to. + * + * @param lineNumber + * The line number that we successfully got to. + */ + onGoToLineSuccess(lineNumber) { + // We'll pre-populate the "Go to line" prompt with this value the next + // time it comes up. + this.lastLineFound = lineNumber; + } + + /** + * Called when the child reports that we failed to go to a particular + * line. This informs the user that their selection was likely out of range, + * and then reprompts the user to try again. + */ + onGoToLineFailed() { + let window = Services.wm.getMostRecentWindow(null); + Services.prompt.alert( + window, + this.bundle.GetStringFromName("outOfRangeTitle"), + this.bundle.GetStringFromName("outOfRangeText") + ); + this.promptAndGoToLine(); + } + + /** + * @return {boolean} the wrapping state + */ + queryIsWrapping() { + return this.sendQuery("ViewSource:IsWrapping"); + } + + /** + * @return {boolean} the syntax highlighting state + */ + queryIsSyntaxHighlighting() { + return this.sendQuery("ViewSource:IsSyntaxHighlighting"); + } + + /** + * Update the wrapping pref based on the child's current state. + * @param state + * Whether wrapping is currently enabled in the child. + */ + storeWrapping(state) { + Services.prefs.setBoolPref("view_source.wrap_long_lines", state); + } + + /** + * Update the syntax highlighting pref based on the child's current state. + * @param state + * Whether syntax highlighting is currently enabled in the child. + */ + storeSyntaxHighlighting(state) { + Services.prefs.setBoolPref("view_source.syntax_highlight", state); + } +} diff --git a/toolkit/actors/WebChannelChild.sys.mjs b/toolkit/actors/WebChannelChild.sys.mjs new file mode 100644 index 0000000000..563f2566a0 --- /dev/null +++ b/toolkit/actors/WebChannelChild.sys.mjs @@ -0,0 +1,132 @@ +/* -*- 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { ContentDOMReference } from "resource://gre/modules/ContentDOMReference.sys.mjs"; + +// Preference containing the list (space separated) of origins that are +// allowed to send non-string values through a WebChannel, mainly for +// backwards compatability. See bug 1238128 for more information. +const URL_WHITELIST_PREF = "webchannel.allowObject.urlWhitelist"; + +let _cachedWhitelist = null; + +const CACHED_PREFS = {}; +XPCOMUtils.defineLazyPreferenceGetter( + CACHED_PREFS, + "URL_WHITELIST", + URL_WHITELIST_PREF, + "", + // Null this out so we update it. + () => (_cachedWhitelist = null) +); + +export class WebChannelChild extends JSWindowActorChild { + handleEvent(event) { + if (event.type === "WebChannelMessageToChrome") { + return this._onMessageToChrome(event); + } + return undefined; + } + + receiveMessage(msg) { + if (msg.name === "WebChannelMessageToContent") { + return this._onMessageToContent(msg); + } + return undefined; + } + + _getWhitelistedPrincipals() { + if (!_cachedWhitelist) { + let urls = CACHED_PREFS.URL_WHITELIST.split(/\s+/); + _cachedWhitelist = urls.map(origin => + Services.scriptSecurityManager.createContentPrincipalFromOrigin(origin) + ); + } + return _cachedWhitelist; + } + + _onMessageToChrome(e) { + // If target is window then we want the document principal, otherwise fallback to target itself. + let principal = e.target.nodePrincipal + ? e.target.nodePrincipal + : e.target.document.nodePrincipal; + + if (e.detail) { + if (typeof e.detail != "string") { + // Check if the principal is one of the ones that's allowed to send + // non-string values for e.detail. They're whitelisted by site origin, + // so we compare on originNoSuffix in order to avoid other origin attributes + // that are not relevant here, such as containers or private browsing. + let objectsAllowed = this._getWhitelistedPrincipals().some( + whitelisted => principal.originNoSuffix == whitelisted.originNoSuffix + ); + if (!objectsAllowed) { + console.error( + "WebChannelMessageToChrome sent with an object from a non-whitelisted principal" + ); + return; + } + } + + let eventTarget = + e.target instanceof Ci.nsIDOMWindow + ? null + : ContentDOMReference.get(e.target); + this.sendAsyncMessage("WebChannelMessageToChrome", { + contentData: e.detail, + eventTarget, + principal, + }); + } else { + console.error("WebChannel message failed. No message detail."); + } + } + + _onMessageToContent(msg) { + if (msg.data && this.contentWindow) { + // msg.objects.eventTarget will be defined if sending a response to + // a WebChannelMessageToChrome event. An unsolicited send + // may not have an eventTarget defined, in this case send to the + // main content window. + let { eventTarget, principal } = msg.data; + if (!eventTarget) { + eventTarget = this.contentWindow; + } else { + eventTarget = ContentDOMReference.resolve(eventTarget); + } + if (!eventTarget) { + console.error("WebChannel message failed. No target."); + return; + } + + // Use nodePrincipal if available, otherwise fallback to document principal. + let targetPrincipal = + eventTarget instanceof Ci.nsIDOMWindow + ? eventTarget.document.nodePrincipal + : eventTarget.nodePrincipal; + + if (principal.subsumes(targetPrincipal)) { + let targetWindow = this.contentWindow; + eventTarget.dispatchEvent( + new targetWindow.CustomEvent("WebChannelMessageToContent", { + detail: Cu.cloneInto( + { + id: msg.data.id, + message: msg.data.message, + }, + targetWindow + ), + }) + ); + } else { + console.error("WebChannel message failed. Principal mismatch."); + } + } else { + console.error("WebChannel message failed. No message data."); + } + } +} diff --git a/toolkit/actors/WebChannelParent.sys.mjs b/toolkit/actors/WebChannelParent.sys.mjs new file mode 100644 index 0000000000..98b3ad093a --- /dev/null +++ b/toolkit/actors/WebChannelParent.sys.mjs @@ -0,0 +1,92 @@ +/* -*- 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/. */ + +import { WebChannelBroker } from "resource://gre/modules/WebChannel.sys.mjs"; + +const ERRNO_MISSING_PRINCIPAL = 1; +const ERRNO_NO_SUCH_CHANNEL = 2; + +export class WebChannelParent extends JSWindowActorParent { + receiveMessage(msg) { + let data = msg.data.contentData; + let sendingContext = { + browsingContext: this.browsingContext, + browser: this.browsingContext.top.embedderElement, + eventTarget: msg.data.eventTarget, + principal: msg.data.principal, + }; + // data must be a string except for a few legacy origins allowed by browser-content.js. + if (typeof data == "string") { + try { + data = JSON.parse(data); + } catch (e) { + console.error("Failed to parse WebChannel data as a JSON object"); + return; + } + } + + if (data && data.id) { + if (!msg.data.principal) { + this._sendErrorEventToContent( + data.id, + sendingContext, + ERRNO_MISSING_PRINCIPAL, + "Message principal missing" + ); + } else { + let validChannelFound = WebChannelBroker.tryToDeliver( + data, + sendingContext + ); + + // if no valid origins send an event that there is no such valid channel + if (!validChannelFound) { + this._sendErrorEventToContent( + data.id, + sendingContext, + ERRNO_NO_SUCH_CHANNEL, + "No Such Channel" + ); + } + } + } else { + console.error("WebChannel channel id missing"); + } + } + + /** + * + * @param id {String} + * The WebChannel id to include in the message + * @param sendingContext {Object} + * Message sending context + * @param [errorMsg] {String} + * Error message + * @private + */ + _sendErrorEventToContent(id, sendingContext, errorNo, errorMsg) { + let { eventTarget, principal } = sendingContext; + + errorMsg = errorMsg || "Web Channel Parent error"; + + let { currentWindowGlobal = null } = this.browsingContext; + if (currentWindowGlobal) { + currentWindowGlobal + .getActor("WebChannel") + .sendAsyncMessage("WebChannelMessageToContent", { + id, + message: { + errno: errorNo, + error: errorMsg, + }, + eventTarget, + principal, + }); + } else { + console.error("Failed to send a WebChannel error. Target invalid."); + } + console.error(id.toString() + " error message. ", errorMsg); + } +} diff --git a/toolkit/actors/docs/picture-in-picture-child-video-wrapper-api.rst b/toolkit/actors/docs/picture-in-picture-child-video-wrapper-api.rst new file mode 100644 index 0000000000..2e33741539 --- /dev/null +++ b/toolkit/actors/docs/picture-in-picture-child-video-wrapper-api.rst @@ -0,0 +1,6 @@ +.. _picture_in_picture_child_video_wrapper_api: + +PictureInPictureChildVideoWrapper Reference +=========================================== +.. js:autoclass:: PictureInPictureChildVideoWrapper + :members: diff --git a/toolkit/actors/moz.build b/toolkit/actors/moz.build new file mode 100644 index 0000000000..a0a987a5e6 --- /dev/null +++ b/toolkit/actors/moz.build @@ -0,0 +1,85 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +with Files("**"): + BUG_COMPONENT = ("Toolkit", "General") + +with Files("AutoScroll*.jsm"): + BUG_COMPONENT = ("Core", "Panning and Zooming") + +with Files("Finder*.jsm"): + BUG_COMPONENT = ("Toolkit", "Find Toolbar") + +with Files("KeyPressEventModelCheckerChild.sys.mjs"): + BUG_COMPONENT = ("Core", "DOM: Events") + +with Files("PictureInPictureChild.sys.mjs"): + BUG_COMPONENT = ("Toolkit", "Picture-in-Picture") + +SPHINX_TREES["actors"] = "docs" + +TESTING_JS_MODULES += [ + "TestProcessActorChild.jsm", + "TestProcessActorChild.sys.mjs", + "TestProcessActorParent.jsm", + "TestProcessActorParent.sys.mjs", + "TestWindowChild.jsm", + "TestWindowChild.sys.mjs", + "TestWindowParent.jsm", + "TestWindowParent.sys.mjs", +] + +FINAL_TARGET_FILES.actors += [ + "AboutHttpsOnlyErrorChild.sys.mjs", + "AboutHttpsOnlyErrorParent.sys.mjs", + "AudioPlaybackChild.sys.mjs", + "AudioPlaybackParent.sys.mjs", + "AutoCompleteChild.sys.mjs", + "AutoCompleteParent.sys.mjs", + "AutoplayChild.sys.mjs", + "AutoplayParent.sys.mjs", + "AutoScrollChild.sys.mjs", + "AutoScrollParent.sys.mjs", + "BackgroundThumbnailsChild.sys.mjs", + "BrowserElementChild.sys.mjs", + "BrowserElementParent.sys.mjs", + "ClipboardReadPasteChild.sys.mjs", + "ClipboardReadPasteParent.sys.mjs", + "ContentMetaChild.sys.mjs", + "ContentMetaParent.sys.mjs", + "ControllersChild.sys.mjs", + "ControllersParent.sys.mjs", + "DateTimePickerChild.sys.mjs", + "DateTimePickerParent.sys.mjs", + "ExtFindChild.sys.mjs", + "FindBarChild.sys.mjs", + "FindBarParent.sys.mjs", + "FinderChild.sys.mjs", + "InlineSpellCheckerChild.sys.mjs", + "InlineSpellCheckerParent.sys.mjs", + "KeyPressEventModelCheckerChild.sys.mjs", + "NetErrorChild.sys.mjs", + "NetErrorParent.sys.mjs", + "PictureInPictureChild.sys.mjs", + "PopupBlockingChild.sys.mjs", + "PopupBlockingParent.sys.mjs", + "PrintingChild.sys.mjs", + "PrintingParent.sys.mjs", + "PrintingSelectionChild.sys.mjs", + "PurgeSessionHistoryChild.sys.mjs", + "RemotePageChild.sys.mjs", + "SelectChild.sys.mjs", + "SelectParent.sys.mjs", + "ThumbnailsChild.sys.mjs", + "UAWidgetsChild.sys.mjs", + "UnselectedTabHoverChild.sys.mjs", + "UnselectedTabHoverParent.sys.mjs", + "ViewSourceChild.sys.mjs", + "ViewSourcePageChild.sys.mjs", + "ViewSourcePageParent.sys.mjs", + "WebChannelChild.sys.mjs", + "WebChannelParent.sys.mjs", +] |