summaryrefslogtreecommitdiffstats
path: root/browser/actors
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /browser/actors
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/actors')
-rw-r--r--browser/actors/AboutNewTabChild.sys.mjs104
-rw-r--r--browser/actors/AboutNewTabParent.sys.mjs168
-rw-r--r--browser/actors/AboutPocketChild.sys.mjs8
-rw-r--r--browser/actors/AboutPocketParent.sys.mjs132
-rw-r--r--browser/actors/AboutPrivateBrowsingChild.sys.mjs62
-rw-r--r--browser/actors/AboutPrivateBrowsingParent.sys.mjs168
-rw-r--r--browser/actors/AboutProtectionsChild.sys.mjs24
-rw-r--r--browser/actors/AboutProtectionsParent.sys.mjs447
-rw-r--r--browser/actors/AboutReaderChild.sys.mjs252
-rw-r--r--browser/actors/AboutReaderParent.sys.mjs294
-rw-r--r--browser/actors/AboutTabCrashedChild.sys.mjs8
-rw-r--r--browser/actors/AboutTabCrashedParent.sys.mjs87
-rw-r--r--browser/actors/BlockedSiteChild.sys.mjs187
-rw-r--r--browser/actors/BlockedSiteParent.sys.mjs70
-rw-r--r--browser/actors/BrowserProcessChild.sys.mjs36
-rw-r--r--browser/actors/BrowserTabChild.sys.mjs55
-rw-r--r--browser/actors/ClickHandlerChild.sys.mjs174
-rw-r--r--browser/actors/ClickHandlerParent.sys.mjs156
-rw-r--r--browser/actors/ContentSearchChild.sys.mjs35
-rw-r--r--browser/actors/ContentSearchParent.sys.mjs658
-rw-r--r--browser/actors/ContextMenuChild.sys.mjs1243
-rw-r--r--browser/actors/ContextMenuParent.sys.mjs117
-rw-r--r--browser/actors/DOMFullscreenChild.sys.mjs164
-rw-r--r--browser/actors/DOMFullscreenParent.sys.mjs318
-rw-r--r--browser/actors/DecoderDoctorChild.sys.mjs28
-rw-r--r--browser/actors/DecoderDoctorParent.sys.mjs272
-rw-r--r--browser/actors/EncryptedMediaChild.sys.mjs121
-rw-r--r--browser/actors/EncryptedMediaParent.sys.mjs276
-rw-r--r--browser/actors/FormValidationChild.sys.mjs193
-rw-r--r--browser/actors/FormValidationParent.sys.mjs202
-rw-r--r--browser/actors/LightweightThemeChild.sys.mjs82
-rw-r--r--browser/actors/LinkHandlerChild.sys.mjs175
-rw-r--r--browser/actors/LinkHandlerParent.sys.mjs164
-rw-r--r--browser/actors/PageInfoChild.sys.mjs402
-rw-r--r--browser/actors/PageStyleChild.sys.mjs199
-rw-r--r--browser/actors/PageStyleParent.sys.mjs72
-rw-r--r--browser/actors/PluginChild.sys.mjs92
-rw-r--r--browser/actors/PluginParent.sys.mjs204
-rw-r--r--browser/actors/PointerLockChild.sys.mjs17
-rw-r--r--browser/actors/PointerLockParent.sys.mjs22
-rw-r--r--browser/actors/PromptParent.sys.mjs468
-rw-r--r--browser/actors/RFPHelperChild.sys.mjs25
-rw-r--r--browser/actors/RFPHelperParent.sys.mjs33
-rw-r--r--browser/actors/RefreshBlockerChild.sys.mjs234
-rw-r--r--browser/actors/RefreshBlockerParent.sys.mjs17
-rw-r--r--browser/actors/ScreenshotsComponentChild.sys.mjs353
-rw-r--r--browser/actors/SearchSERPTelemetryChild.sys.mjs1357
-rw-r--r--browser/actors/SearchSERPTelemetryParent.sys.mjs38
-rw-r--r--browser/actors/SpeechDispatcherChild.sys.mjs10
-rw-r--r--browser/actors/SpeechDispatcherParent.sys.mjs90
-rw-r--r--browser/actors/SwitchDocumentDirectionChild.sys.mjs27
-rw-r--r--browser/actors/WebRTCChild.sys.mjs578
-rw-r--r--browser/actors/WebRTCParent.sys.mjs1484
-rw-r--r--browser/actors/moz.build92
-rw-r--r--browser/actors/test/browser/browser.toml6
-rw-r--r--browser/actors/test/browser/browser_nested_link_clicks.js45
-rw-r--r--browser/actors/test/browser/browser_untrusted_click_event.js107
-rw-r--r--browser/actors/test/browser/click.html76
58 files changed, 12528 insertions, 0 deletions
diff --git a/browser/actors/AboutNewTabChild.sys.mjs b/browser/actors/AboutNewTabChild.sys.mjs
new file mode 100644
index 0000000000..bc57c9cdf8
--- /dev/null
+++ b/browser/actors/AboutNewTabChild.sys.mjs
@@ -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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs";
+import { RemotePageChild } from "resource://gre/actors/RemotePageChild.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "ACTIVITY_STREAM_DEBUG",
+ "browser.newtabpage.activity-stream.debug",
+ false
+);
+
+let gNextPortID = 0;
+
+export class AboutNewTabChild extends RemotePageChild {
+ handleEvent(event) {
+ if (event.type == "DOMDocElementInserted") {
+ let portID = Services.appinfo.processID + ":" + ++gNextPortID;
+
+ this.sendAsyncMessage("Init", {
+ portID,
+ url: this.contentWindow.document.documentURI.replace(/[\#|\?].*$/, ""),
+ });
+ } else if (event.type == "load") {
+ this.sendAsyncMessage("Load");
+ } else if (event.type == "DOMContentLoaded") {
+ if (!this.contentWindow.document.body.firstElementChild) {
+ return; // about:newtab is a blank page
+ }
+
+ // If the separate about:welcome page is enabled, we can skip all of this,
+ // since that mode doesn't load any of the Activity Stream bits.
+ if (
+ (lazy.NimbusFeatures.aboutwelcome.getVariable("enabled") ?? true) &&
+ this.contentWindow.location.pathname.includes("welcome")
+ ) {
+ return;
+ }
+
+ const debug = !AppConstants.RELEASE_OR_BETA && lazy.ACTIVITY_STREAM_DEBUG;
+ const debugString = debug ? "-dev" : "";
+
+ // This list must match any similar ones in render-activity-stream-html.js.
+ const scripts = [
+ "chrome://browser/content/contentSearchUI.js",
+ "chrome://browser/content/contentSearchHandoffUI.js",
+ "chrome://browser/content/contentTheme.js",
+ `resource://activity-stream/vendor/react${debugString}.js`,
+ `resource://activity-stream/vendor/react-dom${debugString}.js`,
+ "resource://activity-stream/vendor/prop-types.js",
+ "resource://activity-stream/vendor/react-transition-group.js",
+ "resource://activity-stream/vendor/redux.js",
+ "resource://activity-stream/vendor/react-redux.js",
+ "resource://activity-stream/data/content/activity-stream.bundle.js",
+ "resource://activity-stream/data/content/newtab-render.js",
+ ];
+
+ for (let script of scripts) {
+ Services.scriptloader.loadSubScript(script, this.contentWindow);
+ }
+ } else if (event.type == "unload") {
+ try {
+ this.sendAsyncMessage("Unload");
+ } catch (e) {
+ // If the tab has been closed the frame message manager has already been
+ // destroyed
+ }
+ } else if (
+ (event.type == "pageshow" || event.type == "visibilitychange") &&
+ // The default browser notification shouldn't be shown on about:welcome
+ // since we don't want to distract from the onboarding wizard.
+ !this.contentWindow.location.pathname.includes("welcome")
+ ) {
+ // Don't show the notification in non-permanent private windows
+ // since it is expected to have very little opt-in here.
+ let contentWindowPrivate = PrivateBrowsingUtils.isContentWindowPrivate(
+ this.contentWindow
+ );
+ if (
+ this.document.visibilityState == "visible" &&
+ (!contentWindowPrivate ||
+ (contentWindowPrivate &&
+ PrivateBrowsingUtils.permanentPrivateBrowsing))
+ ) {
+ this.sendAsyncMessage("AboutNewTabVisible");
+
+ // Note: newtab feature info is currently being loaded in PrefsFeed.sys.mjs,
+ // But we're recording exposure events here.
+ lazy.NimbusFeatures.newtab.recordExposureEvent({ once: true });
+ }
+ }
+ }
+}
diff --git a/browser/actors/AboutNewTabParent.sys.mjs b/browser/actors/AboutNewTabParent.sys.mjs
new file mode 100644
index 0000000000..c2ee068b04
--- /dev/null
+++ b/browser/actors/AboutNewTabParent.sys.mjs
@@ -0,0 +1,168 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs",
+ ASRouter: "resource:///modules/asrouter/ASRouter.sys.mjs",
+});
+
+// A mapping of loaded new tab pages, where the mapping is:
+// browser -> { actor, browser, browsingContext, portID, url, loaded }
+let gLoadedTabs = new Map();
+
+export class AboutNewTabParent extends JSWindowActorParent {
+ static get loadedTabs() {
+ return gLoadedTabs;
+ }
+
+ getTabDetails() {
+ let browser = this.browsingContext.top.embedderElement;
+ return browser ? gLoadedTabs.get(browser) : null;
+ }
+
+ handleEvent(event) {
+ if (event.type == "SwapDocShells") {
+ let oldBrowser = this.browsingContext.top.embedderElement;
+ let newBrowser = event.detail;
+
+ let tabDetails = gLoadedTabs.get(oldBrowser);
+ if (tabDetails) {
+ tabDetails.browser = newBrowser;
+ gLoadedTabs.delete(oldBrowser);
+ gLoadedTabs.set(newBrowser, tabDetails);
+
+ oldBrowser.removeEventListener("SwapDocShells", this);
+ newBrowser.addEventListener("SwapDocShells", this);
+ }
+ }
+ }
+
+ async receiveMessage(message) {
+ switch (message.name) {
+ case "AboutNewTabVisible":
+ await lazy.ASRouter.waitForInitialized;
+ lazy.ASRouter.sendTriggerMessage({
+ browser: this.browsingContext.top.embedderElement,
+ // triggerId and triggerContext
+ id: "defaultBrowserCheck",
+ context: { source: "newtab" },
+ });
+ break;
+
+ case "Init": {
+ let browsingContext = this.browsingContext;
+ let browser = browsingContext.top.embedderElement;
+ if (!browser) {
+ return;
+ }
+
+ let tabDetails = {
+ actor: this,
+ browser,
+ browsingContext,
+ portID: message.data.portID,
+ url: message.data.url,
+ };
+ gLoadedTabs.set(browser, tabDetails);
+
+ browser.addEventListener("SwapDocShells", this);
+ browser.addEventListener("EndSwapDocShells", this);
+
+ this.notifyActivityStreamChannel("onNewTabInit", message, tabDetails);
+ break;
+ }
+
+ case "Load":
+ this.notifyActivityStreamChannel("onNewTabLoad", message);
+ break;
+
+ case "Unload": {
+ let tabDetails = this.getTabDetails();
+ if (!tabDetails) {
+ // When closing a tab, the embedderElement can already be disconnected, so
+ // as a backup, look up the tab details by browsing context.
+ tabDetails = this.getByBrowsingContext(this.browsingContext);
+ }
+
+ if (!tabDetails) {
+ return;
+ }
+
+ tabDetails.browser.removeEventListener("EndSwapDocShells", this);
+
+ gLoadedTabs.delete(tabDetails.browser);
+
+ this.notifyActivityStreamChannel("onNewTabUnload", message, tabDetails);
+ break;
+ }
+
+ case "ActivityStream:ContentToMain":
+ this.notifyActivityStreamChannel("onMessage", message);
+ break;
+ }
+ }
+
+ notifyActivityStreamChannel(name, message, tabDetails) {
+ if (!tabDetails) {
+ tabDetails = this.getTabDetails();
+ if (!tabDetails) {
+ return;
+ }
+ }
+
+ let channel = this.getChannel();
+ if (!channel) {
+ // We're not yet ready to deal with these messages. We'll queue
+ // them for now, and then dispatch them once the channel has finished
+ // being set up.
+ AboutNewTabParent.#queuedMessages.push({
+ actor: this,
+ name,
+ message,
+ tabDetails,
+ });
+ return;
+ }
+
+ let messageToSend = {
+ target: this,
+ data: message.data || {},
+ };
+
+ channel[name](messageToSend, tabDetails);
+ }
+
+ getByBrowsingContext(expectedBrowsingContext) {
+ for (let tabDetails of AboutNewTabParent.loadedTabs.values()) {
+ if (tabDetails.browsingContext === expectedBrowsingContext) {
+ return tabDetails;
+ }
+ }
+
+ return null;
+ }
+
+ getChannel() {
+ return lazy.AboutNewTab.activityStream?.store?.getMessageChannel();
+ }
+
+ // Queued messages sent from the content process. These are only queued
+ // if an AboutNewTabParent receives them before the
+ // ActivityStreamMessageChannel exists.
+ static #queuedMessages = [];
+
+ /**
+ * If there were any messages sent from content before the
+ * ActivityStreamMessageChannel was set up, dispatch them now.
+ */
+ static flushQueuedMessagesFromContent() {
+ for (let messageData of AboutNewTabParent.#queuedMessages) {
+ let { actor, name, message, tabDetails } = messageData;
+ actor.notifyActivityStreamChannel(name, message, tabDetails);
+ }
+ AboutNewTabParent.#queuedMessages = [];
+ }
+}
diff --git a/browser/actors/AboutPocketChild.sys.mjs b/browser/actors/AboutPocketChild.sys.mjs
new file mode 100644
index 0000000000..e7f382882a
--- /dev/null
+++ b/browser/actors/AboutPocketChild.sys.mjs
@@ -0,0 +1,8 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { RemotePageChild } from "resource://gre/actors/RemotePageChild.sys.mjs";
+
+export class AboutPocketChild extends RemotePageChild {}
diff --git a/browser/actors/AboutPocketParent.sys.mjs b/browser/actors/AboutPocketParent.sys.mjs
new file mode 100644
index 0000000000..ae40dfa1d9
--- /dev/null
+++ b/browser/actors/AboutPocketParent.sys.mjs
@@ -0,0 +1,132 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ pktApi: "chrome://pocket/content/pktApi.sys.mjs",
+ SaveToPocket: "chrome://pocket/content/SaveToPocket.sys.mjs",
+});
+
+export class AboutPocketParent extends JSWindowActorParent {
+ sendResponseMessageToPanel(messageId, payload) {
+ this.sendAsyncMessage(`${messageId}_response`, payload);
+ }
+
+ isPanalAvailable() {
+ return !!this.manager && !this.manager.isClosed;
+ }
+
+ async receiveMessage(message) {
+ switch (message.name) {
+ case "PKT_show_signup": {
+ this.browsingContext.topChromeWindow?.pktUI.onShowSignup();
+ break;
+ }
+ case "PKT_show_saved": {
+ this.browsingContext.topChromeWindow?.pktUI.onShowSaved();
+ break;
+ }
+ case "PKT_show_home": {
+ this.browsingContext.topChromeWindow?.pktUI.onShowHome();
+ break;
+ }
+ case "PKT_close": {
+ this.browsingContext.topChromeWindow?.pktUI.closePanel();
+ break;
+ }
+ case "PKT_openTabWithUrl": {
+ this.browsingContext.topChromeWindow?.pktUI.onOpenTabWithUrl(
+ message.data,
+ this.browsingContext.embedderElement.contentPrincipal,
+ this.browsingContext.embedderElement.csp
+ );
+ break;
+ }
+ case "PKT_openTabWithPocketUrl": {
+ this.browsingContext.topChromeWindow?.pktUI.onOpenTabWithPocketUrl(
+ message.data,
+ this.browsingContext.embedderElement.contentPrincipal,
+ this.browsingContext.embedderElement.csp
+ );
+ break;
+ }
+ case "PKT_resizePanel": {
+ this.browsingContext.topChromeWindow?.pktUI.resizePanel(message.data);
+ this.sendResponseMessageToPanel("PKT_resizePanel");
+ break;
+ }
+ case "PKT_getTags": {
+ this.sendResponseMessageToPanel("PKT_getTags", lazy.pktApi.getTags());
+ break;
+ }
+ case "PKT_getRecentTags": {
+ this.sendResponseMessageToPanel(
+ "PKT_getRecentTags",
+ lazy.pktApi.getRecentTags()
+ );
+ break;
+ }
+ case "PKT_getSuggestedTags": {
+ // Ask for suggested tags based on passed url
+ const result = await new Promise(resolve => {
+ lazy.pktApi.getSuggestedTagsForURL(message.data.url, {
+ success: data => {
+ var successResponse = {
+ status: "success",
+ value: {
+ suggestedTags: data.suggested_tags,
+ },
+ };
+ resolve(successResponse);
+ },
+ error: error => resolve({ status: "error", error }),
+ });
+ });
+
+ // If the doorhanger is still open, send the result.
+ if (this.isPanalAvailable()) {
+ this.sendResponseMessageToPanel("PKT_getSuggestedTags", result);
+ }
+ break;
+ }
+ case "PKT_addTags": {
+ // Pass url and array list of tags, add to existing save item accordingly
+ const result = await new Promise(resolve => {
+ lazy.pktApi.addTagsToURL(message.data.url, message.data.tags, {
+ success: () => resolve({ status: "success" }),
+ error: error => resolve({ status: "error", error }),
+ });
+ });
+
+ // If the doorhanger is still open, send the result.
+ if (this.isPanalAvailable()) {
+ this.sendResponseMessageToPanel("PKT_addTags", result);
+ }
+ break;
+ }
+ case "PKT_deleteItem": {
+ // Based on clicking "remove page" CTA, and passed unique item id, remove the item
+ const result = await new Promise(resolve => {
+ lazy.pktApi.deleteItem(message.data.itemId, {
+ success: () => {
+ resolve({ status: "success" });
+ lazy.SaveToPocket.itemDeleted();
+ },
+ error: error => resolve({ status: "error", error }),
+ });
+ });
+
+ // If the doorhanger is still open, send the result.
+ if (this.isPanalAvailable()) {
+ this.sendResponseMessageToPanel("PKT_deleteItem", result);
+ }
+ break;
+ }
+ case "PKT_log": {
+ console.log(...Object.values(message.data));
+ break;
+ }
+ }
+ }
+}
diff --git a/browser/actors/AboutPrivateBrowsingChild.sys.mjs b/browser/actors/AboutPrivateBrowsingChild.sys.mjs
new file mode 100644
index 0000000000..277156065c
--- /dev/null
+++ b/browser/actors/AboutPrivateBrowsingChild.sys.mjs
@@ -0,0 +1,62 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { RemotePageChild } from "resource://gre/actors/RemotePageChild.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+ ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
+});
+
+export class AboutPrivateBrowsingChild extends RemotePageChild {
+ actorCreated() {
+ super.actorCreated();
+ let window = this.contentWindow;
+
+ Cu.exportFunction(this.PrivateBrowsingRecordClick.bind(this), window, {
+ defineAs: "PrivateBrowsingRecordClick",
+ });
+ Cu.exportFunction(
+ this.PrivateBrowsingShouldHideDefault.bind(this),
+ window,
+ {
+ defineAs: "PrivateBrowsingShouldHideDefault",
+ }
+ );
+ Cu.exportFunction(
+ this.PrivateBrowsingPromoExposureTelemetry.bind(this),
+ window,
+ { defineAs: "PrivateBrowsingPromoExposureTelemetry" }
+ );
+ Cu.exportFunction(this.FeltPrivacyExposureTelemetry.bind(this), window, {
+ defineAs: "FeltPrivacyExposureTelemetry",
+ });
+ }
+
+ PrivateBrowsingRecordClick(source) {
+ const experiment = lazy.ExperimentAPI.getExperimentMetaData({
+ featureId: "pbNewtab",
+ });
+ if (experiment) {
+ Services.telemetry.recordEvent("aboutprivatebrowsing", "click", source);
+ }
+ return experiment;
+ }
+
+ PrivateBrowsingShouldHideDefault() {
+ const config = lazy.NimbusFeatures.pbNewtab.getAllVariables() || {};
+ return config?.content?.hideDefault;
+ }
+
+ PrivateBrowsingPromoExposureTelemetry() {
+ lazy.NimbusFeatures.pbNewtab.recordExposureEvent({ once: false });
+ }
+
+ FeltPrivacyExposureTelemetry() {
+ lazy.NimbusFeatures.feltPrivacy.recordExposureEvent({ once: true });
+ }
+}
diff --git a/browser/actors/AboutPrivateBrowsingParent.sys.mjs b/browser/actors/AboutPrivateBrowsingParent.sys.mjs
new file mode 100644
index 0000000000..5c6757b8a0
--- /dev/null
+++ b/browser/actors/AboutPrivateBrowsingParent.sys.mjs
@@ -0,0 +1,168 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { ASRouter } from "resource:///modules/asrouter/ASRouter.sys.mjs";
+import { BrowserUtils } from "resource://gre/modules/BrowserUtils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const SHOWN_PREF = "browser.search.separatePrivateDefault.ui.banner.shown";
+const lazy = {};
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "MAX_SEARCH_BANNER_SHOW_COUNT",
+ "browser.search.separatePrivateDefault.ui.banner.max",
+ 0
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "isPrivateSearchUIEnabled",
+ "browser.search.separatePrivateDefault.ui.enabled",
+ false
+);
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ SpecialMessageActions:
+ "resource://messaging-system/lib/SpecialMessageActions.sys.mjs",
+});
+
+// We only show the private search banner once per browser session.
+let gSearchBannerShownThisSession;
+
+export class AboutPrivateBrowsingParent extends JSWindowActorParent {
+ constructor() {
+ super();
+ Services.telemetry.setEventRecordingEnabled("aboutprivatebrowsing", true);
+ }
+ // Used by tests
+ static setShownThisSession(shown) {
+ gSearchBannerShownThisSession = shown;
+ }
+
+ receiveMessage(aMessage) {
+ let browser = this.browsingContext.top.embedderElement;
+ if (!browser) {
+ return undefined;
+ }
+
+ let win = browser.ownerGlobal;
+
+ switch (aMessage.name) {
+ case "OpenPrivateWindow": {
+ win.OpenBrowserWindow({ private: true });
+ break;
+ }
+ case "OpenSearchPreferences": {
+ win.openPreferences("search", { origin: "about-privatebrowsing" });
+ break;
+ }
+ case "SearchHandoff": {
+ let urlBar = win.gURLBar;
+ let searchEngine = Services.search.defaultPrivateEngine;
+ let isFirstChange = true;
+
+ if (!aMessage.data || !aMessage.data.text) {
+ urlBar.setHiddenFocus();
+ } else {
+ // Pass the provided text to the awesomebar
+ urlBar.handoff(aMessage.data.text, searchEngine);
+ isFirstChange = false;
+ }
+
+ let checkFirstChange = () => {
+ // Check if this is the first change since we hidden focused. If it is,
+ // remove hidden focus styles, prepend the search alias and hide the
+ // in-content search.
+ if (isFirstChange) {
+ isFirstChange = false;
+ urlBar.removeHiddenFocus(true);
+ urlBar.handoff("", searchEngine);
+ this.sendAsyncMessage("DisableSearch");
+ urlBar.removeEventListener("compositionstart", checkFirstChange);
+ urlBar.removeEventListener("paste", checkFirstChange);
+ }
+ };
+
+ let onKeydown = ev => {
+ // Check if the keydown will cause a value change.
+ if (ev.key.length === 1 && !ev.altKey && !ev.ctrlKey && !ev.metaKey) {
+ checkFirstChange();
+ }
+ // If the Esc button is pressed, we are done. Show in-content search and cleanup.
+ if (ev.key === "Escape") {
+ onDone();
+ }
+ };
+
+ let onDone = ev => {
+ // We are done. Show in-content search again and cleanup.
+ this.sendAsyncMessage("ShowSearch");
+
+ const forceSuppressFocusBorder = ev?.type === "mousedown";
+ urlBar.removeHiddenFocus(forceSuppressFocusBorder);
+
+ urlBar.removeEventListener("keydown", onKeydown);
+ urlBar.removeEventListener("mousedown", onDone);
+ urlBar.removeEventListener("blur", onDone);
+ urlBar.removeEventListener("compositionstart", checkFirstChange);
+ urlBar.removeEventListener("paste", checkFirstChange);
+ };
+
+ urlBar.addEventListener("keydown", onKeydown);
+ urlBar.addEventListener("mousedown", onDone);
+ urlBar.addEventListener("blur", onDone);
+ urlBar.addEventListener("compositionstart", checkFirstChange);
+ urlBar.addEventListener("paste", checkFirstChange);
+ break;
+ }
+ case "ShouldShowSearchBanner": {
+ // If this is a pre-loaded private browsing new tab, then we don't want
+ // to display the banner - it might never get displayed to the user
+ // and we won't know, or it might get displayed at the wrong time.
+ // This has the minor downside of not displaying the banner if
+ // you go into private browsing via opening a link, and then opening
+ // a new tab, we won't display the banner, for now, that's ok.
+ if (browser.getAttribute("preloadedState") === "preloaded") {
+ return null;
+ }
+
+ if (!lazy.isPrivateSearchUIEnabled || gSearchBannerShownThisSession) {
+ return null;
+ }
+ gSearchBannerShownThisSession = true;
+ const shownTimes = Services.prefs.getIntPref(SHOWN_PREF, 0);
+ if (shownTimes >= lazy.MAX_SEARCH_BANNER_SHOW_COUNT) {
+ return null;
+ }
+ Services.prefs.setIntPref(SHOWN_PREF, shownTimes + 1);
+ return new Promise(resolve => {
+ Services.search.getDefaultPrivate().then(engine => {
+ resolve(engine.name);
+ });
+ });
+ }
+ case "SearchBannerDismissed": {
+ Services.prefs.setIntPref(
+ SHOWN_PREF,
+ lazy.MAX_SEARCH_BANNER_SHOW_COUNT
+ );
+ break;
+ }
+ case "ShouldShowPromo": {
+ return BrowserUtils.shouldShowPromo(
+ BrowserUtils.PromoType[aMessage.data.type]
+ );
+ }
+ case "SpecialMessageActionDispatch": {
+ lazy.SpecialMessageActions.handleAction(aMessage.data, browser);
+ break;
+ }
+ case "IsPromoBlocked": {
+ return !ASRouter.isUnblockedMessage(aMessage.data);
+ }
+ }
+
+ return undefined;
+ }
+}
diff --git a/browser/actors/AboutProtectionsChild.sys.mjs b/browser/actors/AboutProtectionsChild.sys.mjs
new file mode 100644
index 0000000000..62a8b1ddff
--- /dev/null
+++ b/browser/actors/AboutProtectionsChild.sys.mjs
@@ -0,0 +1,24 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { RemotePageChild } from "resource://gre/actors/RemotePageChild.sys.mjs";
+
+export class AboutProtectionsChild extends RemotePageChild {
+ actorCreated() {
+ super.actorCreated();
+
+ this.exportFunctions(["RPMRecordTelemetryEvent"]);
+ }
+
+ RPMRecordTelemetryEvent(category, event, object, value, extra) {
+ return Services.telemetry.recordEvent(
+ category,
+ event,
+ object,
+ value,
+ extra
+ );
+ }
+}
diff --git a/browser/actors/AboutProtectionsParent.sys.mjs b/browser/actors/AboutProtectionsParent.sys.mjs
new file mode 100644
index 0000000000..1c647561ec
--- /dev/null
+++ b/browser/actors/AboutProtectionsParent.sys.mjs
@@ -0,0 +1,447 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
+ FXA_PWDMGR_HOST: "resource://gre/modules/FxAccountsCommon.sys.mjs",
+ FXA_PWDMGR_REALM: "resource://gre/modules/FxAccountsCommon.sys.mjs",
+ LoginBreaches: "resource:///modules/LoginBreaches.sys.mjs",
+ LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ Region: "resource://gre/modules/Region.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => {
+ return ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+ ).getFxAccountsSingleton();
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "TrackingDBService",
+ "@mozilla.org/tracking-db-service;1",
+ "nsITrackingDBService"
+);
+
+let idToTextMap = new Map([
+ [Ci.nsITrackingDBService.TRACKERS_ID, "tracker"],
+ [Ci.nsITrackingDBService.TRACKING_COOKIES_ID, "cookie"],
+ [Ci.nsITrackingDBService.CRYPTOMINERS_ID, "cryptominer"],
+ [Ci.nsITrackingDBService.FINGERPRINTERS_ID, "fingerprinter"],
+ // We map the suspicious fingerprinter to fingerprinter category to aggregate
+ // the number.
+ [Ci.nsITrackingDBService.SUSPICIOUS_FINGERPRINTERS_ID, "fingerprinter"],
+ [Ci.nsITrackingDBService.SOCIAL_ID, "social"],
+]);
+
+const MONITOR_API_ENDPOINT = Services.urlFormatter.formatURLPref(
+ "browser.contentblocking.report.endpoint_url"
+);
+
+const SECURE_PROXY_ADDON_ID = "secure-proxy@mozilla.com";
+
+const SCOPE_MONITOR = [
+ "profile:uid",
+ "https://identity.mozilla.com/apps/monitor",
+];
+
+const SCOPE_VPN = "profile https://identity.mozilla.com/account/subscriptions";
+const VPN_ENDPOINT = `${Services.prefs.getStringPref(
+ "identity.fxaccounts.auth.uri"
+)}oauth/subscriptions/active`;
+
+// The ID of the vpn subscription, if we see this ID attached to a user's account then they have subscribed to vpn.
+const VPN_SUB_ID = Services.prefs.getStringPref(
+ "browser.contentblocking.report.vpn_sub_id"
+);
+
+// Error messages
+const INVALID_OAUTH_TOKEN = "Invalid OAuth token";
+const USER_UNSUBSCRIBED_TO_MONITOR = "User is not subscribed to Monitor";
+const SERVICE_UNAVAILABLE = "Service unavailable";
+const UNEXPECTED_RESPONSE = "Unexpected response";
+const UNKNOWN_ERROR = "Unknown error";
+
+// Valid response info for successful Monitor data
+const MONITOR_RESPONSE_PROPS = [
+ "monitoredEmails",
+ "numBreaches",
+ "passwords",
+ "numBreachesResolved",
+ "passwordsResolved",
+];
+
+let gTestOverride = null;
+let monitorResponse = null;
+let entrypoint = "direct";
+
+export class AboutProtectionsParent extends JSWindowActorParent {
+ constructor() {
+ super();
+ }
+
+ // Some tests wish to override certain functions with ones that mostly do nothing.
+ static setTestOverride(callback) {
+ gTestOverride = callback;
+ }
+
+ /**
+ * Fetches and validates data from the Monitor endpoint. If successful, then return
+ * expected data. Otherwise, throw the appropriate error depending on the status code.
+ *
+ * @return valid data from endpoint.
+ */
+ async fetchUserBreachStats(token) {
+ if (monitorResponse && monitorResponse.timestamp) {
+ var timeDiff = Date.now() - monitorResponse.timestamp;
+ let oneDayInMS = 24 * 60 * 60 * 1000;
+ if (timeDiff >= oneDayInMS) {
+ monitorResponse = null;
+ } else {
+ return monitorResponse;
+ }
+ }
+
+ // Make the request
+ const headers = new Headers();
+ headers.append("Authorization", `Bearer ${token}`);
+ const request = new Request(MONITOR_API_ENDPOINT, { headers });
+ const response = await fetch(request);
+
+ if (response.ok) {
+ // Validate the shape of the response is what we're expecting.
+ const json = await response.json();
+
+ // Make sure that we're getting the expected data.
+ let isValid = null;
+ for (let prop in json) {
+ isValid = MONITOR_RESPONSE_PROPS.includes(prop);
+
+ if (!isValid) {
+ break;
+ }
+ }
+
+ monitorResponse = isValid ? json : new Error(UNEXPECTED_RESPONSE);
+ if (isValid) {
+ monitorResponse.timestamp = Date.now();
+ }
+ } else {
+ // Check the reason for the error
+ switch (response.status) {
+ case 400:
+ case 401:
+ monitorResponse = new Error(INVALID_OAUTH_TOKEN);
+ break;
+ case 404:
+ monitorResponse = new Error(USER_UNSUBSCRIBED_TO_MONITOR);
+ break;
+ case 503:
+ monitorResponse = new Error(SERVICE_UNAVAILABLE);
+ break;
+ default:
+ monitorResponse = new Error(UNKNOWN_ERROR);
+ break;
+ }
+ }
+
+ if (monitorResponse instanceof Error) {
+ throw monitorResponse;
+ }
+ return monitorResponse;
+ }
+
+ /**
+ * Retrieves login data for the user.
+ *
+ * @return {{
+ * numLogins: Number,
+ * potentiallyBreachedLogins: Number,
+ * mobileDeviceConnected: Boolean }}
+ */
+ async getLoginData() {
+ if (gTestOverride && "getLoginData" in gTestOverride) {
+ return gTestOverride.getLoginData();
+ }
+
+ try {
+ if (await lazy.fxAccounts.getSignedInUser()) {
+ await lazy.fxAccounts.device.refreshDeviceList();
+ }
+ } catch (e) {
+ console.error("There was an error fetching login data: ", e.message);
+ }
+
+ const userFacingLogins =
+ Services.logins.countLogins("", "", "") -
+ Services.logins.countLogins(
+ lazy.FXA_PWDMGR_HOST,
+ null,
+ lazy.FXA_PWDMGR_REALM
+ );
+
+ let potentiallyBreachedLogins = null;
+ // Get the stats for number of potentially breached Lockwise passwords
+ // if the Primary Password isn't locked.
+ if (userFacingLogins && Services.logins.isLoggedIn) {
+ const logins = await lazy.LoginHelper.getAllUserFacingLogins();
+ potentiallyBreachedLogins =
+ await lazy.LoginBreaches.getPotentialBreachesByLoginGUID(logins);
+ }
+
+ let mobileDeviceConnected =
+ lazy.fxAccounts.device.recentDeviceList &&
+ lazy.fxAccounts.device.recentDeviceList.filter(
+ device => device.type == "mobile"
+ ).length;
+
+ return {
+ numLogins: userFacingLogins,
+ potentiallyBreachedLogins: potentiallyBreachedLogins
+ ? potentiallyBreachedLogins.size
+ : 0,
+ mobileDeviceConnected,
+ };
+ }
+
+ /**
+ * Retrieves monitor data for the user.
+ *
+ * @return {{ monitoredEmails: Number,
+ * numBreaches: Number,
+ * passwords: Number,
+ * userEmail: String|null,
+ * error: Boolean }}
+ * Monitor data.
+ */
+ async getMonitorData() {
+ if (gTestOverride && "getMonitorData" in gTestOverride) {
+ monitorResponse = gTestOverride.getMonitorData();
+ monitorResponse.timestamp = Date.now();
+ // In a test, expect this to not fetch from the monitor endpoint due to the timestamp guaranteeing we use the cache.
+ monitorResponse = await this.fetchUserBreachStats();
+ return monitorResponse;
+ }
+
+ let monitorData = {};
+ let userEmail = null;
+ let token = await this.getMonitorScopedOAuthToken();
+
+ try {
+ if (token) {
+ monitorData = await this.fetchUserBreachStats(token);
+
+ // Send back user's email so the protections report can direct them to the proper
+ // OAuth flow on Monitor.
+ const { email } = await lazy.fxAccounts.getSignedInUser();
+ userEmail = email;
+ } else {
+ // If no account exists, then the user is not logged in with an fxAccount.
+ monitorData = {
+ errorMessage: "No account",
+ };
+ }
+ } catch (e) {
+ console.error(e.message);
+ monitorData.errorMessage = e.message;
+
+ // If the user's OAuth token is invalid, we clear the cached token and refetch
+ // again. If OAuth token is invalid after the second fetch, then the monitor UI
+ // will simply show the "no logins" UI version.
+ if (e.message === INVALID_OAUTH_TOKEN) {
+ await lazy.fxAccounts.removeCachedOAuthToken({ token });
+ token = await this.getMonitorScopedOAuthToken();
+
+ try {
+ monitorData = await this.fetchUserBreachStats(token);
+ } catch (_) {
+ console.error(e.message);
+ }
+ } else if (e.message === USER_UNSUBSCRIBED_TO_MONITOR) {
+ // Send back user's email so the protections report can direct them to the proper
+ // OAuth flow on Monitor.
+ const { email } = await lazy.fxAccounts.getSignedInUser();
+ userEmail = email;
+ } else {
+ monitorData.errorMessage = e.message || "An error ocurred.";
+ }
+ }
+
+ return {
+ ...monitorData,
+ userEmail,
+ error: !!monitorData.errorMessage,
+ };
+ }
+
+ async getMonitorScopedOAuthToken() {
+ let token = null;
+
+ try {
+ token = await lazy.fxAccounts.getOAuthToken({ scope: SCOPE_MONITOR });
+ } catch (e) {
+ console.error(
+ "There was an error fetching the user's token: ",
+ e.message
+ );
+ }
+
+ return token;
+ }
+
+ /**
+ * The proxy card will only show if the user is in the US, has the browser language in "en-US",
+ * and does not yet have Proxy installed.
+ */
+ async shouldShowProxyCard() {
+ const region = lazy.Region.home || "";
+ const languages = Services.prefs.getComplexValue(
+ "intl.accept_languages",
+ Ci.nsIPrefLocalizedString
+ );
+ const alreadyInstalled = await lazy.AddonManager.getAddonByID(
+ SECURE_PROXY_ADDON_ID
+ );
+
+ return (
+ region.toLowerCase() === "us" &&
+ !alreadyInstalled &&
+ languages.data.toLowerCase().includes("en-us")
+ );
+ }
+
+ async VPNSubStatus() {
+ // For testing, set vpn sub status manually
+ if (gTestOverride && "vpnOverrides" in gTestOverride) {
+ return gTestOverride.vpnOverrides();
+ }
+
+ let vpnToken;
+ try {
+ vpnToken = await lazy.fxAccounts.getOAuthToken({ scope: SCOPE_VPN });
+ } catch (e) {
+ console.error(
+ "There was an error fetching the user's token: ",
+ e.message
+ );
+ // there was an error, assume user is not subscribed to VPN
+ return false;
+ }
+ let headers = new Headers();
+ headers.append("Authorization", `Bearer ${vpnToken}`);
+ const request = new Request(VPN_ENDPOINT, { headers });
+ const res = await fetch(request);
+ if (res.ok) {
+ const result = await res.json();
+ for (let sub of result) {
+ if (sub.subscriptionId == VPN_SUB_ID) {
+ return true;
+ }
+ }
+ return false;
+ }
+ // unknown logic: assume user is not subscribed to VPN
+ return false;
+ }
+
+ async receiveMessage(aMessage) {
+ let win = this.browsingContext.top.embedderElement.ownerGlobal;
+ switch (aMessage.name) {
+ case "OpenAboutLogins":
+ lazy.LoginHelper.openPasswordManager(win, {
+ entryPoint: "aboutprotections",
+ });
+ break;
+ case "OpenContentBlockingPreferences":
+ win.openPreferences("privacy-trackingprotection", {
+ origin: "about-protections",
+ });
+ break;
+ case "OpenSyncPreferences":
+ win.openTrustedLinkIn("about:preferences#sync", "tab");
+ break;
+ case "FetchContentBlockingEvents":
+ let dataToSend = {};
+ let displayNames = new Services.intl.DisplayNames(undefined, {
+ type: "weekday",
+ style: "abbreviated",
+ calendar: "gregory",
+ });
+
+ // Weekdays starting Sunday (7) to Saturday (6).
+ let weekdays = [7, 1, 2, 3, 4, 5, 6].map(day => displayNames.of(day));
+ dataToSend.weekdays = weekdays;
+
+ if (lazy.PrivateBrowsingUtils.isWindowPrivate(win)) {
+ dataToSend.isPrivate = true;
+ return dataToSend;
+ }
+ let sumEvents = await lazy.TrackingDBService.sumAllEvents();
+ let earliestDate =
+ await lazy.TrackingDBService.getEarliestRecordedDate();
+ let eventsByDate = await lazy.TrackingDBService.getEventsByDateRange(
+ aMessage.data.from,
+ aMessage.data.to
+ );
+ let largest = 0;
+
+ for (let result of eventsByDate) {
+ let count = result.getResultByName("count");
+ let type = result.getResultByName("type");
+ let timestamp = result.getResultByName("timestamp");
+ let typeStr = idToTextMap.get(type);
+ dataToSend[timestamp] = dataToSend[timestamp] ?? { total: 0 };
+ let currentCnt = dataToSend[timestamp][typeStr] ?? 0;
+ currentCnt += count;
+ dataToSend[timestamp][typeStr] = currentCnt;
+ dataToSend[timestamp].total += count;
+ // Record the largest amount of tracking events found per day,
+ // to create the tallest column on the graph and compare other days to.
+ if (largest < dataToSend[timestamp].total) {
+ largest = dataToSend[timestamp].total;
+ }
+ }
+ dataToSend.largest = largest;
+ dataToSend.earliestDate = earliestDate;
+ dataToSend.sumEvents = sumEvents;
+
+ return dataToSend;
+
+ case "FetchMonitorData":
+ return this.getMonitorData();
+
+ case "FetchUserLoginsData":
+ return this.getLoginData();
+
+ case "ClearMonitorCache":
+ monitorResponse = null;
+ break;
+
+ case "GetShowProxyCard":
+ let card = await this.shouldShowProxyCard();
+ return card;
+
+ case "RecordEntryPoint":
+ entrypoint = aMessage.data.entrypoint;
+ break;
+
+ case "FetchEntryPoint":
+ return entrypoint;
+
+ case "FetchVPNSubStatus":
+ return this.VPNSubStatus();
+
+ case "FetchShowVPNCard":
+ return lazy.BrowserUtils.shouldShowVPNPromo();
+ }
+
+ return undefined;
+ }
+}
diff --git a/browser/actors/AboutReaderChild.sys.mjs b/browser/actors/AboutReaderChild.sys.mjs
new file mode 100644
index 0000000000..b92078aaec
--- /dev/null
+++ b/browser/actors/AboutReaderChild.sys.mjs
@@ -0,0 +1,252 @@
+/* vim: set ts=2 sw=2 sts=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AboutReader: "resource://gre/modules/AboutReader.sys.mjs",
+ ReaderMode: "resource://gre/modules/ReaderMode.sys.mjs",
+ Readerable: "resource://gre/modules/Readerable.sys.mjs",
+});
+
+var gUrlsToDocContentType = new Map();
+var gUrlsToDocTitle = new Map();
+
+export class AboutReaderChild extends JSWindowActorChild {
+ constructor() {
+ super();
+
+ this._reader = null;
+ this._articlePromise = null;
+ this._isLeavingReaderableReaderMode = false;
+ }
+
+ didDestroy() {
+ this.cancelPotentialPendingReadabilityCheck();
+ this.readerModeHidden();
+ }
+
+ readerModeHidden() {
+ if (this._reader) {
+ this._reader.clearActor();
+ }
+ this._reader = null;
+ }
+
+ async receiveMessage(message) {
+ switch (message.name) {
+ case "Reader:ToggleReaderMode":
+ if (!this.isAboutReader) {
+ gUrlsToDocContentType.set(
+ this.document.URL,
+ this.document.contentType
+ );
+ gUrlsToDocTitle.set(this.document.URL, this.document.title);
+ this._articlePromise = lazy.ReaderMode.parseDocument(
+ this.document
+ ).catch(console.error);
+
+ // Get the article data and cache it in the parent process. The reader mode
+ // page will retrieve it when it has loaded.
+ let article = await this._articlePromise;
+ this.sendAsyncMessage("Reader:EnterReaderMode", article);
+ } else {
+ this.closeReaderMode();
+ }
+ break;
+
+ case "Reader:PushState":
+ this.updateReaderButton(!!(message.data && message.data.isArticle));
+ break;
+ case "Reader:EnterReaderMode": {
+ lazy.ReaderMode.enterReaderMode(this.docShell, this.contentWindow);
+ break;
+ }
+ case "Reader:LeaveReaderMode": {
+ lazy.ReaderMode.leaveReaderMode(this.docShell, this.contentWindow);
+ break;
+ }
+ }
+
+ // Forward the message to the reader if it has been created.
+ if (this._reader) {
+ this._reader.receiveMessage(message);
+ }
+ }
+
+ get isAboutReader() {
+ if (!this.document) {
+ return false;
+ }
+ return this.document.documentURI.startsWith("about:reader");
+ }
+
+ get isReaderableAboutReader() {
+ return this.isAboutReader && !this.document.documentElement.dataset.isError;
+ }
+
+ handleEvent(aEvent) {
+ if (aEvent.originalTarget.defaultView != this.contentWindow) {
+ return;
+ }
+
+ switch (aEvent.type) {
+ case "DOMContentLoaded":
+ if (!this.isAboutReader) {
+ this.updateReaderButton();
+ return;
+ }
+
+ if (this.document.body) {
+ let url = this.document.documentURI;
+ if (!this._articlePromise) {
+ url = decodeURIComponent(url.substr("about:reader?url=".length));
+ this._articlePromise = this.sendQuery("Reader:GetCachedArticle", {
+ url,
+ });
+ }
+ // Update the toolbar icon to show the "reader active" icon.
+ this.sendAsyncMessage("Reader:UpdateReaderButton");
+ let docContentType =
+ gUrlsToDocContentType.get(url) === "text/plain"
+ ? "text/plain"
+ : "document";
+
+ let docTitle = gUrlsToDocTitle.get(url);
+ this._reader = new lazy.AboutReader(
+ this,
+ this._articlePromise,
+ docContentType,
+ docTitle
+ );
+ this._articlePromise = null;
+ }
+ break;
+
+ case "pagehide":
+ this.cancelPotentialPendingReadabilityCheck();
+ // this._isLeavingReaderableReaderMode is used here to keep the Reader Mode icon
+ // visible in the location bar when transitioning from reader-mode page
+ // back to the readable source page.
+ this.sendAsyncMessage("Reader:UpdateReaderButton", {
+ isArticle: this._isLeavingReaderableReaderMode,
+ });
+ this._isLeavingReaderableReaderMode = false;
+ break;
+
+ case "pageshow":
+ // If a page is loaded from the bfcache, we won't get a "DOMContentLoaded"
+ // event, so we need to rely on "pageshow" in this case.
+ if (aEvent.persisted && this.canDoReadabilityCheck()) {
+ this.performReadabilityCheckNow();
+ }
+ break;
+ }
+ }
+
+ /**
+ * NB: this function will update the state of the reader button asynchronously
+ * after the next mozAfterPaint call (assuming reader mode is enabled and
+ * this is a suitable document). Calling it on things which won't be
+ * painted is not going to work.
+ */
+ updateReaderButton(forceNonArticle) {
+ if (!this.canDoReadabilityCheck()) {
+ return;
+ }
+
+ this.scheduleReadabilityCheckPostPaint(forceNonArticle);
+ }
+
+ canDoReadabilityCheck() {
+ return (
+ lazy.Readerable.isEnabledForParseOnLoad &&
+ !this.isAboutReader &&
+ this.contentWindow &&
+ this.contentWindow.windowRoot &&
+ this.contentWindow.HTMLDocument.isInstance(this.document) &&
+ !this.document.mozSyntheticDocument
+ );
+ }
+
+ cancelPotentialPendingReadabilityCheck() {
+ if (this._pendingReadabilityCheck) {
+ if (this._listenerWindow) {
+ this._listenerWindow.removeEventListener(
+ "MozAfterPaint",
+ this._pendingReadabilityCheck
+ );
+ }
+ delete this._pendingReadabilityCheck;
+ delete this._listenerWindow;
+ }
+ }
+
+ scheduleReadabilityCheckPostPaint(forceNonArticle) {
+ if (this._pendingReadabilityCheck) {
+ // We need to stop this check before we re-add one because we don't know
+ // if forceNonArticle was true or false last time.
+ this.cancelPotentialPendingReadabilityCheck();
+ }
+ this._pendingReadabilityCheck = this.onPaintWhenWaitedFor.bind(
+ this,
+ forceNonArticle
+ );
+
+ this._listenerWindow = this.contentWindow.windowRoot;
+ this.contentWindow.windowRoot.addEventListener(
+ "MozAfterPaint",
+ this._pendingReadabilityCheck
+ );
+ }
+
+ onPaintWhenWaitedFor(forceNonArticle, event) {
+ // In non-e10s, we'll get called for paints other than ours, and so it's
+ // possible that this page hasn't been laid out yet, in which case we
+ // should wait until we get an event that does relate to our layout. We
+ // determine whether any of our this.contentWindow got painted by checking
+ // if there are any painted rects.
+ if (!event.clientRects.length) {
+ return;
+ }
+
+ this.performReadabilityCheckNow(forceNonArticle);
+ }
+
+ performReadabilityCheckNow(forceNonArticle) {
+ this.cancelPotentialPendingReadabilityCheck();
+
+ // Ignore errors from actors that have been unloaded before the
+ // paint event timer fires.
+ let document;
+ try {
+ document = this.document;
+ } catch (ex) {
+ return;
+ }
+
+ // Only send updates when there are articles; there's no point updating with
+ // |false| all the time.
+ if (
+ lazy.Readerable.shouldCheckUri(document.baseURIObject, true) &&
+ lazy.Readerable.isProbablyReaderable(document)
+ ) {
+ this.sendAsyncMessage("Reader:UpdateReaderButton", {
+ isArticle: true,
+ });
+ } else if (forceNonArticle) {
+ this.sendAsyncMessage("Reader:UpdateReaderButton", {
+ isArticle: false,
+ });
+ }
+ }
+
+ closeReaderMode() {
+ if (this.isAboutReader) {
+ this._isLeavingReaderableReaderMode = this.isReaderableAboutReader;
+ this.sendAsyncMessage("Reader:LeaveReaderMode", {});
+ }
+ }
+}
diff --git a/browser/actors/AboutReaderParent.sys.mjs b/browser/actors/AboutReaderParent.sys.mjs
new file mode 100644
index 0000000000..544a257cbc
--- /dev/null
+++ b/browser/actors/AboutReaderParent.sys.mjs
@@ -0,0 +1,294 @@
+// -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PageActions: "resource:///modules/PageActions.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ ReaderMode: "resource://gre/modules/ReaderMode.sys.mjs",
+});
+
+// A set of all of the AboutReaderParent actors that exist.
+// See bug 1631146 for a request for a less manual way of doing this.
+let gAllActors = new Set();
+
+// A map of message names to listeners that listen to messages
+// received by the AboutReaderParent actors.
+let gListeners = new Map();
+
+// As a reader mode document could be loaded in a different process than
+// the source article, temporarily cache the article data here in the
+// parent while switching to it.
+let gCachedArticles = new Map();
+
+export class AboutReaderParent extends JSWindowActorParent {
+ didDestroy() {
+ gAllActors.delete(this);
+
+ if (this.isReaderMode()) {
+ let url = this.manager.documentURI.spec;
+ url = decodeURIComponent(url.substr("about:reader?url=".length));
+ gCachedArticles.delete(url);
+ }
+ }
+
+ isReaderMode() {
+ return this.manager.documentURI.spec.startsWith("about:reader");
+ }
+
+ static addMessageListener(name, listener) {
+ if (!gListeners.has(name)) {
+ gListeners.set(name, new Set([listener]));
+ } else {
+ gListeners.get(name).add(listener);
+ }
+ }
+
+ static removeMessageListener(name, listener) {
+ if (!gListeners.has(name)) {
+ return;
+ }
+
+ gListeners.get(name).delete(listener);
+ }
+
+ static broadcastAsyncMessage(name, data) {
+ for (let actor of gAllActors) {
+ // Ignore errors for actors that might not be valid yet or anymore.
+ try {
+ actor.sendAsyncMessage(name, data);
+ } catch (ex) {}
+ }
+ }
+
+ callListeners(message) {
+ let listeners = gListeners.get(message.name);
+ if (!listeners) {
+ return;
+ }
+
+ message.target = this.browsingContext.embedderElement;
+ for (let listener of listeners.values()) {
+ try {
+ listener.receiveMessage(message);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ }
+
+ async receiveMessage(message) {
+ switch (message.name) {
+ case "Reader:EnterReaderMode": {
+ gCachedArticles.set(message.data.url, message.data);
+ this.enterReaderMode(message.data.url);
+ break;
+ }
+ case "Reader:LeaveReaderMode": {
+ this.leaveReaderMode();
+ break;
+ }
+ case "Reader:GetCachedArticle": {
+ let cachedArticle = gCachedArticles.get(message.data.url);
+ gCachedArticles.delete(message.data.url);
+ return cachedArticle;
+ }
+ case "Reader:FaviconRequest": {
+ try {
+ let preferredWidth = message.data.preferredWidth || 0;
+ let uri = Services.io.newURI(message.data.url);
+
+ let result = await new Promise(resolve => {
+ lazy.PlacesUtils.favicons.getFaviconURLForPage(
+ uri,
+ iconUri => {
+ if (iconUri) {
+ resolve({
+ url: message.data.url,
+ faviconUrl: iconUri.spec,
+ });
+ } else {
+ resolve(null);
+ }
+ },
+ preferredWidth
+ );
+ });
+
+ this.callListeners(message);
+ return result;
+ } catch (ex) {
+ console.error(
+ "Error requesting favicon URL for about:reader content: ",
+ ex
+ );
+ }
+
+ break;
+ }
+
+ case "Reader:UpdateReaderButton": {
+ let browser = this.browsingContext.embedderElement;
+ if (!browser) {
+ return undefined;
+ }
+
+ if (message.data && message.data.isArticle !== undefined) {
+ browser.isArticle = message.data.isArticle;
+ }
+ this.updateReaderButton(browser);
+ this.callListeners(message);
+ break;
+ }
+
+ case "RedirectTo": {
+ gCachedArticles.set(message.data.newURL, message.data.article);
+ // This is setup as a query so we can navigate the page after we've
+ // cached the relevant info in the parent.
+ return true;
+ }
+
+ default:
+ this.callListeners(message);
+ break;
+ }
+
+ return undefined;
+ }
+
+ static updateReaderButton(browser) {
+ let windowGlobal = browser.browsingContext.currentWindowGlobal;
+ let actor = windowGlobal.getActor("AboutReader");
+ actor.updateReaderButton(browser);
+ }
+
+ updateReaderButton(browser) {
+ let tabBrowser = browser.getTabBrowser();
+ if (!tabBrowser || browser != tabBrowser.selectedBrowser) {
+ return;
+ }
+
+ let doc = browser.ownerGlobal.document;
+ let button = doc.getElementById("reader-mode-button");
+ let menuitem = doc.getElementById("menu_readerModeItem");
+ let key = doc.getElementById("key_toggleReaderMode");
+ if (this.isReaderMode()) {
+ gAllActors.add(this);
+
+ button.setAttribute("readeractive", true);
+ button.hidden = false;
+ doc.l10n.setAttributes(button, "reader-view-close-button");
+
+ menuitem.hidden = false;
+ doc.l10n.setAttributes(menuitem, "menu-view-close-readerview");
+
+ key.setAttribute("disabled", false);
+
+ Services.obs.notifyObservers(null, "reader-mode-available");
+ } else {
+ button.removeAttribute("readeractive");
+ button.hidden = !browser.isArticle;
+ doc.l10n.setAttributes(button, "reader-view-enter-button");
+
+ menuitem.hidden = !browser.isArticle;
+ doc.l10n.setAttributes(menuitem, "menu-view-enter-readerview");
+
+ key.setAttribute("disabled", !browser.isArticle);
+
+ if (browser.isArticle) {
+ Services.obs.notifyObservers(null, "reader-mode-available");
+ }
+ }
+
+ if (!button.hidden) {
+ lazy.PageActions.sendPlacedInUrlbarTrigger(button);
+ }
+ }
+
+ static forceShowReaderIcon(browser) {
+ browser.isArticle = true;
+ AboutReaderParent.updateReaderButton(browser);
+ }
+
+ static buttonClick(event) {
+ if (event.button != 0) {
+ return;
+ }
+ AboutReaderParent.toggleReaderMode(event);
+ }
+
+ static toggleReaderMode(event) {
+ let win = event.target.ownerGlobal;
+ if (win.gBrowser) {
+ let browser = win.gBrowser.selectedBrowser;
+
+ let windowGlobal = browser.browsingContext.currentWindowGlobal;
+ let actor = windowGlobal.getActor("AboutReader");
+ if (actor) {
+ if (actor.isReaderMode()) {
+ gAllActors.delete(this);
+ }
+ actor.sendAsyncMessage("Reader:ToggleReaderMode", {});
+ }
+ }
+ }
+
+ hasReaderModeEntryAtOffset(url, offset) {
+ if (Services.appinfo.sessionHistoryInParent) {
+ let browsingContext = this.browsingContext;
+ if (browsingContext.childSessionHistory.canGo(offset)) {
+ let shistory = browsingContext.sessionHistory;
+ let nextEntry = shistory.getEntryAtIndex(shistory.index + offset);
+ let nextURL = nextEntry.URI.spec;
+ return nextURL && (nextURL == url || !url);
+ }
+ }
+
+ return false;
+ }
+
+ enterReaderMode(url) {
+ let readerURL = "about:reader?url=" + encodeURIComponent(url);
+ if (this.hasReaderModeEntryAtOffset(readerURL, +1)) {
+ let browsingContext = this.browsingContext;
+ browsingContext.childSessionHistory.go(+1);
+ return;
+ }
+
+ this.sendAsyncMessage("Reader:EnterReaderMode", {});
+ }
+
+ leaveReaderMode() {
+ let browsingContext = this.browsingContext;
+ let url = browsingContext.currentWindowGlobal.documentURI.spec;
+ let originalURL = lazy.ReaderMode.getOriginalUrl(url);
+ if (this.hasReaderModeEntryAtOffset(originalURL, -1)) {
+ browsingContext.childSessionHistory.go(-1);
+ return;
+ }
+
+ this.sendAsyncMessage("Reader:LeaveReaderMode", {});
+ }
+
+ /**
+ * Gets an article for a given URL. This method will download and parse a document.
+ *
+ * @param url The article URL.
+ * @param browser The browser where the article is currently loaded.
+ * @return {Promise}
+ * @resolves JS object representing the article, or null if no article is found.
+ */
+ async _getArticle(url, browser) {
+ return lazy.ReaderMode.downloadAndParseDocument(url).catch(e => {
+ if (e && e.newURL) {
+ // Pass up the error so we can navigate the browser in question to the new URL:
+ throw e;
+ }
+ console.error("Error downloading and parsing document: ", e);
+ return null;
+ });
+ }
+}
diff --git a/browser/actors/AboutTabCrashedChild.sys.mjs b/browser/actors/AboutTabCrashedChild.sys.mjs
new file mode 100644
index 0000000000..52d633b90f
--- /dev/null
+++ b/browser/actors/AboutTabCrashedChild.sys.mjs
@@ -0,0 +1,8 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { RemotePageChild } from "resource://gre/actors/RemotePageChild.sys.mjs";
+
+export class AboutTabCrashedChild extends RemotePageChild {}
diff --git a/browser/actors/AboutTabCrashedParent.sys.mjs b/browser/actors/AboutTabCrashedParent.sys.mjs
new file mode 100644
index 0000000000..d24b838a23
--- /dev/null
+++ b/browser/actors/AboutTabCrashedParent.sys.mjs
@@ -0,0 +1,87 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
+ TabCrashHandler: "resource:///modules/ContentCrashHandlers.sys.mjs",
+});
+
+// A list of all of the open about:tabcrashed pages.
+let gAboutTabCrashedPages = new Map();
+
+export class AboutTabCrashedParent extends JSWindowActorParent {
+ didDestroy() {
+ this.removeCrashedPage();
+ }
+
+ async receiveMessage(message) {
+ let browser = this.browsingContext.top.embedderElement;
+ if (!browser) {
+ // If there is no browser, remove the crashed page from the set
+ // and return.
+ this.removeCrashedPage();
+ return;
+ }
+
+ let gBrowser = browser.getTabBrowser();
+ let tab = gBrowser.getTabForBrowser(browser);
+
+ switch (message.name) {
+ case "Load": {
+ gAboutTabCrashedPages.set(this, browser);
+ this.updateTabCrashedCount();
+
+ let report = lazy.TabCrashHandler.onAboutTabCrashedLoad(browser);
+ this.sendAsyncMessage("SetCrashReportAvailable", report);
+ break;
+ }
+
+ case "closeTab": {
+ lazy.TabCrashHandler.maybeSendCrashReport(browser, message);
+ gBrowser.removeTab(tab, { animate: true });
+ break;
+ }
+
+ case "restoreTab": {
+ lazy.TabCrashHandler.maybeSendCrashReport(browser, message);
+ lazy.SessionStore.reviveCrashedTab(tab);
+ break;
+ }
+
+ case "restoreAll": {
+ lazy.TabCrashHandler.maybeSendCrashReport(browser, message);
+ lazy.SessionStore.reviveAllCrashedTabs();
+ break;
+ }
+ }
+ }
+
+ removeCrashedPage() {
+ let browser =
+ this.browsingContext.top.embedderElement ||
+ gAboutTabCrashedPages.get(this);
+
+ gAboutTabCrashedPages.delete(this);
+ this.updateTabCrashedCount();
+
+ lazy.TabCrashHandler.onAboutTabCrashedUnload(browser);
+ }
+
+ updateTabCrashedCount() {
+ // Broadcast to all about:tabcrashed pages a count of
+ // how many about:tabcrashed pages exist, so that they
+ // can decide whether or not to display the "Restore All
+ // Crashed Tabs" button.
+ let count = gAboutTabCrashedPages.size;
+
+ for (let actor of gAboutTabCrashedPages.keys()) {
+ let browser = actor.browsingContext.top.embedderElement;
+ if (browser) {
+ browser.sendMessageToActor("UpdateCount", { count }, "AboutTabCrashed");
+ }
+ }
+ }
+}
diff --git a/browser/actors/BlockedSiteChild.sys.mjs b/browser/actors/BlockedSiteChild.sys.mjs
new file mode 100644
index 0000000000..cbe37c8688
--- /dev/null
+++ b/browser/actors/BlockedSiteChild.sys.mjs
@@ -0,0 +1,187 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ SafeBrowsing: "resource://gre/modules/SafeBrowsing.sys.mjs",
+});
+
+function getSiteBlockedErrorDetails(docShell) {
+ let blockedInfo = {};
+ if (docShell.failedChannel) {
+ let classifiedChannel = docShell.failedChannel.QueryInterface(
+ Ci.nsIClassifiedChannel
+ );
+ if (classifiedChannel) {
+ let httpChannel = docShell.failedChannel.QueryInterface(
+ Ci.nsIHttpChannel
+ );
+
+ let reportUri = httpChannel.URI;
+
+ // Remove the query to avoid leaking sensitive data
+ if (reportUri instanceof Ci.nsIURL) {
+ reportUri = reportUri.mutate().setQuery("").finalize();
+ }
+
+ let triggeringPrincipal = docShell.failedChannel.loadInfo
+ ? docShell.failedChannel.loadInfo.triggeringPrincipal
+ : null;
+ blockedInfo = {
+ list: classifiedChannel.matchedList,
+ triggeringPrincipal,
+ provider: classifiedChannel.matchedProvider,
+ uri: reportUri.asciiSpec,
+ };
+ }
+ }
+ return blockedInfo;
+}
+
+export class BlockedSiteChild extends JSWindowActorChild {
+ receiveMessage(msg) {
+ if (msg.name == "DeceptiveBlockedDetails") {
+ return getSiteBlockedErrorDetails(this.docShell);
+ }
+ return null;
+ }
+
+ handleEvent(event) {
+ if (event.type == "AboutBlockedLoaded") {
+ this.onAboutBlockedLoaded(event);
+ } else if (event.type == "click" && event.button == 0) {
+ this.onClick(event);
+ }
+ }
+
+ onAboutBlockedLoaded(aEvent) {
+ let content = aEvent.target.ownerGlobal;
+
+ let blockedInfo = getSiteBlockedErrorDetails(this.docShell);
+ let provider = blockedInfo.provider || "";
+
+ let doc = content.document;
+
+ /**
+ * Set error description link in error details.
+ * For example, the "reported as a deceptive site" link for
+ * blocked phishing pages.
+ */
+ let desc = Services.prefs.getCharPref(
+ "browser.safebrowsing.provider." + provider + ".reportURL",
+ ""
+ );
+ if (desc) {
+ doc
+ .getElementById("error_desc_link")
+ .setAttribute("href", desc + encodeURIComponent(aEvent.detail.url));
+ }
+
+ // Set other links in error details.
+ switch (aEvent.detail.err) {
+ case "malware":
+ doc
+ .getElementById("report_detection")
+ .setAttribute(
+ "href",
+ lazy.SafeBrowsing.getReportURL("MalwareMistake", blockedInfo)
+ );
+ break;
+ case "unwanted":
+ doc
+ .getElementById("learn_more_link")
+ .setAttribute(
+ "href",
+ "https://www.google.com/about/unwanted-software-policy.html"
+ );
+ break;
+ case "phishing":
+ doc
+ .getElementById("report_detection")
+ .setAttribute(
+ "href",
+ lazy.SafeBrowsing.getReportURL("PhishMistake", blockedInfo) ||
+ "https://safebrowsing.google.com/safebrowsing/report_error/?tpl=mozilla"
+ );
+ doc
+ .getElementById("learn_more_link")
+ .setAttribute("href", "https://www.antiphishing.org//");
+ break;
+ }
+
+ // Set the firefox support url.
+ doc
+ .getElementById("firefox_support")
+ .setAttribute(
+ "href",
+ Services.urlFormatter.formatURLPref("app.support.baseURL") +
+ "phishing-malware"
+ );
+
+ // Show safe browsing details on load if the pref is set to true.
+ let showDetails = Services.prefs.getBoolPref(
+ "browser.xul.error_pages.show_safe_browsing_details_on_load"
+ );
+ if (showDetails) {
+ let details = content.document.getElementById(
+ "errorDescriptionContainer"
+ );
+ details.removeAttribute("hidden");
+ }
+
+ // Set safe browsing advisory link.
+ let advisoryUrl = Services.prefs.getCharPref(
+ "browser.safebrowsing.provider." + provider + ".advisoryURL",
+ ""
+ );
+ let advisoryDesc = content.document.getElementById("advisoryDescText");
+ if (!advisoryUrl) {
+ advisoryDesc.remove();
+ return;
+ }
+
+ let advisoryLinkText = Services.prefs.getCharPref(
+ "browser.safebrowsing.provider." + provider + ".advisoryName",
+ ""
+ );
+ if (!advisoryLinkText) {
+ advisoryDesc.remove();
+ return;
+ }
+
+ content.document.l10n.setAttributes(
+ advisoryDesc,
+ "safeb-palm-advisory-desc",
+ { advisoryname: advisoryLinkText }
+ );
+ content.document
+ .getElementById("advisory_provider")
+ .setAttribute("href", advisoryUrl);
+ }
+
+ onClick(event) {
+ let ownerDoc = event.target.ownerDocument;
+ if (!ownerDoc) {
+ return;
+ }
+
+ var reason = "phishing";
+ if (/e=malwareBlocked/.test(ownerDoc.documentURI)) {
+ reason = "malware";
+ } else if (/e=unwantedBlocked/.test(ownerDoc.documentURI)) {
+ reason = "unwanted";
+ } else if (/e=harmfulBlocked/.test(ownerDoc.documentURI)) {
+ reason = "harmful";
+ }
+
+ this.sendAsyncMessage("Browser:SiteBlockedError", {
+ location: ownerDoc.location.href,
+ reason,
+ elementId: event.target.getAttribute("id"),
+ blockedInfo: getSiteBlockedErrorDetails(this.docShell),
+ });
+ }
+}
diff --git a/browser/actors/BlockedSiteParent.sys.mjs b/browser/actors/BlockedSiteParent.sys.mjs
new file mode 100644
index 0000000000..25d90ce2be
--- /dev/null
+++ b/browser/actors/BlockedSiteParent.sys.mjs
@@ -0,0 +1,70 @@
+/* -*- 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/. */
+
+export class BlockedSiteParent extends JSWindowActorParent {
+ receiveMessage(msg) {
+ switch (msg.name) {
+ case "Browser:SiteBlockedError":
+ this._onAboutBlocked(
+ msg.data.elementId,
+ msg.data.reason,
+ this.browsingContext === this.browsingContext.top,
+ msg.data.blockedInfo
+ );
+ break;
+ }
+ }
+
+ _onAboutBlocked(elementId, reason, isTopFrame, blockedInfo) {
+ let browser = this.browsingContext.top.embedderElement;
+ if (!browser) {
+ return;
+ }
+ let { BrowserOnClick } = browser.ownerGlobal;
+ // Depending on what page we are displaying here (malware/phishing/unwanted)
+ // use the right strings and links for each.
+ let bucketName = "";
+ let sendTelemetry = false;
+ if (reason === "malware") {
+ sendTelemetry = true;
+ bucketName = "WARNING_MALWARE_PAGE_";
+ } else if (reason === "phishing") {
+ sendTelemetry = true;
+ bucketName = "WARNING_PHISHING_PAGE_";
+ } else if (reason === "unwanted") {
+ sendTelemetry = true;
+ bucketName = "WARNING_UNWANTED_PAGE_";
+ } else if (reason === "harmful") {
+ sendTelemetry = true;
+ bucketName = "WARNING_HARMFUL_PAGE_";
+ }
+ let secHistogram = Services.telemetry.getHistogramById(
+ "URLCLASSIFIER_UI_EVENTS"
+ );
+ let nsISecTel = Ci.IUrlClassifierUITelemetry;
+ bucketName += isTopFrame ? "TOP_" : "FRAME_";
+
+ switch (elementId) {
+ case "goBackButton":
+ if (sendTelemetry) {
+ secHistogram.add(nsISecTel[bucketName + "GET_ME_OUT_OF_HERE"]);
+ }
+ browser.ownerGlobal.getMeOutOfHere(this.browsingContext);
+ break;
+ case "ignore_warning_link":
+ if (Services.prefs.getBoolPref("browser.safebrowsing.allowOverride")) {
+ if (sendTelemetry) {
+ secHistogram.add(nsISecTel[bucketName + "IGNORE_WARNING"]);
+ }
+ BrowserOnClick.ignoreWarningLink(
+ reason,
+ blockedInfo,
+ this.browsingContext
+ );
+ }
+ break;
+ }
+ }
+}
diff --git a/browser/actors/BrowserProcessChild.sys.mjs b/browser/actors/BrowserProcessChild.sys.mjs
new file mode 100644
index 0000000000..c16b0c6e92
--- /dev/null
+++ b/browser/actors/BrowserProcessChild.sys.mjs
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AboutHomeStartupCacheChild: "resource:///modules/AboutNewTabService.sys.mjs",
+ WebRTCChild: "resource:///actors/WebRTCChild.sys.mjs",
+});
+
+export class BrowserProcessChild extends JSProcessActorChild {
+ receiveMessage(message) {
+ switch (message.name) {
+ case "AboutHomeStartupCache:InputStreams":
+ let { pageInputStream, scriptInputStream } = message.data;
+ lazy.AboutHomeStartupCacheChild.init(
+ pageInputStream,
+ scriptInputStream
+ );
+ break;
+ }
+ }
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "getUserMedia:request":
+ case "recording-device-stopped":
+ case "PeerConnection:request":
+ case "recording-device-events":
+ case "recording-window-ended":
+ lazy.WebRTCChild.observe(subject, topic, data);
+ break;
+ }
+ }
+}
diff --git a/browser/actors/BrowserTabChild.sys.mjs b/browser/actors/BrowserTabChild.sys.mjs
new file mode 100644
index 0000000000..04c6e2f17a
--- /dev/null
+++ b/browser/actors/BrowserTabChild.sys.mjs
@@ -0,0 +1,55 @@
+/* vim: set ts=2 sw=2 sts=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
+});
+
+export class BrowserTabChild extends JSWindowActorChild {
+ constructor() {
+ super();
+ }
+
+ receiveMessage(message) {
+ let context = this.manager.browsingContext;
+ let docShell = context.docShell;
+
+ switch (message.name) {
+ // XXX(nika): Should we try to call this in the parent process instead?
+ case "Browser:Reload":
+ /* First, we'll try to use the session history object to reload so
+ * that framesets are handled properly. If we're in a special
+ * window (such as view-source) that has no session history, fall
+ * back on using the web navigation's reload method.
+ */
+ let webNav = docShell.QueryInterface(Ci.nsIWebNavigation);
+ try {
+ if (webNav.sessionHistory) {
+ webNav = webNav.sessionHistory;
+ }
+ } catch (e) {}
+
+ let reloadFlags = message.data.flags;
+ if (message.data.handlingUserInput) {
+ reloadFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_USER_ACTIVATION;
+ }
+
+ try {
+ lazy.E10SUtils.wrapHandlingUserInput(
+ this.document.defaultView,
+ message.data.handlingUserInput,
+ () => webNav.reload(reloadFlags)
+ );
+ } catch (e) {}
+ break;
+
+ case "ForceEncodingDetection":
+ docShell.forceEncodingDetection();
+ break;
+ }
+ }
+}
diff --git a/browser/actors/ClickHandlerChild.sys.mjs b/browser/actors/ClickHandlerChild.sys.mjs
new file mode 100644
index 0000000000..2fa0a87df4
--- /dev/null
+++ b/browser/actors/ClickHandlerChild.sys.mjs
@@ -0,0 +1,174 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
+ E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
+});
+
+export class MiddleMousePasteHandlerChild extends JSWindowActorChild {
+ handleEvent(clickEvent) {
+ if (
+ clickEvent.defaultPrevented ||
+ clickEvent.button != 1 ||
+ MiddleMousePasteHandlerChild.autoscrollEnabled
+ ) {
+ return;
+ }
+ this.manager
+ .getActor("ClickHandler")
+ .handleClickEvent(
+ clickEvent,
+ /* is from middle mouse paste handler */ true
+ );
+ }
+
+ onProcessedClick(data) {
+ this.sendAsyncMessage("MiddleClickPaste", data);
+ }
+}
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ MiddleMousePasteHandlerChild,
+ "autoscrollEnabled",
+ "general.autoScroll",
+ true
+);
+
+export class ClickHandlerChild extends JSWindowActorChild {
+ handleEvent(wrapperEvent) {
+ this.handleClickEvent(wrapperEvent.sourceEvent);
+ }
+
+ handleClickEvent(event, isFromMiddleMousePasteHandler = false) {
+ if (event.defaultPrevented || event.button == 2) {
+ return;
+ }
+ // Don't do anything on editable things, we shouldn't open links in
+ // contenteditables, and editor needs to possibly handle middlemouse paste
+ let composedTarget = event.composedTarget;
+ if (
+ composedTarget.isContentEditable ||
+ (composedTarget.ownerDocument &&
+ composedTarget.ownerDocument.designMode == "on") ||
+ ChromeUtils.getClassName(composedTarget) == "HTMLInputElement" ||
+ ChromeUtils.getClassName(composedTarget) == "HTMLTextAreaElement"
+ ) {
+ return;
+ }
+
+ let originalTarget = event.originalTarget;
+ let ownerDoc = originalTarget.ownerDocument;
+ if (!ownerDoc) {
+ return;
+ }
+
+ // Handle click events from about pages
+ if (event.button == 0) {
+ if (ownerDoc.documentURI.startsWith("about:blocked")) {
+ return;
+ }
+ }
+
+ // For untrusted events, require a valid transient user gesture activation.
+ if (!event.isTrusted && !ownerDoc.hasValidTransientUserGestureActivation) {
+ return;
+ }
+
+ let [href, node, principal] =
+ lazy.BrowserUtils.hrefAndLinkNodeForClickEvent(event);
+
+ let csp = ownerDoc.csp;
+ if (csp) {
+ csp = lazy.E10SUtils.serializeCSP(csp);
+ }
+
+ let referrerInfo = Cc["@mozilla.org/referrer-info;1"].createInstance(
+ Ci.nsIReferrerInfo
+ );
+ if (node) {
+ referrerInfo.initWithElement(node);
+ } else {
+ referrerInfo.initWithDocument(ownerDoc);
+ }
+ referrerInfo = lazy.E10SUtils.serializeReferrerInfo(referrerInfo);
+
+ let json = {
+ button: event.button,
+ shiftKey: event.shiftKey,
+ ctrlKey: event.ctrlKey,
+ metaKey: event.metaKey,
+ altKey: event.altKey,
+ href: null,
+ title: null,
+ csp,
+ referrerInfo,
+ };
+
+ if (href && !isFromMiddleMousePasteHandler) {
+ try {
+ Services.scriptSecurityManager.checkLoadURIStrWithPrincipal(
+ principal,
+ href
+ );
+ } catch (e) {
+ return;
+ }
+
+ if (
+ !event.isTrusted &&
+ lazy.BrowserUtils.whereToOpenLink(event) != "current"
+ ) {
+ // If we'll open the link, we want to consume the user gesture
+ // activation to ensure that we don't allow multiple links to open
+ // from one user gesture.
+ // Avoid doing so for links opened in the current tab, which get
+ // handled later, by gecko, as otherwise its popup blocker will stop
+ // the link from opening.
+ // We will do the same check (whereToOpenLink) again in the parent and
+ // avoid handling the click for such links... but we still need the
+ // click information in the parent because otherwise places link
+ // tracking breaks. (bug 1742894 tracks improving this.)
+ ownerDoc.consumeTransientUserGestureActivation();
+ // We don't care about the return value because we already checked that
+ // hasValidTransientUserGestureActivation was true earlier in this
+ // function.
+ }
+
+ json.href = href;
+ if (node) {
+ json.title = node.getAttribute("title");
+ }
+
+ if (
+ (ownerDoc.URL === "about:newtab" || ownerDoc.URL === "about:home") &&
+ node.dataset.isSponsoredLink === "true"
+ ) {
+ json.globalHistoryOptions = { triggeringSponsoredURL: href };
+ }
+
+ // If a link element is clicked with middle button, user wants to open
+ // the link somewhere rather than pasting clipboard content. Therefore,
+ // when it's clicked with middle button, we should prevent multiple
+ // actions here to avoid leaking clipboard content unexpectedly.
+ // Note that whether the link will work actually or not does not matter
+ // because in this case, user does not intent to paste clipboard content.
+ // We also need to do this to prevent multiple tabs opening if there are
+ // nested link elements.
+ event.preventMultipleActions();
+
+ this.sendAsyncMessage("Content:Click", json);
+ }
+
+ // This might be middle mouse navigation, in which case pass this back:
+ if (!href && event.button == 1 && isFromMiddleMousePasteHandler) {
+ this.manager.getActor("MiddleMousePasteHandler").onProcessedClick(json);
+ }
+ }
+}
diff --git a/browser/actors/ClickHandlerParent.sys.mjs b/browser/actors/ClickHandlerParent.sys.mjs
new file mode 100644
index 0000000000..4078c6404f
--- /dev/null
+++ b/browser/actors/ClickHandlerParent.sys.mjs
@@ -0,0 +1,156 @@
+/* -*- mode: js; indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
+ PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ WebNavigationFrames: "resource://gre/modules/WebNavigationFrames.sys.mjs",
+});
+
+let gContentClickListeners = new Set();
+
+// Fill in fields which are not sent by the content process for the click event
+// based on known data in the parent process.
+function fillInClickEvent(actor, data) {
+ const wgp = actor.manager;
+ data.frameID = lazy.WebNavigationFrames.getFrameId(wgp.browsingContext);
+ data.triggeringPrincipal = wgp.documentPrincipal;
+ data.originPrincipal = wgp.documentPrincipal;
+ data.originStoragePrincipal = wgp.documentStoragePrincipal;
+ data.originAttributes = wgp.documentPrincipal?.originAttributes ?? {};
+ data.isContentWindowPrivate = wgp.browsingContext.usePrivateBrowsing;
+}
+
+export class MiddleMousePasteHandlerParent extends JSWindowActorParent {
+ receiveMessage(message) {
+ if (message.name == "MiddleClickPaste") {
+ // This is heavily based on contentAreaClick from browser.js (Bug 903016)
+ // The data is set up in a way to look like an Event.
+ let browser = this.manager.browsingContext.top.embedderElement;
+ if (!browser) {
+ // Can be null if the tab disappeared by the time we got the message.
+ // Just bail.
+ return;
+ }
+ fillInClickEvent(this, message.data);
+ browser.ownerGlobal.middleMousePaste(message.data);
+ }
+ }
+}
+
+export class ClickHandlerParent extends JSWindowActorParent {
+ static addContentClickListener(listener) {
+ gContentClickListeners.add(listener);
+ }
+
+ static removeContentClickListener(listener) {
+ gContentClickListeners.delete(listener);
+ }
+
+ receiveMessage(message) {
+ switch (message.name) {
+ case "Content:Click":
+ fillInClickEvent(this, message.data);
+ this.contentAreaClick(message.data);
+ this.notifyClickListeners(message.data);
+ break;
+ }
+ }
+
+ /**
+ * Handles clicks in the content area.
+ *
+ * @param data {Object} object that looks like an Event
+ * @param browser {Element<browser>}
+ */
+ contentAreaClick(data) {
+ // This is heavily based on contentAreaClick from browser.js (Bug 903016)
+ // The data is set up in a way to look like an Event.
+ let browser = this.manager.browsingContext.top.embedderElement;
+ if (!browser) {
+ // Can be null if the tab disappeared by the time we got the message.
+ // Just bail.
+ return;
+ }
+ let window = browser.ownerGlobal;
+
+ // If the browser is not in a place where we can open links, bail out.
+ // This can happen in osx sheets, dialogs, etc. that are not browser
+ // windows. Specifically the payments UI is in an osx sheet.
+ if (window.openLinkIn === undefined) {
+ return;
+ }
+
+ // Mark the page as a user followed link. This is done so that history can
+ // distinguish automatic embed visits from user activated ones. For example
+ // pages loaded in frames are embed visits and lost with the session, while
+ // visits across frames should be preserved.
+ try {
+ if (!lazy.PrivateBrowsingUtils.isWindowPrivate(window)) {
+ lazy.PlacesUIUtils.markPageAsFollowedLink(data.href);
+ }
+ } catch (ex) {
+ /* Skip invalid URIs. */
+ }
+
+ // This part is based on handleLinkClick.
+ var where = window.whereToOpenLink(data);
+ if (where == "current") {
+ return;
+ }
+
+ // Todo(903022): code for where == save
+
+ let params = {
+ charset: browser.characterSet,
+ referrerInfo: lazy.E10SUtils.deserializeReferrerInfo(data.referrerInfo),
+ isContentWindowPrivate: data.isContentWindowPrivate,
+ originPrincipal: data.originPrincipal,
+ originStoragePrincipal: data.originStoragePrincipal,
+ triggeringPrincipal: data.triggeringPrincipal,
+ csp: data.csp ? lazy.E10SUtils.deserializeCSP(data.csp) : null,
+ frameID: data.frameID,
+ openerBrowser: browser,
+ // The child ensures that untrusted events have a valid user activation.
+ hasValidUserGestureActivation: true,
+ triggeringRemoteType: this.manager.domProcess?.remoteType,
+ };
+
+ if (data.globalHistoryOptions) {
+ params.globalHistoryOptions = data.globalHistoryOptions;
+ } else {
+ params.globalHistoryOptions = {
+ triggeringSponsoredURL: browser.getAttribute("triggeringSponsoredURL"),
+ triggeringSponsoredURLVisitTimeMS: browser.getAttribute(
+ "triggeringSponsoredURLVisitTimeMS"
+ ),
+ };
+ }
+
+ // The new tab/window must use the same userContextId.
+ if (data.originAttributes.userContextId) {
+ params.userContextId = data.originAttributes.userContextId;
+ }
+
+ params.allowInheritPrincipal = true;
+
+ window.openLinkIn(data.href, where, params);
+ }
+
+ notifyClickListeners(data) {
+ for (let listener of gContentClickListeners) {
+ try {
+ let browser = this.browsingContext.top.embedderElement;
+
+ listener.onContentClick(browser, data);
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ }
+}
diff --git a/browser/actors/ContentSearchChild.sys.mjs b/browser/actors/ContentSearchChild.sys.mjs
new file mode 100644
index 0000000000..79cca36bae
--- /dev/null
+++ b/browser/actors/ContentSearchChild.sys.mjs
@@ -0,0 +1,35 @@
+/* vim: set ts=2 sw=2 sts=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export class ContentSearchChild extends JSWindowActorChild {
+ handleEvent(event) {
+ // The event gets translated into a message that
+ // is then sent to the parent.
+ if (event.type == "ContentSearchClient") {
+ this.sendAsyncMessage(event.detail.type, event.detail.data);
+ }
+ }
+
+ receiveMessage(msg) {
+ // The message gets translated into an event that
+ // is then sent to the content.
+ this._fireEvent(msg.name, msg.data);
+ }
+
+ _fireEvent(type, data = null) {
+ let event = Cu.cloneInto(
+ {
+ detail: {
+ type,
+ data,
+ },
+ },
+ this.contentWindow
+ );
+ this.contentWindow.dispatchEvent(
+ new this.contentWindow.CustomEvent("ContentSearchService", event)
+ );
+ }
+}
diff --git a/browser/actors/ContentSearchParent.sys.mjs b/browser/actors/ContentSearchParent.sys.mjs
new file mode 100644
index 0000000000..7c1a39536c
--- /dev/null
+++ b/browser/actors/ContentSearchParent.sys.mjs
@@ -0,0 +1,658 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.sys.mjs",
+ FormHistory: "resource://gre/modules/FormHistory.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ SearchSuggestionController:
+ "resource://gre/modules/SearchSuggestionController.sys.mjs",
+ UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
+});
+
+const MAX_LOCAL_SUGGESTIONS = 3;
+const MAX_SUGGESTIONS = 6;
+const SEARCH_ENGINE_PLACEHOLDER_ICON =
+ "chrome://browser/skin/search-engine-placeholder.png";
+
+// Set of all ContentSearch actors, used to broadcast messages to all of them.
+let gContentSearchActors = new Set();
+
+/**
+ * Inbound messages have the following types:
+ *
+ * AddFormHistoryEntry
+ * Adds an entry to the search form history.
+ * data: the entry, a string
+ * GetSuggestions
+ * Retrieves an array of search suggestions given a search string.
+ * data: { engineName, searchString }
+ * GetState
+ * Retrieves the current search engine state.
+ * data: null
+ * GetStrings
+ * Retrieves localized search UI strings.
+ * data: null
+ * ManageEngines
+ * Opens the search engine management window.
+ * data: null
+ * RemoveFormHistoryEntry
+ * Removes an entry from the search form history.
+ * data: the entry, a string
+ * Search
+ * Performs a search.
+ * Any GetSuggestions messages in the queue from the same target will be
+ * cancelled.
+ * data: { engineName, searchString, healthReportKey, searchPurpose }
+ * SetCurrentEngine
+ * Sets the current engine.
+ * data: the name of the engine
+ * SpeculativeConnect
+ * Speculatively connects to an engine.
+ * data: the name of the engine
+ *
+ * Outbound messages have the following types:
+ *
+ * CurrentEngine
+ * Broadcast when the current engine changes.
+ * data: see _currentEngineObj
+ * CurrentState
+ * Broadcast when the current search state changes.
+ * data: see currentStateObj
+ * State
+ * Sent in reply to GetState.
+ * data: see currentStateObj
+ * Strings
+ * Sent in reply to GetStrings
+ * data: Object containing string names and values for the current locale.
+ * Suggestions
+ * Sent in reply to GetSuggestions.
+ * data: see _onMessageGetSuggestions
+ * SuggestionsCancelled
+ * Sent in reply to GetSuggestions when pending GetSuggestions events are
+ * cancelled.
+ * data: null
+ */
+
+export let ContentSearch = {
+ initialized: false,
+
+ // Inbound events are queued and processed in FIFO order instead of handling
+ // them immediately, which would result in non-FIFO responses due to the
+ // asynchrononicity added by converting image data URIs to ArrayBuffers.
+ _eventQueue: [],
+ _currentEventPromise: null,
+
+ // This is used to handle search suggestions. It maps xul:browsers to objects
+ // { controller, previousFormHistoryResults }. See _onMessageGetSuggestions.
+ _suggestionMap: new WeakMap(),
+
+ // Resolved when we finish shutting down.
+ _destroyedPromise: null,
+
+ // The current controller and browser in _onMessageGetSuggestions. Allows
+ // fetch cancellation from _cancelSuggestions.
+ _currentSuggestion: null,
+
+ init() {
+ if (!this.initialized) {
+ Services.obs.addObserver(this, "browser-search-engine-modified");
+ Services.obs.addObserver(this, "shutdown-leaks-before-check");
+ lazy.UrlbarPrefs.addObserver(this);
+
+ this.initialized = true;
+ }
+ },
+
+ get searchSuggestionUIStrings() {
+ if (this._searchSuggestionUIStrings) {
+ return this._searchSuggestionUIStrings;
+ }
+ this._searchSuggestionUIStrings = {};
+ let searchBundle = Services.strings.createBundle(
+ "chrome://browser/locale/search.properties"
+ );
+ let stringNames = [
+ "searchHeader",
+ "searchForSomethingWith2",
+ "searchWithHeader",
+ "searchSettings",
+ ];
+
+ for (let name of stringNames) {
+ this._searchSuggestionUIStrings[name] =
+ searchBundle.GetStringFromName(name);
+ }
+ return this._searchSuggestionUIStrings;
+ },
+
+ destroy() {
+ if (!this.initialized) {
+ return new Promise();
+ }
+
+ if (this._destroyedPromise) {
+ return this._destroyedPromise;
+ }
+
+ Services.obs.removeObserver(this, "browser-search-engine-modified");
+ Services.obs.removeObserver(this, "shutdown-leaks-before-check");
+
+ this._eventQueue.length = 0;
+ this._destroyedPromise = Promise.resolve(this._currentEventPromise);
+ return this._destroyedPromise;
+ },
+
+ observe(subj, topic, data) {
+ switch (topic) {
+ case "browser-search-engine-modified":
+ this._eventQueue.push({
+ type: "Observe",
+ data,
+ });
+ this._processEventQueue();
+ break;
+ case "shutdown-leaks-before-check":
+ subj.wrappedJSObject.client.addBlocker(
+ "ContentSearch: Wait until the service is destroyed",
+ () => this.destroy()
+ );
+ break;
+ }
+ },
+
+ /**
+ * Observes changes in prefs tracked by UrlbarPrefs.
+ * @param {string} pref
+ * The name of the pref, relative to `browser.urlbar.` if the pref is
+ * in that branch.
+ */
+ onPrefChanged(pref) {
+ if (lazy.UrlbarPrefs.shouldHandOffToSearchModePrefs.includes(pref)) {
+ this._eventQueue.push({
+ type: "Observe",
+ data: "shouldHandOffToSearchMode",
+ });
+ this._processEventQueue();
+ }
+ },
+
+ removeFormHistoryEntry(browser, entry) {
+ let browserData = this._suggestionDataForBrowser(browser);
+ if (browserData?.previousFormHistoryResults) {
+ let result = browserData.previousFormHistoryResults.find(
+ e => e.text == entry
+ );
+ lazy.FormHistory.update({
+ op: "remove",
+ fieldname: browserData.controller.formHistoryParam,
+ value: entry,
+ guid: result.guid,
+ }).catch(err =>
+ console.error("Error removing form history entry: ", err)
+ );
+ }
+ },
+
+ performSearch(actor, browser, data) {
+ this._ensureDataHasProperties(data, [
+ "engineName",
+ "searchString",
+ "healthReportKey",
+ "searchPurpose",
+ ]);
+ let engine = Services.search.getEngineByName(data.engineName);
+ let submission = engine.getSubmission(
+ data.searchString,
+ "",
+ data.searchPurpose
+ );
+ let win = browser.ownerGlobal;
+ if (!win) {
+ // The browser may have been closed between the time its content sent the
+ // message and the time we handle it.
+ return;
+ }
+ let where = win.whereToOpenLink(data.originalEvent);
+
+ // There is a chance that by the time we receive the search message, the user
+ // has switched away from the tab that triggered the search. If, based on the
+ // event, we need to load the search in the same tab that triggered it (i.e.
+ // where === "current"), openUILinkIn will not work because that tab is no
+ // longer the current one. For this case we manually load the URI.
+ if (where === "current") {
+ // Since we're going to load the search in the same browser, blur the search
+ // UI to prevent further interaction before we start loading.
+ this._reply(actor, "Blur");
+ browser.loadURI(submission.uri, {
+ postData: submission.postData,
+ triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
+ {
+ userContextId:
+ win.gBrowser.selectedBrowser.getAttribute("userContextId"),
+ }
+ ),
+ });
+ } else {
+ let params = {
+ postData: submission.postData,
+ inBackground: Services.prefs.getBoolPref(
+ "browser.tabs.loadInBackground"
+ ),
+ };
+ win.openTrustedLinkIn(submission.uri.spec, where, params);
+ }
+ lazy.BrowserSearchTelemetry.recordSearch(
+ browser,
+ engine,
+ data.healthReportKey,
+ {
+ selection: data.selection,
+ }
+ );
+ },
+
+ async getSuggestions(engineName, searchString, browser) {
+ let engine = Services.search.getEngineByName(engineName);
+ if (!engine) {
+ throw new Error("Unknown engine name: " + engineName);
+ }
+
+ let browserData = this._suggestionDataForBrowser(browser, true);
+ let { controller } = browserData;
+ let ok = lazy.SearchSuggestionController.engineOffersSuggestions(engine);
+ controller.maxLocalResults = ok ? MAX_LOCAL_SUGGESTIONS : MAX_SUGGESTIONS;
+ controller.maxRemoteResults = ok ? MAX_SUGGESTIONS : 0;
+ let priv = lazy.PrivateBrowsingUtils.isBrowserPrivate(browser);
+ // fetch() rejects its promise if there's a pending request, but since we
+ // process our event queue serially, there's never a pending request.
+ this._currentSuggestion = { controller, browser };
+ let suggestions = await controller.fetch(searchString, priv, engine);
+
+ // Simplify results since we do not support rich results in this component.
+ suggestions.local = suggestions.local.map(e => e.value);
+ // We shouldn't show tail suggestions in their full-text form.
+ let nonTailEntries = suggestions.remote.filter(
+ e => !e.matchPrefix && !e.tail
+ );
+ suggestions.remote = nonTailEntries.map(e => e.value);
+
+ this._currentSuggestion = null;
+
+ // suggestions will be null if the request was cancelled
+ let result = {};
+ if (!suggestions) {
+ return result;
+ }
+
+ // Keep the form history results so RemoveFormHistoryEntry can remove entries
+ // from it. Keeping only one result isn't foolproof because the client may
+ // try to remove an entry from one set of suggestions after it has requested
+ // more but before it's received them. In that case, the entry may not
+ // appear in the new suggestions. But that should happen rarely.
+ browserData.previousFormHistoryResults = suggestions.formHistoryResults;
+ result = {
+ engineName,
+ term: suggestions.term,
+ local: suggestions.local,
+ remote: suggestions.remote,
+ };
+ return result;
+ },
+
+ async addFormHistoryEntry(browser, entry = null) {
+ let isPrivate = false;
+ try {
+ // isBrowserPrivate assumes that the passed-in browser has all the normal
+ // properties, which won't be true if the browser has been destroyed.
+ // That may be the case here due to the asynchronous nature of messaging.
+ isPrivate = lazy.PrivateBrowsingUtils.isBrowserPrivate(browser);
+ } catch (err) {
+ return false;
+ }
+ if (
+ isPrivate ||
+ !entry ||
+ entry.value.length >
+ lazy.SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH
+ ) {
+ return false;
+ }
+ let browserData = this._suggestionDataForBrowser(browser, true);
+ lazy.FormHistory.update({
+ op: "bump",
+ fieldname: browserData.controller.formHistoryParam,
+ value: entry.value,
+ source: entry.engineName,
+ }).catch(err => console.error("Error adding form history entry: ", err));
+ return true;
+ },
+
+ async currentStateObj(window) {
+ let state = {
+ engines: [],
+ currentEngine: await this._currentEngineObj(false),
+ currentPrivateEngine: await this._currentEngineObj(true),
+ };
+
+ for (let engine of await Services.search.getVisibleEngines()) {
+ state.engines.push({
+ name: engine.name,
+ iconData: await this._getEngineIconURL(engine),
+ hidden: engine.hideOneOffButton,
+ isAppProvided: engine.isAppProvided,
+ });
+ }
+
+ if (window) {
+ state.isInPrivateBrowsingMode =
+ lazy.PrivateBrowsingUtils.isContentWindowPrivate(window);
+ state.isAboutPrivateBrowsing =
+ window.gBrowser.currentURI.spec == "about:privatebrowsing";
+ }
+
+ return state;
+ },
+
+ _processEventQueue() {
+ if (this._currentEventPromise || !this._eventQueue.length) {
+ return;
+ }
+
+ let event = this._eventQueue.shift();
+
+ this._currentEventPromise = (async () => {
+ try {
+ await this["_on" + event.type](event);
+ } catch (err) {
+ console.error(err);
+ } finally {
+ this._currentEventPromise = null;
+
+ this._processEventQueue();
+ }
+ })();
+ },
+
+ _cancelSuggestions({ actor, browser }) {
+ let cancelled = false;
+ // cancel active suggestion request
+ if (
+ this._currentSuggestion &&
+ this._currentSuggestion.browser === browser
+ ) {
+ this._currentSuggestion.controller.stop();
+ cancelled = true;
+ }
+ // cancel queued suggestion requests
+ for (let i = 0; i < this._eventQueue.length; i++) {
+ let m = this._eventQueue[i];
+ if (actor === m.actor && m.name === "GetSuggestions") {
+ this._eventQueue.splice(i, 1);
+ cancelled = true;
+ i--;
+ }
+ }
+ if (cancelled) {
+ this._reply(actor, "SuggestionsCancelled");
+ }
+ },
+
+ async _onMessage(eventItem) {
+ let methodName = "_onMessage" + eventItem.name;
+ if (methodName in this) {
+ await this._initService();
+ await this[methodName](eventItem);
+ eventItem.browser.removeEventListener("SwapDocShells", eventItem, true);
+ }
+ },
+
+ _onMessageGetState({ actor, browser }) {
+ return this.currentStateObj(browser.ownerGlobal).then(state => {
+ this._reply(actor, "State", state);
+ });
+ },
+
+ _onMessageGetEngine({ actor, browser }) {
+ return this.currentStateObj(browser.ownerGlobal).then(state => {
+ this._reply(actor, "Engine", {
+ isPrivateEngine: state.isInPrivateBrowsingMode,
+ isAboutPrivateBrowsing: state.isAboutPrivateBrowsing,
+ engine: state.isInPrivateBrowsingMode
+ ? state.currentPrivateEngine
+ : state.currentEngine,
+ });
+ });
+ },
+
+ _onMessageGetHandoffSearchModePrefs({ actor }) {
+ this._reply(
+ actor,
+ "HandoffSearchModePrefs",
+ lazy.UrlbarPrefs.get("shouldHandOffToSearchMode")
+ );
+ },
+
+ _onMessageGetStrings({ actor }) {
+ this._reply(actor, "Strings", this.searchSuggestionUIStrings);
+ },
+
+ _onMessageSearch({ actor, browser, data }) {
+ this.performSearch(actor, browser, data);
+ },
+
+ _onMessageSetCurrentEngine({ data }) {
+ Services.search.setDefault(
+ Services.search.getEngineByName(data),
+ Ci.nsISearchService.CHANGE_REASON_USER_SEARCHBAR
+ );
+ },
+
+ _onMessageManageEngines({ browser }) {
+ browser.ownerGlobal.openPreferences("paneSearch");
+ },
+
+ async _onMessageGetSuggestions({ actor, browser, data }) {
+ this._ensureDataHasProperties(data, ["engineName", "searchString"]);
+ let { engineName, searchString } = data;
+ let suggestions = await this.getSuggestions(
+ engineName,
+ searchString,
+ browser
+ );
+
+ this._reply(actor, "Suggestions", {
+ engineName: data.engineName,
+ searchString: suggestions.term,
+ formHistory: suggestions.local,
+ remote: suggestions.remote,
+ });
+ },
+
+ async _onMessageAddFormHistoryEntry({ browser, data: entry }) {
+ await this.addFormHistoryEntry(browser, entry);
+ },
+
+ _onMessageRemoveFormHistoryEntry({ browser, data: entry }) {
+ this.removeFormHistoryEntry(browser, entry);
+ },
+
+ _onMessageSpeculativeConnect({ browser, data: engineName }) {
+ let engine = Services.search.getEngineByName(engineName);
+ if (!engine) {
+ throw new Error("Unknown engine name: " + engineName);
+ }
+ if (browser.contentWindow) {
+ engine.speculativeConnect({
+ window: browser.contentWindow,
+ originAttributes: browser.contentPrincipal.originAttributes,
+ });
+ }
+ },
+
+ async _onObserve(eventItem) {
+ let engine;
+ switch (eventItem.data) {
+ case "engine-default":
+ engine = await this._currentEngineObj(false);
+ this._broadcast("CurrentEngine", engine);
+ break;
+ case "engine-default-private":
+ engine = await this._currentEngineObj(true);
+ this._broadcast("CurrentPrivateEngine", engine);
+ break;
+ case "shouldHandOffToSearchMode":
+ this._broadcast(
+ "HandoffSearchModePrefs",
+ lazy.UrlbarPrefs.get("shouldHandOffToSearchMode")
+ );
+ break;
+ default:
+ let state = await this.currentStateObj();
+ this._broadcast("CurrentState", state);
+ break;
+ }
+ },
+
+ _suggestionDataForBrowser(browser, create = false) {
+ let data = this._suggestionMap.get(browser);
+ if (!data && create) {
+ // Since one SearchSuggestionController instance is meant to be used per
+ // autocomplete widget, this means that we assume each xul:browser has at
+ // most one such widget.
+ data = {
+ controller: new lazy.SearchSuggestionController(),
+ };
+ this._suggestionMap.set(browser, data);
+ }
+ return data;
+ },
+
+ _reply(actor, type, data) {
+ actor.sendAsyncMessage(type, data);
+ },
+
+ _broadcast(type, data) {
+ for (let actor of gContentSearchActors) {
+ actor.sendAsyncMessage(type, data);
+ }
+ },
+
+ async _currentEngineObj(usePrivate) {
+ let engine =
+ Services.search[usePrivate ? "defaultPrivateEngine" : "defaultEngine"];
+ let obj = {
+ name: engine.name,
+ iconData: await this._getEngineIconURL(engine),
+ isAppProvided: engine.isAppProvided,
+ };
+ return obj;
+ },
+
+ /**
+ * Converts the engine's icon into an appropriate URL for display at
+ */
+ async _getEngineIconURL(engine) {
+ let url = engine.getIconURL();
+ if (!url) {
+ return SEARCH_ENGINE_PLACEHOLDER_ICON;
+ }
+
+ // The uri received here can be of two types
+ // 1 - moz-extension://[uuid]/path/to/icon.ico
+ // 2 - data:image/x-icon;base64,VERY-LONG-STRING
+ //
+ // If the URI is not a data: URI, there's no point in converting
+ // it to an arraybuffer (which is used to optimize passing the data
+ // accross processes): we can just pass the original URI, which is cheaper.
+ if (!url.startsWith("data:")) {
+ return url;
+ }
+
+ return new Promise(resolve => {
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", url, true);
+ xhr.responseType = "arraybuffer";
+ xhr.onload = () => {
+ resolve(xhr.response);
+ };
+ xhr.onerror =
+ xhr.onabort =
+ xhr.ontimeout =
+ () => {
+ resolve(SEARCH_ENGINE_PLACEHOLDER_ICON);
+ };
+ try {
+ // This throws if the URI is erroneously encoded.
+ xhr.send();
+ } catch (err) {
+ resolve(SEARCH_ENGINE_PLACEHOLDER_ICON);
+ }
+ });
+ },
+
+ _ensureDataHasProperties(data, requiredProperties) {
+ for (let prop of requiredProperties) {
+ if (!(prop in data)) {
+ throw new Error("Message data missing required property: " + prop);
+ }
+ }
+ },
+
+ _initService() {
+ if (!this._initServicePromise) {
+ this._initServicePromise = Services.search.init();
+ }
+ return this._initServicePromise;
+ },
+};
+
+export class ContentSearchParent extends JSWindowActorParent {
+ constructor() {
+ super();
+ ContentSearch.init();
+ gContentSearchActors.add(this);
+ }
+
+ didDestroy() {
+ gContentSearchActors.delete(this);
+ }
+
+ receiveMessage(msg) {
+ // Add a temporary event handler that exists only while the message is in
+ // the event queue. If the message's source docshell changes browsers in
+ // the meantime, then we need to update the browser. event.detail will be
+ // the docshell's new parent <xul:browser> element.
+ let browser = this.browsingContext.top.embedderElement;
+ let eventItem = {
+ type: "Message",
+ name: msg.name,
+ data: msg.data,
+ browser,
+ actor: this,
+ handleEvent: event => {
+ let browserData = ContentSearch._suggestionMap.get(eventItem.browser);
+ if (browserData) {
+ ContentSearch._suggestionMap.delete(eventItem.browser);
+ ContentSearch._suggestionMap.set(event.detail, browserData);
+ }
+ browser.removeEventListener("SwapDocShells", eventItem, true);
+ eventItem.browser = event.detail;
+ eventItem.browser.addEventListener("SwapDocShells", eventItem, true);
+ },
+ };
+ browser.addEventListener("SwapDocShells", eventItem, true);
+
+ // Search requests cause cancellation of all Suggestion requests from the
+ // same browser.
+ if (msg.name === "Search") {
+ ContentSearch._cancelSuggestions(eventItem);
+ }
+
+ ContentSearch._eventQueue.push(eventItem);
+ ContentSearch._processEventQueue();
+ }
+}
diff --git a/browser/actors/ContextMenuChild.sys.mjs b/browser/actors/ContextMenuChild.sys.mjs
new file mode 100644
index 0000000000..34e39101c2
--- /dev/null
+++ b/browser/actors/ContextMenuChild.sys.mjs
@@ -0,0 +1,1243 @@
+/* -*- mode: js; indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 sw=2 sts=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ContentDOMReference: "resource://gre/modules/ContentDOMReference.sys.mjs",
+ E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
+ InlineSpellCheckerContent:
+ "resource://gre/modules/InlineSpellCheckerContent.sys.mjs",
+ LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
+ LoginManagerChild: "resource://gre/modules/LoginManagerChild.sys.mjs",
+ SelectionUtils: "resource://gre/modules/SelectionUtils.sys.mjs",
+ SpellCheckHelper: "resource://gre/modules/InlineSpellChecker.sys.mjs",
+});
+
+let contextMenus = new WeakMap();
+
+export class ContextMenuChild extends JSWindowActorChild {
+ // PUBLIC
+ constructor() {
+ super();
+
+ this.target = null;
+ this.context = null;
+ this.lastMenuTarget = null;
+ }
+
+ static getTarget(browsingContext, message, key) {
+ let actor = contextMenus.get(browsingContext);
+ if (!actor) {
+ throw new Error(
+ "Can't find ContextMenu actor for browsing context with " +
+ "ID: " +
+ browsingContext.id
+ );
+ }
+ return actor.getTarget(message, key);
+ }
+
+ static getLastTarget(browsingContext) {
+ let contextMenu = contextMenus.get(browsingContext);
+ return contextMenu && contextMenu.lastMenuTarget;
+ }
+
+ receiveMessage(message) {
+ switch (message.name) {
+ case "ContextMenu:GetFrameTitle": {
+ let target = lazy.ContentDOMReference.resolve(
+ message.data.targetIdentifier
+ );
+ return Promise.resolve(target.ownerDocument.title);
+ }
+
+ case "ContextMenu:Canvas:ToBlobURL": {
+ let target = lazy.ContentDOMReference.resolve(
+ message.data.targetIdentifier
+ );
+ return new Promise(resolve => {
+ target.toBlob(blob => {
+ let blobURL = URL.createObjectURL(blob);
+ resolve(blobURL);
+ });
+ });
+ }
+
+ case "ContextMenu:Hiding": {
+ this.context = null;
+ this.target = null;
+ break;
+ }
+
+ case "ContextMenu:MediaCommand": {
+ lazy.E10SUtils.wrapHandlingUserInput(
+ this.contentWindow,
+ message.data.handlingUserInput,
+ () => {
+ let media = lazy.ContentDOMReference.resolve(
+ message.data.targetIdentifier
+ );
+
+ switch (message.data.command) {
+ case "play":
+ media.play();
+ break;
+ case "pause":
+ media.pause();
+ break;
+ case "loop":
+ media.loop = !media.loop;
+ break;
+ case "mute":
+ media.muted = true;
+ break;
+ case "unmute":
+ media.muted = false;
+ break;
+ case "playbackRate":
+ media.playbackRate = message.data.data;
+ break;
+ case "hidecontrols":
+ media.removeAttribute("controls");
+ break;
+ case "showcontrols":
+ media.setAttribute("controls", "true");
+ break;
+ case "fullscreen":
+ if (this.document.fullscreenEnabled) {
+ media.requestFullscreen();
+ }
+ break;
+ case "pictureinpicture":
+ if (!media.isCloningElementVisually) {
+ Services.telemetry.keyedScalarAdd(
+ "pictureinpicture.opened_method",
+ "contextmenu",
+ 1
+ );
+ }
+ let event = new this.contentWindow.CustomEvent(
+ "MozTogglePictureInPicture",
+ {
+ bubbles: true,
+ detail: { reason: "contextMenu" },
+ },
+ this.contentWindow
+ );
+ media.dispatchEvent(event);
+ break;
+ }
+ }
+ );
+ break;
+ }
+
+ case "ContextMenu:ReloadFrame": {
+ let target = lazy.ContentDOMReference.resolve(
+ message.data.targetIdentifier
+ );
+ target.ownerDocument.location.reload(message.data.forceReload);
+ break;
+ }
+
+ case "ContextMenu:GetImageText": {
+ let img = lazy.ContentDOMReference.resolve(
+ message.data.targetIdentifier
+ );
+ const { direction } = this.contentWindow.getComputedStyle(img);
+
+ return img.recognizeCurrentImageText().then(results => {
+ return { results, direction };
+ });
+ }
+
+ case "ContextMenu:ToggleRevealPassword": {
+ let target = lazy.ContentDOMReference.resolve(
+ message.data.targetIdentifier
+ );
+ target.revealPassword = !target.revealPassword;
+ break;
+ }
+
+ case "ContextMenu:UseRelayMask": {
+ const input = lazy.ContentDOMReference.resolve(
+ message.data.targetIdentifier
+ );
+ input.setUserInput(message.data.emailMask);
+ break;
+ }
+
+ case "ContextMenu:ReloadImage": {
+ let image = lazy.ContentDOMReference.resolve(
+ message.data.targetIdentifier
+ );
+
+ if (image instanceof Ci.nsIImageLoadingContent) {
+ image.forceReload();
+ }
+ break;
+ }
+
+ case "ContextMenu:SearchFieldBookmarkData": {
+ let node = lazy.ContentDOMReference.resolve(
+ message.data.targetIdentifier
+ );
+ let charset = node.ownerDocument.characterSet;
+ let formBaseURI = Services.io.newURI(node.form.baseURI, charset);
+ let formURI = Services.io.newURI(
+ node.form.getAttribute("action"),
+ charset,
+ formBaseURI
+ );
+ let spec = formURI.spec;
+ let isURLEncoded =
+ node.form.method.toUpperCase() == "POST" &&
+ (node.form.enctype == "application/x-www-form-urlencoded" ||
+ node.form.enctype == "");
+ let title = node.ownerDocument.title;
+
+ function escapeNameValuePair([aName, aValue]) {
+ if (isURLEncoded) {
+ return escape(aName + "=" + aValue);
+ }
+
+ return encodeURIComponent(aName) + "=" + encodeURIComponent(aValue);
+ }
+ let formData = new this.contentWindow.FormData(node.form);
+ formData.delete(node.name);
+ formData = Array.from(formData).map(escapeNameValuePair);
+ formData.push(
+ escape(node.name) + (isURLEncoded ? escape("=%s") : "=%s")
+ );
+
+ let postData;
+
+ if (isURLEncoded) {
+ postData = formData.join("&");
+ } else {
+ let separator = spec.includes("?") ? "&" : "?";
+ spec += separator + formData.join("&");
+ }
+
+ return Promise.resolve({ spec, title, postData, charset });
+ }
+
+ case "ContextMenu:SaveVideoFrameAsImage": {
+ let video = lazy.ContentDOMReference.resolve(
+ message.data.targetIdentifier
+ );
+ let canvas = this.document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "canvas"
+ );
+ canvas.width = video.videoWidth;
+ canvas.height = video.videoHeight;
+
+ let ctxDraw = canvas.getContext("2d");
+ ctxDraw.drawImage(video, 0, 0);
+
+ // Note: if changing the content type, don't forget to update
+ // consumers that also hardcode this content type.
+ return Promise.resolve(canvas.toDataURL("image/jpeg", ""));
+ }
+
+ case "ContextMenu:SetAsDesktopBackground": {
+ let target = lazy.ContentDOMReference.resolve(
+ message.data.targetIdentifier
+ );
+
+ // Paranoia: check disableSetDesktopBackground again, in case the
+ // image changed since the context menu was initiated.
+ let disable = this._disableSetDesktopBackground(target);
+
+ if (!disable) {
+ try {
+ Services.scriptSecurityManager.checkLoadURIWithPrincipal(
+ target.ownerDocument.nodePrincipal,
+ target.currentURI
+ );
+ let canvas = this.document.createElement("canvas");
+ canvas.width = target.naturalWidth;
+ canvas.height = target.naturalHeight;
+ let ctx = canvas.getContext("2d");
+ ctx.drawImage(target, 0, 0);
+ let dataURL = canvas.toDataURL();
+ let url = new URL(target.ownerDocument.location.href).pathname;
+ let imageName = url.substr(url.lastIndexOf("/") + 1);
+ return Promise.resolve({ failed: false, dataURL, imageName });
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ return Promise.resolve({
+ failed: true,
+ dataURL: null,
+ imageName: null,
+ });
+ }
+ }
+
+ return undefined;
+ }
+
+ /**
+ * Returns the event target of the context menu, using a locally stored
+ * reference if possible. If not, and aMessage.objects is defined,
+ * aMessage.objects[aKey] is returned. Otherwise null.
+ * @param {Object} aMessage Message with a objects property
+ * @param {String} aKey Key for the target on aMessage.objects
+ * @return {Object} Context menu target
+ */
+ getTarget(aMessage, aKey = "target") {
+ return this.target || (aMessage.objects && aMessage.objects[aKey]);
+ }
+
+ // PRIVATE
+ _isXULTextLinkLabel(aNode) {
+ const XUL_NS =
+ "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+ return (
+ aNode.namespaceURI == XUL_NS &&
+ aNode.tagName == "label" &&
+ aNode.classList.contains("text-link") &&
+ aNode.href
+ );
+ }
+
+ // Generate fully qualified URL for clicked-on link.
+ _getLinkURL() {
+ let href = this.context.link.href;
+
+ if (href) {
+ // Handle SVG links:
+ if (typeof href == "object" && href.animVal) {
+ return this._makeURLAbsolute(this.context.link.baseURI, href.animVal);
+ }
+
+ return href;
+ }
+
+ href =
+ this.context.link.getAttribute("href") ||
+ this.context.link.getAttributeNS("http://www.w3.org/1999/xlink", "href");
+
+ if (!href || !href.match(/\S/)) {
+ // Without this we try to save as the current doc,
+ // for example, HTML case also throws if empty
+ throw new Error("Empty href");
+ }
+
+ return this._makeURLAbsolute(this.context.link.baseURI, href);
+ }
+
+ _getLinkURI() {
+ try {
+ return Services.io.newURI(this.context.linkURL);
+ } catch (ex) {
+ // e.g. empty URL string
+ }
+
+ return null;
+ }
+
+ // Get text of link.
+ _getLinkText() {
+ let text = this._gatherTextUnder(this.context.link);
+
+ if (!text || !text.match(/\S/)) {
+ text = this.context.link.getAttribute("title");
+ if (!text || !text.match(/\S/)) {
+ text = this.context.link.getAttribute("alt");
+ if (!text || !text.match(/\S/)) {
+ text = this.context.linkURL;
+ }
+ }
+ }
+
+ return text;
+ }
+
+ _getLinkProtocol() {
+ if (this.context.linkURI) {
+ return this.context.linkURI.scheme; // can be |undefined|
+ }
+
+ return null;
+ }
+
+ // Returns true if clicked-on link targets a resource that can be saved.
+ _isLinkSaveable(aLink) {
+ // We don't do the Right Thing for news/snews yet, so turn them off
+ // until we do.
+ return (
+ this.context.linkProtocol &&
+ !(
+ this.context.linkProtocol == "mailto" ||
+ this.context.linkProtocol == "tel" ||
+ this.context.linkProtocol == "javascript" ||
+ this.context.linkProtocol == "news" ||
+ this.context.linkProtocol == "snews"
+ )
+ );
+ }
+
+ // Gather all descendent text under given document node.
+ _gatherTextUnder(root) {
+ let text = "";
+ let node = root.firstChild;
+ let depth = 1;
+ while (node && depth > 0) {
+ // See if this node is text.
+ if (node.nodeType == node.TEXT_NODE) {
+ // Add this text to our collection.
+ text += " " + node.data;
+ } else if (this.contentWindow.HTMLImageElement.isInstance(node)) {
+ // If it has an "alt" attribute, add that.
+ let altText = node.getAttribute("alt");
+ if (altText && altText != "") {
+ text += " " + altText;
+ }
+ }
+ // Find next node to test.
+ // First, see if this node has children.
+ if (node.hasChildNodes()) {
+ // Go to first child.
+ node = node.firstChild;
+ depth++;
+ } else {
+ // No children, try next sibling (or parent next sibling).
+ while (depth > 0 && !node.nextSibling) {
+ node = node.parentNode;
+ depth--;
+ }
+ if (node.nextSibling) {
+ node = node.nextSibling;
+ }
+ }
+ }
+
+ // Strip leading and tailing whitespace.
+ text = text.trim();
+ // Compress remaining whitespace.
+ text = text.replace(/\s+/g, " ");
+ return text;
+ }
+
+ // Returns a "url"-type computed style attribute value, with the url() stripped.
+ _getComputedURL(aElem, aProp) {
+ let urls = aElem.ownerGlobal.getComputedStyle(aElem).getCSSImageURLs(aProp);
+
+ if (!urls.length) {
+ return null;
+ }
+
+ if (urls.length != 1) {
+ throw new Error("found multiple URLs");
+ }
+
+ return urls[0];
+ }
+
+ _makeURLAbsolute(aBase, aUrl) {
+ return Services.io.newURI(aUrl, null, Services.io.newURI(aBase)).spec;
+ }
+
+ _isProprietaryDRM() {
+ return (
+ this.context.target.isEncrypted &&
+ this.context.target.mediaKeys &&
+ this.context.target.mediaKeys.keySystem != "org.w3.clearkey"
+ );
+ }
+
+ _isMediaURLReusable(aURL) {
+ if (aURL.startsWith("blob:")) {
+ return URL.isValidObjectURL(aURL);
+ }
+
+ return true;
+ }
+
+ _isTargetATextBox(node) {
+ if (this.contentWindow.HTMLInputElement.isInstance(node)) {
+ return node.mozIsTextField(false);
+ }
+
+ return this.contentWindow.HTMLTextAreaElement.isInstance(node);
+ }
+
+ /**
+ * Check if we are in the parent process and the current iframe is the RDM iframe.
+ */
+ _isTargetRDMFrame(node) {
+ return (
+ Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT &&
+ node.tagName === "iframe" &&
+ node.hasAttribute("mozbrowser")
+ );
+ }
+
+ _isSpellCheckEnabled(aNode) {
+ // We can always force-enable spellchecking on textboxes
+ if (this._isTargetATextBox(aNode)) {
+ return true;
+ }
+
+ // We can never spell check something which is not content editable
+ let editable = aNode.isContentEditable;
+
+ if (!editable && aNode.ownerDocument) {
+ editable = aNode.ownerDocument.designMode == "on";
+ }
+
+ if (!editable) {
+ return false;
+ }
+
+ // Otherwise make sure that nothing in the parent chain disables spellchecking
+ return aNode.spellcheck;
+ }
+
+ _disableSetDesktopBackground(aTarget) {
+ // Disable the Set as Desktop Background menu item if we're still trying
+ // to load the image or the load failed.
+ if (!(aTarget instanceof Ci.nsIImageLoadingContent)) {
+ return true;
+ }
+
+ if ("complete" in aTarget && !aTarget.complete) {
+ return true;
+ }
+
+ if (aTarget.currentURI.schemeIs("javascript")) {
+ return true;
+ }
+
+ let request = aTarget.getRequest(Ci.nsIImageLoadingContent.CURRENT_REQUEST);
+
+ if (!request) {
+ return true;
+ }
+
+ return false;
+ }
+
+ async handleEvent(aEvent) {
+ contextMenus.set(this.browsingContext, this);
+
+ let defaultPrevented = aEvent.defaultPrevented;
+
+ if (
+ // If the event is not from a chrome-privileged document, and if
+ // `dom.event.contextmenu.enabled` is false, force defaultPrevented=false.
+ !aEvent.composedTarget.nodePrincipal.isSystemPrincipal &&
+ !Services.prefs.getBoolPref("dom.event.contextmenu.enabled")
+ ) {
+ defaultPrevented = false;
+ }
+
+ if (defaultPrevented) {
+ return;
+ }
+
+ if (this._isTargetRDMFrame(aEvent.composedTarget)) {
+ // The target is in the DevTools RDM iframe, a proper context menu event
+ // will be created from the RDM browser.
+ return;
+ }
+
+ let doc = aEvent.composedTarget.ownerDocument;
+ let {
+ mozDocumentURIIfNotForErrorPages: docLocation,
+ characterSet: charSet,
+ baseURI,
+ } = doc;
+ docLocation = docLocation && docLocation.spec;
+ const loginManagerChild = lazy.LoginManagerChild.forWindow(doc.defaultView);
+ const docState = loginManagerChild.stateForDocument(doc);
+ const loginFillInfo = docState.getFieldContext(aEvent.composedTarget);
+
+ let disableSetDesktopBackground = null;
+
+ // Media related cache info parent needs for saving
+ let contentType = null;
+ let contentDisposition = null;
+ if (
+ aEvent.composedTarget.nodeType == aEvent.composedTarget.ELEMENT_NODE &&
+ aEvent.composedTarget instanceof Ci.nsIImageLoadingContent &&
+ aEvent.composedTarget.currentURI
+ ) {
+ disableSetDesktopBackground = this._disableSetDesktopBackground(
+ aEvent.composedTarget
+ );
+
+ try {
+ let imageCache = Cc["@mozilla.org/image/tools;1"]
+ .getService(Ci.imgITools)
+ .getImgCacheForDocument(doc);
+ // The image cache's notion of where this image is located is
+ // the currentURI of the image loading content.
+ let props = imageCache.findEntryProperties(
+ aEvent.composedTarget.currentURI,
+ doc
+ );
+
+ try {
+ contentType = props.get("type", Ci.nsISupportsCString).data;
+ } catch (e) {}
+
+ try {
+ contentDisposition = props.get(
+ "content-disposition",
+ Ci.nsISupportsCString
+ ).data;
+ } catch (e) {}
+ } catch (e) {}
+ }
+
+ let selectionInfo = lazy.SelectionUtils.getSelectionDetails(
+ this.contentWindow
+ );
+
+ this._setContext(aEvent);
+ let context = this.context;
+ this.target = context.target;
+
+ let spellInfo = null;
+ let editFlags = null;
+
+ let referrerInfo = Cc["@mozilla.org/referrer-info;1"].createInstance(
+ Ci.nsIReferrerInfo
+ );
+ referrerInfo.initWithElement(aEvent.composedTarget);
+ referrerInfo = lazy.E10SUtils.serializeReferrerInfo(referrerInfo);
+
+ // In the case "onLink" we may have to send link referrerInfo to use in
+ // _openLinkInParameters
+ let linkReferrerInfo = null;
+ if (context.onLink) {
+ linkReferrerInfo = Cc["@mozilla.org/referrer-info;1"].createInstance(
+ Ci.nsIReferrerInfo
+ );
+ linkReferrerInfo.initWithElement(context.link);
+ }
+
+ let target = context.target;
+ if (target) {
+ this._cleanContext();
+ }
+
+ editFlags = lazy.SpellCheckHelper.isEditable(
+ aEvent.composedTarget,
+ this.contentWindow
+ );
+
+ if (editFlags & lazy.SpellCheckHelper.SPELLCHECKABLE) {
+ spellInfo = lazy.InlineSpellCheckerContent.initContextMenu(
+ aEvent,
+ editFlags,
+ this
+ );
+ }
+
+ // Set the event target first as the copy image command needs it to
+ // determine what was context-clicked on. Then, update the state of the
+ // commands on the context menu.
+ this.docShell.docViewer
+ .QueryInterface(Ci.nsIDocumentViewerEdit)
+ .setCommandNode(aEvent.composedTarget);
+ aEvent.composedTarget.ownerGlobal.updateCommands("contentcontextmenu");
+
+ let data = {
+ context,
+ charSet,
+ baseURI,
+ referrerInfo,
+ editFlags,
+ contentType,
+ docLocation,
+ loginFillInfo,
+ selectionInfo,
+ contentDisposition,
+ disableSetDesktopBackground,
+ };
+
+ if (context.inFrame && !context.inSrcdocFrame) {
+ data.frameReferrerInfo = lazy.E10SUtils.serializeReferrerInfo(
+ doc.referrerInfo
+ );
+ }
+
+ if (linkReferrerInfo) {
+ data.linkReferrerInfo =
+ lazy.E10SUtils.serializeReferrerInfo(linkReferrerInfo);
+ }
+
+ // Notify observers (currently only webextensions) of the context menu being
+ // prepared, allowing them to set webExtContextData for us.
+ let prepareContextMenu = {
+ principal: doc.nodePrincipal,
+ setWebExtContextData(webExtContextData) {
+ data.webExtContextData = webExtContextData;
+ },
+ };
+ Services.obs.notifyObservers(prepareContextMenu, "on-prepare-contextmenu");
+
+ // In the event that the content is running in the parent process, we don't
+ // actually want the contextmenu events to reach the parent - we'll dispatch
+ // a new contextmenu event after the async message has reached the parent
+ // instead.
+ aEvent.stopPropagation();
+
+ data.spellInfo = null;
+ if (!spellInfo) {
+ this.sendAsyncMessage("contextmenu", data);
+ return;
+ }
+
+ try {
+ data.spellInfo = await spellInfo;
+ } catch (ex) {}
+ this.sendAsyncMessage("contextmenu", data);
+ }
+
+ /**
+ * Some things are not serializable, so we either have to only send
+ * their needed data or regenerate them in nsContextMenu.js
+ * - target and target.ownerDocument
+ * - link
+ * - linkURI
+ */
+ _cleanContext(aEvent) {
+ const context = this.context;
+ const cleanTarget = Object.create(null);
+
+ cleanTarget.ownerDocument = {
+ // used for nsContextMenu.initLeaveDOMFullScreenItems and
+ // nsContextMenu.initMediaPlayerItems
+ fullscreen: context.target.ownerDocument.fullscreen,
+
+ // used for nsContextMenu.initMiscItems
+ contentType: context.target.ownerDocument.contentType,
+ };
+
+ // used for nsContextMenu.initMediaPlayerItems
+ Object.assign(cleanTarget, {
+ ended: context.target.ended,
+ muted: context.target.muted,
+ paused: context.target.paused,
+ controls: context.target.controls,
+ duration: context.target.duration,
+ });
+
+ const onMedia = context.onVideo || context.onAudio;
+
+ if (onMedia) {
+ Object.assign(cleanTarget, {
+ loop: context.target.loop,
+ error: context.target.error,
+ networkState: context.target.networkState,
+ playbackRate: context.target.playbackRate,
+ NETWORK_NO_SOURCE: context.target.NETWORK_NO_SOURCE,
+ });
+
+ if (context.onVideo) {
+ Object.assign(cleanTarget, {
+ readyState: context.target.readyState,
+ HAVE_CURRENT_DATA: context.target.HAVE_CURRENT_DATA,
+ });
+ }
+ }
+
+ context.target = cleanTarget;
+
+ if (context.link) {
+ context.link = { href: context.linkURL };
+ }
+
+ delete context.linkURI;
+ }
+
+ _setContext(aEvent) {
+ this.context = Object.create(null);
+ const context = this.context;
+
+ context.timeStamp = aEvent.timeStamp;
+ context.screenXDevPx = aEvent.screenX * this.contentWindow.devicePixelRatio;
+ context.screenYDevPx = aEvent.screenY * this.contentWindow.devicePixelRatio;
+ context.inputSource = aEvent.inputSource;
+
+ let node = aEvent.composedTarget;
+
+ // Set the node to containing <video>/<audio>/<embed>/<object> if the node
+ // is in the videocontrols UA Widget.
+ if (node.containingShadowRoot?.isUAWidget()) {
+ const host = node.containingShadowRoot.host;
+ if (
+ this.contentWindow.HTMLMediaElement.isInstance(host) ||
+ this.contentWindow.HTMLEmbedElement.isInstance(host) ||
+ this.contentWindow.HTMLObjectElement.isInstance(host)
+ ) {
+ node = host;
+ }
+ }
+
+ const XUL_NS =
+ "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
+ context.shouldDisplay = true;
+
+ if (
+ node.nodeType == node.DOCUMENT_NODE ||
+ // Don't display for XUL element unless <label class="text-link">
+ (node.namespaceURI == XUL_NS && !this._isXULTextLinkLabel(node))
+ ) {
+ context.shouldDisplay = false;
+ return;
+ }
+
+ const isAboutDevtoolsToolbox = this.document.documentURI.startsWith(
+ "about:devtools-toolbox"
+ );
+ const editFlags = lazy.SpellCheckHelper.isEditable(
+ node,
+ this.contentWindow
+ );
+
+ if (
+ isAboutDevtoolsToolbox &&
+ (editFlags & lazy.SpellCheckHelper.TEXTINPUT) === 0
+ ) {
+ // Don't display for about:devtools-toolbox page unless the source was text input.
+ context.shouldDisplay = false;
+ return;
+ }
+
+ // Initialize context to be sent to nsContextMenu
+ // Keep this consistent with the similar code in nsContextMenu's setContext
+ context.bgImageURL = "";
+ context.imageDescURL = "";
+ context.imageInfo = null;
+ context.mediaURL = "";
+ context.webExtBrowserType = "";
+
+ context.canSpellCheck = false;
+ context.hasBGImage = false;
+ context.hasMultipleBGImages = false;
+ context.isDesignMode = false;
+ context.inFrame = false;
+ context.inPDFViewer = false;
+ context.inSrcdocFrame = false;
+ context.inSyntheticDoc = false;
+ context.inTabBrowser = true;
+ context.inWebExtBrowser = false;
+
+ context.link = null;
+ context.linkDownload = "";
+ context.linkProtocol = "";
+ context.linkTextStr = "";
+ context.linkURL = "";
+ context.linkURI = null;
+
+ context.onAudio = false;
+ context.onCanvas = false;
+ context.onCompletedImage = false;
+ context.onDRMMedia = false;
+ context.onPiPVideo = false;
+ context.onEditable = false;
+ context.onImage = false;
+ context.onKeywordField = false;
+ context.onLink = false;
+ context.onLoadedImage = false;
+ context.onMailtoLink = false;
+ context.onTelLink = false;
+ context.onMozExtLink = false;
+ context.onNumeric = false;
+ context.onPassword = false;
+ context.passwordRevealed = false;
+ context.onSaveableLink = false;
+ context.onSpellcheckable = false;
+ context.onTextInput = false;
+ context.onVideo = false;
+ context.inPDFEditor = false;
+
+ // Remember the node and its owner document that was clicked
+ // This may be modifed before sending to nsContextMenu
+ context.target = node;
+ context.targetIdentifier = lazy.ContentDOMReference.get(node);
+
+ context.csp = lazy.E10SUtils.serializeCSP(context.target.ownerDocument.csp);
+
+ // Check if we are in the PDF Viewer.
+ context.inPDFViewer =
+ context.target.ownerDocument.nodePrincipal.originNoSuffix ==
+ "resource://pdf.js";
+ if (context.inPDFViewer) {
+ context.pdfEditorStates = context.target.ownerDocument.editorStates;
+ context.inPDFEditor = !!context.pdfEditorStates?.isEditing;
+ }
+
+ // Check if we are in a synthetic document (stand alone image, video, etc.).
+ context.inSyntheticDoc = context.target.ownerDocument.mozSyntheticDocument;
+
+ context.shouldInitInlineSpellCheckerUINoChildren = false;
+ context.shouldInitInlineSpellCheckerUIWithChildren = false;
+
+ this._setContextForNodesNoChildren(editFlags);
+ this._setContextForNodesWithChildren(editFlags);
+
+ this.lastMenuTarget = {
+ // Remember the node for extensions.
+ targetRef: Cu.getWeakReference(node),
+ // The timestamp is used to verify that the target wasn't changed since the observed menu event.
+ timeStamp: context.timeStamp,
+ };
+
+ if (isAboutDevtoolsToolbox) {
+ // Setup the menu items on text input in about:devtools-toolbox.
+ context.inAboutDevtoolsToolbox = true;
+ context.canSpellCheck = false;
+ context.inTabBrowser = false;
+ context.inFrame = false;
+ context.inSrcdocFrame = false;
+ context.onSpellcheckable = false;
+ }
+ }
+
+ /**
+ * Sets up the parts of the context menu for when when nodes have no children.
+ *
+ * @param {Integer} editFlags The edit flags for the node. See SpellCheckHelper
+ * for the details.
+ */
+ _setContextForNodesNoChildren(editFlags) {
+ const context = this.context;
+
+ if (context.target.nodeType == context.target.TEXT_NODE) {
+ // For text nodes, look at the parent node to determine the spellcheck attribute.
+ context.canSpellCheck =
+ context.target.parentNode && this._isSpellCheckEnabled(context.target);
+ return;
+ }
+
+ // We only deal with TEXT_NODE and ELEMENT_NODE in this function, so return
+ // early if we don't have one.
+ if (context.target.nodeType != context.target.ELEMENT_NODE) {
+ return;
+ }
+
+ // See if the user clicked on an image. This check mirrors
+ // nsDocumentViewer::GetInImage. Make sure to update both if this is
+ // changed.
+ if (
+ context.target instanceof Ci.nsIImageLoadingContent &&
+ (context.target.currentRequestFinalURI || context.target.currentURI)
+ ) {
+ context.onImage = true;
+
+ context.imageInfo = {
+ currentSrc: context.target.currentSrc,
+ width: context.target.width,
+ height: context.target.height,
+ imageText: this.contentWindow.ImageDocument.isInstance(
+ context.target.ownerDocument
+ )
+ ? undefined
+ : context.target.title || context.target.alt,
+ };
+ const { SVGAnimatedLength } = context.target.ownerGlobal;
+ if (SVGAnimatedLength.isInstance(context.imageInfo.height)) {
+ context.imageInfo.height = context.imageInfo.height.animVal.value;
+ }
+ if (SVGAnimatedLength.isInstance(context.imageInfo.width)) {
+ context.imageInfo.width = context.imageInfo.width.animVal.value;
+ }
+
+ const request = context.target.getRequest(
+ Ci.nsIImageLoadingContent.CURRENT_REQUEST
+ );
+
+ if (request && request.imageStatus & request.STATUS_SIZE_AVAILABLE) {
+ context.onLoadedImage = true;
+ }
+
+ if (
+ request &&
+ request.imageStatus & request.STATUS_LOAD_COMPLETE &&
+ !(request.imageStatus & request.STATUS_ERROR)
+ ) {
+ context.onCompletedImage = true;
+ }
+
+ // The URL of the image before redirects is the currentURI. This is
+ // intended to be used for "Copy Image Link".
+ context.originalMediaURL = (() => {
+ let currentURI = context.target.currentURI?.spec;
+ if (currentURI && this._isMediaURLReusable(currentURI)) {
+ return currentURI;
+ }
+ return "";
+ })();
+
+ // The actual URL the image was loaded from (after redirects) is the
+ // currentRequestFinalURI. We should use that as the URL for purposes of
+ // deciding on the filename, if it is present. It might not be present
+ // if images are blocked.
+ //
+ // It is important to check both the final and the current URI, as they
+ // could be different blob URIs, see bug 1625786.
+ context.mediaURL = (() => {
+ let finalURI = context.target.currentRequestFinalURI?.spec;
+ if (finalURI && this._isMediaURLReusable(finalURI)) {
+ return finalURI;
+ }
+ let currentURI = context.target.currentURI?.spec;
+ if (currentURI && this._isMediaURLReusable(currentURI)) {
+ return currentURI;
+ }
+ return "";
+ })();
+
+ const descURL = context.target.getAttribute("longdesc");
+
+ if (descURL) {
+ context.imageDescURL = this._makeURLAbsolute(
+ context.target.ownerDocument.body.baseURI,
+ descURL
+ );
+ }
+ } else if (
+ this.contentWindow.HTMLCanvasElement.isInstance(context.target)
+ ) {
+ context.onCanvas = true;
+ } else if (this.contentWindow.HTMLVideoElement.isInstance(context.target)) {
+ const mediaURL = context.target.currentSrc || context.target.src;
+
+ if (this._isMediaURLReusable(mediaURL)) {
+ context.mediaURL = mediaURL;
+ }
+
+ if (this._isProprietaryDRM()) {
+ context.onDRMMedia = true;
+ }
+
+ if (context.target.isCloningElementVisually) {
+ context.onPiPVideo = true;
+ }
+
+ // Firefox always creates a HTMLVideoElement when loading an ogg file
+ // directly. If the media is actually audio, be smarter and provide a
+ // context menu with audio operations.
+ if (
+ context.target.readyState >= context.target.HAVE_METADATA &&
+ (context.target.videoWidth == 0 || context.target.videoHeight == 0)
+ ) {
+ context.onAudio = true;
+ } else {
+ context.onVideo = true;
+ }
+ } else if (this.contentWindow.HTMLAudioElement.isInstance(context.target)) {
+ context.onAudio = true;
+ const mediaURL = context.target.currentSrc || context.target.src;
+
+ if (this._isMediaURLReusable(mediaURL)) {
+ context.mediaURL = mediaURL;
+ }
+
+ if (this._isProprietaryDRM()) {
+ context.onDRMMedia = true;
+ }
+ } else if (
+ editFlags &
+ (lazy.SpellCheckHelper.INPUT | lazy.SpellCheckHelper.TEXTAREA)
+ ) {
+ context.onTextInput = (editFlags & lazy.SpellCheckHelper.TEXTINPUT) !== 0;
+ context.onNumeric = (editFlags & lazy.SpellCheckHelper.NUMERIC) !== 0;
+ context.onEditable = (editFlags & lazy.SpellCheckHelper.EDITABLE) !== 0;
+ context.onPassword = (editFlags & lazy.SpellCheckHelper.PASSWORD) !== 0;
+
+ context.showRelay =
+ HTMLInputElement.isInstance(context.target) &&
+ !context.target.disabled &&
+ !context.target.readOnly &&
+ (lazy.LoginHelper.isInferredEmailField(context.target) ||
+ lazy.LoginHelper.isInferredUsernameField(context.target));
+ context.isDesignMode =
+ (editFlags & lazy.SpellCheckHelper.CONTENTEDITABLE) !== 0;
+ context.passwordRevealed =
+ context.onPassword && context.target.revealPassword;
+ context.onSpellcheckable =
+ (editFlags & lazy.SpellCheckHelper.SPELLCHECKABLE) !== 0;
+
+ // This is guaranteed to be an input or textarea because of the condition above,
+ // so the no-children flag is always correct. We deal with contenteditable elsewhere.
+ if (context.onSpellcheckable) {
+ context.shouldInitInlineSpellCheckerUINoChildren = true;
+ }
+
+ context.onKeywordField = editFlags & lazy.SpellCheckHelper.KEYWORD;
+ } else if (this.contentWindow.HTMLHtmlElement.isInstance(context.target)) {
+ const bodyElt = context.target.ownerDocument.body;
+
+ if (bodyElt) {
+ let computedURL;
+
+ try {
+ computedURL = this._getComputedURL(bodyElt, "background-image");
+ context.hasMultipleBGImages = false;
+ } catch (e) {
+ context.hasMultipleBGImages = true;
+ }
+
+ if (computedURL) {
+ context.hasBGImage = true;
+ context.bgImageURL = this._makeURLAbsolute(
+ bodyElt.baseURI,
+ computedURL
+ );
+ }
+ }
+ }
+
+ context.canSpellCheck = this._isSpellCheckEnabled(context.target);
+ }
+
+ /**
+ * Sets up the parts of the context menu for when when nodes have children.
+ *
+ * @param {Integer} editFlags The edit flags for the node. See SpellCheckHelper
+ * for the details.
+ */
+ _setContextForNodesWithChildren(editFlags) {
+ const context = this.context;
+
+ // Second, bubble out, looking for items of interest that can have childen.
+ // Always pick the innermost link, background image, etc.
+ let elem = context.target;
+
+ while (elem) {
+ if (elem.nodeType == elem.ELEMENT_NODE) {
+ // Link?
+ const XLINK_NS = "http://www.w3.org/1999/xlink";
+
+ if (
+ !context.onLink &&
+ // Be consistent with what hrefAndLinkNodeForClickEvent
+ // does in browser.js
+ (this._isXULTextLinkLabel(elem) ||
+ (this.contentWindow.HTMLAnchorElement.isInstance(elem) &&
+ elem.href) ||
+ (this.contentWindow.SVGAElement.isInstance(elem) &&
+ (elem.href || elem.hasAttributeNS(XLINK_NS, "href"))) ||
+ (this.contentWindow.HTMLAreaElement.isInstance(elem) &&
+ elem.href) ||
+ this.contentWindow.HTMLLinkElement.isInstance(elem) ||
+ elem.getAttributeNS(XLINK_NS, "type") == "simple")
+ ) {
+ // Target is a link or a descendant of a link.
+ context.onLink = true;
+
+ // Remember corresponding element.
+ context.link = elem;
+ context.linkURL = this._getLinkURL();
+ context.linkURI = this._getLinkURI();
+ context.linkTextStr = this._getLinkText();
+ context.linkProtocol = this._getLinkProtocol();
+ context.onMailtoLink = context.linkProtocol == "mailto";
+ context.onTelLink = context.linkProtocol == "tel";
+ context.onMozExtLink = context.linkProtocol == "moz-extension";
+ context.onSaveableLink = this._isLinkSaveable(context.link);
+
+ context.isSponsoredLink =
+ (elem.ownerDocument.URL === "about:newtab" ||
+ elem.ownerDocument.URL === "about:home") &&
+ elem.dataset.isSponsoredLink === "true";
+
+ try {
+ if (elem.download) {
+ // Ignore download attribute on cross-origin links
+ context.target.ownerDocument.nodePrincipal.checkMayLoad(
+ context.linkURI,
+ true
+ );
+ context.linkDownload = elem.download;
+ }
+ } catch (ex) {}
+ }
+
+ // Background image? Don't bother if we've already found a
+ // background image further down the hierarchy. Otherwise,
+ // we look for the computed background-image style.
+ if (!context.hasBGImage && !context.hasMultipleBGImages) {
+ let bgImgUrl = null;
+
+ try {
+ bgImgUrl = this._getComputedURL(elem, "background-image");
+ context.hasMultipleBGImages = false;
+ } catch (e) {
+ context.hasMultipleBGImages = true;
+ }
+
+ if (bgImgUrl) {
+ context.hasBGImage = true;
+ context.bgImageURL = this._makeURLAbsolute(elem.baseURI, bgImgUrl);
+ }
+ }
+ }
+
+ elem = elem.flattenedTreeParentNode;
+ }
+
+ // See if the user clicked in a frame.
+ const docDefaultView = context.target.ownerGlobal;
+
+ if (docDefaultView != docDefaultView.top) {
+ context.inFrame = true;
+
+ if (context.target.ownerDocument.isSrcdocDocument) {
+ context.inSrcdocFrame = true;
+ }
+ }
+
+ // if the document is editable, show context menu like in text inputs
+ if (!context.onEditable) {
+ if (editFlags & lazy.SpellCheckHelper.CONTENTEDITABLE) {
+ // If this.onEditable is false but editFlags is CONTENTEDITABLE, then
+ // the document itself must be editable.
+ context.onTextInput = true;
+ context.onKeywordField = false;
+ context.onImage = false;
+ context.onLoadedImage = false;
+ context.onCompletedImage = false;
+ context.inFrame = false;
+ context.inSrcdocFrame = false;
+ context.hasBGImage = false;
+ context.isDesignMode = true;
+ context.onEditable = true;
+ context.onSpellcheckable = true;
+ context.shouldInitInlineSpellCheckerUIWithChildren = true;
+ }
+ }
+ }
+
+ _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/browser/actors/ContextMenuParent.sys.mjs b/browser/actors/ContextMenuParent.sys.mjs
new file mode 100644
index 0000000000..4c67bc75ef
--- /dev/null
+++ b/browser/actors/ContextMenuParent.sys.mjs
@@ -0,0 +1,117 @@
+/* vim: set ts=2 sw=2 sts=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ FirefoxRelay: "resource://gre/modules/FirefoxRelay.sys.mjs",
+});
+
+export class ContextMenuParent extends JSWindowActorParent {
+ receiveMessage(message) {
+ let browser = this.manager.rootFrameLoader.ownerElement;
+ let win = browser.ownerGlobal;
+ // It's possible that the <xul:browser> associated with this
+ // ContextMenu message doesn't belong to a window that actually
+ // loads nsContextMenu.js. In that case, try to find the chromeEventHandler,
+ // since that'll likely be the "top" <xul:browser>, and then use its window's
+ // nsContextMenu instance instead.
+ if (!win.openContextMenu) {
+ let topBrowser = browser.ownerGlobal.docShell.chromeEventHandler;
+ win = topBrowser.ownerGlobal;
+ }
+
+ message.data.context.showRelay &&= lazy.FirefoxRelay.isEnabled;
+
+ win.openContextMenu(message, browser, this);
+ }
+
+ hiding() {
+ try {
+ this.sendAsyncMessage("ContextMenu:Hiding", {});
+ } catch (e) {
+ // This will throw if the content goes away while the
+ // context menu is still open.
+ }
+ }
+
+ reloadFrame(targetIdentifier, forceReload) {
+ this.sendAsyncMessage("ContextMenu:ReloadFrame", {
+ targetIdentifier,
+ forceReload,
+ });
+ }
+
+ getImageText(targetIdentifier) {
+ return this.sendQuery("ContextMenu:GetImageText", {
+ targetIdentifier,
+ });
+ }
+
+ toggleRevealPassword(targetIdentifier) {
+ this.sendAsyncMessage("ContextMenu:ToggleRevealPassword", {
+ targetIdentifier,
+ });
+ }
+
+ async useRelayMask(targetIdentifier, origin) {
+ if (!origin) {
+ return;
+ }
+
+ const windowGlobal = this.manager.browsingContext.currentWindowGlobal;
+ const browser = windowGlobal.rootFrameLoader.ownerElement;
+ const emailMask = await lazy.FirefoxRelay.generateUsername(browser, origin);
+ if (emailMask) {
+ this.sendAsyncMessage("ContextMenu:UseRelayMask", {
+ targetIdentifier,
+ emailMask,
+ });
+ }
+ }
+
+ reloadImage(targetIdentifier) {
+ this.sendAsyncMessage("ContextMenu:ReloadImage", { targetIdentifier });
+ }
+
+ getFrameTitle(targetIdentifier) {
+ return this.sendQuery("ContextMenu:GetFrameTitle", { targetIdentifier });
+ }
+
+ mediaCommand(targetIdentifier, command, data) {
+ let windowGlobal = this.manager.browsingContext.currentWindowGlobal;
+ let browser = windowGlobal.rootFrameLoader.ownerElement;
+ let win = browser.ownerGlobal;
+ let windowUtils = win.windowUtils;
+ this.sendAsyncMessage("ContextMenu:MediaCommand", {
+ targetIdentifier,
+ command,
+ data,
+ handlingUserInput: windowUtils.isHandlingUserInput,
+ });
+ }
+
+ canvasToBlobURL(targetIdentifier) {
+ return this.sendQuery("ContextMenu:Canvas:ToBlobURL", { targetIdentifier });
+ }
+
+ saveVideoFrameAsImage(targetIdentifier) {
+ return this.sendQuery("ContextMenu:SaveVideoFrameAsImage", {
+ targetIdentifier,
+ });
+ }
+
+ setAsDesktopBackground(targetIdentifier) {
+ return this.sendQuery("ContextMenu:SetAsDesktopBackground", {
+ targetIdentifier,
+ });
+ }
+
+ getSearchFieldBookmarkData(targetIdentifier) {
+ return this.sendQuery("ContextMenu:SearchFieldBookmarkData", {
+ targetIdentifier,
+ });
+ }
+}
diff --git a/browser/actors/DOMFullscreenChild.sys.mjs b/browser/actors/DOMFullscreenChild.sys.mjs
new file mode 100644
index 0000000000..4088def44b
--- /dev/null
+++ b/browser/actors/DOMFullscreenChild.sys.mjs
@@ -0,0 +1,164 @@
+/* vim: set ts=2 sw=2 sts=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export class DOMFullscreenChild extends JSWindowActorChild {
+ receiveMessage(aMessage) {
+ let window = this.contentWindow;
+ let windowUtils = window?.windowUtils;
+
+ switch (aMessage.name) {
+ case "DOMFullscreen:Entered": {
+ if (!windowUtils) {
+ // If we are not able to enter fullscreen, tell the parent to just
+ // exit.
+ this.sendAsyncMessage("DOMFullscreen:Exit", {});
+ break;
+ }
+
+ let remoteFrameBC = aMessage.data.remoteFrameBC;
+ if (remoteFrameBC) {
+ let remoteFrame = remoteFrameBC.embedderElement;
+ if (!remoteFrame) {
+ // This could happen when the page navigate away and trigger a
+ // process switching during fullscreen transition, tell the parent
+ // to just exit.
+ this.sendAsyncMessage("DOMFullscreen:Exit", {});
+ break;
+ }
+ this._isNotTheRequestSource = true;
+ windowUtils.remoteFrameFullscreenChanged(remoteFrame);
+ } else {
+ this._waitForMozAfterPaint = true;
+ this._lastTransactionId = windowUtils.lastTransactionId;
+ if (
+ !windowUtils.handleFullscreenRequests() &&
+ !this.document.fullscreenElement
+ ) {
+ // If we don't actually have any pending fullscreen request
+ // to handle, neither we have been in fullscreen, tell the
+ // parent to just exit.
+ this.sendAsyncMessage("DOMFullscreen:Exit", {});
+ }
+ }
+ break;
+ }
+ case "DOMFullscreen:CleanUp": {
+ let isNotTheRequestSource = !!aMessage.data.remoteFrameBC;
+ // If we've exited fullscreen at this point, no need to record
+ // transaction id or call exit fullscreen. This is especially
+ // important for pre-e10s, since in that case, it is possible
+ // that no more paint would be triggered after this point.
+ if (this.document.fullscreenElement) {
+ this._isNotTheRequestSource = isNotTheRequestSource;
+ // Need to wait for the MozAfterPaint after exiting fullscreen if
+ // this is the request source.
+ this._waitForMozAfterPaint = !this._isNotTheRequestSource;
+ // windowUtils could be null if the associated window is not current
+ // active window. In this case, document must be in the process of
+ // exiting fullscreen, it is okay to not ask it to exit fullscreen.
+ if (windowUtils) {
+ this._lastTransactionId = windowUtils.lastTransactionId;
+ windowUtils.exitFullscreen();
+ }
+ } else if (isNotTheRequestSource) {
+ // If we are not the request source and have exited fullscreen, reply
+ // Exited to parent as parent is waiting for our reply.
+ this.sendAsyncMessage("DOMFullscreen:Exited", {});
+ } else {
+ // If we've already exited fullscreen, it is possible that no more
+ // paint would be triggered, so don't wait for MozAfterPaint.
+ // TODO: There might be some way to move this code around a bit to
+ // make it easier to follow. Somehow handle the "local" case in
+ // one place and the isNotTheRequestSource case after that.
+ this.sendAsyncMessage("DOMFullscreen:Painted", {});
+ }
+ break;
+ }
+ case "DOMFullscreen:Painted": {
+ Services.obs.notifyObservers(window, "fullscreen-painted");
+ break;
+ }
+ }
+ }
+
+ handleEvent(aEvent) {
+ if (this.hasBeenDestroyed()) {
+ // Make sure that this actor is alive before going further because
+ // if it's not the case, any attempt to send a message or access
+ // objects such as 'contentWindow' will fail. (See bug 1590138)
+ return;
+ }
+
+ switch (aEvent.type) {
+ case "MozDOMFullscreen:Request": {
+ this.sendAsyncMessage("DOMFullscreen:Request", {});
+ break;
+ }
+ case "MozDOMFullscreen:NewOrigin": {
+ this.sendAsyncMessage("DOMFullscreen:NewOrigin", {
+ originNoSuffix: aEvent.target.nodePrincipal.originNoSuffix,
+ });
+ break;
+ }
+ case "MozDOMFullscreen:Exit": {
+ this.sendAsyncMessage("DOMFullscreen:Exit", {});
+ break;
+ }
+ case "MozDOMFullscreen:Entered":
+ case "MozDOMFullscreen:Exited": {
+ if (this._isNotTheRequestSource) {
+ // Fullscreen change event for a frame in the
+ // middle (content frame embedding the oop frame where the
+ // request comes from)
+
+ delete this._isNotTheRequestSource;
+ this.sendAsyncMessage(aEvent.type.replace("Moz", ""), {});
+ break;
+ }
+
+ if (this._waitForMozAfterPaint) {
+ delete this._waitForMozAfterPaint;
+ this._listeningWindow = this.contentWindow.windowRoot;
+ this._listeningWindow.addEventListener("MozAfterPaint", this);
+ }
+
+ if (!this.document || !this.document.fullscreenElement) {
+ // If we receive any fullscreen change event, and find we are
+ // actually not in fullscreen, also ask the parent to exit to
+ // ensure that the parent always exits fullscreen when we do.
+ this.sendAsyncMessage("DOMFullscreen:Exit", {});
+ }
+ break;
+ }
+ case "MozAfterPaint": {
+ // Only send Painted signal after we actually finish painting
+ // the transition for the fullscreen change.
+ // Note that this._lastTransactionId is not set when in pre-e10s
+ // mode, so we need to check that explicitly.
+ if (
+ !this._lastTransactionId ||
+ aEvent.transactionId > this._lastTransactionId
+ ) {
+ this._listeningWindow.removeEventListener("MozAfterPaint", this);
+ delete this._listeningWindow;
+ this.sendAsyncMessage("DOMFullscreen:Painted", {});
+ }
+ break;
+ }
+ }
+ }
+
+ hasBeenDestroyed() {
+ // The 'didDestroy' callback is not always getting called.
+ // So we can't rely on it here. Instead, we will try to access
+ // the browsing context to judge wether the actor has
+ // been destroyed or not.
+ try {
+ return !this.browsingContext;
+ } catch {
+ return true;
+ }
+ }
+}
diff --git a/browser/actors/DOMFullscreenParent.sys.mjs b/browser/actors/DOMFullscreenParent.sys.mjs
new file mode 100644
index 0000000000..7c481c1051
--- /dev/null
+++ b/browser/actors/DOMFullscreenParent.sys.mjs
@@ -0,0 +1,318 @@
+/* vim: set ts=2 sw=2 sts=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export class DOMFullscreenParent extends JSWindowActorParent {
+ // These properties get set by browser-fullScreenAndPointerLock.js.
+ // TODO: Bug 1743703 - Consider moving the messaging component of
+ // browser-fullScreenAndPointerLock.js into the actor
+ waitingForChildEnterFullscreen = false;
+ waitingForChildExitFullscreen = false;
+ // Cache the next message recipient actor and in-process browsing context that
+ // is computed by _getNextMsgRecipientActor() of
+ // browser-fullScreenAndPointerLock.js, this is used to ensure the fullscreen
+ // cleanup messages goes the same route as fullscreen request, especially for
+ // the cleanup that happens after actor is destroyed.
+ // TODO: Bug 1743703 - Consider moving the messaging component of
+ // browser-fullScreenAndPointerLock.js into the actor
+ nextMsgRecipient = null;
+
+ updateFullscreenWindowReference(aWindow) {
+ if (aWindow.document.documentElement.hasAttribute("inDOMFullscreen")) {
+ this._fullscreenWindow = aWindow;
+ } else {
+ delete this._fullscreenWindow;
+ }
+ }
+
+ cleanupDomFullscreen(aWindow) {
+ if (!aWindow.FullScreen) {
+ return;
+ }
+
+ // If we don't need to wait for child reply, i.e. cleanupDomFullscreen
+ // doesn't message to child, and we've exit the fullscreen, there won't be
+ // DOMFullscreen:Painted message from child and it is possible that no more
+ // paint would be triggered, so just notify fullscreen-painted observer.
+ if (
+ !aWindow.FullScreen.cleanupDomFullscreen(this) &&
+ !aWindow.document.fullscreen
+ ) {
+ Services.obs.notifyObservers(aWindow, "fullscreen-painted");
+ }
+ }
+
+ /**
+ * Clean up fullscreen state and resume chrome UI if window is in fullscreen
+ * and this actor is the one where the original fullscreen enter or
+ * exit request comes.
+ */
+ _cleanupFullscreenStateAndResumeChromeUI(aWindow) {
+ this.cleanupDomFullscreen(aWindow);
+ if (this.requestOrigin == this && aWindow.document.fullscreen) {
+ aWindow.windowUtils.remoteFrameFullscreenReverted();
+ }
+ }
+
+ didDestroy() {
+ this._didDestroy = true;
+
+ let window = this._fullscreenWindow;
+ if (!window) {
+ let topBrowsingContext = this.browsingContext.top;
+ let browser = topBrowsingContext.embedderElement;
+ if (!browser) {
+ return;
+ }
+
+ if (
+ this.waitingForChildExitFullscreen ||
+ this.waitingForChildEnterFullscreen
+ ) {
+ this.waitingForChildExitFullscreen = false;
+ this.waitingForChildEnterFullscreen = false;
+ // We were destroyed while waiting for our DOMFullscreenChild to exit
+ // or enter fullscreen, run cleanup steps anyway.
+ this._cleanupFullscreenStateAndResumeChromeUI(browser.ownerGlobal);
+ }
+
+ if (this != this.requestOrigin) {
+ // The current fullscreen requester should handle the fullsceen event
+ // if any.
+ this.removeListeners(browser.ownerGlobal);
+ }
+ return;
+ }
+
+ if (this.waitingForChildEnterFullscreen) {
+ this.waitingForChildEnterFullscreen = false;
+ if (window.document.fullscreen) {
+ // We were destroyed while waiting for our DOMFullscreenChild
+ // to transition to fullscreen so we abort the entire
+ // fullscreen transition to prevent getting stuck in a
+ // partial fullscreen state. We need to go through the
+ // document since window.Fullscreen could be undefined
+ // at this point.
+ //
+ // This could reject if we're not currently in fullscreen
+ // so just ignore rejection.
+ window.document.exitFullscreen().catch(() => {});
+ return;
+ }
+ this.cleanupDomFullscreen(window);
+ }
+
+ // Need to resume Chrome UI if the window is still in fullscreen UI
+ // to avoid the window stays in fullscreen problem. (See Bug 1620341)
+ if (window.document.documentElement.hasAttribute("inDOMFullscreen")) {
+ this.cleanupDomFullscreen(window);
+ if (window.windowUtils) {
+ window.windowUtils.remoteFrameFullscreenReverted();
+ }
+ } else if (this.waitingForChildExitFullscreen) {
+ this.waitingForChildExitFullscreen = false;
+ // We were destroyed while waiting for our DOMFullscreenChild to exit
+ // run cleanup steps anyway.
+ this._cleanupFullscreenStateAndResumeChromeUI(window);
+ }
+ this.updateFullscreenWindowReference(window);
+ }
+
+ receiveMessage(aMessage) {
+ let topBrowsingContext = this.browsingContext.top;
+ let browser = topBrowsingContext.embedderElement;
+
+ if (!browser) {
+ // No need to go further when the browser is not accessible anymore
+ // (which can happen when the tab is closed for instance),
+ return;
+ }
+
+ let window = browser.ownerGlobal;
+ switch (aMessage.name) {
+ case "DOMFullscreen:Request": {
+ this.manager.fullscreen = true;
+ this.waitingForChildExitFullscreen = false;
+ this.requestOrigin = this;
+ this.addListeners(window);
+ window.windowUtils.remoteFrameFullscreenChanged(browser);
+ break;
+ }
+ case "DOMFullscreen:NewOrigin": {
+ // Don't show the warning if we've already exited fullscreen.
+ if (window.document.fullscreen) {
+ window.PointerlockFsWarning.showFullScreen(
+ aMessage.data.originNoSuffix
+ );
+ }
+ this.updateFullscreenWindowReference(window);
+ break;
+ }
+ case "DOMFullscreen:Entered": {
+ this.manager.fullscreen = true;
+ this.nextMsgRecipient = null;
+ this.waitingForChildEnterFullscreen = false;
+ window.FullScreen.enterDomFullscreen(browser, this);
+ this.updateFullscreenWindowReference(window);
+ break;
+ }
+ case "DOMFullscreen:Exit": {
+ this.manager.fullscreen = false;
+ this.waitingForChildEnterFullscreen = false;
+ window.windowUtils.remoteFrameFullscreenReverted();
+ break;
+ }
+ case "DOMFullscreen:Exited": {
+ this.manager.fullscreen = false;
+ this.waitingForChildExitFullscreen = false;
+ this.cleanupDomFullscreen(window);
+ this.updateFullscreenWindowReference(window);
+ break;
+ }
+ case "DOMFullscreen:Painted": {
+ this.waitingForChildExitFullscreen = false;
+ Services.obs.notifyObservers(window, "fullscreen-painted");
+ this.sendAsyncMessage("DOMFullscreen:Painted", {});
+ TelemetryStopwatch.finish("FULLSCREEN_CHANGE_MS");
+ break;
+ }
+ }
+ }
+
+ handleEvent(aEvent) {
+ let window = aEvent.currentTarget.ownerGlobal;
+ // We can not get the corresponding browsing context from actor if the actor
+ // has already destroyed, so use event target to get browsing context
+ // instead.
+ let requestOrigin = window.browsingContext.fullscreenRequestOrigin?.get();
+ if (this != requestOrigin) {
+ // The current fullscreen requester should handle the fullsceen event,
+ // ignore them if we are not the current requester.
+ this.removeListeners(window);
+ return;
+ }
+
+ switch (aEvent.type) {
+ case "MozDOMFullscreen:Entered": {
+ // The event target is the element which requested the DOM
+ // fullscreen. If we were entering DOM fullscreen for a remote
+ // browser, the target would be the browser which was the parameter of
+ // `remoteFrameFullscreenChanged` call. If the fullscreen
+ // request was initiated from an in-process browser, we need
+ // to get its corresponding browser here.
+ let browser;
+ if (aEvent.target.ownerGlobal == window) {
+ browser = aEvent.target;
+ } else {
+ browser = aEvent.target.ownerGlobal.docShell.chromeEventHandler;
+ }
+
+ // Addon installation should be cancelled when entering fullscreen for security and usability reasons.
+ // Installation prompts in fullscreen can trick the user into installing unwanted addons.
+ // In fullscreen the notification box does not have a clear visual association with its parent anymore.
+ if (window.gXPInstallObserver) {
+ window.gXPInstallObserver.removeAllNotifications(browser);
+ }
+
+ TelemetryStopwatch.start("FULLSCREEN_CHANGE_MS");
+ window.FullScreen.enterDomFullscreen(browser, this);
+ this.updateFullscreenWindowReference(window);
+
+ if (!this.hasBeenDestroyed() && this.requestOrigin) {
+ window.PointerlockFsWarning.showFullScreen(
+ this.requestOrigin.manager.documentPrincipal.originNoSuffix
+ );
+ }
+ break;
+ }
+ case "MozDOMFullscreen:Exited": {
+ TelemetryStopwatch.start("FULLSCREEN_CHANGE_MS");
+
+ // Make sure that the actor has not been destroyed before
+ // accessing its browsing context. Otherwise, a error may
+ // occur and hence cleanupDomFullscreen not executed, resulting
+ // in the browser window being in an unstable state.
+ // (Bug 1590138).
+ if (!this.hasBeenDestroyed() && !this.requestOrigin) {
+ this.requestOrigin = this;
+ }
+ this.cleanupDomFullscreen(window);
+ this.updateFullscreenWindowReference(window);
+
+ // If the document is supposed to be in fullscreen, keep the listener to wait for
+ // further events.
+ if (!this.manager.fullscreen) {
+ this.removeListeners(window);
+ }
+ break;
+ }
+ }
+ }
+
+ addListeners(aWindow) {
+ aWindow.addEventListener(
+ "MozDOMFullscreen:Entered",
+ this,
+ /* useCapture */ true,
+ /* wantsUntrusted */
+ false
+ );
+ aWindow.addEventListener(
+ "MozDOMFullscreen:Exited",
+ this,
+ /* useCapture */ true,
+ /* wantsUntrusted */ false
+ );
+ }
+
+ removeListeners(aWindow) {
+ aWindow.removeEventListener("MozDOMFullscreen:Entered", this, true);
+ aWindow.removeEventListener("MozDOMFullscreen:Exited", this, true);
+ }
+
+ /**
+ * Get the actor where the original fullscreen
+ * enter or exit request comes from.
+ */
+ get requestOrigin() {
+ let chromeBC = this.browsingContext.topChromeWindow?.browsingContext;
+ let requestOrigin = chromeBC?.fullscreenRequestOrigin;
+ return requestOrigin && requestOrigin.get();
+ }
+
+ /**
+ * Store the actor where the original fullscreen
+ * enter or exit request comes from in the top level
+ * browsing context.
+ */
+ set requestOrigin(aActor) {
+ let chromeBC = this.browsingContext.topChromeWindow?.browsingContext;
+ if (!chromeBC) {
+ console.error("not able to get browsingContext for chrome window.");
+ return;
+ }
+
+ if (aActor) {
+ chromeBC.fullscreenRequestOrigin = Cu.getWeakReference(aActor);
+ } else {
+ delete chromeBC.fullscreenRequestOrigin;
+ }
+ }
+
+ hasBeenDestroyed() {
+ if (this._didDestroy) {
+ return true;
+ }
+
+ // The 'didDestroy' callback is not always getting called.
+ // So we can't rely on it here. Instead, we will try to access
+ // the browsing context to judge wether the actor has
+ // been destroyed or not.
+ try {
+ return !this.browsingContext;
+ } catch {
+ return true;
+ }
+ }
+}
diff --git a/browser/actors/DecoderDoctorChild.sys.mjs b/browser/actors/DecoderDoctorChild.sys.mjs
new file mode 100644
index 0000000000..d85be3656e
--- /dev/null
+++ b/browser/actors/DecoderDoctorChild.sys.mjs
@@ -0,0 +1,28 @@
+/* -*- mode: js; indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 sw=2 sts=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export class DecoderDoctorChild extends JSWindowActorChild {
+ // Observes 'decoder-doctor-notification'. This actor handles most such notifications, but does not deal with notifications with the 'cannot-play' type, which is handled
+ // @param aSubject the nsPIDOMWindowInner associated with the notification.
+ // @param aTopic should be "decoder-doctor-notification".
+ // @param aData json data that contains analysis information from Decoder Doctor:
+ // - 'type' is the type of issue, it determines which text to show in the
+ // infobar.
+ // - 'isSolved' is true when the notification actually indicates the
+ // resolution of that issue, to be reported as telemetry.
+ // - 'decoderDoctorReportId' is the Decoder Doctor issue identifier, to be
+ // used here as key for the telemetry (counting infobar displays,
+ // "Learn how" buttons clicks, and resolutions) and for the prefs used
+ // to store at-issue formats.
+ // - 'formats' contains a comma-separated list of formats (or key systems)
+ // that suffer the issue. These are kept in a pref, which the backend
+ // uses to later find when an issue is resolved.
+ // - 'decodeIssue' is a description of the decode error/warning.
+ // - 'resourceURL' is the resource with the issue.
+ observe(aSubject, aTopic, aData) {
+ this.sendAsyncMessage("DecoderDoctor:Notification", aData);
+ }
+}
diff --git a/browser/actors/DecoderDoctorParent.sys.mjs b/browser/actors/DecoderDoctorParent.sys.mjs
new file mode 100644
index 0000000000..4973a309af
--- /dev/null
+++ b/browser/actors/DecoderDoctorParent.sys.mjs
@@ -0,0 +1,272 @@
+/* -*- mode: js; indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 sw=2 sts=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineLazyGetter(lazy, "gNavigatorBundle", function () {
+ return Services.strings.createBundle(
+ "chrome://browser/locale/browser.properties"
+ );
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "DEBUG_LOG",
+ "media.decoder-doctor.testing",
+ false
+);
+
+function LOG_DD(message) {
+ if (lazy.DEBUG_LOG) {
+ dump("[DecoderDoctorParent] " + message + "\n");
+ }
+}
+
+export class DecoderDoctorParent extends JSWindowActorParent {
+ getLabelForNotificationBox({ type, decoderDoctorReportId }) {
+ if (type == "platform-decoder-not-found") {
+ if (decoderDoctorReportId == "MediaWMFNeeded") {
+ return lazy.gNavigatorBundle.GetStringFromName(
+ "decoder.noHWAcceleration.message"
+ );
+ }
+ // Although this name seems generic, this is actually for not being able
+ // to find libavcodec on Linux.
+ if (decoderDoctorReportId == "MediaPlatformDecoderNotFound") {
+ return lazy.gNavigatorBundle.GetStringFromName(
+ "decoder.noCodecsLinux.message"
+ );
+ }
+ }
+ if (type == "cannot-initialize-pulseaudio") {
+ return lazy.gNavigatorBundle.GetStringFromName(
+ "decoder.noPulseAudio.message"
+ );
+ }
+ if (type == "unsupported-libavcodec" && AppConstants.platform == "linux") {
+ return lazy.gNavigatorBundle.GetStringFromName(
+ "decoder.unsupportedLibavcodec.message"
+ );
+ }
+ if (type == "decode-error") {
+ return lazy.gNavigatorBundle.GetStringFromName(
+ "decoder.decodeError.message"
+ );
+ }
+ if (type == "decode-warning") {
+ return lazy.gNavigatorBundle.GetStringFromName(
+ "decoder.decodeWarning.message"
+ );
+ }
+ return "";
+ }
+
+ getSumoForLearnHowButton({ type, decoderDoctorReportId }) {
+ if (
+ type == "platform-decoder-not-found" &&
+ decoderDoctorReportId == "MediaWMFNeeded"
+ ) {
+ return "fix-video-audio-problems-firefox-windows";
+ }
+ if (type == "cannot-initialize-pulseaudio") {
+ return "fix-common-audio-and-video-issues";
+ }
+ return "";
+ }
+
+ getEndpointForReportIssueButton(type) {
+ if (type == "decode-error" || type == "decode-warning") {
+ return Services.prefs.getStringPref(
+ "media.decoder-doctor.new-issue-endpoint",
+ ""
+ );
+ }
+ return "";
+ }
+
+ receiveMessage(aMessage) {
+ // The top level browsing context's embedding element should be a xul browser element.
+ let browser = this.browsingContext.top.embedderElement;
+ // The xul browser is owned by a window.
+ let window = browser?.ownerGlobal;
+
+ if (!browser || !window) {
+ // We don't have a browser or window so bail!
+ return;
+ }
+
+ let box = browser.getTabBrowser().getNotificationBox(browser);
+ let notificationId = "decoder-doctor-notification";
+ if (box.getNotificationWithValue(notificationId)) {
+ // We already have a notification showing, bail.
+ return;
+ }
+
+ let parsedData;
+ try {
+ parsedData = JSON.parse(aMessage.data);
+ } catch (ex) {
+ console.error(
+ "Malformed Decoder Doctor message with data: ",
+ aMessage.data
+ );
+ return;
+ }
+ // parsedData (the result of parsing the incoming 'data' json string)
+ // contains analysis information from Decoder Doctor:
+ // - 'type' is the type of issue, it determines which text to show in the
+ // infobar.
+ // - 'isSolved' is true when the notification actually indicates the
+ // resolution of that issue, to be reported as telemetry.
+ // - 'decoderDoctorReportId' is the Decoder Doctor issue identifier, to be
+ // used here as key for the telemetry (counting infobar displays,
+ // "Learn how" buttons clicks, and resolutions) and for the prefs used
+ // to store at-issue formats.
+ // - 'formats' contains a comma-separated list of formats (or key systems)
+ // that suffer the issue. These are kept in a pref, which the backend
+ // uses to later find when an issue is resolved.
+ // - 'decodeIssue' is a description of the decode error/warning.
+ // - 'resourceURL' is the resource with the issue.
+ let {
+ type,
+ isSolved,
+ decoderDoctorReportId,
+ formats,
+ decodeIssue,
+ docURL,
+ resourceURL,
+ } = parsedData;
+ type = type.toLowerCase();
+ // Error out early on invalid ReportId
+ if (!/^\w+$/im.test(decoderDoctorReportId)) {
+ return;
+ }
+ LOG_DD(
+ `type=${type}, isSolved=${isSolved}, ` +
+ `decoderDoctorReportId=${decoderDoctorReportId}, formats=${formats}, ` +
+ `decodeIssue=${decodeIssue}, docURL=${docURL}, ` +
+ `resourceURL=${resourceURL}`
+ );
+ let title = this.getLabelForNotificationBox({
+ type,
+ decoderDoctorReportId,
+ });
+ if (!title) {
+ return;
+ }
+
+ // We keep the list of formats in prefs for the sake of the decoder itself,
+ // which reads it to determine when issues get solved for these formats.
+ // (Writing prefs from e10s content is not allowed.)
+ let formatsPref =
+ formats && "media.decoder-doctor." + decoderDoctorReportId + ".formats";
+ let buttonClickedPref =
+ "media.decoder-doctor." + decoderDoctorReportId + ".button-clicked";
+ let formatsInPref = formats && Services.prefs.getCharPref(formatsPref, "");
+
+ if (!isSolved) {
+ if (formats) {
+ if (!formatsInPref) {
+ Services.prefs.setCharPref(formatsPref, formats);
+ } else {
+ // Split existing formats into an array of strings.
+ let existing = formatsInPref.split(",").map(x => x.trim());
+ // Keep given formats that were not already recorded.
+ let newbies = formats
+ .split(",")
+ .map(x => x.trim())
+ .filter(x => !existing.includes(x));
+ // And rewrite pref with the added new formats (if any).
+ if (newbies.length) {
+ Services.prefs.setCharPref(
+ formatsPref,
+ existing.concat(newbies).join(", ")
+ );
+ }
+ }
+ } else if (!decodeIssue) {
+ console.error(
+ "Malformed Decoder Doctor unsolved message with no formats nor decode issue"
+ );
+ return;
+ }
+
+ let buttons = [];
+ let sumo = this.getSumoForLearnHowButton({ type, decoderDoctorReportId });
+ if (sumo) {
+ LOG_DD(`sumo=${sumo}`);
+ buttons.push({
+ label: lazy.gNavigatorBundle.GetStringFromName(
+ "decoder.noCodecs.button"
+ ),
+ supportPage: sumo,
+ callback() {
+ let clickedInPref = Services.prefs.getBoolPref(
+ buttonClickedPref,
+ false
+ );
+ if (!clickedInPref) {
+ Services.prefs.setBoolPref(buttonClickedPref, true);
+ }
+ },
+ });
+ }
+ let endpoint = this.getEndpointForReportIssueButton(type);
+ if (endpoint) {
+ LOG_DD(`endpoint=${endpoint}`);
+ buttons.push({
+ label: lazy.gNavigatorBundle.GetStringFromName(
+ "decoder.decodeError.button"
+ ),
+ accessKey: lazy.gNavigatorBundle.GetStringFromName(
+ "decoder.decodeError.accesskey"
+ ),
+ callback() {
+ let clickedInPref = Services.prefs.getBoolPref(
+ buttonClickedPref,
+ false
+ );
+ if (!clickedInPref) {
+ Services.prefs.setBoolPref(buttonClickedPref, true);
+ }
+
+ let params = new URLSearchParams();
+ params.append("url", docURL);
+ params.append("label", "type-media");
+ params.append("problem_type", "video_bug");
+ params.append("src", "media-decode-error");
+
+ let details = { "Technical Information:": decodeIssue };
+ if (resourceURL) {
+ details["Resource:"] = resourceURL;
+ }
+
+ params.append("details", JSON.stringify(details));
+ window.openTrustedLinkIn(endpoint + "?" + params.toString(), "tab");
+ },
+ });
+ }
+
+ box.appendNotification(
+ notificationId,
+ {
+ label: title,
+ image: "", // This uses the info icon as specified below.
+ priority: box.PRIORITY_INFO_LOW,
+ },
+ buttons
+ );
+ } else if (formatsInPref) {
+ // Issue is solved, and prefs haven't been cleared yet, meaning it's the
+ // first time we get this resolution -> Clear prefs and report telemetry.
+ Services.prefs.clearUserPref(formatsPref);
+ Services.prefs.clearUserPref(buttonClickedPref);
+ }
+ }
+}
diff --git a/browser/actors/EncryptedMediaChild.sys.mjs b/browser/actors/EncryptedMediaChild.sys.mjs
new file mode 100644
index 0000000000..7db643df67
--- /dev/null
+++ b/browser/actors/EncryptedMediaChild.sys.mjs
@@ -0,0 +1,121 @@
+/* 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/. */
+
+/**
+ * GlobalCaptureListener is a class that listens for changes to the global
+ * capture state of windows and screens. It uses this information to notify
+ * observers if it's possible that media is being shared by these captures.
+ * You probably only want one instance of this class per content process.
+ */
+class GlobalCaptureListener {
+ constructor() {
+ Services.cpmm.sharedData.addEventListener("change", this);
+ // Tracks if screen capture is taking place based on shared data. Default
+ // to true for safety.
+ this._isScreenCaptured = true;
+ // Tracks if any windows are being captured. Default to true for safety.
+ this._isAnyWindowCaptured = true;
+ }
+
+ /**
+ * Updates the capture state and forces that the state is notified to
+ * observers even if it hasn't changed since the last update.
+ */
+ requestUpdateAndNotify() {
+ this._updateCaptureState({ forceNotify: true });
+ }
+
+ /**
+ * Handle changes in shared data that may alter the capture state.
+ * @param event a notification that sharedData has changed. If this includes
+ * changes to screen or window sharing state then we'll update the capture
+ * state.
+ */
+ handleEvent(event) {
+ if (
+ event.changedKeys.includes("webrtcUI:isSharingScreen") ||
+ event.changedKeys.includes("webrtcUI:sharedTopInnerWindowIds")
+ ) {
+ this._updateCaptureState();
+ }
+ }
+
+ /**
+ * Updates the capture state and notifies the state to observers if the
+ * state has changed since last update, or if forced.
+ * @param forceNotify if true then the capture state will be sent to
+ * observers even if it didn't change since the last update.
+ */
+ _updateCaptureState({ forceNotify = false } = {}) {
+ const previousCaptureState =
+ this._isScreenCaptured || this._isAnyWindowCaptured;
+
+ this._isScreenCaptured = Boolean(
+ Services.cpmm.sharedData.get("webrtcUI:isSharingScreen")
+ );
+
+ const capturedTopInnerWindowIds = Services.cpmm.sharedData.get(
+ "webrtcUI:sharedTopInnerWindowIds"
+ );
+ if (capturedTopInnerWindowIds && capturedTopInnerWindowIds.size > 0) {
+ this._isAnyWindowCaptured = true;
+ } else {
+ this._isAnyWindowCaptured = false;
+ }
+ const newCaptureState = this._isScreenCaptured || this._isAnyWindowCaptured;
+
+ const captureStateChanged = previousCaptureState != newCaptureState;
+
+ if (forceNotify || captureStateChanged) {
+ // Notify the state if the caller forces it, or if the state changed.
+ this._notifyCaptureState();
+ }
+ }
+
+ /**
+ * Notifies observers of the current capture state. Notifies observers
+ * with a null subject, "mediakeys-response" topic, and data that is either
+ * "capture-possible" or "capture-not-possible", depending on if capture is
+ * possible or not.
+ */
+ _notifyCaptureState() {
+ const isCapturePossible =
+ this._isScreenCaptured || this._isAnyWindowCaptured;
+ const isCapturePossibleString = isCapturePossible
+ ? "capture-possible"
+ : "capture-not-possible";
+ Services.obs.notifyObservers(
+ null,
+ "mediakeys-response",
+ isCapturePossibleString
+ );
+ }
+}
+
+const gGlobalCaptureListener = new GlobalCaptureListener();
+
+export class EncryptedMediaChild extends JSWindowActorChild {
+ // Expected to observe 'mediakeys-request' as notified from MediaKeySystemAccess.
+ // @param aSubject the nsPIDOMWindowInner associated with the notifying MediaKeySystemAccess.
+ // @param aTopic should be "mediakeys-request".
+ // @param aData json containing a `status` and a `keysystem`.
+ observe(aSubject, aTopic, aData) {
+ let parsedData;
+ try {
+ parsedData = JSON.parse(aData);
+ } catch (ex) {
+ console.error("Malformed EME video message with data: ", aData);
+ return;
+ }
+ const { status } = parsedData;
+ if (status == "is-capture-possible") {
+ // We handle this status in process -- don't send a message to the parent.
+ gGlobalCaptureListener.requestUpdateAndNotify();
+ return;
+ }
+
+ this.sendAsyncMessage("EMEVideo:ContentMediaKeysRequest", aData);
+ }
+}
diff --git a/browser/actors/EncryptedMediaParent.sys.mjs b/browser/actors/EncryptedMediaParent.sys.mjs
new file mode 100644
index 0000000000..66fc21f9be
--- /dev/null
+++ b/browser/actors/EncryptedMediaParent.sys.mjs
@@ -0,0 +1,276 @@
+/* vim: set ts=2 sw=2 sts=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineLazyGetter(lazy, "gBrandBundle", function () {
+ return Services.strings.createBundle(
+ "chrome://branding/locale/brand.properties"
+ );
+});
+
+ChromeUtils.defineLazyGetter(lazy, "gNavigatorBundle", function () {
+ return Services.strings.createBundle(
+ "chrome://browser/locale/browser.properties"
+ );
+});
+
+ChromeUtils.defineLazyGetter(lazy, "gFluentStrings", function () {
+ return new Localization(["branding/brand.ftl", "browser/browser.ftl"], true);
+});
+
+export class EncryptedMediaParent extends JSWindowActorParent {
+ isUiEnabled() {
+ return Services.prefs.getBoolPref("browser.eme.ui.enabled");
+ }
+
+ ensureEMEEnabled(aBrowser, aKeySystem) {
+ Services.prefs.setBoolPref("media.eme.enabled", true);
+ if (
+ aKeySystem &&
+ aKeySystem == "com.widevine.alpha" &&
+ Services.prefs.getPrefType("media.gmp-widevinecdm.enabled") &&
+ !Services.prefs.getBoolPref("media.gmp-widevinecdm.enabled")
+ ) {
+ Services.prefs.setBoolPref("media.gmp-widevinecdm.enabled", true);
+ }
+ aBrowser.reload();
+ }
+
+ isKeySystemVisible(aKeySystem) {
+ if (!aKeySystem) {
+ return false;
+ }
+ if (
+ aKeySystem == "com.widevine.alpha" &&
+ Services.prefs.getPrefType("media.gmp-widevinecdm.visible")
+ ) {
+ return Services.prefs.getBoolPref("media.gmp-widevinecdm.visible");
+ }
+ return true;
+ }
+
+ getMessageWithBrandName(aNotificationId) {
+ let msgId = "emeNotifications." + aNotificationId + ".message";
+ return lazy.gNavigatorBundle.formatStringFromName(msgId, [
+ lazy.gBrandBundle.GetStringFromName("brandShortName"),
+ ]);
+ }
+
+ async receiveMessage(aMessage) {
+ if (!this.handledMessages) {
+ this.handledMessages = new Set();
+ }
+ // The top level browsing context's embedding element should be a xul browser element.
+ let browser = this.browsingContext.top.embedderElement;
+
+ if (!browser) {
+ // We don't have a browser so bail!
+ return;
+ }
+
+ let parsedData;
+ try {
+ parsedData = JSON.parse(aMessage.data);
+ } catch (ex) {
+ console.error("Malformed EME video message with data: ", aMessage.data);
+ return;
+ }
+ let { status, keySystem } = parsedData;
+ if (this.handledMessages.has(status)) {
+ return;
+ }
+
+ // First, see if we need to do updates. We don't need to do anything for
+ // hidden keysystems:
+ if (!this.isKeySystemVisible(keySystem)) {
+ return;
+ }
+ if (status == "cdm-not-installed") {
+ Services.obs.notifyObservers(browser, "EMEVideo:CDMMissing");
+ }
+
+ // Don't need to show UI if disabled.
+ if (!this.isUiEnabled()) {
+ return;
+ }
+
+ let notificationId;
+ let buttonCallback;
+ let supportPage;
+ // Notification message can be either a string or a DOM fragment.
+ let notificationMessage;
+ switch (status) {
+ case "available":
+ case "cdm-created":
+ // Only show the chain icon for proprietary CDMs. Clearkey is not one.
+ if (keySystem != "org.w3.clearkey") {
+ this.showPopupNotificationForSuccess(browser, keySystem);
+ }
+ // ... and bail!
+ return;
+
+ case "api-disabled":
+ case "cdm-disabled":
+ this.handledMessages.add(status);
+ notificationId = "drmContentDisabled";
+ buttonCallback = () => {
+ this.ensureEMEEnabled(browser, keySystem);
+ };
+ notificationMessage = lazy.gNavigatorBundle.GetStringFromName(
+ "emeNotifications.drmContentDisabled.message2"
+ );
+ supportPage = "drm-content";
+ break;
+
+ case "cdm-not-installed":
+ this.handledMessages.add(status);
+ notificationId = "drmContentCDMInstalling";
+ notificationMessage = this.getMessageWithBrandName(notificationId);
+ break;
+
+ case "cdm-not-supported":
+ // Not to pop up user-level notification because they cannot do anything
+ // about it.
+ return;
+ default:
+ console.error(
+ new Error(
+ "Unknown message ('" +
+ status +
+ "') dealing with EME key request: " +
+ aMessage.data
+ )
+ );
+ return;
+ }
+
+ // Now actually create the notification
+
+ let notificationBox = browser.getTabBrowser().getNotificationBox(browser);
+ if (notificationBox.getNotificationWithValue(notificationId)) {
+ this.handledMessages.delete(status);
+ return;
+ }
+
+ let buttons = [];
+ if (supportPage) {
+ buttons.push({ supportPage });
+ }
+ if (buttonCallback) {
+ let msgPrefix = "emeNotifications." + notificationId + ".";
+ let manageLabelId = msgPrefix + "button.label";
+ let manageAccessKeyId = msgPrefix + "button.accesskey";
+ buttons.push({
+ label: lazy.gNavigatorBundle.GetStringFromName(manageLabelId),
+ accessKey: lazy.gNavigatorBundle.GetStringFromName(manageAccessKeyId),
+ callback: buttonCallback,
+ });
+ }
+
+ let iconURL = "chrome://browser/skin/drm-icon.svg";
+ await notificationBox.appendNotification(
+ notificationId,
+ {
+ label: notificationMessage,
+ image: iconURL,
+ priority: notificationBox.PRIORITY_INFO_HIGH,
+ },
+ buttons
+ );
+ this.handledMessages.delete(status);
+ }
+
+ async showPopupNotificationForSuccess(aBrowser) {
+ // We're playing EME content! Remove any "we can't play because..." messages.
+ let notificationBox = aBrowser.getTabBrowser().getNotificationBox(aBrowser);
+ ["drmContentDisabled", "drmContentCDMInstalling"].forEach(function (value) {
+ let notification = notificationBox.getNotificationWithValue(value);
+ if (notification) {
+ notificationBox.removeNotification(notification);
+ }
+ });
+
+ // Don't bother creating it if it's already there:
+ if (
+ aBrowser.ownerGlobal.PopupNotifications.getNotification(
+ "drmContentPlaying",
+ aBrowser
+ )
+ ) {
+ return;
+ }
+
+ let msgPrefix = "eme-notifications-drm-content-playing";
+ let msgId = msgPrefix;
+ let manageLabelId = msgPrefix + "-manage";
+ let manageAccessKeyId = msgPrefix + "-manage-accesskey";
+ let dismissLabelId = msgPrefix + "-dismiss";
+ let dismissAccessKeyId = msgPrefix + "-dismiss-accesskey";
+
+ let [
+ message,
+ manageLabel,
+ manageAccessKey,
+ dismissLabel,
+ dismissAccessKey,
+ ] = await lazy.gFluentStrings.formatValues([
+ msgId,
+ manageLabelId,
+ manageAccessKeyId,
+ dismissLabelId,
+ dismissAccessKeyId,
+ ]);
+
+ let anchorId = "eme-notification-icon";
+ let firstPlayPref = "browser.eme.ui.firstContentShown";
+ let document = aBrowser.ownerDocument;
+ if (
+ !Services.prefs.getPrefType(firstPlayPref) ||
+ !Services.prefs.getBoolPref(firstPlayPref)
+ ) {
+ document.getElementById(anchorId).setAttribute("firstplay", "true");
+ Services.prefs.setBoolPref(firstPlayPref, true);
+ } else {
+ document.getElementById(anchorId).removeAttribute("firstplay");
+ }
+
+ let mainAction = {
+ label: manageLabel,
+ accessKey: manageAccessKey,
+ callback() {
+ aBrowser.ownerGlobal.openPreferences("general-drm");
+ },
+ dismiss: true,
+ };
+
+ let secondaryActions = [
+ {
+ label: dismissLabel,
+ accessKey: dismissAccessKey,
+ callback: () => {},
+ dismiss: true,
+ },
+ ];
+
+ let options = {
+ dismissed: true,
+ eventCallback: aTopic => aTopic == "swapping",
+ learnMoreURL:
+ Services.urlFormatter.formatURLPref("app.support.baseURL") +
+ "drm-content",
+ hideClose: true,
+ };
+ aBrowser.ownerGlobal.PopupNotifications.show(
+ aBrowser,
+ "drmContentPlaying",
+ message,
+ anchorId,
+ mainAction,
+ secondaryActions,
+ options
+ );
+ }
+}
diff --git a/browser/actors/FormValidationChild.sys.mjs b/browser/actors/FormValidationChild.sys.mjs
new file mode 100644
index 0000000000..6fa2e3c90d
--- /dev/null
+++ b/browser/actors/FormValidationChild.sys.mjs
@@ -0,0 +1,193 @@
+/* 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/. */
+
+/**
+ * Handles the validation callback from nsIFormFillController and
+ * the display of the help panel on invalid elements.
+ */
+
+import { LayoutUtils } from "resource://gre/modules/LayoutUtils.sys.mjs";
+
+export class FormValidationChild extends JSWindowActorChild {
+ constructor() {
+ super();
+ this._validationMessage = "";
+ this._element = null;
+ }
+
+ /*
+ * Events
+ */
+
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "MozInvalidForm":
+ aEvent.preventDefault();
+ this.notifyInvalidSubmit(aEvent.detail);
+ break;
+ case "pageshow":
+ if (this._isRootDocumentEvent(aEvent)) {
+ this._hidePopup();
+ }
+ break;
+ case "pagehide":
+ // Act as if the element is being blurred. This will remove any
+ // listeners and hide the popup.
+ this._onBlur();
+ break;
+ case "input":
+ this._onInput(aEvent);
+ break;
+ case "blur":
+ this._onBlur(aEvent);
+ break;
+ }
+ }
+
+ notifyInvalidSubmit(aInvalidElements) {
+ // Show a validation message on the first focusable element.
+ for (let element of aInvalidElements) {
+ // Insure that this is the FormSubmitObserver associated with the
+ // element / window this notification is about.
+ if (this.contentWindow != element.ownerGlobal.document.defaultView) {
+ return;
+ }
+
+ if (
+ !(
+ ChromeUtils.getClassName(element) === "HTMLInputElement" ||
+ ChromeUtils.getClassName(element) === "HTMLTextAreaElement" ||
+ ChromeUtils.getClassName(element) === "HTMLSelectElement" ||
+ ChromeUtils.getClassName(element) === "HTMLButtonElement" ||
+ element.isFormAssociatedCustomElements
+ )
+ ) {
+ continue;
+ }
+
+ let validationMessage = element.isFormAssociatedCustomElements
+ ? element.internals.validationMessage
+ : element.validationMessage;
+
+ if (element.isFormAssociatedCustomElements) {
+ // For element that are form-associated custom elements, user agents
+ // should use their validation anchor instead.
+ element = element.internals.validationAnchor;
+ }
+
+ if (!element || !Services.focus.elementIsFocusable(element, 0)) {
+ continue;
+ }
+
+ // Update validation message before showing notification
+ this._validationMessage = validationMessage;
+
+ // Don't connect up to the same element more than once.
+ if (this._element == element) {
+ this._showPopup(element);
+ break;
+ }
+ this._element = element;
+
+ element.focus();
+
+ // Watch for input changes which may change the validation message.
+ element.addEventListener("input", this);
+
+ // Watch for focus changes so we can disconnect our listeners and
+ // hide the popup.
+ element.addEventListener("blur", this);
+
+ this._showPopup(element);
+ break;
+ }
+ }
+
+ /*
+ * Internal
+ */
+
+ /*
+ * Handles input changes on the form element we've associated a popup
+ * with. Updates the validation message or closes the popup if form data
+ * becomes valid.
+ */
+ _onInput(aEvent) {
+ let element = aEvent.originalTarget;
+
+ // If the form input is now valid, hide the popup.
+ if (element.validity.valid) {
+ this._hidePopup();
+ return;
+ }
+
+ // If the element is still invalid for a new reason, we should update
+ // the popup error message.
+ if (this._validationMessage != element.validationMessage) {
+ this._validationMessage = element.validationMessage;
+ this._showPopup(element);
+ }
+ }
+
+ /*
+ * Blur event handler in which we disconnect from the form element and
+ * hide the popup.
+ */
+ _onBlur(aEvent) {
+ if (this._element) {
+ this._element.removeEventListener("input", this);
+ this._element.removeEventListener("blur", this);
+ }
+ this._hidePopup();
+ this._element = null;
+ }
+
+ /*
+ * Send the show popup message to chrome with appropriate position
+ * information. Can be called repetitively to update the currently
+ * displayed popup position and text.
+ */
+ _showPopup(aElement) {
+ // Collect positional information and show the popup
+ let panelData = {};
+
+ panelData.message = this._validationMessage;
+
+ panelData.screenRect = LayoutUtils.getElementBoundingScreenRect(aElement);
+
+ // We want to show the popup at the middle of checkbox and radio buttons
+ // and where the content begin for the other elements.
+ if (
+ aElement.tagName == "INPUT" &&
+ (aElement.type == "radio" || aElement.type == "checkbox")
+ ) {
+ panelData.position = "bottomcenter topleft";
+ } else {
+ panelData.position = "after_start";
+ }
+ this.sendAsyncMessage("FormValidation:ShowPopup", panelData);
+
+ aElement.ownerGlobal.addEventListener("pagehide", this, {
+ mozSystemGroup: true,
+ });
+ }
+
+ _hidePopup() {
+ this.sendAsyncMessage("FormValidation:HidePopup", {});
+ this._element.ownerGlobal.removeEventListener("pagehide", this, {
+ mozSystemGroup: true,
+ });
+ }
+
+ _isRootDocumentEvent(aEvent) {
+ if (this.contentWindow == null) {
+ return true;
+ }
+ let target = aEvent.originalTarget;
+ return (
+ target == this.document ||
+ (target.ownerDocument && target.ownerDocument == this.document)
+ );
+ }
+}
diff --git a/browser/actors/FormValidationParent.sys.mjs b/browser/actors/FormValidationParent.sys.mjs
new file mode 100644
index 0000000000..e95a8e86fb
--- /dev/null
+++ b/browser/actors/FormValidationParent.sys.mjs
@@ -0,0 +1,202 @@
+/* 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/. */
+
+/*
+ * Chrome side handling of form validation popup.
+ */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
+});
+
+class PopupShownObserver {
+ _weakContext = null;
+
+ constructor(browsingContext) {
+ this._weakContext = Cu.getWeakReference(browsingContext);
+ }
+
+ observe(subject, topic, data) {
+ let ctxt = this._weakContext.get();
+ let actor = ctxt.currentWindowGlobal?.getExistingActor("FormValidation");
+ if (!actor) {
+ Services.obs.removeObserver(this, "popup-shown");
+ return;
+ }
+ // If any panel besides ourselves shows, hide ourselves again.
+ if (topic == "popup-shown" && subject != actor._panel) {
+ actor._hidePopup();
+ }
+ }
+
+ QueryInterface = ChromeUtils.generateQI([
+ Ci.nsIObserver,
+ Ci.nsISupportsWeakReference,
+ ]);
+}
+
+export class FormValidationParent extends JSWindowActorParent {
+ constructor() {
+ super();
+
+ this._panel = null;
+ this._obs = null;
+ }
+
+ static hasOpenPopups() {
+ for (let win of lazy.BrowserWindowTracker.orderedWindows) {
+ let popups = win.document.querySelectorAll("panel,menupopup");
+ for (let popup of popups) {
+ let { state } = popup;
+ if (state == "open" || state == "showing") {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /*
+ * Public apis
+ */
+
+ uninit() {
+ this._panel = null;
+ this._obs = null;
+ }
+
+ hidePopup() {
+ this._hidePopup();
+ }
+
+ /*
+ * Events
+ */
+
+ receiveMessage(aMessage) {
+ switch (aMessage.name) {
+ case "FormValidation:ShowPopup":
+ let browser = this.browsingContext.top.embedderElement;
+ let window = browser.ownerGlobal;
+ let data = aMessage.data;
+ let tabBrowser = window.gBrowser;
+
+ // target is the <browser>, make sure we're receiving a message
+ // from the foreground tab.
+ if (tabBrowser && browser != tabBrowser.selectedBrowser) {
+ return;
+ }
+
+ if (FormValidationParent.hasOpenPopups()) {
+ return;
+ }
+
+ this._showPopup(browser, data);
+ break;
+ case "FormValidation:HidePopup":
+ this._hidePopup();
+ break;
+ }
+ }
+
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "FullZoomChange":
+ case "TextZoomChange":
+ case "scroll":
+ this._hidePopup();
+ break;
+ case "popuphidden":
+ this._onPopupHidden(aEvent);
+ break;
+ }
+ }
+
+ /*
+ * Internal
+ */
+
+ _onPopupHidden(aEvent) {
+ aEvent.originalTarget.removeEventListener("popuphidden", this, true);
+ Services.obs.removeObserver(this._obs, "popup-shown");
+ let tabBrowser = aEvent.originalTarget.ownerGlobal.gBrowser;
+ tabBrowser.selectedBrowser.removeEventListener("scroll", this, true);
+ tabBrowser.selectedBrowser.removeEventListener("FullZoomChange", this);
+ tabBrowser.selectedBrowser.removeEventListener("TextZoomChange", this);
+
+ this._obs = null;
+ this._panel = null;
+ }
+
+ /*
+ * Shows the form validation popup at a specified position or updates the
+ * messaging and position if the popup is already displayed.
+ *
+ * @aBrowser - Browser element that requests the popup.
+ * @aPanelData - Object that contains popup information
+ * aPanelData stucture detail:
+ * screenRect - the screen rect of the target element.
+ * position - popup positional string constants.
+ * message - the form element validation message text.
+ */
+ _showPopup(aBrowser, aPanelData) {
+ let previouslyShown = !!this._panel;
+ this._panel = this._getAndMaybeCreatePanel();
+ this._panel.firstChild.textContent = aPanelData.message;
+
+ // Display the panel if it isn't already visible.
+ if (previouslyShown) {
+ return;
+ }
+ // Cleanup after the popup is hidden
+ this._panel.addEventListener("popuphidden", this, true);
+ // Hide ourselves if other popups shown
+ this._obs = new PopupShownObserver(this.browsingContext);
+ Services.obs.addObserver(this._obs, "popup-shown", true);
+
+ // Hide if the user scrolls the page
+ aBrowser.addEventListener("scroll", this, true);
+ aBrowser.addEventListener("FullZoomChange", this);
+ aBrowser.addEventListener("TextZoomChange", this);
+
+ aBrowser.constrainPopup(this._panel);
+
+ // Open the popup
+ let rect = aPanelData.screenRect;
+ this._panel.openPopupAtScreenRect(
+ aPanelData.position,
+ rect.left,
+ rect.top,
+ rect.width,
+ rect.height,
+ false,
+ false
+ );
+ }
+
+ /*
+ * Hide the popup if currently displayed. Will fire an event to onPopupHiding
+ * above if visible.
+ */
+ _hidePopup() {
+ this._panel?.hidePopup();
+ }
+
+ _getAndMaybeCreatePanel() {
+ // Lazy load the invalid form popup the first time we need to display it.
+ if (!this._panel) {
+ let browser = this.browsingContext.top.embedderElement;
+ let window = browser.ownerGlobal;
+ let template = window.document.getElementById("invalidFormTemplate");
+ if (template) {
+ template.replaceWith(template.content);
+ }
+ this._panel = window.document.getElementById("invalid-form-popup");
+ }
+
+ return this._panel;
+ }
+}
diff --git a/browser/actors/LightweightThemeChild.sys.mjs b/browser/actors/LightweightThemeChild.sys.mjs
new file mode 100644
index 0000000000..97be44511c
--- /dev/null
+++ b/browser/actors/LightweightThemeChild.sys.mjs
@@ -0,0 +1,82 @@
+/* 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/. */
+
+/**
+ * LightweightThemeChild forwards theme data to in-content pages.
+ */
+export class LightweightThemeChild extends JSWindowActorChild {
+ constructor() {
+ super();
+ this._initted = false;
+ Services.cpmm.sharedData.addEventListener("change", this);
+ }
+
+ didDestroy() {
+ Services.cpmm.sharedData.removeEventListener("change", this);
+ }
+
+ _getChromeOuterWindowID() {
+ try {
+ // Getting the browserChild throws an exception when it is null.
+ let browserChild = this.docShell.browserChild;
+ if (browserChild) {
+ return browserChild.chromeOuterWindowID;
+ }
+ } catch (ex) {}
+
+ if (
+ Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT
+ ) {
+ return this.browsingContext.topChromeWindow.docShell.outerWindowID;
+ }
+
+ // Return a false-y outerWindowID if we can't manage to get a proper one.
+ // Note that no outerWindowID will ever have this ID.
+ return 0;
+ }
+
+ /**
+ * Handles "change" events on the child sharedData map, and notifies
+ * our content page if its theme data was among the changed keys.
+ */
+ handleEvent(event) {
+ switch (event.type) {
+ // Make sure to update the theme data on first page show.
+ case "pageshow":
+ case "DOMContentLoaded":
+ if (!this._initted && this._getChromeOuterWindowID()) {
+ this._initted = true;
+ this.update();
+ }
+ break;
+
+ case "change":
+ if (
+ event.changedKeys.includes(`theme/${this._getChromeOuterWindowID()}`)
+ ) {
+ this.update();
+ }
+ break;
+ }
+ }
+
+ /**
+ * Forward the theme data to the page.
+ */
+ update() {
+ const event = Cu.cloneInto(
+ {
+ detail: {
+ data: Services.cpmm.sharedData.get(
+ `theme/${this._getChromeOuterWindowID()}`
+ ),
+ },
+ },
+ this.contentWindow
+ );
+ this.contentWindow.dispatchEvent(
+ new this.contentWindow.CustomEvent("LightweightTheme:Set", event)
+ );
+ }
+}
diff --git a/browser/actors/LinkHandlerChild.sys.mjs b/browser/actors/LinkHandlerChild.sys.mjs
new file mode 100644
index 0000000000..95c86b2d0f
--- /dev/null
+++ b/browser/actors/LinkHandlerChild.sys.mjs
@@ -0,0 +1,175 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ FaviconLoader: "resource:///modules/FaviconLoader.sys.mjs",
+});
+
+export class LinkHandlerChild extends JSWindowActorChild {
+ constructor() {
+ super();
+
+ this.seenTabIcon = false;
+ this._iconLoader = null;
+ }
+
+ get iconLoader() {
+ if (!this._iconLoader) {
+ this._iconLoader = new lazy.FaviconLoader(this);
+ }
+ return this._iconLoader;
+ }
+
+ addRootIcon() {
+ if (
+ !this.seenTabIcon &&
+ Services.prefs.getBoolPref("browser.chrome.guess_favicon", true) &&
+ Services.prefs.getBoolPref("browser.chrome.site_icons", true)
+ ) {
+ // Inject the default icon. Use documentURIObject so that we do the right
+ // thing with about:-style error pages. See bug 453442
+ let pageURI = this.document.documentURIObject;
+ if (["http", "https"].includes(pageURI.scheme)) {
+ this.seenTabIcon = true;
+ this.iconLoader.addDefaultIcon(pageURI);
+ }
+ }
+ }
+
+ onHeadParsed(event) {
+ if (event.target.ownerDocument != this.document) {
+ return;
+ }
+
+ // Per spec icons are meant to be in the <head> tag so we should have seen
+ // all the icons now so add the root icon if no other tab icons have been
+ // seen.
+ this.addRootIcon();
+
+ // We're likely done with icon parsing so load the pending icons now.
+ if (this._iconLoader) {
+ this._iconLoader.onPageShow();
+ }
+ }
+
+ onPageShow(event) {
+ if (event.target != this.document) {
+ return;
+ }
+
+ this.addRootIcon();
+
+ if (this._iconLoader) {
+ this._iconLoader.onPageShow();
+ }
+ }
+
+ onPageHide(event) {
+ if (event.target != this.document) {
+ return;
+ }
+
+ if (this._iconLoader) {
+ this._iconLoader.onPageHide();
+ }
+
+ this.seenTabIcon = false;
+ }
+
+ onLinkEvent(event) {
+ let link = event.target;
+ // Ignore sub-frames (bugs 305472, 479408).
+ if (link.ownerGlobal != this.contentWindow) {
+ return;
+ }
+
+ let rel = link.rel && link.rel.toLowerCase();
+ // We also check .getAttribute, since an empty href attribute will give us
+ // a link.href that is the same as the document.
+ if (!rel || !link.href || !link.getAttribute("href")) {
+ return;
+ }
+
+ // Note: following booleans only work for the current link, not for the
+ // whole content
+ let iconAdded = false;
+ let searchAdded = false;
+ let rels = {};
+ for (let relString of rel.split(/\s+/)) {
+ rels[relString] = true;
+ }
+
+ for (let relVal in rels) {
+ let isRichIcon = false;
+
+ switch (relVal) {
+ case "apple-touch-icon":
+ case "apple-touch-icon-precomposed":
+ case "fluid-icon":
+ isRichIcon = true;
+ // fall through
+ case "icon":
+ if (iconAdded || link.hasAttribute("mask")) {
+ // Masked icons are not supported yet.
+ break;
+ }
+
+ if (!Services.prefs.getBoolPref("browser.chrome.site_icons", true)) {
+ return;
+ }
+
+ if (this.iconLoader.addIconFromLink(link, isRichIcon)) {
+ iconAdded = true;
+ if (!isRichIcon) {
+ this.seenTabIcon = true;
+ }
+ }
+ break;
+ case "search":
+ if (
+ Services.policies &&
+ !Services.policies.isAllowed("installSearchEngine")
+ ) {
+ break;
+ }
+
+ if (!searchAdded && event.type == "DOMLinkAdded") {
+ let type = link.type && link.type.toLowerCase();
+ type = type.replace(/^\s+|\s*(?:;.*)?$/g, "");
+
+ // Note: This protocol list should be kept in sync with
+ // the one in OpenSearchEngine's install function.
+ let re = /^https?:/i;
+ if (
+ type == "application/opensearchdescription+xml" &&
+ link.title &&
+ re.test(link.href)
+ ) {
+ let engine = { title: link.title, href: link.href };
+ this.sendAsyncMessage("Link:AddSearch", {
+ engine,
+ });
+ searchAdded = true;
+ }
+ }
+ break;
+ }
+ }
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "pageshow":
+ return this.onPageShow(event);
+ case "pagehide":
+ return this.onPageHide(event);
+ case "DOMHeadElementParsed":
+ return this.onHeadParsed(event);
+ default:
+ return this.onLinkEvent(event);
+ }
+ }
+}
diff --git a/browser/actors/LinkHandlerParent.sys.mjs b/browser/actors/LinkHandlerParent.sys.mjs
new file mode 100644
index 0000000000..5610c7122c
--- /dev/null
+++ b/browser/actors/LinkHandlerParent.sys.mjs
@@ -0,0 +1,164 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs",
+});
+
+let gTestListeners = new Set();
+
+export class LinkHandlerParent extends JSWindowActorParent {
+ static addListenerForTests(listener) {
+ gTestListeners.add(listener);
+ }
+
+ static removeListenerForTests(listener) {
+ gTestListeners.delete(listener);
+ }
+
+ receiveMessage(aMsg) {
+ let browser = this.browsingContext.top.embedderElement;
+ if (!browser) {
+ return;
+ }
+
+ let win = browser.ownerGlobal;
+
+ let gBrowser = win.gBrowser;
+
+ switch (aMsg.name) {
+ case "Link:LoadingIcon":
+ if (!gBrowser) {
+ return;
+ }
+
+ if (aMsg.data.canUseForTab) {
+ let tab = gBrowser.getTabForBrowser(browser);
+ if (tab.hasAttribute("busy")) {
+ tab.setAttribute("pendingicon", "true");
+ }
+ }
+
+ this.notifyTestListeners("LoadingIcon", aMsg.data);
+ break;
+
+ case "Link:SetIcon":
+ // Cache the most recent icon and rich icon locally.
+ if (aMsg.data.canUseForTab) {
+ this.icon = aMsg.data;
+ } else {
+ this.richIcon = aMsg.data;
+ }
+
+ if (!gBrowser) {
+ return;
+ }
+
+ this.setIconFromLink(gBrowser, browser, aMsg.data);
+
+ this.notifyTestListeners("SetIcon", aMsg.data);
+ break;
+
+ case "Link:SetFailedIcon":
+ if (!gBrowser) {
+ return;
+ }
+
+ if (aMsg.data.canUseForTab) {
+ this.clearPendingIcon(gBrowser, browser);
+ }
+
+ this.notifyTestListeners("SetFailedIcon", aMsg.data);
+ break;
+
+ case "Link:AddSearch":
+ if (!gBrowser) {
+ return;
+ }
+
+ let tab = gBrowser.getTabForBrowser(browser);
+ if (!tab) {
+ break;
+ }
+
+ if (win.BrowserSearch) {
+ win.BrowserSearch.addEngine(browser, aMsg.data.engine);
+ }
+ break;
+ }
+ }
+
+ notifyTestListeners(name, data) {
+ for (let listener of gTestListeners) {
+ listener(name, data);
+ }
+ }
+
+ clearPendingIcon(gBrowser, aBrowser) {
+ let tab = gBrowser.getTabForBrowser(aBrowser);
+ tab.removeAttribute("pendingicon");
+ }
+
+ setIconFromLink(
+ gBrowser,
+ browser,
+ {
+ pageURL,
+ originalURL,
+ canUseForTab,
+ expiration,
+ iconURL,
+ canStoreIcon,
+ beforePageShow,
+ }
+ ) {
+ let tab = gBrowser.getTabForBrowser(browser);
+ if (!tab) {
+ return;
+ }
+
+ if (canUseForTab) {
+ this.clearPendingIcon(gBrowser, browser);
+ }
+
+ let iconURI;
+ try {
+ iconURI = Services.io.newURI(iconURL);
+ } catch (ex) {
+ console.error(ex);
+ return;
+ }
+ if (iconURI.scheme != "data") {
+ try {
+ Services.scriptSecurityManager.checkLoadURIWithPrincipal(
+ browser.contentPrincipal,
+ iconURI,
+ Services.scriptSecurityManager.ALLOW_CHROME
+ );
+ } catch (ex) {
+ return;
+ }
+ }
+ if (canStoreIcon) {
+ try {
+ lazy.PlacesUIUtils.loadFavicon(
+ browser,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ Services.io.newURI(pageURL),
+ Services.io.newURI(originalURL),
+ expiration,
+ iconURI
+ );
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+
+ if (canUseForTab) {
+ gBrowser.setIcon(tab, iconURL, originalURL, null, beforePageShow);
+ }
+ }
+}
diff --git a/browser/actors/PageInfoChild.sys.mjs b/browser/actors/PageInfoChild.sys.mjs
new file mode 100644
index 0000000000..aee59ef295
--- /dev/null
+++ b/browser/actors/PageInfoChild.sys.mjs
@@ -0,0 +1,402 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+export class PageInfoChild extends JSWindowActorChild {
+ async receiveMessage(message) {
+ let window = this.contentWindow;
+ let document = window.document;
+
+ //Handles two different types of messages: one for general info (PageInfo:getData)
+ //and one for media info (PageInfo:getMediaData)
+ switch (message.name) {
+ case "PageInfo:getData": {
+ return Promise.resolve({
+ metaViewRows: this.getMetaInfo(document),
+ docInfo: this.getDocumentInfo(document),
+ windowInfo: this.getWindowInfo(window),
+ });
+ }
+ case "PageInfo:getMediaData": {
+ return Promise.resolve({
+ mediaItems: await this.getDocumentMedia(document),
+ });
+ }
+ case "PageInfo:getPartitionKey": {
+ return Promise.resolve({
+ partitionKey: await this.getPartitionKey(document),
+ });
+ }
+ }
+
+ return undefined;
+ }
+
+ getPartitionKey(document) {
+ let partitionKey = document.cookieJarSettings.partitionKey;
+ return partitionKey;
+ }
+
+ getMetaInfo(document) {
+ let metaViewRows = [];
+
+ // Get the meta tags from the page.
+ let metaNodes = document.getElementsByTagName("meta");
+
+ for (let metaNode of metaNodes) {
+ metaViewRows.push([
+ metaNode.name ||
+ metaNode.httpEquiv ||
+ metaNode.getAttribute("property"),
+ metaNode.content,
+ ]);
+ }
+
+ return metaViewRows;
+ }
+
+ getWindowInfo(window) {
+ let windowInfo = {};
+ windowInfo.isTopWindow = window == window.top;
+
+ let hostName = null;
+ try {
+ hostName = Services.io.newURI(window.location.href).displayHost;
+ } catch (exception) {}
+
+ windowInfo.hostName = hostName;
+ return windowInfo;
+ }
+
+ getDocumentInfo(document) {
+ let docInfo = {};
+ docInfo.title = document.title;
+ docInfo.location = document.location.toString();
+ try {
+ docInfo.location = Services.io.newURI(
+ document.location.toString()
+ ).displaySpec;
+ } catch (exception) {}
+ docInfo.referrer = document.referrer;
+ try {
+ if (document.referrer) {
+ docInfo.referrer = Services.io.newURI(document.referrer).displaySpec;
+ }
+ } catch (exception) {}
+ docInfo.compatMode = document.compatMode;
+ docInfo.contentType = document.contentType;
+ docInfo.characterSet = document.characterSet;
+ docInfo.lastModified = document.lastModified;
+ docInfo.principal = document.nodePrincipal;
+ docInfo.cookieJarSettings = lazy.E10SUtils.serializeCookieJarSettings(
+ document.cookieJarSettings
+ );
+
+ let documentURIObject = {};
+ documentURIObject.spec = document.documentURIObject.spec;
+ docInfo.documentURIObject = documentURIObject;
+
+ docInfo.isContentWindowPrivate =
+ lazy.PrivateBrowsingUtils.isContentWindowPrivate(document.ownerGlobal);
+
+ return docInfo;
+ }
+
+ /**
+ * Returns an array that stores all mediaItems found in the document
+ * Calls getMediaItems for all nodes within the constructed tree walker and forms
+ * resulting array.
+ */
+ async getDocumentMedia(document) {
+ let nodeCount = 0;
+ let content = document.ownerGlobal;
+ let iterator = document.createTreeWalker(
+ document,
+ content.NodeFilter.SHOW_ELEMENT
+ );
+
+ let totalMediaItems = [];
+
+ while (iterator.nextNode()) {
+ let mediaItems = this.getMediaItems(document, iterator.currentNode);
+
+ if (++nodeCount % 500 == 0) {
+ // setTimeout every 500 elements so we don't keep blocking the content process.
+ await new Promise(resolve => lazy.setTimeout(resolve, 10));
+ }
+ totalMediaItems.push(...mediaItems);
+ }
+
+ return totalMediaItems;
+ }
+
+ getMediaItems(document, elem) {
+ // Check for images defined in CSS (e.g. background, borders)
+ let computedStyle = elem.ownerGlobal.getComputedStyle(elem);
+ // A node can have multiple media items associated with it - for example,
+ // multiple background images.
+ let mediaItems = [];
+ let content = document.ownerGlobal;
+
+ let addMedia = (url, type, alt, el, isBg, altNotProvided = false) => {
+ let element = this.serializeElementInfo(document, url, el, isBg);
+ mediaItems.push({
+ url,
+ type,
+ alt,
+ altNotProvided,
+ element,
+ isBg,
+ });
+ };
+
+ if (computedStyle) {
+ let addImgFunc = (type, urls) => {
+ for (let url of urls) {
+ addMedia(url, type, "", elem, true, true);
+ }
+ };
+ // FIXME: This is missing properties. See the implementation of
+ // getCSSImageURLs for a list of properties.
+ //
+ // If you don't care about the message you can also pass "all" here and
+ // get all the ones the browser knows about.
+ addImgFunc("bg-img", computedStyle.getCSSImageURLs("background-image"));
+ addImgFunc(
+ "border-img",
+ computedStyle.getCSSImageURLs("border-image-source")
+ );
+ addImgFunc("list-img", computedStyle.getCSSImageURLs("list-style-image"));
+ addImgFunc("cursor", computedStyle.getCSSImageURLs("cursor"));
+ }
+
+ // One swi^H^H^Hif-else to rule them all.
+ if (content.HTMLImageElement.isInstance(elem)) {
+ addMedia(
+ elem.currentSrc,
+ "img",
+ elem.getAttribute("alt"),
+ elem,
+ false,
+ !elem.hasAttribute("alt")
+ );
+ } else if (content.SVGImageElement.isInstance(elem)) {
+ try {
+ // Note: makeURLAbsolute will throw if either the baseURI is not a valid URI
+ // or the URI formed from the baseURI and the URL is not a valid URI.
+ if (elem.href.baseVal) {
+ let href = Services.io.newURI(
+ elem.href.baseVal,
+ null,
+ Services.io.newURI(elem.baseURI)
+ ).spec;
+ addMedia(href, "img", "", elem, false);
+ }
+ } catch (e) {}
+ } else if (content.HTMLVideoElement.isInstance(elem)) {
+ addMedia(elem.currentSrc, "video", "", elem, false);
+ } else if (content.HTMLAudioElement.isInstance(elem)) {
+ addMedia(elem.currentSrc, "audio", "", elem, false);
+ } else if (content.HTMLLinkElement.isInstance(elem)) {
+ if (elem.rel && /\bicon\b/i.test(elem.rel)) {
+ addMedia(elem.href, "link", "", elem, false);
+ }
+ } else if (
+ content.HTMLInputElement.isInstance(elem) ||
+ content.HTMLButtonElement.isInstance(elem)
+ ) {
+ if (elem.type.toLowerCase() == "image") {
+ addMedia(
+ elem.src,
+ "input",
+ elem.getAttribute("alt"),
+ elem,
+ false,
+ !elem.hasAttribute("alt")
+ );
+ }
+ } else if (content.HTMLObjectElement.isInstance(elem)) {
+ addMedia(elem.data, "object", this.getValueText(elem), elem, false);
+ } else if (content.HTMLEmbedElement.isInstance(elem)) {
+ addMedia(elem.src, "embed", "", elem, false);
+ }
+
+ return mediaItems;
+ }
+
+ /**
+ * Set up a JSON element object with all the instanceOf and other infomation that
+ * makePreview in pageInfo.js uses to figure out how to display the preview.
+ */
+
+ serializeElementInfo(document, url, item, isBG) {
+ let result = {};
+ let content = document.ownerGlobal;
+
+ let imageText;
+ if (
+ !isBG &&
+ !content.SVGImageElement.isInstance(item) &&
+ !content.ImageDocument.isInstance(document)
+ ) {
+ imageText = item.title || item.alt;
+
+ if (!imageText && !content.HTMLImageElement.isInstance(item)) {
+ imageText = this.getValueText(item);
+ }
+ }
+
+ result.imageText = imageText;
+ result.longDesc = item.longDesc;
+ result.numFrames = 1;
+
+ if (
+ content.HTMLObjectElement.isInstance(item) ||
+ content.HTMLEmbedElement.isInstance(item) ||
+ content.HTMLLinkElement.isInstance(item)
+ ) {
+ result.mimeType = item.type;
+ }
+
+ if (
+ !result.mimeType &&
+ !isBG &&
+ item instanceof Ci.nsIImageLoadingContent
+ ) {
+ // Interface for image loading content.
+ let imageRequest = item.getRequest(
+ Ci.nsIImageLoadingContent.CURRENT_REQUEST
+ );
+ if (imageRequest) {
+ result.mimeType = imageRequest.mimeType;
+ let image =
+ !(imageRequest.imageStatus & imageRequest.STATUS_ERROR) &&
+ imageRequest.image;
+ if (image) {
+ result.numFrames = image.numFrames;
+ }
+ }
+ }
+
+ // If we have a data url, get the MIME type from the url.
+ if (!result.mimeType && url.startsWith("data:")) {
+ let dataMimeType = /^data:(image\/[^;,]+)/i.exec(url);
+ if (dataMimeType) {
+ result.mimeType = dataMimeType[1].toLowerCase();
+ }
+ }
+
+ result.HTMLLinkElement = content.HTMLLinkElement.isInstance(item);
+ result.HTMLInputElement = content.HTMLInputElement.isInstance(item);
+ result.HTMLImageElement = content.HTMLImageElement.isInstance(item);
+ result.HTMLObjectElement = content.HTMLObjectElement.isInstance(item);
+ result.SVGImageElement = content.SVGImageElement.isInstance(item);
+ result.HTMLVideoElement = content.HTMLVideoElement.isInstance(item);
+ result.HTMLAudioElement = content.HTMLAudioElement.isInstance(item);
+
+ if (isBG) {
+ // Items that are showing this image as a background
+ // image might not necessarily have a width or height,
+ // so we'll dynamically generate an image and send up the
+ // natural dimensions.
+ let img = content.document.createElement("img");
+ img.src = url;
+ result.naturalWidth = img.naturalWidth;
+ result.naturalHeight = img.naturalHeight;
+ } else if (!content.SVGImageElement.isInstance(item)) {
+ // SVG items do not have integer values for height or width,
+ // so we must handle them differently in order to correctly
+ // serialize
+
+ // Otherwise, we can use the current width and height
+ // of the image.
+ result.width = item.width;
+ result.height = item.height;
+ }
+
+ if (content.SVGImageElement.isInstance(item)) {
+ result.SVGImageElementWidth = item.width.baseVal.value;
+ result.SVGImageElementHeight = item.height.baseVal.value;
+ }
+
+ result.baseURI = item.baseURI;
+
+ return result;
+ }
+
+ // Other Misc Stuff
+ // Modified from the Links Panel v2.3, http://segment7.net/mozilla/links/links.html
+ // parse a node to extract the contents of the node
+ getValueText(node) {
+ let valueText = "";
+ let content = node.ownerGlobal;
+
+ // Form input elements don't generally contain information that is useful to our callers, so return nothing.
+ if (
+ content.HTMLInputElement.isInstance(node) ||
+ content.HTMLSelectElement.isInstance(node) ||
+ content.HTMLTextAreaElement.isInstance(node)
+ ) {
+ return valueText;
+ }
+
+ // Otherwise recurse for each child.
+ let length = node.childNodes.length;
+
+ for (let i = 0; i < length; i++) {
+ let childNode = node.childNodes[i];
+ let nodeType = childNode.nodeType;
+
+ // Text nodes are where the goods are.
+ if (nodeType == content.Node.TEXT_NODE) {
+ valueText += " " + childNode.nodeValue;
+ } else if (nodeType == content.Node.ELEMENT_NODE) {
+ // And elements can have more text inside them.
+ // Images are special, we want to capture the alt text as if the image weren't there.
+ if (content.HTMLImageElement.isInstance(childNode)) {
+ valueText += " " + this.getAltText(childNode);
+ } else {
+ valueText += " " + this.getValueText(childNode);
+ }
+ }
+ }
+
+ return this.stripWS(valueText);
+ }
+
+ // Copied from the Links Panel v2.3, http://segment7.net/mozilla/links/links.html.
+ // Traverse the tree in search of an img or area element and grab its alt tag.
+ getAltText(node) {
+ let altText = "";
+
+ if (node.alt) {
+ return node.alt;
+ }
+ let length = node.childNodes.length;
+ for (let i = 0; i < length; i++) {
+ if ((altText = this.getAltText(node.childNodes[i]) != undefined)) {
+ // stupid js warning...
+ return altText;
+ }
+ }
+ return "";
+ }
+
+ // Copied from the Links Panel v2.3, http://segment7.net/mozilla/links/links.html.
+ // Strip leading and trailing whitespace, and replace multiple consecutive whitespace characters with a single space.
+ stripWS(text) {
+ let middleRE = /\s+/g;
+ let endRE = /(^\s+)|(\s+$)/g;
+
+ text = text.replace(middleRE, " ");
+ return text.replace(endRE, "");
+ }
+}
diff --git a/browser/actors/PageStyleChild.sys.mjs b/browser/actors/PageStyleChild.sys.mjs
new file mode 100644
index 0000000000..3970d010b0
--- /dev/null
+++ b/browser/actors/PageStyleChild.sys.mjs
@@ -0,0 +1,199 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export class PageStyleChild extends JSWindowActorChild {
+ actorCreated() {
+ // C++ can create the actor and call us here once an "interesting" link
+ // element gets added to the DOM. If pageload hasn't finished yet, just
+ // wait for that by doing nothing; the actor registration event
+ // listeners will ensure we get the pageshow event.
+ // It is also possible we get created in response to the parent
+ // sending us a message - in that case, it's still worth doing the
+ // same things here:
+ if (!this.browsingContext || !this.browsingContext.associatedWindow) {
+ return;
+ }
+ let { document } = this.browsingContext.associatedWindow;
+ if (document.readyState != "complete") {
+ return;
+ }
+ // If we've already seen a pageshow, send stylesheets now:
+ this.#collectAndSendSheets();
+ }
+
+ handleEvent(event) {
+ if (event?.type != "pageshow") {
+ throw new Error("Unexpected event!");
+ }
+
+ // On page show, tell the parent all of the stylesheets this document
+ // has. If we are in the topmost browsing context, delete the stylesheets
+ // from the previous page.
+ if (this.browsingContext.top === this.browsingContext) {
+ this.sendAsyncMessage("PageStyle:Clear");
+ }
+
+ this.#collectAndSendSheets();
+ }
+
+ receiveMessage(msg) {
+ switch (msg.name) {
+ // Sent when the page's enabled style sheet is changed.
+ case "PageStyle:Switch":
+ if (this.browsingContext.top == this.browsingContext) {
+ this.browsingContext.authorStyleDisabledDefault = false;
+ }
+ this.docShell.docViewer.authorStyleDisabled = false;
+ this._switchStylesheet(msg.data.title);
+ break;
+ // Sent when "No Style" is chosen.
+ case "PageStyle:Disable":
+ if (this.browsingContext.top == this.browsingContext) {
+ this.browsingContext.authorStyleDisabledDefault = true;
+ }
+ this.docShell.docViewer.authorStyleDisabled = true;
+ break;
+ }
+ }
+
+ /**
+ * Returns links that would represent stylesheets once loaded.
+ */
+ _collectLinks(document) {
+ let result = [];
+ for (let link of document.querySelectorAll("link")) {
+ if (link.namespaceURI !== "http://www.w3.org/1999/xhtml") {
+ continue;
+ }
+ let isStyleSheet = Array.from(link.relList).some(
+ r => r.toLowerCase() == "stylesheet"
+ );
+ if (!isStyleSheet) {
+ continue;
+ }
+ if (!link.href) {
+ continue;
+ }
+ result.push(link);
+ }
+ return result;
+ }
+
+ /**
+ * Switch the stylesheet so that only the sheet with the given title is enabled.
+ */
+ _switchStylesheet(title) {
+ let document = this.document;
+ let docStyleSheets = Array.from(document.styleSheets);
+ let links;
+
+ // Does this doc contain a stylesheet with this title?
+ // If not, it's a subframe's stylesheet that's being changed,
+ // so no need to disable stylesheets here.
+ let docContainsStyleSheet = !title;
+ if (title) {
+ links = this._collectLinks(document);
+ docContainsStyleSheet =
+ docStyleSheets.some(sheet => sheet.title == title) ||
+ links.some(link => link.title == title);
+ }
+
+ for (let sheet of docStyleSheets) {
+ if (sheet.title) {
+ if (docContainsStyleSheet) {
+ sheet.disabled = sheet.title !== title;
+ }
+ } else if (sheet.disabled) {
+ sheet.disabled = false;
+ }
+ }
+
+ // If there's no title, we just need to disable potentially-enabled
+ // stylesheets via document.styleSheets, so no need to deal with links
+ // there.
+ //
+ // We don't want to enable <link rel="stylesheet" disabled> without title
+ // that were not enabled before.
+ if (title) {
+ for (let link of links) {
+ if (link.title == title && link.disabled) {
+ link.disabled = false;
+ }
+ }
+ }
+ }
+
+ #collectAndSendSheets() {
+ let window = this.browsingContext.associatedWindow;
+ window.requestIdleCallback(() => {
+ if (!window || window.closed) {
+ return;
+ }
+ let filteredStyleSheets = this.#collectStyleSheets(window);
+ this.sendAsyncMessage("PageStyle:Add", {
+ filteredStyleSheets,
+ preferredStyleSheetSet: this.document.preferredStyleSheetSet,
+ });
+ });
+ }
+
+ /**
+ * Get the stylesheets that have a title (and thus can be switched) in this
+ * webpage.
+ *
+ * @param content The window object for the page.
+ */
+ #collectStyleSheets(content) {
+ let result = [];
+ let document = content.document;
+
+ for (let sheet of document.styleSheets) {
+ let title = sheet.title;
+ if (!title) {
+ // Sheets without a title are not alternates.
+ continue;
+ }
+
+ // Skip any stylesheets that don't match the screen media type.
+ let media = sheet.media.mediaText;
+ if (media && !content.matchMedia(media).matches) {
+ continue;
+ }
+
+ // We skip links here, see below.
+ if (
+ sheet.href &&
+ sheet.ownerNode &&
+ sheet.ownerNode.nodeName.toLowerCase() == "link"
+ ) {
+ continue;
+ }
+
+ let disabled = sheet.disabled;
+ result.push({ title, disabled });
+ }
+
+ // This is tricky, because we can't just rely on document.styleSheets, as
+ // `<link disabled>` makes the sheet don't appear there at all.
+ for (let link of this._collectLinks(document)) {
+ let title = link.title;
+ if (!title) {
+ continue;
+ }
+
+ let media = link.media;
+ if (media && !content.matchMedia(media).matches) {
+ continue;
+ }
+
+ let disabled =
+ link.disabled ||
+ !!link.sheet?.disabled ||
+ document.preferredStyleSheetSet != title;
+ result.push({ title, disabled });
+ }
+
+ return result;
+ }
+}
diff --git a/browser/actors/PageStyleParent.sys.mjs b/browser/actors/PageStyleParent.sys.mjs
new file mode 100644
index 0000000000..26cf7bbf1a
--- /dev/null
+++ b/browser/actors/PageStyleParent.sys.mjs
@@ -0,0 +1,72 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export class PageStyleParent extends JSWindowActorParent {
+ // This has the most recent information about the content stylesheets for
+ // that actor. It's populated via the PageStyle:Add and PageStyle:Clear
+ // messages from the content process. It has the following structure:
+ //
+ // filteredStyleSheets (Array):
+ // An Array of objects with a filtered list representing all stylesheets
+ // that the current page offers. Each object has the following members:
+ //
+ // title (String):
+ // The title of the stylesheet
+ //
+ // disabled (bool):
+ // Whether or not the stylesheet is currently applied
+ //
+ // href (String):
+ // The URL of the stylesheet. Stylesheets loaded via a data URL will
+ // have this property set to null.
+ //
+ // preferredStyleSheetSet (bool):
+ // Whether or not the user currently has the "Default" style selected
+ // for the current page.
+ #styleSheetInfo = null;
+
+ receiveMessage(msg) {
+ // Check if things are alive:
+ let browser = this.browsingContext.top.embedderElement;
+ if (!browser || browser.ownerGlobal.closed) {
+ return;
+ }
+
+ // We always store information at the top of the frame tree.
+ let actor =
+ this.browsingContext.top.currentWindowGlobal.getActor("PageStyle");
+ switch (msg.name) {
+ case "PageStyle:Add":
+ actor.addSheetInfo(msg.data);
+ break;
+ case "PageStyle:Clear":
+ if (actor == this) {
+ this.#styleSheetInfo = null;
+ }
+ break;
+ }
+ }
+
+ /**
+ * Add/append styleSheets to the _pageStyleSheets weakmap.
+ * @param newSheetData
+ * The stylesheet data, including new stylesheets to add,
+ * and the preferred stylesheet set for this document.
+ */
+ addSheetInfo(newSheetData) {
+ let info = this.getSheetInfo();
+ info.filteredStyleSheets.push(...newSheetData.filteredStyleSheets);
+ info.preferredStyleSheetSet ||= newSheetData.preferredStyleSheetSet;
+ }
+
+ getSheetInfo() {
+ if (!this.#styleSheetInfo) {
+ this.#styleSheetInfo = {
+ filteredStyleSheets: [],
+ preferredStyleSheetSet: true,
+ };
+ }
+ return this.#styleSheetInfo;
+ }
+}
diff --git a/browser/actors/PluginChild.sys.mjs b/browser/actors/PluginChild.sys.mjs
new file mode 100644
index 0000000000..6eea749ef9
--- /dev/null
+++ b/browser/actors/PluginChild.sys.mjs
@@ -0,0 +1,92 @@
+/* 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/. */
+
+// Handle GMP crashes
+export class PluginChild extends JSWindowActorChild {
+ handleEvent(event) {
+ // Ignore events for other frames.
+ let eventDoc = event.target.ownerDocument || event.target.document;
+ if (eventDoc && eventDoc != this.document) {
+ return;
+ }
+
+ let eventType = event.type;
+ if (eventType == "PluginCrashed") {
+ this.onPluginCrashed(event);
+ }
+ }
+
+ /**
+ * Determines whether or not the crashed plugin is contained within current
+ * full screen DOM element.
+ * @param fullScreenElement (DOM element)
+ * The DOM element that is currently full screen, or null.
+ * @param domElement
+ * The DOM element which contains the crashed plugin, or the crashed plugin
+ * itself.
+ * @returns bool
+ * True if the plugin is a descendant of the full screen DOM element, false otherwise.
+ **/
+ isWithinFullScreenElement(fullScreenElement, domElement) {
+ /**
+ * Traverses down iframes until it find a non-iframe full screen DOM element.
+ * @param fullScreenIframe
+ * Target iframe to begin searching from.
+ * @returns DOM element
+ * The full screen DOM element contained within the iframe (could be inner iframe), or the original iframe if no inner DOM element is found.
+ **/
+ let getTrueFullScreenElement = fullScreenIframe => {
+ if (
+ typeof fullScreenIframe.contentDocument !== "undefined" &&
+ fullScreenIframe.contentDocument.mozFullScreenElement
+ ) {
+ return getTrueFullScreenElement(
+ fullScreenIframe.contentDocument.mozFullScreenElement
+ );
+ }
+ return fullScreenIframe;
+ };
+
+ if (fullScreenElement.tagName === "IFRAME") {
+ fullScreenElement = getTrueFullScreenElement(fullScreenElement);
+ }
+
+ if (fullScreenElement.contains(domElement)) {
+ return true;
+ }
+ let parentIframe = domElement.ownerGlobal.frameElement;
+ if (parentIframe) {
+ return this.isWithinFullScreenElement(fullScreenElement, parentIframe);
+ }
+ return false;
+ }
+
+ /**
+ * The PluginCrashed event handler. The target of the event is the
+ * document that GMP is being used in.
+ */
+ async onPluginCrashed(aEvent) {
+ if (!this.contentWindow.PluginCrashedEvent.isInstance(aEvent)) {
+ return;
+ }
+
+ let { target, gmpPlugin, pluginID } = aEvent;
+ let fullScreenElement =
+ this.contentWindow.top.document.mozFullScreenElement;
+ if (fullScreenElement) {
+ if (this.isWithinFullScreenElement(fullScreenElement, target)) {
+ this.contentWindow.top.document.mozCancelFullScreen();
+ }
+ }
+
+ if (!gmpPlugin || !target.document) {
+ // TODO: Throw exception? How did we get here?
+ return;
+ }
+
+ this.sendAsyncMessage("PluginContent:ShowPluginCrashedNotification", {
+ pluginCrashID: { pluginID },
+ });
+ }
+}
diff --git a/browser/actors/PluginParent.sys.mjs b/browser/actors/PluginParent.sys.mjs
new file mode 100644
index 0000000000..fa93c1d5ab
--- /dev/null
+++ b/browser/actors/PluginParent.sys.mjs
@@ -0,0 +1,204 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ CrashSubmit: "resource://gre/modules/CrashSubmit.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "gNavigatorBundle", function () {
+ const url = "chrome://browser/locale/browser.properties";
+ return Services.strings.createBundle(url);
+});
+
+export const PluginManager = {
+ gmpCrashes: new Map(),
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "gmp-plugin-crash":
+ this._registerGMPCrash(subject);
+ break;
+ }
+ },
+
+ _registerGMPCrash(subject) {
+ let propertyBag = subject;
+ if (
+ !(propertyBag instanceof Ci.nsIWritablePropertyBag2) ||
+ !propertyBag.hasKey("pluginID") ||
+ !propertyBag.hasKey("pluginDumpID") ||
+ !propertyBag.hasKey("pluginName")
+ ) {
+ console.error("PluginManager can not read plugin information.");
+ return;
+ }
+
+ let pluginID = propertyBag.getPropertyAsUint32("pluginID");
+ let pluginDumpID = propertyBag.getPropertyAsAString("pluginDumpID");
+ let pluginName = propertyBag.getPropertyAsACString("pluginName");
+ if (pluginDumpID) {
+ this.gmpCrashes.set(pluginID, { pluginDumpID, pluginID, pluginName });
+ }
+
+ // Only the parent process gets the gmp-plugin-crash observer
+ // notification, so we need to inform any content processes that
+ // the GMP has crashed. This then fires PluginCrashed events in
+ // all the relevant windows, which will trigger child actors being
+ // created, which will contact us again, when we'll use the
+ // gmpCrashes collection to respond.
+ if (Services.ppmm) {
+ Services.ppmm.broadcastAsyncMessage("gmp-plugin-crash", {
+ pluginName,
+ pluginID,
+ });
+ }
+ },
+
+ /**
+ * Submit a crash report for a crashed plugin.
+ *
+ * @param pluginCrashID
+ * An object with a pluginID.
+ * @param keyVals
+ * An object whose key-value pairs will be merged
+ * with the ".extra" file submitted with the report.
+ * The properties of htis object will override properties
+ * of the same name in the .extra file.
+ */
+ submitCrashReport(pluginCrashID, keyVals = {}) {
+ let report = this.getCrashReport(pluginCrashID);
+ if (!report) {
+ console.error(
+ `Could not find plugin dump IDs for ${JSON.stringify(pluginCrashID)}.` +
+ `It is possible that a report was already submitted.`
+ );
+ return;
+ }
+
+ let { pluginDumpID } = report;
+ lazy.CrashSubmit.submit(
+ pluginDumpID,
+ lazy.CrashSubmit.SUBMITTED_FROM_CRASH_TAB,
+ {
+ recordSubmission: true,
+ extraExtraKeyVals: keyVals,
+ }
+ );
+
+ this.gmpCrashes.delete(pluginCrashID.pluginID);
+ },
+
+ getCrashReport(pluginCrashID) {
+ return this.gmpCrashes.get(pluginCrashID.pluginID);
+ },
+};
+
+export class PluginParent extends JSWindowActorParent {
+ receiveMessage(msg) {
+ let browser = this.manager.rootFrameLoader.ownerElement;
+ switch (msg.name) {
+ case "PluginContent:ShowPluginCrashedNotification":
+ this.showPluginCrashedNotification(browser, msg.data.pluginCrashID);
+ break;
+
+ default:
+ console.error(
+ "PluginParent did not expect to handle message ",
+ msg.name
+ );
+ break;
+ }
+
+ return null;
+ }
+
+ /**
+ * Shows a plugin-crashed notification bar for a browser that has had a
+ * GMP plugin crash.
+ *
+ * @param browser
+ * The browser to show the notification for.
+ * @param pluginCrashID
+ * The unique-per-process identifier for GMP.
+ */
+ showPluginCrashedNotification(browser, pluginCrashID) {
+ // If there's already an existing notification bar, don't do anything.
+ let notificationBox = browser.getTabBrowser().getNotificationBox(browser);
+ let notification =
+ notificationBox.getNotificationWithValue("plugin-crashed");
+
+ let report = PluginManager.getCrashReport(pluginCrashID);
+ if (notification || !report) {
+ return;
+ }
+
+ // Configure the notification bar
+ let priority = notificationBox.PRIORITY_WARNING_MEDIUM;
+ let iconURL = "chrome://global/skin/icons/plugin.svg";
+ let reloadLabel = lazy.gNavigatorBundle.GetStringFromName(
+ "crashedpluginsMessage.reloadButton.label"
+ );
+ let reloadKey = lazy.gNavigatorBundle.GetStringFromName(
+ "crashedpluginsMessage.reloadButton.accesskey"
+ );
+
+ let buttons = [
+ {
+ label: reloadLabel,
+ accessKey: reloadKey,
+ popup: null,
+ callback() {
+ browser.reload();
+ },
+ },
+ ];
+
+ if (AppConstants.MOZ_CRASHREPORTER) {
+ let submitLabel = lazy.gNavigatorBundle.GetStringFromName(
+ "crashedpluginsMessage.submitButton.label"
+ );
+ let submitKey = lazy.gNavigatorBundle.GetStringFromName(
+ "crashedpluginsMessage.submitButton.accesskey"
+ );
+ let submitButton = {
+ label: submitLabel,
+ accessKey: submitKey,
+ popup: null,
+ callback: () => {
+ PluginManager.submitCrashReport(pluginCrashID);
+ },
+ };
+
+ buttons.push(submitButton);
+ }
+
+ // Add the "learn more" link.
+ let learnMoreLink = {
+ supportPage: "plugin-crashed-notificationbar",
+ label: lazy.gNavigatorBundle.GetStringFromName(
+ "crashedpluginsMessage.learnMore"
+ ),
+ };
+ buttons.push(learnMoreLink);
+
+ let messageString = lazy.gNavigatorBundle.formatStringFromName(
+ "crashedpluginsMessage.title",
+ [report.pluginName]
+ );
+ notificationBox.appendNotification(
+ "plugin-crashed",
+ {
+ label: messageString,
+ image: iconURL,
+ priority,
+ },
+ buttons
+ );
+ }
+}
diff --git a/browser/actors/PointerLockChild.sys.mjs b/browser/actors/PointerLockChild.sys.mjs
new file mode 100644
index 0000000000..f042fff98f
--- /dev/null
+++ b/browser/actors/PointerLockChild.sys.mjs
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export class PointerLockChild extends JSWindowActorChild {
+ handleEvent(event) {
+ switch (event.type) {
+ case "MozDOMPointerLock:Entered":
+ this.sendAsyncMessage("PointerLock:Entered");
+ break;
+
+ case "MozDOMPointerLock:Exited":
+ this.sendAsyncMessage("PointerLock:Exited");
+ break;
+ }
+ }
+}
diff --git a/browser/actors/PointerLockParent.sys.mjs b/browser/actors/PointerLockParent.sys.mjs
new file mode 100644
index 0000000000..8d36b5cb57
--- /dev/null
+++ b/browser/actors/PointerLockParent.sys.mjs
@@ -0,0 +1,22 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export class PointerLockParent extends JSWindowActorParent {
+ receiveMessage(message) {
+ let browser = this.manager.browsingContext.top.embedderElement;
+ switch (message.name) {
+ case "PointerLock:Entered": {
+ browser.ownerGlobal.PointerLock.entered(
+ this.manager.documentPrincipal.originNoSuffix
+ );
+ break;
+ }
+
+ case "PointerLock:Exited": {
+ browser.ownerGlobal.PointerLock.exited();
+ break;
+ }
+ }
+ }
+}
diff --git a/browser/actors/PromptParent.sys.mjs b/browser/actors/PromptParent.sys.mjs
new file mode 100644
index 0000000000..1407e06a75
--- /dev/null
+++ b/browser/actors/PromptParent.sys.mjs
@@ -0,0 +1,468 @@
+/* vim: set ts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PromptUtils: "resource://gre/modules/PromptUtils.sys.mjs",
+ BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
+});
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "tabChromePromptSubDialog",
+ "prompts.tabChromePromptSubDialog",
+ false
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "contentPromptSubDialog",
+ "prompts.contentPromptSubDialog",
+ false
+);
+
+ChromeUtils.defineLazyGetter(lazy, "gTabBrowserLocalization", function () {
+ return new Localization(["browser/tabbrowser.ftl"], true);
+});
+
+/**
+ * @typedef {Object} Prompt
+ * @property {Function} resolver
+ * The resolve function to be called with the data from the Prompt
+ * after the user closes it.
+ * @property {Object} tabModalPrompt
+ * The TabModalPrompt being shown to the user.
+ */
+
+/**
+ * gBrowserPrompts weakly maps BrowsingContexts to a Map of their currently
+ * active Prompts.
+ *
+ * @type {WeakMap<BrowsingContext, Prompt>}
+ */
+let gBrowserPrompts = new WeakMap();
+
+export class PromptParent extends JSWindowActorParent {
+ didDestroy() {
+ // In the event that the subframe or tab crashed, make sure that
+ // we close any active Prompts.
+ this.forceClosePrompts();
+ }
+
+ /**
+ * Registers a new Prompt to be tracked for a particular BrowsingContext.
+ * We need to track a Prompt so that we can, for example, force-close the
+ * TabModalPrompt if the originating subframe or tab unloads or crashes.
+ *
+ * @param {Object} tabModalPrompt
+ * The TabModalPrompt that will be shown to the user.
+ * @param {string} id
+ * A unique ID to differentiate multiple Prompts coming from the same
+ * BrowsingContext.
+ * @return {Promise}
+ * @resolves {Object}
+ * Resolves with the arguments returned from the TabModalPrompt when it
+ * is dismissed.
+ */
+ registerPrompt(tabModalPrompt, id) {
+ let prompts = gBrowserPrompts.get(this.browsingContext);
+ if (!prompts) {
+ prompts = new Map();
+ gBrowserPrompts.set(this.browsingContext, prompts);
+ }
+
+ let promise = new Promise(resolve => {
+ prompts.set(id, {
+ tabModalPrompt,
+ resolver: resolve,
+ });
+ });
+
+ return promise;
+ }
+
+ /**
+ * Removes a Prompt for a BrowsingContext with a particular ID from the registry.
+ * This needs to be done to avoid leaking <xul:browser>'s.
+ *
+ * @param {string} id
+ * A unique ID to differentiate multiple Prompts coming from the same
+ * BrowsingContext.
+ */
+ unregisterPrompt(id) {
+ let prompts = gBrowserPrompts.get(this.browsingContext);
+ if (prompts) {
+ prompts.delete(id);
+ }
+ }
+
+ /**
+ * Programmatically closes all Prompts for the current BrowsingContext.
+ */
+ forceClosePrompts() {
+ let prompts = gBrowserPrompts.get(this.browsingContext) || [];
+
+ for (let [, prompt] of prompts) {
+ prompt.tabModalPrompt && prompt.tabModalPrompt.abortPrompt();
+ }
+ }
+
+ isAboutAddonsOptionsPage(browsingContext) {
+ const { embedderWindowGlobal, name } = browsingContext;
+ if (!embedderWindowGlobal) {
+ // Return earlier if there is no embedder global, this is definitely
+ // not an about:addons extensions options page.
+ return false;
+ }
+
+ return (
+ embedderWindowGlobal.documentPrincipal.isSystemPrincipal &&
+ embedderWindowGlobal.documentURI.spec === "about:addons" &&
+ name === "addon-inline-options"
+ );
+ }
+
+ receiveMessage(message) {
+ let args = message.data;
+ let id = args._remoteId;
+
+ switch (message.name) {
+ case "Prompt:Open":
+ if (!this.windowContext.isActiveInTab) {
+ return undefined;
+ }
+
+ if (
+ (args.modalType === Ci.nsIPrompt.MODAL_TYPE_CONTENT &&
+ !lazy.contentPromptSubDialog) ||
+ (args.modalType === Ci.nsIPrompt.MODAL_TYPE_TAB &&
+ !lazy.tabChromePromptSubDialog) ||
+ this.isAboutAddonsOptionsPage(this.browsingContext)
+ ) {
+ return this.openContentPrompt(args, id);
+ }
+ return this.openPromptWithTabDialogBox(args);
+ }
+
+ return undefined;
+ }
+
+ /**
+ * Opens a TabModalPrompt for a BrowsingContext, and puts the associated browser
+ * in the modal state until the TabModalPrompt is closed.
+ *
+ * @param {Object} args
+ * The arguments passed up from the BrowsingContext to be passed directly
+ * to the TabModalPrompt.
+ * @param {string} id
+ * A unique ID to differentiate multiple Prompts coming from the same
+ * BrowsingContext.
+ * @return {Promise}
+ * Resolves when the TabModalPrompt is dismissed.
+ * @resolves {Object}
+ * The arguments returned from the TabModalPrompt.
+ */
+ openContentPrompt(args, id) {
+ let browser = this.browsingContext.top.embedderElement;
+ if (!browser) {
+ throw new Error("Cannot tab-prompt without a browser!");
+ }
+ let window = browser.ownerGlobal;
+ let tabPrompt = window.gBrowser.getTabModalPromptBox(browser);
+ let newPrompt;
+ let needRemove = false;
+
+ // If the page which called the prompt is different from the the top context
+ // where we show the prompt, ask the prompt implementation to display the origin.
+ // For example, this can happen if a cross origin subframe shows a prompt.
+ args.showCallerOrigin =
+ args.promptPrincipal &&
+ !browser.contentPrincipal.equals(args.promptPrincipal);
+
+ let onPromptClose = () => {
+ let promptData = gBrowserPrompts.get(this.browsingContext);
+ if (!promptData || !promptData.has(id)) {
+ throw new Error(
+ "Failed to close a prompt since it wasn't registered for some reason."
+ );
+ }
+
+ let { resolver, tabModalPrompt } = promptData.get(id);
+ // It's possible that we removed the prompt during the
+ // appendPrompt call below. In that case, newPrompt will be
+ // undefined. We set the needRemove flag to remember to remove
+ // it right after we've finished adding it.
+ if (tabModalPrompt) {
+ tabPrompt.removePrompt(tabModalPrompt);
+ } else {
+ needRemove = true;
+ }
+
+ this.unregisterPrompt(id);
+
+ lazy.PromptUtils.fireDialogEvent(
+ window,
+ "DOMModalDialogClosed",
+ browser,
+ this.getClosingEventDetail(args)
+ );
+ resolver(args);
+ browser.maybeLeaveModalState();
+ };
+
+ try {
+ browser.enterModalState();
+ lazy.PromptUtils.fireDialogEvent(
+ window,
+ "DOMWillOpenModalDialog",
+ browser,
+ this.getOpenEventDetail(args)
+ );
+
+ args.promptActive = true;
+
+ newPrompt = tabPrompt.appendPrompt(args, onPromptClose);
+ let promise = this.registerPrompt(newPrompt, id);
+
+ if (needRemove) {
+ tabPrompt.removePrompt(newPrompt);
+ }
+
+ return promise;
+ } catch (ex) {
+ console.error(ex);
+ onPromptClose(true);
+ }
+
+ return null;
+ }
+
+ /**
+ * Opens either a window prompt or TabDialogBox at the content or tab level
+ * for a BrowsingContext, and puts the associated browser in the modal state
+ * until the prompt is closed.
+ *
+ * @param {Object} args
+ * The arguments passed up from the BrowsingContext to be passed
+ * directly to the modal prompt.
+ * @return {Promise}
+ * Resolves when the modal prompt is dismissed.
+ * @resolves {Object}
+ * The arguments returned from the modal prompt.
+ */
+ async openPromptWithTabDialogBox(args) {
+ const COMMON_DIALOG = "chrome://global/content/commonDialog.xhtml";
+ const SELECT_DIALOG = "chrome://global/content/selectDialog.xhtml";
+ let uri = args.promptType == "select" ? SELECT_DIALOG : COMMON_DIALOG;
+
+ let browsingContext = this.browsingContext.top;
+
+ let browser = browsingContext.embedderElement;
+ let promptRequiresBrowser =
+ args.modalType === Services.prompt.MODAL_TYPE_TAB ||
+ args.modalType === Services.prompt.MODAL_TYPE_CONTENT;
+ if (promptRequiresBrowser && !browser) {
+ let modal_type =
+ args.modalType === Services.prompt.MODAL_TYPE_TAB ? "tab" : "content";
+ throw new Error(`Cannot ${modal_type}-prompt without a browser!`);
+ }
+
+ let win;
+
+ // If we are a chrome actor we can use the associated chrome win.
+ if (!browsingContext.isContent && browsingContext.window) {
+ win = browsingContext.window;
+ } else {
+ win = browser?.ownerGlobal;
+ }
+
+ // There's a requirement for prompts to be blocked if a window is
+ // passed and that window is hidden (eg, auth prompts are suppressed if the
+ // passed window is the hidden window).
+ // See bug 875157 comment 30 for more..
+ if (win?.winUtils && !win.winUtils.isParentWindowMainWidgetVisible) {
+ throw new Error("Cannot open a prompt in a hidden window");
+ }
+
+ try {
+ if (browser) {
+ browser.enterModalState();
+ lazy.PromptUtils.fireDialogEvent(
+ win,
+ "DOMWillOpenModalDialog",
+ browser,
+ this.getOpenEventDetail(args)
+ );
+ }
+
+ args.promptAborted = false;
+ args.openedWithTabDialog = true;
+ args.owningBrowsingContext = this.browsingContext;
+
+ // Convert args object to a prop bag for the dialog to consume.
+ let bag;
+
+ if (promptRequiresBrowser && win?.gBrowser?.getTabDialogBox) {
+ // Tab or content level prompt
+ let dialogBox = win.gBrowser.getTabDialogBox(browser);
+
+ if (dialogBox._allowTabFocusByPromptPrincipal) {
+ this.addTabSwitchCheckboxToArgs(dialogBox, args);
+ }
+
+ let currentLocationsTabLabel;
+
+ let targetTab = win.gBrowser.getTabForBrowser(browser);
+ if (
+ !Services.prefs.getBoolPref(
+ "privacy.authPromptSpoofingProtection",
+ false
+ )
+ ) {
+ args.isTopLevelCrossDomainAuth = false;
+ }
+ // Auth prompt spoofing protection, see bug 791594.
+ if (args.isTopLevelCrossDomainAuth && targetTab) {
+ // Set up the url bar with the url of the cross domain resource.
+ // onLocationChange will change the url back to the current browsers
+ // if we do not hold the state here.
+ // onLocationChange will favour currentAuthPromptURI over the current browsers uri
+ browser.currentAuthPromptURI = args.channel.URI;
+ if (browser == win.gBrowser.selectedBrowser) {
+ win.gURLBar.setURI();
+ }
+ // Set up the tab title for the cross domain resource.
+ // We need to remember the original tab title in case
+ // the load does not happen after the prompt, then we need to reset the tab title manually.
+ currentLocationsTabLabel = targetTab.label;
+ win.gBrowser.setTabLabelForAuthPrompts(
+ targetTab,
+ lazy.BrowserUtils.formatURIForDisplay(args.channel.URI)
+ );
+ }
+ bag = lazy.PromptUtils.objectToPropBag(args);
+ try {
+ await dialogBox.open(
+ uri,
+ {
+ features: "resizable=no",
+ modalType: args.modalType,
+ allowFocusCheckbox: args.allowFocusCheckbox,
+ hideContent: args.isTopLevelCrossDomainAuth,
+ },
+ bag
+ ).closedPromise;
+ } finally {
+ if (args.isTopLevelCrossDomainAuth) {
+ browser.currentAuthPromptURI = null;
+ // If the user is stopping the page load before answering the prompt, no navigation will happen after the prompt
+ // so we need to reset the uri and tab title here to the current browsers for that specific case
+ if (browser == win.gBrowser.selectedBrowser) {
+ win.gURLBar.setURI();
+ }
+ win.gBrowser.setTabLabelForAuthPrompts(
+ targetTab,
+ currentLocationsTabLabel
+ );
+ }
+ }
+ } else {
+ // Ensure we set the correct modal type at this point.
+ // If we use window prompts as a fallback it may not be set.
+ args.modalType = Services.prompt.MODAL_TYPE_WINDOW;
+ // Window prompt
+ bag = lazy.PromptUtils.objectToPropBag(args);
+ Services.ww.openWindow(
+ win,
+ uri,
+ "_blank",
+ "centerscreen,chrome,modal,titlebar",
+ bag
+ );
+ }
+
+ lazy.PromptUtils.propBagToObject(bag, args);
+ } finally {
+ if (browser) {
+ browser.maybeLeaveModalState();
+ lazy.PromptUtils.fireDialogEvent(
+ win,
+ "DOMModalDialogClosed",
+ browser,
+ this.getClosingEventDetail(args)
+ );
+ }
+ }
+ return args;
+ }
+
+ getClosingEventDetail(args) {
+ let details =
+ args.modalType === Services.prompt.MODAL_TYPE_CONTENT
+ ? {
+ wasPermitUnload: args.inPermitUnload,
+ areLeaving: args.ok,
+ // If a prompt was not accepted, do not return the prompt value.
+ value: args.ok ? args.value : null,
+ }
+ : null;
+
+ return details;
+ }
+
+ getOpenEventDetail(args) {
+ let details =
+ args.modalType === Services.prompt.MODAL_TYPE_CONTENT
+ ? {
+ inPermitUnload: args.inPermitUnload,
+ promptPrincipal: args.promptPrincipal,
+ tabPrompt: true,
+ }
+ : null;
+
+ return details;
+ }
+
+ /**
+ * Set properties on `args` needed by the dialog to allow tab switching for the
+ * page that opened the prompt.
+ *
+ * @param {TabDialogBox} dialogBox
+ * The dialog to show the tab-switch checkbox for.
+ * @param {Object} args
+ * The `args` object to set tab switching permission info on.
+ */
+ addTabSwitchCheckboxToArgs(dialogBox, args) {
+ let allowTabFocusByPromptPrincipal =
+ dialogBox._allowTabFocusByPromptPrincipal;
+
+ if (
+ allowTabFocusByPromptPrincipal &&
+ args.modalType === Services.prompt.MODAL_TYPE_CONTENT
+ ) {
+ let domain = allowTabFocusByPromptPrincipal.addonPolicy?.name;
+ try {
+ domain ||= allowTabFocusByPromptPrincipal.URI.displayHostPort;
+ } catch (ex) {
+ /* Ignore exceptions from fetching the display host/port. */
+ }
+ // If it's still empty, use `prePath` so we have *something* to show:
+ domain ||= allowTabFocusByPromptPrincipal.URI.prePath;
+ let [allowFocusMsg] = lazy.gTabBrowserLocalization.formatMessagesSync([
+ {
+ id: "tabbrowser-allow-dialogs-to-get-focus",
+ args: { domain },
+ },
+ ]);
+ let labelAttr = allowFocusMsg.attributes.find(a => a.name == "label");
+ if (labelAttr) {
+ args.allowFocusCheckbox = true;
+ args.checkLabel = labelAttr.value;
+ }
+ }
+ }
+}
diff --git a/browser/actors/RFPHelperChild.sys.mjs b/browser/actors/RFPHelperChild.sys.mjs
new file mode 100644
index 0000000000..14b17bc2db
--- /dev/null
+++ b/browser/actors/RFPHelperChild.sys.mjs
@@ -0,0 +1,25 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const kPrefLetterboxing = "privacy.resistFingerprinting.letterboxing";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "isLetterboxingEnabled",
+ kPrefLetterboxing,
+ false
+);
+
+export class RFPHelperChild extends JSWindowActorChild {
+ handleEvent(event) {
+ if (lazy.isLetterboxingEnabled && event.type == "resize") {
+ this.sendAsyncMessage("Letterboxing:ContentSizeUpdated");
+ }
+ }
+}
diff --git a/browser/actors/RFPHelperParent.sys.mjs b/browser/actors/RFPHelperParent.sys.mjs
new file mode 100644
index 0000000000..0e4e3e8be6
--- /dev/null
+++ b/browser/actors/RFPHelperParent.sys.mjs
@@ -0,0 +1,33 @@
+1; /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ RFPHelper: "resource://gre/modules/RFPHelper.sys.mjs",
+});
+
+const kPrefLetterboxing = "privacy.resistFingerprinting.letterboxing";
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "isLetterboxingEnabled",
+ kPrefLetterboxing,
+ false
+);
+
+export class RFPHelperParent extends JSWindowActorParent {
+ receiveMessage(aMessage) {
+ if (
+ lazy.isLetterboxingEnabled &&
+ aMessage.name == "Letterboxing:ContentSizeUpdated"
+ ) {
+ let browser = this.browsingContext.top.embedderElement;
+ let window = browser.ownerGlobal;
+ lazy.RFPHelper.contentSizeUpdated(window);
+ }
+ }
+}
diff --git a/browser/actors/RefreshBlockerChild.sys.mjs b/browser/actors/RefreshBlockerChild.sys.mjs
new file mode 100644
index 0000000000..6ba63298b1
--- /dev/null
+++ b/browser/actors/RefreshBlockerChild.sys.mjs
@@ -0,0 +1,234 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This file has two actors, RefreshBlockerChild js a window actor which
+ * handles the refresh notifications. RefreshBlockerObserverChild is a process
+ * actor that enables refresh blocking on each docshell that is created.
+ */
+
+import { setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+
+const REFRESHBLOCKING_PREF = "accessibility.blockautorefresh";
+
+var progressListener = {
+ // Bug 1247100 - When a refresh is caused by an HTTP header,
+ // onRefreshAttempted will be fired before onLocationChange.
+ // When a refresh is caused by a <meta> tag in the document,
+ // onRefreshAttempted will be fired after onLocationChange.
+ //
+ // We only ever want to send a message to the parent after
+ // onLocationChange has fired, since the parent uses the
+ // onLocationChange update to clear transient notifications.
+ // Sending the message before onLocationChange will result in
+ // us creating the notification, and then clearing it very
+ // soon after.
+ //
+ // To account for both cases (onRefreshAttempted before
+ // onLocationChange, and onRefreshAttempted after onLocationChange),
+ // we'll hold a mapping of DOM Windows that we see get
+ // sent through both onLocationChange and onRefreshAttempted.
+ // When either run, they'll check the WeakMap for the existence
+ // of the DOM Window. If it doesn't exist, it'll add it. If
+ // it finds it, it'll know that it's safe to send the message
+ // to the parent, since we know that both have fired.
+ //
+ // The DOM Window is removed from blockedWindows when we notice
+ // the nsIWebProgress change state to STATE_STOP for the
+ // STATE_IS_WINDOW case.
+ //
+ // DOM Windows are mapped to a JS object that contains the data
+ // to be sent to the parent to show the notification. Since that
+ // data is only known when onRefreshAttempted is fired, it's only
+ // ever stashed in the map if onRefreshAttempted fires first -
+ // otherwise, null is set as the value of the mapping.
+ blockedWindows: new WeakMap(),
+
+ /**
+ * Notices when the nsIWebProgress transitions to STATE_STOP for
+ * the STATE_IS_WINDOW case, which will clear any mappings from
+ * blockedWindows.
+ */
+ onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) {
+ if (
+ aStateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW &&
+ aStateFlags & Ci.nsIWebProgressListener.STATE_STOP
+ ) {
+ this.blockedWindows.delete(aWebProgress.DOMWindow);
+ }
+ },
+
+ /**
+ * Notices when the location has changed. If, when running,
+ * onRefreshAttempted has already fired for this DOM Window, will
+ * send the appropriate refresh blocked data to the parent.
+ */
+ onLocationChange(aWebProgress, aRequest, aLocation, aFlags) {
+ let win = aWebProgress.DOMWindow;
+ if (this.blockedWindows.has(win)) {
+ let data = this.blockedWindows.get(win);
+ if (data) {
+ // We saw onRefreshAttempted before onLocationChange, so
+ // send the message to the parent to show the notification.
+ this.send(win, data);
+ }
+ } else {
+ this.blockedWindows.set(win, null);
+ }
+ },
+
+ /**
+ * Notices when a refresh / reload was attempted. If, when running,
+ * onLocationChange has not yet run, will stash the appropriate data
+ * into the blockedWindows map to be sent when onLocationChange fires.
+ */
+ onRefreshAttempted(aWebProgress, aURI, aDelay, aSameURI) {
+ let win = aWebProgress.DOMWindow;
+
+ let data = {
+ browsingContext: win.browsingContext,
+ URI: aURI.spec,
+ delay: aDelay,
+ sameURI: aSameURI,
+ };
+
+ if (this.blockedWindows.has(win)) {
+ // onLocationChange must have fired before, so we can tell the
+ // parent to show the notification.
+ this.send(win, data);
+ } else {
+ // onLocationChange hasn't fired yet, so stash the data in the
+ // map so that onLocationChange can send it when it fires.
+ this.blockedWindows.set(win, data);
+ }
+
+ return false;
+ },
+
+ send(win, data) {
+ // Due to the |nsDocLoader| calling its |nsIWebProgressListener|s in
+ // reverse order, this will occur *before* the |BrowserChild| can send its
+ // |OnLocationChange| event to the parent, but we need this message to
+ // arrive after to ensure that the refresh blocker notification is not
+ // immediately cleared by the |OnLocationChange| from |BrowserChild|.
+ setTimeout(() => {
+ // An exception can occur if refresh blocking was turned off
+ // during a pageload.
+ try {
+ let actor = win.windowGlobalChild.getActor("RefreshBlocker");
+ if (actor) {
+ actor.sendAsyncMessage("RefreshBlocker:Blocked", data);
+ }
+ } catch (ex) {}
+ }, 0);
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener2",
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+};
+
+export class RefreshBlockerChild extends JSWindowActorChild {
+ didDestroy() {
+ // If the refresh blocking preference is turned off, all of the
+ // RefreshBlockerChild actors will get destroyed, so disable
+ // refresh blocking only in this case.
+ if (!Services.prefs.getBoolPref(REFRESHBLOCKING_PREF)) {
+ this.disable(this.docShell);
+ }
+ }
+
+ enable() {
+ ChromeUtils.domProcessChild
+ .getActor("RefreshBlockerObserver")
+ .enable(this.docShell);
+ }
+
+ disable() {
+ ChromeUtils.domProcessChild
+ .getActor("RefreshBlockerObserver")
+ .disable(this.docShell);
+ }
+
+ receiveMessage(message) {
+ let data = message.data;
+
+ switch (message.name) {
+ case "RefreshBlocker:Refresh":
+ let docShell = data.browsingContext.docShell;
+ let refreshURI = docShell.QueryInterface(Ci.nsIRefreshURI);
+ let URI = Services.io.newURI(data.URI);
+ refreshURI.forceRefreshURI(URI, null, data.delay);
+ break;
+
+ case "PreferenceChanged":
+ if (data.isEnabled) {
+ this.enable(this.docShell);
+ } else {
+ this.disable(this.docShell);
+ }
+ }
+ }
+}
+
+export class RefreshBlockerObserverChild extends JSProcessActorChild {
+ constructor() {
+ super();
+ this.filtersMap = new Map();
+ }
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "webnavigation-create":
+ case "chrome-webnavigation-create":
+ if (Services.prefs.getBoolPref(REFRESHBLOCKING_PREF)) {
+ this.enable(subject.QueryInterface(Ci.nsIDocShell));
+ }
+ break;
+
+ case "webnavigation-destroy":
+ case "chrome-webnavigation-destroy":
+ if (Services.prefs.getBoolPref(REFRESHBLOCKING_PREF)) {
+ this.disable(subject.QueryInterface(Ci.nsIDocShell));
+ }
+ break;
+ }
+ }
+
+ enable(docShell) {
+ if (this.filtersMap.has(docShell)) {
+ return;
+ }
+
+ let filter = Cc[
+ "@mozilla.org/appshell/component/browser-status-filter;1"
+ ].createInstance(Ci.nsIWebProgress);
+
+ filter.addProgressListener(progressListener, Ci.nsIWebProgress.NOTIFY_ALL);
+
+ this.filtersMap.set(docShell, filter);
+
+ let webProgress = docShell
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebProgress);
+ webProgress.addProgressListener(filter, Ci.nsIWebProgress.NOTIFY_ALL);
+ }
+
+ disable(docShell) {
+ let filter = this.filtersMap.get(docShell);
+ if (!filter) {
+ return;
+ }
+
+ let webProgress = docShell
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebProgress);
+ webProgress.removeProgressListener(filter);
+
+ filter.removeProgressListener(progressListener);
+ this.filtersMap.delete(docShell);
+ }
+}
diff --git a/browser/actors/RefreshBlockerParent.sys.mjs b/browser/actors/RefreshBlockerParent.sys.mjs
new file mode 100644
index 0000000000..dcdbe67b69
--- /dev/null
+++ b/browser/actors/RefreshBlockerParent.sys.mjs
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export class RefreshBlockerParent extends JSWindowActorParent {
+ receiveMessage(message) {
+ if (message.name == "RefreshBlocker:Blocked") {
+ let browser = this.browsingContext.top.embedderElement;
+ if (browser) {
+ let gBrowser = browser.ownerGlobal.gBrowser;
+ if (gBrowser) {
+ gBrowser.refreshBlocked(this, browser, message.data);
+ }
+ }
+ }
+ }
+}
diff --git a/browser/actors/ScreenshotsComponentChild.sys.mjs b/browser/actors/ScreenshotsComponentChild.sys.mjs
new file mode 100644
index 0000000000..0a4d6d2539
--- /dev/null
+++ b/browser/actors/ScreenshotsComponentChild.sys.mjs
@@ -0,0 +1,353 @@
+/* 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/browser-window */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+ ScreenshotsOverlay: "resource:///modules/ScreenshotsOverlayChild.sys.mjs",
+});
+
+export class ScreenshotsComponentChild extends JSWindowActorChild {
+ #resizeTask;
+ #scrollTask;
+ #overlay;
+
+ static OVERLAY_EVENTS = [
+ "click",
+ "pointerdown",
+ "pointermove",
+ "pointerup",
+ "keyup",
+ "keydown",
+ ];
+
+ get overlay() {
+ return this.#overlay;
+ }
+
+ receiveMessage(message) {
+ switch (message.name) {
+ case "Screenshots:ShowOverlay":
+ return this.startScreenshotsOverlay();
+ case "Screenshots:HideOverlay":
+ return this.endScreenshotsOverlay(message.data);
+ case "Screenshots:isOverlayShowing":
+ return this.overlay?.initialized;
+ case "Screenshots:getFullPageBounds":
+ return this.getFullPageBounds();
+ case "Screenshots:getVisibleBounds":
+ return this.getVisibleBounds();
+ case "Screenshots:getDocumentTitle":
+ return this.getDocumentTitle();
+ case "Screenshots:GetMethodsUsed":
+ return this.getMethodsUsed();
+ }
+ return null;
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "click":
+ case "pointerdown":
+ case "pointermove":
+ case "pointerup":
+ case "keyup":
+ case "keydown":
+ if (!this.overlay?.initialized) {
+ return;
+ }
+ this.overlay.handleEvent(event);
+ break;
+ case "beforeunload":
+ this.requestCancelScreenshot("navigation");
+ break;
+ case "resize":
+ if (!this.#resizeTask && this.overlay?.initialized) {
+ this.#resizeTask = new lazy.DeferredTask(() => {
+ this.overlay.updateScreenshotsOverlayDimensions("resize");
+ }, 16);
+ }
+ this.#resizeTask.arm();
+ break;
+ case "scroll":
+ if (!this.#scrollTask && this.overlay?.initialized) {
+ this.#scrollTask = new lazy.DeferredTask(() => {
+ this.overlay.updateScreenshotsOverlayDimensions("scroll");
+ }, 16);
+ }
+ this.#scrollTask.arm();
+ break;
+ case "visibilitychange":
+ if (
+ event.target.visibilityState === "hidden" &&
+ this.overlay?.state === "crosshairs"
+ ) {
+ this.requestCancelScreenshot("navigation");
+ }
+ break;
+ case "Screenshots:Close":
+ this.requestCancelScreenshot(event.detail.reason);
+ break;
+ case "Screenshots:Copy":
+ this.requestCopyScreenshot(event.detail.region);
+ break;
+ case "Screenshots:Download":
+ this.requestDownloadScreenshot(event.detail.region);
+ break;
+ case "Screenshots:OverlaySelection":
+ let { hasSelection } = event.detail;
+ this.sendOverlaySelection({ hasSelection });
+ break;
+ case "Screenshots:RecordEvent":
+ let { eventName, reason, args } = event.detail;
+ this.recordTelemetryEvent(eventName, reason, args);
+ break;
+ case "Screenshots:ShowPanel":
+ this.showPanel();
+ break;
+ case "Screenshots:HidePanel":
+ this.hidePanel();
+ break;
+ }
+ }
+
+ /**
+ * Send a request to cancel the screenshot to the parent process
+ */
+ requestCancelScreenshot(reason) {
+ this.sendAsyncMessage("Screenshots:CancelScreenshot", {
+ closeOverlay: false,
+ reason,
+ });
+ this.endScreenshotsOverlay();
+ }
+
+ /**
+ * Send a request to copy the screenshots
+ * @param {Object} region The region dimensions of the screenshot to be copied
+ */
+ requestCopyScreenshot(region) {
+ region.devicePixelRatio = this.contentWindow.devicePixelRatio;
+ this.sendAsyncMessage("Screenshots:CopyScreenshot", { region });
+ this.endScreenshotsOverlay({ doNotResetMethods: true });
+ }
+
+ /**
+ * Send a request to download the screenshots
+ * @param {Object} region The region dimensions of the screenshot to be downloaded
+ */
+ requestDownloadScreenshot(region) {
+ region.devicePixelRatio = this.contentWindow.devicePixelRatio;
+ this.sendAsyncMessage("Screenshots:DownloadScreenshot", {
+ title: this.getDocumentTitle(),
+ region,
+ });
+ this.endScreenshotsOverlay({ doNotResetMethods: true });
+ }
+
+ showPanel() {
+ this.sendAsyncMessage("Screenshots:ShowPanel");
+ }
+
+ hidePanel() {
+ this.sendAsyncMessage("Screenshots:HidePanel");
+ }
+
+ getDocumentTitle() {
+ return this.document.title;
+ }
+
+ sendOverlaySelection(data) {
+ this.sendAsyncMessage("Screenshots:OverlaySelection", data);
+ }
+
+ getMethodsUsed() {
+ let methodsUsed = this.#overlay.methodsUsed;
+ this.#overlay.resetMethodsUsed();
+ return methodsUsed;
+ }
+
+ /**
+ * Resolves when the document is ready to have an overlay injected into it.
+ *
+ * @returns {Promise}
+ * @resolves {Boolean} true when document is ready or rejects
+ */
+ documentIsReady() {
+ const document = this.document;
+ // Some pages take ages to finish loading - if at all.
+ // We want to respond to enable the screenshots UI as soon that is possible
+ function readyEnough() {
+ return (
+ document.readyState !== "uninitialized" && document.documentElement
+ );
+ }
+
+ if (readyEnough()) {
+ return Promise.resolve();
+ }
+ return new Promise((resolve, reject) => {
+ function onChange(event) {
+ if (event.type === "pagehide") {
+ document.removeEventListener("readystatechange", onChange);
+ this.contentWindow.removeEventListener("pagehide", onChange);
+ reject(new Error("document unloaded before it was ready"));
+ } else if (readyEnough()) {
+ document.removeEventListener("readystatechange", onChange);
+ this.contentWindow.removeEventListener("pagehide", onChange);
+ resolve();
+ }
+ }
+ document.addEventListener("readystatechange", onChange);
+ this.contentWindow.addEventListener("pagehide", onChange, { once: true });
+ });
+ }
+
+ addOverlayEventListeners() {
+ let chromeEventHandler = this.docShell.chromeEventHandler;
+ for (let event of ScreenshotsComponentChild.OVERLAY_EVENTS) {
+ chromeEventHandler.addEventListener(event, this, true);
+ }
+ }
+
+ /**
+ * Wait until the document is ready and then show the screenshots overlay
+ *
+ * @returns {Boolean} true when document is ready and the overlay is shown
+ * otherwise false
+ */
+ async startScreenshotsOverlay() {
+ try {
+ await this.documentIsReady();
+ } catch (ex) {
+ console.warn(`ScreenshotsComponentChild: ${ex.message}`);
+ return false;
+ }
+ await this.documentIsReady();
+ let overlay =
+ this.overlay ||
+ (this.#overlay = new lazy.ScreenshotsOverlay(this.document));
+ this.document.ownerGlobal.addEventListener("beforeunload", this);
+ this.contentWindow.addEventListener("resize", this);
+ this.contentWindow.addEventListener("scroll", this);
+ this.contentWindow.addEventListener("visibilitychange", this);
+ this.addOverlayEventListeners();
+
+ overlay.initialize();
+ return true;
+ }
+
+ removeOverlayEventListeners() {
+ let chromeEventHandler = this.docShell.chromeEventHandler;
+ for (let event of ScreenshotsComponentChild.OVERLAY_EVENTS) {
+ chromeEventHandler.removeEventListener(event, this, true);
+ }
+ }
+
+ /**
+ * Removes event listeners and the screenshots overlay.
+ */
+ endScreenshotsOverlay(options = {}) {
+ this.document.ownerGlobal.removeEventListener("beforeunload", this);
+ this.contentWindow.removeEventListener("resize", this);
+ this.contentWindow.removeEventListener("scroll", this);
+ this.contentWindow.removeEventListener("visibilitychange", this);
+ this.removeOverlayEventListeners();
+
+ this.overlay?.tearDown(options);
+ this.#resizeTask?.disarm();
+ this.#scrollTask?.disarm();
+ }
+
+ didDestroy() {
+ this.#resizeTask?.disarm();
+ this.#scrollTask?.disarm();
+ }
+
+ /**
+ * Gets the full page bounds for a full page screenshot.
+ *
+ * @returns { object }
+ * The device pixel ratio and a DOMRect of the scrollable content bounds.
+ *
+ * devicePixelRatio (float):
+ * The device pixel ratio of the screen
+ *
+ * rect (object):
+ * top (int):
+ * The scroll top position for the content window.
+ *
+ * left (int):
+ * The scroll left position for the content window.
+ *
+ * width (int):
+ * The scroll width of the content window.
+ *
+ * height (int):
+ * The scroll height of the content window.
+ */
+ getFullPageBounds() {
+ let {
+ scrollMinX,
+ scrollMinY,
+ scrollWidth,
+ scrollHeight,
+ devicePixelRatio,
+ } = this.#overlay.windowDimensions.dimensions;
+ let rect = {
+ left: scrollMinX,
+ top: scrollMinY,
+ right: scrollWidth,
+ bottom: scrollHeight,
+ width: scrollWidth,
+ height: scrollHeight,
+ devicePixelRatio,
+ };
+ return rect;
+ }
+
+ /**
+ * Gets the visible page bounds for a visible screenshot.
+ *
+ * @returns { object }
+ * The device pixel ratio and a DOMRect of the current visible
+ * content bounds.
+ *
+ * devicePixelRatio (float):
+ * The device pixel ratio of the screen
+ *
+ * rect (object):
+ * top (int):
+ * The top position for the content window.
+ *
+ * left (int):
+ * The left position for the content window.
+ *
+ * width (int):
+ * The width of the content window.
+ *
+ * height (int):
+ * The height of the content window.
+ */
+ getVisibleBounds() {
+ let { scrollX, scrollY, clientWidth, clientHeight, devicePixelRatio } =
+ this.#overlay.windowDimensions.dimensions;
+ let rect = {
+ left: scrollX,
+ top: scrollY,
+ right: scrollX + clientWidth,
+ bottom: scrollY + clientHeight,
+ width: clientWidth,
+ height: clientHeight,
+ devicePixelRatio,
+ };
+ return rect;
+ }
+
+ recordTelemetryEvent(type, object, args = {}) {
+ Services.telemetry.recordEvent("screenshots", type, object, null, args);
+ }
+}
diff --git a/browser/actors/SearchSERPTelemetryChild.sys.mjs b/browser/actors/SearchSERPTelemetryChild.sys.mjs
new file mode 100644
index 0000000000..e6187e9e4b
--- /dev/null
+++ b/browser/actors/SearchSERPTelemetryChild.sys.mjs
@@ -0,0 +1,1357 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ clearTimeout: "resource://gre/modules/Timer.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "serpEventsEnabled",
+ "browser.search.serpEventTelemetry.enabled",
+ true
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "serpEventTelemetryCategorization",
+ "browser.search.serpEventTelemetryCategorization.enabled",
+ false
+);
+
+// Duplicated from SearchSERPTelemetry to avoid loading the module on content
+// startup.
+const SEARCH_TELEMETRY_SHARED = {
+ PROVIDER_INFO: "SearchTelemetry:ProviderInfo",
+ LOAD_TIMEOUT: "SearchTelemetry:LoadTimeout",
+ SPA_LOAD_TIMEOUT: "SearchTelemetry:SPALoadTimeout",
+};
+
+/**
+ * SearchProviders looks after keeping track of the search provider information
+ * received from the main process.
+ *
+ * It is separate to SearchTelemetryChild so that it is not constructed for each
+ * tab, but once per process.
+ */
+class SearchProviders {
+ constructor() {
+ this._searchProviderInfo = null;
+ Services.cpmm.sharedData.addEventListener("change", this);
+ }
+
+ /**
+ * Gets the search provider information for any provider with advert information.
+ * If there is nothing in the cache, it will obtain it from shared data.
+ *
+ * @returns {object} Returns the search provider information. @see SearchTelemetry.jsm
+ */
+ get info() {
+ if (this._searchProviderInfo) {
+ return this._searchProviderInfo;
+ }
+
+ this._searchProviderInfo = Services.cpmm.sharedData.get(
+ SEARCH_TELEMETRY_SHARED.PROVIDER_INFO
+ );
+
+ if (!this._searchProviderInfo) {
+ return null;
+ }
+
+ this._searchProviderInfo = this._searchProviderInfo
+ // Filter-out non-ad providers so that we're not trying to match against
+ // those unnecessarily.
+ .filter(p => "extraAdServersRegexps" in p)
+ // Pre-build the regular expressions.
+ .map(p => {
+ p.adServerAttributes = p.adServerAttributes ?? [];
+ if (p.shoppingTab?.inspectRegexpInSERP) {
+ p.shoppingTab.regexp = new RegExp(p.shoppingTab.regexp);
+ }
+ return {
+ ...p,
+ searchPageRegexp: new RegExp(p.searchPageRegexp),
+ extraAdServersRegexps: p.extraAdServersRegexps.map(
+ r => new RegExp(r)
+ ),
+ };
+ });
+
+ return this._searchProviderInfo;
+ }
+
+ /**
+ * Handles events received from sharedData notifications.
+ *
+ * @param {object} event The event details.
+ */
+ handleEvent(event) {
+ switch (event.type) {
+ case "change": {
+ if (event.changedKeys.includes(SEARCH_TELEMETRY_SHARED.PROVIDER_INFO)) {
+ // Just null out the provider information for now, we'll fetch it next
+ // time we need it.
+ this._searchProviderInfo = null;
+ }
+ break;
+ }
+ }
+ }
+}
+
+/**
+ * Scans SERPs for ad components.
+ */
+class SearchAdImpression {
+ /**
+ * A reference to ad component information that is used if an anchor
+ * element could not be categorized to a specific ad component.
+ *
+ * @type {object}
+ */
+ #defaultComponent = null;
+
+ /**
+ * Maps DOM elements to AdData.
+ *
+ * @type {Map<Element, AdData>}
+ *
+ * @typedef AdData
+ * @type {object}
+ * @property {string} type
+ * The type of ad component.
+ * @property {number} adsLoaded
+ * The number of ads counted as loaded for the component.
+ * @property {boolean} countChildren
+ * Whether all the children were counted for the component.
+ */
+ #elementToAdDataMap = new Map();
+
+ /**
+ * An array of components to do a top-down search.
+ */
+ #topDownComponents = [];
+
+ /**
+ * A reference the providerInfo for this SERP.
+ *
+ * @type {object}
+ */
+ #providerInfo = null;
+
+ set providerInfo(providerInfo) {
+ if (this.#providerInfo?.telemetryId == providerInfo.telemetryId) {
+ return;
+ }
+
+ this.#providerInfo = providerInfo;
+
+ // Reset values.
+ this.#topDownComponents = [];
+
+ for (let component of this.#providerInfo.components) {
+ if (component.default) {
+ this.#defaultComponent = component;
+ continue;
+ }
+ if (component.topDown) {
+ this.#topDownComponents.push(component);
+ }
+ }
+ }
+
+ /**
+ * Check if the page has a shopping tab.
+ *
+ * @param {Document} document
+ * @return {boolean}
+ * Whether the page has a shopping tab. Defaults to false.
+ */
+ hasShoppingTab(document) {
+ if (!this.#providerInfo?.shoppingTab) {
+ return false;
+ }
+
+ // If a provider has the inspectRegexpInSERP, we assume there must be an
+ // associated regexp that must be used on any hrefs matched by the elements
+ // found using the selector. If inspectRegexpInSERP is false, then check if
+ // the number of items found using the selector matches exactly one element
+ // to ensure we've used a fine-grained search.
+ let elements = document.querySelectorAll(
+ this.#providerInfo.shoppingTab.selector
+ );
+ if (this.#providerInfo.shoppingTab.inspectRegexpInSERP) {
+ let regexp = this.#providerInfo.shoppingTab.regexp;
+ for (let element of elements) {
+ let href = element.getAttribute("href");
+ if (href && regexp.test(href)) {
+ this.#recordElementData(element, {
+ type: "shopping_tab",
+ count: 1,
+ });
+ return true;
+ }
+ }
+ } else if (elements.length == 1) {
+ this.#recordElementData(elements[0], {
+ type: "shopping_tab",
+ count: 1,
+ });
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Examine the list of anchors and the document object and find components
+ * on the page.
+ *
+ * With the list of anchors, go through each and find the component it
+ * belongs to and save it in elementToAdDataMap.
+ *
+ * Then, with the document object find components and save the results to
+ * elementToAdDataMap.
+ *
+ * Lastly, combine the results together in a new Map that contains the number
+ * of loaded, visible, and blocked results for the component.
+ *
+ * @param {HTMLCollectionOf<HTMLAnchorElement>} anchors
+ * @param {Document} document
+ *
+ * @returns {Map<string, object>}
+ * A map where the key is a string containing the type of ad component
+ * and the value is an object containing the number of adsLoaded,
+ * adsVisible, and adsHidden within the component.
+ */
+ categorize(anchors, document) {
+ // Used for various functions to make relative URLs absolute.
+ let origin = new URL(document.documentURI).origin;
+
+ // Bottom up approach.
+ this.#categorizeAnchors(anchors, origin);
+
+ // Top down approach.
+ this.#categorizeDocument(document);
+
+ let componentToVisibilityMap = new Map();
+ let hrefToComponentMap = new Map();
+
+ let innerWindowHeight = document.ownerGlobal.innerHeight;
+ let scrollY = document.ownerGlobal.scrollY;
+
+ // Iterate over the results:
+ // - If it's searchbox add event listeners.
+ // - If it is a non_ads_link, map its href to component type.
+ // - For others, map its component type and check visibility.
+ for (let [element, data] of this.#elementToAdDataMap.entries()) {
+ if (data.type == "incontent_searchbox") {
+ // If searchbox has child elements, observe those, otherwise
+ // fallback to its parent element.
+ this.#addEventListenerToElements(
+ data.childElements.length ? data.childElements : [element],
+ data.type,
+ false
+ );
+ continue;
+ }
+ if (data.childElements.length) {
+ for (let child of data.childElements) {
+ let href = this.#extractHref(child, origin);
+ if (href) {
+ hrefToComponentMap.set(href, data.type);
+ }
+ }
+ } else {
+ let href = this.#extractHref(element, origin);
+ if (href) {
+ hrefToComponentMap.set(href, data.type);
+ }
+ }
+
+ // If the component is a non_ads_link, skip visibility checks.
+ if (data.type == "non_ads_link") {
+ continue;
+ }
+
+ // If proxy children were found, check the visibility of all of them
+ // otherwise just check the visiblity of the first child.
+ let childElements;
+ if (data.proxyChildElements.length) {
+ childElements = data.proxyChildElements;
+ } else if (data.childElements.length) {
+ childElements = [data.childElements[0]];
+ }
+
+ let count = this.#countVisibleAndHiddenAds(
+ element,
+ data.adsLoaded,
+ childElements,
+ innerWindowHeight,
+ scrollY
+ );
+ if (componentToVisibilityMap.has(data.type)) {
+ let componentInfo = componentToVisibilityMap.get(data.type);
+ componentInfo.adsLoaded += data.adsLoaded;
+ componentInfo.adsVisible += count.adsVisible;
+ componentInfo.adsHidden += count.adsHidden;
+ } else {
+ componentToVisibilityMap.set(data.type, {
+ adsLoaded: data.adsLoaded,
+ adsVisible: count.adsVisible,
+ adsHidden: count.adsHidden,
+ });
+ }
+ }
+
+ // Release the DOM elements from the Map.
+ this.#elementToAdDataMap.clear();
+
+ return { componentToVisibilityMap, hrefToComponentMap };
+ }
+
+ /**
+ * Given an element, find the href that is most likely to make the request if
+ * the element is clicked. If the element contains a specific data attribute
+ * known to contain the url used to make the initial request, use it,
+ * otherwise use its href. Specific character conversions are done to mimic
+ * conversions likely to take place when urls are observed in network
+ * activity.
+ *
+ * @param {Element} element
+ * The element to inspect.
+ * @param {string} origin
+ * The origin for relative urls.
+ * @returns {string}
+ * The href of the element.
+ */
+ #extractHref(element, origin) {
+ let href;
+ // Prioritize the href from a known data attribute value instead of
+ // its href property, as the former is the initial url the page will
+ // navigate to before being re-directed to the href.
+ for (let name of this.#providerInfo.adServerAttributes) {
+ if (
+ element.dataset[name] &&
+ this.#providerInfo.extraAdServersRegexps.some(regexp =>
+ regexp.test(element.dataset[name])
+ )
+ ) {
+ href = element.dataset[name];
+ break;
+ }
+ }
+ // If a data attribute value was not found, fallback to the href.
+ href = href ?? element.getAttribute("href");
+ if (!href) {
+ return "";
+ }
+ // Hrefs can be relative.
+ if (!href.startsWith("https://") && !href.startsWith("http://")) {
+ href = origin + href;
+ }
+ // Per Bug 376844, apostrophes in query params are escaped, and thus, are
+ // percent-encoded by the time they are observed in the network. Even
+ // though it's more comprehensive, we avoid using newURI because its more
+ // expensive and conversions should be the exception.
+ // e.g. /path'?q=Mozilla's -> /path'?q=Mozilla%27s
+ let arr = href.split("?");
+ if (arr.length == 2 && arr[1].includes("'")) {
+ href = arr[0] + "?" + arr[1].replaceAll("'", "%27");
+ }
+ return href;
+ }
+
+ /**
+ * Given a list of anchor elements, group them into ad components.
+ *
+ * The first step in the process is to check if the anchor should be
+ * inspected. This is based on whether it contains an href or a
+ * data-attribute values that matches an ad link, or if it contains a
+ * pattern caught by a components included regular expression.
+ *
+ * Determine which component it belongs to and the number of matches for
+ * the component. The heuristic is described in findDataForAnchor.
+ * If there was a result and we haven't seen it before, save it in
+ * elementToAdDataMap.
+ *
+ * @param {HTMLCollectionOf<HTMLAnchorElement>} anchors
+ * The list of anchors to inspect.
+ * @param {string} origin
+ * The origin of the document the anchors belong to.
+ */
+ #categorizeAnchors(anchors, origin) {
+ for (let anchor of anchors) {
+ if (this.#shouldInspectAnchor(anchor, origin)) {
+ let result = this.#findDataForAnchor(anchor);
+ if (result) {
+ this.#recordElementData(result.element, {
+ type: result.type,
+ count: result.count,
+ proxyChildElements: result.proxyChildElements,
+ childElements: result.childElements,
+ });
+ }
+ if (result.relatedElements?.length) {
+ this.#addEventListenerToElements(result.relatedElements, result.type);
+ }
+ }
+ }
+ }
+
+ /**
+ * Find components from the document object. This is mostly relevant for
+ * components that are non-ads and don't have an obvious regular expression
+ * that could match the pattern of the href.
+ *
+ * @param {Document} document
+ */
+ #categorizeDocument(document) {
+ // using the subset of components that are top down,
+ // go through each one.
+ for (let component of this.#topDownComponents) {
+ // Top-down searches must have the topDown attribute.
+ if (!component.topDown) {
+ continue;
+ }
+ // Top down searches must include a parent.
+ if (!component.included?.parent) {
+ continue;
+ }
+ let parents = document.querySelectorAll(
+ component.included.parent.selector
+ );
+ if (parents.length) {
+ for (let parent of parents) {
+ if (component.included.related?.selector) {
+ this.#addEventListenerToElements(
+ parent.querySelectorAll(component.included.related.selector),
+ component.type
+ );
+ }
+ if (component.included.children) {
+ for (let child of component.included.children) {
+ let childElements = parent.querySelectorAll(child.selector);
+ if (childElements.length) {
+ this.#recordElementData(parent, {
+ type: component.type,
+ childElements: Array.from(childElements),
+ });
+ break;
+ }
+ }
+ } else {
+ this.#recordElementData(parent, {
+ type: component.type,
+ });
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Evaluates whether an anchor should be inspected based on matching
+ * regular expressions on either its href or specified data-attribute values.
+ *
+ * @param {HTMLAnchorElement} anchor
+ * @param {string} origin
+ * @returns {boolean}
+ */
+ #shouldInspectAnchor(anchor, origin) {
+ let href = anchor.getAttribute("href");
+ if (!href) {
+ return false;
+ }
+
+ // Some hrefs might be relative.
+ if (!href.startsWith("https://") && !href.startsWith("http://")) {
+ href = origin + href;
+ }
+
+ let regexps = this.#providerInfo.extraAdServersRegexps;
+ // Anchors can contain ad links in a data-attribute.
+ for (let name of this.#providerInfo.adServerAttributes) {
+ let attributeValue = anchor.dataset[name];
+ if (
+ attributeValue &&
+ regexps.some(regexp => regexp.test(attributeValue))
+ ) {
+ return true;
+ }
+ }
+ // Anchors can contain ad links in a specific href.
+ if (regexps.some(regexp => regexp.test(href))) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Find the component data for an anchor.
+ *
+ * To categorize the anchor, we iterate over the list of possible components
+ * the anchor could be categorized. If the component is default, we skip
+ * checking because the fallback option for all anchor links is the default.
+ *
+ * First, get the "parent" of the anchor which best represents the DOM element
+ * that contains the anchor links for the component and no other component.
+ * This parent will be cached so that other anchors that share the same
+ * parent can be counted together.
+ *
+ * The check for a parent is a loop because we can define more than one best
+ * parent since on certain SERPs, it's possible for a "better" DOM element
+ * parent to appear occassionally.
+ *
+ * If no parent is found, skip this component.
+ *
+ * If a parent was found, check for specific child elements.
+ *
+ * Finding child DOM elements of a parent is optional. One reason to do so is
+ * to use child elements instead of anchor links to count the number of ads for
+ * a component via the `countChildren` property. This is provided because some ads
+ * (i.e. carousels) have multiple ad links in a single child element that go to the
+ * same location. In this scenario, all instances of the child are recorded as ads.
+ * Subsequent anchor elements that map to the same parent are ignored.
+ *
+ * Whether or not a child was found, return the information that was found,
+ * including whether or not all child elements were counted instead of anchors.
+ *
+ * If another anchor belonging to a parent that was previously recorded is the input
+ * for this function, we either increment the ad count by 1 or don't increment the ad
+ * count because the parent used `countChildren` completed the calculation in a
+ * previous step.
+ *
+ *
+ * @param {HTMLAnchorElement} anchor
+ * The anchor to be inspected.
+ * @returns {object}
+ * An object containing the element representing the root DOM element for
+ * the component, the type of component, how many ads were counted,
+ * and whether or not the count was of all the children.
+ */
+ #findDataForAnchor(anchor) {
+ for (let component of this.#providerInfo.components) {
+ // First, check various conditions for skipping a component.
+
+ // A component should always have at least one included statement.
+ if (!component.included) {
+ continue;
+ }
+
+ // Top down searches are done after the bottom up search.
+ if (component.topDown) {
+ continue;
+ }
+
+ // The default component doesn't need to be checked,
+ // as it will be the fallback option.
+ if (component.default) {
+ continue;
+ }
+
+ // The anchor shouldn't belong to an excluded parent component if one
+ // is provided.
+ if (
+ component.excluded?.parent?.selector &&
+ anchor.closest(component.excluded.parent.selector)
+ ) {
+ continue;
+ }
+
+ // All components with included should have a parent entry.
+ if (!component.included.parent) {
+ continue;
+ }
+
+ // Find the parent of the anchor.
+ let parent = anchor.closest(component.included.parent.selector);
+
+ if (!parent) {
+ continue;
+ }
+
+ // If we've already inspected the parent, add the child element to the
+ // list of anchors. Don't increment the ads loaded count, as we only care
+ // about grouping the anchor with the correct parent.
+ if (this.#elementToAdDataMap.has(parent)) {
+ return {
+ element: parent,
+ childElements: [anchor],
+ };
+ }
+
+ let relatedElements = [];
+ if (component.included.related?.selector) {
+ relatedElements = parent.querySelectorAll(
+ component.included.related.selector
+ );
+ }
+
+ // If the component has no defined children, return the parent element.
+ if (component.included.children) {
+ // Look for the first instance of a matching child selector.
+ for (let child of component.included.children) {
+ // If counting by child, get all of them at once.
+ if (child.countChildren) {
+ let proxyChildElements = parent.querySelectorAll(child.selector);
+ if (proxyChildElements.length) {
+ return {
+ element: parent,
+ type: child.type ?? component.type,
+ proxyChildElements: Array.from(proxyChildElements),
+ count: proxyChildElements.length,
+ childElements: [anchor],
+ relatedElements,
+ };
+ }
+ } else if (parent.querySelector(child.selector)) {
+ return {
+ element: parent,
+ type: child.type ?? component.type,
+ childElements: [anchor],
+ relatedElements,
+ };
+ }
+ }
+ }
+ // If no children were defined for this component, or none were found
+ // in the DOM, use the default definition.
+ return {
+ element: parent,
+ type: component.type,
+ childElements: [anchor],
+ relatedElements,
+ };
+ }
+ // If no component was found, use default values.
+ return {
+ element: anchor,
+ type: this.#defaultComponent.type,
+ };
+ }
+
+ /**
+ * Determines whether or not an ad was visible or hidden.
+ *
+ * An ad is considered visible if the parent element containing the
+ * component has non-zero dimensions, and all child element in the
+ * component have non-zero dimensions and fits within the window
+ * at the time when the impression was takent.
+ *
+ * For some components, like text ads, we don't send every child
+ * element for visibility, just the first text ad. For other components
+ * like carousels, we send all child elements because we do care about
+ * counting how many elements of the carousel were visible.
+ *
+ * @param {Element} element
+ * Element to be inspected
+ * @param {number} adsLoaded
+ * Number of ads initially determined to be loaded for this element.
+ * @param {Array<Element>} childElements
+ * List of children belonging to element.
+ * @param {number} innerWindowHeight
+ * Current height of the window containing the elements.
+ * @param {number} scrollY
+ * Current distance the window has been scrolled.
+ * @returns {object}
+ * Contains adsVisible which is the number of ads shown for the element
+ * and adsHidden, the number of ads not visible to the user.
+ */
+ #countVisibleAndHiddenAds(
+ element,
+ adsLoaded,
+ childElements,
+ innerWindowHeight,
+ scrollY
+ ) {
+ let elementRect =
+ element.ownerGlobal.windowUtils.getBoundsWithoutFlushing(element);
+
+ // If the element lacks a dimension, assume all ads that
+ // were contained within it are hidden.
+ if (elementRect.width == 0 || elementRect.height == 0) {
+ return {
+ adsVisible: 0,
+ adsHidden: adsLoaded,
+ };
+ }
+
+ // If an ad is far above the possible visible area of a window, an
+ // adblocker might be doing it as a workaround for blocking the ad.
+ if (
+ elementRect.bottom < 0 &&
+ innerWindowHeight + scrollY + elementRect.bottom < 0
+ ) {
+ return {
+ adsVisible: 0,
+ adsHidden: adsLoaded,
+ };
+ }
+
+ // Since the parent element has dimensions but no child elements we want
+ // to inspect, check the parent itself is within the viewable area.
+ if (!childElements || !childElements.length) {
+ if (innerWindowHeight < elementRect.y + elementRect.height) {
+ return {
+ adsVisible: 0,
+ adsHidden: 0,
+ };
+ }
+ return {
+ adsVisible: 1,
+ adsHidden: 0,
+ };
+ }
+
+ let adsVisible = 0;
+ let adsHidden = 0;
+ for (let child of childElements) {
+ let itemRect =
+ child.ownerGlobal.windowUtils.getBoundsWithoutFlushing(child);
+
+ // If the child element we're inspecting has no dimension, it is hidden.
+ if (itemRect.height == 0 || itemRect.width == 0) {
+ adsHidden += 1;
+ continue;
+ }
+
+ // If the child element is to the left of the containing element, or to
+ // the right of the containing element, skip it.
+ if (
+ itemRect.x < elementRect.x ||
+ itemRect.x + itemRect.width > elementRect.x + elementRect.width
+ ) {
+ continue;
+ }
+
+ // If the child element is too far down, skip it.
+ if (innerWindowHeight < itemRect.y + itemRect.height) {
+ continue;
+ }
+ ++adsVisible;
+ }
+
+ return {
+ adsVisible,
+ adsHidden,
+ };
+ }
+
+ /**
+ * Caches ad data for a DOM element. The key of the map is by Element rather
+ * than Component for fast lookup on whether an Element has been already been
+ * categorized as a component. Subsequent calls to this passing the same
+ * element will update the list of child elements.
+ *
+ * @param {Element} element
+ * The element considered to be the root for the component.
+ * @param {object} params
+ * Various parameters that can be recorded. Whether the input values exist
+ * or not depends on which component was found, which heuristic should be used
+ * to determine whether an ad was visible, and whether we've already seen this
+ * element.
+ * @param {string | null} params.type
+ * The type of component.
+ * @param {number} params.count
+ * The number of ads found for a component. The number represents either
+ * the number of elements that match an ad expression or the number of DOM
+ * elements containing an ad link.
+ * @param {Array<Element>} params.proxyChildElements
+ * An array of DOM elements that should be inspected for visibility instead
+ * of the actual child elements, possibly because they are grouped.
+ * @param {Array<Element>} params.childElements
+ * An array of DOM elements to inspect.
+ */
+ #recordElementData(
+ element,
+ { type, count = 1, proxyChildElements = [], childElements = [] } = {}
+ ) {
+ if (this.#elementToAdDataMap.has(element)) {
+ let recordedValues = this.#elementToAdDataMap.get(element);
+ if (childElements.length) {
+ recordedValues.childElements =
+ recordedValues.childElements.concat(childElements);
+ }
+ } else {
+ this.#elementToAdDataMap.set(element, {
+ type,
+ adsLoaded: count,
+ proxyChildElements,
+ childElements,
+ });
+ }
+ }
+
+ /**
+ * Adds a click listener to a specific element.
+ *
+ * @param {Array<Element>} elements
+ * DOM elements to add event listeners to.
+ * @param {string} type
+ * The component type of the element.
+ * @param {boolean} isRelated
+ * Whether the elements input are related to components or are actual
+ * components.
+ */
+ #addEventListenerToElements(elements, type, isRelated = true) {
+ if (!elements?.length) {
+ return;
+ }
+ let clickAction = "clicked";
+ let keydownEnterAction = "clicked";
+
+ switch (type) {
+ case "incontent_searchbox":
+ keydownEnterAction = "submitted";
+ if (isRelated) {
+ // The related element to incontent_search are autosuggested elements
+ // which when clicked should cause different action than if the
+ // searchbox is clicked.
+ clickAction = "submitted";
+ }
+ break;
+ case "ad_carousel":
+ case "refined_search_buttons":
+ if (isRelated) {
+ clickAction = "expanded";
+ }
+ break;
+ }
+
+ let document = elements[0].ownerGlobal.document;
+ let url = document.documentURI;
+ let callback = documentToEventCallbackMap.get(document);
+
+ let removeListenerCallbacks = [];
+
+ for (let element of elements) {
+ let clickCallback = () => {
+ if (clickAction == "submitted") {
+ documentToSubmitMap.set(document, true);
+ }
+ callback({
+ type,
+ url,
+ action: clickAction,
+ });
+ };
+ element.addEventListener("click", clickCallback);
+
+ let keydownCallback = event => {
+ if (event.key == "Enter") {
+ if (keydownEnterAction == "submitted") {
+ documentToSubmitMap.set(document, true);
+ }
+ callback({
+ type,
+ url,
+ action: keydownEnterAction,
+ });
+ }
+ };
+ element.addEventListener("keydown", keydownCallback);
+
+ removeListenerCallbacks.push(() => {
+ element.removeEventListener("click", clickCallback);
+ element.removeEventListener("keydown", keydownCallback);
+ });
+ }
+
+ document.ownerGlobal.addEventListener(
+ "pagehide",
+ () => {
+ let callbacks = documentToRemoveEventListenersMap.get(document);
+ if (callbacks) {
+ for (let removeEventListenerCallback of callbacks) {
+ removeEventListenerCallback();
+ }
+ documentToRemoveEventListenersMap.delete(document);
+ }
+ },
+ { once: true }
+ );
+
+ // The map might have entries from previous callers, so we must ensure
+ // we don't discard existing event listener callbacks.
+ if (documentToRemoveEventListenersMap.has(document)) {
+ let callbacks = documentToRemoveEventListenersMap.get(document);
+ removeListenerCallbacks = removeListenerCallbacks.concat(callbacks);
+ }
+
+ documentToRemoveEventListenersMap.set(document, removeListenerCallbacks);
+ }
+}
+
+/**
+ * An object indicating which elements to examine for domains to extract and
+ * which heuristic technique to use to extract that element's domain.
+ *
+ * @typedef {object} ExtractorInfo
+ * @property {string} selectors
+ * A string representing the CSS selector that targets the elements on the
+ * page that contain domains we want to extract.
+ * @property {string} method
+ * A string representing which domain extraction heuristic to use.
+ * One of: "href" or "data-attribute".
+ * @property {object | null} options
+ * Options related to the domain extraction heuristic used.
+ * @property {string | null} options.dataAttributeKey
+ * The key name of the data attribute to lookup.
+ * @property {string | null} options.queryParamKey
+ * The key name of the query param value to lookup.
+ * @property {boolean | null} options.queryParamValueIsHref
+ * Whether the query param value is expected to contain an href.
+ */
+
+/**
+ * DomainExtractor examines elements on a page to retrieve the domains.
+ */
+class DomainExtractor {
+ /**
+ * Extract domains from the page using an array of information pertaining to
+ * the SERP.
+ *
+ * @param {Document} document
+ * The document for the SERP we are extracting domains from.
+ * @param {Array<ExtractorInfo>} extractorInfos
+ * Information used to target the domains we need to extract.
+ * @return {Set<string>}
+ * A set of the domains extracted from the page.
+ */
+ extractDomainsFromDocument(document, extractorInfos) {
+ let extractedDomains = new Set();
+ if (!extractorInfos?.length) {
+ return extractedDomains;
+ }
+
+ for (let extractorInfo of extractorInfos) {
+ if (!extractorInfo.selectors) {
+ continue;
+ }
+
+ let elements = document.querySelectorAll(extractorInfo.selectors);
+ if (!elements) {
+ continue;
+ }
+
+ switch (extractorInfo.method) {
+ case "href": {
+ // Origin is used in case a URL needs to be made absolute.
+ let origin = new URL(document.documentURI).origin;
+ this.#fromElementsConvertHrefsIntoDomains(
+ elements,
+ origin,
+ extractedDomains,
+ extractorInfo.options?.queryParamKey,
+ extractorInfo.options?.queryParamValueIsHref
+ );
+ break;
+ }
+ case "data-attribute": {
+ this.#fromElementsRetrieveDataAttributeValues(
+ elements,
+ extractorInfo.options?.dataAttributeKey,
+ extractedDomains
+ );
+ break;
+ }
+ }
+ }
+
+ return extractedDomains;
+ }
+
+ /**
+ * Given a list of elements, extract domains using href attributes. If the
+ * URL in the href includes the specified query param, the domain will be
+ * that query param's value. Otherwise it will be the hostname of the href
+ * attribute's URL.
+ *
+ * @param {NodeList<Element>} elements
+ * A list of elements from the page whose href attributes we want to
+ * inspect.
+ * @param {string} origin
+ * Origin of the current page.
+ * @param {Set<string>} extractedDomains
+ * The result set of domains extracted from the page.
+ * @param {string | null} queryParam
+ * An optional query param to search for in an element's href attribute.
+ * @param {boolean | null} queryParamValueIsHref
+ * Whether the query param value is expected to contain an href.
+ */
+ #fromElementsConvertHrefsIntoDomains(
+ elements,
+ origin,
+ extractedDomains,
+ queryParam,
+ queryParamValueIsHref
+ ) {
+ for (let element of elements) {
+ let href = element.getAttribute("href");
+
+ let url;
+ try {
+ url = new URL(href, origin);
+ } catch (ex) {
+ continue;
+ }
+
+ // Ignore non-standard protocols.
+ if (url.protocol != "https:" && url.protocol != "http:") {
+ continue;
+ }
+
+ if (queryParam) {
+ let paramValue = url.searchParams.get(queryParam);
+ if (queryParamValueIsHref) {
+ try {
+ paramValue = new URL(paramValue).hostname;
+ } catch (e) {
+ continue;
+ }
+ }
+ if (paramValue && !extractedDomains.has(paramValue)) {
+ extractedDomains.add(paramValue);
+ }
+ } else if (url.hostname && !extractedDomains.has(url.hostname)) {
+ extractedDomains.add(url.hostname);
+ }
+ }
+ }
+
+ /**
+ * Given a list of elements, examine each for the specified data attribute.
+ * If found, add that data attribute's value to the result set of extracted
+ * domains as is.
+ *
+ * @param {NodeList<Element>} elements
+ * A list of elements from the page whose data attributes we want to
+ * inspect.
+ * @param {string} attribute
+ * The name of a data attribute to search for within an element.
+ * @param {Set<string>} extractedDomains
+ * The result set of domains extracted from the page.
+ */
+ #fromElementsRetrieveDataAttributeValues(
+ elements,
+ attribute,
+ extractedDomains
+ ) {
+ for (let element of elements) {
+ let value = element.dataset[attribute];
+ if (value && !extractedDomains.has(value)) {
+ extractedDomains.add(value);
+ }
+ }
+ }
+}
+
+export const domainExtractor = new DomainExtractor();
+const searchProviders = new SearchProviders();
+const searchAdImpression = new SearchAdImpression();
+
+const documentToEventCallbackMap = new WeakMap();
+const documentToRemoveEventListenersMap = new WeakMap();
+const documentToSubmitMap = new WeakMap();
+
+/**
+ * SearchTelemetryChild monitors for pages that are partner searches, and
+ * looks through them to find links which looks like adverts and sends back
+ * a notification to SearchTelemetry for possible telemetry reporting.
+ *
+ * Only the partner details and the fact that at least one ad was found on the
+ * page are returned to SearchTelemetry. If no ads are found, no notification is
+ * given.
+ */
+export class SearchSERPTelemetryChild extends JSWindowActorChild {
+ /**
+ * Amount of time to wait after a page event before examining the page
+ * for ads.
+ *
+ * @type {number | null}
+ */
+ #adTimeout;
+ /**
+ * Determines if there is a provider that matches the supplied URL and returns
+ * the information associated with that provider.
+ *
+ * @param {string} url The url to check
+ * @returns {array|null} Returns null if there's no match, otherwise an array
+ * of provider name and the provider information.
+ */
+ _getProviderInfoForUrl(url) {
+ return searchProviders.info?.find(info => info.searchPageRegexp.test(url));
+ }
+
+ /**
+ * Checks to see if the page is a partner and has an ad link within it. If so,
+ * it will notify SearchTelemetry.
+ */
+ _checkForAdLink(eventType) {
+ try {
+ if (!this.contentWindow) {
+ return;
+ }
+ } catch (ex) {
+ // unload occurred before the timer expired
+ return;
+ }
+
+ let doc = this.document;
+ let url = doc.documentURI;
+ let providerInfo = this._getProviderInfoForUrl(url);
+ if (!providerInfo) {
+ return;
+ }
+
+ let regexps = providerInfo.extraAdServersRegexps;
+ let anchors = doc.getElementsByTagName("a");
+ let hasAds = false;
+ for (let anchor of anchors) {
+ if (!anchor.href) {
+ continue;
+ }
+ for (let name of providerInfo.adServerAttributes) {
+ hasAds = regexps.some(regexp => regexp.test(anchor.dataset[name]));
+ if (hasAds) {
+ break;
+ }
+ }
+ if (!hasAds) {
+ hasAds = regexps.some(regexp => regexp.test(anchor.href));
+ }
+ if (hasAds) {
+ break;
+ }
+ }
+
+ if (hasAds) {
+ this.sendAsyncMessage("SearchTelemetry:PageInfo", {
+ hasAds,
+ url,
+ });
+ }
+
+ if (
+ lazy.serpEventsEnabled &&
+ providerInfo.components?.length &&
+ (eventType == "load" || eventType == "pageshow")
+ ) {
+ // Start performance measurements.
+ let start = Cu.now();
+ let timerId = Glean.serp.categorizationDuration.start();
+
+ let pageActionCallback = info => {
+ this.sendAsyncMessage("SearchTelemetry:Action", {
+ type: info.type,
+ url: info.url,
+ action: info.action,
+ });
+ };
+ documentToEventCallbackMap.set(this.document, pageActionCallback);
+
+ let componentToVisibilityMap, hrefToComponentMap;
+ try {
+ let result = searchAdImpression.categorize(anchors, doc);
+ componentToVisibilityMap = result.componentToVisibilityMap;
+ hrefToComponentMap = result.hrefToComponentMap;
+ } catch (e) {
+ // Cancel the timer if an error encountered.
+ Glean.serp.categorizationDuration.cancel(timerId);
+ }
+
+ if (componentToVisibilityMap && hrefToComponentMap) {
+ // End measurements.
+ ChromeUtils.addProfilerMarker(
+ "SearchSERPTelemetryChild._checkForAdLink",
+ start,
+ "Checked anchors for visibility"
+ );
+ Glean.serp.categorizationDuration.stopAndAccumulate(timerId);
+ this.sendAsyncMessage("SearchTelemetry:AdImpressions", {
+ adImpressions: componentToVisibilityMap,
+ hrefToComponentMap,
+ url,
+ });
+ }
+ }
+
+ if (
+ lazy.serpEventTelemetryCategorization &&
+ providerInfo.domainExtraction &&
+ (eventType == "load" || eventType == "pageshow")
+ ) {
+ let start = Cu.now();
+ let nonAdDomains = domainExtractor.extractDomainsFromDocument(
+ doc,
+ providerInfo.domainExtraction.nonAds
+ );
+ let adDomains = domainExtractor.extractDomainsFromDocument(
+ doc,
+ providerInfo.domainExtraction.ads
+ );
+
+ this.sendAsyncMessage("SearchTelemetry:Domains", {
+ url,
+ nonAdDomains,
+ adDomains,
+ });
+
+ ChromeUtils.addProfilerMarker(
+ "SearchSERPTelemetryChild._checkForAdLink",
+ start,
+ "Extract domains from elements"
+ );
+ }
+ }
+
+ /**
+ * Checks for the presence of certain components on the page that are
+ * required for recording the page impression.
+ */
+ #checkForPageImpressionComponents() {
+ let url = this.document.documentURI;
+ let providerInfo = this._getProviderInfoForUrl(url);
+ if (providerInfo.components?.length) {
+ searchAdImpression.providerInfo = providerInfo;
+ let start = Cu.now();
+ let shoppingTabDisplayed = searchAdImpression.hasShoppingTab(
+ this.document
+ );
+ ChromeUtils.addProfilerMarker(
+ "SearchSERPTelemetryChild.#recordImpression",
+ start,
+ "Checked for shopping tab"
+ );
+ this.sendAsyncMessage("SearchTelemetry:PageImpression", {
+ url,
+ shoppingTabDisplayed,
+ });
+ }
+ }
+
+ #removeEventListeners() {
+ let callbacks = documentToRemoveEventListenersMap.get(this.document);
+ if (callbacks) {
+ for (let callback of callbacks) {
+ callback();
+ }
+ documentToRemoveEventListenersMap.delete(this.document);
+ }
+ }
+
+ /**
+ * Handles events received from the actor child notifications.
+ *
+ * @param {object} event The event details.
+ */
+ handleEvent(event) {
+ if (!this.#urlIsSERP(this.document.documentURI)) {
+ return;
+ }
+ switch (event.type) {
+ case "pageshow": {
+ // If a page is loaded from the bfcache, we won't get a "DOMContentLoaded"
+ // event, so we need to rely on "pageshow" in this case. Note: we do this
+ // so that we remain consistent with the *.in-content:sap* count for the
+ // SEARCH_COUNTS histogram.
+ if (event.persisted) {
+ this.#check(event.type);
+ if (lazy.serpEventsEnabled) {
+ this.#checkForPageImpressionComponents();
+ }
+ }
+ break;
+ }
+ case "DOMContentLoaded": {
+ if (lazy.serpEventsEnabled) {
+ this.#checkForPageImpressionComponents();
+ }
+ this.#check(event.type);
+ break;
+ }
+ case "load": {
+ // We check both DOMContentLoaded and load in case the page has
+ // taken a long time to load and the ad is only detected on load.
+ // We still check at DOMContentLoaded because if the page hasn't
+ // finished loading and the user navigates away, we still want to know
+ // if there were ads on the page or not at that time.
+ this.#check(event.type);
+ break;
+ }
+ case "pagehide": {
+ this.#cancelCheck();
+ break;
+ }
+ }
+ }
+
+ async receiveMessage(message) {
+ switch (message.name) {
+ case "SearchSERPTelemetry:WaitForSPAPageLoad":
+ lazy.setTimeout(() => {
+ this.#checkForPageImpressionComponents();
+ this._checkForAdLink("load");
+ }, Services.cpmm.sharedData.get(SEARCH_TELEMETRY_SHARED.SPA_LOAD_TIMEOUT));
+ break;
+ case "SearchSERPTelemetry:StopTrackingDocument":
+ this.#removeDocumentFromSubmitMap();
+ this.#removeEventListeners();
+ break;
+ case "SearchSERPTelemetry:DidSubmit":
+ return this.#didSubmit();
+ }
+ return null;
+ }
+
+ #didSubmit() {
+ return documentToSubmitMap.get(this.document);
+ }
+
+ #removeDocumentFromSubmitMap() {
+ documentToSubmitMap.delete(this.document);
+ }
+
+ #urlIsSERP(url) {
+ let provider = this._getProviderInfoForUrl(this.document.documentURI);
+ if (provider) {
+ // Some URLs can match provider info but also be the provider's homepage
+ // instead of a SERP.
+ // e.g. https://example.com/ vs. https://example.com/?foo=bar
+ // To check this, we look for the presence of the query parameter
+ // that contains a search term.
+ let queries = new URLSearchParams(url.split("#")[0].split("?")[1]);
+ for (let queryParamName of provider.queryParamNames) {
+ if (queries.get(queryParamName)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ #cancelCheck() {
+ if (this._waitForContentTimeout) {
+ lazy.clearTimeout(this._waitForContentTimeout);
+ }
+ }
+
+ #check(eventType) {
+ if (!this.#adTimeout) {
+ this.#adTimeout = Services.cpmm.sharedData.get(
+ SEARCH_TELEMETRY_SHARED.LOAD_TIMEOUT
+ );
+ }
+ this.#cancelCheck();
+ this._waitForContentTimeout = lazy.setTimeout(() => {
+ this._checkForAdLink(eventType);
+ }, this.#adTimeout);
+ }
+}
diff --git a/browser/actors/SearchSERPTelemetryParent.sys.mjs b/browser/actors/SearchSERPTelemetryParent.sys.mjs
new file mode 100644
index 0000000000..4e4011b1f8
--- /dev/null
+++ b/browser/actors/SearchSERPTelemetryParent.sys.mjs
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs",
+});
+
+export class SearchSERPTelemetryParent extends JSWindowActorParent {
+ receiveMessage(msg) {
+ let browser = this.browsingContext.top.embedderElement;
+
+ switch (msg.name) {
+ case "SearchTelemetry:PageInfo": {
+ lazy.SearchSERPTelemetry.reportPageWithAds(msg.data, browser);
+ break;
+ }
+ case "SearchTelemetry:AdImpressions": {
+ lazy.SearchSERPTelemetry.reportPageWithAdImpressions(msg.data, browser);
+ break;
+ }
+ case "SearchTelemetry:Action": {
+ lazy.SearchSERPTelemetry.reportPageAction(msg.data, browser);
+ break;
+ }
+ case "SearchTelemetry:PageImpression": {
+ lazy.SearchSERPTelemetry.reportPageImpression(msg.data, browser);
+ break;
+ }
+ case "SearchTelemetry:Domains": {
+ lazy.SearchSERPTelemetry.reportPageDomains(msg.data, browser);
+ break;
+ }
+ }
+ }
+}
diff --git a/browser/actors/SpeechDispatcherChild.sys.mjs b/browser/actors/SpeechDispatcherChild.sys.mjs
new file mode 100644
index 0000000000..1184d72446
--- /dev/null
+++ b/browser/actors/SpeechDispatcherChild.sys.mjs
@@ -0,0 +1,10 @@
+/* vim: set ts=2 sw=2 sts=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export class SpeechDispatcherChild extends JSWindowActorChild {
+ observe(aSubject, aTopic, aData) {
+ this.sendAsyncMessage("SpeechDispatcher:Error", aData);
+ }
+}
diff --git a/browser/actors/SpeechDispatcherParent.sys.mjs b/browser/actors/SpeechDispatcherParent.sys.mjs
new file mode 100644
index 0000000000..40ddf0b3c4
--- /dev/null
+++ b/browser/actors/SpeechDispatcherParent.sys.mjs
@@ -0,0 +1,90 @@
+/* vim: set ts=2 sw=2 sts=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export class SpeechDispatcherParent extends JSWindowActorParent {
+ prefName() {
+ return "media.webspeech.synth.dont_notify_on_error";
+ }
+
+ disableNotification() {
+ Services.prefs.setBoolPref(this.prefName(), true);
+ }
+
+ async receiveMessage(aMessage) {
+ // The top level browsing context's embedding element should be a xul browser element.
+ let browser = this.browsingContext.top.embedderElement;
+
+ if (!browser) {
+ // We don't have a browser so bail!
+ return;
+ }
+
+ let notificationId;
+
+ if (Services.prefs.getBoolPref(this.prefName(), false)) {
+ console.info("Opted out from speech-dispatcher error notification");
+ return;
+ }
+
+ let messageId;
+ switch (aMessage.data) {
+ case "lib-missing":
+ messageId = "speech-dispatcher-lib-missing";
+ break;
+
+ case "lib-too-old":
+ messageId = "speech-dispatcher-lib-too-old";
+ break;
+
+ case "missing-symbol":
+ messageId = "speech-dispatcher-missing-symbol";
+ break;
+
+ case "open-fail":
+ messageId = "speech-dispatcher-open-fail";
+ break;
+
+ case "no-voices":
+ messageId = "speech-dispatcher-no-voices";
+ break;
+
+ default:
+ break;
+ }
+
+ let MozXULElement = browser.ownerGlobal.MozXULElement;
+ MozXULElement.insertFTLIfNeeded("browser/speechDispatcher.ftl");
+
+ // Now actually create the notification
+ let notificationBox = browser.getTabBrowser().getNotificationBox(browser);
+ if (notificationBox.getNotificationWithValue(notificationId)) {
+ return;
+ }
+
+ let buttons = [
+ {
+ supportPage: "speechd-setup",
+ },
+ {
+ "l10n-id": "speech-dispatcher-dismiss-button",
+ callback: () => {
+ this.disableNotification();
+ },
+ },
+ ];
+
+ let iconURL = "chrome://browser/skin/drm-icon.svg";
+ notificationBox.appendNotification(
+ notificationId,
+ {
+ label: { "l10n-id": messageId },
+ image: iconURL,
+ priority: notificationBox.PRIORITY_INFO_HIGH,
+ type: "warning",
+ },
+ buttons
+ );
+ }
+}
diff --git a/browser/actors/SwitchDocumentDirectionChild.sys.mjs b/browser/actors/SwitchDocumentDirectionChild.sys.mjs
new file mode 100644
index 0000000000..302662f07b
--- /dev/null
+++ b/browser/actors/SwitchDocumentDirectionChild.sys.mjs
@@ -0,0 +1,27 @@
+/* vim: set ts=2 sw=2 sts=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export class SwitchDocumentDirectionChild extends JSWindowActorChild {
+ receiveMessage(message) {
+ if (message.name == "SwitchDocumentDirection") {
+ let docShell = this.manager.browsingContext.docShell;
+ let document = docShell.QueryInterface(Ci.nsIWebNavigation).document;
+ this.switchDocumentDirection(document);
+ }
+ }
+
+ switchDocumentDirection(document) {
+ // document.dir can also be "auto", in which case it won't change
+ if (document.dir == "ltr" || document.dir == "") {
+ document.dir = "rtl";
+ } else if (document.dir == "rtl") {
+ document.dir = "ltr";
+ }
+
+ for (let frame of document.defaultView.frames) {
+ this.switchDocumentDirection(frame.document);
+ }
+ }
+}
diff --git a/browser/actors/WebRTCChild.sys.mjs b/browser/actors/WebRTCChild.sys.mjs
new file mode 100644
index 0000000000..9febd74b05
--- /dev/null
+++ b/browser/actors/WebRTCChild.sys.mjs
@@ -0,0 +1,578 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "MediaManagerService",
+ "@mozilla.org/mediaManagerService;1",
+ "nsIMediaManagerService"
+);
+
+const kBrowserURL = AppConstants.BROWSER_CHROME_URL;
+
+/**
+ * GlobalMuteListener is a process-global object that listens for changes to
+ * the global mute state of the camera and microphone. When it notices a
+ * change in that state, it tells the underlying platform code to mute or
+ * unmute those devices.
+ */
+const GlobalMuteListener = {
+ _initted: false,
+
+ /**
+ * Initializes the listener if it hasn't been already. This will also
+ * ensure that the microphone and camera are initially in the right
+ * muting state.
+ */
+ init() {
+ if (!this._initted) {
+ Services.cpmm.sharedData.addEventListener("change", this);
+ this._updateCameraMuteState();
+ this._updateMicrophoneMuteState();
+ this._initted = true;
+ }
+ },
+
+ handleEvent(event) {
+ if (event.changedKeys.includes("WebRTC:GlobalCameraMute")) {
+ this._updateCameraMuteState();
+ }
+ if (event.changedKeys.includes("WebRTC:GlobalMicrophoneMute")) {
+ this._updateMicrophoneMuteState();
+ }
+ },
+
+ _updateCameraMuteState() {
+ let shouldMute = Services.cpmm.sharedData.get("WebRTC:GlobalCameraMute");
+ let topic = shouldMute
+ ? "getUserMedia:muteVideo"
+ : "getUserMedia:unmuteVideo";
+ Services.obs.notifyObservers(null, topic);
+ },
+
+ _updateMicrophoneMuteState() {
+ let shouldMute = Services.cpmm.sharedData.get(
+ "WebRTC:GlobalMicrophoneMute"
+ );
+ let topic = shouldMute
+ ? "getUserMedia:muteAudio"
+ : "getUserMedia:unmuteAudio";
+
+ Services.obs.notifyObservers(null, topic);
+ },
+};
+
+export class WebRTCChild extends JSWindowActorChild {
+ actorCreated() {
+ // The user might request that DOM notifications be silenced
+ // when sharing the screen. There doesn't seem to be a great
+ // way of storing that state in any of the objects going into
+ // the WebRTC API or coming out via the observer notification
+ // service, so we store it here on the actor.
+ //
+ // If the user chooses to silence notifications during screen
+ // share, this will get set to true.
+ this.suppressNotifications = false;
+ }
+
+ // Called only for 'unload' to remove pending gUM prompts in reloaded frames.
+ static handleEvent(aEvent) {
+ let contentWindow = aEvent.target.defaultView;
+ let actor = getActorForWindow(contentWindow);
+ if (actor) {
+ for (let key of contentWindow.pendingGetUserMediaRequests.keys()) {
+ actor.sendAsyncMessage("webrtc:CancelRequest", key);
+ }
+ for (let key of contentWindow.pendingPeerConnectionRequests.keys()) {
+ actor.sendAsyncMessage("rtcpeer:CancelRequest", key);
+ }
+ }
+ }
+
+ // This observer is called from BrowserProcessChild to avoid
+ // loading this .jsm when WebRTC is not in use.
+ static observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "getUserMedia:request":
+ handleGUMRequest(aSubject, aTopic, aData);
+ break;
+ case "recording-device-stopped":
+ handleGUMStop(aSubject, aTopic, aData);
+ break;
+ case "PeerConnection:request":
+ handlePCRequest(aSubject, aTopic, aData);
+ break;
+ case "recording-device-events":
+ updateIndicators(aSubject, aTopic, aData);
+ break;
+ case "recording-window-ended":
+ removeBrowserSpecificIndicator(aSubject, aTopic, aData);
+ break;
+ }
+ }
+
+ receiveMessage(aMessage) {
+ switch (aMessage.name) {
+ case "rtcpeer:Allow":
+ case "rtcpeer:Deny": {
+ let callID = aMessage.data.callID;
+ let contentWindow = Services.wm.getOuterWindowWithId(
+ aMessage.data.windowID
+ );
+ forgetPCRequest(contentWindow, callID);
+ let topic =
+ aMessage.name == "rtcpeer:Allow"
+ ? "PeerConnection:response:allow"
+ : "PeerConnection:response:deny";
+ Services.obs.notifyObservers(null, topic, callID);
+ break;
+ }
+ case "webrtc:Allow": {
+ let callID = aMessage.data.callID;
+ let contentWindow = Services.wm.getOuterWindowWithId(
+ aMessage.data.windowID
+ );
+ let devices = contentWindow.pendingGetUserMediaRequests.get(callID);
+ forgetGUMRequest(contentWindow, callID);
+
+ let allowedDevices = Cc["@mozilla.org/array;1"].createInstance(
+ Ci.nsIMutableArray
+ );
+ for (let deviceIndex of aMessage.data.devices) {
+ allowedDevices.appendElement(devices[deviceIndex]);
+ }
+
+ Services.obs.notifyObservers(
+ allowedDevices,
+ "getUserMedia:response:allow",
+ callID
+ );
+
+ this.suppressNotifications = !!aMessage.data.suppressNotifications;
+
+ break;
+ }
+ case "webrtc:Deny":
+ denyGUMRequest(aMessage.data);
+ break;
+ case "webrtc:StopSharing":
+ Services.obs.notifyObservers(
+ null,
+ "getUserMedia:revoke",
+ aMessage.data
+ );
+ break;
+ case "webrtc:MuteCamera":
+ Services.obs.notifyObservers(
+ null,
+ "getUserMedia:muteVideo",
+ aMessage.data
+ );
+ break;
+ case "webrtc:UnmuteCamera":
+ Services.obs.notifyObservers(
+ null,
+ "getUserMedia:unmuteVideo",
+ aMessage.data
+ );
+ break;
+ case "webrtc:MuteMicrophone":
+ Services.obs.notifyObservers(
+ null,
+ "getUserMedia:muteAudio",
+ aMessage.data
+ );
+ break;
+ case "webrtc:UnmuteMicrophone":
+ Services.obs.notifyObservers(
+ null,
+ "getUserMedia:unmuteAudio",
+ aMessage.data
+ );
+ break;
+ }
+ }
+}
+
+function getActorForWindow(window) {
+ try {
+ let windowGlobal = window.windowGlobalChild;
+ if (windowGlobal) {
+ return windowGlobal.getActor("WebRTC");
+ }
+ } catch (ex) {
+ // There might not be an actor for a parent process chrome URL,
+ // and we may not even be allowed to access its windowGlobalChild.
+ }
+
+ return null;
+}
+
+function handlePCRequest(aSubject, aTopic, aData) {
+ let { windowID, innerWindowID, callID, isSecure } = aSubject;
+ let contentWindow = Services.wm.getOuterWindowWithId(windowID);
+ if (!contentWindow.pendingPeerConnectionRequests) {
+ setupPendingListsInitially(contentWindow);
+ }
+ contentWindow.pendingPeerConnectionRequests.add(callID);
+
+ let request = {
+ windowID,
+ innerWindowID,
+ callID,
+ documentURI: contentWindow.document.documentURI,
+ secure: isSecure,
+ };
+
+ let actor = getActorForWindow(contentWindow);
+ if (actor) {
+ actor.sendAsyncMessage("rtcpeer:Request", request);
+ }
+}
+
+function handleGUMStop(aSubject, aTopic, aData) {
+ let contentWindow = Services.wm.getOuterWindowWithId(aSubject.windowID);
+
+ let request = {
+ windowID: aSubject.windowID,
+ rawID: aSubject.rawID,
+ mediaSource: aSubject.mediaSource,
+ };
+
+ let actor = getActorForWindow(contentWindow);
+ if (actor) {
+ actor.sendAsyncMessage("webrtc:StopRecording", request);
+ }
+}
+
+function handleGUMRequest(aSubject, aTopic, aData) {
+ // Now that a getUserMedia request has been created, we should check
+ // to see if we're supposed to have any devices muted. This needs
+ // to occur after the getUserMedia request is made, since the global
+ // mute state is associated with the GetUserMediaWindowListener, which
+ // is only created after a getUserMedia request.
+ GlobalMuteListener.init();
+
+ let constraints = aSubject.getConstraints();
+ let contentWindow = Services.wm.getOuterWindowWithId(aSubject.windowID);
+
+ prompt(
+ aSubject.type,
+ contentWindow,
+ aSubject.windowID,
+ aSubject.callID,
+ constraints,
+ aSubject.getAudioOutputOptions(),
+ aSubject.devices,
+ aSubject.isSecure,
+ aSubject.isHandlingUserInput
+ );
+}
+
+function prompt(
+ aRequestType,
+ aContentWindow,
+ aWindowID,
+ aCallID,
+ aConstraints,
+ aAudioOutputOptions,
+ aDevices,
+ aSecure,
+ aIsHandlingUserInput
+) {
+ let audioInputDevices = [];
+ let videoInputDevices = [];
+ let audioOutputDevices = [];
+ let devices = [];
+
+ // MediaStreamConstraints defines video as 'boolean or MediaTrackConstraints'.
+ let video = aConstraints.video || aConstraints.picture;
+ let audio = aConstraints.audio;
+ let sharingScreen =
+ video && typeof video != "boolean" && video.mediaSource != "camera";
+ let sharingAudio =
+ audio && typeof audio != "boolean" && audio.mediaSource != "microphone";
+
+ const hasInherentConstraints = ({ facingMode, groupId, deviceId }) => {
+ const id = [deviceId].flat()[0];
+ return facingMode || groupId || (id && id != "default"); // flock workaround
+ };
+ let hasInherentAudioConstraints =
+ audio &&
+ !sharingAudio &&
+ [audio, ...(audio.advanced || [])].some(hasInherentConstraints);
+ let hasInherentVideoConstraints =
+ video &&
+ !sharingScreen &&
+ [video, ...(video.advanced || [])].some(hasInherentConstraints);
+
+ for (let device of aDevices) {
+ device = device.QueryInterface(Ci.nsIMediaDevice);
+ let deviceObject = {
+ name: device.rawName, // unfiltered device name to show to the user
+ deviceIndex: devices.length,
+ rawId: device.rawId,
+ id: device.id,
+ mediaSource: device.mediaSource,
+ canRequestOsLevelPrompt: device.canRequestOsLevelPrompt,
+ };
+ switch (device.type) {
+ case "audioinput":
+ // Check that if we got a microphone, we have not requested an audio
+ // capture, and if we have requested an audio capture, we are not
+ // getting a microphone instead.
+ if (audio && (device.mediaSource == "microphone") != sharingAudio) {
+ audioInputDevices.push(deviceObject);
+ devices.push(device);
+ }
+ break;
+ case "videoinput":
+ // Verify that if we got a camera, we haven't requested a screen share,
+ // or that if we requested a screen share we aren't getting a camera.
+ if (video && (device.mediaSource == "camera") != sharingScreen) {
+ if (device.scary) {
+ deviceObject.scary = true;
+ }
+ videoInputDevices.push(deviceObject);
+ devices.push(device);
+ }
+ break;
+ case "audiooutput":
+ if (aRequestType == "selectaudiooutput") {
+ audioOutputDevices.push(deviceObject);
+ devices.push(device);
+ }
+ break;
+ }
+ }
+
+ let requestTypes = [];
+ if (videoInputDevices.length) {
+ requestTypes.push(sharingScreen ? "Screen" : "Camera");
+ }
+ if (audioInputDevices.length) {
+ requestTypes.push(sharingAudio ? "AudioCapture" : "Microphone");
+ }
+ if (audioOutputDevices.length) {
+ requestTypes.push("Speaker");
+ }
+
+ if (!requestTypes.length) {
+ // Device enumeration is done ahead of handleGUMRequest, so we're not
+ // responsible for handling the NotFoundError spec case.
+ denyGUMRequest({ callID: aCallID });
+ return;
+ }
+
+ if (!aContentWindow.pendingGetUserMediaRequests) {
+ setupPendingListsInitially(aContentWindow);
+ }
+ aContentWindow.pendingGetUserMediaRequests.set(aCallID, devices);
+
+ // WebRTC prompts have a bunch of special requirements, such as being able to
+ // grant two permissions (microphone and camera), selecting devices and showing
+ // a screen sharing preview. All this could have probably been baked into
+ // nsIContentPermissionRequest prompts, but the team that implemented this back
+ // then chose to just build their own prompting mechanism instead.
+ //
+ // So, what you are looking at here is not a real nsIContentPermissionRequest, but
+ // something that looks really similar and will be transmitted to webrtcUI.sys.mjs
+ // for showing the prompt.
+ // Note that we basically do the permission delegate check in
+ // nsIContentPermissionRequest, but because webrtc uses their own prompting
+ // system, we should manually apply the delegate policy here. Permission
+ // should be delegated using Feature Policy and top principal
+ const permDelegateHandler =
+ aContentWindow.document.permDelegateHandler.QueryInterface(
+ Ci.nsIPermissionDelegateHandler
+ );
+
+ let secondOrigin = undefined;
+ if (permDelegateHandler.maybeUnsafePermissionDelegate(requestTypes)) {
+ // We are going to prompt both first party and third party origin.
+ // SecondOrigin should be third party
+ secondOrigin = aContentWindow.document.nodePrincipal.origin;
+ }
+
+ let request = {
+ callID: aCallID,
+ windowID: aWindowID,
+ secondOrigin,
+ documentURI: aContentWindow.document.documentURI,
+ secure: aSecure,
+ isHandlingUserInput: aIsHandlingUserInput,
+ requestTypes,
+ sharingScreen,
+ sharingAudio,
+ audioInputDevices,
+ videoInputDevices,
+ audioOutputDevices,
+ hasInherentAudioConstraints,
+ hasInherentVideoConstraints,
+ audioOutputId: aAudioOutputOptions.deviceId,
+ };
+
+ let actor = getActorForWindow(aContentWindow);
+ if (actor) {
+ actor.sendAsyncMessage("webrtc:Request", request);
+ }
+}
+
+function denyGUMRequest(aData) {
+ let subject;
+ if (aData.noOSPermission) {
+ subject = "getUserMedia:response:noOSPermission";
+ } else {
+ subject = "getUserMedia:response:deny";
+ }
+ Services.obs.notifyObservers(null, subject, aData.callID);
+
+ if (!aData.windowID) {
+ return;
+ }
+ let contentWindow = Services.wm.getOuterWindowWithId(aData.windowID);
+ if (contentWindow.pendingGetUserMediaRequests) {
+ forgetGUMRequest(contentWindow, aData.callID);
+ }
+}
+
+function forgetGUMRequest(aContentWindow, aCallID) {
+ aContentWindow.pendingGetUserMediaRequests.delete(aCallID);
+ forgetPendingListsEventually(aContentWindow);
+}
+
+function forgetPCRequest(aContentWindow, aCallID) {
+ aContentWindow.pendingPeerConnectionRequests.delete(aCallID);
+ forgetPendingListsEventually(aContentWindow);
+}
+
+function setupPendingListsInitially(aContentWindow) {
+ if (aContentWindow.pendingGetUserMediaRequests) {
+ return;
+ }
+ aContentWindow.pendingGetUserMediaRequests = new Map();
+ aContentWindow.pendingPeerConnectionRequests = new Set();
+ aContentWindow.addEventListener("unload", WebRTCChild.handleEvent);
+}
+
+function forgetPendingListsEventually(aContentWindow) {
+ if (
+ aContentWindow.pendingGetUserMediaRequests.size ||
+ aContentWindow.pendingPeerConnectionRequests.size
+ ) {
+ return;
+ }
+ aContentWindow.pendingGetUserMediaRequests = null;
+ aContentWindow.pendingPeerConnectionRequests = null;
+ aContentWindow.removeEventListener("unload", WebRTCChild.handleEvent);
+}
+
+function updateIndicators(aSubject, aTopic, aData) {
+ if (
+ aSubject instanceof Ci.nsIPropertyBag &&
+ aSubject.getProperty("requestURL") == kBrowserURL
+ ) {
+ // Ignore notifications caused by the browser UI showing previews.
+ return;
+ }
+
+ let contentWindow = aSubject.getProperty("window");
+
+ let actor = contentWindow ? getActorForWindow(contentWindow) : null;
+ if (actor) {
+ let tabState = getTabStateForContentWindow(contentWindow, false);
+ tabState.windowId = getInnerWindowIDForWindow(contentWindow);
+
+ // If we were silencing DOM notifications before, but we've updated
+ // state such that we're no longer sharing one of our displays, then
+ // reset the silencing state.
+ if (actor.suppressNotifications) {
+ if (!tabState.screen && !tabState.window && !tabState.browser) {
+ actor.suppressNotifications = false;
+ }
+ }
+
+ tabState.suppressNotifications = actor.suppressNotifications;
+
+ actor.sendAsyncMessage("webrtc:UpdateIndicators", tabState);
+ }
+}
+
+function removeBrowserSpecificIndicator(aSubject, aTopic, aData) {
+ let contentWindow = Services.wm.getOuterWindowWithId(aData);
+ if (contentWindow.document.documentURI == kBrowserURL) {
+ // Ignore notifications caused by the browser UI showing previews.
+ return;
+ }
+
+ let tabState = getTabStateForContentWindow(contentWindow, true);
+
+ tabState.windowId = aData;
+
+ let actor = getActorForWindow(contentWindow);
+ if (actor) {
+ actor.sendAsyncMessage("webrtc:UpdateIndicators", tabState);
+ }
+}
+
+function getTabStateForContentWindow(aContentWindow, aForRemove = false) {
+ let camera = {},
+ microphone = {},
+ screen = {},
+ window = {},
+ browser = {},
+ devices = {};
+ lazy.MediaManagerService.mediaCaptureWindowState(
+ aContentWindow,
+ camera,
+ microphone,
+ screen,
+ window,
+ browser,
+ devices
+ );
+
+ if (
+ camera.value == lazy.MediaManagerService.STATE_NOCAPTURE &&
+ microphone.value == lazy.MediaManagerService.STATE_NOCAPTURE &&
+ screen.value == lazy.MediaManagerService.STATE_NOCAPTURE &&
+ window.value == lazy.MediaManagerService.STATE_NOCAPTURE &&
+ browser.value == lazy.MediaManagerService.STATE_NOCAPTURE
+ ) {
+ return { remove: true };
+ }
+
+ if (aForRemove) {
+ return { remove: true };
+ }
+
+ let serializedDevices = [];
+ if (Array.isArray(devices.value)) {
+ serializedDevices = devices.value.map(device => {
+ return {
+ type: device.type,
+ mediaSource: device.mediaSource,
+ rawId: device.rawId,
+ scary: device.scary,
+ };
+ });
+ }
+
+ return {
+ camera: camera.value,
+ microphone: microphone.value,
+ screen: screen.value,
+ window: window.value,
+ browser: browser.value,
+ devices: serializedDevices,
+ };
+}
+
+function getInnerWindowIDForWindow(aContentWindow) {
+ return aContentWindow.windowGlobalChild.innerWindowId;
+}
diff --git a/browser/actors/WebRTCParent.sys.mjs b/browser/actors/WebRTCParent.sys.mjs
new file mode 100644
index 0000000000..09c39e7393
--- /dev/null
+++ b/browser/actors/WebRTCParent.sys.mjs
@@ -0,0 +1,1484 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ SitePermissions: "resource:///modules/SitePermissions.sys.mjs",
+ webrtcUI: "resource:///modules/webrtcUI.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "OSPermissions",
+ "@mozilla.org/ospermissionrequest;1",
+ "nsIOSPermissionRequest"
+);
+
+export class WebRTCParent extends JSWindowActorParent {
+ didDestroy() {
+ // Media stream tracks end on unload, so call stopRecording() on them early
+ // *before* we go away, to ensure we're working with the right principal.
+ this.stopRecording(this.manager.outerWindowId);
+ lazy.webrtcUI.forgetStreamsFromBrowserContext(this.browsingContext);
+ // Must clear activePerms here to prevent them from being read by laggard
+ // stopRecording() calls, which due to IPC, may come in *after* navigation.
+ // This is to prevent granting temporary grace periods to the wrong page.
+ lazy.webrtcUI.activePerms.delete(this.manager.outerWindowId);
+ }
+
+ getBrowser() {
+ return this.browsingContext.top.embedderElement;
+ }
+
+ receiveMessage(aMessage) {
+ switch (aMessage.name) {
+ case "rtcpeer:Request": {
+ let params = Object.freeze(
+ Object.assign(
+ {
+ origin: this.manager.documentPrincipal.origin,
+ },
+ aMessage.data
+ )
+ );
+
+ let blockers = Array.from(lazy.webrtcUI.peerConnectionBlockers);
+
+ (async function () {
+ for (let blocker of blockers) {
+ try {
+ let result = await blocker(params);
+ if (result == "deny") {
+ return false;
+ }
+ } catch (err) {
+ console.error(`error in PeerConnection blocker: ${err.message}`);
+ }
+ }
+ return true;
+ })().then(decision => {
+ let message;
+ if (decision) {
+ lazy.webrtcUI.emitter.emit("peer-request-allowed", params);
+ message = "rtcpeer:Allow";
+ } else {
+ lazy.webrtcUI.emitter.emit("peer-request-blocked", params);
+ message = "rtcpeer:Deny";
+ }
+
+ this.sendAsyncMessage(message, {
+ callID: params.callID,
+ windowID: params.windowID,
+ });
+ });
+ break;
+ }
+ case "rtcpeer:CancelRequest": {
+ let params = Object.freeze({
+ origin: this.manager.documentPrincipal.origin,
+ callID: aMessage.data,
+ });
+ lazy.webrtcUI.emitter.emit("peer-request-cancel", params);
+ break;
+ }
+ case "webrtc:Request": {
+ let data = aMessage.data;
+
+ // Record third party origins for telemetry.
+ let isThirdPartyOrigin =
+ this.manager.documentPrincipal.origin !=
+ this.manager.topWindowContext.documentPrincipal.origin;
+ data.isThirdPartyOrigin = isThirdPartyOrigin;
+
+ data.origin = this.manager.topWindowContext.documentPrincipal.origin;
+
+ let browser = this.getBrowser();
+ if (browser.fxrPermissionPrompt) {
+ // For Firefox Reality on Desktop, switch to a different mechanism to
+ // prompt the user since fewer permissions are available and since many
+ // UI dependencies are not available.
+ browser.fxrPermissionPrompt(data);
+ } else {
+ prompt(this, this.getBrowser(), data);
+ }
+ break;
+ }
+ case "webrtc:StopRecording":
+ this.stopRecording(
+ aMessage.data.windowID,
+ aMessage.data.mediaSource,
+ aMessage.data.rawID
+ );
+ break;
+ case "webrtc:CancelRequest": {
+ let browser = this.getBrowser();
+ // browser can be null when closing the window
+ if (browser) {
+ removePrompt(browser, aMessage.data);
+ }
+ break;
+ }
+ case "webrtc:UpdateIndicators": {
+ let { data } = aMessage;
+ data.documentURI = this.manager.documentURI?.spec;
+ if (data.windowId) {
+ if (!data.remove) {
+ data.principal = this.manager.topWindowContext.documentPrincipal;
+ }
+ lazy.webrtcUI.streamAddedOrRemoved(this.browsingContext, data);
+ }
+ this.updateIndicators(data);
+ break;
+ }
+ }
+ }
+
+ updateIndicators(aData) {
+ let browsingContext = this.browsingContext;
+ let state = lazy.webrtcUI.updateIndicators(browsingContext.top);
+
+ let browser = this.getBrowser();
+ if (!browser) {
+ return;
+ }
+
+ state.browsingContext = browsingContext;
+ state.windowId = aData.windowId;
+
+ let tabbrowser = browser.ownerGlobal.gBrowser;
+ if (tabbrowser) {
+ tabbrowser.updateBrowserSharing(browser, {
+ webRTC: state,
+ });
+ }
+ }
+
+ denyRequest(aRequest) {
+ this.sendAsyncMessage("webrtc:Deny", {
+ callID: aRequest.callID,
+ windowID: aRequest.windowID,
+ });
+ }
+
+ //
+ // Deny the request because the browser does not have access to the
+ // camera or microphone due to OS security restrictions. The user may
+ // have granted camera/microphone access to the site, but not have
+ // allowed the browser access in OS settings.
+ //
+ denyRequestNoPermission(aRequest) {
+ this.sendAsyncMessage("webrtc:Deny", {
+ callID: aRequest.callID,
+ windowID: aRequest.windowID,
+ noOSPermission: true,
+ });
+ }
+
+ //
+ // Check if we have permission to access the camera or screen-sharing and/or
+ // microphone at the OS level. Triggers a request to access the device if access
+ // is needed and the permission state has not yet been determined.
+ //
+ async checkOSPermission(camNeeded, micNeeded, scrNeeded) {
+ // Don't trigger OS permission requests for fake devices. Fake devices don't
+ // require OS permission and the dialogs are problematic in automated testing
+ // (where fake devices are used) because they require user interaction.
+ if (
+ !scrNeeded &&
+ Services.prefs.getBoolPref("media.navigator.streams.fake", false)
+ ) {
+ return true;
+ }
+ let camStatus = {},
+ micStatus = {};
+ if (camNeeded || micNeeded) {
+ lazy.OSPermissions.getMediaCapturePermissionState(camStatus, micStatus);
+ }
+ if (camNeeded) {
+ let camPermission = camStatus.value;
+ let camAccessible = await this.checkAndGetOSPermission(
+ camPermission,
+ lazy.OSPermissions.requestVideoCapturePermission
+ );
+ if (!camAccessible) {
+ return false;
+ }
+ }
+ if (micNeeded) {
+ let micPermission = micStatus.value;
+ let micAccessible = await this.checkAndGetOSPermission(
+ micPermission,
+ lazy.OSPermissions.requestAudioCapturePermission
+ );
+ if (!micAccessible) {
+ return false;
+ }
+ }
+ let scrStatus = {};
+ if (scrNeeded) {
+ lazy.OSPermissions.getScreenCapturePermissionState(scrStatus);
+ if (scrStatus.value == lazy.OSPermissions.PERMISSION_STATE_DENIED) {
+ lazy.OSPermissions.maybeRequestScreenCapturePermission();
+ return false;
+ }
+ }
+ return true;
+ }
+
+ //
+ // Given a device's permission, return true if the device is accessible. If
+ // the device's permission is not yet determined, request access to the device.
+ // |requestPermissionFunc| must return a promise that resolves with true
+ // if the device is accessible and false otherwise.
+ //
+ async checkAndGetOSPermission(devicePermission, requestPermissionFunc) {
+ if (
+ devicePermission == lazy.OSPermissions.PERMISSION_STATE_DENIED ||
+ devicePermission == lazy.OSPermissions.PERMISSION_STATE_RESTRICTED
+ ) {
+ return false;
+ }
+ if (devicePermission == lazy.OSPermissions.PERMISSION_STATE_NOTDETERMINED) {
+ let deviceAllowed = await requestPermissionFunc();
+ if (!deviceAllowed) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ stopRecording(aOuterWindowId, aMediaSource, aRawId) {
+ for (let { browsingContext, state } of lazy.webrtcUI._streams) {
+ if (browsingContext == this.browsingContext) {
+ let { principal } = state;
+ for (let { mediaSource, rawId } of state.devices) {
+ if (aRawId && (aRawId != rawId || aMediaSource != mediaSource)) {
+ continue;
+ }
+ // Deactivate this device (no aRawId means all devices).
+ this.deactivateDevicePerm(
+ aOuterWindowId,
+ mediaSource,
+ rawId,
+ principal
+ );
+ }
+ }
+ }
+ }
+
+ /**
+ * Add a device record to webrtcUI.activePerms, denoting a device as in use.
+ * Important to call for permission grace periods to work correctly.
+ */
+ activateDevicePerm(aOuterWindowId, aMediaSource, aId) {
+ if (!lazy.webrtcUI.activePerms.has(this.manager.outerWindowId)) {
+ lazy.webrtcUI.activePerms.set(this.manager.outerWindowId, new Map());
+ }
+ lazy.webrtcUI.activePerms
+ .get(this.manager.outerWindowId)
+ .set(aOuterWindowId + aMediaSource + aId, aMediaSource);
+ }
+
+ /**
+ * Remove a device record from webrtcUI.activePerms, denoting a device as
+ * no longer in use by the site. Meaning: gUM requests for this device will
+ * no longer be implicitly granted through the webrtcUI.activePerms mechanism.
+ *
+ * However, if webrtcUI.deviceGracePeriodTimeoutMs is defined, the implicit
+ * grant is extended for an additional period of time through SitePermissions.
+ */
+ deactivateDevicePerm(
+ aOuterWindowId,
+ aMediaSource,
+ aId,
+ aPermissionPrincipal
+ ) {
+ // If we don't have active permissions for the given window anymore don't
+ // set a grace period. This happens if there has been a user revoke and
+ // webrtcUI clears the permissions.
+ if (!lazy.webrtcUI.activePerms.has(this.manager.outerWindowId)) {
+ return;
+ }
+ let map = lazy.webrtcUI.activePerms.get(this.manager.outerWindowId);
+ map.delete(aOuterWindowId + aMediaSource + aId);
+
+ // Add a permission grace period for camera and microphone only
+ if (
+ (aMediaSource != "camera" && aMediaSource != "microphone") ||
+ !this.browsingContext.top.embedderElement
+ ) {
+ return;
+ }
+ let gracePeriodMs = lazy.webrtcUI.deviceGracePeriodTimeoutMs;
+ if (gracePeriodMs > 0) {
+ // A grace period is extended (even past navigation) to this outer window
+ // + origin + deviceId only. This avoids re-prompting without the user
+ // having to persist permission to the site, in a common case of a web
+ // conference asking them for the camera in a lobby page, before
+ // navigating to the actual meeting room page. Does not survive tab close.
+ //
+ // Caution: since navigation causes deactivation, we may be in the middle
+ // of one. We must pass in a principal & URI for SitePermissions to use
+ // instead of browser.currentURI, because the latter may point to a new
+ // page already, and we must not leak permission to unrelated pages.
+ //
+ let permissionName = [aMediaSource, aId].join("^");
+ lazy.SitePermissions.setForPrincipal(
+ aPermissionPrincipal,
+ permissionName,
+ lazy.SitePermissions.ALLOW,
+ lazy.SitePermissions.SCOPE_TEMPORARY,
+ this.browsingContext.top.embedderElement,
+ gracePeriodMs
+ );
+ }
+ }
+
+ /**
+ * Checks if the principal has sufficient permissions
+ * to fulfill the given request. If the request can be
+ * fulfilled, a message is sent to the child
+ * signaling that WebRTC permissions were given and
+ * this function will return true.
+ */
+ checkRequestAllowed(aRequest, aPrincipal) {
+ if (!aRequest.secure) {
+ return false;
+ }
+ // Always prompt for screen sharing
+ if (aRequest.sharingScreen) {
+ return false;
+ }
+ let {
+ callID,
+ windowID,
+ audioInputDevices,
+ videoInputDevices,
+ audioOutputDevices,
+ hasInherentAudioConstraints,
+ hasInherentVideoConstraints,
+ audioOutputId,
+ } = aRequest;
+
+ if (audioOutputDevices?.length) {
+ // Prompt if a specific device is not requested, available and allowed.
+ let device = audioOutputDevices.find(({ id }) => id == audioOutputId);
+ if (
+ !device ||
+ !lazy.SitePermissions.getForPrincipal(
+ aPrincipal,
+ ["speaker", device.id].join("^"),
+ this.getBrowser()
+ ).state == lazy.SitePermissions.ALLOW
+ ) {
+ return false;
+ }
+ this.sendAsyncMessage("webrtc:Allow", {
+ callID,
+ windowID,
+ devices: [device.deviceIndex],
+ });
+ return true;
+ }
+
+ let { perms } = Services;
+ if (
+ perms.testExactPermissionFromPrincipal(aPrincipal, "MediaManagerVideo")
+ ) {
+ perms.removeFromPrincipal(aPrincipal, "MediaManagerVideo");
+ }
+
+ // Don't use persistent permissions from the top-level principal
+ // if we're handling a potentially insecure third party
+ // through a wildcard ("*") allow attribute.
+ let limited = aRequest.secondOrigin;
+
+ let map = lazy.webrtcUI.activePerms.get(this.manager.outerWindowId);
+ // We consider a camera or mic active if it is active or was active within a
+ // grace period of milliseconds ago.
+ const isAllowed = ({ mediaSource, rawId }, permissionID) =>
+ map?.get(windowID + mediaSource + rawId) ||
+ (!limited &&
+ (lazy.SitePermissions.getForPrincipal(aPrincipal, permissionID).state ==
+ lazy.SitePermissions.ALLOW ||
+ lazy.SitePermissions.getForPrincipal(
+ aPrincipal,
+ [mediaSource, rawId].join("^"),
+ this.getBrowser()
+ ).state == lazy.SitePermissions.ALLOW));
+
+ let microphone;
+ if (audioInputDevices.length) {
+ for (let device of audioInputDevices) {
+ if (isAllowed(device, "microphone")) {
+ microphone = device;
+ break;
+ }
+ if (hasInherentAudioConstraints) {
+ // Inherent constraints suggest site is looking for a specific mic
+ break;
+ }
+ // Some sites don't look too hard at what they get, and spam gUM without
+ // adjusting what they ask for to match what they got last time. To keep
+ // users in charge and reduce prompts, ignore other constraints by
+ // returning the most-fit microphone a site already has access to.
+ }
+ if (!microphone) {
+ return false;
+ }
+ }
+ let camera;
+ if (videoInputDevices.length) {
+ for (let device of videoInputDevices) {
+ if (isAllowed(device, "camera")) {
+ camera = device;
+ break;
+ }
+ if (hasInherentVideoConstraints) {
+ // Inherent constraints suggest site is looking for a specific camera
+ break;
+ }
+ // Some sites don't look too hard at what they get, and spam gUM without
+ // adjusting what they ask for to match what they got last time. To keep
+ // users in charge and reduce prompts, ignore other constraints by
+ // returning the most-fit camera a site already has access to.
+ }
+ if (!camera) {
+ return false;
+ }
+ }
+ let devices = [];
+ if (camera) {
+ perms.addFromPrincipal(
+ aPrincipal,
+ "MediaManagerVideo",
+ perms.ALLOW_ACTION,
+ perms.EXPIRE_SESSION
+ );
+ devices.push(camera.deviceIndex);
+ this.activateDevicePerm(windowID, camera.mediaSource, camera.rawId);
+ }
+ if (microphone) {
+ devices.push(microphone.deviceIndex);
+ this.activateDevicePerm(
+ windowID,
+ microphone.mediaSource,
+ microphone.rawId
+ );
+ }
+ this.checkOSPermission(!!camera, !!microphone, false).then(
+ havePermission => {
+ if (havePermission) {
+ this.sendAsyncMessage("webrtc:Allow", { callID, windowID, devices });
+ } else {
+ this.denyRequestNoPermission(aRequest);
+ }
+ }
+ );
+ return true;
+ }
+}
+
+function prompt(aActor, aBrowser, aRequest) {
+ let {
+ audioInputDevices,
+ videoInputDevices,
+ audioOutputDevices,
+ sharingScreen,
+ sharingAudio,
+ requestTypes,
+ } = aRequest;
+
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ aRequest.origin
+ );
+
+ // For add-on principals, we immediately check for permission instead
+ // of waiting for the notification to focus. This allows for supporting
+ // cases such as browserAction popups where no prompt is shown.
+ if (principal.addonPolicy) {
+ let isPopup = false;
+ let isBackground = false;
+
+ for (let view of principal.addonPolicy.extension.views) {
+ if (view.viewType == "popup" && view.xulBrowser == aBrowser) {
+ isPopup = true;
+ }
+ if (view.viewType == "background" && view.xulBrowser == aBrowser) {
+ isBackground = true;
+ }
+ }
+
+ // Recording from background pages is considered too sensitive and will
+ // always be denied.
+ if (isBackground) {
+ aActor.denyRequest(aRequest);
+ return;
+ }
+
+ // If the request comes from a popup, we don't want to show the prompt,
+ // but we do want to allow the request if the user previously gave permission.
+ if (isPopup) {
+ if (!aActor.checkRequestAllowed(aRequest, principal, aBrowser)) {
+ aActor.denyRequest(aRequest);
+ }
+ return;
+ }
+ }
+
+ // If the user has already denied access once in this tab,
+ // deny again without even showing the notification icon.
+ for (const type of requestTypes) {
+ const permissionID =
+ type == "AudioCapture" ? "microphone" : type.toLowerCase();
+ if (
+ lazy.SitePermissions.getForPrincipal(principal, permissionID, aBrowser)
+ .state == lazy.SitePermissions.BLOCK
+ ) {
+ aActor.denyRequest(aRequest);
+ return;
+ }
+ }
+
+ let chromeDoc = aBrowser.ownerDocument;
+ const localization = new Localization(
+ ["browser/webrtcIndicator.ftl", "branding/brand.ftl"],
+ true
+ );
+
+ /** @type {"Screen" | "Camera" | null} */
+ let reqVideoInput = null;
+ if (videoInputDevices.length) {
+ reqVideoInput = sharingScreen ? "Screen" : "Camera";
+ }
+ /** @type {"AudioCapture" | "Microphone" | null} */
+ let reqAudioInput = null;
+ if (audioInputDevices.length) {
+ reqAudioInput = sharingAudio ? "AudioCapture" : "Microphone";
+ }
+ const reqAudioOutput = !!audioOutputDevices.length;
+
+ const stringId = getPromptMessageId(
+ reqVideoInput,
+ reqAudioInput,
+ reqAudioOutput,
+ !!aRequest.secondOrigin
+ );
+ let message;
+ let originToShow;
+ if (principal.schemeIs("file")) {
+ message = localization.formatValueSync(stringId + "-with-file");
+ originToShow = null;
+ } else {
+ message = localization.formatValueSync(stringId, {
+ origin: "<>",
+ thirdParty: "{}",
+ });
+ originToShow = lazy.webrtcUI.getHostOrExtensionName(principal.URI);
+ }
+ let notification; // Used by action callbacks.
+ const actionL10nIds = [{ id: "webrtc-action-allow" }];
+
+ let notificationSilencingEnabled = Services.prefs.getBoolPref(
+ "privacy.webrtc.allowSilencingNotifications"
+ );
+
+ const isNotNowLabelEnabled =
+ reqAudioOutput || allowedOrActiveCameraOrMicrophone(aBrowser);
+ let secondaryActions = [];
+ if (reqAudioOutput || (notificationSilencingEnabled && sharingScreen)) {
+ // We want to free up the checkbox at the bottom of the permission
+ // panel for the notification silencing option, so we use a
+ // different configuration for the permissions panel when
+ // notification silencing is enabled.
+
+ let permissionName = reqAudioOutput ? "speaker" : "screen";
+ // When selecting speakers, we always offer 'Not now' instead of 'Block'.
+ // When selecting screens, we offer 'Not now' if and only if we have a
+ // (temporary) allow permission for some mic/cam device.
+ const id = isNotNowLabelEnabled
+ ? "webrtc-action-not-now"
+ : "webrtc-action-block";
+ actionL10nIds.push({ id }, { id: "webrtc-action-always-block" });
+ secondaryActions = [
+ {
+ callback(aState) {
+ aActor.denyRequest(aRequest);
+ if (!isNotNowLabelEnabled) {
+ lazy.SitePermissions.setForPrincipal(
+ principal,
+ permissionName,
+ lazy.SitePermissions.BLOCK,
+ lazy.SitePermissions.SCOPE_TEMPORARY,
+ notification.browser
+ );
+ }
+ },
+ },
+ {
+ callback(aState) {
+ aActor.denyRequest(aRequest);
+ lazy.SitePermissions.setForPrincipal(
+ principal,
+ permissionName,
+ lazy.SitePermissions.BLOCK,
+ lazy.SitePermissions.SCOPE_PERSISTENT,
+ notification.browser
+ );
+ },
+ },
+ ];
+ } else {
+ // We have a (temporary) allow permission for some device
+ // hence we offer a 'Not now' label instead of 'Block'.
+ const id = isNotNowLabelEnabled
+ ? "webrtc-action-not-now"
+ : "webrtc-action-block";
+ actionL10nIds.push({ id });
+ secondaryActions = [
+ {
+ callback(aState) {
+ aActor.denyRequest(aRequest);
+
+ const isPersistent = aState?.checkboxChecked;
+
+ // Choosing 'Not now' will not set a block permission
+ // we just deny the request. This enables certain use cases
+ // where sites want to switch devices, but users back out of the permission request
+ // (See Bug 1609578).
+ // Selecting 'Remember this decision' and clicking 'Not now' will set a persistent block
+ if (!isPersistent && isNotNowLabelEnabled) {
+ return;
+ }
+
+ // Denying a camera / microphone prompt means we set a temporary or
+ // persistent permission block. There may still be active grace period
+ // permissions at this point. We need to remove them.
+ clearTemporaryGrants(
+ notification.browser,
+ reqVideoInput === "Camera",
+ !!reqAudioInput
+ );
+
+ const scope = isPersistent
+ ? lazy.SitePermissions.SCOPE_PERSISTENT
+ : lazy.SitePermissions.SCOPE_TEMPORARY;
+ if (reqAudioInput) {
+ lazy.SitePermissions.setForPrincipal(
+ principal,
+ "microphone",
+ lazy.SitePermissions.BLOCK,
+ scope,
+ notification.browser
+ );
+ }
+ if (reqVideoInput) {
+ lazy.SitePermissions.setForPrincipal(
+ principal,
+ sharingScreen ? "screen" : "camera",
+ lazy.SitePermissions.BLOCK,
+ scope,
+ notification.browser
+ );
+ }
+ },
+ },
+ ];
+ }
+
+ // The formatMessagesSync method returns an array of results
+ // for each message that was requested, and for the ones with
+ // attributes, returns an attributes array with objects like:
+ // { name: "label", value: "somevalue" }
+ const [mainMessage, ...secondaryMessages] = localization
+ .formatMessagesSync(actionL10nIds)
+ .map(msg =>
+ msg.attributes.reduce(
+ (acc, { name, value }) => ({ ...acc, [name]: value }),
+ {}
+ )
+ );
+
+ const mainAction = {
+ label: mainMessage.label,
+ accessKey: mainMessage.accesskey,
+ // The real callback will be set during the "showing" event. The
+ // empty function here is so that PopupNotifications.show doesn't
+ // reject the action.
+ callback() {},
+ };
+
+ for (let i = 0; i < secondaryActions.length; ++i) {
+ secondaryActions[i].label = secondaryMessages[i].label;
+ secondaryActions[i].accessKey = secondaryMessages[i].accesskey;
+ }
+
+ let options = {
+ name: originToShow,
+ persistent: true,
+ hideClose: true,
+ eventCallback(aTopic, aNewBrowser, isCancel) {
+ if (aTopic == "swapping") {
+ return true;
+ }
+
+ let doc = this.browser.ownerDocument;
+
+ // Clean-up video streams of screensharing previews.
+ if (
+ reqVideoInput !== "Screen" ||
+ aTopic == "dismissed" ||
+ aTopic == "removed"
+ ) {
+ let video = doc.getElementById("webRTC-previewVideo");
+ video.deviceId = null; // Abort previews still being started.
+ if (video.stream) {
+ video.stream.getTracks().forEach(t => t.stop());
+ video.stream = null;
+ video.src = null;
+ doc.getElementById("webRTC-preview").hidden = true;
+ }
+ let menupopup = doc.getElementById("webRTC-selectWindow-menupopup");
+ if (menupopup._commandEventListener) {
+ menupopup.removeEventListener(
+ "command",
+ menupopup._commandEventListener
+ );
+ menupopup._commandEventListener = null;
+ }
+ }
+
+ if (aTopic == "removed" && notification && isCancel) {
+ // The notification has been cancelled (e.g. due to entering
+ // full-screen). Also cancel the webRTC request.
+ aActor.denyRequest(aRequest);
+ } else if (
+ aTopic == "shown" &&
+ audioOutputDevices.length > 1 &&
+ !notification.wasDismissed
+ ) {
+ // Focus the list on first show so that arrow keys select the speaker.
+ doc.getElementById("webRTC-selectSpeaker-richlistbox").focus();
+ }
+
+ if (aTopic != "showing") {
+ return false;
+ }
+
+ // If BLOCK has been set persistently in the permission manager or has
+ // been set on the tab, then it is handled synchronously before we add
+ // the notification.
+ // Handling of ALLOW is delayed until the popupshowing event,
+ // to avoid granting permissions automatically to background tabs.
+ if (aActor.checkRequestAllowed(aRequest, principal, aBrowser)) {
+ this.remove();
+ return true;
+ }
+
+ /**
+ * Prepare the device selector for one kind of device.
+ * @param {Object[]} devices - available devices of this kind.
+ * @param {string} IDPrefix - indicating kind of device and so
+ * associated UI elements.
+ * @param {string[]} describedByIDs - an array to which might be
+ * appended ids of elements that describe the panel, for the caller to
+ * use in the aria-describedby attribute.
+ */
+ function listDevices(devices, IDPrefix, describedByIDs) {
+ let labelID = `${IDPrefix}-single-device-label`;
+ let list;
+ let itemParent;
+ if (IDPrefix == "webRTC-selectSpeaker") {
+ list = doc.getElementById(`${IDPrefix}-richlistbox`);
+ itemParent = list;
+ } else {
+ itemParent = doc.getElementById(`${IDPrefix}-menupopup`);
+ list = itemParent.parentNode; // menulist
+ }
+ while (itemParent.lastChild) {
+ itemParent.removeChild(itemParent.lastChild);
+ }
+
+ // Removing the child nodes of a menupopup doesn't clear the value
+ // attribute of its menulist. Similary for richlistbox state. This can
+ // have unfortunate side effects when the list is rebuilt with a
+ // different content, so we set the selectedIndex explicitly to reset
+ // state.
+ let defaultIndex = 0;
+
+ for (let device of devices) {
+ let item = addDeviceToList(list, device.name, device.deviceIndex);
+ if (IDPrefix == "webRTC-selectSpeaker") {
+ item.addEventListener("dblclick", event => {
+ // Allow the chosen speakers via
+ // .popup-notification-primary-button so that
+ // "security.notification_enable_delay" is checked.
+ event.target.closest("popupnotification").button.doCommand();
+ });
+ if (device.id == aRequest.audioOutputId) {
+ defaultIndex = device.deviceIndex;
+ }
+ }
+ }
+ list.selectedIndex = defaultIndex;
+
+ let label = doc.getElementById(labelID);
+ if (devices.length == 1) {
+ describedByIDs.push(`${IDPrefix}-icon`, labelID);
+ label.value = devices[0].name;
+ label.hidden = false;
+ list.hidden = true;
+ } else {
+ label.hidden = true;
+ list.hidden = false;
+ }
+ }
+
+ let notificationElement = doc.getElementById(
+ "webRTC-shareDevices-notification"
+ );
+
+ function checkDisabledWindowMenuItem() {
+ let list = doc.getElementById("webRTC-selectWindow-menulist");
+ let item = list.selectedItem;
+ if (!item || item.hasAttribute("disabled")) {
+ notificationElement.setAttribute("invalidselection", "true");
+ } else {
+ notificationElement.removeAttribute("invalidselection");
+ }
+ }
+
+ function listScreenShareDevices(menupopup, devices) {
+ while (menupopup.lastChild) {
+ menupopup.removeChild(menupopup.lastChild);
+ }
+
+ // Removing the child nodes of the menupopup doesn't clear the value
+ // attribute of the menulist. This can have unfortunate side effects
+ // when the list is rebuilt with a different content, so we remove
+ // the value attribute and unset the selectedItem explicitly.
+ menupopup.parentNode.removeAttribute("value");
+ menupopup.parentNode.selectedItem = null;
+
+ // "Select a Window or Screen" is the default because we can't and don't
+ // want to pick a 'default' window to share (Full screen is "scary").
+ addDeviceToList(
+ menupopup.parentNode,
+ localization.formatValueSync("webrtc-pick-window-or-screen"),
+ "-1"
+ );
+ menupopup.appendChild(doc.createXULElement("menuseparator"));
+
+ let isPipeWireDetected = false;
+
+ // Build the list of 'devices'.
+ let monitorIndex = 1;
+ for (let i = 0; i < devices.length; ++i) {
+ let device = devices[i];
+ let type = device.mediaSource;
+ let name;
+ if (device.canRequestOsLevelPrompt) {
+ // When we share content by PipeWire add only one item to the device
+ // list. When it's selected PipeWire portal dialog is opened and
+ // user confirms actual window/screen sharing there.
+ // Don't mark it as scary as there's an extra confirmation step by
+ // PipeWire portal dialog.
+
+ isPipeWireDetected = true;
+ let item = addDeviceToList(
+ menupopup.parentNode,
+ localization.formatValueSync("webrtc-share-pipe-wire-portal"),
+ i,
+ type
+ );
+ item.deviceId = device.rawId;
+ item.mediaSource = type;
+
+ // In this case the OS sharing dialog will be the only option and
+ // can be safely pre-selected.
+ menupopup.parentNode.selectedItem = item;
+ continue;
+ } else if (type == "screen") {
+ // Building screen list from available screens.
+ if (device.name == "Primary Monitor") {
+ name = localization.formatValueSync("webrtc-share-entire-screen");
+ } else {
+ name = localization.formatValueSync("webrtc-share-monitor", {
+ monitorIndex,
+ });
+ ++monitorIndex;
+ }
+ } else {
+ name = device.name;
+
+ if (type == "application") {
+ // The application names returned by the platform are of the form:
+ // <window count>\x1e<application name>
+ const [count, appName] = name.split("\x1e");
+ name = localization.formatValueSync("webrtc-share-application", {
+ appName,
+ windowCount: parseInt(count),
+ });
+ }
+ }
+ let item = addDeviceToList(menupopup.parentNode, name, i, type);
+ item.deviceId = device.rawId;
+ item.mediaSource = type;
+ if (device.scary) {
+ item.scary = true;
+ }
+ }
+
+ // Always re-select the "No <type>" item.
+ doc
+ .getElementById("webRTC-selectWindow-menulist")
+ .removeAttribute("value");
+ doc.getElementById("webRTC-all-windows-shared").hidden = true;
+
+ menupopup._commandEventListener = event => {
+ checkDisabledWindowMenuItem();
+ let video = doc.getElementById("webRTC-previewVideo");
+ if (video.stream) {
+ video.stream.getTracks().forEach(t => t.stop());
+ video.stream = null;
+ }
+
+ const { deviceId, mediaSource, scary } = event.target;
+ if (deviceId == undefined) {
+ doc.getElementById("webRTC-preview").hidden = true;
+ video.src = null;
+ return;
+ }
+
+ let warning = doc.getElementById("webRTC-previewWarning");
+ let warningBox = doc.getElementById("webRTC-previewWarningBox");
+ warningBox.hidden = !scary;
+ let chromeWin = doc.defaultView;
+ if (scary) {
+ const warnId =
+ mediaSource == "screen"
+ ? "webrtc-share-screen-warning"
+ : "webrtc-share-browser-warning";
+ doc.l10n.setAttributes(warning, warnId);
+
+ const learnMore = doc.getElementById(
+ "webRTC-previewWarning-learnMore"
+ );
+ const baseURL = Services.urlFormatter.formatURLPref(
+ "app.support.baseURL"
+ );
+ learnMore.setAttribute("href", baseURL + "screenshare-safety");
+ doc.l10n.setAttributes(learnMore, "webrtc-share-screen-learn-more");
+
+ // On Catalina, we don't want to blow our chance to show the
+ // OS-level helper prompt to enable screen recording if the user
+ // intends to reject anyway. OTOH showing it when they click Allow
+ // is too late. A happy middle is to show it when the user makes a
+ // choice in the picker. This already happens implicitly if the
+ // user chooses "Entire desktop", as a side-effect of our preview,
+ // we just need to also do it if they choose "Firefox". These are
+ // the lone two options when permission is absent on Catalina.
+ // Ironically, these are the two sources marked "scary" from a
+ // web-sharing perspective, which is why this code resides here.
+ // A restart doesn't appear to be necessary in spite of OS wording.
+ let scrStatus = {};
+ lazy.OSPermissions.getScreenCapturePermissionState(scrStatus);
+ if (scrStatus.value == lazy.OSPermissions.PERMISSION_STATE_DENIED) {
+ lazy.OSPermissions.maybeRequestScreenCapturePermission();
+ }
+ }
+
+ let perms = Services.perms;
+ let chromePrincipal =
+ Services.scriptSecurityManager.getSystemPrincipal();
+ perms.addFromPrincipal(
+ chromePrincipal,
+ "MediaManagerVideo",
+ perms.ALLOW_ACTION,
+ perms.EXPIRE_SESSION
+ );
+
+ // We don't have access to any screen content besides our browser tabs
+ // on Wayland, therefore there are no previews we can show.
+ if (
+ (!isPipeWireDetected || mediaSource == "browser") &&
+ Services.prefs.getBoolPref(
+ "media.getdisplaymedia.previews.enabled",
+ true
+ )
+ ) {
+ video.deviceId = deviceId;
+ let constraints = {
+ video: { mediaSource, deviceId: { exact: deviceId } },
+ };
+ chromeWin.navigator.mediaDevices.getUserMedia(constraints).then(
+ stream => {
+ if (video.deviceId != deviceId) {
+ // The user has selected a different device or closed the panel
+ // before getUserMedia finished.
+ stream.getTracks().forEach(t => t.stop());
+ return;
+ }
+ video.srcObject = stream;
+ video.stream = stream;
+ doc.getElementById("webRTC-preview").hidden = false;
+ video.onloadedmetadata = function (e) {
+ video.play();
+ };
+ },
+ err => {
+ if (
+ err.name == "OverconstrainedError" &&
+ err.constraint == "deviceId"
+ ) {
+ // Window has disappeared since enumeration, which can happen.
+ // No preview for you.
+ return;
+ }
+ console.error(
+ `error in preview: ${err.message} ${err.constraint}`
+ );
+ }
+ );
+ }
+ };
+ menupopup.addEventListener("command", menupopup._commandEventListener);
+ }
+
+ function addDeviceToList(list, deviceName, deviceIndex, type) {
+ let item = list.appendItem(deviceName, deviceIndex);
+ item.setAttribute("tooltiptext", deviceName);
+ if (type) {
+ item.setAttribute("devicetype", type);
+ }
+
+ if (deviceIndex == "-1") {
+ item.setAttribute("disabled", true);
+ }
+
+ return item;
+ }
+
+ doc.getElementById("webRTC-selectCamera").hidden =
+ reqVideoInput !== "Camera";
+ doc.getElementById("webRTC-selectWindowOrScreen").hidden =
+ reqVideoInput !== "Screen";
+ doc.getElementById("webRTC-selectMicrophone").hidden =
+ reqAudioInput !== "Microphone";
+ doc.getElementById("webRTC-selectSpeaker").hidden = !reqAudioOutput;
+
+ let describedByIDs = ["webRTC-shareDevices-notification-description"];
+
+ if (sharingScreen) {
+ let windowMenupopup = doc.getElementById(
+ "webRTC-selectWindow-menupopup"
+ );
+ listScreenShareDevices(windowMenupopup, videoInputDevices);
+ checkDisabledWindowMenuItem();
+ } else {
+ listDevices(videoInputDevices, "webRTC-selectCamera", describedByIDs);
+ notificationElement.removeAttribute("invalidselection");
+ }
+ if (!sharingAudio) {
+ listDevices(
+ audioInputDevices,
+ "webRTC-selectMicrophone",
+ describedByIDs
+ );
+ }
+ listDevices(audioOutputDevices, "webRTC-selectSpeaker", describedByIDs);
+
+ // PopupNotifications knows to clear the aria-describedby attribute
+ // when hiding, so we don't have to worry about cleaning it up ourselves.
+ chromeDoc.defaultView.PopupNotifications.panel.setAttribute(
+ "aria-describedby",
+ describedByIDs.join(" ")
+ );
+
+ this.mainAction.callback = async function (aState) {
+ let remember = false;
+ let silenceNotifications = false;
+
+ if (notificationSilencingEnabled && sharingScreen) {
+ silenceNotifications = aState && aState.checkboxChecked;
+ } else {
+ remember = aState && aState.checkboxChecked;
+ }
+
+ let allowedDevices = [];
+ let perms = Services.perms;
+ if (reqVideoInput) {
+ let listId = sharingScreen
+ ? "webRTC-selectWindow-menulist"
+ : "webRTC-selectCamera-menulist";
+ let videoDeviceIndex = doc.getElementById(listId).value;
+ let allowVideoDevice = videoDeviceIndex != "-1";
+ if (allowVideoDevice) {
+ allowedDevices.push(videoDeviceIndex);
+ // Session permission will be removed after use
+ // (it's really one-shot, not for the entire session)
+ perms.addFromPrincipal(
+ principal,
+ "MediaManagerVideo",
+ perms.ALLOW_ACTION,
+ perms.EXPIRE_SESSION
+ );
+ let { mediaSource, rawId } = videoInputDevices.find(
+ ({ deviceIndex }) => deviceIndex == videoDeviceIndex
+ );
+ aActor.activateDevicePerm(aRequest.windowID, mediaSource, rawId);
+ if (remember) {
+ lazy.SitePermissions.setForPrincipal(
+ principal,
+ "camera",
+ lazy.SitePermissions.ALLOW
+ );
+ }
+ }
+ }
+
+ if (reqAudioInput === "Microphone") {
+ let audioDeviceIndex = doc.getElementById(
+ "webRTC-selectMicrophone-menulist"
+ ).value;
+ let allowMic = audioDeviceIndex != "-1";
+ if (allowMic) {
+ allowedDevices.push(audioDeviceIndex);
+ let { mediaSource, rawId } = audioInputDevices.find(
+ ({ deviceIndex }) => deviceIndex == audioDeviceIndex
+ );
+ aActor.activateDevicePerm(aRequest.windowID, mediaSource, rawId);
+ if (remember) {
+ lazy.SitePermissions.setForPrincipal(
+ principal,
+ "microphone",
+ lazy.SitePermissions.ALLOW
+ );
+ }
+ }
+ } else if (reqAudioInput === "AudioCapture") {
+ // Only one device possible for audio capture.
+ allowedDevices.push(0);
+ }
+
+ if (reqAudioOutput) {
+ let audioDeviceIndex = doc.getElementById(
+ "webRTC-selectSpeaker-richlistbox"
+ ).value;
+ let allowSpeaker = audioDeviceIndex != "-1";
+ if (allowSpeaker) {
+ allowedDevices.push(audioDeviceIndex);
+ let { id } = audioOutputDevices.find(
+ ({ deviceIndex }) => deviceIndex == audioDeviceIndex
+ );
+ lazy.SitePermissions.setForPrincipal(
+ principal,
+ ["speaker", id].join("^"),
+ lazy.SitePermissions.ALLOW
+ );
+ }
+ }
+
+ if (!allowedDevices.length) {
+ aActor.denyRequest(aRequest);
+ return;
+ }
+
+ const camNeeded = reqVideoInput === "Camera";
+ const micNeeded = !!reqAudioInput;
+ const scrNeeded = reqVideoInput === "Screen";
+ const havePermission = await aActor.checkOSPermission(
+ camNeeded,
+ micNeeded,
+ scrNeeded
+ );
+ if (!havePermission) {
+ aActor.denyRequestNoPermission(aRequest);
+ return;
+ }
+
+ aActor.sendAsyncMessage("webrtc:Allow", {
+ callID: aRequest.callID,
+ windowID: aRequest.windowID,
+ devices: allowedDevices,
+ suppressNotifications: silenceNotifications,
+ });
+ };
+
+ // If we haven't handled the permission yet, we want to show the doorhanger.
+ return false;
+ },
+ };
+
+ function shouldShowAlwaysRemember() {
+ // Don't offer "always remember" action in PB mode
+ if (lazy.PrivateBrowsingUtils.isBrowserPrivate(aBrowser)) {
+ return false;
+ }
+
+ // Don't offer "always remember" action in maybe unsafe permission
+ // delegation
+ if (aRequest.secondOrigin) {
+ return false;
+ }
+
+ // Speaker grants are always remembered, so no checkbox is required.
+ if (reqAudioOutput) {
+ return false;
+ }
+
+ return true;
+ }
+
+ if (shouldShowAlwaysRemember()) {
+ // Disable the permanent 'Allow' action if the connection isn't secure, or for
+ // screen/audio sharing (because we can't guess which window the user wants to
+ // share without prompting). Note that we never enter this block for private
+ // browsing windows.
+ let reason = "";
+ if (sharingScreen) {
+ reason = "webrtc-reason-for-no-permanent-allow-screen";
+ } else if (sharingAudio) {
+ reason = "webrtc-reason-for-no-permanent-allow-audio";
+ } else if (!aRequest.secure) {
+ reason = "webrtc-reason-for-no-permanent-allow-insecure";
+ }
+
+ options.checkbox = {
+ label: localization.formatValueSync("webrtc-remember-allow-checkbox"),
+ checked: principal.isAddonOrExpandedAddonPrincipal,
+ checkedState: reason
+ ? {
+ disableMainAction: true,
+ warningLabel: localization.formatValueSync(reason),
+ }
+ : undefined,
+ };
+ }
+
+ // If the notification silencing feature is enabled and we're sharing a
+ // screen, then the checkbox for the permission panel is what controls
+ // notification silencing.
+ if (notificationSilencingEnabled && sharingScreen) {
+ options.checkbox = {
+ label: localization.formatValueSync("webrtc-mute-notifications-checkbox"),
+ checked: false,
+ checkedState: {
+ disableMainAction: false,
+ },
+ };
+ }
+
+ let anchorId = "webRTC-shareDevices-notification-icon";
+ if (reqVideoInput === "Screen") {
+ anchorId = "webRTC-shareScreen-notification-icon";
+ } else if (!reqVideoInput) {
+ if (reqAudioInput && !reqAudioOutput) {
+ anchorId = "webRTC-shareMicrophone-notification-icon";
+ } else if (!reqAudioInput && reqAudioOutput) {
+ anchorId = "webRTC-shareSpeaker-notification-icon";
+ }
+ }
+
+ if (aRequest.secondOrigin) {
+ options.secondName = lazy.webrtcUI.getHostOrExtensionName(
+ null,
+ aRequest.secondOrigin
+ );
+ }
+
+ notification = chromeDoc.defaultView.PopupNotifications.show(
+ aBrowser,
+ "webRTC-shareDevices",
+ message,
+ anchorId,
+ mainAction,
+ secondaryActions,
+ options
+ );
+ notification.callID = aRequest.callID;
+
+ let schemeHistogram = Services.telemetry.getKeyedHistogramById(
+ "PERMISSION_REQUEST_ORIGIN_SCHEME"
+ );
+ let userInputHistogram = Services.telemetry.getKeyedHistogramById(
+ "PERMISSION_REQUEST_HANDLING_USER_INPUT"
+ );
+
+ let docURI = aRequest.documentURI;
+ let scheme = 0;
+ if (docURI.startsWith("https")) {
+ scheme = 2;
+ } else if (docURI.startsWith("http")) {
+ scheme = 1;
+ }
+
+ for (let requestType of requestTypes) {
+ if (requestType == "AudioCapture") {
+ requestType = "Microphone";
+ }
+ requestType = requestType.toLowerCase();
+
+ schemeHistogram.add(requestType, scheme);
+ userInputHistogram.add(requestType, aRequest.isHandlingUserInput);
+ }
+}
+
+/**
+ * @param {"Screen" | "Camera" | null} reqVideoInput
+ * @param {"AudioCapture" | "Microphone" | null} reqAudioInput
+ * @param {boolean} reqAudioOutput
+ * @param {boolean} delegation - Is the access delegated to a third party?
+ * @returns {string} Localization message identifier
+ */
+function getPromptMessageId(
+ reqVideoInput,
+ reqAudioInput,
+ reqAudioOutput,
+ delegation
+) {
+ switch (reqVideoInput) {
+ case "Camera":
+ switch (reqAudioInput) {
+ case "Microphone":
+ return delegation
+ ? "webrtc-allow-share-camera-and-microphone-unsafe-delegation"
+ : "webrtc-allow-share-camera-and-microphone";
+ case "AudioCapture":
+ return delegation
+ ? "webrtc-allow-share-camera-and-audio-capture-unsafe-delegation"
+ : "webrtc-allow-share-camera-and-audio-capture";
+ default:
+ return delegation
+ ? "webrtc-allow-share-camera-unsafe-delegation"
+ : "webrtc-allow-share-camera";
+ }
+
+ case "Screen":
+ switch (reqAudioInput) {
+ case "Microphone":
+ return delegation
+ ? "webrtc-allow-share-screen-and-microphone-unsafe-delegation"
+ : "webrtc-allow-share-screen-and-microphone";
+ case "AudioCapture":
+ return delegation
+ ? "webrtc-allow-share-screen-and-audio-capture-unsafe-delegation"
+ : "webrtc-allow-share-screen-and-audio-capture";
+ default:
+ return delegation
+ ? "webrtc-allow-share-screen-unsafe-delegation"
+ : "webrtc-allow-share-screen";
+ }
+
+ default:
+ switch (reqAudioInput) {
+ case "Microphone":
+ return delegation
+ ? "webrtc-allow-share-microphone-unsafe-delegation"
+ : "webrtc-allow-share-microphone";
+ case "AudioCapture":
+ return delegation
+ ? "webrtc-allow-share-audio-capture-unsafe-delegation"
+ : "webrtc-allow-share-audio-capture";
+ default:
+ // This should be always true, if we've reached this far.
+ if (reqAudioOutput) {
+ return delegation
+ ? "webrtc-allow-share-speaker-unsafe-delegation"
+ : "webrtc-allow-share-speaker";
+ }
+ return undefined;
+ }
+ }
+}
+
+/**
+ * Checks whether we have a microphone/camera in use by checking the activePerms map
+ * or if we have an allow permission for a microphone/camera in sitePermissions
+ * @param {Browser} browser - Browser to find all active and allowed microphone and camera devices for
+ * @return true if one of the above conditions is met
+ */
+function allowedOrActiveCameraOrMicrophone(browser) {
+ // Do we have an allow permission for cam/mic in the permissions manager?
+ if (
+ lazy.SitePermissions.getAllForBrowser(browser).some(perm => {
+ return (
+ perm.state == lazy.SitePermissions.ALLOW &&
+ (perm.id.startsWith("camera") || perm.id.startsWith("microphone"))
+ );
+ })
+ ) {
+ // Return early, no need to check for active devices
+ return true;
+ }
+
+ // Do we have an active device?
+ return (
+ // Find all windowIDs that belong to our browsing contexts
+ browser.browsingContext
+ .getAllBrowsingContextsInSubtree()
+ // Only keep the outerWindowIds
+ .map(bc => bc.currentWindowGlobal?.outerWindowId)
+ .filter(id => id != null)
+ // We have an active device if one of our windowIds has a non empty map in the activePerms map
+ // that includes one device of type "camera" or "microphone"
+ .some(id => {
+ let map = lazy.webrtcUI.activePerms.get(id);
+ if (!map) {
+ // This windowId has no active device
+ return false;
+ }
+ // Let's see if one of the devices is a camera or a microphone
+ let types = [...map.values()];
+ return types.includes("microphone") || types.includes("camera");
+ })
+ );
+}
+
+function removePrompt(aBrowser, aCallId) {
+ let chromeWin = aBrowser.ownerGlobal;
+ let notification = chromeWin.PopupNotifications.getNotification(
+ "webRTC-shareDevices",
+ aBrowser
+ );
+ if (notification && notification.callID == aCallId) {
+ notification.remove();
+ }
+}
+
+/**
+ * Clears temporary permission grants used for WebRTC device grace periods.
+ * @param browser - Browser element to clear permissions for.
+ * @param {boolean} clearCamera - Clear camera grants.
+ * @param {boolean} clearMicrophone - Clear microphone grants.
+ */
+function clearTemporaryGrants(browser, clearCamera, clearMicrophone) {
+ if (!clearCamera && !clearMicrophone) {
+ // Nothing to clear.
+ return;
+ }
+ let perms = lazy.SitePermissions.getAllForBrowser(browser);
+ perms
+ .filter(perm => {
+ let [id, key] = perm.id.split(lazy.SitePermissions.PERM_KEY_DELIMITER);
+ // We only want to clear WebRTC grace periods. These are temporary, device
+ // specifc (double-keyed) microphone or camera permissions.
+ return (
+ key &&
+ perm.state == lazy.SitePermissions.ALLOW &&
+ perm.scope == lazy.SitePermissions.SCOPE_TEMPORARY &&
+ ((clearCamera && id == "camera") ||
+ (clearMicrophone && id == "microphone"))
+ );
+ })
+ .forEach(perm =>
+ lazy.SitePermissions.removeFromPrincipal(null, perm.id, browser)
+ );
+}
diff --git a/browser/actors/moz.build b/browser/actors/moz.build
new file mode 100644
index 0000000000..25957ca710
--- /dev/null
+++ b/browser/actors/moz.build
@@ -0,0 +1,92 @@
+# -*- 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 = ("Firefox", "General")
+
+with Files("ContentSearch*.sys.mjs"):
+ BUG_COMPONENT = ("Firefox", "Search")
+
+with Files("AboutReaderParent.sys.mjs"):
+ BUG_COMPONENT = ("Toolkit", "Reader Mode")
+
+with Files("LightweightThemeChild.sys.mjs"):
+ BUG_COMPONENT = ("WebExtensions", "Themes")
+
+with Files("PageInfoChild.sys.mjs"):
+ BUG_COMPONENT = ("Firefox", "Page Info Window")
+
+with Files("PageStyleChild.sys.mjs"):
+ BUG_COMPONENT = ("Firefox", "Menus")
+
+with Files("PluginChild.sys.mjs"):
+ BUG_COMPONENT = ("Core", "Audio/Video: GMP")
+
+with Files("ScreenshotsComponentChild.sys.mjs"):
+ BUG_COMPONENT = ("Firefox", "Screenshots")
+
+with Files("WebRTCChild.sys.mjs"):
+ BUG_COMPONENT = ("Firefox", "Site Permissions")
+
+FINAL_TARGET_FILES.actors += [
+ "AboutNewTabChild.sys.mjs",
+ "AboutNewTabParent.sys.mjs",
+ "AboutPocketChild.sys.mjs",
+ "AboutPocketParent.sys.mjs",
+ "AboutPrivateBrowsingChild.sys.mjs",
+ "AboutPrivateBrowsingParent.sys.mjs",
+ "AboutProtectionsChild.sys.mjs",
+ "AboutProtectionsParent.sys.mjs",
+ "AboutReaderChild.sys.mjs",
+ "AboutReaderParent.sys.mjs",
+ "AboutTabCrashedChild.sys.mjs",
+ "AboutTabCrashedParent.sys.mjs",
+ "BlockedSiteChild.sys.mjs",
+ "BlockedSiteParent.sys.mjs",
+ "BrowserProcessChild.sys.mjs",
+ "BrowserTabChild.sys.mjs",
+ "ClickHandlerChild.sys.mjs",
+ "ClickHandlerParent.sys.mjs",
+ "ContentSearchChild.sys.mjs",
+ "ContentSearchParent.sys.mjs",
+ "ContextMenuChild.sys.mjs",
+ "ContextMenuParent.sys.mjs",
+ "DecoderDoctorChild.sys.mjs",
+ "DecoderDoctorParent.sys.mjs",
+ "DOMFullscreenChild.sys.mjs",
+ "DOMFullscreenParent.sys.mjs",
+ "EncryptedMediaChild.sys.mjs",
+ "EncryptedMediaParent.sys.mjs",
+ "FormValidationChild.sys.mjs",
+ "FormValidationParent.sys.mjs",
+ "LightweightThemeChild.sys.mjs",
+ "LinkHandlerChild.sys.mjs",
+ "LinkHandlerParent.sys.mjs",
+ "PageInfoChild.sys.mjs",
+ "PageStyleChild.sys.mjs",
+ "PageStyleParent.sys.mjs",
+ "PluginChild.sys.mjs",
+ "PluginParent.sys.mjs",
+ "PointerLockChild.sys.mjs",
+ "PointerLockParent.sys.mjs",
+ "PromptParent.sys.mjs",
+ "RefreshBlockerChild.sys.mjs",
+ "RefreshBlockerParent.sys.mjs",
+ "RFPHelperChild.sys.mjs",
+ "RFPHelperParent.sys.mjs",
+ "ScreenshotsComponentChild.sys.mjs",
+ "SearchSERPTelemetryChild.sys.mjs",
+ "SearchSERPTelemetryParent.sys.mjs",
+ "SpeechDispatcherChild.sys.mjs",
+ "SpeechDispatcherParent.sys.mjs",
+ "SwitchDocumentDirectionChild.sys.mjs",
+ "WebRTCChild.sys.mjs",
+ "WebRTCParent.sys.mjs",
+]
+
+BROWSER_CHROME_MANIFESTS += [
+ "test/browser/browser.toml",
+]
diff --git a/browser/actors/test/browser/browser.toml b/browser/actors/test/browser/browser.toml
new file mode 100644
index 0000000000..ab2f7ba311
--- /dev/null
+++ b/browser/actors/test/browser/browser.toml
@@ -0,0 +1,6 @@
+[DEFAULT]
+
+["browser_nested_link_clicks.js"]
+
+["browser_untrusted_click_event.js"]
+support-files = ["click.html"]
diff --git a/browser/actors/test/browser/browser_nested_link_clicks.js b/browser/actors/test/browser/browser_nested_link_clicks.js
new file mode 100644
index 0000000000..32ac6a4685
--- /dev/null
+++ b/browser/actors/test/browser/browser_nested_link_clicks.js
@@ -0,0 +1,45 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Nested links should only open a single tab when ctrl-clicked.
+ */
+add_task(async function nested_link_click_opens_single_tab() {
+ await BrowserTestUtils.withNewTab(
+ "https://example.com/empty",
+ async browser => {
+ await SpecialPowers.spawn(browser, [], () => {
+ let doc = content.document;
+ let outerLink = doc.createElement("a");
+ outerLink.href = "https://mozilla.org/";
+ let link = doc.createElement("a");
+ link.href = "https://example.org/linked";
+ link.textContent = "Click me";
+ link.id = "mylink";
+ outerLink.append(link);
+ doc.body.append(outerLink);
+ });
+
+ let startingNumberOfTabs = gBrowser.tabs.length;
+ let newTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "https://example.org/linked",
+ true
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#mylink",
+ { accelKey: true },
+ browser
+ );
+ let tab = await newTabPromise;
+ is(
+ gBrowser.tabs.length,
+ startingNumberOfTabs + 1,
+ "Should only have opened 1 tab."
+ );
+ BrowserTestUtils.removeTab(tab);
+ }
+ );
+});
diff --git a/browser/actors/test/browser/browser_untrusted_click_event.js b/browser/actors/test/browser/browser_untrusted_click_event.js
new file mode 100644
index 0000000000..7bb579223d
--- /dev/null
+++ b/browser/actors/test/browser/browser_untrusted_click_event.js
@@ -0,0 +1,107 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+/**
+ * Ensure that click handlers that prevent the default action but fire
+ * a different click event that does open a tab are allowed to work.
+ */
+add_task(async function test_untrusted_click_opens_tab() {
+ await BrowserTestUtils.withNewTab(TEST_PATH + "click.html", async browser => {
+ let newTabOpened = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "https://example.org/",
+ true
+ );
+ info("clicking link with modifier pressed.");
+ let eventObj = {};
+ eventObj[AppConstants.platform == "macosx" ? "metaKey" : "ctrlKey"] = true;
+ await BrowserTestUtils.synthesizeMouseAtCenter("#test", eventObj, browser);
+ info(
+ "Waiting for new tab to open from frontend; if we timeout the test is broken."
+ );
+ let newTab = await newTabOpened;
+ ok(newTab, "New tab should be opened.");
+ BrowserTestUtils.removeTab(newTab);
+ });
+});
+
+/**
+ * Ensure that click handlers that redirect a modifier-less click into
+ * an untrusted event without modifiers don't run afoul of the popup
+ * blocker by having their transient user gesture bit consumed in the
+ * child by the handling code for modified clicks.
+ */
+add_task(async function test_unused_click_doesnt_consume_activation() {
+ // Enable the popup blocker.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["dom.disable_open_during_load", true],
+ ["dom.block_multiple_popups", true],
+ ],
+ });
+ await BrowserTestUtils.withNewTab(TEST_PATH + "click.html", async browser => {
+ let newTabOpened = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "https://example.com/",
+ true
+ );
+ info("clicking button that generates link press.");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#fire-untrusted",
+ {},
+ browser
+ );
+ info(
+ "Waiting for new tab to open through gecko; if we timeout the test is broken."
+ );
+ let newTab = await newTabOpened;
+ ok(newTab, "New tab should be opened.");
+ BrowserTestUtils.removeTab(newTab);
+ });
+});
+
+/**
+ * Ensure that click handlers redirecting to elements without href don't
+ * trigger user gesture consumption either.
+ */
+add_task(async function test_click_without_href_doesnt_consume_activation() {
+ // Enable the popup blocker.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["dom.disable_open_during_load", true],
+ ["dom.block_multiple_popups", true],
+ ],
+ });
+ await BrowserTestUtils.withNewTab(TEST_PATH + "click.html", async browser => {
+ let newTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, "about:blank");
+ info("clicking link that generates link click on target-less link.");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#fire-targetless-link",
+ {},
+ browser
+ );
+ info(
+ "Waiting for new tab to open after targetless link; if we timeout the test is broken."
+ );
+ let newTab = await newTabOpened;
+ ok(newTab, "New tab should be opened.");
+ BrowserTestUtils.removeTab(newTab);
+
+ newTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, "about:blank");
+ info("clicking link that generates button click.");
+ await BrowserTestUtils.synthesizeMouseAtCenter("#fire-button", {}, browser);
+ info(
+ "Waiting for new tab to open after button redirect; if we timeout the test is broken."
+ );
+ newTab = await newTabOpened;
+ ok(newTab, "New tab should be opened.");
+ BrowserTestUtils.removeTab(newTab);
+ });
+});
diff --git a/browser/actors/test/browser/click.html b/browser/actors/test/browser/click.html
new file mode 100644
index 0000000000..42afdf8050
--- /dev/null
+++ b/browser/actors/test/browser/click.html
@@ -0,0 +1,76 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <title>Click a link whose event we intercept</title>
+</head>
+<body>
+
+<a id="test" href="https://example.com/">click me</a><br><br>
+
+<a id="untrusted-only" href="https://example.com/" target="_blank">This is a link, works only with untrusted events</a><br>
+<input id="fire-untrusted" type=button value="Click me to trigger an untrusted event"><br><br>
+
+<a id="fire-targetless-link" href="unused">Click me (dispatch untrusted event to link without href)</a><br>
+<a id="fire-button" href="unused">Click me (dispatch untrusted event to button)</a>
+
+<script>
+
+ document.getElementById("test").addEventListener("click", originalClick => {
+ const newLink = document.createElement("a");
+ newLink.setAttribute("rel", "noreferrer");
+ newLink.setAttribute("target", "_blank");
+ newLink.setAttribute("href", "https://example.org/");
+
+
+ document.body.appendChild(newLink);
+
+ const evt = document.createEvent('MouseEvent');
+ evt.initMouseEvent(
+ "click", true, true, window, 0,
+ originalClick.screenX, originalClick.screenY, originalClick.clientX, originalClick.clientY,
+ originalClick.ctrlKey, originalClick.altKey, originalClick.shiftKey, originalClick.metaKey,
+ originalClick.button, originalClick.relatedTarget
+ );
+ newLink.dispatchEvent(evt);
+
+
+ originalClick.preventDefault();
+ originalClick.stopPropagation();
+ });
+
+ document.getElementById("untrusted-only").addEventListener("click", ev => {
+ if (ev.isTrusted) {
+ ev.preventDefault();
+ }
+ });
+
+ document.getElementById("fire-untrusted").addEventListener("click", () => {
+ document.getElementById("untrusted-only").dispatchEvent(new MouseEvent("click"));
+ });
+
+ function handleTargetless(e) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const buttonOrLink = e.target.id.includes("button") ? "button" : "a";
+ const newElement = document.createElement(buttonOrLink);
+ document.body.appendChild(newElement);
+
+ const evt = document.createEvent("MouseEvent");
+ evt.initMouseEvent("click", true, true, window, 0, e.screenX, e.screenY,
+ e.clientX, e.clientY, e.ctrlKey, e.altKey, e.shiftKey,
+ e.metaKey, e.button, e.relatedTarget);
+ newElement.dispatchEvent(evt);
+ newElement.remove();
+
+ setTimeout(() => {
+ window.open();
+ }, 0);
+ }
+ document.getElementById("fire-targetless-link").addEventListener("click", handleTargetless);
+ document.getElementById("fire-button").addEventListener("click", handleTargetless);
+
+</script>
+</body>
+</html>