summaryrefslogtreecommitdiffstats
path: root/toolkit/actors
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /toolkit/actors
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/actors')
-rw-r--r--toolkit/actors/AboutHttpsOnlyErrorChild.sys.mjs83
-rw-r--r--toolkit/actors/AboutHttpsOnlyErrorParent.sys.mjs48
-rw-r--r--toolkit/actors/AudioPlaybackChild.sys.mjs20
-rw-r--r--toolkit/actors/AudioPlaybackParent.sys.mjs42
-rw-r--r--toolkit/actors/AutoCompleteChild.sys.mjs197
-rw-r--r--toolkit/actors/AutoCompleteParent.sys.mjs513
-rw-r--r--toolkit/actors/AutoScrollChild.sys.mjs442
-rw-r--r--toolkit/actors/AutoScrollParent.sys.mjs48
-rw-r--r--toolkit/actors/AutoplayChild.sys.mjs10
-rw-r--r--toolkit/actors/AutoplayParent.sys.mjs17
-rw-r--r--toolkit/actors/BackgroundThumbnailsChild.sys.mjs101
-rw-r--r--toolkit/actors/BrowserElementChild.sys.mjs35
-rw-r--r--toolkit/actors/BrowserElementParent.sys.mjs36
-rw-r--r--toolkit/actors/ContentMetaChild.sys.mjs199
-rw-r--r--toolkit/actors/ContentMetaParent.sys.mjs23
-rw-r--r--toolkit/actors/ControllersChild.sys.mjs63
-rw-r--r--toolkit/actors/ControllersParent.sys.mjs90
-rw-r--r--toolkit/actors/DateTimePickerChild.sys.mjs204
-rw-r--r--toolkit/actors/DateTimePickerParent.sys.mjs157
-rw-r--r--toolkit/actors/ExtFindChild.sys.mjs31
-rw-r--r--toolkit/actors/FindBarChild.sys.mjs168
-rw-r--r--toolkit/actors/FindBarParent.sys.mjs47
-rw-r--r--toolkit/actors/FinderChild.sys.mjs129
-rw-r--r--toolkit/actors/InlineSpellCheckerChild.sys.mjs38
-rw-r--r--toolkit/actors/InlineSpellCheckerParent.sys.mjs50
-rw-r--r--toolkit/actors/KeyPressEventModelCheckerChild.sys.mjs107
-rw-r--r--toolkit/actors/NetErrorChild.sys.mjs244
-rw-r--r--toolkit/actors/NetErrorParent.sys.mjs342
-rw-r--r--toolkit/actors/PictureInPictureChild.sys.mjs3162
-rw-r--r--toolkit/actors/PopupBlockingChild.sys.mjs147
-rw-r--r--toolkit/actors/PopupBlockingParent.sys.mjs268
-rw-r--r--toolkit/actors/PrintingChild.sys.mjs260
-rw-r--r--toolkit/actors/PrintingParent.sys.mjs22
-rw-r--r--toolkit/actors/PrintingSelectionChild.sys.mjs20
-rw-r--r--toolkit/actors/PurgeSessionHistoryChild.sys.mjs35
-rw-r--r--toolkit/actors/RemotePageChild.sys.mjs219
-rw-r--r--toolkit/actors/SelectChild.sys.mjs490
-rw-r--r--toolkit/actors/SelectParent.sys.mjs808
-rw-r--r--toolkit/actors/TestProcessActorChild.jsm59
-rw-r--r--toolkit/actors/TestProcessActorChild.sys.mjs56
-rw-r--r--toolkit/actors/TestProcessActorParent.jsm41
-rw-r--r--toolkit/actors/TestProcessActorParent.sys.mjs38
-rw-r--r--toolkit/actors/TestWindowChild.jsm102
-rw-r--r--toolkit/actors/TestWindowChild.sys.mjs99
-rw-r--r--toolkit/actors/TestWindowParent.jsm54
-rw-r--r--toolkit/actors/TestWindowParent.sys.mjs51
-rw-r--r--toolkit/actors/ThumbnailsChild.sys.mjs60
-rw-r--r--toolkit/actors/UAWidgetsChild.sys.mjs236
-rw-r--r--toolkit/actors/UnselectedTabHoverChild.sys.mjs20
-rw-r--r--toolkit/actors/UnselectedTabHoverParent.sys.mjs15
-rw-r--r--toolkit/actors/ViewSourceChild.sys.mjs346
-rw-r--r--toolkit/actors/ViewSourcePageChild.sys.mjs472
-rw-r--r--toolkit/actors/ViewSourcePageParent.sys.mjs165
-rw-r--r--toolkit/actors/WebChannelChild.sys.mjs132
-rw-r--r--toolkit/actors/WebChannelParent.sys.mjs92
-rw-r--r--toolkit/actors/docs/picture-in-picture-child-video-wrapper-api.rst6
-rw-r--r--toolkit/actors/moz.build83
57 files changed, 11042 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..3d4ed65daa
--- /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/. */
+
+import { HomePage } from "resource:///modules/HomePage.sys.mjs";
+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..4cd9b9f3de
--- /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 || !element?.isConnected) {
+ 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..27f3dcbe07
--- /dev/null
+++ b/toolkit/actors/AutoCompleteParent.sys.mjs
@@ -0,0 +1,513 @@
+/* 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.defineLazyPreferenceGetter(
+ lazy,
+ "DELEGATE_AUTOCOMPLETE",
+ "toolkit.autocomplete.delegate",
+ false
+);
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ GeckoViewAutocomplete: "resource://gre/modules/GeckoViewAutocomplete.sys.mjs",
+ 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..25e2ae77a5
--- /dev/null
+++ b/toolkit/actors/AutoScrollChild.sys.mjs
@@ -0,0 +1,442 @@
+/* -*- 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;
+ }
+ }
+ 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 &&
+ Services.prefs.getBoolPref("general.autoscroll", false)
+ ) {
+ // 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..d2d168cda4
--- /dev/null
+++ b/toolkit/actors/BackgroundThumbnailsChild.sys.mjs
@@ -0,0 +1,101 @@
+/* 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.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/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..645456ad1f
--- /dev/null
+++ b/toolkit/actors/FindBarChild.sys.mjs
@@ -0,0 +1,168 @@
+/* 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;
+
+ this.inQuickFind = false;
+ this.inPassThrough = false;
+
+ ChromeUtils.defineLazyGetter(this, "FindBarContent", () => {
+ const { FindBarContent } = ChromeUtils.importESModule(
+ "resource://gre/modules/FindBarContent.sys.mjs"
+ );
+
+ let findBarContent = new FindBarContent(this);
+
+ Object.defineProperties(this, {
+ inQuickFind: {
+ get() {
+ return findBarContent.inQuickFind;
+ },
+ },
+ inPassThrough: {
+ get() {
+ return findBarContent.inPassThrough;
+ },
+ },
+ });
+
+ return findBarContent;
+ });
+ }
+
+ receiveMessage(msg) {
+ if (msg.name == "Findbar:UpdateState") {
+ 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) {
+ if (!this.inPassThrough && this.eventMatchesFindShortcut(event)) {
+ return this.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 (this.inPassThrough || this.inQuickFind) {
+ return this.FindBarContent.onKeypress(event);
+ }
+
+ if (event.charCode && this.shouldFastFind(event.target)) {
+ let key = String.fromCharCode(event.charCode);
+ if ((key == "/" || key == "'") && FindBarChild.manualFAYT) {
+ return this.FindBarContent.startQuickFind(event);
+ }
+ if (key != " " && FindBarChild.findAsYouType) {
+ return this.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..ac0dbf49a0
--- /dev/null
+++ b/toolkit/actors/NetErrorParent.sys.mjs
@@ -0,0 +1,342 @@
+/* -*- 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",
+ HomePage: "resource:///modules/HomePage.sys.mjs",
+});
+
+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 "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..62dc6cdfff
--- /dev/null
+++ b/toolkit/actors/PictureInPictureChild.sys.mjs
@@ -0,0 +1,3162 @@
+/* -*- 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.
+ChromeUtils.defineLazyGetter(lazy, "gSiteOverrides", () => {
+ return PictureInPictureToggleChild.getSiteOverrides();
+});
+
+ChromeUtils.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,
+ eventExtraKeys: event.detail?.eventExtraKeys,
+ });
+ }
+ 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
+ * @param {HTMLVideoElement} pipObject.video
+ * @param {String} pipObject.reason What toggled PiP, e.g. "shortcut"
+ * @param {Object} pipObject.eventExtraKeys Extra telemetry keys to record
+ *
+ * @return {Promise}
+ * @resolves {undefined} Once the new Picture-in-Picture window
+ * has been requested.
+ */
+ async togglePictureInPicture(pipObject) {
+ let { video, reason, eventExtraKeys = {} } = 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,
+ volume: PictureInPictureChild.videoWrapper.getVolume(video),
+ });
+
+ Services.telemetry.recordEvent(
+ "pictureinpicture",
+ "opened_method",
+ reason,
+ null,
+ {
+ firstTimeToggle: (!Services.prefs.getBoolPref(
+ TOGGLE_HAS_USED_PREF
+ )).toString(),
+ ...eventExtraKeys,
+ }
+ );
+ }
+
+ /**
+ * 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(message.data);
+ 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.disablePictureInPicture ? 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.disablePictureInPicture ? 1 : 0),
+ 0
+ ),
+ });
+ }
+
+ urlbarToggle(eventExtraKeys) {
+ let video = ChromeUtils.nondeterministicGetWeakSetKeys(
+ this.eligiblePipVideos
+ )[0];
+ if (video) {
+ let pipEvent = new this.contentWindow.CustomEvent(
+ "MozTogglePictureInPicture",
+ {
+ bubbles: true,
+ detail: { reason: "urlBar", eventExtraKeys },
+ }
+ );
+ 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,
+ inputSource: event.inputSource,
+ });
+ 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");
+ }
+ this.sendAsyncMessage("PictureInPicture:VolumeChange", {
+ volume: this.videoWrapper.getVolume(video),
+ });
+ 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;
+ }
+ case "PictureInPicture:SetVolume": {
+ const { volume } = message.data;
+ let video = this.getWeakVideo();
+ this.videoWrapper.setVolume(video, volume);
+ 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) ||
+ this.videoWrapper.isMuted(video)
+ ) {
+ return;
+ }
+ oldval = this.videoWrapper.getVolume(video);
+ newval = oldval < 0.1 ? 0 : oldval - 0.1;
+ this.videoWrapper.setVolume(video, newval);
+ this.videoWrapper.setMuted(video, newval === 0);
+ 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..4fca3ab403
--- /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.docViewer;
+ cv.QueryInterface(Ci.nsIWebBrowserPrint);
+ this.sendAsyncMessage("Printing:Preview:CurrentPage", {
+ currentPage: cv.printPreviewCurrentPageNumber,
+ });
+ }
+
+ navigate(navType, pageNum) {
+ let cv = this.docShell.docViewer;
+ 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..b7cf7b20e9
--- /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",
+ "RPMSetPref",
+ "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);
+ }
+
+ RPMSetPref(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..a6d96d1b79
--- /dev/null
+++ b/toolkit/actors/SelectChild.sys.mjs
@@ -0,0 +1,490 @@
+/* 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
+ {
+ let handlingUserInput = win.windowUtils.setHandlingUserInput(true);
+ try {
+ element.userFinishedInteracting(
+ this.initialSelection !== selectedOption
+ );
+ } 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 = [];
+
+ let lastWasHR = false;
+ for (let child of node.children) {
+ let className = ChromeUtils.getClassName(child);
+ let isOption = className == "HTMLOptionElement";
+ let isOptGroup = className == "HTMLOptGroupElement";
+ let isHR = className == "HTMLHRElement";
+ if (!isOption && !isOptGroup && !isHR) {
+ continue;
+ }
+ if (child.hidden) {
+ continue;
+ }
+
+ let cs = getComputedStyles(child);
+
+ if (isHR) {
+ // https://html.spec.whatwg.org/#the-select-element-2
+ // "Each sequence of one or more child hr element siblings may be rendered as a single separator."
+ if (lastWasHR) {
+ continue;
+ }
+
+ let info = {
+ index: child.index,
+ display: cs.display,
+ isHR,
+ };
+
+ const defaultHRStyle = node.ownerGlobal.getDefaultComputedStyle(child);
+ if (cs.color != defaultHRStyle.color) {
+ info.color = cs.color;
+ }
+
+ result.push(info);
+
+ lastWasHR = true;
+ continue;
+ }
+ lastWasHR = false;
+
+ // The option code-path should match HTMLOptionElement::GetRenderedLabel.
+ let textContent = isOptGroup
+ ? child.getAttribute("label")
+ : child.label || child.text;
+ if (textContent == null) {
+ textContent = "";
+ }
+
+ 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..6d14807e44
--- /dev/null
+++ b/toolkit/actors/SelectParent.sys.mjs
@@ -0,0 +1,808 @@
+/* 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
+);
+
+// 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,
+ custom,
+ 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);
+
+ let menupopup = menulist.menupopup;
+ menupopup.classList.toggle("isOpenedViaTouch", isOpenedViaTouch);
+
+ let win = menulist.ownerGlobal;
+ 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,
+ custom,
+ 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 isHR = option.isHR;
+
+ let xulElement = "menuitem";
+ if (isOptGroup) {
+ xulElement = "menucaption";
+ }
+ if (isHR) {
+ xulElement = "menuseparator";
+ }
+
+ let item = element.ownerDocument.createXULElement(xulElement);
+ item.hidden =
+ option.display == "none" || (parentElement && parentElement.hidden);
+
+ 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++;
+
+ if (isHR) {
+ item.style.color = (custom && option.color) || "";
+
+ // Continue early as HRs do not have other attributes.
+ continue;
+ }
+
+ item.className = `ContentSelectDropdown-item-${option.styleIndex}`;
+
+ if (isOptGroup) {
+ item.setAttribute("role", "group");
+ }
+ item.setAttribute("label", option.textContent);
+ // 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");
+ }
+
+ // 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,
+ custom,
+ 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..8a504737d7
--- /dev/null
+++ b/toolkit/actors/TestWindowParent.jsm
@@ -0,0 +1,54 @@
+/* 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;
+ case "messagePort": {
+ const { port } = aMessage.data;
+ port.postMessage("Message sent from parent over a MessagePort.");
+ port.close();
+ }
+ }
+
+ 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..0befbea65e
--- /dev/null
+++ b/toolkit/actors/TestWindowParent.sys.mjs
@@ -0,0 +1,51 @@
+/* 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;
+ case "messagePort": {
+ const { port } = aMessage.data;
+ port.postMessage("Message sent from parent over a MessagePort.");
+ port.close();
+ }
+ }
+
+ 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..d5c2ba46cd
--- /dev/null
+++ b/toolkit/actors/ViewSourcePageChild.sys.mjs
@@ -0,0 +1,472 @@
+/* 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";
+
+// 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();
+
+ ChromeUtils.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..8e2d8a5efc
--- /dev/null
+++ b/toolkit/actors/moz.build
@@ -0,0 +1,83 @@
+# -*- 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*.sys.mjs"):
+ BUG_COMPONENT = ("Core", "Panning and Zooming")
+
+with Files("Finder*.sys.mjs"):
+ 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",
+ "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",
+]