summaryrefslogtreecommitdiffstats
path: root/toolkit/actors
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/actors')
-rw-r--r--toolkit/actors/AboutHttpsOnlyErrorChild.jsm13
-rw-r--r--toolkit/actors/AboutHttpsOnlyErrorParent.jsm127
-rw-r--r--toolkit/actors/AudioPlaybackChild.jsm23
-rw-r--r--toolkit/actors/AudioPlaybackParent.jsm45
-rw-r--r--toolkit/actors/AutoCompleteChild.jsm211
-rw-r--r--toolkit/actors/AutoCompleteParent.jsm536
-rw-r--r--toolkit/actors/AutoScrollChild.jsm392
-rw-r--r--toolkit/actors/AutoScrollParent.jsm33
-rw-r--r--toolkit/actors/AutoplayChild.jsm13
-rw-r--r--toolkit/actors/AutoplayParent.jsm20
-rw-r--r--toolkit/actors/BackgroundThumbnailsChild.jsm100
-rw-r--r--toolkit/actors/BrowserElementChild.jsm38
-rw-r--r--toolkit/actors/BrowserElementParent.jsm39
-rw-r--r--toolkit/actors/ControllersChild.jsm35
-rw-r--r--toolkit/actors/ControllersParent.jsm100
-rw-r--r--toolkit/actors/DateTimePickerChild.jsm213
-rw-r--r--toolkit/actors/DateTimePickerParent.jsm138
-rw-r--r--toolkit/actors/ExtFindChild.jsm34
-rw-r--r--toolkit/actors/FindBarChild.jsm158
-rw-r--r--toolkit/actors/FindBarParent.jsm50
-rw-r--r--toolkit/actors/FinderChild.jsm127
-rw-r--r--toolkit/actors/InlineSpellCheckerChild.jsm41
-rw-r--r--toolkit/actors/InlineSpellCheckerParent.jsm52
-rw-r--r--toolkit/actors/KeyPressEventModelCheckerChild.jsm116
-rw-r--r--toolkit/actors/PictureInPictureChild.jsm1632
-rw-r--r--toolkit/actors/PopupBlockingChild.jsm159
-rw-r--r--toolkit/actors/PopupBlockingParent.jsm271
-rw-r--r--toolkit/actors/PrintingChild.jsm498
-rw-r--r--toolkit/actors/PrintingParent.jsm113
-rw-r--r--toolkit/actors/PrintingSelectionChild.jsm30
-rw-r--r--toolkit/actors/PurgeSessionHistoryChild.jsm37
-rw-r--r--toolkit/actors/RemotePageChild.jsm228
-rw-r--r--toolkit/actors/SelectChild.jsm474
-rw-r--r--toolkit/actors/SelectParent.jsm771
-rw-r--r--toolkit/actors/TestProcessActorChild.jsm61
-rw-r--r--toolkit/actors/TestProcessActorParent.jsm41
-rw-r--r--toolkit/actors/TestWindowChild.jsm104
-rw-r--r--toolkit/actors/TestWindowParent.jsm51
-rw-r--r--toolkit/actors/ThumbnailsChild.jsm62
-rw-r--r--toolkit/actors/UAWidgetsChild.jsm241
-rw-r--r--toolkit/actors/UnselectedTabHoverChild.jsm25
-rw-r--r--toolkit/actors/UnselectedTabHoverParent.jsm18
-rw-r--r--toolkit/actors/ViewSourceChild.jsm353
-rw-r--r--toolkit/actors/ViewSourcePageChild.jsm555
-rw-r--r--toolkit/actors/ViewSourcePageParent.jsm159
-rw-r--r--toolkit/actors/WebChannelChild.jsm140
-rw-r--r--toolkit/actors/WebChannelParent.jsm96
-rw-r--r--toolkit/actors/moz.build70
48 files changed, 8843 insertions, 0 deletions
diff --git a/toolkit/actors/AboutHttpsOnlyErrorChild.jsm b/toolkit/actors/AboutHttpsOnlyErrorChild.jsm
new file mode 100644
index 0000000000..d0b621ef5d
--- /dev/null
+++ b/toolkit/actors/AboutHttpsOnlyErrorChild.jsm
@@ -0,0 +1,13 @@
+/* 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 = ["AboutHttpsOnlyErrorChild"];
+
+const { RemotePageChild } = ChromeUtils.import(
+ "resource://gre/actors/RemotePageChild.jsm"
+);
+
+class AboutHttpsOnlyErrorChild extends RemotePageChild {}
diff --git a/toolkit/actors/AboutHttpsOnlyErrorParent.jsm b/toolkit/actors/AboutHttpsOnlyErrorParent.jsm
new file mode 100644
index 0000000000..b949cb065a
--- /dev/null
+++ b/toolkit/actors/AboutHttpsOnlyErrorParent.jsm
@@ -0,0 +1,127 @@
+/* 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 = ["AboutHttpsOnlyErrorParent"];
+
+const { HomePage } = ChromeUtils.import("resource:///modules/HomePage.jsm");
+const { PrivateBrowsingUtils } = ChromeUtils.import(
+ "resource://gre/modules/PrivateBrowsingUtils.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { SessionStore } = ChromeUtils.import(
+ "resource:///modules/sessionstore/SessionStore.jsm"
+);
+
+class AboutHttpsOnlyErrorParent extends JSWindowActorParent {
+ get browser() {
+ return this.browsingContext.top.embedderElement;
+ }
+
+ receiveMessage(aMessage) {
+ switch (aMessage.name) {
+ case "goBack":
+ this.goBackFromErrorPage(this.browser.ownerGlobal);
+ break;
+ case "openInsecure":
+ this.openWebsiteInsecure(this.browser, aMessage.data.inFrame);
+ break;
+ }
+ }
+
+ goBackFromErrorPage(aWindow) {
+ if (!aWindow.gBrowser) {
+ return;
+ }
+
+ let state = JSON.parse(
+ SessionStore.getTabState(aWindow.gBrowser.selectedTab)
+ );
+ if (state.index == 1) {
+ // If the unsafe page is the first or the only one in history, go to the
+ // start page.
+ aWindow.gBrowser.loadURI(this.getDefaultHomePage(aWindow), {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ } else {
+ aWindow.gBrowser.goBack();
+ }
+ }
+
+ openWebsiteInsecure(aBrowser, aIsIFrame) {
+ // No matter if the the error-page shows up within an iFrame or not, we always
+ // create an exception for the top-level page.
+ const currentURI = aBrowser.currentURI;
+ const isViewSource = currentURI.schemeIs("view-source");
+
+ let innerURI = isViewSource
+ ? currentURI.QueryInterface(Ci.nsINestedURI).innerURI
+ : currentURI;
+
+ if (!innerURI.schemeIs("https") && !innerURI.schemeIs("http")) {
+ // This should never happen
+ throw new Error(
+ "Exceptions can only be created for http or https sites."
+ );
+ }
+
+ // If the error page is within an iFrame, we create an exception for whatever
+ // scheme the top-level site is currently on, because the user wants to
+ // unbreak the iFrame and not the top-level page. When the error page shows up
+ // on a top-level request, then we replace the scheme with http, because the
+ // user wants to unbreak the whole page.
+ let newURI = aIsIFrame
+ ? innerURI
+ : innerURI
+ .mutate()
+ .setScheme("http")
+ .finalize();
+
+ const oldOriginAttributes = aBrowser.contentPrincipal.originAttributes;
+ const hasFpiAttribute = !!oldOriginAttributes.firstPartyDomain.length;
+
+ // Create new content principal for the permission. If first-party isolation
+ // is enabled, we have to replace the about-page first-party domain with the
+ // one from the exempt website.
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ newURI,
+ {
+ ...oldOriginAttributes,
+ firstPartyDomain: hasFpiAttribute
+ ? Services.eTLD.getBaseDomain(newURI)
+ : "",
+ }
+ );
+
+ // Create exception for this website that expires with the session.
+ Services.perms.addFromPrincipal(
+ principal,
+ "https-only-load-insecure",
+ Ci.nsIHttpsOnlyModePermission.LOAD_INSECURE_ALLOW_SESSION,
+ Ci.nsIPermissionManager.EXPIRE_SESSION
+ );
+
+ const insecureSpec = isViewSource
+ ? `view-source:${newURI.spec}`
+ : newURI.spec;
+ aBrowser.loadURI(insecureSpec, {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY,
+ });
+ }
+
+ getDefaultHomePage(win) {
+ let url = win.BROWSER_NEW_TAB_URL;
+ if (PrivateBrowsingUtils.isWindowPrivate(win)) {
+ return url;
+ }
+ 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.jsm b/toolkit/actors/AudioPlaybackChild.jsm
new file mode 100644
index 0000000000..7c1e624743
--- /dev/null
+++ b/toolkit/actors/AudioPlaybackChild.jsm
@@ -0,0 +1,23 @@
+/* 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 = ["AudioPlaybackChild"];
+
+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.jsm b/toolkit/actors/AudioPlaybackParent.jsm
new file mode 100644
index 0000000000..5c54058528
--- /dev/null
+++ b/toolkit/actors/AudioPlaybackParent.jsm
@@ -0,0 +1,45 @@
+/* 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 = ["AudioPlaybackParent"];
+
+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.jsm b/toolkit/actors/AutoCompleteChild.jsm
new file mode 100644
index 0000000000..b860d10c21
--- /dev/null
+++ b/toolkit/actors/AutoCompleteChild.jsm
@@ -0,0 +1,211 @@
+/* -*- 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/. */
+
+var EXPORTED_SYMBOLS = ["AutoCompleteChild"];
+
+/* eslint no-unused-vars: ["error", {args: "none"}] */
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "BrowserUtils",
+ "resource://gre/modules/BrowserUtils.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ContentDOMReference",
+ "resource://gre/modules/ContentDOMReference.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "LoginHelper",
+ "resource://gre/modules/LoginHelper.jsm"
+);
+
+let autoCompleteListeners = new Set();
+
+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) {
+ Cu.reportError(ex);
+ }
+ }
+ }
+
+ get input() {
+ return this._input;
+ }
+
+ set selectedIndex(index) {
+ this.sendAsyncMessage("FormAutoComplete:SetSelectedIndex", { index });
+ }
+
+ get selectedIndex() {
+ // selectedIndex getter must be synchronous because we need the
+ // correct value when the controller is in controller::HandleEnter.
+ // We can't easily just let the parent inform us the new value every
+ // time it changes because not every action that can change the
+ // selectedIndex is trivial to catch (e.g. moving the mouse over the
+ // list).
+ let selectedIndexResult = Services.cpmm.sendSyncMessage(
+ "FormAutoComplete:GetSelectedIndex",
+ {
+ browsingContext: this.browsingContext,
+ }
+ );
+
+ if (
+ selectedIndexResult.length != 1 ||
+ !Number.isInteger(selectedIndexResult[0])
+ ) {
+ throw new Error("Invalid autocomplete selectedIndex");
+ }
+ return selectedIndexResult[0];
+ }
+
+ get popupOpen() {
+ return this._popupOpen;
+ }
+
+ openAutocompletePopup(input, element) {
+ if (this._popupOpen || !input) {
+ return;
+ }
+
+ let rect = BrowserUtils.getElementBoundingScreenRect(element);
+ let window = element.ownerGlobal;
+ let dir = window.getComputedStyle(element).direction;
+ let results = this.getResultsFromController(input);
+ let formOrigin = LoginHelper.getLoginOrigin(
+ element.ownerDocument.documentURI
+ );
+ let inputElementIdentifier = 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.jsm b/toolkit/actors/AutoCompleteParent.jsm
new file mode 100644
index 0000000000..8204034568
--- /dev/null
+++ b/toolkit/actors/AutoCompleteParent.jsm
@@ -0,0 +1,536 @@
+/* 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 = ["AutoCompleteParent"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ GeckoViewAutocomplete: "resource://gre/modules/GeckoViewAutocomplete.jsm",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "DELEGATE_AUTOCOMPLETE",
+ "toolkit.autocomplete.delegate",
+ false
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "setTimeout",
+ "resource://gre/modules/Timer.jsm"
+);
+
+const PREF_SECURITY_DELAY = "security.notification_enable_delay";
+
+// Stores the browser and actor that has the active popup, used by formfill
+let currentBrowserWeakRef = null;
+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;
+ },
+};
+
+class AutoCompleteParent extends JSWindowActorParent {
+ didDestroy() {
+ if (this.openedPopup) {
+ this.openedPopup.closePopup();
+ }
+ }
+
+ static getCurrentActor() {
+ return currentActor;
+ }
+
+ static getCurrentBrowser() {
+ return currentBrowserWeakRef ? currentBrowserWeakRef.get() : null;
+ }
+
+ 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;
+ currentBrowserWeakRef = 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));
+ currentBrowserWeakRef = Cu.getWeakReference(browser);
+ 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.setAttribute("width", Math.max(100, rect.width));
+ 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;
+ }
+ 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);
+ 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;
+
+ delete rawExtraData.formHostname;
+
+ // Add counts by result style to rawExtraData.
+ results.reduce((accumulated, r) => {
+ // Ignore learn more as it is only added after importable logins.
+ if (r.style === "importableLearnMore") {
+ 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 || (!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 (DELEGATE_AUTOCOMPLETE) {
+ 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": {
+ this.closePopup();
+ break;
+ }
+
+ case "FormAutoComplete:Disconnect": {
+ // The controller stopped controlling the current input, so clear
+ // any cached data. This is necessary cause otherwise we'd clear data
+ // only when starting a new search, but the next input could not support
+ // autocomplete and it would end up inheriting the existing data.
+ AutoCompleteResultView.clearResults();
+ 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));
+
+ 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) {
+ Cu.reportError(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.jsm b/toolkit/actors/AutoScrollChild.jsm
new file mode 100644
index 0000000000..4ca86090ee
--- /dev/null
+++ b/toolkit/actors/AutoScrollChild.jsm
@@ -0,0 +1,392 @@
+/* -*- 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 { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+var EXPORTED_SYMBOLS = ["AutoScrollChild"];
+
+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(node) {
+ let mmPaste = Services.prefs.getBoolPref("middlemouse.paste");
+ let mmScrollbarPosition = Services.prefs.getBoolPref(
+ "middlemouse.scrollbarPosition"
+ );
+ let content = node.ownerGlobal;
+
+ while (node) {
+ if (
+ (node instanceof content.HTMLAnchorElement ||
+ node instanceof content.HTMLAreaElement) &&
+ node.hasAttribute("href")
+ ) {
+ return true;
+ }
+
+ if (
+ mmPaste &&
+ (node instanceof content.HTMLInputElement ||
+ node instanceof content.HTMLTextAreaElement)
+ ) {
+ return true;
+ }
+
+ if (
+ node instanceof content.XULElement &&
+ ((mmScrollbarPosition &&
+ (node.localName == "scrollbar" ||
+ node.localName == "scrollcorner")) ||
+ node.localName == "treechildren")
+ ) {
+ return true;
+ }
+
+ node = node.parentNode;
+ }
+ return false;
+ }
+
+ isScrollableElement(aNode) {
+ let content = aNode.ownerGlobal;
+ if (aNode instanceof content.HTMLElement) {
+ return !(aNode instanceof content.HTMLSelectElement) || aNode.multiple;
+ }
+
+ return aNode instanceof content.XULElement;
+ }
+
+ 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 &&
+ (node instanceof global.HTMLSelectElement ||
+ scrollingAllowed.includes(overflowy));
+
+ // do not allow horizontal scrolling for select elements, it leads
+ // to visual artifacts and is not the expected behavior anyway
+ if (
+ !(node instanceof global.HTMLSelectElement) &&
+ 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,
+ screenX: event.screenX,
+ screenY: event.screenY,
+ scrollId: this._scrollId,
+ presShellId,
+ browsingContext: this.browsingContext,
+ }
+ );
+ if (!autoscrollEnabled) {
+ this._scrollable = null;
+ return;
+ }
+
+ Services.els.addSystemEventListener(this.document, "mousemove", this, true);
+ this.document.addEventListener("pagehide", this, true);
+
+ this._ignoreMouseEvents = 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);
+
+ const kAutoscroll = 15; // defined in mozilla/layers/ScrollInputMethods.h
+ Services.telemetry
+ .getHistogramById("SCROLL_INPUT_METHODS")
+ .add(kAutoscroll);
+ }
+
+ stopScroll() {
+ if (this._scrollable) {
+ this._scrollable.mozScrollSnap();
+ this._scrollable = null;
+
+ Services.els.removeSystemEventListener(
+ this.document,
+ "mousemove",
+ 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);
+ }
+
+ handleEvent(event) {
+ if (event.type == "mousemove") {
+ this._screenX = event.screenX;
+ this._screenY = event.screenY;
+ } else if (event.type == "mousedown") {
+ if (
+ event.isTrusted & !event.defaultPrevented &&
+ event.button == 1 &&
+ !this._scrollable &&
+ !this.isAutoscrollBlocker(event.originalTarget)
+ ) {
+ this.startScroll(event);
+ }
+ } else if (event.type == "pagehide") {
+ if (this._scrollable) {
+ var doc = this._scrollable.ownerDocument || this._scrollable.document;
+ if (doc == event.target) {
+ this.sendAsyncMessage("Autoscroll:Cancel");
+ this.stopScroll();
+ }
+ }
+ }
+ }
+
+ 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.jsm b/toolkit/actors/AutoScrollParent.jsm
new file mode 100644
index 0000000000..f5e6b7b8c3
--- /dev/null
+++ b/toolkit/actors/AutoScrollParent.jsm
@@ -0,0 +1,33 @@
+/* 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 = ["AutoScrollParent"];
+
+class AutoScrollParent extends JSWindowActorParent {
+ receiveMessage(msg) {
+ let browser = this.manager.browsingContext.top.embedderElement;
+ if (!browser) {
+ return null;
+ }
+
+ let data = msg.data;
+ switch (msg.name) {
+ case "Autoscroll:Start":
+ return Promise.resolve(browser.startScroll(data));
+ case "Autoscroll:MaybeStartInParent":
+ 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.jsm b/toolkit/actors/AutoplayChild.jsm
new file mode 100644
index 0000000000..54071266ce
--- /dev/null
+++ b/toolkit/actors/AutoplayChild.jsm
@@ -0,0 +1,13 @@
+/* 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 = ["AutoplayChild"];
+
+class AutoplayChild extends JSWindowActorChild {
+ handleEvent(event) {
+ this.sendAsyncMessage("GloballyAutoplayBlocked", {});
+ }
+}
diff --git a/toolkit/actors/AutoplayParent.jsm b/toolkit/actors/AutoplayParent.jsm
new file mode 100644
index 0000000000..4daa791630
--- /dev/null
+++ b/toolkit/actors/AutoplayParent.jsm
@@ -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/. */
+"use strict";
+
+var EXPORTED_SYMBOLS = ["AutoplayParent"];
+
+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.jsm b/toolkit/actors/BackgroundThumbnailsChild.jsm
new file mode 100644
index 0000000000..da365a4288
--- /dev/null
+++ b/toolkit/actors/BackgroundThumbnailsChild.jsm
@@ -0,0 +1,100 @@
+/* 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 = ["BackgroundThumbnailsChild"];
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "PageThumbUtils",
+ "resource://gre/modules/PageThumbUtils.jsm"
+);
+
+// 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;
+
+class BackgroundThumbnailsChild extends JSWindowActorChild {
+ receiveMessage(message) {
+ switch (message.name) {
+ case "Browser:Thumbnail:ContentInfo": {
+ if (
+ message.data.isImage ||
+ this.document instanceof this.contentWindow.ImageDocument
+ ) {
+ // To avoid sending additional messages between processes, we return
+ // the image data directly with the size info.
+ return PageThumbUtils.createImageThumbnailCanvas(
+ this.contentWindow,
+ this.document.location,
+ message.data.targetWidth,
+ message.data.backgroundColor
+ );
+ }
+
+ let [width, height] = PageThumbUtils.getContentSize(this.contentWindow);
+ return { width, height };
+ }
+
+ case "Browser:Thumbnail:LoadURL": {
+ let docShell = this.docShell.QueryInterface(Ci.nsIWebNavigation);
+
+ // We want a low network priority for this service - lower than b/g tabs
+ // etc - so set it to the lowest priority available.
+ docShell
+ .QueryInterface(Ci.nsIDocumentLoader)
+ .loadGroup.QueryInterface(Ci.nsISupportsPriority).priority =
+ Ci.nsISupportsPriority.PRIORITY_LOWEST;
+
+ docShell.allowMedia = false;
+ docShell.allowPlugins = false;
+ docShell.allowContentRetargeting = false;
+ let defaultFlags =
+ Ci.nsIRequest.LOAD_ANONYMOUS |
+ Ci.nsIRequest.LOAD_BYPASS_CACHE |
+ Ci.nsIRequest.INHIBIT_CACHING |
+ Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_HISTORY;
+ docShell.defaultLoadFlags = defaultFlags;
+ this.browsingContext.sandboxFlags |= SANDBOXED_AUXILIARY_NAVIGATION;
+ docShell.useTrackingProtection = true;
+
+ // Get the document to force a content viewer to be created, otherwise
+ // the first load can fail.
+ if (!this.document) {
+ return false;
+ }
+
+ let loadURIOptions = {
+ // Bug 1498603 verify usages of systemPrincipal here
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_STOP_CONTENT,
+ };
+ try {
+ docShell.loadURI(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.jsm b/toolkit/actors/BrowserElementChild.jsm
new file mode 100644
index 0000000000..ab02a52a7f
--- /dev/null
+++ b/toolkit/actors/BrowserElementChild.jsm
@@ -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/. */
+"use strict";
+
+var EXPORTED_SYMBOLS = ["BrowserElementChild"];
+
+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.jsm b/toolkit/actors/BrowserElementParent.jsm
new file mode 100644
index 0000000000..4e6d36d677
--- /dev/null
+++ b/toolkit/actors/BrowserElementParent.jsm
@@ -0,0 +1,39 @@
+/* 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 = ["BrowserElementParent"];
+
+/**
+ * The BrowserElementParent is for performing actions on one or more subframes of
+ * a <xul:browser> from the browser element binding.
+ */
+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/ControllersChild.jsm b/toolkit/actors/ControllersChild.jsm
new file mode 100644
index 0000000000..2a4e9e4cb6
--- /dev/null
+++ b/toolkit/actors/ControllersChild.jsm
@@ -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/. */
+"use strict";
+
+var EXPORTED_SYMBOLS = ["ControllersChild"];
+
+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();
+ for (var name in data.params) {
+ var value = data.params[name];
+ if (value.type == "long") {
+ 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.jsm b/toolkit/actors/ControllersParent.jsm
new file mode 100644
index 0000000000..3f912076cb
--- /dev/null
+++ b/toolkit/actors/ControllersParent.jsm
@@ -0,0 +1,100 @@
+/* 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 = ["ControllersParent"];
+
+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") {
+ // Although getBoundingClientRect of the element is logical pixel, but
+ // x and y parameter of cmd_lookUpDictionary are device pixel.
+ // So we need calculate child process's coordinate using correct unit.
+ let browser = this.browser;
+ let rect = browser.getBoundingClientRect();
+ let scale = browser.ownerGlobal.devicePixelRatio;
+ cmd.params = {
+ x: {
+ type: "long",
+ value: aCommandParams.getLongValue("x") - rect.left * scale,
+ },
+ y: {
+ type: "long",
+ value: aCommandParams.getLongValue("y") - rect.top * scale,
+ },
+ };
+ } 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.jsm b/toolkit/actors/DateTimePickerChild.jsm
new file mode 100644
index 0000000000..0bfce7f0a7
--- /dev/null
+++ b/toolkit/actors/DateTimePickerChild.jsm
@@ -0,0 +1,213 @@
+/* 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 { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+ChromeUtils.defineModuleGetter(
+ this,
+ "BrowserUtils",
+ "resource://gre/modules/BrowserUtils.jsm"
+);
+
+var EXPORTED_SYMBOLS = ["DateTimePickerChild"];
+
+/**
+ * DateTimePickerChild is the communication channel between the input box
+ * (content) for date/time input types and its picker (chrome).
+ */
+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;
+ }
+
+ if (this._inputElement.openOrClosedShadowRoot) {
+ // 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 BrowserUtils.getElementBoundingScreenRect(aElement);
+ }
+
+ getTimePickerPref() {
+ return Services.prefs.getBoolPref("dom.forms.datetime.timepicker");
+ }
+
+ /**
+ * nsIMessageListener.
+ */
+ receiveMessage(aMessage) {
+ switch (aMessage.name) {
+ case "FormDateTime:PickerClosed": {
+ this.close();
+ break;
+ }
+ case "FormDateTime:PickerValueChanged": {
+ if (!this._inputElement) {
+ return;
+ }
+
+ let dateTimeBoxElement = this._inputElement.dateTimeBoxElement;
+ if (!dateTimeBoxElement) {
+ return;
+ }
+
+ let win = this._inputElement.ownerGlobal;
+
+ if (this._inputElement.openOrClosedShadowRoot) {
+ // 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 instanceof
+ aEvent.originalTarget.ownerGlobal.HTMLInputElement
+ ) ||
+ (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 or XBL binding?"
+ );
+ }
+
+ if (this._inputElement.openOrClosedShadowRoot) {
+ // 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.jsm b/toolkit/actors/DateTimePickerParent.jsm
new file mode 100644
index 0000000000..d3296581ca
--- /dev/null
+++ b/toolkit/actors/DateTimePickerParent.jsm
@@ -0,0 +1,138 @@
+/* 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";
+
+const DEBUG = false;
+function debug(aStr) {
+ if (DEBUG) {
+ dump("-*- DateTimePickerParent: " + aStr + "\n");
+ }
+}
+
+var EXPORTED_SYMBOLS = ["DateTimePickerParent"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+ChromeUtils.defineModuleGetter(
+ this,
+ "DateTimePickerPanel",
+ "resource://gre/modules/DateTimePickerPanel.jsm"
+);
+
+/*
+ * 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.
+ */
+class DateTimePickerParent extends JSWindowActorParent {
+ receiveMessage(aMessage) {
+ debug("receiveMessage: " + aMessage.name);
+ switch (aMessage.name) {
+ case "FormDateTime:OpenPicker": {
+ let topBrowsingContext = this.manager.browsingContext.top;
+ let browser = topBrowsingContext.embedderElement;
+ this.showPicker(browser, aMessage.data);
+ break;
+ }
+ case "FormDateTime:ClosePicker": {
+ if (!this._picker) {
+ return;
+ }
+ this._picker.closePicker();
+ 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 "DateTimePickerValueChanged": {
+ this.sendAsyncMessage("FormDateTime:PickerValueChanged", aEvent.detail);
+ break;
+ }
+ case "popuphidden": {
+ this.sendAsyncMessage("FormDateTime:PickerClosed", {});
+ this._picker.closePicker();
+ this.close();
+ break;
+ }
+ default:
+ break;
+ }
+ }
+
+ // Get picker from browser and show it anchored to the input box.
+ showPicker(aBrowser, aData) {
+ let rect = aData.rect;
+ let type = aData.type;
+ let detail = aData.detail;
+
+ debug("Opening picker with details: " + JSON.stringify(detail));
+
+ let window = aBrowser.ownerGlobal;
+ let tabbrowser = window.gBrowser;
+ if (
+ Services.focus.activeWindow != window ||
+ (tabbrowser && tabbrowser.selectedBrowser != aBrowser)
+ ) {
+ // We were sent a message from a window or tab that went into the
+ // background, so we'll ignore it for now.
+ return;
+ }
+
+ let panel;
+ if (tabbrowser) {
+ panel = tabbrowser._getAndMaybeCreateDateTimePickerPanel();
+ } else {
+ panel = aBrowser.dateTimePicker;
+ }
+ if (!panel) {
+ debug("aBrowser.dateTimePicker not found, exiting now.");
+ return;
+ }
+ this._picker = new DateTimePickerPanel(panel);
+ this._picker.openPicker(type, rect, detail);
+
+ this.addPickerListeners();
+ }
+
+ // Picker is closed, do some cleanup.
+ close() {
+ 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);
+ }
+
+ // Stop listening to picker's event.
+ removePickerListeners() {
+ if (!this._picker) {
+ return;
+ }
+ this._picker.element.removeEventListener("popuphidden", this);
+ this._picker.element.removeEventListener(
+ "DateTimePickerValueChanged",
+ this
+ );
+ }
+}
diff --git a/toolkit/actors/ExtFindChild.jsm b/toolkit/actors/ExtFindChild.jsm
new file mode 100644
index 0000000000..b39be9beaf
--- /dev/null
+++ b/toolkit/actors/ExtFindChild.jsm
@@ -0,0 +1,34 @@
+/* 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 = ["ExtFindChild"];
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "FindContent",
+ "resource://gre/modules/FindContent.jsm"
+);
+
+class ExtFindChild extends JSWindowActorChild {
+ receiveMessage(message) {
+ if (!this._findContent) {
+ this._findContent = new 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.jsm b/toolkit/actors/FindBarChild.jsm
new file mode 100644
index 0000000000..f7fddb3a36
--- /dev/null
+++ b/toolkit/actors/FindBarChild.jsm
@@ -0,0 +1,158 @@
+/* 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 = ["FindBarChild"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "BrowserUtils",
+ "resource://gre/modules/BrowserUtils.jsm"
+);
+
+class FindBarChild extends JSWindowActorChild {
+ constructor() {
+ super();
+
+ this._findKey = null;
+
+ XPCOMUtils.defineLazyProxy(
+ this,
+ "FindBarContent",
+ () => {
+ let tmp = {};
+ ChromeUtils.import("resource://gre/modules/FindBarContent.jsm", tmp);
+ return new tmp.FindBarContent(this);
+ },
+ { inQuickFind: false, inPassThrough: false }
+ );
+ }
+
+ receiveMessage(msg) {
+ if (msg.name == "Findbar:UpdateState") {
+ let { FindBarContent } = this;
+ FindBarContent.updateState(msg.data);
+ }
+ }
+
+ /**
+ * Check whether this key event will start the findbar in the parent,
+ * in which case we should pass any further key events to the parent to avoid
+ * them being lost.
+ * @param aEvent the key event to check.
+ */
+ eventMatchesFindShortcut(aEvent) {
+ if (!this._findKey) {
+ this._findKey = Services.cpmm.sharedData.get("Findbar:Shortcut");
+ if (!this._findKey) {
+ return false;
+ }
+ }
+ for (let k in this._findKey) {
+ if (this._findKey[k] != aEvent[k]) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ handleEvent(event) {
+ if (event.type == "keypress") {
+ this.onKeypress(event);
+ }
+ }
+
+ onKeypress(event) {
+ let { FindBarContent } = this;
+
+ if (!FindBarContent.inPassThrough && this.eventMatchesFindShortcut(event)) {
+ return FindBarContent.start(event);
+ }
+
+ // disable FAYT in about:blank to prevent FAYT opening unexpectedly.
+ let location = this.document.location.href;
+ if (location == "about:blank") {
+ return null;
+ }
+
+ if (
+ event.ctrlKey ||
+ event.altKey ||
+ event.metaKey ||
+ event.defaultPrevented ||
+ !BrowserUtils.mimeTypeIsTextBased(this.document.contentType) ||
+ !BrowserUtils.canFindInPage(location)
+ ) {
+ return null;
+ }
+
+ if (FindBarContent.inPassThrough || FindBarContent.inQuickFind) {
+ return FindBarContent.onKeypress(event);
+ }
+
+ if (event.charCode && this.shouldFastFind(event.target)) {
+ let key = String.fromCharCode(event.charCode);
+ if ((key == "/" || key == "'") && FindBarChild.manualFAYT) {
+ return FindBarContent.startQuickFind(event);
+ }
+ if (key != " " && FindBarChild.findAsYouType) {
+ return FindBarContent.startQuickFind(event, true);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Return true if we should FAYT for this node:
+ *
+ * @param elt
+ * The element that is focused
+ */
+ shouldFastFind(elt) {
+ if (elt) {
+ let win = elt.ownerGlobal;
+ if (elt instanceof win.HTMLInputElement && elt.mozIsTextField(false)) {
+ return false;
+ }
+
+ if (elt.isContentEditable || win.document.designMode == "on") {
+ return false;
+ }
+
+ if (
+ elt instanceof win.HTMLTextAreaElement ||
+ elt instanceof win.HTMLSelectElement ||
+ elt instanceof win.HTMLObjectElement ||
+ elt instanceof win.HTMLEmbedElement
+ ) {
+ return false;
+ }
+
+ if (elt instanceof win.HTMLIFrameElement && elt.mozbrowser) {
+ // If we're targeting a mozbrowser iframe, it should be allowed to
+ // handle FastFind itself.
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ FindBarChild,
+ "findAsYouType",
+ "accessibility.typeaheadfind"
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FindBarChild,
+ "manualFAYT",
+ "accessibility.typeaheadfind.manual"
+);
diff --git a/toolkit/actors/FindBarParent.jsm b/toolkit/actors/FindBarParent.jsm
new file mode 100644
index 0000000000..b71f8e729b
--- /dev/null
+++ b/toolkit/actors/FindBarParent.jsm
@@ -0,0 +1,50 @@
+/* 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 = ["FindBarParent"];
+
+// Map of browser elements to findbars.
+let findbars = new WeakMap();
+
+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.jsm b/toolkit/actors/FinderChild.jsm
new file mode 100644
index 0000000000..7fb6f39b0f
--- /dev/null
+++ b/toolkit/actors/FinderChild.jsm
@@ -0,0 +1,127 @@
+// -*- 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/.
+
+var EXPORTED_SYMBOLS = ["FinderChild"];
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "Finder",
+ "resource://gre/modules/Finder.jsm"
+);
+
+class FinderChild extends JSWindowActorChild {
+ get finder() {
+ if (!this._finder) {
+ this._finder = new 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;
+ }
+
+ return null;
+ }
+}
diff --git a/toolkit/actors/InlineSpellCheckerChild.jsm b/toolkit/actors/InlineSpellCheckerChild.jsm
new file mode 100644
index 0000000000..acfa0d30ae
--- /dev/null
+++ b/toolkit/actors/InlineSpellCheckerChild.jsm
@@ -0,0 +1,41 @@
+/* -*- 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/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["InlineSpellCheckerChild"];
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "InlineSpellCheckerContent",
+ "resource://gre/modules/InlineSpellCheckerContent.jsm"
+);
+
+class InlineSpellCheckerChild extends JSWindowActorChild {
+ receiveMessage(msg) {
+ switch (msg.name) {
+ case "InlineSpellChecker:selectDictionary":
+ InlineSpellCheckerContent.selectDictionary(msg.data.localeCode);
+ break;
+
+ case "InlineSpellChecker:replaceMisspelling":
+ InlineSpellCheckerContent.replaceMisspelling(msg.data.index);
+ break;
+
+ case "InlineSpellChecker:toggleEnabled":
+ InlineSpellCheckerContent.toggleEnabled();
+ break;
+
+ case "InlineSpellChecker:recheck":
+ InlineSpellCheckerContent.recheck();
+ break;
+
+ case "InlineSpellChecker:uninit":
+ InlineSpellCheckerContent.uninitContextMenu();
+ break;
+ }
+ }
+}
diff --git a/toolkit/actors/InlineSpellCheckerParent.jsm b/toolkit/actors/InlineSpellCheckerParent.jsm
new file mode 100644
index 0000000000..84b8a616cc
--- /dev/null
+++ b/toolkit/actors/InlineSpellCheckerParent.jsm
@@ -0,0 +1,52 @@
+/* -*- 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/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["InlineSpellCheckerParent"];
+
+class InlineSpellCheckerParent extends JSWindowActorParent {
+ selectDictionary({ localeCode }) {
+ this.sendAsyncMessage("InlineSpellChecker:selectDictionary", {
+ localeCode,
+ });
+ }
+
+ replaceMisspelling({ index }) {
+ this.sendAsyncMessage("InlineSpellChecker:replaceMisspelling", { index });
+ }
+
+ 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.jsm b/toolkit/actors/KeyPressEventModelCheckerChild.jsm
new file mode 100644
index 0000000000..ba3980acff
--- /dev/null
+++ b/toolkit/actors/KeyPressEventModelCheckerChild.jsm
@@ -0,0 +1,116 @@
+/* -*- 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/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["KeyPressEventModelCheckerChild"];
+
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+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/PictureInPictureChild.jsm b/toolkit/actors/PictureInPictureChild.jsm
new file mode 100644
index 0000000000..06f99e06d5
--- /dev/null
+++ b/toolkit/actors/PictureInPictureChild.jsm
@@ -0,0 +1,1632 @@
+/* -*- 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/. */
+"use strict";
+
+var EXPORTED_SYMBOLS = [
+ "PictureInPictureChild",
+ "PictureInPictureToggleChild",
+ "PictureInPictureLauncherChild",
+];
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "DeferredTask",
+ "resource://gre/modules/DeferredTask.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "Services",
+ "resource://gre/modules/Services.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "KEYBOARD_CONTROLS",
+ "resource://gre/modules/PictureInPictureControls.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "TOGGLE_POLICIES",
+ "resource://gre/modules/PictureInPictureControls.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "TOGGLE_POLICY_STRINGS",
+ "resource://gre/modules/PictureInPictureControls.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "Rect",
+ "resource://gre/modules/Geometry.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ContentDOMReference",
+ "resource://gre/modules/ContentDOMReference.jsm"
+);
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+const TOGGLE_ENABLED_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.enabled";
+const TOGGLE_TESTING_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.testing";
+const MOUSEMOVE_PROCESSING_DELAY_MS = 50;
+const TOGGLE_HIDING_TIMEOUT_MS = 2000;
+
+// The ToggleChild does not want to capture events from the PiP
+// windows themselves. This set contains all currently open PiP
+// players' content windows
+var gPlayerContents = new WeakSet();
+
+// To make it easier to write tests, we have a process-global
+// WeakSet of all <video> elements that are being tracked for
+// mouseover
+var gWeakIntersectingVideosForTesting = new WeakSet();
+
+// Overrides are expected to stay constant for the lifetime of a
+// content process, so we set this as a lazy process global.
+// See PictureInPictureToggleChild.getSiteOverrides for a
+// sense of what the return types are.
+XPCOMUtils.defineLazyGetter(this, "gSiteOverrides", () => {
+ return PictureInPictureToggleChild.getSiteOverrides();
+});
+
+class PictureInPictureLauncherChild extends JSWindowActorChild {
+ handleEvent(event) {
+ switch (event.type) {
+ case "MozTogglePictureInPicture": {
+ if (event.isTrusted) {
+ this.togglePictureInPicture(event.target);
+ }
+ 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 {Element} video The <video> element to view in a Picture
+ * in Picture window.
+ *
+ * @return {Promise}
+ * @resolves {undefined} Once the new Picture-in-Picture window
+ * has been requested.
+ */
+ async togglePictureInPicture(video) {
+ if (video.isCloningElementVisually) {
+ // The only way we could have entered here for the same video is if
+ // we are toggling via the context menu, 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: "context-menu" },
+ }
+ );
+ video.dispatchEvent(stopPipEvent);
+ return;
+ }
+
+ // All other requests to toggle PiP should open a new PiP
+ // window
+ const videoRef = ContentDOMReference.get(video);
+ this.sendAsyncMessage("PictureInPicture:Request", {
+ isMuted: PictureInPictureChild.videoIsMuted(video),
+ playing: PictureInPictureChild.videoIsPlaying(video),
+ videoHeight: video.videoHeight,
+ videoWidth: video.videoWidth,
+ videoRef,
+ });
+ }
+
+ //
+ /**
+ * The keyboard was used to attempt to open Picture-in-Picture. In this case,
+ * find the focused window, and open Picture-in-Picture for 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.
+ */
+ keyToggle() {
+ let focusedWindow = Services.focus.focusedWindow;
+ if (focusedWindow) {
+ let doc = focusedWindow.document;
+ if (doc) {
+ let listOfVideos = [...doc.querySelectorAll("video")];
+ // Get the first non-paused video, otherwise the longest video. This
+ // fallback is designed to skip over "preview"-style videos on sidebars.
+ let video =
+ listOfVideos.filter(v => !v.paused)[0] ||
+ listOfVideos.sort((a, b) => b.duration - a.duration)[0];
+ if (video) {
+ this.togglePictureInPicture(video);
+ }
+ }
+ }
+ }
+}
+
+/**
+ * The PictureInPictureToggleChild is responsible for displaying the overlaid
+ * Picture-in-Picture toggle over top of <video> elements that the mouse is
+ * hovering.
+ */
+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);
+ 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.cpmm.sharedData.addEventListener("change", this);
+ }
+
+ didDestroy() {
+ this.stopTrackingMouseOverVideos();
+ Services.prefs.removeObserver(TOGGLE_ENABLED_PREF, this.observerFunction);
+ Services.cpmm.sharedData.removeEventListener("change", this);
+ }
+
+ observe(subject, topic, data) {
+ if (topic != "nsPref:changed") {
+ return;
+ }
+
+ this.toggleEnabled = Services.prefs.getBoolPref(TOGGLE_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);
+ }
+ });
+ }
+ }
+
+ /**
+ * 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 = {
+ // 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: TOGGLE_POLICIES.DEFAULT,
+ toggleVisibilityThreshold: 1.0,
+ // 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 "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 {
+ 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 &&
+ event.target instanceof this.contentWindow.HTMLVideoElement &&
+ 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 "mousedown":
+ case "pointerup":
+ case "mouseup":
+ case "click": {
+ 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;
+ }
+ }
+ }
+
+ /**
+ * 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);
+ }
+
+ /**
+ * 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;
+ 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.beginTrackingMouseOverVideos();
+ } else {
+ this.contentWindow.requestIdleCallback(() => {
+ this.beginTrackingMouseOverVideos();
+ });
+ }
+ } else if (oldVisibleVideosCount && !state.visibleVideosCount) {
+ if (this.toggleTesting) {
+ 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,
+ });
+ }
+
+ removeMouseButtonListeners() {
+ // This can be null when closing the tab, but the event
+ // listeners should be removed in that case already.
+ if (!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,
+ });
+ }
+
+ /**
+ * 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 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,
+ });
+ 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 { 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,
+ true /* aOnlyVisible */,
+ state.toggleVisibilityThreshold
+ );
+ if (!Array.from(elements).includes(video)) {
+ return;
+ }
+
+ let toggle = this.getToggleElement(shadowRoot);
+ if (this.isMouseOverToggle(toggle, event)) {
+ state.isClickingToggle = true;
+ state.clickedElement = Cu.getWeakReference(event.originalTarget);
+ event.stopImmediatePropagation();
+
+ Services.telemetry.keyedScalarAdd(
+ "pictureinpicture.opened_method",
+ "toggle",
+ 1
+ );
+
+ let pipEvent = new this.contentWindow.CustomEvent(
+ "MozTogglePictureInPicture",
+ {
+ bubbles: true,
+ }
+ );
+ 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") {
+ // 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;
+ 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,
+ true
+ );
+
+ for (let element of elements) {
+ if (
+ state.weakVisibleVideos.has(element) &&
+ !element.isCloningElementVisually
+ ) {
+ this.onMouseOverVideo(element, 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 (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 = 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()
+ : gSiteOverrides;
+
+ // 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 = policy || TOGGLE_POLICIES.DEFAULT;
+ state.toggleVisibilityThreshold = visibilityThreshold || 1.0;
+ 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 != TOGGLE_POLICIES.DEFAULT &&
+ !(state.togglePolicy == TOGGLE_POLICIES.BOTTOM && video.controls)
+ ) {
+ toggle.setAttribute("policy", TOGGLE_POLICY_STRINGS[state.togglePolicy]);
+ } else {
+ toggle.removeAttribute("policy");
+ }
+
+ 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 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 != TOGGLE_POLICIES.HIDDEN &&
+ !toggle.hasAttribute("hidden")
+ ) {
+ Services.telemetry.scalarAdd("pictureinpicture.saw_toggle", 1);
+ }
+
+ // 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 = Rect.fromRect(toggleRect);
+ let clickableChildren = toggle.querySelectorAll(".clickable");
+ for (let child of clickableChildren) {
+ let childRect = 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)) {
+ event.stopImmediatePropagation();
+ event.preventDefault();
+
+ this.sendAsyncMessage("PictureInPicture:OpenToggleContextMenu", {
+ screenX: event.screenX,
+ screenY: event.screenY,
+ mozInputSource: event.mozInputSource,
+ });
+ }
+ }
+
+ /**
+ * 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, keyboardControls } (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 keyboardControls 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;
+ }
+}
+
+class PictureInPictureChild extends JSWindowActorChild {
+ // A weak reference to this PiP window's video element
+ weakVideo = null;
+
+ // A weak reference to this PiP window's content window
+ weakPlayerContent = null;
+
+ /**
+ * 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 video.muted;
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "MozStopPictureInPicture": {
+ if (event.isTrusted && event.target === this.getWeakVideo()) {
+ const reason = event.detail?.reason || "video-el-remove";
+ 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) {
+ Cu.reportError(
+ "PictureInPictureChild received volumechange for " +
+ "the wrong video!"
+ );
+ return;
+ }
+
+ if (video.muted) {
+ this.sendAsyncMessage("PictureInPicture:Muting");
+ } else {
+ this.sendAsyncMessage("PictureInPicture:Unmuting");
+ }
+ break;
+ }
+ case "resize": {
+ let video = event.target;
+ if (this.inPictureInPicture(video)) {
+ this.sendAsyncMessage("PictureInPicture:Resize", {
+ videoHeight: video.videoHeight,
+ videoWidth: video.videoWidth,
+ });
+ }
+ 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 && video.srcObject instanceof MediaStream) {
+ break;
+ }
+ }
+ this.pause();
+ break;
+ }
+ case "PictureInPicture:Mute": {
+ this.mute();
+ break;
+ }
+ case "PictureInPicture:Unmute": {
+ this.unmute();
+ break;
+ }
+ case "PictureInPicture:KeyDown": {
+ this.keyDown(message.data);
+ break;
+ }
+ }
+ }
+
+ /**
+ * 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) {
+ 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);
+
+ let chromeEventHandler = originatingWindow.docShell.chromeEventHandler;
+ chromeEventHandler.addEventListener(
+ "MozDOMFullscreen:Request",
+ this,
+ true
+ );
+ chromeEventHandler.addEventListener(
+ "MozStopPictureInPicture",
+ this,
+ true
+ );
+ }
+ }
+
+ /**
+ * 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) {
+ 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);
+
+ 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 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: "setup-failure" });
+ return;
+ }
+
+ 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");
+
+ 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";
+
+ doc.body.appendChild(playerVideo);
+
+ originatingVideo.cloneElementVisually(playerVideo);
+
+ let shadowRoot = originatingVideo.openOrClosedShadowRoot;
+ if (originatingVideo.getTransformToViewport().a == -1) {
+ shadowRoot.firstChild.setAttribute("flipped", true);
+ playerVideo.style.transform = "scaleX(-1)";
+ }
+
+ this.trackOriginatingVideo(originatingVideo);
+
+ 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) {
+ video.play();
+ }
+ }
+
+ pause() {
+ let video = this.getWeakVideo();
+ if (video) {
+ video.pause();
+ }
+ }
+
+ mute() {
+ let video = this.getWeakVideo();
+ if (video) {
+ video.muted = true;
+ }
+ }
+
+ unmute() {
+ let video = this.getWeakVideo();
+ if (video) {
+ video.muted = false;
+ }
+ }
+
+ /**
+ * This checks if a given keybinding has been disabled for the specific site
+ * currently being viewed.
+ */
+ isKeyEnabled(key) {
+ const video = this.getWeakVideo();
+ if (!video) {
+ return false;
+ }
+ const { documentURI } = video.ownerDocument;
+ if (!documentURI) {
+ return true;
+ }
+ for (let [override, { keyboardControls }] of gSiteOverrides) {
+ if (keyboardControls !== undefined && override.matches(documentURI)) {
+ if (keyboardControls === KEYBOARD_CONTROLS.NONE) {
+ return false;
+ }
+ return keyboardControls & key;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * 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;
+ }
+
+ const isVideoStreaming = video.duration == +Infinity;
+ var oldval, newval;
+
+ try {
+ switch (keystroke) {
+ case "space" /* Toggle Play / Pause */:
+ if (!this.isKeyEnabled(KEYBOARD_CONTROLS.PLAY_PAUSE)) {
+ return;
+ }
+ if (video.paused || video.ended) {
+ video.play();
+ } else {
+ video.pause();
+ }
+ break;
+ case "downArrow" /* Volume decrease */:
+ if (!this.isKeyEnabled(KEYBOARD_CONTROLS.VOLUME)) {
+ return;
+ }
+ oldval = video.volume;
+ video.volume = oldval < 0.1 ? 0 : oldval - 0.1;
+ video.muted = false;
+ break;
+ case "upArrow" /* Volume increase */:
+ if (!this.isKeyEnabled(KEYBOARD_CONTROLS.VOLUME)) {
+ return;
+ }
+ oldval = video.volume;
+ video.volume = oldval > 0.9 ? 1 : oldval + 0.1;
+ video.muted = false;
+ break;
+ case "accel-downArrow" /* Mute */:
+ if (!this.isKeyEnabled(KEYBOARD_CONTROLS.MUTE_UNMUTE)) {
+ return;
+ }
+ video.muted = true;
+ break;
+ case "accel-upArrow" /* Unmute */:
+ if (!this.isKeyEnabled(KEYBOARD_CONTROLS.MUTE_UNMUTE)) {
+ return;
+ }
+ video.muted = false;
+ break;
+ case "leftArrow": /* Seek back 15 seconds */
+ case "accel-leftArrow" /* Seek back 10% */:
+ if (isVideoStreaming || !this.isKeyEnabled(KEYBOARD_CONTROLS.SEEK)) {
+ return;
+ }
+
+ oldval = video.currentTime;
+ if (keystroke == "leftArrow") {
+ newval = oldval - 15;
+ } else {
+ newval = oldval - video.duration / 10;
+ }
+ video.currentTime = newval >= 0 ? newval : 0;
+ break;
+ case "rightArrow": /* Seek forward 15 seconds */
+ case "accel-rightArrow" /* Seek forward 10% */:
+ if (isVideoStreaming || !this.isKeyEnabled(KEYBOARD_CONTROLS.SEEK)) {
+ return;
+ }
+
+ oldval = video.currentTime;
+ var maxtime = video.duration;
+ if (keystroke == "rightArrow") {
+ newval = oldval + 15;
+ } else {
+ newval = oldval + maxtime / 10;
+ }
+ video.currentTime = newval <= maxtime ? newval : maxtime;
+ break;
+ case "home" /* Seek to beginning */:
+ if (!this.isKeyEnabled(KEYBOARD_CONTROLS.SEEK)) {
+ return;
+ }
+ if (!isVideoStreaming) {
+ video.currentTime = 0;
+ }
+ break;
+ case "end" /* Seek to end */:
+ if (!this.isKeyEnabled(KEYBOARD_CONTROLS.SEEK)) {
+ return;
+ }
+ if (!isVideoStreaming && video.currentTime != video.duration) {
+ video.currentTime = video.duration;
+ }
+ break;
+ default:
+ }
+ } catch (e) {
+ /* ignore any exception from setting video.currentTime */
+ }
+ }
+}
diff --git a/toolkit/actors/PopupBlockingChild.jsm b/toolkit/actors/PopupBlockingChild.jsm
new file mode 100644
index 0000000000..0d4b465229
--- /dev/null
+++ b/toolkit/actors/PopupBlockingChild.jsm
@@ -0,0 +1,159 @@
+/* -*- 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"}] */
+
+var EXPORTED_SYMBOLS = ["PopupBlockingChild"];
+
+// The maximum number of popup information we'll send to the parent.
+const MAX_SENT_POPUPS = 15;
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+class PopupBlockingChild extends JSWindowActorChild {
+ constructor() {
+ super();
+ this.weakDocStates = new WeakMap();
+ }
+
+ actorCreated() {
+ this.contentWindow.addEventListener("pageshow", this);
+ }
+
+ didDestroy() {
+ this.contentWindow.removeEventListener("pageshow", this);
+ }
+
+ /**
+ * 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.jsm b/toolkit/actors/PopupBlockingParent.jsm
new file mode 100644
index 0000000000..8c7dc4ab33
--- /dev/null
+++ b/toolkit/actors/PopupBlockingParent.jsm
@@ -0,0 +1,271 @@
+/* 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 = ["PopupBlocker", "PopupBlockingParent"];
+
+/**
+ * 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.
+ */
+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.
+ */
+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.jsm b/toolkit/actors/PrintingChild.jsm
new file mode 100644
index 0000000000..e03aacc053
--- /dev/null
+++ b/toolkit/actors/PrintingChild.jsm
@@ -0,0 +1,498 @@
+/* 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 = ["PrintingChild"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "setTimeout",
+ "resource://gre/modules/Timer.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ReaderMode",
+ "resource://gre/modules/ReaderMode.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "DeferredTask",
+ "resource://gre/modules/DeferredTask.jsm"
+);
+
+let gPrintPreviewInitializingInfo = null;
+
+let gPendingPreviewsMap = new Map();
+
+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);
+ }
+
+ // Bug 1088061: nsPrintJob's DoCommonPrint currently expects the
+ // progress listener passed to it to QI to an nsIPrintingPromptService
+ // in order to know that a printing progress dialog has been shown. That's
+ // really all the interface is used for, hence the fact that I don't actually
+ // implement the interface here. Bug 1088061 has been filed to remove
+ // this hackery.
+
+ get shouldSavePrintSettings() {
+ return Services.prefs.getBoolPref("print.save_print_settings");
+ }
+
+ 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 "printPreviewUpdate": {
+ let info = gPrintPreviewInitializingInfo;
+ if (!info) {
+ // If there is no gPrintPreviewInitializingInfo then we did not
+ // initiate the preview so ignore this event.
+ return;
+ }
+
+ // Only send Printing:Preview:Entered message on first update, indicated
+ // by gPrintPreviewInitializingInfo.entered not being set.
+ if (!info.entered) {
+ gPendingPreviewsMap.delete(this.browsingContext.id);
+
+ info.entered = true;
+ this.sendAsyncMessage("Printing:Preview:Entered", {
+ failed: false,
+ changingBrowsers: info.changingBrowsers,
+ });
+
+ // If we have another request waiting, dispatch it now.
+ if (info.nextRequest) {
+ Services.tm.dispatchToMainThread(info.nextRequest);
+ }
+ }
+
+ // Always send page count update.
+ this.updatePageCount();
+ break;
+ }
+
+ case "scroll":
+ if (!this._scrollTask) {
+ this._scrollTask = new DeferredTask(
+ () => this.updateCurrentPage(),
+ 16,
+ 16
+ );
+ }
+ this._scrollTask.arm();
+ break;
+ }
+ }
+
+ receiveMessage(message) {
+ let data = message.data;
+ switch (message.name) {
+ case "Printing:Preview:Enter": {
+ this.enterPrintPreview(
+ BrowsingContext.get(data.browsingContextId),
+ data.simplifiedMode,
+ data.changingBrowsers,
+ data.lastUsedPrinterName
+ );
+ break;
+ }
+
+ case "Printing:Preview:Exit": {
+ this.exitPrintPreview();
+ break;
+ }
+
+ 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;
+ }
+
+ getPrintSettings(lastUsedPrinterName) {
+ try {
+ let PSSVC = Cc["@mozilla.org/gfx/printsettings-service;1"].getService(
+ Ci.nsIPrintSettingsService
+ );
+
+ let printSettings = PSSVC.newPrintSettings;
+ if (!printSettings.printerName) {
+ printSettings.printerName = lastUsedPrinterName;
+ }
+ // First get any defaults from the printer
+ PSSVC.initPrintSettingsFromPrinter(
+ printSettings.printerName,
+ printSettings
+ );
+ // now augment them with any values from last time
+ PSSVC.initPrintSettingsFromPrefs(
+ printSettings,
+ true,
+ printSettings.kInitSaveAll
+ );
+
+ return printSettings;
+ } catch (e) {
+ Cu.reportError(e);
+ }
+
+ return null;
+ }
+
+ 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 ReaderMode.parseDocument(contentWindow.document);
+ } catch (ex) {
+ Cu.reportError(ex);
+ }
+
+ // 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");
+ };
+ contentWindow.addEventListener("MozAfterPaint", onPaint);
+ // This timer is needed for when display list invalidation doesn't invalidate.
+ setTimeout(() => {
+ contentWindow.removeEventListener("MozAfterPaint", onPaint);
+ actor.sendAsyncMessage("Printing:Preview:ReaderModeReady");
+ }, 100);
+ } else {
+ actor.sendAsyncMessage("Printing:Preview:ReaderModeReady");
+ }
+ }
+ },
+
+ 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("id", "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("id", "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("id", "reader-title");
+ titleElement.textContent = article.title;
+ headerElement.appendChild(titleElement);
+
+ let bylineElement = document.createElement("div");
+ bylineElement.setAttribute("id", "reader-credits");
+ bylineElement.setAttribute("class", "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("id", "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 {
+ let aboutReaderStrings = Services.strings.createBundle(
+ "chrome://global/locale/aboutReader.properties"
+ );
+ let errorMessage = aboutReaderStrings.GetStringFromName(
+ "aboutReader.loadError"
+ );
+
+ 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";
+ }
+ }
+
+ enterPrintPreview(
+ browsingContext,
+ simplifiedMode,
+ changingBrowsers,
+ lastUsedPrinterName
+ ) {
+ const { docShell } = this;
+
+ try {
+ let contentWindow = browsingContext.window;
+ let printSettings = this.getPrintSettings(lastUsedPrinterName);
+
+ // Disable the progress dialog for generating previews.
+ printSettings.showPrintProgress = !Services.prefs.getBoolPref(
+ "print.tab_modal.enabled",
+ false
+ );
+
+ // If we happen to be on simplified mode, we need to set docURL in order
+ // to generate header/footer content correctly, since simplified tab has
+ // "about:blank" as its URI.
+ if (printSettings && simplifiedMode) {
+ printSettings.docURL = contentWindow.document.baseURI;
+ }
+
+ // Get this early in case the actor goes away during print preview.
+ let browserContextId = this.browsingContext.id;
+
+ // The print preview docshell will be in a different TabGroup, so
+ // printPreviewInitialize must be run in a separate runnable to avoid
+ // touching a different TabGroup in our own runnable.
+ let printPreviewInitialize = () => {
+ // During dispatching this function to the main-thread, the docshell
+ // might be destroyed, for example the print preview window gets closed
+ // soon after it's opened, in such case we should just simply bail out.
+ if (docShell.isBeingDestroyed()) {
+ this.sendAsyncMessage("Printing:Preview:Entered", {
+ failed: true,
+ });
+ return;
+ }
+
+ try {
+ let listener = new PrintingListener(this);
+ gPendingPreviewsMap.set(browserContextId, listener);
+
+ gPrintPreviewInitializingInfo = { changingBrowsers };
+
+ contentWindow.printPreview(printSettings, listener, docShell);
+ } catch (error) {
+ // This might fail if we, for example, attempt to print a XUL document.
+ // In that case, we inform the parent to bail out of print preview.
+ Cu.reportError(error);
+ gPrintPreviewInitializingInfo = null;
+ this.sendAsyncMessage("Printing:Preview:Entered", {
+ failed: true,
+ });
+ }
+ };
+
+ // If gPrintPreviewInitializingInfo.entered is not set we are still in the
+ // initial setup of a previous preview request. We delay this one until
+ // that has finished because running them at the same time will almost
+ // certainly cause failures.
+ if (
+ gPrintPreviewInitializingInfo &&
+ !gPrintPreviewInitializingInfo.entered
+ ) {
+ gPrintPreviewInitializingInfo.nextRequest = printPreviewInitialize;
+ } else {
+ Services.tm.dispatchToMainThread(printPreviewInitialize);
+ }
+ } catch (error) {
+ // This might fail if we, for example, attempt to print a XUL document.
+ // In that case, we inform the parent to bail out of print preview.
+ Cu.reportError(error);
+ this.sendAsyncMessage("Printing:Preview:Entered", {
+ failed: true,
+ });
+ }
+ }
+
+ exitPrintPreview() {
+ gPrintPreviewInitializingInfo = null;
+ this.docShell.exitPrintPreview();
+ }
+
+ updatePageCount() {
+ let cv = this.docShell.contentViewer;
+ cv.QueryInterface(Ci.nsIWebBrowserPrint);
+ this.sendAsyncMessage("Printing:Preview:UpdatePageCount", {
+ numPages: cv.printPreviewNumPages,
+ totalPages: cv.rawNumPages,
+ });
+ }
+
+ updateCurrentPage() {
+ let cv = this.docShell.contentViewer;
+ cv.QueryInterface(Ci.nsIWebBrowserPrint);
+ this.sendAsyncMessage("Printing:Preview:CurrentPage", {
+ currentPage: cv.printPreviewCurrentPageNumber,
+ });
+ }
+
+ navigate(navType, pageNum) {
+ let cv = this.docShell.contentViewer;
+ cv.QueryInterface(Ci.nsIWebBrowserPrint);
+ cv.printPreviewScrollToPage(navType, pageNum);
+ }
+}
+
+PrintingChild.prototype.QueryInterface = ChromeUtils.generateQI([
+ "nsIPrintingPromptService",
+]);
+
+function PrintingListener(actor) {
+ this.actor = actor;
+}
+PrintingListener.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIWebProgressListener"]),
+
+ onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) {
+ this.actor.sendAsyncMessage("Printing:Preview:StateChange", {
+ stateFlags: aStateFlags,
+ status: aStatus,
+ });
+ },
+
+ onProgressChange(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ ) {
+ this.actor.sendAsyncMessage("Printing:Preview:ProgressChange", {
+ curSelfProgress: aCurSelfProgress,
+ maxSelfProgress: aMaxSelfProgress,
+ curTotalProgress: aCurTotalProgress,
+ maxTotalProgress: aMaxTotalProgress,
+ });
+ },
+
+ onLocationChange(aWebProgress, aRequest, aLocation, aFlags) {},
+ onStatusChange(aWebProgress, aRequest, aStatus, aMessage) {},
+ onSecurityChange(aWebProgress, aRequest, aState) {},
+ onContentBlockingEvent(aWebProgress, aRequest, aEvent) {},
+};
diff --git a/toolkit/actors/PrintingParent.jsm b/toolkit/actors/PrintingParent.jsm
new file mode 100644
index 0000000000..00baca696c
--- /dev/null
+++ b/toolkit/actors/PrintingParent.jsm
@@ -0,0 +1,113 @@
+/* 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 = ["PrintingParent"];
+
+let gTestListener = null;
+
+class PrintingParent extends JSWindowActorParent {
+ static setTestListener(listener) {
+ gTestListener = listener;
+ }
+
+ getPrintPreviewToolbar(browser) {
+ return browser.ownerDocument.getElementById("print-preview-toolbar");
+ }
+
+ receiveMessage(message) {
+ let browser = this.browsingContext.top.embedderElement;
+ let PrintUtils = browser.ownerGlobal.PrintUtils;
+
+ if (message.name == "Printing:Error") {
+ PrintUtils._displayPrintingError(
+ message.data.nsresult,
+ message.data.isPrinting
+ );
+ return undefined;
+ }
+
+ if (this.ignoreListeners) {
+ return undefined;
+ }
+
+ let listener = PrintUtils._webProgressPP?.value;
+ let data = message.data;
+
+ switch (message.name) {
+ case "Printing:Preview:CurrentPage": {
+ browser.setAttribute("current-page", message.data.currentPage);
+ break;
+ }
+
+ case "Printing:Preview:Entered": {
+ // This message is sent by the content process once it has completed
+ // putting the content into print preview mode. We must wait for that to
+ // to complete before switching the chrome UI to print preview mode,
+ // otherwise we have layout issues.
+
+ if (gTestListener) {
+ gTestListener(browser);
+ }
+
+ PrintUtils.printPreviewEntered(browser, message.data);
+ break;
+ }
+
+ case "Printing:Preview:ReaderModeReady": {
+ PrintUtils.readerModeReady(browser);
+ break;
+ }
+
+ case "Printing:Preview:UpdatePageCount": {
+ let toolbar = this.getPrintPreviewToolbar(browser);
+ toolbar.updatePageCount(message.data.totalPages);
+ break;
+ }
+
+ case "Printing:Preview:ProgressChange": {
+ if (!PrintUtils._webProgressPP.value) {
+ // We somehow didn't get a nsIWebProgressListener to be updated...
+ // I guess there's nothing to do.
+ return undefined;
+ }
+
+ return listener.onProgressChange(
+ null,
+ null,
+ data.curSelfProgress,
+ data.maxSelfProgress,
+ data.curTotalProgress,
+ data.maxTotalProgress
+ );
+ }
+
+ case "Printing:Preview:StateChange": {
+ if (!PrintUtils._webProgressPP.value) {
+ // We somehow didn't get a nsIWebProgressListener to be updated...
+ // I guess there's nothing to do.
+ return undefined;
+ }
+
+ if (data.stateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
+ // Strangely, the printing engine sends 2 STATE_STOP messages when
+ // print preview is finishing. One has the STATE_IS_DOCUMENT flag,
+ // the other has the STATE_IS_NETWORK flag. However, the webProgressPP
+ // listener stops listening once the first STATE_STOP is sent.
+ // Any subsequent messages result in NS_ERROR_FAILURE errors getting
+ // thrown. This should all get torn out once bug 1088061 is fixed.
+
+ // Enable toobar elements that we disabled during update.
+ let printPreviewTB = this.getPrintPreviewToolbar(browser);
+ printPreviewTB.disableUpdateTriggers(false);
+ }
+
+ return listener.onStateChange(null, null, data.stateFlags, data.status);
+ }
+ }
+
+ return undefined;
+ }
+}
diff --git a/toolkit/actors/PrintingSelectionChild.jsm b/toolkit/actors/PrintingSelectionChild.jsm
new file mode 100644
index 0000000000..b13ced53ad
--- /dev/null
+++ b/toolkit/actors/PrintingSelectionChild.jsm
@@ -0,0 +1,30 @@
+/* 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 = ["PrintingSelectionChild"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+class PrintingSelectionChild extends JSWindowActorChild {
+ receiveMessage(message) {
+ switch (message.name) {
+ case "PrintingSelection:HasSelection":
+ return this.hasSelection();
+ }
+
+ return undefined;
+ }
+
+ hasSelection() {
+ let focusedWindow = Services.focus.focusedWindow;
+ if (focusedWindow) {
+ let selection = focusedWindow.getSelection();
+ return selection.type == "Range";
+ }
+
+ return false;
+ }
+}
diff --git a/toolkit/actors/PurgeSessionHistoryChild.jsm b/toolkit/actors/PurgeSessionHistoryChild.jsm
new file mode 100644
index 0000000000..da9badfb95
--- /dev/null
+++ b/toolkit/actors/PurgeSessionHistoryChild.jsm
@@ -0,0 +1,37 @@
+/* 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 = ["PurgeSessionHistoryChild"];
+
+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.jsm b/toolkit/actors/RemotePageChild.jsm
new file mode 100644
index 0000000000..c5a7357a7d
--- /dev/null
+++ b/toolkit/actors/RemotePageChild.jsm
@@ -0,0 +1,228 @@
+/* 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 = ["RemotePageChild"];
+
+/**
+ * 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 { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+ChromeUtils.defineModuleGetter(
+ this,
+ "AsyncPrefs",
+ "resource://gre/modules/AsyncPrefs.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "PrivateBrowsingUtils",
+ "resource://gre/modules/PrivateBrowsingUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "RemotePageAccessManager",
+ "resource://gre/modules/RemotePageAccessManager.jsm"
+);
+
+class RemotePageChild extends JSWindowActorChild {
+ actorCreated() {
+ this.listeners = new Map();
+ this.exportBaseFunctions();
+ }
+
+ exportBaseFunctions() {
+ const exportableFunctions = [
+ "RPMSendAsyncMessage",
+ "RPMSendQuery",
+ "RPMAddMessageListener",
+ "RPMRemoveMessageListener",
+ "RPMGetIntPref",
+ "RPMGetStringPref",
+ "RPMGetBoolPref",
+ "RPMSetBoolPref",
+ "RPMGetFormatURLPref",
+ "RPMIsWindowPrivate",
+ ];
+
+ this.exportFunctions(exportableFunctions);
+ }
+
+ /**
+ * Exports a list of functions to be accessible by the privileged page.
+ * Subclasses may call this function to add functions that are specific
+ * to a page. When the page calls a function, a function with the same
+ * name is called within the child actor.
+ *
+ * Only functions that appear in the whitelist in the
+ * RemotePageAccessManager for that page will be exported.
+ *
+ * @param array of function names.
+ */
+ exportFunctions(functions) {
+ let document = this.document;
+ let principal = document.nodePrincipal;
+
+ // If there is no content principal, don't export any functions.
+ if (!principal) {
+ return;
+ }
+
+ let window = this.contentWindow;
+
+ for (let fnname of functions) {
+ let allowAccess = 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 DOMWindowCreated 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) {
+ Cu.reportError(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 (!RemotePageAccessManager.checkAllowAccess(doc, aFeature, aValue)) {
+ throw new Error(
+ "RemotePageAccessManager does not allow access to " + aFeature
+ );
+ }
+
+ return true;
+ }
+
+ // Implementation of functions that are exported into the page.
+
+ RPMSendAsyncMessage(aName, aData = null) {
+ this.sendAsyncMessage(aName, aData);
+ }
+
+ RPMSendQuery(aName, aData = null) {
+ return this.wrapPromise(
+ new Promise(resolve => {
+ this.sendQuery(aName, aData).then(result => {
+ resolve(Cu.cloneInto(result, this.contentWindow));
+ });
+ })
+ );
+ }
+
+ /**
+ * Adds a listener for messages. Many callbacks can be registered for the
+ * same message if necessary. An attempt to register the same callback for the
+ * same message twice will be ignored. When called the callback is passed an
+ * object with these properties:
+ * name: The message name
+ * data: Any data sent with the message
+ */
+ RPMAddMessageListener(aName, aCallback) {
+ if (!this.listeners.has(aName)) {
+ this.listeners.set(aName, new Set([aCallback]));
+ } else {
+ this.listeners.get(aName).add(aCallback);
+ }
+ }
+
+ /**
+ * Removes a listener for messages.
+ */
+ RPMRemoveMessageListener(aName, aCallback) {
+ if (!this.listeners.has(aName)) {
+ return;
+ }
+
+ this.listeners.get(aName).delete(aCallback);
+ }
+
+ RPMGetIntPref(aPref, defaultValue) {
+ // Only call with a default value if it's defined, to be able to throw
+ // errors for non-existent prefs.
+ if (defaultValue !== undefined) {
+ return Services.prefs.getIntPref(aPref, defaultValue);
+ }
+ return Services.prefs.getIntPref(aPref);
+ }
+
+ RPMGetStringPref(aPref) {
+ return Services.prefs.getStringPref(aPref);
+ }
+
+ RPMGetBoolPref(aPref, defaultValue) {
+ // Only call with a default value if it's defined, to be able to throw
+ // errors for non-existent prefs.
+ if (defaultValue !== undefined) {
+ return Services.prefs.getBoolPref(aPref, defaultValue);
+ }
+ return Services.prefs.getBoolPref(aPref);
+ }
+
+ RPMSetBoolPref(aPref, aVal) {
+ return this.wrapPromise(AsyncPrefs.set(aPref, aVal));
+ }
+
+ RPMGetFormatURLPref(aFormatURL) {
+ return Services.urlFormatter.formatURLPref(aFormatURL);
+ }
+
+ RPMIsWindowPrivate() {
+ return PrivateBrowsingUtils.isContentWindowPrivate(this.contentWindow);
+ }
+}
diff --git a/toolkit/actors/SelectChild.jsm b/toolkit/actors/SelectChild.jsm
new file mode 100644
index 0000000000..54a60d178b
--- /dev/null
+++ b/toolkit/actors/SelectChild.jsm
@@ -0,0 +1,474 @@
+/* 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 = ["SelectChild"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "BrowserUtils",
+ "resource://gre/modules/BrowserUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "DeferredTask",
+ "resource://gre/modules/DeferredTask.jsm"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["InspectorUtils"]);
+
+const kStateActive = 0x00000001; // NS_EVENT_STATE_ACTIVE
+const kStateHover = 0x00000004; // NS_EVENT_STATE_HOVER
+
+// Duplicated in SelectParent.jsm
+// Please keep these lists in sync.
+const SUPPORTED_OPTION_OPTGROUP_PROPERTIES = [
+ "direction",
+ "color",
+ "background-color",
+ "text-shadow",
+ "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;
+
+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 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,
+ selectedIndex: this.element.selectedIndex,
+ 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 BrowserUtils.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(),
+ selectedIndex: this.element.selectedIndex,
+ style: supportedStyles(computedStyles, SUPPORTED_SELECT_PROPERTIES),
+ defaultStyle: supportedStyles(defaultStyles, SUPPORTED_SELECT_PROPERTIES),
+ });
+ this._clearPseudoClassStyles();
+ },
+
+ dispatchMouseEvent(win, target, eventName) {
+ let mouseEvent = new win.MouseEvent(eventName, {
+ view: win,
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ });
+ target.dispatchEvent(mouseEvent);
+ },
+
+ receiveMessage(message) {
+ switch (message.name) {
+ case "Forms:SelectDropDownItem":
+ this.element.selectedIndex = message.data.value;
+ this.closedWithClickOn = !message.data.closedWithEnter;
+ break;
+
+ case "Forms:DismissedDropDown": {
+ if (!this.element) {
+ return;
+ }
+
+ let win = this.element.ownerGlobal;
+
+ // Running arbitrary script below (dispatching events for example) can
+ // close us, but we should still send events consistently.
+ let element = this.element;
+
+ let selectedOption = element.item(element.selectedIndex);
+
+ // For ordering of events, we're using non-e10s as our guide here,
+ // since the spec isn't exactly clear. In non-e10s:
+ // - If the user clicks on an element in the dropdown, we fire
+ // mousedown, mouseup, input, change, and click events.
+ // - If the user uses the keyboard to select an element in the
+ // dropdown, we only fire input and change events.
+ // - If the user pressed ESC key or clicks outside the dropdown,
+ // we fire nothing as the selected option is unchanged.
+ if (this.closedWithClickOn) {
+ this.dispatchMouseEvent(win, selectedOption, "mousedown");
+ this.dispatchMouseEvent(win, selectedOption, "mouseup");
+ }
+
+ // Clear active document no matter user selects via keyboard or mouse
+ InspectorUtils.removeContentState(
+ element,
+ kStateActive,
+ /* aClearActiveDocument */ true
+ );
+
+ // Fire input and change events when selected option changes
+ if (this.initialSelection !== selectedOption) {
+ let inputEvent = new win.Event("input", {
+ bubbles: true,
+ });
+
+ let changeEvent = new win.Event("change", {
+ bubbles: true,
+ });
+
+ let handlingUserInput = win.windowUtils.setHandlingUserInput(true);
+ try {
+ element.dispatchEvent(inputEvent);
+ element.dispatchEvent(changeEvent);
+ } finally {
+ handlingUserInput.destruct();
+ }
+ }
+
+ // Fire click event
+ if (this.closedWithClickOn) {
+ this.dispatchMouseEvent(win, selectedOption, "click");
+ }
+
+ this.uninit();
+ break;
+ }
+
+ case "Forms:MouseOver":
+ InspectorUtils.setContentState(this.element, kStateHover);
+ break;
+
+ case "Forms:MouseOut":
+ InspectorUtils.removeContentState(this.element, kStateHover);
+ break;
+
+ case "Forms:MouseUp":
+ let win = this.element.ownerGlobal;
+ if (message.data.onAnchor) {
+ this.dispatchMouseEvent(win, this.element, "mouseup");
+ }
+ InspectorUtils.removeContentState(this.element, kStateActive);
+ if (message.data.onAnchor) {
+ this.dispatchMouseEvent(win, this.element, "click");
+ }
+ break;
+
+ case "Forms:SearchFocused":
+ this._closeAfterBlur = false;
+ break;
+
+ case "Forms:BlurDropDown-Pong":
+ if (!this._closeAfterBlur || !gOpen) {
+ return;
+ }
+ this.actor.sendAsyncMessage("Forms:HideDropDown", {});
+ this.uninit();
+ break;
+ }
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "pagehide":
+ if (this.element.ownerDocument === event.target) {
+ this.actor.sendAsyncMessage("Forms:HideDropDown", {});
+ this.uninit();
+ }
+ break;
+ case "blur": {
+ if (this.element !== event.target || this.disablePopupAutohide) {
+ break;
+ }
+ this._closeAfterBlur = true;
+ // Send a ping-pong message to make sure that we wait for
+ // enough cycles to pass from the potential focusing of the
+ // search box to disable closing-after-blur.
+ this.actor.sendAsyncMessage("Forms:BlurDropDown-Ping", {});
+ break;
+ }
+ case "mozhidedropdown":
+ if (this.element === event.target) {
+ this.actor.sendAsyncMessage("Forms:HideDropDown", {});
+ this.uninit();
+ }
+ break;
+ case "transitionend":
+ if (SUPPORTED_SELECT_PROPERTIES.includes(event.propertyName)) {
+ this._updateTimer.arm();
+ }
+ break;
+ }
+ },
+};
+
+function getComputedStyles(element) {
+ return element.ownerGlobal.getComputedStyle(element);
+}
+
+function supportedStyles(cs, supportedProps) {
+ let styles = {};
+ for (let property of supportedProps) {
+ styles[property] = cs.getPropertyValue(property);
+ }
+ return styles;
+}
+
+function supportedStylesEqual(styles, otherStyles) {
+ for (let property in styles) {
+ if (styles[property] !== otherStyles[property]) {
+ return false;
+ }
+ }
+ return true;
+}
+
+function uniqueStylesIndex(cs, uniqueStyles) {
+ let styles = supportedStyles(cs, SUPPORTED_OPTION_OPTGROUP_PROPERTIES);
+ for (let i = uniqueStyles.length; i--; ) {
+ if (supportedStylesEqual(uniqueStyles[i], styles)) {
+ return i;
+ }
+ }
+ uniqueStyles.push(styles);
+ return uniqueStyles.length - 1;
+}
+
+function buildOptionListForChildren(node, uniqueStyles) {
+ let result = [];
+
+ for (let child of node.children) {
+ let tagName = child.tagName.toUpperCase();
+
+ if (tagName == "OPTION" || tagName == "OPTGROUP") {
+ if (child.hidden) {
+ continue;
+ }
+
+ // The option code-path should match HTMLOptionElement::GetRenderedLabel.
+ let textContent =
+ tagName == "OPTGROUP"
+ ? child.getAttribute("label")
+ : child.label || child.text;
+ if (textContent == null) {
+ textContent = "";
+ }
+
+ let cs = getComputedStyles(child);
+ let info = {
+ index: child.index,
+ tagName,
+ textContent,
+ disabled: child.disabled,
+ display: cs.display,
+ tooltip: child.title,
+ children:
+ tagName == "OPTGROUP"
+ ? 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();
+
+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.jsm b/toolkit/actors/SelectParent.jsm
new file mode 100644
index 0000000000..045052e013
--- /dev/null
+++ b/toolkit/actors/SelectParent.jsm
@@ -0,0 +1,771 @@
+/* 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 = ["SelectParent", "SelectParentHelper"];
+
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+// Maximum number of rows to display in the select dropdown.
+const MAX_ROWS = 20;
+
+// Minimum elements required to show select search
+const SEARCH_MINIMUM_ELEMENTS = 40;
+
+// The properties that we should respect only when the item is not active.
+const PROPERTIES_RESET_WHEN_ACTIVE = [
+ "color",
+ "background-color",
+ "text-shadow",
+];
+
+// Duplicated in SelectChild.jsm
+// Please keep these lists in sync.
+const SUPPORTED_OPTION_OPTGROUP_PROPERTIES = [
+ "direction",
+ "color",
+ "background-color",
+ "text-shadow",
+ "font-family",
+ "font-weight",
+ "font-size",
+ "font-style",
+];
+
+const SUPPORTED_SELECT_PROPERTIES = [
+ ...SUPPORTED_OPTION_OPTGROUP_PROPERTIES,
+ "scrollbar-width",
+ "scrollbar-color",
+];
+
+const customStylingEnabled = Services.prefs.getBoolPref(
+ "dom.forms.select.customstyling"
+);
+
+var SelectParentHelper = {
+ /**
+ * `populate` takes the `menulist` element and a list of `items` and generates
+ * a popup list of options.
+ *
+ * If `customStylingEnabled` 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 {Object} uaStyle
+ * @param {Object} selectStyle
+ *
+ * FIXME(emilio): injecting a stylesheet is a somewhat inefficient way to do
+ * this, can we use more style attributes?
+ *
+ * FIXME(emilio, bug 1530709): At the very least we should use CSSOM to avoid
+ * trusting the IPC message too much.
+ */
+ populate(
+ menulist,
+ items,
+ uniqueItemStyles,
+ selectedIndex,
+ zoom,
+ uaStyle,
+ selectStyle
+ ) {
+ // Clear the current contents of the popup
+ menulist.menupopup.textContent = "";
+ let stylesheet = menulist.querySelector("#ContentSelectDropdownStylesheet");
+ if (stylesheet) {
+ stylesheet.remove();
+ }
+
+ let doc = menulist.ownerDocument;
+ let sheet;
+ if (customStylingEnabled) {
+ stylesheet = doc.createElementNS("http://www.w3.org/1999/xhtml", "style");
+ stylesheet.setAttribute("id", "ContentSelectDropdownStylesheet");
+ stylesheet.hidden = true;
+ stylesheet = menulist.appendChild(stylesheet);
+ sheet = stylesheet.sheet;
+ } else {
+ selectStyle = uaStyle;
+ }
+
+ let selectBackgroundSet = false;
+
+ 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;
+ }
+
+ if (customStylingEnabled) {
+ if (selectStyle["text-shadow"] != "none") {
+ sheet.insertRule(
+ `#ContentSelectDropdown > menupopup > [_moz-menuactive="true"] {
+ text-shadow: none;
+ }`,
+ 0
+ );
+ }
+
+ let addedRule = false;
+ for (let property of SUPPORTED_SELECT_PROPERTIES) {
+ if (property == "direction") {
+ continue;
+ } // Handled above, or before.
+ if (
+ !selectStyle[property] ||
+ selectStyle[property] == uaStyle[property]
+ ) {
+ continue;
+ }
+ if (!addedRule) {
+ sheet.insertRule("#ContentSelectDropdown > menupopup {}", 0);
+ addedRule = true;
+ }
+ 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";
+ }
+ sheet.cssRules[0].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 (
+ customStylingEnabled &&
+ selectStyle["background-color"] != uaStyle["background-color"]
+ ) {
+ // We intentionally use the parsed color to prevent color
+ // values like `url(..)` being injected into the
+ // `background-image` property.
+ let parsedColor = sheet.cssRules[0].style["background-color"];
+ sheet.cssRules[0].style["background-color"] = "";
+ sheet.cssRules[0].style[
+ "background-image"
+ ] = `linear-gradient(${parsedColor}, ${parsedColor})`;
+ selectBackgroundSet = true;
+ }
+ if (addedRule) {
+ sheet.insertRule(
+ `#ContentSelectDropdown > menupopup > :not([_moz-menuactive="true"]) {
+ color: inherit;
+ }`,
+ 0
+ );
+ }
+ }
+
+ // 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 (selectBackgroundSet) {
+ menulist.menupopup.setAttribute("customoptionstyling", "true");
+ } else {
+ menulist.menupopup.removeAttribute("customoptionstyling");
+ }
+
+ this._currentZoom = zoom;
+ this._currentMenulist = menulist;
+ this.populateChildren(
+ menulist,
+ items,
+ uniqueItemStyles,
+ selectedIndex,
+ zoom,
+ selectStyle,
+ selectBackgroundSet,
+ sheet
+ );
+ },
+
+ open(browser, menulist, rect, isOpenedViaTouch, selectParentActor) {
+ this._actor = selectParentActor;
+ menulist.hidden = false;
+ this._currentBrowser = browser;
+ this._closedWithEnter = false;
+ this._selectRect = rect;
+ this._registerListeners(browser, menulist.menupopup);
+
+ let win = browser.ownerGlobal;
+
+ // Set the maximum height to show exactly MAX_ROWS items.
+ let menupopup = menulist.menupopup;
+ let firstItem = menupopup.firstElementChild;
+ while (firstItem && firstItem.hidden) {
+ firstItem = firstItem.nextElementSibling;
+ }
+
+ if (firstItem) {
+ let itemHeight = firstItem.getBoundingClientRect().height;
+
+ // Include the padding and border on the popup.
+ let cs = win.getComputedStyle(menupopup);
+ let bpHeight =
+ parseFloat(cs.borderTopWidth) +
+ parseFloat(cs.borderBottomWidth) +
+ parseFloat(cs.paddingTop) +
+ parseFloat(cs.paddingBottom);
+ menupopup.style.maxHeight = itemHeight * MAX_ROWS + bpHeight + "px";
+ }
+
+ menupopup.classList.toggle("isOpenedViaTouch", isOpenedViaTouch);
+
+ if (browser.getAttribute("selectmenuconstrained") != "false") {
+ let constraintRect = browser.getBoundingClientRect();
+ constraintRect = new win.DOMRect(
+ constraintRect.left + win.mozInnerScreenX,
+ constraintRect.top + win.mozInnerScreenY,
+ constraintRect.width,
+ constraintRect.height
+ );
+ menupopup.setConstraintRect(constraintRect);
+ } 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":
+ this._actor.sendAsyncMessage("Forms:MouseOver", {});
+
+ break;
+
+ case "mouseout":
+ 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":
+ if (this._currentMenulist) {
+ this._currentMenulist.menupopup.hidePopup();
+ }
+ break;
+
+ case "popuphidden":
+ this._actor.sendAsyncMessage("Forms:DismissedDropDown", {});
+ let popup = event.target;
+ this._unregisterListeners(this._currentBrowser, popup);
+ popup.parentNode.hidden = true;
+ this._currentBrowser = null;
+ this._currentMenulist = null;
+ this._selectRect = null;
+ this._currentZoom = 1;
+ this._actor = null;
+ break;
+ }
+ },
+
+ receiveMessage(msg) {
+ if (!this._currentBrowser) {
+ return;
+ }
+
+ if (msg.name == "Forms:UpdateDropDown") {
+ // Sanity check - we'd better know what the currently
+ // opened menulist is, and what browser it belongs to...
+ if (!this._currentMenulist) {
+ return;
+ }
+
+ 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.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(browser, popup) {
+ popup.addEventListener("command", this);
+ popup.addEventListener("popuphidden", this);
+ popup.addEventListener("mouseover", this);
+ popup.addEventListener("mouseout", this);
+ browser.ownerGlobal.addEventListener("mouseup", this, true);
+ browser.ownerGlobal.addEventListener("keydown", this, true);
+ browser.ownerGlobal.addEventListener("fullscreen", this, true);
+ },
+
+ _unregisterListeners(browser, popup) {
+ popup.removeEventListener("command", this);
+ popup.removeEventListener("popuphidden", this);
+ popup.removeEventListener("mouseover", this);
+ popup.removeEventListener("mouseout", this);
+ browser.ownerGlobal.removeEventListener("mouseup", this, true);
+ browser.ownerGlobal.removeEventListener("keydown", this, true);
+ browser.ownerGlobal.removeEventListener("fullscreen", 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 {Number} zoom
+ * @param {Object} selectStyle
+ * @param {Boolean} selectBackgroundSet
+ * @param {CSSStyleSheet} sheet
+ * @param {Element} parentElement
+ * @param {Boolean} isGroupDisabled
+ * @param {Boolean} addSearch
+ * @param {Number} nthChildIndex
+ * @returns {Number}
+ *
+ * FIXME(emilio): Again, using a stylesheet + :nth-child is not really efficient.
+ */
+ populateChildren(
+ menulist,
+ options,
+ uniqueOptionStyles,
+ selectedIndex,
+ zoom,
+ selectStyle,
+ selectBackgroundSet,
+ sheet,
+ parentElement = null,
+ isGroupDisabled = false,
+ addSearch = true,
+ nthChildIndex = 1
+ ) {
+ let element = menulist.menupopup;
+
+ let ariaOwns = "";
+ for (let option of options) {
+ let isOptGroup = option.tagName == "OPTGROUP";
+ let item = element.ownerDocument.createXULElement(
+ isOptGroup ? "menucaption" : "menuitem"
+ );
+ if (isOptGroup) {
+ item.setAttribute("role", "group");
+ }
+ let style = uniqueOptionStyles[option.styleIndex];
+
+ item.setAttribute("label", option.textContent);
+ item.style.direction = style.direction;
+ item.style.fontSize = zoom * parseFloat(style["font-size"], 10) + "px";
+ item.hidden =
+ option.display == "none" || (parentElement && parentElement.hidden);
+ // Keep track of which options are hidden by page content, so we can avoid showing
+ // them on search input
+ item.hiddenByContent = item.hidden;
+ item.setAttribute("tooltiptext", option.tooltip);
+
+ if (style["background-color"] == "rgba(0, 0, 0, 0)") {
+ style["background-color"] = selectStyle["background-color"];
+ }
+
+ let optionBackgroundSet =
+ style["background-color"] != selectStyle["background-color"];
+
+ if (style.color == style["background-color"]) {
+ style.color = selectStyle.color;
+ }
+
+ if (customStylingEnabled) {
+ let addedRule = false;
+ for (const property of SUPPORTED_OPTION_OPTGROUP_PROPERTIES) {
+ if (property == "direction" || property == "font-size") {
+ continue;
+ } // handled above
+ if (!style[property] || style[property] == selectStyle[property]) {
+ continue;
+ }
+ if (PROPERTIES_RESET_WHEN_ACTIVE.includes(property)) {
+ if (!addedRule) {
+ sheet.insertRule(
+ `#ContentSelectDropdown > menupopup > :nth-child(${nthChildIndex}):not([_moz-menuactive="true"]) {
+ }`,
+ 0
+ );
+ addedRule = true;
+ }
+ sheet.cssRules[0].style[property] = style[property];
+ } else {
+ item.style.setProperty(property, style[property]);
+ }
+ }
+
+ if (addedRule) {
+ if (
+ style["text-shadow"] != "none" &&
+ style["text-shadow"] != selectStyle["text-shadow"]
+ ) {
+ // Need to explicitly disable the possibly inherited
+ // text-shadow rule when _moz-menuactive=true since
+ // _moz-menuactive=true disables custom option styling.
+ sheet.insertRule(
+ `#ContentSelectDropdown > menupopup > :nth-child(${nthChildIndex})[_moz-menuactive="true"] {
+ text-shadow: none;
+ }`,
+ 0
+ );
+ }
+ }
+ }
+
+ if (
+ customStylingEnabled &&
+ (optionBackgroundSet || selectBackgroundSet)
+ ) {
+ item.setAttribute("customoptionstyling", "true");
+ } else {
+ item.removeAttribute("customoptionstyling");
+ }
+
+ if (parentElement) {
+ // In the menupopup, the optgroup is a sibling of its contained options.
+ // For accessibility, we want to preserve the hierarchy such that the
+ // options are inside the optgroup. We do this using aria-owns on the
+ // parent.
+ item.id = "ContentSelectDropdownOption" + nthChildIndex;
+ item.setAttribute("aria-level", "2");
+ ariaOwns += item.id + " ";
+ }
+
+ element.appendChild(item);
+ nthChildIndex++;
+
+ // A disabled optgroup disables all of its child options.
+ let isDisabled = isGroupDisabled || option.disabled;
+ if (isDisabled) {
+ item.setAttribute("disabled", "true");
+ }
+
+ if (isOptGroup) {
+ nthChildIndex = this.populateChildren(
+ menulist,
+ option.children,
+ uniqueOptionStyles,
+ selectedIndex,
+ zoom,
+ selectStyle,
+ selectBackgroundSet,
+ sheet,
+ 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 (
+ Services.prefs.getBoolPref("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", "false");
+ },
+};
+
+class SelectParent extends JSWindowActorParent {
+ receiveMessage(message) {
+ switch (message.name) {
+ case "Forms:ShowDropDown": {
+ let topBrowsingContext = this.manager.browsingContext.top;
+ let browser = topBrowsingContext.embedderElement;
+
+ if (!browser.hasAttribute("selectmenulist")) {
+ return;
+ }
+
+ let document = browser.ownerDocument;
+ let menulist = document.getElementById(
+ browser.getAttribute("selectmenulist")
+ );
+
+ if (!this._menulist) {
+ // Cache the menulist to have access to it
+ // when the document is gone (eg: Tab closed)
+ this._menulist = menulist;
+ }
+
+ let data = message.data;
+ menulist.menupopup.style.direction = data.style.direction;
+
+ let { ZoomManager } = topBrowsingContext.topChromeWindow;
+ 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.
+ ZoomManager.getFullZoomForBrowser(browser),
+ data.defaultStyle,
+ data.style
+ );
+ SelectParentHelper.open(
+ browser,
+ menulist,
+ data.rect,
+ data.isOpenedViaTouch,
+ this
+ );
+ break;
+ }
+
+ case "Forms:HideDropDown": {
+ let topBrowsingContext = this.manager.browsingContext.top;
+ let browser = topBrowsingContext.embedderElement;
+
+ SelectParentHelper.hide(this._menulist, browser);
+ break;
+ }
+
+ default:
+ SelectParentHelper.receiveMessage(message);
+ }
+ }
+}
diff --git a/toolkit/actors/TestProcessActorChild.jsm b/toolkit/actors/TestProcessActorChild.jsm
new file mode 100644
index 0000000000..1d621b7dc8
--- /dev/null
+++ b/toolkit/actors/TestProcessActorChild.jsm
@@ -0,0 +1,61 @@
+/* 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";
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+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/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/TestWindowChild.jsm b/toolkit/actors/TestWindowChild.jsm
new file mode 100644
index 0000000000..b233dfd9bb
--- /dev/null
+++ b/toolkit/actors/TestWindowChild.jsm
@@ -0,0 +1,104 @@
+/* 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";
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+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/TestWindowParent.jsm b/toolkit/actors/TestWindowParent.jsm
new file mode 100644
index 0000000000..ee5dcab469
--- /dev/null
+++ b/toolkit/actors/TestWindowParent.jsm
@@ -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/. */
+"use strict";
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+var EXPORTED_SYMBOLS = ["TestWindowParent"];
+
+class TestWindowParent extends JSWindowActorParent {
+ constructor() {
+ super();
+ this.wrappedJSObject = this;
+ this.sawActorCreated = false;
+ }
+
+ actorCreated() {
+ this.sawActorCreated = true;
+ }
+
+ receiveMessage(aMessage) {
+ switch (aMessage.name) {
+ case "init":
+ aMessage.data.initial = true;
+ this.sendAsyncMessage("toChild", aMessage.data);
+ break;
+ case "toParent":
+ aMessage.data.toParent = true;
+ this.sendAsyncMessage("done", aMessage.data);
+ break;
+ case "asyncMul":
+ let { a, b } = aMessage.data;
+ return { result: a * b };
+
+ case "event":
+ Services.obs.notifyObservers(
+ this,
+ "test-js-window-actor-parent-event",
+ aMessage.data.type
+ );
+ break;
+ }
+
+ return undefined;
+ }
+
+ show() {
+ return "TestWindowParent";
+ }
+}
diff --git a/toolkit/actors/ThumbnailsChild.jsm b/toolkit/actors/ThumbnailsChild.jsm
new file mode 100644
index 0000000000..d54a217840
--- /dev/null
+++ b/toolkit/actors/ThumbnailsChild.jsm
@@ -0,0 +1,62 @@
+/* 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 = ["ThumbnailsChild"];
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "PageThumbUtils",
+ "resource://gre/modules/PageThumbUtils.jsm"
+);
+
+class ThumbnailsChild extends JSWindowActorChild {
+ receiveMessage(message) {
+ switch (message.name) {
+ case "Browser:Thumbnail:ContentInfo": {
+ let [width, height] = 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 = 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 = PageThumbUtils.isChannelErrorResponse(channel);
+ let originalURL;
+ try {
+ originalURL = channel.originalURI.spec;
+ } catch (ex) {}
+ return { channelError, originalURL };
+ }
+ }
+ return undefined;
+ }
+}
diff --git a/toolkit/actors/UAWidgetsChild.jsm b/toolkit/actors/UAWidgetsChild.jsm
new file mode 100644
index 0000000000..7dfa773ed4
--- /dev/null
+++ b/toolkit/actors/UAWidgetsChild.jsm
@@ -0,0 +1,241 @@
+/* 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 = ["UAWidgetsChild"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+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)
+ ) {
+ Cu.reportError(
+ "Getting a UAWidgetSetupOrChange event without the ShadowRoot. " +
+ "Torn down already?"
+ );
+ return;
+ }
+ try {
+ widget.onchange();
+ } catch (ex) {
+ Cu.reportError(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.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",
+ ];
+ break;
+ case "input":
+ uri = "chrome://global/content/elements/datetimebox.js";
+ widgetName = "DateTimeBoxWidget";
+ break;
+ case "embed":
+ case "object":
+ uri = "chrome://global/content/elements/pluginProblem.js";
+ widgetName = "PluginProblemWidget";
+ break;
+ case "marquee":
+ uri = "chrome://global/content/elements/marquee.js";
+ widgetName = "MarqueeWidget";
+ break;
+ }
+
+ if (!uri || !widgetName) {
+ Cu.reportError(
+ "Getting a UAWidgetSetupOrChange event on undefined element."
+ );
+ return;
+ }
+
+ let shadowRoot = aElement.openOrClosedShadowRoot;
+ if (!shadowRoot) {
+ Cu.reportError(
+ "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)) {
+ Cu.reportError("Widgets should expose their shadow root.");
+ }
+ this.widgets.set(aElement, { widget, widgetName });
+ try {
+ widget.onsetup();
+ } catch (ex) {
+ Cu.reportError(ex);
+ }
+ }
+
+ teardownWidget(aElement) {
+ if (!this.widgets.has(aElement)) {
+ return;
+ }
+ let { widget } = this.widgets.get(aElement);
+ if (typeof widget.destructor == "function") {
+ try {
+ widget.destructor();
+ } catch (ex) {
+ Cu.reportError(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) {
+ Cu.reportError(ex);
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/toolkit/actors/UnselectedTabHoverChild.jsm b/toolkit/actors/UnselectedTabHoverChild.jsm
new file mode 100644
index 0000000000..1c3076462b
--- /dev/null
+++ b/toolkit/actors/UnselectedTabHoverChild.jsm
@@ -0,0 +1,25 @@
+/* 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 = ["UnselectedTabHoverChild"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+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.jsm b/toolkit/actors/UnselectedTabHoverParent.jsm
new file mode 100644
index 0000000000..96e836b5d6
--- /dev/null
+++ b/toolkit/actors/UnselectedTabHoverParent.jsm
@@ -0,0 +1,18 @@
+/* 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 = ["UnselectedTabHoverParent"];
+
+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.jsm b/toolkit/actors/ViewSourceChild.jsm
new file mode 100644
index 0000000000..471d5929b3
--- /dev/null
+++ b/toolkit/actors/ViewSourceChild.jsm
@@ -0,0 +1,353 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["ViewSourceChild"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ViewSourcePageChild",
+ "resource://gre/actors/ViewSourcePageChild.jsm"
+);
+
+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, forcedCharSet;
+
+ if (outerWindowID) {
+ let contentWindow = Services.wm.getOuterWindowWithId(outerWindowID);
+ if (contentWindow) {
+ otherDocShell = contentWindow.docShell;
+
+ let utils = contentWindow.windowUtils;
+ let doc = contentWindow.document;
+ forcedCharSet = utils.docCharsetIsForced ? doc.characterSet : null;
+ }
+ }
+
+ this.loadSource(URL, otherDocShell, lineNumber, forcedCharSet);
+ }
+
+ /**
+ * 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.
+ 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.loadURI(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 forcedCharSet (optional)
+ * The document character set to use instead of the default one.
+ */
+ loadSource(URL, otherDocShell, lineNumber, forcedCharSet) {
+ const viewSrcURL = "view-source:" + URL;
+
+ if (forcedCharSet) {
+ try {
+ this.docShell.charset = forcedCharSet;
+ } catch (e) {
+ /* invalid charset */
+ }
+ }
+
+ 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.loadURI(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.jsm b/toolkit/actors/ViewSourcePageChild.jsm
new file mode 100644
index 0000000000..b96a5431d1
--- /dev/null
+++ b/toolkit/actors/ViewSourcePageChild.jsm
@@ -0,0 +1,555 @@
+/* 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-env mozilla/frame-script */
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+var EXPORTED_SYMBOLS = ["ViewSourcePageChild"];
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["NodeFilter"]);
+
+const NS_XHTML = "http://www.w3.org/1999/xhtml";
+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;
+
+/**
+ * In-page context menu items that are injected after page load.
+ */
+let gContextMenuItems = [
+ {
+ id: "goToLine",
+ accesskey: true,
+ handler(actor) {
+ actor.sendAsyncMessage("ViewSource:PromptAndGoToLine");
+ },
+ },
+ {
+ id: "wrapLongLines",
+ get checked() {
+ return Services.prefs.getBoolPref("view_source.wrap_long_lines");
+ },
+ handler(actor) {
+ actor.toggleWrapping();
+ },
+ },
+ {
+ id: "highlightSyntax",
+ get checked() {
+ return Services.prefs.getBoolPref("view_source.syntax_highlight");
+ },
+ handler(actor) {
+ actor.toggleSyntaxHighlighting();
+ },
+ },
+];
+
+class ViewSourcePageChild extends JSWindowActorChild {
+ constructor() {
+ super();
+
+ XPCOMUtils.defineLazyGetter(this, "bundle", function() {
+ return Services.strings.createBundle(BUNDLE_URL);
+ });
+ }
+
+ static setNeedsDrawSelection(value) {
+ gNeedsDrawSelection = value;
+ }
+
+ static setInitialLineNumber(value) {
+ gInitialLineNumber = value;
+ }
+
+ receiveMessage(msg) {
+ if (msg.name == "ViewSource:GoToLine") {
+ this.goToLine(msg.data.lineNumber);
+ }
+ }
+
+ /**
+ * 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.
+ * * in-page context menu actions
+ */
+ onClick(event) {
+ let target = event.originalTarget;
+ // Check for content menu actions
+ if (target.id) {
+ gContextMenuItems.forEach(itemSpec => {
+ if (itemSpec.id !== target.id) {
+ return;
+ }
+ itemSpec.handler(this);
+ event.stopPropagation();
+ });
+ }
+
+ // 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;
+ }
+
+ if (this.document.body) {
+ this.injectContextMenu();
+ }
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * Add context menu items for view source specific actions.
+ */
+ injectContextMenu() {
+ let doc = this.document;
+
+ let menu = doc.createElementNS(NS_XHTML, "menu");
+ menu.setAttribute("type", "context");
+ menu.setAttribute("id", "actions");
+ doc.body.appendChild(menu);
+ doc.body.setAttribute("contextmenu", "actions");
+
+ gContextMenuItems.forEach(itemSpec => {
+ let item = doc.createElementNS(NS_XHTML, "menuitem");
+ item.setAttribute("id", itemSpec.id);
+ let labelName = `context_${itemSpec.id}_label`;
+ let label = this.bundle.GetStringFromName(labelName);
+ item.setAttribute("label", label);
+ if ("checked" in itemSpec) {
+ item.setAttribute("type", "checkbox");
+ }
+ if (itemSpec.accesskey) {
+ let accesskeyName = `context_${itemSpec.id}_accesskey`;
+ item.setAttribute(
+ "accesskey",
+ this.bundle.GetStringFromName(accesskeyName)
+ );
+ }
+ menu.appendChild(item);
+ });
+
+ this.updateContextMenu();
+ }
+
+ /**
+ * Update state of checkbox-style context menu items.
+ */
+ updateContextMenu() {
+ let doc = this.document;
+ gContextMenuItems.forEach(itemSpec => {
+ if (!("checked" in itemSpec)) {
+ return;
+ }
+ let item = doc.getElementById(itemSpec.id);
+ if (itemSpec.checked) {
+ item.setAttribute("checked", true);
+ } else {
+ item.removeAttribute("checked");
+ }
+ });
+ }
+}
diff --git a/toolkit/actors/ViewSourcePageParent.jsm b/toolkit/actors/ViewSourcePageParent.jsm
new file mode 100644
index 0000000000..4070431a92
--- /dev/null
+++ b/toolkit/actors/ViewSourcePageParent.jsm
@@ -0,0 +1,159 @@
+// -*- 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/. */
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "Services",
+ "resource://gre/modules/Services.jsm"
+);
+
+const BUNDLE_URL = "chrome://global/locale/viewSource.properties";
+
+var EXPORTED_SYMBOLS = ["ViewSourcePageParent"];
+
+/**
+ * ViewSourcePageParent manages the view source <browser> from the chrome side.
+ */
+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();
+ }
+
+ /**
+ * 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.jsm b/toolkit/actors/WebChannelChild.jsm
new file mode 100644
index 0000000000..ccdab327a1
--- /dev/null
+++ b/toolkit/actors/WebChannelChild.jsm
@@ -0,0 +1,140 @@
+/* -*- 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"}] */
+
+var EXPORTED_SYMBOLS = ["WebChannelChild"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { ContentDOMReference } = ChromeUtils.import(
+ "resource://gre/modules/ContentDOMReference.jsm"
+);
+
+// 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)
+);
+
+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) {
+ Cu.reportError(
+ "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 {
+ Cu.reportError("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) {
+ Cu.reportError("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 {
+ Cu.reportError("WebChannel message failed. Principal mismatch.");
+ }
+ } else {
+ Cu.reportError("WebChannel message failed. No message data.");
+ }
+ }
+}
diff --git a/toolkit/actors/WebChannelParent.jsm b/toolkit/actors/WebChannelParent.jsm
new file mode 100644
index 0000000000..7fdeeaf490
--- /dev/null
+++ b/toolkit/actors/WebChannelParent.jsm
@@ -0,0 +1,96 @@
+/* -*- 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/. */
+
+var EXPORTED_SYMBOLS = ["WebChannelParent"];
+
+const { WebChannelBroker } = ChromeUtils.import(
+ "resource://gre/modules/WebChannel.jsm"
+);
+
+const ERRNO_MISSING_PRINCIPAL = 1;
+const ERRNO_NO_SUCH_CHANNEL = 2;
+
+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) {
+ Cu.reportError("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 {
+ Cu.reportError("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 {
+ Cu.reportError("Failed to send a WebChannel error. Target invalid.");
+ }
+ Cu.reportError(id.toString() + " error message. " + errorMsg);
+ }
+}
diff --git a/toolkit/actors/moz.build b/toolkit/actors/moz.build
new file mode 100644
index 0000000000..40729d7210
--- /dev/null
+++ b/toolkit/actors/moz.build
@@ -0,0 +1,70 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Toolkit", "General")
+
+with Files("AutoScroll*.jsm"):
+ BUG_COMPONENT = ("Core", "Panning and Zooming")
+
+with Files("Finder*.jsm"):
+ BUG_COMPONENT = ("Toolkit", "Find Toolbar")
+
+with Files("KeyPressEventModelCheckerChild.jsm"):
+ BUG_COMPONENT = ("Core", "DOM: Events")
+
+TESTING_JS_MODULES += [
+ "TestProcessActorChild.jsm",
+ "TestProcessActorParent.jsm",
+ "TestWindowChild.jsm",
+ "TestWindowParent.jsm",
+]
+
+FINAL_TARGET_FILES.actors += [
+ "AboutHttpsOnlyErrorChild.jsm",
+ "AboutHttpsOnlyErrorParent.jsm",
+ "AudioPlaybackChild.jsm",
+ "AudioPlaybackParent.jsm",
+ "AutoCompleteChild.jsm",
+ "AutoCompleteParent.jsm",
+ "AutoplayChild.jsm",
+ "AutoplayParent.jsm",
+ "AutoScrollChild.jsm",
+ "AutoScrollParent.jsm",
+ "BackgroundThumbnailsChild.jsm",
+ "BrowserElementChild.jsm",
+ "BrowserElementParent.jsm",
+ "ControllersChild.jsm",
+ "ControllersParent.jsm",
+ "DateTimePickerChild.jsm",
+ "DateTimePickerParent.jsm",
+ "ExtFindChild.jsm",
+ "FindBarChild.jsm",
+ "FindBarParent.jsm",
+ "FinderChild.jsm",
+ "InlineSpellCheckerChild.jsm",
+ "InlineSpellCheckerParent.jsm",
+ "KeyPressEventModelCheckerChild.jsm",
+ "PictureInPictureChild.jsm",
+ "PopupBlockingChild.jsm",
+ "PopupBlockingParent.jsm",
+ "PrintingChild.jsm",
+ "PrintingParent.jsm",
+ "PrintingSelectionChild.jsm",
+ "PurgeSessionHistoryChild.jsm",
+ "RemotePageChild.jsm",
+ "SelectChild.jsm",
+ "SelectParent.jsm",
+ "ThumbnailsChild.jsm",
+ "UAWidgetsChild.jsm",
+ "UnselectedTabHoverChild.jsm",
+ "UnselectedTabHoverParent.jsm",
+ "ViewSourceChild.jsm",
+ "ViewSourcePageChild.jsm",
+ "ViewSourcePageParent.jsm",
+ "WebChannelChild.jsm",
+ "WebChannelParent.jsm",
+]