diff options
Diffstat (limited to 'browser/actors')
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> |