summaryrefslogtreecommitdiffstats
path: root/browser/modules
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /browser/modules
parentInitial commit. (diff)
downloadthunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz
thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/modules')
-rw-r--r--browser/modules/AboutNewTab.jsm256
-rw-r--r--browser/modules/AsyncTabSwitcher.jsm1500
-rw-r--r--browser/modules/BackgroundTask_install.sys.mjs29
-rw-r--r--browser/modules/BackgroundTask_uninstall.sys.mjs58
-rw-r--r--browser/modules/BrowserUIUtils.jsm179
-rw-r--r--browser/modules/BrowserUsageTelemetry.jsm1454
-rw-r--r--browser/modules/BrowserWindowTracker.jsm321
-rw-r--r--browser/modules/ContentCrashHandlers.jsm1144
-rw-r--r--browser/modules/Discovery.jsm156
-rw-r--r--browser/modules/EveryWindow.jsm109
-rw-r--r--browser/modules/ExtensionsUI.jsm669
-rw-r--r--browser/modules/FaviconLoader.jsm710
-rw-r--r--browser/modules/FeatureCallout.sys.mjs1222
-rw-r--r--browser/modules/HomePage.jsm359
-rw-r--r--browser/modules/LaterRun.jsm192
-rw-r--r--browser/modules/NewTabPagePreloading.jsm211
-rw-r--r--browser/modules/OpenInTabsUtils.jsm85
-rw-r--r--browser/modules/PageActions.jsm1266
-rw-r--r--browser/modules/PartnerLinkAttribution.sys.mjs218
-rw-r--r--browser/modules/PermissionUI.sys.mjs1429
-rw-r--r--browser/modules/PingCentre.jsm175
-rw-r--r--browser/modules/ProcessHangMonitor.jsm693
-rw-r--r--browser/modules/Sanitizer.sys.mjs1146
-rw-r--r--browser/modules/SelectionChangedMenulist.jsm32
-rw-r--r--browser/modules/SiteDataManager.jsm667
-rw-r--r--browser/modules/SitePermissions.sys.mjs1326
-rw-r--r--browser/modules/TabUnloader.jsm523
-rw-r--r--browser/modules/TabsList.jsm566
-rw-r--r--browser/modules/TransientPrefs.jsm27
-rw-r--r--browser/modules/URILoadingHelper.sys.mjs739
-rw-r--r--browser/modules/WindowsJumpLists.jsm665
-rw-r--r--browser/modules/WindowsPreviewPerTab.jsm910
-rw-r--r--browser/modules/ZoomUI.jsm213
-rw-r--r--browser/modules/metrics.yaml155
-rw-r--r--browser/modules/moz.build165
-rw-r--r--browser/modules/test/browser/blank_iframe.html7
-rw-r--r--browser/modules/test/browser/browser.ini73
-rw-r--r--browser/modules/test/browser/browser_BrowserWindowTracker.js234
-rw-r--r--browser/modules/test/browser/browser_ContentSearch.js519
-rw-r--r--browser/modules/test/browser/browser_EveryWindow.js161
-rw-r--r--browser/modules/test/browser/browser_HomePage_add_button.js159
-rw-r--r--browser/modules/test/browser/browser_PageActions.js1402
-rw-r--r--browser/modules/test/browser/browser_PageActions_contextMenus.js226
-rw-r--r--browser/modules/test/browser/browser_PageActions_newWindow.js377
-rw-r--r--browser/modules/test/browser/browser_PartnerLinkAttribution.js428
-rw-r--r--browser/modules/test/browser/browser_PermissionUI.js692
-rw-r--r--browser/modules/test/browser/browser_PermissionUI_prompts.js284
-rw-r--r--browser/modules/test/browser/browser_ProcessHangNotifications.js484
-rw-r--r--browser/modules/test/browser/browser_SitePermissions.js227
-rw-r--r--browser/modules/test/browser/browser_SitePermissions_combinations.js144
-rw-r--r--browser/modules/test/browser/browser_SitePermissions_expiry.js44
-rw-r--r--browser/modules/test/browser/browser_SitePermissions_tab_urls.js128
-rw-r--r--browser/modules/test/browser/browser_TabUnloader.js381
-rw-r--r--browser/modules/test/browser/browser_Telemetry_numberOfSiteOrigins.js53
-rw-r--r--browser/modules/test/browser/browser_Telemetry_numberOfSiteOriginsPerDocument.js134
-rw-r--r--browser/modules/test/browser/browser_UnsubmittedCrashHandler.js819
-rw-r--r--browser/modules/test/browser/browser_UsageTelemetry.js684
-rw-r--r--browser/modules/test/browser/browser_UsageTelemetry_content_aboutRestartRequired.js33
-rw-r--r--browser/modules/test/browser/browser_UsageTelemetry_domains.js190
-rw-r--r--browser/modules/test/browser/browser_UsageTelemetry_interaction.js967
-rw-r--r--browser/modules/test/browser/browser_UsageTelemetry_private_and_restore.js164
-rw-r--r--browser/modules/test/browser/browser_UsageTelemetry_toolbars.js550
-rw-r--r--browser/modules/test/browser/browser_UsageTelemetry_uniqueOriginsVisitedInPast24Hours.js89
-rw-r--r--browser/modules/test/browser/browser_preloading_tab_moving.js150
-rw-r--r--browser/modules/test/browser/browser_taskbar_preview.js129
-rw-r--r--browser/modules/test/browser/browser_urlBar_zoom.js107
-rw-r--r--browser/modules/test/browser/contain_iframe.html7
-rw-r--r--browser/modules/test/browser/contentSearchBadImage.xml6
-rw-r--r--browser/modules/test/browser/contentSearchSuggestions.sjs9
-rw-r--r--browser/modules/test/browser/contentSearchSuggestions.xml6
-rw-r--r--browser/modules/test/browser/file_webrtc.html11
-rw-r--r--browser/modules/test/browser/formValidation/browser.ini7
-rw-r--r--browser/modules/test/browser/formValidation/browser_form_validation.js519
-rw-r--r--browser/modules/test/browser/formValidation/browser_validation_iframe.js67
-rw-r--r--browser/modules/test/browser/formValidation/browser_validation_invisible.js67
-rw-r--r--browser/modules/test/browser/formValidation/browser_validation_navigation.js49
-rw-r--r--browser/modules/test/browser/formValidation/browser_validation_other_popups.js123
-rw-r--r--browser/modules/test/browser/head.js331
-rw-r--r--browser/modules/test/browser/search-engines/basic/manifest.json19
-rw-r--r--browser/modules/test/browser/search-engines/engines.json28
-rw-r--r--browser/modules/test/browser/search-engines/simple/manifest.json29
-rw-r--r--browser/modules/test/browser/testEngine_chromeicon.xml12
-rw-r--r--browser/modules/test/unit/test_E10SUtils_nested_URIs.js90
-rw-r--r--browser/modules/test/unit/test_HomePage.js92
-rw-r--r--browser/modules/test/unit/test_HomePage_ignore.js135
-rw-r--r--browser/modules/test/unit/test_InstallationTelemetry.js284
-rw-r--r--browser/modules/test/unit/test_LaterRun.js242
-rw-r--r--browser/modules/test/unit/test_PartnerLinkAttribution.js54
-rw-r--r--browser/modules/test/unit/test_PingCentre.js194
-rw-r--r--browser/modules/test/unit/test_ProfileCounter.js239
-rw-r--r--browser/modules/test/unit/test_Sanitizer_interrupted.js139
-rw-r--r--browser/modules/test/unit/test_SiteDataManager.js277
-rw-r--r--browser/modules/test/unit/test_SiteDataManagerContainers.js140
-rw-r--r--browser/modules/test/unit/test_SitePermissions.js401
-rw-r--r--browser/modules/test/unit/test_SitePermissions_temporary.js710
-rw-r--r--browser/modules/test/unit/test_TabUnloader.js449
-rw-r--r--browser/modules/test/unit/test_discovery.js138
-rw-r--r--browser/modules/test/unit/xpcshell.ini23
-rw-r--r--browser/modules/webrtcUI.jsm1296
99 files changed, 35801 insertions, 0 deletions
diff --git a/browser/modules/AboutNewTab.jsm b/browser/modules/AboutNewTab.jsm
new file mode 100644
index 0000000000..07d3d9b1bd
--- /dev/null
+++ b/browser/modules/AboutNewTab.jsm
@@ -0,0 +1,256 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ ActivityStream: "resource://activity-stream/lib/ActivityStream.jsm",
+ ObjectUtils: "resource://gre/modules/ObjectUtils.jsm",
+});
+
+const ABOUT_URL = "about:newtab";
+const PREF_ACTIVITY_STREAM_DEBUG = "browser.newtabpage.activity-stream.debug";
+const TOPIC_APP_QUIT = "quit-application-granted";
+const BROWSER_READY_NOTIFICATION = "sessionstore-windows-restored";
+
+const AboutNewTab = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ // AboutNewTab
+ initialized: false,
+
+ willNotifyUser: false,
+
+ _activityStreamEnabled: false,
+ activityStream: null,
+ activityStreamDebug: false,
+
+ _cachedTopSites: null,
+
+ _newTabURL: ABOUT_URL,
+ _newTabURLOverridden: false,
+
+ /**
+ * init - Initializes an instance of Activity Stream if one doesn't exist already.
+ */
+ init() {
+ Services.obs.addObserver(this, TOPIC_APP_QUIT);
+ if (!AppConstants.RELEASE_OR_BETA) {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "activityStreamDebug",
+ PREF_ACTIVITY_STREAM_DEBUG,
+ false,
+ () => {
+ this.notifyChange();
+ }
+ );
+ }
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "privilegedAboutProcessEnabled",
+ "browser.tabs.remote.separatePrivilegedContentProcess",
+ false,
+ () => {
+ this.notifyChange();
+ }
+ );
+
+ // More initialization happens here
+ this.toggleActivityStream(true);
+ this.initialized = true;
+
+ Services.obs.addObserver(this, BROWSER_READY_NOTIFICATION);
+ },
+
+ /**
+ * React to changes to the activity stream being enabled or not.
+ *
+ * This will only act if there is a change of state and if not overridden.
+ *
+ * @returns {Boolean} Returns if there has been a state change
+ *
+ * @param {Boolean} stateEnabled activity stream enabled state to set to
+ * @param {Boolean} forceState force state change
+ */
+ toggleActivityStream(stateEnabled, forceState = false) {
+ if (
+ !forceState &&
+ (this._newTabURLOverridden ||
+ stateEnabled === this._activityStreamEnabled)
+ ) {
+ // exit there is no change of state
+ return false;
+ }
+ if (stateEnabled) {
+ this._activityStreamEnabled = true;
+ } else {
+ this._activityStreamEnabled = false;
+ }
+
+ this._newTabURL = ABOUT_URL;
+ return true;
+ },
+
+ get newTabURL() {
+ return this._newTabURL;
+ },
+
+ set newTabURL(aNewTabURL) {
+ let newTabURL = aNewTabURL.trim();
+ if (newTabURL === ABOUT_URL) {
+ // avoid infinite redirects in case one sets the URL to about:newtab
+ this.resetNewTabURL();
+ return;
+ } else if (newTabURL === "") {
+ newTabURL = "about:blank";
+ }
+
+ this.toggleActivityStream(false);
+ this._newTabURL = newTabURL;
+ this._newTabURLOverridden = true;
+ this.notifyChange();
+ },
+
+ get newTabURLOverridden() {
+ return this._newTabURLOverridden;
+ },
+
+ get activityStreamEnabled() {
+ return this._activityStreamEnabled;
+ },
+
+ resetNewTabURL() {
+ this._newTabURLOverridden = false;
+ this._newTabURL = ABOUT_URL;
+ this.toggleActivityStream(true, true);
+ this.notifyChange();
+ },
+
+ notifyChange() {
+ Services.obs.notifyObservers(null, "newtab-url-changed", this._newTabURL);
+ },
+
+ /**
+ * onBrowserReady - Continues the initialization of Activity Stream after browser is ready.
+ */
+ onBrowserReady() {
+ if (this.activityStream && this.activityStream.initialized) {
+ return;
+ }
+
+ this.activityStream = new lazy.ActivityStream();
+ try {
+ this.activityStream.init();
+ this._subscribeToActivityStream();
+ } catch (e) {
+ console.error(e);
+ }
+ },
+
+ _subscribeToActivityStream() {
+ let unsubscribe = this.activityStream.store.subscribe(() => {
+ // If the top sites changed, broadcast "newtab-top-sites-changed". We
+ // ignore changes to the `screenshot` property in each site because
+ // screenshots are generated at times that are hard to predict and it ends
+ // up interfering with tests that rely on "newtab-top-sites-changed".
+ // Observers likely don't care about screenshots anyway.
+ let topSites = this.activityStream.store
+ .getState()
+ .TopSites.rows.map(site => {
+ site = { ...site };
+ delete site.screenshot;
+ return site;
+ });
+ if (!lazy.ObjectUtils.deepEqual(topSites, this._cachedTopSites)) {
+ this._cachedTopSites = topSites;
+ Services.obs.notifyObservers(null, "newtab-top-sites-changed");
+ }
+ });
+ this._unsubscribeFromActivityStream = () => {
+ try {
+ unsubscribe();
+ } catch (e) {
+ console.error(e);
+ }
+ };
+ },
+
+ /**
+ * uninit - Uninitializes Activity Stream if it exists.
+ */
+ uninit() {
+ if (this.activityStream) {
+ this._unsubscribeFromActivityStream?.();
+ this.activityStream.uninit();
+ this.activityStream = null;
+ }
+
+ this.initialized = false;
+ },
+
+ getTopSites() {
+ return this.activityStream
+ ? this.activityStream.store.getState().TopSites.rows
+ : [];
+ },
+
+ _alreadyRecordedTopsitesPainted: false,
+ _nonDefaultStartup: false,
+
+ noteNonDefaultStartup() {
+ this._nonDefaultStartup = true;
+ },
+
+ maybeRecordTopsitesPainted(timestamp) {
+ if (this._alreadyRecordedTopsitesPainted || this._nonDefaultStartup) {
+ return;
+ }
+
+ const SCALAR_KEY = "timestamps.about_home_topsites_first_paint";
+
+ let startupInfo = Services.startup.getStartupInfo();
+ let processStartTs = startupInfo.process.getTime();
+ let delta = Math.round(timestamp - processStartTs);
+ Services.telemetry.scalarSet(SCALAR_KEY, delta);
+ ChromeUtils.addProfilerMarker("aboutHomeTopsitesFirstPaint");
+ this._alreadyRecordedTopsitesPainted = true;
+ },
+
+ // nsIObserver implementation
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case TOPIC_APP_QUIT: {
+ // We defer to this to the next tick of the event loop since the
+ // AboutHomeStartupCache might want to read from the ActivityStream
+ // store during TOPIC_APP_QUIT.
+ Services.tm.dispatchToMainThread(() => this.uninit());
+ break;
+ }
+ case BROWSER_READY_NOTIFICATION: {
+ Services.obs.removeObserver(this, BROWSER_READY_NOTIFICATION);
+ // Avoid running synchronously during this event that's used for timing
+ Services.tm.dispatchToMainThread(() => this.onBrowserReady());
+ break;
+ }
+ }
+ },
+};
+
+var EXPORTED_SYMBOLS = ["AboutNewTab"];
diff --git a/browser/modules/AsyncTabSwitcher.jsm b/browser/modules/AsyncTabSwitcher.jsm
new file mode 100644
index 0000000000..b97a6e634b
--- /dev/null
+++ b/browser/modules/AsyncTabSwitcher.jsm
@@ -0,0 +1,1500 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["AsyncTabSwitcher"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PictureInPicture: "resource://gre/modules/PictureInPicture.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "gTabWarmingEnabled",
+ "browser.tabs.remote.warmup.enabled"
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "gTabWarmingMax",
+ "browser.tabs.remote.warmup.maxTabs"
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "gTabWarmingUnloadDelayMs",
+ "browser.tabs.remote.warmup.unloadDelayMs"
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "gTabCacheSize",
+ "browser.tabs.remote.tabCacheSize"
+);
+
+/**
+ * The tab switcher is responsible for asynchronously switching
+ * tabs in e10s. It waits until the new tab is ready (i.e., the
+ * layer tree is available) before switching to it. Then it
+ * unloads the layer tree for the old tab.
+ *
+ * The tab switcher is a state machine. For each tab, it
+ * maintains state about whether the layer tree for the tab is
+ * available, being loaded, being unloaded, or unavailable. It
+ * also keeps track of the tab currently being displayed, the tab
+ * it's trying to load, and the tab the user has asked to switch
+ * to. The switcher object is created upon tab switch. It is
+ * released when there are no pending tabs to load or unload.
+ *
+ * The following general principles have guided the design:
+ *
+ * 1. We only request one layer tree at a time. If the user
+ * switches to a different tab while waiting, we don't request
+ * the new layer tree until the old tab has loaded or timed out.
+ *
+ * 2. If loading the layers for a tab times out, we show the
+ * spinner and possibly request the layer tree for another tab if
+ * the user has requested one.
+ *
+ * 3. We discard layer trees on a delay. This way, if the user is
+ * switching among the same tabs frequently, we don't continually
+ * load the same tabs.
+ *
+ * It's important that we always show either the spinner or a tab
+ * whose layers are available. Otherwise the compositor will draw
+ * an entirely black frame, which is very jarring. To ensure this
+ * never happens when switching away from a tab, we assume the
+ * old tab might still be drawn until a MozAfterPaint event
+ * occurs. Because layout and compositing happen asynchronously,
+ * we don't have any other way of knowing when the switch
+ * actually takes place. Therefore, we don't unload the old tab
+ * until the next MozAfterPaint event.
+ */
+class AsyncTabSwitcher {
+ constructor(tabbrowser) {
+ this.log("START");
+
+ // How long to wait for a tab's layers to load. After this
+ // time elapses, we're free to put up the spinner and start
+ // trying to load a different tab.
+ this.TAB_SWITCH_TIMEOUT = 400; // ms
+
+ // When the user hasn't switched tabs for this long, we unload
+ // layers for all tabs that aren't in use.
+ this.UNLOAD_DELAY = 300; // ms
+
+ // The next three tabs form the principal state variables.
+ // See the assertions in postActions for their invariants.
+
+ // Tab the user requested most recently.
+ this.requestedTab = tabbrowser.selectedTab;
+
+ // Tab we're currently trying to load.
+ this.loadingTab = null;
+
+ // We show this tab in case the requestedTab hasn't loaded yet.
+ this.lastVisibleTab = tabbrowser.selectedTab;
+
+ // Auxilliary state variables:
+
+ this.visibleTab = tabbrowser.selectedTab; // Tab that's on screen.
+ this.spinnerTab = null; // Tab showing a spinner.
+ this.blankTab = null; // Tab showing blank.
+ this.lastPrimaryTab = tabbrowser.selectedTab; // Tab with primary="true"
+
+ this.tabbrowser = tabbrowser;
+ this.window = tabbrowser.ownerGlobal;
+ this.loadTimer = null; // TAB_SWITCH_TIMEOUT nsITimer instance.
+ this.unloadTimer = null; // UNLOAD_DELAY nsITimer instance.
+
+ // Map from tabs to STATE_* (below).
+ this.tabState = new Map();
+
+ // True if we're in the midst of switching tabs.
+ this.switchInProgress = false;
+
+ // Transaction id for the composite that will show the requested
+ // tab for the first tab after a tab switch.
+ // Set to -1 when we're not waiting for notification of a
+ // completed switch.
+ this.switchPaintId = -1;
+
+ // Set of tabs that might be visible right now. We maintain
+ // this set because we can't be sure when a tab is actually
+ // drawn. A tab is added to this set when we ask to make it
+ // visible. All tabs but the most recently shown tab are
+ // removed from the set upon MozAfterPaint.
+ this.maybeVisibleTabs = new Set([tabbrowser.selectedTab]);
+
+ // This holds onto the set of tabs that we've been asked to warm up,
+ // and tabs are evicted once they're done loading or are unloaded.
+ this.warmingTabs = new WeakSet();
+
+ this.STATE_UNLOADED = 0;
+ this.STATE_LOADING = 1;
+ this.STATE_LOADED = 2;
+ this.STATE_UNLOADING = 3;
+
+ // re-entrancy guard:
+ this._processing = false;
+
+ // For telemetry, keeps track of what most recently cleared
+ // the loadTimer, which can tell us something about the cause
+ // of tab switch spinners.
+ this._loadTimerClearedBy = "none";
+
+ this._useDumpForLogging = false;
+ this._logInit = false;
+ this._logFlags = [];
+
+ this.window.addEventListener("MozAfterPaint", this);
+ this.window.addEventListener("MozLayerTreeReady", this);
+ this.window.addEventListener("MozLayerTreeCleared", this);
+ this.window.addEventListener("TabRemotenessChange", this);
+ this.window.addEventListener("SwapDocShells", this, true);
+ this.window.addEventListener("EndSwapDocShells", this, true);
+ this.window.document.addEventListener("visibilitychange", this);
+
+ let initialTab = this.requestedTab;
+ let initialBrowser = initialTab.linkedBrowser;
+
+ let tabIsLoaded =
+ !initialBrowser.isRemoteBrowser ||
+ initialBrowser.frameLoader.remoteTab?.hasLayers;
+
+ // If we minimized the window before the switcher was activated,
+ // we might have set the preserveLayers flag for the current
+ // browser. Let's clear it.
+ initialBrowser.preserveLayers(false);
+
+ if (!this.windowHidden) {
+ this.log("Initial tab is loaded?: " + tabIsLoaded);
+ this.setTabState(
+ initialTab,
+ tabIsLoaded ? this.STATE_LOADED : this.STATE_LOADING
+ );
+ }
+
+ for (let ppBrowser of this.tabbrowser._printPreviewBrowsers) {
+ let ppTab = this.tabbrowser.getTabForBrowser(ppBrowser);
+ let state = ppBrowser.hasLayers ? this.STATE_LOADED : this.STATE_LOADING;
+ this.setTabState(ppTab, state);
+ }
+ }
+
+ destroy() {
+ if (this.unloadTimer) {
+ this.clearTimer(this.unloadTimer);
+ this.unloadTimer = null;
+ }
+ if (this.loadTimer) {
+ this.clearTimer(this.loadTimer);
+ this.loadTimer = null;
+ }
+
+ this.window.removeEventListener("MozAfterPaint", this);
+ this.window.removeEventListener("MozLayerTreeReady", this);
+ this.window.removeEventListener("MozLayerTreeCleared", this);
+ this.window.removeEventListener("TabRemotenessChange", this);
+ this.window.removeEventListener("SwapDocShells", this, true);
+ this.window.removeEventListener("EndSwapDocShells", this, true);
+ this.window.document.removeEventListener("visibilitychange", this);
+
+ this.tabbrowser._switcher = null;
+ }
+
+ // Wraps nsITimer. Must not use the vanilla setTimeout and
+ // clearTimeout, because they will be blocked by nsIPromptService
+ // dialogs.
+ setTimer(callback, timeout) {
+ let event = {
+ notify: callback,
+ };
+
+ var timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.initWithCallback(event, timeout, Ci.nsITimer.TYPE_ONE_SHOT);
+ return timer;
+ }
+
+ clearTimer(timer) {
+ timer.cancel();
+ }
+
+ getTabState(tab) {
+ let state = this.tabState.get(tab);
+
+ // As an optimization, we lazily evaluate the state of tabs
+ // that we've never seen before. Once we've figured it out,
+ // we stash it in our state map.
+ if (state === undefined) {
+ state = this.STATE_UNLOADED;
+
+ if (tab && tab.linkedPanel) {
+ let b = tab.linkedBrowser;
+ if (b.renderLayers && b.hasLayers) {
+ state = this.STATE_LOADED;
+ } else if (b.renderLayers && !b.hasLayers) {
+ state = this.STATE_LOADING;
+ } else if (!b.renderLayers && b.hasLayers) {
+ state = this.STATE_UNLOADING;
+ }
+ }
+
+ this.setTabStateNoAction(tab, state);
+ }
+
+ return state;
+ }
+
+ setTabStateNoAction(tab, state) {
+ if (state == this.STATE_UNLOADED) {
+ this.tabState.delete(tab);
+ } else {
+ this.tabState.set(tab, state);
+ }
+ }
+
+ setTabState(tab, state) {
+ if (state == this.getTabState(tab)) {
+ return;
+ }
+
+ this.setTabStateNoAction(tab, state);
+
+ let browser = tab.linkedBrowser;
+ let { remoteTab } = browser.frameLoader;
+ if (state == this.STATE_LOADING) {
+ this.assert(!this.windowHidden);
+
+ // If we're not in the process of warming this tab, we
+ // don't need to delay activating its DocShell.
+ if (!this.warmingTabs.has(tab)) {
+ browser.docShellIsActive = true;
+ }
+
+ if (remoteTab) {
+ browser.renderLayers = true;
+ remoteTab.priorityHint = true;
+ } else {
+ this.onLayersReady(browser);
+ }
+ } else if (state == this.STATE_UNLOADING) {
+ this.unwarmTab(tab);
+ // Setting the docShell to be inactive will also cause it
+ // to stop rendering layers.
+ browser.docShellIsActive = false;
+ if (remoteTab) {
+ remoteTab.priorityHint = false;
+ } else {
+ this.onLayersCleared(browser);
+ }
+ } else if (state == this.STATE_LOADED) {
+ this.maybeActivateDocShell(tab);
+ }
+
+ if (!tab.linkedBrowser.isRemoteBrowser) {
+ // setTabState is potentially re-entrant in the non-remote case,
+ // so we must re-get the state for this assertion.
+ let nonRemoteState = this.getTabState(tab);
+ // Non-remote tabs can never stay in the STATE_LOADING
+ // or STATE_UNLOADING states. By the time this function
+ // exits, a non-remote tab must be in STATE_LOADED or
+ // STATE_UNLOADED, since the painting and the layer
+ // upload happen synchronously.
+ this.assert(
+ nonRemoteState == this.STATE_UNLOADED ||
+ nonRemoteState == this.STATE_LOADED
+ );
+ }
+ }
+
+ get windowHidden() {
+ return this.window.document.hidden;
+ }
+
+ get tabLayerCache() {
+ return this.tabbrowser._tabLayerCache;
+ }
+
+ finish() {
+ this.log("FINISH");
+
+ this.assert(this.tabbrowser._switcher);
+ this.assert(this.tabbrowser._switcher === this);
+ this.assert(!this.spinnerTab);
+ this.assert(!this.blankTab);
+ this.assert(!this.loadTimer);
+ this.assert(!this.loadingTab);
+ this.assert(this.lastVisibleTab === this.requestedTab);
+ this.assert(
+ this.windowHidden ||
+ this.getTabState(this.requestedTab) == this.STATE_LOADED
+ );
+
+ this.destroy();
+
+ this.window.document.commandDispatcher.unlock();
+
+ let event = new this.window.CustomEvent("TabSwitchDone", {
+ bubbles: true,
+ cancelable: true,
+ });
+ this.tabbrowser.dispatchEvent(event);
+ }
+
+ // This function is called after all the main state changes to
+ // make sure we display the right tab.
+ updateDisplay() {
+ let requestedTabState = this.getTabState(this.requestedTab);
+ let requestedBrowser = this.requestedTab.linkedBrowser;
+
+ // It is often more desirable to show a blank tab when appropriate than
+ // the tab switch spinner - especially since the spinner is usually
+ // preceded by a perceived lag of TAB_SWITCH_TIMEOUT ms in the
+ // tab switch. We can hide this lag, and hide the time being spent
+ // constructing BrowserChild's, layer trees, etc, by showing a blank
+ // tab instead and focusing it immediately.
+ let shouldBeBlank = false;
+ if (requestedBrowser.isRemoteBrowser) {
+ // If a tab is remote and the window is not minimized, we can show a
+ // blank tab instead of a spinner in the following cases:
+ //
+ // 1. The tab has just crashed, and we haven't started showing the
+ // tab crashed page yet (in this case, the RemoteTab is null)
+ // 2. The tab has never presented, and has not finished loading
+ // a non-local-about: page.
+ //
+ // For (2), "finished loading a non-local-about: page" is
+ // determined by the busy state on the tab element and checking
+ // if the loaded URI is local.
+ let isBusy = this.requestedTab.hasAttribute("busy");
+ let isLocalAbout = requestedBrowser.currentURI.schemeIs("about");
+ let hasSufficientlyLoaded = !isBusy && !isLocalAbout;
+
+ let fl = requestedBrowser.frameLoader;
+ shouldBeBlank =
+ !this.windowHidden &&
+ (!fl.remoteTab ||
+ (!hasSufficientlyLoaded && !fl.remoteTab.hasPresented));
+
+ if (this.logging()) {
+ let flag = shouldBeBlank ? "blank" : "nonblank";
+ this.addLogFlag(
+ flag,
+ this.windowHidden,
+ fl.remoteTab,
+ isBusy,
+ isLocalAbout,
+ fl.remoteTab ? fl.remoteTab.hasPresented : 0
+ );
+ }
+ }
+
+ if (requestedBrowser.isRemoteBrowser) {
+ this.addLogFlag("isRemote");
+ }
+
+ // Figure out which tab we actually want visible right now.
+ let showTab = null;
+ if (
+ requestedTabState != this.STATE_LOADED &&
+ this.lastVisibleTab &&
+ this.loadTimer &&
+ !shouldBeBlank
+ ) {
+ // If we can't show the requestedTab, and lastVisibleTab is
+ // available, show it.
+ showTab = this.lastVisibleTab;
+ } else {
+ // Show the requested tab. If it's not available, we'll show the spinner or a blank tab.
+ showTab = this.requestedTab;
+ }
+
+ // First, let's deal with blank tabs, which we show instead
+ // of the spinner when the tab is not currently set up
+ // properly in the content process.
+ if (!shouldBeBlank && this.blankTab) {
+ this.blankTab.linkedBrowser.removeAttribute("blank");
+ this.blankTab = null;
+ } else if (shouldBeBlank && this.blankTab !== showTab) {
+ if (this.blankTab) {
+ this.blankTab.linkedBrowser.removeAttribute("blank");
+ }
+ this.blankTab = showTab;
+ this.blankTab.linkedBrowser.setAttribute("blank", "true");
+ }
+
+ // Show or hide the spinner as needed.
+ let needSpinner =
+ this.getTabState(showTab) != this.STATE_LOADED &&
+ !this.windowHidden &&
+ !shouldBeBlank &&
+ !this.loadTimer;
+
+ if (!needSpinner && this.spinnerTab) {
+ this.noteSpinnerHidden();
+ this.tabbrowser.tabpanels.removeAttribute("pendingpaint");
+ this.spinnerTab.linkedBrowser.removeAttribute("pendingpaint");
+ this.spinnerTab = null;
+ } else if (needSpinner && this.spinnerTab !== showTab) {
+ if (this.spinnerTab) {
+ this.spinnerTab.linkedBrowser.removeAttribute("pendingpaint");
+ } else {
+ this.noteSpinnerDisplayed();
+ }
+ this.spinnerTab = showTab;
+ this.tabbrowser.tabpanels.setAttribute("pendingpaint", "true");
+ this.spinnerTab.linkedBrowser.setAttribute("pendingpaint", "true");
+ }
+
+ // Switch to the tab we've decided to make visible.
+ if (this.visibleTab !== showTab) {
+ this.tabbrowser._adjustFocusBeforeTabSwitch(this.visibleTab, showTab);
+ this.visibleTab = showTab;
+
+ this.maybeVisibleTabs.add(showTab);
+
+ let tabpanels = this.tabbrowser.tabpanels;
+ let showPanel = this.tabbrowser.tabContainer.getRelatedElement(showTab);
+ let index = Array.prototype.indexOf.call(tabpanels.children, showPanel);
+ if (index != -1) {
+ this.log(`Switch to tab ${index} - ${this.tinfo(showTab)}`);
+ tabpanels.updateSelectedIndex(index);
+ if (showTab === this.requestedTab) {
+ if (requestedTabState == this.STATE_LOADED) {
+ // The new tab will be made visible in the next paint, record the expected
+ // transaction id for that, and we'll mark when we get notified of its
+ // completion.
+ this.switchPaintId = this.window.windowUtils.lastTransactionId + 1;
+ } else {
+ this.noteMakingTabVisibleWithoutLayers();
+ }
+
+ this.tabbrowser._adjustFocusAfterTabSwitch(showTab);
+ this.window.gURLBar.afterTabSwitchFocusChange();
+ this.maybeActivateDocShell(this.requestedTab);
+ }
+ }
+
+ // This doesn't necessarily exist if we're a new window and haven't switched tabs yet
+ if (this.lastVisibleTab) {
+ this.lastVisibleTab._visuallySelected = false;
+ }
+
+ this.visibleTab._visuallySelected = true;
+ this.tabbrowser.tabContainer._setPositionalAttributes();
+ }
+
+ this.lastVisibleTab = this.visibleTab;
+ }
+
+ assert(cond) {
+ if (!cond) {
+ dump("Assertion failure\n" + Error().stack);
+
+ // Don't break a user's browser if an assertion fails.
+ if (AppConstants.DEBUG) {
+ throw new Error("Assertion failure");
+ }
+ }
+ }
+
+ maybeClearLoadTimer(caller) {
+ if (this.loadingTab) {
+ this._loadTimerClearedBy = caller;
+ this.loadingTab = null;
+ if (this.loadTimer) {
+ this.clearTimer(this.loadTimer);
+ this.loadTimer = null;
+ }
+ }
+ }
+
+ // We've decided to try to load requestedTab.
+ loadRequestedTab() {
+ this.assert(!this.loadTimer);
+ this.assert(!this.windowHidden);
+
+ // loadingTab can be non-null here if we timed out loading the current tab.
+ // In that case we just overwrite it with a different tab; it's had its chance.
+ this.loadingTab = this.requestedTab;
+ this.log("Loading tab " + this.tinfo(this.loadingTab));
+
+ this.loadTimer = this.setTimer(
+ () => this.handleEvent({ type: "loadTimeout" }),
+ this.TAB_SWITCH_TIMEOUT
+ );
+ this.setTabState(this.requestedTab, this.STATE_LOADING);
+ }
+
+ maybeActivateDocShell(tab) {
+ // If we've reached the point where the requested tab has entered
+ // the loaded state, but the DocShell is still not yet active, we
+ // should activate it.
+ let browser = tab.linkedBrowser;
+ let state = this.getTabState(tab);
+ let canCheckDocShellState =
+ !browser.mDestroyed &&
+ (browser.docShell || browser.frameLoader.remoteTab);
+ if (
+ tab == this.requestedTab &&
+ canCheckDocShellState &&
+ state == this.STATE_LOADED &&
+ !browser.docShellIsActive &&
+ !this.windowHidden
+ ) {
+ browser.docShellIsActive = true;
+ this.logState(
+ "Set requested tab docshell to active and preserveLayers to false"
+ );
+ // If we minimized the window before the switcher was activated,
+ // we might have set the preserveLayers flag for the current
+ // browser. Let's clear it.
+ browser.preserveLayers(false);
+ }
+ }
+
+ // This function runs before every event. It fixes up the state
+ // to account for closed tabs.
+ preActions() {
+ this.assert(this.tabbrowser._switcher);
+ this.assert(this.tabbrowser._switcher === this);
+
+ for (let i = 0; i < this.tabLayerCache.length; i++) {
+ let tab = this.tabLayerCache[i];
+ if (!tab.linkedBrowser) {
+ this.tabState.delete(tab);
+ this.tabLayerCache.splice(i, 1);
+ i--;
+ }
+ }
+
+ for (let [tab] of this.tabState) {
+ if (!tab.linkedBrowser) {
+ this.tabState.delete(tab);
+ this.unwarmTab(tab);
+ }
+ }
+
+ if (this.lastVisibleTab && !this.lastVisibleTab.linkedBrowser) {
+ this.lastVisibleTab = null;
+ }
+ if (this.lastPrimaryTab && !this.lastPrimaryTab.linkedBrowser) {
+ this.lastPrimaryTab = null;
+ }
+ if (this.blankTab && !this.blankTab.linkedBrowser) {
+ this.blankTab = null;
+ }
+ if (this.spinnerTab && !this.spinnerTab.linkedBrowser) {
+ this.noteSpinnerHidden();
+ this.spinnerTab = null;
+ }
+ if (this.loadingTab && !this.loadingTab.linkedBrowser) {
+ this.maybeClearLoadTimer("preActions");
+ }
+ }
+
+ // This code runs after we've responded to an event or requested a new
+ // tab. It's expected that we've already updated all the principal
+ // state variables. This function takes care of updating any auxilliary
+ // state.
+ postActions(eventString) {
+ // Once we finish loading loadingTab, we null it out. So the state should
+ // always be LOADING.
+ this.assert(
+ !this.loadingTab ||
+ this.getTabState(this.loadingTab) == this.STATE_LOADING
+ );
+
+ // We guarantee that loadingTab is non-null iff loadTimer is non-null. So
+ // the timer is set only when we're loading something.
+ this.assert(!this.loadTimer || this.loadingTab);
+ this.assert(!this.loadingTab || this.loadTimer);
+
+ // If we're switching to a non-remote tab, there's no need to wait
+ // for it to send layers to the compositor, as this will happen
+ // synchronously. Clearing this here means that in the next step,
+ // we can load the non-remote browser immediately.
+ if (!this.requestedTab.linkedBrowser.isRemoteBrowser) {
+ this.maybeClearLoadTimer("postActions");
+ }
+
+ // If we're not loading anything, try loading the requested tab.
+ let stateOfRequestedTab = this.getTabState(this.requestedTab);
+ if (
+ !this.loadTimer &&
+ !this.windowHidden &&
+ (stateOfRequestedTab == this.STATE_UNLOADED ||
+ stateOfRequestedTab == this.STATE_UNLOADING ||
+ this.warmingTabs.has(this.requestedTab))
+ ) {
+ this.assert(stateOfRequestedTab != this.STATE_LOADED);
+ this.loadRequestedTab();
+ }
+
+ let numBackgroundCached = 0;
+ for (let tab of this.tabLayerCache) {
+ if (tab !== this.requestedTab) {
+ numBackgroundCached++;
+ }
+ }
+
+ // See how many tabs still have work to do.
+ let numPending = 0;
+ let numWarming = 0;
+ for (let [tab, state] of this.tabState) {
+ if (!this.shouldDeactivateDocShell(tab.linkedBrowser)) {
+ continue;
+ }
+
+ if (
+ state == this.STATE_LOADED &&
+ tab !== this.requestedTab &&
+ !this.tabLayerCache.includes(tab)
+ ) {
+ numPending++;
+
+ if (tab !== this.visibleTab) {
+ numWarming++;
+ }
+ }
+ if (state == this.STATE_LOADING || state == this.STATE_UNLOADING) {
+ numPending++;
+ }
+ }
+
+ this.updateDisplay();
+
+ // It's possible for updateDisplay to trigger one of our own event
+ // handlers, which might cause finish() to already have been called.
+ // Check for that before calling finish() again.
+ if (!this.tabbrowser._switcher) {
+ return;
+ }
+
+ this.maybeFinishTabSwitch();
+
+ if (numBackgroundCached > 0) {
+ this.deactivateCachedBackgroundTabs();
+ }
+
+ if (numWarming > lazy.gTabWarmingMax) {
+ this.logState("Hit tabWarmingMax");
+ if (this.unloadTimer) {
+ this.clearTimer(this.unloadTimer);
+ }
+ this.unloadNonRequiredTabs();
+ }
+
+ if (numPending == 0) {
+ this.finish();
+ }
+
+ this.logState("/" + eventString);
+ }
+
+ // Fires when we're ready to unload unused tabs.
+ onUnloadTimeout() {
+ this.unloadTimer = null;
+ this.unloadNonRequiredTabs();
+ }
+
+ deactivateCachedBackgroundTabs() {
+ for (let tab of this.tabLayerCache) {
+ if (tab !== this.requestedTab) {
+ let browser = tab.linkedBrowser;
+ browser.preserveLayers(true);
+ browser.docShellIsActive = false;
+ }
+ }
+ }
+
+ // If there are any non-visible and non-requested tabs in
+ // STATE_LOADED, sets them to STATE_UNLOADING. Also queues
+ // up the unloadTimer to run onUnloadTimeout if there are still
+ // tabs in the process of unloading.
+ unloadNonRequiredTabs() {
+ this.warmingTabs = new WeakSet();
+ let numPending = 0;
+
+ // Unload any tabs that can be unloaded.
+ for (let [tab, state] of this.tabState) {
+ if (!this.shouldDeactivateDocShell(tab.linkedBrowser)) {
+ continue;
+ }
+
+ let isInLayerCache = this.tabLayerCache.includes(tab);
+
+ if (
+ state == this.STATE_LOADED &&
+ !this.maybeVisibleTabs.has(tab) &&
+ tab !== this.lastVisibleTab &&
+ tab !== this.loadingTab &&
+ tab !== this.requestedTab &&
+ !isInLayerCache
+ ) {
+ this.setTabState(tab, this.STATE_UNLOADING);
+ }
+
+ if (
+ state != this.STATE_UNLOADED &&
+ tab !== this.requestedTab &&
+ !isInLayerCache
+ ) {
+ numPending++;
+ }
+ }
+
+ if (numPending) {
+ // Keep the timer going since there may be more tabs to unload.
+ this.unloadTimer = this.setTimer(
+ () => this.handleEvent({ type: "unloadTimeout" }),
+ this.UNLOAD_DELAY
+ );
+ }
+ }
+
+ // Fires when an ongoing load has taken too long.
+ onLoadTimeout() {
+ this.maybeClearLoadTimer("onLoadTimeout");
+ }
+
+ // Fires when the layers become available for a tab.
+ onLayersReady(browser) {
+ let tab = this.tabbrowser.getTabForBrowser(browser);
+ if (!tab) {
+ // We probably got a layer update from a tab that got before
+ // the switcher was created, or for browser that's not being
+ // tracked by the async tab switcher (like the preloaded about:newtab).
+ return;
+ }
+
+ this.logState(`onLayersReady(${tab._tPos}, ${browser.isRemoteBrowser})`);
+
+ this.assert(
+ this.getTabState(tab) == this.STATE_LOADING ||
+ this.getTabState(tab) == this.STATE_LOADED
+ );
+ this.setTabState(tab, this.STATE_LOADED);
+ this.unwarmTab(tab);
+
+ if (this.loadingTab === tab) {
+ this.maybeClearLoadTimer("onLayersReady");
+ }
+ }
+
+ // Fires when we paint the screen. Any tab switches we initiated
+ // previously are done, so there's no need to keep the old layers
+ // around.
+ onPaint(event) {
+ this.addLogFlag(
+ "onPaint",
+ this.switchPaintId != -1,
+ event.transactionId >= this.switchPaintId
+ );
+ this.notePaint(event);
+ this.maybeVisibleTabs.clear();
+ }
+
+ // Called when we're done clearing the layers for a tab.
+ onLayersCleared(browser) {
+ let tab = this.tabbrowser.getTabForBrowser(browser);
+ if (tab) {
+ this.logState(`onLayersCleared(${tab._tPos})`);
+ this.assert(
+ this.getTabState(tab) == this.STATE_UNLOADING ||
+ this.getTabState(tab) == this.STATE_UNLOADED
+ );
+ this.setTabState(tab, this.STATE_UNLOADED);
+ }
+ }
+
+ // Called when a tab switches from remote to non-remote. In this case
+ // a MozLayerTreeReady notification that we requested may never fire,
+ // so we need to simulate it.
+ onRemotenessChange(tab) {
+ this.logState(
+ `onRemotenessChange(${tab._tPos}, ${tab.linkedBrowser.isRemoteBrowser})`
+ );
+ if (!tab.linkedBrowser.isRemoteBrowser) {
+ if (this.getTabState(tab) == this.STATE_LOADING) {
+ this.onLayersReady(tab.linkedBrowser);
+ } else if (this.getTabState(tab) == this.STATE_UNLOADING) {
+ this.onLayersCleared(tab.linkedBrowser);
+ }
+ } else if (this.getTabState(tab) == this.STATE_LOADED) {
+ // A tab just changed from non-remote to remote, which means
+ // that it's gone back into the STATE_LOADING state until
+ // it sends up a layer tree.
+ this.setTabState(tab, this.STATE_LOADING);
+ }
+ }
+
+ onTabRemoved(tab) {
+ if (this.lastVisibleTab == tab) {
+ this.handleEvent({ type: "tabRemoved", tab });
+ }
+ }
+
+ // Called when a tab has been removed, and the browser node is
+ // about to be removed from the DOM.
+ onTabRemovedImpl(tab) {
+ this.lastVisibleTab = null;
+ }
+
+ onVisibilityChange() {
+ if (this.windowHidden) {
+ for (let [tab, state] of this.tabState) {
+ if (!this.shouldDeactivateDocShell(tab.linkedBrowser)) {
+ continue;
+ }
+
+ if (state == this.STATE_LOADING || state == this.STATE_LOADED) {
+ this.setTabState(tab, this.STATE_UNLOADING);
+ }
+ }
+ this.maybeClearLoadTimer("onSizeModeOrOcc");
+ } else {
+ // We're no longer minimized or occluded. This means we might want
+ // to activate the current tab's docShell.
+ this.maybeActivateDocShell(this.tabbrowser.selectedTab);
+ }
+ }
+
+ onSwapDocShells(ourBrowser, otherBrowser) {
+ // This event fires before the swap. ourBrowser is from
+ // our window. We save the state of otherBrowser since ourBrowser
+ // needs to take on that state at the end of the swap.
+
+ let otherTabbrowser = otherBrowser.ownerGlobal.gBrowser;
+ let otherState;
+ if (otherTabbrowser && otherTabbrowser._switcher) {
+ let otherTab = otherTabbrowser.getTabForBrowser(otherBrowser);
+ let otherSwitcher = otherTabbrowser._switcher;
+ otherState = otherSwitcher.getTabState(otherTab);
+ } else {
+ otherState = otherBrowser.docShellIsActive
+ ? this.STATE_LOADED
+ : this.STATE_UNLOADED;
+ }
+ if (!this.swapMap) {
+ this.swapMap = new WeakMap();
+ }
+ this.swapMap.set(otherBrowser, {
+ state: otherState,
+ });
+ }
+
+ onEndSwapDocShells(ourBrowser, otherBrowser) {
+ // The swap has happened. We reset the loadingTab in
+ // case it has been swapped. We also set ourBrowser's state
+ // to whatever otherBrowser's state was before the swap.
+
+ // Clearing the load timer means that we will
+ // immediately display a spinner if ourBrowser isn't
+ // ready yet. Typically it will already be ready
+ // though. If it's not, we're probably in a new window,
+ // in which case we have no other tabs to display anyway.
+ this.maybeClearLoadTimer("onEndSwapDocShells");
+
+ let { state: otherState } = this.swapMap.get(otherBrowser);
+
+ this.swapMap.delete(otherBrowser);
+
+ let ourTab = this.tabbrowser.getTabForBrowser(ourBrowser);
+ if (ourTab) {
+ this.setTabStateNoAction(ourTab, otherState);
+ }
+ }
+
+ /**
+ * Check if the browser should be deactivated. If the browser is a print preivew or
+ * PiP browser then we won't deactive it.
+ * @param browser The browser to check if it should be deactivated
+ * @returns false if a print preview or PiP browser else true
+ */
+ shouldDeactivateDocShell(browser) {
+ return !(
+ this.tabbrowser._printPreviewBrowsers.has(browser) ||
+ lazy.PictureInPicture.isOriginatingBrowser(browser)
+ );
+ }
+
+ shouldActivateDocShell(browser) {
+ let tab = this.tabbrowser.getTabForBrowser(browser);
+ let state = this.getTabState(tab);
+ return state == this.STATE_LOADING || state == this.STATE_LOADED;
+ }
+
+ activateBrowserForPrintPreview(browser) {
+ let tab = this.tabbrowser.getTabForBrowser(browser);
+ let state = this.getTabState(tab);
+ if (state != this.STATE_LOADING && state != this.STATE_LOADED) {
+ this.setTabState(tab, this.STATE_LOADING);
+ this.logState(
+ "Activated browser " + this.tinfo(tab) + " for print preview"
+ );
+ }
+ }
+
+ canWarmTab(tab) {
+ if (!lazy.gTabWarmingEnabled) {
+ return false;
+ }
+
+ if (!tab) {
+ return false;
+ }
+
+ // If the tab is not yet inserted, closing, not remote,
+ // crashed, already visible, or already requested, warming
+ // up the tab makes no sense.
+ if (
+ this.windowHidden ||
+ !tab.linkedPanel ||
+ tab.closing ||
+ !tab.linkedBrowser.isRemoteBrowser ||
+ !tab.linkedBrowser.frameLoader.remoteTab
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ shouldWarmTab(tab) {
+ if (this.canWarmTab(tab)) {
+ // Tabs that are already in STATE_LOADING or STATE_LOADED
+ // have no need to be warmed up.
+ let state = this.getTabState(tab);
+ if (state === this.STATE_UNLOADING || state === this.STATE_UNLOADED) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ unwarmTab(tab) {
+ this.warmingTabs.delete(tab);
+ }
+
+ warmupTab(tab) {
+ if (!this.shouldWarmTab(tab)) {
+ return;
+ }
+
+ this.logState("warmupTab " + this.tinfo(tab));
+
+ this.warmingTabs.add(tab);
+ this.setTabState(tab, this.STATE_LOADING);
+ this.queueUnload(lazy.gTabWarmingUnloadDelayMs);
+ }
+
+ cleanUpTabAfterEviction(tab) {
+ this.assert(tab !== this.requestedTab);
+ let browser = tab.linkedBrowser;
+ if (browser) {
+ browser.preserveLayers(false);
+ }
+ this.setTabState(tab, this.STATE_UNLOADING);
+ }
+
+ evictOldestTabFromCache() {
+ let tab = this.tabLayerCache.shift();
+ this.cleanUpTabAfterEviction(tab);
+ }
+
+ maybePromoteTabInLayerCache(tab) {
+ if (
+ lazy.gTabCacheSize > 1 &&
+ tab.linkedBrowser.isRemoteBrowser &&
+ tab.linkedBrowser.currentURI.spec != "about:blank"
+ ) {
+ let tabIndex = this.tabLayerCache.indexOf(tab);
+
+ if (tabIndex != -1) {
+ this.tabLayerCache.splice(tabIndex, 1);
+ }
+
+ this.tabLayerCache.push(tab);
+
+ if (this.tabLayerCache.length > lazy.gTabCacheSize) {
+ this.evictOldestTabFromCache();
+ }
+ }
+ }
+
+ // Called when the user asks to switch to a given tab.
+ requestTab(tab) {
+ if (tab === this.requestedTab) {
+ return;
+ }
+
+ let tabState = this.getTabState(tab);
+ this.noteTabRequested(tab, tabState);
+
+ this.logState("requestTab " + this.tinfo(tab));
+ this.startTabSwitch();
+
+ let oldBrowser = this.requestedTab.linkedBrowser;
+ oldBrowser.deprioritize();
+ this.requestedTab = tab;
+ if (tabState == this.STATE_LOADED) {
+ this.maybeVisibleTabs.clear();
+ }
+
+ tab.linkedBrowser.setAttribute("primary", "true");
+ if (this.lastPrimaryTab && this.lastPrimaryTab != tab) {
+ this.lastPrimaryTab.linkedBrowser.removeAttribute("primary");
+ }
+ this.lastPrimaryTab = tab;
+
+ this.queueUnload(this.UNLOAD_DELAY);
+ }
+
+ queueUnload(unloadTimeout) {
+ this.handleEvent({ type: "queueUnload", unloadTimeout });
+ }
+
+ onQueueUnload(unloadTimeout) {
+ if (this.unloadTimer) {
+ this.clearTimer(this.unloadTimer);
+ }
+ this.unloadTimer = this.setTimer(
+ () => this.handleEvent({ type: "unloadTimeout" }),
+ unloadTimeout
+ );
+ }
+
+ handleEvent(event, delayed = false) {
+ if (this._processing) {
+ this.setTimer(() => this.handleEvent(event, true), 0);
+ return;
+ }
+ if (delayed && this.tabbrowser._switcher != this) {
+ // if we delayed processing this event, we might be out of date, in which
+ // case we drop the delayed events
+ return;
+ }
+ this._processing = true;
+ try {
+ this.preActions();
+
+ switch (event.type) {
+ case "queueUnload":
+ this.onQueueUnload(event.unloadTimeout);
+ break;
+ case "unloadTimeout":
+ this.onUnloadTimeout();
+ break;
+ case "loadTimeout":
+ this.onLoadTimeout();
+ break;
+ case "tabRemoved":
+ this.onTabRemovedImpl(event.tab);
+ break;
+ case "MozLayerTreeReady":
+ this.onLayersReady(event.originalTarget);
+ break;
+ case "MozAfterPaint":
+ this.onPaint(event);
+ break;
+ case "MozLayerTreeCleared":
+ this.onLayersCleared(event.originalTarget);
+ break;
+ case "TabRemotenessChange":
+ this.onRemotenessChange(event.target);
+ break;
+ case "visibilitychange":
+ this.onVisibilityChange();
+ break;
+ case "SwapDocShells":
+ this.onSwapDocShells(event.originalTarget, event.detail);
+ break;
+ case "EndSwapDocShells":
+ this.onEndSwapDocShells(event.originalTarget, event.detail);
+ break;
+ }
+
+ this.postActions(event.type);
+ } finally {
+ this._processing = false;
+ }
+ }
+
+ /*
+ * Telemetry and Profiler related helpers for recording tab switch
+ * timing.
+ */
+
+ startTabSwitch() {
+ this.noteStartTabSwitch();
+ this.switchInProgress = true;
+ }
+
+ /**
+ * Something has occurred that might mean that we've completed
+ * the tab switch (layers are ready, paints are done, spinners
+ * are hidden). This checks to make sure all conditions are
+ * satisfied, and then records the tab switch as finished.
+ */
+ maybeFinishTabSwitch() {
+ if (
+ this.switchInProgress &&
+ this.requestedTab &&
+ (this.getTabState(this.requestedTab) == this.STATE_LOADED ||
+ this.requestedTab === this.blankTab)
+ ) {
+ if (this.requestedTab !== this.blankTab) {
+ this.maybePromoteTabInLayerCache(this.requestedTab);
+ }
+
+ this.noteFinishTabSwitch();
+ this.switchInProgress = false;
+
+ let event = new this.window.CustomEvent("TabSwitched", {
+ bubbles: true,
+ detail: {
+ tab: this.requestedTab,
+ },
+ });
+ this.tabbrowser.dispatchEvent(event);
+ }
+ }
+
+ /*
+ * Debug related logging for switcher.
+ */
+ logging() {
+ if (this._useDumpForLogging) {
+ return true;
+ }
+ if (this._logInit) {
+ return this._shouldLog;
+ }
+ let result = Services.prefs.getBoolPref(
+ "browser.tabs.remote.logSwitchTiming",
+ false
+ );
+ this._shouldLog = result;
+ this._logInit = true;
+ return this._shouldLog;
+ }
+
+ tinfo(tab) {
+ if (tab) {
+ return tab._tPos + "(" + tab.linkedBrowser.currentURI.spec + ")";
+ }
+ return "null";
+ }
+
+ log(s) {
+ if (!this.logging()) {
+ return;
+ }
+ if (this._useDumpForLogging) {
+ dump(s + "\n");
+ } else {
+ Services.console.logStringMessage(s);
+ }
+ }
+
+ addLogFlag(flag, ...subFlags) {
+ if (this.logging()) {
+ if (subFlags.length) {
+ flag += `(${subFlags.map(f => (f ? 1 : 0)).join("")})`;
+ }
+ this._logFlags.push(flag);
+ }
+ }
+
+ logState(suffix) {
+ if (!this.logging()) {
+ return;
+ }
+
+ let getTabString = tab => {
+ let tabString = "";
+
+ let state = this.getTabState(tab);
+ let isWarming = this.warmingTabs.has(tab);
+ let isCached = this.tabLayerCache.includes(tab);
+ let isClosing = tab.closing;
+ let linkedBrowser = tab.linkedBrowser;
+ let isActive = linkedBrowser && linkedBrowser.docShellIsActive;
+ let isRendered = linkedBrowser && linkedBrowser.renderLayers;
+ let isPiP =
+ linkedBrowser &&
+ lazy.PictureInPicture.isOriginatingBrowser(linkedBrowser);
+
+ if (tab === this.lastVisibleTab) {
+ tabString += "V";
+ }
+ if (tab === this.loadingTab) {
+ tabString += "L";
+ }
+ if (tab === this.requestedTab) {
+ tabString += "R";
+ }
+ if (tab === this.blankTab) {
+ tabString += "B";
+ }
+ if (this.maybeVisibleTabs.has(tab)) {
+ tabString += "M";
+ }
+
+ let extraStates = "";
+ if (isWarming) {
+ extraStates += "W";
+ }
+ if (isCached) {
+ extraStates += "C";
+ }
+ if (isClosing) {
+ extraStates += "X";
+ }
+ if (isActive) {
+ extraStates += "A";
+ }
+ if (isRendered) {
+ extraStates += "R";
+ }
+ if (isPiP) {
+ extraStates += "P";
+ }
+ if (extraStates != "") {
+ tabString += `(${extraStates})`;
+ }
+
+ switch (state) {
+ case this.STATE_LOADED: {
+ tabString += "(loaded)";
+ break;
+ }
+ case this.STATE_LOADING: {
+ tabString += "(loading)";
+ break;
+ }
+ case this.STATE_UNLOADING: {
+ tabString += "(unloading)";
+ break;
+ }
+ case this.STATE_UNLOADED: {
+ tabString += "(unloaded)";
+ break;
+ }
+ }
+
+ return tabString;
+ };
+
+ let accum = "";
+
+ // This is a bit tricky to read, but what we're doing here is collapsing
+ // identical tab states down to make the overal string shorter and easier
+ // to read, and we move all simply unloaded tabs to the back of the list.
+ // I.e., we turn
+ // "0:(unloaded) 1:(unloaded) 2:(unloaded) 3:(loaded)""
+ // into
+ // "3:(loaded) 0...2:(unloaded)"
+ let tabStrings = this.tabbrowser.tabs.map(t => getTabString(t));
+ let lastMatch = -1;
+ let unloadedTabsStrings = [];
+ for (let i = 0; i <= tabStrings.length; i++) {
+ if (i > 0) {
+ if (i < tabStrings.length && tabStrings[i] == tabStrings[lastMatch]) {
+ continue;
+ }
+
+ if (tabStrings[lastMatch] == "(unloaded)") {
+ if (lastMatch == i - 1) {
+ unloadedTabsStrings.push(lastMatch.toString());
+ } else {
+ unloadedTabsStrings.push(`${lastMatch}...${i - 1}`);
+ }
+ } else if (lastMatch == i - 1) {
+ accum += `${lastMatch}:${tabStrings[lastMatch]} `;
+ } else {
+ accum += `${lastMatch}...${i - 1}:${tabStrings[lastMatch]} `;
+ }
+ }
+
+ lastMatch = i;
+ }
+
+ if (unloadedTabsStrings.length) {
+ accum += `${unloadedTabsStrings.join(",")}:(unloaded) `;
+ }
+
+ accum += "cached: " + this.tabLayerCache.length + " ";
+
+ if (this._logFlags.length) {
+ accum += `[${this._logFlags.join(",")}] `;
+ this._logFlags = [];
+ }
+
+ // It can be annoying to read through the entirety of a log string just
+ // to check if something changed or not. So if we can tell that nothing
+ // changed, just write "unchanged" to save the reader's time.
+ let logString;
+ if (this._lastLogString == accum) {
+ accum = "unchanged";
+ } else {
+ this._lastLogString = accum;
+ }
+ logString = `ATS: ${accum}{${suffix}}`;
+
+ if (this._useDumpForLogging) {
+ dump(logString + "\n");
+ } else {
+ Services.console.logStringMessage(logString);
+ }
+ }
+
+ noteMakingTabVisibleWithoutLayers() {
+ // We're making the tab visible even though we haven't yet got layers for it.
+ // It's hard to know which composite the layers will first be available in (and
+ // the parent process might not even get MozAfterPaint delivered for it), so just
+ // give up measuring this for now. :(
+ TelemetryStopwatch.cancel("FX_TAB_SWITCH_COMPOSITE_E10S_MS", this.window);
+ }
+
+ notePaint(event) {
+ if (this.switchPaintId != -1 && event.transactionId >= this.switchPaintId) {
+ if (
+ TelemetryStopwatch.running(
+ "FX_TAB_SWITCH_COMPOSITE_E10S_MS",
+ this.window
+ )
+ ) {
+ let time = TelemetryStopwatch.timeElapsed(
+ "FX_TAB_SWITCH_COMPOSITE_E10S_MS",
+ this.window
+ );
+ if (time != -1) {
+ TelemetryStopwatch.finish(
+ "FX_TAB_SWITCH_COMPOSITE_E10S_MS",
+ this.window
+ );
+ }
+ }
+ let { innerWindowId } = this.window.windowGlobalChild;
+ ChromeUtils.addProfilerMarker("AsyncTabSwitch:Composited", {
+ innerWindowId,
+ });
+ this.switchPaintId = -1;
+ }
+ }
+
+ noteTabRequested(tab, tabState) {
+ if (lazy.gTabWarmingEnabled) {
+ let warmingState = "disqualified";
+
+ if (this.canWarmTab(tab)) {
+ if (tabState == this.STATE_LOADING) {
+ warmingState = "stillLoading";
+ } else if (tabState == this.STATE_LOADED) {
+ warmingState = "loaded";
+ } else if (
+ tabState == this.STATE_UNLOADING ||
+ tabState == this.STATE_UNLOADED
+ ) {
+ // At this point, if the tab's browser was being inserted
+ // lazily, we never had a chance to warm it up, and unfortunately
+ // there's no great way to detect that case. Those cases will
+ // end up in the "notWarmed" bucket, along with legitimate cases
+ // where tabs could have been warmed but weren't.
+ warmingState = "notWarmed";
+ }
+ }
+
+ Services.telemetry
+ .getHistogramById("FX_TAB_SWITCH_REQUEST_TAB_WARMING_STATE")
+ .add(warmingState);
+ }
+ }
+
+ noteStartTabSwitch() {
+ TelemetryStopwatch.cancel("FX_TAB_SWITCH_TOTAL_E10S_MS", this.window);
+ TelemetryStopwatch.start("FX_TAB_SWITCH_TOTAL_E10S_MS", this.window);
+
+ if (
+ TelemetryStopwatch.running("FX_TAB_SWITCH_COMPOSITE_E10S_MS", this.window)
+ ) {
+ TelemetryStopwatch.cancel("FX_TAB_SWITCH_COMPOSITE_E10S_MS", this.window);
+ }
+ TelemetryStopwatch.start("FX_TAB_SWITCH_COMPOSITE_E10S_MS", this.window);
+ let { innerWindowId } = this.window.windowGlobalChild;
+ ChromeUtils.addProfilerMarker("AsyncTabSwitch:Start", { innerWindowId });
+ }
+
+ noteFinishTabSwitch() {
+ // After this point the tab has switched from the content thread's point of view.
+ // The changes will be visible after the next refresh driver tick + composite.
+ let time = TelemetryStopwatch.timeElapsed(
+ "FX_TAB_SWITCH_TOTAL_E10S_MS",
+ this.window
+ );
+ if (time != -1) {
+ TelemetryStopwatch.finish("FX_TAB_SWITCH_TOTAL_E10S_MS", this.window);
+ this.log("DEBUG: tab switch time = " + time);
+ let { innerWindowId } = this.window.windowGlobalChild;
+ ChromeUtils.addProfilerMarker("AsyncTabSwitch:Finish", { innerWindowId });
+ }
+ }
+
+ noteSpinnerDisplayed() {
+ this.assert(!this.spinnerTab);
+ let browser = this.requestedTab.linkedBrowser;
+ this.assert(browser.isRemoteBrowser);
+ TelemetryStopwatch.start("FX_TAB_SWITCH_SPINNER_VISIBLE_MS", this.window);
+ // We have a second, similar probe for capturing recordings of
+ // when the spinner is displayed for very long periods.
+ TelemetryStopwatch.start(
+ "FX_TAB_SWITCH_SPINNER_VISIBLE_LONG_MS",
+ this.window
+ );
+ let { innerWindowId } = this.window.windowGlobalChild;
+ ChromeUtils.addProfilerMarker("AsyncTabSwitch:SpinnerShown", {
+ innerWindowId,
+ });
+ Services.telemetry
+ .getHistogramById("FX_TAB_SWITCH_SPINNER_VISIBLE_TRIGGER")
+ .add(this._loadTimerClearedBy);
+ if (AppConstants.NIGHTLY_BUILD) {
+ Services.obs.notifyObservers(null, "tabswitch-spinner");
+ }
+ }
+
+ noteSpinnerHidden() {
+ this.assert(this.spinnerTab);
+ this.log(
+ "DEBUG: spinner time = " +
+ TelemetryStopwatch.timeElapsed(
+ "FX_TAB_SWITCH_SPINNER_VISIBLE_MS",
+ this.window
+ )
+ );
+ TelemetryStopwatch.finish("FX_TAB_SWITCH_SPINNER_VISIBLE_MS", this.window);
+ TelemetryStopwatch.finish(
+ "FX_TAB_SWITCH_SPINNER_VISIBLE_LONG_MS",
+ this.window
+ );
+ let { innerWindowId } = this.window.windowGlobalChild;
+ ChromeUtils.addProfilerMarker("AsyncTabSwitch:SpinnerHidden", {
+ innerWindowId,
+ });
+ // we do not get a onPaint after displaying the spinner
+ this._loadTimerClearedBy = "none";
+ }
+}
diff --git a/browser/modules/BackgroundTask_install.sys.mjs b/browser/modules/BackgroundTask_install.sys.mjs
new file mode 100644
index 0000000000..8f13aa8789
--- /dev/null
+++ b/browser/modules/BackgroundTask_install.sys.mjs
@@ -0,0 +1,29 @@
+/* -*- 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/. */
+
+/**
+ * This task ought to have an ephemeral profile and should not apply updates.
+ * These settings are controlled externally, by
+ * `BackgroundTasks::IsUpdatingTaskName` and
+ * `BackgroundTasks::IsEphemeralProfileTaskName`.
+ */
+
+// This happens synchronously during installation. It shouldn't take that long
+// and if something goes wrong we really don't want to sit around waiting for
+// it.
+export const backgroundTaskTimeoutSec = 30;
+
+export async function runBackgroundTask(commandLine) {
+ console.log("Running BackgroundTask_install.");
+
+ console.log("Cleaning up update files.");
+ try {
+ Cc["@mozilla.org/updates/update-manager;1"]
+ .getService(Ci.nsIUpdateManager)
+ .doInstallCleanup();
+ } catch (ex) {
+ console.error(ex);
+ }
+}
diff --git a/browser/modules/BackgroundTask_uninstall.sys.mjs b/browser/modules/BackgroundTask_uninstall.sys.mjs
new file mode 100644
index 0000000000..bb83a194f8
--- /dev/null
+++ b/browser/modules/BackgroundTask_uninstall.sys.mjs
@@ -0,0 +1,58 @@
+/* -*- 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/. */
+
+/**
+ * This task ought to have an ephemeral profile and should not apply updates.
+ * These settings are controlled externally, by
+ * `BackgroundTasks::IsUpdatingTaskName` and
+ * `BackgroundTasks::IsEphemeralProfileTaskName`.
+ */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+export async function runBackgroundTask(commandLine) {
+ console.log("Running BackgroundTask_uninstall.");
+
+ if (AppConstants.platform === "win") {
+ try {
+ removeNotifications();
+ } catch (ex) {
+ console.error(ex);
+ }
+ } else {
+ console.log("Not a Windows install. Skipping notification removal.");
+ }
+
+ console.log("Cleaning up update files.");
+ try {
+ Cc["@mozilla.org/updates/update-manager;1"]
+ .getService(Ci.nsIUpdateManager)
+ .doUninstallCleanup();
+ } catch (ex) {
+ console.error(ex);
+ }
+}
+
+function removeNotifications() {
+ console.log("Removing Windows toast notifications.");
+
+ if (!("nsIWindowsAlertsService" in Ci)) {
+ console.log("nsIWindowsAlertService not present.");
+ return;
+ }
+
+ let alertsService;
+ try {
+ alertsService = Cc["@mozilla.org/system-alerts-service;1"]
+ .getService(Ci.nsIAlertsService)
+ .QueryInterface(Ci.nsIWindowsAlertsService);
+ } catch (e) {
+ console.error("Error retrieving nsIWindowsAlertService: " + e.message);
+ return;
+ }
+
+ alertsService.removeAllNotificationsForInstall();
+ console.log("Finished removing Windows toast notifications.");
+}
diff --git a/browser/modules/BrowserUIUtils.jsm b/browser/modules/BrowserUIUtils.jsm
new file mode 100644
index 0000000000..c338b26fca
--- /dev/null
+++ b/browser/modules/BrowserUIUtils.jsm
@@ -0,0 +1,179 @@
+/* -*- 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/. */
+
+"use strict";
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+var EXPORTED_SYMBOLS = ["BrowserUIUtils"];
+
+var BrowserUIUtils = {
+ /**
+ * Check whether a page can be considered as 'empty', that its URI
+ * reflects its origin, and that if it's loaded in a tab, that tab
+ * could be considered 'empty' (e.g. like the result of opening
+ * a 'blank' new tab).
+ *
+ * We have to do more than just check the URI, because especially
+ * for things like about:blank, it is possible that the opener or
+ * some other page has control over the contents of the page.
+ *
+ * @param {Browser} browser
+ * The browser whose page we're checking.
+ * @param {nsIURI} [uri]
+ * The URI against which we're checking (the browser's currentURI
+ * if omitted).
+ *
+ * @return {boolean} false if the page was opened by or is controlled by
+ * arbitrary web content, unless that content corresponds with the URI.
+ * true if the page is blank and controlled by a principal matching
+ * that URI (or the system principal if the principal has no URI)
+ */
+ checkEmptyPageOrigin(browser, uri = browser.currentURI) {
+ // If another page opened this page with e.g. window.open, this page might
+ // be controlled by its opener.
+ if (browser.hasContentOpener) {
+ return false;
+ }
+ let contentPrincipal = browser.contentPrincipal;
+ // Not all principals have URIs...
+ // There are two special-cases involving about:blank. One is where
+ // the user has manually loaded it and it got created with a null
+ // principal. The other involves the case where we load
+ // some other empty page in a browser and the current page is the
+ // initial about:blank page (which has that as its principal, not
+ // just URI in which case it could be web-based). Especially in
+ // e10s, we need to tackle that case specifically to avoid race
+ // conditions when updating the URL bar.
+ //
+ // Note that we check the documentURI here, since the currentURI on
+ // the browser might have been set by SessionStore in order to
+ // support switch-to-tab without having actually loaded the content
+ // yet.
+ let uriToCheck = browser.documentURI || uri;
+ if (
+ (uriToCheck.spec == "about:blank" && contentPrincipal.isNullPrincipal) ||
+ contentPrincipal.spec == "about:blank"
+ ) {
+ return true;
+ }
+ if (contentPrincipal.isContentPrincipal) {
+ return contentPrincipal.equalsURI(uri);
+ }
+ // ... so for those that don't have them, enforce that the page has the
+ // system principal (this matches e.g. on about:newtab).
+ return contentPrincipal.isSystemPrincipal;
+ },
+
+ /**
+ * Generate a document fragment for a localized string that has DOM
+ * node replacements. This avoids using getFormattedString followed
+ * by assigning to innerHTML. Fluent can probably replace this when
+ * it is in use everywhere.
+ *
+ * @param {Document} doc
+ * @param {String} msg
+ * The string to put replacements in. Fetch from
+ * a stringbundle using getString or GetStringFromName,
+ * or even an inserted dtd string.
+ * @param {Node|String} nodesOrStrings
+ * The replacement items. Can be a mix of Nodes
+ * and Strings. However, for correct behaviour, the
+ * number of items provided needs to exactly match
+ * the number of replacement strings in the l10n string.
+ * @returns {DocumentFragment}
+ * A document fragment. In the trivial case (no
+ * replacements), this will simply be a fragment with 1
+ * child, a text node containing the localized string.
+ */
+ getLocalizedFragment(doc, msg, ...nodesOrStrings) {
+ // Ensure replacement points are indexed:
+ for (let i = 1; i <= nodesOrStrings.length; i++) {
+ if (!msg.includes("%" + i + "$S")) {
+ msg = msg.replace(/%S/, "%" + i + "$S");
+ }
+ }
+ let numberOfInsertionPoints = msg.match(/%\d+\$S/g).length;
+ if (numberOfInsertionPoints != nodesOrStrings.length) {
+ console.error(
+ `Message has ${numberOfInsertionPoints} insertion points, ` +
+ `but got ${nodesOrStrings.length} replacement parameters!`
+ );
+ }
+
+ let fragment = doc.createDocumentFragment();
+ let parts = [msg];
+ let insertionPoint = 1;
+ for (let replacement of nodesOrStrings) {
+ let insertionString = "%" + insertionPoint++ + "$S";
+ let partIndex = parts.findIndex(
+ part => typeof part == "string" && part.includes(insertionString)
+ );
+ if (partIndex == -1) {
+ fragment.appendChild(doc.createTextNode(msg));
+ return fragment;
+ }
+
+ if (typeof replacement == "string") {
+ parts[partIndex] = parts[partIndex].replace(
+ insertionString,
+ replacement
+ );
+ } else {
+ let [firstBit, lastBit] = parts[partIndex].split(insertionString);
+ parts.splice(partIndex, 1, firstBit, replacement, lastBit);
+ }
+ }
+
+ // Put everything in a document fragment:
+ for (let part of parts) {
+ if (typeof part == "string") {
+ if (part) {
+ fragment.appendChild(doc.createTextNode(part));
+ }
+ } else {
+ fragment.appendChild(part);
+ }
+ }
+ return fragment;
+ },
+
+ removeSingleTrailingSlashFromURL(aURL) {
+ // remove single trailing slash for http/https/ftp URLs
+ return aURL.replace(/^((?:http|https|ftp):\/\/[^/]+)\/$/, "$1");
+ },
+
+ /**
+ * Returns a URL which has been trimmed by removing 'http://' and any
+ * trailing slash (in http/https/ftp urls).
+ * Note that a trimmed url may not load the same page as the original url, so
+ * before loading it, it must be passed through URIFixup, to check trimming
+ * doesn't change its destination. We don't run the URIFixup check here,
+ * because trimURL is in the page load path (see onLocationChange), so it
+ * must be fast and simple.
+ *
+ * @param {string} aURL The URL to trim.
+ * @returns {string} The trimmed string.
+ */
+ get trimURLProtocol() {
+ return "http://";
+ },
+ trimURL(aURL) {
+ let url = this.removeSingleTrailingSlashFromURL(aURL);
+ // Remove "http://" prefix.
+ return url.startsWith(this.trimURLProtocol)
+ ? url.substring(this.trimURLProtocol.length)
+ : url;
+ },
+};
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ BrowserUIUtils,
+ "quitShortcutDisabled",
+ "browser.quitShortcut.disabled",
+ false
+);
diff --git a/browser/modules/BrowserUsageTelemetry.jsm b/browser/modules/BrowserUsageTelemetry.jsm
new file mode 100644
index 0000000000..41525f06d0
--- /dev/null
+++ b/browser/modules/BrowserUsageTelemetry.jsm
@@ -0,0 +1,1454 @@
+/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = [
+ "BrowserUsageTelemetry",
+ "getUniqueDomainsVisitedInPast24Hours",
+ "URICountListener",
+ "MINIMUM_TAB_COUNT_INTERVAL_MS",
+];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ClientID: "resource://gre/modules/ClientID.sys.mjs",
+ CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs",
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ ProvenanceData: "resource:///modules/ProvenanceData.sys.mjs",
+ SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs",
+ SearchSERPTelemetryUtils: "resource:///modules/SearchSERPTelemetry.sys.mjs",
+
+ WindowsInstallsInfo:
+ "resource://gre/modules/components-utils/WindowsInstallsInfo.sys.mjs",
+
+ clearTimeout: "resource://gre/modules/Timer.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ PageActions: "resource:///modules/PageActions.jsm",
+});
+
+// This pref is in seconds!
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "gRecentVisitedOriginsExpiry",
+ "browser.engagement.recent_visited_origins.expiry"
+);
+
+// The upper bound for the count of the visited unique domain names.
+const MAX_UNIQUE_VISITED_DOMAINS = 100;
+
+// Observed topic names.
+const TAB_RESTORING_TOPIC = "SSTabRestoring";
+const TELEMETRY_SUBSESSIONSPLIT_TOPIC =
+ "internal-telemetry-after-subsession-split";
+const DOMWINDOW_OPENED_TOPIC = "domwindowopened";
+
+// Probe names.
+const MAX_TAB_COUNT_SCALAR_NAME = "browser.engagement.max_concurrent_tab_count";
+const MAX_WINDOW_COUNT_SCALAR_NAME =
+ "browser.engagement.max_concurrent_window_count";
+const TAB_OPEN_EVENT_COUNT_SCALAR_NAME =
+ "browser.engagement.tab_open_event_count";
+const MAX_TAB_PINNED_COUNT_SCALAR_NAME =
+ "browser.engagement.max_concurrent_tab_pinned_count";
+const TAB_PINNED_EVENT_COUNT_SCALAR_NAME =
+ "browser.engagement.tab_pinned_event_count";
+const WINDOW_OPEN_EVENT_COUNT_SCALAR_NAME =
+ "browser.engagement.window_open_event_count";
+const UNIQUE_DOMAINS_COUNT_SCALAR_NAME =
+ "browser.engagement.unique_domains_count";
+const TOTAL_URI_COUNT_SCALAR_NAME = "browser.engagement.total_uri_count";
+const UNFILTERED_URI_COUNT_SCALAR_NAME =
+ "browser.engagement.unfiltered_uri_count";
+const TOTAL_URI_COUNT_NORMAL_AND_PRIVATE_MODE_SCALAR_NAME =
+ "browser.engagement.total_uri_count_normal_and_private_mode";
+
+const MINIMUM_TAB_COUNT_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes, in ms
+
+// The elements we consider to be interactive.
+const UI_TARGET_ELEMENTS = [
+ "menuitem",
+ "toolbarbutton",
+ "key",
+ "command",
+ "checkbox",
+ "input",
+ "button",
+ "image",
+ "radio",
+ "richlistitem",
+];
+
+// The containers of interactive elements that we care about and their pretty
+// names. These should be listed in order of most-specific to least-specific,
+// when iterating JavaScript will guarantee that ordering and so we will find
+// the most specific area first.
+const BROWSER_UI_CONTAINER_IDS = {
+ "toolbar-menubar": "menu-bar",
+ TabsToolbar: "tabs-bar",
+ PersonalToolbar: "bookmarks-bar",
+ "appMenu-popup": "app-menu",
+ tabContextMenu: "tabs-context",
+ contentAreaContextMenu: "content-context",
+ "widget-overflow-list": "overflow-menu",
+ "widget-overflow-fixed-list": "pinned-overflow-menu",
+ "page-action-buttons": "pageaction-urlbar",
+ pageActionPanel: "pageaction-panel",
+ "unified-extensions-area": "unified-extensions-area",
+ "allTabsMenu-allTabsView": "alltabs-menu",
+
+ // This should appear last as some of the above are inside the nav bar.
+ "nav-bar": "nav-bar",
+};
+
+const ENTRYPOINT_TRACKED_CONTEXT_MENU_IDS = {
+ [BROWSER_UI_CONTAINER_IDS.tabContextMenu]: "tabs-context-entrypoint",
+};
+
+// A list of the expected panes in about:preferences
+const PREFERENCES_PANES = [
+ "paneHome",
+ "paneGeneral",
+ "panePrivacy",
+ "paneSearch",
+ "paneSearchResults",
+ "paneSync",
+ "paneContainers",
+ "paneExperimental",
+ "paneMoreFromMozilla",
+];
+
+const IGNORABLE_EVENTS = new WeakMap();
+
+const KNOWN_ADDONS = [];
+
+// Buttons that, when clicked, set a preference to true. The convention
+// is that the preference is named:
+//
+// browser.engagement.<button id>.has-used
+//
+// and is defaulted to false.
+const SET_USAGE_PREF_BUTTONS = [
+ "downloads-button",
+ "fxa-toolbar-menu-button",
+ "home-button",
+ "sidebar-button",
+ "library-button",
+];
+
+// Buttons that, when clicked, increase a counter. The convention
+// is that the preference is named:
+//
+// browser.engagement.<button id>.used-count
+//
+// and doesn't have a default value.
+const SET_USAGECOUNT_PREF_BUTTONS = [
+ "pageAction-panel-copyURL",
+ "pageAction-panel-emailLink",
+ "pageAction-panel-pinTab",
+ "pageAction-panel-screenshots_mozilla_org",
+ "pageAction-panel-shareURL",
+];
+
+// Places context menu IDs.
+const PLACES_CONTEXT_MENU_ID = "placesContext";
+const PLACES_OPEN_IN_CONTAINER_TAB_MENU_ID =
+ "placesContext_open:newcontainertab";
+
+// Commands used to open history or bookmark links from places context menu.
+const PLACES_OPEN_COMMANDS = [
+ "placesCmd_open",
+ "placesCmd_open:window",
+ "placesCmd_open:privatewindow",
+ "placesCmd_open:tab",
+];
+
+function telemetryId(widgetId, obscureAddons = true) {
+ // Add-on IDs need to be obscured.
+ function addonId(id) {
+ if (!obscureAddons) {
+ return id;
+ }
+
+ let pos = KNOWN_ADDONS.indexOf(id);
+ if (pos < 0) {
+ pos = KNOWN_ADDONS.length;
+ KNOWN_ADDONS.push(id);
+ }
+ return `addon${pos}`;
+ }
+
+ if (widgetId.endsWith("-browser-action")) {
+ widgetId = addonId(
+ widgetId.substring(0, widgetId.length - "-browser-action".length)
+ );
+ } else if (widgetId.startsWith("pageAction-")) {
+ let actionId;
+ if (widgetId.startsWith("pageAction-urlbar-")) {
+ actionId = widgetId.substring("pageAction-urlbar-".length);
+ } else if (widgetId.startsWith("pageAction-panel-")) {
+ actionId = widgetId.substring("pageAction-panel-".length);
+ }
+
+ if (actionId) {
+ let action = lazy.PageActions.actionForID(actionId);
+ widgetId = action?._isMozillaAction ? actionId : addonId(actionId);
+ }
+ } else if (widgetId.startsWith("ext-keyset-id-")) {
+ // Webextension command shortcuts don't have an id on their key element so
+ // we see the id from the keyset that contains them.
+ widgetId = addonId(widgetId.substring("ext-keyset-id-".length));
+ } else if (widgetId.startsWith("ext-key-id-")) {
+ // The command for a webextension sidebar action is an exception to the above rule.
+ widgetId = widgetId.substring("ext-key-id-".length);
+ if (widgetId.endsWith("-sidebar-action")) {
+ widgetId = addonId(
+ widgetId.substring(0, widgetId.length - "-sidebar-action".length)
+ );
+ }
+ }
+
+ return widgetId.replace(/_/g, "-");
+}
+
+function getOpenTabsAndWinsCounts() {
+ let loadedTabCount = 0;
+ let tabCount = 0;
+ let winCount = 0;
+
+ for (let win of Services.wm.getEnumerator("navigator:browser")) {
+ winCount++;
+ tabCount += win.gBrowser.tabs.length;
+ for (const tab of win.gBrowser.tabs) {
+ if (tab.getAttribute("pending") !== "true") {
+ loadedTabCount += 1;
+ }
+ }
+ }
+
+ return { loadedTabCount, tabCount, winCount };
+}
+
+function getPinnedTabsCount() {
+ let pinnedTabs = 0;
+
+ for (let win of Services.wm.getEnumerator("navigator:browser")) {
+ pinnedTabs += [...win.ownerGlobal.gBrowser.tabs].filter(
+ t => t.pinned
+ ).length;
+ }
+
+ return pinnedTabs;
+}
+
+let URICountListener = {
+ // A set containing the visited domains, see bug 1271310.
+ _domainSet: new Set(),
+ // A set containing the visited origins during the last 24 hours (similar to domains, but not quite the same)
+ _domain24hrSet: new Set(),
+ // A map to keep track of the URIs loaded from the restored tabs.
+ _restoredURIsMap: new WeakMap(),
+ // Ongoing expiration timeouts.
+ _timeouts: new Set(),
+
+ isHttpURI(uri) {
+ // Only consider http(s) schemas.
+ return uri.schemeIs("http") || uri.schemeIs("https");
+ },
+
+ addRestoredURI(browser, uri) {
+ if (!this.isHttpURI(uri)) {
+ return;
+ }
+
+ this._restoredURIsMap.set(browser, uri.spec);
+ },
+
+ onLocationChange(browser, webProgress, request, uri, flags) {
+ if (
+ !(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) &&
+ webProgress.isTopLevel
+ ) {
+ // By default, assume we no longer need to track this tab.
+ lazy.SearchSERPTelemetry.stopTrackingBrowser(
+ browser,
+ lazy.SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION
+ );
+ }
+
+ // Don't count this URI if it's an error page.
+ if (flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE) {
+ return;
+ }
+
+ // We only care about top level loads.
+ if (!webProgress.isTopLevel) {
+ return;
+ }
+
+ // The SessionStore sets the URI of a tab first, firing onLocationChange the
+ // first time, then manages content loading using its scheduler. Once content
+ // loads, we will hit onLocationChange again.
+ // We can catch the first case by checking for null requests: be advised that
+ // this can also happen when navigating page fragments, so account for it.
+ if (
+ !request &&
+ !(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT)
+ ) {
+ return;
+ }
+
+ // Don't include URI and domain counts when in private mode.
+ let shouldCountURI =
+ !lazy.PrivateBrowsingUtils.isWindowPrivate(browser.ownerGlobal) ||
+ Services.prefs.getBoolPref(
+ "browser.engagement.total_uri_count.pbm",
+ false
+ );
+
+ // Track URI loads, even if they're not http(s).
+ let uriSpec = null;
+ try {
+ uriSpec = uri.spec;
+ } catch (e) {
+ // If we have troubles parsing the spec, still count this as
+ // an unfiltered URI.
+ if (shouldCountURI) {
+ Services.telemetry.scalarAdd(UNFILTERED_URI_COUNT_SCALAR_NAME, 1);
+ }
+ return;
+ }
+
+ // Don't count about:blank and similar pages, as they would artificially
+ // inflate the counts.
+ if (browser.ownerGlobal.gInitialPages.includes(uriSpec)) {
+ return;
+ }
+
+ // If the URI we're loading is in the _restoredURIsMap, then it comes from a
+ // restored tab. If so, let's skip it and remove it from the map as we want to
+ // count page refreshes.
+ if (this._restoredURIsMap.get(browser) === uriSpec) {
+ this._restoredURIsMap.delete(browser);
+ return;
+ }
+
+ // The URI wasn't from a restored tab. Count it among the unfiltered URIs.
+ // If this is an http(s) URI, this also gets counted by the "total_uri_count"
+ // probe.
+ if (shouldCountURI) {
+ Services.telemetry.scalarAdd(UNFILTERED_URI_COUNT_SCALAR_NAME, 1);
+ }
+
+ if (!this.isHttpURI(uri)) {
+ return;
+ }
+
+ if (!(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT)) {
+ lazy.SearchSERPTelemetry.updateTrackingStatus(
+ browser,
+ uriSpec,
+ webProgress.loadType
+ );
+ }
+
+ // Update total URI count, including when in private mode.
+ Services.telemetry.scalarAdd(
+ TOTAL_URI_COUNT_NORMAL_AND_PRIVATE_MODE_SCALAR_NAME,
+ 1
+ );
+ Glean.browserEngagement.uriCount.add(1);
+
+ if (!shouldCountURI) {
+ return;
+ }
+
+ // Update the URI counts.
+ Services.telemetry.scalarAdd(TOTAL_URI_COUNT_SCALAR_NAME, 1);
+
+ // Update tab count
+ BrowserUsageTelemetry._recordTabCounts(getOpenTabsAndWinsCounts());
+
+ // Unique domains should be aggregated by (eTLD + 1): x.test.com and y.test.com
+ // are counted once as test.com.
+ let baseDomain;
+ try {
+ // Even if only considering http(s) URIs, |getBaseDomain| could still throw
+ // due to the URI containing invalid characters or the domain actually being
+ // an ipv4 or ipv6 address.
+ baseDomain = Services.eTLD.getBaseDomain(uri);
+ } catch (e) {
+ return;
+ }
+
+ // We only want to count the unique domains up to MAX_UNIQUE_VISITED_DOMAINS.
+ if (this._domainSet.size < MAX_UNIQUE_VISITED_DOMAINS) {
+ this._domainSet.add(baseDomain);
+ Services.telemetry.scalarSet(
+ UNIQUE_DOMAINS_COUNT_SCALAR_NAME,
+ this._domainSet.size
+ );
+ }
+
+ this._domain24hrSet.add(baseDomain);
+ if (lazy.gRecentVisitedOriginsExpiry) {
+ let timeoutId = lazy.setTimeout(() => {
+ this._domain24hrSet.delete(baseDomain);
+ this._timeouts.delete(timeoutId);
+ }, lazy.gRecentVisitedOriginsExpiry * 1000);
+ this._timeouts.add(timeoutId);
+ }
+ },
+
+ /**
+ * Reset the counts. This should be called when breaking a session in Telemetry.
+ */
+ reset() {
+ this._domainSet.clear();
+ },
+
+ /**
+ * Returns the number of unique domains visited in this session during the
+ * last 24 hours.
+ */
+ get uniqueDomainsVisitedInPast24Hours() {
+ return this._domain24hrSet.size;
+ },
+
+ /**
+ * Resets the number of unique domains visited in this session.
+ */
+ resetUniqueDomainsVisitedInPast24Hours() {
+ this._timeouts.forEach(timeoutId => lazy.clearTimeout(timeoutId));
+ this._timeouts.clear();
+ this._domain24hrSet.clear();
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+};
+
+let BrowserUsageTelemetry = {
+ /**
+ * This is a policy object used to override behavior for testing.
+ */
+ Policy: {
+ getTelemetryClientId: async () => lazy.ClientID.getClientID(),
+ getUpdateDirectory: () => Services.dirsvc.get("UpdRootD", Ci.nsIFile),
+ readProfileCountFile: async path => IOUtils.readUTF8(path),
+ writeProfileCountFile: async (path, data) => IOUtils.writeUTF8(path, data),
+ },
+
+ _inited: false,
+
+ init() {
+ this._lastRecordTabCount = 0;
+ this._lastRecordLoadedTabCount = 0;
+ this._setupAfterRestore();
+ this._inited = true;
+
+ Services.prefs.addObserver("browser.tabs.inTitlebar", this);
+
+ this._recordUITelemetry();
+
+ this._onTabsOpenedTask = new lazy.DeferredTask(
+ () => this._onTabsOpened(),
+ 0
+ );
+ },
+
+ /**
+ * Resets the masked add-on identifiers. Only for use in tests.
+ */
+ _resetAddonIds() {
+ KNOWN_ADDONS.length = 0;
+ },
+
+ /**
+ * Handle subsession splits in the parent process.
+ */
+ afterSubsessionSplit() {
+ // Scalars just got cleared due to a subsession split. We need to set the maximum
+ // concurrent tab and window counts so that they reflect the correct value for the
+ // new subsession.
+ const counts = getOpenTabsAndWinsCounts();
+ Services.telemetry.scalarSetMaximum(
+ MAX_TAB_COUNT_SCALAR_NAME,
+ counts.tabCount
+ );
+ Services.telemetry.scalarSetMaximum(
+ MAX_WINDOW_COUNT_SCALAR_NAME,
+ counts.winCount
+ );
+
+ // Reset the URI counter.
+ URICountListener.reset();
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ uninit() {
+ if (!this._inited) {
+ return;
+ }
+ Services.obs.removeObserver(this, DOMWINDOW_OPENED_TOPIC);
+ Services.obs.removeObserver(this, TELEMETRY_SUBSESSIONSPLIT_TOPIC);
+ },
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case DOMWINDOW_OPENED_TOPIC:
+ this._onWindowOpen(subject);
+ break;
+ case TELEMETRY_SUBSESSIONSPLIT_TOPIC:
+ this.afterSubsessionSplit();
+ break;
+ case "nsPref:changed":
+ switch (data) {
+ case "browser.tabs.inTitlebar":
+ this._recordWidgetChange(
+ "titlebar",
+ Services.appinfo.drawInTitlebar ? "off" : "on",
+ "pref"
+ );
+ break;
+ }
+ break;
+ }
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "TabOpen":
+ this._onTabOpen();
+ break;
+ case "TabPinned":
+ this._onTabPinned();
+ break;
+ case "unload":
+ this._unregisterWindow(event.target);
+ break;
+ case TAB_RESTORING_TOPIC:
+ // We're restoring a new tab from a previous or crashed session.
+ // We don't want to track the URIs from these tabs, so let
+ // |URICountListener| know about them.
+ let browser = event.target.linkedBrowser;
+ URICountListener.addRestoredURI(browser, browser.currentURI);
+
+ const { loadedTabCount } = getOpenTabsAndWinsCounts();
+ this._recordTabCounts({ loadedTabCount });
+ break;
+ }
+ },
+
+ /**
+ * This gets called shortly after the SessionStore has finished restoring
+ * windows and tabs. It counts the open tabs and adds listeners to all the
+ * windows.
+ */
+ _setupAfterRestore() {
+ // Make sure to catch new chrome windows and subsession splits.
+ Services.obs.addObserver(this, DOMWINDOW_OPENED_TOPIC, true);
+ Services.obs.addObserver(this, TELEMETRY_SUBSESSIONSPLIT_TOPIC, true);
+
+ // Attach the tabopen handlers to the existing Windows.
+ for (let win of Services.wm.getEnumerator("navigator:browser")) {
+ this._registerWindow(win);
+ }
+
+ // Get the initial tab and windows max counts.
+ const counts = getOpenTabsAndWinsCounts();
+ Services.telemetry.scalarSetMaximum(
+ MAX_TAB_COUNT_SCALAR_NAME,
+ counts.tabCount
+ );
+ Services.telemetry.scalarSetMaximum(
+ MAX_WINDOW_COUNT_SCALAR_NAME,
+ counts.winCount
+ );
+ },
+
+ _buildWidgetPositions() {
+ let widgetMap = new Map();
+
+ const toolbarState = nodeId => {
+ let value;
+ if (nodeId == "PersonalToolbar") {
+ value = Services.prefs.getCharPref(
+ "browser.toolbars.bookmarks.visibility",
+ "newtab"
+ );
+ if (value != "newtab") {
+ return value == "never" ? "off" : "on";
+ }
+ return value;
+ }
+ value = Services.xulStore.getValue(
+ AppConstants.BROWSER_CHROME_URL,
+ nodeId,
+ "collapsed"
+ );
+
+ if (value) {
+ return value == "true" ? "off" : "on";
+ }
+ return "off";
+ };
+
+ widgetMap.set(
+ BROWSER_UI_CONTAINER_IDS.PersonalToolbar,
+ toolbarState("PersonalToolbar")
+ );
+
+ let menuBarHidden =
+ Services.xulStore.getValue(
+ AppConstants.BROWSER_CHROME_URL,
+ "toolbar-menubar",
+ "autohide"
+ ) != "false";
+
+ widgetMap.set("menu-toolbar", menuBarHidden ? "off" : "on");
+
+ // Drawing in the titlebar means not showing the titlebar, hence the negation.
+ widgetMap.set("titlebar", Services.appinfo.drawInTitlebar ? "off" : "on");
+
+ for (let area of lazy.CustomizableUI.areas) {
+ if (!(area in BROWSER_UI_CONTAINER_IDS)) {
+ continue;
+ }
+
+ let position = BROWSER_UI_CONTAINER_IDS[area];
+ if (area == "nav-bar") {
+ position = `${BROWSER_UI_CONTAINER_IDS[area]}-start`;
+ }
+
+ let widgets = lazy.CustomizableUI.getWidgetsInArea(area);
+
+ for (let widget of widgets) {
+ if (!widget) {
+ continue;
+ }
+
+ if (widget.id.startsWith("customizableui-special-")) {
+ continue;
+ }
+
+ if (area == "nav-bar" && widget.id == "urlbar-container") {
+ position = `${BROWSER_UI_CONTAINER_IDS[area]}-end`;
+ continue;
+ }
+
+ widgetMap.set(widget.id, position);
+ }
+ }
+
+ let actions = lazy.PageActions.actions;
+ for (let action of actions) {
+ if (action.pinnedToUrlbar) {
+ widgetMap.set(action.id, "pageaction-urlbar");
+ }
+ }
+
+ return widgetMap;
+ },
+
+ _getWidgetID(node) {
+ // We want to find a sensible ID for this element.
+ if (!node) {
+ return null;
+ }
+
+ // See if this is a customizable widget.
+ if (node.ownerDocument.URL == AppConstants.BROWSER_CHROME_URL) {
+ // First find if it is inside one of the customizable areas.
+ for (let area of lazy.CustomizableUI.areas) {
+ if (node.closest(`#${CSS.escape(area)}`)) {
+ for (let widget of lazy.CustomizableUI.getWidgetIdsInArea(area)) {
+ if (
+ // We care about the buttons on the tabs themselves.
+ widget == "tabbrowser-tabs" ||
+ // We care about the page action and other buttons in here.
+ widget == "urlbar-container" ||
+ // We care about the actual menu items.
+ widget == "menubar-items" ||
+ // We care about individual bookmarks here.
+ widget == "personal-bookmarks"
+ ) {
+ continue;
+ }
+
+ if (node.closest(`#${CSS.escape(widget)}`)) {
+ return widget;
+ }
+ }
+ break;
+ }
+ }
+ }
+
+ if (node.id) {
+ return node.id;
+ }
+
+ // A couple of special cases in the tabs.
+ for (let cls of ["bookmark-item", "tab-icon-sound", "tab-close-button"]) {
+ if (!node.classList.contains(cls)) {
+ continue;
+ }
+ if (cls == "bookmark-item" && node.parentElement.id.includes("history")) {
+ return "history-item";
+ }
+ return cls;
+ }
+
+ // One of these will at least let us know what the widget is for.
+ let possibleAttributes = [
+ "preference",
+ "command",
+ "observes",
+ "data-l10n-id",
+ ];
+
+ // The key attribute on key elements is the actual key to listen for.
+ if (node.localName != "key") {
+ possibleAttributes.unshift("key");
+ }
+
+ for (let idAttribute of possibleAttributes) {
+ if (node.hasAttribute(idAttribute)) {
+ return node.getAttribute(idAttribute);
+ }
+ }
+
+ return this._getWidgetID(node.parentElement);
+ },
+
+ _getBrowserWidgetContainer(node) {
+ // Find the container holding this element.
+ for (let containerId of Object.keys(BROWSER_UI_CONTAINER_IDS)) {
+ let container = node.ownerDocument.getElementById(containerId);
+ if (container && container.contains(node)) {
+ return BROWSER_UI_CONTAINER_IDS[containerId];
+ }
+ }
+ // Treat toolbar context menu items that relate to tabs as the tab menu:
+ if (
+ node.closest("#toolbar-context-menu") &&
+ node.getAttribute("contexttype") == "tabbar"
+ ) {
+ return BROWSER_UI_CONTAINER_IDS.tabContextMenu;
+ }
+ return null;
+ },
+
+ _getWidgetContainer(node) {
+ if (node.localName == "key") {
+ return "keyboard";
+ }
+
+ const { URL } = node.ownerDocument;
+ if (URL == AppConstants.BROWSER_CHROME_URL) {
+ return this._getBrowserWidgetContainer(node);
+ }
+ if (URL.startsWith("about:preferences")) {
+ // Find the element's category.
+ let container = node.closest("[data-category]");
+ if (!container) {
+ return null;
+ }
+
+ let pane = container.getAttribute("data-category");
+
+ if (!PREFERENCES_PANES.includes(pane)) {
+ pane = "paneUnknown";
+ }
+
+ return `preferences_${pane}`;
+ }
+
+ return null;
+ },
+
+ lastClickTarget: null,
+
+ ignoreEvent(event) {
+ IGNORABLE_EVENTS.set(event, true);
+ },
+
+ _recordCommand(event) {
+ if (IGNORABLE_EVENTS.get(event)) {
+ return;
+ }
+
+ let sourceEvent = event;
+ while (sourceEvent.sourceEvent) {
+ sourceEvent = sourceEvent.sourceEvent;
+ }
+
+ let lastTarget = this.lastClickTarget?.get();
+ if (
+ lastTarget &&
+ sourceEvent.type == "command" &&
+ sourceEvent.target.contains(lastTarget)
+ ) {
+ // Ignore a command event triggered by a click.
+ this.lastClickTarget = null;
+ return;
+ }
+
+ this.lastClickTarget = null;
+
+ if (sourceEvent.type == "click") {
+ // Only care about main button clicks.
+ if (sourceEvent.button != 0) {
+ return;
+ }
+
+ // This click may trigger a command event so retain the target to be able
+ // to dedupe that event.
+ this.lastClickTarget = Cu.getWeakReference(sourceEvent.target);
+ }
+
+ // We should never see events from web content as they are fired in a
+ // content process, but let's be safe.
+ let url = sourceEvent.target.ownerDocument.documentURIObject;
+ if (!url.schemeIs("chrome") && !url.schemeIs("about")) {
+ return;
+ }
+
+ // This is what events targetted at content will actually look like.
+ if (sourceEvent.target.localName == "browser") {
+ return;
+ }
+
+ // Find the actual element we're interested in.
+ let node = sourceEvent.target;
+ const isAboutPreferences =
+ node.ownerDocument.URL.startsWith("about:preferences");
+ while (
+ !UI_TARGET_ELEMENTS.includes(node.localName) &&
+ !node.classList?.contains("wants-telemetry") &&
+ // We are interested in links on about:preferences as well.
+ !(
+ isAboutPreferences &&
+ (node.getAttribute("is") === "text-link" || node.localName === "a")
+ )
+ ) {
+ node = node.parentNode;
+ if (!node?.parentNode) {
+ // A click on a space or label or top-level document or something we're
+ // not interested in.
+ return;
+ }
+ }
+
+ if (sourceEvent.type === "command") {
+ const { command, ownerDocument, parentNode } = node;
+ // Check if this command is for a history or bookmark link being opened
+ // from the context menu. In this case, we are interested in the DOM node
+ // for the link, not the menu item itself.
+ if (
+ PLACES_OPEN_COMMANDS.includes(command) ||
+ parentNode?.parentNode?.id === PLACES_OPEN_IN_CONTAINER_TAB_MENU_ID
+ ) {
+ node = ownerDocument.getElementById(PLACES_CONTEXT_MENU_ID).triggerNode;
+ }
+ }
+
+ let item = this._getWidgetID(node);
+ let source = this._getWidgetContainer(node);
+
+ if (item && source) {
+ let scalar = `browser.ui.interaction.${source.replace(/-/g, "_")}`;
+ Services.telemetry.keyedScalarAdd(scalar, telemetryId(item), 1);
+ if (SET_USAGECOUNT_PREF_BUTTONS.includes(item)) {
+ let pref = `browser.engagement.${item}.used-count`;
+ Services.prefs.setIntPref(pref, Services.prefs.getIntPref(pref, 0) + 1);
+ }
+ if (SET_USAGE_PREF_BUTTONS.includes(item)) {
+ Services.prefs.setBoolPref(`browser.engagement.${item}.has-used`, true);
+ }
+ }
+
+ if (ENTRYPOINT_TRACKED_CONTEXT_MENU_IDS[source]) {
+ let contextMenu = ENTRYPOINT_TRACKED_CONTEXT_MENU_IDS[source];
+ let triggerContainer = this._getWidgetContainer(
+ node.closest("menupopup")?.triggerNode
+ );
+ if (triggerContainer) {
+ let scalar = `browser.ui.interaction.${contextMenu.replace(/-/g, "_")}`;
+ Services.telemetry.keyedScalarAdd(
+ scalar,
+ telemetryId(triggerContainer),
+ 1
+ );
+ }
+ }
+ },
+
+ /**
+ * Listens for UI interactions in the window.
+ */
+ _addUsageListeners(win) {
+ // Listen for command events from the UI.
+ win.addEventListener("command", event => this._recordCommand(event), true);
+ win.addEventListener("click", event => this._recordCommand(event), true);
+ },
+
+ /**
+ * A public version of the private method to take care of the `nav-bar-start`,
+ * `nav-bar-end` thing that callers shouldn't have to care about. It also
+ * accepts the DOM ids for the areas rather than the cleaner ones we report
+ * to telemetry.
+ */
+ recordWidgetChange(widgetId, newPos, reason) {
+ try {
+ if (newPos) {
+ newPos = BROWSER_UI_CONTAINER_IDS[newPos];
+ }
+
+ if (newPos == "nav-bar") {
+ let { position } = lazy.CustomizableUI.getPlacementOfWidget(widgetId);
+ let { position: urlPosition } =
+ lazy.CustomizableUI.getPlacementOfWidget("urlbar-container");
+ newPos = newPos + (urlPosition > position ? "-start" : "-end");
+ }
+
+ this._recordWidgetChange(widgetId, newPos, reason);
+ } catch (e) {
+ console.error(e);
+ }
+ },
+
+ recordToolbarVisibility(toolbarId, newState, reason) {
+ if (typeof newState != "string") {
+ newState = newState ? "on" : "off";
+ }
+ this._recordWidgetChange(
+ BROWSER_UI_CONTAINER_IDS[toolbarId],
+ newState,
+ reason
+ );
+ },
+
+ _recordWidgetChange(widgetId, newPos, reason) {
+ // In some cases (like when add-ons are detected during startup) this gets
+ // called before we've reported the initial positions. Ignore such cases.
+ if (!this.widgetMap) {
+ return;
+ }
+
+ if (widgetId == "urlbar-container") {
+ // We don't report the position of the url bar, it is after nav-bar-start
+ // and before nav-bar-end. But moving it means the widgets around it have
+ // effectively moved so update those.
+ let position = "nav-bar-start";
+ let widgets = lazy.CustomizableUI.getWidgetsInArea("nav-bar");
+
+ for (let widget of widgets) {
+ if (!widget) {
+ continue;
+ }
+
+ if (widget.id.startsWith("customizableui-special-")) {
+ continue;
+ }
+
+ if (widget.id == "urlbar-container") {
+ position = "nav-bar-end";
+ continue;
+ }
+
+ // This will do nothing if the position hasn't changed.
+ this._recordWidgetChange(widget.id, position, reason);
+ }
+
+ return;
+ }
+
+ let oldPos = this.widgetMap.get(widgetId);
+ if (oldPos == newPos) {
+ return;
+ }
+
+ let action = "move";
+
+ if (!oldPos) {
+ action = "add";
+ } else if (!newPos) {
+ action = "remove";
+ }
+
+ let key = `${telemetryId(widgetId, false)}_${action}_${oldPos ?? "na"}_${
+ newPos ?? "na"
+ }_${reason}`;
+ Services.telemetry.keyedScalarAdd("browser.ui.customized_widgets", key, 1);
+
+ if (newPos) {
+ this.widgetMap.set(widgetId, newPos);
+ } else {
+ this.widgetMap.delete(widgetId);
+ }
+ },
+
+ _recordUITelemetry() {
+ this.widgetMap = this._buildWidgetPositions();
+
+ for (let [widgetId, position] of this.widgetMap.entries()) {
+ let key = `${telemetryId(widgetId, false)}_pinned_${position}`;
+ Services.telemetry.keyedScalarSet(
+ "browser.ui.toolbar_widgets",
+ key,
+ true
+ );
+ }
+ },
+
+ /**
+ * Adds listeners to a single chrome window.
+ */
+ _registerWindow(win) {
+ this._addUsageListeners(win);
+
+ win.addEventListener("unload", this);
+ win.addEventListener("TabOpen", this, true);
+ win.addEventListener("TabPinned", this, true);
+
+ win.gBrowser.tabContainer.addEventListener(TAB_RESTORING_TOPIC, this);
+ win.gBrowser.addTabsProgressListener(URICountListener);
+ },
+
+ /**
+ * Removes listeners from a single chrome window.
+ */
+ _unregisterWindow(win) {
+ win.removeEventListener("unload", this);
+ win.removeEventListener("TabOpen", this, true);
+ win.removeEventListener("TabPinned", this, true);
+
+ win.defaultView.gBrowser.tabContainer.removeEventListener(
+ TAB_RESTORING_TOPIC,
+ this
+ );
+ win.defaultView.gBrowser.removeTabsProgressListener(URICountListener);
+ },
+
+ /**
+ * Updates the tab counts.
+ */
+ _onTabOpen() {
+ // Update the "tab opened" count and its maximum.
+ Services.telemetry.scalarAdd(TAB_OPEN_EVENT_COUNT_SCALAR_NAME, 1);
+
+ // In the case of opening multiple tabs at once, avoid enumerating all open
+ // tabs and windows each time a tab opens.
+ this._onTabsOpenedTask.disarm();
+ this._onTabsOpenedTask.arm();
+ },
+
+ /**
+ * Update tab counts after opening multiple tabs.
+ */
+ _onTabsOpened() {
+ const { tabCount, loadedTabCount } = getOpenTabsAndWinsCounts();
+ Services.telemetry.scalarSetMaximum(MAX_TAB_COUNT_SCALAR_NAME, tabCount);
+
+ this._recordTabCounts({ tabCount, loadedTabCount });
+ },
+
+ _onTabPinned(target) {
+ const pinnedTabs = getPinnedTabsCount();
+
+ // Update the "tab pinned" count and its maximum.
+ Services.telemetry.scalarAdd(TAB_PINNED_EVENT_COUNT_SCALAR_NAME, 1);
+ Services.telemetry.scalarSetMaximum(
+ MAX_TAB_PINNED_COUNT_SCALAR_NAME,
+ pinnedTabs
+ );
+ },
+
+ /**
+ * Tracks the window count and registers the listeners for the tab count.
+ * @param{Object} win The window object.
+ */
+ _onWindowOpen(win) {
+ // Make sure to have a |nsIDOMWindow|.
+ if (!(win instanceof Ci.nsIDOMWindow)) {
+ return;
+ }
+
+ let onLoad = () => {
+ win.removeEventListener("load", onLoad);
+
+ // Ignore non browser windows.
+ if (
+ win.document.documentElement.getAttribute("windowtype") !=
+ "navigator:browser"
+ ) {
+ return;
+ }
+
+ this._registerWindow(win);
+ // Track the window open event and check the maximum.
+ const counts = getOpenTabsAndWinsCounts();
+ Services.telemetry.scalarAdd(WINDOW_OPEN_EVENT_COUNT_SCALAR_NAME, 1);
+ Services.telemetry.scalarSetMaximum(
+ MAX_WINDOW_COUNT_SCALAR_NAME,
+ counts.winCount
+ );
+
+ // We won't receive the "TabOpen" event for the first tab within a new window.
+ // Account for that.
+ this._onTabOpen(counts);
+ };
+ win.addEventListener("load", onLoad);
+ },
+
+ /**
+ * Record telemetry about the given tab counts.
+ *
+ * Telemetry for each count will only be recorded if the value isn't
+ * `undefined`.
+ *
+ * @param {object} [counts] The tab counts to register with telemetry.
+ * @param {number} [counts.tabCount] The number of tabs in all browsers.
+ * @param {number} [counts.loadedTabCount] The number of loaded (i.e., not
+ * pending) tabs in all browsers.
+ */
+ _recordTabCounts({ tabCount, loadedTabCount }) {
+ let currentTime = Date.now();
+ if (
+ tabCount !== undefined &&
+ currentTime > this._lastRecordTabCount + MINIMUM_TAB_COUNT_INTERVAL_MS
+ ) {
+ Services.telemetry.getHistogramById("TAB_COUNT").add(tabCount);
+ this._lastRecordTabCount = currentTime;
+ }
+
+ if (
+ loadedTabCount !== undefined &&
+ currentTime >
+ this._lastRecordLoadedTabCount + MINIMUM_TAB_COUNT_INTERVAL_MS
+ ) {
+ Services.telemetry
+ .getHistogramById("LOADED_TAB_COUNT")
+ .add(loadedTabCount);
+ this._lastRecordLoadedTabCount = currentTime;
+ }
+ },
+
+ _checkProfileCountFileSchema(fileData) {
+ // Verifies that the schema of the file is the expected schema
+ if (typeof fileData.version != "string") {
+ throw new Error("Schema Mismatch Error: Bad type for 'version' field");
+ }
+ if (!Array.isArray(fileData.profileTelemetryIds)) {
+ throw new Error(
+ "Schema Mismatch Error: Bad type for 'profileTelemetryIds' field"
+ );
+ }
+ for (let profileTelemetryId of fileData.profileTelemetryIds) {
+ if (typeof profileTelemetryId != "string") {
+ throw new Error(
+ "Schema Mismatch Error: Bad type for an element of 'profileTelemetryIds'"
+ );
+ }
+ }
+ },
+
+ // Reports the number of Firefox profiles on this machine to telemetry.
+ async reportProfileCount() {
+ if (
+ AppConstants.platform != "win" ||
+ !AppConstants.MOZ_TELEMETRY_REPORTING
+ ) {
+ // This is currently a windows-only feature.
+ // Also, this function writes directly to disk, without using the usual
+ // telemetry recording functions. So we excplicitly check if telemetry
+ // reporting was disabled at compile time, and we do not do anything in
+ // case.
+ return;
+ }
+
+ // To report only as much data as we need, we will bucket our values.
+ // Rather than the raw value, we will report the greatest value in the list
+ // below that is no larger than the raw value.
+ const buckets = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 100, 1000, 10000];
+
+ // We need both the C:\ProgramData\Mozilla directory and the install
+ // directory hash to create the profile count file path. We can easily
+ // reassemble this from the update directory, which looks like:
+ // C:\ProgramData\Mozilla\updates\hash
+ // Retrieving the directory this way also ensures that the "Mozilla"
+ // directory is created with the correct permissions.
+ // The ProgramData directory, by default, grants write permissions only to
+ // file creators. The directory service calls GetCommonUpdateDirectory,
+ // which makes sure the the directory is created with user-writable
+ // permissions.
+ const updateDirectory = BrowserUsageTelemetry.Policy.getUpdateDirectory();
+ const hash = updateDirectory.leafName;
+ const profileCountFilename = "profile_count_" + hash + ".json";
+ let profileCountFile = updateDirectory.parent.parent;
+ profileCountFile.append(profileCountFilename);
+
+ let readError = false;
+ let fileData;
+ try {
+ let json = await BrowserUsageTelemetry.Policy.readProfileCountFile(
+ profileCountFile.path
+ );
+ fileData = JSON.parse(json);
+ BrowserUsageTelemetry._checkProfileCountFileSchema(fileData);
+ } catch (ex) {
+ // Note that since this also catches the "no such file" error, this is
+ // always the template that we use when writing to the file for the first
+ // time.
+ fileData = { version: "1", profileTelemetryIds: [] };
+ if (!(ex.name == "NotFoundError")) {
+ console.error(ex);
+ // Don't just return here on a read error. We need to send the error
+ // value to telemetry and we want to attempt to fix the file.
+ // However, we will still report an error for this ping, even if we
+ // fix the file. This is to prevent always sending a profile count of 1
+ // if, for some reason, we always get a read error but never a write
+ // error.
+ readError = true;
+ }
+ }
+
+ let writeError = false;
+ let currentTelemetryId =
+ await BrowserUsageTelemetry.Policy.getTelemetryClientId();
+ // Don't add our telemetry ID to the file if we've already reached the
+ // largest bucket. This prevents the file size from growing forever.
+ if (
+ !fileData.profileTelemetryIds.includes(currentTelemetryId) &&
+ fileData.profileTelemetryIds.length < Math.max(...buckets)
+ ) {
+ fileData.profileTelemetryIds.push(currentTelemetryId);
+ try {
+ await BrowserUsageTelemetry.Policy.writeProfileCountFile(
+ profileCountFile.path,
+ JSON.stringify(fileData)
+ );
+ } catch (ex) {
+ console.error(ex);
+ writeError = true;
+ }
+ }
+
+ // Determine the bucketed value to report
+ let rawProfileCount = fileData.profileTelemetryIds.length;
+ let valueToReport = 0;
+ for (let bucket of buckets) {
+ if (bucket <= rawProfileCount && bucket > valueToReport) {
+ valueToReport = bucket;
+ }
+ }
+
+ if (readError || writeError) {
+ // We convey errors via a profile count of 0.
+ valueToReport = 0;
+ }
+
+ Services.telemetry.scalarSet(
+ "browser.engagement.profile_count",
+ valueToReport
+ );
+ // Manually mirror to Glean
+ Glean.browserEngagement.profileCount.set(valueToReport);
+ },
+
+ /**
+ * Check if this is the first run of this profile since installation,
+ * if so then send installation telemetry.
+ *
+ * @param {nsIFile} [dataPathOverride] Optional, full data file path, for tests.
+ * @param {Array<string>} [msixPackagePrefixes] Optional, list of prefixes to
+ consider "existing" installs when looking at installed MSIX packages.
+ Defaults to prefixes for builds produced in Firefox automation.
+ * @return {Promise}
+ * @resolves When the event has been recorded, or if the data file was not found.
+ * @rejects JavaScript exception on any failure.
+ */
+ async reportInstallationTelemetry(
+ dataPathOverride,
+ msixPackagePrefixes = ["Mozilla.Firefox", "Mozilla.MozillaFirefox"]
+ ) {
+ if (AppConstants.platform != "win") {
+ // This is a windows-only feature.
+ return;
+ }
+
+ let provenanceExtra = {};
+ try {
+ provenanceExtra = await lazy.ProvenanceData.submitProvenanceTelemetry();
+ } catch (ex) {
+ console.warn(
+ "reportInstallationTelemetry - submitProvenanceTelemetry failed",
+ ex
+ );
+ }
+
+ const TIMESTAMP_PREF = "app.installation.timestamp";
+ const lastInstallTime = Services.prefs.getStringPref(TIMESTAMP_PREF, null);
+ const wpm = Cc["@mozilla.org/windows-package-manager;1"].createInstance(
+ Ci.nsIWindowsPackageManager
+ );
+ let installer_type = "";
+ let pfn;
+ try {
+ pfn = Services.sysinfo.getProperty("winPackageFamilyName");
+ } catch (e) {}
+
+ function getInstallData() {
+ // We only care about where _any_ other install existed - no
+ // need to count more than 1.
+ const installPaths = lazy.WindowsInstallsInfo.getInstallPaths(
+ 1,
+ new Set([Services.dirsvc.get("GreBinD", Ci.nsIFile).path])
+ );
+ const msixInstalls = new Set();
+ // We're just going to eat all errors here -- we don't want the event
+ // to go unsent if we were unable to look for MSIX installs.
+ try {
+ wpm
+ .findUserInstalledPackages(msixPackagePrefixes)
+ .forEach(i => msixInstalls.add(i));
+ if (pfn) {
+ msixInstalls.delete(pfn);
+ }
+ } catch (ex) {}
+ return {
+ installPaths,
+ msixInstalls,
+ };
+ }
+
+ let extra = {};
+
+ if (pfn) {
+ if (lastInstallTime != null) {
+ // We've already seen this install
+ return;
+ }
+
+ // First time seeing this install, record the timestamp.
+ Services.prefs.setStringPref(TIMESTAMP_PREF, wpm.getInstalledDate());
+ let install_data = getInstallData();
+
+ installer_type = "msix";
+
+ // Build the extra event data
+ extra.version = AppConstants.MOZ_APP_VERSION;
+ extra.build_id = AppConstants.MOZ_BUILDID;
+ // The next few keys are static for the reasons described
+ // No way to detect whether or not we were installed by an admin
+ extra.admin_user = "false";
+ // Always false at the moment, because we create a new profile
+ // on first launch
+ extra.profdir_existed = "false";
+ // Obviously false for MSIX installs
+ extra.from_msi = "false";
+ // We have no way of knowing whether we were installed via the GUI,
+ // through the command line, or some Enterprise management tool.
+ extra.silent = "false";
+ // There's no way to change the install path for an MSIX package
+ extra.default_path = "true";
+ extra.install_existed = install_data.msixInstalls.has(pfn).toString();
+ install_data.msixInstalls.delete(pfn);
+ extra.other_inst = (!!install_data.installPaths.size).toString();
+ extra.other_msix_inst = (!!install_data.msixInstalls.size).toString();
+ } else {
+ let dataPath = dataPathOverride;
+ if (!dataPath) {
+ dataPath = Services.dirsvc.get("GreD", Ci.nsIFile);
+ dataPath.append("installation_telemetry.json");
+ }
+
+ let dataBytes;
+ try {
+ dataBytes = await IOUtils.read(dataPath.path);
+ } catch (ex) {
+ if (ex.name == "NotFoundError") {
+ // Many systems will not have the data file, return silently if not found as
+ // there is nothing to record.
+ return;
+ }
+ throw ex;
+ }
+ const dataString = new TextDecoder("utf-16").decode(dataBytes);
+ const data = JSON.parse(dataString);
+
+ if (lastInstallTime && data.install_timestamp == lastInstallTime) {
+ // We've already seen this install
+ return;
+ }
+
+ // First time seeing this install, record the timestamp.
+ Services.prefs.setStringPref(TIMESTAMP_PREF, data.install_timestamp);
+ let install_data = getInstallData();
+
+ installer_type = data.installer_type;
+
+ // Installation timestamp is not intended to be sent with telemetry,
+ // remove it to emphasize this point.
+ delete data.install_timestamp;
+
+ // Build the extra event data
+ extra.version = data.version;
+ extra.build_id = data.build_id;
+ extra.admin_user = data.admin_user.toString();
+ extra.install_existed = data.install_existed.toString();
+ extra.profdir_existed = data.profdir_existed.toString();
+ extra.other_inst = (!!install_data.installPaths.size).toString();
+ extra.other_msix_inst = (!!install_data.msixInstalls.size).toString();
+
+ if (data.installer_type == "full") {
+ extra.silent = data.silent.toString();
+ extra.from_msi = data.from_msi.toString();
+ extra.default_path = data.default_path.toString();
+ }
+ }
+ // Record the event
+ Services.telemetry.setEventRecordingEnabled("installation", true);
+ Services.telemetry.recordEvent(
+ "installation",
+ "first_seen",
+ installer_type,
+ null,
+ extra
+ );
+ Services.telemetry.recordEvent(
+ "installation",
+ "first_seen_prov_ext",
+ installer_type,
+ null,
+ provenanceExtra
+ );
+ },
+};
+
+// Used by nsIBrowserUsage
+function getUniqueDomainsVisitedInPast24Hours() {
+ return URICountListener.uniqueDomainsVisitedInPast24Hours;
+}
diff --git a/browser/modules/BrowserWindowTracker.jsm b/browser/modules/BrowserWindowTracker.jsm
new file mode 100644
index 0000000000..6872c4d55d
--- /dev/null
+++ b/browser/modules/BrowserWindowTracker.jsm
@@ -0,0 +1,321 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 module tracks each browser window and informs network module
+ * the current selected tab's content outer window ID.
+ */
+
+var EXPORTED_SYMBOLS = ["BrowserWindowTracker"];
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+const lazy = {};
+
+// Lazy getters
+ChromeUtils.defineESModuleGetters(lazy, {
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs",
+});
+
+// Constants
+const TAB_EVENTS = ["TabBrowserInserted", "TabSelect"];
+const WINDOW_EVENTS = ["activate", "unload"];
+const DEBUG = false;
+
+// Variables
+let _lastCurrentBrowserId = 0;
+let _trackedWindows = [];
+
+// Global methods
+function debug(s) {
+ if (DEBUG) {
+ dump("-*- UpdateBrowserIDHelper: " + s + "\n");
+ }
+}
+
+function _updateCurrentBrowserId(browser) {
+ if (
+ !browser.browserId ||
+ browser.browserId === _lastCurrentBrowserId ||
+ browser.ownerGlobal != _trackedWindows[0]
+ ) {
+ return;
+ }
+
+ // Guard on DEBUG here because materializing a long data URI into
+ // a JS string for concatenation is not free.
+ if (DEBUG) {
+ debug(
+ `Current window uri=${browser.currentURI?.spec} browser id=${browser.browserId}`
+ );
+ }
+
+ _lastCurrentBrowserId = browser.browserId;
+ let idWrapper = Cc["@mozilla.org/supports-PRUint64;1"].createInstance(
+ Ci.nsISupportsPRUint64
+ );
+ idWrapper.data = _lastCurrentBrowserId;
+ Services.obs.notifyObservers(idWrapper, "net:current-browser-id");
+}
+
+function _handleEvent(event) {
+ switch (event.type) {
+ case "TabBrowserInserted":
+ if (
+ event.target.ownerGlobal.gBrowser.selectedBrowser ===
+ event.target.linkedBrowser
+ ) {
+ _updateCurrentBrowserId(event.target.linkedBrowser);
+ }
+ break;
+ case "TabSelect":
+ _updateCurrentBrowserId(event.target.linkedBrowser);
+ break;
+ case "activate":
+ WindowHelper.onActivate(event.target);
+ break;
+ case "unload":
+ WindowHelper.removeWindow(event.currentTarget);
+ break;
+ }
+}
+
+function _trackWindowOrder(window) {
+ if (window.windowState == window.STATE_MINIMIZED) {
+ let firstMinimizedWindow = _trackedWindows.findIndex(
+ w => w.windowState == w.STATE_MINIMIZED
+ );
+ if (firstMinimizedWindow == -1) {
+ firstMinimizedWindow = _trackedWindows.length;
+ }
+ _trackedWindows.splice(firstMinimizedWindow, 0, window);
+ } else {
+ _trackedWindows.unshift(window);
+ }
+}
+
+function _untrackWindowOrder(window) {
+ let idx = _trackedWindows.indexOf(window);
+ if (idx >= 0) {
+ _trackedWindows.splice(idx, 1);
+ }
+}
+
+// Methods that impact a window. Put into single object for organization.
+var WindowHelper = {
+ addWindow(window) {
+ // Add event listeners
+ TAB_EVENTS.forEach(function (event) {
+ window.gBrowser.tabContainer.addEventListener(event, _handleEvent);
+ });
+ WINDOW_EVENTS.forEach(function (event) {
+ window.addEventListener(event, _handleEvent);
+ });
+
+ _trackWindowOrder(window);
+
+ // Update the selected tab's content outer window ID.
+ _updateCurrentBrowserId(window.gBrowser.selectedBrowser);
+ },
+
+ removeWindow(window) {
+ _untrackWindowOrder(window);
+
+ // Remove the event listeners
+ TAB_EVENTS.forEach(function (event) {
+ window.gBrowser.tabContainer.removeEventListener(event, _handleEvent);
+ });
+ WINDOW_EVENTS.forEach(function (event) {
+ window.removeEventListener(event, _handleEvent);
+ });
+ },
+
+ onActivate(window) {
+ // If this window was the last focused window, we don't need to do anything
+ if (window == _trackedWindows[0]) {
+ return;
+ }
+
+ _untrackWindowOrder(window);
+ _trackWindowOrder(window);
+
+ _updateCurrentBrowserId(window.gBrowser.selectedBrowser);
+ },
+};
+
+const BrowserWindowTracker = {
+ pendingWindows: new Map(),
+
+ /**
+ * Get the most recent browser window.
+ *
+ * @param options an object accepting the arguments for the search.
+ * * private: true to restrict the search to private windows
+ * only, false to restrict the search to non-private only.
+ * Omit the property to search in both groups.
+ * * allowPopups: true if popup windows are permissable.
+ */
+ getTopWindow(options = {}) {
+ for (let win of _trackedWindows) {
+ if (
+ !win.closed &&
+ (options.allowPopups || win.toolbar.visible) &&
+ (!("private" in options) ||
+ lazy.PrivateBrowsingUtils.permanentPrivateBrowsing ||
+ lazy.PrivateBrowsingUtils.isWindowPrivate(win) == options.private)
+ ) {
+ return win;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Get a window that is in the process of loading. Only supports windows
+ * opened via the `openWindow` function in this module or that have been
+ * registered with the `registerOpeningWindow` function.
+ *
+ * @param {Object} options
+ * Options for the search.
+ * @param {boolean} [options.private]
+ * true to restrict the search to private windows only, false to restrict
+ * the search to non-private only. Omit the property to search in both
+ * groups.
+ *
+ * @returns {Promise<Window> | null}
+ */
+ getPendingWindow(options = {}) {
+ for (let pending of this.pendingWindows.values()) {
+ if (
+ !("private" in options) ||
+ lazy.PrivateBrowsingUtils.permanentPrivateBrowsing ||
+ pending.isPrivate == options.private
+ ) {
+ return pending.deferred.promise;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Registers a browser window that is in the process of opening. Normally it
+ * would be preferable to use the standard method for opening the window from
+ * this module.
+ *
+ * @param {Window} window
+ * The opening window.
+ * @param {boolean} isPrivate
+ * Whether the opening window is a private browsing window.
+ */
+ registerOpeningWindow(window, isPrivate) {
+ let deferred = lazy.PromiseUtils.defer();
+
+ this.pendingWindows.set(window, {
+ isPrivate,
+ deferred,
+ });
+ },
+
+ /**
+ * A standard function for opening a new browser window.
+ *
+ * @param {Object} [options]
+ * Options for the new window.
+ * @param {boolean} [options.private]
+ * True to make the window a private browsing window.
+ * @param {String} [options.features]
+ * Additional window features to give the new window.
+ * @param {nsIArray | nsISupportsString} [options.args]
+ * Arguments to pass to the new window.
+ *
+ * @returns {Window}
+ */
+ openWindow({
+ private: isPrivate = false,
+ features = undefined,
+ args = null,
+ } = {}) {
+ let windowFeatures = "chrome,dialog=no,all";
+ if (features) {
+ windowFeatures += `,${features}`;
+ }
+ if (isPrivate) {
+ windowFeatures += ",private";
+ }
+
+ let win = Services.ww.openWindow(
+ null,
+ AppConstants.BROWSER_CHROME_URL,
+ "_blank",
+ windowFeatures,
+ args
+ );
+ this.registerOpeningWindow(win, isPrivate);
+ return win;
+ },
+
+ /**
+ * Number of currently open browser windows.
+ */
+ get windowCount() {
+ return _trackedWindows.length;
+ },
+
+ /**
+ * Array of browser windows ordered by z-index, in reverse order.
+ * This means that the top-most browser window will be the first item.
+ */
+ get orderedWindows() {
+ // Clone the windows array immediately as it may change during iteration,
+ // we'd rather have an outdated order than skip/revisit windows.
+ return [..._trackedWindows];
+ },
+
+ getAllVisibleTabs() {
+ let tabs = [];
+ for (let win of BrowserWindowTracker.orderedWindows) {
+ for (let tab of win.gBrowser.visibleTabs) {
+ // Only use tabs which are not discarded / unrestored
+ if (tab.linkedPanel) {
+ let { contentTitle, browserId } = tab.linkedBrowser;
+ tabs.push({ contentTitle, browserId });
+ }
+ }
+ }
+ return tabs;
+ },
+
+ track(window) {
+ let pending = this.pendingWindows.get(window);
+ if (pending) {
+ this.pendingWindows.delete(window);
+ // Waiting for delayed startup to complete ensures that this new window
+ // has started loading its initial urls.
+ window.delayedStartupPromise.then(() => pending.deferred.resolve(window));
+ }
+
+ return WindowHelper.addWindow(window);
+ },
+
+ getBrowserById(browserId) {
+ for (let win of BrowserWindowTracker.orderedWindows) {
+ for (let tab of win.gBrowser.visibleTabs) {
+ if (tab.linkedPanel && tab.linkedBrowser.browserId === browserId) {
+ return tab.linkedBrowser;
+ }
+ }
+ }
+ return null;
+ },
+
+ // For tests only, this function will remove this window from the list of
+ // tracked windows. Please don't forget to add it back at the end of your
+ // tests!
+ untrackForTestsOnly(window) {
+ return WindowHelper.removeWindow(window);
+ },
+};
diff --git a/browser/modules/ContentCrashHandlers.jsm b/browser/modules/ContentCrashHandlers.jsm
new file mode 100644
index 0000000000..9abd3da006
--- /dev/null
+++ b/browser/modules/ContentCrashHandlers.jsm
@@ -0,0 +1,1144 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["TabCrashHandler", "UnsubmittedCrashHandler"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ CrashSubmit: "resource://gre/modules/CrashSubmit.sys.mjs",
+ E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
+ SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
+ clearTimeout: "resource://gre/modules/Timer.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm",
+});
+
+// We don't process crash reports older than 28 days, so don't bother
+// submitting them
+const PENDING_CRASH_REPORT_DAYS = 28;
+const DAY = 24 * 60 * 60 * 1000; // milliseconds
+const DAYS_TO_SUPPRESS = 30;
+const MAX_UNSEEN_CRASHED_CHILD_IDS = 20;
+const MAX_UNSEEN_CRASHED_SUBFRAME_IDS = 10;
+
+// Time after which we will begin scanning for unsubmitted crash reports
+const CHECK_FOR_UNSUBMITTED_CRASH_REPORTS_DELAY_MS = 60 * 10000; // 10 minutes
+
+// This is SIGUSR1 and indicates a user-invoked crash
+const EXIT_CODE_CONTENT_CRASHED = 245;
+
+const TABCRASHED_ICON_URI = "chrome://browser/skin/tab-crashed.svg";
+
+const SUBFRAMECRASH_LEARNMORE_URI =
+ "https://support.mozilla.org/kb/firefox-crashes-troubleshoot-prevent-and-get-help";
+
+/**
+ * BrowserWeakMap is exactly like a WeakMap, but expects <xul:browser>
+ * objects only.
+ *
+ * Under the hood, BrowserWeakMap keys the map off of the <xul:browser>
+ * permanentKey. If, however, the browser has never gotten a permanentKey,
+ * it falls back to keying on the <xul:browser> element itself.
+ */
+class BrowserWeakMap extends WeakMap {
+ get(browser) {
+ if (browser.permanentKey) {
+ return super.get(browser.permanentKey);
+ }
+ return super.get(browser);
+ }
+
+ set(browser, value) {
+ if (browser.permanentKey) {
+ return super.set(browser.permanentKey, value);
+ }
+ return super.set(browser, value);
+ }
+
+ delete(browser) {
+ if (browser.permanentKey) {
+ return super.delete(browser.permanentKey);
+ }
+ return super.delete(browser);
+ }
+}
+
+var TabCrashHandler = {
+ _crashedTabCount: 0,
+ childMap: new Map(),
+ browserMap: new BrowserWeakMap(),
+ notificationsMap: new Map(),
+ unseenCrashedChildIDs: [],
+ pendingSubFrameCrashes: new Map(),
+ pendingSubFrameCrashesIDs: [],
+ crashedBrowserQueues: new Map(),
+ restartRequiredBrowsers: new WeakSet(),
+ testBuildIDMismatch: false,
+
+ get prefs() {
+ delete this.prefs;
+ return (this.prefs = Services.prefs.getBranch(
+ "browser.tabs.crashReporting."
+ ));
+ },
+
+ init() {
+ if (this.initialized) {
+ return;
+ }
+ this.initialized = true;
+
+ Services.obs.addObserver(this, "ipc:content-shutdown");
+ Services.obs.addObserver(this, "oop-frameloader-crashed");
+ },
+
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "ipc:content-shutdown": {
+ aSubject.QueryInterface(Ci.nsIPropertyBag2);
+
+ if (!aSubject.get("abnormal")) {
+ return;
+ }
+
+ let childID = aSubject.get("childID");
+ let dumpID = aSubject.get("dumpID");
+
+ // Get and remove the subframe crash info first.
+ let subframeCrashItem = this.getAndRemoveSubframeCrash(childID);
+
+ if (!dumpID) {
+ Services.telemetry
+ .getHistogramById("FX_CONTENT_CRASH_DUMP_UNAVAILABLE")
+ .add(1);
+ } else if (AppConstants.MOZ_CRASHREPORTER) {
+ this.childMap.set(childID, dumpID);
+
+ // If this is a subframe crash, show the crash notification. Only
+ // show subframe notifications when there is a minidump available.
+ if (subframeCrashItem) {
+ let browsers =
+ ChromeUtils.nondeterministicGetWeakMapKeys(subframeCrashItem) ||
+ [];
+ for (let browserItem of browsers) {
+ let browser = subframeCrashItem.get(browserItem);
+ if (browser.isConnected && !browser.ownerGlobal.closed) {
+ this.showSubFrameNotification(browser, childID, dumpID);
+ }
+ }
+ }
+ }
+
+ if (!this.flushCrashedBrowserQueue(childID)) {
+ this.unseenCrashedChildIDs.push(childID);
+ // The elements in unseenCrashedChildIDs will only be removed if
+ // the tab crash page is shown. However, ipc:content-shutdown might
+ // be fired for processes for which we'll never show the tab crash
+ // page - for example, the thumbnailing process. Another case to
+ // consider is if the user is configured to submit backlogged crash
+ // reports automatically, and a background tab crashes. In that case,
+ // we will never show the tab crash page, and never remove the element
+ // from the list.
+ //
+ // Instead of trying to account for all of those cases, we prevent
+ // this list from getting too large by putting a reasonable upper
+ // limit on how many childIDs we track. It's unlikely that this
+ // array would ever get so large as to be unwieldy (that'd be a lot
+ // or crashes!), but a leak is a leak.
+ if (
+ this.unseenCrashedChildIDs.length > MAX_UNSEEN_CRASHED_CHILD_IDS
+ ) {
+ this.unseenCrashedChildIDs.shift();
+ }
+ }
+
+ // check for environment affecting crash reporting
+ let shutdown = Services.env.exists("MOZ_CRASHREPORTER_SHUTDOWN");
+
+ if (shutdown) {
+ dump(
+ "A content process crashed and MOZ_CRASHREPORTER_SHUTDOWN is " +
+ "set, shutting down\n"
+ );
+ Services.startup.quit(
+ Ci.nsIAppStartup.eForceQuit,
+ EXIT_CODE_CONTENT_CRASHED
+ );
+ }
+
+ break;
+ }
+ case "oop-frameloader-crashed": {
+ let browser = aSubject.ownerElement;
+ if (!browser) {
+ return;
+ }
+
+ this.browserMap.set(browser, aSubject.childID);
+ break;
+ }
+ }
+ },
+
+ /**
+ * This should be called once a content process has finished
+ * shutting down abnormally. Any tabbrowser browsers that were
+ * selected at the time of the crash will then be sent to
+ * the crashed tab page.
+ *
+ * @param childID (int)
+ * The childID of the content process that just crashed.
+ * @returns boolean
+ * True if one or more browsers were sent to the tab crashed
+ * page.
+ */
+ flushCrashedBrowserQueue(childID) {
+ let browserQueue = this.crashedBrowserQueues.get(childID);
+ if (!browserQueue) {
+ return false;
+ }
+
+ this.crashedBrowserQueues.delete(childID);
+
+ let sentBrowser = false;
+ for (let weakBrowser of browserQueue) {
+ let browser = weakBrowser.get();
+ if (browser) {
+ if (
+ this.restartRequiredBrowsers.has(browser) ||
+ this.testBuildIDMismatch
+ ) {
+ this.sendToRestartRequiredPage(browser);
+ } else {
+ this.sendToTabCrashedPage(browser);
+ }
+ sentBrowser = true;
+ }
+ }
+
+ return sentBrowser;
+ },
+
+ /**
+ * Called by a tabbrowser when it notices that its selected browser
+ * has crashed. This will queue the browser to show the tab crash
+ * page once the content process has finished tearing down.
+ *
+ * @param browser (<xul:browser>)
+ * The selected browser that just crashed.
+ * @param restartRequired (bool)
+ * Whether or not a browser restart is required to recover.
+ */
+ onSelectedBrowserCrash(browser, restartRequired) {
+ if (!browser.isRemoteBrowser) {
+ console.error("Selected crashed browser is not remote.");
+ return;
+ }
+ if (!browser.frameLoader) {
+ console.error("Selected crashed browser has no frameloader.");
+ return;
+ }
+
+ let childID = browser.frameLoader.childID;
+
+ let browserQueue = this.crashedBrowserQueues.get(childID);
+ if (!browserQueue) {
+ browserQueue = [];
+ this.crashedBrowserQueues.set(childID, browserQueue);
+ }
+ // It's probably unnecessary to store this browser as a
+ // weak reference, since the content process should complete
+ // its teardown in the same tick of the event loop, and then
+ // this queue will be flushed. The weak reference is to avoid
+ // leaking browsers in case anything goes wrong during this
+ // teardown process.
+ browserQueue.push(Cu.getWeakReference(browser));
+
+ if (restartRequired) {
+ this.restartRequiredBrowsers.add(browser);
+ }
+
+ // In the event that the content process failed to launch, then
+ // the childID will be 0. In that case, we will never receive
+ // a dumpID nor an ipc:content-shutdown observer notification,
+ // so we should flush the queue for childID 0 immediately.
+ if (childID == 0) {
+ this.flushCrashedBrowserQueue(0);
+ }
+ },
+
+ /**
+ * Called by a tabbrowser when it notices that a background browser
+ * has crashed. This will flip its remoteness to non-remote, and attempt
+ * to revive the crashed tab so that upon selection the tab either shows
+ * an error page, or automatically restores.
+ *
+ * @param browser (<xul:browser>)
+ * The background browser that just crashed.
+ * @param restartRequired (bool)
+ * Whether or not a browser restart is required to recover.
+ */
+ onBackgroundBrowserCrash(browser, restartRequired) {
+ if (restartRequired) {
+ this.restartRequiredBrowsers.add(browser);
+ }
+
+ let gBrowser = browser.getTabBrowser();
+ let tab = gBrowser.getTabForBrowser(browser);
+
+ gBrowser.updateBrowserRemoteness(browser, {
+ remoteType: lazy.E10SUtils.NOT_REMOTE,
+ });
+
+ lazy.SessionStore.reviveCrashedTab(tab);
+ },
+
+ /**
+ * Called when a subframe crashes. If the dump is available, shows a subframe
+ * crashed notification, otherwise waits for one to be available.
+ *
+ * @param browser (<xul:browser>)
+ * The browser containing the frame that just crashed.
+ * @param childId
+ * The id of the process that just crashed.
+ */
+ async onSubFrameCrash(browser, childID) {
+ if (!AppConstants.MOZ_CRASHREPORTER) {
+ return;
+ }
+
+ // If a crash dump is available, use it. Otherwise, add the child id to the pending
+ // subframe crashes list, and wait for the crash "ipc:content-shutdown" notification
+ // to get the minidump. If it never arrives, don't show the notification.
+ let dumpID = this.childMap.get(childID);
+ if (dumpID) {
+ this.showSubFrameNotification(browser, childID, dumpID);
+ } else {
+ let item = this.pendingSubFrameCrashes.get(childID);
+ if (!item) {
+ item = new BrowserWeakMap();
+ this.pendingSubFrameCrashes.set(childID, item);
+
+ // Add the childID to an array that only has room for MAX_UNSEEN_CRASHED_SUBFRAME_IDS
+ // items. If there is no more room, pop the oldest off and remove it. This technique
+ // is used instead of a timeout.
+ if (
+ this.pendingSubFrameCrashesIDs.length >=
+ MAX_UNSEEN_CRASHED_SUBFRAME_IDS
+ ) {
+ let idToDelete = this.pendingSubFrameCrashesIDs.shift();
+ this.pendingSubFrameCrashes.delete(idToDelete);
+ }
+ this.pendingSubFrameCrashesIDs.push(childID);
+ }
+ item.set(browser, browser);
+ }
+ },
+
+ /**
+ * Given a childID, retrieve the subframe crash info for it
+ * from the pendingSubFrameCrashes map. The data is removed
+ * from the map and returned.
+ *
+ * @param childID number
+ * childID of the content that crashed.
+ * @returns subframe crash info added by previous call to onSubFrameCrash.
+ */
+ getAndRemoveSubframeCrash(childID) {
+ let item = this.pendingSubFrameCrashes.get(childID);
+ if (item) {
+ this.pendingSubFrameCrashes.delete(childID);
+ let idx = this.pendingSubFrameCrashesIDs.indexOf(childID);
+ if (idx >= 0) {
+ this.pendingSubFrameCrashesIDs.splice(idx, 1);
+ }
+ }
+
+ return item;
+ },
+
+ /**
+ * Called to indicate that a subframe within a browser has crashed. A notification
+ * bar will be shown.
+ *
+ * @param browser (<xul:browser>)
+ * The browser containing the frame that just crashed.
+ * @param childId
+ * The id of the process that just crashed.
+ * @param dumpID
+ * Minidump id of the crash.
+ */
+ showSubFrameNotification(browser, childID, dumpID) {
+ let gBrowser = browser.getTabBrowser();
+ let notificationBox = gBrowser.getNotificationBox(browser);
+
+ const value = "subframe-crashed";
+ let notification = notificationBox.getNotificationWithValue(value);
+ if (notification) {
+ // Don't show multiple notifications for a browser.
+ return;
+ }
+
+ let closeAllNotifications = () => {
+ // Close all other notifications on other tabs that might
+ // be open for the same crashed process.
+ let existingItem = this.notificationsMap.get(childID);
+ if (existingItem) {
+ for (let notif of existingItem.slice()) {
+ notif.close();
+ }
+ }
+ };
+
+ gBrowser.ownerGlobal.MozXULElement.insertFTLIfNeeded(
+ "browser/contentCrash.ftl"
+ );
+
+ let buttons = [
+ {
+ "l10n-id": "crashed-subframe-learnmore-link",
+ popup: null,
+ link: SUBFRAMECRASH_LEARNMORE_URI,
+ },
+ {
+ "l10n-id": "crashed-subframe-submit",
+ popup: null,
+ callback: async () => {
+ if (dumpID) {
+ UnsubmittedCrashHandler.submitReports(
+ [dumpID],
+ lazy.CrashSubmit.SUBMITTED_FROM_CRASH_TAB
+ );
+ }
+ closeAllNotifications();
+ },
+ },
+ ];
+
+ notification = notificationBox.appendNotification(
+ value,
+ {
+ label: { "l10n-id": "crashed-subframe-message" },
+ image: TABCRASHED_ICON_URI,
+ priority: notificationBox.PRIORITY_INFO_MEDIUM,
+ eventCallback: eventName => {
+ if (eventName == "disconnected") {
+ let existingItem = this.notificationsMap.get(childID);
+ if (existingItem) {
+ let idx = existingItem.indexOf(notification);
+ if (idx >= 0) {
+ existingItem.splice(idx, 1);
+ }
+
+ if (!existingItem.length) {
+ this.notificationsMap.delete(childID);
+ }
+ }
+ } else if (eventName == "dismissed") {
+ if (dumpID) {
+ lazy.CrashSubmit.ignore(dumpID);
+ this.childMap.delete(childID);
+ }
+
+ closeAllNotifications();
+ }
+ },
+ },
+ buttons
+ );
+
+ let existingItem = this.notificationsMap.get(childID);
+ if (existingItem) {
+ existingItem.push(notification);
+ } else {
+ this.notificationsMap.set(childID, [notification]);
+ }
+ },
+
+ /**
+ * This method is exposed for SessionStore to call if the user selects
+ * a tab which will restore on demand. It's possible that the tab
+ * is in this state because it recently crashed. If that's the case, then
+ * it's also possible that the user has not seen the tab crash page for
+ * that particular crash, in which case, we might show it to them instead
+ * of restoring the tab.
+ *
+ * @param browser (<xul:browser>)
+ * A browser from a browser tab that the user has just selected
+ * to restore on demand.
+ * @returns (boolean)
+ * True if TabCrashHandler will send the user to the tab crash
+ * page instead.
+ */
+ willShowCrashedTab(browser) {
+ let childID = this.browserMap.get(browser);
+ // We will only show the tab crash page if:
+ // 1) We are aware that this browser crashed
+ // 2) We know we've never shown the tab crash page for the
+ // crash yet
+ // 3) The user is not configured to automatically submit backlogged
+ // crash reports. If they are, we'll send the crash report
+ // immediately.
+ if (childID && this.unseenCrashedChildIDs.includes(childID)) {
+ if (UnsubmittedCrashHandler.autoSubmit) {
+ let dumpID = this.childMap.get(childID);
+ if (dumpID) {
+ UnsubmittedCrashHandler.submitReports(
+ [dumpID],
+ lazy.CrashSubmit.SUBMITTED_FROM_AUTO
+ );
+ }
+ } else {
+ this.sendToTabCrashedPage(browser);
+ return true;
+ }
+ } else if (childID === 0) {
+ if (this.restartRequiredBrowsers.has(browser)) {
+ this.sendToRestartRequiredPage(browser);
+ } else {
+ this.sendToTabCrashedPage(browser);
+ }
+ return true;
+ }
+
+ return false;
+ },
+
+ sendToRestartRequiredPage(browser) {
+ let uri = browser.currentURI;
+ let gBrowser = browser.getTabBrowser();
+ let tab = gBrowser.getTabForBrowser(browser);
+ // The restart required page is non-remote by default.
+ gBrowser.updateBrowserRemoteness(browser, {
+ remoteType: lazy.E10SUtils.NOT_REMOTE,
+ });
+
+ browser.docShell.displayLoadError(Cr.NS_ERROR_BUILDID_MISMATCH, uri, null);
+ tab.setAttribute("crashed", true);
+ gBrowser.tabContainer.updateTabIndicatorAttr(tab);
+
+ // Make sure to only count once even if there are multiple windows
+ // that will all show about:restartrequired.
+ if (this._crashedTabCount == 1) {
+ Services.telemetry.scalarAdd("dom.contentprocess.buildID_mismatch", 1);
+ }
+ },
+
+ /**
+ * We show a special page to users when a normal browser tab has crashed.
+ * This method should be called to send a browser to that page once the
+ * process has completely closed.
+ *
+ * @param browser (<xul:browser>)
+ * The browser that has recently crashed.
+ */
+ sendToTabCrashedPage(browser) {
+ let title = browser.contentTitle;
+ let uri = browser.currentURI;
+ let gBrowser = browser.getTabBrowser();
+ let tab = gBrowser.getTabForBrowser(browser);
+ // The tab crashed page is non-remote by default.
+ gBrowser.updateBrowserRemoteness(browser, {
+ remoteType: lazy.E10SUtils.NOT_REMOTE,
+ });
+
+ browser.setAttribute("crashedPageTitle", title);
+ browser.docShell.displayLoadError(Cr.NS_ERROR_CONTENT_CRASHED, uri, null);
+ browser.removeAttribute("crashedPageTitle");
+ tab.setAttribute("crashed", true);
+ gBrowser.tabContainer.updateTabIndicatorAttr(tab);
+ },
+
+ /**
+ * Submits a crash report from about:tabcrashed, if the crash
+ * reporter is enabled and a crash report can be found.
+ *
+ * @param browser
+ * The <xul:browser> that the report was sent from.
+ * @param message
+ * Message data with the following properties:
+ *
+ * includeURL (bool):
+ * Whether to include the URL that the user was on
+ * in the crashed tab before the crash occurred.
+ * URL (String)
+ * The URL that the user was on in the crashed tab
+ * before the crash occurred.
+ * comments (String):
+ * Any additional comments from the user.
+ *
+ * Note that it is expected that all properties are set,
+ * even if they are empty.
+ */
+ maybeSendCrashReport(browser, message) {
+ if (!AppConstants.MOZ_CRASHREPORTER) {
+ return;
+ }
+
+ if (!message.data.hasReport) {
+ // There was no report, so nothing to do.
+ return;
+ }
+
+ if (message.data.autoSubmit) {
+ // The user has opted in to autosubmitted backlogged
+ // crash reports in the future.
+ UnsubmittedCrashHandler.autoSubmit = true;
+ }
+
+ let childID = this.browserMap.get(browser);
+ let dumpID = this.childMap.get(childID);
+ if (!dumpID) {
+ return;
+ }
+
+ if (!message.data.sendReport) {
+ Services.telemetry
+ .getHistogramById("FX_CONTENT_CRASH_NOT_SUBMITTED")
+ .add(1);
+ this.prefs.setBoolPref("sendReport", false);
+ return;
+ }
+
+ let { includeURL, comments, URL } = message.data;
+
+ let extraExtraKeyVals = {
+ Comments: comments,
+ URL,
+ };
+
+ // For the entries in extraExtraKeyVals, we only want to submit the
+ // extra data values where they are not the empty string.
+ for (let key in extraExtraKeyVals) {
+ let val = extraExtraKeyVals[key].trim();
+ if (!val) {
+ delete extraExtraKeyVals[key];
+ }
+ }
+
+ // URL is special, since it's already been written to extra data by
+ // default. In order to make sure we don't send it, we overwrite it
+ // with the empty string.
+ if (!includeURL) {
+ extraExtraKeyVals.URL = "";
+ }
+
+ lazy.CrashSubmit.submit(dumpID, lazy.CrashSubmit.SUBMITTED_FROM_CRASH_TAB, {
+ recordSubmission: true,
+ extraExtraKeyVals,
+ }).catch(console.error);
+
+ this.prefs.setBoolPref("sendReport", true);
+ this.prefs.setBoolPref("includeURL", includeURL);
+
+ this.childMap.set(childID, null); // Avoid resubmission.
+ this.removeSubmitCheckboxesForSameCrash(childID);
+ },
+
+ removeSubmitCheckboxesForSameCrash(childID) {
+ for (let window of Services.wm.getEnumerator("navigator:browser")) {
+ if (!window.gMultiProcessBrowser) {
+ continue;
+ }
+
+ for (let browser of window.gBrowser.browsers) {
+ if (browser.isRemoteBrowser) {
+ continue;
+ }
+
+ let doc = browser.contentDocument;
+ if (!doc.documentURI.startsWith("about:tabcrashed")) {
+ continue;
+ }
+
+ if (this.browserMap.get(browser) == childID) {
+ this.browserMap.delete(browser);
+ browser.sendMessageToActor("CrashReportSent", {}, "AboutTabCrashed");
+ }
+ }
+ }
+ },
+
+ /**
+ * Process a crashed tab loaded into a browser.
+ *
+ * @param browser
+ * The <xul:browser> containing the page that crashed.
+ * @returns crash data
+ * Message data containing information about the crash.
+ */
+ onAboutTabCrashedLoad(browser) {
+ this._crashedTabCount++;
+
+ let window = browser.ownerGlobal;
+
+ // Reset the zoom for the tabcrashed page.
+ window.ZoomManager.setZoomForBrowser(browser, 1);
+
+ let childID = this.browserMap.get(browser);
+ let index = this.unseenCrashedChildIDs.indexOf(childID);
+ if (index != -1) {
+ this.unseenCrashedChildIDs.splice(index, 1);
+ }
+
+ let dumpID = this.getDumpID(browser);
+ if (!dumpID) {
+ return {
+ hasReport: false,
+ };
+ }
+
+ let requestAutoSubmit = !UnsubmittedCrashHandler.autoSubmit;
+ let sendReport = this.prefs.getBoolPref("sendReport");
+ let includeURL = this.prefs.getBoolPref("includeURL");
+
+ let data = {
+ hasReport: true,
+ sendReport,
+ includeURL,
+ requestAutoSubmit,
+ };
+
+ return data;
+ },
+
+ onAboutTabCrashedUnload(browser) {
+ if (!this._crashedTabCount) {
+ console.error("Can not decrement crashed tab count to below 0");
+ return;
+ }
+ this._crashedTabCount--;
+
+ let childID = this.browserMap.get(browser);
+
+ // Make sure to only count once even if there are multiple windows
+ // that will all show about:tabcrashed.
+ if (this._crashedTabCount == 0 && childID) {
+ Services.telemetry
+ .getHistogramById("FX_CONTENT_CRASH_NOT_SUBMITTED")
+ .add(1);
+ }
+ },
+
+ /**
+ * For some <xul:browser>, return a crash report dump ID for that browser
+ * if we have been informed of one. Otherwise, return null.
+ *
+ * @param browser (<xul:browser)
+ * The browser to try to get the dump ID for
+ * @returns dumpID (String)
+ */
+ getDumpID(browser) {
+ if (!AppConstants.MOZ_CRASHREPORTER) {
+ return null;
+ }
+
+ return this.childMap.get(this.browserMap.get(browser));
+ },
+
+ /**
+ * This is intended for TESTING ONLY. It returns the amount of
+ * content processes that have crashed such that we're still waiting
+ * for dump IDs for their crash reports.
+ *
+ * For our automated tests, accessing the crashed content process
+ * count helps us test the behaviour when content processes crash due
+ * to launch failure, since in those cases we should not increase the
+ * crashed browser queue (since we never receive dump IDs for launch
+ * failures).
+ */
+ get queuedCrashedBrowsers() {
+ return this.crashedBrowserQueues.size;
+ },
+};
+
+/**
+ * This component is responsible for scanning the pending
+ * crash report directory for reports, and (if enabled), to
+ * prompt the user to submit those reports. It might also
+ * submit those reports automatically without prompting if
+ * the user has opted in.
+ */
+var UnsubmittedCrashHandler = {
+ get prefs() {
+ delete this.prefs;
+ return (this.prefs = Services.prefs.getBranch(
+ "browser.crashReports.unsubmittedCheck."
+ ));
+ },
+
+ get enabled() {
+ return this.prefs.getBoolPref("enabled");
+ },
+
+ // showingNotification is set to true once a notification
+ // is successfully shown, and then set back to false if
+ // the notification is dismissed by an action by the user.
+ showingNotification: false,
+ // suppressed is true if we've determined that we've shown
+ // the notification too many times across too many days without
+ // user interaction, so we're suppressing the notification for
+ // some number of days. See the documentation for
+ // shouldShowPendingSubmissionsNotification().
+ suppressed: false,
+
+ _checkTimeout: null,
+
+ init() {
+ if (this.initialized) {
+ return;
+ }
+
+ this.initialized = true;
+
+ // UnsubmittedCrashHandler can be initialized but still be disabled.
+ // This is intentional, as this makes simulating UnsubmittedCrashHandler's
+ // reactions to browser startup and shutdown easier in test automation.
+ //
+ // UnsubmittedCrashHandler, when initialized but not enabled, is inert.
+ if (this.enabled) {
+ if (this.prefs.prefHasUserValue("suppressUntilDate")) {
+ if (this.prefs.getCharPref("suppressUntilDate") > this.dateString()) {
+ // We'll be suppressing any notifications until after suppressedDate,
+ // so there's no need to do anything more.
+ this.suppressed = true;
+ return;
+ }
+
+ // We're done suppressing, so we don't need this pref anymore.
+ this.prefs.clearUserPref("suppressUntilDate");
+ }
+
+ Services.obs.addObserver(this, "profile-before-change");
+ }
+ },
+
+ uninit() {
+ if (!this.initialized) {
+ return;
+ }
+
+ this.initialized = false;
+
+ if (this._checkTimeout) {
+ lazy.clearTimeout(this._checkTimeout);
+ this._checkTimeout = null;
+ }
+
+ if (!this.enabled) {
+ return;
+ }
+
+ if (this.suppressed) {
+ this.suppressed = false;
+ // No need to do any more clean-up, since we were suppressed.
+ return;
+ }
+
+ if (this.showingNotification) {
+ this.prefs.setBoolPref("shutdownWhileShowing", true);
+ this.showingNotification = false;
+ }
+
+ Services.obs.removeObserver(this, "profile-before-change");
+ },
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "profile-before-change": {
+ this.uninit();
+ break;
+ }
+ }
+ },
+
+ scheduleCheckForUnsubmittedCrashReports() {
+ this._checkTimeout = lazy.setTimeout(() => {
+ Services.tm.idleDispatchToMainThread(() => {
+ this.checkForUnsubmittedCrashReports();
+ });
+ }, CHECK_FOR_UNSUBMITTED_CRASH_REPORTS_DELAY_MS);
+ },
+
+ /**
+ * Scans the profile directory for unsubmitted crash reports
+ * within the past PENDING_CRASH_REPORT_DAYS days. If it
+ * finds any, it will, if necessary, attempt to open a notification
+ * bar to prompt the user to submit them.
+ *
+ * @returns Promise
+ * Resolves with the <xul:notification> after it tries to
+ * show a notification on the most recent browser window.
+ * If a notification cannot be shown, will resolve with null.
+ */
+ async checkForUnsubmittedCrashReports() {
+ if (!this.enabled || this.suppressed) {
+ return null;
+ }
+
+ let dateLimit = new Date();
+ dateLimit.setDate(dateLimit.getDate() - PENDING_CRASH_REPORT_DAYS);
+
+ let reportIDs = [];
+ try {
+ reportIDs = await lazy.CrashSubmit.pendingIDs(dateLimit);
+ } catch (e) {
+ console.error(e);
+ return null;
+ }
+
+ if (reportIDs.length) {
+ if (this.autoSubmit) {
+ this.submitReports(reportIDs, lazy.CrashSubmit.SUBMITTED_FROM_AUTO);
+ } else if (this.shouldShowPendingSubmissionsNotification()) {
+ return this.showPendingSubmissionsNotification(reportIDs);
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Returns true if the notification should be shown.
+ * shouldShowPendingSubmissionsNotification makes this decision
+ * by looking at whether or not the user has seen the notification
+ * over several days without ever interacting with it. If this occurs
+ * too many times, we suppress the notification for DAYS_TO_SUPPRESS
+ * days.
+ *
+ * @returns bool
+ */
+ shouldShowPendingSubmissionsNotification() {
+ if (!this.prefs.prefHasUserValue("shutdownWhileShowing")) {
+ return true;
+ }
+
+ let shutdownWhileShowing = this.prefs.getBoolPref("shutdownWhileShowing");
+ this.prefs.clearUserPref("shutdownWhileShowing");
+
+ if (!this.prefs.prefHasUserValue("lastShownDate")) {
+ // This isn't expected, but we're being defensive here. We'll
+ // opt for showing the notification in this case.
+ return true;
+ }
+
+ let lastShownDate = this.prefs.getCharPref("lastShownDate");
+ if (this.dateString() > lastShownDate && shutdownWhileShowing) {
+ // We're on a newer day then when we last showed the
+ // notification without closing it. We don't want to do
+ // this too many times, so we'll decrement a counter for
+ // this situation. Too many of these, and we'll assume the
+ // user doesn't know or care about unsubmitted notifications,
+ // and we'll suppress the notification for a while.
+ let chances = this.prefs.getIntPref("chancesUntilSuppress");
+ if (--chances < 0) {
+ // We're out of chances!
+ this.prefs.clearUserPref("chancesUntilSuppress");
+ // We'll suppress for DAYS_TO_SUPPRESS days.
+ let suppressUntil = this.dateString(
+ new Date(Date.now() + DAY * DAYS_TO_SUPPRESS)
+ );
+ this.prefs.setCharPref("suppressUntilDate", suppressUntil);
+ return false;
+ }
+ this.prefs.setIntPref("chancesUntilSuppress", chances);
+ }
+
+ return true;
+ },
+
+ /**
+ * Given an array of unsubmitted crash report IDs, try to open
+ * up a notification asking the user to submit them.
+ *
+ * @param reportIDs (Array<string>)
+ * The Array of report IDs to offer the user to send.
+ * @returns The <xul:notification> if one is shown. null otherwise.
+ */
+ showPendingSubmissionsNotification(reportIDs) {
+ if (!reportIDs.length) {
+ return null;
+ }
+
+ let notification = this.show({
+ notificationID: "pending-crash-reports",
+ reportIDs,
+ onAction: () => {
+ this.showingNotification = false;
+ },
+ });
+
+ if (notification) {
+ this.showingNotification = true;
+ this.prefs.setCharPref("lastShownDate", this.dateString());
+ }
+
+ return notification;
+ },
+
+ /**
+ * Returns a string representation of a Date in the format
+ * YYYYMMDD.
+ *
+ * @param someDate (Date, optional)
+ * The Date to convert to the string. If not provided,
+ * defaults to today's date.
+ * @returns String
+ */
+ dateString(someDate = new Date()) {
+ let year = String(someDate.getFullYear()).padStart(4, "0");
+ let month = String(someDate.getMonth() + 1).padStart(2, "0");
+ let day = String(someDate.getDate()).padStart(2, "0");
+ return year + month + day;
+ },
+
+ /**
+ * Attempts to show a notification bar to the user in the most
+ * recent browser window asking them to submit some crash report
+ * IDs. If a notification cannot be shown (for example, there
+ * is no browser window), this method exits silently.
+ *
+ * The notification will allow the user to submit their crash
+ * reports. If the user dismissed the notification, the crash
+ * reports will be marked to be ignored (though they can
+ * still be manually submitted via about:crashes).
+ *
+ * @param JS Object
+ * An Object with the following properties:
+ *
+ * notificationID (string)
+ * The ID for the notification to be opened.
+ *
+ * reportIDs (Array<string>)
+ * The array of report IDs to offer to the user.
+ *
+ * onAction (function, optional)
+ * A callback to fire once the user performs an
+ * action on the notification bar (this includes
+ * dismissing the notification).
+ *
+ * @returns The <xul:notification> if one is shown. null otherwise.
+ */
+ show({ notificationID, reportIDs, onAction }) {
+ let chromeWin = lazy.BrowserWindowTracker.getTopWindow();
+ if (!chromeWin) {
+ // Can't show a notification in this case. We'll hopefully
+ // get another opportunity to have the user submit their
+ // crash reports later.
+ return null;
+ }
+
+ let notification =
+ chromeWin.gNotificationBox.getNotificationWithValue(notificationID);
+ if (notification) {
+ return null;
+ }
+
+ chromeWin.MozXULElement.insertFTLIfNeeded("browser/contentCrash.ftl");
+
+ let buttons = [
+ {
+ "l10n-id": "pending-crash-reports-send",
+ callback: () => {
+ this.submitReports(
+ reportIDs,
+ lazy.CrashSubmit.SUBMITTED_FROM_INFOBAR
+ );
+ if (onAction) {
+ onAction();
+ }
+ },
+ },
+ {
+ "l10n-id": "pending-crash-reports-always-send",
+ callback: () => {
+ this.autoSubmit = true;
+ this.submitReports(
+ reportIDs,
+ lazy.CrashSubmit.SUBMITTED_FROM_INFOBAR
+ );
+ if (onAction) {
+ onAction();
+ }
+ },
+ },
+ {
+ "l10n-id": "pending-crash-reports-view-all",
+ callback() {
+ chromeWin.openTrustedLinkIn("about:crashes", "tab");
+ return true;
+ },
+ },
+ ];
+
+ let eventCallback = eventType => {
+ if (eventType == "dismissed") {
+ // The user intentionally dismissed the notification,
+ // which we interpret as meaning that they don't care
+ // to submit the reports. We'll ignore these particular
+ // reports going forward.
+ reportIDs.forEach(function (reportID) {
+ lazy.CrashSubmit.ignore(reportID);
+ });
+ if (onAction) {
+ onAction();
+ }
+ }
+ };
+
+ return chromeWin.gNotificationBox.appendNotification(
+ notificationID,
+ {
+ label: {
+ "l10n-id": "pending-crash-reports-message",
+ "l10n-args": { reportCount: reportIDs.length },
+ },
+ image: TABCRASHED_ICON_URI,
+ priority: chromeWin.gNotificationBox.PRIORITY_INFO_HIGH,
+ eventCallback,
+ },
+ buttons
+ );
+ },
+
+ get autoSubmit() {
+ return Services.prefs.getBoolPref(
+ "browser.crashReports.unsubmittedCheck.autoSubmit2"
+ );
+ },
+
+ set autoSubmit(val) {
+ Services.prefs.setBoolPref(
+ "browser.crashReports.unsubmittedCheck.autoSubmit2",
+ val
+ );
+ },
+
+ /**
+ * Attempt to submit reports to the crash report server.
+ *
+ * @param reportIDs (Array<string>)
+ * The array of reportIDs to submit.
+ * @param submittedFrom (string)
+ * One of the CrashSubmit.SUBMITTED_FROM_* constants representing
+ * how this crash was submitted.
+ */
+ submitReports(reportIDs, submittedFrom) {
+ for (let reportID of reportIDs) {
+ lazy.CrashSubmit.submit(reportID, submittedFrom).catch(console.error);
+ }
+ },
+};
diff --git a/browser/modules/Discovery.jsm b/browser/modules/Discovery.jsm
new file mode 100644
index 0000000000..4e858aeb26
--- /dev/null
+++ b/browser/modules/Discovery.jsm
@@ -0,0 +1,156 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+var EXPORTED_SYMBOLS = ["Discovery"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ClientID: "resource://gre/modules/ClientID.sys.mjs",
+ ContextualIdentityService:
+ "resource://gre/modules/ContextualIdentityService.sys.mjs",
+});
+
+const RECOMMENDATION_ENABLED = "browser.discovery.enabled";
+const TELEMETRY_ENABLED = "datareporting.healthreport.uploadEnabled";
+const TAAR_COOKIE_NAME = "taarId";
+
+const Discovery = {
+ set enabled(val) {
+ val = !!val;
+ if (val && !lazy.gTelemetryEnabled) {
+ throw Error("unable to turn on recommendations");
+ }
+ Services.prefs.setBoolPref(RECOMMENDATION_ENABLED, val);
+ },
+
+ get enabled() {
+ return lazy.gTelemetryEnabled && lazy.gRecommendationEnabled;
+ },
+
+ reset() {
+ return DiscoveryInternal.update(true);
+ },
+
+ update() {
+ return DiscoveryInternal.update();
+ },
+};
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "gRecommendationEnabled",
+ RECOMMENDATION_ENABLED,
+ false,
+ Discovery.update
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "gTelemetryEnabled",
+ TELEMETRY_ENABLED,
+ false,
+ Discovery.update
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "gCachedClientID",
+ "toolkit.telemetry.cachedClientID",
+ "",
+ Discovery.reset
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "gContainersEnabled",
+ "browser.discovery.containers.enabled",
+ false,
+ Discovery.reset
+);
+
+Services.obs.addObserver(Discovery.update, "contextual-identity-created");
+
+const DiscoveryInternal = {
+ get sites() {
+ delete this.sites;
+ this.sites = Services.prefs
+ .getCharPref("browser.discovery.sites", "")
+ .split(",");
+ return this.sites;
+ },
+
+ getContextualIDs() {
+ // There is never a zero id, this is just for use in update.
+ let IDs = [0];
+ if (lazy.gContainersEnabled) {
+ lazy.ContextualIdentityService.getPublicIdentities().forEach(identity => {
+ IDs.push(identity.userContextId);
+ });
+ }
+ return IDs;
+ },
+
+ async update(reset = false) {
+ if (reset || !Discovery.enabled) {
+ for (let site of this.sites) {
+ Services.cookies.remove(site, TAAR_COOKIE_NAME, "/", {});
+ lazy.ContextualIdentityService.getPublicIdentities().forEach(
+ identity => {
+ let { userContextId } = identity;
+ Services.cookies.remove(site, TAAR_COOKIE_NAME, "/", {
+ userContextId,
+ });
+ }
+ );
+ }
+ }
+
+ if (Discovery.enabled) {
+ // If the client id is not cached, wait for the notification that it is
+ // cached. This will happen shortly after startup in TelemetryController.sys.mjs.
+ // When that happens, we'll get a pref notification for the cached id,
+ // which will call update again.
+ if (!lazy.gCachedClientID) {
+ return;
+ }
+ let id = await lazy.ClientID.getClientIdHash();
+ for (let site of this.sites) {
+ // This cookie gets tied down as much as possible. Specifically,
+ // SameSite, Secure, HttpOnly and non-PrivateBrowsing.
+ for (let userContextId of this.getContextualIDs()) {
+ let originAttributes = { privateBrowsingId: 0 };
+ if (userContextId > 0) {
+ originAttributes.userContextId = userContextId;
+ }
+ if (
+ Services.cookies.cookieExists(
+ site,
+ "/",
+ TAAR_COOKIE_NAME,
+ originAttributes
+ )
+ ) {
+ continue;
+ }
+ Services.cookies.add(
+ site,
+ "/",
+ TAAR_COOKIE_NAME,
+ id,
+ true, // secure
+ true, // httpOnly
+ true, // session
+ Date.now(),
+ originAttributes,
+ Ci.nsICookie.SAMESITE_LAX,
+ Ci.nsICookie.SCHEME_HTTPS
+ );
+ }
+ }
+ }
+ },
+};
diff --git a/browser/modules/EveryWindow.jsm b/browser/modules/EveryWindow.jsm
new file mode 100644
index 0000000000..4a67db57a0
--- /dev/null
+++ b/browser/modules/EveryWindow.jsm
@@ -0,0 +1,109 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["EveryWindow"];
+
+/*
+ * This module enables consumers to register callbacks on every
+ * current and future browser window.
+ *
+ * Usage: EveryWindow.registerCallback(id, init, uninit);
+ * EveryWindow.unregisterCallback(id);
+ *
+ * id is expected to be a unique value that identifies the
+ * consumer, to be used for unregistration. If the id is already
+ * in use, registerCallback returns false without doing anything.
+ *
+ * Each callback will receive the window for which it is presently
+ * being called as the first argument.
+ *
+ * init is called on every existing window at the time of registration,
+ * and on all future windows at browser-delayed-startup-finished.
+ *
+ * uninit is called on every existing window if requested at the time
+ * of unregistration, and at the time of domwindowclosed.
+ * If the window is closing, a second argument is passed with value `true`.
+ */
+
+var initialized = false;
+var callbacks = new Map();
+
+function callForEveryWindow(callback) {
+ let windowList = Services.wm.getEnumerator("navigator:browser");
+ for (let win of windowList) {
+ win.delayedStartupPromise.then(() => {
+ callback(win);
+ });
+ }
+}
+
+const EveryWindow = {
+ /**
+ * Registers init and uninit functions to be called on every window.
+ *
+ * @param {string} id A unique identifier for the consumer, to be
+ * used for unregistration.
+ * @param {function} init The function to be called on every currently
+ * existing window and every future window after delayed startup.
+ * @param {function} uninit The function to be called on every window
+ * at the time of callback unregistration or after domwindowclosed.
+ * @returns {boolean} Returns false if the id was taken, else true.
+ */
+ registerCallback: function EW_registerCallback(id, init, uninit) {
+ if (callbacks.has(id)) {
+ return false;
+ }
+
+ if (!initialized) {
+ let addUnloadListener = win => {
+ function observer(subject, topic, data) {
+ if (topic == "domwindowclosed" && subject === win) {
+ Services.ww.unregisterNotification(observer);
+ for (let c of callbacks.values()) {
+ c.uninit(win, true);
+ }
+ }
+ }
+ Services.ww.registerNotification(observer);
+ };
+
+ Services.obs.addObserver(win => {
+ for (let c of callbacks.values()) {
+ c.init(win);
+ }
+ addUnloadListener(win);
+ }, "browser-delayed-startup-finished");
+
+ callForEveryWindow(addUnloadListener);
+
+ initialized = true;
+ }
+
+ callForEveryWindow(init);
+ callbacks.set(id, { id, init, uninit });
+
+ return true;
+ },
+
+ /**
+ * Unregisters a previously registered consumer.
+ *
+ * @param {string} id The id to unregister.
+ * @param {boolean} [callUninit=true] Whether to call the registered uninit
+ * function on every window.
+ */
+ unregisterCallback: function EW_unregisterCallback(id, callUninit = true) {
+ if (!callbacks.has(id)) {
+ return;
+ }
+
+ if (callUninit) {
+ callForEveryWindow(callbacks.get(id).uninit);
+ }
+
+ callbacks.delete(id);
+ },
+};
diff --git a/browser/modules/ExtensionsUI.jsm b/browser/modules/ExtensionsUI.jsm
new file mode 100644
index 0000000000..cbe03b40eb
--- /dev/null
+++ b/browser/modules/ExtensionsUI.jsm
@@ -0,0 +1,669 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+var EXPORTED_SYMBOLS = ["ExtensionsUI"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { EventEmitter } = ChromeUtils.importESModule(
+ "resource://gre/modules/EventEmitter.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AMTelemetry: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
+ AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs",
+ ExtensionData: "resource://gre/modules/Extension.sys.mjs",
+ ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs",
+ OriginControls: "resource://gre/modules/ExtensionPermissions.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "l10n",
+ () => new Localization(["browser/extensionsUI.ftl"], true)
+);
+
+const DEFAULT_EXTENSION_ICON =
+ "chrome://mozapps/skin/extensions/extensionGeneric.svg";
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+
+function getTabBrowser(browser) {
+ while (browser.ownerGlobal.docShell.itemType !== Ci.nsIDocShell.typeChrome) {
+ browser = browser.ownerGlobal.docShell.chromeEventHandler;
+ }
+ let window = browser.ownerGlobal;
+ let viewType = browser.getAttribute("webextension-view-type");
+ if (viewType == "sidebar") {
+ window = window.browsingContext.topChromeWindow;
+ }
+ if (viewType == "popup" || viewType == "sidebar") {
+ browser = window.gBrowser.selectedBrowser;
+ }
+ return { browser, window };
+}
+
+var ExtensionsUI = {
+ sideloaded: new Set(),
+ updates: new Set(),
+ sideloadListener: null,
+
+ pendingNotifications: new WeakMap(),
+
+ async init() {
+ Services.obs.addObserver(this, "webextension-permission-prompt");
+ Services.obs.addObserver(this, "webextension-update-permissions");
+ Services.obs.addObserver(this, "webextension-install-notify");
+ Services.obs.addObserver(this, "webextension-optional-permission-prompt");
+ Services.obs.addObserver(this, "webextension-defaultsearch-prompt");
+
+ await Services.wm.getMostRecentWindow("navigator:browser")
+ .delayedStartupPromise;
+
+ this._checkForSideloaded();
+ },
+
+ async _checkForSideloaded() {
+ let sideloaded = await lazy.AddonManagerPrivate.getNewSideloads();
+
+ if (!sideloaded.length) {
+ // No new side-loads. We're done.
+ return;
+ }
+
+ // The ordering shouldn't matter, but tests depend on notifications
+ // happening in a specific order.
+ sideloaded.sort((a, b) => a.id.localeCompare(b.id));
+
+ if (!this.sideloadListener) {
+ this.sideloadListener = {
+ onEnabled: addon => {
+ if (!this.sideloaded.has(addon)) {
+ return;
+ }
+
+ this.sideloaded.delete(addon);
+ this._updateNotifications();
+
+ if (this.sideloaded.size == 0) {
+ lazy.AddonManager.removeAddonListener(this.sideloadListener);
+ this.sideloadListener = null;
+ }
+ },
+ };
+ lazy.AddonManager.addAddonListener(this.sideloadListener);
+ }
+
+ for (let addon of sideloaded) {
+ this.sideloaded.add(addon);
+ }
+ this._updateNotifications();
+ },
+
+ _updateNotifications() {
+ if (this.sideloaded.size + this.updates.size == 0) {
+ lazy.AppMenuNotifications.removeNotification("addon-alert");
+ } else {
+ lazy.AppMenuNotifications.showBadgeOnlyNotification("addon-alert");
+ }
+ this.emit("change");
+ },
+
+ showAddonsManager(tabbrowser, strings, icon) {
+ let global = tabbrowser.selectedBrowser.ownerGlobal;
+ return global
+ .BrowserOpenAddonsMgr("addons://list/extension")
+ .then(aomWin => {
+ let aomBrowser = aomWin.docShell.chromeEventHandler;
+ return this.showPermissionsPrompt(aomBrowser, strings, icon);
+ });
+ },
+
+ showSideloaded(tabbrowser, addon) {
+ addon.markAsSeen();
+ this.sideloaded.delete(addon);
+ this._updateNotifications();
+
+ let strings = this._buildStrings({
+ addon,
+ permissions: addon.userPermissions,
+ type: "sideload",
+ });
+
+ lazy.AMTelemetry.recordManageEvent(addon, "sideload_prompt", {
+ num_strings: strings.msgs.length,
+ });
+
+ this.showAddonsManager(tabbrowser, strings, addon.iconURL).then(
+ async answer => {
+ if (answer) {
+ await addon.enable();
+
+ this._updateNotifications();
+
+ // The user has just enabled a sideloaded extension, if the permission
+ // can be changed for the extension, show the post-install panel to
+ // give the user that opportunity.
+ if (
+ addon.permissions &
+ lazy.AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS
+ ) {
+ this.showInstallNotification(tabbrowser.selectedBrowser, addon);
+ }
+ }
+ this.emit("sideload-response");
+ }
+ );
+ },
+
+ showUpdate(browser, info) {
+ lazy.AMTelemetry.recordInstallEvent(info.install, {
+ step: "permissions_prompt",
+ num_strings: info.strings.msgs.length,
+ });
+
+ this.showAddonsManager(browser, info.strings, info.addon.iconURL).then(
+ answer => {
+ if (answer) {
+ info.resolve();
+ } else {
+ info.reject();
+ }
+ // At the moment, this prompt will re-appear next time we do an update
+ // check. See bug 1332360 for proposal to avoid this.
+ this.updates.delete(info);
+ this._updateNotifications();
+ }
+ );
+ },
+
+ observe(subject, topic, data) {
+ if (topic == "webextension-permission-prompt") {
+ let { target, info } = subject.wrappedJSObject;
+
+ let { browser, window } = getTabBrowser(target);
+
+ // Dismiss the progress notification. Note that this is bad if
+ // there are multiple simultaneous installs happening, see
+ // bug 1329884 for a longer explanation.
+ let progressNotification = window.PopupNotifications.getNotification(
+ "addon-progress",
+ browser
+ );
+ if (progressNotification) {
+ progressNotification.remove();
+ }
+
+ info.unsigned =
+ info.addon.signedState <= lazy.AddonManager.SIGNEDSTATE_MISSING;
+ if (
+ info.unsigned &&
+ Cu.isInAutomation &&
+ Services.prefs.getBoolPref("extensions.ui.ignoreUnsigned", false)
+ ) {
+ info.unsigned = false;
+ }
+
+ let strings = this._buildStrings(info);
+
+ // If this is an update with no promptable permissions, just apply it
+ if (info.type == "update" && !strings.msgs.length) {
+ info.resolve();
+ return;
+ }
+
+ let icon = info.unsigned
+ ? "chrome://global/skin/icons/warning.svg"
+ : info.icon;
+
+ if (info.type == "sideload") {
+ lazy.AMTelemetry.recordManageEvent(info.addon, "sideload_prompt", {
+ num_strings: strings.msgs.length,
+ });
+ } else {
+ lazy.AMTelemetry.recordInstallEvent(info.install, {
+ step: "permissions_prompt",
+ num_strings: strings.msgs.length,
+ });
+ }
+
+ this.showPermissionsPrompt(browser, strings, icon).then(answer => {
+ if (answer) {
+ info.resolve();
+ } else {
+ info.reject();
+ }
+ });
+ } else if (topic == "webextension-update-permissions") {
+ let info = subject.wrappedJSObject;
+ info.type = "update";
+ let strings = this._buildStrings(info);
+
+ // If we don't prompt for any new permissions, just apply it
+ if (!strings.msgs.length) {
+ info.resolve();
+ return;
+ }
+
+ let update = {
+ strings,
+ permissions: info.permissions,
+ install: info.install,
+ addon: info.addon,
+ resolve: info.resolve,
+ reject: info.reject,
+ };
+
+ this.updates.add(update);
+ this._updateNotifications();
+ } else if (topic == "webextension-install-notify") {
+ let { target, addon, callback } = subject.wrappedJSObject;
+ this.showInstallNotification(target, addon).then(() => {
+ if (callback) {
+ callback();
+ }
+ });
+ } else if (topic == "webextension-optional-permission-prompt") {
+ let { browser, name, icon, permissions, resolve } =
+ subject.wrappedJSObject;
+ let strings = this._buildStrings({
+ type: "optional",
+ addon: { name },
+ permissions,
+ });
+
+ // If we don't have any promptable permissions, just proceed
+ if (!strings.msgs.length) {
+ resolve(true);
+ return;
+ }
+ resolve(this.showPermissionsPrompt(browser, strings, icon));
+ } else if (topic == "webextension-defaultsearch-prompt") {
+ let { browser, name, icon, respond, currentEngine, newEngine } =
+ subject.wrappedJSObject;
+
+ const [searchDesc, searchYes, searchNo] = lazy.l10n.formatMessagesSync([
+ {
+ id: "webext-default-search-description",
+ args: { addonName: "<>", currentEngine, newEngine },
+ },
+ "webext-default-search-yes",
+ "webext-default-search-no",
+ ]);
+
+ const strings = { addonName: name, text: searchDesc.value };
+ for (let attr of searchYes.attributes) {
+ if (attr.name === "label") {
+ strings.acceptText = attr.value;
+ } else if (attr.name === "accesskey") {
+ strings.acceptKey = attr.value;
+ }
+ }
+ for (let attr of searchNo.attributes) {
+ if (attr.name === "label") {
+ strings.cancelText = attr.value;
+ } else if (attr.name === "accesskey") {
+ strings.cancelKey = attr.value;
+ }
+ }
+
+ this.showDefaultSearchPrompt(browser, strings, icon).then(respond);
+ }
+ },
+
+ // Create a set of formatted strings for a permission prompt
+ _buildStrings(info) {
+ const strings = lazy.ExtensionData.formatPermissionStrings(info, {
+ collapseOrigins: true,
+ });
+ strings.addonName = info.addon.name;
+ strings.learnMore = lazy.l10n.formatValueSync("webext-perms-learn-more");
+ return strings;
+ },
+
+ async showPermissionsPrompt(target, strings, icon) {
+ let { browser, window } = getTabBrowser(target);
+
+ // Wait for any pending prompts to complete before showing the next one.
+ let pending;
+ while ((pending = this.pendingNotifications.get(browser))) {
+ await pending;
+ }
+
+ let promise = new Promise(resolve => {
+ function eventCallback(topic) {
+ let doc = this.browser.ownerDocument;
+ if (topic == "showing") {
+ let textEl = doc.getElementById("addon-webext-perm-text");
+ textEl.textContent = strings.text;
+ textEl.hidden = !strings.text;
+
+ // By default, multiline strings don't get formatted properly. These
+ // are presently only used in site permission add-ons, so we treat it
+ // as a special case to avoid unintended effects on other things.
+ let isMultiline = strings.text.includes("\n\n");
+ textEl.classList.toggle(
+ "addon-webext-perm-text-multiline",
+ isMultiline
+ );
+
+ let listIntroEl = doc.getElementById("addon-webext-perm-intro");
+ listIntroEl.textContent = strings.listIntro;
+ listIntroEl.hidden = !strings.msgs.length || !strings.listIntro;
+
+ let listInfoEl = doc.getElementById("addon-webext-perm-info");
+ listInfoEl.textContent = strings.learnMore;
+ listInfoEl.href =
+ Services.urlFormatter.formatURLPref("app.support.baseURL") +
+ "extension-permissions";
+ listInfoEl.hidden = !strings.msgs.length;
+
+ let list = doc.getElementById("addon-webext-perm-list");
+ while (list.firstChild) {
+ list.firstChild.remove();
+ }
+ let singleEntryEl = doc.getElementById(
+ "addon-webext-perm-single-entry"
+ );
+ singleEntryEl.textContent = "";
+ singleEntryEl.hidden = true;
+ list.hidden = true;
+
+ if (strings.msgs.length === 1) {
+ singleEntryEl.textContent = strings.msgs[0];
+ singleEntryEl.hidden = false;
+ } else if (strings.msgs.length) {
+ for (let msg of strings.msgs) {
+ let item = doc.createElementNS(HTML_NS, "li");
+ item.textContent = msg;
+ list.appendChild(item);
+ }
+ list.hidden = false;
+ }
+ } else if (topic == "swapping") {
+ return true;
+ }
+ if (topic == "removed") {
+ Services.tm.dispatchToMainThread(() => {
+ resolve(false);
+ });
+ }
+ return false;
+ }
+
+ let options = {
+ hideClose: true,
+ popupIconURL: icon || DEFAULT_EXTENSION_ICON,
+ popupIconClass: icon ? "" : "addon-warning-icon",
+ persistent: true,
+ eventCallback,
+ removeOnDismissal: true,
+ popupOptions: {
+ position: "bottomright topright",
+ },
+ };
+ // The prompt/notification machinery has a special affordance wherein
+ // certain subsets of the header string can be designated "names", and
+ // referenced symbolically as "<>" and "{}" to receive special formatting.
+ // That code assumes that the existence of |name| and |secondName| in the
+ // options object imply the presence of "<>" and "{}" (respectively) in
+ // in the string.
+ //
+ // At present, WebExtensions use this affordance while SitePermission
+ // add-ons don't, so we need to conditionally set the |name| field.
+ //
+ // NB: This could potentially be cleaned up, see bug 1799710.
+ if (strings.header.includes("<>")) {
+ options.name = strings.addonName;
+ }
+
+ let action = {
+ label: strings.acceptText,
+ accessKey: strings.acceptKey,
+ callback: () => {
+ resolve(true);
+ },
+ };
+ let secondaryActions = [
+ {
+ label: strings.cancelText,
+ accessKey: strings.cancelKey,
+ callback: () => {
+ resolve(false);
+ },
+ },
+ ];
+
+ window.PopupNotifications.show(
+ browser,
+ "addon-webext-permissions",
+ strings.header,
+ browser.ownerGlobal.gUnifiedExtensions.getPopupAnchorID(
+ browser,
+ window
+ ),
+ action,
+ secondaryActions,
+ options
+ );
+ });
+
+ this.pendingNotifications.set(browser, promise);
+ promise.finally(() => this.pendingNotifications.delete(browser));
+ return promise;
+ },
+
+ showDefaultSearchPrompt(target, strings, icon) {
+ return new Promise(resolve => {
+ let options = {
+ hideClose: true,
+ popupIconURL: icon || DEFAULT_EXTENSION_ICON,
+ persistent: true,
+ removeOnDismissal: true,
+ eventCallback(topic) {
+ if (topic == "removed") {
+ resolve(false);
+ }
+ },
+ name: strings.addonName,
+ };
+
+ let action = {
+ label: strings.acceptText,
+ accessKey: strings.acceptKey,
+ callback: () => {
+ resolve(true);
+ },
+ };
+ let secondaryActions = [
+ {
+ label: strings.cancelText,
+ accessKey: strings.cancelKey,
+ callback: () => {
+ resolve(false);
+ },
+ },
+ ];
+
+ let { browser, window } = getTabBrowser(target);
+
+ window.PopupNotifications.show(
+ browser,
+ "addon-webext-defaultsearch",
+ strings.text,
+ "addons-notification-icon",
+ action,
+ secondaryActions,
+ options
+ );
+ });
+ },
+
+ async showInstallNotification(target, addon) {
+ let { window } = getTabBrowser(target);
+
+ const message = await lazy.l10n.formatValue("addon-post-install-message", {
+ addonName: "<>",
+ });
+ const permissionName = "internal:privateBrowsingAllowed";
+ const { permissions } = await lazy.ExtensionPermissions.get(addon.id);
+ const hasIncognito = permissions.includes(permissionName);
+
+ return new Promise(resolve => {
+ // Show or hide private permission ui based on the pref.
+ function setCheckbox(win) {
+ let checkbox = win.document.getElementById("addon-incognito-checkbox");
+ checkbox.checked = hasIncognito;
+ checkbox.hidden = !(
+ addon.permissions &
+ lazy.AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS
+ );
+ }
+
+ async function actionResolve(win) {
+ let checkbox = win.document.getElementById("addon-incognito-checkbox");
+
+ if (checkbox.checked == hasIncognito) {
+ resolve();
+ return;
+ }
+
+ let incognitoPermission = {
+ permissions: [permissionName],
+ origins: [],
+ };
+
+ // The checkbox has been changed at this point, otherwise we would
+ // have exited early above.
+ if (checkbox.checked) {
+ await lazy.ExtensionPermissions.add(addon.id, incognitoPermission);
+ } else if (hasIncognito) {
+ await lazy.ExtensionPermissions.remove(addon.id, incognitoPermission);
+ }
+ // Reload the extension if it is already enabled. This ensures any change
+ // on the private browsing permission is properly handled.
+ if (addon.isActive) {
+ await addon.reload();
+ }
+
+ resolve();
+ }
+
+ let action = {
+ callback: actionResolve,
+ };
+
+ let icon = addon.isWebExtension
+ ? lazy.AddonManager.getPreferredIconURL(addon, 32, window) ||
+ DEFAULT_EXTENSION_ICON
+ : "chrome://browser/skin/addons/addon-install-installed.svg";
+ let options = {
+ name: addon.name,
+ message,
+ popupIconURL: icon,
+ onRefresh: setCheckbox,
+ onDismissed: win => {
+ lazy.AppMenuNotifications.removeNotification("addon-installed");
+ actionResolve(win);
+ },
+ };
+ lazy.AppMenuNotifications.showNotification(
+ "addon-installed",
+ action,
+ null,
+ options
+ );
+ });
+ },
+
+ // Populate extension toolbar popup menu with origin controls.
+ originControlsMenu(popup, extensionId) {
+ let policy = WebExtensionPolicy.getByID(extensionId);
+
+ let win = popup.ownerGlobal;
+ let tab = win.gBrowser.selectedTab;
+ let uri = tab.linkedBrowser?.currentURI;
+ let state = lazy.OriginControls.getState(policy, tab);
+
+ let doc = popup.ownerDocument;
+ let whenClicked, alwaysOn, allDomains;
+ let separator = doc.createXULElement("menuseparator");
+
+ let headerItem = doc.createXULElement("menuitem");
+ headerItem.setAttribute("disabled", true);
+
+ // MV2 normally don't have controls, but we show the quarantined state.
+ if (!policy?.extension.originControls && !state.quarantined) {
+ return;
+ }
+
+ if (state.noAccess) {
+ if (state.quarantined) {
+ doc.l10n.setAttributes(headerItem, "origin-controls-quarantined");
+ } else {
+ doc.l10n.setAttributes(headerItem, "origin-controls-no-access");
+ }
+ } else {
+ doc.l10n.setAttributes(headerItem, "origin-controls-options");
+ }
+
+ if (state.allDomains) {
+ allDomains = doc.createXULElement("menuitem");
+ allDomains.setAttribute("type", "radio");
+ allDomains.setAttribute("checked", state.hasAccess);
+ doc.l10n.setAttributes(allDomains, "origin-controls-option-all-domains");
+ }
+
+ if (state.whenClicked) {
+ whenClicked = doc.createXULElement("menuitem");
+ whenClicked.setAttribute("type", "radio");
+ whenClicked.setAttribute("checked", !state.hasAccess);
+ doc.l10n.setAttributes(
+ whenClicked,
+ "origin-controls-option-when-clicked"
+ );
+ whenClicked.addEventListener("command", async () => {
+ await lazy.OriginControls.setWhenClicked(policy, uri);
+ win.gUnifiedExtensions.updateAttention();
+ });
+ }
+
+ if (state.alwaysOn) {
+ alwaysOn = doc.createXULElement("menuitem");
+ alwaysOn.setAttribute("type", "radio");
+ alwaysOn.setAttribute("checked", state.hasAccess);
+ doc.l10n.setAttributes(alwaysOn, "origin-controls-option-always-on", {
+ domain: uri.host,
+ });
+ alwaysOn.addEventListener("command", async () => {
+ await lazy.OriginControls.setAlwaysOn(policy, uri);
+ win.gUnifiedExtensions.updateAttention();
+ });
+ }
+
+ // Insert all before Pin to toolbar OR Manage Extension, after any
+ // extension's menu items.
+ let items = [headerItem, whenClicked, alwaysOn, allDomains, separator];
+ let manageItem =
+ popup.querySelector(".customize-context-manageExtension") ||
+ popup.querySelector(".unified-extensions-context-menu-pin-to-toolbar");
+ items.forEach(item => item && popup.insertBefore(item, manageItem));
+
+ let cleanup = e => {
+ if (e.target === popup) {
+ items.forEach(item => item?.remove());
+ popup.removeEventListener("popuphidden", cleanup);
+ }
+ };
+ popup.addEventListener("popuphidden", cleanup);
+ },
+};
+
+EventEmitter.decorate(ExtensionsUI);
diff --git a/browser/modules/FaviconLoader.jsm b/browser/modules/FaviconLoader.jsm
new file mode 100644
index 0000000000..fd7d80754a
--- /dev/null
+++ b/browser/modules/FaviconLoader.jsm
@@ -0,0 +1,710 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["FaviconLoader"];
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+ PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs",
+});
+
+const STREAM_SEGMENT_SIZE = 4096;
+const PR_UINT32_MAX = 0xffffffff;
+
+const BinaryInputStream = Components.Constructor(
+ "@mozilla.org/binaryinputstream;1",
+ "nsIBinaryInputStream",
+ "setInputStream"
+);
+const StorageStream = Components.Constructor(
+ "@mozilla.org/storagestream;1",
+ "nsIStorageStream",
+ "init"
+);
+const BufferedOutputStream = Components.Constructor(
+ "@mozilla.org/network/buffered-output-stream;1",
+ "nsIBufferedOutputStream",
+ "init"
+);
+
+const SIZES_TELEMETRY_ENUM = {
+ NO_SIZES: 0,
+ ANY: 1,
+ DIMENSION: 2,
+ INVALID: 3,
+};
+
+const FAVICON_PARSING_TIMEOUT = 100;
+const FAVICON_RICH_ICON_MIN_WIDTH = 96;
+const PREFERRED_WIDTH = 16;
+
+// URL schemes that we don't want to load and convert to data URLs.
+const LOCAL_FAVICON_SCHEMES = ["chrome", "about", "resource", "data"];
+
+const MAX_FAVICON_EXPIRATION = 7 * 24 * 60 * 60 * 1000;
+const MAX_ICON_SIZE = 2048;
+
+const TYPE_ICO = "image/x-icon";
+const TYPE_SVG = "image/svg+xml";
+
+function promiseBlobAsDataURL(blob) {
+ return new Promise((resolve, reject) => {
+ let reader = new FileReader();
+ reader.addEventListener("load", () => resolve(reader.result));
+ reader.addEventListener("error", reject);
+ reader.readAsDataURL(blob);
+ });
+}
+
+function promiseBlobAsOctets(blob) {
+ return new Promise((resolve, reject) => {
+ let reader = new FileReader();
+ reader.addEventListener("load", () => {
+ resolve(Array.from(reader.result).map(c => c.charCodeAt(0)));
+ });
+ reader.addEventListener("error", reject);
+ reader.readAsBinaryString(blob);
+ });
+}
+
+function promiseImage(stream, type) {
+ return new Promise((resolve, reject) => {
+ let imgTools = Cc["@mozilla.org/image/tools;1"].getService(Ci.imgITools);
+
+ imgTools.decodeImageAsync(
+ stream,
+ type,
+ (image, result) => {
+ if (!Components.isSuccessCode(result)) {
+ reject();
+ return;
+ }
+
+ resolve(image);
+ },
+ Services.tm.currentThread
+ );
+ });
+}
+
+class FaviconLoad {
+ constructor(iconInfo) {
+ this.icon = iconInfo;
+
+ let securityFlags;
+ if (iconInfo.node.crossOrigin === "anonymous") {
+ securityFlags = Ci.nsILoadInfo.SEC_REQUIRE_CORS_INHERITS_SEC_CONTEXT;
+ } else if (iconInfo.node.crossOrigin === "use-credentials") {
+ securityFlags =
+ Ci.nsILoadInfo.SEC_REQUIRE_CORS_INHERITS_SEC_CONTEXT |
+ Ci.nsILoadInfo.SEC_COOKIES_INCLUDE;
+ } else {
+ securityFlags =
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_INHERITS_SEC_CONTEXT;
+ }
+
+ this.channel = Services.io.newChannelFromURI(
+ iconInfo.iconUri,
+ iconInfo.node,
+ iconInfo.node.nodePrincipal,
+ iconInfo.node.nodePrincipal,
+ securityFlags |
+ Ci.nsILoadInfo.SEC_ALLOW_CHROME |
+ Ci.nsILoadInfo.SEC_DISALLOW_SCRIPT,
+ Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_FAVICON
+ );
+
+ if (this.channel instanceof Ci.nsIHttpChannel) {
+ this.channel.QueryInterface(Ci.nsIHttpChannel);
+ let referrerInfo = Cc["@mozilla.org/referrer-info;1"].createInstance(
+ Ci.nsIReferrerInfo
+ );
+ // Sometimes node is a document and sometimes it is an element. We need
+ // to set the referrer info correctly either way.
+ if (iconInfo.node.nodeType == iconInfo.node.DOCUMENT_NODE) {
+ referrerInfo.initWithDocument(iconInfo.node);
+ } else {
+ referrerInfo.initWithElement(iconInfo.node);
+ }
+ this.channel.referrerInfo = referrerInfo;
+ }
+ this.channel.loadFlags |=
+ Ci.nsIRequest.LOAD_BACKGROUND |
+ Ci.nsIRequest.VALIDATE_NEVER |
+ Ci.nsIRequest.LOAD_FROM_CACHE;
+ // Sometimes node is a document and sometimes it is an element. This is
+ // the easiest single way to get to the load group in both those cases.
+ this.channel.loadGroup =
+ iconInfo.node.ownerGlobal.document.documentLoadGroup;
+ this.channel.notificationCallbacks = this;
+
+ if (this.channel instanceof Ci.nsIHttpChannelInternal) {
+ this.channel.blockAuthPrompt = true;
+ }
+
+ if (
+ Services.prefs.getBoolPref("network.http.tailing.enabled", true) &&
+ this.channel instanceof Ci.nsIClassOfService
+ ) {
+ this.channel.addClassFlags(
+ Ci.nsIClassOfService.Tail | Ci.nsIClassOfService.Throttleable
+ );
+ }
+ }
+
+ load() {
+ this._deferred = lazy.PromiseUtils.defer();
+
+ // Clear the references when we succeed or fail.
+ let cleanup = () => {
+ this.channel = null;
+ this.dataBuffer = null;
+ this.stream = null;
+ };
+ this._deferred.promise.then(cleanup, cleanup);
+
+ this.dataBuffer = new StorageStream(STREAM_SEGMENT_SIZE, PR_UINT32_MAX);
+
+ // storage streams do not implement writeFrom so wrap it with a buffered stream.
+ this.stream = new BufferedOutputStream(
+ this.dataBuffer.getOutputStream(0),
+ STREAM_SEGMENT_SIZE * 2
+ );
+
+ try {
+ this.channel.asyncOpen(this);
+ } catch (e) {
+ this._deferred.reject(e);
+ }
+
+ return this._deferred.promise;
+ }
+
+ cancel() {
+ if (!this.channel) {
+ return;
+ }
+
+ this.channel.cancel(Cr.NS_BINDING_ABORTED);
+ }
+
+ onStartRequest(request) {}
+
+ onDataAvailable(request, inputStream, offset, count) {
+ this.stream.writeFrom(inputStream, count);
+ }
+
+ asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) {
+ if (oldChannel == this.channel) {
+ this.channel = newChannel;
+ }
+
+ callback.onRedirectVerifyCallback(Cr.NS_OK);
+ }
+
+ async onStopRequest(request, statusCode) {
+ if (request != this.channel) {
+ // Indicates that a redirect has occurred. We don't care about the result
+ // of the original channel.
+ return;
+ }
+
+ this.stream.close();
+ this.stream = null;
+
+ if (!Components.isSuccessCode(statusCode)) {
+ if (statusCode == Cr.NS_BINDING_ABORTED) {
+ this._deferred.reject(
+ Components.Exception(
+ `Favicon load from ${this.icon.iconUri.spec} was cancelled.`,
+ statusCode
+ )
+ );
+ } else {
+ this._deferred.reject(
+ Components.Exception(
+ `Favicon at "${this.icon.iconUri.spec}" failed to load.`,
+ statusCode
+ )
+ );
+ }
+ return;
+ }
+
+ if (this.channel instanceof Ci.nsIHttpChannel) {
+ if (!this.channel.requestSucceeded) {
+ this._deferred.reject(
+ Components.Exception(
+ `Favicon at "${this.icon.iconUri.spec}" failed to load: ${this.channel.responseStatusText}.`,
+ { data: { httpStatus: this.channel.responseStatus } }
+ )
+ );
+ return;
+ }
+ }
+
+ // By default don't store icons added after "pageshow".
+ let canStoreIcon = this.icon.beforePageShow;
+ if (canStoreIcon) {
+ // Don't store icons responding with Cache-Control: no-store, but always
+ // allow root domain icons.
+ try {
+ if (
+ this.icon.iconUri.filePath != "/favicon.ico" &&
+ this.channel instanceof Ci.nsIHttpChannel &&
+ this.channel.isNoStoreResponse()
+ ) {
+ canStoreIcon = false;
+ }
+ } catch (ex) {
+ if (ex.result != Cr.NS_ERROR_NOT_AVAILABLE) {
+ throw ex;
+ }
+ }
+ }
+
+ // Attempt to get an expiration time from the cache. If this fails, we'll
+ // use this default.
+ let expiration = Date.now() + MAX_FAVICON_EXPIRATION;
+
+ // This stuff isn't available after onStopRequest returns (so don't start
+ // any async operations before this!).
+ if (this.channel instanceof Ci.nsICacheInfoChannel) {
+ try {
+ expiration = Math.min(
+ this.channel.cacheTokenExpirationTime * 1000,
+ expiration
+ );
+ } catch (e) {
+ // Ignore failures to get the expiration time.
+ }
+ }
+
+ try {
+ let stream = new BinaryInputStream(this.dataBuffer.newInputStream(0));
+ let buffer = new ArrayBuffer(this.dataBuffer.length);
+ stream.readArrayBuffer(buffer.byteLength, buffer);
+
+ let type = this.channel.contentType;
+ let blob = new Blob([buffer], { type });
+
+ if (type != "image/svg+xml") {
+ let octets = await promiseBlobAsOctets(blob);
+ let sniffer = Cc["@mozilla.org/image/loader;1"].createInstance(
+ Ci.nsIContentSniffer
+ );
+ type = sniffer.getMIMETypeFromContent(
+ this.channel,
+ octets,
+ octets.length
+ );
+
+ if (!type) {
+ throw Components.Exception(
+ `Favicon at "${this.icon.iconUri.spec}" did not match a known mimetype.`,
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ blob = blob.slice(0, blob.size, type);
+
+ let image;
+ try {
+ image = await promiseImage(this.dataBuffer.newInputStream(0), type);
+ } catch (e) {
+ throw Components.Exception(
+ `Favicon at "${this.icon.iconUri.spec}" could not be decoded.`,
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ if (image.width > MAX_ICON_SIZE || image.height > MAX_ICON_SIZE) {
+ throw Components.Exception(
+ `Favicon at "${this.icon.iconUri.spec}" is too large.`,
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+ }
+
+ let dataURL = await promiseBlobAsDataURL(blob);
+
+ this._deferred.resolve({
+ expiration,
+ dataURL,
+ canStoreIcon,
+ });
+ } catch (e) {
+ this._deferred.reject(e);
+ }
+ }
+
+ getInterface(iid) {
+ if (iid.equals(Ci.nsIChannelEventSink)) {
+ return this;
+ }
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ }
+}
+
+/*
+ * Extract the icon width from the size attribute. It also sends the telemetry
+ * about the size type and size dimension info.
+ *
+ * @param {Array} aSizes An array of strings about size.
+ * @return {Number} A width of the icon in pixel.
+ */
+function extractIconSize(aSizes) {
+ let width = -1;
+ let sizesType;
+ const re = /^([1-9][0-9]*)x[1-9][0-9]*$/i;
+
+ if (aSizes.length) {
+ for (let size of aSizes) {
+ if (size.toLowerCase() == "any") {
+ sizesType = SIZES_TELEMETRY_ENUM.ANY;
+ break;
+ } else {
+ let values = re.exec(size);
+ if (values && values.length > 1) {
+ sizesType = SIZES_TELEMETRY_ENUM.DIMENSION;
+ width = parseInt(values[1]);
+ break;
+ } else {
+ sizesType = SIZES_TELEMETRY_ENUM.INVALID;
+ break;
+ }
+ }
+ }
+ } else {
+ sizesType = SIZES_TELEMETRY_ENUM.NO_SIZES;
+ }
+
+ // Telemetry probes for measuring the sizes attribute
+ // usage and available dimensions.
+ Services.telemetry
+ .getHistogramById("LINK_ICON_SIZES_ATTR_USAGE")
+ .add(sizesType);
+ if (width > 0) {
+ Services.telemetry
+ .getHistogramById("LINK_ICON_SIZES_ATTR_DIMENSION")
+ .add(width);
+ }
+
+ return width;
+}
+
+/*
+ * Get link icon URI from a link dom node.
+ *
+ * @param {DOMNode} aLink A link dom node.
+ * @return {nsIURI} A uri of the icon.
+ */
+function getLinkIconURI(aLink) {
+ let targetDoc = aLink.ownerDocument;
+ let uri = Services.io.newURI(aLink.href, targetDoc.characterSet);
+ try {
+ uri = uri.mutate().setUserPass("").finalize();
+ } catch (e) {
+ // some URIs are immutable
+ }
+ return uri;
+}
+
+/**
+ * Guess a type for an icon based on its declared type or file extension.
+ */
+function guessType(icon) {
+ // No type with no icon
+ if (!icon) {
+ return "";
+ }
+
+ // Use the file extension to guess at a type we're interested in
+ if (!icon.type) {
+ let extension = icon.iconUri.filePath.split(".").pop();
+ switch (extension) {
+ case "ico":
+ return TYPE_ICO;
+ case "svg":
+ return TYPE_SVG;
+ }
+ }
+
+ // Fuzzily prefer the type or fall back to the declared type
+ return icon.type == "image/vnd.microsoft.icon" ? TYPE_ICO : icon.type || "";
+}
+
+/*
+ * Selects the best rich icon and tab icon from a list of IconInfo objects.
+ *
+ * @param {Array} iconInfos A list of IconInfo objects.
+ * @param {integer} preferredWidth The preferred width for tab icons.
+ */
+function selectIcons(iconInfos, preferredWidth) {
+ if (!iconInfos.length) {
+ return {
+ richIcon: null,
+ tabIcon: null,
+ };
+ }
+
+ let preferredIcon;
+ let bestSizedIcon;
+ // Other links with the "icon" tag are the default icons
+ let defaultIcon;
+ // Rich icons are either apple-touch or fluid icons, or the ones of the
+ // dimension 96x96 or greater
+ let largestRichIcon;
+
+ for (let icon of iconInfos) {
+ if (!icon.isRichIcon) {
+ // First check for svg. If it's not available check for an icon with a
+ // size adapt to the current resolution. If both are not available, prefer
+ // ico files. When multiple icons are in the same set, the latest wins.
+ if (guessType(icon) == TYPE_SVG) {
+ preferredIcon = icon;
+ } else if (
+ icon.width == preferredWidth &&
+ guessType(preferredIcon) != TYPE_SVG
+ ) {
+ preferredIcon = icon;
+ } else if (
+ guessType(icon) == TYPE_ICO &&
+ (!preferredIcon || guessType(preferredIcon) == TYPE_ICO)
+ ) {
+ preferredIcon = icon;
+ }
+
+ // Check for an icon larger yet closest to preferredWidth, that can be
+ // downscaled efficiently.
+ if (
+ icon.width >= preferredWidth &&
+ (!bestSizedIcon || bestSizedIcon.width >= icon.width)
+ ) {
+ bestSizedIcon = icon;
+ }
+ }
+
+ // Note that some sites use hi-res icons without specifying them as
+ // apple-touch or fluid icons.
+ if (icon.isRichIcon || icon.width >= FAVICON_RICH_ICON_MIN_WIDTH) {
+ if (!largestRichIcon || largestRichIcon.width < icon.width) {
+ largestRichIcon = icon;
+ }
+ } else {
+ defaultIcon = icon;
+ }
+ }
+
+ // Now set the favicons for the page in the following order:
+ // 1. Set the best rich icon if any.
+ // 2. Set the preferred one if any, otherwise check if there's a better
+ // sized fit.
+ // This order allows smaller icon frames to eventually override rich icon
+ // frames.
+
+ let tabIcon = null;
+ if (preferredIcon) {
+ tabIcon = preferredIcon;
+ } else if (bestSizedIcon) {
+ tabIcon = bestSizedIcon;
+ } else if (defaultIcon) {
+ tabIcon = defaultIcon;
+ }
+
+ return {
+ richIcon: largestRichIcon,
+ tabIcon,
+ };
+}
+
+class IconLoader {
+ constructor(actor) {
+ this.actor = actor;
+ }
+
+ async load(iconInfo) {
+ if (this._loader) {
+ this._loader.cancel();
+ }
+
+ if (LOCAL_FAVICON_SCHEMES.includes(iconInfo.iconUri.scheme)) {
+ // We need to do a manual security check because the channel won't do
+ // it for us.
+ try {
+ Services.scriptSecurityManager.checkLoadURIWithPrincipal(
+ iconInfo.node.nodePrincipal,
+ iconInfo.iconUri,
+ Services.scriptSecurityManager.ALLOW_CHROME
+ );
+ } catch (ex) {
+ return;
+ }
+ this.actor.sendAsyncMessage("Link:SetIcon", {
+ pageURL: iconInfo.pageUri.spec,
+ originalURL: iconInfo.iconUri.spec,
+ canUseForTab: !iconInfo.isRichIcon,
+ expiration: undefined,
+ iconURL: iconInfo.iconUri.spec,
+ canStoreIcon: iconInfo.beforePageShow,
+ });
+ return;
+ }
+
+ // Let the main process that a tab icon is possibly coming.
+ this.actor.sendAsyncMessage("Link:LoadingIcon", {
+ originalURL: iconInfo.iconUri.spec,
+ canUseForTab: !iconInfo.isRichIcon,
+ });
+
+ try {
+ this._loader = new FaviconLoad(iconInfo);
+ let { dataURL, expiration, canStoreIcon } = await this._loader.load();
+
+ this.actor.sendAsyncMessage("Link:SetIcon", {
+ pageURL: iconInfo.pageUri.spec,
+ originalURL: iconInfo.iconUri.spec,
+ canUseForTab: !iconInfo.isRichIcon,
+ expiration,
+ iconURL: dataURL,
+ canStoreIcon,
+ });
+ } catch (e) {
+ if (e.result != Cr.NS_BINDING_ABORTED) {
+ if (typeof e.data?.wrappedJSObject?.httpStatus !== "number") {
+ console.error(e);
+ }
+
+ // Used mainly for tests currently.
+ this.actor.sendAsyncMessage("Link:SetFailedIcon", {
+ originalURL: iconInfo.iconUri.spec,
+ canUseForTab: !iconInfo.isRichIcon,
+ });
+ }
+ } finally {
+ this._loader = null;
+ }
+ }
+
+ cancel() {
+ if (!this._loader) {
+ return;
+ }
+
+ this._loader.cancel();
+ this._loader = null;
+ }
+}
+
+class FaviconLoader {
+ constructor(actor) {
+ this.actor = actor;
+ this.iconInfos = [];
+
+ // Icons added after onPageShow() are likely added by modifying <link> tags
+ // through javascript; we want to avoid storing those permanently because
+ // they are probably used to show badges, and many of them could be
+ // randomly generated. This boolean can be used to track that case.
+ this.beforePageShow = true;
+
+ // For every page we attempt to find a rich icon and a tab icon. These
+ // objects take care of the load process for each.
+ this.richIconLoader = new IconLoader(actor);
+ this.tabIconLoader = new IconLoader(actor);
+
+ this.iconTask = new lazy.DeferredTask(
+ () => this.loadIcons(),
+ FAVICON_PARSING_TIMEOUT
+ );
+ }
+
+ loadIcons() {
+ // If the page is unloaded immediately after the DeferredTask's timer fires
+ // we can still attempt to load icons, which will fail since the content
+ // window is no longer available. Checking if iconInfos has been cleared
+ // allows us to bail out early in this case.
+ if (!this.iconInfos.length) {
+ return;
+ }
+
+ let preferredWidth =
+ PREFERRED_WIDTH * Math.ceil(this.actor.contentWindow.devicePixelRatio);
+ let { richIcon, tabIcon } = selectIcons(this.iconInfos, preferredWidth);
+ this.iconInfos = [];
+
+ if (richIcon) {
+ this.richIconLoader.load(richIcon);
+ }
+
+ if (tabIcon) {
+ this.tabIconLoader.load(tabIcon);
+ }
+ }
+
+ addIconFromLink(aLink, aIsRichIcon) {
+ let iconInfo = makeFaviconFromLink(aLink, aIsRichIcon);
+ if (iconInfo) {
+ iconInfo.beforePageShow = this.beforePageShow;
+ this.iconInfos.push(iconInfo);
+ this.iconTask.arm();
+ return true;
+ }
+ return false;
+ }
+
+ addDefaultIcon(pageUri) {
+ // Currently ImageDocuments will just load the default favicon, see bug
+ // 403651 for discussion.
+ this.iconInfos.push({
+ pageUri,
+ iconUri: pageUri.mutate().setPathQueryRef("/favicon.ico").finalize(),
+ width: -1,
+ isRichIcon: false,
+ type: TYPE_ICO,
+ node: this.actor.document,
+ beforePageShow: this.beforePageShow,
+ });
+ this.iconTask.arm();
+ }
+
+ onPageShow() {
+ // We're likely done with icon parsing so load the pending icons now.
+ if (this.iconTask.isArmed) {
+ this.iconTask.disarm();
+ this.loadIcons();
+ }
+ this.beforePageShow = false;
+ }
+
+ onPageHide() {
+ this.richIconLoader.cancel();
+ this.tabIconLoader.cancel();
+
+ this.iconTask.disarm();
+ this.iconInfos = [];
+ }
+}
+
+function makeFaviconFromLink(aLink, aIsRichIcon) {
+ let iconUri = getLinkIconURI(aLink);
+ if (!iconUri) {
+ return null;
+ }
+
+ // Extract the size type and width.
+ let width = extractIconSize(aLink.sizes);
+
+ return {
+ pageUri: aLink.ownerDocument.documentURIObject,
+ iconUri,
+ width,
+ isRichIcon: aIsRichIcon,
+ type: aLink.type,
+ node: aLink,
+ };
+}
diff --git a/browser/modules/FeatureCallout.sys.mjs b/browser/modules/FeatureCallout.sys.mjs
new file mode 100644
index 0000000000..6c378a878d
--- /dev/null
+++ b/browser/modules/FeatureCallout.sys.mjs
@@ -0,0 +1,1222 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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, {
+ PageEventManager: "resource://activity-stream/lib/PageEventManager.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ AboutWelcomeParent: "resource:///actors/AboutWelcomeParent.jsm",
+ ASRouter: "resource://activity-stream/lib/ASRouter.jsm",
+});
+
+const TRANSITION_MS = 500;
+const CONTAINER_ID = "multi-stage-message-root";
+const BUNDLE_SRC =
+ "resource://activity-stream/aboutwelcome/aboutwelcome.bundle.js";
+
+/**
+ * Feature Callout fetches messages relevant to a given source and displays them
+ * in the parent page pointing to the element they describe.
+ */
+export class FeatureCallout {
+ /**
+ * @typedef {Object} FeatureCalloutOptions
+ * @property {Window} win window in which messages will be rendered
+ * @property {String} prefName name of the pref used to track progress through
+ * a given feature tour, e.g. "browser.pdfjs.feature-tour"
+ * @property {String} [page] string to pass as the page when requesting
+ * messages from ASRouter and sending telemetry. for browser chrome, the
+ * string "chrome" is used
+ * @property {MozBrowser} [browser] <browser> element responsible for the
+ * feature callout. for content pages, this is the browser element that the
+ * callout is being shown in. for chrome, this is the active browser
+ * @property {FeatureCalloutTheme} [theme] @see FeatureCallout.themePresets
+ */
+
+ /** @param {FeatureCalloutOptions} options */
+ constructor({ win, prefName, page, browser, theme = {} } = {}) {
+ this.win = win;
+ this.doc = win.document;
+ this.browser = browser || this.win.docShell.chromeEventHandler;
+ this.config = null;
+ this.loadingConfig = false;
+ this.message = null;
+ this.currentScreen = null;
+ this.renderObserver = null;
+ this.savedActiveElement = null;
+ this.ready = false;
+ this._positionListenersRegistered = false;
+ this.AWSetup = false;
+ this.page = page;
+ this._initTheme(theme);
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "featureTourProgress",
+ prefName,
+ '{"screen":"","complete":true}',
+ this._handlePrefChange.bind(this),
+ val => {
+ try {
+ return JSON.parse(val);
+ } catch (error) {
+ return null;
+ }
+ }
+ );
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "cfrFeaturesUserPref",
+ "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features",
+ true,
+ function (pref, previous, latest) {
+ if (latest) {
+ this.showFeatureCallout();
+ } else {
+ this._handlePrefChange();
+ }
+ }.bind(this)
+ );
+ this.featureTourProgress; // Load initial value of progress pref
+
+ // When the window is focused, ensure tour is synced with tours in any other
+ // instances of the parent page. This does not apply when the Callout is
+ // shown in the browser chrome.
+ if (this.page !== "chrome") {
+ this.win.addEventListener("visibilitychange", this);
+ }
+ }
+
+ /**
+ * Get the page event manager and instantiate it if necessary. Only used by
+ * _attachPageEventListeners, since we don't want to do this unnecessary work
+ * if a message with page event listeners hasn't loaded. Other consumers
+ * should use `this._pageEventManager?.property` instead.
+ */
+ get _loadPageEventManager() {
+ if (!this._pageEventManager) {
+ this._pageEventManager = new lazy.PageEventManager(this.doc);
+ }
+ return this._pageEventManager;
+ }
+
+ _addPositionListeners() {
+ if (!this._positionListenersRegistered) {
+ this.win.addEventListener("resize", this);
+ const parentEl = this.doc.querySelector(
+ this.currentScreen?.parent_selector
+ );
+ parentEl?.addEventListener("toggle", this);
+ this._positionListenersRegistered = true;
+ }
+ }
+
+ _removePositionListeners() {
+ if (this._positionListenersRegistered) {
+ this.win.removeEventListener("resize", this);
+ const parentEl = this.doc.querySelector(
+ this.currentScreen?.parent_selector
+ );
+ parentEl?.removeEventListener("toggle", this);
+ this._positionListenersRegistered = false;
+ }
+ }
+
+ async _handlePrefChange() {
+ if (this.doc.visibilityState === "hidden" || !this.featureTourProgress) {
+ return;
+ }
+
+ // If we have more than one screen, it means that we're
+ // displaying a feature tour, and transitions are handled
+ // based on the value of a tour progress pref. Otherwise,
+ // just show the feature callout.
+ if (this.config?.screens.length === 1) {
+ this.showFeatureCallout();
+ return;
+ }
+
+ // If a pref change results from an event in a Spotlight message,
+ // reload the page to clear the Spotlight and initialize the
+ // feature callout with the next message in the tour.
+ if (this.currentScreen == "spotlight") {
+ this.win.location.reload();
+ return;
+ }
+
+ let prefVal = this.featureTourProgress;
+ // End the tour according to the tour progress pref or if the user disabled
+ // contextual feature recommendations.
+ if (prefVal.complete || !this.cfrFeaturesUserPref) {
+ this.endTour();
+ this.currentScreen = null;
+ } else if (prefVal.screen !== this.currentScreen?.id) {
+ this.ready = false;
+ this._container?.classList.add("hidden");
+ this._pageEventManager?.clear();
+ // wait for fade out transition
+ this.win.setTimeout(async () => {
+ await this._loadConfig();
+ this._container?.remove();
+ this._removePositionListeners();
+ this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove();
+ await this._renderCallout();
+ }, TRANSITION_MS);
+ }
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "focus": {
+ if (!this._container) {
+ return;
+ }
+ // If focus has fired on the feature callout window itself, or on something
+ // contained in that window, ignore it, as we can't possibly place the focus
+ // on it after the callout is closd.
+ if (
+ event.target === this._container ||
+ (Node.isInstance(event.target) &&
+ this._container.contains(event.target))
+ ) {
+ return;
+ }
+ // Save this so that if the next focus event is re-entering the popup,
+ // then we'll put the focus back here where the user left it once we exit
+ // the feature callout series.
+ this.savedActiveElement = this.doc.activeElement;
+ break;
+ }
+
+ case "keypress": {
+ if (event.key !== "Escape") {
+ return;
+ }
+ if (!this._container) {
+ return;
+ }
+ let focusedElement =
+ this.page === "chrome"
+ ? Services.focus.focusedElement
+ : this.doc.activeElement;
+ // If the window has a focused element, let it handle the ESC key instead.
+ if (
+ !focusedElement ||
+ focusedElement === this.doc.body ||
+ focusedElement === this.browser ||
+ this._container.contains(focusedElement)
+ ) {
+ this.win.AWSendEventTelemetry?.({
+ event: "DISMISS",
+ event_context: {
+ source: `KEY_${event.key}`,
+ page: this.page,
+ },
+ message_id: this.config?.id.toUpperCase(),
+ });
+ this._dismiss();
+ event.preventDefault();
+ }
+ break;
+ }
+
+ case "visibilitychange":
+ this._handlePrefChange();
+ break;
+
+ case "resize":
+ case "toggle":
+ this._positionCallout();
+ break;
+
+ default:
+ }
+ }
+
+ _addCalloutLinkElements() {
+ const addStylesheet = href => {
+ if (this.doc.querySelector(`link[href="${href}"]`)) {
+ return;
+ }
+ const link = this.doc.head.appendChild(this.doc.createElement("link"));
+ link.rel = "stylesheet";
+ link.href = href;
+ };
+ const addLocalization = hrefs => {
+ hrefs.forEach(href => {
+ // eslint-disable-next-line no-undef
+ this.win.MozXULElement.insertFTLIfNeeded(href);
+ });
+ };
+
+ // Update styling to be compatible with about:welcome bundle
+ addStylesheet(
+ "chrome://activity-stream/content/aboutwelcome/aboutwelcome.css"
+ );
+
+ addLocalization([
+ "browser/newtab/onboarding.ftl",
+ "browser/spotlight.ftl",
+ "branding/brand.ftl",
+ "toolkit/branding/brandings.ftl",
+ "browser/newtab/asrouter.ftl",
+ "browser/featureCallout.ftl",
+ ]);
+ }
+
+ _createContainer() {
+ let parent = this.doc.querySelector(this.currentScreen?.parent_selector);
+ // Don't render the callout if the parent element is not present.
+ // This means the message was misconfigured, mistargeted, or the
+ // content of the parent page is not as expected.
+ if (!parent && !this.currentScreen?.content?.callout_position_override) {
+ if (this.message?.template === "feature_callout") {
+ Services.telemetry.recordEvent(
+ "messaging_experiments",
+ "feature_callout",
+ "create_failed",
+ `${this.message.id || "no_message"}-${
+ this.currentScreen?.parent_selector || "no_current_screen"
+ }`
+ );
+ }
+
+ return false;
+ }
+
+ if (!this._container?.parentElement) {
+ this._container = this.doc.createElement("div");
+ this._container.classList.add(
+ "onboardingContainer",
+ "featureCallout",
+ "callout-arrow",
+ "hidden"
+ );
+ this._container.classList.toggle(
+ "hidden-arrow",
+ this.currentScreen?.content?.hide_arrow
+ );
+ this._container.id = CONTAINER_ID;
+ // This value is reported as the "page" in about:welcome telemetry
+ this._container.dataset.page = this.page;
+ this._container.setAttribute(
+ "aria-describedby",
+ `#${CONTAINER_ID} .welcome-text`
+ );
+ this._container.tabIndex = 0;
+ this._applyTheme();
+ this.doc.body.prepend(this._container);
+ }
+ return this._container;
+ }
+
+ /**
+ * Set callout's position relative to parent element
+ */
+ _positionCallout() {
+ const container = this._container;
+ const parentEl = this.doc.querySelector(
+ this.currentScreen?.parent_selector
+ );
+ const doc = this.doc;
+ // All possible arrow positions
+ // If the position contains a dash, the value before the dash
+ // refers to which edge of the feature callout the arrow points
+ // from. The value after the dash describes where along that edge
+ // the arrow sits, with middle as the default.
+ const arrowPositions = [
+ "top",
+ "bottom",
+ "end",
+ "start",
+ "top-end",
+ "top-start",
+ ];
+ const arrowPosition = this.currentScreen?.content?.arrow_position || "top";
+ // Callout should overlap the parent element by 17px (so the box, not
+ // including the arrow, will overlap by 5px)
+ const arrowWidth = 12;
+ let overlap = 17;
+ // If we have no overlap, we send the callout the same number of pixels
+ // in the opposite direction
+ overlap = this.currentScreen?.content?.noCalloutOverlap
+ ? overlap * -1
+ : overlap;
+ overlap -= arrowWidth;
+ // Is the document layout right to left?
+ const RTL = this.doc.dir === "rtl";
+ const customPosition =
+ this.currentScreen?.content.callout_position_override;
+
+ // Early exit if the container doesn't exist,
+ // or if we're missing a parent element and don't have a custom callout position
+ if (!container || (!parentEl && !customPosition)) {
+ return;
+ }
+
+ const getOffset = el => {
+ const rect = el.getBoundingClientRect();
+ return {
+ left: rect.left + this.win.scrollX,
+ right: rect.right + this.win.scrollX,
+ top: rect.top + this.win.scrollY,
+ bottom: rect.bottom + this.win.scrollY,
+ };
+ };
+
+ const clearPosition = () => {
+ Object.keys(positioners).forEach(position => {
+ container.style[position] = "unset";
+ });
+ arrowPositions.forEach(position => {
+ if (container.classList.contains(`arrow-${position}`)) {
+ container.classList.remove(`arrow-${position}`);
+ }
+ if (container.classList.contains(`arrow-inline-${position}`)) {
+ container.classList.remove(`arrow-inline-${position}`);
+ }
+ });
+ };
+
+ const addArrowPositionClassToContainer = finalArrowPosition => {
+ let className;
+ switch (finalArrowPosition) {
+ case "bottom":
+ className = "arrow-bottom";
+ break;
+ case "left":
+ className = "arrow-inline-start";
+ break;
+ case "right":
+ className = "arrow-inline-end";
+ break;
+ case "top-start":
+ className = RTL ? "arrow-top-end" : "arrow-top-start";
+ break;
+ case "top-end":
+ className = RTL ? "arrow-top-start" : "arrow-top-end";
+ break;
+ case "top":
+ default:
+ className = "arrow-top";
+ break;
+ }
+
+ container.classList.add(className);
+ };
+
+ const addValueToPixelValue = (value, pixelValue) => {
+ return `${Number(pixelValue.split("px")[0]) + value}px`;
+ };
+
+ const subtractPixelValueFromValue = (pixelValue, value) => {
+ return `${value - Number(pixelValue.split("px")[0])}px`;
+ };
+
+ const overridePosition = () => {
+ // We override _every_ positioner here, because we want to manually set all
+ // container.style.positions in every positioner's "position" function
+ // regardless of the actual arrow position
+ // Note: We override the position functions with new functions here,
+ // but they don't actually get executed until the respective position functions are called
+ // and this function is not executed unless the message has a custom position property.
+
+ // We're positioning relative to a parent element's bounds,
+ // if that parent element exists.
+
+ for (const position in positioners) {
+ positioners[position].position = () => {
+ if (customPosition.top) {
+ container.style.top = addValueToPixelValue(
+ parentEl.getBoundingClientRect().top,
+ customPosition.top
+ );
+ }
+
+ if (customPosition.left) {
+ const leftPosition = addValueToPixelValue(
+ parentEl.getBoundingClientRect().left,
+ customPosition.left
+ );
+
+ RTL
+ ? (container.style.right = leftPosition)
+ : (container.style.left = leftPosition);
+ }
+
+ if (customPosition.right) {
+ const rightPosition = subtractPixelValueFromValue(
+ customPosition.right,
+ parentEl.getBoundingClientRect().right - container.clientWidth
+ );
+
+ RTL
+ ? (container.style.right = rightPosition)
+ : (container.style.left = rightPosition);
+ }
+
+ if (customPosition.bottom) {
+ container.style.top = subtractPixelValueFromValue(
+ customPosition.bottom,
+ parentEl.getBoundingClientRect().bottom - container.clientHeight
+ );
+ }
+ };
+ }
+ };
+
+ const positioners = {
+ // availableSpace should be the space between the edge of the page in the assumed direction
+ // and the edge of the parent (with the callout being intended to fit between those two edges)
+ // while needed space should be the space necessary to fit the callout container
+ top: {
+ availableSpace() {
+ return (
+ doc.documentElement.clientHeight -
+ getOffset(parentEl).top -
+ parentEl.clientHeight
+ );
+ },
+ neededSpace: container.clientHeight - overlap,
+ position() {
+ // Point to an element above the callout
+ let containerTop =
+ getOffset(parentEl).top + parentEl.clientHeight - overlap;
+ container.style.top = `${Math.max(0, containerTop)}px`;
+ alignHorizontally("center");
+ },
+ },
+ bottom: {
+ availableSpace() {
+ return getOffset(parentEl).top;
+ },
+ neededSpace: container.clientHeight - overlap,
+ position() {
+ // Point to an element below the callout
+ let containerTop =
+ getOffset(parentEl).top - container.clientHeight + overlap;
+ container.style.top = `${Math.max(0, containerTop)}px`;
+ alignHorizontally("center");
+ },
+ },
+ right: {
+ availableSpace() {
+ return getOffset(parentEl).left;
+ },
+ neededSpace: container.clientWidth - overlap,
+ position() {
+ // Point to an element to the right of the callout
+ let containerLeft =
+ getOffset(parentEl).left - container.clientWidth + overlap;
+ container.style.left = `${Math.max(0, containerLeft)}px`;
+ if (container.offsetHeight <= parentEl.offsetHeight) {
+ container.style.top = `${getOffset(parentEl).top}px`;
+ } else {
+ centerVertically();
+ }
+ },
+ },
+ left: {
+ availableSpace() {
+ return doc.documentElement.clientWidth - getOffset(parentEl).right;
+ },
+ neededSpace: container.clientWidth - overlap,
+ position() {
+ // Point to an element to the left of the callout
+ let containerLeft =
+ getOffset(parentEl).left + parentEl.clientWidth - overlap;
+ container.style.left = `${Math.max(0, containerLeft)}px`;
+ if (container.offsetHeight <= parentEl.offsetHeight) {
+ container.style.top = `${getOffset(parentEl).top}px`;
+ } else {
+ centerVertically();
+ }
+ },
+ },
+ "top-start": {
+ availableSpace() {
+ doc.documentElement.clientHeight -
+ getOffset(parentEl).top -
+ parentEl.clientHeight;
+ },
+ neededSpace: container.clientHeight - overlap,
+ position() {
+ // Point to an element above and at the start of the callout
+ let containerTop =
+ getOffset(parentEl).top + parentEl.clientHeight - overlap;
+ container.style.top = `${Math.max(
+ container.clientHeight - overlap,
+ containerTop
+ )}px`;
+ alignHorizontally("start");
+ },
+ },
+ "top-end": {
+ availableSpace() {
+ doc.documentElement.clientHeight -
+ getOffset(parentEl).top -
+ parentEl.clientHeight;
+ },
+ neededSpace: container.clientHeight - overlap,
+ position() {
+ // Point to an element above and at the end of the callout
+ let containerTop =
+ getOffset(parentEl).top + parentEl.clientHeight - overlap;
+ container.style.top = `${Math.max(
+ container.clientHeight - overlap,
+ containerTop
+ )}px`;
+ alignHorizontally("end");
+ },
+ },
+ };
+
+ const calloutFits = position => {
+ // Does callout element fit in this position relative
+ // to the parent element without going off screen?
+
+ // Only consider which edge of the callout the arrow points from,
+ // not the alignment of the arrow along the edge of the callout
+ let edgePosition = position.split("-")[0];
+ return (
+ positioners[edgePosition].availableSpace() >
+ positioners[edgePosition].neededSpace
+ );
+ };
+
+ const choosePosition = () => {
+ let position = arrowPosition;
+ if (!arrowPositions.includes(position)) {
+ // Configured arrow position is not valid
+ return false;
+ }
+ if (["start", "end"].includes(position)) {
+ // position here is referencing the direction that the callout container
+ // is pointing to, and therefore should be the _opposite_ side of the arrow
+ // eg. if arrow is at the "end" in LTR layouts, the container is pointing
+ // at an element to the right of itself, while in RTL layouts it is pointing to the left of itself
+ position = RTL ^ (position === "start") ? "left" : "right";
+ }
+ // If we're overriding the position, we don't need to sort for available space
+ if (customPosition || calloutFits(position)) {
+ return position;
+ }
+ let sortedPositions = Object.keys(positioners)
+ .filter(p => p !== position)
+ .filter(calloutFits)
+ .sort((a, b) => {
+ return (
+ positioners[b].availableSpace() - positioners[b].neededSpace >
+ positioners[a].availableSpace() - positioners[a].neededSpace
+ );
+ });
+ // If the callout doesn't fit in any position, use the configured one.
+ // The callout will be adjusted to overlap the parent element so that
+ // the former doesn't go off screen.
+ return sortedPositions[0] || position;
+ };
+
+ const centerVertically = () => {
+ let topOffset = (container.offsetHeight - parentEl.offsetHeight) / 2;
+ container.style.top = `${getOffset(parentEl).top - topOffset}px`;
+ };
+
+ /**
+ * Horizontally align a top/bottom-positioned callout according to the
+ * passed position.
+ * @param {String} [position = "start"] <"start"|"end"|"center">
+ */
+ const alignHorizontally = position => {
+ switch (position) {
+ case "center": {
+ let sideOffset = (parentEl.clientWidth - container.clientWidth) / 2;
+ let containerSide = RTL
+ ? doc.documentElement.clientWidth -
+ getOffset(parentEl).right +
+ sideOffset
+ : getOffset(parentEl).left + sideOffset;
+ container.style[RTL ? "right" : "left"] = `${Math.max(
+ containerSide,
+ 0
+ )}px`;
+ break;
+ }
+ default: {
+ let containerSide =
+ RTL ^ (position === "end")
+ ? parentEl.getBoundingClientRect().left +
+ parentEl.clientWidth -
+ container.clientWidth
+ : parentEl.getBoundingClientRect().left;
+ container.style.left = `${Math.max(containerSide, 0)}px`;
+ break;
+ }
+ }
+ };
+
+ clearPosition(container);
+
+ if (customPosition) {
+ overridePosition();
+ }
+
+ let finalPosition = choosePosition();
+ if (finalPosition) {
+ positioners[finalPosition].position();
+ addArrowPositionClassToContainer(finalPosition);
+ }
+
+ container.classList.remove("hidden");
+ }
+
+ /** Expose top level functions expected by the aboutwelcome bundle. */
+ _setupWindowFunctions() {
+ if (this.AWSetup) {
+ return;
+ }
+ const AWParent = new lazy.AboutWelcomeParent();
+ this.win.addEventListener("unload", () => {
+ AWParent.didDestroy();
+ });
+ const receive = name => data =>
+ AWParent.onContentMessage(`AWPage:${name}`, data, this.doc);
+ this.win.AWGetFeatureConfig = () => this.config;
+ this.win.AWGetSelectedTheme = receive("GET_SELECTED_THEME");
+ // Do not send telemetry if message config sets metrics as 'block'.
+ if (this.config?.metrics !== "block") {
+ this.win.AWSendEventTelemetry = receive("TELEMETRY_EVENT");
+ }
+ this.win.AWSendToDeviceEmailsSupported = receive(
+ "SEND_TO_DEVICE_EMAILS_SUPPORTED"
+ );
+ this.win.AWSendToParent = (name, data) => receive(name)(data);
+ this.win.AWFinish = () => {
+ this.endTour();
+ };
+ this.win.AWEvaluateScreenTargeting = receive("EVALUATE_SCREEN_TARGETING");
+ this.AWSetup = true;
+ }
+
+ /** Clean up the functions defined above. */
+ _clearWindowFunctions() {
+ const windowFuncs = [
+ "AWGetFeatureConfig",
+ "AWGetSelectedTheme",
+ "AWSendEventTelemetry",
+ "AWSendToDeviceEmailsSupported",
+ "AWSendToParent",
+ "AWFinish",
+ ];
+ windowFuncs.forEach(func => delete this.win[func]);
+ }
+
+ endTour(skipFadeOut = false) {
+ // We don't want focus events that happen during teardown to affect
+ // this.savedActiveElement
+ this.win.removeEventListener("focus", this, {
+ capture: true,
+ passive: true,
+ });
+ this.win.removeEventListener("keypress", this, { capture: true });
+ this._pageEventManager?.clear();
+
+ // We're deleting featureTourProgress here to ensure that the
+ // reference is freed for garbage collection. This prevents errors
+ // caused by lingering instances when instantiating and removing
+ // multiple feature tour instances in succession.
+ delete this.featureTourProgress;
+ this.ready = false;
+ // wait for fade out transition
+ this._container?.classList.add("hidden");
+ this._clearWindowFunctions();
+ this.win.setTimeout(
+ () => {
+ this._container?.remove();
+ this.renderObserver?.disconnect();
+ this._removePositionListeners();
+ this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove();
+ // Put the focus back to the last place the user focused outside of the
+ // featureCallout windows.
+ if (this.savedActiveElement) {
+ this.savedActiveElement.focus({ focusVisible: true });
+ }
+ },
+ skipFadeOut ? 0 : TRANSITION_MS
+ );
+ }
+
+ _dismiss() {
+ let action = this.currentScreen?.content.dismiss_button?.action;
+ if (action?.type) {
+ this.win.AWSendToParent("SPECIAL_ACTION", action);
+ } else {
+ this.endTour();
+ }
+ }
+
+ async _addScriptsAndRender() {
+ const reactSrc = "resource://activity-stream/vendor/react.js";
+ const domSrc = "resource://activity-stream/vendor/react-dom.js";
+ // Add React script
+ const getReactReady = async () => {
+ return new Promise(resolve => {
+ let reactScript = this.doc.createElement("script");
+ reactScript.src = reactSrc;
+ this.doc.head.appendChild(reactScript);
+ reactScript.addEventListener("load", resolve);
+ });
+ };
+ // Add ReactDom script
+ const getDomReady = async () => {
+ return new Promise(resolve => {
+ let domScript = this.doc.createElement("script");
+ domScript.src = domSrc;
+ this.doc.head.appendChild(domScript);
+ domScript.addEventListener("load", resolve);
+ });
+ };
+ // Load React, then React Dom
+ if (!this.doc.querySelector(`[src="${reactSrc}"]`)) {
+ await getReactReady();
+ }
+ if (!this.doc.querySelector(`[src="${domSrc}"]`)) {
+ await getDomReady();
+ }
+ // Load the bundle to render the content as configured.
+ this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove();
+ let bundleScript = this.doc.createElement("script");
+ bundleScript.src = BUNDLE_SRC;
+ this.doc.head.appendChild(bundleScript);
+ }
+
+ _observeRender(container) {
+ this.renderObserver?.observe(container, { childList: true });
+ }
+
+ /**
+ * Request a message from ASRouter, targeting the `browser` and `page` values
+ * passed to the constructor. The message content is stored in this.config,
+ * which is returned by AWGetFeatureConfig. The aboutwelcome bundle will use
+ * that function to get the content. It will only be called when the bundle
+ * loads, so the bundle must be reloaded for a new message to be rendered.
+ * @returns {Promise<boolean>} true if a message is loaded, false if not.
+ */
+ async _loadConfig() {
+ if (this.loadingConfig) {
+ return false;
+ }
+ this.loadingConfig = true;
+ await lazy.ASRouter.waitForInitialized;
+ let result = await lazy.ASRouter.sendTriggerMessage({
+ browser: this.browser,
+ // triggerId and triggerContext
+ id: "featureCalloutCheck",
+ context: { source: this.page },
+ });
+ this.message = result.message;
+ this.loadingConfig = false;
+
+ if (result.message.template !== "feature_callout") {
+ // If another message type, like a Spotlight modal, is included
+ // in the tour, save the template name as the current screen.
+ this.currentScreen = result.message.template;
+ return false;
+ }
+
+ this.config = result.message.content;
+
+ let newScreen = this.config?.screens?.[this.config?.startScreen || 0];
+ if (newScreen?.id === this.currentScreen?.id) {
+ return false;
+ }
+
+ // Only add an impression if we actually have a message to impress
+ if (Object.keys(result.message).length) {
+ lazy.ASRouter.addImpression(result.message);
+ }
+
+ this.currentScreen = newScreen;
+ return true;
+ }
+
+ async _renderCallout() {
+ let container = this._createContainer();
+ if (container) {
+ // This results in rendering the Feature Callout
+ await this._addScriptsAndRender();
+ this._observeRender(container);
+ this._addPositionListeners();
+ }
+ }
+
+ /**
+ * For each member of the screen's page_event_listeners array, add a listener.
+ * @param {Array<PageEventListener>} listeners An array of listeners to set up
+ *
+ * @typedef {Object} PageEventListener
+ * @property {PageEventListenerParams} params Event listener parameters
+ * @property {PageEventListenerAction} action Sent when the event fires
+ *
+ * @typedef {Object} PageEventListenerParams See PageEventManager.sys.mjs
+ * @property {String} type Event type string e.g. `click`
+ * @property {String} selectors Target selector, e.g. `tag.class, #id[attr]`
+ * @property {PageEventListenerOptions} [options] addEventListener options
+ *
+ * @typedef {Object} PageEventListenerOptions
+ * @property {Boolean} [capture] Use event capturing phase?
+ * @property {Boolean} [once] Remove listener after first event?
+ * @property {Boolean} [preventDefault] Prevent default action?
+ *
+ * @typedef {Object} PageEventListenerAction Action sent to AboutWelcomeParent
+ * @property {String} [type] Action type, e.g. `OPEN_URL`
+ * @property {Object} [data] Extra data, properties depend on action type
+ * @property {Boolean} [dismiss] Dismiss screen after performing action?
+ */
+ _attachPageEventListeners(listeners) {
+ listeners?.forEach(({ params, action }) =>
+ this._loadPageEventManager[params.options?.once ? "once" : "on"](
+ params,
+ event => {
+ this._handlePageEventAction(action, event);
+ if (params.options?.preventDefault) {
+ event.preventDefault?.();
+ }
+ }
+ )
+ );
+ }
+
+ /**
+ * Perform an action in response to a page event.
+ * @param {PageEventListenerAction} action
+ * @param {Event} event Triggering event
+ */
+ _handlePageEventAction(action, event) {
+ const page = this.page;
+ const message_id = this.config?.id.toUpperCase();
+ const source = this._getUniqueElementIdentifier(event.target);
+ this.win.AWSendEventTelemetry?.({
+ event: "PAGE_EVENT",
+ event_context: {
+ action: action.type ?? (action.dismiss ? "DISMISS" : ""),
+ reason: event.type?.toUpperCase(),
+ source,
+ page,
+ },
+ message_id,
+ });
+ if (action.type) {
+ this.win.AWSendToParent("SPECIAL_ACTION", action);
+ }
+ if (action.dismiss) {
+ this.win.AWSendEventTelemetry?.({
+ event: "DISMISS",
+ event_context: { source: `PAGE_EVENT:${source}`, page },
+ message_id,
+ });
+ this._dismiss();
+ }
+ }
+
+ /**
+ * For a given element, calculate a unique string that identifies it.
+ * @param {Element} target Element to calculate the selector for
+ * @returns {String} Computed event target selector, e.g. `button#next`
+ */
+ _getUniqueElementIdentifier(target) {
+ let source;
+ if (Element.isInstance(target)) {
+ source = target.localName;
+ if (target.className) {
+ source += `.${[...target.classList].join(".")}`;
+ }
+ if (target.id) {
+ source += `#${target.id}`;
+ }
+ if (target.attributes.length) {
+ source += `${[...target.attributes]
+ .filter(attr => ["is", "role", "open"].includes(attr.name))
+ .map(attr => `[${attr.name}="${attr.value}"]`)
+ .join("")}`;
+ }
+ if (this.doc.querySelectorAll(source).length > 1) {
+ let uniqueAncestor = target.closest(`[id]:not(:scope, :root, body)`);
+ if (uniqueAncestor) {
+ source = `${this._getUniqueElementIdentifier(
+ uniqueAncestor
+ )} > ${source}`;
+ }
+ }
+ }
+ return source;
+ }
+
+ async showFeatureCallout() {
+ let updated = await this._loadConfig();
+
+ if (!updated || !this.config?.screens?.length) {
+ return;
+ }
+
+ this.renderObserver = new this.win.MutationObserver(() => {
+ // Check if the Feature Callout screen has loaded for the first time
+ if (!this.ready && this._container.querySelector(".screen")) {
+ // Once the screen element is added to the DOM, wait for the
+ // animation frame after next to ensure that _positionCallout
+ // has access to the rendered screen with the correct height
+ this.win.requestAnimationFrame(() => {
+ this.win.requestAnimationFrame(() => {
+ this.ready = true;
+ this._attachPageEventListeners(
+ this.currentScreen?.content?.page_event_listeners
+ );
+ this.win.addEventListener("keypress", this, { capture: true });
+ this._positionCallout();
+ let button = this._container.querySelector(".primary");
+ button.focus();
+ this.win.addEventListener("focus", this, {
+ capture: true, // get the event before retargeting
+ passive: true,
+ });
+ });
+ });
+ }
+ });
+
+ this._pageEventManager?.clear();
+ this.ready = false;
+ this._container?.remove();
+
+ // If user has disabled CFR, don't show any callouts. But make sure we load
+ // the necessary stylesheets first, since re-enabling CFR should allow
+ // callouts to be shown without needing to reload. In the future this could
+ // allow adding a CTA to disable recommendations with a label like "Don't show
+ // these again" (or potentially a toggle to re-enable them).
+ if (!this.cfrFeaturesUserPref) {
+ this.currentScreen = null;
+ return;
+ }
+
+ this._addCalloutLinkElements();
+ this._setupWindowFunctions();
+ await this._renderCallout();
+ }
+
+ /**
+ * @typedef {Object} FeatureCalloutTheme An object with a set of custom color
+ * schemes and/or a preset key. If both are provided, the preset will be
+ * applied first, then the custom themes will override the preset values.
+ * @property {String} [preset] Key of {@link FeatureCallout.themePresets}
+ * @property {ColorScheme} [light] Custom light scheme
+ * @property {ColorScheme} [dark] Custom dark scheme
+ * @property {ColorScheme} [hcm] Custom high contrast scheme
+ * @property {ColorScheme} [all] Custom scheme that will be applied in all
+ * cases, but overridden by the other schemes if they are present. This is
+ * useful if the values are already controlled by the browser theme.
+ * @property {Boolean} [simulateContent] Set to true if the feature callout
+ * exists in the browser chrome but is meant to be displayed over the
+ * content area to appear as if it is part of the page. This will cause the
+ * styles to use a media query targeting the content instead of the chrome,
+ * so that if the browser theme doesn't match the content color scheme, the
+ * callout will correctly follow the content scheme. This is currently used
+ * for the feature callouts displayed over the PDF.js viewer.
+ */
+
+ /**
+ * @typedef {Object} ColorScheme An object with key-value pairs, with keys
+ * from {@link FeatureCallout.themePropNames}, mapped to CSS color values
+ */
+
+ /**
+ * Combine the preset and custom themes into a single object and store it.
+ * @param {FeatureCalloutTheme} theme
+ */
+ _initTheme(theme) {
+ /** @type {FeatureCalloutTheme} */
+ this.theme = Object.assign(
+ {},
+ FeatureCallout.themePresets[theme.preset],
+ theme
+ );
+ }
+
+ /**
+ * Apply all the theme colors to the feature callout's root element as CSS
+ * custom properties in inline styles. These custom properties are consumed by
+ * _feature-callout-theme.scss, which is bundled with the other styles that
+ * are loaded by {@link FeatureCallout.prototype._addCalloutLinkElements}.
+ */
+ _applyTheme() {
+ if (this._container) {
+ // This tells the stylesheets to use -moz-content-prefers-color-scheme
+ // instead of prefers-color-scheme, in order to follow the content color
+ // scheme instead of the chrome color scheme, in case of a mismatch when
+ // the feature callout exists in the chrome but is meant to look like it's
+ // part of the content of a page in a browser tab (like PDF.js).
+ this._container.classList.toggle(
+ "simulateContent",
+ this.page === "chrome" && this.theme.simulateContent
+ );
+ for (const type of ["light", "dark", "hcm"]) {
+ const scheme = this.theme[type];
+ for (const name of FeatureCallout.themePropNames) {
+ this._setThemeVariable(
+ `--fc-${name}-${type}`,
+ scheme?.[name] || this.theme.all?.[name]
+ );
+ }
+ }
+ }
+ }
+
+ /**
+ * Set or remove a CSS custom property on the feature callout container
+ * @param {String} name Name of the CSS custom property
+ * @param {String|void} [value] Value of the property, or omit to remove it
+ */
+ _setThemeVariable(name, value) {
+ if (value) {
+ this._container.style.setProperty(name, value);
+ } else {
+ this._container.style.removeProperty(name);
+ }
+ }
+
+ /** A list of all the theme properties that can be set */
+ static themePropNames = [
+ "background",
+ "color",
+ "border",
+ "accent-color",
+ "button-background",
+ "button-color",
+ "button-border",
+ "button-background-hover",
+ "button-color-hover",
+ "button-border-hover",
+ "button-background-active",
+ "button-color-active",
+ "button-border-active",
+ ];
+
+ /** @type {Object<String, FeatureCalloutTheme>} */
+ static themePresets = {
+ // For themed system pages like New Tab and Firefox View. Themed content
+ // colors inherit from the user's theme through contentTheme.js.
+ "themed-content": {
+ all: {
+ background: "var(--newtab-background-color-secondary)",
+ color: "var(--newtab-text-primary-color, var(--in-content-page-color))",
+ border:
+ "color-mix(in srgb, var(--newtab-background-color-secondary) 80%, #000)",
+ "accent-color": "var(--in-content-primary-button-background)",
+ "button-background": "color-mix(in srgb, transparent 93%, #000)",
+ "button-color":
+ "var(--newtab-text-primary-color, var(--in-content-page-color))",
+ "button-border": "transparent",
+ "button-background-hover": "color-mix(in srgb, transparent 88%, #000)",
+ "button-color-hover":
+ "var(--newtab-text-primary-color, var(--in-content-page-color))",
+ "button-border-hover": "transparent",
+ "button-background-active": "color-mix(in srgb, transparent 80%, #000)",
+ "button-color-active":
+ "var(--newtab-text-primary-color, var(--in-content-page-color))",
+ "button-border-active": "transparent",
+ },
+ dark: {
+ border:
+ "color-mix(in srgb, var(--newtab-background-color-secondary) 80%, #FFF)",
+ "button-background": "color-mix(in srgb, transparent 80%, #000)",
+ "button-background-hover": "color-mix(in srgb, transparent 65%, #000)",
+ "button-background-active": "color-mix(in srgb, transparent 55%, #000)",
+ },
+ hcm: {
+ background: "-moz-dialog",
+ color: "-moz-dialogtext",
+ border: "-moz-dialogtext",
+ "accent-color": "LinkText",
+ "button-background": "ButtonFace",
+ "button-color": "ButtonText",
+ "button-border": "ButtonText",
+ "button-background-hover": "ButtonText",
+ "button-color-hover": "ButtonFace",
+ "button-border-hover": "ButtonText",
+ "button-background-active": "ButtonText",
+ "button-color-active": "ButtonFace",
+ "button-border-active": "ButtonText",
+ },
+ },
+ // PDF.js colors are from toolkit/components/pdfjs/content/web/viewer.css
+ pdfjs: {
+ all: {
+ background: "#FFF",
+ color: "rgb(12, 12, 13)",
+ border: "#CFCFD8",
+ "accent-color": "#0A84FF",
+ "button-background": "rgb(215, 215, 219)",
+ "button-color": "rgb(12, 12, 13)",
+ "button-border": "transparent",
+ "button-background-hover": "rgb(221, 222, 223)",
+ "button-color-hover": "rgb(12, 12, 13)",
+ "button-border-hover": "transparent",
+ "button-background-active": "rgb(221, 222, 223)",
+ "button-color-active": "rgb(12, 12, 13)",
+ "button-border-active": "transparent",
+ },
+ dark: {
+ background: "#1C1B22",
+ color: "#F9F9FA",
+ border: "#3A3944",
+ "button-background": "rgb(74, 74, 79)",
+ "button-color": "#F9F9FA",
+ "button-background-hover": "rgb(102, 102, 103)",
+ "button-color-hover": "#F9F9FA",
+ "button-background-active": "rgb(102, 102, 103)",
+ "button-color-active": "#F9F9FA",
+ },
+ hcm: {
+ background: "-moz-dialog",
+ color: "-moz-dialogtext",
+ border: "CanvasText",
+ "accent-color": "Highlight",
+ "button-background": "ButtonFace",
+ "button-color": "ButtonText",
+ "button-border": "ButtonText",
+ "button-background-hover": "Highlight",
+ "button-color-hover": "CanvasText",
+ "button-border-hover": "Highlight",
+ "button-background-active": "Highlight",
+ "button-color-active": "CanvasText",
+ "button-border-active": "Highlight",
+ },
+ },
+ // These colors are intended to inherit the user's theme properties from the
+ // main chrome window, for callouts to be anchored to chrome elements.
+ // Specific schemes aren't necessary since the theme and frontend
+ // stylesheets handle these variables' values.
+ chrome: {
+ all: {
+ background: "var(--arrowpanel-background)",
+ color: "var(--arrowpanel-color)",
+ border: "var(--arrowpanel-border-color)",
+ "accent-color": "var(--focus-outline-color)",
+ "button-background": "var(--button-bgcolor)",
+ "button-color": "var(--arrowpanel-color)",
+ "button-border": "transparent",
+ "button-background-hover": "var(--button-hover-bgcolor)",
+ "button-color-hover": "var(--arrowpanel-color)",
+ "button-border-hover": "transparent",
+ "button-background-active": "var(--button-active-bgcolor)",
+ "button-color-active": "var(--arrowpanel-color)",
+ "button-border-active": "transparent",
+ },
+ },
+ };
+}
diff --git a/browser/modules/HomePage.jsm b/browser/modules/HomePage.jsm
new file mode 100644
index 0000000000..3c4103f14f
--- /dev/null
+++ b/browser/modules/HomePage.jsm
@@ -0,0 +1,359 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["HomePage"];
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs",
+ ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
+ ExtensionPreferencesManager:
+ "resource://gre/modules/ExtensionPreferencesManager.sys.mjs",
+ IgnoreLists: "resource://gre/modules/IgnoreLists.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+const kPrefName = "browser.startup.homepage";
+const kDefaultHomePage = "about:home";
+const kExtensionControllerPref =
+ "browser.startup.homepage_override.extensionControlled";
+const kHomePageIgnoreListId = "homepage-urls";
+const kWidgetId = "home-button";
+const kWidgetRemovedPref = "browser.engagement.home-button.has-removed";
+
+function getHomepagePref(useDefault) {
+ let homePage;
+ let prefs = Services.prefs;
+ if (useDefault) {
+ prefs = prefs.getDefaultBranch(null);
+ }
+ try {
+ // Historically, this was a localizable pref, but default Firefox builds
+ // don't use this.
+ // Distributions and local customizations might still use this, so let's
+ // keep it.
+ homePage = prefs.getComplexValue(kPrefName, Ci.nsIPrefLocalizedString).data;
+ } catch (ex) {}
+
+ if (!homePage) {
+ homePage = prefs.getStringPref(kPrefName);
+ }
+
+ // Apparently at some point users ended up with blank home pages somehow.
+ // If that happens, reset the pref and read it again.
+ if (!homePage && !useDefault) {
+ Services.prefs.clearUserPref(kPrefName);
+ homePage = getHomepagePref(true);
+ }
+
+ return homePage;
+}
+
+/**
+ * HomePage provides tools to keep track of the current homepage, and the
+ * applications's default homepage. It includes tools to insure that certain
+ * urls are ignored. As a result, all set/get requests for the homepage
+ * preferences should be routed through here.
+ */
+let HomePage = {
+ // This is an array of strings that should be matched against URLs to see
+ // if they should be ignored or not.
+ _ignoreList: [],
+
+ // A promise that is set when initialization starts and resolved when it
+ // completes.
+ _initializationPromise: null,
+
+ /**
+ * Used to initialise the ignore lists. This may be called later than
+ * the first call to get or set, which may cause a used to get an ignored
+ * homepage, but this is deemed acceptable, as we'll correct it once
+ * initialised.
+ */
+ async delayedStartup() {
+ if (this._initializationPromise) {
+ await this._initializationPromise;
+ return;
+ }
+
+ Services.telemetry.setEventRecordingEnabled("homepage", true);
+
+ // Now we have the values, listen for future updates.
+ this._ignoreListListener = this._handleIgnoreListUpdated.bind(this);
+
+ this._initializationPromise = lazy.IgnoreLists.getAndSubscribe(
+ this._ignoreListListener
+ );
+
+ this._addCustomizableUiListener();
+
+ const current = await this._initializationPromise;
+
+ await this._handleIgnoreListUpdated({ data: { current } });
+ },
+
+ /**
+ * Gets the homepage for the given window.
+ *
+ * @param {DOMWindow} [aWindow]
+ * The window associated with the get, used to check for private browsing
+ * mode. If not supplied, normal mode is assumed.
+ * @returns {string}
+ * Returns the home page value, this could be a single url, or a `|`
+ * separated list of URLs.
+ */
+ get(aWindow) {
+ let homePages = getHomepagePref();
+ if (
+ lazy.PrivateBrowsingUtils.permanentPrivateBrowsing ||
+ (aWindow && lazy.PrivateBrowsingUtils.isWindowPrivate(aWindow))
+ ) {
+ // If an extension controls the setting and does not have private
+ // browsing permission, use the default setting.
+ let extensionControlled = Services.prefs.getBoolPref(
+ kExtensionControllerPref,
+ false
+ );
+ let privateAllowed = Services.prefs.getBoolPref(
+ "browser.startup.homepage_override.privateAllowed",
+ false
+ );
+ // There is a potential on upgrade that the prefs are not set yet, so we double check
+ // for moz-extension.
+ if (
+ !privateAllowed &&
+ (extensionControlled || homePages.includes("moz-extension://"))
+ ) {
+ return this.getDefault();
+ }
+ }
+
+ if (homePages == "about:blank") {
+ homePages = "chrome://browser/content/blanktab.html";
+ }
+
+ return homePages;
+ },
+
+ /**
+ * @returns {string}
+ * Returns the application default homepage.
+ */
+ getDefault() {
+ return getHomepagePref(true);
+ },
+
+ /**
+ * @returns {string}
+ * Returns the original application homepage URL (not from prefs).
+ */
+ getOriginalDefault() {
+ return kDefaultHomePage;
+ },
+
+ /**
+ * @returns {boolean}
+ * Returns true if the homepage has been changed.
+ */
+ get overridden() {
+ return Services.prefs.prefHasUserValue(kPrefName);
+ },
+
+ /**
+ * @returns {boolean}
+ * Returns true if the homepage preference is locked.
+ */
+ get locked() {
+ return Services.prefs.prefIsLocked(kPrefName);
+ },
+
+ /**
+ * @returns {boolean}
+ * Returns true if the current homepage is the application default.
+ */
+ get isDefault() {
+ return HomePage.get() === kDefaultHomePage;
+ },
+
+ /**
+ * Sets the homepage preference to a new page.
+ *
+ * @param {string} value
+ * The new value to set the preference to. This could be a single url, or a
+ * `|` separated list of URLs.
+ */
+ async set(value) {
+ await this.delayedStartup();
+
+ if (await this.shouldIgnore(value)) {
+ console.error(
+ `Ignoring homepage setting for ${value} as it is on the ignore list.`
+ );
+ Services.telemetry.recordEvent(
+ "homepage",
+ "preference",
+ "ignore",
+ "set_blocked"
+ );
+ return false;
+ }
+ Services.prefs.setStringPref(kPrefName, value);
+ this._maybeAddHomeButtonToToolbar(value);
+ return true;
+ },
+
+ /**
+ * Sets the homepage preference to a new page. This is an synchronous version
+ * that should only be used when we know the source is safe as it bypasses the
+ * ignore list, e.g. when setting directly to about:blank or a value not
+ * supplied externally.
+ *
+ * @param {string} value
+ * The new value to set the preference to. This could be a single url, or a
+ * `|` separated list of URLs.
+ */
+ safeSet(value) {
+ Services.prefs.setStringPref(kPrefName, value);
+ },
+
+ /**
+ * Clears the homepage preference if it is not the default. Note that for
+ * policy/locking use, the default homepage might not be about:home after this.
+ */
+ clear() {
+ Services.prefs.clearUserPref(kPrefName);
+ },
+
+ /**
+ * Resets the homepage preference to be about:home.
+ */
+ reset() {
+ Services.prefs.setStringPref(kPrefName, kDefaultHomePage);
+ },
+
+ /**
+ * Determines if a url should be ignored according to the ignore list.
+ *
+ * @param {string} url
+ * A string that is the url or urls to be ignored.
+ * @returns {boolean}
+ * True if the url should be ignored.
+ */
+ async shouldIgnore(url) {
+ await this.delayedStartup();
+
+ const lowerURL = url.toLowerCase();
+ return this._ignoreList.some(code => lowerURL.includes(code.toLowerCase()));
+ },
+
+ /**
+ * Handles updates of the ignore list, checking the existing preference and
+ * correcting it as necessary.
+ *
+ * @param {Object} eventData
+ * The event data as received from RemoteSettings.
+ */
+ async _handleIgnoreListUpdated({ data: { current } }) {
+ for (const entry of current) {
+ if (entry.id == kHomePageIgnoreListId) {
+ this._ignoreList = [...entry.matches];
+ }
+ }
+
+ // Only check if we're overridden as we assume the default value is fine,
+ // or won't be changeable (e.g. enterprise policy).
+ if (this.overridden) {
+ let homePages = getHomepagePref().toLowerCase();
+ if (
+ this._ignoreList.some(code => homePages.includes(code.toLowerCase()))
+ ) {
+ if (Services.prefs.getBoolPref(kExtensionControllerPref, false)) {
+ if (Services.appinfo.inSafeMode) {
+ // Add-ons don't get started in safe mode, so just abort this.
+ // We'll get to remove them when we next start in normal mode.
+ return;
+ }
+ // getSetting does not need the module to be loaded.
+ const item = await lazy.ExtensionPreferencesManager.getSetting(
+ "homepage_override"
+ );
+ if (item && item.id) {
+ // During startup some modules may not be loaded yet, so we load
+ // the setting we need prior to removal.
+ await lazy.ExtensionParent.apiManager.asyncLoadModule(
+ "chrome_settings_overrides"
+ );
+ lazy.ExtensionPreferencesManager.removeSetting(
+ item.id,
+ "homepage_override"
+ ).catch(console.error);
+ } else {
+ // If we don't have a setting for it, we assume the pref has
+ // been incorrectly set somehow.
+ Services.prefs.clearUserPref(kExtensionControllerPref);
+ Services.prefs.clearUserPref(
+ "browser.startup.homepage_override.privateAllowed"
+ );
+ }
+ } else {
+ this.clear();
+ }
+ Services.telemetry.recordEvent(
+ "homepage",
+ "preference",
+ "ignore",
+ "saved_reset"
+ );
+ }
+ }
+ },
+
+ onWidgetRemoved(widgetId, area) {
+ if (widgetId == kWidgetId) {
+ Services.prefs.setBoolPref(kWidgetRemovedPref, true);
+ lazy.CustomizableUI.removeListener(this);
+ }
+ },
+
+ /**
+ * Add the home button to the toolbar if the user just set a custom homepage.
+ *
+ * This should only be done once, so we check HOME_BUTTON_REMOVED_PREF which
+ * gets set to true when the home button is removed from the toolbar.
+ *
+ * If the home button is already on the toolbar it won't be moved.
+ */
+ _maybeAddHomeButtonToToolbar(homePage) {
+ if (
+ homePage !== "about:home" &&
+ homePage !== "about:blank" &&
+ !Services.prefs.getBoolPref(kExtensionControllerPref, false) &&
+ !Services.prefs.getBoolPref(kWidgetRemovedPref, false) &&
+ !lazy.CustomizableUI.getWidget(kWidgetId).areaType
+ ) {
+ // Find a spot for the home button, ideally it will be in its default
+ // position beside the stop/refresh button.
+ // Work backwards from the URL bar since it can't be removed and put
+ // the button after the first non-spring we find.
+ let navbarPlacements = lazy.CustomizableUI.getWidgetIdsInArea("nav-bar");
+ let position = navbarPlacements.indexOf("urlbar-container");
+ for (let i = position - 1; i >= 0; i--) {
+ if (!navbarPlacements[i].startsWith("customizableui-special-spring")) {
+ position = i + 1;
+ break;
+ }
+ }
+ lazy.CustomizableUI.addWidgetToArea(kWidgetId, "nav-bar", position);
+ }
+ },
+
+ _addCustomizableUiListener() {
+ if (!Services.prefs.getBoolPref(kWidgetRemovedPref, false)) {
+ lazy.CustomizableUI.addListener(this);
+ }
+ },
+};
diff --git a/browser/modules/LaterRun.jsm b/browser/modules/LaterRun.jsm
new file mode 100644
index 0000000000..90926b2407
--- /dev/null
+++ b/browser/modules/LaterRun.jsm
@@ -0,0 +1,192 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["LaterRun"];
+
+const kEnabledPref = "browser.laterrun.enabled";
+const kPagePrefRoot = "browser.laterrun.pages.";
+// Number of sessions we've been active in
+const kSessionCountPref = "browser.laterrun.bookkeeping.sessionCount";
+// Time the profile was created at:
+const kProfileCreationTime = "browser.laterrun.bookkeeping.profileCreationTime";
+
+// After 50 sessions or 1 month since install, assume we will no longer be
+// interested in showing anything to "new" users
+const kSelfDestructSessionLimit = 50;
+const kSelfDestructHoursLimit = 31 * 24;
+
+class Page {
+ constructor({
+ pref,
+ minimumHoursSinceInstall,
+ minimumSessionCount,
+ requireBoth,
+ url,
+ }) {
+ this.pref = pref;
+ this.minimumHoursSinceInstall = minimumHoursSinceInstall || 0;
+ this.minimumSessionCount = minimumSessionCount || 1;
+ this.requireBoth = requireBoth || false;
+ this.url = url;
+ }
+
+ get hasRun() {
+ return Services.prefs.getBoolPref(this.pref + "hasRun", false);
+ }
+
+ applies(sessionInfo) {
+ if (this.hasRun) {
+ return false;
+ }
+ if (this.requireBoth) {
+ return (
+ sessionInfo.sessionCount >= this.minimumSessionCount &&
+ sessionInfo.hoursSinceInstall >= this.minimumHoursSinceInstall
+ );
+ }
+ return (
+ sessionInfo.sessionCount >= this.minimumSessionCount ||
+ sessionInfo.hoursSinceInstall >= this.minimumHoursSinceInstall
+ );
+ }
+}
+
+let LaterRun = {
+ init() {
+ if (!this.enabled) {
+ return;
+ }
+ // If this is the first run, set the time we were installed
+ if (
+ Services.prefs.getPrefType(kProfileCreationTime) ==
+ Ci.nsIPrefBranch.PREF_INVALID
+ ) {
+ // We need to store seconds in order to fit within int prefs.
+ Services.prefs.setIntPref(
+ kProfileCreationTime,
+ Math.floor(Date.now() / 1000)
+ );
+ }
+ this.sessionCount++;
+
+ if (
+ this.hoursSinceInstall > kSelfDestructHoursLimit ||
+ this.sessionCount > kSelfDestructSessionLimit
+ ) {
+ this.selfDestruct();
+ }
+ },
+
+ // The enabled, hoursSinceInstall and sessionCount properties mirror the
+ // preferences system, and are here for convenience.
+ get enabled() {
+ return Services.prefs.getBoolPref(kEnabledPref, false);
+ },
+
+ set enabled(val) {
+ let wasEnabled = this.enabled;
+ Services.prefs.setBoolPref(kEnabledPref, val);
+ if (val && !wasEnabled) {
+ this.init();
+ }
+ },
+
+ get hoursSinceInstall() {
+ let installStamp = Services.prefs.getIntPref(
+ kProfileCreationTime,
+ Date.now() / 1000
+ );
+ return Math.floor((Date.now() / 1000 - installStamp) / 3600);
+ },
+
+ get sessionCount() {
+ if (this._sessionCount) {
+ return this._sessionCount;
+ }
+ return (this._sessionCount = Services.prefs.getIntPref(
+ kSessionCountPref,
+ 0
+ ));
+ },
+
+ set sessionCount(val) {
+ this._sessionCount = val;
+ Services.prefs.setIntPref(kSessionCountPref, val);
+ },
+
+ // Because we don't want to keep incrementing this indefinitely for no reason,
+ // we will turn ourselves off after a set amount of time/sessions (see top of
+ // file).
+ selfDestruct() {
+ Services.prefs.setBoolPref(kEnabledPref, false);
+ },
+
+ // Create an array of Page objects based on the currently set prefs
+ readPages() {
+ // Enumerate all the pages.
+ let allPrefsForPages = Services.prefs.getChildList(kPagePrefRoot);
+ let pageDataStore = new Map();
+ for (let pref of allPrefsForPages) {
+ let [slug, prop] = pref.substring(kPagePrefRoot.length).split(".");
+ if (!pageDataStore.has(slug)) {
+ pageDataStore.set(slug, {
+ pref: pref.substring(0, pref.length - prop.length),
+ });
+ }
+ if (prop == "requireBoth" || prop == "hasRun") {
+ pageDataStore.get(slug)[prop] = Services.prefs.getBoolPref(pref, false);
+ } else if (prop == "url") {
+ pageDataStore.get(slug)[prop] = Services.prefs.getStringPref(pref, "");
+ } else {
+ pageDataStore.get(slug)[prop] = Services.prefs.getIntPref(pref, 0);
+ }
+ }
+ let rv = [];
+ for (let [, pageData] of pageDataStore) {
+ if (pageData.url) {
+ let uri = null;
+ try {
+ let urlString = Services.urlFormatter.formatURL(pageData.url.trim());
+ uri = Services.io.newURI(urlString);
+ } catch (ex) {
+ console.error(
+ "Invalid LaterRun page URL ",
+ pageData.url,
+ " ignored."
+ );
+ continue;
+ }
+ if (!uri.schemeIs("https")) {
+ console.error("Insecure LaterRun page URL ", uri.spec, " ignored.");
+ } else {
+ pageData.url = uri.spec;
+ rv.push(new Page(pageData));
+ }
+ }
+ }
+ return rv;
+ },
+
+ // Return a URL for display as a 'later run' page if its criteria are matched,
+ // or null otherwise.
+ // NB: will only return one page at a time; if multiple pages match, it's up
+ // to the preference service which one gets shown first, and the next one
+ // will be shown next startup instead.
+ getURL() {
+ if (!this.enabled) {
+ return null;
+ }
+ let pages = this.readPages();
+ let page = pages.find(p => p.applies(this));
+ if (page) {
+ Services.prefs.setBoolPref(page.pref + "hasRun", true);
+ return page.url;
+ }
+ return null;
+ },
+};
+
+LaterRun.init();
diff --git a/browser/modules/NewTabPagePreloading.jsm b/browser/modules/NewTabPagePreloading.jsm
new file mode 100644
index 0000000000..b0725f9030
--- /dev/null
+++ b/browser/modules/NewTabPagePreloading.jsm
@@ -0,0 +1,211 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 module is in charge of preloading 'new tab' pages for use when
+ * the user opens a new tab.
+ */
+
+var EXPORTED_SYMBOLS = ["NewTabPagePreloading"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ AboutNewTab: "resource:///modules/AboutNewTab.jsm",
+ BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm",
+});
+
+let NewTabPagePreloading = {
+ // Maximum number of instances of a given page we'll preload at any time.
+ // Because we preload about:newtab for normal windows, and about:privatebrowsing
+ // for private ones, we could have 3 of each.
+ MAX_COUNT: 3,
+
+ // How many preloaded tabs we have, across all windows, for the private and non-private
+ // case:
+ browserCounts: {
+ normal: 0,
+ private: 0,
+ },
+
+ get enabled() {
+ return (
+ this.prefEnabled &&
+ this.newTabEnabled &&
+ !lazy.AboutNewTab.newTabURLOverridden
+ );
+ },
+
+ /**
+ * Create a browser in the right process type.
+ */
+ _createBrowser(win) {
+ const {
+ gBrowser,
+ gMultiProcessBrowser,
+ gFissionBrowser,
+ BROWSER_NEW_TAB_URL,
+ } = win;
+
+ let oa = lazy.E10SUtils.predictOriginAttributes({ window: win });
+
+ let remoteType = lazy.E10SUtils.getRemoteTypeForURI(
+ BROWSER_NEW_TAB_URL,
+ gMultiProcessBrowser,
+ gFissionBrowser,
+ lazy.E10SUtils.DEFAULT_REMOTE_TYPE,
+ null,
+ oa
+ );
+ let browser = gBrowser.createBrowser({
+ isPreloadBrowser: true,
+ remoteType,
+ });
+ gBrowser.preloadedBrowser = browser;
+
+ let panel = gBrowser.getPanel(browser);
+ gBrowser.tabpanels.appendChild(panel);
+
+ return browser;
+ },
+
+ /**
+ * Move the contents of a preload browser across to a different window.
+ */
+ _adoptBrowserFromOtherWindow(window) {
+ let winPrivate = lazy.PrivateBrowsingUtils.isWindowPrivate(window);
+ // Grab the least-recently-focused window with a preloaded browser:
+ let oldWin = lazy.BrowserWindowTracker.orderedWindows
+ .filter(w => {
+ return (
+ winPrivate == lazy.PrivateBrowsingUtils.isWindowPrivate(w) &&
+ w.gBrowser &&
+ w.gBrowser.preloadedBrowser
+ );
+ })
+ .pop();
+ if (!oldWin) {
+ return null;
+ }
+ // Don't call getPreloadedBrowser because it'll consume the browser:
+ let oldBrowser = oldWin.gBrowser.preloadedBrowser;
+ oldWin.gBrowser.preloadedBrowser = null;
+
+ let newBrowser = this._createBrowser(window);
+
+ oldBrowser.swapBrowsers(newBrowser);
+
+ newBrowser.permanentKey = oldBrowser.permanentKey;
+
+ oldWin.gBrowser.getPanel(oldBrowser).remove();
+ return newBrowser;
+ },
+
+ maybeCreatePreloadedBrowser(window) {
+ // If we're not enabled, have already got one, are in a popup window, or the
+ // window is minimized / occluded, don't bother creating a preload browser -
+ // there's no point.
+ if (
+ !this.enabled ||
+ window.gBrowser.preloadedBrowser ||
+ !window.toolbar.visible ||
+ window.document.hidden
+ ) {
+ return;
+ }
+
+ // Don't bother creating a preload browser if we're not in the top set of windows:
+ let windowPrivate = lazy.PrivateBrowsingUtils.isWindowPrivate(window);
+ let countKey = windowPrivate ? "private" : "normal";
+ let topWindows = lazy.BrowserWindowTracker.orderedWindows.filter(
+ w => lazy.PrivateBrowsingUtils.isWindowPrivate(w) == windowPrivate
+ );
+ if (topWindows.indexOf(window) >= this.MAX_COUNT) {
+ return;
+ }
+
+ // If we're in the top set of windows, and we already have enough preloaded
+ // tabs, don't create yet another one, just steal an existing one:
+ if (this.browserCounts[countKey] >= this.MAX_COUNT) {
+ let browser = this._adoptBrowserFromOtherWindow(window);
+ // We can potentially get null here if we couldn't actually find another
+ // browser to adopt from. This can be the case when there's a mix of
+ // private and non-private windows, for instance.
+ if (browser) {
+ return;
+ }
+ }
+
+ let browser = this._createBrowser(window);
+ browser.loadURI(Services.io.newURI(window.BROWSER_NEW_TAB_URL), {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ browser.docShellIsActive = false;
+ browser._urlbarFocused = true;
+
+ // Make sure the preloaded browser is loaded with desired zoom level
+ let tabURI = Services.io.newURI(window.BROWSER_NEW_TAB_URL);
+ window.FullZoom.onLocationChange(tabURI, false, browser);
+
+ this.browserCounts[countKey]++;
+ },
+
+ getPreloadedBrowser(window) {
+ if (!this.enabled) {
+ return null;
+ }
+
+ // The preloaded browser might be null.
+ let browser = window.gBrowser.preloadedBrowser;
+
+ // Consume the browser.
+ window.gBrowser.preloadedBrowser = null;
+
+ // Attach the nsIFormFillController now that we know the browser
+ // will be used. If we do that before and the preloaded browser
+ // won't be consumed until shutdown then we leak a docShell.
+ // Also, we do not need to take care of attaching nsIFormFillControllers
+ // in the case that the browser is remote, as remote browsers take
+ // care of that themselves.
+ if (browser) {
+ let countKey = lazy.PrivateBrowsingUtils.isWindowPrivate(window)
+ ? "private"
+ : "normal";
+ this.browserCounts[countKey]--;
+ browser.removeAttribute("preloadedState");
+ browser.setAttribute("autocompletepopup", "PopupAutoComplete");
+ }
+
+ return browser;
+ },
+
+ removePreloadedBrowser(window) {
+ let browser = this.getPreloadedBrowser(window);
+ if (browser) {
+ window.gBrowser.getPanel(browser).remove();
+ }
+ },
+};
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ NewTabPagePreloading,
+ "prefEnabled",
+ "browser.newtab.preload",
+ true
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ NewTabPagePreloading,
+ "newTabEnabled",
+ "browser.newtabpage.enabled",
+ true
+);
diff --git a/browser/modules/OpenInTabsUtils.jsm b/browser/modules/OpenInTabsUtils.jsm
new file mode 100644
index 0000000000..c080dd1112
--- /dev/null
+++ b/browser/modules/OpenInTabsUtils.jsm
@@ -0,0 +1,85 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["OpenInTabsUtils"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "l10n", () => {
+ return new Localization(
+ ["browser/tabbrowser.ftl", "branding/brand.ftl"],
+ true
+ );
+});
+
+/**
+ * Utility functions that can be used when opening multiple tabs, that can be
+ * called without any tabbrowser instance.
+ */
+const OpenInTabsUtils = {
+ /**
+ * Gives the user a chance to cancel loading lots of tabs at once.
+ */
+ confirmOpenInTabs(numTabsToOpen, aWindow) {
+ const WARN_ON_OPEN_PREF = "browser.tabs.warnOnOpen";
+ const MAX_OPNE_PREF = "browser.tabs.maxOpenBeforeWarn";
+ if (!Services.prefs.getBoolPref(WARN_ON_OPEN_PREF)) {
+ return true;
+ }
+ if (numTabsToOpen < Services.prefs.getIntPref(MAX_OPNE_PREF)) {
+ return true;
+ }
+
+ // default to true: if it were false, we wouldn't get this far
+ let warnOnOpen = { value: true };
+
+ const [title, message, button, checkbox] = lazy.l10n.formatMessagesSync([
+ { id: "tabbrowser-confirm-open-multiple-tabs-title" },
+ {
+ id: "tabbrowser-confirm-open-multiple-tabs-message",
+ args: { tabCount: numTabsToOpen },
+ },
+ { id: "tabbrowser-confirm-open-multiple-tabs-button" },
+ { id: "tabbrowser-confirm-open-multiple-tabs-checkbox" },
+ ]);
+
+ let buttonPressed = Services.prompt.confirmEx(
+ aWindow,
+ title.value,
+ message.value,
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
+ Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1,
+ button.value,
+ null,
+ null,
+ checkbox.value,
+ warnOnOpen
+ );
+
+ let reallyOpen = buttonPressed == 0;
+ // don't set the pref unless they press OK and it's false
+ if (reallyOpen && !warnOnOpen.value) {
+ Services.prefs.setBoolPref(WARN_ON_OPEN_PREF, false);
+ }
+
+ return reallyOpen;
+ },
+
+ /*
+ * Async version of confirmOpenInTabs.
+ */
+ promiseConfirmOpenInTabs(numTabsToOpen, aWindow) {
+ return new Promise(resolve => {
+ Services.tm.dispatchToMainThread(() => {
+ resolve(this.confirmOpenInTabs(numTabsToOpen, aWindow));
+ });
+ });
+ },
+};
diff --git a/browser/modules/PageActions.jsm b/browser/modules/PageActions.jsm
new file mode 100644
index 0000000000..c3dc272823
--- /dev/null
+++ b/browser/modules/PageActions.jsm
@@ -0,0 +1,1266 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = [
+ "PageActions",
+ // PageActions.Action
+ // PageActions.ACTION_ID_BOOKMARK
+ // PageActions.ACTION_ID_BUILT_IN_SEPARATOR
+ // PageActions.ACTION_ID_TRANSIENT_SEPARATOR
+];
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
+ BinarySearch: "resource://gre/modules/BinarySearch.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+const ACTION_ID_BOOKMARK = "bookmark";
+const ACTION_ID_BUILT_IN_SEPARATOR = "builtInSeparator";
+const ACTION_ID_TRANSIENT_SEPARATOR = "transientSeparator";
+
+const PREF_PERSISTED_ACTIONS = "browser.pageActions.persistedActions";
+const PERSISTED_ACTIONS_CURRENT_VERSION = 1;
+
+// Escapes the given raw URL string, and returns an equivalent CSS url()
+// value for it.
+function escapeCSSURL(url) {
+ return `url("${url.replace(/[\\\s"]/g, encodeURIComponent)}")`;
+}
+
+var PageActions = {
+ /**
+ * Initializes PageActions.
+ *
+ * @param {boolean} addShutdownBlocker
+ * This param exists only for tests. Normally the default value of true
+ * must be used.
+ */
+ init(addShutdownBlocker = true) {
+ this._initBuiltInActions();
+
+ let callbacks = this._deferredAddActionCalls;
+ delete this._deferredAddActionCalls;
+
+ this._loadPersistedActions();
+
+ // Register the built-in actions, which are defined below in this file.
+ for (let options of gBuiltInActions) {
+ if (!this.actionForID(options.id)) {
+ this._registerAction(new Action(options));
+ }
+ }
+
+ // Now place them all in each window. Instead of splitting the register and
+ // place steps, we could simply call addAction, which does both, but doing
+ // it this way means that all windows initially place their actions in the
+ // urlbar the same way -- placeAllActions -- regardless of whether they're
+ // open when this method is called or opened later.
+ for (let bpa of allBrowserPageActions()) {
+ bpa.placeAllActionsInUrlbar();
+ }
+
+ // These callbacks are deferred until init happens and all built-in actions
+ // are added.
+ while (callbacks && callbacks.length) {
+ callbacks.shift()();
+ }
+
+ if (addShutdownBlocker) {
+ // Purge removed actions from persisted state on shutdown. The point is
+ // not to do it on Action.remove(). That way actions that are removed and
+ // re-added while the app is running will have their urlbar placement and
+ // other state remembered and restored. This happens for upgraded and
+ // downgraded extensions, for example.
+ lazy.AsyncShutdown.profileBeforeChange.addBlocker(
+ "PageActions: purging unregistered actions from cache",
+ () => this._purgeUnregisteredPersistedActions()
+ );
+ }
+ },
+
+ _deferredAddActionCalls: [],
+
+ /**
+ * A list of all Action objects, not in any particular order. Not live.
+ * (array of Action objects)
+ */
+ get actions() {
+ let lists = [
+ this._builtInActions,
+ this._nonBuiltInActions,
+ this._transientActions,
+ ];
+ return lists.reduce((memo, list) => memo.concat(list), []);
+ },
+
+ /**
+ * The list of Action objects that should appear in the panel for a given
+ * window, sorted in the order in which they appear. If there are both
+ * built-in and non-built-in actions, then the list will include the separator
+ * between the two. The list is not live. (array of Action objects)
+ *
+ * @param browserWindow (DOM window, required)
+ * This window's actions will be returned.
+ * @return (array of PageAction.Action objects) The actions currently in the
+ * given window's panel.
+ */
+ actionsInPanel(browserWindow) {
+ function filter(action) {
+ return action.shouldShowInPanel(browserWindow);
+ }
+ let actions = this._builtInActions.filter(filter);
+ let nonBuiltInActions = this._nonBuiltInActions.filter(filter);
+ if (nonBuiltInActions.length) {
+ if (actions.length) {
+ actions.push(
+ new Action({
+ id: ACTION_ID_BUILT_IN_SEPARATOR,
+ _isSeparator: true,
+ })
+ );
+ }
+ actions.push(...nonBuiltInActions);
+ }
+ let transientActions = this._transientActions.filter(filter);
+ if (transientActions.length) {
+ if (actions.length) {
+ actions.push(
+ new Action({
+ id: ACTION_ID_TRANSIENT_SEPARATOR,
+ _isSeparator: true,
+ })
+ );
+ }
+ actions.push(...transientActions);
+ }
+ return actions;
+ },
+
+ /**
+ * The list of actions currently in the urlbar, sorted in the order in which
+ * they appear. Not live.
+ *
+ * @param browserWindow (DOM window, required)
+ * This window's actions will be returned.
+ * @return (array of PageAction.Action objects) The actions currently in the
+ * given window's urlbar.
+ */
+ actionsInUrlbar(browserWindow) {
+ // Remember that IDs in idsInUrlbar may belong to actions that aren't
+ // currently registered.
+ return this._persistedActions.idsInUrlbar.reduce((actions, id) => {
+ let action = this.actionForID(id);
+ if (action && action.shouldShowInUrlbar(browserWindow)) {
+ actions.push(action);
+ }
+ return actions;
+ }, []);
+ },
+
+ /**
+ * Gets an action.
+ *
+ * @param id (string, required)
+ * The ID of the action to get.
+ * @return The Action object, or null if none.
+ */
+ actionForID(id) {
+ return this._actionsByID.get(id);
+ },
+
+ /**
+ * Registers an action.
+ *
+ * Actions are registered by their IDs. An error is thrown if an action with
+ * the given ID has already been added. Use actionForID() before calling this
+ * method if necessary.
+ *
+ * Be sure to call remove() on the action if the lifetime of the code that
+ * owns it is shorter than the browser's -- if it lives in an extension, for
+ * example.
+ *
+ * @param action (Action, required)
+ * The Action object to register.
+ * @return The given Action.
+ */
+ addAction(action) {
+ if (this._deferredAddActionCalls) {
+ // init() hasn't been called yet. Defer all additions until it's called,
+ // at which time _deferredAddActionCalls will be deleted.
+ this._deferredAddActionCalls.push(() => this.addAction(action));
+ return action;
+ }
+ this._registerAction(action);
+ for (let bpa of allBrowserPageActions()) {
+ bpa.placeAction(action);
+ }
+ return action;
+ },
+
+ _registerAction(action) {
+ if (this.actionForID(action.id)) {
+ throw new Error(`Action with ID '${action.id}' already added`);
+ }
+ this._actionsByID.set(action.id, action);
+
+ // Insert the action into the appropriate list, either _builtInActions or
+ // _nonBuiltInActions.
+
+ // Keep in mind that _insertBeforeActionID may be present but null, which
+ // means the action should be appended to the built-ins.
+ if ("__insertBeforeActionID" in action) {
+ // A "semi-built-in" action, probably an action from an extension
+ // bundled with the browser. Right now we simply assume that no other
+ // consumers will use _insertBeforeActionID.
+ let index = !action.__insertBeforeActionID
+ ? -1
+ : this._builtInActions.findIndex(a => {
+ return a.id == action.__insertBeforeActionID;
+ });
+ if (index < 0) {
+ // Append the action (excluding transient actions).
+ index = this._builtInActions.filter(a => !a.__transient).length;
+ }
+ this._builtInActions.splice(index, 0, action);
+ } else if (action.__transient) {
+ // A transient action.
+ this._transientActions.push(action);
+ } else if (action._isBuiltIn) {
+ // A built-in action. These are mostly added on init before all other
+ // actions, one after the other. Extension actions load later and should
+ // be at the end, so just push onto the array.
+ this._builtInActions.push(action);
+ } else {
+ // A non-built-in action, like a non-bundled extension potentially.
+ // Keep this list sorted by title.
+ let index = lazy.BinarySearch.insertionIndexOf(
+ (a1, a2) => {
+ return a1.getTitle().localeCompare(a2.getTitle());
+ },
+ this._nonBuiltInActions,
+ action
+ );
+ this._nonBuiltInActions.splice(index, 0, action);
+ }
+
+ let isNew = !this._persistedActions.ids.includes(action.id);
+ if (isNew) {
+ // The action is new. Store it in the persisted actions.
+ this._persistedActions.ids.push(action.id);
+ }
+
+ // Actions are always pinned to the urlbar, except for panel separators.
+ action._pinnedToUrlbar = !action.__isSeparator;
+ this._updateIDsPinnedToUrlbarForAction(action);
+ },
+
+ _updateIDsPinnedToUrlbarForAction(action) {
+ let index = this._persistedActions.idsInUrlbar.indexOf(action.id);
+ if (action.pinnedToUrlbar) {
+ if (index < 0) {
+ index =
+ action.id == ACTION_ID_BOOKMARK
+ ? -1
+ : this._persistedActions.idsInUrlbar.indexOf(ACTION_ID_BOOKMARK);
+ if (index < 0) {
+ index = this._persistedActions.idsInUrlbar.length;
+ }
+ this._persistedActions.idsInUrlbar.splice(index, 0, action.id);
+ }
+ } else if (index >= 0) {
+ this._persistedActions.idsInUrlbar.splice(index, 1);
+ }
+ this._storePersistedActions();
+ },
+
+ // These keep track of currently registered actions.
+ _builtInActions: [],
+ _nonBuiltInActions: [],
+ _transientActions: [],
+ _actionsByID: new Map(),
+
+ /**
+ * Call this when an action is removed.
+ *
+ * @param action (Action object, required)
+ * The action that was removed.
+ */
+ onActionRemoved(action) {
+ if (!this.actionForID(action.id)) {
+ // The action isn't registered (yet). Not an error.
+ return;
+ }
+
+ this._actionsByID.delete(action.id);
+ let lists = [
+ this._builtInActions,
+ this._nonBuiltInActions,
+ this._transientActions,
+ ];
+ for (let list of lists) {
+ let index = list.findIndex(a => a.id == action.id);
+ if (index >= 0) {
+ list.splice(index, 1);
+ break;
+ }
+ }
+
+ for (let bpa of allBrowserPageActions()) {
+ bpa.removeAction(action);
+ }
+ },
+
+ /**
+ * Call this when an action's pinnedToUrlbar property changes.
+ *
+ * @param action (Action object, required)
+ * The action whose pinnedToUrlbar property changed.
+ */
+ onActionToggledPinnedToUrlbar(action) {
+ if (!this.actionForID(action.id)) {
+ // This may be called before the action has been added.
+ return;
+ }
+ this._updateIDsPinnedToUrlbarForAction(action);
+ for (let bpa of allBrowserPageActions()) {
+ bpa.placeActionInUrlbar(action);
+ }
+ },
+
+ // For tests. See Bug 1413692.
+ _reset() {
+ PageActions._purgeUnregisteredPersistedActions();
+ PageActions._builtInActions = [];
+ PageActions._nonBuiltInActions = [];
+ PageActions._transientActions = [];
+ PageActions._actionsByID = new Map();
+ },
+
+ _storePersistedActions() {
+ let json = JSON.stringify(this._persistedActions);
+ Services.prefs.setStringPref(PREF_PERSISTED_ACTIONS, json);
+ },
+
+ _loadPersistedActions() {
+ let actions;
+ try {
+ let json = Services.prefs.getStringPref(PREF_PERSISTED_ACTIONS);
+ actions = this._migratePersistedActions(JSON.parse(json));
+ } catch (ex) {}
+
+ // Handle migrating to and from Proton. We want to gracefully handle
+ // downgrades from Proton, and since Proton is controlled by a pref, we also
+ // don't want to assume that a downgrade is possible only by downgrading the
+ // app. That makes it hard to use the normal migration approach of creating
+ // a new persisted actions version, so we handle Proton migration specially.
+ // We try-catch it separately from the earlier _migratePersistedActions call
+ // because it should not be short-circuited when the pref load or usual
+ // migration fails.
+ try {
+ actions = this._migratePersistedActionsProton(actions);
+ } catch (ex) {}
+
+ // If `actions` is still not defined, then this._persistedActions will
+ // remain its default value.
+ if (actions) {
+ this._persistedActions = actions;
+ }
+ },
+
+ _purgeUnregisteredPersistedActions() {
+ // Remove all action IDs from persisted state that do not correspond to
+ // currently registered actions.
+ for (let name of ["ids", "idsInUrlbar"]) {
+ this._persistedActions[name] = this._persistedActions[name].filter(id => {
+ return this.actionForID(id);
+ });
+ }
+ this._storePersistedActions();
+ },
+
+ _migratePersistedActions(actions) {
+ // Start with actions.version and migrate one version at a time, all the way
+ // up to the current version.
+ for (
+ let version = actions.version || 0;
+ version < PERSISTED_ACTIONS_CURRENT_VERSION;
+ version++
+ ) {
+ let methodName = `_migratePersistedActionsTo${version + 1}`;
+ actions = this[methodName](actions);
+ actions.version = version + 1;
+ }
+ return actions;
+ },
+
+ _migratePersistedActionsTo1(actions) {
+ // The `ids` object is a mapping: action ID => true. Convert it to an array
+ // to save space in the prefs.
+ let ids = [];
+ for (let id in actions.ids) {
+ ids.push(id);
+ }
+ // Move the bookmark ID to the end of idsInUrlbar. The bookmark action
+ // should always remain at the end of the urlbar, if present.
+ let bookmarkIndex = actions.idsInUrlbar.indexOf(ACTION_ID_BOOKMARK);
+ if (bookmarkIndex >= 0) {
+ actions.idsInUrlbar.splice(bookmarkIndex, 1);
+ actions.idsInUrlbar.push(ACTION_ID_BOOKMARK);
+ }
+ return {
+ ids,
+ idsInUrlbar: actions.idsInUrlbar,
+ };
+ },
+
+ _migratePersistedActionsProton(actions) {
+ if (actions?.idsInUrlbarPreProton) {
+ // continue with Proton
+ } else if (actions) {
+ // upgrade to Proton
+ actions.idsInUrlbarPreProton = [...(actions.idsInUrlbar || [])];
+ } else {
+ // new profile with Proton
+ actions = {
+ ids: [],
+ idsInUrlbar: [],
+ idsInUrlbarPreProton: [],
+ version: PERSISTED_ACTIONS_CURRENT_VERSION,
+ };
+ }
+ return actions;
+ },
+
+ // This keeps track of all actions, even those that are not currently
+ // registered because they have been removed, so long as
+ // _purgeUnregisteredPersistedActions has not been called.
+ _persistedActions: {
+ version: PERSISTED_ACTIONS_CURRENT_VERSION,
+ // action IDs that have ever been seen and not removed, order not important
+ ids: [],
+ // action IDs ordered by position in urlbar
+ idsInUrlbar: [],
+ },
+};
+
+/**
+ * A single page action.
+ *
+ * Each action can have both per-browser-window state and global state.
+ * Per-window state takes precedence over global state. This is reflected in
+ * the title, tooltip, disabled, and icon properties. Each of these properties
+ * has a getter method and setter method that takes a browser window. Pass null
+ * to get the action's global state. Pass a browser window to get the per-
+ * window state. However, if you pass a window and the action has no state for
+ * that window, then the global state will be returned.
+ *
+ * `options` is a required object with the following properties. Regarding the
+ * properties discussed in the previous paragraph, the values in `options` set
+ * global state.
+ *
+ * @param id (string, required)
+ * The action's ID. Treat this like the ID of a DOM node.
+ * @param title (string, optional)
+ * The action's title. It is optional for built in actions.
+ * @param anchorIDOverride (string, optional)
+ * Pass a string to override the node to which the action's activated-
+ * action panel is anchored.
+ * @param disabled (bool, optional)
+ * Pass true to cause the action to be disabled initially in all browser
+ * windows. False by default.
+ * @param extensionID (string, optional)
+ * If the action lives in an extension, pass its ID.
+ * @param iconURL (string or object, optional)
+ * The URL string of the action's icon. Usually you want to specify an
+ * icon in CSS, but this option is useful if that would be a pain for
+ * some reason. You can also pass an object that maps pixel sizes to
+ * URLs, like { 16: url16, 32: url32 }. The best size for the user's
+ * screen will be used.
+ * @param isBadged (bool, optional)
+ * If true, the toolbarbutton for this action will get a
+ * "badged" attribute.
+ * @param onBeforePlacedInWindow (function, optional)
+ * Called before the action is placed in the window:
+ * onBeforePlacedInWindow(window)
+ * * window: The window that the action will be placed in.
+ * @param onCommand (function, optional)
+ * Called when the action is clicked, but only if it has neither a
+ * subview nor an iframe:
+ * onCommand(event, buttonNode)
+ * * event: The triggering event.
+ * * buttonNode: The button node that was clicked.
+ * @param onIframeHiding (function, optional)
+ * Called when the action's iframe is hiding:
+ * onIframeHiding(iframeNode, parentPanelNode)
+ * * iframeNode: The iframe.
+ * * parentPanelNode: The panel node in which the iframe is shown.
+ * @param onIframeHidden (function, optional)
+ * Called when the action's iframe is hidden:
+ * onIframeHidden(iframeNode, parentPanelNode)
+ * * iframeNode: The iframe.
+ * * parentPanelNode: The panel node in which the iframe is shown.
+ * @param onIframeShowing (function, optional)
+ * Called when the action's iframe is showing to the user:
+ * onIframeShowing(iframeNode, parentPanelNode)
+ * * iframeNode: The iframe.
+ * * parentPanelNode: The panel node in which the iframe is shown.
+ * @param onLocationChange (function, optional)
+ * Called after tab switch or when the current <browser>'s location
+ * changes:
+ * onLocationChange(browserWindow)
+ * * browserWindow: The browser window containing the tab switch or
+ * changed <browser>.
+ * @param onPlacedInPanel (function, optional)
+ * Called when the action is added to the page action panel in a browser
+ * window:
+ * onPlacedInPanel(buttonNode)
+ * * buttonNode: The action's node in the page action panel.
+ * @param onPlacedInUrlbar (function, optional)
+ * Called when the action is added to the urlbar in a browser window:
+ * onPlacedInUrlbar(buttonNode)
+ * * buttonNode: The action's node in the urlbar.
+ * @param onRemovedFromWindow (function, optional)
+ * Called after the action is removed from a browser window:
+ * onRemovedFromWindow(browserWindow)
+ * * browserWindow: The browser window that the action was removed from.
+ * @param onShowingInPanel (function, optional)
+ * Called when a browser window's page action panel is showing:
+ * onShowingInPanel(buttonNode)
+ * * buttonNode: The action's node in the page action panel.
+ * @param onSubviewPlaced (function, optional)
+ * Called when the action's subview is added to its parent panel in a
+ * browser window:
+ * onSubviewPlaced(panelViewNode)
+ * * panelViewNode: The subview's panelview node.
+ * @param onSubviewShowing (function, optional)
+ * Called when the action's subview is showing in a browser window:
+ * onSubviewShowing(panelViewNode)
+ * * panelViewNode: The subview's panelview node.
+ * @param pinnedToUrlbar (bool, optional)
+ * Pass true to pin the action to the urlbar. An action is shown in the
+ * urlbar if it's pinned and not disabled. False by default.
+ * @param tooltip (string, optional)
+ * The action's button tooltip text.
+ * @param urlbarIDOverride (string, optional)
+ * Usually the ID of the action's button in the urlbar will be generated
+ * automatically. Pass a string for this property to override that with
+ * your own ID.
+ * @param wantsIframe (bool, optional)
+ * Pass true to make an action that shows an iframe in a panel when
+ * clicked.
+ * @param wantsSubview (bool, optional)
+ * Pass true to make an action that shows a panel subview when clicked.
+ * @param disablePrivateBrowsing (bool, optional)
+ * Pass true to prevent the action from showing in a private browsing window.
+ */
+function Action(options) {
+ setProperties(this, options, {
+ id: true,
+ title: false,
+ anchorIDOverride: false,
+ disabled: false,
+ extensionID: false,
+ iconURL: false,
+ isBadged: false,
+ labelForHistogram: false,
+ onBeforePlacedInWindow: false,
+ onCommand: false,
+ onIframeHiding: false,
+ onIframeHidden: false,
+ onIframeShowing: false,
+ onLocationChange: false,
+ onPlacedInPanel: false,
+ onPlacedInUrlbar: false,
+ onRemovedFromWindow: false,
+ onShowingInPanel: false,
+ onSubviewPlaced: false,
+ onSubviewShowing: false,
+ onPinToUrlbarToggled: false,
+ pinnedToUrlbar: false,
+ tooltip: false,
+ urlbarIDOverride: false,
+ wantsIframe: false,
+ wantsSubview: false,
+ disablePrivateBrowsing: false,
+
+ // private
+
+ // (string, optional)
+ // The ID of another action before which to insert this new action in the
+ // panel.
+ _insertBeforeActionID: false,
+
+ // (bool, optional)
+ // True if this isn't really an action but a separator to be shown in the
+ // page action panel.
+ _isSeparator: false,
+
+ // (bool, optional)
+ // Transient actions have a couple of special properties: (1) They stick to
+ // the bottom of the panel, and (2) they're hidden in the panel when they're
+ // disabled. Other than that they behave like other actions.
+ _transient: false,
+
+ // (bool, optional)
+ // True if the action's urlbar button is defined in markup. In that case, a
+ // node with the action's urlbar node ID should already exist in the DOM
+ // (either the auto-generated ID or urlbarIDOverride). That node will be
+ // shown when the action is added to the urlbar and hidden when the action
+ // is removed from the urlbar.
+ _urlbarNodeInMarkup: false,
+ });
+
+ /**
+ * A cache of the pre-computed CSS variable values for a given icon
+ * URLs object, as passed to _createIconProperties.
+ */
+ this._iconProperties = new WeakMap();
+
+ /**
+ * The global values for the action properties.
+ */
+ this._globalProps = {
+ disabled: this._disabled,
+ iconURL: this._iconURL,
+ iconProps: this._createIconProperties(this._iconURL),
+ title: this._title,
+ tooltip: this._tooltip,
+ wantsSubview: this._wantsSubview,
+ };
+
+ /**
+ * A mapping of window-specific action property objects, each of which
+ * derives from the _globalProps object.
+ */
+ this._windowProps = new WeakMap();
+}
+
+Action.prototype = {
+ /**
+ * The ID of the action's parent extension (string)
+ */
+ get extensionID() {
+ return this._extensionID;
+ },
+
+ /**
+ * The action's ID (string)
+ */
+ get id() {
+ return this._id;
+ },
+
+ get disablePrivateBrowsing() {
+ return !!this._disablePrivateBrowsing;
+ },
+
+ /**
+ * Verifies that the action can be shown in a private window. For
+ * extensions, verifies the extension has access to the window.
+ */
+ canShowInWindow(browserWindow) {
+ if (this._extensionID) {
+ let policy = WebExtensionPolicy.getByID(this._extensionID);
+ if (!policy.canAccessWindow(browserWindow)) {
+ return false;
+ }
+ }
+ return !(
+ this.disablePrivateBrowsing &&
+ lazy.PrivateBrowsingUtils.isWindowPrivate(browserWindow)
+ );
+ },
+
+ /**
+ * True if the action is pinned to the urlbar. The action is shown in the
+ * urlbar if it's pinned and not disabled. (bool)
+ */
+ get pinnedToUrlbar() {
+ return this._pinnedToUrlbar || false;
+ },
+ set pinnedToUrlbar(shown) {
+ if (this.pinnedToUrlbar != shown) {
+ this._pinnedToUrlbar = shown;
+ PageActions.onActionToggledPinnedToUrlbar(this);
+ this.onPinToUrlbarToggled();
+ }
+ },
+
+ /**
+ * The action's disabled state (bool)
+ */
+ getDisabled(browserWindow = null) {
+ return !!this._getProperties(browserWindow).disabled;
+ },
+ setDisabled(value, browserWindow = null) {
+ return this._setProperty("disabled", !!value, browserWindow);
+ },
+
+ /**
+ * The action's icon URL string, or an object mapping sizes to URL strings
+ * (string or object)
+ */
+ getIconURL(browserWindow = null) {
+ return this._getProperties(browserWindow).iconURL;
+ },
+ setIconURL(value, browserWindow = null) {
+ let props = this._getProperties(browserWindow, !!browserWindow);
+ props.iconURL = value;
+ props.iconProps = this._createIconProperties(value);
+
+ this._updateProperty("iconURL", props.iconProps, browserWindow);
+ return value;
+ },
+
+ /**
+ * The set of CSS variables which define the action's icons in various
+ * sizes. This is generated automatically from the iconURL property.
+ */
+ getIconProperties(browserWindow = null) {
+ return this._getProperties(browserWindow).iconProps;
+ },
+
+ _createIconProperties(urls) {
+ if (urls && typeof urls == "object") {
+ let props = this._iconProperties.get(urls);
+ if (!props) {
+ props = Object.freeze({
+ "--pageAction-image-16px": escapeCSSURL(
+ this._iconURLForSize(urls, 16)
+ ),
+ "--pageAction-image-32px": escapeCSSURL(
+ this._iconURLForSize(urls, 32)
+ ),
+ });
+ this._iconProperties.set(urls, props);
+ }
+ return props;
+ }
+
+ let cssURL = urls ? escapeCSSURL(urls) : null;
+ return Object.freeze({
+ "--pageAction-image-16px": cssURL,
+ "--pageAction-image-32px": cssURL,
+ });
+ },
+
+ /**
+ * The action's title (string). Note, built in actions will
+ * not have a title property.
+ */
+ getTitle(browserWindow = null) {
+ return this._getProperties(browserWindow).title;
+ },
+ setTitle(value, browserWindow = null) {
+ return this._setProperty("title", value, browserWindow);
+ },
+
+ /**
+ * The action's tooltip (string)
+ */
+ getTooltip(browserWindow = null) {
+ return this._getProperties(browserWindow).tooltip;
+ },
+ setTooltip(value, browserWindow = null) {
+ return this._setProperty("tooltip", value, browserWindow);
+ },
+
+ /**
+ * Whether the action wants a subview (bool)
+ */
+ getWantsSubview(browserWindow = null) {
+ return !!this._getProperties(browserWindow).wantsSubview;
+ },
+ setWantsSubview(value, browserWindow = null) {
+ return this._setProperty("wantsSubview", !!value, browserWindow);
+ },
+
+ /**
+ * Sets a property, optionally for a particular browser window.
+ *
+ * @param name (string, required)
+ * The (non-underscored) name of the property.
+ * @param value
+ * The value.
+ * @param browserWindow (DOM window, optional)
+ * If given, then the property will be set in this window's state, not
+ * globally.
+ */
+ _setProperty(name, value, browserWindow) {
+ let props = this._getProperties(browserWindow, !!browserWindow);
+ props[name] = value;
+
+ this._updateProperty(name, value, browserWindow);
+ return value;
+ },
+
+ _updateProperty(name, value, browserWindow) {
+ // This may be called before the action has been added.
+ if (PageActions.actionForID(this.id)) {
+ for (let bpa of allBrowserPageActions(browserWindow)) {
+ bpa.updateAction(this, name, { value });
+ }
+ }
+ },
+
+ /**
+ * Returns the properties object for the given window, if it exists,
+ * or the global properties object if no window-specific properties
+ * exist.
+ *
+ * @param {Window?} window
+ * The window for which to return the properties object, or
+ * null to return the global properties object.
+ * @param {bool} [forceWindowSpecific = false]
+ * If true, always returns a window-specific properties object.
+ * If a properties object does not exist for the given window,
+ * one is created and cached.
+ * @returns {object}
+ */
+ _getProperties(window, forceWindowSpecific = false) {
+ let props = window && this._windowProps.get(window);
+
+ if (!props && forceWindowSpecific) {
+ props = Object.create(this._globalProps);
+ this._windowProps.set(window, props);
+ }
+
+ return props || this._globalProps;
+ },
+
+ /**
+ * Override for the ID of the action's activated-action panel anchor (string)
+ */
+ get anchorIDOverride() {
+ return this._anchorIDOverride;
+ },
+
+ /**
+ * Override for the ID of the action's urlbar node (string)
+ */
+ get urlbarIDOverride() {
+ return this._urlbarIDOverride;
+ },
+
+ /**
+ * True if the action is shown in an iframe (bool)
+ */
+ get wantsIframe() {
+ return this._wantsIframe || false;
+ },
+
+ get isBadged() {
+ return this._isBadged || false;
+ },
+
+ get labelForHistogram() {
+ // The histogram label value has a length limit of 20 and restricted to a
+ // pattern. See MAX_LABEL_LENGTH and CPP_IDENTIFIER_PATTERN in
+ // toolkit/components/telemetry/parse_histograms.py
+ return (
+ this._labelForHistogram ||
+ this._id.replace(/_\w{1}/g, match => match[1].toUpperCase()).substr(0, 20)
+ );
+ },
+
+ /**
+ * Selects the best matching icon from the given URLs object for the
+ * given preferred size.
+ *
+ * @param {object} urls
+ * An object containing square icons of various sizes. The name
+ * of each property is its width, and the value is its image URL.
+ * @param {integer} peferredSize
+ * The preferred icon width. The most appropriate icon in the
+ * urls object will be chosen to match that size. An exact
+ * match will be preferred, followed by an icon exactly double
+ * the size, followed by the smallest icon larger than the
+ * preferred size, followed by the largest available icon.
+ * @returns {string}
+ * The chosen icon URL.
+ */
+ _iconURLForSize(urls, preferredSize) {
+ // This case is copied from ExtensionParent.jsm so that our image logic is
+ // the same, so that WebExtensions page action tests that deal with icons
+ // pass.
+ let bestSize = null;
+ if (urls[preferredSize]) {
+ bestSize = preferredSize;
+ } else if (urls[2 * preferredSize]) {
+ bestSize = 2 * preferredSize;
+ } else {
+ let sizes = Object.keys(urls)
+ .map(key => parseInt(key, 10))
+ .sort((a, b) => a - b);
+ bestSize =
+ sizes.find(candidate => candidate > preferredSize) || sizes.pop();
+ }
+ return urls[bestSize];
+ },
+
+ /**
+ * Performs the command for an action. If the action has an onCommand
+ * handler, then it's called. If the action has a subview or iframe, then a
+ * panel is opened, displaying the subview or iframe.
+ *
+ * @param browserWindow (DOM window, required)
+ * The browser window in which to perform the action.
+ */
+ doCommand(browserWindow) {
+ browserPageActions(browserWindow).doCommandForAction(this);
+ },
+
+ /**
+ * Call this when before placing the action in the window.
+ *
+ * @param browserWindow (DOM window, required)
+ * The browser window the action will be placed in.
+ */
+ onBeforePlacedInWindow(browserWindow) {
+ if (this._onBeforePlacedInWindow) {
+ this._onBeforePlacedInWindow(browserWindow);
+ }
+ },
+
+ /**
+ * Call this when the user activates the action.
+ *
+ * @param event (DOM event, required)
+ * The triggering event.
+ * @param buttonNode (DOM node, required)
+ * The action's panel or urlbar button node that was clicked.
+ */
+ onCommand(event, buttonNode) {
+ if (this._onCommand) {
+ this._onCommand(event, buttonNode);
+ }
+ },
+
+ /**
+ * Call this when the action's iframe is hiding.
+ *
+ * @param iframeNode (DOM node, required)
+ * The iframe that's hiding.
+ * @param parentPanelNode (DOM node, required)
+ * The panel in which the iframe is hiding.
+ */
+ onIframeHiding(iframeNode, parentPanelNode) {
+ if (this._onIframeHiding) {
+ this._onIframeHiding(iframeNode, parentPanelNode);
+ }
+ },
+
+ /**
+ * Call this when the action's iframe is hidden.
+ *
+ * @param iframeNode (DOM node, required)
+ * The iframe that's being hidden.
+ * @param parentPanelNode (DOM node, required)
+ * The panel in which the iframe is hidden.
+ */
+ onIframeHidden(iframeNode, parentPanelNode) {
+ if (this._onIframeHidden) {
+ this._onIframeHidden(iframeNode, parentPanelNode);
+ }
+ },
+
+ /**
+ * Call this when the action's iframe is showing.
+ *
+ * @param iframeNode (DOM node, required)
+ * The iframe that's being shown.
+ * @param parentPanelNode (DOM node, required)
+ * The panel in which the iframe is shown.
+ */
+ onIframeShowing(iframeNode, parentPanelNode) {
+ if (this._onIframeShowing) {
+ this._onIframeShowing(iframeNode, parentPanelNode);
+ }
+ },
+
+ /**
+ * Call this on tab switch or when the current <browser>'s location changes.
+ *
+ * @param browserWindow (DOM window, required)
+ * The browser window containing the tab switch or changed <browser>.
+ */
+ onLocationChange(browserWindow) {
+ if (this._onLocationChange) {
+ this._onLocationChange(browserWindow);
+ }
+ },
+
+ /**
+ * Call this when a DOM node for the action is added to the page action panel.
+ *
+ * @param buttonNode (DOM node, required)
+ * The action's panel button node.
+ */
+ onPlacedInPanel(buttonNode) {
+ if (this._onPlacedInPanel) {
+ this._onPlacedInPanel(buttonNode);
+ }
+ },
+
+ /**
+ * Call this when a DOM node for the action is added to the urlbar.
+ *
+ * @param buttonNode (DOM node, required)
+ * The action's urlbar button node.
+ */
+ onPlacedInUrlbar(buttonNode) {
+ if (this._onPlacedInUrlbar) {
+ this._onPlacedInUrlbar(buttonNode);
+ }
+ },
+
+ /**
+ * Call this when the DOM nodes for the action are removed from a browser
+ * window.
+ *
+ * @param browserWindow (DOM window, required)
+ * The browser window the action was removed from.
+ */
+ onRemovedFromWindow(browserWindow) {
+ if (this._onRemovedFromWindow) {
+ this._onRemovedFromWindow(browserWindow);
+ }
+ },
+
+ /**
+ * Call this when the action's button is shown in the page action panel.
+ *
+ * @param buttonNode (DOM node, required)
+ * The action's panel button node.
+ */
+ onShowingInPanel(buttonNode) {
+ if (this._onShowingInPanel) {
+ this._onShowingInPanel(buttonNode);
+ }
+ },
+
+ /**
+ * Call this when a panelview node for the action's subview is added to the
+ * DOM.
+ *
+ * @param panelViewNode (DOM node, required)
+ * The subview's panelview node.
+ */
+ onSubviewPlaced(panelViewNode) {
+ if (this._onSubviewPlaced) {
+ this._onSubviewPlaced(panelViewNode);
+ }
+ },
+
+ /**
+ * Call this when a panelview node for the action's subview is showing.
+ *
+ * @param panelViewNode (DOM node, required)
+ * The subview's panelview node.
+ */
+ onSubviewShowing(panelViewNode) {
+ if (this._onSubviewShowing) {
+ this._onSubviewShowing(panelViewNode);
+ }
+ },
+ /**
+ * Call this when an icon in the url is pinned or unpinned.
+ */
+ onPinToUrlbarToggled() {
+ if (this._onPinToUrlbarToggled) {
+ this._onPinToUrlbarToggled();
+ }
+ },
+
+ /**
+ * Removes the action's DOM nodes from all browser windows.
+ *
+ * PageActions will remember the action's urlbar placement, if any, after this
+ * method is called until app shutdown. If the action is not added again
+ * before shutdown, then PageActions will discard the placement, and the next
+ * time the action is added, its placement will be reset.
+ */
+ remove() {
+ PageActions.onActionRemoved(this);
+ },
+
+ /**
+ * Returns whether the action should be shown in a given window's panel.
+ *
+ * @param browserWindow (DOM window, required)
+ * The window.
+ * @return True if the action should be shown and false otherwise. Actions
+ * are always shown in the panel unless they're both transient and
+ * disabled.
+ */
+ shouldShowInPanel(browserWindow) {
+ // When Proton is enabled, the extension page actions should behave similarly
+ // to a transient action, and be hidden from the urlbar overflow menu if they
+ // are disabled (as in the urlbar when the overflow menu isn't available)
+ //
+ // TODO(Bug 1704139): as a follow up we may look into just set on all
+ // extensions pageActions `_transient: true`, at least once we sunset
+ // the proton preference and we don't need the pre-Proton behavior anymore,
+ // and remove this special case.
+ const isProtonExtensionAction = this.extensionID;
+
+ return (
+ (!(this.__transient || isProtonExtensionAction) ||
+ !this.getDisabled(browserWindow)) &&
+ this.canShowInWindow(browserWindow)
+ );
+ },
+
+ /**
+ * Returns whether the action should be shown in a given window's urlbar.
+ *
+ * @param browserWindow (DOM window, required)
+ * The window.
+ * @return True if the action should be shown and false otherwise. The action
+ * should be shown if it's both pinned and not disabled.
+ */
+ shouldShowInUrlbar(browserWindow) {
+ return (
+ this.pinnedToUrlbar &&
+ !this.getDisabled(browserWindow) &&
+ this.canShowInWindow(browserWindow)
+ );
+ },
+
+ get _isBuiltIn() {
+ let builtInIDs = ["screenshots_mozilla_org"].concat(
+ gBuiltInActions.filter(a => !a.__isSeparator).map(a => a.id)
+ );
+ return builtInIDs.includes(this.id);
+ },
+
+ get _isMozillaAction() {
+ return this._isBuiltIn || this.id == "webcompat-reporter_mozilla_org";
+ },
+};
+
+PageActions.Action = Action;
+
+PageActions.ACTION_ID_BUILT_IN_SEPARATOR = ACTION_ID_BUILT_IN_SEPARATOR;
+PageActions.ACTION_ID_TRANSIENT_SEPARATOR = ACTION_ID_TRANSIENT_SEPARATOR;
+
+// These are only necessary so that the test can use them.
+PageActions.ACTION_ID_BOOKMARK = ACTION_ID_BOOKMARK;
+PageActions.PREF_PERSISTED_ACTIONS = PREF_PERSISTED_ACTIONS;
+
+// Sorted in the order in which they should appear in the page action panel.
+// Does not include the page actions of extensions bundled with the browser.
+// They're added by the relevant extension code.
+// NOTE: If you add items to this list (or system add-on actions that we
+// want to keep track of), make sure to also update Histograms.json for the
+// new actions.
+var gBuiltInActions;
+
+PageActions._initBuiltInActions = function () {
+ gBuiltInActions = [
+ // bookmark
+ {
+ id: ACTION_ID_BOOKMARK,
+ urlbarIDOverride: "star-button-box",
+ _urlbarNodeInMarkup: true,
+ pinnedToUrlbar: true,
+ onShowingInPanel(buttonNode) {
+ browserPageActions(buttonNode).bookmark.onShowingInPanel(buttonNode);
+ },
+ onCommand(event, buttonNode) {
+ browserPageActions(buttonNode).bookmark.onCommand(event, buttonNode);
+ },
+ },
+ ];
+};
+
+/**
+ * Gets a BrowserPageActions object in a browser window.
+ *
+ * @param obj
+ * Either a DOM node or a browser window.
+ * @return The BrowserPageActions object in the browser window related to the
+ * given object.
+ */
+function browserPageActions(obj) {
+ if (obj.BrowserPageActions) {
+ return obj.BrowserPageActions;
+ }
+ return obj.ownerGlobal.BrowserPageActions;
+}
+
+/**
+ * A generator function for all open browser windows.
+ *
+ * @param browserWindow (DOM window, optional)
+ * If given, then only this window will be yielded. That may sound
+ * pointless, but it can make callers nicer to write since they don't
+ * need two separate cases, one where a window is given and another where
+ * it isn't.
+ */
+function* allBrowserWindows(browserWindow = null) {
+ if (browserWindow) {
+ yield browserWindow;
+ return;
+ }
+ yield* Services.wm.getEnumerator("navigator:browser");
+}
+
+/**
+ * A generator function for BrowserPageActions objects in all open windows.
+ *
+ * @param browserWindow (DOM window, optional)
+ * If given, then the BrowserPageActions for only this window will be
+ * yielded.
+ */
+function* allBrowserPageActions(browserWindow = null) {
+ for (let win of allBrowserWindows(browserWindow)) {
+ yield browserPageActions(win);
+ }
+}
+
+/**
+ * A simple function that sets properties on a given object while doing basic
+ * required-properties checking. If a required property isn't specified in the
+ * given options object, or if the options object has properties that aren't in
+ * the given schema, then an error is thrown.
+ *
+ * @param obj
+ * The object to set properties on.
+ * @param options
+ * An options object supplied by the consumer.
+ * @param schema
+ * An object a property for each required and optional property. The
+ * keys are property names; the value of a key is a bool that is true if
+ * the property is required.
+ */
+function setProperties(obj, options, schema) {
+ for (let name in schema) {
+ let required = schema[name];
+ if (required && !(name in options)) {
+ throw new Error(`'${name}' must be specified`);
+ }
+ let nameInObj = "_" + name;
+ if (name[0] == "_") {
+ // The property is "private". If it's defined in the options, then define
+ // it on obj exactly as it's defined on options.
+ if (name in options) {
+ obj[nameInObj] = options[name];
+ }
+ } else {
+ // The property is "public". Make sure the property is defined on obj.
+ obj[nameInObj] = options[name] || null;
+ }
+ }
+ for (let name in options) {
+ if (!(name in schema)) {
+ throw new Error(`Unrecognized option '${name}'`);
+ }
+ }
+}
diff --git a/browser/modules/PartnerLinkAttribution.sys.mjs b/browser/modules/PartnerLinkAttribution.sys.mjs
new file mode 100644
index 0000000000..aea221512e
--- /dev/null
+++ b/browser/modules/PartnerLinkAttribution.sys.mjs
@@ -0,0 +1,218 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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, {
+ Region: "resource://gre/modules/Region.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ PingCentre: "resource:///modules/PingCentre.jsm",
+});
+
+// Endpoint base URL for Structured Ingestion
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "structuredIngestionEndpointBase",
+ "browser.newtabpage.activity-stream.telemetry.structuredIngestion.endpoint",
+ ""
+);
+const NAMESPACE_CONTEXUAL_SERVICES = "contextual-services";
+
+// PingCentre client to send custom pings
+XPCOMUtils.defineLazyGetter(lazy, "pingcentre", () => {
+ return new lazy.PingCentre({ topic: "contextual-services" });
+});
+
+// `contextId` is a unique identifier used by Contextual Services
+const CONTEXT_ID_PREF = "browser.contextual-services.contextId";
+XPCOMUtils.defineLazyGetter(lazy, "contextId", () => {
+ let _contextId = Services.prefs.getStringPref(CONTEXT_ID_PREF, null);
+ if (!_contextId) {
+ _contextId = String(Services.uuid.generateUUID());
+ Services.prefs.setStringPref(CONTEXT_ID_PREF, _contextId);
+ }
+ return _contextId;
+});
+
+export const CONTEXTUAL_SERVICES_PING_TYPES = {
+ TOPSITES_IMPRESSION: "topsites-impression",
+ TOPSITES_SELECTION: "topsites-click",
+ QS_BLOCK: "quicksuggest-block",
+ QS_IMPRESSION: "quicksuggest-impression",
+ QS_SELECTION: "quicksuggest-click",
+};
+
+export var PartnerLinkAttribution = {
+ /**
+ * Sends an attribution request to an anonymizing proxy.
+ *
+ * @param {string} targetURL
+ * The URL we are routing through the anonmyzing proxy.
+ * @param {string} source
+ * The source of the anonmized request, e.g. "urlbar".
+ * @param {string} [campaignID]
+ * The campaign ID for attribution. This should be a valid path on the
+ * anonymizing proxy. For example, if `campaignID` was `foo`, we'd send an
+ * attribution request to https://topsites.mozilla.com/cid/foo.
+ * Optional. If it's not provided, we default to the topsites campaign.
+ */
+ async makeRequest({ targetURL, source, campaignID }) {
+ let partner = targetURL.match(/^https?:\/\/(?:www.)?([^.]*)/)[1];
+
+ function record(method, objectString) {
+ recordTelemetryEvent({
+ method,
+ objectString,
+ value: partner,
+ });
+ }
+ record("click", source);
+
+ let attributionUrl = Services.prefs.getStringPref(
+ "browser.partnerlink.attributionURL"
+ );
+ if (!attributionUrl) {
+ record("attribution", "abort");
+ return;
+ }
+
+ // The default campaign is topsites.
+ if (!campaignID) {
+ campaignID = Services.prefs.getStringPref(
+ "browser.partnerlink.campaign.topsites"
+ );
+ }
+ attributionUrl = attributionUrl + campaignID;
+ let result = await sendRequest(attributionUrl, source, targetURL);
+ record("attribution", result ? "success" : "failure");
+ },
+
+ /**
+ * Makes a request to the attribution URL for a search engine search.
+ *
+ * @param {nsISearchEngine} engine
+ * The search engine to save the attribution for.
+ * @param {nsIURI} targetUrl
+ * The target URL to filter and include in the attribution.
+ */
+ async makeSearchEngineRequest(engine, targetUrl) {
+ let cid;
+ if (engine.attribution?.cid) {
+ cid = engine.attribution.cid;
+ } else if (engine.sendAttributionRequest) {
+ cid = Services.prefs.getStringPref(
+ "browser.partnerlink.campaign.topsites"
+ );
+ } else {
+ return;
+ }
+
+ let searchUrlQueryParamName = engine.searchUrlQueryParamName;
+ if (!searchUrlQueryParamName) {
+ console.error("makeSearchEngineRequest can't find search terms key");
+ return;
+ }
+
+ let url = targetUrl;
+ if (typeof url == "string") {
+ url = Services.io.newURI(url);
+ }
+
+ let targetParams = new URLSearchParams(url.query);
+ if (!targetParams.has(searchUrlQueryParamName)) {
+ console.error("makeSearchEngineRequest can't remove target search terms");
+ return;
+ }
+
+ let attributionUrl = Services.prefs.getStringPref(
+ "browser.partnerlink.attributionURL",
+ ""
+ );
+ attributionUrl = attributionUrl + cid;
+
+ targetParams.delete(searchUrlQueryParamName);
+ let strippedTargetUrl = `${url.prePath}${url.filePath}`;
+ let newParams = targetParams.toString();
+ if (newParams) {
+ strippedTargetUrl += "?" + newParams;
+ }
+
+ await sendRequest(attributionUrl, "searchurl", strippedTargetUrl);
+ },
+
+ /**
+ * Sends a Contextual Services ping to the Mozilla data pipeline.
+ *
+ * Note:
+ * * All Contextual Services pings are sent as custom pings
+ * (https://docs.telemetry.mozilla.org/cookbooks/new_ping.html#sending-a-custom-ping)
+ *
+ * * The full event list can be found at https://github.com/mozilla-services/mozilla-pipeline-schemas
+ * under the "contextual-services" namespace
+ *
+ * @param {object} payload
+ * The ping payload to be sent to the Mozilla Structured Ingestion endpoint
+ * @param {String} pingType
+ * The ping type. Must be one of CONTEXTUAL_SERVICES_PING_TYPES
+ */
+ sendContextualServicesPing(payload, pingType) {
+ if (!Object.values(CONTEXTUAL_SERVICES_PING_TYPES).includes(pingType)) {
+ console.error("Invalid Contextual Services ping type");
+ return;
+ }
+
+ const endpoint = makeEndpointUrl(pingType, "1");
+ payload.context_id = lazy.contextId;
+ lazy.pingcentre.sendStructuredIngestionPing(
+ payload,
+ endpoint,
+ NAMESPACE_CONTEXUAL_SERVICES
+ );
+ },
+
+ /**
+ * Gets the underlying PingCentre client, only used for tests.
+ */
+ get _pingCentre() {
+ return lazy.pingcentre;
+ },
+};
+
+async function sendRequest(attributionUrl, source, targetURL) {
+ const request = new Request(attributionUrl);
+ request.headers.set("X-Region", lazy.Region.home);
+ request.headers.set("X-Source", source);
+ request.headers.set("X-Target-URL", targetURL);
+ const response = await fetch(request);
+ return response.ok;
+}
+
+function recordTelemetryEvent({ method, objectString, value }) {
+ Services.telemetry.setEventRecordingEnabled("partner_link", true);
+ Services.telemetry.recordEvent("partner_link", method, objectString, value);
+}
+
+/**
+ * Makes a new endpoint URL for a ping submission. Note that each submission
+ * to Structured Ingesttion requires a new endpoint. See more details about
+ * the specs:
+ *
+ * https://docs.telemetry.mozilla.org/concepts/pipeline/http_edge_spec.html?highlight=docId#postput-request
+ *
+ * @param {String} pingType
+ * The ping type. Must be one of CONTEXTUAL_SERVICES_PING_TYPES
+ * @param {String} version
+ * The schema version of the ping.
+ */
+function makeEndpointUrl(pingType, version) {
+ // Structured Ingestion does not support the UUID generated by gUUIDGenerator.
+ // Stripping off the leading and trailing braces to make it happy.
+ const docID = Services.uuid.generateUUID().toString().slice(1, -1);
+ const extension = `${NAMESPACE_CONTEXUAL_SERVICES}/${pingType}/${version}/${docID}`;
+ return `${lazy.structuredIngestionEndpointBase}/${extension}`;
+}
diff --git a/browser/modules/PermissionUI.sys.mjs b/browser/modules/PermissionUI.sys.mjs
new file mode 100644
index 0000000000..8fc173886b
--- /dev/null
+++ b/browser/modules/PermissionUI.sys.mjs
@@ -0,0 +1,1429 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * PermissionUI is responsible for exposing both a prototype
+ * PermissionPrompt that can be used by arbitrary browser
+ * components and add-ons, but also hosts the implementations of
+ * built-in permission prompts.
+ *
+ * If you're developing a feature that requires web content to ask
+ * for special permissions from the user, this module is for you.
+ *
+ * Suppose a system add-on wants to add a new prompt for a new request
+ * for getting more low-level access to the user's sound card, and the
+ * permission request is coming up from content by way of the
+ * nsContentPermissionHelper. The system add-on could then do the following:
+ *
+ * const { Integration } = ChromeUtils.importESModule(
+ * "resource://gre/modules/Integration.sys.mjs"
+ * );
+ * const { PermissionUI } = ChromeUtils.import(
+ * "resource:///modules/PermissionUI.jsm"
+ * );
+ *
+ * const SoundCardIntegration = base => {
+ * let soundCardObj = {
+ * createPermissionPrompt(type, request) {
+ * if (type != "sound-api") {
+ * return super.createPermissionPrompt(...arguments);
+ * }
+ *
+ * let permissionPrompt = {
+ * get permissionKey() {
+ * return "sound-permission";
+ * }
+ * // etc - see the documentation for PermissionPrompt for
+ * // a better idea of what things one can and should override.
+ * };
+ * Object.setPrototypeOf(
+ * permissionPrompt,
+ * PermissionUI.PermissionPromptForRequest
+ * );
+ * return permissionPrompt;
+ * },
+ * };
+ * Object.setPrototypeOf(soundCardObj, base);
+ * return soundCardObj;
+ * };
+ *
+ * // Add-on startup:
+ * Integration.contentPermission.register(SoundCardIntegration);
+ * // ...
+ * // Add-on shutdown:
+ * Integration.contentPermission.unregister(SoundCardIntegration);
+ *
+ * Note that PermissionPromptForRequest must be used as the
+ * prototype, since the prompt is wrapping an nsIContentPermissionRequest,
+ * and going through nsIContentPermissionPrompt.
+ *
+ * It is, however, possible to take advantage of PermissionPrompt without
+ * having to go through nsIContentPermissionPrompt or with a
+ * nsIContentPermissionRequest. The PermissionPrompt can be
+ * imported, subclassed, and have prompt() called directly, without
+ * the caller having called into createPermissionPrompt.
+ */
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ SitePermissions: "resource:///modules/SitePermissions.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "IDNService",
+ "@mozilla.org/network/idn-service;1",
+ "nsIIDNService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "ContentPrefService2",
+ "@mozilla.org/content-pref/service;1",
+ "nsIContentPrefService2"
+);
+XPCOMUtils.defineLazyGetter(lazy, "gBrowserBundle", function () {
+ return Services.strings.createBundle(
+ "chrome://browser/locale/browser.properties"
+ );
+});
+
+import { SITEPERMS_ADDON_PROVIDER_PREF } from "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs";
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "sitePermsAddonsProviderEnabled",
+ SITEPERMS_ADDON_PROVIDER_PREF,
+ false
+);
+
+/**
+ * PermissionPrompt should be subclassed by callers that
+ * want to display prompts to the user. See each method and property
+ * below for guidance on what to override.
+ *
+ * Note that if you're creating a prompt for an
+ * nsIContentPermissionRequest, you'll want to subclass
+ * PermissionPromptForRequest instead.
+ */
+class PermissionPrompt {
+ /**
+ * Returns the associated <xul:browser> for the request. This should
+ * work for the e10s and non-e10s case.
+ *
+ * Subclasses must override this.
+ *
+ * @return {<xul:browser>}
+ */
+ get browser() {
+ throw new Error("Not implemented.");
+ }
+
+ /**
+ * Returns the nsIPrincipal associated with the request.
+ *
+ * Subclasses must override this.
+ *
+ * @return {nsIPrincipal}
+ */
+ get principal() {
+ throw new Error("Not implemented.");
+ }
+
+ /**
+ * Indicates the type of the permission request from content. This type might
+ * be different from the permission key used in the permissions database.
+ */
+ get type() {
+ return undefined;
+ }
+
+ /**
+ * If the nsIPermissionManager is being queried and written
+ * to for this permission request, set this to the key to be
+ * used. If this is undefined, no integration with temporary
+ * permissions infrastructure will be provided.
+ *
+ * Note that if a permission is set, in any follow-up
+ * prompting within the expiry window of that permission,
+ * the prompt will be skipped and the allow or deny choice
+ * will be selected automatically.
+ */
+ get permissionKey() {
+ return undefined;
+ }
+
+ /**
+ * If true, user permissions will be read from and written to.
+ * When this is false, we still provide integration with
+ * infrastructure such as temporary permissions. permissionKey should
+ * still return a valid name in those cases for that integration to work.
+ */
+ get usePermissionManager() {
+ return true;
+ }
+
+ /**
+ * Indicates what URI should be used as the scope when using temporary
+ * permissions. If undefined, it defaults to the browser.currentURI.
+ */
+ get temporaryPermissionURI() {
+ return undefined;
+ }
+
+ /**
+ * These are the options that will be passed to the PopupNotification when it
+ * is shown. See the documentation of `PopupNotifications_show` in
+ * PopupNotifications.sys.mjs for details.
+ *
+ * Note that prompt() will automatically set displayURI to
+ * be the URI of the requesting pricipal, unless the displayURI is exactly
+ * set to false.
+ */
+ get popupOptions() {
+ return {};
+ }
+
+ /**
+ * If true, automatically denied permission requests will
+ * spawn a "post-prompt" that allows the user to correct the
+ * automatic denial by giving permanent permission access to
+ * the site.
+ *
+ * Note that if this function returns true, the permissionKey
+ * and postPromptActions attributes must be implemented.
+ */
+ get postPromptEnabled() {
+ return false;
+ }
+
+ /**
+ * If true, the prompt will be cancelled automatically unless
+ * request.hasValidTransientUserGestureActivation is true.
+ */
+ get requiresUserInput() {
+ return false;
+ }
+
+ /**
+ * PopupNotification requires a unique ID to open the notification.
+ * You must return a unique ID string here, for which PopupNotification
+ * will then create a <xul:popupnotification> node with the ID
+ * "<notificationID>-notification".
+ *
+ * If there's a custom <xul:popupnotification> you're hoping to show,
+ * then you need to make sure its ID has the "-notification" suffix,
+ * and then return the prefix here.
+ *
+ * See PopupNotifications.sys.mjs for more details.
+ *
+ * @return {string}
+ * The unique ID that will be used to as the
+ * "<unique ID>-notification" ID for the <xul:popupnotification>
+ * to use or create.
+ */
+ get notificationID() {
+ throw new Error("Not implemented.");
+ }
+
+ /**
+ * The ID of the element to anchor the PopupNotification to.
+ *
+ * @return {string}
+ */
+ get anchorID() {
+ return "default-notification-icon";
+ }
+
+ /**
+ * The message to show to the user in the PopupNotification, see
+ * `PopupNotifications_show` in PopupNotifications.sys.mjs.
+ *
+ * Subclasses must override this.
+ *
+ * @return {string}
+ */
+ get message() {
+ throw new Error("Not implemented.");
+ }
+
+ /**
+ * Provides the preferred name to use in the permission popups,
+ * based on the principal URI (the URI.hostPort for any URI scheme
+ * besides the moz-extension one which should default to the
+ * extension name).
+ */
+ getPrincipalName(principal = this.principal) {
+ if (principal.addonPolicy) {
+ return principal.addonPolicy.name;
+ }
+
+ return principal.hostPort;
+ }
+
+ /**
+ * This will be called if the request is to be cancelled.
+ *
+ * Subclasses only need to override this if they provide a
+ * permissionKey.
+ */
+ cancel() {
+ throw new Error("Not implemented.");
+ }
+
+ /**
+ * This will be called if the request is to be allowed.
+ *
+ * Subclasses only need to override this if they provide a
+ * permissionKey.
+ */
+ allow() {
+ throw new Error("Not implemented.");
+ }
+
+ /**
+ * The actions that will be displayed in the PopupNotification
+ * via a dropdown menu. The first item in this array will be
+ * the default selection. Each action is an Object with the
+ * following properties:
+ *
+ * label (string):
+ * The label that will be displayed for this choice.
+ * accessKey (string):
+ * The access key character that will be used for this choice.
+ * action (SitePermissions state)
+ * The action that will be associated with this choice.
+ * This should be either SitePermissions.ALLOW or SitePermissions.BLOCK.
+ * scope (SitePermissions scope)
+ * The scope of the associated action (e.g. SitePermissions.SCOPE_PERSISTENT)
+ *
+ * callback (function, optional)
+ * A callback function that will fire if the user makes this choice, with
+ * a single parameter, state. State is an Object that contains the property
+ * checkboxChecked, which identifies whether the checkbox to remember this
+ * decision was checked.
+ */
+ get promptActions() {
+ return [];
+ }
+
+ /**
+ * The actions that will be displayed in the PopupNotification
+ * for post-prompt notifications via a dropdown menu.
+ * The first item in this array will be the default selection.
+ * Each action is an Object with the following properties:
+ *
+ * label (string):
+ * The label that will be displayed for this choice.
+ * accessKey (string):
+ * The access key character that will be used for this choice.
+ * action (SitePermissions state)
+ * The action that will be associated with this choice.
+ * This should be either SitePermissions.ALLOW or SitePermissions.BLOCK.
+ * Note that the scope of this action will always be persistent.
+ *
+ * callback (function, optional)
+ * A callback function that will fire if the user makes this choice.
+ */
+ get postPromptActions() {
+ return null;
+ }
+
+ /**
+ * If the prompt will be shown to the user, this callback will
+ * be called just before. Subclasses may want to override this
+ * in order to, for example, bump a counter Telemetry probe for
+ * how often a particular permission request is seen.
+ *
+ * If this returns false, it cancels the process of showing the prompt. In
+ * that case, it is the responsibility of the onBeforeShow() implementation
+ * to ensure that allow() or cancel() are called on the object appropriately.
+ */
+ onBeforeShow() {
+ return true;
+ }
+
+ /**
+ * If the prompt was shown to the user, this callback will be called just
+ * after it's been shown.
+ */
+ onShown() {}
+
+ /**
+ * If the prompt was shown to the user, this callback will be called just
+ * after it's been hidden.
+ */
+ onAfterShow() {}
+
+ /**
+ * Will determine if a prompt should be shown to the user, and if so,
+ * will show it.
+ *
+ * If a permissionKey is defined prompt() might automatically
+ * allow or cancel itself based on the user's current
+ * permission settings without displaying the prompt.
+ *
+ * If the permission is not already set and the <xul:browser> that the request
+ * is associated with does not belong to a browser window with the
+ * PopupNotifications global set, the prompt request is ignored.
+ */
+ prompt() {
+ // We ignore requests from non-nsIStandardURLs
+ let requestingURI = this.principal.URI;
+ if (!(requestingURI instanceof Ci.nsIStandardURL)) {
+ return;
+ }
+
+ if (this.usePermissionManager && this.permissionKey) {
+ // If we're reading and setting permissions, then we need
+ // to check to see if we already have a permission setting
+ // for this particular principal.
+ let { state } = lazy.SitePermissions.getForPrincipal(
+ this.principal,
+ this.permissionKey,
+ this.browser,
+ this.temporaryPermissionURI
+ );
+
+ if (state == lazy.SitePermissions.BLOCK) {
+ // If this block was done based on a global user setting, we want to show
+ // a post prompt to give the user some more granular control without
+ // annoying them too much.
+ if (
+ this.postPromptEnabled &&
+ lazy.SitePermissions.getDefault(this.permissionKey) ==
+ lazy.SitePermissions.BLOCK
+ ) {
+ this.postPrompt();
+ }
+ this.cancel();
+ return;
+ }
+
+ if (
+ state == lazy.SitePermissions.ALLOW &&
+ !this.request.isRequestDelegatedToUnsafeThirdParty
+ ) {
+ this.allow();
+ return;
+ }
+ } else if (this.permissionKey) {
+ // If we're reading a permission which already has a temporary value,
+ // see if we can use the temporary value.
+ let { state } = lazy.SitePermissions.getForPrincipal(
+ null,
+ this.permissionKey,
+ this.browser,
+ this.temporaryPermissionURI
+ );
+
+ if (state == lazy.SitePermissions.BLOCK) {
+ this.cancel();
+ return;
+ }
+ }
+
+ if (
+ this.requiresUserInput &&
+ !this.request.hasValidTransientUserGestureActivation
+ ) {
+ if (this.postPromptEnabled) {
+ this.postPrompt();
+ }
+ this.cancel();
+ return;
+ }
+
+ let chromeWin = this.browser.ownerGlobal;
+ if (!chromeWin.PopupNotifications) {
+ this.cancel();
+ return;
+ }
+
+ // Transform the PermissionPrompt actions into PopupNotification actions.
+ let popupNotificationActions = [];
+ for (let promptAction of this.promptActions) {
+ let action = {
+ label: promptAction.label,
+ accessKey: promptAction.accessKey,
+ callback: state => {
+ if (promptAction.callback) {
+ promptAction.callback();
+ }
+
+ if (this.usePermissionManager && this.permissionKey) {
+ if (
+ (state && state.checkboxChecked && state.source != "esc-press") ||
+ promptAction.scope == lazy.SitePermissions.SCOPE_PERSISTENT
+ ) {
+ // Permanently store permission.
+ let scope = lazy.SitePermissions.SCOPE_PERSISTENT;
+ // Only remember permission for session if in PB mode.
+ if (lazy.PrivateBrowsingUtils.isBrowserPrivate(this.browser)) {
+ scope = lazy.SitePermissions.SCOPE_SESSION;
+ }
+ lazy.SitePermissions.setForPrincipal(
+ this.principal,
+ this.permissionKey,
+ promptAction.action,
+ scope
+ );
+ } else if (promptAction.action == lazy.SitePermissions.BLOCK) {
+ // Temporarily store BLOCK permissions only
+ // SitePermissions does not consider subframes when storing temporary
+ // permissions on a tab, thus storing ALLOW could be exploited.
+ lazy.SitePermissions.setForPrincipal(
+ this.principal,
+ this.permissionKey,
+ promptAction.action,
+ lazy.SitePermissions.SCOPE_TEMPORARY,
+ this.browser
+ );
+ }
+
+ // Grant permission if action is ALLOW.
+ if (promptAction.action == lazy.SitePermissions.ALLOW) {
+ this.allow();
+ } else {
+ this.cancel();
+ }
+ } else if (this.permissionKey) {
+ // TODO: Add support for permitTemporaryAllow
+ if (promptAction.action == lazy.SitePermissions.BLOCK) {
+ // Temporarily store BLOCK permissions.
+ // We don't consider subframes when storing temporary
+ // permissions on a tab, thus storing ALLOW could be exploited.
+ lazy.SitePermissions.setForPrincipal(
+ null,
+ this.permissionKey,
+ promptAction.action,
+ lazy.SitePermissions.SCOPE_TEMPORARY,
+ this.browser
+ );
+ }
+ }
+ },
+ };
+ if (promptAction.dismiss) {
+ action.dismiss = promptAction.dismiss;
+ }
+
+ popupNotificationActions.push(action);
+ }
+
+ this.#showNotification(popupNotificationActions);
+ }
+
+ postPrompt() {
+ let browser = this.browser;
+ let principal = this.principal;
+ let chromeWin = browser.ownerGlobal;
+ if (!chromeWin.PopupNotifications) {
+ return;
+ }
+
+ if (!this.permissionKey) {
+ throw new Error("permissionKey is required to show a post-prompt");
+ }
+
+ if (!this.postPromptActions) {
+ throw new Error("postPromptActions are required to show a post-prompt");
+ }
+
+ // Transform the PermissionPrompt actions into PopupNotification actions.
+ let popupNotificationActions = [];
+ for (let promptAction of this.postPromptActions) {
+ let action = {
+ label: promptAction.label,
+ accessKey: promptAction.accessKey,
+ callback: state => {
+ if (promptAction.callback) {
+ promptAction.callback();
+ }
+
+ // Post-prompt permissions are stored permanently by default.
+ // Since we can not reply to the original permission request anymore,
+ // the page will need to listen for permission changes which are triggered
+ // by permanent entries in the permission manager.
+ let scope = lazy.SitePermissions.SCOPE_PERSISTENT;
+ // Only remember permission for session if in PB mode.
+ if (lazy.PrivateBrowsingUtils.isBrowserPrivate(browser)) {
+ scope = lazy.SitePermissions.SCOPE_SESSION;
+ }
+ lazy.SitePermissions.setForPrincipal(
+ principal,
+ this.permissionKey,
+ promptAction.action,
+ scope
+ );
+ },
+ };
+ popupNotificationActions.push(action);
+ }
+
+ // Post-prompt animation
+ if (!chromeWin.gReduceMotion) {
+ let anchor = chromeWin.document.getElementById(this.anchorID);
+ // Only show the animation on the first request, not after e.g. tab switching.
+ anchor.addEventListener(
+ "animationend",
+ () => anchor.removeAttribute("animate"),
+ { once: true }
+ );
+ anchor.setAttribute("animate", "true");
+ }
+
+ this.#showNotification(popupNotificationActions, true);
+ }
+
+ #showNotification(actions, postPrompt = false) {
+ let chromeWin = this.browser.ownerGlobal;
+ let mainAction = actions.length ? actions[0] : null;
+ let secondaryActions = actions.splice(1);
+
+ let options = this.popupOptions;
+
+ if (!options.hasOwnProperty("displayURI") || options.displayURI) {
+ options.displayURI = this.principal.URI;
+ }
+
+ if (!postPrompt) {
+ // Permission prompts are always persistent; the close button is controlled by a pref.
+ options.persistent = true;
+ options.hideClose = true;
+ }
+
+ options.eventCallback = (topic, nextRemovalReason, isCancel) => {
+ // When the docshell of the browser is aboout to be swapped to another one,
+ // the "swapping" event is called. Returning true causes the notification
+ // to be moved to the new browser.
+ if (topic == "swapping") {
+ return true;
+ }
+ // The prompt has been shown, notify the PermissionUI.
+ // onShown() is currently not called for post-prompts,
+ // because there is no prompt that would make use of this.
+ // You can remove this restriction if you need it, but be
+ // mindful of other consumers.
+ if (topic == "shown" && !postPrompt) {
+ this.onShown();
+ }
+ // The prompt has been removed, notify the PermissionUI.
+ // onAfterShow() is currently not called for post-prompts,
+ // because there is no prompt that would make use of this.
+ // You can remove this restriction if you need it, but be
+ // mindful of other consumers.
+ if (topic == "removed" && !postPrompt) {
+ if (isCancel) {
+ this.cancel();
+ }
+ this.onAfterShow();
+ }
+ return false;
+ };
+
+ // Post-prompts show up as dismissed.
+ options.dismissed = postPrompt;
+
+ // onBeforeShow() is currently not called for post-prompts,
+ // because there is no prompt that would make use of this.
+ // You can remove this restriction if you need it, but be
+ // mindful of other consumers.
+ if (postPrompt || this.onBeforeShow() !== false) {
+ chromeWin.PopupNotifications.show(
+ this.browser,
+ this.notificationID,
+ this.message,
+ this.anchorID,
+ mainAction,
+ secondaryActions,
+ options
+ );
+ }
+ }
+}
+
+/**
+ * A subclass of PermissionPrompt that assumes
+ * that this.request is an nsIContentPermissionRequest
+ * and fills in some of the required properties on the
+ * PermissionPrompt. For callers that are wrapping an
+ * nsIContentPermissionRequest, this should be subclassed
+ * rather than PermissionPrompt.
+ */
+class PermissionPromptForRequest extends PermissionPrompt {
+ get browser() {
+ // In the e10s-case, the <xul:browser> will be at request.element.
+ // In the single-process case, we have to use some XPCOM incantations
+ // to resolve to the <xul:browser>.
+ if (this.request.element) {
+ return this.request.element;
+ }
+ return this.request.window.docShell.chromeEventHandler;
+ }
+
+ get principal() {
+ let request = this.request.QueryInterface(Ci.nsIContentPermissionRequest);
+ return request.getDelegatePrincipal(this.type);
+ }
+
+ cancel() {
+ this.request.cancel();
+ }
+
+ allow(choices) {
+ this.request.allow(choices);
+ }
+}
+
+/**
+ * A subclass of PermissionPromptForRequest that prompts
+ * for a Synthetic SitePermsAddon addon type and starts a synthetic
+ * addon install flow.
+ */
+class SitePermsAddonInstallRequest extends PermissionPromptForRequest {
+ prompt() {
+ // fallback to regular permission prompt for localhost,
+ // or when the SitePermsAddonProvider is not enabled.
+ if (this.principal.isLoopbackHost || !lazy.sitePermsAddonsProviderEnabled) {
+ super.prompt();
+ return;
+ }
+
+ // Otherwise, we'll use the addon install flow.
+ lazy.AddonManager.installSitePermsAddonFromWebpage(
+ this.browser,
+ this.principal,
+ this.permName
+ ).then(
+ () => {
+ this.allow();
+ },
+ err => {
+ this.cancel();
+
+ // Print an error message in the console to give more information to the developer.
+ let scriptErrorClass = Cc["@mozilla.org/scripterror;1"];
+ let errorMessage =
+ this.getInstallErrorMessage(err) ||
+ `${this.permName} access was rejected: ${err.message}`;
+
+ let scriptError = scriptErrorClass.createInstance(Ci.nsIScriptError);
+ scriptError.initWithWindowID(
+ errorMessage,
+ null,
+ null,
+ 0,
+ 0,
+ 0,
+ "content javascript",
+ this.browser.browsingContext.currentWindowGlobal.innerWindowId
+ );
+ Services.console.logMessage(scriptError);
+ }
+ );
+ }
+
+ /**
+ * Returns an error message that will be printed to the console given a passed Component.Exception.
+ * This should be overriden by children classes.
+ *
+ * @param {Components.Exception} err
+ * @returns {String} The error message
+ */
+ getInstallErrorMessage(err) {
+ return null;
+ }
+}
+
+/**
+ * Creates a PermissionPrompt for a nsIContentPermissionRequest for
+ * the GeoLocation API.
+ *
+ * @param request (nsIContentPermissionRequest)
+ * The request for a permission from content.
+ */
+class GeolocationPermissionPrompt extends PermissionPromptForRequest {
+ constructor(request) {
+ super();
+ this.request = request;
+ }
+
+ get type() {
+ return "geo";
+ }
+
+ get permissionKey() {
+ return "geo";
+ }
+
+ get popupOptions() {
+ let pref = "browser.geolocation.warning.infoURL";
+ let options = {
+ learnMoreURL: Services.urlFormatter.formatURLPref(pref),
+ displayURI: false,
+ name: this.getPrincipalName(),
+ };
+
+ // Don't offer "always remember" action in PB mode
+ options.checkbox = {
+ show: !lazy.PrivateBrowsingUtils.isWindowPrivate(
+ this.browser.ownerGlobal
+ ),
+ };
+
+ if (this.request.isRequestDelegatedToUnsafeThirdParty) {
+ // Second name should be the third party origin
+ options.secondName = this.getPrincipalName(this.request.principal);
+ options.checkbox = { show: false };
+ }
+
+ if (options.checkbox.show) {
+ options.checkbox.label = lazy.gBrowserBundle.GetStringFromName(
+ "geolocation.remember"
+ );
+ }
+
+ return options;
+ }
+
+ get notificationID() {
+ return "geolocation";
+ }
+
+ get anchorID() {
+ return "geo-notification-icon";
+ }
+
+ get message() {
+ if (this.principal.schemeIs("file")) {
+ return lazy.gBrowserBundle.GetStringFromName(
+ "geolocation.shareWithFile4"
+ );
+ }
+
+ if (this.request.isRequestDelegatedToUnsafeThirdParty) {
+ return lazy.gBrowserBundle.formatStringFromName(
+ "geolocation.shareWithSiteUnsafeDelegation2",
+ ["<>", "{}"]
+ );
+ }
+
+ return lazy.gBrowserBundle.formatStringFromName(
+ "geolocation.shareWithSite4",
+ ["<>"]
+ );
+ }
+
+ get promptActions() {
+ return [
+ {
+ label: lazy.gBrowserBundle.GetStringFromName("geolocation.allow"),
+ accessKey: lazy.gBrowserBundle.GetStringFromName(
+ "geolocation.allow.accesskey"
+ ),
+ action: lazy.SitePermissions.ALLOW,
+ },
+ {
+ label: lazy.gBrowserBundle.GetStringFromName("geolocation.block"),
+ accessKey: lazy.gBrowserBundle.GetStringFromName(
+ "geolocation.block.accesskey"
+ ),
+ action: lazy.SitePermissions.BLOCK,
+ },
+ ];
+ }
+
+ #updateGeoSharing(state) {
+ let gBrowser = this.browser.ownerGlobal.gBrowser;
+ if (gBrowser == null) {
+ return;
+ }
+ gBrowser.updateBrowserSharing(this.browser, { geo: state });
+
+ // Update last access timestamp
+ let host;
+ try {
+ host = this.browser.currentURI.host;
+ } catch (e) {
+ return;
+ }
+ if (host == null || host == "") {
+ return;
+ }
+ lazy.ContentPrefService2.set(
+ this.browser.currentURI.host,
+ "permissions.geoLocation.lastAccess",
+ new Date().toString(),
+ this.browser.loadContext
+ );
+ }
+
+ allow(...args) {
+ this.#updateGeoSharing(true);
+ super.allow(...args);
+ }
+
+ cancel(...args) {
+ this.#updateGeoSharing(false);
+ super.cancel(...args);
+ }
+}
+
+/**
+ * Creates a PermissionPrompt for a nsIContentPermissionRequest for
+ * the WebXR API.
+ *
+ * @param request (nsIContentPermissionRequest)
+ * The request for a permission from content.
+ */
+class XRPermissionPrompt extends PermissionPromptForRequest {
+ constructor(request) {
+ super();
+ this.request = request;
+ }
+
+ get type() {
+ return "xr";
+ }
+
+ get permissionKey() {
+ return "xr";
+ }
+
+ get popupOptions() {
+ let pref = "browser.xr.warning.infoURL";
+ let options = {
+ learnMoreURL: Services.urlFormatter.formatURLPref(pref),
+ displayURI: false,
+ name: this.getPrincipalName(),
+ };
+
+ // Don't offer "always remember" action in PB mode
+ options.checkbox = {
+ show: !lazy.PrivateBrowsingUtils.isWindowPrivate(
+ this.browser.ownerGlobal
+ ),
+ };
+
+ if (options.checkbox.show) {
+ options.checkbox.label =
+ lazy.gBrowserBundle.GetStringFromName("xr.remember");
+ }
+
+ return options;
+ }
+
+ get notificationID() {
+ return "xr";
+ }
+
+ get anchorID() {
+ return "xr-notification-icon";
+ }
+
+ get message() {
+ if (this.principal.schemeIs("file")) {
+ return lazy.gBrowserBundle.GetStringFromName("xr.shareWithFile4");
+ }
+
+ return lazy.gBrowserBundle.formatStringFromName("xr.shareWithSite4", [
+ "<>",
+ ]);
+ }
+
+ get promptActions() {
+ return [
+ {
+ label: lazy.gBrowserBundle.GetStringFromName("xr.allow2"),
+ accessKey: lazy.gBrowserBundle.GetStringFromName("xr.allow2.accesskey"),
+ action: lazy.SitePermissions.ALLOW,
+ },
+ {
+ label: lazy.gBrowserBundle.GetStringFromName("xr.block"),
+ accessKey: lazy.gBrowserBundle.GetStringFromName("xr.block.accesskey"),
+ action: lazy.SitePermissions.BLOCK,
+ },
+ ];
+ }
+
+ #updateXRSharing(state) {
+ let gBrowser = this.browser.ownerGlobal.gBrowser;
+ if (gBrowser == null) {
+ return;
+ }
+ gBrowser.updateBrowserSharing(this.browser, { xr: state });
+
+ let devicePermOrigins = this.browser.getDevicePermissionOrigins("xr");
+ if (!state) {
+ devicePermOrigins.delete(this.principal.origin);
+ return;
+ }
+ devicePermOrigins.add(this.principal.origin);
+ }
+
+ allow(...args) {
+ this.#updateXRSharing(true);
+ super.allow(...args);
+ }
+
+ cancel(...args) {
+ this.#updateXRSharing(false);
+ super.cancel(...args);
+ }
+}
+
+/**
+ * Creates a PermissionPrompt for a nsIContentPermissionRequest for
+ * the Desktop Notification API.
+ *
+ * @param request (nsIContentPermissionRequest)
+ * The request for a permission from content.
+ * @return {PermissionPrompt} (see documentation in header)
+ */
+class DesktopNotificationPermissionPrompt extends PermissionPromptForRequest {
+ constructor(request) {
+ super();
+ this.request = request;
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "requiresUserInput",
+ "dom.webnotifications.requireuserinteraction"
+ );
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "postPromptEnabled",
+ "permissions.desktop-notification.postPrompt.enabled"
+ );
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "notNowEnabled",
+ "permissions.desktop-notification.notNow.enabled"
+ );
+ }
+
+ get type() {
+ return "desktop-notification";
+ }
+
+ get permissionKey() {
+ return "desktop-notification";
+ }
+
+ get popupOptions() {
+ let learnMoreURL =
+ Services.urlFormatter.formatURLPref("app.support.baseURL") + "push";
+
+ return {
+ learnMoreURL,
+ displayURI: false,
+ name: this.getPrincipalName(),
+ };
+ }
+
+ get notificationID() {
+ return "web-notifications";
+ }
+
+ get anchorID() {
+ return "web-notifications-notification-icon";
+ }
+
+ get message() {
+ return lazy.gBrowserBundle.formatStringFromName(
+ "webNotifications.receiveFromSite3",
+ ["<>"]
+ );
+ }
+
+ get promptActions() {
+ let actions = [
+ {
+ label: lazy.gBrowserBundle.GetStringFromName("webNotifications.allow2"),
+ accessKey: lazy.gBrowserBundle.GetStringFromName(
+ "webNotifications.allow2.accesskey"
+ ),
+ action: lazy.SitePermissions.ALLOW,
+ scope: lazy.SitePermissions.SCOPE_PERSISTENT,
+ },
+ ];
+ if (this.notNowEnabled) {
+ actions.push({
+ label: lazy.gBrowserBundle.GetStringFromName("webNotifications.notNow"),
+ accessKey: lazy.gBrowserBundle.GetStringFromName(
+ "webNotifications.notNow.accesskey"
+ ),
+ action: lazy.SitePermissions.BLOCK,
+ });
+ }
+
+ let isBrowserPrivate = lazy.PrivateBrowsingUtils.isBrowserPrivate(
+ this.browser
+ );
+ actions.push({
+ label: isBrowserPrivate
+ ? lazy.gBrowserBundle.GetStringFromName("webNotifications.block")
+ : lazy.gBrowserBundle.GetStringFromName("webNotifications.alwaysBlock"),
+ accessKey: isBrowserPrivate
+ ? lazy.gBrowserBundle.GetStringFromName(
+ "webNotifications.block.accesskey"
+ )
+ : lazy.gBrowserBundle.GetStringFromName(
+ "webNotifications.alwaysBlock.accesskey"
+ ),
+ action: lazy.SitePermissions.BLOCK,
+ scope: isBrowserPrivate
+ ? lazy.SitePermissions.SCOPE_SESSION
+ : lazy.SitePermissions.SCOPE_PERSISTENT,
+ });
+ return actions;
+ }
+
+ get postPromptActions() {
+ let actions = [
+ {
+ label: lazy.gBrowserBundle.GetStringFromName("webNotifications.allow2"),
+ accessKey: lazy.gBrowserBundle.GetStringFromName(
+ "webNotifications.allow2.accesskey"
+ ),
+ action: lazy.SitePermissions.ALLOW,
+ },
+ ];
+
+ let isBrowserPrivate = lazy.PrivateBrowsingUtils.isBrowserPrivate(
+ this.browser
+ );
+ actions.push({
+ label: isBrowserPrivate
+ ? lazy.gBrowserBundle.GetStringFromName("webNotifications.block")
+ : lazy.gBrowserBundle.GetStringFromName("webNotifications.alwaysBlock"),
+ accessKey: isBrowserPrivate
+ ? lazy.gBrowserBundle.GetStringFromName(
+ "webNotifications.block.accesskey"
+ )
+ : lazy.gBrowserBundle.GetStringFromName(
+ "webNotifications.alwaysBlock.accesskey"
+ ),
+ action: lazy.SitePermissions.BLOCK,
+ });
+ return actions;
+ }
+}
+
+/**
+ * Creates a PermissionPrompt for a nsIContentPermissionRequest for
+ * the persistent-storage API.
+ *
+ * @param request (nsIContentPermissionRequest)
+ * The request for a permission from content.
+ */
+class PersistentStoragePermissionPrompt extends PermissionPromptForRequest {
+ constructor(request) {
+ super();
+ this.request = request;
+ }
+
+ get type() {
+ return "persistent-storage";
+ }
+
+ get permissionKey() {
+ return "persistent-storage";
+ }
+
+ get popupOptions() {
+ let learnMoreURL =
+ Services.urlFormatter.formatURLPref("app.support.baseURL") +
+ "storage-permissions";
+ return {
+ learnMoreURL,
+ displayURI: false,
+ name: this.getPrincipalName(),
+ };
+ }
+
+ get notificationID() {
+ return "persistent-storage";
+ }
+
+ get anchorID() {
+ return "persistent-storage-notification-icon";
+ }
+
+ get message() {
+ return lazy.gBrowserBundle.formatStringFromName(
+ "persistentStorage.allowWithSite2",
+ ["<>"]
+ );
+ }
+
+ get promptActions() {
+ return [
+ {
+ label: lazy.gBrowserBundle.GetStringFromName("persistentStorage.allow"),
+ accessKey: lazy.gBrowserBundle.GetStringFromName(
+ "persistentStorage.allow.accesskey"
+ ),
+ action: Ci.nsIPermissionManager.ALLOW_ACTION,
+ scope: lazy.SitePermissions.SCOPE_PERSISTENT,
+ },
+ {
+ label: lazy.gBrowserBundle.GetStringFromName(
+ "persistentStorage.block.label"
+ ),
+ accessKey: lazy.gBrowserBundle.GetStringFromName(
+ "persistentStorage.block.accesskey"
+ ),
+ action: lazy.SitePermissions.BLOCK,
+ },
+ ];
+ }
+}
+
+/**
+ * Creates a PermissionPrompt for a nsIContentPermissionRequest for
+ * the WebMIDI API.
+ *
+ * @param request (nsIContentPermissionRequest)
+ * The request for a permission from content.
+ */
+class MIDIPermissionPrompt extends SitePermsAddonInstallRequest {
+ constructor(request) {
+ super();
+ this.request = request;
+ let types = request.types.QueryInterface(Ci.nsIArray);
+ let perm = types.queryElementAt(0, Ci.nsIContentPermissionType);
+ this.isSysexPerm =
+ !!perm.options.length &&
+ perm.options.queryElementAt(0, Ci.nsISupportsString) == "sysex";
+ this.permName = "midi";
+ if (this.isSysexPerm) {
+ this.permName = "midi-sysex";
+ }
+ }
+
+ get type() {
+ return "midi";
+ }
+
+ get permissionKey() {
+ return this.permName;
+ }
+
+ get popupOptions() {
+ // TODO (bug 1433235) We need a security/permissions explanation URL for this
+ let options = {
+ displayURI: false,
+ name: this.getPrincipalName(),
+ };
+
+ // Don't offer "always remember" action in PB mode
+ options.checkbox = {
+ show: !lazy.PrivateBrowsingUtils.isWindowPrivate(
+ this.browser.ownerGlobal
+ ),
+ };
+
+ if (options.checkbox.show) {
+ options.checkbox.label =
+ lazy.gBrowserBundle.GetStringFromName("midi.remember");
+ }
+
+ return options;
+ }
+
+ get notificationID() {
+ return "midi";
+ }
+
+ get anchorID() {
+ return "midi-notification-icon";
+ }
+
+ get message() {
+ let message;
+ if (this.principal.schemeIs("file")) {
+ if (this.isSysexPerm) {
+ message = lazy.gBrowserBundle.GetStringFromName(
+ "midi.shareSysexWithFile"
+ );
+ } else {
+ message = lazy.gBrowserBundle.GetStringFromName("midi.shareWithFile");
+ }
+ } else if (this.isSysexPerm) {
+ message = lazy.gBrowserBundle.formatStringFromName(
+ "midi.shareSysexWithSite",
+ ["<>"]
+ );
+ } else {
+ message = lazy.gBrowserBundle.formatStringFromName("midi.shareWithSite", [
+ "<>",
+ ]);
+ }
+ return message;
+ }
+
+ get promptActions() {
+ return [
+ {
+ label: lazy.gBrowserBundle.GetStringFromName("midi.allow.label"),
+ accessKey: lazy.gBrowserBundle.GetStringFromName(
+ "midi.allow.accesskey"
+ ),
+ action: Ci.nsIPermissionManager.ALLOW_ACTION,
+ },
+ {
+ label: lazy.gBrowserBundle.GetStringFromName("midi.block.label"),
+ accessKey: lazy.gBrowserBundle.GetStringFromName(
+ "midi.block.accesskey"
+ ),
+ action: Ci.nsIPermissionManager.DENY_ACTION,
+ },
+ ];
+ }
+
+ /**
+ * @override
+ * @param {Components.Exception} err
+ * @returns {String}
+ */
+ getInstallErrorMessage(err) {
+ return `WebMIDI access request was denied: ❝${err.message}❞. See https://developer.mozilla.org/docs/Web/API/Navigator/requestMIDIAccess for more information`;
+ }
+}
+
+class StorageAccessPermissionPrompt extends PermissionPromptForRequest {
+ constructor(request) {
+ super();
+ this.request = request;
+ this.siteOption = null;
+
+ let types = this.request.types.QueryInterface(Ci.nsIArray);
+ let perm = types.queryElementAt(0, Ci.nsIContentPermissionType);
+ let options = perm.options.QueryInterface(Ci.nsIArray);
+ // If we have an option, we are in a call from requestStorageAccessUnderSite
+ // which means that the embedding principal is not the current top-level.
+ // Instead we have to grab the Site string out of the option and use that
+ // in the UI.
+ if (options.length) {
+ this.siteOption = options.queryElementAt(0, Ci.nsISupportsString).data;
+ }
+ }
+
+ get usePermissionManager() {
+ return false;
+ }
+
+ get type() {
+ return "storage-access";
+ }
+
+ get permissionKey() {
+ // Make sure this name is unique per each third-party tracker
+ return `3rdPartyStorage${lazy.SitePermissions.PERM_KEY_DELIMITER}${this.principal.origin}`;
+ }
+
+ get temporaryPermissionURI() {
+ if (this.siteOption) {
+ return Services.io.newURI(this.siteOption);
+ }
+ return undefined;
+ }
+
+ prettifyHostPort(hostport) {
+ let [host, port] = hostport.split(":");
+ host = lazy.IDNService.convertToDisplayIDN(host, {});
+ if (port) {
+ return `${host}:${port}`;
+ }
+ return host;
+ }
+
+ get popupOptions() {
+ let learnMoreURL =
+ Services.urlFormatter.formatURLPref("app.support.baseURL") +
+ "third-party-cookies";
+ let hostPort = this.prettifyHostPort(this.principal.hostPort);
+ let hintText = lazy.gBrowserBundle.formatStringFromName(
+ "storageAccess1.hintText",
+ [hostPort]
+ );
+ return {
+ learnMoreURL,
+ displayURI: false,
+ hintText,
+ escAction: "secondarybuttoncommand",
+ };
+ }
+
+ get notificationID() {
+ return "storage-access";
+ }
+
+ get anchorID() {
+ return "storage-access-notification-icon";
+ }
+
+ get message() {
+ let embeddingHost = this.topLevelPrincipal.host;
+
+ if (this.siteOption) {
+ embeddingHost = this.siteOption.split("://").at(-1);
+ }
+
+ return lazy.gBrowserBundle.formatStringFromName("storageAccess4.message", [
+ this.prettifyHostPort(this.principal.hostPort),
+ this.prettifyHostPort(embeddingHost),
+ ]);
+ }
+
+ get promptActions() {
+ let self = this;
+
+ return [
+ {
+ label: lazy.gBrowserBundle.GetStringFromName(
+ "storageAccess1.Allow.label"
+ ),
+ accessKey: lazy.gBrowserBundle.GetStringFromName(
+ "storageAccess1.Allow.accesskey"
+ ),
+ action: Ci.nsIPermissionManager.ALLOW_ACTION,
+ callback(state) {
+ self.allow({ "storage-access": "allow" });
+ },
+ },
+ {
+ label: lazy.gBrowserBundle.GetStringFromName(
+ "storageAccess1.DontAllow.label"
+ ),
+ accessKey: lazy.gBrowserBundle.GetStringFromName(
+ "storageAccess1.DontAllow.accesskey"
+ ),
+ action: Ci.nsIPermissionManager.DENY_ACTION,
+ callback(state) {
+ self.cancel();
+ },
+ },
+ ];
+ }
+
+ get topLevelPrincipal() {
+ return this.request.topLevelPrincipal;
+ }
+}
+
+export const PermissionUI = {
+ PermissionPromptForRequest,
+ GeolocationPermissionPrompt,
+ XRPermissionPrompt,
+ DesktopNotificationPermissionPrompt,
+ PersistentStoragePermissionPrompt,
+ MIDIPermissionPrompt,
+ StorageAccessPermissionPrompt,
+};
diff --git a/browser/modules/PingCentre.jsm b/browser/modules/PingCentre.jsm
new file mode 100644
index 0000000000..670523a7f2
--- /dev/null
+++ b/browser/modules/PingCentre.jsm
@@ -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 { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
+ UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
+ sendStandalonePing: "resource://gre/modules/TelemetrySend.sys.mjs",
+});
+
+const PREF_BRANCH = "browser.ping-centre.";
+
+const TELEMETRY_PREF = `${PREF_BRANCH}telemetry`;
+const LOGGING_PREF = `${PREF_BRANCH}log`;
+
+const FHR_UPLOAD_ENABLED_PREF = "datareporting.healthreport.uploadEnabled";
+
+/**
+ * Observe various notifications and send them to a telemetry endpoint.
+ *
+ * @param {Object} options
+ * @param {string} options.topic - a unique ID for users of PingCentre to distinguish
+ * their data on the server side.
+ */
+class PingCentre {
+ constructor(options) {
+ if (!options.topic) {
+ throw new Error("Must specify topic.");
+ }
+
+ this._topic = options.topic;
+ this._prefs = Services.prefs.getBranch("");
+
+ this._enabled = this._prefs.getBoolPref(TELEMETRY_PREF);
+ this._onTelemetryPrefChange = this._onTelemetryPrefChange.bind(this);
+ this._prefs.addObserver(TELEMETRY_PREF, this._onTelemetryPrefChange);
+
+ this._fhrEnabled = this._prefs.getBoolPref(FHR_UPLOAD_ENABLED_PREF);
+ this._onFhrPrefChange = this._onFhrPrefChange.bind(this);
+ this._prefs.addObserver(FHR_UPLOAD_ENABLED_PREF, this._onFhrPrefChange);
+
+ this.logging = this._prefs.getBoolPref(LOGGING_PREF);
+ this._onLoggingPrefChange = this._onLoggingPrefChange.bind(this);
+ this._prefs.addObserver(LOGGING_PREF, this._onLoggingPrefChange);
+ }
+
+ get enabled() {
+ return this._enabled && this._fhrEnabled;
+ }
+
+ _onLoggingPrefChange(aSubject, aTopic, prefKey) {
+ this.logging = this._prefs.getBoolPref(prefKey);
+ }
+
+ _onTelemetryPrefChange(aSubject, aTopic, prefKey) {
+ this._enabled = this._prefs.getBoolPref(prefKey);
+ }
+
+ _onFhrPrefChange(aSubject, aTopic, prefKey) {
+ this._fhrEnabled = this._prefs.getBoolPref(prefKey);
+ }
+
+ _createExperimentsPayload() {
+ let activeExperiments = lazy.TelemetryEnvironment.getActiveExperiments();
+ let experiments = {};
+ for (let experimentID in activeExperiments) {
+ if (
+ activeExperiments[experimentID] &&
+ activeExperiments[experimentID].branch
+ ) {
+ experiments[experimentID] = {
+ branch: activeExperiments[experimentID].branch,
+ };
+ }
+ }
+ return experiments;
+ }
+
+ _createStructuredIngestionPing(data) {
+ let experiments = this._createExperimentsPayload();
+ let locale = data.locale || Services.locale.appLocaleAsBCP47;
+ const payload = {
+ experiments,
+ locale,
+ version: AppConstants.MOZ_APP_VERSION,
+ release_channel: lazy.UpdateUtils.getUpdateChannel(false),
+ ...data,
+ };
+
+ return payload;
+ }
+
+ // We route through this helper because it gets hooked in testing.
+ static _sendStandalonePing(endpoint, payload) {
+ return lazy.sendStandalonePing(endpoint, payload);
+ }
+
+ /**
+ * Sends a ping to the Structured Ingestion telemetry pipeline.
+ *
+ * The payload would be compressed using gzip.
+ *
+ * @param {Object} data The payload to be sent.
+ * @param {String} endpoint The destination endpoint. Note that Structured Ingestion
+ * requires a different endpoint for each ping. It's up to the
+ * caller to provide that. See more details at
+ * https://github.com/mozilla/gcp-ingestion/blob/master/docs/edge.md#postput-request
+ * @param {String} namespace Optional. The structured ingestion namespace.
+ * Used for data collection.
+ */
+ sendStructuredIngestionPing(data, endpoint, namespace = undefined) {
+ if (!this.enabled) {
+ return Promise.resolve();
+ }
+
+ const ping = this._createStructuredIngestionPing(data);
+ const payload = JSON.stringify(ping);
+
+ if (this.logging) {
+ Services.console.logStringMessage(
+ `TELEMETRY PING (${this._topic}): ${payload}\n`
+ );
+ }
+
+ let gleanNamespace = "other";
+ switch (namespace) {
+ case "activity-stream":
+ gleanNamespace = "activity_stream";
+ break;
+ case "messaging-system":
+ gleanNamespace = "messaging_system";
+ break;
+ case "contextual-services":
+ gleanNamespace = "contextual_services";
+ break;
+ }
+
+ return PingCentre._sendStandalonePing(endpoint, payload).then(
+ () => {
+ Glean.pingCentre.sendSuccessesByNamespace[gleanNamespace].add(1);
+ },
+ event => {
+ Glean.pingCentre.sendFailures.add(1);
+ Glean.pingCentre.sendFailuresByNamespace[gleanNamespace].add(1);
+ console.error(
+ `Structured Ingestion ping failure with error: ${event.type}`
+ );
+ }
+ );
+ }
+
+ uninit() {
+ try {
+ this._prefs.removeObserver(TELEMETRY_PREF, this._onTelemetryPrefChange);
+ this._prefs.removeObserver(LOGGING_PREF, this._onLoggingPrefChange);
+ this._prefs.removeObserver(
+ FHR_UPLOAD_ENABLED_PREF,
+ this._onFhrPrefChange
+ );
+ } catch (e) {
+ console.error(e);
+ }
+ }
+}
+
+const PingCentreConstants = {
+ FHR_UPLOAD_ENABLED_PREF,
+ TELEMETRY_PREF,
+ LOGGING_PREF,
+};
+const EXPORTED_SYMBOLS = ["PingCentre", "PingCentreConstants"];
diff --git a/browser/modules/ProcessHangMonitor.jsm b/browser/modules/ProcessHangMonitor.jsm
new file mode 100644
index 0000000000..f28a7994bc
--- /dev/null
+++ b/browser/modules/ProcessHangMonitor.jsm
@@ -0,0 +1,693 @@
+/* -*- 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/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["ProcessHangMonitor"];
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+/**
+ * Elides the middle of a string by replacing it with an elipsis if it is
+ * longer than `threshold` characters. Does its best to not break up grapheme
+ * clusters.
+ */
+function elideMiddleOfString(str, threshold) {
+ const searchDistance = 5;
+ const stubLength = threshold / 2 - searchDistance;
+ if (str.length <= threshold || stubLength < searchDistance) {
+ return str;
+ }
+
+ function searchElisionPoint(position) {
+ let unsplittableCharacter = c => /[\p{M}\uDC00-\uDFFF]/u.test(c);
+ for (let i = 0; i < searchDistance; i++) {
+ if (!unsplittableCharacter(str[position + i])) {
+ return position + i;
+ }
+
+ if (!unsplittableCharacter(str[position - i])) {
+ return position - i;
+ }
+ }
+ return position;
+ }
+
+ let elisionStart = searchElisionPoint(stubLength);
+ let elisionEnd = searchElisionPoint(str.length - stubLength);
+ if (elisionStart < elisionEnd) {
+ str = str.slice(0, elisionStart) + "\u2026" + str.slice(elisionEnd);
+ }
+ return str;
+}
+
+/**
+ * This JSM is responsible for observing content process hang reports
+ * and asking the user what to do about them. See nsIHangReport for
+ * the platform interface.
+ */
+
+var ProcessHangMonitor = {
+ /**
+ * This timeout is the wait period applied after a user selects "Wait" in
+ * an existing notification.
+ */
+ get WAIT_EXPIRATION_TIME() {
+ try {
+ return Services.prefs.getIntPref("browser.hangNotification.waitPeriod");
+ } catch (ex) {
+ return 10000;
+ }
+ },
+
+ /**
+ * Should only be set to true once the quit-application-granted notification
+ * has been fired.
+ */
+ _shuttingDown: false,
+
+ /**
+ * Collection of hang reports that haven't expired or been dismissed
+ * by the user. These are nsIHangReports. They are mapped to objects
+ * containing:
+ * - notificationTime: when (Cu.now()) we first showed a notification
+ * - waitCount: how often the user asked to wait for the script to finish
+ * - lastReportFromChild: when (Cu.now()) we last got hang info from the
+ * child.
+ */
+ _activeReports: new Map(),
+
+ /**
+ * Collection of hang reports that have been suppressed for a short
+ * period of time. Value is an object like in _activeReports, but also
+ * including a `timer` prop, which is an nsITimer for when the wait time
+ * expires.
+ */
+ _pausedReports: new Map(),
+
+ /**
+ * Initialize hang reporting. Called once in the parent process.
+ */
+ init() {
+ Services.obs.addObserver(this, "process-hang-report");
+ Services.obs.addObserver(this, "clear-hang-report");
+ Services.obs.addObserver(this, "quit-application-granted");
+ Services.obs.addObserver(this, "xpcom-shutdown");
+ Services.ww.registerNotification(this);
+ Services.telemetry.setEventRecordingEnabled("slow_script_warning", true);
+ },
+
+ /**
+ * Terminate JavaScript associated with the hang being reported for
+ * the selected browser in |win|.
+ */
+ terminateScript(win) {
+ this.handleUserInput(win, report => report.terminateScript());
+ },
+
+ /**
+ * Start devtools debugger for JavaScript associated with the hang
+ * being reported for the selected browser in |win|.
+ */
+ debugScript(win) {
+ this.handleUserInput(win, report => {
+ function callback() {
+ report.endStartingDebugger();
+ }
+
+ this._recordTelemetryForReport(report, "debugging");
+ report.beginStartingDebugger();
+
+ let svc = Cc["@mozilla.org/dom/slow-script-debug;1"].getService(
+ Ci.nsISlowScriptDebug
+ );
+ let handler = svc.remoteActivationHandler;
+ handler.handleSlowScriptDebug(report.scriptBrowser, callback);
+ });
+ },
+
+ /**
+ * Dismiss the browser notification and invoke an appropriate action based on
+ * the hang type.
+ */
+ stopIt(win) {
+ let report = this.findActiveReport(win.gBrowser.selectedBrowser);
+ if (!report) {
+ return;
+ }
+
+ this._recordTelemetryForReport(report, "user-aborted");
+ this.terminateScript(win);
+ },
+
+ /**
+ * Terminate the script causing this report. This is done without
+ * updating any report notifications.
+ */
+ stopHang(report, endReason, backupInfo) {
+ this._recordTelemetryForReport(report, endReason, backupInfo);
+ report.terminateScript();
+ },
+
+ /**
+ * Dismiss the notification, clear the report from the active list and set up
+ * a new timer to track a wait period during which we won't notify.
+ */
+ waitLonger(win) {
+ let report = this.findActiveReport(win.gBrowser.selectedBrowser);
+ if (!report) {
+ return;
+ }
+ // Update the other info we keep.
+ let reportInfo = this._activeReports.get(report);
+ reportInfo.waitCount++;
+
+ // Remove the report from the active list.
+ this.removeActiveReport(report);
+
+ // NOTE, we didn't call userCanceled on nsIHangReport here. This insures
+ // we don't repeatedly generate and cache crash report data for this hang
+ // in the process hang reporter. It already has one report for the browser
+ // process we want it hold onto.
+
+ // Create a new wait timer with notify callback
+ let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.initWithCallback(
+ () => {
+ for (let [stashedReport, pausedInfo] of this._pausedReports) {
+ if (pausedInfo.timer === timer) {
+ this.removePausedReport(stashedReport);
+
+ // We're still hung, so move the report back to the active
+ // list and update the UI.
+ this._activeReports.set(report, pausedInfo);
+ this.updateWindows();
+ break;
+ }
+ }
+ },
+ this.WAIT_EXPIRATION_TIME,
+ timer.TYPE_ONE_SHOT
+ );
+
+ reportInfo.timer = timer;
+ this._pausedReports.set(report, reportInfo);
+
+ // remove the browser notification associated with this hang
+ this.updateWindows();
+ },
+
+ /**
+ * If there is a hang report associated with the selected browser in
+ * |win|, invoke |func| on that report and stop notifying the user
+ * about it.
+ */
+ handleUserInput(win, func) {
+ let report = this.findActiveReport(win.gBrowser.selectedBrowser);
+ if (!report) {
+ return null;
+ }
+ this.removeActiveReport(report);
+
+ return func(report);
+ },
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "xpcom-shutdown": {
+ Services.obs.removeObserver(this, "xpcom-shutdown");
+ Services.obs.removeObserver(this, "process-hang-report");
+ Services.obs.removeObserver(this, "clear-hang-report");
+ Services.obs.removeObserver(this, "quit-application-granted");
+ Services.ww.unregisterNotification(this);
+ break;
+ }
+
+ case "quit-application-granted": {
+ this.onQuitApplicationGranted();
+ break;
+ }
+
+ case "process-hang-report": {
+ this.reportHang(subject.QueryInterface(Ci.nsIHangReport));
+ break;
+ }
+
+ case "clear-hang-report": {
+ this.clearHang(subject.QueryInterface(Ci.nsIHangReport));
+ break;
+ }
+
+ case "domwindowopened": {
+ // Install event listeners on the new window in case one of
+ // its tabs is already hung.
+ let win = subject;
+ let listener = ev => {
+ win.removeEventListener("load", listener, true);
+ this.updateWindows();
+ };
+ win.addEventListener("load", listener, true);
+ break;
+ }
+
+ case "domwindowclosed": {
+ let win = subject;
+ this.onWindowClosed(win);
+ break;
+ }
+ }
+ },
+
+ /**
+ * Called early on in the shutdown sequence. We take this opportunity to
+ * take any pre-existing hang reports, and terminate them. We also put
+ * ourselves in a state so that if any more hang reports show up while
+ * we're shutting down, we terminate them immediately.
+ */
+ onQuitApplicationGranted() {
+ this._shuttingDown = true;
+ this.stopAllHangs("quit-application-granted");
+ this.updateWindows();
+ },
+
+ onWindowClosed(win) {
+ let maybeStopHang = report => {
+ let hungBrowserWindow = null;
+ try {
+ hungBrowserWindow = report.scriptBrowser.ownerGlobal;
+ } catch (e) {
+ // Ignore failures to get the script browser - we'll be
+ // conservative, and assume that if we cannot access the
+ // window that belongs to this report that we should stop
+ // the hang.
+ }
+ if (!hungBrowserWindow || hungBrowserWindow == win) {
+ this.stopHang(report, "window-closed");
+ return true;
+ }
+ return false;
+ };
+
+ // If there are any script hangs for browsers that are in this window
+ // that is closing, we can stop them now.
+ for (let [report] of this._activeReports) {
+ if (maybeStopHang(report)) {
+ this._activeReports.delete(report);
+ }
+ }
+
+ for (let [pausedReport] of this._pausedReports) {
+ if (maybeStopHang(pausedReport)) {
+ this.removePausedReport(pausedReport);
+ }
+ }
+
+ this.updateWindows();
+ },
+
+ stopAllHangs(endReason) {
+ for (let [report] of this._activeReports) {
+ this.stopHang(report, endReason);
+ }
+
+ this._activeReports = new Map();
+
+ for (let [pausedReport] of this._pausedReports) {
+ this.stopHang(pausedReport, endReason);
+ this.removePausedReport(pausedReport);
+ }
+ },
+
+ /**
+ * Find a active hang report for the given <browser> element.
+ */
+ findActiveReport(browser) {
+ let frameLoader = browser.frameLoader;
+ for (let report of this._activeReports.keys()) {
+ if (report.isReportForBrowserOrChildren(frameLoader)) {
+ return report;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Find a paused hang report for the given <browser> element.
+ */
+ findPausedReport(browser) {
+ let frameLoader = browser.frameLoader;
+ for (let [report] of this._pausedReports) {
+ if (report.isReportForBrowserOrChildren(frameLoader)) {
+ return report;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Tell telemetry about the report.
+ */
+ _recordTelemetryForReport(report, endReason, backupInfo) {
+ let info =
+ this._activeReports.get(report) ||
+ this._pausedReports.get(report) ||
+ backupInfo;
+ if (!info) {
+ return;
+ }
+ try {
+ let uri_type;
+ if (report.addonId) {
+ uri_type = "extension";
+ } else if (report.scriptFileName?.startsWith("debugger")) {
+ uri_type = "devtools";
+ } else {
+ try {
+ let url = new URL(report.scriptFileName);
+ if (url.protocol == "chrome:" || url.protocol == "resource:") {
+ uri_type = "browser";
+ } else {
+ uri_type = "content";
+ }
+ } catch (ex) {
+ console.error(ex);
+ uri_type = "unknown";
+ }
+ }
+ let uptime = 0;
+ if (info.notificationTime) {
+ uptime = Cu.now() - info.notificationTime;
+ }
+ uptime = "" + uptime;
+ // We combine the duration of the hang in the content process with the
+ // time since we were last told about the hang in the parent. This is
+ // not the same as the time we showed a notification, as we only do that
+ // for the currently selected browser. It's as messy as it is because
+ // there is no cross-process monotonically increasing timestamp we can
+ // use. :-(
+ let hangDuration =
+ report.hangDuration + Cu.now() - info.lastReportFromChild;
+ Services.telemetry.recordEvent(
+ "slow_script_warning",
+ "shown",
+ "content",
+ null,
+ {
+ end_reason: endReason,
+ hang_duration: "" + hangDuration,
+ n_tab_deselect: "" + info.deselectCount,
+ uri_type,
+ uptime,
+ wait_count: "" + info.waitCount,
+ }
+ );
+ } catch (ex) {
+ console.error(ex);
+ }
+ },
+
+ /**
+ * Remove an active hang report from the active list and cancel the timer
+ * associated with it.
+ */
+ removeActiveReport(report) {
+ this._activeReports.delete(report);
+ this.updateWindows();
+ },
+
+ /**
+ * Remove a paused hang report from the paused list and cancel the timer
+ * associated with it.
+ */
+ removePausedReport(report) {
+ let info = this._pausedReports.get(report);
+ info?.timer?.cancel();
+ this._pausedReports.delete(report);
+ },
+
+ /**
+ * Iterate over all XUL windows and ensure that the proper hang
+ * reports are shown for each one. Also install event handlers in
+ * each window to watch for events that would cause a different hang
+ * report to be displayed.
+ */
+ updateWindows() {
+ let e = Services.wm.getEnumerator("navigator:browser");
+
+ // If it turns out we have no windows (this can happen on macOS),
+ // we have no opportunity to ask the user whether or not they want
+ // to stop the hang or wait, so we'll opt for stopping the hang.
+ if (!e.hasMoreElements()) {
+ this.stopAllHangs("no-windows-left");
+ return;
+ }
+
+ for (let win of e) {
+ this.updateWindow(win);
+
+ // Only listen for these events if there are active hang reports.
+ if (this._activeReports.size) {
+ this.trackWindow(win);
+ } else {
+ this.untrackWindow(win);
+ }
+ }
+ },
+
+ /**
+ * If there is a hang report for the current tab in |win|, display it.
+ */
+ updateWindow(win) {
+ let report = this.findActiveReport(win.gBrowser.selectedBrowser);
+
+ if (report) {
+ let info = this._activeReports.get(report);
+ if (info && !info.notificationTime) {
+ info.notificationTime = Cu.now();
+ }
+ this.showNotification(win, report);
+ } else {
+ this.hideNotification(win);
+ }
+ },
+
+ /**
+ * Show the notification for a hang.
+ */
+ showNotification(win, report) {
+ let bundle = win.gNavigatorBundle;
+
+ let buttons = [
+ {
+ label: bundle.getString("processHang.button_stop2.label"),
+ accessKey: bundle.getString("processHang.button_stop2.accessKey"),
+ callback() {
+ ProcessHangMonitor.stopIt(win);
+ },
+ },
+ ];
+
+ let message;
+ let doc = win.document;
+ let brandShortName = doc
+ .getElementById("bundle_brand")
+ .getString("brandShortName");
+ let notificationTag;
+ if (report.addonId) {
+ notificationTag = report.addonId;
+ let aps = Cc["@mozilla.org/addons/policy-service;1"].getService(
+ Ci.nsIAddonPolicyService
+ );
+
+ let addonName = aps.getExtensionName(report.addonId);
+
+ message = bundle.getFormattedString("processHang.add-on.label2", [
+ addonName,
+ brandShortName,
+ ]);
+
+ buttons.unshift({
+ label: bundle.getString("processHang.add-on.learn-more.text"),
+ link: "https://support.mozilla.org/kb/warning-unresponsive-script#w_other-causes",
+ });
+ } else {
+ let scriptBrowser = report.scriptBrowser;
+ if (scriptBrowser == win.gBrowser?.selectedBrowser) {
+ notificationTag = "selected-tab";
+ message = bundle.getFormattedString("processHang.selected_tab.label", [
+ brandShortName,
+ ]);
+ } else {
+ let tab =
+ scriptBrowser?.ownerGlobal.gBrowser?.getTabForBrowser(scriptBrowser);
+ if (!tab) {
+ notificationTag = "nonspecific_tab";
+ message = bundle.getFormattedString(
+ "processHang.nonspecific_tab.label",
+ [brandShortName]
+ );
+ } else {
+ notificationTag = scriptBrowser.browserId.toString();
+ let title = tab.getAttribute("label");
+ title = elideMiddleOfString(title, 60);
+ message = bundle.getFormattedString(
+ "processHang.specific_tab.label",
+ [title, brandShortName]
+ );
+ }
+ }
+ }
+
+ let notification =
+ win.gNotificationBox.getNotificationWithValue("process-hang");
+ if (notificationTag == notification?.getAttribute("notification-tag")) {
+ return;
+ }
+
+ if (notification) {
+ notification.label = message;
+ notification.setAttribute("notification-tag", notificationTag);
+ return;
+ }
+
+ // Show the "debug script" button unconditionally if we are in Developer edition,
+ // or, if DevTools are opened on the slow tab.
+ if (
+ AppConstants.MOZ_DEV_EDITION ||
+ report.scriptBrowser.browsingContext.watchedByDevTools
+ ) {
+ buttons.push({
+ label: bundle.getString("processHang.button_debug.label"),
+ accessKey: bundle.getString("processHang.button_debug.accessKey"),
+ callback() {
+ ProcessHangMonitor.debugScript(win);
+ },
+ });
+ }
+
+ win.gNotificationBox
+ .appendNotification(
+ "process-hang",
+ {
+ label: message,
+ image: "chrome://browser/content/aboutRobots-icon.png",
+ priority: win.gNotificationBox.PRIORITY_INFO_HIGH,
+ eventCallback: event => {
+ if (event == "dismissed") {
+ ProcessHangMonitor.waitLonger(win);
+ }
+ },
+ },
+ buttons
+ )
+ .setAttribute("notification-tag", notificationTag);
+ },
+
+ /**
+ * Ensure that no hang notifications are visible in |win|.
+ */
+ hideNotification(win) {
+ let notification =
+ win.gNotificationBox.getNotificationWithValue("process-hang");
+ if (notification) {
+ win.gNotificationBox.removeNotification(notification);
+ }
+ },
+
+ /**
+ * Install event handlers on |win| to watch for events that would
+ * cause a different hang report to be displayed.
+ */
+ trackWindow(win) {
+ win.gBrowser.tabContainer.addEventListener("TabSelect", this, true);
+ win.gBrowser.tabContainer.addEventListener(
+ "TabRemotenessChange",
+ this,
+ true
+ );
+ },
+
+ untrackWindow(win) {
+ win.gBrowser.tabContainer.removeEventListener("TabSelect", this, true);
+ win.gBrowser.tabContainer.removeEventListener(
+ "TabRemotenessChange",
+ this,
+ true
+ );
+ },
+
+ handleEvent(event) {
+ let win = event.target.ownerGlobal;
+
+ // If a new tab is selected or if a tab changes remoteness, then
+ // we may need to show or hide a hang notification.
+ if (event.type == "TabSelect" || event.type == "TabRemotenessChange") {
+ if (event.type == "TabSelect" && event.detail.previousTab) {
+ // If we've got a notification, check the previous tab's report and
+ // indicate the user switched tabs while the notification was up.
+ let r =
+ this.findActiveReport(event.detail.previousTab.linkedBrowser) ||
+ this.findPausedReport(event.detail.previousTab.linkedBrowser);
+ if (r) {
+ let info = this._activeReports.get(r) || this._pausedReports.get(r);
+ info.deselectCount++;
+ }
+ }
+ this.updateWindow(win);
+ }
+ },
+
+ /**
+ * Handle a potentially new hang report. If it hasn't been seen
+ * before, show a notification for it in all open XUL windows.
+ */
+ reportHang(report) {
+ let now = Cu.now();
+ if (this._shuttingDown) {
+ this.stopHang(report, "shutdown-in-progress", {
+ lastReportFromChild: now,
+ waitCount: 0,
+ deselectCount: 0,
+ });
+ return;
+ }
+
+ // If this hang was already reported reset the timer for it.
+ if (this._activeReports.has(report)) {
+ this._activeReports.get(report).lastReportFromChild = now;
+ // if this report is in active but doesn't have a notification associated
+ // with it, display a notification.
+ this.updateWindows();
+ return;
+ }
+
+ // If this hang was already reported and paused by the user ignore it.
+ if (this._pausedReports.has(report)) {
+ this._pausedReports.get(report).lastReportFromChild = now;
+ return;
+ }
+
+ // On e10s this counts slow-script notice only once.
+ // This code is not reached on non-e10s.
+ Services.telemetry.getHistogramById("SLOW_SCRIPT_NOTICE_COUNT").add();
+
+ this._activeReports.set(report, {
+ deselectCount: 0,
+ lastReportFromChild: now,
+ waitCount: 0,
+ });
+ this.updateWindows();
+ },
+
+ clearHang(report) {
+ this._recordTelemetryForReport(report, "cleared");
+
+ this.removeActiveReport(report);
+ this.removePausedReport(report);
+ report.userCanceled();
+ },
+};
diff --git a/browser/modules/Sanitizer.sys.mjs b/browser/modules/Sanitizer.sys.mjs
new file mode 100644
index 0000000000..fa56444bda
--- /dev/null
+++ b/browser/modules/Sanitizer.sys.mjs
@@ -0,0 +1,1146 @@
+// -*- 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, {
+ ContextualIdentityService:
+ "resource://gre/modules/ContextualIdentityService.sys.mjs",
+ FormHistory: "resource://gre/modules/FormHistory.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ PrincipalsCollector: "resource://gre/modules/PrincipalsCollector.sys.mjs",
+});
+
+var logConsole;
+function log(msg) {
+ if (!logConsole) {
+ logConsole = console.createInstance({
+ prefix: "** Sanitizer.jsm",
+ maxLogLevelPref: "browser.sanitizer.loglevel",
+ });
+ }
+
+ logConsole.log(msg);
+}
+
+// Used as unique id for pending sanitizations.
+var gPendingSanitizationSerial = 0;
+
+var gPrincipalsCollector = null;
+
+export var Sanitizer = {
+ /**
+ * Whether we should sanitize on shutdown.
+ */
+ PREF_SANITIZE_ON_SHUTDOWN: "privacy.sanitize.sanitizeOnShutdown",
+
+ /**
+ * During a sanitization this is set to a JSON containing an array of the
+ * pending sanitizations. This allows to retry sanitizations on startup in
+ * case they dind't run or were interrupted by a crash.
+ * Use addPendingSanitization and removePendingSanitization to manage it.
+ */
+ PREF_PENDING_SANITIZATIONS: "privacy.sanitize.pending",
+
+ /**
+ * Pref branches to fetch sanitization options from.
+ */
+ PREF_CPD_BRANCH: "privacy.cpd.",
+ PREF_SHUTDOWN_BRANCH: "privacy.clearOnShutdown.",
+
+ /**
+ * The fallback timestamp used when no argument is given to
+ * Sanitizer.getClearRange.
+ */
+ PREF_TIMESPAN: "privacy.sanitize.timeSpan",
+
+ /**
+ * Pref to newTab segregation. If true, on shutdown, the private container
+ * used in about:newtab is cleaned up. Exposed because used in tests.
+ */
+ PREF_NEWTAB_SEGREGATION:
+ "privacy.usercontext.about_newtab_segregation.enabled",
+
+ /**
+ * Time span constants corresponding to values of the privacy.sanitize.timeSpan
+ * pref. Used to determine how much history to clear, for various items
+ */
+ TIMESPAN_EVERYTHING: 0,
+ TIMESPAN_HOUR: 1,
+ TIMESPAN_2HOURS: 2,
+ TIMESPAN_4HOURS: 3,
+ TIMESPAN_TODAY: 4,
+ TIMESPAN_5MIN: 5,
+ TIMESPAN_24HOURS: 6,
+
+ /**
+ * Whether we should sanitize on shutdown.
+ * When this is set, a pending sanitization should also be added and removed
+ * when shutdown sanitization is complete. This allows to retry incomplete
+ * sanitizations on startup.
+ */
+ shouldSanitizeOnShutdown: false,
+
+ /**
+ * Whether we should sanitize the private container for about:newtab.
+ */
+ shouldSanitizeNewTabContainer: false,
+
+ /**
+ * Shows a sanitization dialog to the user. Returns after the dialog box has
+ * closed.
+ *
+ * @param parentWindow the browser window to use as parent for the created
+ * dialog.
+ * @throws if parentWindow is undefined or doesn't have a gDialogBox.
+ */
+ showUI(parentWindow) {
+ // Treat the hidden window as not being a parent window:
+ if (
+ parentWindow?.document.documentURI ==
+ "chrome://browser/content/hiddenWindowMac.xhtml"
+ ) {
+ parentWindow = null;
+ }
+ if (parentWindow?.gDialogBox) {
+ parentWindow.gDialogBox.open("chrome://browser/content/sanitize.xhtml", {
+ inBrowserWindow: true,
+ });
+ } else {
+ Services.ww.openWindow(
+ parentWindow,
+ "chrome://browser/content/sanitize.xhtml",
+ "Sanitize",
+ "chrome,titlebar,dialog,centerscreen,modal",
+ { needNativeUI: true }
+ );
+ }
+ },
+
+ /**
+ * Performs startup tasks:
+ * - Checks if sanitizations were not completed during the last session.
+ * - Registers sanitize-on-shutdown.
+ */
+ async onStartup() {
+ // First, collect pending sanitizations from the last session, before we
+ // add pending sanitizations for this session.
+ let pendingSanitizations = getAndClearPendingSanitizations();
+
+ // Check if we should sanitize on shutdown.
+ this.shouldSanitizeOnShutdown = Services.prefs.getBoolPref(
+ Sanitizer.PREF_SANITIZE_ON_SHUTDOWN,
+ false
+ );
+ Services.prefs.addObserver(Sanitizer.PREF_SANITIZE_ON_SHUTDOWN, this, true);
+ // Add a pending shutdown sanitization, if necessary.
+ if (this.shouldSanitizeOnShutdown) {
+ let itemsToClear = getItemsToClearFromPrefBranch(
+ Sanitizer.PREF_SHUTDOWN_BRANCH
+ );
+ addPendingSanitization("shutdown", itemsToClear, {});
+ }
+ // Shutdown sanitization is always pending, but the user may change the
+ // sanitize on shutdown prefs during the session. Then the pending
+ // sanitization would become stale and must be updated.
+ Services.prefs.addObserver(Sanitizer.PREF_SHUTDOWN_BRANCH, this, true);
+
+ // Make sure that we are triggered during shutdown.
+ let shutdownClient = lazy.PlacesUtils.history.shutdownClient.jsclient;
+ // We need to pass to sanitize() (through sanitizeOnShutdown) a state object
+ // that tracks the status of the shutdown blocker. This `progress` object
+ // will be updated during sanitization and reported with the crash in case of
+ // a shutdown timeout.
+ // We use the `options` argument to pass the `progress` object to sanitize().
+ let progress = { isShutdown: true, clearHonoringExceptions: true };
+ shutdownClient.addBlocker(
+ "sanitize.js: Sanitize on shutdown",
+ () => sanitizeOnShutdown(progress),
+ { fetchState: () => ({ progress }) }
+ );
+
+ this.shouldSanitizeNewTabContainer = Services.prefs.getBoolPref(
+ this.PREF_NEWTAB_SEGREGATION,
+ false
+ );
+ if (this.shouldSanitizeNewTabContainer) {
+ addPendingSanitization("newtab-container", [], {});
+ }
+
+ let i = pendingSanitizations.findIndex(s => s.id == "newtab-container");
+ if (i != -1) {
+ pendingSanitizations.splice(i, 1);
+ sanitizeNewTabSegregation();
+ }
+
+ // Finally, run the sanitizations that were left pending, because we crashed
+ // before completing them.
+ for (let { itemsToClear, options } of pendingSanitizations) {
+ try {
+ // We need to set this flag to watch out for the users exceptions like we do on shutdown
+ options.progress = { clearHonoringExceptions: true };
+ await this.sanitize(itemsToClear, options);
+ } catch (ex) {
+ console.error(
+ "A previously pending sanitization failed: ",
+ itemsToClear,
+ ex
+ );
+ }
+ }
+ },
+
+ /**
+ * Returns a 2 element array representing the start and end times,
+ * in the uSec-since-epoch format that PRTime likes. If we should
+ * clear everything, this function returns null.
+ *
+ * @param ts [optional] a timespan to convert to start and end time.
+ * Falls back to the privacy.sanitize.timeSpan preference
+ * if this argument is omitted.
+ * If this argument is provided, it has to be one of the
+ * Sanitizer.TIMESPAN_* constants. This function will
+ * throw an error otherwise.
+ *
+ * @return {Array} a 2-element Array containing the start and end times.
+ */
+ getClearRange(ts) {
+ if (ts === undefined) {
+ ts = Services.prefs.getIntPref(Sanitizer.PREF_TIMESPAN);
+ }
+ if (ts === Sanitizer.TIMESPAN_EVERYTHING) {
+ return null;
+ }
+
+ // PRTime is microseconds while JS time is milliseconds
+ var endDate = Date.now() * 1000;
+ switch (ts) {
+ case Sanitizer.TIMESPAN_5MIN:
+ var startDate = endDate - 300000000; // 5*60*1000000
+ break;
+ case Sanitizer.TIMESPAN_HOUR:
+ startDate = endDate - 3600000000; // 1*60*60*1000000
+ break;
+ case Sanitizer.TIMESPAN_2HOURS:
+ startDate = endDate - 7200000000; // 2*60*60*1000000
+ break;
+ case Sanitizer.TIMESPAN_4HOURS:
+ startDate = endDate - 14400000000; // 4*60*60*1000000
+ break;
+ case Sanitizer.TIMESPAN_TODAY:
+ var d = new Date(); // Start with today
+ d.setHours(0); // zero us back to midnight...
+ d.setMinutes(0);
+ d.setSeconds(0);
+ d.setMilliseconds(0);
+ startDate = d.valueOf() * 1000; // convert to epoch usec
+ break;
+ case Sanitizer.TIMESPAN_24HOURS:
+ startDate = endDate - 86400000000; // 24*60*60*1000000
+ break;
+ default:
+ throw new Error("Invalid time span for clear private data: " + ts);
+ }
+ return [startDate, endDate];
+ },
+
+ /**
+ * Deletes privacy sensitive data in a batch, according to user preferences.
+ * Returns a promise which is resolved if no errors occurred. If an error
+ * occurs, a message is reported to the console and all other items are still
+ * cleared before the promise is finally rejected.
+ *
+ * @param [optional] itemsToClear
+ * Array of items to be cleared. if specified only those
+ * items get cleared, irrespectively of the preference settings.
+ * @param [optional] options
+ * Object whose properties are options for this sanitization:
+ * - ignoreTimespan (default: true): Time span only makes sense in
+ * certain cases. Consumers who want to only clear some private
+ * data can opt in by setting this to false, and can optionally
+ * specify a specific range.
+ * If timespan is not ignored, and range is not set, sanitize() will
+ * use the value of the timespan pref to determine a range.
+ * - range (default: null): array-tuple of [from, to] timestamps
+ * - privateStateForNewWindow (default: "non-private"): when clearing
+ * open windows, defines the private state for the newly opened window.
+ * @returns {object} An object containing debug information about the
+ * sanitization progress. This state object is also used as
+ * AsyncShutdown metadata.
+ */
+ async sanitize(itemsToClear = null, options = {}) {
+ let progress = options.progress;
+ // initialise the principals collector
+ gPrincipalsCollector = new lazy.PrincipalsCollector();
+ if (!progress) {
+ progress = options.progress = {};
+ }
+
+ if (!itemsToClear) {
+ itemsToClear = getItemsToClearFromPrefBranch(this.PREF_CPD_BRANCH);
+ }
+ let promise = sanitizeInternal(this.items, itemsToClear, options);
+
+ // Depending on preferences, the sanitizer may perform asynchronous
+ // work before it starts cleaning up the Places database (e.g. closing
+ // windows). We need to make sure that the connection to that database
+ // hasn't been closed by the time we use it.
+ // Though, if this is a sanitize on shutdown, we already have a blocker.
+ if (!progress.isShutdown) {
+ let shutdownClient = lazy.PlacesUtils.history.shutdownClient.jsclient;
+ shutdownClient.addBlocker("sanitize.js: Sanitize", promise, {
+ fetchState: () => ({ progress }),
+ });
+ }
+
+ try {
+ await promise;
+ } finally {
+ Services.obs.notifyObservers(null, "sanitizer-sanitization-complete");
+ }
+ return progress;
+ },
+
+ observe(subject, topic, data) {
+ if (topic == "nsPref:changed") {
+ if (
+ data.startsWith(this.PREF_SHUTDOWN_BRANCH) &&
+ this.shouldSanitizeOnShutdown
+ ) {
+ // Update the pending shutdown sanitization.
+ removePendingSanitization("shutdown");
+ let itemsToClear = getItemsToClearFromPrefBranch(
+ Sanitizer.PREF_SHUTDOWN_BRANCH
+ );
+ addPendingSanitization("shutdown", itemsToClear, {});
+ } else if (data == this.PREF_SANITIZE_ON_SHUTDOWN) {
+ this.shouldSanitizeOnShutdown = Services.prefs.getBoolPref(
+ Sanitizer.PREF_SANITIZE_ON_SHUTDOWN,
+ false
+ );
+ removePendingSanitization("shutdown");
+ if (this.shouldSanitizeOnShutdown) {
+ let itemsToClear = getItemsToClearFromPrefBranch(
+ Sanitizer.PREF_SHUTDOWN_BRANCH
+ );
+ addPendingSanitization("shutdown", itemsToClear, {});
+ }
+ } else if (data == this.PREF_NEWTAB_SEGREGATION) {
+ this.shouldSanitizeNewTabContainer = Services.prefs.getBoolPref(
+ this.PREF_NEWTAB_SEGREGATION,
+ false
+ );
+ removePendingSanitization("newtab-container");
+ if (this.shouldSanitizeNewTabContainer) {
+ addPendingSanitization("newtab-container", [], {});
+ }
+ }
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ // This method is meant to be used by tests.
+ async runSanitizeOnShutdown() {
+ // The collector needs to be reset for each test, as the collection only happens
+ // once and does not update after that.
+ // Pretend that it has never been initialized to mimic the actual browser behavior
+ // by setting it to null.
+ // The actually initialization will happen either via sanitize() or directly in
+ // sanitizeOnShutdown.
+ gPrincipalsCollector = null;
+ return sanitizeOnShutdown({
+ isShutdown: true,
+ clearHonoringExceptions: true,
+ });
+ },
+
+ // When making any changes to the sanitize implementations here,
+ // please check whether the changes are applicable to Android
+ // (mobile/android/modules/geckoview/GeckoViewStorageController.jsm) as well.
+
+ items: {
+ cache: {
+ async clear(range) {
+ let refObj = {};
+ TelemetryStopwatch.start("FX_SANITIZE_CACHE", refObj);
+ await clearData(range, Ci.nsIClearDataService.CLEAR_ALL_CACHES);
+ TelemetryStopwatch.finish("FX_SANITIZE_CACHE", refObj);
+ },
+ },
+
+ cookies: {
+ async clear(range, { progress }, clearHonoringExceptions) {
+ let refObj = {};
+ TelemetryStopwatch.start("FX_SANITIZE_COOKIES_2", refObj);
+ // This is true if called by sanitizeOnShutdown.
+ // On shutdown we clear by principal to be able to honor the users exceptions
+ if (clearHonoringExceptions) {
+ progress.step = "getAllPrincipals";
+ let principalsForShutdownClearing =
+ await gPrincipalsCollector.getAllPrincipals(progress);
+ await maybeSanitizeSessionPrincipals(
+ progress,
+ principalsForShutdownClearing,
+ Ci.nsIClearDataService.CLEAR_COOKIES
+ );
+ } else {
+ // Not on shutdown
+ await clearData(range, Ci.nsIClearDataService.CLEAR_COOKIES);
+ }
+ await clearData(range, Ci.nsIClearDataService.CLEAR_MEDIA_DEVICES);
+ TelemetryStopwatch.finish("FX_SANITIZE_COOKIES_2", refObj);
+ },
+ },
+
+ offlineApps: {
+ async clear(range, { progress }, clearHonoringExceptions) {
+ // This is true if called by sanitizeOnShutdown.
+ // On shutdown we clear by principal to be able to honor the users exceptions
+ if (clearHonoringExceptions) {
+ progress.step = "getAllPrincipals";
+ let principalsForShutdownClearing =
+ await gPrincipalsCollector.getAllPrincipals(progress);
+ await maybeSanitizeSessionPrincipals(
+ progress,
+ principalsForShutdownClearing,
+ Ci.nsIClearDataService.CLEAR_DOM_STORAGES
+ );
+ } else {
+ // Not on shutdown
+ await clearData(range, Ci.nsIClearDataService.CLEAR_DOM_STORAGES);
+ }
+ },
+ },
+
+ history: {
+ async clear(range, { progress }) {
+ // TODO: This check is needed for the case that this method is invoked directly and not via the sanitizer.sanitize API.
+ // This can be removed once bug 1803799 has landed.
+ if (!gPrincipalsCollector) {
+ gPrincipalsCollector = new lazy.PrincipalsCollector();
+ }
+ progress.step = "getAllPrincipals";
+ let principals = await gPrincipalsCollector.getAllPrincipals(progress);
+ let refObj = {};
+ TelemetryStopwatch.start("FX_SANITIZE_HISTORY", refObj);
+ progress.step = "clearing browsing history";
+ await clearData(
+ range,
+ Ci.nsIClearDataService.CLEAR_HISTORY |
+ Ci.nsIClearDataService.CLEAR_SESSION_HISTORY |
+ Ci.nsIClearDataService.CLEAR_CONTENT_BLOCKING_RECORDS
+ );
+
+ // storageAccessAPI permissions record every site that the user
+ // interacted with and thus mirror history quite closely. It makes
+ // sense to clear them when we clear history. However, since their absence
+ // indicates that we can purge cookies and site data for tracking origins without
+ // user interaction, we need to ensure that we only delete those permissions that
+ // do not have any existing storage.
+ progress.step = "clearing user interaction";
+ await new Promise(resolve => {
+ Services.clearData.deleteUserInteractionForClearingHistory(
+ principals,
+ range ? range[0] : 0,
+ resolve
+ );
+ });
+ TelemetryStopwatch.finish("FX_SANITIZE_HISTORY", refObj);
+ },
+ },
+
+ formdata: {
+ async clear(range) {
+ let seenException;
+ let refObj = {};
+ TelemetryStopwatch.start("FX_SANITIZE_FORMDATA", refObj);
+ try {
+ // Clear undo history of all search bars.
+ for (let currentWindow of Services.wm.getEnumerator(
+ "navigator:browser"
+ )) {
+ let currentDocument = currentWindow.document;
+
+ // searchBar may not exist if it's in the customize mode.
+ let searchBar = currentDocument.getElementById("searchbar");
+ if (searchBar) {
+ let input = searchBar.textbox;
+ input.value = "";
+ input.editor?.clearUndoRedo();
+ }
+
+ let tabBrowser = currentWindow.gBrowser;
+ if (!tabBrowser) {
+ // No tab browser? This means that it's too early during startup (typically,
+ // Session Restore hasn't completed yet). Since we don't have find
+ // bars at that stage and since Session Restore will not restore
+ // find bars further down during startup, we have nothing to clear.
+ continue;
+ }
+ for (let tab of tabBrowser.tabs) {
+ if (tabBrowser.isFindBarInitialized(tab)) {
+ tabBrowser.getCachedFindBar(tab).clear();
+ }
+ }
+ // Clear any saved find value
+ tabBrowser._lastFindValue = "";
+ }
+ } catch (ex) {
+ seenException = ex;
+ }
+
+ try {
+ let change = { op: "remove" };
+ if (range) {
+ [change.firstUsedStart, change.firstUsedEnd] = range;
+ }
+ await lazy.FormHistory.update(change).catch(e => {
+ seenException = new Error("Error " + e.result + ": " + e.message);
+ });
+ } catch (ex) {
+ seenException = ex;
+ }
+
+ TelemetryStopwatch.finish("FX_SANITIZE_FORMDATA", refObj);
+ if (seenException) {
+ throw seenException;
+ }
+ },
+ },
+
+ downloads: {
+ async clear(range) {
+ let refObj = {};
+ TelemetryStopwatch.start("FX_SANITIZE_DOWNLOADS", refObj);
+ await clearData(range, Ci.nsIClearDataService.CLEAR_DOWNLOADS);
+ TelemetryStopwatch.finish("FX_SANITIZE_DOWNLOADS", refObj);
+ },
+ },
+
+ sessions: {
+ async clear(range) {
+ let refObj = {};
+ TelemetryStopwatch.start("FX_SANITIZE_SESSIONS", refObj);
+ await clearData(
+ range,
+ Ci.nsIClearDataService.CLEAR_AUTH_TOKENS |
+ Ci.nsIClearDataService.CLEAR_AUTH_CACHE
+ );
+ TelemetryStopwatch.finish("FX_SANITIZE_SESSIONS", refObj);
+ },
+ },
+
+ siteSettings: {
+ async clear(range) {
+ let refObj = {};
+ TelemetryStopwatch.start("FX_SANITIZE_SITESETTINGS", refObj);
+ await clearData(
+ range,
+ Ci.nsIClearDataService.CLEAR_PERMISSIONS |
+ Ci.nsIClearDataService.CLEAR_CONTENT_PREFERENCES |
+ Ci.nsIClearDataService.CLEAR_DOM_PUSH_NOTIFICATIONS |
+ Ci.nsIClearDataService.CLEAR_CLIENT_AUTH_REMEMBER_SERVICE |
+ Ci.nsIClearDataService.CLEAR_CERT_EXCEPTIONS |
+ Ci.nsIClearDataService.CLEAR_CREDENTIAL_MANAGER_STATE
+ );
+ TelemetryStopwatch.finish("FX_SANITIZE_SITESETTINGS", refObj);
+ },
+ },
+
+ openWindows: {
+ _canCloseWindow(win) {
+ if (win.CanCloseWindow()) {
+ // We already showed PermitUnload for the window, so let's
+ // make sure we don't do it again when we actually close the
+ // window.
+ win.skipNextCanClose = true;
+ return true;
+ }
+ return false;
+ },
+ _resetAllWindowClosures(windowList) {
+ for (let win of windowList) {
+ win.skipNextCanClose = false;
+ }
+ },
+ async clear(range, { privateStateForNewWindow = "non-private" }) {
+ // NB: this closes all *browser* windows, not other windows like the library, about window,
+ // browser console, etc.
+
+ // Keep track of the time in case we get stuck in la-la-land because of onbeforeunload
+ // dialogs
+ let startDate = Date.now();
+
+ // First check if all these windows are OK with being closed:
+ let windowList = [];
+ for (let someWin of Services.wm.getEnumerator("navigator:browser")) {
+ windowList.push(someWin);
+ // If someone says "no" to a beforeunload prompt, we abort here:
+ if (!this._canCloseWindow(someWin)) {
+ this._resetAllWindowClosures(windowList);
+ throw new Error(
+ "Sanitize could not close windows: cancelled by user"
+ );
+ }
+
+ // ...however, beforeunload prompts spin the event loop, and so the code here won't get
+ // hit until the prompt has been dismissed. If more than 1 minute has elapsed since we
+ // started prompting, stop, because the user might not even remember initiating the
+ // 'forget', and the timespans will be all wrong by now anyway:
+ if (Date.now() > startDate + 60 * 1000) {
+ this._resetAllWindowClosures(windowList);
+ throw new Error("Sanitize could not close windows: timeout");
+ }
+ }
+
+ if (!windowList.length) {
+ return;
+ }
+
+ // If/once we get here, we should actually be able to close all windows.
+
+ let refObj = {};
+ TelemetryStopwatch.start("FX_SANITIZE_OPENWINDOWS", refObj);
+
+ // First create a new window. We do this first so that on non-mac, we don't
+ // accidentally close the app by closing all the windows.
+ let handler = Cc["@mozilla.org/browser/clh;1"].getService(
+ Ci.nsIBrowserHandler
+ );
+ let defaultArgs = handler.defaultArgs;
+ let features = "chrome,all,dialog=no," + privateStateForNewWindow;
+ let newWindow = windowList[0].openDialog(
+ AppConstants.BROWSER_CHROME_URL,
+ "_blank",
+ features,
+ defaultArgs
+ );
+
+ let onFullScreen = null;
+ if (AppConstants.platform == "macosx") {
+ onFullScreen = function (e) {
+ newWindow.removeEventListener("fullscreen", onFullScreen);
+ let docEl = newWindow.document.documentElement;
+ let sizemode = docEl.getAttribute("sizemode");
+ if (!newWindow.fullScreen && sizemode == "fullscreen") {
+ docEl.setAttribute("sizemode", "normal");
+ e.preventDefault();
+ e.stopPropagation();
+ return false;
+ }
+ return undefined;
+ };
+ newWindow.addEventListener("fullscreen", onFullScreen);
+ }
+
+ let promiseReady = new Promise(resolve => {
+ // Window creation and destruction is asynchronous. We need to wait
+ // until all existing windows are fully closed, and the new window is
+ // fully open, before continuing. Otherwise the rest of the sanitizer
+ // could run too early (and miss new cookies being set when a page
+ // closes) and/or run too late (and not have a fully-formed window yet
+ // in existence). See bug 1088137.
+ let newWindowOpened = false;
+ let onWindowOpened = function (subject, topic, data) {
+ if (subject != newWindow) {
+ return;
+ }
+
+ Services.obs.removeObserver(
+ onWindowOpened,
+ "browser-delayed-startup-finished"
+ );
+ if (AppConstants.platform == "macosx") {
+ newWindow.removeEventListener("fullscreen", onFullScreen);
+ }
+ newWindowOpened = true;
+ // If we're the last thing to happen, invoke callback.
+ if (numWindowsClosing == 0) {
+ TelemetryStopwatch.finish("FX_SANITIZE_OPENWINDOWS", refObj);
+ resolve();
+ }
+ };
+
+ let numWindowsClosing = windowList.length;
+ let onWindowClosed = function () {
+ numWindowsClosing--;
+ if (numWindowsClosing == 0) {
+ Services.obs.removeObserver(
+ onWindowClosed,
+ "xul-window-destroyed"
+ );
+ // If we're the last thing to happen, invoke callback.
+ if (newWindowOpened) {
+ TelemetryStopwatch.finish("FX_SANITIZE_OPENWINDOWS", refObj);
+ resolve();
+ }
+ }
+ };
+ Services.obs.addObserver(
+ onWindowOpened,
+ "browser-delayed-startup-finished"
+ );
+ Services.obs.addObserver(onWindowClosed, "xul-window-destroyed");
+ });
+
+ // Start the process of closing windows
+ while (windowList.length) {
+ windowList.pop().close();
+ }
+ newWindow.focus();
+ await promiseReady;
+ },
+ },
+
+ pluginData: {
+ async clear(range) {},
+ },
+ },
+};
+
+async function sanitizeInternal(items, aItemsToClear, options) {
+ let { ignoreTimespan = true, range, progress } = options;
+ let seenError = false;
+ // Shallow copy the array, as we are going to modify it in place later.
+ if (!Array.isArray(aItemsToClear)) {
+ throw new Error("Must pass an array of items to clear.");
+ }
+ let itemsToClear = [...aItemsToClear];
+
+ // Store the list of items to clear, in case we are killed before we
+ // get a chance to complete.
+ let uid = gPendingSanitizationSerial++;
+ // Shutdown sanitization is managed outside.
+ if (!progress.isShutdown) {
+ addPendingSanitization(uid, itemsToClear, options);
+ }
+
+ // Store the list of items to clear, for debugging/forensics purposes
+ for (let k of itemsToClear) {
+ progress[k] = "ready";
+ // Create a progress object specific to each cleaner. We'll pass down this
+ // to the cleaners instead of the main progress object, so they don't end
+ // up overriding properties each other.
+ // This specific progress is deleted if the cleaner completes successfully,
+ // so the metadata will only contain progress of unresolved cleaners.
+ progress[k + "Progress"] = {};
+ }
+
+ // Ensure open windows get cleared first, if they're in our list, so that
+ // they don't stick around in the recently closed windows list, and so we
+ // can cancel the whole thing if the user selects to keep a window open
+ // from a beforeunload prompt.
+ let openWindowsIndex = itemsToClear.indexOf("openWindows");
+ if (openWindowsIndex != -1) {
+ itemsToClear.splice(openWindowsIndex, 1);
+ await items.openWindows.clear(
+ null,
+ Object.assign(options, { progress: progress.openWindowsProgress })
+ );
+ progress.openWindows = "cleared";
+ delete progress.openWindowsProgress;
+ }
+
+ // If we ignore timespan, clear everything,
+ // otherwise, pick a range.
+ if (!ignoreTimespan && !range) {
+ range = Sanitizer.getClearRange();
+ }
+
+ // For performance reasons we start all the clear tasks at once, then wait
+ // for their promises later.
+ // Some of the clear() calls may raise exceptions (for example bug 265028),
+ // we catch and store them, but continue to sanitize as much as possible.
+ // Callers should check returned errors and give user feedback
+ // about items that could not be sanitized
+ let refObj = {};
+ TelemetryStopwatch.start("FX_SANITIZE_TOTAL", refObj);
+
+ let annotateError = (name, ex) => {
+ progress[name] = "failed";
+ seenError = true;
+ console.error("Error sanitizing " + name, ex);
+ };
+
+ // Array of objects in form { name, promise }.
+ // `name` is the item's name and `promise` may be a promise, if the
+ // sanitization is asynchronous, or the function return value, otherwise.
+ let handles = [];
+ for (let name of itemsToClear) {
+ progress[name] = "blocking";
+ let item = items[name];
+ try {
+ // Catch errors here, so later we can just loop through these.
+ handles.push({
+ name,
+ promise: item
+ .clear(
+ range,
+ Object.assign(options, { progress: progress[name + "Progress"] }),
+ progress.clearHonoringExceptions
+ )
+ .then(
+ () => {
+ progress[name] = "cleared";
+ delete progress[name + "Progress"];
+ },
+ ex => annotateError(name, ex)
+ ),
+ });
+ } catch (ex) {
+ annotateError(name, ex);
+ }
+ }
+ await Promise.all(handles.map(h => h.promise));
+
+ // Sanitization is complete.
+ TelemetryStopwatch.finish("FX_SANITIZE_TOTAL", refObj);
+ if (!progress.isShutdown) {
+ removePendingSanitization(uid);
+ }
+ progress = {};
+ if (seenError) {
+ throw new Error("Error sanitizing");
+ }
+}
+
+async function sanitizeOnShutdown(progress) {
+ log("Sanitizing on shutdown");
+ progress.sanitizationPrefs = {
+ privacy_sanitize_sanitizeOnShutdown: Services.prefs.getBoolPref(
+ "privacy.sanitize.sanitizeOnShutdown"
+ ),
+ privacy_clearOnShutdown_cookies: Services.prefs.getBoolPref(
+ "privacy.clearOnShutdown.cookies"
+ ),
+ privacy_clearOnShutdown_history: Services.prefs.getBoolPref(
+ "privacy.clearOnShutdown.history"
+ ),
+ privacy_clearOnShutdown_formdata: Services.prefs.getBoolPref(
+ "privacy.clearOnShutdown.formdata"
+ ),
+ privacy_clearOnShutdown_downloads: Services.prefs.getBoolPref(
+ "privacy.clearOnShutdown.downloads"
+ ),
+ privacy_clearOnShutdown_cache: Services.prefs.getBoolPref(
+ "privacy.clearOnShutdown.cache"
+ ),
+ privacy_clearOnShutdown_sessions: Services.prefs.getBoolPref(
+ "privacy.clearOnShutdown.sessions"
+ ),
+ privacy_clearOnShutdown_offlineApps: Services.prefs.getBoolPref(
+ "privacy.clearOnShutdown.offlineApps"
+ ),
+ privacy_clearOnShutdown_siteSettings: Services.prefs.getBoolPref(
+ "privacy.clearOnShutdown.siteSettings"
+ ),
+ privacy_clearOnShutdown_openWindows: Services.prefs.getBoolPref(
+ "privacy.clearOnShutdown.openWindows"
+ ),
+ };
+
+ let needsSyncSavePrefs = false;
+ if (Sanitizer.shouldSanitizeOnShutdown) {
+ // Need to sanitize upon shutdown
+ progress.advancement = "shutdown-cleaner";
+ let itemsToClear = getItemsToClearFromPrefBranch(
+ Sanitizer.PREF_SHUTDOWN_BRANCH
+ );
+ await Sanitizer.sanitize(itemsToClear, { progress });
+
+ // We didn't crash during shutdown sanitization, so annotate it to avoid
+ // sanitizing again on startup.
+ removePendingSanitization("shutdown");
+ needsSyncSavePrefs = true;
+ }
+
+ if (Sanitizer.shouldSanitizeNewTabContainer) {
+ progress.advancement = "newtab-segregation";
+ sanitizeNewTabSegregation();
+ removePendingSanitization("newtab-container");
+ needsSyncSavePrefs = true;
+ }
+
+ if (needsSyncSavePrefs) {
+ Services.prefs.savePrefFile(null);
+ }
+
+ if (!Sanitizer.shouldSanitizeOnShutdown) {
+ // In case the user has not activated sanitizeOnShutdown but has explicitely set exceptions
+ // to always clear particular origins, we clear those here
+
+ progress.advancement = "session-permission";
+
+ let exceptions = 0;
+ let selectedPrincipals = [];
+ // Let's see if we have to forget some particular site.
+ for (let permission of Services.perms.all) {
+ if (
+ permission.type != "cookie" ||
+ permission.capability != Ci.nsICookiePermission.ACCESS_SESSION
+ ) {
+ continue;
+ }
+
+ // We consider just permissions set for http, https and file URLs.
+ if (!isSupportedPrincipal(permission.principal)) {
+ continue;
+ }
+
+ log(
+ "Custom session cookie permission detected for: " +
+ permission.principal.asciiSpec
+ );
+ exceptions++;
+
+ // We use just the URI here, because permissions ignore OriginAttributes.
+ // The principalsCollector is lazy, this is computed only once
+ if (!gPrincipalsCollector) {
+ gPrincipalsCollector = new lazy.PrincipalsCollector();
+ }
+ let principals = await gPrincipalsCollector.getAllPrincipals(progress);
+ selectedPrincipals.push(
+ ...extractMatchingPrincipals(principals, permission.principal.host)
+ );
+ }
+ await maybeSanitizeSessionPrincipals(
+ progress,
+ selectedPrincipals,
+ Ci.nsIClearDataService.CLEAR_ALL_CACHES |
+ Ci.nsIClearDataService.CLEAR_COOKIES |
+ Ci.nsIClearDataService.CLEAR_DOM_STORAGES |
+ Ci.nsIClearDataService.CLEAR_EME
+ );
+ progress.sanitizationPrefs.session_permission_exceptions = exceptions;
+ }
+ progress.advancement = "done";
+}
+
+// Extracts the principals matching matchUri as root domain.
+function extractMatchingPrincipals(principals, matchHost) {
+ return principals.filter(principal => {
+ return Services.eTLD.hasRootDomain(matchHost, principal.host);
+ });
+}
+
+/** This method receives a list of principals and it checks if some of them or
+ * some of their sub-domain need to be sanitize.
+ * @param {Object} progress - Object to keep track of the sanitization progress, prefs and mode
+ * @param {nsIPrincipal[]} principals - The principals generated by the PrincipalsCollector
+ * @param {int} flags - The cleaning categories that need to be cleaned for the principals.
+ * @returns {Promise} - Resolves once the clearing of the principals to be cleared is done
+ */
+async function maybeSanitizeSessionPrincipals(progress, principals, flags) {
+ log("Sanitizing " + principals.length + " principals");
+
+ let promises = [];
+ let permissions = new Map();
+ Services.perms.getAllWithTypePrefix("cookie").forEach(perm => {
+ permissions.set(perm.principal.origin, perm);
+ });
+
+ principals.forEach(principal => {
+ progress.step = "checking-principal";
+ let cookieAllowed = cookiesAllowedForDomainOrSubDomain(
+ principal,
+ permissions
+ );
+ progress.step = "principal-checked:" + cookieAllowed;
+
+ if (!cookieAllowed) {
+ promises.push(sanitizeSessionPrincipal(progress, principal, flags));
+ }
+ });
+
+ progress.step = "promises:" + promises.length;
+ if (promises.length) {
+ await Promise.all(promises);
+ await new Promise(resolve =>
+ Services.clearData.cleanupAfterDeletionAtShutdown(flags, resolve)
+ );
+ }
+ progress.step = "promises resolved";
+}
+
+function cookiesAllowedForDomainOrSubDomain(principal, permissions) {
+ log("Checking principal: " + principal.asciiSpec);
+
+ // If we have the 'cookie' permission for this principal, let's return
+ // immediately.
+ let cookiePermission = checkIfCookiePermissionIsSet(principal);
+ if (cookiePermission != null) {
+ return cookiePermission;
+ }
+
+ for (let perm of permissions.values()) {
+ if (perm.type != "cookie") {
+ permissions.delete(perm.principal.origin);
+ continue;
+ }
+ // We consider just permissions set for http, https and file URLs.
+ if (!isSupportedPrincipal(perm.principal)) {
+ permissions.delete(perm.principal.origin);
+ continue;
+ }
+
+ // We don't care about scheme, port, and anything else.
+ if (Services.eTLD.hasRootDomain(perm.principal.host, principal.host)) {
+ log("Cookie check on principal: " + perm.principal.asciiSpec);
+ let rootDomainCookiePermission = checkIfCookiePermissionIsSet(
+ perm.principal
+ );
+ if (rootDomainCookiePermission != null) {
+ return rootDomainCookiePermission;
+ }
+ }
+ }
+
+ log("Cookie not allowed.");
+ return false;
+}
+
+/**
+ * Checks if a cookie permission is set for a given principal
+ * @returns {boolean} - true: cookie permission "ACCESS_ALLOW", false: cookie permission "ACCESS_DENY"/"ACCESS_SESSION"
+ * @returns {null} - No cookie permission is set for this principal
+ */
+function checkIfCookiePermissionIsSet(principal) {
+ let p = Services.perms.testPermissionFromPrincipal(principal, "cookie");
+
+ if (p == Ci.nsICookiePermission.ACCESS_ALLOW) {
+ log("Cookie allowed!");
+ return true;
+ }
+
+ if (
+ p == Ci.nsICookiePermission.ACCESS_DENY ||
+ p == Ci.nsICookiePermission.ACCESS_SESSION
+ ) {
+ log("Cookie denied or session!");
+ return false;
+ }
+ // This is an old profile with unsupported permission values
+ if (p != Ci.nsICookiePermission.ACCESS_DEFAULT) {
+ log("Not supported cookie permission: " + p);
+ return false;
+ }
+ return null;
+}
+
+async function sanitizeSessionPrincipal(progress, principal, flags) {
+ log("Sanitizing principal: " + principal.asciiSpec);
+
+ await new Promise(resolve => {
+ progress.sanitizePrincipal = "started";
+ Services.clearData.deleteDataFromPrincipal(
+ principal,
+ true /* user request */,
+ flags,
+ resolve
+ );
+ });
+ progress.sanitizePrincipal = "completed";
+}
+
+function sanitizeNewTabSegregation() {
+ let identity = lazy.ContextualIdentityService.getPrivateIdentity(
+ "userContextIdInternal.thumbnail"
+ );
+ if (identity) {
+ Services.clearData.deleteDataFromOriginAttributesPattern({
+ userContextId: identity.userContextId,
+ });
+ }
+}
+
+/**
+ * Gets an array of items to clear from the given pref branch.
+ * @param branch The pref branch to fetch.
+ * @return Array of items to clear
+ */
+function getItemsToClearFromPrefBranch(branch) {
+ branch = Services.prefs.getBranch(branch);
+ return Object.keys(Sanitizer.items).filter(itemName => {
+ try {
+ return branch.getBoolPref(itemName);
+ } catch (ex) {
+ return false;
+ }
+ });
+}
+
+/**
+ * These functions are used to track pending sanitization on the next startup
+ * in case of a crash before a sanitization could happen.
+ * @param id A unique id identifying the sanitization
+ * @param itemsToClear The items to clear
+ * @param options The Sanitize options
+ */
+function addPendingSanitization(id, itemsToClear, options) {
+ let pendingSanitizations = safeGetPendingSanitizations();
+ pendingSanitizations.push({ id, itemsToClear, options });
+ Services.prefs.setStringPref(
+ Sanitizer.PREF_PENDING_SANITIZATIONS,
+ JSON.stringify(pendingSanitizations)
+ );
+}
+
+function removePendingSanitization(id) {
+ let pendingSanitizations = safeGetPendingSanitizations();
+ let i = pendingSanitizations.findIndex(s => s.id == id);
+ let [s] = pendingSanitizations.splice(i, 1);
+ Services.prefs.setStringPref(
+ Sanitizer.PREF_PENDING_SANITIZATIONS,
+ JSON.stringify(pendingSanitizations)
+ );
+ return s;
+}
+
+function getAndClearPendingSanitizations() {
+ let pendingSanitizations = safeGetPendingSanitizations();
+ if (pendingSanitizations.length) {
+ Services.prefs.clearUserPref(Sanitizer.PREF_PENDING_SANITIZATIONS);
+ }
+ return pendingSanitizations;
+}
+
+function safeGetPendingSanitizations() {
+ try {
+ return JSON.parse(
+ Services.prefs.getStringPref(Sanitizer.PREF_PENDING_SANITIZATIONS, "[]")
+ );
+ } catch (ex) {
+ console.error("Invalid JSON value for pending sanitizations: ", ex);
+ return [];
+ }
+}
+
+async function clearData(range, flags) {
+ if (range) {
+ await new Promise(resolve => {
+ Services.clearData.deleteDataInTimeRange(
+ range[0],
+ range[1],
+ true /* user request */,
+ flags,
+ resolve
+ );
+ });
+ } else {
+ await new Promise(resolve => {
+ Services.clearData.deleteData(flags, resolve);
+ });
+ }
+}
+
+function isSupportedPrincipal(principal) {
+ return ["http", "https", "file"].some(scheme => principal.schemeIs(scheme));
+}
diff --git a/browser/modules/SelectionChangedMenulist.jsm b/browser/modules/SelectionChangedMenulist.jsm
new file mode 100644
index 0000000000..7fcae982cc
--- /dev/null
+++ b/browser/modules/SelectionChangedMenulist.jsm
@@ -0,0 +1,32 @@
+/* - This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ - You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["SelectionChangedMenulist"];
+
+class SelectionChangedMenulist {
+ // A menulist wrapper that will open the popup when navigating with the
+ // keyboard on Windows and trigger the provided handler when the popup
+ // is hiding. This matches the behaviour of MacOS and Linux more closely.
+
+ constructor(menulist, onCommand) {
+ let popup = menulist.menupopup;
+ let lastEvent;
+
+ menulist.addEventListener("command", event => {
+ lastEvent = event;
+ if (popup.state != "open" && popup.state != "showing") {
+ popup.openPopup();
+ }
+ });
+
+ popup.addEventListener("popuphiding", () => {
+ if (lastEvent) {
+ onCommand(lastEvent);
+ lastEvent = null;
+ }
+ });
+ }
+}
diff --git a/browser/modules/SiteDataManager.jsm b/browser/modules/SiteDataManager.jsm
new file mode 100644
index 0000000000..ce2a77edd8
--- /dev/null
+++ b/browser/modules/SiteDataManager.jsm
@@ -0,0 +1,667 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+var EXPORTED_SYMBOLS = ["SiteDataManager"];
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "gStringBundle", function () {
+ return Services.strings.createBundle(
+ "chrome://browser/locale/siteData.properties"
+ );
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "gBrandBundle", function () {
+ return Services.strings.createBundle(
+ "chrome://branding/locale/brand.properties"
+ );
+});
+
+var SiteDataManager = {
+ // A Map of sites and their disk usage according to Quota Manager.
+ // Key is base domain (group sites based on base domain across scheme, port,
+ // origin attributes) or host if the entry does not have a base domain.
+ // Value is one object holding:
+ // - baseDomainOrHost: Same as key.
+ // - principals: instances of nsIPrincipal (only when the site has
+ // quota storage).
+ // - persisted: the persistent-storage status.
+ // - quotaUsage: the usage of indexedDB and localStorage.
+ // - containersData: a map containing cookiesBlocked,lastAccessed and quotaUsage by userContextID.
+ _sites: new Map(),
+
+ _getCacheSizeObserver: null,
+
+ _getCacheSizePromise: null,
+
+ _getQuotaUsagePromise: null,
+
+ _quotaUsageRequest: null,
+
+ /**
+ * Retrieve the latest site data and store it in SiteDataManager.
+ *
+ * Updating site data is a *very* expensive operation. This method exists so that
+ * consumers can manually decide when to update, most methods on SiteDataManager
+ * will not trigger updates automatically.
+ *
+ * It is *highly discouraged* to await on this function to finish before showing UI.
+ * Either trigger the update some time before the data is needed or use the
+ * entryUpdatedCallback parameter to update the UI async.
+ *
+ * @param {entryUpdatedCallback} a function to be called whenever a site is added or
+ * updated. This can be used to e.g. fill a UI that lists sites without
+ * blocking on the entire update to finish.
+ * @returns a Promise that resolves when updating is done.
+ **/
+ async updateSites(entryUpdatedCallback) {
+ Services.obs.notifyObservers(null, "sitedatamanager:updating-sites");
+ // Clear old data and requests first
+ this._sites.clear();
+ this._getAllCookies(entryUpdatedCallback);
+ await this._getQuotaUsage(entryUpdatedCallback);
+ Services.obs.notifyObservers(null, "sitedatamanager:sites-updated");
+ },
+
+ /**
+ * Get the base domain of a host on a best-effort basis.
+ * @param {string} host - Host to convert.
+ * @returns {string} Computed base domain. If the base domain cannot be
+ * determined, because the host is an IP address or does not have enough
+ * domain levels we will return the original host. This includes the empty
+ * string.
+ * @throws {Error} Throws for unexpected conversion errors from eTLD service.
+ */
+ getBaseDomainFromHost(host) {
+ let result = host;
+ try {
+ result = Services.eTLD.getBaseDomainFromHost(host);
+ } catch (e) {
+ if (
+ e.result == Cr.NS_ERROR_HOST_IS_IP_ADDRESS ||
+ e.result == Cr.NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS
+ ) {
+ // For these 2 expected errors, just take the host as the result.
+ // - NS_ERROR_HOST_IS_IP_ADDRESS: the host is in ipv4/ipv6.
+ // - NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS: not enough domain parts to extract.
+ result = host;
+ } else {
+ throw e;
+ }
+ }
+ return result;
+ },
+
+ _getOrInsertSite(baseDomainOrHost) {
+ let site = this._sites.get(baseDomainOrHost);
+ if (!site) {
+ site = {
+ baseDomainOrHost,
+ cookies: [],
+ persisted: false,
+ quotaUsage: 0,
+ lastAccessed: 0,
+ principals: [],
+ };
+ this._sites.set(baseDomainOrHost, site);
+ }
+ return site;
+ },
+
+ _getOrInsertContainersData(site, userContextId) {
+ if (!site.containersData) {
+ site.containersData = new Map();
+ }
+
+ let containerData = site.containersData.get(userContextId);
+ if (!containerData) {
+ containerData = {
+ cookiesBlocked: 0,
+ lastAccessed: new Date(0),
+ quotaUsage: 0,
+ };
+ site.containersData.set(userContextId, containerData);
+ }
+ return containerData;
+ },
+
+ /**
+ * Retrieves the amount of space currently used by disk cache.
+ *
+ * You can use DownloadUtils.convertByteUnits to convert this to
+ * a user-understandable size/unit combination.
+ *
+ * @returns a Promise that resolves with the cache size on disk in bytes.
+ */
+ getCacheSize() {
+ if (this._getCacheSizePromise) {
+ return this._getCacheSizePromise;
+ }
+
+ this._getCacheSizePromise = new Promise((resolve, reject) => {
+ // Needs to root the observer since cache service keeps only a weak reference.
+ this._getCacheSizeObserver = {
+ onNetworkCacheDiskConsumption: consumption => {
+ resolve(consumption);
+ this._getCacheSizePromise = null;
+ this._getCacheSizeObserver = null;
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsICacheStorageConsumptionObserver",
+ "nsISupportsWeakReference",
+ ]),
+ };
+
+ try {
+ Services.cache2.asyncGetDiskConsumption(this._getCacheSizeObserver);
+ } catch (e) {
+ reject(e);
+ this._getCacheSizePromise = null;
+ this._getCacheSizeObserver = null;
+ }
+ });
+
+ return this._getCacheSizePromise;
+ },
+
+ _getQuotaUsage(entryUpdatedCallback) {
+ this._cancelGetQuotaUsage();
+ this._getQuotaUsagePromise = new Promise(resolve => {
+ let onUsageResult = request => {
+ if (request.resultCode == Cr.NS_OK) {
+ let items = request.result;
+ for (let item of items) {
+ if (!item.persisted && item.usage <= 0) {
+ // An non-persistent-storage site with 0 byte quota usage is redundant for us so skip it.
+ continue;
+ }
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ item.origin
+ );
+ if (principal.schemeIs("http") || principal.schemeIs("https")) {
+ // Group dom storage by first party. If an entry is partitioned
+ // the first party site will be in the partitionKey, instead of
+ // the principal baseDomain.
+ let pkBaseDomain;
+ try {
+ pkBaseDomain = ChromeUtils.getBaseDomainFromPartitionKey(
+ principal.originAttributes.partitionKey
+ );
+ } catch (e) {
+ console.error(e);
+ }
+ let site = this._getOrInsertSite(
+ pkBaseDomain || principal.baseDomain
+ );
+ // Assume 3 sites:
+ // - Site A (not persisted): https://www.foo.com
+ // - Site B (not persisted): https://www.foo.com^userContextId=2
+ // - Site C (persisted): https://www.foo.com:1234
+ // Although only C is persisted, grouping by base domain, as a
+ // result, we still mark as persisted here under this base
+ // domain group.
+ if (item.persisted) {
+ site.persisted = true;
+ }
+ if (site.lastAccessed < item.lastAccessed) {
+ site.lastAccessed = item.lastAccessed;
+ }
+ if (Number.isInteger(principal.userContextId)) {
+ let containerData = this._getOrInsertContainersData(
+ site,
+ principal.userContextId
+ );
+ containerData.quotaUsage = item.usage;
+ let itemTime = item.lastAccessed / 1000;
+ if (containerData.lastAccessed.getTime() < itemTime) {
+ containerData.lastAccessed.setTime(itemTime);
+ }
+ }
+ site.principals.push(principal);
+ site.quotaUsage += item.usage;
+ if (entryUpdatedCallback) {
+ entryUpdatedCallback(principal.baseDomain, site);
+ }
+ }
+ }
+ }
+ resolve();
+ };
+ // XXX: The work of integrating localStorage into Quota Manager is in progress.
+ // After the bug 742822 and 1286798 landed, localStorage usage will be included.
+ // So currently only get indexedDB usage.
+ this._quotaUsageRequest = Services.qms.getUsage(onUsageResult);
+ });
+ return this._getQuotaUsagePromise;
+ },
+
+ _getAllCookies(entryUpdatedCallback) {
+ for (let cookie of Services.cookies.cookies) {
+ // Group cookies by first party. If a cookie is partitioned the
+ // partitionKey will contain the first party site, instead of the host
+ // field.
+ let pkBaseDomain;
+ try {
+ pkBaseDomain = ChromeUtils.getBaseDomainFromPartitionKey(
+ cookie.originAttributes.partitionKey
+ );
+ } catch (e) {
+ console.error(e);
+ }
+ let baseDomainOrHost =
+ pkBaseDomain || this.getBaseDomainFromHost(cookie.rawHost);
+ let site = this._getOrInsertSite(baseDomainOrHost);
+ if (entryUpdatedCallback) {
+ entryUpdatedCallback(baseDomainOrHost, site);
+ }
+ site.cookies.push(cookie);
+ if (Number.isInteger(cookie.originAttributes.userContextId)) {
+ let containerData = this._getOrInsertContainersData(
+ site,
+ cookie.originAttributes.userContextId
+ );
+ containerData.cookiesBlocked += 1;
+ let cookieTime = cookie.lastAccessed / 1000;
+ if (containerData.lastAccessed.getTime() < cookieTime) {
+ containerData.lastAccessed.setTime(cookieTime);
+ }
+ }
+ if (site.lastAccessed < cookie.lastAccessed) {
+ site.lastAccessed = cookie.lastAccessed;
+ }
+ }
+ },
+
+ _cancelGetQuotaUsage() {
+ if (this._quotaUsageRequest) {
+ this._quotaUsageRequest.cancel();
+ this._quotaUsageRequest = null;
+ }
+ },
+
+ /**
+ * Checks if the site with the provided ASCII host is using any site data at all.
+ * This will check for:
+ * - Cookies (incl. subdomains)
+ * - Quota Usage
+ * in that order. This function is meant to be fast, and thus will
+ * end searching and return true once the first trace of site data is found.
+ *
+ * @param {String} the ASCII host to check
+ * @returns {Boolean} whether the site has any data associated with it
+ */
+ async hasSiteData(asciiHost) {
+ if (Services.cookies.countCookiesFromHost(asciiHost)) {
+ return true;
+ }
+
+ let hasQuota = await new Promise(resolve => {
+ Services.qms.getUsage(request => {
+ if (request.resultCode != Cr.NS_OK) {
+ resolve(false);
+ return;
+ }
+
+ for (let item of request.result) {
+ if (!item.persisted && item.usage <= 0) {
+ continue;
+ }
+
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ item.origin
+ );
+ if (principal.asciiHost == asciiHost) {
+ resolve(true);
+ return;
+ }
+ }
+
+ resolve(false);
+ });
+ });
+
+ if (hasQuota) {
+ return true;
+ }
+
+ return false;
+ },
+
+ getTotalUsage() {
+ return this._getQuotaUsagePromise.then(() => {
+ let usage = 0;
+ for (let site of this._sites.values()) {
+ usage += site.quotaUsage;
+ }
+ return usage;
+ });
+ },
+
+ /**
+ * Gets all sites that are currently storing site data. Entries are grouped by
+ * parent base domain if applicable. For example "foo.example.com",
+ * "example.com" and "bar.example.com" will have one entry with the baseDomain
+ * "example.com".
+ * A base domain entry will represent all data of its storage jar. The storage
+ * jar holds all first party data of the domain as well as any third party
+ * data partitioned under the domain. Additionally we will add data which
+ * belongs to the domain but is part of other domains storage jars . That is
+ * data third-party partitioned under other domains.
+ * Sites which cannot be associated with a base domain, for example IP hosts,
+ * are not grouped.
+ *
+ * The list is not automatically up-to-date. You need to call
+ * {@link updateSites} before you can use this method for the first time (and
+ * whenever you want to get an updated set of list.)
+ *
+ * @returns {Promise} Promise that resolves with the list of all sites.
+ */
+ async getSites() {
+ await this._getQuotaUsagePromise;
+
+ return Array.from(this._sites.values()).map(site => ({
+ baseDomain: site.baseDomainOrHost,
+ cookies: site.cookies,
+ usage: site.quotaUsage,
+ containersData: site.containersData,
+ persisted: site.persisted,
+ lastAccessed: new Date(site.lastAccessed / 1000),
+ }));
+ },
+
+ /**
+ * Get site, which stores data, by base domain or host.
+ *
+ * The list is not automatically up-to-date. You need to call
+ * {@link updateSites} before you can use this method for the first time (and
+ * whenever you want to get an updated set of list.)
+ *
+ * @param {String} baseDomainOrHost - Base domain or host of the site to get.
+ *
+ * @returns {Promise<Object|null>} Promise that resolves with the site object
+ * or null if no site with given base domain or host stores data.
+ */
+ async getSite(baseDomainOrHost) {
+ let baseDomain = this.getBaseDomainFromHost(baseDomainOrHost);
+
+ let site = this._sites.get(baseDomain);
+ if (!site) {
+ return null;
+ }
+ return {
+ baseDomain: site.baseDomainOrHost,
+ cookies: site.cookies,
+ usage: site.quotaUsage,
+ containersData: site.containersData,
+ persisted: site.persisted,
+ lastAccessed: new Date(site.lastAccessed / 1000),
+ };
+ },
+
+ _removePermission(site) {
+ let removals = new Set();
+ for (let principal of site.principals) {
+ let { originNoSuffix } = principal;
+ if (removals.has(originNoSuffix)) {
+ // In case of encountering
+ // - https://www.foo.com
+ // - https://www.foo.com^userContextId=2
+ // because setting/removing permission is across OAs already so skip the same origin without suffix
+ continue;
+ }
+ removals.add(originNoSuffix);
+ Services.perms.removeFromPrincipal(principal, "persistent-storage");
+ }
+ },
+
+ _removeQuotaUsage(site) {
+ let promises = [];
+ let removals = new Set();
+ for (let principal of site.principals) {
+ let { originNoSuffix } = principal;
+ if (removals.has(originNoSuffix)) {
+ // In case of encountering
+ // - https://www.foo.com
+ // - https://www.foo.com^userContextId=2
+ // below we have already removed across OAs so skip the same origin without suffix
+ continue;
+ }
+ removals.add(originNoSuffix);
+ promises.push(
+ new Promise(resolve => {
+ // We are clearing *All* across OAs so need to ensure a principal without suffix here,
+ // or the call of `clearStoragesForPrincipal` would fail.
+ principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ originNoSuffix
+ );
+ let request = this._qms.clearStoragesForPrincipal(
+ principal,
+ null,
+ null,
+ true
+ );
+ request.callback = resolve;
+ })
+ );
+ }
+ return Promise.all(promises);
+ },
+
+ _removeCookies(site) {
+ for (let cookie of site.cookies) {
+ Services.cookies.remove(
+ cookie.host,
+ cookie.name,
+ cookie.path,
+ cookie.originAttributes
+ );
+ }
+ site.cookies = [];
+ },
+
+ // Returns a list of permissions from the permission manager that
+ // we consider part of "site data and cookies".
+ _getDeletablePermissions() {
+ let perms = [];
+
+ for (let permission of Services.perms.all) {
+ if (
+ permission.type == "persistent-storage" ||
+ permission.type == "storage-access"
+ ) {
+ perms.push(permission);
+ }
+ }
+
+ return perms;
+ },
+
+ /**
+ * Removes all site data for the specified list of domains and hosts.
+ * This includes site data of subdomains belonging to the domains or hosts and
+ * partitioned storage. Data is cleared per storage jar, which means if we
+ * clear "example.com", we will also clear third parties embedded on
+ * "example.com". Additionally we will clear all data of "example.com" (as a
+ * third party) from other jars.
+ *
+ * @param {string|string[]} domainsOrHosts - List of domains and hosts or
+ * single domain or host to remove.
+ * @returns {Promise} Promise that resolves when data is removed and the site
+ * data manager has been updated.
+ */
+ async remove(domainsOrHosts) {
+ if (domainsOrHosts == null) {
+ throw new Error("domainsOrHosts is required.");
+ }
+ // Allow the caller to pass a single base domain or host.
+ if (!Array.isArray(domainsOrHosts)) {
+ domainsOrHosts = [domainsOrHosts];
+ }
+ let perms = this._getDeletablePermissions();
+ let promises = [];
+ for (let domainOrHost of domainsOrHosts) {
+ const kFlags =
+ Ci.nsIClearDataService.CLEAR_COOKIES |
+ Ci.nsIClearDataService.CLEAR_DOM_STORAGES |
+ Ci.nsIClearDataService.CLEAR_EME |
+ Ci.nsIClearDataService.CLEAR_ALL_CACHES;
+ promises.push(
+ new Promise(function (resolve) {
+ const { clearData } = Services;
+ if (domainOrHost) {
+ // First try to clear by base domain for aDomainOrHost. If we can't
+ // get a base domain, fall back to clearing by just host.
+ try {
+ clearData.deleteDataFromBaseDomain(
+ domainOrHost,
+ true,
+ kFlags,
+ resolve
+ );
+ } catch (e) {
+ if (
+ e.result != Cr.NS_ERROR_HOST_IS_IP_ADDRESS &&
+ e.result != Cr.NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS
+ ) {
+ throw e;
+ }
+ clearData.deleteDataFromHost(domainOrHost, true, kFlags, resolve);
+ }
+ } else {
+ clearData.deleteDataFromLocalFiles(true, kFlags, resolve);
+ }
+ })
+ );
+
+ for (let perm of perms) {
+ // Specialcase local file permissions.
+ if (!domainOrHost) {
+ if (perm.principal.schemeIs("file")) {
+ Services.perms.removePermission(perm);
+ }
+ } else if (
+ Services.eTLD.hasRootDomain(perm.principal.host, domainOrHost)
+ ) {
+ Services.perms.removePermission(perm);
+ }
+ }
+ }
+
+ await Promise.all(promises);
+
+ return this.updateSites();
+ },
+
+ /**
+ * In the specified window, shows a prompt for removing all site data or the
+ * specified list of base domains or hosts, warning the user that this may log
+ * them out of websites.
+ *
+ * @param {mozIDOMWindowProxy} win - a parent DOM window to host the dialog.
+ * @param {string[]} [removals] - an array of base domain or host strings that
+ * will be removed.
+ * @returns {boolean} whether the user confirmed the prompt.
+ */
+ promptSiteDataRemoval(win, removals) {
+ if (removals) {
+ let args = {
+ hosts: removals,
+ allowed: false,
+ };
+ let features = "centerscreen,chrome,modal,resizable=no";
+ win.browsingContext.topChromeWindow.openDialog(
+ "chrome://browser/content/preferences/dialogs/siteDataRemoveSelected.xhtml",
+ "",
+ features,
+ args
+ );
+ return args.allowed;
+ }
+
+ let brandName = lazy.gBrandBundle.GetStringFromName("brandShortName");
+ let flags =
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
+ Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1 +
+ Services.prompt.BUTTON_POS_0_DEFAULT;
+ let title = lazy.gStringBundle.GetStringFromName(
+ "clearSiteDataPromptTitle"
+ );
+ let text = lazy.gStringBundle.formatStringFromName(
+ "clearSiteDataPromptText",
+ [brandName]
+ );
+ let btn0Label = lazy.gStringBundle.GetStringFromName("clearSiteDataNow");
+
+ let result = Services.prompt.confirmEx(
+ win,
+ title,
+ text,
+ flags,
+ btn0Label,
+ null,
+ null,
+ null,
+ {}
+ );
+ return result == 0;
+ },
+
+ /**
+ * Clears all site data and cache
+ *
+ * @returns a Promise that resolves when the data is cleared.
+ */
+ async removeAll() {
+ await this.removeCache();
+ return this.removeSiteData();
+ },
+
+ /**
+ * Clears all caches.
+ *
+ * @returns a Promise that resolves when the data is cleared.
+ */
+ removeCache() {
+ return new Promise(function (resolve) {
+ Services.clearData.deleteData(
+ Ci.nsIClearDataService.CLEAR_ALL_CACHES,
+ resolve
+ );
+ });
+ },
+
+ /**
+ * Clears all site data, but not cache, because the UI offers
+ * that functionality separately.
+ *
+ * @returns a Promise that resolves when the data is cleared.
+ */
+ async removeSiteData() {
+ await new Promise(function (resolve) {
+ Services.clearData.deleteData(
+ Ci.nsIClearDataService.CLEAR_COOKIES |
+ Ci.nsIClearDataService.CLEAR_DOM_STORAGES |
+ Ci.nsIClearDataService.CLEAR_HSTS |
+ Ci.nsIClearDataService.CLEAR_EME,
+ resolve
+ );
+ });
+
+ for (let permission of this._getDeletablePermissions()) {
+ Services.perms.removePermission(permission);
+ }
+
+ return this.updateSites();
+ },
+};
diff --git a/browser/modules/SitePermissions.sys.mjs b/browser/modules/SitePermissions.sys.mjs
new file mode 100644
index 0000000000..e7adf0d153
--- /dev/null
+++ b/browser/modules/SitePermissions.sys.mjs
@@ -0,0 +1,1326 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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",
+});
+
+var gStringBundle = Services.strings.createBundle(
+ "chrome://browser/locale/sitePermissions.properties"
+);
+
+/**
+ * A helper module to manage temporary permissions.
+ *
+ * Permissions are keyed by browser, so methods take a Browser
+ * element to identify the corresponding permission set.
+ *
+ * This uses a WeakMap to key browsers, so that entries are
+ * automatically cleared once the browser stops existing
+ * (once there are no other references to the browser object);
+ */
+const TemporaryPermissions = {
+ // This is a three level deep map with the following structure:
+ //
+ // Browser => {
+ // <baseDomain|origin>: {
+ // <permissionID>: {state: Number, expireTimeout: Number}
+ // }
+ // }
+ //
+ // Only the top level browser elements are stored via WeakMap. The WeakMap
+ // value is an object with URI baseDomains or origins as keys. The keys of
+ // that object are ids that identify permissions that were set for the
+ // specific URI. The final value is an object containing the permission state
+ // and the id of the timeout which will cause permission expiry.
+ // BLOCK permissions are keyed under baseDomain to prevent bypassing the block
+ // (see Bug 1492668). Any other permissions are keyed under origin.
+ _stateByBrowser: new WeakMap(),
+
+ // Extract baseDomain from uri. Fallback to hostname on conversion error.
+ _uriToBaseDomain(uri) {
+ try {
+ return Services.eTLD.getBaseDomain(uri);
+ } catch (error) {
+ if (
+ error.result !== Cr.NS_ERROR_HOST_IS_IP_ADDRESS &&
+ error.result !== Cr.NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS
+ ) {
+ throw error;
+ }
+ return uri.host;
+ }
+ },
+
+ /**
+ * Generate keys to store temporary permissions under. The strict key is
+ * origin, non-strict is baseDomain.
+ * @param {nsIPrincipal} principal - principal to derive keys from.
+ * @returns {Object} keys - Object containing the generated permission keys.
+ * @returns {string} keys.strict - Key to be used for strict matching.
+ * @returns {string} keys.nonStrict - Key to be used for non-strict matching.
+ * @throws {Error} - Throws if principal is undefined or no valid permission key can
+ * be generated.
+ */
+ _getKeysFromPrincipal(principal) {
+ return { strict: principal.origin, nonStrict: principal.baseDomain };
+ },
+
+ /**
+ * Sets a new permission for the specified browser.
+ * @returns {boolean} whether the permission changed, effectively.
+ */
+ set(
+ browser,
+ id,
+ state,
+ expireTimeMS,
+ principal = browser.contentPrincipal,
+ expireCallback
+ ) {
+ if (
+ !browser ||
+ !principal ||
+ !SitePermissions.isSupportedPrincipal(principal)
+ ) {
+ return false;
+ }
+ let entry = this._stateByBrowser.get(browser);
+ if (!entry) {
+ entry = { browser: Cu.getWeakReference(browser), uriToPerm: {} };
+ this._stateByBrowser.set(browser, entry);
+ }
+ let { uriToPerm } = entry;
+ // We store blocked permissions by baseDomain. Other states by origin.
+ let { strict, nonStrict } = this._getKeysFromPrincipal(principal);
+ let setKey;
+ let deleteKey;
+ // Differentiate between block and non-block permissions. If we store a
+ // block permission we need to delete old entries which may be set under
+ // origin before setting the new permission for baseDomain. For non-block
+ // permissions this is swapped.
+ if (state == SitePermissions.BLOCK) {
+ setKey = nonStrict;
+ deleteKey = strict;
+ } else {
+ setKey = strict;
+ deleteKey = nonStrict;
+ }
+
+ if (!uriToPerm[setKey]) {
+ uriToPerm[setKey] = {};
+ }
+
+ let expireTimeout = uriToPerm[setKey][id]?.expireTimeout;
+ let previousState = uriToPerm[setKey][id]?.state;
+ // If overwriting a permission state. We need to cancel the old timeout.
+ if (expireTimeout) {
+ lazy.clearTimeout(expireTimeout);
+ }
+ // Construct the new timeout to remove the permission once it has expired.
+ expireTimeout = lazy.setTimeout(() => {
+ let entryBrowser = entry.browser.get();
+ // Exit early if the browser is no longer alive when we get the timeout
+ // callback.
+ if (!entryBrowser || !uriToPerm[setKey]) {
+ return;
+ }
+ delete uriToPerm[setKey][id];
+ // Notify SitePermissions that a temporary permission has expired.
+ // Get the browser the permission is currently set for. If this.copy was
+ // used this browser is different from the original one passed above.
+ expireCallback(entryBrowser);
+ }, expireTimeMS);
+ uriToPerm[setKey][id] = {
+ expireTimeout,
+ state,
+ };
+
+ // If we set a permission state for a origin we need to reset the old state
+ // which may be set for baseDomain and vice versa. An individual permission
+ // must only ever be keyed by either origin or baseDomain.
+ let permissions = uriToPerm[deleteKey];
+ if (permissions) {
+ expireTimeout = permissions[id]?.expireTimeout;
+ if (expireTimeout) {
+ lazy.clearTimeout(expireTimeout);
+ }
+ delete permissions[id];
+ }
+
+ return state != previousState;
+ },
+
+ /**
+ * Removes a permission with the specified id for the specified browser.
+ * @returns {boolean} whether the permission was removed.
+ */
+ remove(browser, id) {
+ if (
+ !browser ||
+ !SitePermissions.isSupportedPrincipal(browser.contentPrincipal) ||
+ !this._stateByBrowser.has(browser)
+ ) {
+ return false;
+ }
+ // Permission can be stored by any of the two keys (strict and non-strict).
+ // getKeysFromURI can throw. We let the caller handle the exception.
+ let { strict, nonStrict } = this._getKeysFromPrincipal(
+ browser.contentPrincipal
+ );
+ let { uriToPerm } = this._stateByBrowser.get(browser);
+ for (let key of [nonStrict, strict]) {
+ if (uriToPerm[key]?.[id] != null) {
+ let { expireTimeout } = uriToPerm[key][id];
+ if (expireTimeout) {
+ lazy.clearTimeout(expireTimeout);
+ }
+ delete uriToPerm[key][id];
+ // Individual permissions can only ever be keyed either strict or
+ // non-strict. If we find the permission via the first key run we can
+ // return early.
+ return true;
+ }
+ }
+ return false;
+ },
+
+ // Gets a permission with the specified id for the specified browser.
+ get(browser, id) {
+ if (
+ !browser ||
+ !browser.contentPrincipal ||
+ !SitePermissions.isSupportedPrincipal(browser.contentPrincipal) ||
+ !this._stateByBrowser.has(browser)
+ ) {
+ return null;
+ }
+ let { uriToPerm } = this._stateByBrowser.get(browser);
+
+ let { strict, nonStrict } = this._getKeysFromPrincipal(
+ browser.contentPrincipal
+ );
+ for (let key of [nonStrict, strict]) {
+ if (uriToPerm[key]) {
+ let permission = uriToPerm[key][id];
+ if (permission) {
+ return {
+ id,
+ state: permission.state,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ };
+ }
+ }
+ }
+ return null;
+ },
+
+ // Gets all permissions for the specified browser.
+ // Note that only permissions that apply to the current URI
+ // of the passed browser element will be returned.
+ getAll(browser) {
+ let permissions = [];
+ if (
+ !SitePermissions.isSupportedPrincipal(browser.contentPrincipal) ||
+ !this._stateByBrowser.has(browser)
+ ) {
+ return permissions;
+ }
+ let { uriToPerm } = this._stateByBrowser.get(browser);
+
+ let { strict, nonStrict } = this._getKeysFromPrincipal(
+ browser.contentPrincipal
+ );
+ for (let key of [nonStrict, strict]) {
+ if (uriToPerm[key]) {
+ let perms = uriToPerm[key];
+ for (let id of Object.keys(perms)) {
+ let permission = perms[id];
+ if (permission) {
+ permissions.push({
+ id,
+ state: permission.state,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ });
+ }
+ }
+ }
+ }
+
+ return permissions;
+ },
+
+ // Clears all permissions for the specified browser.
+ // Unlike other methods, this does NOT clear only for
+ // the currentURI but the whole browser state.
+
+ /**
+ * Clear temporary permissions for the specified browser. Unlike other
+ * methods, this does NOT clear only for the currentURI but the whole browser
+ * state.
+ * @param {Browser} browser - Browser to clear permissions for.
+ * @param {Number} [filterState] - Only clear permissions with the given state
+ * value. Defaults to all permissions.
+ */
+ clear(browser, filterState = null) {
+ let entry = this._stateByBrowser.get(browser);
+ if (!entry?.uriToPerm) {
+ return;
+ }
+
+ let { uriToPerm } = entry;
+ Object.entries(uriToPerm).forEach(([uriKey, permissions]) => {
+ Object.entries(permissions).forEach(
+ ([permId, { state, expireTimeout }]) => {
+ // We need to explicitly check for null or undefined here, because the
+ // permission state may be 0.
+ if (filterState != null) {
+ if (state != filterState) {
+ // Skip permission entry if it doesn't match the filter.
+ return;
+ }
+ delete permissions[permId];
+ }
+ // For the clear-all case we remove the entire browser entry, so we
+ // only need to clear the timeouts.
+ if (!expireTimeout) {
+ return;
+ }
+ lazy.clearTimeout(expireTimeout);
+ }
+ );
+ // If there are no more permissions, remove the entry from the URI map.
+ if (filterState != null && !Object.keys(permissions).length) {
+ delete uriToPerm[uriKey];
+ }
+ });
+
+ // We're either clearing all permissions or only the permissions with state
+ // == filterState. If we have a filter, we can only clean up the browser if
+ // there are no permission entries left in the map.
+ if (filterState == null || !Object.keys(uriToPerm).length) {
+ this._stateByBrowser.delete(browser);
+ }
+ },
+
+ // Copies the temporary permission state of one browser
+ // into a new entry for the other browser.
+ copy(browser, newBrowser) {
+ let entry = this._stateByBrowser.get(browser);
+ if (entry) {
+ entry.browser = Cu.getWeakReference(newBrowser);
+ this._stateByBrowser.set(newBrowser, entry);
+ }
+ },
+};
+
+// This hold a flag per browser to indicate whether we should show the
+// user a notification as a permission has been requested that has been
+// blocked globally. We only want to notify the user in the case that
+// they actually requested the permission within the current page load
+// so will clear the flag on navigation.
+const GloballyBlockedPermissions = {
+ _stateByBrowser: new WeakMap(),
+
+ /**
+ * @returns {boolean} whether the permission was removed.
+ */
+ set(browser, id) {
+ if (!this._stateByBrowser.has(browser)) {
+ this._stateByBrowser.set(browser, {});
+ }
+ let entry = this._stateByBrowser.get(browser);
+ let origin = browser.contentPrincipal.origin;
+ if (!entry[origin]) {
+ entry[origin] = {};
+ }
+
+ if (entry[origin][id]) {
+ return false;
+ }
+ entry[origin][id] = true;
+
+ // Clear the flag and remove the listener once the user has navigated.
+ // WebProgress will report various things including hashchanges to us, the
+ // navigation we care about is either leaving the current page or reloading.
+ let { prePath } = browser.currentURI;
+ browser.addProgressListener(
+ {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+ onLocationChange(aWebProgress, aRequest, aLocation, aFlags) {
+ let hasLeftPage =
+ aLocation.prePath != prePath ||
+ !(aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT);
+ let isReload = !!(
+ aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_RELOAD
+ );
+
+ if (aWebProgress.isTopLevel && (hasLeftPage || isReload)) {
+ GloballyBlockedPermissions.remove(browser, id, origin);
+ browser.removeProgressListener(this);
+ }
+ },
+ },
+ Ci.nsIWebProgress.NOTIFY_LOCATION
+ );
+ return true;
+ },
+
+ // Removes a permission with the specified id for the specified browser.
+ remove(browser, id, origin = null) {
+ let entry = this._stateByBrowser.get(browser);
+ if (!origin) {
+ origin = browser.contentPrincipal.origin;
+ }
+ if (entry && entry[origin]) {
+ delete entry[origin][id];
+ }
+ },
+
+ // Gets all permissions for the specified browser.
+ // Note that only permissions that apply to the current URI
+ // of the passed browser element will be returned.
+ getAll(browser) {
+ let permissions = [];
+ let entry = this._stateByBrowser.get(browser);
+ let origin = browser.contentPrincipal.origin;
+ if (entry && entry[origin]) {
+ let timeStamps = entry[origin];
+ for (let id of Object.keys(timeStamps)) {
+ permissions.push({
+ id,
+ state: gPermissions.get(id).getDefault(),
+ scope: SitePermissions.SCOPE_GLOBAL,
+ });
+ }
+ }
+ return permissions;
+ },
+
+ // Copies the globally blocked permission state of one browser
+ // into a new entry for the other browser.
+ copy(browser, newBrowser) {
+ let entry = this._stateByBrowser.get(browser);
+ if (entry) {
+ this._stateByBrowser.set(newBrowser, entry);
+ }
+ },
+};
+
+/**
+ * A module to manage permanent and temporary permissions
+ * by URI and browser.
+ *
+ * Some methods have the side effect of dispatching a "PermissionStateChange"
+ * event on changes to temporary permissions, as mentioned in the respective docs.
+ */
+export var SitePermissions = {
+ // Permission states.
+ UNKNOWN: Services.perms.UNKNOWN_ACTION,
+ ALLOW: Services.perms.ALLOW_ACTION,
+ BLOCK: Services.perms.DENY_ACTION,
+ PROMPT: Services.perms.PROMPT_ACTION,
+ ALLOW_COOKIES_FOR_SESSION: Ci.nsICookiePermission.ACCESS_SESSION,
+ AUTOPLAY_BLOCKED_ALL: Ci.nsIAutoplay.BLOCKED_ALL,
+
+ // Permission scopes.
+ SCOPE_REQUEST: "{SitePermissions.SCOPE_REQUEST}",
+ SCOPE_TEMPORARY: "{SitePermissions.SCOPE_TEMPORARY}",
+ SCOPE_SESSION: "{SitePermissions.SCOPE_SESSION}",
+ SCOPE_PERSISTENT: "{SitePermissions.SCOPE_PERSISTENT}",
+ SCOPE_POLICY: "{SitePermissions.SCOPE_POLICY}",
+ SCOPE_GLOBAL: "{SitePermissions.SCOPE_GLOBAL}",
+
+ // The delimiter used for double keyed permissions.
+ // For example: open-protocol-handler^irc
+ PERM_KEY_DELIMITER: "^",
+
+ _permissionsArray: null,
+ _defaultPrefBranch: Services.prefs.getBranch("permissions.default."),
+
+ // For testing use only.
+ _temporaryPermissions: TemporaryPermissions,
+
+ /**
+ * Gets all custom permissions for a given principal.
+ * Install addon permission is excluded, check bug 1303108.
+ *
+ * @return {Array} a list of objects with the keys:
+ * - id: the permissionId of the permission
+ * - scope: the scope of the permission (e.g. SitePermissions.SCOPE_TEMPORARY)
+ * - state: a constant representing the current permission state
+ * (e.g. SitePermissions.ALLOW)
+ */
+ getAllByPrincipal(principal) {
+ if (!principal) {
+ throw new Error("principal argument cannot be null.");
+ }
+ if (!this.isSupportedPrincipal(principal)) {
+ return [];
+ }
+
+ // Get all permissions from the permission manager by principal, excluding
+ // the ones set to be disabled.
+ let permissions = Services.perms
+ .getAllForPrincipal(principal)
+ .filter(permission => {
+ let entry = gPermissions.get(permission.type);
+ if (!entry || entry.disabled) {
+ return false;
+ }
+ let type = entry.id;
+
+ /* Hide persistent storage permission when extension principal
+ * have WebExtensions-unlimitedStorage permission. */
+ if (
+ type == "persistent-storage" &&
+ SitePermissions.getForPrincipal(
+ principal,
+ "WebExtensions-unlimitedStorage"
+ ).state == SitePermissions.ALLOW
+ ) {
+ return false;
+ }
+
+ return true;
+ });
+
+ return permissions.map(permission => {
+ let scope = this.SCOPE_PERSISTENT;
+ if (permission.expireType == Services.perms.EXPIRE_SESSION) {
+ scope = this.SCOPE_SESSION;
+ } else if (permission.expireType == Services.perms.EXPIRE_POLICY) {
+ scope = this.SCOPE_POLICY;
+ }
+
+ return {
+ id: permission.type,
+ scope,
+ state: permission.capability,
+ };
+ });
+ },
+
+ /**
+ * Returns all custom permissions for a given browser.
+ *
+ * To receive a more detailed, albeit less performant listing see
+ * SitePermissions.getAllPermissionDetailsForBrowser().
+ *
+ * @param {Browser} browser
+ * The browser to fetch permission for.
+ *
+ * @return {Array} a list of objects with the keys:
+ * - id: the permissionId of the permission
+ * - state: a constant representing the current permission state
+ * (e.g. SitePermissions.ALLOW)
+ * - scope: a constant representing how long the permission will
+ * be kept.
+ */
+ getAllForBrowser(browser) {
+ let permissions = {};
+
+ for (let permission of TemporaryPermissions.getAll(browser)) {
+ permission.scope = this.SCOPE_TEMPORARY;
+ permissions[permission.id] = permission;
+ }
+
+ for (let permission of GloballyBlockedPermissions.getAll(browser)) {
+ permissions[permission.id] = permission;
+ }
+
+ for (let permission of this.getAllByPrincipal(browser.contentPrincipal)) {
+ permissions[permission.id] = permission;
+ }
+
+ return Object.values(permissions);
+ },
+
+ /**
+ * Returns a list of objects with detailed information on all permissions
+ * that are currently set for the given browser.
+ *
+ * @param {Browser} browser
+ * The browser to fetch permission for.
+ *
+ * @return {Array<Object>} a list of objects with the keys:
+ * - id: the permissionID of the permission
+ * - state: a constant representing the current permission state
+ * (e.g. SitePermissions.ALLOW)
+ * - scope: a constant representing how long the permission will
+ * be kept.
+ * - label: the localized label, or null if none is available.
+ */
+ getAllPermissionDetailsForBrowser(browser) {
+ return this.getAllForBrowser(browser).map(({ id, scope, state }) => ({
+ id,
+ scope,
+ state,
+ label: this.getPermissionLabel(id),
+ }));
+ },
+
+ /**
+ * Checks whether a UI for managing permissions should be exposed for a given
+ * principal.
+ *
+ * @param {nsIPrincipal} principal
+ * The principal to check.
+ *
+ * @return {boolean} if the principal is supported.
+ */
+ isSupportedPrincipal(principal) {
+ if (!principal) {
+ return false;
+ }
+ if (!(principal instanceof Ci.nsIPrincipal)) {
+ throw new Error(
+ "Argument passed as principal is not an instance of Ci.nsIPrincipal"
+ );
+ }
+ return this.isSupportedScheme(principal.scheme);
+ },
+
+ /**
+ * Checks whether we support managing permissions for a specific scheme.
+ * @param {string} scheme - Scheme to test.
+ * @returns {boolean} Whether the scheme is supported.
+ */
+ isSupportedScheme(scheme) {
+ return ["http", "https", "moz-extension", "file"].includes(scheme);
+ },
+
+ /**
+ * Gets an array of all permission IDs.
+ *
+ * @return {Array<String>} an array of all permission IDs.
+ */
+ listPermissions() {
+ if (this._permissionsArray === null) {
+ this._permissionsArray = gPermissions.getEnabledPermissions();
+ }
+ return this._permissionsArray;
+ },
+
+ /**
+ * Test whether a permission is managed by SitePermissions.
+ * @param {string} type - Permission type.
+ * @returns {boolean}
+ */
+ isSitePermission(type) {
+ return gPermissions.has(type);
+ },
+
+ /**
+ * Called when a preference changes its value.
+ *
+ * @param {string} data
+ * The last argument passed to the preference change observer
+ * @param {string} previous
+ * The previous value of the preference
+ * @param {string} latest
+ * The latest value of the preference
+ */
+ invalidatePermissionList(data, previous, latest) {
+ // Ensure that listPermissions() will reconstruct its return value the next
+ // time it's called.
+ this._permissionsArray = null;
+ },
+
+ /**
+ * Returns an array of permission states to be exposed to the user for a
+ * permission with the given ID.
+ *
+ * @param {string} permissionID
+ * The ID to get permission states for.
+ *
+ * @return {Array<SitePermissions state>} an array of all permission states.
+ */
+ getAvailableStates(permissionID) {
+ if (
+ gPermissions.has(permissionID) &&
+ gPermissions.get(permissionID).states
+ ) {
+ return gPermissions.get(permissionID).states;
+ }
+
+ /* Since the permissions we are dealing with have adopted the convention
+ * of treating UNKNOWN == PROMPT, we only include one of either UNKNOWN
+ * or PROMPT in this list, to avoid duplicating states. */
+ if (this.getDefault(permissionID) == this.UNKNOWN) {
+ return [
+ SitePermissions.UNKNOWN,
+ SitePermissions.ALLOW,
+ SitePermissions.BLOCK,
+ ];
+ }
+
+ return [
+ SitePermissions.PROMPT,
+ SitePermissions.ALLOW,
+ SitePermissions.BLOCK,
+ ];
+ },
+
+ /**
+ * Returns the default state of a particular permission.
+ *
+ * @param {string} permissionID
+ * The ID to get the default for.
+ *
+ * @return {SitePermissions.state} the default state.
+ */
+ getDefault(permissionID) {
+ // If the permission has custom logic for getting its default value,
+ // try that first.
+ if (
+ gPermissions.has(permissionID) &&
+ gPermissions.get(permissionID).getDefault
+ ) {
+ return gPermissions.get(permissionID).getDefault();
+ }
+
+ // Otherwise try to get the default preference for that permission.
+ return this._defaultPrefBranch.getIntPref(permissionID, this.UNKNOWN);
+ },
+
+ /**
+ * Set the default state of a particular permission.
+ *
+ * @param {string} permissionID
+ * The ID to set the default for.
+ *
+ * @param {string} state
+ * The state to set.
+ */
+ setDefault(permissionID, state) {
+ if (
+ gPermissions.has(permissionID) &&
+ gPermissions.get(permissionID).setDefault
+ ) {
+ return gPermissions.get(permissionID).setDefault(state);
+ }
+ let key = "permissions.default." + permissionID;
+ return Services.prefs.setIntPref(key, state);
+ },
+
+ /**
+ * Returns the state and scope of a particular permission for a given principal.
+ *
+ * This method will NOT dispatch a "PermissionStateChange" event on the specified
+ * browser if a temporary permission was removed because it has expired.
+ *
+ * @param {nsIPrincipal} principal
+ * The principal to check.
+ * @param {String} permissionID
+ * The id of the permission.
+ * @param {Browser} [browser] The browser object to check for temporary
+ * permissions.
+ *
+ * @return {Object} an object with the keys:
+ * - state: The current state of the permission
+ * (e.g. SitePermissions.ALLOW)
+ * - scope: The scope of the permission
+ * (e.g. SitePermissions.SCOPE_PERSISTENT)
+ */
+ getForPrincipal(principal, permissionID, browser) {
+ if (!principal && !browser) {
+ throw new Error(
+ "Atleast one of the arguments, either principal or browser should not be null."
+ );
+ }
+ let defaultState = this.getDefault(permissionID);
+ let result = { state: defaultState, scope: this.SCOPE_PERSISTENT };
+ if (this.isSupportedPrincipal(principal)) {
+ let permission = null;
+ if (
+ gPermissions.has(permissionID) &&
+ gPermissions.get(permissionID).exactHostMatch
+ ) {
+ permission = Services.perms.getPermissionObject(
+ principal,
+ permissionID,
+ true
+ );
+ } else {
+ permission = Services.perms.getPermissionObject(
+ principal,
+ permissionID,
+ false
+ );
+ }
+
+ if (permission) {
+ result.state = permission.capability;
+ if (permission.expireType == Services.perms.EXPIRE_SESSION) {
+ result.scope = this.SCOPE_SESSION;
+ } else if (permission.expireType == Services.perms.EXPIRE_POLICY) {
+ result.scope = this.SCOPE_POLICY;
+ }
+ }
+ }
+
+ if (result.state == defaultState) {
+ // If there's no persistent permission saved, check if we have something
+ // set temporarily.
+ let value = TemporaryPermissions.get(browser, permissionID);
+
+ if (value) {
+ result.state = value.state;
+ result.scope = this.SCOPE_TEMPORARY;
+ }
+ }
+
+ return result;
+ },
+
+ /**
+ * Sets the state of a particular permission for a given principal or browser.
+ * This method will dispatch a "PermissionStateChange" event on the specified
+ * browser if a temporary permission was set
+ *
+ * @param {nsIPrincipal} [principal] The principal to set the permission for.
+ * When setting temporary permissions passing a principal is optional.
+ * If the principal is still passed here it takes precedence over the
+ * browser's contentPrincipal for permission keying. This can be
+ * helpful in situations where the browser has already navigated away
+ * from a site you want to set a permission for.
+ * @param {String} permissionID The id of the permission.
+ * @param {SitePermissions state} state The state of the permission.
+ * @param {SitePermissions scope} [scope] The scope of the permission.
+ * Defaults to SCOPE_PERSISTENT.
+ * @param {Browser} [browser] The browser object to set temporary permissions
+ * on. This needs to be provided if the scope is SCOPE_TEMPORARY!
+ * @param {number} [expireTimeMS] If setting a temporary permission, how many
+ * milliseconds it should be valid for. The default is controlled by
+ * the 'privacy.temporary_permission_expire_time_ms' pref.
+ */
+ setForPrincipal(
+ principal,
+ permissionID,
+ state,
+ scope = this.SCOPE_PERSISTENT,
+ browser = null,
+ expireTimeMS = SitePermissions.temporaryPermissionExpireTime
+ ) {
+ if (!principal && !browser) {
+ throw new Error(
+ "Atleast one of the arguments, either principal or browser should not be null."
+ );
+ }
+ if (scope == this.SCOPE_GLOBAL && state == this.BLOCK) {
+ if (GloballyBlockedPermissions.set(browser, permissionID)) {
+ browser.dispatchEvent(
+ new browser.ownerGlobal.CustomEvent("PermissionStateChange")
+ );
+ }
+ return;
+ }
+
+ if (state == this.UNKNOWN || state == this.getDefault(permissionID)) {
+ // Because they are controlled by two prefs with many states that do not
+ // correspond to the classical ALLOW/DENY/PROMPT model, we want to always
+ // allow the user to add exceptions to their cookie rules without removing them.
+ if (permissionID != "cookie") {
+ this.removeFromPrincipal(principal, permissionID, browser);
+ return;
+ }
+ }
+
+ if (state == this.ALLOW_COOKIES_FOR_SESSION && permissionID != "cookie") {
+ throw new Error(
+ "ALLOW_COOKIES_FOR_SESSION can only be set on the cookie permission"
+ );
+ }
+
+ // Save temporary permissions.
+ if (scope == this.SCOPE_TEMPORARY) {
+ if (!browser) {
+ throw new Error(
+ "TEMPORARY scoped permissions require a browser object"
+ );
+ }
+ if (!Number.isInteger(expireTimeMS) || expireTimeMS <= 0) {
+ throw new Error("expireTime must be a positive integer");
+ }
+
+ if (
+ TemporaryPermissions.set(
+ browser,
+ permissionID,
+ state,
+ expireTimeMS,
+ principal ?? browser.contentPrincipal,
+ // On permission expiry
+ origBrowser => {
+ if (!origBrowser.ownerGlobal) {
+ return;
+ }
+ origBrowser.dispatchEvent(
+ new origBrowser.ownerGlobal.CustomEvent("PermissionStateChange")
+ );
+ }
+ )
+ ) {
+ browser.dispatchEvent(
+ new browser.ownerGlobal.CustomEvent("PermissionStateChange")
+ );
+ }
+ } else if (this.isSupportedPrincipal(principal)) {
+ let perms_scope = Services.perms.EXPIRE_NEVER;
+ if (scope == this.SCOPE_SESSION) {
+ perms_scope = Services.perms.EXPIRE_SESSION;
+ } else if (scope == this.SCOPE_POLICY) {
+ perms_scope = Services.perms.EXPIRE_POLICY;
+ }
+
+ Services.perms.addFromPrincipal(
+ principal,
+ permissionID,
+ state,
+ perms_scope
+ );
+ }
+ },
+
+ /**
+ * Removes the saved state of a particular permission for a given principal and/or browser.
+ * This method will dispatch a "PermissionStateChange" event on the specified
+ * browser if a temporary permission was removed.
+ *
+ * @param {nsIPrincipal} principal
+ * The principal to remove the permission for.
+ * @param {String} permissionID
+ * The id of the permission.
+ * @param {Browser} browser (optional)
+ * The browser object to remove temporary permissions on.
+ */
+ removeFromPrincipal(principal, permissionID, browser) {
+ if (!principal && !browser) {
+ throw new Error(
+ "Atleast one of the arguments, either principal or browser should not be null."
+ );
+ }
+ if (this.isSupportedPrincipal(principal)) {
+ Services.perms.removeFromPrincipal(principal, permissionID);
+ }
+
+ // TemporaryPermissions.get() deletes expired permissions automatically,
+ // if it hasn't expired, remove it explicitly.
+ if (TemporaryPermissions.remove(browser, permissionID)) {
+ // Send a PermissionStateChange event only if the permission hasn't expired.
+ browser.dispatchEvent(
+ new browser.ownerGlobal.CustomEvent("PermissionStateChange")
+ );
+ }
+ },
+
+ /**
+ * Clears all block permissions that were temporarily saved.
+ *
+ * @param {Browser} browser
+ * The browser object to clear.
+ */
+ clearTemporaryBlockPermissions(browser) {
+ TemporaryPermissions.clear(browser, SitePermissions.BLOCK);
+ },
+
+ /**
+ * Copy all permissions that were temporarily saved on one
+ * browser object to a new browser.
+ *
+ * @param {Browser} browser
+ * The browser object to copy from.
+ * @param {Browser} newBrowser
+ * The browser object to copy to.
+ */
+ copyTemporaryPermissions(browser, newBrowser) {
+ TemporaryPermissions.copy(browser, newBrowser);
+ GloballyBlockedPermissions.copy(browser, newBrowser);
+ },
+
+ /**
+ * Returns the localized label for the permission with the given ID, to be
+ * used in a UI for managing permissions.
+ * If a permission is double keyed (has an additional key in the ID), the
+ * second key is split off and supplied to the string formatter as a variable.
+ *
+ * @param {string} permissionID
+ * The permission to get the label for. May include second key.
+ *
+ * @return {String} the localized label or null if none is available.
+ */
+ getPermissionLabel(permissionID) {
+ let [id, key] = permissionID.split(this.PERM_KEY_DELIMITER);
+ if (!gPermissions.has(id)) {
+ // Permission can't be found.
+ return null;
+ }
+ if (
+ "labelID" in gPermissions.get(id) &&
+ gPermissions.get(id).labelID === null
+ ) {
+ // Permission doesn't support having a label.
+ return null;
+ }
+ if (id == "3rdPartyStorage") {
+ // The key is the 3rd party origin, which we use for the label.
+ return key;
+ }
+ let labelID = gPermissions.get(id).labelID || id;
+ return gStringBundle.formatStringFromName(`permission.${labelID}.label`, [
+ key,
+ ]);
+ },
+
+ /**
+ * Returns the localized label for the given permission state, to be used in
+ * a UI for managing permissions.
+ *
+ * @param {string} permissionID
+ * The permission to get the label for.
+ *
+ * @param {SitePermissions state} state
+ * The state to get the label for.
+ *
+ * @return {String|null} the localized label or null if an
+ * unknown state was passed.
+ */
+ getMultichoiceStateLabel(permissionID, state) {
+ // If the permission has custom logic for getting its default value,
+ // try that first.
+ if (
+ gPermissions.has(permissionID) &&
+ gPermissions.get(permissionID).getMultichoiceStateLabel
+ ) {
+ return gPermissions.get(permissionID).getMultichoiceStateLabel(state);
+ }
+
+ switch (state) {
+ case this.UNKNOWN:
+ case this.PROMPT:
+ return gStringBundle.GetStringFromName("state.multichoice.alwaysAsk");
+ case this.ALLOW:
+ return gStringBundle.GetStringFromName("state.multichoice.allow");
+ case this.ALLOW_COOKIES_FOR_SESSION:
+ return gStringBundle.GetStringFromName(
+ "state.multichoice.allowForSession"
+ );
+ case this.BLOCK:
+ return gStringBundle.GetStringFromName("state.multichoice.block");
+ default:
+ return null;
+ }
+ },
+
+ /**
+ * Returns the localized label for a permission's current state.
+ *
+ * @param {SitePermissions state} state
+ * The state to get the label for.
+ * @param {string} id
+ * The permission to get the state label for.
+ * @param {SitePermissions scope} scope (optional)
+ * The scope to get the label for.
+ *
+ * @return {String|null} the localized label or null if an
+ * unknown state was passed.
+ */
+ getCurrentStateLabel(state, id, scope = null) {
+ switch (state) {
+ case this.PROMPT:
+ return gStringBundle.GetStringFromName("state.current.prompt");
+ case this.ALLOW:
+ if (
+ scope &&
+ scope != this.SCOPE_PERSISTENT &&
+ scope != this.SCOPE_POLICY
+ ) {
+ return gStringBundle.GetStringFromName(
+ "state.current.allowedTemporarily"
+ );
+ }
+ return gStringBundle.GetStringFromName("state.current.allowed");
+ case this.ALLOW_COOKIES_FOR_SESSION:
+ return gStringBundle.GetStringFromName(
+ "state.current.allowedForSession"
+ );
+ case this.BLOCK:
+ if (
+ scope &&
+ scope != this.SCOPE_PERSISTENT &&
+ scope != this.SCOPE_POLICY &&
+ scope != this.SCOPE_GLOBAL
+ ) {
+ return gStringBundle.GetStringFromName(
+ "state.current.blockedTemporarily"
+ );
+ }
+ return gStringBundle.GetStringFromName("state.current.blocked");
+ default:
+ return null;
+ }
+ },
+};
+
+let gPermissions = {
+ _getId(type) {
+ // Split off second key (if it exists).
+ let [id] = type.split(SitePermissions.PERM_KEY_DELIMITER);
+ return id;
+ },
+
+ has(type) {
+ return this._getId(type) in this._permissions;
+ },
+
+ get(type) {
+ let id = this._getId(type);
+ let perm = this._permissions[id];
+ if (perm) {
+ perm.id = id;
+ }
+ return perm;
+ },
+
+ getEnabledPermissions() {
+ return Object.keys(this._permissions).filter(
+ id => !this._permissions[id].disabled
+ );
+ },
+
+ /* Holds permission ID => options pairs.
+ *
+ * Supported options:
+ *
+ * - exactHostMatch
+ * Allows sub domains to have their own permissions.
+ * Defaults to false.
+ *
+ * - getDefault
+ * Called to get the permission's default state.
+ * Defaults to UNKNOWN, indicating that the user will be asked each time
+ * a page asks for that permissions.
+ *
+ * - labelID
+ * Use the given ID instead of the permission name for looking up strings.
+ * e.g. "desktop-notification2" to use permission.desktop-notification2.label
+ *
+ * - states
+ * Array of permission states to be exposed to the user.
+ * Defaults to ALLOW, BLOCK and the default state (see getDefault).
+ *
+ * - getMultichoiceStateLabel
+ * Optional method to overwrite SitePermissions#getMultichoiceStateLabel with custom label logic.
+ */
+ _permissions: {
+ "autoplay-media": {
+ exactHostMatch: true,
+ getDefault() {
+ let pref = Services.prefs.getIntPref(
+ "media.autoplay.default",
+ Ci.nsIAutoplay.BLOCKED
+ );
+ if (pref == Ci.nsIAutoplay.ALLOWED) {
+ return SitePermissions.ALLOW;
+ }
+ if (pref == Ci.nsIAutoplay.BLOCKED_ALL) {
+ return SitePermissions.AUTOPLAY_BLOCKED_ALL;
+ }
+ return SitePermissions.BLOCK;
+ },
+ setDefault(value) {
+ let prefValue = Ci.nsIAutoplay.BLOCKED;
+ if (value == SitePermissions.ALLOW) {
+ prefValue = Ci.nsIAutoplay.ALLOWED;
+ } else if (value == SitePermissions.AUTOPLAY_BLOCKED_ALL) {
+ prefValue = Ci.nsIAutoplay.BLOCKED_ALL;
+ }
+ Services.prefs.setIntPref("media.autoplay.default", prefValue);
+ },
+ labelID: "autoplay",
+ states: [
+ SitePermissions.ALLOW,
+ SitePermissions.BLOCK,
+ SitePermissions.AUTOPLAY_BLOCKED_ALL,
+ ],
+ getMultichoiceStateLabel(state) {
+ switch (state) {
+ case SitePermissions.AUTOPLAY_BLOCKED_ALL:
+ return gStringBundle.GetStringFromName(
+ "state.multichoice.autoplayblockall"
+ );
+ case SitePermissions.BLOCK:
+ return gStringBundle.GetStringFromName(
+ "state.multichoice.autoplayblock"
+ );
+ case SitePermissions.ALLOW:
+ return gStringBundle.GetStringFromName(
+ "state.multichoice.autoplayallow"
+ );
+ }
+ throw new Error(`Unknown state: ${state}`);
+ },
+ },
+
+ cookie: {
+ states: [
+ SitePermissions.ALLOW,
+ SitePermissions.ALLOW_COOKIES_FOR_SESSION,
+ SitePermissions.BLOCK,
+ ],
+ getDefault() {
+ if (
+ Services.cookies.getCookieBehavior(false) ==
+ Ci.nsICookieService.BEHAVIOR_REJECT
+ ) {
+ return SitePermissions.BLOCK;
+ }
+
+ return SitePermissions.ALLOW;
+ },
+ },
+
+ "desktop-notification": {
+ exactHostMatch: true,
+ labelID: "desktop-notification3",
+ },
+
+ camera: {
+ exactHostMatch: true,
+ },
+
+ microphone: {
+ exactHostMatch: true,
+ },
+
+ screen: {
+ exactHostMatch: true,
+ states: [SitePermissions.UNKNOWN, SitePermissions.BLOCK],
+ },
+
+ speaker: {
+ exactHostMatch: true,
+ states: [SitePermissions.UNKNOWN, SitePermissions.BLOCK],
+ get disabled() {
+ return !SitePermissions.setSinkIdEnabled;
+ },
+ },
+
+ popup: {
+ getDefault() {
+ return Services.prefs.getBoolPref("dom.disable_open_during_load")
+ ? SitePermissions.BLOCK
+ : SitePermissions.ALLOW;
+ },
+ states: [SitePermissions.ALLOW, SitePermissions.BLOCK],
+ },
+
+ install: {
+ getDefault() {
+ return Services.prefs.getBoolPref("xpinstall.whitelist.required")
+ ? SitePermissions.UNKNOWN
+ : SitePermissions.ALLOW;
+ },
+ },
+
+ geo: {
+ exactHostMatch: true,
+ },
+
+ "open-protocol-handler": {
+ labelID: "open-protocol-handler",
+ exactHostMatch: true,
+ states: [SitePermissions.UNKNOWN, SitePermissions.ALLOW],
+ get disabled() {
+ return !SitePermissions.openProtoPermissionEnabled;
+ },
+ },
+
+ xr: {
+ exactHostMatch: true,
+ },
+
+ "focus-tab-by-prompt": {
+ exactHostMatch: true,
+ states: [SitePermissions.UNKNOWN, SitePermissions.ALLOW],
+ },
+ "persistent-storage": {
+ exactHostMatch: true,
+ },
+
+ shortcuts: {
+ states: [SitePermissions.ALLOW, SitePermissions.BLOCK],
+ },
+
+ canvas: {
+ get disabled() {
+ return !SitePermissions.resistFingerprinting;
+ },
+ },
+
+ midi: {
+ exactHostMatch: true,
+ get disabled() {
+ return !SitePermissions.midiPermissionEnabled;
+ },
+ },
+
+ "midi-sysex": {
+ exactHostMatch: true,
+ get disabled() {
+ return !SitePermissions.midiPermissionEnabled;
+ },
+ },
+
+ "storage-access": {
+ labelID: null,
+ getDefault() {
+ return SitePermissions.UNKNOWN;
+ },
+ },
+
+ "3rdPartyStorage": {},
+ },
+};
+
+SitePermissions.midiPermissionEnabled = Services.prefs.getBoolPref(
+ "dom.webmidi.enabled"
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ SitePermissions,
+ "temporaryPermissionExpireTime",
+ "privacy.temporary_permission_expire_time_ms",
+ 3600 * 1000
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ SitePermissions,
+ "setSinkIdEnabled",
+ "media.setsinkid.enabled",
+ false,
+ SitePermissions.invalidatePermissionList.bind(SitePermissions)
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ SitePermissions,
+ "resistFingerprinting",
+ "privacy.resistFingerprinting",
+ false,
+ SitePermissions.invalidatePermissionList.bind(SitePermissions)
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ SitePermissions,
+ "openProtoPermissionEnabled",
+ "security.external_protocol_requires_permission",
+ true,
+ SitePermissions.invalidatePermissionList.bind(SitePermissions)
+);
diff --git a/browser/modules/TabUnloader.jsm b/browser/modules/TabUnloader.jsm
new file mode 100644
index 0000000000..1baf177df7
--- /dev/null
+++ b/browser/modules/TabUnloader.jsm
@@ -0,0 +1,523 @@
+/* -*- 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/. */
+
+"use strict";
+
+/*
+ * TabUnloader is used to discard tabs when memory or resource constraints
+ * are reached. The discarded tabs are determined using a heuristic that
+ * accounts for when the tab was last used, how many resources the tab uses,
+ * and whether the tab is likely to affect the user if it is closed.
+ */
+var EXPORTED_SYMBOLS = ["TabUnloader"];
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "webrtcUI",
+ "resource:///modules/webrtcUI.jsm"
+);
+ChromeUtils.defineESModuleGetters(lazy, {
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+// If there are only this many or fewer tabs open, just sort by weight, and close
+// the lowest tab. Otherwise, do a more intensive compuation that determines the
+// tabs to close based on memory and process use.
+const MIN_TABS_COUNT = 10;
+
+// Weight for non-discardable tabs.
+const NEVER_DISCARD = 100000;
+
+// Default minimum inactive duration. Tabs that were accessed in the last
+// period of this duration are not unloaded.
+const kMinInactiveDurationInMs = Services.prefs.getIntPref(
+ "browser.tabs.min_inactive_duration_before_unload"
+);
+
+let criteriaTypes = [
+ ["isNonDiscardable", NEVER_DISCARD],
+ ["isLoading", 8],
+ ["usingPictureInPicture", NEVER_DISCARD],
+ ["playingMedia", NEVER_DISCARD],
+ ["usingWebRTC", NEVER_DISCARD],
+ ["isPinned", 2],
+ ["isPrivate", NEVER_DISCARD],
+];
+
+// Indicies into the criteriaTypes lists.
+let CRITERIA_METHOD = 0;
+let CRITERIA_WEIGHT = 1;
+
+/**
+ * This is an object that supplies methods that determine details about
+ * each tab. This default object is used if another one is not passed
+ * to the tab unloader functions. This allows tests to override the methods
+ * with tab specific data rather than creating test tabs.
+ */
+let DefaultTabUnloaderMethods = {
+ isNonDiscardable(tab, weight) {
+ if (tab.selected) {
+ return weight;
+ }
+
+ return !tab.linkedBrowser.isConnected ? -1 : 0;
+ },
+
+ isPinned(tab, weight) {
+ return tab.pinned ? weight : 0;
+ },
+
+ isLoading(tab, weight) {
+ return 0;
+ },
+
+ usingPictureInPicture(tab, weight) {
+ // This has higher weight even when paused.
+ return tab.pictureinpicture ? weight : 0;
+ },
+
+ playingMedia(tab, weight) {
+ return tab.soundPlaying ? weight : 0;
+ },
+
+ usingWebRTC(tab, weight) {
+ const browser = tab.linkedBrowser;
+ if (!browser) {
+ return 0;
+ }
+
+ // No need to iterate browser contexts for hasActivePeerConnection
+ // because hasActivePeerConnection is set only in the top window.
+ return lazy.webrtcUI.browserHasStreams(browser) ||
+ browser.browsingContext?.currentWindowGlobal?.hasActivePeerConnections()
+ ? weight
+ : 0;
+ },
+
+ isPrivate(tab, weight) {
+ return lazy.PrivateBrowsingUtils.isBrowserPrivate(tab.linkedBrowser)
+ ? weight
+ : 0;
+ },
+
+ getMinTabCount() {
+ return MIN_TABS_COUNT;
+ },
+
+ getNow() {
+ return Date.now();
+ },
+
+ *iterateTabs() {
+ for (let win of Services.wm.getEnumerator("navigator:browser")) {
+ for (let tab of win.gBrowser.tabs) {
+ yield { tab, gBrowser: win.gBrowser };
+ }
+ }
+ },
+
+ *iterateBrowsingContexts(bc) {
+ yield bc;
+ for (let childBC of bc.children) {
+ yield* this.iterateBrowsingContexts(childBC);
+ }
+ },
+
+ *iterateProcesses(tab) {
+ let bc = tab?.linkedBrowser?.browsingContext;
+ if (!bc) {
+ return;
+ }
+
+ const iter = this.iterateBrowsingContexts(bc);
+ for (let childBC of iter) {
+ if (childBC?.currentWindowGlobal) {
+ yield childBC.currentWindowGlobal.osPid;
+ }
+ }
+ },
+
+ /**
+ * Add the amount of memory used by each process to the process map.
+ *
+ * @param tabs array of tabs, used only by unit tests
+ * @param map of processes returned by getAllProcesses.
+ */
+ async calculateMemoryUsage(processMap) {
+ let parentProcessInfo = await ChromeUtils.requestProcInfo();
+ let childProcessInfoList = parentProcessInfo.children;
+ for (let childProcInfo of childProcessInfoList) {
+ let processInfo = processMap.get(childProcInfo.pid);
+ if (!processInfo) {
+ processInfo = { count: 0, topCount: 0, tabSet: new Set() };
+ processMap.set(childProcInfo.pid, processInfo);
+ }
+ processInfo.memory = childProcInfo.memory;
+ }
+ },
+};
+
+/**
+ * This module is responsible for detecting low-memory scenarios and unloading
+ * tabs in response to them.
+ */
+
+var TabUnloader = {
+ /**
+ * Initialize low-memory detection and tab auto-unloading.
+ */
+ init() {
+ const watcher = Cc["@mozilla.org/xpcom/memory-watcher;1"].getService(
+ Ci.nsIAvailableMemoryWatcherBase
+ );
+ watcher.registerTabUnloader(this);
+ },
+
+ isDiscardable(tab) {
+ if (!("weight" in tab)) {
+ return false;
+ }
+ return tab.weight < NEVER_DISCARD;
+ },
+
+ // This method is exposed on nsITabUnloader
+ async unloadTabAsync(minInactiveDuration = kMinInactiveDurationInMs) {
+ const watcher = Cc["@mozilla.org/xpcom/memory-watcher;1"].getService(
+ Ci.nsIAvailableMemoryWatcherBase
+ );
+
+ if (!Services.prefs.getBoolPref("browser.tabs.unloadOnLowMemory", true)) {
+ watcher.onUnloadAttemptCompleted(Cr.NS_ERROR_NOT_AVAILABLE);
+ return;
+ }
+
+ if (this._isUnloading) {
+ // Don't post multiple unloading requests. The situation may be solved
+ // when the active unloading task is completed.
+ Services.console.logStringMessage("Unloading a tab is in progress.");
+ watcher.onUnloadAttemptCompleted(Cr.NS_ERROR_ABORT);
+ return;
+ }
+
+ this._isUnloading = true;
+ const isTabUnloaded = await this.unloadLeastRecentlyUsedTab(
+ minInactiveDuration
+ );
+ this._isUnloading = false;
+
+ watcher.onUnloadAttemptCompleted(
+ isTabUnloaded ? Cr.NS_OK : Cr.NS_ERROR_NOT_AVAILABLE
+ );
+ },
+
+ /**
+ * Get a list of tabs that can be discarded. This list includes all tabs in
+ * all windows and is sorted based on a weighting described below.
+ *
+ * @param minInactiveDuration If this value is a number, tabs that were accessed
+ * in the last |minInactiveDuration| msec are not unloaded even if they
+ * are least-recently-used.
+ *
+ * @param tabMethods an helper object with methods called by this algorithm.
+ *
+ * The algorithm used is:
+ * 1. Sort all of the tabs by a base weight. Tabs with a higher weight, such as
+ * those that are pinned or playing audio, will appear at the end. When two
+ * tabs have the same weight, sort by the order in which they were last.
+ * recently accessed Tabs that have a weight of NEVER_DISCARD are included in
+ * the list, but will not be discarded.
+ * 2. Exclude the last X tabs, where X is the value returned by getMinTabCount().
+ * These tabs are considered to have been recently accessed and are not further
+ * reweighted. This also saves time when there are less than X tabs open.
+ * 3. Calculate the amount of processes that are used only by each tab, as the
+ * resources used by these proceses can be freed up if the tab is closed. Sort
+ * the tabs by the number of unique processes used and add a reweighting factor
+ * based on this.
+ * 4. Futher reweight based on an approximation of the amount of memory that each
+ * tab uses.
+ * 5. Combine these weights to produce a final tab discard order, and discard the
+ * first tab. If this fails, then discard the next tab in the list until no more
+ * non-discardable tabs are found.
+ *
+ * The tabMethods are used so that unit tests can use false tab objects and
+ * override their behaviour.
+ */
+ async getSortedTabs(
+ minInactiveDuration = kMinInactiveDurationInMs,
+ tabMethods = DefaultTabUnloaderMethods
+ ) {
+ let tabs = [];
+
+ const now = tabMethods.getNow();
+
+ let lowestWeight = 1000;
+ for (let tab of tabMethods.iterateTabs()) {
+ if (
+ typeof minInactiveDuration == "number" &&
+ now - tab.tab.lastAccessed < minInactiveDuration
+ ) {
+ // Skip "fresh" tabs, which were accessed within the specified duration.
+ continue;
+ }
+
+ let weight = determineTabBaseWeight(tab, tabMethods);
+
+ // Don't add tabs that have a weight of -1.
+ if (weight != -1) {
+ tab.weight = weight;
+ tabs.push(tab);
+ if (weight < lowestWeight) {
+ lowestWeight = weight;
+ }
+ }
+ }
+
+ tabs = tabs.sort((a, b) => {
+ if (a.weight != b.weight) {
+ return a.weight - b.weight;
+ }
+
+ return a.tab.lastAccessed - b.tab.lastAccessed;
+ });
+
+ // If the lowest priority tab is not discardable, no need to continue.
+ if (!tabs.length || !this.isDiscardable(tabs[0])) {
+ return tabs;
+ }
+
+ // Determine the lowest weight that the tabs have. The tabs with the
+ // lowest weight (should be most non-selected tabs) will be additionally
+ // weighted by the number of processes and memory that they use.
+ let higherWeightedCount = 0;
+ for (let idx = 0; idx < tabs.length; idx++) {
+ if (tabs[idx].weight != lowestWeight) {
+ higherWeightedCount = tabs.length - idx;
+ break;
+ }
+ }
+
+ // Don't continue to reweight the last few tabs, the number of which is
+ // determined by getMinTabCount. This prevents extra work when there are
+ // only a few tabs, or for the last few tabs that have likely been used
+ // recently.
+ let minCount = tabMethods.getMinTabCount();
+ if (higherWeightedCount < minCount) {
+ higherWeightedCount = minCount;
+ }
+
+ // If |lowestWeightedCount| is 1, no benefit from calculating
+ // the tab's memory and additional weight.
+ const lowestWeightedCount = tabs.length - higherWeightedCount;
+ if (lowestWeightedCount > 1) {
+ let processMap = getAllProcesses(tabs, tabMethods);
+
+ let higherWeightedTabs = tabs.splice(-higherWeightedCount);
+
+ await adjustForResourceUse(tabs, processMap, tabMethods);
+ tabs = tabs.concat(higherWeightedTabs);
+ }
+
+ return tabs;
+ },
+
+ /**
+ * Select and discard one tab.
+ * @returns true if a tab was unloaded, otherwise false.
+ */
+ async unloadLeastRecentlyUsedTab(
+ minInactiveDuration = kMinInactiveDurationInMs
+ ) {
+ const sortedTabs = await this.getSortedTabs(minInactiveDuration);
+
+ for (let tabInfo of sortedTabs) {
+ if (!this.isDiscardable(tabInfo)) {
+ // Since |sortedTabs| is sorted, once we see an undiscardable tab
+ // no need to continue the loop.
+ return false;
+ }
+
+ const remoteType = tabInfo.tab?.linkedBrowser?.remoteType;
+ if (tabInfo.gBrowser.discardBrowser(tabInfo.tab)) {
+ Services.console.logStringMessage(
+ `TabUnloader discarded <${remoteType}>`
+ );
+ tabInfo.tab.updateLastUnloadedByTabUnloader();
+ return true;
+ }
+ }
+ return false;
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+};
+
+/** Determine the base weight of the tab without accounting for
+ * resource use
+ * @param tab tab to use
+ * @returns the tab's base weight
+ */
+function determineTabBaseWeight(tab, tabMethods) {
+ let totalWeight = 0;
+
+ for (let criteriaType of criteriaTypes) {
+ let weight = tabMethods[criteriaType[CRITERIA_METHOD]](
+ tab.tab,
+ criteriaType[CRITERIA_WEIGHT]
+ );
+
+ // If a criteria returns -1, then never discard this tab.
+ if (weight == -1) {
+ return -1;
+ }
+
+ totalWeight += weight;
+ }
+
+ return totalWeight;
+}
+
+/**
+ * Constuct a map of the processes that are used by the supplied tabs.
+ * The map will map process ids to an object with two properties:
+ * count - the number of tabs or subframes that use this process
+ * topCount - the number of top-level tabs that use this process
+ * tabSet - the indices of the tabs hosted by this process
+ *
+ * @param tabs array of tabs
+ * @param tabMethods an helper object with methods called by this algorithm.
+ * @returns process map
+ */
+function getAllProcesses(tabs, tabMethods) {
+ // Determine the number of tabs that reference each process. This
+ // is stored in the map 'processMap' where the key is the process
+ // and the value is that number of browsing contexts that use that
+ // process.
+ // XXXndeakin this should be unique processes per tab, in the case multiple
+ // subframes use the same process?
+
+ let processMap = new Map();
+
+ for (let tabIndex = 0; tabIndex < tabs.length; ++tabIndex) {
+ const tab = tabs[tabIndex];
+
+ // The per-tab map will map process ids to an object with three properties:
+ // isTopLevel - whether the process hosts the tab's top-level frame or not
+ // frameCount - the number of frames hosted by the process
+ // (a top frame contributes 2 and a sub frame contributes 1)
+ // entryToProcessMap - the reference to the object in |processMap|
+ tab.processes = new Map();
+
+ let topLevel = true;
+ for (let pid of tabMethods.iterateProcesses(tab.tab)) {
+ let processInfo = processMap.get(pid);
+ if (processInfo) {
+ processInfo.count++;
+ processInfo.tabSet.add(tabIndex);
+ } else {
+ processInfo = { count: 1, topCount: 0, tabSet: new Set([tabIndex]) };
+ processMap.set(pid, processInfo);
+ }
+
+ let tabProcessEntry = tab.processes.get(pid);
+ if (tabProcessEntry) {
+ ++tabProcessEntry.frameCount;
+ } else {
+ tabProcessEntry = {
+ isTopLevel: topLevel,
+ frameCount: 1,
+ entryToProcessMap: processInfo,
+ };
+ tab.processes.set(pid, tabProcessEntry);
+ }
+
+ if (topLevel) {
+ topLevel = false;
+ processInfo.topCount = processInfo.topCount
+ ? processInfo.topCount + 1
+ : 1;
+ // top-level frame contributes two frame counts
+ ++tabProcessEntry.frameCount;
+ }
+ }
+ }
+
+ return processMap;
+}
+
+/**
+ * Adjust the tab info and reweight the tabs based on the process and memory
+ * use that is used, as described by getSortedTabs
+
+ * @param tabs array of tabs
+ * @param processMap map of processes returned by getAllProcesses
+ * @param tabMethods an helper object with methods called by this algorithm.
+ */
+async function adjustForResourceUse(tabs, processMap, tabMethods) {
+ // The second argument is needed for testing.
+ await tabMethods.calculateMemoryUsage(processMap, tabs);
+
+ let sortWeight = 0;
+ for (let tab of tabs) {
+ tab.sortWeight = ++sortWeight;
+
+ let uniqueCount = 0;
+ let totalMemory = 0;
+ for (const procEntry of tab.processes.values()) {
+ const processInfo = procEntry.entryToProcessMap;
+ if (processInfo.tabSet.size == 1) {
+ uniqueCount++;
+ }
+
+ // Guess how much memory the frame might be using using by dividing
+ // the total memory used by a process by the number of tabs and
+ // frames that are using that process. Assume that any subframes take up
+ // only half as much memory as a process loaded in a top level tab.
+ // So for example, if a process is used in four top level tabs and two
+ // subframes, the top level tabs share 80% of the memory and the subframes
+ // use 20% of the memory.
+ const perFrameMemory =
+ processInfo.memory /
+ (processInfo.topCount * 2 + (processInfo.count - processInfo.topCount));
+ totalMemory += perFrameMemory * procEntry.frameCount;
+ }
+
+ tab.uniqueCount = uniqueCount;
+ tab.memory = totalMemory;
+ }
+
+ tabs.sort((a, b) => {
+ return b.uniqueCount - a.uniqueCount;
+ });
+ sortWeight = 0;
+ for (let tab of tabs) {
+ tab.sortWeight += ++sortWeight;
+ if (tab.uniqueCount > 1) {
+ // If the tab has a number of processes that are only used by this tab,
+ // subtract off an additional amount to the sorting weight value. That
+ // way, tabs that use lots of processes are more likely to be discarded.
+ tab.sortWeight -= tab.uniqueCount - 1;
+ }
+ }
+
+ tabs.sort((a, b) => {
+ return b.memory - a.memory;
+ });
+ sortWeight = 0;
+ for (let tab of tabs) {
+ tab.sortWeight += ++sortWeight;
+ }
+
+ tabs.sort((a, b) => {
+ if (a.sortWeight != b.sortWeight) {
+ return a.sortWeight - b.sortWeight;
+ }
+ return a.tab.lastAccessed - b.tab.lastAccessed;
+ });
+}
diff --git a/browser/modules/TabsList.jsm b/browser/modules/TabsList.jsm
new file mode 100644
index 0000000000..a09f58195a
--- /dev/null
+++ b/browser/modules/TabsList.jsm
@@ -0,0 +1,566 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs",
+});
+
+var EXPORTED_SYMBOLS = ["TabsPanel"];
+
+const TAB_DROP_TYPE = "application/x-moz-tabbrowser-tab";
+
+function setAttributes(element, attrs) {
+ for (let [name, value] of Object.entries(attrs)) {
+ if (value) {
+ element.setAttribute(name, value);
+ } else {
+ element.removeAttribute(name);
+ }
+ }
+}
+
+class TabsListBase {
+ constructor({
+ className,
+ filterFn,
+ insertBefore,
+ containerNode,
+ dropIndicator = null,
+ }) {
+ this.className = className;
+ this.filterFn = filterFn;
+ this.insertBefore = insertBefore;
+ this.containerNode = containerNode;
+ this.dropIndicator = dropIndicator;
+
+ if (this.dropIndicator) {
+ this.dropTargetRow = null;
+ this.dropTargetDirection = 0;
+ }
+
+ this.doc = containerNode.ownerDocument;
+ this.gBrowser = this.doc.defaultView.gBrowser;
+ this.tabToElement = new Map();
+ this.listenersRegistered = false;
+ }
+
+ get rows() {
+ return this.tabToElement.values();
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "TabAttrModified":
+ this._tabAttrModified(event.target);
+ break;
+ case "TabClose":
+ this._tabClose(event.target);
+ break;
+ case "TabMove":
+ this._moveTab(event.target);
+ break;
+ case "TabPinned":
+ if (!this.filterFn(event.target)) {
+ this._tabClose(event.target);
+ }
+ break;
+ case "command":
+ this._selectTab(event.target.tab);
+ break;
+ case "dragstart":
+ this._onDragStart(event);
+ break;
+ case "dragover":
+ this._onDragOver(event);
+ break;
+ case "dragleave":
+ this._onDragLeave(event);
+ break;
+ case "dragend":
+ this._onDragEnd(event);
+ break;
+ case "drop":
+ this._onDrop(event);
+ break;
+ case "click":
+ this._onClick(event);
+ break;
+ }
+ }
+
+ _selectTab(tab) {
+ if (this.gBrowser.selectedTab != tab) {
+ this.gBrowser.selectedTab = tab;
+ } else {
+ this.gBrowser.tabContainer._handleTabSelect();
+ }
+ }
+
+ /*
+ * Populate the popup with menuitems and setup the listeners.
+ */
+ _populate(event) {
+ let fragment = this.doc.createDocumentFragment();
+
+ for (let tab of this.gBrowser.tabs) {
+ if (this.filterFn(tab)) {
+ fragment.appendChild(this._createRow(tab));
+ }
+ }
+
+ this._addElement(fragment);
+ this._setupListeners();
+ }
+
+ _addElement(elementOrFragment) {
+ this.containerNode.insertBefore(elementOrFragment, this.insertBefore);
+ }
+
+ /*
+ * Remove the menuitems from the DOM, cleanup internal state and listeners.
+ */
+ _cleanup() {
+ for (let item of this.rows) {
+ item.remove();
+ }
+ this.tabToElement = new Map();
+ this._cleanupListeners();
+ this._clearDropTarget();
+ }
+
+ _setupListeners() {
+ this.listenersRegistered = true;
+
+ this.gBrowser.tabContainer.addEventListener("TabAttrModified", this);
+ this.gBrowser.tabContainer.addEventListener("TabClose", this);
+ this.gBrowser.tabContainer.addEventListener("TabMove", this);
+ this.gBrowser.tabContainer.addEventListener("TabPinned", this);
+
+ this.containerNode.addEventListener("click", this);
+
+ if (this.dropIndicator) {
+ this.containerNode.addEventListener("dragstart", this);
+ this.containerNode.addEventListener("dragover", this);
+ this.containerNode.addEventListener("dragleave", this);
+ this.containerNode.addEventListener("dragend", this);
+ this.containerNode.addEventListener("drop", this);
+ }
+ }
+
+ _cleanupListeners() {
+ this.gBrowser.tabContainer.removeEventListener("TabAttrModified", this);
+ this.gBrowser.tabContainer.removeEventListener("TabClose", this);
+ this.gBrowser.tabContainer.removeEventListener("TabMove", this);
+ this.gBrowser.tabContainer.removeEventListener("TabPinned", this);
+
+ this.containerNode.removeEventListener("click", this);
+
+ if (this.dropIndicator) {
+ this.containerNode.removeEventListener("dragstart", this);
+ this.containerNode.removeEventListener("dragover", this);
+ this.containerNode.removeEventListener("dragleave", this);
+ this.containerNode.removeEventListener("dragend", this);
+ this.containerNode.removeEventListener("drop", this);
+ }
+
+ this.listenersRegistered = false;
+ }
+
+ _tabAttrModified(tab) {
+ let item = this.tabToElement.get(tab);
+ if (item) {
+ if (!this.filterFn(tab)) {
+ // The tab no longer matches our criteria, remove it.
+ this._removeItem(item, tab);
+ } else {
+ this._setRowAttributes(item, tab);
+ }
+ } else if (this.filterFn(tab)) {
+ // The tab now matches our criteria, add a row for it.
+ this._addTab(tab);
+ }
+ }
+
+ _moveTab(tab) {
+ let item = this.tabToElement.get(tab);
+ if (item) {
+ this._removeItem(item, tab);
+ this._addTab(tab);
+ }
+ }
+ _addTab(newTab) {
+ if (!this.filterFn(newTab)) {
+ return;
+ }
+ let newRow = this._createRow(newTab);
+ let nextTab = newTab.nextElementSibling;
+
+ while (nextTab && !this.filterFn(nextTab)) {
+ nextTab = nextTab.nextElementSibling;
+ }
+
+ // If we found a tab after this one in the list, insert the new row before it.
+ let nextRow = this.tabToElement.get(nextTab);
+ if (nextRow) {
+ nextRow.parentNode.insertBefore(newRow, nextRow);
+ } else {
+ // If there's no next tab then insert it as usual.
+ this._addElement(newRow);
+ }
+ }
+ _tabClose(tab) {
+ let item = this.tabToElement.get(tab);
+ if (item) {
+ this._removeItem(item, tab);
+ }
+ }
+
+ _removeItem(item, tab) {
+ this.tabToElement.delete(tab);
+ item.remove();
+ }
+}
+
+const TABS_PANEL_EVENTS = {
+ show: "ViewShowing",
+ hide: "PanelMultiViewHidden",
+};
+
+class TabsPanel extends TabsListBase {
+ constructor(opts) {
+ super({
+ ...opts,
+ containerNode: opts.containerNode || opts.view.firstElementChild,
+ });
+ this.view = opts.view;
+ this.view.addEventListener(TABS_PANEL_EVENTS.show, this);
+ this.panelMultiView = null;
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case TABS_PANEL_EVENTS.hide:
+ if (event.target == this.panelMultiView) {
+ this._cleanup();
+ this.panelMultiView = null;
+ }
+ break;
+ case TABS_PANEL_EVENTS.show:
+ if (!this.listenersRegistered && event.target == this.view) {
+ this.panelMultiView = this.view.panelMultiView;
+ this._populate(event);
+ this.gBrowser.translateTabContextMenu();
+ }
+ break;
+ case "command":
+ if (event.target.classList.contains("all-tabs-mute-button")) {
+ event.target.tab.toggleMuteAudio();
+ break;
+ }
+ if (event.target.classList.contains("all-tabs-close-button")) {
+ this.gBrowser.removeTab(event.target.tab);
+ break;
+ }
+ // fall through
+ default:
+ super.handleEvent(event);
+ break;
+ }
+ }
+
+ _populate(event) {
+ super._populate(event);
+
+ // The loading throbber can't be set until the toolbarbutton is rendered,
+ // so set the image attributes again now that the elements are in the DOM.
+ for (let row of this.rows) {
+ this._setImageAttributes(row, row.tab);
+ }
+ }
+
+ _selectTab(tab) {
+ super._selectTab(tab);
+ lazy.PanelMultiView.hidePopup(this.view.closest("panel"));
+ }
+
+ _setupListeners() {
+ super._setupListeners();
+ this.panelMultiView.addEventListener(TABS_PANEL_EVENTS.hide, this);
+ }
+
+ _cleanupListeners() {
+ super._cleanupListeners();
+ this.panelMultiView.removeEventListener(TABS_PANEL_EVENTS.hide, this);
+ }
+
+ _createRow(tab) {
+ let { doc } = this;
+ let row = doc.createXULElement("toolbaritem");
+ row.setAttribute("class", "all-tabs-item");
+ row.setAttribute("context", "tabContextMenu");
+ if (this.className) {
+ row.classList.add(this.className);
+ }
+ row.tab = tab;
+ row.addEventListener("command", this);
+ this.tabToElement.set(tab, row);
+
+ let button = doc.createXULElement("toolbarbutton");
+ button.setAttribute(
+ "class",
+ "all-tabs-button subviewbutton subviewbutton-iconic"
+ );
+ button.setAttribute("flex", "1");
+ button.setAttribute("crop", "end");
+ button.tab = tab;
+
+ row.appendChild(button);
+
+ let muteButton = doc.createXULElement("toolbarbutton");
+ muteButton.classList.add(
+ "all-tabs-mute-button",
+ "all-tabs-secondary-button",
+ "subviewbutton"
+ );
+ muteButton.setAttribute("closemenu", "none");
+ muteButton.tab = tab;
+ row.appendChild(muteButton);
+
+ let closeButton = doc.createXULElement("toolbarbutton");
+ closeButton.classList.add(
+ "all-tabs-close-button",
+ "all-tabs-secondary-button",
+ "subviewbutton"
+ );
+ closeButton.setAttribute("closemenu", "none");
+ doc.l10n.setAttributes(closeButton, "tabbrowser-manager-close-tab");
+ closeButton.tab = tab;
+ row.appendChild(closeButton);
+
+ this._setRowAttributes(row, tab);
+
+ return row;
+ }
+
+ _setRowAttributes(row, tab) {
+ setAttributes(row, { selected: tab.selected });
+
+ let busy = tab.getAttribute("busy");
+ let button = row.firstElementChild;
+ setAttributes(button, {
+ busy,
+ label: tab.label,
+ image: !busy && tab.getAttribute("image"),
+ iconloadingprincipal: tab.getAttribute("iconloadingprincipal"),
+ });
+
+ this._setImageAttributes(row, tab);
+
+ let muteButton = row.querySelector(".all-tabs-mute-button");
+ let muteButtonTooltipString = tab.muted
+ ? "tabbrowser-manager-unmute-tab"
+ : "tabbrowser-manager-mute-tab";
+ this.doc.l10n.setAttributes(muteButton, muteButtonTooltipString);
+
+ setAttributes(muteButton, {
+ muted: tab.muted,
+ soundplaying: tab.soundPlaying,
+ hidden: !(tab.muted || tab.soundPlaying),
+ });
+ }
+
+ _setImageAttributes(row, tab) {
+ let button = row.firstElementChild;
+ let image = button.icon;
+
+ if (image) {
+ let busy = tab.getAttribute("busy");
+ let progress = tab.getAttribute("progress");
+ setAttributes(image, { busy, progress });
+ if (busy) {
+ image.classList.add("tab-throbber-tabslist");
+ } else {
+ image.classList.remove("tab-throbber-tabslist");
+ }
+ }
+ }
+
+ _onDragStart(event) {
+ const row = this._getTargetRowFromEvent(event);
+ if (!row) {
+ return;
+ }
+
+ this.gBrowser.tabContainer.startTabDrag(event, row.firstElementChild.tab, {
+ fromTabList: true,
+ });
+ }
+
+ _getTargetRowFromEvent(event) {
+ return event.target.closest("toolbaritem");
+ }
+
+ _isMovingTabs(event) {
+ var effects = this.gBrowser.tabContainer.getDropEffectForTabDrag(event);
+ return effects == "move";
+ }
+
+ _onDragOver(event) {
+ if (!this._isMovingTabs(event)) {
+ return;
+ }
+
+ if (!this._updateDropTarget(event)) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ _getRowIndex(row) {
+ return Array.prototype.indexOf.call(this.containerNode.children, row);
+ }
+
+ _onDrop(event) {
+ if (!this._isMovingTabs(event)) {
+ return;
+ }
+
+ if (!this._updateDropTarget(event)) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ let draggedTab = event.dataTransfer.mozGetDataAt(TAB_DROP_TYPE, 0);
+
+ if (draggedTab === this.dropTargetRow.firstElementChild.tab) {
+ this._clearDropTarget();
+ return;
+ }
+
+ const targetTab = this.dropTargetRow.firstElementChild.tab;
+
+ // NOTE: Given the list is opened only when the window is focused,
+ // we don't have to check `draggedTab.container`.
+
+ let pos;
+ if (draggedTab._tPos < targetTab._tPos) {
+ pos = targetTab._tPos + this.dropTargetDirection;
+ } else {
+ pos = targetTab._tPos + this.dropTargetDirection + 1;
+ }
+ this.gBrowser.moveTabTo(draggedTab, pos);
+
+ this._clearDropTarget();
+ }
+
+ _onDragLeave(event) {
+ if (!this._isMovingTabs(event)) {
+ return;
+ }
+
+ let target = event.relatedTarget;
+ while (target && target != this.containerNode) {
+ target = target.parentNode;
+ }
+ if (target) {
+ return;
+ }
+
+ this._clearDropTarget();
+ }
+
+ _onDragEnd(event) {
+ if (!this._isMovingTabs(event)) {
+ return;
+ }
+
+ this._clearDropTarget();
+ }
+
+ _updateDropTarget(event) {
+ const row = this._getTargetRowFromEvent(event);
+ if (!row) {
+ return false;
+ }
+
+ const rect = row.getBoundingClientRect();
+ const index = this._getRowIndex(row);
+ if (index === -1) {
+ return false;
+ }
+
+ const threshold = rect.height * 0.5;
+ if (event.clientY < rect.top + threshold) {
+ this._setDropTarget(row, -1);
+ } else {
+ this._setDropTarget(row, 0);
+ }
+
+ return true;
+ }
+
+ _setDropTarget(row, direction) {
+ this.dropTargetRow = row;
+ this.dropTargetDirection = direction;
+
+ const holder = this.dropIndicator.parentNode;
+ const holderOffset = holder.getBoundingClientRect().top;
+
+ // Set top to before/after the target row.
+ let top;
+ if (this.dropTargetDirection === -1) {
+ if (this.dropTargetRow.previousSibling) {
+ const rect = this.dropTargetRow.previousSibling.getBoundingClientRect();
+ top = rect.top + rect.height;
+ } else {
+ const rect = this.dropTargetRow.getBoundingClientRect();
+ top = rect.top;
+ }
+ } else {
+ const rect = this.dropTargetRow.getBoundingClientRect();
+ top = rect.top + rect.height;
+ }
+
+ // Avoid overflowing the sub view body.
+ const indicatorHeight = 12;
+ const subViewBody = holder.parentNode;
+ const subViewBodyRect = subViewBody.getBoundingClientRect();
+ top = Math.min(top, subViewBodyRect.bottom - indicatorHeight);
+
+ this.dropIndicator.style.top = `${top - holderOffset - 12}px`;
+ this.dropIndicator.collapsed = false;
+ }
+
+ _clearDropTarget() {
+ if (this.dropTargetRow) {
+ this.dropTargetRow = null;
+ }
+
+ if (this.dropIndicator) {
+ this.dropIndicator.style.top = `0px`;
+ this.dropIndicator.collapsed = true;
+ }
+ }
+
+ _onClick(event) {
+ if (event.button == 1) {
+ const row = this._getTargetRowFromEvent(event);
+ if (!row) {
+ return;
+ }
+
+ this.gBrowser.removeTab(row.tab, {
+ animate: true,
+ });
+ }
+ }
+}
diff --git a/browser/modules/TransientPrefs.jsm b/browser/modules/TransientPrefs.jsm
new file mode 100644
index 0000000000..693c80b505
--- /dev/null
+++ b/browser/modules/TransientPrefs.jsm
@@ -0,0 +1,27 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["TransientPrefs"];
+
+const { Preferences } = ChromeUtils.importESModule(
+ "resource://gre/modules/Preferences.sys.mjs"
+);
+
+var prefVisibility = new Map();
+
+/* Use for preferences that should only be visible when they've been modified.
+ When reset to their default state, they remain visible until restarting the
+ application. */
+
+var TransientPrefs = {
+ prefShouldBeVisible(prefName) {
+ if (Preferences.isSet(prefName)) {
+ prefVisibility.set(prefName, true);
+ }
+
+ return !!prefVisibility.get(prefName);
+ },
+};
diff --git a/browser/modules/URILoadingHelper.sys.mjs b/browser/modules/URILoadingHelper.sys.mjs
new file mode 100644
index 0000000000..d08bc68ce8
--- /dev/null
+++ b/browser/modules/URILoadingHelper.sys.mjs
@@ -0,0 +1,739 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 { BrowserUtils } from "resource://gre/modules/BrowserUtils.sys.mjs";
+import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ AboutNewTab: "resource:///modules/AboutNewTab.jsm",
+ BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "ReferrerInfo", () =>
+ Components.Constructor(
+ "@mozilla.org/referrer-info;1",
+ "nsIReferrerInfo",
+ "init"
+ )
+);
+
+function saveLink(window, url, params) {
+ if ("isContentWindowPrivate" in params) {
+ window.saveURL(
+ url,
+ null,
+ null,
+ null,
+ true,
+ true,
+ params.referrerInfo,
+ null,
+ null,
+ params.isContentWindowPrivate,
+ params.originPrincipal
+ );
+ } else {
+ if (!params.initiatingDoc) {
+ console.error(
+ "openUILink/openLinkIn was called with " +
+ "where == 'save' but without initiatingDoc. See bug 814264."
+ );
+ return;
+ }
+ window.saveURL(
+ url,
+ null,
+ null,
+ null,
+ true,
+ true,
+ params.referrerInfo,
+ null,
+ params.initiatingDoc
+ );
+ }
+}
+
+function openInWindow(url, params, sourceWindow) {
+ let {
+ referrerInfo,
+ forceNonPrivate,
+ triggeringRemoteType,
+ forceAllowDataURI,
+ globalHistoryOptions,
+ allowThirdPartyFixup,
+ userContextId,
+ postData,
+ originPrincipal,
+ originStoragePrincipal,
+ triggeringPrincipal,
+ csp,
+ resolveOnContentBrowserCreated,
+ } = params;
+ let features = "chrome,dialog=no,all";
+ if (params.private) {
+ features += ",private";
+ // To prevent regular browsing data from leaking to private browsing sites,
+ // strip the referrer when opening a new private window. (See Bug: 1409226)
+ referrerInfo = new lazy.ReferrerInfo(
+ referrerInfo.referrerPolicy,
+ false,
+ referrerInfo.originalReferrer
+ );
+ } else if (forceNonPrivate) {
+ features += ",non-private";
+ }
+
+ // This propagates to window.arguments.
+ var sa = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
+
+ var wuri = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ wuri.data = url;
+
+ let extraOptions = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
+ Ci.nsIWritablePropertyBag2
+ );
+ if (triggeringRemoteType) {
+ extraOptions.setPropertyAsACString(
+ "triggeringRemoteType",
+ triggeringRemoteType
+ );
+ }
+ if (params.hasValidUserGestureActivation !== undefined) {
+ extraOptions.setPropertyAsBool(
+ "hasValidUserGestureActivation",
+ params.hasValidUserGestureActivation
+ );
+ }
+ if (forceAllowDataURI) {
+ extraOptions.setPropertyAsBool("forceAllowDataURI", true);
+ }
+ if (params.fromExternal !== undefined) {
+ extraOptions.setPropertyAsBool("fromExternal", params.fromExternal);
+ }
+ if (globalHistoryOptions?.triggeringSponsoredURL) {
+ extraOptions.setPropertyAsACString(
+ "triggeringSponsoredURL",
+ globalHistoryOptions.triggeringSponsoredURL
+ );
+ if (globalHistoryOptions.triggeringSponsoredURLVisitTimeMS) {
+ extraOptions.setPropertyAsUint64(
+ "triggeringSponsoredURLVisitTimeMS",
+ globalHistoryOptions.triggeringSponsoredURLVisitTimeMS
+ );
+ }
+ }
+
+ var allowThirdPartyFixupSupports = Cc[
+ "@mozilla.org/supports-PRBool;1"
+ ].createInstance(Ci.nsISupportsPRBool);
+ allowThirdPartyFixupSupports.data = allowThirdPartyFixup;
+
+ var userContextIdSupports = Cc[
+ "@mozilla.org/supports-PRUint32;1"
+ ].createInstance(Ci.nsISupportsPRUint32);
+ userContextIdSupports.data = userContextId;
+
+ sa.appendElement(wuri);
+ sa.appendElement(extraOptions);
+ sa.appendElement(referrerInfo);
+ sa.appendElement(postData);
+ sa.appendElement(allowThirdPartyFixupSupports);
+ sa.appendElement(userContextIdSupports);
+ sa.appendElement(originPrincipal);
+ sa.appendElement(originStoragePrincipal);
+ sa.appendElement(triggeringPrincipal);
+ sa.appendElement(null); // allowInheritPrincipal
+ sa.appendElement(csp);
+
+ let win;
+
+ // Returns a promise that will be resolved when the new window's startup is finished.
+ function waitForWindowStartup() {
+ return new Promise(resolve => {
+ const delayedStartupObserver = aSubject => {
+ if (aSubject == win) {
+ Services.obs.removeObserver(
+ delayedStartupObserver,
+ "browser-delayed-startup-finished"
+ );
+ resolve();
+ }
+ };
+ Services.obs.addObserver(
+ delayedStartupObserver,
+ "browser-delayed-startup-finished"
+ );
+ });
+ }
+
+ if (params.frameID != undefined && sourceWindow) {
+ // Only notify it as a WebExtensions' webNavigation.onCreatedNavigationTarget
+ // event if it contains the expected frameID params.
+ // (e.g. we should not notify it as a onCreatedNavigationTarget if the user is
+ // opening a new window using the keyboard shortcut).
+ const sourceTabBrowser = sourceWindow.gBrowser.selectedBrowser;
+ waitForWindowStartup().then(() => {
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ url,
+ createdTabBrowser: win.gBrowser.selectedBrowser,
+ sourceTabBrowser,
+ sourceFrameID: params.frameID,
+ },
+ },
+ "webNavigation-createdNavigationTarget"
+ );
+ });
+ }
+
+ if (resolveOnContentBrowserCreated) {
+ waitForWindowStartup().then(() =>
+ resolveOnContentBrowserCreated(win.gBrowser.selectedBrowser)
+ );
+ }
+
+ win = Services.ww.openWindow(
+ sourceWindow,
+ AppConstants.BROWSER_CHROME_URL,
+ null,
+ features,
+ sa
+ );
+}
+
+function openInCurrentTab(targetBrowser, url, uriObj, params) {
+ let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
+
+ if (params.allowThirdPartyFixup) {
+ flags |= Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP;
+ flags |= Ci.nsIWebNavigation.LOAD_FLAGS_FIXUP_SCHEME_TYPOS;
+ }
+ // LOAD_FLAGS_DISALLOW_INHERIT_PRINCIPAL isn't supported for javascript URIs,
+ // i.e. it causes them not to load at all. Callers should strip
+ // "javascript:" from pasted strings to prevent blank tabs
+ if (!params.allowInheritPrincipal) {
+ flags |= Ci.nsIWebNavigation.LOAD_FLAGS_DISALLOW_INHERIT_PRINCIPAL;
+ }
+
+ if (params.allowPopups) {
+ flags |= Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_POPUPS;
+ }
+ if (params.indicateErrorPageLoad) {
+ flags |= Ci.nsIWebNavigation.LOAD_FLAGS_ERROR_LOAD_CHANGES_RV;
+ }
+ if (params.forceAllowDataURI) {
+ flags |= Ci.nsIWebNavigation.LOAD_FLAGS_FORCE_ALLOW_DATA_URI;
+ }
+ if (params.fromExternal) {
+ flags |= Ci.nsIWebNavigation.LOAD_FLAGS_FROM_EXTERNAL;
+ }
+
+ let { URI_INHERITS_SECURITY_CONTEXT } = Ci.nsIProtocolHandler;
+ if (
+ params.forceAboutBlankViewerInCurrent &&
+ (!uriObj ||
+ Services.io.getDynamicProtocolFlags(uriObj) &
+ URI_INHERITS_SECURITY_CONTEXT)
+ ) {
+ // Unless we know for sure we're not inheriting principals,
+ // force the about:blank viewer to have the right principal:
+ targetBrowser.createAboutBlankContentViewer(
+ params.originPrincipal,
+ params.originStoragePrincipal
+ );
+ }
+
+ let {
+ triggeringPrincipal,
+ csp,
+ referrerInfo,
+ postData,
+ userContextId,
+ hasValidUserGestureActivation,
+ globalHistoryOptions,
+ triggeringRemoteType,
+ } = params;
+
+ targetBrowser.fixupAndLoadURIString(url, {
+ triggeringPrincipal,
+ csp,
+ flags,
+ referrerInfo,
+ postData,
+ userContextId,
+ hasValidUserGestureActivation,
+ globalHistoryOptions,
+ triggeringRemoteType,
+ });
+ params.resolveOnContentBrowserCreated?.(targetBrowser);
+}
+
+function updatePrincipals(window, params) {
+ let { userContextId } = params;
+ // Teach the principal about the right OA to use, e.g. in case when
+ // opening a link in a new private window, or in a new container tab.
+ // Please note we do not have to do that for SystemPrincipals and we
+ // can not do it for NullPrincipals since NullPrincipals are only
+ // identical if they actually are the same object (See Bug: 1346759)
+ function useOAForPrincipal(principal) {
+ if (principal && principal.isContentPrincipal) {
+ let privateBrowsingId =
+ params.private ||
+ (window && PrivateBrowsingUtils.isWindowPrivate(window));
+ let attrs = {
+ userContextId,
+ privateBrowsingId,
+ firstPartyDomain: principal.originAttributes.firstPartyDomain,
+ };
+ return Services.scriptSecurityManager.principalWithOA(principal, attrs);
+ }
+ return principal;
+ }
+ params.originPrincipal = useOAForPrincipal(params.originPrincipal);
+ params.originStoragePrincipal = useOAForPrincipal(
+ params.originStoragePrincipal
+ );
+ params.triggeringPrincipal = useOAForPrincipal(params.triggeringPrincipal);
+}
+
+export const URILoadingHelper = {
+ /* openLinkIn opens a URL in a place specified by the parameter |where|.
+ *
+ * The params object is the same as for `openLinkIn` and documented below.
+ *
+ * @param {String} where
+ * |where| can be:
+ * "current" current tab (if there aren't any browser windows, then in a new window instead)
+ * "tab" new tab (if there aren't any browser windows, then in a new window instead)
+ * "tabshifted" same as "tab" but in background if default is to select new tabs, and vice versa
+ * "window" new window
+ * "save" save to disk (with no filename hint!)
+ *
+ * @param {Object} params
+ *
+ * Options relating to what tab/window to use and how to open it:
+ *
+ * @param {boolean} params.private
+ * Load the URL in a private window.
+ * @param {boolean} params.forceNonPrivate
+ * Force the load to happen in non-private windows.
+ * @param {boolean} params.relatedToCurrent
+ * Whether new tabs should go immediately next to the current tab.
+ * @param {Element} params.targetBrowser
+ * The browser to use for the load. Only used if where == "current".
+ * @param {boolean} params.inBackground
+ * If explicitly true or false, whether to switch to the tab immediately.
+ * If null, will switch to the tab if `forceForeground` was true. If
+ * neither is passed, will defer to the user preference browser.tabs.loadInBackground.
+ * @param {boolean} params.forceForeground
+ * Ignore the user preference and load in the foreground.
+ * @param {boolean} params.allowPinnedTabHostChange
+ * Allow even a pinned tab to change hosts.
+ * @param {boolean} params.allowPopups
+ * whether the link is allowed to open in a popup window (ie one with no browser
+ * chrome)
+ * @param {boolean} params.skipTabAnimation
+ * Skip the tab opening animation.
+ * @param {Element} params.openerBrowser
+ * The browser that started the load.
+ * @param {boolean} params.avoidBrowserFocus
+ * Don't focus the browser element immediately after starting
+ * the load. Used by the URL bar to avoid leaking user input
+ * into web content, see bug 1641287.
+ *
+ * Options relating to the load itself:
+ *
+ * @param {boolean} params.allowThirdPartyFixup
+ * Allow transforming the 'url' into a search query.
+ * @param {nsIInputStream} params.postData
+ * Data to post as part of the request.
+ * @param {nsIReferrerInfo} params.referrerInfo
+ * Referrer info for the request.
+ * @param {boolean} params.indicateErrorPageLoad
+ * Whether docshell should throw an exception (i.e. return non-NS_OK)
+ * if the load fails.
+ * @param {string} params.charset
+ * Character set to use for the load. Only honoured for tabs.
+ * Legacy argument - do not use.
+ *
+ * Options relating to security, whether the load is allowed to happen,
+ * and what cookie container to use for the load:
+ *
+ * @param {boolean} params.forceAllowDataURI
+ * Force allow a data URI to load as a toplevel load.
+ * @param {number} params.userContextId
+ * The userContextId (container identifier) to use for the load.
+ * @param {boolean} params.allowInheritPrincipal
+ * Allow the load to inherit the triggering principal.
+ * @param {boolean} params.forceAboutBlankViewerInCurrent
+ * Force load an about:blank page first. Only used if
+ * allowInheritPrincipal is passed or no URL was provided.
+ * @param {nsIPrincipal} params.triggeringPrincipal
+ * Triggering principal to pass to docshell for the load.
+ * @param {nsIPrincipal} params.originPrincipal
+ * Origin principal to pass to docshell for the load.
+ * @param {nsIPrincipal} params.originStoragePrincipal
+ * Storage principal to pass to docshell for the load.
+ * @param {string} params.triggeringRemoteType
+ * The remoteType triggering this load.
+ * @param {nsIContentSecurityPolicy} params.csp
+ * The CSP that should apply to the load.
+ * @param {boolean} params.hasValidUserGestureActivation
+ * Indicates if a valid user gesture caused this load. This
+ * informs e.g. popup blocker decisions.
+ * @param {boolean} params.fromExternal
+ * Indicates the load was started outside of the browser,
+ * e.g. passed on the commandline or through OS mechanisms.
+ *
+ * Options used to track the load elsewhere
+ *
+ * @param {function} params.resolveOnNewTabCreated
+ * This callback will be called when a new tab is created.
+ * @param {function} params.resolveOnContentBrowserCreated
+ * This callback will be called with the content browser once it's created.
+ * @param {Object} params.globalHistoryOptions
+ * Used by places to keep track of search related metadata for loads.
+ * @param {Number} params.frameID
+ * Used by webextensions for their loads.
+ *
+ * Options used for where="save" only:
+ *
+ * @param {boolean} params.isContentWindowPrivate
+ * Save content as coming from a private window.
+ * @param {Document} params.initiatingDoc
+ * Used to determine where to prompt for a filename.
+ */
+ openLinkIn(window, url, where, params) {
+ if (!where || !url) {
+ return;
+ }
+
+ let {
+ allowThirdPartyFixup,
+ postData,
+ charset,
+ relatedToCurrent,
+ allowInheritPrincipal,
+ forceAllowDataURI,
+ forceNonPrivate,
+ skipTabAnimation,
+ allowPinnedTabHostChange,
+ userContextId,
+ triggeringPrincipal,
+ originPrincipal,
+ originStoragePrincipal,
+ triggeringRemoteType,
+ csp,
+ resolveOnNewTabCreated,
+ resolveOnContentBrowserCreated,
+ globalHistoryOptions,
+ } = params;
+
+ // We want to overwrite some things for convenience when passing it to other
+ // methods. To avoid impacting callers, copy the params.
+ params = Object.assign({}, params);
+
+ if (!params.referrerInfo) {
+ params.referrerInfo = new lazy.ReferrerInfo(
+ Ci.nsIReferrerInfo.EMPTY,
+ true,
+ null
+ );
+ }
+
+ if (!triggeringPrincipal) {
+ throw new Error("Must load with a triggering Principal");
+ }
+
+ if (where == "save") {
+ saveLink(window, url, params);
+ return;
+ }
+
+ // Establish which window we'll load the link in.
+ let w;
+ if (where == "current" && params.targetBrowser) {
+ w = params.targetBrowser.ownerGlobal;
+ } else {
+ w = this.getTargetWindow(window, { forceNonPrivate });
+ }
+ // We don't want to open tabs in popups, so try to find a non-popup window in
+ // that case.
+ if ((where == "tab" || where == "tabshifted") && w && !w.toolbar.visible) {
+ w = this.getTargetWindow(window, {
+ skipPopups: true,
+ forceNonPrivate,
+ });
+ relatedToCurrent = false;
+ }
+
+ updatePrincipals(w, params);
+
+ if (!w || where == "window") {
+ openInWindow(url, params, w || window);
+ return;
+ }
+
+ // We're now committed to loading the link in an existing browser window.
+
+ // Raise the target window before loading the URI, since loading it may
+ // result in a new frontmost window (e.g. "javascript:window.open('');").
+ w.focus();
+
+ let targetBrowser;
+ let loadInBackground;
+ let uriObj;
+
+ if (where == "current") {
+ targetBrowser = params.targetBrowser || w.gBrowser.selectedBrowser;
+ loadInBackground = false;
+ try {
+ uriObj = Services.io.newURI(url);
+ } catch (e) {}
+
+ // In certain tabs, we restrict what if anything may replace the loaded
+ // page. If a load request bounces off for the currently selected tab,
+ // we'll open a new tab instead.
+ let tab = w.gBrowser.getTabForBrowser(targetBrowser);
+ if (tab == w.FirefoxViewHandler.tab) {
+ where = "tab";
+ targetBrowser = null;
+ } else if (
+ !allowPinnedTabHostChange &&
+ tab.pinned &&
+ url != "about:crashcontent"
+ ) {
+ try {
+ // nsIURI.host can throw for non-nsStandardURL nsIURIs.
+ if (
+ !uriObj ||
+ (!uriObj.schemeIs("javascript") &&
+ targetBrowser.currentURI.host != uriObj.host)
+ ) {
+ where = "tab";
+ targetBrowser = null;
+ }
+ } catch (err) {
+ where = "tab";
+ targetBrowser = null;
+ }
+ }
+ } else {
+ // `where` is "tab" or "tabshifted", so we'll load the link in a new tab.
+ loadInBackground = params.inBackground;
+ if (loadInBackground == null) {
+ loadInBackground = params.forceForeground
+ ? false
+ : Services.prefs.getBoolPref("browser.tabs.loadInBackground");
+ }
+ }
+
+ let focusUrlBar = false;
+
+ switch (where) {
+ case "current":
+ openInCurrentTab(targetBrowser, url, uriObj, params);
+
+ // Don't focus the content area if focus is in the address bar and we're
+ // loading the New Tab page.
+ focusUrlBar =
+ w.document.activeElement == w.gURLBar.inputField &&
+ w.isBlankPageURL(url);
+ break;
+ case "tabshifted":
+ loadInBackground = !loadInBackground;
+ // fall through
+ case "tab":
+ focusUrlBar =
+ !loadInBackground &&
+ w.isBlankPageURL(url) &&
+ !lazy.AboutNewTab.willNotifyUser;
+
+ let tabUsedForLoad = w.gBrowser.addTab(url, {
+ referrerInfo: params.referrerInfo,
+ charset,
+ postData,
+ inBackground: loadInBackground,
+ allowThirdPartyFixup,
+ relatedToCurrent,
+ skipAnimation: skipTabAnimation,
+ userContextId,
+ originPrincipal,
+ originStoragePrincipal,
+ triggeringPrincipal,
+ allowInheritPrincipal,
+ triggeringRemoteType,
+ csp,
+ forceAllowDataURI,
+ focusUrlBar,
+ openerBrowser: params.openerBrowser,
+ fromExternal: params.fromExternal,
+ globalHistoryOptions,
+ });
+ targetBrowser = tabUsedForLoad.linkedBrowser;
+
+ resolveOnNewTabCreated?.(targetBrowser);
+ resolveOnContentBrowserCreated?.(targetBrowser);
+
+ if (params.frameID != undefined && w) {
+ // Only notify it as a WebExtensions' webNavigation.onCreatedNavigationTarget
+ // event if it contains the expected frameID params.
+ // (e.g. we should not notify it as a onCreatedNavigationTarget if the user is
+ // opening a new tab using the keyboard shortcut).
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ url,
+ createdTabBrowser: targetBrowser,
+ sourceTabBrowser: w.gBrowser.selectedBrowser,
+ sourceFrameID: params.frameID,
+ },
+ },
+ "webNavigation-createdNavigationTarget"
+ );
+ }
+ break;
+ }
+
+ if (
+ !params.avoidBrowserFocus &&
+ !focusUrlBar &&
+ targetBrowser == w.gBrowser.selectedBrowser
+ ) {
+ // Focus the content, but only if the browser used for the load is selected.
+ targetBrowser.focus();
+ }
+ },
+
+ /**
+ * Finds a browser window suitable for opening a link matching the
+ * requirements given in the `params` argument. If the current window matches
+ * the requirements then it is returned otherwise the top-most window that
+ * matches will be returned.
+ *
+ * @param {Window} window - The current window.
+ * @param {Object} params - Parameters for selecting the window.
+ * @param {boolean} params.skipPopups - Require a non-popup window.
+ * @param {boolean} params.forceNonPrivate - Require a non-private window.
+ * @returns {Window | null} A matching browser window or null if none matched.
+ */
+ getTargetWindow(window, { skipPopups, forceNonPrivate } = {}) {
+ let { top } = window;
+ // If this is called in a browser window, use that window regardless of
+ // whether it's the frontmost window, since commands can be executed in
+ // background windows (bug 626148).
+ if (
+ top.document.documentElement.getAttribute("windowtype") ==
+ "navigator:browser" &&
+ (!skipPopups || top.toolbar.visible) &&
+ (!forceNonPrivate || !PrivateBrowsingUtils.isWindowPrivate(top))
+ ) {
+ return top;
+ }
+
+ return lazy.BrowserWindowTracker.getTopWindow({
+ private: !forceNonPrivate && PrivateBrowsingUtils.isWindowPrivate(window),
+ allowPopups: !skipPopups,
+ });
+ },
+
+ /**
+ * openUILink handles clicks on UI elements that cause URLs to load.
+ *
+ * @param {string} url
+ * @param {Event | Object} event Event or JSON object representing an Event
+ * @param {Boolean | Object} aIgnoreButton
+ * Boolean or object with the same properties as
+ * accepted by openLinkIn, plus "ignoreButton"
+ * and "ignoreAlt".
+ * @param {Boolean} aIgnoreAlt
+ * @param {Boolean} aAllowThirdPartyFixup
+ * @param {Object} aPostData
+ * @param {Object} aReferrerInfo
+ */
+ openUILink(
+ window,
+ url,
+ event,
+ aIgnoreButton,
+ aIgnoreAlt,
+ aAllowThirdPartyFixup,
+ aPostData,
+ aReferrerInfo
+ ) {
+ event = BrowserUtils.getRootEvent(event);
+ let params;
+
+ if (aIgnoreButton && typeof aIgnoreButton == "object") {
+ params = aIgnoreButton;
+
+ // don't forward "ignoreButton" and "ignoreAlt" to openLinkIn
+ aIgnoreButton = params.ignoreButton;
+ aIgnoreAlt = params.ignoreAlt;
+ delete params.ignoreButton;
+ delete params.ignoreAlt;
+ } else {
+ params = {
+ allowThirdPartyFixup: aAllowThirdPartyFixup,
+ postData: aPostData,
+ referrerInfo: aReferrerInfo,
+ initiatingDoc: event ? event.target.ownerDocument : null,
+ };
+ }
+
+ if (!params.triggeringPrincipal) {
+ throw new Error(
+ "Required argument triggeringPrincipal missing within openUILink"
+ );
+ }
+
+ let where = BrowserUtils.whereToOpenLink(event, aIgnoreButton, aIgnoreAlt);
+ params.forceForeground ??= true;
+ this.openLinkIn(window, url, where, params);
+ },
+
+ /* openTrustedLinkIn will attempt to open the given URI using the SystemPrincipal
+ * as the trigeringPrincipal, unless a more specific Principal is provided.
+ *
+ * Otherwise, parameters are the same as openLinkIn, but we will set `forceForeground`
+ * to true.
+ */
+ openTrustedLinkIn(window, url, where, params = {}) {
+ if (!params.triggeringPrincipal) {
+ params.triggeringPrincipal =
+ Services.scriptSecurityManager.getSystemPrincipal();
+ }
+
+ params.forceForeground ??= true;
+ this.openLinkIn(window, url, where, params);
+ },
+
+ /* openWebLinkIn will attempt to open the given URI using the NullPrincipal
+ * as the triggeringPrincipal, unless a more specific Principal is provided.
+ *
+ * Otherwise, parameters are the same as openLinkIn, but we will set `forceForeground`
+ * to true.
+ */
+ openWebLinkIn(window, url, where, params = {}) {
+ if (!params.triggeringPrincipal) {
+ params.triggeringPrincipal =
+ Services.scriptSecurityManager.createNullPrincipal({});
+ }
+ if (params.triggeringPrincipal.isSystemPrincipal) {
+ throw new Error(
+ "System principal should never be passed into openWebLinkIn()"
+ );
+ }
+ params.forceForeground ??= true;
+ this.openLinkIn(window, url, where, params);
+ },
+};
diff --git a/browser/modules/WindowsJumpLists.jsm b/browser/modules/WindowsJumpLists.jsm
new file mode 100644
index 0000000000..0a5ae0d422
--- /dev/null
+++ b/browser/modules/WindowsJumpLists.jsm
@@ -0,0 +1,665 @@
+/* -*- 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 { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+// Stop updating jumplists after some idle time.
+const IDLE_TIMEOUT_SECONDS = 5 * 60;
+
+// Prefs
+const PREF_TASKBAR_BRANCH = "browser.taskbar.lists.";
+const PREF_TASKBAR_ENABLED = "enabled";
+const PREF_TASKBAR_ITEMCOUNT = "maxListItemCount";
+const PREF_TASKBAR_FREQUENT = "frequent.enabled";
+const PREF_TASKBAR_RECENT = "recent.enabled";
+const PREF_TASKBAR_TASKS = "tasks.enabled";
+const PREF_TASKBAR_REFRESH = "refreshInSeconds";
+
+// Hash keys for pendingStatements.
+const LIST_TYPE = {
+ FREQUENT: 0,
+ RECENT: 1,
+};
+
+/**
+ * Exports
+ */
+
+var EXPORTED_SYMBOLS = ["WinTaskbarJumpList"];
+
+const lazy = {};
+
+/**
+ * Smart getters
+ */
+
+XPCOMUtils.defineLazyGetter(lazy, "_prefs", function () {
+ return Services.prefs.getBranch(PREF_TASKBAR_BRANCH);
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "_stringBundle", function () {
+ return Services.strings.createBundle(
+ "chrome://browser/locale/taskbar.properties"
+ );
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "_idle",
+ "@mozilla.org/widget/useridleservice;1",
+ "nsIUserIdleService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "_taskbarService",
+ "@mozilla.org/windows-taskbar;1",
+ "nsIWinTaskbar"
+);
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+/**
+ * Global functions
+ */
+
+function _getString(name) {
+ return lazy._stringBundle.GetStringFromName(name);
+}
+
+// Task list configuration data object.
+
+var tasksCfg = [
+ /**
+ * Task configuration options: title, description, args, iconIndex, open, close.
+ *
+ * title - Task title displayed in the list. (strings in the table are temp fillers.)
+ * description - Tooltip description on the list item.
+ * args - Command line args to invoke the task.
+ * iconIndex - Optional win icon index into the main application for the
+ * list item.
+ * open - Boolean indicates if the command should be visible after the browser opens.
+ * close - Boolean indicates if the command should be visible after the browser closes.
+ */
+ // Open new tab
+ {
+ get title() {
+ return _getString("taskbar.tasks.newTab.label");
+ },
+ get description() {
+ return _getString("taskbar.tasks.newTab.description");
+ },
+ args: "-new-tab about:blank",
+ iconIndex: 3, // New window icon
+ open: true,
+ close: true, // The jump list already has an app launch icon, but
+ // we don't always update the list on shutdown.
+ // Thus true for consistency.
+ },
+
+ // Open new window
+ {
+ get title() {
+ return _getString("taskbar.tasks.newWindow.label");
+ },
+ get description() {
+ return _getString("taskbar.tasks.newWindow.description");
+ },
+ args: "-browser",
+ iconIndex: 2, // New tab icon
+ open: true,
+ close: true, // No point, but we don't always update the list on
+ // shutdown. Thus true for consistency.
+ },
+];
+
+// Open new private window
+let privateWindowTask = {
+ get title() {
+ return _getString("taskbar.tasks.newPrivateWindow.label");
+ },
+ get description() {
+ return _getString("taskbar.tasks.newPrivateWindow.description");
+ },
+ args: "-private-window",
+ iconIndex: 4, // Private browsing mode icon
+ open: true,
+ close: true, // No point, but we don't always update the list on
+ // shutdown. Thus true for consistency.
+};
+
+// Implementation
+
+var Builder = class {
+ constructor(builder) {
+ this._builder = builder;
+ this._tasks = null;
+ this._pendingStatements = {};
+ this._shuttingDown = false;
+ // These are ultimately controlled by prefs, so we disable
+ // everything until is read from there
+ this._showTasks = false;
+ this._showFrequent = false;
+ this._showRecent = false;
+ this._maxItemCount = 0;
+ }
+
+ refreshPrefs(showTasks, showFrequent, showRecent, maxItemCount) {
+ this._showTasks = showTasks;
+ this._showFrequent = showFrequent;
+ this._showRecent = showRecent;
+ this._maxItemCount = maxItemCount;
+ }
+
+ updateShutdownState(shuttingDown) {
+ this._shuttingDown = shuttingDown;
+ }
+
+ delete() {
+ delete this._builder;
+ }
+
+ /**
+ * List building
+ *
+ * @note Async builders must add their mozIStoragePendingStatement to
+ * _pendingStatements object, using a different LIST_TYPE entry for
+ * each statement. Once finished they must remove it and call
+ * commitBuild(). When there will be no more _pendingStatements,
+ * commitBuild() will commit for real.
+ */
+
+ _hasPendingStatements() {
+ return !!Object.keys(this._pendingStatements).length;
+ }
+
+ async buildList() {
+ if (
+ (this._showFrequent || this._showRecent) &&
+ this._hasPendingStatements()
+ ) {
+ // We were requested to update the list while another update was in
+ // progress, this could happen at shutdown, idle or privatebrowsing.
+ // Abort the current list building.
+ for (let listType in this._pendingStatements) {
+ this._pendingStatements[listType].cancel();
+ delete this._pendingStatements[listType];
+ }
+ this._builder.abortListBuild();
+ }
+
+ // anything to build?
+ if (!this._showFrequent && !this._showRecent && !this._showTasks) {
+ // don't leave the last list hanging on the taskbar.
+ this._deleteActiveJumpList();
+ return;
+ }
+
+ await this._startBuild();
+
+ if (this._showTasks) {
+ this._buildTasks();
+ }
+
+ // Space for frequent items takes priority over recent.
+ if (this._showFrequent) {
+ this._buildFrequent();
+ }
+
+ if (this._showRecent) {
+ this._buildRecent();
+ }
+
+ this._commitBuild();
+ }
+
+ /**
+ * Taskbar api wrappers
+ */
+
+ async _startBuild() {
+ this._builder.abortListBuild();
+ let URIsToRemove = await this._builder.initListBuild();
+ if (URIsToRemove.length) {
+ // Prior to building, delete removed items from history.
+ this._clearHistory(URIsToRemove);
+ }
+ }
+
+ _commitBuild() {
+ if (
+ (this._showFrequent || this._showRecent) &&
+ this._hasPendingStatements()
+ ) {
+ return;
+ }
+
+ this._builder.commitListBuild(succeed => {
+ if (!succeed) {
+ this._builder.abortListBuild();
+ }
+ });
+ }
+
+ _buildTasks() {
+ var items = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
+ this._tasks.forEach(function (task) {
+ if (
+ (this._shuttingDown && !task.close) ||
+ (!this._shuttingDown && !task.open)
+ ) {
+ return;
+ }
+ var item = this._getHandlerAppItem(
+ task.title,
+ task.description,
+ task.args,
+ task.iconIndex,
+ null
+ );
+ items.appendElement(item);
+ }, this);
+
+ if (items.length) {
+ this._builder.addListToBuild(
+ this._builder.JUMPLIST_CATEGORY_TASKS,
+ items
+ );
+ }
+ }
+
+ _buildCustom(title, items) {
+ if (items.length) {
+ this._builder.addListToBuild(
+ this._builder.JUMPLIST_CATEGORY_CUSTOMLIST,
+ items,
+ title
+ );
+ }
+ }
+
+ _buildFrequent() {
+ // Windows supports default frequent and recent lists,
+ // but those depend on internal windows visit tracking
+ // which we don't populate. So we build our own custom
+ // frequent and recent lists using our nav history data.
+
+ var items = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
+ // track frequent items so that we don't add them to
+ // the recent list.
+ this._frequentHashList = [];
+
+ this._pendingStatements[LIST_TYPE.FREQUENT] = this._getHistoryResults(
+ Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_DESCENDING,
+ this._maxItemCount,
+ function (aResult) {
+ if (!aResult) {
+ delete this._pendingStatements[LIST_TYPE.FREQUENT];
+ // The are no more results, build the list.
+ this._buildCustom(_getString("taskbar.frequent.label"), items);
+ this._commitBuild();
+ return;
+ }
+
+ let title = aResult.title || aResult.uri;
+ let faviconPageUri = Services.io.newURI(aResult.uri);
+ let shortcut = this._getHandlerAppItem(
+ title,
+ title,
+ aResult.uri,
+ 1,
+ faviconPageUri
+ );
+ items.appendElement(shortcut);
+ this._frequentHashList.push(aResult.uri);
+ },
+ this
+ );
+ }
+
+ _buildRecent() {
+ var items = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
+ // Frequent items will be skipped, so we select a double amount of
+ // entries and stop fetching results at _maxItemCount.
+ var count = 0;
+
+ this._pendingStatements[LIST_TYPE.RECENT] = this._getHistoryResults(
+ Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING,
+ this._maxItemCount * 2,
+ function (aResult) {
+ if (!aResult) {
+ // The are no more results, build the list.
+ this._buildCustom(_getString("taskbar.recent.label"), items);
+ delete this._pendingStatements[LIST_TYPE.RECENT];
+ this._commitBuild();
+ return;
+ }
+
+ if (count >= this._maxItemCount) {
+ return;
+ }
+
+ // Do not add items to recent that have already been added to frequent.
+ if (
+ this._frequentHashList &&
+ this._frequentHashList.includes(aResult.uri)
+ ) {
+ return;
+ }
+
+ let title = aResult.title || aResult.uri;
+ let faviconPageUri = Services.io.newURI(aResult.uri);
+ let shortcut = this._getHandlerAppItem(
+ title,
+ title,
+ aResult.uri,
+ 1,
+ faviconPageUri
+ );
+ items.appendElement(shortcut);
+ count++;
+ },
+ this
+ );
+ }
+
+ _deleteActiveJumpList() {
+ this._builder.deleteActiveList();
+ }
+
+ /**
+ * Jump list item creation helpers
+ */
+
+ _getHandlerAppItem(name, description, args, iconIndex, faviconPageUri) {
+ var file = Services.dirsvc.get("XREExeF", Ci.nsIFile);
+
+ var handlerApp = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ handlerApp.executable = file;
+ // handlers default to the leaf name if a name is not specified
+ if (name && name.length) {
+ handlerApp.name = name;
+ }
+ handlerApp.detailedDescription = description;
+ handlerApp.appendParameter(args);
+
+ var item = Cc["@mozilla.org/windows-jumplistshortcut;1"].createInstance(
+ Ci.nsIJumpListShortcut
+ );
+ item.app = handlerApp;
+ item.iconIndex = iconIndex;
+ item.faviconPageUri = faviconPageUri;
+ return item;
+ }
+
+ /**
+ * Nav history helpers
+ */
+
+ _getHistoryResults(aSortingMode, aLimit, aCallback, aScope) {
+ var options = lazy.PlacesUtils.history.getNewQueryOptions();
+ options.maxResults = aLimit;
+ options.sortingMode = aSortingMode;
+ var query = lazy.PlacesUtils.history.getNewQuery();
+
+ // Return the pending statement to the caller, to allow cancelation.
+ return lazy.PlacesUtils.history.asyncExecuteLegacyQuery(query, options, {
+ handleResult(aResultSet) {
+ for (let row; (row = aResultSet.getNextRow()); ) {
+ try {
+ aCallback.call(aScope, {
+ uri: row.getResultByIndex(1),
+ title: row.getResultByIndex(2),
+ });
+ } catch (e) {}
+ }
+ },
+ handleError(aError) {
+ console.error(
+ "Async execution error (",
+ aError.result,
+ "): ",
+ aError.message
+ );
+ },
+ handleCompletion(aReason) {
+ aCallback.call(aScope, null);
+ },
+ });
+ }
+
+ _clearHistory(uriSpecsToRemove) {
+ let URIsToRemove = uriSpecsToRemove
+ .map(spec => {
+ try {
+ // in case we get a bad uri
+ return Services.io.newURI(spec);
+ } catch (e) {
+ return null;
+ }
+ })
+ .filter(uri => !!uri);
+
+ if (URIsToRemove.length) {
+ lazy.PlacesUtils.history.remove(URIsToRemove).catch(console.error);
+ }
+ }
+};
+
+var WinTaskbarJumpList = {
+ // We build two separate jump lists -- one for the regular Firefox icon
+ // and one for the Private Browsing icon
+ _builder: null,
+ _pbBuilder: null,
+ _builtPb: false,
+ _shuttingDown: false,
+
+ /**
+ * Startup, shutdown, and update
+ */
+
+ startup: function WTBJL_startup() {
+ // exit if this isn't win7 or higher.
+ if (!this._initTaskbar()) {
+ return;
+ }
+
+ if (lazy.PrivateBrowsingUtils.enabled) {
+ tasksCfg.push(privateWindowTask);
+ }
+ // Store our task list config data
+ this._builder._tasks = tasksCfg;
+ this._pbBuilder._tasks = tasksCfg;
+
+ // retrieve taskbar related prefs.
+ this._refreshPrefs();
+
+ // observer for private browsing and our prefs branch
+ this._initObs();
+
+ // jump list refresh timer
+ this._updateTimer();
+ },
+
+ update: function WTBJL_update() {
+ // are we disabled via prefs? don't do anything!
+ if (!this._enabled) {
+ return;
+ }
+
+ // we only need to do this once, but we do it here
+ // to avoid main thread io on startup
+ if (!this._builtPb) {
+ this._pbBuilder.buildList();
+ this._builtPb = true;
+ }
+
+ // do what we came here to do, update the taskbar jumplist
+ this._builder.buildList();
+ },
+
+ _shutdown: function WTBJL__shutdown() {
+ this._builder.updateShutdownState(true);
+ this._pbBuilder.updateShutdownState(true);
+ this._shuttingDown = true;
+ this._free();
+ },
+
+ /**
+ * Prefs utilities
+ */
+
+ _refreshPrefs: function WTBJL__refreshPrefs() {
+ this._enabled = lazy._prefs.getBoolPref(PREF_TASKBAR_ENABLED);
+ var showTasks = lazy._prefs.getBoolPref(PREF_TASKBAR_TASKS);
+ this._builder.refreshPrefs(
+ showTasks,
+ lazy._prefs.getBoolPref(PREF_TASKBAR_FREQUENT),
+ lazy._prefs.getBoolPref(PREF_TASKBAR_RECENT),
+ lazy._prefs.getIntPref(PREF_TASKBAR_ITEMCOUNT)
+ );
+ // showTasks is the only relevant pref for the Private Browsing Jump List
+ // the others are are related to frequent/recent entries, which are
+ // explicitly disabled for it
+ this._pbBuilder.refreshPrefs(showTasks, false, false, 0);
+ },
+
+ /**
+ * Init and shutdown utilities
+ */
+
+ _initTaskbar: function WTBJL__initTaskbar() {
+ var builder = lazy._taskbarService.createJumpListBuilder(false);
+ var pbBuilder = lazy._taskbarService.createJumpListBuilder(true);
+ if (!builder || !builder.available || !pbBuilder || !pbBuilder.available) {
+ return false;
+ }
+
+ this._builder = new Builder(builder, true, true, true);
+ this._pbBuilder = new Builder(pbBuilder, true, false, false);
+
+ return true;
+ },
+
+ _initObs: function WTBJL__initObs() {
+ // If the browser is closed while in private browsing mode, the "exit"
+ // notification is fired on quit-application-granted.
+ // History cleanup can happen at profile-change-teardown.
+ Services.obs.addObserver(this, "profile-before-change");
+ Services.obs.addObserver(this, "browser:purge-session-history");
+ lazy._prefs.addObserver("", this);
+ this._placesObserver = new PlacesWeakCallbackWrapper(
+ this.update.bind(this)
+ );
+ lazy.PlacesUtils.observers.addListener(
+ ["history-cleared"],
+ this._placesObserver
+ );
+ },
+
+ _freeObs: function WTBJL__freeObs() {
+ Services.obs.removeObserver(this, "profile-before-change");
+ Services.obs.removeObserver(this, "browser:purge-session-history");
+ lazy._prefs.removeObserver("", this);
+ if (this._placesObserver) {
+ lazy.PlacesUtils.observers.removeListener(
+ ["history-cleared"],
+ this._placesObserver
+ );
+ }
+ },
+
+ _updateTimer: function WTBJL__updateTimer() {
+ if (this._enabled && !this._shuttingDown && !this._timer) {
+ this._timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ this._timer.initWithCallback(
+ this,
+ lazy._prefs.getIntPref(PREF_TASKBAR_REFRESH) * 1000,
+ this._timer.TYPE_REPEATING_SLACK
+ );
+ } else if ((!this._enabled || this._shuttingDown) && this._timer) {
+ this._timer.cancel();
+ delete this._timer;
+ }
+ },
+
+ _hasIdleObserver: false,
+ _updateIdleObserver: function WTBJL__updateIdleObserver() {
+ if (this._enabled && !this._shuttingDown && !this._hasIdleObserver) {
+ lazy._idle.addIdleObserver(this, IDLE_TIMEOUT_SECONDS);
+ this._hasIdleObserver = true;
+ } else if (
+ (!this._enabled || this._shuttingDown) &&
+ this._hasIdleObserver
+ ) {
+ lazy._idle.removeIdleObserver(this, IDLE_TIMEOUT_SECONDS);
+ this._hasIdleObserver = false;
+ }
+ },
+
+ _free: function WTBJL__free() {
+ this._freeObs();
+ this._updateTimer();
+ this._updateIdleObserver();
+ this._builder.delete();
+ this._pbBuilder.delete();
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsINamed",
+ "nsIObserver",
+ "nsITimerCallback",
+ ]),
+
+ name: "WinTaskbarJumpList",
+
+ notify: function WTBJL_notify(aTimer) {
+ // Add idle observer on the first notification so it doesn't hit startup.
+ this._updateIdleObserver();
+ Services.tm.idleDispatchToMainThread(() => {
+ this.update();
+ });
+ },
+
+ observe: function WTBJL_observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "nsPref:changed":
+ if (this._enabled && !lazy._prefs.getBoolPref(PREF_TASKBAR_ENABLED)) {
+ this._deleteActiveJumpList();
+ }
+ this._refreshPrefs();
+ this._updateTimer();
+ this._updateIdleObserver();
+ Services.tm.idleDispatchToMainThread(() => {
+ this.update();
+ });
+ break;
+
+ case "profile-before-change":
+ this._shutdown();
+ break;
+
+ case "browser:purge-session-history":
+ this.update();
+ break;
+ case "idle":
+ if (this._timer) {
+ this._timer.cancel();
+ delete this._timer;
+ }
+ break;
+
+ case "active":
+ this._updateTimer();
+ break;
+ }
+ },
+};
diff --git a/browser/modules/WindowsPreviewPerTab.jsm b/browser/modules/WindowsPreviewPerTab.jsm
new file mode 100644
index 0000000000..dc5f4fddba
--- /dev/null
+++ b/browser/modules/WindowsPreviewPerTab.jsm
@@ -0,0 +1,910 @@
+/* vim: se cin sw=2 ts=2 et filetype=javascript :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 module implements the front end behavior for AeroPeek. Starting in
+ * Windows Vista, the taskbar began showing live thumbnail previews of windows
+ * when the user hovered over the window icon in the taskbar. Starting with
+ * Windows 7, the taskbar allows an application to expose its tabbed interface
+ * in the taskbar by showing thumbnail previews rather than the default window
+ * preview. Additionally, when a user hovers over a thumbnail (tab or window),
+ * they are shown a live preview of the window (or tab + its containing window).
+ *
+ * In Windows 7, a title, icon, close button and optional toolbar are shown for
+ * each preview. This feature does not make use of the toolbar. For window
+ * previews, the title is the window title and the icon the window icon. For
+ * tab previews, the title is the page title and the page's favicon. In both
+ * cases, the close button "does the right thing."
+ *
+ * The primary objects behind this feature are nsITaskbarTabPreview and
+ * nsITaskbarPreviewController. Each preview has a controller. The controller
+ * responds to the user's interactions on the taskbar and provides the required
+ * data to the preview for determining the size of the tab and thumbnail. The
+ * PreviewController class implements this interface. The preview will request
+ * the controller to provide a thumbnail or preview when the user interacts with
+ * the taskbar. To reduce the overhead of drawing the tab area, the controller
+ * implementation caches the tab's contents in a <canvas> element. If no
+ * previews or thumbnails have been requested for some time, the controller will
+ * discard its cached tab contents.
+ *
+ * Screen real estate is limited so when there are too many thumbnails to fit
+ * on the screen, the taskbar stops displaying thumbnails and instead displays
+ * just the title, icon and close button in a similar fashion to previous
+ * versions of the taskbar. If there are still too many previews to fit on the
+ * screen, the taskbar resorts to a scroll up and scroll down button pair to let
+ * the user scroll through the list of tabs. Since this is undoubtedly
+ * inconvenient for users with many tabs, the AeroPeek objects turns off all of
+ * the tab previews. This tells the taskbar to revert to one preview per window.
+ * If the number of tabs falls below this magic threshold, the preview-per-tab
+ * behavior returns. There is no reliable way to determine when the scroll
+ * buttons appear on the taskbar, so a magic pref-controlled number determines
+ * when this threshold has been crossed.
+ */
+var EXPORTED_SYMBOLS = ["AeroPeek"];
+
+const { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+const { PlacesUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PlacesUtils.sys.mjs"
+);
+const { PrivateBrowsingUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PrivateBrowsingUtils.sys.mjs"
+);
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+// Pref to enable/disable preview-per-tab
+const TOGGLE_PREF_NAME = "browser.taskbar.previews.enable";
+// Pref to determine the magic auto-disable threshold
+const DISABLE_THRESHOLD_PREF_NAME = "browser.taskbar.previews.max";
+// Pref to control the time in seconds that tab contents live in the cache
+const CACHE_EXPIRATION_TIME_PREF_NAME = "browser.taskbar.previews.cachetime";
+
+const WINTASKBAR_CONTRACTID = "@mozilla.org/windows-taskbar;1";
+
+const lazy = {};
+
+// Various utility properties
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "imgTools",
+ "@mozilla.org/image/tools;1",
+ "imgITools"
+);
+ChromeUtils.defineESModuleGetters(lazy, {
+ PageThumbs: "resource://gre/modules/PageThumbs.sys.mjs",
+});
+
+// nsIURI -> imgIContainer
+function _imageFromURI(uri, privateMode, callback) {
+ let channel = NetUtil.newChannel({
+ uri,
+ loadUsingSystemPrincipal: true,
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE,
+ });
+
+ try {
+ channel.QueryInterface(Ci.nsIPrivateBrowsingChannel);
+ channel.setPrivate(privateMode);
+ } catch (e) {
+ // Ignore channels which do not support nsIPrivateBrowsingChannel
+ }
+ NetUtil.asyncFetch(channel, function (inputStream, resultCode) {
+ if (!Components.isSuccessCode(resultCode)) {
+ return;
+ }
+
+ const decodeCallback = {
+ onImageReady(image, status) {
+ if (!image) {
+ // We failed, so use the default favicon (only if this wasn't the
+ // default favicon).
+ let defaultURI = PlacesUtils.favicons.defaultFavicon;
+ if (!defaultURI.equals(uri)) {
+ _imageFromURI(defaultURI, privateMode, callback);
+ return;
+ }
+ }
+
+ callback(image);
+ },
+ };
+
+ try {
+ let threadManager = Cc["@mozilla.org/thread-manager;1"].getService();
+ lazy.imgTools.decodeImageAsync(
+ inputStream,
+ channel.contentType,
+ decodeCallback,
+ threadManager.currentThread
+ );
+ } catch (e) {
+ // We failed, so use the default favicon (only if this wasn't the default
+ // favicon).
+ let defaultURI = PlacesUtils.favicons.defaultFavicon;
+ if (!defaultURI.equals(uri)) {
+ _imageFromURI(defaultURI, privateMode, callback);
+ }
+ }
+ });
+}
+
+// string? -> imgIContainer
+function getFaviconAsImage(iconurl, privateMode, callback) {
+ if (iconurl) {
+ _imageFromURI(NetUtil.newURI(iconurl), privateMode, callback);
+ } else {
+ _imageFromURI(PlacesUtils.favicons.defaultFavicon, privateMode, callback);
+ }
+}
+
+// PreviewController
+
+/*
+ * This class manages the behavior of thumbnails and previews. It has the following
+ * responsibilities:
+ * 1) responding to requests from Windows taskbar for a thumbnail or window
+ * preview.
+ * 2) listens for dom events that result in a thumbnail or window preview needing
+ * to be refresh, and communicates this to the taskbar.
+ * 3) Handles querying and returning to the taskbar new thumbnail or window
+ * preview images through PageThumbs.
+ *
+ * @param win
+ * The TabWindow (see below) that owns the preview that this controls
+ * @param tab
+ * The <tab> that this preview is associated with
+ */
+function PreviewController(win, tab) {
+ this.win = win;
+ this.tab = tab;
+ this.linkedBrowser = tab.linkedBrowser;
+ this.preview = this.win.createTabPreview(this);
+
+ this.tab.addEventListener("TabAttrModified", this);
+
+ XPCOMUtils.defineLazyGetter(this, "canvasPreview", function () {
+ let canvas = lazy.PageThumbs.createCanvas(this.win.win);
+ canvas.mozOpaque = true;
+ return canvas;
+ });
+}
+
+PreviewController.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsITaskbarPreviewController"]),
+
+ destroy() {
+ this.tab.removeEventListener("TabAttrModified", this);
+
+ // Break cycles, otherwise we end up leaking the window with everything
+ // attached to it.
+ delete this.win;
+ delete this.preview;
+ },
+
+ get wrappedJSObject() {
+ return this;
+ },
+
+ // Resizes the canvasPreview to 0x0, essentially freeing its memory.
+ resetCanvasPreview() {
+ this.canvasPreview.width = 0;
+ this.canvasPreview.height = 0;
+ },
+
+ /**
+ * Set the canvas dimensions.
+ */
+ resizeCanvasPreview(aRequestedWidth, aRequestedHeight) {
+ this.canvasPreview.width = aRequestedWidth;
+ this.canvasPreview.height = aRequestedHeight;
+ },
+
+ get browserDims() {
+ return this.tab.linkedBrowser.getBoundingClientRect();
+ },
+
+ cacheBrowserDims() {
+ let dims = this.browserDims;
+ this._cachedWidth = dims.width;
+ this._cachedHeight = dims.height;
+ },
+
+ testCacheBrowserDims() {
+ let dims = this.browserDims;
+ return this._cachedWidth == dims.width && this._cachedHeight == dims.height;
+ },
+
+ /**
+ * Capture a new thumbnail image for this preview. Called by the controller
+ * in response to a request for a new thumbnail image.
+ */
+ updateCanvasPreview(aFullScale) {
+ // Update our cached browser dims so that delayed resize
+ // events don't trigger another invalidation if this tab becomes active.
+ this.cacheBrowserDims();
+ AeroPeek.resetCacheTimer();
+ return lazy.PageThumbs.captureToCanvas(
+ this.linkedBrowser,
+ this.canvasPreview,
+ {
+ fullScale: aFullScale,
+ }
+ ).catch(console.error);
+ // If we're updating the canvas, then we're in the middle of a peek so
+ // don't discard the cache of previews.
+ },
+
+ updateTitleAndTooltip() {
+ let title = this.win.tabbrowser.getWindowTitleForBrowser(
+ this.linkedBrowser
+ );
+ this.preview.title = title;
+ this.preview.tooltip = title;
+ },
+
+ // nsITaskbarPreviewController
+
+ // window width and height, not browser
+ get width() {
+ return this.win.width;
+ },
+
+ // window width and height, not browser
+ get height() {
+ return this.win.height;
+ },
+
+ get thumbnailAspectRatio() {
+ let browserDims = this.browserDims;
+ // Avoid returning 0
+ let tabWidth = browserDims.width || 1;
+ // Avoid divide by 0
+ let tabHeight = browserDims.height || 1;
+ return tabWidth / tabHeight;
+ },
+
+ /**
+ * Responds to taskbar requests for window previews. Returns the results asynchronously
+ * through updateCanvasPreview.
+ *
+ * @param aTaskbarCallback nsITaskbarPreviewCallback results callback
+ */
+ requestPreview(aTaskbarCallback) {
+ // Grab a high res content preview
+ this.resetCanvasPreview();
+ this.updateCanvasPreview(true).then(aPreviewCanvas => {
+ let winWidth = this.win.width;
+ let winHeight = this.win.height;
+
+ let composite = lazy.PageThumbs.createCanvas(this.win.win);
+
+ // Use transparency, Aero glass is drawn black without it.
+ composite.mozOpaque = false;
+
+ let ctx = composite.getContext("2d");
+ let scale = this.win.win.devicePixelRatio;
+
+ composite.width = winWidth * scale;
+ composite.height = winHeight * scale;
+
+ ctx.save();
+ ctx.scale(scale, scale);
+
+ // Draw chrome. Note we currently do not get scrollbars for remote frames
+ // in the image above.
+ ctx.drawWindow(this.win.win, 0, 0, winWidth, winHeight, "rgba(0,0,0,0)");
+
+ // Draw the content are into the composite canvas at the right location.
+ ctx.drawImage(
+ aPreviewCanvas,
+ this.browserDims.x,
+ this.browserDims.y,
+ aPreviewCanvas.width,
+ aPreviewCanvas.height
+ );
+ ctx.restore();
+
+ // Deliver the resulting composite canvas to Windows
+ this.win.tabbrowser.previewTab(this.tab, function () {
+ aTaskbarCallback.done(composite, false);
+ });
+ });
+ },
+
+ /**
+ * Responds to taskbar requests for tab thumbnails. Returns the results asynchronously
+ * through updateCanvasPreview.
+ *
+ * Note Windows requests a specific width and height here, if the resulting thumbnail
+ * does not match these dimensions thumbnail display will fail.
+ *
+ * @param aTaskbarCallback nsITaskbarPreviewCallback results callback
+ * @param aRequestedWidth width of the requested thumbnail
+ * @param aRequestedHeight height of the requested thumbnail
+ */
+ requestThumbnail(aTaskbarCallback, aRequestedWidth, aRequestedHeight) {
+ this.resizeCanvasPreview(aRequestedWidth, aRequestedHeight);
+ this.updateCanvasPreview(false).then(aThumbnailCanvas => {
+ aTaskbarCallback.done(aThumbnailCanvas, false);
+ });
+ },
+
+ // Event handling
+
+ onClose() {
+ this.win.tabbrowser.removeTab(this.tab);
+ },
+
+ onActivate() {
+ this.win.tabbrowser.selectedTab = this.tab;
+
+ // Accept activation - this will restore the browser window
+ // if it's minimized
+ return true;
+ },
+
+ // EventListener
+ handleEvent(evt) {
+ switch (evt.type) {
+ case "TabAttrModified":
+ this.updateTitleAndTooltip();
+ break;
+ }
+ },
+};
+
+// TabWindow
+
+/*
+ * This class monitors a browser window for changes to its tabs
+ *
+ * @param win
+ * The nsIDOMWindow browser window
+ */
+function TabWindow(win) {
+ this.win = win;
+ this.tabbrowser = win.gBrowser;
+
+ this.previews = new Map();
+
+ for (let i = 0; i < this.tabEvents.length; i++) {
+ this.tabbrowser.tabContainer.addEventListener(this.tabEvents[i], this);
+ }
+
+ for (let i = 0; i < this.winEvents.length; i++) {
+ this.win.addEventListener(this.winEvents[i], this);
+ }
+
+ this.tabbrowser.addTabsProgressListener(this);
+
+ AeroPeek.windows.push(this);
+ let tabs = this.tabbrowser.tabs;
+ for (let i = 0; i < tabs.length; i++) {
+ this.newTab(tabs[i]);
+ }
+
+ this.updateTabOrdering();
+ AeroPeek.checkPreviewCount();
+}
+
+TabWindow.prototype = {
+ _enabled: false,
+ _cachedWidth: 0,
+ _cachedHeight: 0,
+ tabEvents: ["TabOpen", "TabClose", "TabSelect", "TabMove"],
+ winEvents: ["resize"],
+
+ destroy() {
+ this._destroying = true;
+
+ let tabs = this.tabbrowser.tabs;
+
+ this.tabbrowser.removeTabsProgressListener(this);
+
+ for (let i = 0; i < this.winEvents.length; i++) {
+ this.win.removeEventListener(this.winEvents[i], this);
+ }
+
+ for (let i = 0; i < this.tabEvents.length; i++) {
+ this.tabbrowser.tabContainer.removeEventListener(this.tabEvents[i], this);
+ }
+
+ for (let i = 0; i < tabs.length; i++) {
+ this.removeTab(tabs[i]);
+ }
+
+ let idx = AeroPeek.windows.indexOf(this.win.gTaskbarTabGroup);
+ AeroPeek.windows.splice(idx, 1);
+ AeroPeek.checkPreviewCount();
+ },
+
+ get width() {
+ return this.win.innerWidth;
+ },
+ get height() {
+ return this.win.innerHeight;
+ },
+
+ cacheDims() {
+ this._cachedWidth = this.width;
+ this._cachedHeight = this.height;
+ },
+
+ testCacheDims() {
+ return this._cachedWidth == this.width && this._cachedHeight == this.height;
+ },
+
+ // Invoked when the given tab is added to this window
+ newTab(tab) {
+ let controller = new PreviewController(this, tab);
+ // It's OK to add the preview now while the favicon still loads.
+ this.previews.set(tab, controller.preview);
+ AeroPeek.addPreview(controller.preview);
+ // updateTitleAndTooltip relies on having controller.preview which is lazily resolved.
+ // Now that we've updated this.previews, it will resolve successfully.
+ controller.updateTitleAndTooltip();
+ },
+
+ createTabPreview(controller) {
+ let docShell = this.win.docShell;
+ let preview = AeroPeek.taskbar.createTaskbarTabPreview(
+ docShell,
+ controller
+ );
+ preview.visible = AeroPeek.enabled;
+ let { tab } = controller;
+ preview.active = this.tabbrowser.selectedTab == tab;
+ this.updateFavicon(tab, tab.getAttribute("image"));
+ return preview;
+ },
+
+ // Invoked when the given tab is closed
+ removeTab(tab) {
+ let preview = this.previewFromTab(tab);
+ preview.active = false;
+ preview.visible = false;
+ preview.move(null);
+ preview.controller.wrappedJSObject.destroy();
+
+ this.previews.delete(tab);
+ AeroPeek.removePreview(preview);
+ },
+
+ get enabled() {
+ return this._enabled;
+ },
+
+ set enabled(enable) {
+ this._enabled = enable;
+ // Because making a tab visible requires that the tab it is next to be
+ // visible, it is far simpler to unset the 'next' tab and recreate them all
+ // at once.
+ for (let [, preview] of this.previews) {
+ preview.move(null);
+ preview.visible = enable;
+ }
+ this.updateTabOrdering();
+ },
+
+ previewFromTab(tab) {
+ return this.previews.get(tab);
+ },
+
+ updateTabOrdering() {
+ let previews = this.previews;
+ let tabs = this.tabbrowser.tabs;
+
+ // Previews are internally stored using a map, so we need to iterate the
+ // tabbrowser's array of tabs to retrieve previews in the same order.
+ let inorder = [];
+ for (let t of tabs) {
+ if (previews.has(t)) {
+ inorder.push(previews.get(t));
+ }
+ }
+
+ // Since the internal taskbar array has not yet been updated we must force
+ // on it the sorting order of our local array. To do so we must walk
+ // the local array backwards, otherwise we would send move requests in the
+ // wrong order. See bug 522610 for details.
+ for (let i = inorder.length - 1; i >= 0; i--) {
+ inorder[i].move(inorder[i + 1] || null);
+ }
+ },
+
+ // EventListener
+ handleEvent(evt) {
+ let tab = evt.originalTarget;
+ switch (evt.type) {
+ case "TabOpen":
+ this.newTab(tab);
+ this.updateTabOrdering();
+ break;
+ case "TabClose":
+ this.removeTab(tab);
+ this.updateTabOrdering();
+ break;
+ case "TabSelect":
+ this.previewFromTab(tab).active = true;
+ break;
+ case "TabMove":
+ this.updateTabOrdering();
+ break;
+ case "resize":
+ if (!AeroPeek._prefenabled) {
+ return;
+ }
+ this.onResize();
+ break;
+ }
+ },
+
+ // Set or reset a timer that will invalidate visible thumbnails soon.
+ setInvalidationTimer() {
+ if (!this.invalidateTimer) {
+ this.invalidateTimer = Cc["@mozilla.org/timer;1"].createInstance(
+ Ci.nsITimer
+ );
+ }
+ this.invalidateTimer.cancel();
+
+ // delay 1 second before invalidating
+ this.invalidateTimer.initWithCallback(
+ () => {
+ // invalidate every preview. note the internal implementation of
+ // invalidate ignores thumbnails that aren't visible.
+ this.previews.forEach(function (aPreview) {
+ let controller = aPreview.controller.wrappedJSObject;
+ if (!controller.testCacheBrowserDims()) {
+ controller.cacheBrowserDims();
+ aPreview.invalidate();
+ }
+ });
+ },
+ 1000,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ },
+
+ onResize() {
+ // Specific to a window.
+
+ // Call invalidate on each tab thumbnail so that Windows will request an
+ // updated image. However don't do this repeatedly across multiple resize
+ // events triggered during window border drags.
+
+ if (this.testCacheDims()) {
+ return;
+ }
+
+ // update the window dims on our TabWindow object.
+ this.cacheDims();
+
+ // invalidate soon
+ this.setInvalidationTimer();
+ },
+
+ invalidateTabPreview(aBrowser) {
+ for (let [tab, preview] of this.previews) {
+ if (aBrowser == tab.linkedBrowser) {
+ preview.invalidate();
+ break;
+ }
+ }
+ },
+
+ // Browser progress listener
+
+ onLocationChange(aBrowser) {
+ // I'm not sure we need this, onStateChange does a really good job
+ // of picking up page changes.
+ // this.invalidateTabPreview(aBrowser);
+ },
+
+ onStateChange(aBrowser, aWebProgress, aRequest, aStateFlags, aStatus) {
+ if (
+ aStateFlags & Ci.nsIWebProgressListener.STATE_STOP &&
+ aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK
+ ) {
+ this.invalidateTabPreview(aBrowser);
+ }
+ },
+
+ directRequestProtocols: new Set([
+ "file",
+ "chrome",
+ "resource",
+ "about",
+ "data",
+ ]),
+ onLinkIconAvailable(aBrowser, aIconURL) {
+ let tab = this.win.gBrowser.getTabForBrowser(aBrowser);
+ this.updateFavicon(tab, aIconURL);
+ },
+ updateFavicon(aTab, aIconURL) {
+ let requestURL = null;
+ if (aIconURL) {
+ let shouldRequestFaviconURL = true;
+ try {
+ let urlObject = NetUtil.newURI(aIconURL);
+ shouldRequestFaviconURL = !this.directRequestProtocols.has(
+ urlObject.scheme
+ );
+ } catch (ex) {}
+
+ requestURL = shouldRequestFaviconURL
+ ? "moz-anno:favicon:" + aIconURL
+ : aIconURL;
+ }
+ let isDefaultFavicon = !requestURL;
+ getFaviconAsImage(
+ requestURL,
+ PrivateBrowsingUtils.isWindowPrivate(this.win),
+ img => {
+ // The tab could have closed, and there's no guarantee the icons
+ // will have finished fetching 'in order'.
+ if (this.win.closed || aTab.closing || !aTab.linkedBrowser) {
+ return;
+ }
+ // Note that bizarrely, we can get to updateFavicon via a sync codepath
+ // where the new preview controller hasn't yet been added to the
+ // window's map of previews. So `preview` would be null here - except
+ // getFaviconAsImage is async so that should never happen, as we add
+ // the controller to the preview collection straight after creating it.
+ // However, if any of this code ever tries to access this
+ // synchronously, that won't work.
+ let preview = this.previews.get(aTab);
+ if (
+ aTab.getAttribute("image") == aIconURL ||
+ (!preview.icon && isDefaultFavicon)
+ ) {
+ preview.icon = img;
+ }
+ }
+ );
+ },
+};
+
+// AeroPeek
+
+/*
+ * This object acts as global storage and external interface for this feature.
+ * It maintains the values of the prefs.
+ */
+var AeroPeek = {
+ available: false,
+ // Does the pref say we're enabled?
+ __prefenabled: false,
+
+ _enabled: true,
+
+ initialized: false,
+
+ // nsITaskbarTabPreview array
+ previews: [],
+
+ // TabWindow array
+ windows: [],
+
+ // nsIWinTaskbar service
+ taskbar: null,
+
+ // Maximum number of previews
+ maxpreviews: 20,
+
+ // Length of time in seconds that previews are cached
+ cacheLifespan: 20,
+
+ initialize() {
+ if (!(WINTASKBAR_CONTRACTID in Cc)) {
+ return;
+ }
+ this.taskbar = Cc[WINTASKBAR_CONTRACTID].getService(Ci.nsIWinTaskbar);
+ this.available = this.taskbar.available;
+ if (!this.available) {
+ return;
+ }
+
+ Services.prefs.addObserver(TOGGLE_PREF_NAME, this, true);
+ this.enabled = this._prefenabled =
+ Services.prefs.getBoolPref(TOGGLE_PREF_NAME);
+ this.initialized = true;
+ },
+
+ destroy: function destroy() {
+ this._enabled = false;
+
+ if (this.cacheTimer) {
+ this.cacheTimer.cancel();
+ }
+ },
+
+ get enabled() {
+ return this._enabled;
+ },
+
+ set enabled(enable) {
+ if (this._enabled == enable) {
+ return;
+ }
+
+ this._enabled = enable;
+
+ this.windows.forEach(function (win) {
+ win.enabled = enable;
+ });
+ },
+
+ get _prefenabled() {
+ return this.__prefenabled;
+ },
+
+ set _prefenabled(enable) {
+ if (enable == this.__prefenabled) {
+ return;
+ }
+ this.__prefenabled = enable;
+
+ if (enable) {
+ this.enable();
+ } else {
+ this.disable();
+ }
+ },
+
+ _observersAdded: false,
+
+ enable() {
+ if (!this._observersAdded) {
+ Services.prefs.addObserver(DISABLE_THRESHOLD_PREF_NAME, this, true);
+ Services.prefs.addObserver(CACHE_EXPIRATION_TIME_PREF_NAME, this, true);
+ this._placesListener = this.handlePlacesEvents.bind(this);
+ PlacesUtils.observers.addListener(
+ ["favicon-changed"],
+ this._placesListener
+ );
+ this._observersAdded = true;
+ }
+
+ this.cacheLifespan = Services.prefs.getIntPref(
+ CACHE_EXPIRATION_TIME_PREF_NAME
+ );
+
+ this.maxpreviews = Services.prefs.getIntPref(DISABLE_THRESHOLD_PREF_NAME);
+
+ // If the user toggled us on/off while the browser was already up
+ // (rather than this code running on startup because the pref was
+ // already set to true), we must initialize previews for open windows:
+ if (this.initialized) {
+ for (let win of Services.wm.getEnumerator("navigator:browser")) {
+ if (!win.closed) {
+ this.onOpenWindow(win);
+ }
+ }
+ }
+ },
+
+ disable() {
+ while (this.windows.length) {
+ // We can't call onCloseWindow here because it'll bail if we're not
+ // enabled.
+ let tabWinObject = this.windows[0];
+ tabWinObject.destroy(); // This will remove us from the array.
+ delete tabWinObject.win.gTaskbarTabGroup; // Tidy up the window.
+ }
+ PlacesUtils.observers.removeListener(
+ ["favicon-changed"],
+ this._placesListener
+ );
+ },
+
+ addPreview(preview) {
+ this.previews.push(preview);
+ this.checkPreviewCount();
+ },
+
+ removePreview(preview) {
+ let idx = this.previews.indexOf(preview);
+ this.previews.splice(idx, 1);
+ this.checkPreviewCount();
+ },
+
+ checkPreviewCount() {
+ if (!this._prefenabled) {
+ return;
+ }
+ this.enabled = this.previews.length <= this.maxpreviews;
+ },
+
+ onOpenWindow(win) {
+ // This occurs when the taskbar service is not available (xp, vista)
+ if (!this.available || !this._prefenabled) {
+ return;
+ }
+
+ win.gTaskbarTabGroup = new TabWindow(win);
+ },
+
+ onCloseWindow(win) {
+ // This occurs when the taskbar service is not available (xp, vista)
+ if (!this.available || !this._prefenabled) {
+ return;
+ }
+
+ win.gTaskbarTabGroup.destroy();
+ delete win.gTaskbarTabGroup;
+
+ if (!this.windows.length) {
+ this.destroy();
+ }
+ },
+
+ resetCacheTimer() {
+ this.cacheTimer.cancel();
+ this.cacheTimer.init(
+ this,
+ 1000 * this.cacheLifespan,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ },
+
+ // nsIObserver
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "nsPref:changed" && aData == TOGGLE_PREF_NAME) {
+ this._prefenabled = Services.prefs.getBoolPref(TOGGLE_PREF_NAME);
+ }
+ if (!this._prefenabled) {
+ return;
+ }
+ switch (aTopic) {
+ case "nsPref:changed":
+ if (aData == CACHE_EXPIRATION_TIME_PREF_NAME) {
+ break;
+ }
+
+ if (aData == DISABLE_THRESHOLD_PREF_NAME) {
+ this.maxpreviews = Services.prefs.getIntPref(
+ DISABLE_THRESHOLD_PREF_NAME
+ );
+ }
+ // Might need to enable/disable ourselves
+ this.checkPreviewCount();
+ break;
+ case "timer-callback":
+ this.previews.forEach(function (preview) {
+ let controller = preview.controller.wrappedJSObject;
+ controller.resetCanvasPreview();
+ });
+ break;
+ }
+ },
+
+ handlePlacesEvents(events) {
+ for (let event of events) {
+ switch (event.type) {
+ case "favicon-changed": {
+ for (let win of this.windows) {
+ for (let [tab] of win.previews) {
+ if (tab.getAttribute("image") == event.faviconUrl) {
+ win.updateFavicon(tab, event.faviconUrl);
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsISupportsWeakReference",
+ "nsIObserver",
+ ]),
+};
+
+XPCOMUtils.defineLazyGetter(AeroPeek, "cacheTimer", () =>
+ Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer)
+);
+
+AeroPeek.initialize();
diff --git a/browser/modules/ZoomUI.jsm b/browser/modules/ZoomUI.jsm
new file mode 100644
index 0000000000..343f8c16d2
--- /dev/null
+++ b/browser/modules/ZoomUI.jsm
@@ -0,0 +1,213 @@
+// -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["ZoomUI"];
+const gLoadContext = Cu.createLoadContext();
+const gContentPrefs = Cc["@mozilla.org/content-pref/service;1"].getService(
+ Ci.nsIContentPrefService2
+);
+const gZoomPropertyName = "browser.content.full-zoom";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs",
+});
+
+var ZoomUI = {
+ init(aWindow) {
+ aWindow.addEventListener("EndSwapDocShells", onEndSwapDocShells, true);
+ aWindow.addEventListener("FullZoomChange", onZoomChange);
+ aWindow.addEventListener("TextZoomChange", onZoomChange);
+ aWindow.addEventListener(
+ "unload",
+ () => {
+ aWindow.removeEventListener(
+ "EndSwapDocShells",
+ onEndSwapDocShells,
+ true
+ );
+ aWindow.removeEventListener("FullZoomChange", onZoomChange);
+ aWindow.removeEventListener("TextZoomChange", onZoomChange);
+ },
+ { once: true }
+ );
+ },
+
+ /**
+ * Gets the global browser.content.full-zoom content preference.
+ *
+ * @returns Promise<prefValue>
+ * Resolves to the preference value (float) when done.
+ */
+ getGlobalValue() {
+ return new Promise(resolve => {
+ let cachedVal = gContentPrefs.getCachedGlobal(
+ gZoomPropertyName,
+ gLoadContext
+ );
+ if (cachedVal) {
+ // We've got cached information, though it may be we've cached
+ // an undefined value, or the cached info is invalid. To ensure
+ // a valid return, we opt to return the default 1.0 in the
+ // undefined and invalid cases.
+ resolve(parseFloat(cachedVal.value) || 1.0);
+ return;
+ }
+ // Otherwise, nothing is cached, so we must do a full lookup
+ // with `gContentPrefs.getGlobal()`.
+ let value = 1.0;
+ gContentPrefs.getGlobal(gZoomPropertyName, gLoadContext, {
+ handleResult(pref) {
+ if (pref.value) {
+ value = parseFloat(pref.value);
+ }
+ },
+ handleCompletion(reason) {
+ resolve(value);
+ },
+ handleError(error) {
+ console.error(error);
+ },
+ });
+ });
+ },
+};
+
+function fullZoomLocationChangeObserver(aSubject, aTopic) {
+ // If the tab was the last one in its window and has been dragged to another
+ // window, the original browser's window will be unavailable here. Since that
+ // window is closing, we can just ignore this notification.
+ if (!aSubject.ownerGlobal) {
+ return;
+ }
+ updateZoomUI(aSubject, false);
+}
+Services.obs.addObserver(
+ fullZoomLocationChangeObserver,
+ "browser-fullZoom:location-change"
+);
+
+function onEndSwapDocShells(event) {
+ updateZoomUI(event.originalTarget);
+}
+
+function onZoomChange(event) {
+ let browser;
+ if (event.target.nodeType == event.target.DOCUMENT_NODE) {
+ // In non-e10s, the event is dispatched on the contentDocument
+ // so we need to jump through some hoops to get to the <xul:browser>.
+ let topDoc = event.target.defaultView.top.document;
+ if (!topDoc.documentElement) {
+ // In some events, such as loading synthetic documents, the
+ // documentElement will be null and we won't be able to find
+ // an associated browser.
+ return;
+ }
+ browser = topDoc.ownerGlobal.docShell.chromeEventHandler;
+ } else {
+ browser = event.originalTarget;
+ }
+ updateZoomUI(browser, true);
+}
+
+/**
+ * Updates zoom controls.
+ *
+ * @param {object} aBrowser The browser that the zoomed content resides in.
+ * @param {boolean} aAnimate Should be True for all cases unless the zoom
+ * change is related to tab switching. Optional
+ */
+async function updateZoomUI(aBrowser, aAnimate = false) {
+ let win = aBrowser.ownerGlobal;
+ if (!win.gBrowser || win.gBrowser.selectedBrowser != aBrowser) {
+ return;
+ }
+
+ let appMenuZoomReset = lazy.PanelMultiView.getViewNode(
+ win.document,
+ "appMenu-zoomReset-button2"
+ );
+
+ // Exit early if UI elements aren't present.
+ if (!appMenuZoomReset) {
+ return;
+ }
+
+ let customizableZoomControls = win.document.getElementById("zoom-controls");
+ let customizableZoomReset = win.document.getElementById("zoom-reset-button");
+ let urlbarZoomButton = win.document.getElementById("urlbar-zoom-button");
+ let zoomFactor = Math.round(win.ZoomManager.zoom * 100);
+
+ let defaultZoom = Math.round((await ZoomUI.getGlobalValue()) * 100);
+
+ if (!win.gBrowser || win.gBrowser.selectedBrowser != aBrowser) {
+ // Because the CPS call is async, at this point the selected browser
+ // may have changed. We should re-check whether the browser for which we've
+ // been notified is still the selected browser and bail out if not.
+ // If the selected browser changed (again), we will have been called again
+ // with the "right" browser, and that'll update the zoom level.
+ return;
+ }
+
+ // Hide urlbar zoom button if zoom is at the default zoom level,
+ // if we're viewing an about:blank page with an empty/null
+ // principal, if the PDF viewer is currently open,
+ // or if the customizable control is in the toolbar.
+
+ urlbarZoomButton.hidden =
+ defaultZoom == zoomFactor ||
+ (aBrowser.currentURI.spec == "about:blank" &&
+ (!aBrowser.contentPrincipal ||
+ aBrowser.contentPrincipal.isNullPrincipal)) ||
+ (aBrowser.contentPrincipal &&
+ aBrowser.contentPrincipal.spec == "resource://pdf.js/web/viewer.html") ||
+ (customizableZoomControls &&
+ customizableZoomControls.getAttribute("cui-areatype") == "toolbar");
+
+ let label = win.gNavigatorBundle.getFormattedString("zoom-button.label", [
+ zoomFactor,
+ ]);
+ if (appMenuZoomReset) {
+ appMenuZoomReset.setAttribute("label", label);
+ }
+ if (customizableZoomReset) {
+ customizableZoomReset.setAttribute("label", label);
+ }
+ if (!urlbarZoomButton.hidden) {
+ if (aAnimate && !win.gReduceMotion) {
+ urlbarZoomButton.setAttribute("animate", "true");
+ } else {
+ urlbarZoomButton.removeAttribute("animate");
+ }
+ urlbarZoomButton.setAttribute("label", label);
+ }
+
+ win.FullZoom.updateCommands();
+}
+
+const { CustomizableUI } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizableUI.sys.mjs"
+);
+let customizationListener = {};
+customizationListener.onWidgetAdded =
+ customizationListener.onWidgetRemoved =
+ customizationListener.onWidgetMoved =
+ function (aWidgetId) {
+ if (aWidgetId == "zoom-controls") {
+ for (let window of CustomizableUI.windows) {
+ updateZoomUI(window.gBrowser.selectedBrowser);
+ }
+ }
+ };
+customizationListener.onWidgetReset = customizationListener.onWidgetUndoMove =
+ function (aWidgetNode) {
+ if (aWidgetNode.id == "zoom-controls") {
+ updateZoomUI(aWidgetNode.ownerGlobal.gBrowser.selectedBrowser);
+ }
+ };
+CustomizableUI.addListener(customizationListener);
diff --git a/browser/modules/metrics.yaml b/browser/modules/metrics.yaml
new file mode 100644
index 0000000000..d17c92dfaa
--- /dev/null
+++ b/browser/modules/metrics.yaml
@@ -0,0 +1,155 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# Adding a new metric? We have docs for that!
+# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html
+
+---
+$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0
+$tags:
+ - 'Firefox :: General'
+
+browser.engagement:
+ active_ticks:
+ type: counter
+ description: |
+ The number of five-second intervals ('ticks') the user was considered
+ 'active'.
+
+ 'active' means keyboard or mouse interaction with the application.
+ It doesn't take into account whether or not the window has focus or is in
+ the foreground, only if it is receiving these interaction events.
+
+ Migrated from Telemetry's `browser.engagement.active_ticks`.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1376942 # Telemetry
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1545172 # Telemetry
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1741674
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1755050
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1781578
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1545172#c8
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1781578
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1811152#c5
+ data_sensitivity:
+ - interaction
+ notification_emails:
+ - loines@mozilla.com
+ expires: never
+ send_in_pings:
+ - baseline
+ - metrics
+ no_lint:
+ - BASELINE_PING
+
+ uri_count:
+ type: counter
+ description: |
+ The number of total non-unique http(s) URIs visited, including page
+ reloads, after the session has been restored. URIs on minimized or
+ background tabs may also be counted. Private browsing uris are included.
+
+ Migrated from Telemetry's
+ `browser.engagement.total_uri_count_normal_and_private_mode`.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1535169 # Telemetry
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1741674
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1755050
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1781578
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1535169#c14
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1781578
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1811152#c5
+ data_sensitivity:
+ - interaction
+ notification_emails:
+ - loines@mozilla.com
+ expires: never
+ send_in_pings:
+ - baseline
+ - metrics
+ no_lint:
+ - BASELINE_PING
+
+ profile_count:
+ type: quantity
+ unit: profiles
+ description: |
+ Windows only count of the browser profiles on the current system. This
+ counts profiles that have been used across all Windows user accounts on
+ machine since this probe was added. The value persists across installs.
+ A value of 0 is reported if there is an error determining the correct
+ count. Unset on other platforms.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1813195
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1647422#c8
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - pmcmanis@mozilla.com
+ - rtestard@mozilla.com
+ send_in_pings:
+ - metrics
+ expires: never
+
+ping.centre:
+ send_failures:
+ type: counter
+ description: |
+ The number of PingCentre send failures.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1800079
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1827767
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1800079
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1827767
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - chutten@mozilla.com
+ expires: 120
+
+ send_failures_by_namespace:
+ type: labeled_counter
+ description: |
+ The number of PingCentre send failures,
+ broken down by structured ingestion namespace.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1814922
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1827767
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1814922
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1827767
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - chutten@mozilla.com
+ expires: 120
+ labels: &structured_ingestion_namespaces
+ - activity_stream
+ - messaging_system
+ - contextual_services
+ no_lint:
+ - COMMON_PREFIX
+
+ send_successes_by_namespace:
+ type: labeled_counter
+ description: |
+ The number of PingCentre send successes,
+ broken down by structured ingestion namespace.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1814922
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1827767
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1814922
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1827767
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - chutten@mozilla.com
+ expires: 120
+ labels: *structured_ingestion_namespaces
+ no_lint:
+ - COMMON_PREFIX
diff --git a/browser/modules/moz.build b/browser/modules/moz.build
new file mode 100644
index 0000000000..a8804cb1a7
--- /dev/null
+++ b/browser/modules/moz.build
@@ -0,0 +1,165 @@
+# -*- 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("test/browser/*Telemetry*"):
+ BUG_COMPONENT = ("Toolkit", "Telemetry")
+
+with Files("test/browser/*ContentSearch*"):
+ BUG_COMPONENT = ("Firefox", "Search")
+
+with Files("test/browser/*PermissionUI*"):
+ BUG_COMPONENT = ("Firefox", "Site Permissions")
+
+with Files("test/browser/*SitePermissions*"):
+ BUG_COMPONENT = ("Firefox", "Site Permissions")
+
+with Files("test/browser/browser_UnsubmittedCrashHandler.js"):
+ BUG_COMPONENT = ("Toolkit", "Crash Reporting")
+
+with Files("test/browser/browser_taskbar_preview.js"):
+ BUG_COMPONENT = ("Firefox", "Shell Integration")
+
+with Files("test/browser/browser_urlBar_zoom.js"):
+ BUG_COMPONENT = ("Firefox", "General")
+
+with Files("test/unit/test_E10SUtils_nested_URIs.js"):
+ BUG_COMPONENT = ("Core", "Security: Process Sandboxing")
+
+with Files("test/unit/test_LaterRun.js"):
+ BUG_COMPONENT = ("Firefox", "Tours")
+
+with Files("test/unit/test_SitePermissions.js"):
+ BUG_COMPONENT = ("Firefox", "Site Permissions")
+
+with Files("AboutNewTab.jsm"):
+ BUG_COMPONENT = ("Firefox", "New Tab Page")
+
+with Files("AsyncTabSwitcher.jsm"):
+ BUG_COMPONENT = ("Firefox", "Tabbed Browser")
+
+with Files("NewTabPagePreloading.jsm"):
+ BUG_COMPONENT = ("Firefox", "Tabbed Browser")
+
+with Files("BrowserWindowTracker.jsm"):
+ BUG_COMPONENT = ("Firefox", "General")
+
+with Files("*Telemetry.jsm"):
+ BUG_COMPONENT = ("Toolkit", "Telemetry")
+
+with Files("ContentCrashHandlers.jsm"):
+ BUG_COMPONENT = ("Toolkit", "Crash Reporting")
+
+with Files("EveryWindow.jsm"):
+ BUG_COMPONENT = ("Firefox", "General")
+
+with Files("ExtensionsUI.jsm"):
+ BUG_COMPONENT = ("WebExtensions", "General")
+
+with Files("FeatureCallout.sys.mjs"):
+ BUG_COMPONENT = ("Firefox", "Messaging System")
+
+with Files("LaterRun.jsm"):
+ BUG_COMPONENT = ("Firefox", "Tours")
+
+with Files("LiveBookmarkMigrator.jsm"):
+ BUG_COMPONENT = ("Firefox", "General")
+
+with Files("OpenInTabsUtils.jsm"):
+ BUG_COMPONENT = ("Firefox", "Tabbed Browser")
+
+with Files("PartnerLinkAttribution.sys.mjs"):
+ BUG_COMPONENT = ("Firefox", "Search")
+
+with Files("PermissionUI.sys.mjs"):
+ BUG_COMPONENT = ("Firefox", "Site Permissions")
+
+with Files("ProcessHangMonitor.jsm"):
+ BUG_COMPONENT = ("Core", "DOM: Content Processes")
+
+with Files("Sanitizer.sys.mjs"):
+ BUG_COMPONENT = ("Toolkit", "Data Sanitization")
+
+with Files("SelectionChangedMenulist.jsm"):
+ BUG_COMPONENT = ("Firefox", "Settings UI")
+
+with Files("SiteDataManager.jsm"):
+ BUG_COMPONENT = ("Firefox", "Settings UI")
+
+with Files("SitePermissions.sys.mjs"):
+ BUG_COMPONENT = ("Firefox", "Site Permissions")
+
+with Files("TabsList.jsm"):
+ BUG_COMPONENT = ("Firefox", "Tabbed Browser")
+
+with Files("TransientPrefs.jsm"):
+ BUG_COMPONENT = ("Firefox", "Settings UI")
+
+with Files("WindowsJumpLists.jsm"):
+ BUG_COMPONENT = ("Firefox", "Shell Integration")
+ SCHEDULES.exclusive = ["windows"]
+
+with Files("WindowsPreviewPerTab.jsm"):
+ BUG_COMPONENT = ("Core", "Widget: Win32")
+ SCHEDULES.exclusive = ["windows"]
+
+with Files("webrtcUI.jsm"):
+ BUG_COMPONENT = ("Firefox", "Site Permissions")
+
+with Files("ZoomUI.jsm"):
+ BUG_COMPONENT = ("Firefox", "Toolbars and Customization")
+
+BROWSER_CHROME_MANIFESTS += [
+ "test/browser/browser.ini",
+ "test/browser/formValidation/browser.ini",
+]
+XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.ini"]
+
+EXTRA_JS_MODULES += [
+ "AboutNewTab.jsm",
+ "AsyncTabSwitcher.jsm",
+ "BrowserUIUtils.jsm",
+ "BrowserUsageTelemetry.jsm",
+ "BrowserWindowTracker.jsm",
+ "ContentCrashHandlers.jsm",
+ "Discovery.jsm",
+ "EveryWindow.jsm",
+ "ExtensionsUI.jsm",
+ "FaviconLoader.jsm",
+ "FeatureCallout.sys.mjs",
+ "HomePage.jsm",
+ "LaterRun.jsm",
+ "NewTabPagePreloading.jsm",
+ "OpenInTabsUtils.jsm",
+ "PageActions.jsm",
+ "PartnerLinkAttribution.sys.mjs",
+ "PermissionUI.sys.mjs",
+ "PingCentre.jsm",
+ "ProcessHangMonitor.jsm",
+ "Sanitizer.sys.mjs",
+ "SelectionChangedMenulist.jsm",
+ "SiteDataManager.jsm",
+ "SitePermissions.sys.mjs",
+ "TabsList.jsm",
+ "TabUnloader.jsm",
+ "TransientPrefs.jsm",
+ "URILoadingHelper.sys.mjs",
+ "webrtcUI.jsm",
+ "ZoomUI.jsm",
+]
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "windows":
+ EXTRA_JS_MODULES += [
+ "WindowsJumpLists.jsm",
+ "WindowsPreviewPerTab.jsm",
+ ]
+
+ EXTRA_JS_MODULES.backgroundtasks += [
+ "BackgroundTask_install.sys.mjs",
+ "BackgroundTask_uninstall.sys.mjs",
+ ]
diff --git a/browser/modules/test/browser/blank_iframe.html b/browser/modules/test/browser/blank_iframe.html
new file mode 100644
index 0000000000..88cd26088f
--- /dev/null
+++ b/browser/modules/test/browser/blank_iframe.html
@@ -0,0 +1,7 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8"/>
+ </head>
+ <body><iframe></iframe></body>
+</html>
diff --git a/browser/modules/test/browser/browser.ini b/browser/modules/test/browser/browser.ini
new file mode 100644
index 0000000000..280d8bcb0d
--- /dev/null
+++ b/browser/modules/test/browser/browser.ini
@@ -0,0 +1,73 @@
+[DEFAULT]
+support-files =
+ head.js
+prefs =
+ telemetry.number_of_site_origin.min_interval=0
+
+[browser_BrowserWindowTracker.js]
+skip-if = os == "win" && os_version == "6.1" # bug 1715860
+[browser_ContentSearch.js]
+support-files =
+ contentSearchBadImage.xml
+ contentSearchSuggestions.sjs
+ contentSearchSuggestions.xml
+ !/browser/components/search/test/browser/testEngine.xml
+ !/browser/components/search/test/browser/testEngine_diacritics.xml
+ testEngine_chromeicon.xml
+skip-if = (debug && os == "linux" && bits == 64 && os_version == "18.04") # Bug 1649755
+[browser_EveryWindow.js]
+[browser_HomePage_add_button.js]
+[browser_PageActions.js]
+[browser_PageActions_contextMenus.js]
+[browser_PageActions_newWindow.js]
+[browser_PartnerLinkAttribution.js]
+support-files =
+ search-engines/basic/manifest.json
+ search-engines/simple/manifest.json
+ search-engines/engines.json
+[browser_PermissionUI.js]
+[browser_PermissionUI_prompts.js]
+[browser_ProcessHangNotifications.js]
+[browser_SitePermissions.js]
+[browser_SitePermissions_combinations.js]
+[browser_SitePermissions_expiry.js]
+[browser_SitePermissions_tab_urls.js]
+https_first_disabled = true
+[browser_TabUnloader.js]
+support-files =
+ file_webrtc.html
+ ../../../base/content/test/tabs/dummy_page.html
+ ../../../base/content/test/tabs/file_mediaPlayback.html
+ ../../../base/content/test/general/audio.ogg
+[browser_Telemetry_numberOfSiteOrigins.js]
+support-files =
+ contain_iframe.html
+[browser_Telemetry_numberOfSiteOriginsPerDocument.js]
+support-files =
+ contain_iframe.html
+ blank_iframe.html
+[browser_UnsubmittedCrashHandler.js]
+run-if = crashreporter
+[browser_UsageTelemetry.js]
+https_first_disabled = true
+[browser_UsageTelemetry_content_aboutRestartRequired.js]
+[browser_UsageTelemetry_domains.js]
+https_first_disabled = true
+[browser_UsageTelemetry_interaction.js]
+https_first_disabled = true
+[browser_UsageTelemetry_private_and_restore.js]
+https_first_disabled = true
+skip-if = verify && debug
+[browser_UsageTelemetry_toolbars.js]
+[browser_UsageTelemetry_uniqueOriginsVisitedInPast24Hours.js]
+https_first_disabled = true
+[browser_preloading_tab_moving.js]
+skip-if =
+ os == 'linux' && tsan # Bug 1720203
+[browser_taskbar_preview.js]
+skip-if = os != "win" || (os == "win" && bits == 64) # bug 1456807
+[browser_urlBar_zoom.js]
+skip-if =
+ (os == "mac") || (os == "linux" && bits == 64 && os_version == "18.04") || (os == "win" && os_version == '10.0' && bits == 64) # Bug 1528429, Bug 1619835
+ os == 'win' && bits == 32 && debug # Bug 1619835
+
diff --git a/browser/modules/test/browser/browser_BrowserWindowTracker.js b/browser/modules/test/browser/browser_BrowserWindowTracker.js
new file mode 100644
index 0000000000..ea6f75c0e3
--- /dev/null
+++ b/browser/modules/test/browser/browser_BrowserWindowTracker.js
@@ -0,0 +1,234 @@
+"use strict";
+
+const TEST_WINDOW = window;
+
+function windowActivated(win) {
+ if (Services.ww.activeWindow == win) {
+ return Promise.resolve();
+ }
+ return BrowserTestUtils.waitForEvent(win, "activate");
+}
+
+async function withOpenWindows(amount, cont) {
+ let windows = [];
+ for (let i = 0; i < amount; ++i) {
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await windowActivated(win);
+ windows.push(win);
+ }
+ await cont(windows);
+ await Promise.all(
+ windows.map(window => BrowserTestUtils.closeWindow(window))
+ );
+}
+
+add_task(async function test_getTopWindow() {
+ await withOpenWindows(5, async function (windows) {
+ // Without options passed in.
+ let window = BrowserWindowTracker.getTopWindow();
+ let expectedMostRecentIndex = windows.length - 1;
+ Assert.equal(
+ window,
+ windows[expectedMostRecentIndex],
+ "Last opened window should be the most recent one."
+ );
+
+ // Mess with the focused window things a bit.
+ for (let idx of [3, 1]) {
+ let promise = BrowserTestUtils.waitForEvent(windows[idx], "activate");
+ Services.focus.focusedWindow = windows[idx];
+ await promise;
+ window = BrowserWindowTracker.getTopWindow();
+ Assert.equal(
+ window,
+ windows[idx],
+ "Lastly focused window should be the most recent one."
+ );
+ // For this test it's useful to keep the array of created windows in order.
+ windows.splice(idx, 1);
+ windows.push(window);
+ }
+ // Update the pointer to the most recent opened window.
+ expectedMostRecentIndex = windows.length - 1;
+
+ // With 'private' option.
+ window = BrowserWindowTracker.getTopWindow({ private: true });
+ Assert.equal(window, null, "No private windows opened yet.");
+ window = BrowserWindowTracker.getTopWindow({ private: 1 });
+ Assert.equal(window, null, "No private windows opened yet.");
+ windows.push(
+ await BrowserTestUtils.openNewBrowserWindow({ private: true })
+ );
+ ++expectedMostRecentIndex;
+ window = BrowserWindowTracker.getTopWindow({ private: true });
+ Assert.equal(
+ window,
+ windows[expectedMostRecentIndex],
+ "Private window available."
+ );
+ window = BrowserWindowTracker.getTopWindow({ private: 1 });
+ Assert.equal(
+ window,
+ windows[expectedMostRecentIndex],
+ "Private window available."
+ );
+ // Private window checks seems to mysteriously fail on Linux in this test.
+ if (AppConstants.platform != "linux") {
+ window = BrowserWindowTracker.getTopWindow({ private: false });
+ Assert.equal(
+ window,
+ windows[expectedMostRecentIndex - 1],
+ "Private window available, but should not be returned."
+ );
+ }
+
+ // With 'allowPopups' option.
+ window = BrowserWindowTracker.getTopWindow({ allowPopups: true });
+ Assert.equal(
+ window,
+ windows[expectedMostRecentIndex],
+ "Window focused before the private window should be the most recent one."
+ );
+ window = BrowserWindowTracker.getTopWindow({ allowPopups: false });
+ Assert.equal(
+ window,
+ windows[expectedMostRecentIndex],
+ "Window focused before the private window should be the most recent one."
+ );
+ let popupWindowPromise = BrowserTestUtils.waitForNewWindow();
+ SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
+ let features =
+ "location=no, personalbar=no, toolbar=no, scrollbars=no, menubar=no, status=no";
+ content.window.open("about:blank", "_blank", features);
+ });
+ let popupWindow = await popupWindowPromise;
+ await windowActivated(popupWindow);
+ window = BrowserWindowTracker.getTopWindow({ allowPopups: true });
+ Assert.equal(
+ window,
+ popupWindow,
+ "The popup window should be the most recent one, when requested."
+ );
+ window = BrowserWindowTracker.getTopWindow({ allowPopups: false });
+ Assert.equal(
+ window,
+ windows[expectedMostRecentIndex],
+ "Window focused before the popup window should be the most recent one."
+ );
+ popupWindow.close();
+ });
+});
+
+add_task(async function test_orderedWindows() {
+ await withOpenWindows(10, async function (windows) {
+ Assert.equal(
+ BrowserWindowTracker.windowCount,
+ 11,
+ "Number of tracked windows, including the test window"
+ );
+ let ordered = BrowserWindowTracker.orderedWindows.filter(
+ w => w != TEST_WINDOW
+ );
+ Assert.deepEqual(
+ [9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
+ ordered.map(w => windows.indexOf(w)),
+ "Order of opened windows should be as opened."
+ );
+
+ // Mess with the focused window things a bit.
+ for (let idx of [4, 6, 1]) {
+ let promise = BrowserTestUtils.waitForEvent(windows[idx], "activate");
+ Services.focus.focusedWindow = windows[idx];
+ await promise;
+ }
+
+ let ordered2 = BrowserWindowTracker.orderedWindows.filter(
+ w => w != TEST_WINDOW
+ );
+ // After the shuffle, we expect window '1' to be the top-most window, because
+ // it was the last one we called focus on. Then '6', the window we focused
+ // before-last, followed by '4'. The order of the other windows remains
+ // unchanged.
+ let expected = [1, 6, 4, 9, 8, 7, 5, 3, 2, 0];
+ Assert.deepEqual(
+ expected,
+ ordered2.map(w => windows.indexOf(w)),
+ "After shuffle of focused windows, the order should've changed."
+ );
+ });
+});
+
+add_task(async function test_pendingWindows() {
+ Assert.equal(
+ BrowserWindowTracker.windowCount,
+ 1,
+ "Number of tracked windows, including the test window"
+ );
+
+ let pending = BrowserWindowTracker.getPendingWindow();
+ Assert.equal(pending, null, "Should be no pending window");
+
+ let expectedWin = BrowserWindowTracker.openWindow();
+ pending = BrowserWindowTracker.getPendingWindow();
+ Assert.ok(pending, "Should be a pending window now.");
+ Assert.ok(
+ !BrowserWindowTracker.getPendingWindow({ private: true }),
+ "Should not be a pending private window"
+ );
+ Assert.equal(
+ pending,
+ BrowserWindowTracker.getPendingWindow({ private: false }),
+ "Should be the same non-private window pending"
+ );
+
+ let foundWin = await pending;
+ Assert.equal(foundWin, expectedWin, "Should have found the right window");
+ Assert.ok(
+ !BrowserWindowTracker.getPendingWindow(),
+ "Should be no pending window now."
+ );
+
+ await BrowserTestUtils.closeWindow(foundWin);
+
+ expectedWin = BrowserWindowTracker.openWindow({ private: true });
+ pending = BrowserWindowTracker.getPendingWindow();
+ Assert.ok(pending, "Should be a pending window now.");
+ Assert.ok(
+ !BrowserWindowTracker.getPendingWindow({ private: false }),
+ "Should not be a pending non-private window"
+ );
+ Assert.equal(
+ pending,
+ BrowserWindowTracker.getPendingWindow({ private: true }),
+ "Should be the same private window pending"
+ );
+
+ foundWin = await pending;
+ Assert.equal(foundWin, expectedWin, "Should have found the right window");
+ Assert.ok(
+ !BrowserWindowTracker.getPendingWindow(),
+ "Should be no pending window now."
+ );
+
+ await BrowserTestUtils.closeWindow(foundWin);
+
+ expectedWin = Services.ww.openWindow(
+ null,
+ AppConstants.BROWSER_CHROME_URL,
+ "_blank",
+ "chrome,dialog=no,all",
+ null
+ );
+ BrowserWindowTracker.registerOpeningWindow(expectedWin, false);
+ pending = BrowserWindowTracker.getPendingWindow();
+ Assert.ok(pending, "Should be a pending window now.");
+
+ foundWin = await pending;
+ Assert.equal(foundWin, expectedWin, "Should have found the right window");
+ Assert.ok(
+ !BrowserWindowTracker.getPendingWindow(),
+ "Should be no pending window now."
+ );
+
+ await BrowserTestUtils.closeWindow(foundWin);
+});
diff --git a/browser/modules/test/browser/browser_ContentSearch.js b/browser/modules/test/browser/browser_ContentSearch.js
new file mode 100644
index 0000000000..0e489a54a1
--- /dev/null
+++ b/browser/modules/test/browser/browser_ContentSearch.js
@@ -0,0 +1,519 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ChromeUtils.defineESModuleGetters(this, {
+ SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs",
+});
+
+SearchTestUtils.init(this);
+
+const SERVICE_EVENT_TYPE = "ContentSearchService";
+const CLIENT_EVENT_TYPE = "ContentSearchClient";
+
+var arrayBufferIconTested = false;
+var plainURIIconTested = false;
+
+function sendEventToContent(browser, data) {
+ return SpecialPowers.spawn(
+ browser,
+ [CLIENT_EVENT_TYPE, data],
+ (eventName, eventData) => {
+ content.dispatchEvent(
+ new content.CustomEvent(eventName, {
+ detail: Cu.cloneInto(eventData, content),
+ })
+ );
+ }
+ );
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.newtab.preload", false],
+ ["browser.search.separatePrivateDefault.ui.enabled", true],
+ ["browser.search.separatePrivateDefault", true],
+ ],
+ });
+
+ await SearchTestUtils.promiseNewSearchEngine({
+ url: "chrome://mochitests/content/browser/browser/components/search/test/browser/testEngine.xml",
+ setAsDefault: true,
+ });
+
+ await SearchTestUtils.promiseNewSearchEngine({
+ url: "chrome://mochitests/content/browser/browser/components/search/test/browser/testEngine_diacritics.xml",
+ setAsDefaultPrivate: true,
+ });
+
+ await SearchTestUtils.promiseNewSearchEngine({
+ url: getRootDirectory(gTestPath) + "testEngine_chromeicon.xml",
+ });
+});
+
+add_task(async function GetState() {
+ let { browser } = await addTab();
+ let statePromise = await waitForTestMsg(browser, "State");
+ sendEventToContent(browser, {
+ type: "GetState",
+ });
+ let msg = await statePromise.donePromise;
+ checkMsg(msg, {
+ type: "State",
+ data: await currentStateObj(false),
+ });
+
+ ok(arrayBufferIconTested, "ArrayBuffer path for the iconData was tested");
+ ok(plainURIIconTested, "Plain URI path for the iconData was tested");
+});
+
+add_task(async function SetDefaultEngine() {
+ let { browser } = await addTab();
+ let newDefaultEngine = await Services.search.getEngineByName("FooChromeIcon");
+ let oldDefaultEngine = await Services.search.getDefault();
+ let searchPromise = await waitForTestMsg(browser, "CurrentEngine");
+ sendEventToContent(browser, {
+ type: "SetCurrentEngine",
+ data: newDefaultEngine.name,
+ });
+ let deferredPromise = new Promise(resolve => {
+ Services.obs.addObserver(function obs(subj, topic, data) {
+ info("Test observed " + data);
+ if (data == "engine-default") {
+ ok(true, "Test observed engine-default");
+ Services.obs.removeObserver(obs, "browser-search-engine-modified");
+ resolve();
+ }
+ }, "browser-search-engine-modified");
+ });
+ info("Waiting for test to observe engine-default...");
+ await deferredPromise;
+ let msg = await searchPromise.donePromise;
+ checkMsg(msg, {
+ type: "CurrentEngine",
+ data: await constructEngineObj(newDefaultEngine),
+ });
+
+ let enginePromise = await waitForTestMsg(browser, "CurrentEngine");
+ await Services.search.setDefault(
+ oldDefaultEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ msg = await enginePromise.donePromise;
+ checkMsg(msg, {
+ type: "CurrentEngine",
+ data: await constructEngineObj(oldDefaultEngine),
+ });
+});
+
+// ContentSearchChild doesn't support setting the private engine at this time
+// as it doesn't need to, so we just test updating the default here.
+add_task(async function setDefaultEnginePrivate() {
+ const engine = await Services.search.getEngineByName("FooChromeIcon");
+ const { browser } = await addTab();
+ let enginePromise = await waitForTestMsg(browser, "CurrentPrivateEngine");
+ await Services.search.setDefaultPrivate(
+ engine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ let msg = await enginePromise.donePromise;
+ checkMsg(msg, {
+ type: "CurrentPrivateEngine",
+ data: await constructEngineObj(engine),
+ });
+});
+
+add_task(async function modifyEngine() {
+ let { browser } = await addTab();
+ let engine = await Services.search.getDefault();
+ let oldAlias = engine.alias;
+ let statePromise = await waitForTestMsg(browser, "CurrentState");
+ engine.alias = "ContentSearchTest";
+ let msg = await statePromise.donePromise;
+ checkMsg(msg, {
+ type: "CurrentState",
+ data: await currentStateObj(),
+ });
+ statePromise = await waitForTestMsg(browser, "CurrentState");
+ engine.alias = oldAlias;
+ msg = await statePromise.donePromise;
+ checkMsg(msg, {
+ type: "CurrentState",
+ data: await currentStateObj(),
+ });
+});
+
+add_task(async function test_hideEngine() {
+ let { browser } = await addTab();
+ let engine = await Services.search.getEngineByName("Foo \u2661");
+ let statePromise = await waitForTestMsg(browser, "CurrentState");
+ Services.prefs.setStringPref("browser.search.hiddenOneOffs", engine.name);
+ let msg = await statePromise.donePromise;
+ checkMsg(msg, {
+ type: "CurrentState",
+ data: await currentStateObj(undefined, "Foo \u2661"),
+ });
+ statePromise = await waitForTestMsg(browser, "CurrentState");
+ Services.prefs.clearUserPref("browser.search.hiddenOneOffs");
+ msg = await statePromise.donePromise;
+ checkMsg(msg, {
+ type: "CurrentState",
+ data: await currentStateObj(),
+ });
+});
+
+add_task(async function search() {
+ let { browser } = await addTab();
+ let engine = await Services.search.getDefault();
+ let data = {
+ engineName: engine.name,
+ searchString: "ContentSearchTest",
+ healthReportKey: "ContentSearchTest",
+ searchPurpose: "ContentSearchTest",
+ };
+ let submissionURL = engine.getSubmission(data.searchString, "", data.whence)
+ .uri.spec;
+
+ await performSearch(browser, data, submissionURL);
+});
+
+add_task(async function searchInBackgroundTab() {
+ // This test is like search(), but it opens a new tab after starting a search
+ // in another. In other words, it performs a search in a background tab. The
+ // search page should be loaded in the same tab that performed the search, in
+ // the background tab.
+ let { browser } = await addTab();
+ let engine = await Services.search.getDefault();
+ let data = {
+ engineName: engine.name,
+ searchString: "ContentSearchTest",
+ healthReportKey: "ContentSearchTest",
+ searchPurpose: "ContentSearchTest",
+ };
+ let submissionURL = engine.getSubmission(data.searchString, "", data.whence)
+ .uri.spec;
+
+ let searchPromise = performSearch(browser, data, submissionURL);
+ let newTab = BrowserTestUtils.addTab(gBrowser);
+ gBrowser.selectedTab = newTab;
+ registerCleanupFunction(() => gBrowser.removeTab(newTab));
+
+ await searchPromise;
+});
+
+add_task(async function badImage() {
+ let { browser } = await addTab();
+ // If the bad image URI caused an exception to be thrown within ContentSearch,
+ // then we'll hang waiting for the CurrentState responses triggered by the new
+ // engine. That's what we're testing, and obviously it shouldn't happen.
+ let vals = await waitForNewEngine(browser, "contentSearchBadImage.xml");
+ let engine = vals[0];
+ let finalCurrentStateMsg = vals[vals.length - 1];
+ let expectedCurrentState = await currentStateObj();
+ let expectedEngine = expectedCurrentState.engines.find(
+ e => e.name == engine.name
+ );
+ ok(!!expectedEngine, "Sanity check: engine should be in expected state");
+ ok(
+ expectedEngine.iconData ===
+ "chrome://browser/skin/search-engine-placeholder.png",
+ "Sanity check: icon of engine in expected state should be the placeholder: " +
+ expectedEngine.iconData
+ );
+ checkMsg(finalCurrentStateMsg, {
+ type: "CurrentState",
+ data: expectedCurrentState,
+ });
+ // Removing the engine triggers a final CurrentState message. Wait for it so
+ // it doesn't trip up subsequent tests.
+ let statePromise = await waitForTestMsg(browser, "CurrentState");
+ await Services.search.removeEngine(engine);
+ await statePromise.donePromise;
+});
+
+add_task(
+ async function GetSuggestions_AddFormHistoryEntry_RemoveFormHistoryEntry() {
+ let { browser } = await addTab();
+
+ // Add the test engine that provides suggestions.
+ let vals = await waitForNewEngine(browser, "contentSearchSuggestions.xml");
+ let engine = vals[0];
+
+ let searchStr = "browser_ContentSearch.js-suggestions-";
+
+ // Add a form history suggestion and wait for Satchel to notify about it.
+ sendEventToContent(browser, {
+ type: "AddFormHistoryEntry",
+ data: {
+ value: searchStr + "form",
+ engineName: engine.name,
+ },
+ });
+ await new Promise(resolve => {
+ Services.obs.addObserver(function onAdd(subj, topic, data) {
+ if (data == "formhistory-add") {
+ Services.obs.removeObserver(onAdd, "satchel-storage-changed");
+ executeSoon(resolve);
+ }
+ }, "satchel-storage-changed");
+ });
+
+ // Send GetSuggestions using the test engine. Its suggestions should appear
+ // in the remote suggestions in the Suggestions response below.
+ let suggestionsPromise = await waitForTestMsg(browser, "Suggestions");
+ sendEventToContent(browser, {
+ type: "GetSuggestions",
+ data: {
+ engineName: engine.name,
+ searchString: searchStr,
+ },
+ });
+
+ // Check the Suggestions response.
+ let msg = await suggestionsPromise.donePromise;
+ checkMsg(msg, {
+ type: "Suggestions",
+ data: {
+ engineName: engine.name,
+ searchString: searchStr,
+ formHistory: [searchStr + "form"],
+ remote: [searchStr + "foo", searchStr + "bar"],
+ },
+ });
+
+ // Delete the form history suggestion and wait for Satchel to notify about it.
+ sendEventToContent(browser, {
+ type: "RemoveFormHistoryEntry",
+ data: searchStr + "form",
+ });
+
+ await new Promise(resolve => {
+ Services.obs.addObserver(function onRemove(subj, topic, data) {
+ if (data == "formhistory-remove") {
+ Services.obs.removeObserver(onRemove, "satchel-storage-changed");
+ executeSoon(resolve);
+ }
+ }, "satchel-storage-changed");
+ });
+
+ // Send GetSuggestions again.
+ suggestionsPromise = await waitForTestMsg(browser, "Suggestions");
+ sendEventToContent(browser, {
+ type: "GetSuggestions",
+ data: {
+ engineName: engine.name,
+ searchString: searchStr,
+ },
+ });
+
+ // The formHistory suggestions in the Suggestions response should be empty.
+ msg = await suggestionsPromise.donePromise;
+ checkMsg(msg, {
+ type: "Suggestions",
+ data: {
+ engineName: engine.name,
+ searchString: searchStr,
+ formHistory: [],
+ remote: [searchStr + "foo", searchStr + "bar"],
+ },
+ });
+
+ // Finally, clean up by removing the test engine.
+ let statePromise = await waitForTestMsg(browser, "CurrentState");
+ await Services.search.removeEngine(engine);
+ await statePromise.donePromise;
+ }
+);
+
+async function performSearch(browser, data, expectedURL) {
+ let stoppedPromise = BrowserTestUtils.browserStopped(browser, expectedURL);
+ sendEventToContent(browser, {
+ type: "Search",
+ data,
+ expectedURL,
+ });
+
+ await stoppedPromise;
+ // BrowserTestUtils.browserStopped should ensure this, but let's
+ // be absolutely sure.
+ Assert.equal(
+ browser.currentURI.spec,
+ expectedURL,
+ "Correct search page loaded"
+ );
+}
+
+function buffersEqual(actualArrayBuffer, expectedArrayBuffer) {
+ let expectedView = new Int8Array(expectedArrayBuffer);
+ let actualView = new Int8Array(actualArrayBuffer);
+ for (let i = 0; i < expectedView.length; i++) {
+ if (actualView[i] != expectedView[i]) {
+ return false;
+ }
+ }
+ return true;
+}
+
+function arrayBufferEqual(actualArrayBuffer, expectedArrayBuffer) {
+ ok(actualArrayBuffer instanceof ArrayBuffer, "Actual value is ArrayBuffer.");
+ ok(
+ expectedArrayBuffer instanceof ArrayBuffer,
+ "Expected value is ArrayBuffer."
+ );
+ Assert.equal(
+ actualArrayBuffer.byteLength,
+ expectedArrayBuffer.byteLength,
+ "Array buffers have the same length."
+ );
+ ok(
+ buffersEqual(actualArrayBuffer, expectedArrayBuffer),
+ "Buffers are equal."
+ );
+}
+
+function checkArrayBuffers(actual, expected) {
+ if (actual instanceof ArrayBuffer) {
+ arrayBufferEqual(actual, expected);
+ }
+ if (typeof actual == "object") {
+ for (let i in actual) {
+ checkArrayBuffers(actual[i], expected[i]);
+ }
+ }
+}
+
+function checkMsg(actualMsg, expectedMsgData) {
+ SimpleTest.isDeeply(actualMsg, expectedMsgData, "Checking message");
+
+ // Engines contain ArrayBuffers which we have to compare byte by byte and
+ // not as Objects (like SimpleTest.isDeeply does).
+ checkArrayBuffers(actualMsg, expectedMsgData);
+}
+
+async function waitForTestMsg(browser, type, count = 1) {
+ await SpecialPowers.spawn(
+ browser,
+ [SERVICE_EVENT_TYPE, type, count],
+ (childEvent, childType, childCount) => {
+ content.eventDetails = [];
+ function listener(event) {
+ if (event.detail.type != childType) {
+ return;
+ }
+
+ content.eventDetails.push(event.detail);
+
+ if (--childCount > 0) {
+ return;
+ }
+
+ content.removeEventListener(childEvent, listener, true);
+ }
+ content.addEventListener(childEvent, listener, true);
+ }
+ );
+
+ let donePromise = SpecialPowers.spawn(
+ browser,
+ [type, count],
+ async (childType, childCount) => {
+ await ContentTaskUtils.waitForCondition(() => {
+ return content.eventDetails.length == childCount;
+ }, "Expected " + childType + " event");
+
+ return childCount > 1 ? content.eventDetails : content.eventDetails[0];
+ }
+ );
+
+ return { donePromise };
+}
+
+async function waitForNewEngine(browser, basename) {
+ info("Waiting for engine to be added: " + basename);
+
+ // Wait for the search events triggered by adding the new engine.
+ // There are two events triggerd by engine-added and engine-loaded
+ let statePromise = await waitForTestMsg(browser, "CurrentState", 2);
+
+ // Wait for addOpenSearchEngine().
+ let engine = await SearchTestUtils.promiseNewSearchEngine({
+ url: getRootDirectory(gTestPath) + basename,
+ });
+ let results = await statePromise.donePromise;
+ return [engine, ...results];
+}
+
+async function addTab() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:newtab"
+ );
+ registerCleanupFunction(() => gBrowser.removeTab(tab));
+
+ return { browser: tab.linkedBrowser };
+}
+
+var currentStateObj = async function (isPrivateWindowValue, hiddenEngine = "") {
+ let state = {
+ engines: [],
+ currentEngine: await constructEngineObj(await Services.search.getDefault()),
+ currentPrivateEngine: await constructEngineObj(
+ await Services.search.getDefaultPrivate()
+ ),
+ };
+ for (let engine of await Services.search.getVisibleEngines()) {
+ let uri = engine.getIconURLBySize(16, 16);
+ state.engines.push({
+ name: engine.name,
+ iconData: await iconDataFromURI(uri),
+ hidden: engine.name == hiddenEngine,
+ isAppProvided: engine.isAppProvided,
+ });
+ }
+ if (typeof isPrivateWindowValue == "boolean") {
+ state.isInPrivateBrowsingMode = isPrivateWindowValue;
+ state.isAboutPrivateBrowsing = isPrivateWindowValue;
+ }
+ return state;
+};
+
+async function constructEngineObj(engine) {
+ let uriFavicon = engine.getIconURLBySize(16, 16);
+ return {
+ name: engine.name,
+ iconData: await iconDataFromURI(uriFavicon),
+ isAppProvided: engine.isAppProvided,
+ };
+}
+
+function iconDataFromURI(uri) {
+ if (!uri) {
+ return Promise.resolve(
+ "chrome://browser/skin/search-engine-placeholder.png"
+ );
+ }
+
+ if (!uri.startsWith("data:")) {
+ plainURIIconTested = true;
+ return Promise.resolve(uri);
+ }
+
+ return new Promise(resolve => {
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", uri, true);
+ xhr.responseType = "arraybuffer";
+ xhr.onerror = () => {
+ resolve("chrome://browser/skin/search-engine-placeholder.png");
+ };
+ xhr.onload = () => {
+ arrayBufferIconTested = true;
+ resolve(xhr.response);
+ };
+ try {
+ xhr.send();
+ } catch (err) {
+ resolve("chrome://browser/skin/search-engine-placeholder.png");
+ }
+ });
+}
diff --git a/browser/modules/test/browser/browser_EveryWindow.js b/browser/modules/test/browser/browser_EveryWindow.js
new file mode 100644
index 0000000000..7cadfaadad
--- /dev/null
+++ b/browser/modules/test/browser/browser_EveryWindow.js
@@ -0,0 +1,161 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+
+const { EveryWindow } = ChromeUtils.import(
+ "resource:///modules/EveryWindow.jsm"
+);
+
+async function windowInited(aId, aWin) {
+ // TestUtils.topicObserved returns [subject, data]. We return the
+ // subject, which in this case is the window.
+ return (
+ await TestUtils.topicObserved(`${aId}:init`, win => {
+ return aWin ? win == aWin : true;
+ })
+ )[0];
+}
+
+function windowUninited(aId, aWin, aClosing) {
+ return TestUtils.topicObserved(`${aId}:uninit`, (win, closing) => {
+ if (aWin && aWin != win) {
+ return false;
+ }
+ if (!aWin) {
+ return true;
+ }
+ if (!!aClosing != !!closing) {
+ return false;
+ }
+ return true;
+ });
+}
+
+function registerEWCallback(id) {
+ EveryWindow.registerCallback(
+ id,
+ win => {
+ Services.obs.notifyObservers(win, `${id}:init`);
+ },
+ (win, closing) => {
+ Services.obs.notifyObservers(win, `${id}:uninit`, closing);
+ }
+ );
+}
+
+function unregisterEWCallback(id, aCallUninit) {
+ EveryWindow.unregisterCallback(id, aCallUninit);
+}
+
+add_task(async function test_stuff() {
+ let win2 = await BrowserTestUtils.openNewBrowserWindow();
+ let win3 = await BrowserTestUtils.openNewBrowserWindow();
+
+ let callbackId1 = "EveryWindow:test:1";
+ let callbackId2 = "EveryWindow:test:2";
+
+ let initPromise = Promise.all([
+ windowInited(callbackId1, window),
+ windowInited(callbackId1, win2),
+ windowInited(callbackId1, win3),
+ windowInited(callbackId2, window),
+ windowInited(callbackId2, win2),
+ windowInited(callbackId2, win3),
+ ]);
+
+ registerEWCallback(callbackId1);
+ registerEWCallback(callbackId2);
+
+ await initPromise;
+ ok(true, "Init called for all existing windows for all registered consumers");
+
+ let uninitPromise = Promise.all([
+ windowUninited(callbackId1, window, false),
+ windowUninited(callbackId1, win2, false),
+ windowUninited(callbackId1, win3, false),
+ windowUninited(callbackId2, window, false),
+ windowUninited(callbackId2, win2, false),
+ windowUninited(callbackId2, win3, false),
+ ]);
+
+ unregisterEWCallback(callbackId1);
+ unregisterEWCallback(callbackId2);
+ await uninitPromise;
+ ok(true, "Uninit called for all existing windows");
+
+ initPromise = Promise.all([
+ windowInited(callbackId1, window),
+ windowInited(callbackId1, win2),
+ windowInited(callbackId1, win3),
+ windowInited(callbackId2, window),
+ windowInited(callbackId2, win2),
+ windowInited(callbackId2, win3),
+ ]);
+
+ registerEWCallback(callbackId1);
+ registerEWCallback(callbackId2);
+
+ await initPromise;
+ ok(true, "Init called for all existing windows for all registered consumers");
+
+ uninitPromise = Promise.all([
+ windowUninited(callbackId1, win2, true),
+ windowUninited(callbackId2, win2, true),
+ ]);
+ await BrowserTestUtils.closeWindow(win2);
+ await uninitPromise;
+ ok(
+ true,
+ "Uninit called with closing=true for win2 for all registered consumers"
+ );
+
+ uninitPromise = Promise.all([
+ windowUninited(callbackId1, win3, true),
+ windowUninited(callbackId2, win3, true),
+ ]);
+ await BrowserTestUtils.closeWindow(win3);
+ await uninitPromise;
+ ok(
+ true,
+ "Uninit called with closing=true for win3 for all registered consumers"
+ );
+
+ initPromise = windowInited(callbackId1);
+ let initPromise2 = windowInited(callbackId2);
+ win2 = await BrowserTestUtils.openNewBrowserWindow();
+ is(await initPromise, win2, "Init called for new window for callback 1");
+ is(await initPromise2, win2, "Init called for new window for callback 2");
+
+ uninitPromise = Promise.all([
+ windowUninited(callbackId1, win2, true),
+ windowUninited(callbackId2, win2, true),
+ ]);
+ await BrowserTestUtils.closeWindow(win2);
+ await uninitPromise;
+ ok(
+ true,
+ "Uninit called with closing=true for win2 for all registered consumers"
+ );
+
+ uninitPromise = windowUninited(callbackId1, window, false);
+ unregisterEWCallback(callbackId1);
+ await uninitPromise;
+ ok(
+ true,
+ "Uninit called for main window without closing flag for the unregistered consumer"
+ );
+
+ uninitPromise = windowUninited(callbackId2, window, false);
+ let timeoutPromise = new Promise(resolve => setTimeout(resolve, 500));
+ unregisterEWCallback(callbackId2, false);
+ let result = await Promise.race([uninitPromise, timeoutPromise]);
+ is(
+ result,
+ undefined,
+ "Uninit not called when unregistering a consumer with aCallUninit=false"
+ );
+});
diff --git a/browser/modules/test/browser/browser_HomePage_add_button.js b/browser/modules/test/browser/browser_HomePage_add_button.js
new file mode 100644
index 0000000000..045fed870e
--- /dev/null
+++ b/browser/modules/test/browser/browser_HomePage_add_button.js
@@ -0,0 +1,159 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "HomePage",
+ "resource:///modules/HomePage.jsm"
+);
+
+const kPrefHomePage = "browser.startup.homepage";
+const kPrefExtensionControlled =
+ "browser.startup.homepage_override.extensionControlled";
+const kPrefHomeButtonRemoved = "browser.engagement.home-button.has-removed";
+const kHomeButtonId = "home-button";
+const kUrlbarWidgetId = "urlbar-container";
+
+// eslint-disable-next-line no-empty-pattern
+async function withTestSetup({} = {}, testFn) {
+ CustomizableUI.removeWidgetFromArea(kHomeButtonId);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [kPrefHomeButtonRemoved, false],
+ [kPrefHomePage, "about:home"],
+ [kPrefExtensionControlled, false],
+ ],
+ });
+
+ HomePage._addCustomizableUiListener();
+
+ try {
+ await testFn();
+ } finally {
+ await SpecialPowers.popPrefEnv();
+ await CustomizableUI.reset();
+ }
+}
+
+function assertHomeButtonInArea(area) {
+ let placement = CustomizableUI.getPlacementOfWidget(kHomeButtonId);
+ is(placement.area, area, "home button in area");
+}
+
+function assertHomeButtonNotPlaced() {
+ ok(
+ !CustomizableUI.getPlacementOfWidget(kHomeButtonId),
+ "home button not placed"
+ );
+}
+
+function assertHasRemovedPref(val) {
+ is(
+ Services.prefs.getBoolPref(kPrefHomeButtonRemoved),
+ val,
+ "Expected removed pref value"
+ );
+}
+
+async function runAddButtonTest() {
+ await withTestSetup({}, async () => {
+ // Setting the homepage once should add to the toolbar.
+ assertHasRemovedPref(false);
+ assertHomeButtonNotPlaced();
+
+ await HomePage.set("https://example.com/");
+
+ assertHomeButtonInArea("nav-bar");
+ assertHasRemovedPref(false);
+
+ // After removing the home button, a new homepage shouldn't add it.
+ CustomizableUI.removeWidgetFromArea(kHomeButtonId);
+
+ await HomePage.set("https://mozilla.org/");
+ assertHomeButtonNotPlaced();
+ });
+}
+
+add_task(async function testAddHomeButtonOnSet() {
+ await runAddButtonTest();
+});
+
+add_task(async function testHomeButtonDoesNotMove() {
+ await withTestSetup({}, async () => {
+ // Setting the homepage should not move the home button.
+ CustomizableUI.addWidgetToArea(kHomeButtonId, "TabsToolbar");
+ assertHasRemovedPref(false);
+ assertHomeButtonInArea("TabsToolbar");
+
+ await HomePage.set("https://example.com/");
+
+ assertHasRemovedPref(false);
+ assertHomeButtonInArea("TabsToolbar");
+ });
+});
+
+add_task(async function testHomeButtonNotAddedBlank() {
+ await withTestSetup({}, async () => {
+ assertHomeButtonNotPlaced();
+ assertHasRemovedPref(false);
+
+ await HomePage.set("about:blank");
+
+ assertHasRemovedPref(false);
+ assertHomeButtonNotPlaced();
+
+ await HomePage.set("about:home");
+
+ assertHasRemovedPref(false);
+ assertHomeButtonNotPlaced();
+ });
+});
+
+add_task(async function testHomeButtonNotAddedExtensionControlled() {
+ await withTestSetup({}, async () => {
+ assertHomeButtonNotPlaced();
+ assertHasRemovedPref(false);
+ Services.prefs.setBoolPref(kPrefExtensionControlled, true);
+
+ await HomePage.set("https://search.example.com/?q=%s");
+
+ assertHomeButtonNotPlaced();
+ });
+});
+
+add_task(async function testHomeButtonPlacement() {
+ await withTestSetup({}, async () => {
+ assertHomeButtonNotPlaced();
+ HomePage._maybeAddHomeButtonToToolbar("https://example.com");
+ let homePlacement = CustomizableUI.getPlacementOfWidget(kHomeButtonId);
+ is(homePlacement.area, "nav-bar", "Home button is in the nav-bar");
+ is(homePlacement.position, 3, "Home button is after stop/refresh");
+
+ let addressBarPlacement =
+ CustomizableUI.getPlacementOfWidget(kUrlbarWidgetId);
+ is(
+ addressBarPlacement.position,
+ 5,
+ "There's a space between home and urlbar"
+ );
+ CustomizableUI.removeWidgetFromArea(kHomeButtonId);
+ Services.prefs.setBoolPref(kPrefHomeButtonRemoved, false);
+
+ try {
+ CustomizableUI.addWidgetToArea(kUrlbarWidgetId, "nav-bar", 1);
+ HomePage._maybeAddHomeButtonToToolbar("https://example.com");
+ homePlacement = CustomizableUI.getPlacementOfWidget(kHomeButtonId);
+ is(homePlacement.area, "nav-bar", "Home button is in the nav-bar");
+ is(homePlacement.position, 1, "Home button is right before the urlbar");
+ } finally {
+ CustomizableUI.addWidgetToArea(
+ kUrlbarWidgetId,
+ addressBarPlacement.area,
+ addressBarPlacement.position
+ );
+ }
+ });
+});
diff --git a/browser/modules/test/browser/browser_PageActions.js b/browser/modules/test/browser/browser_PageActions.js
new file mode 100644
index 0000000000..a0b6e72211
--- /dev/null
+++ b/browser/modules/test/browser/browser_PageActions.js
@@ -0,0 +1,1402 @@
+"use strict";
+
+// This is a test for PageActions.jsm, specifically the generalized parts that
+// add and remove page actions and toggle them in the urlbar. This does not
+// test the built-in page actions; browser_page_action_menu.js does that.
+
+// Initialization. Must run first.
+add_setup(async function () {
+ // The page action urlbar button, and therefore the panel, is only shown when
+ // the current tab is actionable -- i.e., a normal web page. about:blank is
+ // not, so open a new tab first thing, and close it when this test is done.
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "http://example.com/",
+ });
+ registerCleanupFunction(async () => {
+ BrowserTestUtils.removeTab(tab);
+ });
+
+ await initPageActionsTest();
+});
+
+// Tests a simple non-built-in action without an iframe or subview. Also
+// thoroughly checks most of the action's properties, methods, and DOM nodes, so
+// it's not necessary to do that in general in other test tasks.
+add_task(async function simple() {
+ let iconURL = "chrome://browser/skin/mail.svg";
+ let id = "test-simple";
+ let title = "Test simple";
+ let tooltip = "Test simple tooltip";
+
+ let onCommandCallCount = 0;
+ let onPlacedInPanelCallCount = 0;
+ let onPlacedInUrlbarCallCount = 0;
+ let onShowingInPanelCallCount = 0;
+ let onCommandExpectedButtonID;
+
+ let panelButtonID = BrowserPageActions.panelButtonNodeIDForActionID(id);
+ let urlbarButtonID = BrowserPageActions.urlbarButtonNodeIDForActionID(id);
+
+ // Open the panel so that actions are added to it, and then close it.
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+
+ let initialActions = PageActions.actions;
+ let initialActionsInPanel = PageActions.actionsInPanel(window);
+ let initialActionsInUrlbar = PageActions.actionsInUrlbar(window);
+
+ let action = PageActions.addAction(
+ new PageActions.Action({
+ iconURL,
+ id,
+ title,
+ tooltip,
+ onCommand(event, buttonNode) {
+ onCommandCallCount++;
+ Assert.ok(event, "event should be non-null: " + event);
+ Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
+ Assert.equal(buttonNode.id, onCommandExpectedButtonID, "buttonNode.id");
+ },
+ onPlacedInPanel(buttonNode) {
+ onPlacedInPanelCallCount++;
+ Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
+ Assert.equal(buttonNode.id, panelButtonID, "buttonNode.id");
+ },
+ onPlacedInUrlbar(buttonNode) {
+ onPlacedInUrlbarCallCount++;
+ Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
+ Assert.equal(buttonNode.id, urlbarButtonID, "buttonNode.id");
+ },
+ onShowingInPanel(buttonNode) {
+ onShowingInPanelCallCount++;
+ Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
+ Assert.equal(buttonNode.id, panelButtonID, "buttonNode.id");
+ },
+ })
+ );
+
+ Assert.equal(action.getIconURL(), iconURL, "iconURL");
+ Assert.equal(action.id, id, "id");
+ Assert.equal(action.pinnedToUrlbar, true, "pinnedToUrlbar");
+ Assert.equal(action.getDisabled(), false, "disabled");
+ Assert.equal(action.getDisabled(window), false, "disabled in window");
+ Assert.equal(action.getTitle(), title, "title");
+ Assert.equal(action.getTitle(window), title, "title in window");
+ Assert.equal(action.getTooltip(), tooltip, "tooltip");
+ Assert.equal(action.getTooltip(window), tooltip, "tooltip in window");
+ Assert.equal(action.getWantsSubview(), false, "subview");
+ Assert.equal(action.getWantsSubview(window), false, "subview in window");
+ Assert.equal(action.urlbarIDOverride, null, "urlbarIDOverride");
+ Assert.equal(action.wantsIframe, false, "wantsIframe");
+
+ Assert.ok(!("__insertBeforeActionID" in action), "__insertBeforeActionID");
+ Assert.ok(!("__isSeparator" in action), "__isSeparator");
+ Assert.ok(!("__urlbarNodeInMarkup" in action), "__urlbarNodeInMarkup");
+ Assert.ok(!("__transient" in action), "__transient");
+
+ // The action shouldn't be placed in the panel until it opens for the first
+ // time.
+ Assert.equal(
+ onPlacedInPanelCallCount,
+ 0,
+ "onPlacedInPanelCallCount should remain 0"
+ );
+ Assert.equal(
+ onPlacedInUrlbarCallCount,
+ 1,
+ "onPlacedInUrlbarCallCount after adding the action"
+ );
+ Assert.equal(
+ onShowingInPanelCallCount,
+ 0,
+ "onShowingInPanelCallCount should remain 0"
+ );
+
+ // Open the panel so that actions are added to it, and then close it.
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+
+ Assert.equal(
+ onPlacedInPanelCallCount,
+ 1,
+ "onPlacedInPanelCallCount should be inc'ed"
+ );
+ Assert.equal(
+ onShowingInPanelCallCount,
+ 1,
+ "onShowingInPanelCallCount should be inc'ed"
+ );
+
+ // Build an array of the expected actions in the panel and compare it to the
+ // actual actions. Don't assume that there are or aren't already other non-
+ // built-in actions.
+ let sepIndex = initialActionsInPanel.findIndex(
+ a => a.id == PageActions.ACTION_ID_BUILT_IN_SEPARATOR
+ );
+ let initialSepIndex = sepIndex;
+ let indexInPanel;
+ if (sepIndex < 0) {
+ // No prior non-built-in actions.
+ indexInPanel = initialActionsInPanel.length;
+ } else {
+ // Prior non-built-in actions. Find the index where the action goes.
+ for (
+ indexInPanel = sepIndex + 1;
+ indexInPanel < initialActionsInPanel.length;
+ indexInPanel++
+ ) {
+ let a = initialActionsInPanel[indexInPanel];
+ if (a.getTitle().localeCompare(action.getTitle()) < 1) {
+ break;
+ }
+ }
+ }
+ let expectedActionsInPanel = initialActionsInPanel.slice();
+ expectedActionsInPanel.splice(indexInPanel, 0, action);
+ // The separator between the built-ins and non-built-ins should be present
+ // if it's not already.
+ if (sepIndex < 0) {
+ expectedActionsInPanel.splice(
+ indexInPanel,
+ 0,
+ new PageActions.Action({
+ id: PageActions.ACTION_ID_BUILT_IN_SEPARATOR,
+ _isSeparator: true,
+ })
+ );
+ sepIndex = indexInPanel;
+ indexInPanel++;
+ }
+ Assert.deepEqual(
+ PageActions.actionsInPanel(window),
+ expectedActionsInPanel,
+ "Actions in panel after adding the action"
+ );
+
+ Assert.deepEqual(
+ PageActions.actionsInUrlbar(window),
+ [action].concat(initialActionsInUrlbar),
+ "Actions in urlbar after adding the action"
+ );
+
+ // Check the set of all actions.
+ Assert.deepEqual(
+ new Set(PageActions.actions),
+ new Set(initialActions.concat([action])),
+ "All actions after adding the action"
+ );
+
+ Assert.deepEqual(
+ PageActions.actionForID(action.id),
+ action,
+ "actionForID should be action"
+ );
+
+ Assert.ok(
+ PageActions._persistedActions.ids.includes(action.id),
+ "PageActions should record action in its list of seen actions"
+ );
+
+ // The action's panel button should have been created.
+ let panelButtonNode =
+ BrowserPageActions.mainViewBodyNode.children[indexInPanel];
+ Assert.notEqual(panelButtonNode, null, "panelButtonNode");
+ Assert.equal(panelButtonNode.id, panelButtonID, "panelButtonID");
+ Assert.equal(
+ panelButtonNode.getAttribute("label"),
+ action.getTitle(),
+ "label"
+ );
+
+ // The separator between the built-ins and non-built-ins should exist.
+ let sepNode = BrowserPageActions.mainViewBodyNode.children[sepIndex];
+ Assert.notEqual(sepNode, null, "sepNode");
+ Assert.equal(
+ sepNode.id,
+ BrowserPageActions.panelButtonNodeIDForActionID(
+ PageActions.ACTION_ID_BUILT_IN_SEPARATOR
+ ),
+ "sepNode.id"
+ );
+
+ let urlbarButtonNode = document.getElementById(urlbarButtonID);
+ Assert.equal(!!urlbarButtonNode, true, "urlbarButtonNode");
+
+ // Open the panel, click the action's button.
+ await promiseOpenPageActionPanel();
+ Assert.equal(
+ onShowingInPanelCallCount,
+ 2,
+ "onShowingInPanelCallCount should be inc'ed"
+ );
+ onCommandExpectedButtonID = panelButtonID;
+ EventUtils.synthesizeMouseAtCenter(panelButtonNode, {});
+ await promisePageActionPanelHidden();
+ Assert.equal(onCommandCallCount, 1, "onCommandCallCount should be inc'ed");
+
+ // Show the action's button in the urlbar.
+ action.pinnedToUrlbar = true;
+ Assert.equal(
+ onPlacedInUrlbarCallCount,
+ 1,
+ "onPlacedInUrlbarCallCount should be inc'ed"
+ );
+ urlbarButtonNode = document.getElementById(urlbarButtonID);
+ Assert.notEqual(urlbarButtonNode, null, "urlbarButtonNode");
+
+ // The button should have been inserted before the bookmark star.
+ Assert.notEqual(
+ urlbarButtonNode.nextElementSibling,
+ null,
+ "Should be a next node"
+ );
+ Assert.equal(
+ urlbarButtonNode.nextElementSibling.id,
+ PageActions.actionForID(PageActions.ACTION_ID_BOOKMARK).urlbarIDOverride,
+ "Next node should be the bookmark star"
+ );
+
+ // Disable the action. The button in the urlbar should be removed, and the
+ // button in the panel should be disabled.
+ action.setDisabled(true);
+ urlbarButtonNode = document.getElementById(urlbarButtonID);
+ Assert.equal(urlbarButtonNode, null, "urlbar button should be removed");
+ Assert.equal(
+ panelButtonNode.disabled,
+ true,
+ "panel button should be disabled"
+ );
+
+ // Enable the action. The button in the urlbar should be added back, and the
+ // button in the panel should be enabled.
+ action.setDisabled(false);
+ urlbarButtonNode = document.getElementById(urlbarButtonID);
+ Assert.notEqual(urlbarButtonNode, null, "urlbar button should be added back");
+ Assert.equal(
+ panelButtonNode.disabled,
+ false,
+ "panel button should not be disabled"
+ );
+
+ // Click the urlbar button.
+ onCommandExpectedButtonID = urlbarButtonID;
+ EventUtils.synthesizeMouseAtCenter(urlbarButtonNode, {});
+ Assert.equal(onCommandCallCount, 2, "onCommandCallCount should be inc'ed");
+
+ // Set a new title.
+ let newTitle = title + " new title";
+ action.setTitle(newTitle);
+ Assert.equal(action.getTitle(), newTitle, "New title");
+ Assert.equal(
+ panelButtonNode.getAttribute("label"),
+ action.getTitle(),
+ "New label"
+ );
+
+ // Now that pinnedToUrlbar has been toggled, make sure that it sticks across
+ // app restarts. Simulate that by "unregistering" the action (not by removing
+ // it, which is more permanent) and then registering it again.
+
+ // unregister
+ PageActions._actionsByID.delete(action.id);
+ let index = PageActions._nonBuiltInActions.findIndex(a => a.id == action.id);
+ Assert.ok(index >= 0, "Action should be in _nonBuiltInActions to begin with");
+ PageActions._nonBuiltInActions.splice(index, 1);
+
+ // register again
+ PageActions._registerAction(action);
+
+ // check relevant properties
+ Assert.ok(
+ PageActions._persistedActions.ids.includes(action.id),
+ "PageActions should have 'seen' the action"
+ );
+ Assert.ok(
+ PageActions._persistedActions.idsInUrlbar.includes(action.id),
+ "idsInUrlbar should still include the action"
+ );
+ Assert.ok(action.pinnedToUrlbar, "pinnedToUrlbar should still be true");
+ Assert.ok(
+ action._pinnedToUrlbar,
+ "_pinnedToUrlbar should still be true, for good measure"
+ );
+
+ // Remove the action.
+ action.remove();
+ panelButtonNode = document.getElementById(panelButtonID);
+ Assert.equal(panelButtonNode, null, "panelButtonNode");
+ urlbarButtonNode = document.getElementById(urlbarButtonID);
+ Assert.equal(urlbarButtonNode, null, "urlbarButtonNode");
+
+ let separatorNode = document.getElementById(
+ BrowserPageActions.panelButtonNodeIDForActionID(
+ PageActions.ACTION_ID_BUILT_IN_SEPARATOR
+ )
+ );
+ if (initialSepIndex < 0) {
+ // The separator between the built-in actions and non-built-in actions
+ // should be gone now, too.
+ Assert.equal(separatorNode, null, "No separator");
+ Assert.ok(
+ !BrowserPageActions.mainViewBodyNode.lastElementChild.localName.includes(
+ "separator"
+ ),
+ "Last child should not be separator"
+ );
+ } else {
+ // The separator should still be present.
+ Assert.notEqual(separatorNode, null, "Separator should still exist");
+ }
+
+ Assert.deepEqual(
+ PageActions.actionsInPanel(window),
+ initialActionsInPanel,
+ "Actions in panel should go back to initial"
+ );
+ Assert.deepEqual(
+ PageActions.actionsInUrlbar(window),
+ initialActionsInUrlbar,
+ "Actions in urlbar should go back to initial"
+ );
+ Assert.deepEqual(
+ PageActions.actions,
+ initialActions,
+ "Actions should go back to initial"
+ );
+ Assert.equal(
+ PageActions.actionForID(action.id),
+ null,
+ "actionForID should be null"
+ );
+
+ Assert.ok(
+ PageActions._persistedActions.ids.includes(action.id),
+ "Action ID should remain in cache until purged"
+ );
+ PageActions._purgeUnregisteredPersistedActions();
+ Assert.ok(
+ !PageActions._persistedActions.ids.includes(action.id),
+ "Action ID should be removed from cache after being purged"
+ );
+});
+
+// Tests a non-built-in action with a subview.
+add_task(async function withSubview() {
+ let id = "test-subview";
+
+ let onActionPlacedInPanelCallCount = 0;
+ let onActionPlacedInUrlbarCallCount = 0;
+ let onSubviewPlacedCount = 0;
+ let onSubviewShowingCount = 0;
+
+ let panelButtonID = BrowserPageActions.panelButtonNodeIDForActionID(id);
+ let urlbarButtonID = BrowserPageActions.urlbarButtonNodeIDForActionID(id);
+
+ let panelViewIDPanel = BrowserPageActions._panelViewNodeIDForActionID(
+ id,
+ false
+ );
+ let panelViewIDUrlbar = BrowserPageActions._panelViewNodeIDForActionID(
+ id,
+ true
+ );
+
+ let onSubviewPlacedExpectedPanelViewID = panelViewIDPanel;
+ let onSubviewShowingExpectedPanelViewID;
+
+ let action = PageActions.addAction(
+ new PageActions.Action({
+ iconURL: "chrome://browser/skin/mail.svg",
+ id,
+ pinnedToUrlbar: true,
+ title: "Test subview",
+ wantsSubview: true,
+ onPlacedInPanel(buttonNode) {
+ onActionPlacedInPanelCallCount++;
+ Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
+ Assert.equal(buttonNode.id, panelButtonID, "buttonNode.id");
+ },
+ onPlacedInUrlbar(buttonNode) {
+ onActionPlacedInUrlbarCallCount++;
+ Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
+ Assert.equal(buttonNode.id, urlbarButtonID, "buttonNode.id");
+ },
+ onSubviewPlaced(panelViewNode) {
+ onSubviewPlacedCount++;
+ Assert.ok(
+ panelViewNode,
+ "panelViewNode should be non-null: " + panelViewNode
+ );
+ Assert.equal(
+ panelViewNode.id,
+ onSubviewPlacedExpectedPanelViewID,
+ "panelViewNode.id"
+ );
+ },
+ onSubviewShowing(panelViewNode) {
+ onSubviewShowingCount++;
+ Assert.ok(
+ panelViewNode,
+ "panelViewNode should be non-null: " + panelViewNode
+ );
+ Assert.equal(
+ panelViewNode.id,
+ onSubviewShowingExpectedPanelViewID,
+ "panelViewNode.id"
+ );
+ },
+ })
+ );
+
+ Assert.equal(action.id, id, "id");
+ Assert.equal(action.getWantsSubview(), true, "subview");
+ Assert.equal(action.getWantsSubview(window), true, "subview in window");
+
+ // The action shouldn't be placed in the panel until it opens for the first
+ // time.
+ Assert.equal(
+ onActionPlacedInPanelCallCount,
+ 0,
+ "onActionPlacedInPanelCallCount should be 0"
+ );
+ Assert.equal(onSubviewPlacedCount, 0, "onSubviewPlacedCount should be 0");
+
+ // But it should be placed in the urlbar.
+ Assert.equal(
+ onActionPlacedInUrlbarCallCount,
+ 1,
+ "onActionPlacedInUrlbarCallCount should be 0"
+ );
+
+ // Open the panel, which should place the action in it.
+ await promiseOpenPageActionPanel();
+
+ Assert.equal(
+ onActionPlacedInPanelCallCount,
+ 1,
+ "onActionPlacedInPanelCallCount should be inc'ed"
+ );
+ Assert.equal(
+ onSubviewPlacedCount,
+ 1,
+ "onSubviewPlacedCount should be inc'ed"
+ );
+ Assert.equal(
+ onSubviewShowingCount,
+ 0,
+ "onSubviewShowingCount should remain 0"
+ );
+
+ // The action's panel button and view (in the main page action panel) should
+ // have been created.
+ let panelButtonNode = document.getElementById(panelButtonID);
+ Assert.notEqual(panelButtonNode, null, "panelButtonNode");
+
+ // The action's urlbar button should have been created.
+ let urlbarButtonNode = document.getElementById(urlbarButtonID);
+ Assert.notEqual(urlbarButtonNode, null, "urlbarButtonNode");
+
+ // The button should have been inserted before the bookmark star.
+ Assert.notEqual(
+ urlbarButtonNode.nextElementSibling,
+ null,
+ "Should be a next node"
+ );
+ Assert.equal(
+ urlbarButtonNode.nextElementSibling.id,
+ PageActions.actionForID(PageActions.ACTION_ID_BOOKMARK).urlbarIDOverride,
+ "Next node should be the bookmark star"
+ );
+
+ // Click the action's button in the panel. The subview should be shown.
+ Assert.equal(
+ onSubviewShowingCount,
+ 0,
+ "onSubviewShowingCount should remain 0"
+ );
+ let subviewShownPromise = promisePageActionViewShown();
+ onSubviewShowingExpectedPanelViewID = panelViewIDPanel;
+ panelButtonNode.click();
+ await subviewShownPromise;
+
+ // Click the main button to hide the main panel.
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+
+ // Click the action's urlbar button, which should open the activated-action
+ // panel showing the subview.
+ onSubviewPlacedExpectedPanelViewID = panelViewIDUrlbar;
+ onSubviewShowingExpectedPanelViewID = panelViewIDUrlbar;
+ EventUtils.synthesizeMouseAtCenter(urlbarButtonNode, {});
+ await promisePanelShown(BrowserPageActions._activatedActionPanelID);
+ Assert.equal(
+ onSubviewPlacedCount,
+ 2,
+ "onSubviewPlacedCount should be inc'ed"
+ );
+ Assert.equal(
+ onSubviewShowingCount,
+ 2,
+ "onSubviewShowingCount should be inc'ed"
+ );
+
+ // Click the urlbar button again. The activated-action panel should close.
+ EventUtils.synthesizeMouseAtCenter(urlbarButtonNode, {});
+ assertActivatedPageActionPanelHidden();
+
+ // Remove the action.
+ action.remove();
+ panelButtonNode = document.getElementById(panelButtonID);
+ Assert.equal(panelButtonNode, null, "panelButtonNode");
+ urlbarButtonNode = document.getElementById(urlbarButtonID);
+ Assert.equal(urlbarButtonNode, null, "urlbarButtonNode");
+ let panelViewNodePanel = document.getElementById(panelViewIDPanel);
+ Assert.equal(panelViewNodePanel, null, "panelViewNodePanel");
+ let panelViewNodeUrlbar = document.getElementById(panelViewIDUrlbar);
+ Assert.equal(panelViewNodeUrlbar, null, "panelViewNodeUrlbar");
+});
+
+// Tests a non-built-in action with an iframe.
+add_task(async function withIframe() {
+ let id = "test-iframe";
+
+ let onCommandCallCount = 0;
+ let onPlacedInPanelCallCount = 0;
+ let onPlacedInUrlbarCallCount = 0;
+ let onIframeShowingCount = 0;
+
+ let panelButtonID = BrowserPageActions.panelButtonNodeIDForActionID(id);
+ let urlbarButtonID = BrowserPageActions.urlbarButtonNodeIDForActionID(id);
+
+ let action = PageActions.addAction(
+ new PageActions.Action({
+ iconURL: "chrome://browser/skin/mail.svg",
+ id,
+ pinnedToUrlbar: true,
+ title: "Test iframe",
+ wantsIframe: true,
+ onCommand(event, buttonNode) {
+ onCommandCallCount++;
+ },
+ onIframeShowing(iframeNode, panelNode) {
+ onIframeShowingCount++;
+ Assert.ok(iframeNode, "iframeNode should be non-null: " + iframeNode);
+ Assert.equal(iframeNode.localName, "iframe", "iframe localName");
+ Assert.ok(panelNode, "panelNode should be non-null: " + panelNode);
+ Assert.equal(
+ panelNode.id,
+ BrowserPageActions._activatedActionPanelID,
+ "panelNode.id"
+ );
+ },
+ onPlacedInPanel(buttonNode) {
+ onPlacedInPanelCallCount++;
+ Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
+ Assert.equal(buttonNode.id, panelButtonID, "buttonNode.id");
+ },
+ onPlacedInUrlbar(buttonNode) {
+ onPlacedInUrlbarCallCount++;
+ Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
+ Assert.equal(buttonNode.id, urlbarButtonID, "buttonNode.id");
+ },
+ })
+ );
+
+ Assert.equal(action.id, id, "id");
+ Assert.equal(action.wantsIframe, true, "wantsIframe");
+
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+
+ Assert.equal(
+ onPlacedInPanelCallCount,
+ 1,
+ "onPlacedInPanelCallCount should be inc'ed"
+ );
+ Assert.equal(
+ onPlacedInUrlbarCallCount,
+ 1,
+ "onPlacedInUrlbarCallCount should be inc'ed"
+ );
+ Assert.equal(onIframeShowingCount, 0, "onIframeShowingCount should remain 0");
+ Assert.equal(onCommandCallCount, 0, "onCommandCallCount should remain 0");
+
+ // The action's panel button should have been created.
+ let panelButtonNode = document.getElementById(panelButtonID);
+ Assert.notEqual(panelButtonNode, null, "panelButtonNode");
+
+ // The action's urlbar button should have been created.
+ let urlbarButtonNode = document.getElementById(urlbarButtonID);
+ Assert.notEqual(urlbarButtonNode, null, "urlbarButtonNode");
+
+ // The button should have been inserted before the bookmark star.
+ Assert.notEqual(
+ urlbarButtonNode.nextElementSibling,
+ null,
+ "Should be a next node"
+ );
+ Assert.equal(
+ urlbarButtonNode.nextElementSibling.id,
+ PageActions.actionForID(PageActions.ACTION_ID_BOOKMARK).urlbarIDOverride,
+ "Next node should be the bookmark star"
+ );
+
+ // Open the panel, click the action's button.
+ await promiseOpenPageActionPanel();
+ Assert.equal(onIframeShowingCount, 0, "onIframeShowingCount should remain 0");
+ EventUtils.synthesizeMouseAtCenter(panelButtonNode, {});
+ await promisePanelShown(BrowserPageActions._activatedActionPanelID);
+ Assert.equal(onCommandCallCount, 1, "onCommandCallCount should be inc'ed");
+ Assert.equal(
+ onIframeShowingCount,
+ 1,
+ "onIframeShowingCount should be inc'ed"
+ );
+
+ // The activated-action panel should have opened, anchored to the action's
+ // urlbar button.
+ let aaPanel = document.getElementById(
+ BrowserPageActions._activatedActionPanelID
+ );
+ Assert.notEqual(aaPanel, null, "activated-action panel");
+ Assert.equal(aaPanel.anchorNode.id, urlbarButtonID, "aaPanel.anchorNode.id");
+ EventUtils.synthesizeMouseAtCenter(urlbarButtonNode, {});
+ assertActivatedPageActionPanelHidden();
+
+ // Click the action's urlbar button.
+ EventUtils.synthesizeMouseAtCenter(urlbarButtonNode, {});
+ await promisePanelShown(BrowserPageActions._activatedActionPanelID);
+ Assert.equal(onCommandCallCount, 2, "onCommandCallCount should be inc'ed");
+ Assert.equal(
+ onIframeShowingCount,
+ 2,
+ "onIframeShowingCount should be inc'ed"
+ );
+
+ // The activated-action panel should have opened, again anchored to the
+ // action's urlbar button.
+ aaPanel = document.getElementById(BrowserPageActions._activatedActionPanelID);
+ Assert.notEqual(aaPanel, null, "aaPanel");
+ Assert.equal(aaPanel.anchorNode.id, urlbarButtonID, "aaPanel.anchorNode.id");
+ EventUtils.synthesizeMouseAtCenter(urlbarButtonNode, {});
+ assertActivatedPageActionPanelHidden();
+
+ // Hide the action's button in the urlbar.
+ action.pinnedToUrlbar = false;
+ urlbarButtonNode = document.getElementById(urlbarButtonID);
+ Assert.equal(urlbarButtonNode, null, "urlbarButtonNode");
+
+ // Open the panel, click the action's button.
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(panelButtonNode, {});
+ await promisePanelShown(BrowserPageActions._activatedActionPanelID);
+ Assert.equal(onCommandCallCount, 3, "onCommandCallCount should be inc'ed");
+ Assert.equal(
+ onIframeShowingCount,
+ 3,
+ "onIframeShowingCount should be inc'ed"
+ );
+
+ // The activated-action panel should have opened, this time anchored to the
+ // main page action button in the urlbar.
+ aaPanel = document.getElementById(BrowserPageActions._activatedActionPanelID);
+ Assert.notEqual(aaPanel, null, "aaPanel");
+ Assert.equal(
+ aaPanel.anchorNode.id,
+ BrowserPageActions.mainButtonNode.id,
+ "aaPanel.anchorNode.id"
+ );
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ assertActivatedPageActionPanelHidden();
+
+ // Remove the action.
+ action.remove();
+ panelButtonNode = document.getElementById(panelButtonID);
+ Assert.equal(panelButtonNode, null, "panelButtonNode");
+ urlbarButtonNode = document.getElementById(urlbarButtonID);
+ Assert.equal(urlbarButtonNode, null, "urlbarButtonNode");
+});
+
+// Tests an action with the _insertBeforeActionID option set.
+add_task(async function insertBeforeActionID() {
+ let id = "test-insertBeforeActionID";
+ let panelButtonID = BrowserPageActions.panelButtonNodeIDForActionID(id);
+
+ let initialActions = PageActions.actionsInPanel(window);
+ let initialBuiltInActions = PageActions._builtInActions.slice();
+ let initialNonBuiltInActions = PageActions._nonBuiltInActions.slice();
+
+ let action = PageActions.addAction(
+ new PageActions.Action({
+ id,
+ title: "Test insertBeforeActionID",
+ _insertBeforeActionID: PageActions.ACTION_ID_BOOKMARK_SEPARATOR,
+ })
+ );
+
+ Assert.equal(action.id, id, "id");
+ Assert.ok("__insertBeforeActionID" in action, "__insertBeforeActionID");
+ Assert.equal(
+ action.__insertBeforeActionID,
+ PageActions.ACTION_ID_BOOKMARK_SEPARATOR,
+ "action.__insertBeforeActionID"
+ );
+
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+
+ let newActions = PageActions.actionsInPanel(window);
+ Assert.equal(
+ newActions.length,
+ initialActions.length + 1,
+ "PageActions.actions.length should be updated"
+ );
+ Assert.equal(
+ PageActions._builtInActions.length,
+ initialBuiltInActions.length + 1,
+ "PageActions._builtInActions.length should be updated"
+ );
+ Assert.equal(
+ PageActions._nonBuiltInActions.length,
+ initialNonBuiltInActions.length,
+ "PageActions._nonBuiltInActions.length should remain the same"
+ );
+
+ // The action's panel button should have been created.
+ let panelButtonNode = document.getElementById(panelButtonID);
+ Assert.notEqual(panelButtonNode, null, "panelButtonNode");
+
+ // The separator between the built-in and non-built-in actions should not have
+ // been created.
+ Assert.equal(
+ document.getElementById(
+ BrowserPageActions.panelButtonNodeIDForActionID(
+ PageActions.ACTION_ID_BUILT_IN_SEPARATOR
+ )
+ ),
+ null,
+ "Separator should be gone"
+ );
+
+ action.remove();
+});
+
+// Tests that the ordering in the panel of multiple non-built-in actions is
+// alphabetical.
+add_task(async function multipleNonBuiltInOrdering() {
+ let idPrefix = "test-multipleNonBuiltInOrdering-";
+ let titlePrefix = "Test multipleNonBuiltInOrdering ";
+
+ let initialActions = PageActions.actionsInPanel(window);
+ let initialBuiltInActions = PageActions._builtInActions.slice();
+ let initialNonBuiltInActions = PageActions._nonBuiltInActions.slice();
+
+ // Create some actions in an out-of-order order.
+ let actions = [2, 1, 4, 3].map(index => {
+ return PageActions.addAction(
+ new PageActions.Action({
+ id: idPrefix + index,
+ title: titlePrefix + index,
+ })
+ );
+ });
+
+ // + 1 for the separator between built-in and non-built-in actions.
+ Assert.equal(
+ PageActions.actionsInPanel(window).length,
+ initialActions.length + actions.length + 1,
+ "PageActions.actionsInPanel().length should be updated"
+ );
+
+ Assert.equal(
+ PageActions._builtInActions.length,
+ initialBuiltInActions.length,
+ "PageActions._builtInActions.length should be same"
+ );
+ Assert.equal(
+ PageActions._nonBuiltInActions.length,
+ initialNonBuiltInActions.length + actions.length,
+ "PageActions._nonBuiltInActions.length should be updated"
+ );
+
+ // Look at the final actions.length actions in PageActions.actions, from first
+ // to last.
+ for (let i = 0; i < actions.length; i++) {
+ let expectedIndex = i + 1;
+ let actualAction = PageActions._nonBuiltInActions[i];
+ Assert.equal(
+ actualAction.id,
+ idPrefix + expectedIndex,
+ "actualAction.id for index: " + i
+ );
+ }
+
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+
+ // Check the button nodes in the panel.
+ let expectedIndex = 1;
+ let buttonNode = document.getElementById(
+ BrowserPageActions.panelButtonNodeIDForActionID(idPrefix + expectedIndex)
+ );
+ Assert.notEqual(buttonNode, null, "buttonNode");
+ Assert.notEqual(
+ buttonNode.previousElementSibling,
+ null,
+ "buttonNode.previousElementSibling"
+ );
+ Assert.equal(
+ buttonNode.previousElementSibling.id,
+ BrowserPageActions.panelButtonNodeIDForActionID(
+ PageActions.ACTION_ID_BUILT_IN_SEPARATOR
+ ),
+ "buttonNode.previousElementSibling.id"
+ );
+ for (let i = 0; i < actions.length; i++) {
+ Assert.notEqual(buttonNode, null, "buttonNode at index: " + i);
+ Assert.equal(
+ buttonNode.id,
+ BrowserPageActions.panelButtonNodeIDForActionID(idPrefix + expectedIndex),
+ "buttonNode.id at index: " + i
+ );
+ buttonNode = buttonNode.nextElementSibling;
+ expectedIndex++;
+ }
+ Assert.equal(buttonNode, null, "Nothing should come after the last button");
+
+ for (let action of actions) {
+ action.remove();
+ }
+
+ // The separator between the built-in and non-built-in actions should be gone.
+ Assert.equal(
+ document.getElementById(
+ BrowserPageActions.panelButtonNodeIDForActionID(
+ PageActions.ACTION_ID_BUILT_IN_SEPARATOR
+ )
+ ),
+ null,
+ "Separator should be gone"
+ );
+});
+
+// Makes sure the panel is correctly updated when a non-built-in action is
+// added before the built-in actions; and when all built-in actions are removed
+// and added back.
+add_task(async function nonBuiltFirst() {
+ let initialActions = PageActions.actions;
+ let initialActionsInPanel = PageActions.actionsInPanel(window);
+
+ // Remove all actions.
+ for (let action of initialActions) {
+ action.remove();
+ }
+
+ // Check the actions.
+ Assert.deepEqual(
+ PageActions.actions.map(a => a.id),
+ [],
+ "PageActions.actions should be empty"
+ );
+ Assert.deepEqual(
+ PageActions._builtInActions.map(a => a.id),
+ [],
+ "PageActions._builtInActions should be empty"
+ );
+ Assert.deepEqual(
+ PageActions._nonBuiltInActions.map(a => a.id),
+ [],
+ "PageActions._nonBuiltInActions should be empty"
+ );
+
+ // Check the panel.
+ Assert.equal(
+ BrowserPageActions.mainViewBodyNode.children.length,
+ 0,
+ "All nodes should be gone"
+ );
+
+ // Add a non-built-in action.
+ let action = PageActions.addAction(
+ new PageActions.Action({
+ id: "test-nonBuiltFirst",
+ title: "Test nonBuiltFirst",
+ })
+ );
+
+ // Check the actions.
+ Assert.deepEqual(
+ PageActions.actions.map(a => a.id),
+ [action.id],
+ "Action should be in PageActions.actions"
+ );
+ Assert.deepEqual(
+ PageActions._builtInActions.map(a => a.id),
+ [],
+ "PageActions._builtInActions should be empty"
+ );
+ Assert.deepEqual(
+ PageActions._nonBuiltInActions.map(a => a.id),
+ [action.id],
+ "Action should be in PageActions._nonBuiltInActions"
+ );
+
+ // Check the panel.
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+ Assert.deepEqual(
+ Array.from(BrowserPageActions.mainViewBodyNode.children, n => n.id),
+ [BrowserPageActions.panelButtonNodeIDForActionID(action.id)],
+ "Action should be in panel"
+ );
+
+ // Now add back all the actions.
+ for (let a of initialActions) {
+ PageActions.addAction(a);
+ }
+
+ // Check the actions.
+ Assert.deepEqual(
+ new Set(PageActions.actions.map(a => a.id)),
+ new Set(initialActions.map(a => a.id).concat([action.id])),
+ "All actions should be in PageActions.actions"
+ );
+ Assert.deepEqual(
+ PageActions._builtInActions.map(a => a.id),
+ initialActions.filter(a => !a.__transient).map(a => a.id),
+ "PageActions._builtInActions should be initial actions"
+ );
+ Assert.deepEqual(
+ PageActions._nonBuiltInActions.map(a => a.id),
+ [action.id],
+ "PageActions._nonBuiltInActions should contain action"
+ );
+
+ // Check the panel.
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+ Assert.deepEqual(
+ PageActions.actionsInPanel(window).map(a => a.id),
+ initialActionsInPanel
+ .map(a => a.id)
+ .concat([PageActions.ACTION_ID_BUILT_IN_SEPARATOR], [action.id]),
+ "All actions should be in PageActions.actionsInPanel()"
+ );
+ Assert.deepEqual(
+ Array.from(BrowserPageActions.mainViewBodyNode.children, n => n.id),
+ initialActionsInPanel
+ .map(a => a.id)
+ .concat([PageActions.ACTION_ID_BUILT_IN_SEPARATOR], [action.id])
+ .map(id => BrowserPageActions.panelButtonNodeIDForActionID(id)),
+ "Panel should contain all actions"
+ );
+
+ // Remove the test action.
+ action.remove();
+
+ // Check the actions.
+ Assert.deepEqual(
+ PageActions.actions.map(a => a.id),
+ initialActions.map(a => a.id),
+ "Action should no longer be in PageActions.actions"
+ );
+ Assert.deepEqual(
+ PageActions._builtInActions.map(a => a.id),
+ initialActions.filter(a => !a.__transient).map(a => a.id),
+ "PageActions._builtInActions should be initial actions"
+ );
+ Assert.deepEqual(
+ PageActions._nonBuiltInActions.map(a => a.id),
+ [],
+ "Action should no longer be in PageActions._nonBuiltInActions"
+ );
+
+ // Check the panel.
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+ Assert.deepEqual(
+ PageActions.actionsInPanel(window).map(a => a.id),
+ initialActionsInPanel.map(a => a.id),
+ "Action should no longer be in PageActions.actionsInPanel()"
+ );
+ Assert.deepEqual(
+ Array.from(BrowserPageActions.mainViewBodyNode.children, n => n.id),
+ initialActionsInPanel.map(a =>
+ BrowserPageActions.panelButtonNodeIDForActionID(a.id)
+ ),
+ "Action should no longer be in panel"
+ );
+});
+
+// Adds an action, changes its placement in the urlbar to something non-default,
+// removes the action, and then adds it back. Since the action was removed and
+// re-added without restarting the app (or more accurately without calling
+// PageActions._purgeUnregisteredPersistedActions), the action should remain in
+// persisted state and retain its last placement in the urlbar.
+add_task(async function removeRetainState() {
+ // Get the list of actions initially in the urlbar.
+ let initialActionsInUrlbar = PageActions.actionsInUrlbar(window);
+ Assert.ok(
+ !!initialActionsInUrlbar.length,
+ "This test expects there to be at least one action in the urlbar initially (like the bookmark star)"
+ );
+
+ // Add a test action.
+ let id = "test-removeRetainState";
+ let testAction = PageActions.addAction(
+ new PageActions.Action({
+ id,
+ title: "Test removeRetainState",
+ })
+ );
+
+ // Show its button in the urlbar.
+ testAction.pinnedToUrlbar = true;
+
+ // "Move" the test action to the front of the urlbar by toggling
+ // pinnedToUrlbar for all the other actions in the urlbar.
+ for (let action of initialActionsInUrlbar) {
+ action.pinnedToUrlbar = false;
+ action.pinnedToUrlbar = true;
+ }
+
+ // Check the actions in PageActions.actionsInUrlbar.
+ Assert.deepEqual(
+ PageActions.actionsInUrlbar(window).map(a => a.id),
+ [testAction].concat(initialActionsInUrlbar).map(a => a.id),
+ "PageActions.actionsInUrlbar should be in expected order: testAction followed by all initial actions"
+ );
+
+ // Check the nodes in the urlbar.
+ let actualUrlbarNodeIDs = [];
+ for (
+ let node = BrowserPageActions.mainButtonNode.nextElementSibling;
+ node;
+ node = node.nextElementSibling
+ ) {
+ actualUrlbarNodeIDs.push(node.id);
+ }
+ Assert.deepEqual(
+ actualUrlbarNodeIDs,
+ [testAction]
+ .concat(initialActionsInUrlbar)
+ .map(a => BrowserPageActions.urlbarButtonNodeIDForActionID(a.id)),
+ "urlbar nodes should be in expected order: testAction followed by all initial actions"
+ );
+
+ // Remove the test action.
+ testAction.remove();
+
+ // Check the actions in PageActions.actionsInUrlbar.
+ Assert.deepEqual(
+ PageActions.actionsInUrlbar(window).map(a => a.id),
+ initialActionsInUrlbar.map(a => a.id),
+ "PageActions.actionsInUrlbar should be in expected order after removing test action: all initial actions"
+ );
+
+ // Check the nodes in the urlbar.
+ actualUrlbarNodeIDs = [];
+ for (
+ let node = BrowserPageActions.mainButtonNode.nextElementSibling;
+ node;
+ node = node.nextElementSibling
+ ) {
+ actualUrlbarNodeIDs.push(node.id);
+ }
+ Assert.deepEqual(
+ actualUrlbarNodeIDs,
+ initialActionsInUrlbar.map(a =>
+ BrowserPageActions.urlbarButtonNodeIDForActionID(a.id)
+ ),
+ "urlbar nodes should be in expected order after removing test action: all initial actions"
+ );
+
+ // Add the test action again.
+ testAction = PageActions.addAction(
+ new PageActions.Action({
+ id,
+ title: "Test removeRetainState",
+ })
+ );
+
+ // Show its button in the urlbar again.
+ testAction.pinnedToUrlbar = true;
+
+ // Check the actions in PageActions.actionsInUrlbar.
+ Assert.deepEqual(
+ PageActions.actionsInUrlbar(window).map(a => a.id),
+ [testAction].concat(initialActionsInUrlbar).map(a => a.id),
+ "PageActions.actionsInUrlbar should be in expected order after re-adding test action: testAction followed by all initial actions"
+ );
+
+ // Check the nodes in the urlbar.
+ actualUrlbarNodeIDs = [];
+ for (
+ let node = BrowserPageActions.mainButtonNode.nextElementSibling;
+ node;
+ node = node.nextElementSibling
+ ) {
+ actualUrlbarNodeIDs.push(node.id);
+ }
+ Assert.deepEqual(
+ actualUrlbarNodeIDs,
+ [testAction]
+ .concat(initialActionsInUrlbar)
+ .map(a => BrowserPageActions.urlbarButtonNodeIDForActionID(a.id)),
+ "urlbar nodes should be in expected order after re-adding test action: testAction followed by all initial actions"
+ );
+
+ // Done, clean up.
+ testAction.remove();
+});
+
+// Tests transient actions.
+add_task(async function transient() {
+ let initialActionsInPanel = PageActions.actionsInPanel(window);
+
+ let onPlacedInPanelCount = 0;
+ let onBeforePlacedInWindowCount = 0;
+
+ let action = PageActions.addAction(
+ new PageActions.Action({
+ id: "test-transient",
+ title: "Test transient",
+ _transient: true,
+ onPlacedInPanel(buttonNode) {
+ onPlacedInPanelCount++;
+ },
+ onBeforePlacedInWindow(win) {
+ onBeforePlacedInWindowCount++;
+ },
+ })
+ );
+
+ Assert.equal(action.__transient, true, "__transient");
+
+ Assert.equal(onPlacedInPanelCount, 0, "onPlacedInPanelCount should remain 0");
+ Assert.equal(
+ onBeforePlacedInWindowCount,
+ 1,
+ "onBeforePlacedInWindowCount after adding transient action"
+ );
+
+ Assert.deepEqual(
+ PageActions.actionsInPanel(window).map(a => a.id),
+ initialActionsInPanel
+ .map(a => a.id)
+ .concat([PageActions.ACTION_ID_TRANSIENT_SEPARATOR, action.id]),
+ "PageActions.actionsInPanel() should be updated"
+ );
+
+ // Check the panel.
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+ Assert.deepEqual(
+ Array.from(BrowserPageActions.mainViewBodyNode.children, n => n.id),
+ initialActionsInPanel
+ .map(a => a.id)
+ .concat([PageActions.ACTION_ID_TRANSIENT_SEPARATOR, action.id])
+ .map(id => BrowserPageActions.panelButtonNodeIDForActionID(id)),
+ "Actions in panel should be correct"
+ );
+
+ Assert.equal(
+ onPlacedInPanelCount,
+ 1,
+ "onPlacedInPanelCount should be inc'ed"
+ );
+ Assert.equal(
+ onBeforePlacedInWindowCount,
+ 1,
+ "onBeforePlacedInWindowCount should be inc'ed"
+ );
+
+ // Disable the action. It should be removed from the panel.
+ action.setDisabled(true, window);
+
+ Assert.deepEqual(
+ PageActions.actionsInPanel(window).map(a => a.id),
+ initialActionsInPanel.map(a => a.id),
+ "PageActions.actionsInPanel() should revert to initial"
+ );
+
+ // Check the panel.
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+ Assert.deepEqual(
+ Array.from(BrowserPageActions.mainViewBodyNode.children, n => n.id),
+ initialActionsInPanel.map(a =>
+ BrowserPageActions.panelButtonNodeIDForActionID(a.id)
+ ),
+ "Actions in panel should be correct"
+ );
+
+ // Enable the action. It should be added back to the panel.
+ action.setDisabled(false, window);
+
+ Assert.deepEqual(
+ PageActions.actionsInPanel(window).map(a => a.id),
+ initialActionsInPanel
+ .map(a => a.id)
+ .concat([PageActions.ACTION_ID_TRANSIENT_SEPARATOR, action.id]),
+ "PageActions.actionsInPanel() should be updated"
+ );
+
+ // Check the panel.
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+ Assert.deepEqual(
+ Array.from(BrowserPageActions.mainViewBodyNode.children, n => n.id),
+ initialActionsInPanel
+ .map(a => a.id)
+ .concat([PageActions.ACTION_ID_TRANSIENT_SEPARATOR, action.id])
+ .map(id => BrowserPageActions.panelButtonNodeIDForActionID(id)),
+ "Actions in panel should be correct"
+ );
+
+ Assert.equal(
+ onPlacedInPanelCount,
+ 2,
+ "onPlacedInPanelCount should be inc'ed"
+ );
+ Assert.equal(
+ onBeforePlacedInWindowCount,
+ 2,
+ "onBeforePlacedInWindowCount should be inc'ed"
+ );
+
+ // Add another non-built in but non-transient action.
+ let otherAction = PageActions.addAction(
+ new PageActions.Action({
+ id: "test-transient2",
+ title: "Test transient 2",
+ })
+ );
+
+ Assert.deepEqual(
+ PageActions.actionsInPanel(window).map(a => a.id),
+ initialActionsInPanel
+ .map(a => a.id)
+ .concat([
+ PageActions.ACTION_ID_BUILT_IN_SEPARATOR,
+ otherAction.id,
+ PageActions.ACTION_ID_TRANSIENT_SEPARATOR,
+ action.id,
+ ]),
+ "PageActions.actionsInPanel() should be updated"
+ );
+
+ // Check the panel.
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+ Assert.deepEqual(
+ Array.from(BrowserPageActions.mainViewBodyNode.children, n => n.id),
+ initialActionsInPanel
+ .map(a => a.id)
+ .concat([
+ PageActions.ACTION_ID_BUILT_IN_SEPARATOR,
+ otherAction.id,
+ PageActions.ACTION_ID_TRANSIENT_SEPARATOR,
+ action.id,
+ ])
+ .map(id => BrowserPageActions.panelButtonNodeIDForActionID(id)),
+ "Actions in panel should be correct"
+ );
+
+ Assert.equal(
+ onPlacedInPanelCount,
+ 2,
+ "onPlacedInPanelCount should remain the same"
+ );
+ Assert.equal(
+ onBeforePlacedInWindowCount,
+ 2,
+ "onBeforePlacedInWindowCount should remain the same"
+ );
+
+ // Disable the action again. It should be removed from the panel.
+ action.setDisabled(true, window);
+
+ Assert.deepEqual(
+ PageActions.actionsInPanel(window).map(a => a.id),
+ initialActionsInPanel
+ .map(a => a.id)
+ .concat([PageActions.ACTION_ID_BUILT_IN_SEPARATOR, otherAction.id]),
+ "PageActions.actionsInPanel() should be updated"
+ );
+
+ // Check the panel.
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+ Assert.deepEqual(
+ Array.from(BrowserPageActions.mainViewBodyNode.children, n => n.id),
+ initialActionsInPanel
+ .map(a => a.id)
+ .concat([PageActions.ACTION_ID_BUILT_IN_SEPARATOR, otherAction.id])
+ .map(id => BrowserPageActions.panelButtonNodeIDForActionID(id)),
+ "Actions in panel should be correct"
+ );
+
+ // Enable the action again. It should be added back to the panel.
+ action.setDisabled(false, window);
+
+ Assert.deepEqual(
+ PageActions.actionsInPanel(window).map(a => a.id),
+ initialActionsInPanel
+ .map(a => a.id)
+ .concat([
+ PageActions.ACTION_ID_BUILT_IN_SEPARATOR,
+ otherAction.id,
+ PageActions.ACTION_ID_TRANSIENT_SEPARATOR,
+ action.id,
+ ]),
+ "PageActions.actionsInPanel() should be updated"
+ );
+
+ // Check the panel.
+ await promiseOpenPageActionPanel();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+ Assert.deepEqual(
+ Array.from(BrowserPageActions.mainViewBodyNode.children, n => n.id),
+ initialActionsInPanel
+ .map(a => a.id)
+ .concat([
+ PageActions.ACTION_ID_BUILT_IN_SEPARATOR,
+ otherAction.id,
+ PageActions.ACTION_ID_TRANSIENT_SEPARATOR,
+ action.id,
+ ])
+ .map(id => BrowserPageActions.panelButtonNodeIDForActionID(id)),
+ "Actions in panel should be correct"
+ );
+
+ Assert.equal(
+ onPlacedInPanelCount,
+ 3,
+ "onPlacedInPanelCount should be inc'ed"
+ );
+ Assert.equal(
+ onBeforePlacedInWindowCount,
+ 3,
+ "onBeforePlacedInWindowCount should be inc'ed"
+ );
+
+ // Done, clean up.
+ action.remove();
+ otherAction.remove();
+});
diff --git a/browser/modules/test/browser/browser_PageActions_contextMenus.js b/browser/modules/test/browser/browser_PageActions_contextMenus.js
new file mode 100644
index 0000000000..a76a5bcb16
--- /dev/null
+++ b/browser/modules/test/browser/browser_PageActions_contextMenus.js
@@ -0,0 +1,226 @@
+"use strict";
+
+// This is a test for PageActions.jsm, specifically the context menus.
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs",
+});
+
+// Initialization. Must run first.
+add_setup(async function () {
+ // The page action urlbar button, and therefore the panel, is only shown when
+ // the current tab is actionable -- i.e., a normal web page. about:blank is
+ // not, so open a new tab first thing, and close it when this test is done.
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "http://example.com/",
+ });
+ registerCleanupFunction(async () => {
+ BrowserTestUtils.removeTab(tab);
+ });
+
+ await initPageActionsTest();
+});
+
+// Opens the context menu on a non-built-in action. (The context menu for
+// built-in actions is tested in browser_page_action_menu.js.)
+add_task(async function contextMenu() {
+ // Add an extension with a page action so we can test its context menu.
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Page action test",
+ page_action: { show_matches: ["<all_urls>"] },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+ let actionId = ExtensionCommon.makeWidgetId(extension.id);
+
+ // Open the main panel.
+ await promiseOpenPageActionPanel();
+ let panelButton = BrowserPageActions.panelButtonNodeForActionID(actionId);
+ let cxmenu = document.getElementById("pageActionContextMenu");
+
+ let contextMenuPromise;
+ let menuItems;
+
+ // Open the context menu again on the action's button in the panel. (The
+ // panel should still be open.)
+ contextMenuPromise = promisePanelShown("pageActionContextMenu");
+ EventUtils.synthesizeMouseAtCenter(panelButton, {
+ type: "contextmenu",
+ button: 2,
+ });
+ await contextMenuPromise;
+ menuItems = collectContextMenuItems();
+ Assert.deepEqual(makeMenuItemSpecs(menuItems), makeContextMenuItemSpecs());
+
+ // Click the "manage extension" context menu item. about:addons should open.
+ let manageItemIndex = 0;
+ contextMenuPromise = promisePanelHidden("pageActionContextMenu");
+ let aboutAddonsPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "about:addons"
+ );
+ cxmenu.activateItem(menuItems[manageItemIndex]);
+ let values = await Promise.all([aboutAddonsPromise, contextMenuPromise]);
+ let aboutAddonsTab = values[0];
+ BrowserTestUtils.removeTab(aboutAddonsTab);
+
+ // Wait for the urlbar button to become visible again after about:addons is
+ // closed and the test tab becomes selected.
+ await BrowserTestUtils.waitForCondition(() => {
+ return BrowserPageActions.urlbarButtonNodeForActionID(actionId);
+ }, "Waiting for urlbar button to be added back");
+
+ // Open the context menu on the action's urlbar button.
+ let urlbarButton = BrowserPageActions.urlbarButtonNodeForActionID(actionId);
+ contextMenuPromise = promisePanelShown("pageActionContextMenu");
+ EventUtils.synthesizeMouseAtCenter(urlbarButton, {
+ type: "contextmenu",
+ button: 2,
+ });
+ await contextMenuPromise;
+ menuItems = collectContextMenuItems();
+ Assert.deepEqual(makeMenuItemSpecs(menuItems), makeContextMenuItemSpecs());
+
+ // Click the "manage" context menu item. about:addons should open.
+ contextMenuPromise = promisePanelHidden("pageActionContextMenu");
+ aboutAddonsPromise = BrowserTestUtils.waitForNewTab(gBrowser, "about:addons");
+ cxmenu.activateItem(menuItems[manageItemIndex]);
+ values = await Promise.all([aboutAddonsPromise, contextMenuPromise]);
+ aboutAddonsTab = values[0];
+ BrowserTestUtils.removeTab(aboutAddonsTab);
+
+ // Wait for the urlbar button to become visible again after about:addons is
+ // closed and the test tab becomes selected.
+ await BrowserTestUtils.waitForCondition(() => {
+ return BrowserPageActions.urlbarButtonNodeForActionID(actionId);
+ }, "Waiting for urlbar button to be added back");
+
+ // Open the context menu on the action's urlbar button.
+ urlbarButton = BrowserPageActions.urlbarButtonNodeForActionID(actionId);
+ contextMenuPromise = promisePanelShown("pageActionContextMenu");
+ EventUtils.synthesizeMouseAtCenter(urlbarButton, {
+ type: "contextmenu",
+ button: 2,
+ });
+ await contextMenuPromise;
+ menuItems = collectContextMenuItems();
+ Assert.deepEqual(makeMenuItemSpecs(menuItems), makeContextMenuItemSpecs());
+
+ // Below we'll click the "remove extension" context menu item, which first
+ // opens a prompt using the prompt service and requires confirming the prompt.
+ // Set up a mock prompt service that returns 0 to indicate that the user
+ // pressed the OK button.
+ let { prompt } = Services;
+ let promptService = {
+ QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]),
+ confirmEx() {
+ return 0;
+ },
+ };
+ Services.prompt = promptService;
+ registerCleanupFunction(() => {
+ Services.prompt = prompt;
+ });
+
+ // Now click the "remove extension" context menu item.
+ let removeItemIndex = manageItemIndex + 1;
+ contextMenuPromise = promisePanelHidden("pageActionContextMenu");
+ let promiseUninstalled = promiseAddonUninstalled(extension.id);
+ cxmenu.activateItem(menuItems[removeItemIndex]);
+ await contextMenuPromise;
+ await promiseUninstalled;
+ await extension.unload();
+ Services.prompt = prompt;
+
+ // urlbar tests that run after this one can break if the mouse is left over
+ // the area where the urlbar popup appears, which seems to happen due to the
+ // above synthesized mouse events. Move it over the urlbar.
+ EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, { type: "mousemove" });
+ gURLBar.focus();
+});
+
+// The context menu shouldn't open on separators in the panel.
+add_task(async function contextMenuOnSeparator() {
+ // Add a non-built-in action so the built-in separator will appear in the
+ // panel.
+ let action = PageActions.addAction(
+ new PageActions.Action({
+ id: "contextMenuOnSeparator",
+ title: "contextMenuOnSeparator",
+ pinnedToUrlbar: true,
+ })
+ );
+
+ // Open the panel and get the built-in separator.
+ await promiseOpenPageActionPanel();
+ let separator = BrowserPageActions.panelButtonNodeForActionID(
+ PageActions.ACTION_ID_BUILT_IN_SEPARATOR
+ );
+ Assert.ok(separator, "The built-in separator should be in the panel");
+
+ // Context-click it. popupshowing should be fired, but by the time the event
+ // reaches this listener, preventDefault should have been called on it.
+ let showingPromise = BrowserTestUtils.waitForEvent(
+ document.getElementById("pageActionContextMenu"),
+ "popupshowing",
+ false
+ );
+ EventUtils.synthesizeMouseAtCenter(separator, {
+ type: "contextmenu",
+ button: 2,
+ });
+ let event = await showingPromise;
+ Assert.ok(
+ event.defaultPrevented,
+ "defaultPrevented should be true on popupshowing event"
+ );
+
+ // Click the main button to hide the main panel.
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ await promisePageActionPanelHidden();
+
+ action.remove();
+
+ // urlbar tests that run after this one can break if the mouse is left over
+ // the area where the urlbar popup appears, which seems to happen due to the
+ // above synthesized mouse events. Move it over the urlbar.
+ EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, { type: "mousemove" });
+ gURLBar.focus();
+});
+
+function collectContextMenuItems() {
+ let contextMenu = document.getElementById("pageActionContextMenu");
+ return Array.prototype.filter.call(contextMenu.children, node => {
+ return window.getComputedStyle(node).visibility == "visible";
+ });
+}
+
+function makeMenuItemSpecs(elements) {
+ return elements.map(e =>
+ e.localName == "menuseparator" ? {} : { label: e.label }
+ );
+}
+
+function makeContextMenuItemSpecs() {
+ let items = [
+ { label: "Manage Extension\u2026" },
+ { label: "Remove Extension" },
+ ];
+ return items;
+}
+
+function promiseAddonUninstalled(addonId) {
+ return new Promise(resolve => {
+ let listener = {};
+ listener.onUninstalled = addon => {
+ if (addon.id == addonId) {
+ AddonManager.removeAddonListener(listener);
+ resolve();
+ }
+ };
+ AddonManager.addAddonListener(listener);
+ });
+}
diff --git a/browser/modules/test/browser/browser_PageActions_newWindow.js b/browser/modules/test/browser/browser_PageActions_newWindow.js
new file mode 100644
index 0000000000..e351727bff
--- /dev/null
+++ b/browser/modules/test/browser/browser_PageActions_newWindow.js
@@ -0,0 +1,377 @@
+"use strict";
+
+// This is a test for PageActions.jsm, specifically the generalized parts that
+// add and remove page actions and toggle them in the urlbar. This does not
+// test the built-in page actions; browser_page_action_menu.js does that.
+
+// Initialization. Must run first.
+add_setup(async function () {
+ await initPageActionsTest();
+});
+
+// Makes sure that urlbar nodes appear in the correct order in a new window.
+add_task(async function urlbarOrderNewWindow() {
+ // Make some new actions.
+ let actions = [0, 1, 2].map(i => {
+ return PageActions.addAction(
+ new PageActions.Action({
+ id: `test-urlbarOrderNewWindow-${i}`,
+ title: `Test urlbarOrderNewWindow ${i}`,
+ pinnedToUrlbar: true,
+ })
+ );
+ });
+
+ // Make sure PageActions knows they're inserted before the bookmark action in
+ // the urlbar.
+ Assert.deepEqual(
+ PageActions._persistedActions.idsInUrlbar.slice(
+ PageActions._persistedActions.idsInUrlbar.length - (actions.length + 1)
+ ),
+ actions.map(a => a.id).concat([PageActions.ACTION_ID_BOOKMARK]),
+ "PageActions._persistedActions.idsInUrlbar has new actions inserted"
+ );
+ Assert.deepEqual(
+ PageActions.actionsInUrlbar(window)
+ .slice(PageActions.actionsInUrlbar(window).length - (actions.length + 1))
+ .map(a => a.id),
+ actions.map(a => a.id).concat([PageActions.ACTION_ID_BOOKMARK]),
+ "PageActions.actionsInUrlbar has new actions inserted"
+ );
+
+ // Reach into _persistedActions to move the new actions to the front of the
+ // urlbar, same as if the user moved them. That way we can test that insert-
+ // before IDs are correctly non-null when the urlbar nodes are inserted in the
+ // new window below.
+ PageActions._persistedActions.idsInUrlbar.splice(
+ PageActions._persistedActions.idsInUrlbar.length - (actions.length + 1),
+ actions.length
+ );
+ for (let i = 0; i < actions.length; i++) {
+ PageActions._persistedActions.idsInUrlbar.splice(i, 0, actions[i].id);
+ }
+
+ // Save the right-ordered IDs to use below, just in case they somehow get
+ // changed when the new window opens, which shouldn't happen, but maybe
+ // there's bugs.
+ let ids = PageActions._persistedActions.idsInUrlbar.slice();
+
+ // Make sure that worked.
+ Assert.deepEqual(
+ ids.slice(0, actions.length),
+ actions.map(a => a.id),
+ "PageActions._persistedActions.idsInUrlbar now has new actions at front"
+ );
+
+ // _persistedActions will contain the IDs of test actions added and removed
+ // above (unless PageActions._purgeUnregisteredPersistedActions() was called
+ // for all of them, which it wasn't). Filter them out because they should
+ // not appear in the new window (or any window at this point).
+ ids = ids.filter(id => PageActions.actionForID(id));
+
+ // Open the new window.
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+
+ // Collect its urlbar nodes.
+ let actualUrlbarNodeIDs = [];
+ for (
+ let node = win.BrowserPageActions.mainButtonNode.nextElementSibling;
+ node;
+ node = node.nextElementSibling
+ ) {
+ actualUrlbarNodeIDs.push(node.id);
+ }
+
+ // Now check that they're in the right order.
+ Assert.deepEqual(
+ actualUrlbarNodeIDs,
+ ids.map(id => win.BrowserPageActions.urlbarButtonNodeIDForActionID(id)),
+ "Expected actions in new window's urlbar"
+ );
+
+ // Done, clean up.
+ await BrowserTestUtils.closeWindow(win);
+ for (let action of actions) {
+ action.remove();
+ }
+});
+
+// Stores version-0 (unversioned actually) persisted actions and makes sure that
+// migrating to version 1 works.
+add_task(async function migrate1() {
+ // Add a test action so we can test a non-built-in action below.
+ let actionId = "test-migrate1";
+ PageActions.addAction(
+ new PageActions.Action({
+ id: actionId,
+ title: "Test migrate1",
+ pinnedToUrlbar: true,
+ })
+ );
+
+ // Add the bookmark action first to make sure it ends up last after migration.
+ // Also include a non-default action to make sure we're not accidentally
+ // testing default behavior.
+ let ids = [PageActions.ACTION_ID_BOOKMARK, actionId];
+ let persisted = ids.reduce(
+ (memo, id) => {
+ memo.ids[id] = true;
+ memo.idsInUrlbar.push(id);
+ return memo;
+ },
+ { ids: {}, idsInUrlbar: [] }
+ );
+
+ Services.prefs.setStringPref(
+ PageActions.PREF_PERSISTED_ACTIONS,
+ JSON.stringify(persisted)
+ );
+
+ // Migrate.
+ PageActions._loadPersistedActions();
+
+ Assert.equal(PageActions._persistedActions.version, 1, "Correct version");
+
+ // expected order
+ let orderedIDs = [actionId, PageActions.ACTION_ID_BOOKMARK];
+
+ // Check the ordering.
+ Assert.deepEqual(
+ PageActions._persistedActions.idsInUrlbar,
+ orderedIDs,
+ "PageActions._persistedActions.idsInUrlbar has right order"
+ );
+ Assert.deepEqual(
+ PageActions.actionsInUrlbar(window).map(a => a.id),
+ orderedIDs,
+ "PageActions.actionsInUrlbar has right order"
+ );
+
+ // Open a new window.
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser: win.gBrowser,
+ url: "http://example.com/",
+ });
+
+ // Collect its urlbar nodes.
+ let actualUrlbarNodeIDs = [];
+ for (
+ let node = win.BrowserPageActions.mainButtonNode.nextElementSibling;
+ node;
+ node = node.nextElementSibling
+ ) {
+ actualUrlbarNodeIDs.push(node.id);
+ }
+
+ // Now check that they're in the right order.
+ Assert.deepEqual(
+ actualUrlbarNodeIDs,
+ orderedIDs.map(id =>
+ win.BrowserPageActions.urlbarButtonNodeIDForActionID(id)
+ ),
+ "Expected actions in new window's urlbar"
+ );
+
+ // Done, clean up.
+ await BrowserTestUtils.closeWindow(win);
+ Services.prefs.clearUserPref(PageActions.PREF_PERSISTED_ACTIONS);
+ PageActions.actionForID(actionId).remove();
+});
+
+// Opens a new browser window and makes sure per-window state works right.
+add_task(async function perWindowState() {
+ // Add a test action.
+ let title = "Test perWindowState";
+ let action = PageActions.addAction(
+ new PageActions.Action({
+ iconURL: "chrome://browser/skin/mail.svg",
+ id: "test-perWindowState",
+ pinnedToUrlbar: true,
+ title,
+ })
+ );
+
+ let actionsInUrlbar = PageActions.actionsInUrlbar(window);
+
+ // Open a new browser window and load an actionable page so that the action
+ // shows up in it.
+ let newWindow = await BrowserTestUtils.openNewBrowserWindow();
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser: newWindow.gBrowser,
+ url: "http://example.com/",
+ });
+
+ // Set a new title globally.
+ let newGlobalTitle = title + " new title";
+ action.setTitle(newGlobalTitle);
+ Assert.equal(action.getTitle(), newGlobalTitle, "Title: global");
+ Assert.equal(action.getTitle(window), newGlobalTitle, "Title: old window");
+ Assert.equal(action.getTitle(newWindow), newGlobalTitle, "Title: new window");
+
+ // Initialize panel nodes in the windows
+ document.getElementById("pageActionButton").click();
+ await BrowserTestUtils.waitForEvent(document, "popupshowing", true);
+ newWindow.document.getElementById("pageActionButton").click();
+ await BrowserTestUtils.waitForEvent(newWindow.document, "popupshowing", true);
+
+ // The action's panel button nodes should be updated in both windows.
+ let panelButtonID = BrowserPageActions.panelButtonNodeIDForActionID(
+ action.id
+ );
+ for (let win of [window, newWindow]) {
+ win.BrowserPageActions.placeLazyActionsInPanel();
+ let panelButtonNode = win.document.getElementById(panelButtonID);
+ Assert.equal(
+ panelButtonNode.getAttribute("label"),
+ newGlobalTitle,
+ "Panel button label should be global title"
+ );
+ }
+
+ // Set a new title in the new window.
+ let newPerWinTitle = title + " new title in new window";
+ action.setTitle(newPerWinTitle, newWindow);
+ Assert.equal(
+ action.getTitle(),
+ newGlobalTitle,
+ "Title: global should remain same"
+ );
+ Assert.equal(
+ action.getTitle(window),
+ newGlobalTitle,
+ "Title: old window should remain same"
+ );
+ Assert.equal(
+ action.getTitle(newWindow),
+ newPerWinTitle,
+ "Title: new window should be new"
+ );
+
+ // The action's panel button node should be updated in the new window but the
+ // same in the old window.
+ let panelButtonNode1 = document.getElementById(panelButtonID);
+ Assert.equal(
+ panelButtonNode1.getAttribute("label"),
+ newGlobalTitle,
+ "Panel button label in old window"
+ );
+ let panelButtonNode2 = newWindow.document.getElementById(panelButtonID);
+ Assert.equal(
+ panelButtonNode2.getAttribute("label"),
+ newPerWinTitle,
+ "Panel button label in new window"
+ );
+
+ // Disable the action in the new window.
+ action.setDisabled(true, newWindow);
+ Assert.equal(
+ action.getDisabled(),
+ false,
+ "Disabled: global should remain false"
+ );
+ Assert.equal(
+ action.getDisabled(window),
+ false,
+ "Disabled: old window should remain false"
+ );
+ Assert.equal(
+ action.getDisabled(newWindow),
+ true,
+ "Disabled: new window should be true"
+ );
+
+ // Check PageActions.actionsInUrlbar for each window.
+ Assert.deepEqual(
+ PageActions.actionsInUrlbar(window).map(a => a.id),
+ actionsInUrlbar.map(a => a.id),
+ "PageActions.actionsInUrlbar: old window should have all actions in urlbar"
+ );
+ Assert.deepEqual(
+ PageActions.actionsInUrlbar(newWindow).map(a => a.id),
+ actionsInUrlbar.map(a => a.id).filter(id => id != action.id),
+ "PageActions.actionsInUrlbar: new window should have all actions in urlbar except the test action"
+ );
+
+ // Check the urlbar nodes for the old window.
+ let actualUrlbarNodeIDs = [];
+ for (
+ let node = BrowserPageActions.mainButtonNode.nextElementSibling;
+ node;
+ node = node.nextElementSibling
+ ) {
+ actualUrlbarNodeIDs.push(node.id);
+ }
+ Assert.deepEqual(
+ actualUrlbarNodeIDs,
+ actionsInUrlbar.map(a =>
+ BrowserPageActions.urlbarButtonNodeIDForActionID(a.id)
+ ),
+ "Old window should have all nodes in urlbar"
+ );
+
+ // Check the urlbar nodes for the new window.
+ actualUrlbarNodeIDs = [];
+ for (
+ let node = newWindow.BrowserPageActions.mainButtonNode.nextElementSibling;
+ node;
+ node = node.nextElementSibling
+ ) {
+ actualUrlbarNodeIDs.push(node.id);
+ }
+ Assert.deepEqual(
+ actualUrlbarNodeIDs,
+ actionsInUrlbar
+ .filter(a => a.id != action.id)
+ .map(a => BrowserPageActions.urlbarButtonNodeIDForActionID(a.id)),
+ "New window should have all nodes in urlbar except for the test action's"
+ );
+
+ // Done, clean up.
+ await BrowserTestUtils.closeWindow(newWindow);
+ action.remove();
+});
+
+add_task(async function action_disablePrivateBrowsing() {
+ let id = "testWidget";
+ let action = PageActions.addAction(
+ new PageActions.Action({
+ id,
+ disablePrivateBrowsing: true,
+ title: "title",
+ disabled: false,
+ pinnedToUrlbar: true,
+ })
+ );
+ // Open an actionable page so that the main page action button appears.
+ let url = "http://example.com/";
+ let privateWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ await BrowserTestUtils.openNewForegroundTab(
+ privateWindow.gBrowser,
+ url,
+ true,
+ true
+ );
+
+ Assert.ok(action.canShowInWindow(window), "should show in default window");
+ Assert.ok(
+ !action.canShowInWindow(privateWindow),
+ "should not show in private browser"
+ );
+ Assert.ok(action.shouldShowInUrlbar(window), "should show in default urlbar");
+ Assert.ok(
+ !action.shouldShowInUrlbar(privateWindow),
+ "should not show in default urlbar"
+ );
+ Assert.ok(action.shouldShowInPanel(window), "should show in default urlbar");
+ Assert.ok(
+ !action.shouldShowInPanel(privateWindow),
+ "should not show in default urlbar"
+ );
+
+ action.remove();
+
+ privateWindow.close();
+});
diff --git a/browser/modules/test/browser/browser_PartnerLinkAttribution.js b/browser/modules/test/browser/browser_PartnerLinkAttribution.js
new file mode 100644
index 0000000000..08e393694d
--- /dev/null
+++ b/browser/modules/test/browser/browser_PartnerLinkAttribution.js
@@ -0,0 +1,428 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/**
+ * This file tests urlbar telemetry with search related actions.
+ */
+
+"use strict";
+
+const SCALAR_URLBAR = "browser.engagement.navigation.urlbar";
+
+// The preference to enable suggestions in the urlbar.
+const SUGGEST_URLBAR_PREF = "browser.urlbar.suggest.searches";
+// The name of the search engine used to generate suggestions.
+const SUGGESTION_ENGINE_NAME =
+ "browser_UsageTelemetry usageTelemetrySearchSuggestions.xml";
+
+ChromeUtils.defineESModuleGetters(this, {
+ CustomizableUITestUtils:
+ "resource://testing-common/CustomizableUITestUtils.sys.mjs",
+
+ Region: "resource://gre/modules/Region.sys.mjs",
+ SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs",
+ UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ HttpServer: "resource://testing-common/httpd.js",
+});
+
+let gCUITestUtils = new CustomizableUITestUtils(window);
+SearchTestUtils.init(this);
+
+var gHttpServer = null;
+var gRequests = [];
+
+function submitHandler(request, response) {
+ gRequests.push(request);
+ response.setStatusLine(request.httpVersion, 200, "Ok");
+}
+
+add_setup(async function () {
+ // Ensure the initial init is complete.
+ await Services.search.init();
+
+ // Clear history so that history added by previous tests doesn't mess up this
+ // test when it selects results in the urlbar.
+ await PlacesUtils.history.clear();
+
+ let searchExtensions = getChromeDir(getResolvedURI(gTestPath));
+ searchExtensions.append("search-engines");
+
+ await SearchTestUtils.useMochitestEngines(searchExtensions);
+
+ SearchTestUtils.useMockIdleService();
+ let response = await fetch(`resource://search-extensions/engines.json`);
+ let json = await response.json();
+ await SearchTestUtils.updateRemoteSettingsConfig(json.data);
+
+ let topsitesAttribution = Services.prefs.getStringPref(
+ "browser.partnerlink.campaign.topsites"
+ );
+ gHttpServer = new HttpServer();
+ gHttpServer.registerPathHandler(`/cid/${topsitesAttribution}`, submitHandler);
+ gHttpServer.start(-1);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Enable search suggestions in the urlbar.
+ [SUGGEST_URLBAR_PREF, true],
+ // Clear historical search suggestions to avoid interference from previous
+ // tests.
+ ["browser.urlbar.maxHistoricalSearchSuggestions", 0],
+ [
+ "browser.partnerlink.attributionURL",
+ `http://localhost:${gHttpServer.identity.primaryPort}/cid/`,
+ ],
+ ],
+ });
+
+ await gCUITestUtils.addSearchBar();
+
+ // Make sure to restore the engine once we're done.
+ registerCleanupFunction(async function () {
+ let settingsWritten = SearchTestUtils.promiseSearchNotification(
+ "write-settings-to-disk-complete"
+ );
+ await SearchTestUtils.updateRemoteSettingsConfig();
+ await gHttpServer.stop();
+ gHttpServer = null;
+ await PlacesUtils.history.clear();
+ gCUITestUtils.removeSearchBar();
+ await settingsWritten;
+ });
+});
+
+function searchInAwesomebar(value, win = window) {
+ return UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window: win,
+ waitForFocus,
+ value,
+ fireInputEvent: true,
+ });
+}
+
+async function searchInSearchbar(inputText) {
+ let win = window;
+ await new Promise(r => waitForFocus(r, win));
+ let sb = BrowserSearch.searchBar;
+ // Write the search query in the searchbar.
+ sb.focus();
+ sb.value = inputText;
+ sb.textbox.controller.startSearch(inputText);
+ // Wait for the popup to be shown and built.
+ let popup = sb.textbox.popup;
+ await Promise.all([
+ BrowserTestUtils.waitForEvent(popup, "popupshown"),
+ BrowserTestUtils.waitForEvent(popup.oneOffButtons, "rebuild"),
+ ]);
+ // And then for the search to complete.
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ sb.textbox.controller.searchStatus >=
+ Ci.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH,
+ "The search in the searchbar must complete."
+ );
+}
+
+add_task(async function test_simpleQuery_no_attribution() {
+ await Services.search.setDefault(
+ Services.search.getEngineByName("Simple Engine"),
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+
+ info("Simulate entering a simple search.");
+ let promiseLoad = BrowserTestUtils.waitForDocLoadAndStopIt(
+ "https://example.com/?sourceId=Mozilla-search&search=simple+query",
+ tab
+ );
+ await searchInAwesomebar("simple query");
+ EventUtils.synthesizeKey("KEY_Enter");
+ await promiseLoad;
+
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+
+ Assert.equal(gRequests.length, 0, "Should not have submitted an attribution");
+
+ BrowserTestUtils.removeTab(tab);
+
+ await Services.search.setDefault(
+ Services.search.getEngineByName("basic"),
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+});
+
+async function checkAttributionRecorded(actionFn, cleanupFn) {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "data:text/plain;charset=utf8,simple%20query"
+ );
+
+ let promiseLoad = BrowserTestUtils.waitForDocLoadAndStopIt(
+ "https://mochi.test:8888/browser/browser/components/search/test/browser/?search=simple+query&foo=1",
+ tab
+ );
+ await actionFn(tab);
+ await promiseLoad;
+
+ await BrowserTestUtils.waitForCondition(
+ () => gRequests.length == 1,
+ "Should have received an attribution submission"
+ );
+ Assert.equal(
+ gRequests[0].getHeader("X-Region"),
+ Region.home,
+ "Should have set the region correctly"
+ );
+ Assert.equal(
+ gRequests[0].getHeader("X-Source"),
+ "searchurl",
+ "Should have set the source correctly"
+ );
+ Assert.equal(
+ gRequests[0].getHeader("X-Target-url"),
+ "https://mochi.test:8888/browser/browser/components/search/test/browser/?foo=1",
+ "Should have set the target url correctly and stripped the search terms"
+ );
+ if (cleanupFn) {
+ await cleanupFn();
+ }
+ BrowserTestUtils.removeTab(tab);
+ gRequests = [];
+}
+
+add_task(async function test_urlbar() {
+ await checkAttributionRecorded(async tab => {
+ await searchInAwesomebar("simple query");
+ EventUtils.synthesizeKey("KEY_Enter");
+ });
+});
+
+add_task(async function test_searchbar() {
+ await checkAttributionRecorded(async tab => {
+ let sb = BrowserSearch.searchBar;
+ // Write the search query in the searchbar.
+ sb.focus();
+ sb.value = "simple query";
+ sb.textbox.controller.startSearch("simple query");
+ // Wait for the popup to show.
+ await BrowserTestUtils.waitForEvent(sb.textbox.popup, "popupshown");
+ // And then for the search to complete.
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ sb.textbox.controller.searchStatus >=
+ Ci.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH,
+ "The search in the searchbar must complete."
+ );
+ EventUtils.synthesizeKey("KEY_Enter");
+ });
+});
+
+add_task(async function test_context_menu() {
+ let contextMenu;
+ await checkAttributionRecorded(
+ async tab => {
+ info("Select all the text in the page.");
+ await SpecialPowers.spawn(tab.linkedBrowser, [""], async function () {
+ return new Promise(resolve => {
+ content.document.addEventListener(
+ "selectionchange",
+ () => resolve(),
+ {
+ once: true,
+ }
+ );
+ content.document
+ .getSelection()
+ .selectAllChildren(content.document.body);
+ });
+ });
+
+ info("Open the context menu.");
+ contextMenu = document.getElementById("contentAreaContextMenu");
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "body",
+ { type: "contextmenu", button: 2 },
+ gBrowser.selectedBrowser
+ );
+ await shownPromise;
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+
+ info("Click on search.");
+ let searchItem = contextMenu.querySelector("#context-searchselect");
+ contextMenu.activateItem(searchItem);
+ await hiddenPromise;
+ },
+ () => {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+ );
+});
+
+add_task(async function test_about_newtab() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar",
+ false,
+ ],
+ ],
+ });
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:newtab",
+ false
+ );
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ await ContentTaskUtils.waitForCondition(() => !content.document.hidden);
+ });
+
+ info("Trigger a simple search, just text + enter.");
+ let promiseLoad = BrowserTestUtils.waitForDocLoadAndStopIt(
+ "https://mochi.test:8888/browser/browser/components/search/test/browser/?search=simple+query&foo=1",
+ tab
+ );
+ await typeInSearchField(
+ tab.linkedBrowser,
+ "simple query",
+ "newtab-search-text"
+ );
+ await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, tab.linkedBrowser);
+ await promiseLoad;
+
+ await BrowserTestUtils.waitForCondition(
+ () => gRequests.length == 1,
+ "Should have received an attribution submission"
+ );
+ Assert.equal(
+ gRequests[0].getHeader("X-Region"),
+ Region.home,
+ "Should have set the region correctly"
+ );
+ Assert.equal(
+ gRequests[0].getHeader("X-Source"),
+ "searchurl",
+ "Should have set the source correctly"
+ );
+ Assert.equal(
+ gRequests[0].getHeader("X-Target-url"),
+ "https://mochi.test:8888/browser/browser/components/search/test/browser/?foo=1",
+ "Should have set the target url correctly and stripped the search terms"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+ await SpecialPowers.popPrefEnv();
+ gRequests = [];
+});
+
+add_task(async function test_urlbar_oneOff_click() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+
+ info("Type a query.");
+ let promiseLoad = BrowserTestUtils.waitForDocLoadAndStopIt(
+ "https://mochi.test:8888/browser/browser/components/search/test/browser/?search=query&foo=1",
+ tab
+ );
+ await searchInAwesomebar("query");
+ info("Click the first one-off button.");
+ UrlbarTestUtils.getOneOffSearchButtons(window)
+ .getSelectableButtons(false)[0]
+ .click();
+ EventUtils.synthesizeKey("KEY_Enter");
+ await promiseLoad;
+
+ await BrowserTestUtils.waitForCondition(
+ () => gRequests.length == 1,
+ "Should have received an attribution submission"
+ );
+ Assert.equal(
+ gRequests[0].getHeader("X-Region"),
+ Region.home,
+ "Should have set the region correctly"
+ );
+ Assert.equal(
+ gRequests[0].getHeader("X-Source"),
+ "searchurl",
+ "Should have set the source correctly"
+ );
+ Assert.equal(
+ gRequests[0].getHeader("X-Target-url"),
+ "https://mochi.test:8888/browser/browser/components/search/test/browser/?foo=1",
+ "Should have set the target url correctly and stripped the search terms"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+ gRequests = [];
+});
+
+add_task(async function test_searchbar_oneOff_click() {
+ // For this test, set the other engine as default, so that we can select
+ // the attribution engine as the first one in the one-offs.
+ await Services.search.setDefault(
+ Services.search.getEngineByName("Simple Engine"),
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+
+ info("Type a query.");
+ let promiseLoad = BrowserTestUtils.waitForDocLoadAndStopIt(
+ "https://mochi.test:8888/browser/browser/components/search/test/browser/?search=searchbar&foo=1",
+ tab
+ );
+ await searchInSearchbar("searchbar");
+ info("Click the first one-off button.");
+ BrowserSearch.searchBar.textbox.popup.oneOffButtons
+ .getSelectableButtons(false)[0]
+ .click();
+ await promiseLoad;
+
+ await BrowserTestUtils.waitForCondition(
+ () => gRequests.length == 1,
+ "Should have received an attribution submission"
+ );
+ Assert.equal(
+ gRequests[0].getHeader("X-Region"),
+ Region.home,
+ "Should have set the region correctly"
+ );
+ Assert.equal(
+ gRequests[0].getHeader("X-Source"),
+ "searchurl",
+ "Should have set the source correctly"
+ );
+ Assert.equal(
+ gRequests[0].getHeader("X-Target-url"),
+ "https://mochi.test:8888/browser/browser/components/search/test/browser/?foo=1",
+ "Should have set the target url correctly and stripped the search terms"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+ // Set back the engine in case of other tests in this file.
+ await Services.search.setDefault(
+ Services.search.getEngineByName("basic"),
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ gRequests = [];
+});
diff --git a/browser/modules/test/browser/browser_PermissionUI.js b/browser/modules/test/browser/browser_PermissionUI.js
new file mode 100644
index 0000000000..8b66734093
--- /dev/null
+++ b/browser/modules/test/browser/browser_PermissionUI.js
@@ -0,0 +1,692 @@
+/**
+ * These tests test the ability for the PermissionUI module to open
+ * permission prompts to the user. It also tests to ensure that
+ * add-ons can introduce their own permission prompts.
+ */
+
+"use strict";
+
+const { PermissionUI } = ChromeUtils.importESModule(
+ "resource:///modules/PermissionUI.sys.mjs"
+);
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+/**
+ * Tests the PermissionPromptForRequest prototype to ensure that a prompt
+ * can be displayed. Does not test permission handling.
+ */
+add_task(async function test_permission_prompt_for_request() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "http://example.com/",
+ },
+ async function (browser) {
+ const kTestNotificationID = "test-notification";
+ const kTestMessage = "Test message";
+ let mainAction = {
+ label: "Main",
+ accessKey: "M",
+ };
+ let secondaryAction = {
+ label: "Secondary",
+ accessKey: "S",
+ };
+
+ let mockRequest = makeMockPermissionRequest(browser);
+ class TestPrompt extends PermissionUI.PermissionPromptForRequest {
+ get request() {
+ return mockRequest;
+ }
+ get notificationID() {
+ return kTestNotificationID;
+ }
+ get message() {
+ return kTestMessage;
+ }
+ get promptActions() {
+ return [mainAction, secondaryAction];
+ }
+ }
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ new TestPrompt().prompt();
+ await shownPromise;
+ let notification = PopupNotifications.getNotification(
+ kTestNotificationID,
+ browser
+ );
+ Assert.ok(notification, "Should have gotten the notification");
+
+ Assert.equal(
+ notification.message,
+ kTestMessage,
+ "Should be showing the right message"
+ );
+ Assert.equal(
+ notification.mainAction.label,
+ mainAction.label,
+ "The main action should have the right label"
+ );
+ Assert.equal(
+ notification.mainAction.accessKey,
+ mainAction.accessKey,
+ "The main action should have the right access key"
+ );
+ Assert.equal(
+ notification.secondaryActions.length,
+ 1,
+ "There should only be 1 secondary action"
+ );
+ Assert.equal(
+ notification.secondaryActions[0].label,
+ secondaryAction.label,
+ "The secondary action should have the right label"
+ );
+ Assert.equal(
+ notification.secondaryActions[0].accessKey,
+ secondaryAction.accessKey,
+ "The secondary action should have the right access key"
+ );
+ Assert.ok(
+ notification.options.displayURI.equals(mockRequest.principal.URI),
+ "Should be showing the URI of the requesting page"
+ );
+
+ let removePromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ notification.remove();
+ await removePromise;
+ }
+ );
+});
+
+/**
+ * Tests that if the PermissionPrompt sets displayURI to false in popupOptions,
+ * then there is no URI shown on the popupnotification.
+ */
+add_task(async function test_permission_prompt_for_popupOptions() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "http://example.com/",
+ },
+ async function (browser) {
+ const kTestNotificationID = "test-notification";
+ const kTestMessage = "Test message";
+ let mainAction = {
+ label: "Main",
+ accessKey: "M",
+ };
+ let secondaryAction = {
+ label: "Secondary",
+ accessKey: "S",
+ };
+
+ let mockRequest = makeMockPermissionRequest(browser);
+ class TestPrompt extends PermissionUI.PermissionPromptForRequest {
+ get request() {
+ return mockRequest;
+ }
+ get notificationID() {
+ return kTestNotificationID;
+ }
+ get message() {
+ return kTestMessage;
+ }
+ get promptActions() {
+ return [mainAction, secondaryAction];
+ }
+ get popupOptions() {
+ return {
+ displayURI: false,
+ };
+ }
+ }
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ new TestPrompt().prompt();
+ await shownPromise;
+ let notification = PopupNotifications.getNotification(
+ kTestNotificationID,
+ browser
+ );
+
+ Assert.ok(
+ !notification.options.displayURI,
+ "Should not show the URI of the requesting page"
+ );
+
+ let removePromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ notification.remove();
+ await removePromise;
+ }
+ );
+});
+
+/**
+ * Tests that if the PermissionPrompt has the permissionKey
+ * set that permissions can be set properly by the user. Also
+ * ensures that callbacks for promptActions are properly fired.
+ */
+add_task(async function test_with_permission_key() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "http://example.com",
+ },
+ async function (browser) {
+ const kTestNotificationID = "test-notification";
+ const kTestMessage = "Test message";
+ const kTestPermissionKey = "test-permission-key";
+
+ let allowed = false;
+ let mainAction = {
+ label: "Allow",
+ accessKey: "M",
+ action: SitePermissions.ALLOW,
+ callback() {
+ allowed = true;
+ },
+ };
+
+ let denied = false;
+ let secondaryAction = {
+ label: "Deny",
+ accessKey: "D",
+ action: SitePermissions.BLOCK,
+ callback() {
+ denied = true;
+ },
+ };
+
+ let mockRequest = makeMockPermissionRequest(browser);
+ let principal = mockRequest.principal;
+ registerCleanupFunction(function () {
+ PermissionTestUtils.remove(principal.URI, kTestPermissionKey);
+ });
+ class TestPrompt extends PermissionUI.PermissionPromptForRequest {
+ get request() {
+ return mockRequest;
+ }
+ get notificationID() {
+ return kTestNotificationID;
+ }
+ get permissionKey() {
+ return kTestPermissionKey;
+ }
+ get message() {
+ return kTestMessage;
+ }
+ get promptActions() {
+ return [mainAction, secondaryAction];
+ }
+ get popupOptions() {
+ return {
+ checkbox: {
+ label: "Remember this decision",
+ show: true,
+ checked: true,
+ },
+ };
+ }
+ }
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ new TestPrompt().prompt();
+ await shownPromise;
+ let notification = PopupNotifications.getNotification(
+ kTestNotificationID,
+ browser
+ );
+ Assert.ok(notification, "Should have gotten the notification");
+
+ let curPerm = SitePermissions.getForPrincipal(
+ principal,
+ kTestPermissionKey,
+ browser
+ );
+ Assert.equal(
+ curPerm.state,
+ SitePermissions.UNKNOWN,
+ "Should be no permission set to begin with."
+ );
+
+ // First test denying the permission request without the checkbox checked.
+ let popupNotification = getPopupNotificationNode();
+ popupNotification.checkbox.checked = false;
+
+ Assert.equal(
+ notification.secondaryActions.length,
+ 1,
+ "There should only be 1 secondary action"
+ );
+ await clickSecondaryAction();
+ curPerm = SitePermissions.getForPrincipal(
+ principal,
+ kTestPermissionKey,
+ browser
+ );
+ Assert.deepEqual(
+ curPerm,
+ {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ "Should have denied the action temporarily"
+ );
+ // Try getting the permission without passing the browser object (should fail).
+ curPerm = PermissionTestUtils.getPermissionObject(
+ principal.URI,
+ kTestPermissionKey
+ );
+ Assert.equal(
+ curPerm,
+ null,
+ "Should have made no permanent permission entry"
+ );
+ Assert.ok(denied, "The secondaryAction callback should have fired");
+ Assert.ok(!allowed, "The mainAction callback should not have fired");
+ Assert.ok(
+ mockRequest._cancelled,
+ "The request should have been cancelled"
+ );
+ Assert.ok(
+ !mockRequest._allowed,
+ "The request should not have been allowed"
+ );
+
+ // Clear the permission and pretend we never denied
+ SitePermissions.removeFromPrincipal(
+ principal,
+ kTestPermissionKey,
+ browser
+ );
+ denied = false;
+ mockRequest._cancelled = false;
+
+ // Bring the PopupNotification back up now...
+ shownPromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ new TestPrompt().prompt();
+ await shownPromise;
+
+ // Test denying the permission request.
+ Assert.equal(
+ notification.secondaryActions.length,
+ 1,
+ "There should only be 1 secondary action"
+ );
+ await clickSecondaryAction();
+ curPerm = PermissionTestUtils.getPermissionObject(
+ principal.URI,
+ kTestPermissionKey
+ );
+ Assert.equal(
+ curPerm.capability,
+ Services.perms.DENY_ACTION,
+ "Should have denied the action"
+ );
+ Assert.equal(curPerm.expireTime, 0, "Deny should be permanent");
+ Assert.ok(denied, "The secondaryAction callback should have fired");
+ Assert.ok(!allowed, "The mainAction callback should not have fired");
+ Assert.ok(
+ mockRequest._cancelled,
+ "The request should have been cancelled"
+ );
+ Assert.ok(
+ !mockRequest._allowed,
+ "The request should not have been allowed"
+ );
+
+ // Clear the permission and pretend we never denied
+ PermissionTestUtils.remove(principal.URI, kTestPermissionKey);
+ denied = false;
+ mockRequest._cancelled = false;
+
+ // Bring the PopupNotification back up now...
+ shownPromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ new TestPrompt().prompt();
+ await shownPromise;
+
+ // Test allowing the permission request.
+ await clickMainAction();
+ curPerm = PermissionTestUtils.getPermissionObject(
+ principal.URI,
+ kTestPermissionKey
+ );
+ Assert.equal(
+ curPerm.capability,
+ Services.perms.ALLOW_ACTION,
+ "Should have allowed the action"
+ );
+ Assert.equal(curPerm.expireTime, 0, "Allow should be permanent");
+ Assert.ok(!denied, "The secondaryAction callback should not have fired");
+ Assert.ok(allowed, "The mainAction callback should have fired");
+ Assert.ok(
+ !mockRequest._cancelled,
+ "The request should not have been cancelled"
+ );
+ Assert.ok(mockRequest._allowed, "The request should have been allowed");
+ }
+ );
+});
+
+/**
+ * Tests that the onBeforeShow method will be called before
+ * the popup appears.
+ */
+add_task(async function test_on_before_show() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "http://example.com",
+ },
+ async function (browser) {
+ const kTestNotificationID = "test-notification";
+ const kTestMessage = "Test message";
+
+ let mainAction = {
+ label: "Test action",
+ accessKey: "T",
+ };
+
+ let mockRequest = makeMockPermissionRequest(browser);
+ let beforeShown = false;
+ class TestPrompt extends PermissionUI.PermissionPromptForRequest {
+ get request() {
+ return mockRequest;
+ }
+ get notificationID() {
+ return kTestNotificationID;
+ }
+ get message() {
+ return kTestMessage;
+ }
+ get promptActions() {
+ return [mainAction];
+ }
+ get popupOptions() {
+ return {
+ checkbox: {
+ label: "Remember this decision",
+ show: true,
+ checked: true,
+ },
+ };
+ }
+ onBeforeShow() {
+ beforeShown = true;
+ return true;
+ }
+ }
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ new TestPrompt().prompt();
+ Assert.ok(beforeShown, "Should have called onBeforeShown");
+ await shownPromise;
+ let notification = PopupNotifications.getNotification(
+ kTestNotificationID,
+ browser
+ );
+
+ let removePromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ notification.remove();
+ await removePromise;
+ }
+ );
+});
+
+/**
+ * Tests that we can open a PermissionPrompt without wrapping a
+ * nsIContentPermissionRequest.
+ */
+add_task(async function test_no_request() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "http://example.com",
+ },
+ async function (browser) {
+ const kTestNotificationID = "test-notification";
+ let allowed = false;
+ let mainAction = {
+ label: "Allow",
+ accessKey: "M",
+ callback() {
+ allowed = true;
+ },
+ };
+
+ let denied = false;
+ let secondaryAction = {
+ label: "Deny",
+ accessKey: "D",
+ callback() {
+ denied = true;
+ },
+ };
+
+ const kTestMessage = "Test message with no request";
+ let principal = browser.contentPrincipal;
+ let beforeShown = false;
+ class TestPrompt extends PermissionUI.PermissionPromptForRequest {
+ get notificationID() {
+ return kTestNotificationID;
+ }
+ get principal() {
+ return principal;
+ }
+ get browser() {
+ return browser;
+ }
+ get message() {
+ return kTestMessage;
+ }
+ get promptActions() {
+ return [mainAction, secondaryAction];
+ }
+ onBeforeShow() {
+ beforeShown = true;
+ return true;
+ }
+ }
+
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ new TestPrompt().prompt();
+ Assert.ok(beforeShown, "Should have called onBeforeShown");
+ await shownPromise;
+ let notification = PopupNotifications.getNotification(
+ kTestNotificationID,
+ browser
+ );
+
+ Assert.equal(
+ notification.message,
+ kTestMessage,
+ "Should be showing the right message"
+ );
+ Assert.equal(
+ notification.mainAction.label,
+ mainAction.label,
+ "The main action should have the right label"
+ );
+ Assert.equal(
+ notification.mainAction.accessKey,
+ mainAction.accessKey,
+ "The main action should have the right access key"
+ );
+ Assert.equal(
+ notification.secondaryActions.length,
+ 1,
+ "There should only be 1 secondary action"
+ );
+ Assert.equal(
+ notification.secondaryActions[0].label,
+ secondaryAction.label,
+ "The secondary action should have the right label"
+ );
+ Assert.equal(
+ notification.secondaryActions[0].accessKey,
+ secondaryAction.accessKey,
+ "The secondary action should have the right access key"
+ );
+ Assert.ok(
+ notification.options.displayURI.equals(principal.URI),
+ "Should be showing the URI of the requesting page"
+ );
+
+ // First test denying the permission request.
+ Assert.equal(
+ notification.secondaryActions.length,
+ 1,
+ "There should only be 1 secondary action"
+ );
+ await clickSecondaryAction();
+ Assert.ok(denied, "The secondaryAction callback should have fired");
+ Assert.ok(!allowed, "The mainAction callback should not have fired");
+
+ shownPromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ new TestPrompt().prompt();
+ await shownPromise;
+
+ // Next test allowing the permission request.
+ await clickMainAction();
+ Assert.ok(allowed, "The mainAction callback should have fired");
+ }
+ );
+});
+
+/**
+ * Tests that when the tab is moved to a different window, the notification
+ * is transferred to the new window.
+ */
+add_task(async function test_window_swap() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "http://example.com",
+ },
+ async function (browser) {
+ const kTestNotificationID = "test-notification";
+ const kTestMessage = "Test message";
+
+ let mainAction = {
+ label: "Test action",
+ accessKey: "T",
+ };
+ let secondaryAction = {
+ label: "Secondary",
+ accessKey: "S",
+ };
+
+ let mockRequest = makeMockPermissionRequest(browser);
+ class TestPrompt extends PermissionUI.PermissionPromptForRequest {
+ get request() {
+ return mockRequest;
+ }
+ get notificationID() {
+ return kTestNotificationID;
+ }
+ get message() {
+ return kTestMessage;
+ }
+ get promptActions() {
+ return [mainAction, secondaryAction];
+ }
+ }
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ new TestPrompt().prompt();
+ await shownPromise;
+
+ let newWindowOpened = BrowserTestUtils.waitForNewWindow();
+ gBrowser.replaceTabWithWindow(gBrowser.selectedTab);
+ let newWindow = await newWindowOpened;
+ // We may have already opened the panel, because it was open before we moved the tab.
+ if (newWindow.PopupNotifications.panel.state != "open") {
+ shownPromise = BrowserTestUtils.waitForEvent(
+ newWindow.PopupNotifications.panel,
+ "popupshown"
+ );
+ new TestPrompt().prompt();
+ await shownPromise;
+ }
+
+ let notification = newWindow.PopupNotifications.getNotification(
+ kTestNotificationID,
+ newWindow.gBrowser.selectedBrowser
+ );
+ Assert.ok(notification, "Should have gotten the notification");
+
+ Assert.equal(
+ notification.message,
+ kTestMessage,
+ "Should be showing the right message"
+ );
+ Assert.equal(
+ notification.mainAction.label,
+ mainAction.label,
+ "The main action should have the right label"
+ );
+ Assert.equal(
+ notification.mainAction.accessKey,
+ mainAction.accessKey,
+ "The main action should have the right access key"
+ );
+ Assert.equal(
+ notification.secondaryActions.length,
+ 1,
+ "There should only be 1 secondary action"
+ );
+ Assert.equal(
+ notification.secondaryActions[0].label,
+ secondaryAction.label,
+ "The secondary action should have the right label"
+ );
+ Assert.equal(
+ notification.secondaryActions[0].accessKey,
+ secondaryAction.accessKey,
+ "The secondary action should have the right access key"
+ );
+ Assert.ok(
+ notification.options.displayURI.equals(mockRequest.principal.URI),
+ "Should be showing the URI of the requesting page"
+ );
+
+ await BrowserTestUtils.closeWindow(newWindow);
+ }
+ );
+});
diff --git a/browser/modules/test/browser/browser_PermissionUI_prompts.js b/browser/modules/test/browser/browser_PermissionUI_prompts.js
new file mode 100644
index 0000000000..777e5a4a86
--- /dev/null
+++ b/browser/modules/test/browser/browser_PermissionUI_prompts.js
@@ -0,0 +1,284 @@
+/**
+ * These tests test the ability for the PermissionUI module to open
+ * permission prompts to the user. It also tests to ensure that
+ * add-ons can introduce their own permission prompts.
+ */
+
+"use strict";
+
+const { PermissionUI } = ChromeUtils.importESModule(
+ "resource:///modules/PermissionUI.sys.mjs"
+);
+const { SITEPERMS_ADDON_PROVIDER_PREF } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs"
+);
+
+// Tests that GeolocationPermissionPrompt works as expected
+add_task(async function test_geo_permission_prompt() {
+ await testPrompt(PermissionUI.GeolocationPermissionPrompt);
+});
+
+// Tests that GeolocationPermissionPrompt works as expected with local files
+add_task(async function test_geo_permission_prompt_local_file() {
+ await testPrompt(PermissionUI.GeolocationPermissionPrompt, true);
+});
+
+// Tests that XRPermissionPrompt works as expected
+add_task(async function test_xr_permission_prompt() {
+ await testPrompt(PermissionUI.XRPermissionPrompt);
+});
+
+// Tests that XRPermissionPrompt works as expected with local files
+add_task(async function test_xr_permission_prompt_local_file() {
+ await testPrompt(PermissionUI.XRPermissionPrompt, true);
+});
+
+// Tests that DesktopNotificationPermissionPrompt works as expected
+add_task(async function test_desktop_notification_permission_prompt() {
+ Services.prefs.setBoolPref(
+ "dom.webnotifications.requireuserinteraction",
+ false
+ );
+ Services.prefs.setBoolPref(
+ "permissions.desktop-notification.notNow.enabled",
+ true
+ );
+ await testPrompt(PermissionUI.DesktopNotificationPermissionPrompt);
+ Services.prefs.clearUserPref("dom.webnotifications.requireuserinteraction");
+ Services.prefs.clearUserPref(
+ "permissions.desktop-notification.notNow.enabled"
+ );
+});
+
+// Tests that PersistentStoragePermissionPrompt works as expected
+add_task(async function test_persistent_storage_permission_prompt() {
+ await testPrompt(PermissionUI.PersistentStoragePermissionPrompt);
+});
+
+// Tests that MidiPrompt works as expected
+add_task(async function test_midi_permission_prompt() {
+ if (Services.prefs.getBoolPref(SITEPERMS_ADDON_PROVIDER_PREF, false)) {
+ ok(
+ true,
+ "PermissionUI.MIDIPermissionPrompt uses SitePermsAddon install flow"
+ );
+ return;
+ }
+ await testPrompt(PermissionUI.MIDIPermissionPrompt);
+});
+
+// Tests that MidiPrompt works as expected with local files
+add_task(async function test_midi_permission_prompt_local_file() {
+ if (Services.prefs.getBoolPref(SITEPERMS_ADDON_PROVIDER_PREF, false)) {
+ ok(
+ true,
+ "PermissionUI.MIDIPermissionPrompt uses SitePermsAddon install flow"
+ );
+ return;
+ }
+ await testPrompt(PermissionUI.MIDIPermissionPrompt, true);
+});
+
+// Tests that StoragePermissionPrompt works as expected
+add_task(async function test_storage_access_permission_prompt() {
+ Services.prefs.setBoolPref("dom.storage_access.auto_grants", false);
+ await testPrompt(PermissionUI.StorageAccessPermissionPrompt);
+ Services.prefs.clearUserPref("dom.storage_access.auto_grants");
+});
+
+async function testPrompt(Prompt, useLocalFile = false) {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: useLocalFile ? `file://${PathUtils.tempDir}` : "http://example.com",
+ },
+ async function (browser) {
+ let mockRequest = makeMockPermissionRequest(browser);
+ let principal = mockRequest.principal;
+ let TestPrompt = new Prompt(mockRequest);
+ let { usePermissionManager, permissionKey } = TestPrompt;
+
+ registerCleanupFunction(function () {
+ if (permissionKey) {
+ SitePermissions.removeFromPrincipal(
+ principal,
+ permissionKey,
+ browser
+ );
+ }
+ });
+
+ let shownPromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ TestPrompt.prompt();
+ await shownPromise;
+ let notification = PopupNotifications.getNotification(
+ TestPrompt.notificationID,
+ browser
+ );
+ Assert.ok(notification, "Should have gotten the notification");
+
+ let curPerm;
+ if (permissionKey) {
+ curPerm = SitePermissions.getForPrincipal(
+ principal,
+ permissionKey,
+ browser
+ );
+ Assert.equal(
+ curPerm.state,
+ SitePermissions.UNKNOWN,
+ "Should be no permission set to begin with."
+ );
+ }
+
+ // First test denying the permission request without the checkbox checked.
+ let popupNotification = getPopupNotificationNode();
+ popupNotification.checkbox.checked = false;
+
+ let isNotificationPrompt =
+ Prompt == PermissionUI.DesktopNotificationPermissionPrompt;
+
+ let expectedSecondaryActionsCount = isNotificationPrompt ? 2 : 1;
+ Assert.equal(
+ notification.secondaryActions.length,
+ expectedSecondaryActionsCount,
+ "There should only be " +
+ expectedSecondaryActionsCount +
+ " secondary action(s)"
+ );
+ await clickSecondaryAction();
+ if (permissionKey) {
+ curPerm = SitePermissions.getForPrincipal(
+ principal,
+ permissionKey,
+ browser
+ );
+ Assert.deepEqual(
+ curPerm,
+ {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ "Should have denied the action temporarily"
+ );
+
+ Assert.ok(
+ mockRequest._cancelled,
+ "The request should have been cancelled"
+ );
+ Assert.ok(
+ !mockRequest._allowed,
+ "The request should not have been allowed"
+ );
+ }
+
+ SitePermissions.removeFromPrincipal(
+ principal,
+ TestPrompt.permissionKey,
+ browser
+ );
+ mockRequest._cancelled = false;
+
+ // Bring the PopupNotification back up now...
+ shownPromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ TestPrompt.prompt();
+ await shownPromise;
+
+ // Test denying the permission request with the checkbox checked (for geolocation)
+ // or by clicking the "never" option from the dropdown (for notifications and persistent-storage).
+ popupNotification = getPopupNotificationNode();
+ let secondaryActionToClickIndex = 0;
+ if (isNotificationPrompt) {
+ secondaryActionToClickIndex = 1;
+ } else {
+ popupNotification.checkbox.checked = true;
+ }
+
+ Assert.equal(
+ notification.secondaryActions.length,
+ expectedSecondaryActionsCount,
+ "There should only be " +
+ expectedSecondaryActionsCount +
+ " secondary action(s)"
+ );
+ await clickSecondaryAction(secondaryActionToClickIndex);
+ if (permissionKey) {
+ curPerm = SitePermissions.getForPrincipal(
+ principal,
+ permissionKey,
+ browser
+ );
+ Assert.equal(
+ curPerm.state,
+ SitePermissions.BLOCK,
+ "Should have denied the action"
+ );
+
+ let expectedScope = usePermissionManager
+ ? SitePermissions.SCOPE_PERSISTENT
+ : SitePermissions.SCOPE_TEMPORARY;
+ Assert.equal(
+ curPerm.scope,
+ expectedScope,
+ `Deny should be ${usePermissionManager ? "persistent" : "temporary"}`
+ );
+
+ Assert.ok(
+ mockRequest._cancelled,
+ "The request should have been cancelled"
+ );
+ Assert.ok(
+ !mockRequest._allowed,
+ "The request should not have been allowed"
+ );
+ }
+
+ SitePermissions.removeFromPrincipal(principal, permissionKey, browser);
+ mockRequest._cancelled = false;
+
+ // Bring the PopupNotification back up now...
+ shownPromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ TestPrompt.prompt();
+ await shownPromise;
+
+ // Test allowing the permission request with the checkbox checked.
+ popupNotification = getPopupNotificationNode();
+ popupNotification.checkbox.checked = true;
+
+ await clickMainAction();
+ // If the prompt does not use the permission manager, it can not set a
+ // persistent allow. Temporary allow is not supported.
+ if (usePermissionManager && permissionKey) {
+ curPerm = SitePermissions.getForPrincipal(
+ principal,
+ permissionKey,
+ browser
+ );
+ Assert.equal(
+ curPerm.state,
+ SitePermissions.ALLOW,
+ "Should have allowed the action"
+ );
+ Assert.equal(
+ curPerm.scope,
+ SitePermissions.SCOPE_PERSISTENT,
+ "Allow should be permanent"
+ );
+ Assert.ok(
+ !mockRequest._cancelled,
+ "The request should not have been cancelled"
+ );
+ Assert.ok(mockRequest._allowed, "The request should have been allowed");
+ }
+ }
+ );
+}
diff --git a/browser/modules/test/browser/browser_ProcessHangNotifications.js b/browser/modules/test/browser/browser_ProcessHangNotifications.js
new file mode 100644
index 0000000000..fd8116abfe
--- /dev/null
+++ b/browser/modules/test/browser/browser_ProcessHangNotifications.js
@@ -0,0 +1,484 @@
+/* globals ProcessHangMonitor */
+
+const { WebExtensionPolicy } = Cu.getGlobalForObject(Services);
+
+function promiseNotificationShown(aWindow, aName) {
+ return new Promise(resolve => {
+ let notificationBox = aWindow.gNotificationBox;
+ notificationBox.stack.addEventListener(
+ "AlertActive",
+ function () {
+ is(
+ notificationBox.allNotifications.length,
+ 1,
+ "Notification Displayed."
+ );
+ resolve(notificationBox);
+ },
+ { once: true }
+ );
+ });
+}
+
+function pushPrefs(...aPrefs) {
+ return SpecialPowers.pushPrefEnv({ set: aPrefs });
+}
+
+function popPrefs() {
+ return SpecialPowers.popPrefEnv();
+}
+
+const TEST_ACTION_UNKNOWN = 0;
+const TEST_ACTION_CANCELLED = 1;
+const TEST_ACTION_TERMSCRIPT = 2;
+const TEST_ACTION_TERMGLOBAL = 3;
+const SLOW_SCRIPT = 1;
+const ADDON_HANG = 3;
+const ADDON_ID = "fake-addon";
+
+/**
+ * A mock nsIHangReport that we can pass through nsIObserverService
+ * to trigger notifications.
+ *
+ * @param hangType
+ * One of SLOW_SCRIPT, ADDON_HANG.
+ * @param browser (optional)
+ * The <xul:browser> that this hang should be associated with.
+ * If not supplied, the hang will be associated with every browser,
+ * but the nsIHangReport.scriptBrowser attribute will return the
+ * currently selected browser in this window's gBrowser.
+ */
+let TestHangReport = function (
+ hangType = SLOW_SCRIPT,
+ browser = gBrowser.selectedBrowser
+) {
+ this.promise = new Promise((resolve, reject) => {
+ this._resolver = resolve;
+ });
+
+ if (hangType == ADDON_HANG) {
+ // Add-on hangs need an associated add-on ID for us to blame.
+ this._addonId = ADDON_ID;
+ }
+
+ this._browser = browser;
+};
+
+TestHangReport.prototype = {
+ get addonId() {
+ return this._addonId;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIHangReport"]),
+
+ userCanceled() {
+ this._resolver(TEST_ACTION_CANCELLED);
+ },
+
+ terminateScript() {
+ this._resolver(TEST_ACTION_TERMSCRIPT);
+ },
+
+ isReportForBrowserOrChildren(aFrameLoader) {
+ if (this._browser) {
+ return this._browser.frameLoader === aFrameLoader;
+ }
+
+ return true;
+ },
+
+ get scriptBrowser() {
+ return this._browser;
+ },
+
+ // Shut up warnings about this property missing:
+ get scriptFileName() {
+ return "chrome://browser/content/browser.js";
+ },
+};
+
+// on dev edition we add a button for js debugging of hung scripts.
+let buttonCount = AppConstants.MOZ_DEV_EDITION ? 2 : 1;
+
+add_setup(async function () {
+ // Create a fake WebExtensionPolicy that we can use for
+ // the add-on hang notification.
+ const uuidGen = Services.uuid;
+ const uuid = uuidGen.generateUUID().number.slice(1, -1);
+ let policy = new WebExtensionPolicy({
+ name: "Scapegoat",
+ id: ADDON_ID,
+ mozExtensionHostname: uuid,
+ baseURL: "file:///",
+ allowedOrigins: new MatchPatternSet([]),
+ localizeCallback() {},
+ });
+ policy.active = true;
+
+ registerCleanupFunction(() => {
+ policy.active = false;
+ });
+});
+
+/**
+ * Test if hang reports receive a terminate script callback when the user selects
+ * stop in response to a script hang.
+ */
+add_task(async function terminateScriptTest() {
+ let promise = promiseNotificationShown(window, "process-hang");
+ let hangReport = new TestHangReport();
+ Services.obs.notifyObservers(hangReport, "process-hang-report");
+ let notification = await promise;
+
+ let buttons =
+ notification.currentNotification.buttonContainer.getElementsByTagName(
+ "button"
+ );
+ is(buttons.length, buttonCount, "proper number of buttons");
+
+ // Click the "Stop" button, we should get a terminate script callback
+ buttons[0].click();
+ let action = await hangReport.promise;
+ is(
+ action,
+ TEST_ACTION_TERMSCRIPT,
+ "Clicking 'Stop' should have terminated the script."
+ );
+});
+
+/**
+ * Test if hang reports receive user canceled callbacks after a user selects wait
+ * and the browser frees up from a script hang on its own.
+ */
+add_task(async function waitForScriptTest() {
+ let hangReport = new TestHangReport();
+ let promise = promiseNotificationShown(window, "process-hang");
+ Services.obs.notifyObservers(hangReport, "process-hang-report");
+ let notification = await promise;
+
+ let buttons =
+ notification.currentNotification.buttonContainer.getElementsByTagName(
+ "button"
+ );
+ is(buttons.length, buttonCount, "proper number of buttons");
+
+ await pushPrefs(["browser.hangNotification.waitPeriod", 1000]);
+
+ let ignoringReport = true;
+
+ hangReport.promise.then(action => {
+ if (ignoringReport) {
+ ok(
+ false,
+ "Hang report was somehow dealt with when it " +
+ "should have been ignored."
+ );
+ } else {
+ is(
+ action,
+ TEST_ACTION_CANCELLED,
+ "Hang report should have been cancelled."
+ );
+ }
+ });
+
+ // Click the "Close" button this time, we shouldn't get a callback at all.
+ notification.currentNotification.closeButton.click();
+
+ // send another hang pulse, we should not get a notification here
+ Services.obs.notifyObservers(hangReport, "process-hang-report");
+ is(
+ notification.currentNotification,
+ null,
+ "no notification should be visible"
+ );
+
+ // Make sure that any queued Promises have run to give our report-ignoring
+ // then() a chance to fire.
+ await Promise.resolve();
+
+ ignoringReport = false;
+ Services.obs.notifyObservers(hangReport, "clear-hang-report");
+
+ await popPrefs();
+});
+
+/**
+ * Test if hang reports receive user canceled callbacks after the content
+ * process stops sending hang notifications.
+ */
+add_task(async function hangGoesAwayTest() {
+ await pushPrefs(["browser.hangNotification.expiration", 1000]);
+
+ let hangReport = new TestHangReport();
+ let promise = promiseNotificationShown(window, "process-hang");
+ Services.obs.notifyObservers(hangReport, "process-hang-report");
+ await promise;
+
+ Services.obs.notifyObservers(hangReport, "clear-hang-report");
+ let action = await hangReport.promise;
+ is(action, TEST_ACTION_CANCELLED, "Hang report should have been cancelled.");
+
+ await popPrefs();
+});
+
+/**
+ * Tests that if we're shutting down, any pre-existing hang reports will
+ * be terminated appropriately.
+ */
+add_task(async function terminateAtShutdown() {
+ let pausedHang = new TestHangReport(SLOW_SCRIPT);
+ Services.obs.notifyObservers(pausedHang, "process-hang-report");
+ ProcessHangMonitor.waitLonger(window);
+ ok(
+ ProcessHangMonitor.findPausedReport(gBrowser.selectedBrowser),
+ "There should be a paused report for the selected browser."
+ );
+
+ let scriptHang = new TestHangReport(SLOW_SCRIPT);
+ let addonHang = new TestHangReport(ADDON_HANG);
+
+ [scriptHang, addonHang].forEach(hangReport => {
+ Services.obs.notifyObservers(hangReport, "process-hang-report");
+ });
+
+ // Simulate the browser being told to shutdown. This should cause
+ // hangs to terminate scripts.
+ ProcessHangMonitor.onQuitApplicationGranted();
+
+ // In case this test happens to throw before it can finish, make
+ // sure to reset the shutting-down state.
+ registerCleanupFunction(() => {
+ ProcessHangMonitor._shuttingDown = false;
+ });
+
+ let pausedAction = await pausedHang.promise;
+ let scriptAction = await scriptHang.promise;
+ let addonAction = await addonHang.promise;
+
+ is(
+ pausedAction,
+ TEST_ACTION_TERMSCRIPT,
+ "On shutdown, should have terminated script for paused script hang."
+ );
+ is(
+ scriptAction,
+ TEST_ACTION_TERMSCRIPT,
+ "On shutdown, should have terminated script for script hang."
+ );
+ is(
+ addonAction,
+ TEST_ACTION_TERMSCRIPT,
+ "On shutdown, should have terminated script for add-on hang."
+ );
+
+ // ProcessHangMonitor should now be in the "shutting down" state,
+ // meaning that any further hangs should be handled immediately
+ // without user interaction.
+ let scriptHang2 = new TestHangReport(SLOW_SCRIPT);
+ let addonHang2 = new TestHangReport(ADDON_HANG);
+
+ [scriptHang2, addonHang2].forEach(hangReport => {
+ Services.obs.notifyObservers(hangReport, "process-hang-report");
+ });
+
+ let scriptAction2 = await scriptHang.promise;
+ let addonAction2 = await addonHang.promise;
+
+ is(
+ scriptAction2,
+ TEST_ACTION_TERMSCRIPT,
+ "On shutdown, should have terminated script for script hang."
+ );
+ is(
+ addonAction2,
+ TEST_ACTION_TERMSCRIPT,
+ "On shutdown, should have terminated script for add-on hang."
+ );
+
+ ProcessHangMonitor._shuttingDown = false;
+});
+
+/**
+ * Test that if there happens to be no open browser windows, that any
+ * hang reports that exist or appear while in this state will be handled
+ * automatically.
+ */
+add_task(async function terminateNoWindows() {
+ let testWin = await BrowserTestUtils.openNewBrowserWindow();
+
+ let pausedHang = new TestHangReport(
+ SLOW_SCRIPT,
+ testWin.gBrowser.selectedBrowser
+ );
+ Services.obs.notifyObservers(pausedHang, "process-hang-report");
+ ProcessHangMonitor.waitLonger(testWin);
+ ok(
+ ProcessHangMonitor.findPausedReport(testWin.gBrowser.selectedBrowser),
+ "There should be a paused report for the selected browser."
+ );
+
+ let scriptHang = new TestHangReport(SLOW_SCRIPT);
+ let addonHang = new TestHangReport(ADDON_HANG);
+
+ [scriptHang, addonHang].forEach(hangReport => {
+ Services.obs.notifyObservers(hangReport, "process-hang-report");
+ });
+
+ // Quick and dirty hack to trick the window mediator into thinking there
+ // are no browser windows without actually closing all browser windows.
+ document.documentElement.setAttribute(
+ "windowtype",
+ "navigator:browsertestdummy"
+ );
+
+ // In case this test happens to throw before it can finish, make
+ // sure to reset this.
+ registerCleanupFunction(() => {
+ document.documentElement.setAttribute("windowtype", "navigator:browser");
+ });
+
+ await BrowserTestUtils.closeWindow(testWin);
+
+ let pausedAction = await pausedHang.promise;
+ let scriptAction = await scriptHang.promise;
+ let addonAction = await addonHang.promise;
+
+ is(
+ pausedAction,
+ TEST_ACTION_TERMSCRIPT,
+ "With no open windows, should have terminated script for paused script hang."
+ );
+ is(
+ scriptAction,
+ TEST_ACTION_TERMSCRIPT,
+ "With no open windows, should have terminated script for script hang."
+ );
+ is(
+ addonAction,
+ TEST_ACTION_TERMSCRIPT,
+ "With no open windows, should have terminated script for add-on hang."
+ );
+
+ // ProcessHangMonitor should notice we're in the "no windows" state,
+ // so any further hangs should be handled immediately without user
+ // interaction.
+ let scriptHang2 = new TestHangReport(SLOW_SCRIPT);
+ let addonHang2 = new TestHangReport(ADDON_HANG);
+
+ [scriptHang2, addonHang2].forEach(hangReport => {
+ Services.obs.notifyObservers(hangReport, "process-hang-report");
+ });
+
+ let scriptAction2 = await scriptHang.promise;
+ let addonAction2 = await addonHang.promise;
+
+ is(
+ scriptAction2,
+ TEST_ACTION_TERMSCRIPT,
+ "With no open windows, should have terminated script for script hang."
+ );
+ is(
+ addonAction2,
+ TEST_ACTION_TERMSCRIPT,
+ "With no open windows, should have terminated script for add-on hang."
+ );
+
+ document.documentElement.setAttribute("windowtype", "navigator:browser");
+});
+
+/**
+ * Test that if a script hang occurs in one browser window, and that
+ * browser window goes away, that we clear the hang. For plug-in hangs,
+ * we do the conservative thing and terminate any plug-in hangs when a
+ * window closes, even though we don't exactly know which window it
+ * belongs to.
+ */
+add_task(async function terminateClosedWindow() {
+ let testWin = await BrowserTestUtils.openNewBrowserWindow();
+ let testBrowser = testWin.gBrowser.selectedBrowser;
+
+ let pausedHang = new TestHangReport(SLOW_SCRIPT, testBrowser);
+ Services.obs.notifyObservers(pausedHang, "process-hang-report");
+ ProcessHangMonitor.waitLonger(testWin);
+ ok(
+ ProcessHangMonitor.findPausedReport(testWin.gBrowser.selectedBrowser),
+ "There should be a paused report for the selected browser."
+ );
+
+ let scriptHang = new TestHangReport(SLOW_SCRIPT, testBrowser);
+ let addonHang = new TestHangReport(ADDON_HANG, testBrowser);
+
+ [scriptHang, addonHang].forEach(hangReport => {
+ Services.obs.notifyObservers(hangReport, "process-hang-report");
+ });
+
+ await BrowserTestUtils.closeWindow(testWin);
+
+ let pausedAction = await pausedHang.promise;
+ let scriptAction = await scriptHang.promise;
+ let addonAction = await addonHang.promise;
+
+ is(
+ pausedAction,
+ TEST_ACTION_TERMSCRIPT,
+ "When closing window, should have terminated script for a paused script hang."
+ );
+ is(
+ scriptAction,
+ TEST_ACTION_TERMSCRIPT,
+ "When closing window, should have terminated script for script hang."
+ );
+ is(
+ addonAction,
+ TEST_ACTION_TERMSCRIPT,
+ "When closing window, should have terminated script for add-on hang."
+ );
+});
+
+/**
+ * Test that permitUnload (used for closing or discarding tabs) does not
+ * try to talk to the hung child
+ */
+add_task(async function permitUnload() {
+ let testWin = await BrowserTestUtils.openNewBrowserWindow();
+ let testTab = testWin.gBrowser.selectedTab;
+
+ // Ensure we don't close the window:
+ BrowserTestUtils.addTab(testWin.gBrowser, "about:blank");
+
+ // Set up the test tab and another tab so we can check what happens when
+ // they are closed:
+ let otherTab = BrowserTestUtils.addTab(testWin.gBrowser, "about:blank");
+ let permitUnloadCount = 0;
+ for (let tab of [testTab, otherTab]) {
+ let browser = tab.linkedBrowser;
+ // Fake before unload state:
+ Object.defineProperty(browser, "hasBeforeUnload", { value: true });
+ // Increment permitUnloadCount if we ask for unload permission:
+ browser.asyncPermitUnload = () => {
+ permitUnloadCount++;
+ return Promise.resolve({ permitUnload: true });
+ };
+ }
+
+ // Set up a hang for the selected tab:
+ let testBrowser = testTab.linkedBrowser;
+ let pausedHang = new TestHangReport(SLOW_SCRIPT, testBrowser);
+ Services.obs.notifyObservers(pausedHang, "process-hang-report");
+ ProcessHangMonitor.waitLonger(testWin);
+ ok(
+ ProcessHangMonitor.findPausedReport(testWin.gBrowser.selectedBrowser),
+ "There should be a paused report for the browser we're about to remove."
+ );
+
+ BrowserTestUtils.removeTab(otherTab);
+ BrowserTestUtils.removeTab(testWin.gBrowser.getTabForBrowser(testBrowser));
+ is(
+ permitUnloadCount,
+ 1,
+ "Should have called asyncPermitUnload once (not for the hung tab)."
+ );
+
+ await BrowserTestUtils.closeWindow(testWin);
+});
diff --git a/browser/modules/test/browser/browser_SitePermissions.js b/browser/modules/test/browser/browser_SitePermissions.js
new file mode 100644
index 0000000000..d8542f8f85
--- /dev/null
+++ b/browser/modules/test/browser/browser_SitePermissions.js
@@ -0,0 +1,227 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// This tests the SitePermissions.getAllPermissionDetailsForBrowser function.
+add_task(async function testGetAllPermissionDetailsForBrowser() {
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "https://example.com"
+ );
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ principal.spec
+ );
+
+ Services.prefs.setIntPref("permissions.default.shortcuts", 2);
+
+ let browser = tab.linkedBrowser;
+
+ SitePermissions.setForPrincipal(principal, "camera", SitePermissions.ALLOW);
+
+ SitePermissions.setForPrincipal(
+ principal,
+ "cookie",
+ SitePermissions.ALLOW_COOKIES_FOR_SESSION
+ );
+ SitePermissions.setForPrincipal(principal, "popup", SitePermissions.BLOCK);
+ SitePermissions.setForPrincipal(
+ principal,
+ "geo",
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_SESSION
+ );
+ SitePermissions.setForPrincipal(
+ principal,
+ "shortcuts",
+ SitePermissions.ALLOW
+ );
+
+ SitePermissions.setForPrincipal(principal, "xr", SitePermissions.ALLOW);
+
+ let permissions = SitePermissions.getAllPermissionDetailsForBrowser(browser);
+
+ let camera = permissions.find(({ id }) => id === "camera");
+ Assert.deepEqual(camera, {
+ id: "camera",
+ label: "Use the camera",
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+
+ // Check that removed permissions (State.UNKNOWN) are skipped.
+ SitePermissions.removeFromPrincipal(principal, "camera");
+ permissions = SitePermissions.getAllPermissionDetailsForBrowser(browser);
+
+ camera = permissions.find(({ id }) => id === "camera");
+ Assert.equal(camera, undefined);
+
+ let cookie = permissions.find(({ id }) => id === "cookie");
+ Assert.deepEqual(cookie, {
+ id: "cookie",
+ label: "Set cookies",
+ state: SitePermissions.ALLOW_COOKIES_FOR_SESSION,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+
+ let popup = permissions.find(({ id }) => id === "popup");
+ Assert.deepEqual(popup, {
+ id: "popup",
+ label: "Open pop-up windows",
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+
+ let geo = permissions.find(({ id }) => id === "geo");
+ Assert.deepEqual(geo, {
+ id: "geo",
+ label: "Access your location",
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_SESSION,
+ });
+
+ let shortcuts = permissions.find(({ id }) => id === "shortcuts");
+ Assert.deepEqual(shortcuts, {
+ id: "shortcuts",
+ label: "Override keyboard shortcuts",
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+
+ let xr = permissions.find(({ id }) => id === "xr");
+ Assert.deepEqual(xr, {
+ id: "xr",
+ label: "Access virtual reality devices",
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+
+ SitePermissions.removeFromPrincipal(principal, "cookie");
+ SitePermissions.removeFromPrincipal(principal, "popup");
+ SitePermissions.removeFromPrincipal(principal, "geo");
+ SitePermissions.removeFromPrincipal(principal, "shortcuts");
+
+ SitePermissions.removeFromPrincipal(principal, "xr");
+
+ Services.prefs.clearUserPref("permissions.default.shortcuts");
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+add_task(async function testTemporaryChangeEvent() {
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "https://example.com"
+ );
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ principal.spec
+ );
+
+ let browser = tab.linkedBrowser;
+
+ let changeEventCount = 0;
+ function listener() {
+ changeEventCount++;
+ }
+
+ browser.addEventListener("PermissionStateChange", listener);
+
+ // Test browser-specific permissions.
+ SitePermissions.setForPrincipal(
+ browser.contentPrincipal,
+ "autoplay-media",
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_GLOBAL,
+ browser
+ );
+ is(changeEventCount, 1, "Should've changed");
+
+ // Setting the same value shouldn't dispatch a change event.
+ SitePermissions.setForPrincipal(
+ browser.contentPrincipal,
+ "autoplay-media",
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_GLOBAL,
+ browser
+ );
+ is(changeEventCount, 1, "Shouldn't have changed");
+
+ browser.removeEventListener("PermissionStateChange", listener);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function testInvalidPrincipal() {
+ // Check that an error is thrown when an invalid principal argument is passed.
+ try {
+ SitePermissions.isSupportedPrincipal("file:///example.js");
+ } catch (e) {
+ Assert.equal(
+ e.message,
+ "Argument passed as principal is not an instance of Ci.nsIPrincipal"
+ );
+ }
+ try {
+ SitePermissions.removeFromPrincipal(null, "canvas");
+ } catch (e) {
+ Assert.equal(
+ e.message,
+ "Atleast one of the arguments, either principal or browser should not be null."
+ );
+ }
+ try {
+ SitePermissions.setForPrincipal(
+ "blah",
+ "camera",
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_PERSISTENT,
+ gBrowser.selectedBrowser
+ );
+ } catch (e) {
+ Assert.equal(
+ e.message,
+ "Argument passed as principal is not an instance of Ci.nsIPrincipal"
+ );
+ }
+ try {
+ SitePermissions.getAllByPrincipal("blah");
+ } catch (e) {
+ Assert.equal(
+ e.message,
+ "Argument passed as principal is not an instance of Ci.nsIPrincipal"
+ );
+ }
+ try {
+ SitePermissions.getAllByPrincipal(null);
+ } catch (e) {
+ Assert.equal(e.message, "principal argument cannot be null.");
+ }
+ try {
+ SitePermissions.getForPrincipal(5, "camera");
+ } catch (e) {
+ Assert.equal(
+ e.message,
+ "Argument passed as principal is not an instance of Ci.nsIPrincipal"
+ );
+ }
+ // Check that no error is thrown when passing valid principal and browser arguments.
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(gBrowser.contentPrincipal, "camera"),
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ }
+ );
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, "camera", gBrowser.selectedBrowser),
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ }
+ );
+});
diff --git a/browser/modules/test/browser/browser_SitePermissions_combinations.js b/browser/modules/test/browser/browser_SitePermissions_combinations.js
new file mode 100644
index 0000000000..e6267f72cc
--- /dev/null
+++ b/browser/modules/test/browser/browser_SitePermissions_combinations.js
@@ -0,0 +1,144 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// This function applies combinations of different permissions and
+// checks how they override each other.
+async function checkPermissionCombinations(combinations) {
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "https://example.com"
+ );
+
+ await BrowserTestUtils.withNewTab(principal.spec, function (browser) {
+ let id = "geo";
+ for (let { reverse, states, result } of combinations) {
+ let loop = () => {
+ for (let [state, scope] of states) {
+ SitePermissions.setForPrincipal(principal, id, state, scope, browser);
+ }
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(principal, id, browser),
+ result
+ );
+ SitePermissions.removeFromPrincipal(principal, id, browser);
+ };
+
+ loop();
+
+ if (reverse) {
+ states.reverse();
+ loop();
+ }
+ }
+ });
+}
+
+// Test that passing null as scope becomes SCOPE_PERSISTENT.
+add_task(async function testDefaultScope() {
+ await checkPermissionCombinations([
+ {
+ states: [[SitePermissions.ALLOW, null]],
+ result: {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ },
+ ]);
+});
+
+// Test that "wide" scopes like PERSISTENT always override "narrower" ones like TAB.
+add_task(async function testScopeOverrides() {
+ await checkPermissionCombinations([
+ {
+ // The behavior of SCOPE_SESSION is not in line with the general behavior
+ // because of the legacy nsIPermissionManager implementation.
+ states: [
+ [SitePermissions.ALLOW, SitePermissions.SCOPE_PERSISTENT],
+ [SitePermissions.BLOCK, SitePermissions.SCOPE_SESSION],
+ ],
+ result: {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_SESSION,
+ },
+ },
+ {
+ states: [
+ [SitePermissions.BLOCK, SitePermissions.SCOPE_SESSION],
+ [SitePermissions.ALLOW, SitePermissions.SCOPE_PERSISTENT],
+ ],
+ result: {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ },
+ {
+ reverse: true,
+ states: [
+ [SitePermissions.BLOCK, SitePermissions.SCOPE_TEMPORARY],
+ [SitePermissions.ALLOW, SitePermissions.SCOPE_SESSION],
+ ],
+ result: {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_SESSION,
+ },
+ },
+ {
+ reverse: true,
+ states: [
+ [SitePermissions.BLOCK, SitePermissions.SCOPE_TEMPORARY],
+ [SitePermissions.ALLOW, SitePermissions.SCOPE_PERSISTENT],
+ ],
+ result: {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ },
+ ]);
+});
+
+// Test that clearing a temporary permission also removes a
+// persistent permission that was set for the same URL.
+add_task(async function testClearTempPermission() {
+ await checkPermissionCombinations([
+ {
+ states: [
+ [SitePermissions.BLOCK, SitePermissions.SCOPE_TEMPORARY],
+ [SitePermissions.ALLOW, SitePermissions.SCOPE_PERSISTENT],
+ [SitePermissions.UNKNOWN, SitePermissions.SCOPE_TEMPORARY],
+ ],
+ result: {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ },
+ ]);
+});
+
+// Test that states override each other when applied with the same scope.
+add_task(async function testStateOverride() {
+ await checkPermissionCombinations([
+ {
+ states: [
+ [SitePermissions.ALLOW, SitePermissions.SCOPE_PERSISTENT],
+ [SitePermissions.BLOCK, SitePermissions.SCOPE_PERSISTENT],
+ ],
+ result: {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ },
+ {
+ states: [
+ [SitePermissions.BLOCK, SitePermissions.SCOPE_PERSISTENT],
+ [SitePermissions.ALLOW, SitePermissions.SCOPE_PERSISTENT],
+ ],
+ result: {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ },
+ ]);
+});
diff --git a/browser/modules/test/browser/browser_SitePermissions_expiry.js b/browser/modules/test/browser/browser_SitePermissions_expiry.js
new file mode 100644
index 0000000000..c5806a8008
--- /dev/null
+++ b/browser/modules/test/browser/browser_SitePermissions_expiry.js
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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-disable mozilla/no-arbitrary-setTimeout */
+
+"use strict";
+
+const EXPIRE_TIME_MS = 100;
+const TIMEOUT_MS = 500;
+
+// This tests the time delay to expire temporary permission entries.
+add_task(async function testTemporaryPermissionExpiry() {
+ SpecialPowers.pushPrefEnv({
+ set: [["privacy.temporary_permission_expire_time_ms", EXPIRE_TIME_MS]],
+ });
+
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "https://example.com"
+ );
+ let id = "camera";
+
+ await BrowserTestUtils.withNewTab(principal.spec, async function (browser) {
+ SitePermissions.setForPrincipal(
+ principal,
+ id,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ browser
+ );
+
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, id, browser), {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ });
+
+ await new Promise(c => setTimeout(c, TIMEOUT_MS));
+
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, id, browser), {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+ });
+});
diff --git a/browser/modules/test/browser/browser_SitePermissions_tab_urls.js b/browser/modules/test/browser/browser_SitePermissions_tab_urls.js
new file mode 100644
index 0000000000..36a17ddbe0
--- /dev/null
+++ b/browser/modules/test/browser/browser_SitePermissions_tab_urls.js
@@ -0,0 +1,128 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+function newPrincipal(origin) {
+ return Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ origin
+ );
+}
+
+// This tests the key used to store the URI -> permission map on a tab.
+add_task(async function testTemporaryPermissionTabURLs() {
+ // Prevent showing a dialog for https://name:password@example.com
+ SpecialPowers.pushPrefEnv({
+ set: [["network.http.phishy-userpass-length", 2048]],
+ });
+
+ // This usually takes about 60 seconds on 32bit Linux debug,
+ // due to the combinatory nature of the test that is hard to fix.
+ requestLongerTimeout(2);
+
+ let same = [
+ newPrincipal("https://example.com"),
+ newPrincipal("https://example.com:443"),
+ newPrincipal("https://test1.example.com"),
+ newPrincipal("https://name:password@example.com"),
+ newPrincipal("http://example.com"),
+ ];
+ let different = [
+ newPrincipal("https://example.com"),
+ newPrincipal("http://example.org"),
+ newPrincipal("http://example.net"),
+ ];
+
+ let id = "microphone";
+
+ await BrowserTestUtils.withNewTab("about:blank", async function (browser) {
+ for (let principal of same) {
+ let loaded = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ principal.spec
+ );
+ BrowserTestUtils.loadURIString(browser, principal.spec);
+ await loaded;
+
+ SitePermissions.setForPrincipal(
+ principal,
+ id,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ browser
+ );
+
+ for (let principal2 of same) {
+ let loaded2 = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ principal2.URI.spec
+ );
+ BrowserTestUtils.loadURIString(browser, principal2.URI.spec);
+ await loaded2;
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(principal2, id, browser),
+ {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ `${principal.spec} should share tab permissions with ${principal2.spec}`
+ );
+ }
+
+ SitePermissions.clearTemporaryBlockPermissions(browser);
+ }
+
+ for (let principal of different) {
+ let loaded = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ principal.spec
+ );
+ BrowserTestUtils.loadURIString(browser, principal.spec);
+ await loaded;
+
+ SitePermissions.setForPrincipal(
+ principal,
+ id,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ browser
+ );
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(principal, id, browser),
+ {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ }
+ );
+
+ for (let principal2 of different) {
+ loaded = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ principal2.URI.spec
+ );
+ BrowserTestUtils.loadURIString(browser, principal2.URI.spec);
+ await loaded;
+
+ if (principal2 != principal) {
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(principal2, id, browser),
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ `${principal.spec} should not share tab permissions with ${principal2.spec}`
+ );
+ }
+ }
+
+ SitePermissions.clearTemporaryBlockPermissions(browser);
+ }
+ });
+});
diff --git a/browser/modules/test/browser/browser_TabUnloader.js b/browser/modules/test/browser/browser_TabUnloader.js
new file mode 100644
index 0000000000..a4af0dbdc8
--- /dev/null
+++ b/browser/modules/test/browser/browser_TabUnloader.js
@@ -0,0 +1,381 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { TabUnloader } = ChromeUtils.import(
+ "resource:///modules/TabUnloader.jsm"
+);
+
+const BASE_URL = "https://example.com/browser/browser/modules/test/browser/";
+
+async function play(tab) {
+ let browser = tab.linkedBrowser;
+
+ let waitForAudioPromise = BrowserTestUtils.waitForEvent(
+ tab,
+ "TabAttrModified",
+ false,
+ event => {
+ return (
+ event.detail.changed.includes("soundplaying") &&
+ tab.hasAttribute("soundplaying")
+ );
+ }
+ );
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ let audio = content.document.querySelector("audio");
+ await audio.play();
+ });
+
+ await waitForAudioPromise;
+}
+
+async function addTab(win = window) {
+ return BrowserTestUtils.openNewForegroundTab({
+ gBrowser: win.gBrowser,
+ url: BASE_URL + "dummy_page.html",
+ waitForLoad: true,
+ });
+}
+
+async function addPrivTab(win = window) {
+ const tab = BrowserTestUtils.addTab(
+ win.gBrowser,
+ BASE_URL + "dummy_page.html"
+ );
+ const browser = win.gBrowser.getBrowserForTab(tab);
+ await BrowserTestUtils.browserLoaded(browser);
+ return tab;
+}
+
+async function addAudioTab(win = window) {
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser: win.gBrowser,
+ url: BASE_URL + "file_mediaPlayback.html",
+ waitForLoad: true,
+ waitForStateStop: true,
+ });
+
+ await play(tab);
+ return tab;
+}
+
+async function addWebRTCTab(win = window) {
+ let popupPromise = new Promise(resolve => {
+ win.PopupNotifications.panel.addEventListener(
+ "popupshown",
+ function () {
+ executeSoon(resolve);
+ },
+ { once: true }
+ );
+ });
+
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser: win.gBrowser,
+ url: BASE_URL + "file_webrtc.html",
+ waitForLoad: true,
+ waitForStateStop: true,
+ });
+
+ await popupPromise;
+
+ let recordingPromise = BrowserTestUtils.contentTopicObserved(
+ tab.linkedBrowser.browsingContext,
+ "recording-device-events"
+ );
+ win.PopupNotifications.panel.firstElementChild.button.click();
+ await recordingPromise;
+
+ return tab;
+}
+
+async function pressure() {
+ let tabDiscarded = BrowserTestUtils.waitForEvent(
+ document,
+ "TabBrowserDiscarded",
+ true
+ );
+ TabUnloader.unloadTabAsync(null);
+ return tabDiscarded;
+}
+
+function pressureAndObserve(aExpectedTopic) {
+ const promise = new Promise(resolve => {
+ const observer = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+ observe(aSubject, aTopicInner, aData) {
+ if (aTopicInner == aExpectedTopic) {
+ Services.obs.removeObserver(observer, aTopicInner);
+ resolve(aData);
+ }
+ },
+ };
+ Services.obs.addObserver(observer, aExpectedTopic);
+ });
+ TabUnloader.unloadTabAsync(null);
+ return promise;
+}
+
+async function compareTabOrder(expectedOrder) {
+ let tabInfo = await TabUnloader.getSortedTabs(null);
+
+ is(
+ tabInfo.length,
+ expectedOrder.length,
+ "right number of tabs in discard sort list"
+ );
+ for (let idx = 0; idx < expectedOrder.length; idx++) {
+ is(tabInfo[idx].tab, expectedOrder[idx], "index " + idx + " is correct");
+ }
+}
+
+const PREF_PERMISSION_FAKE = "media.navigator.permission.fake";
+const PREF_AUDIO_LOOPBACK = "media.audio_loopback_dev";
+const PREF_VIDEO_LOOPBACK = "media.video_loopback_dev";
+const PREF_FAKE_STREAMS = "media.navigator.streams.fake";
+const PREF_ENABLE_UNLOADER = "browser.tabs.unloadOnLowMemory";
+const PREF_MAC_LOW_MEM_RESPONSE = "browser.lowMemoryResponseMask";
+
+add_task(async function test() {
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(PREF_ENABLE_UNLOADER);
+ if (AppConstants.platform == "macosx") {
+ Services.prefs.clearUserPref(PREF_MAC_LOW_MEM_RESPONSE);
+ }
+ });
+ Services.prefs.setBoolPref(PREF_ENABLE_UNLOADER, true);
+
+ // On Mac, tab unloading and memory pressure notifications are limited
+ // to Nightly so force them on for this test for non-Nightly builds. i.e.,
+ // tests on Release and Beta builds. Mac tab unloading and memory pressure
+ // notifications require this pref to be set.
+ if (AppConstants.platform == "macosx") {
+ Services.prefs.setIntPref(PREF_MAC_LOW_MEM_RESPONSE, 3);
+ }
+
+ TabUnloader.init();
+
+ // Set some WebRTC simulation preferences.
+ let prefs = [
+ [PREF_PERMISSION_FAKE, true],
+ [PREF_AUDIO_LOOPBACK, ""],
+ [PREF_VIDEO_LOOPBACK, ""],
+ [PREF_FAKE_STREAMS, true],
+ ];
+ await SpecialPowers.pushPrefEnv({ set: prefs });
+
+ // Set up 6 tabs, three normal ones, one pinned, one playing sound and one
+ // pinned playing sound
+ let tab0 = gBrowser.tabs[0];
+ let tab1 = await addTab();
+ let tab2 = await addTab();
+ let pinnedTab = await addTab();
+ gBrowser.pinTab(pinnedTab);
+ let soundTab = await addAudioTab();
+ let pinnedSoundTab = await addAudioTab();
+ gBrowser.pinTab(pinnedSoundTab);
+
+ // Open a new private window and add a tab
+ const windowPriv = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ const tabPriv0 = windowPriv.gBrowser.tabs[0];
+ const tabPriv1 = await addPrivTab(windowPriv);
+
+ // Move the original window to the foreground to pass the tests
+ gBrowser.selectedTab = tab0;
+ tab0.ownerGlobal.focus();
+
+ // Pretend we've visited the tabs
+ await BrowserTestUtils.switchTab(windowPriv.gBrowser, tabPriv1);
+ await BrowserTestUtils.switchTab(windowPriv.gBrowser, tabPriv0);
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await BrowserTestUtils.switchTab(gBrowser, tab2);
+ await BrowserTestUtils.switchTab(gBrowser, pinnedTab);
+ await BrowserTestUtils.switchTab(gBrowser, soundTab);
+ await BrowserTestUtils.switchTab(gBrowser, pinnedSoundTab);
+ await BrowserTestUtils.switchTab(gBrowser, tab0);
+
+ // Checks the tabs are in the state we expect them to be
+ ok(pinnedTab.pinned, "tab is pinned");
+ ok(pinnedSoundTab.soundPlaying, "tab is playing sound");
+ ok(
+ pinnedSoundTab.pinned && pinnedSoundTab.soundPlaying,
+ "tab is pinned and playing sound"
+ );
+
+ await compareTabOrder([
+ tab1,
+ tab2,
+ pinnedTab,
+ tabPriv1,
+ soundTab,
+ tab0,
+ pinnedSoundTab,
+ tabPriv0,
+ ]);
+
+ // Check that the tabs are present
+ ok(
+ tab1.linkedPanel &&
+ tab2.linkedPanel &&
+ pinnedTab.linkedPanel &&
+ soundTab.linkedPanel &&
+ pinnedSoundTab.linkedPanel &&
+ tabPriv0.linkedPanel &&
+ tabPriv1.linkedPanel,
+ "tabs are present"
+ );
+
+ // Check that low-memory memory-pressure events unload tabs
+ await pressure();
+ ok(
+ !tab1.linkedPanel,
+ "low-memory memory-pressure notification unloaded the LRU tab"
+ );
+
+ await compareTabOrder([
+ tab2,
+ pinnedTab,
+ tabPriv1,
+ soundTab,
+ tab0,
+ pinnedSoundTab,
+ tabPriv0,
+ ]);
+
+ // If no normal tab is available unload pinned tabs
+ await pressure();
+ ok(!tab2.linkedPanel, "unloaded a second tab in LRU order");
+ await compareTabOrder([
+ pinnedTab,
+ tabPriv1,
+ soundTab,
+ tab0,
+ pinnedSoundTab,
+ tabPriv0,
+ ]);
+
+ ok(soundTab.soundPlaying, "tab is still playing sound");
+
+ await pressure();
+ ok(!pinnedTab.linkedPanel, "unloaded a pinned tab");
+ await compareTabOrder([tabPriv1, soundTab, tab0, pinnedSoundTab, tabPriv0]);
+
+ ok(pinnedSoundTab.soundPlaying, "tab is still playing sound");
+
+ // There are no unloadable tabs.
+ TabUnloader.unloadTabAsync(null);
+ ok(tabPriv1.linkedPanel, "a tab in a private window is never unloaded");
+
+ const histogram = TelemetryTestUtils.getAndClearHistogram(
+ "TAB_UNLOAD_TO_RELOAD"
+ );
+
+ // It's possible that we're already in the memory-pressure state
+ // and we may receive the "ongoing" message.
+ const message = await pressureAndObserve("memory-pressure");
+ Assert.ok(
+ message == "low-memory" || message == "low-memory-ongoing",
+ "observed the memory-pressure notification because of no discardable tab"
+ );
+
+ // Add a WebRTC tab and another sound tab.
+ let webrtcTab = await addWebRTCTab();
+ let anotherSoundTab = await addAudioTab();
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await BrowserTestUtils.switchTab(gBrowser, pinnedTab);
+
+ const hist = histogram.snapshot();
+ const numEvents = Object.values(hist.values).reduce((a, b) => a + b);
+ Assert.equal(numEvents, 2, "two tabs have been reloaded.");
+
+ // tab0 has never been unloaded. No data is added to the histogram.
+ await BrowserTestUtils.switchTab(gBrowser, tab0);
+
+ await compareTabOrder([
+ tab1,
+ pinnedTab,
+ tabPriv1,
+ soundTab,
+ webrtcTab,
+ anotherSoundTab,
+ tab0,
+ pinnedSoundTab,
+ tabPriv0,
+ ]);
+
+ await BrowserTestUtils.closeWindow(windowPriv);
+
+ let window2 = await BrowserTestUtils.openNewBrowserWindow();
+ let win2tab1 = window2.gBrowser.selectedTab;
+ let win2tab2 = await addTab(window2);
+ let win2winrtcTab = await addWebRTCTab(window2);
+ let win2tab3 = await addTab(window2);
+
+ await compareTabOrder([
+ tab1,
+ win2tab1,
+ win2tab2,
+ pinnedTab,
+ soundTab,
+ webrtcTab,
+ anotherSoundTab,
+ win2winrtcTab,
+ tab0,
+ win2tab3,
+ pinnedSoundTab,
+ ]);
+
+ await BrowserTestUtils.closeWindow(window2);
+
+ await compareTabOrder([
+ tab1,
+ pinnedTab,
+ soundTab,
+ webrtcTab,
+ anotherSoundTab,
+ tab0,
+ pinnedSoundTab,
+ ]);
+
+ // Cleanup
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(pinnedTab);
+ BrowserTestUtils.removeTab(soundTab);
+ BrowserTestUtils.removeTab(pinnedSoundTab);
+ BrowserTestUtils.removeTab(webrtcTab);
+ BrowserTestUtils.removeTab(anotherSoundTab);
+
+ await awaitWebRTCClose();
+});
+
+// Wait for the WebRTC indicator window to close.
+function awaitWebRTCClose() {
+ if (
+ Services.prefs.getBoolPref("privacy.webrtc.legacyGlobalIndicator", false) ||
+ AppConstants.platform == "macosx"
+ ) {
+ return null;
+ }
+
+ let win = Services.wm.getMostRecentWindow("Browser:WebRTCGlobalIndicator");
+ if (!win) {
+ return null;
+ }
+
+ return new Promise(resolve => {
+ win.addEventListener("unload", function listener(e) {
+ if (e.target == win.document) {
+ win.removeEventListener("unload", listener);
+ executeSoon(resolve);
+ }
+ });
+ });
+}
diff --git a/browser/modules/test/browser/browser_Telemetry_numberOfSiteOrigins.js b/browser/modules/test/browser/browser_Telemetry_numberOfSiteOrigins.js
new file mode 100644
index 0000000000..9ce5602eda
--- /dev/null
+++ b/browser/modules/test/browser/browser_Telemetry_numberOfSiteOrigins.js
@@ -0,0 +1,53 @@
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/**
+ * This file tests page reload key combination telemetry
+ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+});
+
+const gTestRoot = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://mochi.test:8888"
+);
+
+const { TimedPromise } = ChromeUtils.importESModule(
+ "chrome://remote/content/marionette/sync.sys.mjs"
+);
+
+async function run_test(count) {
+ const histogram = TelemetryTestUtils.getAndClearHistogram(
+ "FX_NUMBER_OF_UNIQUE_SITE_ORIGINS_ALL_TABS"
+ );
+
+ let newTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: gTestRoot + "contain_iframe.html",
+ waitForStateStop: true,
+ });
+
+ await new Promise(resolve =>
+ setTimeout(function () {
+ window.requestIdleCallback(resolve);
+ }, 1000)
+ );
+
+ if (count < 2) {
+ await BrowserTestUtils.removeTab(newTab);
+ await run_test(count + 1);
+ } else {
+ TelemetryTestUtils.assertHistogram(histogram, 2, 1);
+ await BrowserTestUtils.removeTab(newTab);
+ }
+}
+
+add_task(async function test_telemetryMoreSiteOrigin() {
+ await run_test(1);
+});
diff --git a/browser/modules/test/browser/browser_Telemetry_numberOfSiteOriginsPerDocument.js b/browser/modules/test/browser/browser_Telemetry_numberOfSiteOriginsPerDocument.js
new file mode 100644
index 0000000000..8caaa1ff38
--- /dev/null
+++ b/browser/modules/test/browser/browser_Telemetry_numberOfSiteOriginsPerDocument.js
@@ -0,0 +1,134 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+});
+
+const histogramName = "FX_NUMBER_OF_UNIQUE_SITE_ORIGINS_PER_DOCUMENT";
+const testRoot = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://mochi.test:8888"
+);
+
+function windowGlobalDestroyed(id) {
+ return BrowserUtils.promiseObserved(
+ "window-global-destroyed",
+ aWGP => aWGP.innerWindowId == id
+ );
+}
+
+async function openAndCloseTab(uri) {
+ const tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: uri,
+ });
+
+ const innerWindowId =
+ tab.linkedBrowser.browsingContext.currentWindowGlobal.innerWindowId;
+
+ const wgpDestroyed = windowGlobalDestroyed(innerWindowId);
+ BrowserTestUtils.removeTab(tab);
+ await wgpDestroyed;
+}
+
+add_task(async function test_numberOfSiteOriginsAfterTabClose() {
+ const histogram = TelemetryTestUtils.getAndClearHistogram(histogramName);
+ const testPage = `${testRoot}contain_iframe.html`;
+
+ await openAndCloseTab(testPage);
+
+ // testPage contains two origins: mochi.test:8888 and example.com.
+ TelemetryTestUtils.assertHistogram(histogram, 2, 1);
+});
+
+add_task(async function test_numberOfSiteOriginsAboutBlank() {
+ const histogram = TelemetryTestUtils.getAndClearHistogram(histogramName);
+
+ await openAndCloseTab("about:blank");
+
+ const { values } = histogram.snapshot();
+ Assert.deepEqual(
+ values,
+ {},
+ `Histogram should have no values; had ${JSON.stringify(values)}`
+ );
+});
+
+add_task(async function test_numberOfSiteOriginsMultipleNavigations() {
+ const histogram = TelemetryTestUtils.getAndClearHistogram(histogramName);
+ const testPage = `${testRoot}contain_iframe.html`;
+
+ const tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: testPage,
+ waitForStateStop: true,
+ });
+
+ const wgpDestroyedPromises = [
+ windowGlobalDestroyed(tab.linkedBrowser.innerWindowID),
+ ];
+
+ // Navigate to an interstitial page.
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, "about:blank");
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ // Navigate to another test page.
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, testPage);
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ wgpDestroyedPromises.push(
+ windowGlobalDestroyed(tab.linkedBrowser.innerWindowID)
+ );
+
+ BrowserTestUtils.removeTab(tab);
+ await Promise.all(wgpDestroyedPromises);
+
+ // testPage has been loaded twice and contains two origins: mochi.test:8888
+ // and example.com.
+ TelemetryTestUtils.assertHistogram(histogram, 2, 2);
+});
+
+add_task(async function test_numberOfSiteOriginsAddAndRemove() {
+ const histogram = TelemetryTestUtils.getAndClearHistogram(histogramName);
+ const testPage = `${testRoot}blank_iframe.html`;
+
+ const tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: testPage,
+ waitForStateStop: true,
+ });
+
+ // Load a subdocument in the page's iframe.
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ const iframe = content.window.document.querySelector("iframe");
+ const loaded = new Promise(resolve => {
+ iframe.addEventListener("load", () => resolve(), { once: true });
+ });
+ iframe.src = "http://example.com";
+
+ await loaded;
+ });
+
+ // Load a *new* subdocument in the page's iframe. This will result in the page
+ // having had three different origins, but only two at any one time.
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ const iframe = content.window.document.querySelector("iframe");
+ const loaded = new Promise(resolve => {
+ iframe.addEventListener("load", () => resolve(), { once: true });
+ });
+ iframe.src = "http://example.org";
+
+ await loaded;
+ });
+
+ const wgpDestroyed = windowGlobalDestroyed(tab.linkedBrowser.innerWindowID);
+ BrowserTestUtils.removeTab(tab);
+ await wgpDestroyed;
+
+ // The page only ever had two origins at once.
+ TelemetryTestUtils.assertHistogram(histogram, 2, 1);
+});
diff --git a/browser/modules/test/browser/browser_UnsubmittedCrashHandler.js b/browser/modules/test/browser/browser_UnsubmittedCrashHandler.js
new file mode 100644
index 0000000000..47684b1a5a
--- /dev/null
+++ b/browser/modules/test/browser/browser_UnsubmittedCrashHandler.js
@@ -0,0 +1,819 @@
+"use strict";
+
+/**
+ * This suite tests the "unsubmitted crash report" notification
+ * that is seen when we detect pending crash reports on startup.
+ */
+
+const { UnsubmittedCrashHandler } = ChromeUtils.import(
+ "resource:///modules/ContentCrashHandlers.jsm"
+);
+
+const { makeFakeAppDir } = ChromeUtils.importESModule(
+ "resource://testing-common/AppData.sys.mjs"
+);
+
+const DAY = 24 * 60 * 60 * 1000; // milliseconds
+const SERVER_URL =
+ "http://example.com/browser/toolkit/crashreporter/test/browser/crashreport.sjs";
+
+/**
+ * Returns the directly where the browsing is storing the
+ * pending crash reports.
+ *
+ * @returns nsIFile
+ */
+function getPendingCrashReportDir() {
+ // The fake UAppData directory that makeFakeAppDir provides
+ // is just UAppData under the profile directory.
+ return FileUtils.getDir(
+ "ProfD",
+ ["UAppData", "Crash Reports", "pending"],
+ false
+ );
+}
+
+/**
+ * Synchronously deletes all entries inside the pending
+ * crash report directory.
+ */
+function clearPendingCrashReports() {
+ let dir = getPendingCrashReportDir();
+ let entries = dir.directoryEntries;
+
+ while (entries.hasMoreElements()) {
+ let entry = entries.nextFile;
+ if (entry.isFile()) {
+ entry.remove(false);
+ }
+ }
+}
+
+/**
+ * Randomly generates howMany crash report .dmp and .extra files
+ * to put into the pending crash report directory. We're not
+ * actually creating real crash reports here, just stubbing
+ * out enough of the files to satisfy our notification and
+ * submission code.
+ *
+ * @param howMany (int)
+ * How many pending crash reports to put in the pending
+ * crash report directory.
+ * @param accessDate (Date, optional)
+ * What date to set as the last accessed time on the created
+ * crash reports. This defaults to the current date and time.
+ * @returns Promise
+ */
+function createPendingCrashReports(howMany, accessDate) {
+ let dir = getPendingCrashReportDir();
+ if (!accessDate) {
+ accessDate = new Date();
+ }
+
+ /**
+ * Helper function for creating a file in the pending crash report
+ * directory.
+ *
+ * @param fileName (string)
+ * The filename for the crash report, not including the
+ * extension. This is usually a UUID.
+ * @param extension (string)
+ * The file extension for the created file.
+ * @param accessDate (Date, optional)
+ * The date to set lastAccessed to, if anything.
+ * @param contents (string, optional)
+ * Set this to whatever the file needs to contain, if anything.
+ * @returns Promise
+ */
+ let createFile = async (fileName, extension, lastAccessedDate, contents) => {
+ let file = dir.clone();
+ file.append(fileName + "." + extension);
+ file.create(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+
+ if (contents) {
+ await IOUtils.writeUTF8(file.path, contents, {
+ tmpPath: file.path + ".tmp",
+ });
+ }
+
+ if (lastAccessedDate) {
+ await IOUtils.setAccessTime(file.path, lastAccessedDate.valueOf());
+ }
+ };
+
+ let uuidGenerator = Services.uuid;
+ // Some annotations are always present in the .extra file and CrashSubmit.jsm
+ // expects there to be a ServerURL entry, so we'll add them here.
+ let extraFileContents = JSON.stringify({
+ ServerURL: SERVER_URL,
+ TelemetryServerURL: "http://telemetry.mozilla.org/",
+ TelemetryClientId: "c69e7487-df10-4c98-ab1a-c85660feecf3",
+ TelemetrySessionId: "22af5a41-6e84-4112-b1f7-4cb12cb6f6a5",
+ });
+
+ return (async function () {
+ let uuids = [];
+ for (let i = 0; i < howMany; ++i) {
+ let uuid = uuidGenerator.generateUUID().toString();
+ // Strip the {}...
+ uuid = uuid.substring(1, uuid.length - 1);
+ await createFile(uuid, "dmp", accessDate);
+ await createFile(uuid, "extra", accessDate, extraFileContents);
+ uuids.push(uuid);
+ }
+ return uuids;
+ })();
+}
+
+/**
+ * Returns a Promise that resolves once CrashSubmit starts sending
+ * success notifications for crash submission matching the reportIDs
+ * being passed in.
+ *
+ * @param reportIDs (Array<string>)
+ * The IDs for the reports that we expect CrashSubmit to have sent.
+ * @param extraCheck (Function, optional)
+ * A function that receives the annotations of the crash report and can
+ * be used for checking them
+ * @returns Promise
+ */
+function waitForSubmittedReports(reportIDs, extraCheck) {
+ let promises = [];
+ for (let reportID of reportIDs) {
+ let promise = TestUtils.topicObserved(
+ "crash-report-status",
+ (subject, data) => {
+ if (data == "success") {
+ let propBag = subject.QueryInterface(Ci.nsIPropertyBag2);
+ let dumpID = propBag.getPropertyAsAString("minidumpID");
+ if (dumpID == reportID) {
+ if (extraCheck) {
+ let extra = propBag.getPropertyAsInterface(
+ "extra",
+ Ci.nsIPropertyBag2
+ );
+
+ extraCheck(extra);
+ }
+
+ return true;
+ }
+ }
+ return false;
+ }
+ );
+ promises.push(promise);
+ }
+ return Promise.all(promises);
+}
+
+/**
+ * Returns a Promise that resolves once a .dmp.ignore file is created for
+ * the crashes in the pending directory matching the reportIDs being
+ * passed in.
+ *
+ * @param reportIDs (Array<string>)
+ * The IDs for the reports that we expect CrashSubmit to have been
+ * marked for ignoring.
+ * @returns Promise
+ */
+function waitForIgnoredReports(reportIDs) {
+ let dir = getPendingCrashReportDir();
+ let promises = [];
+ for (let reportID of reportIDs) {
+ let file = dir.clone();
+ file.append(reportID + ".dmp.ignore");
+ promises.push(IOUtils.exists(file.path));
+ }
+ return Promise.all(promises);
+}
+
+add_setup(async function () {
+ // Pending crash reports are stored in the UAppData folder,
+ // which exists outside of the profile folder. In order to
+ // not overwrite / clear pending crash reports for the poor
+ // soul who runs this test, we use AppData.sys.mjs to point to
+ // a special made-up directory inside the profile
+ // directory.
+ await makeFakeAppDir();
+ // We'll assume that the notifications will be shown in the current
+ // browser window's global notification box.
+
+ // If we happen to already be seeing the unsent crash report
+ // notification, it's because the developer running this test
+ // happened to have some unsent reports in their UAppDir.
+ // We'll remove the notification without touching those reports.
+ let notification = gNotificationBox.getNotificationWithValue(
+ "pending-crash-reports"
+ );
+ if (notification) {
+ notification.close();
+ }
+
+ let oldServerURL = Services.env.get("MOZ_CRASHREPORTER_URL");
+ Services.env.set("MOZ_CRASHREPORTER_URL", SERVER_URL);
+
+ // nsBrowserGlue starts up UnsubmittedCrashHandler automatically
+ // on a timer, so at this point, it can be in one of several states:
+ //
+ // 1. The timer hasn't yet finished, and an automatic scan for crash
+ // reports is pending.
+ // 2. The timer has already gone off and the scan has already completed.
+ // 3. The handler is disabled.
+ //
+ // To collapse all of these possibilities, we uninit the UnsubmittedCrashHandler
+ // to cancel the timer, make sure it's preffed on, and then restart it (which
+ // doesn't restart the timer). Note that making the component initialize
+ // even when it's disabled is an intentional choice, as this allows for easier
+ // simulation of startup and shutdown.
+ UnsubmittedCrashHandler.uninit();
+
+ // While we're here, let's test that we don't show the notification
+ // if we're disabled and something happens to check for unsubmitted
+ // crash reports.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.crashReports.unsubmittedCheck.enabled", false]],
+ });
+
+ await createPendingCrashReports(1);
+
+ notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(!notification, "There should not be a notification");
+
+ clearPendingCrashReports();
+ await SpecialPowers.popPrefEnv();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.crashReports.unsubmittedCheck.enabled", true]],
+ });
+ UnsubmittedCrashHandler.init();
+
+ registerCleanupFunction(function () {
+ clearPendingCrashReports();
+ Services.env.set("MOZ_CRASHREPORTER_URL", oldServerURL);
+ });
+});
+
+/**
+ * Tests that if there are no pending crash reports, then the
+ * notification will not show up.
+ */
+add_task(async function test_no_pending_no_notification() {
+ // Make absolutely sure there are no pending crash reports first...
+ clearPendingCrashReports();
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.equal(
+ notification,
+ null,
+ "There should not be a notification if there are no " +
+ "pending crash reports"
+ );
+});
+
+/**
+ * Tests that there is a notification if there is one pending
+ * crash report.
+ */
+add_task(async function test_one_pending() {
+ await createPendingCrashReports(1);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+ gNotificationBox.removeNotification(notification, true);
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that an ignored crash report does not suppress a notification that
+ * would be trigged by another, unignored crash report.
+ */
+add_task(async function test_other_ignored() {
+ let toIgnore = await createPendingCrashReports(1);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ // Dismiss notification, creating the .dmp.ignore file
+ notification.closeButton.click();
+ gNotificationBox.removeNotification(notification, true);
+ await waitForIgnoredReports(toIgnore);
+
+ notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(!notification, "There should not be a notification");
+
+ await createPendingCrashReports(1);
+ notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ gNotificationBox.removeNotification(notification, true);
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that there is a notification if there is more than one
+ * pending crash report.
+ */
+add_task(async function test_several_pending() {
+ await createPendingCrashReports(3);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ gNotificationBox.removeNotification(notification, true);
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that there is no notification if the only pending crash
+ * reports are over 28 days old. Also checks that if we put a newer
+ * crash with that older set, that we can still get a notification.
+ */
+add_task(async function test_several_pending() {
+ // Let's create some crash reports from 30 days ago.
+ let oldDate = new Date(Date.now() - 30 * DAY);
+ await createPendingCrashReports(3, oldDate);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.equal(
+ notification,
+ null,
+ "There should not be a notification if there are only " +
+ "old pending crash reports"
+ );
+ // Now let's create a new one and check again
+ await createPendingCrashReports(1);
+ notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ gNotificationBox.removeNotification(notification, true);
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that the notification can submit a report.
+ */
+add_task(async function test_can_submit() {
+ function extraCheck(extra) {
+ const blockedAnnotations = [
+ "ServerURL",
+ "TelemetryClientId",
+ "TelemetryServerURL",
+ "TelemetrySessionId",
+ ];
+ for (const key of blockedAnnotations) {
+ Assert.ok(
+ !extra.hasKey(key),
+ "The " + key + " annotation should have been stripped away"
+ );
+ }
+
+ Assert.equal(extra.get("SubmittedFrom"), "Infobar");
+ Assert.equal(extra.get("Throttleable"), "1");
+ }
+
+ let reportIDs = await createPendingCrashReports(1);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ // Attempt to submit the notification by clicking on the submit
+ // button
+ let buttons = notification.buttonContainer.querySelectorAll(
+ ".notification-button"
+ );
+ // ...which should be the first button.
+ let submit = buttons[0];
+ let promiseReports = waitForSubmittedReports(reportIDs, extraCheck);
+ info("Sending crash report");
+ submit.click();
+ info("Sent!");
+ // We'll not wait for the notification to finish its transition -
+ // we'll just remove it right away.
+ gNotificationBox.removeNotification(notification, true);
+
+ info("Waiting on reports to be received.");
+ await promiseReports;
+ info("Received!");
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that the notification can submit multiple reports.
+ */
+add_task(async function test_can_submit_several() {
+ let reportIDs = await createPendingCrashReports(3);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ // Attempt to submit the notification by clicking on the submit
+ // button
+ let buttons = notification.buttonContainer.querySelectorAll(
+ ".notification-button"
+ );
+ // ...which should be the first button.
+ let submit = buttons[0];
+
+ let promiseReports = waitForSubmittedReports(reportIDs);
+ info("Sending crash reports");
+ submit.click();
+ info("Sent!");
+ // We'll not wait for the notification to finish its transition -
+ // we'll just remove it right away.
+ gNotificationBox.removeNotification(notification, true);
+
+ info("Waiting on reports to be received.");
+ await promiseReports;
+ info("Received!");
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that choosing "Send Always" flips the autoSubmit pref
+ * and sends the pending crash reports.
+ */
+add_task(async function test_can_submit_always() {
+ let pref = "browser.crashReports.unsubmittedCheck.autoSubmit2";
+ Assert.equal(
+ Services.prefs.getBoolPref(pref),
+ false,
+ "We should not be auto-submitting by default"
+ );
+
+ let reportIDs = await createPendingCrashReports(1);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ // Attempt to submit the notification by clicking on the send all
+ // button
+ let buttons = notification.buttonContainer.querySelectorAll(
+ ".notification-button"
+ );
+ // ...which should be the second button.
+ let sendAll = buttons[1];
+
+ let promiseReports = waitForSubmittedReports(reportIDs);
+ info("Sending crash reports");
+ sendAll.click();
+ info("Sent!");
+ // We'll not wait for the notification to finish its transition -
+ // we'll just remove it right away.
+ gNotificationBox.removeNotification(notification, true);
+
+ info("Waiting on reports to be received.");
+ await promiseReports;
+ info("Received!");
+
+ // Make sure the pref was set
+ Assert.equal(
+ Services.prefs.getBoolPref(pref),
+ true,
+ "The autoSubmit pref should have been set"
+ );
+
+ // Create another report
+ reportIDs = await createPendingCrashReports(1);
+ let result = await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+
+ // Check that the crash was auto-submitted
+ Assert.equal(result, null, "The notification should not be shown");
+ promiseReports = await waitForSubmittedReports(reportIDs, extra => {
+ Assert.equal(extra.get("SubmittedFrom"), "Auto");
+ Assert.equal(extra.get("Throttleable"), "1");
+ });
+
+ // And revert back to default now.
+ Services.prefs.clearUserPref(pref);
+
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that if the user has chosen to automatically send
+ * crash reports that no notification is displayed to the
+ * user.
+ */
+add_task(async function test_can_auto_submit() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.crashReports.unsubmittedCheck.autoSubmit2", true]],
+ });
+
+ let reportIDs = await createPendingCrashReports(3);
+ let promiseReports = waitForSubmittedReports(reportIDs);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.equal(notification, null, "There should be no notification");
+ info("Waiting on reports to be received.");
+ await promiseReports;
+ info("Received!");
+
+ clearPendingCrashReports();
+ await SpecialPowers.popPrefEnv();
+});
+
+/**
+ * Tests that if the user chooses to dismiss the notification,
+ * then the current pending requests won't cause the notification
+ * to appear again in the future.
+ */
+add_task(async function test_can_ignore() {
+ let reportIDs = await createPendingCrashReports(3);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ // Dismiss the notification by clicking on the "X" button.
+ notification.closeButton.click();
+ // We'll not wait for the notification to finish its transition -
+ // we'll just remove it right away.
+ gNotificationBox.removeNotification(notification, true);
+ await waitForIgnoredReports(reportIDs);
+
+ notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.equal(notification, null, "There should be no notification");
+
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that if the notification is shown, then the
+ * lastShownDate is set for today.
+ */
+add_task(async function test_last_shown_date() {
+ await createPendingCrashReports(1);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ let today = UnsubmittedCrashHandler.dateString(new Date());
+ let lastShownDate =
+ UnsubmittedCrashHandler.prefs.getCharPref("lastShownDate");
+ Assert.equal(today, lastShownDate, "Last shown date should be today.");
+
+ UnsubmittedCrashHandler.prefs.clearUserPref("lastShownDate");
+ gNotificationBox.removeNotification(notification, true);
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that if UnsubmittedCrashHandler is uninit with a
+ * notification still being shown, that
+ * browser.crashReports.unsubmittedCheck.shutdownWhileShowing is
+ * set to true.
+ */
+add_task(async function test_shutdown_while_showing() {
+ await createPendingCrashReports(1);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ UnsubmittedCrashHandler.uninit();
+ let shutdownWhileShowing = UnsubmittedCrashHandler.prefs.getBoolPref(
+ "shutdownWhileShowing"
+ );
+ Assert.ok(
+ shutdownWhileShowing,
+ "We should have noticed that we uninitted while showing " +
+ "the notification."
+ );
+ UnsubmittedCrashHandler.prefs.clearUserPref("shutdownWhileShowing");
+ UnsubmittedCrashHandler.init();
+
+ gNotificationBox.removeNotification(notification, true);
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that if UnsubmittedCrashHandler is uninit after
+ * the notification has been closed, that
+ * browser.crashReports.unsubmittedCheck.shutdownWhileShowing is
+ * not set in prefs.
+ */
+add_task(async function test_shutdown_while_not_showing() {
+ let reportIDs = await createPendingCrashReports(1);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ // Dismiss the notification by clicking on the "X" button.
+ notification.closeButton.click();
+ // We'll not wait for the notification to finish its transition -
+ // we'll just remove it right away.
+ gNotificationBox.removeNotification(notification, true);
+
+ await waitForIgnoredReports(reportIDs);
+
+ UnsubmittedCrashHandler.uninit();
+ Assert.throws(
+ () => {
+ UnsubmittedCrashHandler.prefs.getBoolPref("shutdownWhileShowing");
+ },
+ /NS_ERROR_UNEXPECTED/,
+ "We should have noticed that the notification had closed before uninitting."
+ );
+ UnsubmittedCrashHandler.init();
+
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that if
+ * browser.crashReports.unsubmittedCheck.shutdownWhileShowing is
+ * set and the lastShownDate is today, then we don't decrement
+ * browser.crashReports.unsubmittedCheck.chancesUntilSuppress.
+ */
+add_task(async function test_dont_decrement_chances_on_same_day() {
+ let initChances = UnsubmittedCrashHandler.prefs.getIntPref(
+ "chancesUntilSuppress"
+ );
+ Assert.greater(initChances, 1, "We should start with at least 1 chance.");
+
+ await createPendingCrashReports(1);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ UnsubmittedCrashHandler.uninit();
+
+ gNotificationBox.removeNotification(notification, true);
+
+ let shutdownWhileShowing = UnsubmittedCrashHandler.prefs.getBoolPref(
+ "shutdownWhileShowing"
+ );
+ Assert.ok(
+ shutdownWhileShowing,
+ "We should have noticed that we uninitted while showing " +
+ "the notification."
+ );
+
+ let today = UnsubmittedCrashHandler.dateString(new Date());
+ let lastShownDate =
+ UnsubmittedCrashHandler.prefs.getCharPref("lastShownDate");
+ Assert.equal(today, lastShownDate, "Last shown date should be today.");
+
+ UnsubmittedCrashHandler.init();
+
+ notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should still be a notification");
+
+ let chances = UnsubmittedCrashHandler.prefs.getIntPref(
+ "chancesUntilSuppress"
+ );
+
+ Assert.equal(initChances, chances, "We should not have decremented chances.");
+
+ gNotificationBox.removeNotification(notification, true);
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that if
+ * browser.crashReports.unsubmittedCheck.shutdownWhileShowing is
+ * set and the lastShownDate is before today, then we decrement
+ * browser.crashReports.unsubmittedCheck.chancesUntilSuppress.
+ */
+add_task(async function test_decrement_chances_on_other_day() {
+ let initChances = UnsubmittedCrashHandler.prefs.getIntPref(
+ "chancesUntilSuppress"
+ );
+ Assert.greater(initChances, 1, "We should start with at least 1 chance.");
+
+ await createPendingCrashReports(1);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ UnsubmittedCrashHandler.uninit();
+
+ gNotificationBox.removeNotification(notification, true);
+
+ let shutdownWhileShowing = UnsubmittedCrashHandler.prefs.getBoolPref(
+ "shutdownWhileShowing"
+ );
+ Assert.ok(
+ shutdownWhileShowing,
+ "We should have noticed that we uninitted while showing " +
+ "the notification."
+ );
+
+ // Now pretend that the notification was shown yesterday.
+ let yesterday = UnsubmittedCrashHandler.dateString(
+ new Date(Date.now() - DAY)
+ );
+ UnsubmittedCrashHandler.prefs.setCharPref("lastShownDate", yesterday);
+
+ UnsubmittedCrashHandler.init();
+
+ notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should still be a notification");
+
+ let chances = UnsubmittedCrashHandler.prefs.getIntPref(
+ "chancesUntilSuppress"
+ );
+
+ Assert.equal(
+ initChances - 1,
+ chances,
+ "We should have decremented our chances."
+ );
+ UnsubmittedCrashHandler.prefs.clearUserPref("chancesUntilSuppress");
+
+ gNotificationBox.removeNotification(notification, true);
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that if we've shutdown too many times showing the
+ * notification, and we've run out of chances, then
+ * browser.crashReports.unsubmittedCheck.suppressUntilDate is
+ * set for some days into the future.
+ */
+add_task(async function test_can_suppress_after_chances() {
+ // Pretend that a notification was shown yesterday.
+ let yesterday = UnsubmittedCrashHandler.dateString(
+ new Date(Date.now() - DAY)
+ );
+ UnsubmittedCrashHandler.prefs.setCharPref("lastShownDate", yesterday);
+ UnsubmittedCrashHandler.prefs.setBoolPref("shutdownWhileShowing", true);
+ UnsubmittedCrashHandler.prefs.setIntPref("chancesUntilSuppress", 0);
+
+ await createPendingCrashReports(1);
+ let notification =
+ await UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.equal(
+ notification,
+ null,
+ "There should be no notification if we've run out of chances"
+ );
+
+ // We should have set suppressUntilDate into the future
+ let suppressUntilDate =
+ UnsubmittedCrashHandler.prefs.getCharPref("suppressUntilDate");
+
+ let today = UnsubmittedCrashHandler.dateString(new Date());
+ Assert.ok(
+ suppressUntilDate > today,
+ "We should be suppressing until some days into the future."
+ );
+
+ UnsubmittedCrashHandler.prefs.clearUserPref("chancesUntilSuppress");
+ UnsubmittedCrashHandler.prefs.clearUserPref("suppressUntilDate");
+ UnsubmittedCrashHandler.prefs.clearUserPref("lastShownDate");
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that if there's a suppression date set, then no notification
+ * will be shown even if there are pending crash reports.
+ */
+add_task(async function test_suppression() {
+ let future = UnsubmittedCrashHandler.dateString(
+ new Date(Date.now() + DAY * 5)
+ );
+ UnsubmittedCrashHandler.prefs.setCharPref("suppressUntilDate", future);
+ UnsubmittedCrashHandler.uninit();
+ UnsubmittedCrashHandler.init();
+
+ Assert.ok(
+ UnsubmittedCrashHandler.suppressed,
+ "The UnsubmittedCrashHandler should be suppressed."
+ );
+ UnsubmittedCrashHandler.prefs.clearUserPref("suppressUntilDate");
+
+ UnsubmittedCrashHandler.uninit();
+ UnsubmittedCrashHandler.init();
+});
+
+/**
+ * Tests that if there's a suppression date set, but we've exceeded
+ * it, then we can show the notification again.
+ */
+add_task(async function test_end_suppression() {
+ let yesterday = UnsubmittedCrashHandler.dateString(
+ new Date(Date.now() - DAY)
+ );
+ UnsubmittedCrashHandler.prefs.setCharPref("suppressUntilDate", yesterday);
+ UnsubmittedCrashHandler.uninit();
+ UnsubmittedCrashHandler.init();
+
+ Assert.ok(
+ !UnsubmittedCrashHandler.suppressed,
+ "The UnsubmittedCrashHandler should not be suppressed."
+ );
+ Assert.ok(
+ !UnsubmittedCrashHandler.prefs.prefHasUserValue("suppressUntilDate"),
+ "The suppression date should been cleared from preferences."
+ );
+
+ UnsubmittedCrashHandler.uninit();
+ UnsubmittedCrashHandler.init();
+});
diff --git a/browser/modules/test/browser/browser_UsageTelemetry.js b/browser/modules/test/browser/browser_UsageTelemetry.js
new file mode 100644
index 0000000000..aa752f5b7b
--- /dev/null
+++ b/browser/modules/test/browser/browser_UsageTelemetry.js
@@ -0,0 +1,684 @@
+"use strict";
+
+requestLongerTimeout(2);
+
+const MAX_CONCURRENT_TABS = "browser.engagement.max_concurrent_tab_count";
+const TAB_EVENT_COUNT = "browser.engagement.tab_open_event_count";
+const MAX_CONCURRENT_WINDOWS = "browser.engagement.max_concurrent_window_count";
+const MAX_TAB_PINNED = "browser.engagement.max_concurrent_tab_pinned_count";
+const TAB_PINNED_EVENT = "browser.engagement.tab_pinned_event_count";
+const WINDOW_OPEN_COUNT = "browser.engagement.window_open_event_count";
+const TOTAL_URI_COUNT = "browser.engagement.total_uri_count";
+const UNIQUE_DOMAINS_COUNT = "browser.engagement.unique_domains_count";
+const UNFILTERED_URI_COUNT = "browser.engagement.unfiltered_uri_count";
+const TOTAL_URI_COUNT_NORMAL_AND_PRIVATE_MODE =
+ "browser.engagement.total_uri_count_normal_and_private_mode";
+
+const TELEMETRY_SUBSESSION_TOPIC = "internal-telemetry-after-subsession-split";
+
+const RESTORE_ON_DEMAND_PREF = "browser.sessionstore.restore_on-demand";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "MINIMUM_TAB_COUNT_INTERVAL_MS",
+ "resource:///modules/BrowserUsageTelemetry.jsm"
+);
+
+const { ObjectUtils } = ChromeUtils.import(
+ "resource://gre/modules/ObjectUtils.jsm"
+);
+
+BrowserUsageTelemetry._onTabsOpenedTask._timeoutMs = 0;
+registerCleanupFunction(() => {
+ BrowserUsageTelemetry._onTabsOpenedTask._timeoutMs = undefined;
+});
+
+// Reset internal URI counter in case URIs were opened by other tests.
+Services.obs.notifyObservers(null, TELEMETRY_SUBSESSION_TOPIC);
+
+/**
+ * Get a snapshot of the scalars and check them against the provided values.
+ */
+let checkScalars = (countsObject, skipGleanCheck = false) => {
+ const scalars = TelemetryTestUtils.getProcessScalars("parent");
+
+ // Check the expected values. Scalars that are never set must not be reported.
+ const checkScalar = (key, val, msg) =>
+ val > 0
+ ? TelemetryTestUtils.assertScalar(scalars, key, val, msg)
+ : TelemetryTestUtils.assertScalarUnset(scalars, key);
+ checkScalar(
+ MAX_CONCURRENT_TABS,
+ countsObject.maxTabs,
+ "The maximum tab count must match the expected value."
+ );
+ checkScalar(
+ TAB_EVENT_COUNT,
+ countsObject.tabOpenCount,
+ "The number of open tab event count must match the expected value."
+ );
+ checkScalar(
+ MAX_TAB_PINNED,
+ countsObject.maxTabsPinned,
+ "The maximum tabs pinned count must match the expected value."
+ );
+ checkScalar(
+ TAB_PINNED_EVENT,
+ countsObject.tabPinnedCount,
+ "The number of tab pinned event count must match the expected value."
+ );
+ checkScalar(
+ MAX_CONCURRENT_WINDOWS,
+ countsObject.maxWindows,
+ "The maximum window count must match the expected value."
+ );
+ checkScalar(
+ WINDOW_OPEN_COUNT,
+ countsObject.windowsOpenCount,
+ "The number of window open event count must match the expected value."
+ );
+ checkScalar(
+ TOTAL_URI_COUNT,
+ countsObject.totalURIs,
+ "The total URI count must match the expected value."
+ );
+ checkScalar(
+ UNIQUE_DOMAINS_COUNT,
+ countsObject.domainCount,
+ "The unique domains count must match the expected value."
+ );
+ checkScalar(
+ UNFILTERED_URI_COUNT,
+ countsObject.totalUnfilteredURIs,
+ "The unfiltered URI count must match the expected value."
+ );
+ checkScalar(
+ TOTAL_URI_COUNT_NORMAL_AND_PRIVATE_MODE,
+ countsObject.totalURIsNormalAndPrivateMode,
+ "The total URI count for both normal and private mode must match the expected value."
+ );
+ if (!skipGleanCheck) {
+ if (countsObject.totalURIsNormalAndPrivateMode == 0) {
+ Assert.equal(
+ Glean.browserEngagement.uriCount.testGetValue(),
+ undefined,
+ "Total URI count reported in Glean must be unset."
+ );
+ } else {
+ Assert.equal(
+ countsObject.totalURIsNormalAndPrivateMode,
+ Glean.browserEngagement.uriCount.testGetValue(),
+ "The total URI count reported in Glean must be as expected."
+ );
+ }
+ }
+};
+
+add_task(async function test_tabsAndWindows() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+ Services.fog.testResetFOG();
+
+ let openedTabs = [];
+ let expectedTabOpenCount = 0;
+ let expectedWinOpenCount = 0;
+ let expectedMaxTabs = 0;
+ let expectedMaxWins = 0;
+ let expectedMaxTabsPinned = 0;
+ let expectedTabPinned = 0;
+ let expectedTotalURIs = 0;
+
+ // Add a new tab and check that the count is right.
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank")
+ );
+
+ gBrowser.pinTab(openedTabs[0]);
+ gBrowser.unpinTab(openedTabs[0]);
+
+ expectedTabOpenCount = 1;
+ expectedMaxTabs = 2;
+ expectedMaxTabsPinned = 1;
+ expectedTabPinned += 1;
+ // This, and all the checks below, also check that initial pages (about:newtab, about:blank, ..)
+ // are not counted by the total_uri_count and the unfiltered_uri_count probes.
+ checkScalars({
+ maxTabs: expectedMaxTabs,
+ tabOpenCount: expectedTabOpenCount,
+ maxWindows: expectedMaxWins,
+ windowsOpenCount: expectedWinOpenCount,
+ totalURIs: expectedTotalURIs,
+ domainCount: 0,
+ totalUnfilteredURIs: 0,
+ maxTabsPinned: expectedMaxTabsPinned,
+ tabPinnedCount: expectedTabPinned,
+ totalURIsNormalAndPrivateMode: expectedTotalURIs,
+ });
+
+ // Add two new tabs in the same window.
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank")
+ );
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank")
+ );
+
+ gBrowser.pinTab(openedTabs[1]);
+ gBrowser.pinTab(openedTabs[2]);
+ gBrowser.unpinTab(openedTabs[2]);
+ gBrowser.unpinTab(openedTabs[1]);
+
+ expectedTabOpenCount += 2;
+ expectedMaxTabs += 2;
+ expectedMaxTabsPinned = 2;
+ expectedTabPinned += 2;
+ checkScalars({
+ maxTabs: expectedMaxTabs,
+ tabOpenCount: expectedTabOpenCount,
+ maxWindows: expectedMaxWins,
+ windowsOpenCount: expectedWinOpenCount,
+ totalURIs: expectedTotalURIs,
+ domainCount: 0,
+ totalUnfilteredURIs: 0,
+ maxTabsPinned: expectedMaxTabsPinned,
+ tabPinnedCount: expectedTabPinned,
+ totalURIsNormalAndPrivateMode: expectedTotalURIs,
+ });
+
+ // Add a new window and then some tabs in it. An empty new windows counts as a tab.
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await BrowserTestUtils.firstBrowserLoaded(win);
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:blank")
+ );
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:blank")
+ );
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank")
+ );
+ // The new window started with a new tab, so account for it.
+ expectedTabOpenCount += 4;
+ expectedWinOpenCount += 1;
+ expectedMaxWins = 2;
+ expectedMaxTabs += 4;
+
+ // Remove a tab from the first window, the max shouldn't change.
+ BrowserTestUtils.removeTab(openedTabs.pop());
+ checkScalars({
+ maxTabs: expectedMaxTabs,
+ tabOpenCount: expectedTabOpenCount,
+ maxWindows: expectedMaxWins,
+ windowsOpenCount: expectedWinOpenCount,
+ totalURIs: expectedTotalURIs,
+ domainCount: 0,
+ totalUnfilteredURIs: 0,
+ maxTabsPinned: expectedMaxTabsPinned,
+ tabPinnedCount: expectedTabPinned,
+ totalURIsNormalAndPrivateMode: expectedTotalURIs,
+ });
+
+ // Remove all the extra windows and tabs.
+ for (let tab of openedTabs) {
+ BrowserTestUtils.removeTab(tab);
+ }
+ await BrowserTestUtils.closeWindow(win);
+
+ // Make sure all the scalars still have the expected values.
+ checkScalars({
+ maxTabs: expectedMaxTabs,
+ tabOpenCount: expectedTabOpenCount,
+ maxWindows: expectedMaxWins,
+ windowsOpenCount: expectedWinOpenCount,
+ totalURIs: expectedTotalURIs,
+ domainCount: 0,
+ totalUnfilteredURIs: 0,
+ maxTabsPinned: expectedMaxTabsPinned,
+ tabPinnedCount: expectedTabPinned,
+ totalURIsNormalAndPrivateMode: expectedTotalURIs,
+ });
+});
+
+add_task(async function test_subsessionSplit() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+
+ // Add a new window (that will have 4 tabs).
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await BrowserTestUtils.firstBrowserLoaded(win);
+ let openedTabs = [];
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:blank")
+ );
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:mozilla")
+ );
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ "http://www.example.com"
+ )
+ );
+
+ // Check that the scalars have the right values. We expect 2 unfiltered URI loads
+ // (about:mozilla and www.example.com, but no about:blank) and 1 URI totalURIs
+ // (only www.example.com).
+ let expectedTotalURIs = 1;
+
+ checkScalars({
+ maxTabs: 5,
+ tabOpenCount: 4,
+ maxWindows: 2,
+ windowsOpenCount: 1,
+ totalURIs: expectedTotalURIs,
+ domainCount: 1,
+ totalUnfilteredURIs: 2,
+ maxTabsPinned: 0,
+ tabPinnedCount: 0,
+ totalURIsNormalAndPrivateMode: expectedTotalURIs,
+ });
+
+ // Remove a tab.
+ BrowserTestUtils.removeTab(openedTabs.pop());
+
+ // Simulate a subsession split by clearing the scalars (via |getSnapshotForScalars|) and
+ // notifying the subsession split topic.
+ Services.telemetry.getSnapshotForScalars("main", true /* clearScalars */);
+ Services.obs.notifyObservers(null, TELEMETRY_SUBSESSION_TOPIC);
+
+ // After a subsession split, only the MAX_CONCURRENT_* scalars must be available
+ // and have the correct value. No tabs, windows or URIs were opened so other scalars
+ // must not be reported.
+ expectedTotalURIs = 0;
+
+ checkScalars(
+ {
+ maxTabs: 4,
+ tabOpenCount: 0,
+ maxWindows: 2,
+ windowsOpenCount: 0,
+ totalURIs: expectedTotalURIs,
+ domainCount: 0,
+ totalUnfilteredURIs: 0,
+ maxTabsPinned: 0,
+ tabPinnedCount: 0,
+ totalURIsNormalAndPrivateMode: expectedTotalURIs,
+ },
+ true
+ );
+
+ // Remove all the extra windows and tabs.
+ for (let tab of openedTabs) {
+ BrowserTestUtils.removeTab(tab);
+ }
+ await BrowserTestUtils.closeWindow(win);
+});
+
+function checkTabCountHistogram(result, expected, message) {
+ Assert.deepEqual(result.values, expected, message);
+}
+
+add_task(async function test_tabsHistogram() {
+ let openedTabs = [];
+ let tabCountHist = TelemetryTestUtils.getAndClearHistogram("TAB_COUNT");
+
+ checkTabCountHistogram(
+ tabCountHist.snapshot(),
+ {},
+ "TAB_COUNT telemetry - initial tab counts"
+ );
+
+ // Add a new tab and check that the count is right.
+ BrowserUsageTelemetry._lastRecordTabCount = 0;
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank")
+ );
+ checkTabCountHistogram(
+ tabCountHist.snapshot(),
+ { 1: 0, 2: 1, 3: 0 },
+ "TAB_COUNT telemetry - opening tabs"
+ );
+
+ // Open a different page and check the counts.
+ BrowserUsageTelemetry._lastRecordTabCount = 0;
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+ openedTabs.push(tab);
+ BrowserUsageTelemetry._lastRecordTabCount = 0;
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ "http://example.com/"
+ );
+ checkTabCountHistogram(
+ tabCountHist.snapshot(),
+ { 1: 0, 2: 1, 3: 2, 4: 0 },
+ "TAB_COUNT telemetry - loading page"
+ );
+
+ // Open another tab
+ BrowserUsageTelemetry._lastRecordTabCount = 0;
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank")
+ );
+ checkTabCountHistogram(
+ tabCountHist.snapshot(),
+ { 1: 0, 2: 1, 3: 2, 4: 1, 5: 0 },
+ "TAB_COUNT telemetry - opening more tabs"
+ );
+
+ // Add a new window and then some tabs in it. A new window starts with one tab.
+ BrowserUsageTelemetry._lastRecordTabCount = 0;
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await BrowserTestUtils.firstBrowserLoaded(win);
+ checkTabCountHistogram(
+ tabCountHist.snapshot(),
+ { 1: 0, 2: 1, 3: 2, 4: 1, 5: 1, 6: 0 },
+ "TAB_COUNT telemetry - opening window"
+ );
+
+ // Do not trigger a recount if _lastRecordTabCount is recent on new tab
+ BrowserUsageTelemetry._lastRecordTabCount =
+ Date.now() - MINIMUM_TAB_COUNT_INTERVAL_MS / 2;
+ {
+ let oldLastRecordTabCount = BrowserUsageTelemetry._lastRecordTabCount;
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:blank")
+ );
+ checkTabCountHistogram(
+ tabCountHist.snapshot(),
+ { 1: 0, 2: 1, 3: 2, 4: 1, 5: 1, 6: 0 },
+ "TAB_COUNT telemetry - new tab, recount event ignored"
+ );
+ ok(
+ BrowserUsageTelemetry._lastRecordTabCount == oldLastRecordTabCount,
+ "TAB_COUNT telemetry - _lastRecordTabCount unchanged"
+ );
+ }
+
+ // Trigger a recount if _lastRecordTabCount has passed on new tab
+ BrowserUsageTelemetry._lastRecordTabCount =
+ Date.now() - (MINIMUM_TAB_COUNT_INTERVAL_MS + 1000);
+ {
+ let oldLastRecordTabCount = BrowserUsageTelemetry._lastRecordTabCount;
+ openedTabs.push(
+ await BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:blank")
+ );
+ checkTabCountHistogram(
+ tabCountHist.snapshot(),
+ { 1: 0, 2: 1, 3: 2, 4: 1, 5: 1, 7: 1, 8: 0 },
+ "TAB_COUNT telemetry - new tab, recount event included"
+ );
+ ok(
+ BrowserUsageTelemetry._lastRecordTabCount != oldLastRecordTabCount,
+ "TAB_COUNT telemetry - _lastRecordTabCount updated"
+ );
+ ok(
+ BrowserUsageTelemetry._lastRecordTabCount >
+ Date.now() - MINIMUM_TAB_COUNT_INTERVAL_MS,
+ "TAB_COUNT telemetry - _lastRecordTabCount invariant"
+ );
+ }
+
+ // Do not trigger a recount if _lastRecordTabCount is recent on page load
+ BrowserUsageTelemetry._lastRecordTabCount =
+ Date.now() - MINIMUM_TAB_COUNT_INTERVAL_MS / 2;
+ {
+ let oldLastRecordTabCount = BrowserUsageTelemetry._lastRecordTabCount;
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ "http://example.com/"
+ );
+ checkTabCountHistogram(
+ tabCountHist.snapshot(),
+ { 1: 0, 2: 1, 3: 2, 4: 1, 5: 1, 7: 1, 8: 0 },
+ "TAB_COUNT telemetry - page load, recount event ignored"
+ );
+ ok(
+ BrowserUsageTelemetry._lastRecordTabCount == oldLastRecordTabCount,
+ "TAB_COUNT telemetry - _lastRecordTabCount unchanged"
+ );
+ }
+
+ // Trigger a recount if _lastRecordTabCount has passed on page load
+ BrowserUsageTelemetry._lastRecordTabCount =
+ Date.now() - (MINIMUM_TAB_COUNT_INTERVAL_MS + 1000);
+ {
+ let oldLastRecordTabCount = BrowserUsageTelemetry._lastRecordTabCount;
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ "http://example.com/"
+ );
+ checkTabCountHistogram(
+ tabCountHist.snapshot(),
+ { 1: 0, 2: 1, 3: 2, 4: 1, 5: 1, 7: 2, 8: 0 },
+ "TAB_COUNT telemetry - page load, recount event included"
+ );
+ ok(
+ BrowserUsageTelemetry._lastRecordTabCount != oldLastRecordTabCount,
+ "TAB_COUNT telemetry - _lastRecordTabCount updated"
+ );
+ ok(
+ BrowserUsageTelemetry._lastRecordTabCount >
+ Date.now() - MINIMUM_TAB_COUNT_INTERVAL_MS,
+ "TAB_COUNT telemetry - _lastRecordTabCount invariant"
+ );
+ }
+
+ // Remove all the extra windows and tabs.
+ for (let openTab of openedTabs) {
+ BrowserTestUtils.removeTab(openTab);
+ }
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function test_loadedTabsHistogram() {
+ Services.prefs.setBoolPref(RESTORE_ON_DEMAND_PREF, true);
+ registerCleanupFunction(() =>
+ Services.prefs.clearUserPref(RESTORE_ON_DEMAND_PREF)
+ );
+
+ function resetTimestamps() {
+ BrowserUsageTelemetry._lastRecordTabCount = 0;
+ BrowserUsageTelemetry._lastRecordLoadedTabCount = 0;
+ }
+
+ resetTimestamps();
+ const tabCount = TelemetryTestUtils.getAndClearHistogram("TAB_COUNT");
+ const loadedTabCount =
+ TelemetryTestUtils.getAndClearHistogram("LOADED_TAB_COUNT");
+
+ checkTabCountHistogram(tabCount.snapshot(), {}, "TAB_COUNT - initial count");
+ checkTabCountHistogram(
+ loadedTabCount.snapshot(),
+ {},
+ "LOADED_TAB_COUNT - initial count"
+ );
+
+ resetTimestamps();
+ const tabs = [
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank"),
+ ];
+
+ // There are two tabs open: the mochi.test tab and the foreground tab.
+ const snapshot = loadedTabCount.snapshot();
+ checkTabCountHistogram(snapshot, { 1: 0, 2: 1, 3: 0 }, "TAB_COUNT - new tab");
+
+ // Open a pending tab, as if by session restore.
+ resetTimestamps();
+ const lazyTab = BrowserTestUtils.addTab(gBrowser, "about:mozilla", {
+ createLazyBrowser: true,
+ });
+ tabs.push(lazyTab);
+
+ await BrowserTestUtils.waitForCondition(
+ () => !ObjectUtils.deepEqual(snapshot, tabCount.snapshot())
+ );
+
+ checkTabCountHistogram(
+ tabCount.snapshot(),
+ { 1: 0, 2: 1, 3: 1, 4: 0 },
+ "TAB_COUNT - Added pending tab"
+ );
+
+ // Only the mochi.test and foreground tab are loaded.
+ checkTabCountHistogram(
+ loadedTabCount.snapshot(),
+ { 1: 0, 2: 2, 3: 0 },
+ "LOADED_TAB_COUNT - Added pending tab"
+ );
+
+ resetTimestamps();
+ const restoredEvent = BrowserTestUtils.waitForEvent(lazyTab, "SSTabRestored");
+ await BrowserTestUtils.switchTab(gBrowser, lazyTab);
+ await restoredEvent;
+
+ checkTabCountHistogram(
+ tabCount.snapshot(),
+ { 1: 0, 2: 1, 3: 1, 4: 0 },
+ "TAB_COUNT - Restored pending tab"
+ );
+
+ checkTabCountHistogram(
+ loadedTabCount.snapshot(),
+ { 1: 0, 2: 2, 3: 1, 4: 0 },
+ "LOADED_TAB_COUNT - Restored pending tab"
+ );
+
+ resetTimestamps();
+
+ await Promise.all([
+ BrowserTestUtils.loadURIString(
+ lazyTab.linkedBrowser,
+ "http://example.com/"
+ ),
+ BrowserTestUtils.browserLoaded(
+ lazyTab.linkedBrowser,
+ false,
+ "http://example.com/"
+ ),
+ ]);
+
+ checkTabCountHistogram(
+ tabCount.snapshot(),
+ { 1: 0, 2: 1, 3: 2, 4: 0 },
+ "TAB_COUNT - Navigated in existing tab"
+ );
+
+ checkTabCountHistogram(
+ loadedTabCount.snapshot(),
+ { 1: 0, 2: 2, 3: 2, 4: 0 },
+ "LOADED_TAB_COUNT - Navigated in existing tab"
+ );
+
+ resetTimestamps();
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await BrowserTestUtils.firstBrowserLoaded(win);
+
+ // The new window will have a new tab.
+ checkTabCountHistogram(
+ tabCount.snapshot(),
+ { 1: 0, 2: 1, 3: 2, 4: 1, 5: 0 },
+ "TAB_COUNT - Opened new window"
+ );
+
+ checkTabCountHistogram(
+ loadedTabCount.snapshot(),
+ { 1: 0, 2: 2, 3: 2, 4: 1, 5: 0 },
+ "LOADED_TAB_COUNT - Opened new window"
+ );
+
+ resetTimestamps();
+ await BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:robots");
+ checkTabCountHistogram(
+ tabCount.snapshot(),
+ { 1: 0, 2: 1, 3: 2, 4: 1, 5: 1, 6: 0 },
+ "TAB_COUNT - Opened new tab in new window"
+ );
+
+ checkTabCountHistogram(
+ loadedTabCount.snapshot(),
+ { 1: 0, 2: 2, 3: 2, 4: 1, 5: 1, 6: 0 },
+ "LOADED_TAB_COUNT - Opened new tab in new window"
+ );
+
+ for (const tab of tabs) {
+ BrowserTestUtils.removeTab(tab);
+ }
+
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function test_restored_max_pinned_count() {
+ // Following pinned tab testing example from
+ // https://searchfox.org/mozilla-central/rev/1843375acbbca68127713e402be222350ac99301/browser/components/sessionstore/test/browser_pinned_tabs.js
+ Services.telemetry.clearScalars();
+ const { E10SUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/E10SUtils.sys.mjs"
+ );
+ const BACKUP_STATE = SessionStore.getBrowserState();
+ const triggeringPrincipal_base64 = E10SUtils.SERIALIZED_SYSTEMPRINCIPAL;
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.sessionstore.restore_on_demand", true],
+ ["browser.sessionstore.restore_tabs_lazily", true],
+ ],
+ });
+ let sessionRestoredPromise = new Promise(resolve => {
+ Services.obs.addObserver(resolve, "sessionstore-browser-state-restored");
+ });
+
+ info("Set browser state to 1 pinned tab.");
+ await SessionStore.setBrowserState(
+ JSON.stringify({
+ windows: [
+ {
+ selected: 1,
+ tabs: [
+ {
+ pinned: true,
+ entries: [
+ { url: "https://example.com", triggeringPrincipal_base64 },
+ ],
+ },
+ ],
+ },
+ ],
+ })
+ );
+
+ info("Await `sessionstore-browser-state-restored` promise.");
+ await sessionRestoredPromise;
+
+ const scalars = TelemetryTestUtils.getProcessScalars("parent");
+
+ TelemetryTestUtils.assertScalar(
+ scalars,
+ MAX_TAB_PINNED,
+ 1,
+ "The maximum tabs pinned count must match the expected value."
+ );
+
+ gBrowser.unpinTab(gBrowser.selectedTab);
+
+ TelemetryTestUtils.assertScalar(
+ scalars,
+ MAX_TAB_PINNED,
+ 1,
+ "The maximum tabs pinned count must match the expected value."
+ );
+
+ sessionRestoredPromise = new Promise(resolve => {
+ Services.obs.addObserver(resolve, "sessionstore-browser-state-restored");
+ });
+ await SessionStore.setBrowserState(BACKUP_STATE);
+ await SpecialPowers.popPrefEnv();
+ await sessionRestoredPromise;
+});
diff --git a/browser/modules/test/browser/browser_UsageTelemetry_content_aboutRestartRequired.js b/browser/modules/test/browser/browser_UsageTelemetry_content_aboutRestartRequired.js
new file mode 100644
index 0000000000..359bfa9c69
--- /dev/null
+++ b/browser/modules/test/browser/browser_UsageTelemetry_content_aboutRestartRequired.js
@@ -0,0 +1,33 @@
+"use strict";
+
+const SCALAR_BUILDID_MISMATCH = "dom.contentprocess.buildID_mismatch";
+
+add_task(async function test_aboutRestartRequired() {
+ const { TabCrashHandler } = ChromeUtils.import(
+ "resource:///modules/ContentCrashHandlers.jsm"
+ );
+
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent");
+
+ // Check preconditions
+ is(
+ scalars[SCALAR_BUILDID_MISMATCH],
+ undefined,
+ "Build ID mismatch count should be undefined"
+ );
+
+ // Simulate buildID mismatch
+ TabCrashHandler._crashedTabCount = 1;
+ TabCrashHandler.sendToRestartRequiredPage(gBrowser.selectedTab.linkedBrowser);
+
+ scalars = TelemetryTestUtils.getProcessScalars("parent");
+
+ is(
+ scalars[SCALAR_BUILDID_MISMATCH],
+ 1,
+ "Build ID mismatch count should be 1."
+ );
+});
diff --git a/browser/modules/test/browser/browser_UsageTelemetry_domains.js b/browser/modules/test/browser/browser_UsageTelemetry_domains.js
new file mode 100644
index 0000000000..d736809dc5
--- /dev/null
+++ b/browser/modules/test/browser/browser_UsageTelemetry_domains.js
@@ -0,0 +1,190 @@
+"use strict";
+
+const TOTAL_URI_COUNT = "browser.engagement.total_uri_count";
+const UNIQUE_DOMAINS_COUNT = "browser.engagement.unique_domains_count";
+const UNFILTERED_URI_COUNT = "browser.engagement.unfiltered_uri_count";
+const TELEMETRY_SUBSESSION_TOPIC = "internal-telemetry-after-subsession-split";
+
+// Reset internal URI counter in case URIs were opened by other tests.
+Services.obs.notifyObservers(null, TELEMETRY_SUBSESSION_TOPIC);
+
+/**
+ * Waits for the web progress listener associated with this tab to fire an
+ * onLocationChange for a non-error page.
+ *
+ * @param {xul:browser} browser
+ * A xul:browser.
+ *
+ * @return {Promise}
+ * @resolves When navigating to a non-error page.
+ */
+function browserLocationChanged(browser) {
+ return new Promise(resolve => {
+ let wpl = {
+ onStateChange() {},
+ onSecurityChange() {},
+ onStatusChange() {},
+ onContentBlockingEvent() {},
+ onLocationChange(aWebProgress, aRequest, aURI, aFlags) {
+ if (!(aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE)) {
+ browser.webProgress.removeProgressListener(filter);
+ filter.removeProgressListener(wpl);
+ resolve();
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsIWebProgressListener2",
+ ]),
+ };
+ const filter = Cc[
+ "@mozilla.org/appshell/component/browser-status-filter;1"
+ ].createInstance(Ci.nsIWebProgress);
+ filter.addProgressListener(wpl, Ci.nsIWebProgress.NOTIFY_ALL);
+ browser.webProgress.addProgressListener(
+ filter,
+ Ci.nsIWebProgress.NOTIFY_ALL
+ );
+ });
+}
+
+add_task(async function test_URIAndDomainCounts() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+
+ let checkCounts = countsObject => {
+ // Get a snapshot of the scalars and then clear them.
+ const scalars = TelemetryTestUtils.getProcessScalars("parent");
+ TelemetryTestUtils.assertScalar(
+ scalars,
+ TOTAL_URI_COUNT,
+ countsObject.totalURIs,
+ "The URI scalar must contain the expected value."
+ );
+ TelemetryTestUtils.assertScalar(
+ scalars,
+ UNIQUE_DOMAINS_COUNT,
+ countsObject.domainCount,
+ "The unique domains scalar must contain the expected value."
+ );
+ TelemetryTestUtils.assertScalar(
+ scalars,
+ UNFILTERED_URI_COUNT,
+ countsObject.totalUnfilteredURIs,
+ "The unfiltered URI scalar must contain the expected value."
+ );
+ };
+
+ // Check that about:blank doesn't get counted in the URI total.
+ let firstTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+ TelemetryTestUtils.assertScalarUnset(
+ TelemetryTestUtils.getProcessScalars("parent"),
+ TOTAL_URI_COUNT
+ );
+ TelemetryTestUtils.assertScalarUnset(
+ TelemetryTestUtils.getProcessScalars("parent"),
+ UNIQUE_DOMAINS_COUNT
+ );
+ TelemetryTestUtils.assertScalarUnset(
+ TelemetryTestUtils.getProcessScalars("parent"),
+ UNFILTERED_URI_COUNT
+ );
+
+ // Open a different page and check the counts.
+ BrowserTestUtils.loadURIString(firstTab.linkedBrowser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(firstTab.linkedBrowser);
+ checkCounts({ totalURIs: 1, domainCount: 1, totalUnfilteredURIs: 1 });
+
+ // Activating a different tab must not increase the URI count.
+ let secondTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+ await BrowserTestUtils.switchTab(gBrowser, firstTab);
+ checkCounts({ totalURIs: 1, domainCount: 1, totalUnfilteredURIs: 1 });
+ BrowserTestUtils.removeTab(secondTab);
+
+ // Open a new window and set the tab to a new address.
+ let newWin = await BrowserTestUtils.openNewBrowserWindow();
+ BrowserTestUtils.loadURIString(
+ newWin.gBrowser.selectedBrowser,
+ "http://example.com/"
+ );
+ await BrowserTestUtils.browserLoaded(newWin.gBrowser.selectedBrowser);
+ checkCounts({ totalURIs: 2, domainCount: 1, totalUnfilteredURIs: 2 });
+
+ // We should not count AJAX requests.
+ const XHR_URL = "http://example.com/r";
+ await SpecialPowers.spawn(
+ newWin.gBrowser.selectedBrowser,
+ [XHR_URL],
+ function (url) {
+ return new Promise(resolve => {
+ var xhr = new content.window.XMLHttpRequest();
+ xhr.open("GET", url);
+ xhr.onload = () => resolve();
+ xhr.send();
+ });
+ }
+ );
+ checkCounts({ totalURIs: 2, domainCount: 1, totalUnfilteredURIs: 2 });
+
+ // Check that we're counting page fragments.
+ let loadingStopped = browserLocationChanged(newWin.gBrowser.selectedBrowser);
+ BrowserTestUtils.loadURIString(
+ newWin.gBrowser.selectedBrowser,
+ "http://example.com/#2"
+ );
+ await loadingStopped;
+ checkCounts({ totalURIs: 3, domainCount: 1, totalUnfilteredURIs: 3 });
+
+ // Check that a different URI from the example.com domain doesn't increment the unique count.
+ BrowserTestUtils.loadURIString(
+ newWin.gBrowser.selectedBrowser,
+ "http://test1.example.com/"
+ );
+ await BrowserTestUtils.browserLoaded(newWin.gBrowser.selectedBrowser);
+ checkCounts({ totalURIs: 4, domainCount: 1, totalUnfilteredURIs: 4 });
+
+ // Make sure that the unique domains counter is incrementing for a different domain.
+ BrowserTestUtils.loadURIString(
+ newWin.gBrowser.selectedBrowser,
+ "https://example.org/"
+ );
+ await BrowserTestUtils.browserLoaded(newWin.gBrowser.selectedBrowser);
+ checkCounts({ totalURIs: 5, domainCount: 2, totalUnfilteredURIs: 5 });
+
+ // Check that we only account for top level loads (e.g. we don't count URIs from
+ // embedded iframes).
+ await SpecialPowers.spawn(
+ newWin.gBrowser.selectedBrowser,
+ [],
+ async function () {
+ let doc = content.document;
+ let iframe = doc.createElement("iframe");
+ let promiseIframeLoaded = ContentTaskUtils.waitForEvent(
+ iframe,
+ "load",
+ false
+ );
+ iframe.src = "https://example.org/test";
+ doc.body.insertBefore(iframe, doc.body.firstElementChild);
+ await promiseIframeLoaded;
+ }
+ );
+ checkCounts({ totalURIs: 5, domainCount: 2, totalUnfilteredURIs: 5 });
+
+ // Check that uncommon protocols get counted in the unfiltered URI probe.
+ const TEST_PAGE =
+ "data:text/html,<a id='target' href='%23par1'>Click me</a><a name='par1'>The paragraph.</a>";
+ BrowserTestUtils.loadURIString(newWin.gBrowser.selectedBrowser, TEST_PAGE);
+ await BrowserTestUtils.browserLoaded(newWin.gBrowser.selectedBrowser);
+ checkCounts({ totalURIs: 5, domainCount: 2, totalUnfilteredURIs: 6 });
+
+ // Clean up.
+ BrowserTestUtils.removeTab(firstTab);
+ await BrowserTestUtils.closeWindow(newWin);
+});
diff --git a/browser/modules/test/browser/browser_UsageTelemetry_interaction.js b/browser/modules/test/browser/browser_UsageTelemetry_interaction.js
new file mode 100644
index 0000000000..50a3e08391
--- /dev/null
+++ b/browser/modules/test/browser/browser_UsageTelemetry_interaction.js
@@ -0,0 +1,967 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+gReduceMotionOverride = true;
+
+const AREAS = [
+ "keyboard",
+ "menu_bar",
+ "tabs_bar",
+ "nav_bar",
+ "bookmarks_bar",
+ "app_menu",
+ "tabs_context",
+ "content_context",
+ "overflow_menu",
+ "pinned_overflow_menu",
+ "pageaction_urlbar",
+ "pageaction_panel",
+
+ "preferences_paneHome",
+ "preferences_paneGeneral",
+ "preferences_panePrivacy",
+ "preferences_paneSearch",
+ "preferences_paneSearchResults",
+ "preferences_paneSync",
+ "preferences_paneContainers",
+];
+
+// Checks that the correct number of clicks are registered against the correct
+// keys in the scalars. Also runs keyed scalar checks against non-area types
+// passed in through expectedOther.
+function assertInteractionScalars(expectedAreas, expectedOther = {}) {
+ let processScalars =
+ Services.telemetry.getSnapshotForKeyedScalars("main", true)?.parent ?? {};
+
+ let compareSourceWithExpectations = (source, expected = {}) => {
+ let scalars = processScalars?.[`browser.ui.interaction.${source}`] ?? {};
+
+ let expectedKeys = new Set(
+ Object.keys(scalars).concat(Object.keys(expected))
+ );
+
+ for (let key of expectedKeys) {
+ Assert.equal(
+ scalars[key],
+ expected[key],
+ `Expected to see the correct value for ${key} in ${source}.`
+ );
+ }
+ };
+
+ for (let source of AREAS) {
+ compareSourceWithExpectations(source, expectedAreas[source]);
+ }
+
+ for (let source in expectedOther) {
+ compareSourceWithExpectations(source, expectedOther[source]);
+ }
+}
+
+const elem = id => document.getElementById(id);
+const click = el => {
+ if (typeof el == "string") {
+ el = elem(el);
+ }
+
+ EventUtils.synthesizeMouseAtCenter(el, {}, window);
+};
+
+add_task(async function toolbarButtons() {
+ await BrowserTestUtils.withNewTab("https://example.com", async () => {
+ let customButton = await new Promise(resolve => {
+ CustomizableUI.createWidget({
+ // In CSS identifiers cannot start with a number but CustomizableUI accepts that.
+ id: "12foo",
+ onCreated: resolve,
+ defaultArea: "nav-bar",
+ });
+ });
+
+ Services.telemetry.getSnapshotForKeyedScalars("main", true);
+
+ let newTab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+ let tabClose = BrowserTestUtils.waitForTabClosing(newTab);
+
+ let tabs = elem("tabbrowser-tabs");
+ if (!tabs.hasAttribute("overflow")) {
+ tabs.setAttribute("overflow", "true");
+ registerCleanupFunction(() => {
+ tabs.removeAttribute("overflow");
+ });
+ }
+
+ click("stop-reload-button");
+ click("back-button");
+ click("back-button");
+
+ // Make sure the all tabs panel is in the document.
+ gTabsPanel.initElements();
+ let view = elem("allTabsMenu-allTabsView");
+ let shown = BrowserTestUtils.waitForEvent(view, "ViewShown");
+ click("alltabs-button");
+ await shown;
+
+ let hidden = BrowserTestUtils.waitForEvent(view, "ViewHiding");
+ gTabsPanel.hideAllTabsPanel();
+ await hidden;
+
+ click(newTab.querySelector(".tab-close-button"));
+ await tabClose;
+
+ let bookmarksToolbar = gNavToolbox.querySelector("#PersonalToolbar");
+
+ let bookmarksToolbarReady = BrowserTestUtils.waitForMutationCondition(
+ bookmarksToolbar,
+ { attributes: true },
+ () => {
+ return (
+ bookmarksToolbar.getAttribute("collapsed") != "true" &&
+ bookmarksToolbar.getAttribute("initialized") == "true"
+ );
+ }
+ );
+
+ window.setToolbarVisibility(
+ bookmarksToolbar,
+ true /* isVisible */,
+ false /* persist */,
+ false /* animated */
+ );
+ registerCleanupFunction(() => {
+ window.setToolbarVisibility(
+ bookmarksToolbar,
+ false /* isVisible */,
+ false /* persist */,
+ false /* animated */
+ );
+ });
+ await bookmarksToolbarReady;
+
+ // The Bookmarks Toolbar does some optimizations to try not to jank the
+ // browser when populating itself, and does so asynchronously. We wait
+ // until a bookmark item is available in the DOM before continuing.
+ let placesToolbarItems = document.getElementById("PlacesToolbarItems");
+ await BrowserTestUtils.waitForMutationCondition(
+ placesToolbarItems,
+ { childList: true },
+ () => placesToolbarItems.querySelector(".bookmark-item") != null
+ );
+
+ click(placesToolbarItems.querySelector(".bookmark-item"));
+
+ click(customButton);
+
+ assertInteractionScalars(
+ {
+ nav_bar: {
+ "stop-reload-button": 1,
+ "back-button": 2,
+ "12foo": 1,
+ },
+ tabs_bar: {
+ "alltabs-button": 1,
+ "tab-close-button": 1,
+ },
+ bookmarks_bar: {
+ "bookmark-item": 1,
+ },
+ },
+ {
+ all_tabs_panel_entrypoint: {
+ "alltabs-button": 1,
+ },
+ }
+ );
+ CustomizableUI.destroyWidget("12foo");
+ });
+});
+
+add_task(async function contextMenu() {
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ Services.telemetry.getSnapshotForKeyedScalars("main", true);
+
+ let tab = gBrowser.getTabForBrowser(browser);
+ let context = elem("tabContextMenu");
+ let shown = BrowserTestUtils.waitForEvent(context, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ tab,
+ { type: "contextmenu", button: 2 },
+ window
+ );
+ await shown;
+
+ let hidden = BrowserTestUtils.waitForEvent(context, "popuphidden");
+ context.activateItem(document.getElementById("context_toggleMuteTab"));
+ await hidden;
+
+ assertInteractionScalars({
+ tabs_context: {
+ "context-toggleMuteTab": 1,
+ },
+ });
+
+ // Check that tab-related items in the toolbar menu also register telemetry:
+ context = elem("toolbar-context-menu");
+ shown = BrowserTestUtils.waitForEvent(context, "popupshown");
+ let scrollbox = elem("tabbrowser-arrowscrollbox");
+ EventUtils.synthesizeMouse(
+ scrollbox,
+ // offset within the scrollbox - somewhere near the end:
+ scrollbox.getBoundingClientRect().width - 20,
+ 5,
+ { type: "contextmenu", button: 2 },
+ window
+ );
+ await shown;
+
+ hidden = BrowserTestUtils.waitForEvent(context, "popuphidden");
+ context.activateItem(
+ document.getElementById("toolbar-context-selectAllTabs")
+ );
+ await hidden;
+
+ assertInteractionScalars({
+ tabs_context: {
+ "toolbar-context-selectAllTabs": 1,
+ },
+ });
+ // tidy up:
+ gBrowser.clearMultiSelectedTabs();
+ });
+});
+
+add_task(async function contextMenu_entrypoints() {
+ /**
+ * A utility function for this test task that opens the tab context
+ * menu for a particular trigger node, chooses the "Reload Tab" item,
+ * and then waits for the context menu to close.
+ *
+ * @param {Element} triggerNode
+ * The node that the tab context menu should be triggered with.
+ * @returns {Promise<undefined>}
+ * Resolves after the context menu has fired the popuphidden event.
+ */
+ let openAndCloseTabContextMenu = async triggerNode => {
+ let contextMenu = document.getElementById("tabContextMenu");
+ let popupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(triggerNode, {
+ type: "contextmenu",
+ button: 2,
+ });
+ await popupShown;
+
+ let popupHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden");
+ let menuitem = document.getElementById("context_reloadTab");
+ contextMenu.activateItem(menuitem);
+ await popupHidden;
+ };
+
+ const TAB_CONTEXTMENU_ENTRYPOINT_SCALAR =
+ "browser.ui.interaction.tabs_context_entrypoint";
+ Services.telemetry.clearScalars();
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", true, true);
+ TelemetryTestUtils.assertScalarUnset(
+ scalars,
+ TAB_CONTEXTMENU_ENTRYPOINT_SCALAR
+ );
+
+ await openAndCloseTabContextMenu(gBrowser.selectedTab);
+ scalars = TelemetryTestUtils.getProcessScalars("parent", true, true);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ TAB_CONTEXTMENU_ENTRYPOINT_SCALAR,
+ "tabs-bar",
+ 1
+ );
+
+ gTabsPanel.initElements();
+ let allTabsView = document.getElementById("allTabsMenu-allTabsView");
+ let allTabsPopupShownPromise = BrowserTestUtils.waitForEvent(
+ allTabsView,
+ "ViewShown"
+ );
+ gTabsPanel.showAllTabsPanel(null);
+ await allTabsPopupShownPromise;
+
+ let firstTabItem = gTabsPanel.allTabsViewTabs.children[0];
+ await openAndCloseTabContextMenu(firstTabItem);
+ scalars = TelemetryTestUtils.getProcessScalars("parent", true, true);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ TAB_CONTEXTMENU_ENTRYPOINT_SCALAR,
+ "alltabs-menu",
+ 1
+ );
+
+ let allTabsPopupHiddenPromise = BrowserTestUtils.waitForEvent(
+ allTabsView.panelMultiView,
+ "PanelMultiViewHidden"
+ );
+ gTabsPanel.hideAllTabsPanel();
+ await allTabsPopupHiddenPromise;
+});
+
+add_task(async function appMenu() {
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ Services.telemetry.getSnapshotForKeyedScalars("main", true);
+
+ let shown = BrowserTestUtils.waitForEvent(
+ elem("appMenu-popup"),
+ "popupshown"
+ );
+ click("PanelUI-menu-button");
+ await shown;
+
+ let hidden = BrowserTestUtils.waitForEvent(
+ elem("appMenu-popup"),
+ "popuphidden"
+ );
+
+ let findButtonID = "appMenu-find-button2";
+ click(findButtonID);
+ await hidden;
+
+ let expectedScalars = {
+ nav_bar: {
+ "PanelUI-menu-button": 1,
+ },
+ app_menu: {},
+ };
+ expectedScalars.app_menu[findButtonID] = 1;
+
+ assertInteractionScalars(expectedScalars);
+ });
+});
+
+add_task(async function devtools() {
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ Services.telemetry.getSnapshotForKeyedScalars("main", true);
+
+ let shown = BrowserTestUtils.waitForEvent(
+ elem("appMenu-popup"),
+ "popupshown"
+ );
+ click("PanelUI-menu-button");
+ await shown;
+
+ click("appMenu-more-button2");
+ shown = BrowserTestUtils.waitForEvent(
+ elem("appmenu-moreTools"),
+ "ViewShown"
+ );
+ await shown;
+
+ let tabOpen = BrowserTestUtils.waitForNewTab(gBrowser);
+ let hidden = BrowserTestUtils.waitForEvent(
+ elem("appMenu-popup"),
+ "popuphidden"
+ );
+ click(
+ document.querySelector(
+ "#appmenu-moreTools toolbarbutton[key='key_viewSource']"
+ )
+ );
+ await hidden;
+
+ let tab = await tabOpen;
+ BrowserTestUtils.removeTab(tab);
+
+ // Note that item ID's have '_' converted to '-'.
+ assertInteractionScalars({
+ nav_bar: {
+ "PanelUI-menu-button": 1,
+ },
+ app_menu: {
+ "appMenu-more-button2": 1,
+ "key-viewSource": 1,
+ },
+ });
+ });
+});
+
+add_task(async function webextension() {
+ BrowserUsageTelemetry._resetAddonIds();
+
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ Services.telemetry.getSnapshotForKeyedScalars("main", true);
+
+ function background() {
+ browser.commands.onCommand.addListener(() => {
+ browser.test.sendMessage("oncommand");
+ });
+
+ browser.runtime.onMessage.addListener(msg => {
+ if (msg == "from-sidebar-action") {
+ browser.test.sendMessage("sidebar-opened");
+ }
+ });
+
+ browser.test.sendMessage("ready");
+ }
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ version: "1",
+ browser_specific_settings: {
+ gecko: { id: "random_addon@example.com" },
+ },
+ browser_action: {
+ default_icon: "default.png",
+ default_title: "Hello",
+ default_area: "navbar",
+ },
+ page_action: {
+ default_icon: "default.png",
+ default_title: "Hello",
+ show_matches: ["https://example.com/*"],
+ },
+ commands: {
+ test_command: {
+ suggested_key: {
+ default: "Alt+Shift+J",
+ },
+ },
+ _execute_sidebar_action: {
+ suggested_key: {
+ default: "Alt+Shift+Q",
+ },
+ },
+ },
+ sidebar_action: {
+ default_panel: "sidebar.html",
+ open_at_install: false,
+ },
+ },
+ files: {
+ "sidebar.html": `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script src="sidebar.js"></script>
+ </head>
+ </html>
+ `,
+
+ "sidebar.js": function () {
+ browser.runtime.sendMessage("from-sidebar-action");
+ },
+ },
+ background,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ // As the first add-on interacted with this should show up as `addon0`.
+
+ click("random_addon_example_com-browser-action");
+ assertInteractionScalars({
+ nav_bar: {
+ addon0: 1,
+ },
+ });
+
+ // Wait for the element to show up.
+ await TestUtils.waitForCondition(() =>
+ elem("pageAction-urlbar-random_addon_example_com")
+ );
+
+ click("pageAction-urlbar-random_addon_example_com");
+ assertInteractionScalars({
+ pageaction_urlbar: {
+ addon0: 1,
+ },
+ });
+
+ EventUtils.synthesizeKey("j", { altKey: true, shiftKey: true });
+ await extension.awaitMessage("oncommand");
+ assertInteractionScalars({
+ keyboard: {
+ addon0: 1,
+ },
+ });
+
+ EventUtils.synthesizeKey("q", { altKey: true, shiftKey: true });
+ await extension.awaitMessage("sidebar-opened");
+ assertInteractionScalars({
+ keyboard: {
+ addon0: 1,
+ },
+ });
+
+ const extension2 = ExtensionTestUtils.loadExtension({
+ manifest: {
+ version: "1",
+ browser_specific_settings: {
+ gecko: { id: "random_addon2@example.com" },
+ },
+ browser_action: {
+ default_icon: "default.png",
+ default_title: "Hello",
+ default_area: "navbar",
+ },
+ page_action: {
+ default_icon: "default.png",
+ default_title: "Hello",
+ show_matches: ["https://example.com/*"],
+ },
+ commands: {
+ test_command: {
+ suggested_key: {
+ default: "Alt+Shift+9",
+ },
+ },
+ },
+ },
+ background,
+ });
+
+ await extension2.startup();
+ await extension2.awaitMessage("ready");
+
+ // A second extension should be `addon1`.
+
+ click("random_addon2_example_com-browser-action");
+ assertInteractionScalars({
+ nav_bar: {
+ addon1: 1,
+ },
+ });
+
+ // Wait for the element to show up.
+ await TestUtils.waitForCondition(() =>
+ elem("pageAction-urlbar-random_addon2_example_com")
+ );
+
+ click("pageAction-urlbar-random_addon2_example_com");
+ assertInteractionScalars({
+ pageaction_urlbar: {
+ addon1: 1,
+ },
+ });
+
+ EventUtils.synthesizeKey("9", { altKey: true, shiftKey: true });
+ await extension2.awaitMessage("oncommand");
+ assertInteractionScalars({
+ keyboard: {
+ addon1: 1,
+ },
+ });
+
+ // The first should have retained its ID.
+ click("random_addon_example_com-browser-action");
+ assertInteractionScalars({
+ nav_bar: {
+ addon0: 1,
+ },
+ });
+
+ EventUtils.synthesizeKey("j", { altKey: true, shiftKey: true });
+ await extension.awaitMessage("oncommand");
+ assertInteractionScalars({
+ keyboard: {
+ addon0: 1,
+ },
+ });
+
+ click("pageAction-urlbar-random_addon_example_com");
+ assertInteractionScalars({
+ pageaction_urlbar: {
+ addon0: 1,
+ },
+ });
+
+ await extension.unload();
+
+ // Clear the last opened ID so if this test runs again the sidebar won't
+ // automatically open when the extension is installed.
+ window.SidebarUI.lastOpenedId = null;
+
+ // The second should retain its ID.
+ click("random_addon2_example_com-browser-action");
+ click("random_addon2_example_com-browser-action");
+ assertInteractionScalars({
+ nav_bar: {
+ addon1: 2,
+ },
+ });
+
+ click("pageAction-urlbar-random_addon2_example_com");
+ assertInteractionScalars({
+ pageaction_urlbar: {
+ addon1: 1,
+ },
+ });
+
+ EventUtils.synthesizeKey("9", { altKey: true, shiftKey: true });
+ await extension2.awaitMessage("oncommand");
+ assertInteractionScalars({
+ keyboard: {
+ addon1: 1,
+ },
+ });
+
+ await extension2.unload();
+
+ // Now test that browser action items in the add-ons panel also get
+ // telemetry recorded for them.
+ const extension3 = ExtensionTestUtils.loadExtension({
+ manifest: {
+ version: "1",
+ browser_specific_settings: {
+ gecko: { id: "random_addon3@example.com" },
+ },
+ browser_action: {
+ default_icon: "default.png",
+ default_title: "Hello",
+ },
+ },
+ });
+
+ await extension3.startup();
+
+ const shown = BrowserTestUtils.waitForPopupEvent(
+ gUnifiedExtensions.panel,
+ "shown"
+ );
+ await gUnifiedExtensions.togglePanel();
+ await shown;
+
+ click("random_addon3_example_com-browser-action");
+ assertInteractionScalars({
+ unified_extensions_area: {
+ addon2: 1,
+ },
+ });
+ const hidden = BrowserTestUtils.waitForPopupEvent(
+ gUnifiedExtensions.panel,
+ "hidden"
+ );
+ await gUnifiedExtensions.panel.hidePopup();
+ await hidden;
+
+ await extension3.unload();
+ });
+});
+
+add_task(async function mainMenu() {
+ // macOS does not use the menu bar.
+ if (AppConstants.platform == "macosx") {
+ return;
+ }
+
+ BrowserUsageTelemetry._resetAddonIds();
+
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ Services.telemetry.getSnapshotForKeyedScalars("main", true);
+
+ CustomizableUI.setToolbarVisibility("toolbar-menubar", true);
+
+ let shown = BrowserTestUtils.waitForEvent(
+ elem("menu_EditPopup"),
+ "popupshown"
+ );
+ click("edit-menu");
+ await shown;
+
+ let hidden = BrowserTestUtils.waitForEvent(
+ elem("menu_EditPopup"),
+ "popuphidden"
+ );
+ click("menu_selectAll");
+ await hidden;
+
+ assertInteractionScalars({
+ menu_bar: {
+ // Note that the _ is replaced with - for telemetry identifiers.
+ "menu-selectAll": 1,
+ },
+ });
+
+ CustomizableUI.setToolbarVisibility("toolbar-menubar", false);
+ });
+});
+
+add_task(async function preferences() {
+ let initialized = BrowserTestUtils.waitForEvent(gBrowser, "Initialized");
+ await BrowserTestUtils.withNewTab("about:preferences", async browser => {
+ await initialized;
+
+ Services.telemetry.getSnapshotForKeyedScalars("main", true);
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#browserRestoreSession",
+ {},
+ gBrowser.selectedBrowser.browsingContext
+ );
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#category-search",
+ {},
+ gBrowser.selectedBrowser.browsingContext
+ );
+ await BrowserTestUtils.waitForCondition(() =>
+ gBrowser.selectedBrowser.contentDocument.getElementById(
+ "searchBarShownRadio"
+ )
+ );
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#searchBarShownRadio",
+ {},
+ gBrowser.selectedBrowser.browsingContext
+ );
+
+ gBrowser.selectedBrowser.contentDocument
+ .getElementById("openLocationBarPrivacyPreferences")
+ .scrollIntoView();
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#openLocationBarPrivacyPreferences",
+ {},
+ gBrowser.selectedBrowser.browsingContext
+ );
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#category-privacy",
+ {},
+ gBrowser.selectedBrowser.browsingContext
+ );
+ await BrowserTestUtils.waitForCondition(() =>
+ gBrowser.selectedBrowser.contentDocument.getElementById(
+ "contentBlockingLearnMore"
+ )
+ );
+
+ const onLearnMoreOpened = BrowserTestUtils.waitForNewTab(gBrowser);
+ gBrowser.selectedBrowser.contentDocument
+ .getElementById("contentBlockingLearnMore")
+ .scrollIntoView();
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#contentBlockingLearnMore",
+ {},
+ gBrowser.selectedBrowser.browsingContext
+ );
+ await onLearnMoreOpened;
+ gBrowser.removeCurrentTab();
+
+ assertInteractionScalars({
+ preferences_paneGeneral: {
+ browserRestoreSession: 1,
+ },
+ preferences_panePrivacy: {
+ contentBlockingLearnMore: 1,
+ },
+ preferences_paneSearch: {
+ searchBarShownRadio: 1,
+ openLocationBarPrivacyPreferences: 1,
+ },
+ });
+ });
+});
+
+/**
+ * Context click on a history or bookmark link and open it in a new window.
+ *
+ * @param {Element} link - The link to open.
+ */
+async function openLinkUsingContextMenu(link) {
+ const placesContext = document.getElementById("placesContext");
+ const promisePopup = BrowserTestUtils.waitForEvent(
+ placesContext,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(link, {
+ button: 2,
+ type: "contextmenu",
+ });
+ await promisePopup;
+ const promiseNewWindow = BrowserTestUtils.waitForNewWindow();
+ placesContext.activateItem(
+ document.getElementById("placesContext_open:newwindow")
+ );
+ const win = await promiseNewWindow;
+ await BrowserTestUtils.closeWindow(win);
+}
+
+async function history_appMenu(useContextClick) {
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ let shown = BrowserTestUtils.waitForEvent(
+ elem("appMenu-popup"),
+ "popupshown"
+ );
+ click("PanelUI-menu-button");
+ await shown;
+
+ click("appMenu-history-button");
+ shown = BrowserTestUtils.waitForEvent(elem("PanelUI-history"), "ViewShown");
+ await shown;
+
+ let list = document.getElementById("appMenu_historyMenu");
+ let listItem = list.querySelector("toolbarbutton");
+
+ if (useContextClick) {
+ await openLinkUsingContextMenu(listItem);
+ } else {
+ EventUtils.synthesizeMouseAtCenter(listItem, {});
+ }
+
+ let expectedScalars = {
+ nav_bar: {
+ "PanelUI-menu-button": 1,
+ },
+
+ app_menu: { "history-item": 1, "appMenu-history-button": 1 },
+ };
+ assertInteractionScalars(expectedScalars);
+ });
+}
+
+add_task(async function history_appMenu_click() {
+ await history_appMenu(false);
+});
+
+add_task(async function history_appMenu_context_click() {
+ await history_appMenu(true);
+});
+
+async function bookmarks_appMenu(useContextClick) {
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ let shown = BrowserTestUtils.waitForEvent(
+ elem("appMenu-popup"),
+ "popupshown"
+ );
+
+ shown = BrowserTestUtils.waitForEvent(elem("appMenu-popup"), "popupshown");
+ click("PanelUI-menu-button");
+ await shown;
+
+ click("appMenu-bookmarks-button");
+ shown = BrowserTestUtils.waitForEvent(
+ elem("PanelUI-bookmarks"),
+ "ViewShown"
+ );
+ await shown;
+
+ let list = document.getElementById("panelMenu_bookmarksMenu");
+ let listItem = list.querySelector("toolbarbutton");
+
+ if (useContextClick) {
+ await openLinkUsingContextMenu(listItem);
+ } else {
+ EventUtils.synthesizeMouseAtCenter(listItem, {});
+ }
+
+ let expectedScalars = {
+ nav_bar: {
+ "PanelUI-menu-button": 1,
+ },
+
+ app_menu: { "bookmark-item": 1, "appMenu-bookmarks-button": 1 },
+ };
+ assertInteractionScalars(expectedScalars);
+ });
+}
+
+add_task(async function bookmarks_appMenu_click() {
+ await bookmarks_appMenu(false);
+});
+
+add_task(async function bookmarks_appMenu_context_click() {
+ await bookmarks_appMenu(true);
+});
+
+async function bookmarks_library_navbar(useContextClick) {
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ CustomizableUI.addWidgetToArea("library-button", "nav-bar");
+ let button = document.getElementById("library-button");
+ button.click();
+ await BrowserTestUtils.waitForEvent(
+ elem("appMenu-libraryView"),
+ "ViewShown"
+ );
+
+ click("appMenu-library-bookmarks-button");
+ await BrowserTestUtils.waitForEvent(elem("PanelUI-bookmarks"), "ViewShown");
+
+ let list = document.getElementById("panelMenu_bookmarksMenu");
+ let listItem = list.querySelector("toolbarbutton");
+
+ if (useContextClick) {
+ await openLinkUsingContextMenu(listItem);
+ } else {
+ EventUtils.synthesizeMouseAtCenter(listItem, {});
+ }
+
+ let expectedScalars = {
+ nav_bar: {
+ "library-button": 1,
+ "bookmark-item": 1,
+ "appMenu-library-bookmarks-button": 1,
+ },
+ };
+ assertInteractionScalars(expectedScalars);
+ });
+
+ CustomizableUI.removeWidgetFromArea("library-button");
+}
+
+add_task(async function bookmarks_library_navbar_click() {
+ await bookmarks_library_navbar(false);
+});
+
+add_task(async function bookmarks_library_navbar_context_click() {
+ await bookmarks_library_navbar(true);
+});
+
+async function history_library_navbar(useContextClick) {
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ CustomizableUI.addWidgetToArea("library-button", "nav-bar");
+ let button = document.getElementById("library-button");
+ button.click();
+ await BrowserTestUtils.waitForEvent(
+ elem("appMenu-libraryView"),
+ "ViewShown"
+ );
+
+ click("appMenu-library-history-button");
+ let shown = BrowserTestUtils.waitForEvent(
+ elem("PanelUI-history"),
+ "ViewShown"
+ );
+ await shown;
+
+ let list = document.getElementById("appMenu_historyMenu");
+ let listItem = list.querySelector("toolbarbutton");
+
+ if (useContextClick) {
+ await openLinkUsingContextMenu(listItem);
+ } else {
+ EventUtils.synthesizeMouseAtCenter(listItem, {});
+ }
+
+ let expectedScalars = {
+ nav_bar: {
+ "library-button": 1,
+ "history-item": 1,
+ "appMenu-library-history-button": 1,
+ },
+ };
+ assertInteractionScalars(expectedScalars);
+ });
+
+ CustomizableUI.removeWidgetFromArea("library-button");
+}
+
+add_task(async function history_library_navbar_click() {
+ await history_library_navbar(false);
+});
+
+add_task(async function history_library_navbar_context_click() {
+ await history_library_navbar(true);
+});
diff --git a/browser/modules/test/browser/browser_UsageTelemetry_private_and_restore.js b/browser/modules/test/browser/browser_UsageTelemetry_private_and_restore.js
new file mode 100644
index 0000000000..ab0c8651b6
--- /dev/null
+++ b/browser/modules/test/browser/browser_UsageTelemetry_private_and_restore.js
@@ -0,0 +1,164 @@
+"use strict";
+
+const triggeringPrincipal_base64 = E10SUtils.SERIALIZED_SYSTEMPRINCIPAL;
+
+const MAX_CONCURRENT_TABS = "browser.engagement.max_concurrent_tab_count";
+const TAB_EVENT_COUNT = "browser.engagement.tab_open_event_count";
+const MAX_CONCURRENT_WINDOWS = "browser.engagement.max_concurrent_window_count";
+const WINDOW_OPEN_COUNT = "browser.engagement.window_open_event_count";
+const TOTAL_URI_COUNT = "browser.engagement.total_uri_count";
+const UNFILTERED_URI_COUNT = "browser.engagement.unfiltered_uri_count";
+const UNIQUE_DOMAINS_COUNT = "browser.engagement.unique_domains_count";
+const TOTAL_URI_COUNT_NORMAL_AND_PRIVATE_MODE =
+ "browser.engagement.total_uri_count_normal_and_private_mode";
+
+BrowserUsageTelemetry._onTabsOpenedTask._timeoutMs = 0;
+registerCleanupFunction(() => {
+ BrowserUsageTelemetry._onTabsOpenedTask._timeoutMs = undefined;
+});
+
+function promiseBrowserStateRestored() {
+ return new Promise(resolve => {
+ Services.obs.addObserver(function observer(aSubject, aTopic) {
+ Services.obs.removeObserver(
+ observer,
+ "sessionstore-browser-state-restored"
+ );
+ resolve();
+ }, "sessionstore-browser-state-restored");
+ });
+}
+
+add_task(async function test_privateMode() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+ Services.fog.testResetFOG();
+
+ // Open a private window and load a website in it.
+ let privateWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ await BrowserTestUtils.firstBrowserLoaded(privateWin);
+ BrowserTestUtils.loadURIString(
+ privateWin.gBrowser.selectedBrowser,
+ "https://example.com/"
+ );
+ await BrowserTestUtils.browserLoaded(
+ privateWin.gBrowser.selectedBrowser,
+ false,
+ "https://example.com/"
+ );
+
+ // Check that tab and window count is recorded.
+ const scalars = TelemetryTestUtils.getProcessScalars("parent");
+
+ ok(
+ !(TOTAL_URI_COUNT in scalars),
+ "We should not track URIs in private mode."
+ );
+ ok(
+ !(UNFILTERED_URI_COUNT in scalars),
+ "We should not track URIs in private mode."
+ );
+ ok(
+ !(UNIQUE_DOMAINS_COUNT in scalars),
+ "We should not track unique domains in private mode."
+ );
+ is(
+ scalars[TAB_EVENT_COUNT],
+ 1,
+ "The number of open tab event count must match the expected value."
+ );
+ is(
+ scalars[MAX_CONCURRENT_TABS],
+ 2,
+ "The maximum tab count must match the expected value."
+ );
+ is(
+ scalars[WINDOW_OPEN_COUNT],
+ 1,
+ "The number of window open event count must match the expected value."
+ );
+ is(
+ scalars[MAX_CONCURRENT_WINDOWS],
+ 2,
+ "The maximum window count must match the expected value."
+ );
+ is(
+ scalars[TOTAL_URI_COUNT_NORMAL_AND_PRIVATE_MODE],
+ 1,
+ "We should include URIs in private mode as part of the actual total URI count."
+ );
+ is(
+ Glean.browserEngagement.uriCount.testGetValue(),
+ 1,
+ "We should record the URI count in Glean as well."
+ );
+
+ // Clean up.
+ await BrowserTestUtils.closeWindow(privateWin);
+});
+
+add_task(async function test_sessionRestore() {
+ const PREF_RESTORE_ON_DEMAND = "browser.sessionstore.restore_on_demand";
+ Services.prefs.setBoolPref(PREF_RESTORE_ON_DEMAND, false);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(PREF_RESTORE_ON_DEMAND);
+ });
+
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+
+ // The first window will be put into the already open window and the second
+ // window will be opened with _openWindowWithState, which is the source of the problem.
+ const state = {
+ windows: [
+ {
+ tabs: [
+ {
+ entries: [
+ { url: "http://example.org", triggeringPrincipal_base64 },
+ ],
+ extData: { uniq: 3785 },
+ },
+ ],
+ selected: 1,
+ },
+ ],
+ };
+
+ // Save the current session.
+ let { SessionStore } = ChromeUtils.importESModule(
+ "resource:///modules/sessionstore/SessionStore.sys.mjs"
+ );
+
+ // Load the custom state and wait for SSTabRestored, as we want to make sure
+ // that the URI counting code was hit.
+ let tabRestored = BrowserTestUtils.waitForEvent(
+ gBrowser.tabContainer,
+ "SSTabRestored"
+ );
+ SessionStore.setBrowserState(JSON.stringify(state));
+ await tabRestored;
+
+ // Check that the URI is not recorded.
+ const scalars = TelemetryTestUtils.getProcessScalars("parent");
+
+ ok(
+ !(TOTAL_URI_COUNT in scalars),
+ "We should not track URIs from restored sessions."
+ );
+ ok(
+ !(UNFILTERED_URI_COUNT in scalars),
+ "We should not track URIs from restored sessions."
+ );
+ ok(
+ !(UNIQUE_DOMAINS_COUNT in scalars),
+ "We should not track unique domains from restored sessions."
+ );
+
+ // Restore the original session and cleanup.
+ let sessionRestored = promiseBrowserStateRestored();
+ SessionStore.setBrowserState(JSON.stringify(state));
+ await sessionRestored;
+});
diff --git a/browser/modules/test/browser/browser_UsageTelemetry_toolbars.js b/browser/modules/test/browser/browser_UsageTelemetry_toolbars.js
new file mode 100644
index 0000000000..aade03ec84
--- /dev/null
+++ b/browser/modules/test/browser/browser_UsageTelemetry_toolbars.js
@@ -0,0 +1,550 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+gReduceMotionOverride = true;
+
+function enterCustomizationMode(win = window) {
+ let customizationReadyPromise = BrowserTestUtils.waitForEvent(
+ win.gNavToolbox,
+ "customizationready"
+ );
+ win.gCustomizeMode.enter();
+ return customizationReadyPromise;
+}
+
+function leaveCustomizationMode(win = window) {
+ let customizationDonePromise = BrowserTestUtils.waitForEvent(
+ win.gNavToolbox,
+ "aftercustomization"
+ );
+ win.gCustomizeMode.exit();
+ return customizationDonePromise;
+}
+
+Services.prefs.setBoolPref("browser.uiCustomization.skipSourceNodeCheck", true);
+registerCleanupFunction(() => {
+ CustomizableUI.reset();
+ Services.prefs.clearUserPref("browser.uiCustomization.skipSourceNodeCheck");
+});
+
+// Stolen from browser/components/customizableui/tests/browser/head.js
+function simulateItemDrag(aToDrag, aTarget, aEvent = {}, aOffset = 2) {
+ let ev = aEvent;
+ if (ev == "end" || ev == "start") {
+ let win = aTarget.ownerGlobal;
+ const dwu = win.windowUtils;
+ let bounds = dwu.getBoundsWithoutFlushing(aTarget);
+ if (ev == "end") {
+ ev = {
+ clientX: bounds.right - aOffset,
+ clientY: bounds.bottom - aOffset,
+ };
+ } else {
+ ev = { clientX: bounds.left + aOffset, clientY: bounds.top + aOffset };
+ }
+ }
+ ev._domDispatchOnly = true;
+ EventUtils.synthesizeDrop(
+ aToDrag.parentNode,
+ aTarget,
+ null,
+ null,
+ aToDrag.ownerGlobal,
+ aTarget.ownerGlobal,
+ ev
+ );
+ // Ensure dnd suppression is cleared.
+ EventUtils.synthesizeMouseAtCenter(
+ aTarget,
+ { type: "mouseup" },
+ aTarget.ownerGlobal
+ );
+}
+
+function organizeToolbars(state = {}) {
+ // Set up the defaults for the state.
+ let targetState = Object.assign(
+ {
+ // Areas where widgets can be placed, set to an array of widget IDs.
+ "toolbar-menubar": undefined,
+ PersonalToolbar: undefined,
+ TabsToolbar: ["tabbrowser-tabs", "alltabs-button"],
+ "widget-overflow-fixed-list": undefined,
+ "nav-bar": ["back-button", "forward-button", "urlbar-container"],
+
+ // The page action's that should be in the URL bar.
+ pageActionsInUrlBar: [],
+
+ // Areas to show or hide.
+ titlebarVisible: false,
+ menubarVisible: false,
+ personalToolbarVisible: false,
+ },
+ state
+ );
+
+ for (let area of CustomizableUI.areas) {
+ // Clear out anything there already.
+ for (let widgetId of CustomizableUI.getWidgetIdsInArea(area)) {
+ CustomizableUI.removeWidgetFromArea(widgetId);
+ }
+
+ if (targetState[area]) {
+ // We specify the position explicitly to support the toolbars that have
+ // fixed widgets.
+ let position = 0;
+ for (let widgetId of targetState[area]) {
+ CustomizableUI.addWidgetToArea(widgetId, area, position++);
+ }
+ }
+ }
+
+ CustomizableUI.setToolbarVisibility(
+ "toolbar-menubar",
+ targetState.menubarVisible
+ );
+ CustomizableUI.setToolbarVisibility(
+ "PersonalToolbar",
+ targetState.personalToolbarVisible
+ );
+
+ Services.prefs.setIntPref(
+ "browser.tabs.inTitlebar",
+ !targetState.titlebarVisible
+ );
+
+ for (let action of PageActions.actions) {
+ action.pinnedToUrlbar = targetState.pageActionsInUrlBar.includes(action.id);
+ }
+
+ // Clear out the existing telemetry.
+ Services.telemetry.getSnapshotForKeyedScalars("main", true);
+}
+
+function assertVisibilityScalars(expected) {
+ let scalars =
+ Services.telemetry.getSnapshotForKeyedScalars("main", true)?.parent?.[
+ "browser.ui.toolbar_widgets"
+ ] ?? {};
+
+ // Only some platforms have the menubar items.
+ if (AppConstants.MENUBAR_CAN_AUTOHIDE) {
+ expected.push("menubar-items_pinned_menu-bar");
+ }
+
+ let keys = new Set(expected.concat(Object.keys(scalars)));
+ for (let key of keys) {
+ Assert.ok(expected.includes(key), `Scalar key ${key} was unexpected.`);
+ Assert.ok(scalars[key], `Expected to see see scalar key ${key} be true.`);
+ }
+}
+
+function assertCustomizeScalars(expected) {
+ let scalars =
+ Services.telemetry.getSnapshotForKeyedScalars("main", true)?.parent?.[
+ "browser.ui.customized_widgets"
+ ] ?? {};
+
+ let keys = new Set(Object.keys(expected).concat(Object.keys(scalars)));
+ for (let key of keys) {
+ Assert.equal(
+ scalars[key],
+ expected[key],
+ `Expected to see the correct value for scalar ${key}.`
+ );
+ }
+}
+
+add_task(async function widgetPositions() {
+ organizeToolbars();
+
+ BrowserUsageTelemetry._recordUITelemetry();
+
+ assertVisibilityScalars([
+ "menu-toolbar_pinned_off",
+ "titlebar_pinned_off",
+ "bookmarks-bar_pinned_off",
+
+ "tabbrowser-tabs_pinned_tabs-bar",
+ "alltabs-button_pinned_tabs-bar",
+ "unified-extensions-button_pinned_nav-bar-end",
+
+ "forward-button_pinned_nav-bar-start",
+ "back-button_pinned_nav-bar-start",
+ ]);
+
+ organizeToolbars({
+ PersonalToolbar: [
+ "fxa-toolbar-menu-button",
+ "new-tab-button",
+ "developer-button",
+ ],
+
+ TabsToolbar: [
+ "stop-reload-button",
+ "tabbrowser-tabs",
+ "personal-bookmarks",
+ ],
+
+ "nav-bar": [
+ "home-button",
+ "forward-button",
+ "downloads-button",
+ "urlbar-container",
+ "back-button",
+ "library-button",
+ ],
+
+ personalToolbarVisible: true,
+ });
+
+ BrowserUsageTelemetry._recordUITelemetry();
+
+ assertVisibilityScalars([
+ "menu-toolbar_pinned_off",
+ "titlebar_pinned_off",
+ "bookmarks-bar_pinned_on",
+
+ "tabbrowser-tabs_pinned_tabs-bar",
+ "stop-reload-button_pinned_tabs-bar",
+ "personal-bookmarks_pinned_tabs-bar",
+ "alltabs-button_pinned_tabs-bar",
+
+ "home-button_pinned_nav-bar-start",
+ "forward-button_pinned_nav-bar-start",
+ "downloads-button_pinned_nav-bar-start",
+ "back-button_pinned_nav-bar-end",
+ "library-button_pinned_nav-bar-end",
+ "unified-extensions-button_pinned_nav-bar-end",
+
+ "fxa-toolbar-menu-button_pinned_bookmarks-bar",
+ "new-tab-button_pinned_bookmarks-bar",
+ "developer-button_pinned_bookmarks-bar",
+ ]);
+
+ CustomizableUI.reset();
+});
+
+add_task(async function customizeMode() {
+ // Create a default state.
+ organizeToolbars({
+ PersonalToolbar: ["personal-bookmarks"],
+
+ TabsToolbar: ["tabbrowser-tabs", "new-tab-button"],
+
+ "nav-bar": [
+ "back-button",
+ "forward-button",
+ "stop-reload-button",
+ "urlbar-container",
+ "home-button",
+ "library-button",
+ ],
+ });
+
+ BrowserUsageTelemetry._recordUITelemetry();
+
+ assertVisibilityScalars([
+ "menu-toolbar_pinned_off",
+ "titlebar_pinned_off",
+ "bookmarks-bar_pinned_off",
+
+ "tabbrowser-tabs_pinned_tabs-bar",
+ "new-tab-button_pinned_tabs-bar",
+ "alltabs-button_pinned_tabs-bar",
+
+ "back-button_pinned_nav-bar-start",
+ "forward-button_pinned_nav-bar-start",
+ "stop-reload-button_pinned_nav-bar-start",
+ "home-button_pinned_nav-bar-end",
+ "library-button_pinned_nav-bar-end",
+ "unified-extensions-button_pinned_nav-bar-end",
+
+ "personal-bookmarks_pinned_bookmarks-bar",
+ ]);
+
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+
+ await enterCustomizationMode(win);
+
+ let toolbarButton = win.document.getElementById(
+ "customization-toolbar-visibility-button"
+ );
+ let toolbarPopup = win.document.getElementById("customization-toolbar-menu");
+ let popupShown = BrowserTestUtils.waitForEvent(toolbarPopup, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(toolbarButton, {}, win);
+ await popupShown;
+
+ let barMenu = win.document.getElementById("toggle_PersonalToolbar");
+ let popupHidden = BrowserTestUtils.waitForEvent(toolbarPopup, "popuphidden");
+ let subMenu = barMenu.querySelector("menupopup");
+ popupShown = BrowserTestUtils.waitForEvent(subMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(barMenu, {}, win);
+ await popupShown;
+ let alwaysButton = barMenu.querySelector('*[data-visibility-enum="always"]');
+ EventUtils.synthesizeMouseAtCenter(alwaysButton, {}, win);
+ await popupHidden;
+
+ let navbar = CustomizableUI.getCustomizationTarget(
+ win.document.getElementById("nav-bar")
+ );
+ let bookmarksBar = CustomizableUI.getCustomizationTarget(
+ win.document.getElementById("PersonalToolbar")
+ );
+ let tabBar = CustomizableUI.getCustomizationTarget(
+ win.document.getElementById("TabsToolbar")
+ );
+
+ simulateItemDrag(win.document.getElementById("home-button"), navbar, "start");
+ simulateItemDrag(win.document.getElementById("library-button"), bookmarksBar);
+ simulateItemDrag(win.document.getElementById("stop-reload-button"), tabBar);
+ simulateItemDrag(
+ win.document.getElementById("stop-reload-button"),
+ navbar,
+ "start"
+ );
+ simulateItemDrag(win.document.getElementById("stop-reload-button"), tabBar);
+
+ await leaveCustomizationMode(win);
+
+ await BrowserTestUtils.closeWindow(win);
+
+ assertCustomizeScalars({
+ "home-button_move_nav-bar-end_nav-bar-start_drag": 1,
+ "library-button_move_nav-bar-end_bookmarks-bar_drag": 1,
+ "stop-reload-button_move_nav-bar-start_tabs-bar_drag": 2,
+ "stop-reload-button_move_tabs-bar_nav-bar-start_drag": 1,
+ "bookmarks-bar_move_off_always_customization-toolbar-menu": 1,
+ });
+
+ CustomizableUI.reset();
+});
+
+add_task(async function contextMenus() {
+ // Create a default state.
+ organizeToolbars({
+ PersonalToolbar: ["personal-bookmarks"],
+
+ TabsToolbar: ["tabbrowser-tabs", "new-tab-button"],
+
+ "nav-bar": [
+ "back-button",
+ "forward-button",
+ "stop-reload-button",
+ "urlbar-container",
+ "home-button",
+ "library-button",
+ ],
+ });
+
+ BrowserUsageTelemetry._recordUITelemetry();
+
+ assertVisibilityScalars([
+ "menu-toolbar_pinned_off",
+ "titlebar_pinned_off",
+ "bookmarks-bar_pinned_off",
+
+ "tabbrowser-tabs_pinned_tabs-bar",
+ "new-tab-button_pinned_tabs-bar",
+ "alltabs-button_pinned_tabs-bar",
+
+ "back-button_pinned_nav-bar-start",
+ "forward-button_pinned_nav-bar-start",
+ "stop-reload-button_pinned_nav-bar-start",
+ "home-button_pinned_nav-bar-end",
+ "library-button_pinned_nav-bar-end",
+ "unified-extensions-button_pinned_nav-bar-end",
+
+ "personal-bookmarks_pinned_bookmarks-bar",
+ ]);
+
+ let menu = document.getElementById("toolbar-context-menu");
+ let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ let button = document.getElementById("stop-reload-button");
+ EventUtils.synthesizeMouseAtCenter(
+ button,
+ { type: "contextmenu", button: 2 },
+ window
+ );
+ await popupShown;
+
+ let barMenu = document.getElementById("toggle_PersonalToolbar");
+ let popupHidden = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ let subMenu = barMenu.querySelector("menupopup");
+ popupShown = BrowserTestUtils.waitForEvent(subMenu, "popupshown");
+ barMenu.openMenu(true);
+ await popupShown;
+ let alwaysButton = subMenu.querySelector('*[data-visibility-enum="always"]');
+ subMenu.activateItem(alwaysButton);
+ await popupHidden;
+
+ popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ button,
+ { type: "contextmenu", button: 2 },
+ window
+ );
+ await popupShown;
+
+ popupHidden = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ let removeButton = document.querySelector(
+ "#toolbar-context-menu .customize-context-removeFromToolbar"
+ );
+ menu.activateItem(removeButton);
+ await popupHidden;
+
+ assertCustomizeScalars({
+ "bookmarks-bar_move_off_always_toolbar-context-menu": 1,
+ "stop-reload-button_remove_nav-bar-start_na_toolbar-context-menu": 1,
+ });
+
+ CustomizableUI.reset();
+});
+
+add_task(async function extensions() {
+ // The page action button is only visible when a page is loaded.
+ await BrowserTestUtils.withNewTab("http://example.com", async () => {
+ organizeToolbars();
+
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ version: "1",
+ browser_specific_settings: {
+ gecko: { id: "random_addon@example.com" },
+ },
+ browser_action: {
+ default_icon: "default.png",
+ default_title: "Hello",
+ default_area: "navbar",
+ },
+ page_action: {
+ default_icon: "default.png",
+ default_title: "Hello",
+ },
+ },
+ });
+
+ await extension.startup();
+
+ assertCustomizeScalars({
+ "random-addon-example-com_add_na_nav-bar-end_addon": 1,
+ "random-addon-example-com_add_na_pageaction-urlbar_addon": 1,
+ });
+
+ BrowserUsageTelemetry._recordUITelemetry();
+
+ assertVisibilityScalars([
+ "menu-toolbar_pinned_off",
+ "titlebar_pinned_off",
+ "bookmarks-bar_pinned_off",
+
+ "tabbrowser-tabs_pinned_tabs-bar",
+ "alltabs-button_pinned_tabs-bar",
+
+ "forward-button_pinned_nav-bar-start",
+ "back-button_pinned_nav-bar-start",
+
+ "random-addon-example-com_pinned_nav-bar-end",
+ "unified-extensions-button_pinned_nav-bar-end",
+
+ "random-addon-example-com_pinned_pageaction-urlbar",
+ ]);
+
+ let addon = await AddonManager.getAddonByID(extension.id);
+ await addon.disable();
+
+ assertCustomizeScalars({
+ "random-addon-example-com_remove_nav-bar-end_na_addon": 1,
+ "random-addon-example-com_remove_pageaction-urlbar_na_addon": 1,
+ });
+
+ BrowserUsageTelemetry._recordUITelemetry();
+
+ assertVisibilityScalars([
+ "menu-toolbar_pinned_off",
+ "titlebar_pinned_off",
+ "bookmarks-bar_pinned_off",
+
+ "tabbrowser-tabs_pinned_tabs-bar",
+ "alltabs-button_pinned_tabs-bar",
+
+ "forward-button_pinned_nav-bar-start",
+ "back-button_pinned_nav-bar-start",
+ "unified-extensions-button_pinned_nav-bar-end",
+ ]);
+
+ await addon.enable();
+
+ assertCustomizeScalars({
+ "random-addon-example-com_add_na_nav-bar-end_addon": 1,
+ "random-addon-example-com_add_na_pageaction-urlbar_addon": 1,
+ });
+
+ BrowserUsageTelemetry._recordUITelemetry();
+
+ assertVisibilityScalars([
+ "menu-toolbar_pinned_off",
+ "titlebar_pinned_off",
+ "bookmarks-bar_pinned_off",
+
+ "tabbrowser-tabs_pinned_tabs-bar",
+ "alltabs-button_pinned_tabs-bar",
+
+ "forward-button_pinned_nav-bar-start",
+ "back-button_pinned_nav-bar-start",
+
+ "random-addon-example-com_pinned_nav-bar-end",
+ "unified-extensions-button_pinned_nav-bar-end",
+
+ "random-addon-example-com_pinned_pageaction-urlbar",
+ ]);
+
+ await addon.reload();
+
+ assertCustomizeScalars({});
+
+ await enterCustomizationMode();
+
+ let navbar = CustomizableUI.getCustomizationTarget(
+ document.getElementById("nav-bar")
+ );
+
+ simulateItemDrag(
+ document.getElementById("random_addon_example_com-browser-action"),
+ navbar,
+ "start"
+ );
+
+ await leaveCustomizationMode();
+
+ assertCustomizeScalars({
+ "random-addon-example-com_move_nav-bar-end_nav-bar-start_drag": 1,
+ });
+
+ await extension.unload();
+
+ assertCustomizeScalars({
+ "random-addon-example-com_remove_nav-bar-start_na_addon": 1,
+ "random-addon-example-com_remove_pageaction-urlbar_na_addon": 1,
+ });
+
+ BrowserUsageTelemetry._recordUITelemetry();
+
+ assertVisibilityScalars([
+ "menu-toolbar_pinned_off",
+ "titlebar_pinned_off",
+ "bookmarks-bar_pinned_off",
+
+ "tabbrowser-tabs_pinned_tabs-bar",
+ "alltabs-button_pinned_tabs-bar",
+
+ "forward-button_pinned_nav-bar-start",
+ "back-button_pinned_nav-bar-start",
+ "unified-extensions-button_pinned_nav-bar-end",
+ ]);
+ });
+});
diff --git a/browser/modules/test/browser/browser_UsageTelemetry_uniqueOriginsVisitedInPast24Hours.js b/browser/modules/test/browser/browser_UsageTelemetry_uniqueOriginsVisitedInPast24Hours.js
new file mode 100644
index 0000000000..ec5da64882
--- /dev/null
+++ b/browser/modules/test/browser/browser_UsageTelemetry_uniqueOriginsVisitedInPast24Hours.js
@@ -0,0 +1,89 @@
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "URICountListener",
+ "resource:///modules/BrowserUsageTelemetry.jsm"
+);
+
+add_task(async function test_uniqueDomainsVisitedInPast24Hours() {
+ // By default, proxies don't apply to 127.0.0.1. We need them to for this test, though:
+ await SpecialPowers.pushPrefEnv({
+ set: [["network.proxy.allow_hijacking_localhost", true]],
+ });
+ registerCleanupFunction(async () => {
+ info("Cleaning up");
+ URICountListener.resetUniqueDomainsVisitedInPast24Hours();
+ });
+
+ URICountListener.resetUniqueDomainsVisitedInPast24Hours();
+ let startingCount = URICountListener.uniqueDomainsVisitedInPast24Hours;
+ is(
+ startingCount,
+ 0,
+ "We should have no domains recorded in the history right after resetting"
+ );
+
+ // Add a new window and then some tabs in it.
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ "http://example.com"
+ );
+
+ await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ "http://test1.example.com"
+ );
+ is(
+ URICountListener.uniqueDomainsVisitedInPast24Hours,
+ startingCount + 1,
+ "test1.example.com should only count as a unique visit if example.com wasn't visited before"
+ );
+
+ await BrowserTestUtils.openNewForegroundTab(win.gBrowser, "http://127.0.0.1");
+ is(
+ URICountListener.uniqueDomainsVisitedInPast24Hours,
+ startingCount + 1,
+ "127.0.0.1 should not count as a unique visit"
+ );
+
+ // Set the expiry time to 4 seconds. The value should be reasonably short
+ // for testing, but long enough so that waiting for openNewForegroundTab
+ // does not cause the expiry timeout to run.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.engagement.recent_visited_origins.expiry", 4]],
+ });
+
+ // http://www.exämple.test
+ await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ "http://xn--exmple-cua.test"
+ );
+ is(
+ URICountListener.uniqueDomainsVisitedInPast24Hours,
+ startingCount + 2,
+ "www.exämple.test should count as a unique visit"
+ );
+
+ let countBefore = URICountListener.uniqueDomainsVisitedInPast24Hours;
+
+ // If expiration does not work correctly, the following will time out.
+ await BrowserTestUtils.waitForCondition(() => {
+ return (
+ URICountListener.uniqueDomainsVisitedInPast24Hours == countBefore - 1
+ );
+ }, 250);
+
+ let countAfter = URICountListener.uniqueDomainsVisitedInPast24Hours;
+ is(countAfter, countBefore - 1, "The expiry should work correctly");
+
+ BrowserTestUtils.removeTab(win.gBrowser.selectedTab);
+ BrowserTestUtils.removeTab(win.gBrowser.selectedTab);
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/browser/modules/test/browser/browser_preloading_tab_moving.js b/browser/modules/test/browser/browser_preloading_tab_moving.js
new file mode 100644
index 0000000000..ce7cba9e85
--- /dev/null
+++ b/browser/modules/test/browser/browser_preloading_tab_moving.js
@@ -0,0 +1,150 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var gOldCount = NewTabPagePreloading.MAX_COUNT;
+registerCleanupFunction(() => {
+ NewTabPagePreloading.MAX_COUNT = gOldCount;
+});
+
+async function openWinWithPreloadBrowser(options = {}) {
+ let idleFinishedPromise = TestUtils.topicObserved(
+ "browser-idle-startup-tasks-finished",
+ w => {
+ return w != window;
+ }
+ );
+ let newWin = await BrowserTestUtils.openNewBrowserWindow(options);
+ await idleFinishedPromise;
+ await TestUtils.waitForCondition(() => newWin.gBrowser.preloadedBrowser);
+ return newWin;
+}
+
+async function promiseNewTabLoadedInBrowser(browser) {
+ let url = browser.ownerGlobal.BROWSER_NEW_TAB_URL;
+ if (browser.currentURI.spec != url) {
+ info(`Waiting for ${url} to be the location for the browser.`);
+ await new Promise(resolve => {
+ let progressListener = {
+ onLocationChange(aWebProgress, aRequest, aLocationURI, aFlags) {
+ if (!url || aLocationURI.spec == url) {
+ browser.removeProgressListener(progressListener);
+ resolve();
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ Ci.nsISupportsWeakReference,
+ Ci.nsIWebProgressListener2,
+ Ci.nsIWebProgressListener,
+ ]),
+ };
+ browser.addProgressListener(
+ progressListener,
+ Ci.nsIWebProgress.NOTIFY_ALL
+ );
+ });
+ } else {
+ info(`${url} already the current URI for the browser.`);
+ }
+
+ info(`Waiting for readyState complete in the browser`);
+ await SpecialPowers.spawn(browser, [], function () {
+ return ContentTaskUtils.waitForCondition(() => {
+ return content.document.readyState == "complete";
+ });
+ });
+}
+
+/**
+ * Verify that moving a preloaded browser's content from one window to the next
+ * works correctly.
+ */
+add_task(async function moving_works() {
+ NewTabPagePreloading.MAX_COUNT = 1;
+
+ NewTabPagePreloading.removePreloadedBrowser(window);
+
+ NewTabPagePreloading.maybeCreatePreloadedBrowser(window);
+ isnot(gBrowser.preloadedBrowser, null, "Should have preloaded browser");
+
+ let oldKey = gBrowser.preloadedBrowser.permanentKey;
+
+ let newWin = await openWinWithPreloadBrowser();
+ is(gBrowser.preloadedBrowser, null, "Preloaded browser should be gone");
+ isnot(
+ newWin.gBrowser.preloadedBrowser,
+ null,
+ "Should have moved the preload browser"
+ );
+ is(
+ newWin.gBrowser.preloadedBrowser.permanentKey,
+ oldKey,
+ "Should have the same permanent key"
+ );
+ let browser = newWin.gBrowser.preloadedBrowser;
+ let tab = BrowserTestUtils.addTab(
+ newWin.gBrowser,
+ newWin.BROWSER_NEW_TAB_URL
+ );
+ is(
+ tab.linkedBrowser,
+ browser,
+ "Preloaded browser is usable when opening a new tab."
+ );
+ await promiseNewTabLoadedInBrowser(browser);
+ ok(true, "Successfully loaded the tab.");
+
+ tab = browser = null;
+ await BrowserTestUtils.closeWindow(newWin);
+
+ tab = BrowserTestUtils.addTab(gBrowser, BROWSER_NEW_TAB_URL);
+ await promiseNewTabLoadedInBrowser(tab.linkedBrowser);
+
+ ok(true, "Managed to open a tab in the original window still.");
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function moving_shouldnt_move_across_private_state() {
+ NewTabPagePreloading.MAX_COUNT = 1;
+
+ NewTabPagePreloading.removePreloadedBrowser(window);
+
+ NewTabPagePreloading.maybeCreatePreloadedBrowser(window);
+ isnot(gBrowser.preloadedBrowser, null, "Should have preloaded browser");
+
+ let oldKey = gBrowser.preloadedBrowser.permanentKey;
+ let newWin = await openWinWithPreloadBrowser({ private: true });
+
+ isnot(
+ gBrowser.preloadedBrowser,
+ null,
+ "Preloaded browser in original window should persist"
+ );
+ isnot(
+ newWin.gBrowser.preloadedBrowser,
+ null,
+ "Should have created another preload browser"
+ );
+ isnot(
+ newWin.gBrowser.preloadedBrowser.permanentKey,
+ oldKey,
+ "Should not have the same permanent key"
+ );
+ let browser = newWin.gBrowser.preloadedBrowser;
+ let tab = BrowserTestUtils.addTab(
+ newWin.gBrowser,
+ newWin.BROWSER_NEW_TAB_URL
+ );
+ is(
+ tab.linkedBrowser,
+ browser,
+ "Preloaded browser is usable when opening a new tab."
+ );
+ await promiseNewTabLoadedInBrowser(browser);
+ ok(true, "Successfully loaded the tab.");
+
+ tab = browser = null;
+ await BrowserTestUtils.closeWindow(newWin);
+});
diff --git a/browser/modules/test/browser/browser_taskbar_preview.js b/browser/modules/test/browser/browser_taskbar_preview.js
new file mode 100644
index 0000000000..89892e38dd
--- /dev/null
+++ b/browser/modules/test/browser/browser_taskbar_preview.js
@@ -0,0 +1,129 @@
+function test() {
+ var isWin7OrHigher = false;
+ try {
+ let version = Services.sysinfo.getProperty("version");
+ isWin7OrHigher = parseFloat(version) >= 6.1;
+ } catch (ex) {}
+
+ is(
+ !!Win7Features,
+ isWin7OrHigher,
+ "Win7Features available when it should be"
+ );
+ if (!isWin7OrHigher) {
+ return;
+ }
+
+ const ENABLE_PREF_NAME = "browser.taskbar.previews.enable";
+
+ let { AeroPeek } = ChromeUtils.import(
+ "resource:///modules/WindowsPreviewPerTab.jsm"
+ );
+
+ waitForExplicitFinish();
+
+ Services.prefs.setBoolPref(ENABLE_PREF_NAME, true);
+
+ is(1, AeroPeek.windows.length, "Got the expected number of windows");
+
+ checkPreviews(1, "Browser starts with one preview");
+
+ BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.addTab(gBrowser);
+
+ checkPreviews(4, "Correct number of previews after adding");
+
+ for (let preview of AeroPeek.previews) {
+ ok(preview.visible, "Preview is shown as expected");
+ }
+
+ Services.prefs.setBoolPref(ENABLE_PREF_NAME, false);
+ is(0, AeroPeek.previews.length, "Should have 0 previews when disabled");
+
+ Services.prefs.setBoolPref(ENABLE_PREF_NAME, true);
+ checkPreviews(4, "Previews are back when re-enabling");
+ for (let preview of AeroPeek.previews) {
+ ok(preview.visible, "Preview is shown as expected after re-enabling");
+ }
+
+ [1, 2, 3, 4].forEach(function (idx) {
+ gBrowser.selectedTab = gBrowser.tabs[idx];
+ ok(checkSelectedTab(), "Current tab is correctly selected");
+ });
+
+ // Close #4
+ getPreviewForTab(gBrowser.selectedTab).controller.onClose();
+ checkPreviews(
+ 3,
+ "Expected number of previews after closing selected tab via controller"
+ );
+ ok(gBrowser.tabs.length == 3, "Successfully closed a tab");
+
+ // Select #1
+ ok(
+ getPreviewForTab(gBrowser.tabs[0]).controller.onActivate(),
+ "Activation was accepted"
+ );
+ ok(gBrowser.tabs[0].selected, "Correct tab was selected");
+ checkSelectedTab();
+
+ // Remove #3 (non active)
+ gBrowser.removeTab(gBrowser.tabs[gBrowser.tabs.length - 1]);
+ checkPreviews(
+ 2,
+ "Expected number of previews after closing unselected via browser"
+ );
+
+ // Remove #1 (active)
+ gBrowser.removeTab(gBrowser.tabs[0]);
+ checkPreviews(
+ 1,
+ "Expected number of previews after closing selected tab via browser"
+ );
+
+ // Add a new tab
+ BrowserTestUtils.addTab(gBrowser);
+ checkPreviews(2);
+ // Check default selection
+ checkSelectedTab();
+
+ // Change selection
+ gBrowser.selectedTab = gBrowser.tabs[0];
+ checkSelectedTab();
+ // Close nonselected tab via controller
+ getPreviewForTab(gBrowser.tabs[1]).controller.onClose();
+ checkPreviews(1);
+
+ if (Services.prefs.prefHasUserValue(ENABLE_PREF_NAME)) {
+ Services.prefs.setBoolPref(
+ ENABLE_PREF_NAME,
+ !Services.prefs.getBoolPref(ENABLE_PREF_NAME)
+ );
+ }
+
+ finish();
+
+ function checkPreviews(aPreviews, msg) {
+ let nPreviews = AeroPeek.previews.length;
+ is(
+ aPreviews,
+ gBrowser.tabs.length,
+ "Browser has expected number of tabs - " + msg
+ );
+ is(
+ nPreviews,
+ gBrowser.tabs.length,
+ "Browser has one preview per tab - " + msg
+ );
+ is(nPreviews, aPreviews, msg || "Got expected number of previews");
+ }
+
+ function getPreviewForTab(tab) {
+ return window.gTaskbarTabGroup.previewFromTab(tab);
+ }
+
+ function checkSelectedTab() {
+ return getPreviewForTab(gBrowser.selectedTab).active;
+ }
+}
diff --git a/browser/modules/test/browser/browser_urlBar_zoom.js b/browser/modules/test/browser/browser_urlBar_zoom.js
new file mode 100644
index 0000000000..21d8202a52
--- /dev/null
+++ b/browser/modules/test/browser/browser_urlBar_zoom.js
@@ -0,0 +1,107 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var initialPageZoom = ZoomManager.zoom;
+const kTimeoutInMS = 20000;
+
+async function testZoomButtonAppearsAndDisappearsBasedOnZoomChanges(
+ zoomEventType
+) {
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: "https://example.com/",
+ waitForStateStop: true,
+ });
+
+ info("Running this test with " + zoomEventType.substring(0, 9));
+ info("Confirm whether the browser zoom is set to the default level");
+ is(initialPageZoom, 1, "Page zoom is set to default (100%)");
+ let zoomResetButton = document.getElementById("urlbar-zoom-button");
+ is(zoomResetButton.hidden, true, "Zoom reset button is currently hidden");
+
+ info("Change zoom and confirm zoom button appears");
+ let labelUpdatePromise = BrowserTestUtils.waitForAttribute(
+ "label",
+ zoomResetButton
+ );
+ FullZoom.enlarge();
+ await labelUpdatePromise;
+ info("Zoom increased to " + Math.floor(ZoomManager.zoom * 100) + "%");
+ is(zoomResetButton.hidden, false, "Zoom reset button is now visible");
+ let pageZoomLevel = Math.floor(ZoomManager.zoom * 100);
+ let expectedZoomLevel = 110;
+ let buttonZoomLevel = parseInt(zoomResetButton.getAttribute("label"), 10);
+ is(
+ buttonZoomLevel,
+ expectedZoomLevel,
+ "Button label updated successfully to " +
+ Math.floor(ZoomManager.zoom * 100) +
+ "%"
+ );
+
+ let zoomResetPromise = BrowserTestUtils.waitForEvent(window, zoomEventType);
+ zoomResetButton.click();
+ await zoomResetPromise;
+ pageZoomLevel = Math.floor(ZoomManager.zoom * 100);
+ expectedZoomLevel = 100;
+ is(
+ pageZoomLevel,
+ expectedZoomLevel,
+ "Clicking zoom button successfully resets browser zoom to 100%"
+ );
+ is(zoomResetButton.hidden, true, "Zoom reset button returns to being hidden");
+
+ BrowserTestUtils.removeTab(tab);
+}
+
+add_task(async function () {
+ await testZoomButtonAppearsAndDisappearsBasedOnZoomChanges("FullZoomChange");
+ await SpecialPowers.pushPrefEnv({ set: [["browser.zoom.full", false]] });
+ await testZoomButtonAppearsAndDisappearsBasedOnZoomChanges("TextZoomChange");
+ await SpecialPowers.pushPrefEnv({ set: [["browser.zoom.full", true]] });
+});
+
+add_task(async function () {
+ info(
+ "Confirm that URL bar zoom button doesn't appear when customizable zoom widget is added to toolbar"
+ );
+ CustomizableUI.addWidgetToArea("zoom-controls", CustomizableUI.AREA_NAVBAR);
+ let zoomCustomizableWidget = document.getElementById("zoom-reset-button");
+ let zoomResetButton = document.getElementById("urlbar-zoom-button");
+ let zoomChangePromise = BrowserTestUtils.waitForEvent(
+ window,
+ "FullZoomChange"
+ );
+ FullZoom.enlarge();
+ await zoomChangePromise;
+ is(
+ zoomResetButton.hidden,
+ true,
+ "URL zoom button remains hidden despite zoom increase"
+ );
+ is(
+ parseInt(zoomCustomizableWidget.label, 10),
+ 110,
+ "Customizable zoom widget's label has updated to " +
+ zoomCustomizableWidget.label
+ );
+});
+
+add_task(async function asyncCleanup() {
+ // reset zoom level and customizable widget
+ ZoomManager.zoom = initialPageZoom;
+ is(ZoomManager.zoom, 1, "Zoom level was restored");
+ if (document.getElementById("zoom-controls")) {
+ CustomizableUI.removeWidgetFromArea(
+ "zoom-controls",
+ CustomizableUI.AREA_NAVBAR
+ );
+ ok(
+ !document.getElementById("zoom-controls"),
+ "Customizable zoom widget removed from toolbar"
+ );
+ }
+});
diff --git a/browser/modules/test/browser/contain_iframe.html b/browser/modules/test/browser/contain_iframe.html
new file mode 100644
index 0000000000..8cea71fae4
--- /dev/null
+++ b/browser/modules/test/browser/contain_iframe.html
@@ -0,0 +1,7 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8"/>
+ </head>
+ <body><iframe src="http://example.com"></iframe></body>
+</html>
diff --git a/browser/modules/test/browser/contentSearchBadImage.xml b/browser/modules/test/browser/contentSearchBadImage.xml
new file mode 100644
index 0000000000..6e4cb60a58
--- /dev/null
+++ b/browser/modules/test/browser/contentSearchBadImage.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/">
+<ShortName>browser_ContentSearch contentSearchBadImage.xml</ShortName>
+<Url type="text/html" method="GET" template="http://browser-ContentSearch.com/contentSearchBadImage" rel="searchform"/>
+<Image width="16" height="16">data:image/x-icon;base64,notbase64</Image>
+</SearchPlugin>
diff --git a/browser/modules/test/browser/contentSearchSuggestions.sjs b/browser/modules/test/browser/contentSearchSuggestions.sjs
new file mode 100644
index 0000000000..1978b4f665
--- /dev/null
+++ b/browser/modules/test/browser/contentSearchSuggestions.sjs
@@ -0,0 +1,9 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function handleRequest(req, resp) {
+ let suffixes = ["foo", "bar"];
+ let data = [req.queryString, suffixes.map(s => req.queryString + s)];
+ resp.setHeader("Content-Type", "application/json", false);
+ resp.write(JSON.stringify(data));
+}
diff --git a/browser/modules/test/browser/contentSearchSuggestions.xml b/browser/modules/test/browser/contentSearchSuggestions.xml
new file mode 100644
index 0000000000..448a946e1b
--- /dev/null
+++ b/browser/modules/test/browser/contentSearchSuggestions.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/">
+<ShortName>browser_ContentSearch contentSearchSuggestions.xml</ShortName>
+<Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/modules/test/browser/contentSearchSuggestions.sjs?{searchTerms}"/>
+<Url type="text/html" method="GET" template="http://browser-ContentSearch.com/contentSearchSuggestions" rel="searchform"/>
+</SearchPlugin>
diff --git a/browser/modules/test/browser/file_webrtc.html b/browser/modules/test/browser/file_webrtc.html
new file mode 100644
index 0000000000..1c75f7c75b
--- /dev/null
+++ b/browser/modules/test/browser/file_webrtc.html
@@ -0,0 +1,11 @@
+<html>
+<body onload="start()">
+<script>
+let stream;
+async function start()
+{
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true })
+}
+</script>
+</body>
+</html>
diff --git a/browser/modules/test/browser/formValidation/browser.ini b/browser/modules/test/browser/formValidation/browser.ini
new file mode 100644
index 0000000000..1c0b80d782
--- /dev/null
+++ b/browser/modules/test/browser/formValidation/browser.ini
@@ -0,0 +1,7 @@
+[browser_form_validation.js]
+skip-if = true # bug 1057615
+[browser_validation_iframe.js]
+skip-if = true # bug 1057615
+[browser_validation_invisible.js]
+[browser_validation_navigation.js]
+[browser_validation_other_popups.js]
diff --git a/browser/modules/test/browser/formValidation/browser_form_validation.js b/browser/modules/test/browser/formValidation/browser_form_validation.js
new file mode 100644
index 0000000000..a1cbc4d88d
--- /dev/null
+++ b/browser/modules/test/browser/formValidation/browser_form_validation.js
@@ -0,0 +1,519 @@
+/**
+ * COPIED FROM browser/base/content/test/general/head.js.
+ * This function should be removed and replaced with BTU withNewTab calls
+ *
+ * Waits for a load (or custom) event to finish in a given tab. If provided
+ * load an uri into the tab.
+ *
+ * @param tab
+ * The tab to load into.
+ * @param [optional] url
+ * The url to load, or the current url.
+ * @return {Promise} resolved when the event is handled.
+ * @resolves to the received event
+ * @rejects if a valid load event is not received within a meaningful interval
+ */
+function promiseTabLoadEvent(tab, url) {
+ info("Wait tab event: load");
+
+ function handle(loadedUrl) {
+ if (loadedUrl === "about:blank" || (url && loadedUrl !== url)) {
+ info(`Skipping spurious load event for ${loadedUrl}`);
+ return false;
+ }
+
+ info("Tab event received: load");
+ return true;
+ }
+
+ let loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser, false, handle);
+
+ if (url) {
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, url);
+ }
+
+ return loaded;
+}
+
+var gInvalidFormPopup =
+ gBrowser.selectedBrowser.browsingContext.currentWindowGlobal
+ .getActor("FormValidation")
+ ._getAndMaybeCreatePanel(document);
+ok(
+ gInvalidFormPopup,
+ "The browser should have a popup to show when a form is invalid"
+);
+
+function isWithinHalfPixel(a, b) {
+ return Math.abs(a - b) <= 0.5;
+}
+
+function checkPopupShow(anchorRect) {
+ ok(
+ gInvalidFormPopup.state == "showing" || gInvalidFormPopup.state == "open",
+ "[Test " + testId + "] The invalid form popup should be shown"
+ );
+ // Just check the vertical position, as the horizontal position of an
+ // arrow panel will be offset.
+ is(
+ isWithinHalfPixel(gInvalidFormPopup.screenY),
+ isWithinHalfPixel(anchorRect.bottom),
+ "popup top"
+ );
+}
+
+function checkPopupHide() {
+ ok(
+ gInvalidFormPopup.state != "showing" && gInvalidFormPopup.state != "open",
+ "[Test " + testId + "] The invalid form popup should not be shown"
+ );
+}
+
+var testId = 0;
+
+function incrementTest() {
+ testId++;
+ info("Starting next part of test");
+}
+
+function getDocHeader() {
+ return "<html><head><meta charset='utf-8'></head><body>" + getEmptyFrame();
+}
+
+function getDocFooter() {
+ return "</body></html>";
+}
+
+function getEmptyFrame() {
+ return (
+ "<iframe style='width:100px; height:30px; margin:3px; border:1px solid lightgray;' " +
+ "name='t' srcdoc=\"<html><head><meta charset='utf-8'></head><body>form target</body></html>\"></iframe>"
+ );
+}
+
+async function openNewTab(uri, background) {
+ let tab = BrowserTestUtils.addTab(gBrowser);
+ let browser = gBrowser.getBrowserForTab(tab);
+ if (!background) {
+ gBrowser.selectedTab = tab;
+ }
+ await promiseTabLoadEvent(tab, "data:text/html," + escape(uri));
+ return browser;
+}
+
+function clickChildElement(browser) {
+ return SpecialPowers.spawn(browser, [], async function () {
+ let element = content.document.getElementById("s");
+ element.click();
+ return {
+ bottom: content.mozInnerScreenY + element.getBoundingClientRect().bottom,
+ };
+ });
+}
+
+async function blurChildElement(browser) {
+ await SpecialPowers.spawn(browser, [], async function () {
+ content.document.getElementById("i").blur();
+ });
+}
+
+async function checkChildFocus(browser, message) {
+ await SpecialPowers.spawn(
+ browser,
+ [[message, testId]],
+ async function (args) {
+ let [msg, id] = args;
+ var focused =
+ content.document.activeElement == content.document.getElementById("i");
+
+ var validMsg = true;
+ if (msg) {
+ validMsg =
+ msg == content.document.getElementById("i").validationMessage;
+ }
+
+ Assert.equal(
+ focused,
+ true,
+ "Test " + id + " First invalid element should be focused"
+ );
+ Assert.equal(
+ validMsg,
+ true,
+ "Test " +
+ id +
+ " The panel should show the message from validationMessage"
+ );
+ }
+ );
+}
+
+/**
+ * In this test, we check that no popup appears if the form is valid.
+ */
+add_task(async function () {
+ incrementTest();
+ let uri =
+ getDocHeader() +
+ "<form target='t' action='data:text/html,'><input><input id='s' type='submit'></form>" +
+ getDocFooter();
+ let browser = await openNewTab(uri);
+
+ await clickChildElement(browser);
+
+ await new Promise((resolve, reject) => {
+ // XXXndeakin This isn't really going to work when the content is another process
+ executeSoon(function () {
+ checkPopupHide();
+ resolve();
+ });
+ });
+
+ gBrowser.removeCurrentTab();
+});
+
+/**
+ * In this test, we check that, when an invalid form is submitted,
+ * the invalid element is focused and a popup appears.
+ */
+add_task(async function () {
+ incrementTest();
+ let uri =
+ getDocHeader() +
+ "<form target='t' action='data:text/html,'><input required id='i'><input id='s' type='submit'></form>" +
+ getDocFooter();
+ let browser = await openNewTab(uri);
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popupshown"
+ );
+ let anchorRect = await clickChildElement(browser);
+ await popupShownPromise;
+
+ checkPopupShow(anchorRect);
+
+ await checkChildFocus(
+ browser,
+ gInvalidFormPopup.firstElementChild.textContent
+ );
+
+ gBrowser.removeCurrentTab();
+});
+
+/**
+ * In this test, we check that, when an invalid form is submitted,
+ * the first invalid element is focused and a popup appears.
+ */
+add_task(async function () {
+ incrementTest();
+ let uri =
+ getDocHeader() +
+ "<form target='t' action='data:text/html,'><input><input id='i' required><input required><input id='s' type='submit'></form>" +
+ getDocFooter();
+ let browser = await openNewTab(uri);
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popupshown"
+ );
+ let anchorRect = await clickChildElement(browser);
+ await popupShownPromise;
+
+ checkPopupShow(anchorRect);
+ await checkChildFocus(
+ browser,
+ gInvalidFormPopup.firstElementChild.textContent
+ );
+
+ gBrowser.removeCurrentTab();
+});
+
+/**
+ * In this test, we check that, we hide the popup by interacting with the
+ * invalid element if the element becomes valid.
+ */
+add_task(async function () {
+ incrementTest();
+ let uri =
+ getDocHeader() +
+ "<form target='t' action='data:text/html,'><input id='i' required><input id='s' type='submit'></form>" +
+ getDocFooter();
+ let browser = await openNewTab(uri);
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popupshown"
+ );
+ let anchorRect = await clickChildElement(browser);
+ await popupShownPromise;
+
+ checkPopupShow(anchorRect);
+ await checkChildFocus(
+ browser,
+ gInvalidFormPopup.firstElementChild.textContent
+ );
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popuphidden"
+ );
+ EventUtils.sendString("a");
+ await popupHiddenPromise;
+
+ gBrowser.removeCurrentTab();
+});
+
+/**
+ * In this test, we check that, we don't hide the popup by interacting with the
+ * invalid element if the element is still invalid.
+ */
+add_task(async function () {
+ incrementTest();
+ let uri =
+ getDocHeader() +
+ "<form target='t' action='data:text/html,'><input type='email' id='i' required><input id='s' type='submit'></form>" +
+ getDocFooter();
+ let browser = await openNewTab(uri);
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popupshown"
+ );
+ let anchorRect = await clickChildElement(browser);
+ await popupShownPromise;
+
+ checkPopupShow(anchorRect);
+ await checkChildFocus(
+ browser,
+ gInvalidFormPopup.firstElementChild.textContent
+ );
+
+ await new Promise((resolve, reject) => {
+ EventUtils.sendString("a");
+ executeSoon(function () {
+ checkPopupShow(anchorRect);
+ resolve();
+ });
+ });
+
+ gBrowser.removeCurrentTab();
+});
+
+/**
+ * In this test, we check that we can hide the popup by blurring the invalid
+ * element.
+ */
+add_task(async function () {
+ incrementTest();
+ let uri =
+ getDocHeader() +
+ "<form target='t' action='data:text/html,'><input id='i' required><input id='s' type='submit'></form>" +
+ getDocFooter();
+ let browser = await openNewTab(uri);
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popupshown"
+ );
+ let anchorRect = await clickChildElement(browser);
+ await popupShownPromise;
+
+ checkPopupShow(anchorRect);
+ await checkChildFocus(
+ browser,
+ gInvalidFormPopup.firstElementChild.textContent
+ );
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popuphidden"
+ );
+ await blurChildElement(browser);
+ await popupHiddenPromise;
+
+ gBrowser.removeCurrentTab();
+});
+
+/**
+ * In this test, we check that we can hide the popup by pressing TAB.
+ */
+add_task(async function () {
+ incrementTest();
+ let uri =
+ getDocHeader() +
+ "<form target='t' action='data:text/html,'><input id='i' required><input id='s' type='submit'></form>" +
+ getDocFooter();
+ let browser = await openNewTab(uri);
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popupshown"
+ );
+ let anchorRect = await clickChildElement(browser);
+ await popupShownPromise;
+
+ checkPopupShow(anchorRect);
+ await checkChildFocus(
+ browser,
+ gInvalidFormPopup.firstElementChild.textContent
+ );
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popuphidden"
+ );
+ EventUtils.synthesizeKey("KEY_Tab");
+ await popupHiddenPromise;
+
+ gBrowser.removeCurrentTab();
+});
+
+/**
+ * In this test, we check that the popup will hide if we move to another tab.
+ */
+add_task(async function () {
+ incrementTest();
+ let uri =
+ getDocHeader() +
+ "<form target='t' action='data:text/html,'><input id='i' required><input id='s' type='submit'></form>" +
+ getDocFooter();
+ let browser1 = await openNewTab(uri);
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popupshown"
+ );
+ let anchorRect = await clickChildElement(browser1);
+ await popupShownPromise;
+
+ checkPopupShow(anchorRect);
+ await checkChildFocus(
+ browser1,
+ gInvalidFormPopup.firstElementChild.textContent
+ );
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popuphidden"
+ );
+
+ let browser2 = await openNewTab("data:text/html,<html></html>");
+ await popupHiddenPromise;
+
+ gBrowser.removeTab(gBrowser.getTabForBrowser(browser1));
+ gBrowser.removeTab(gBrowser.getTabForBrowser(browser2));
+});
+
+/**
+ * In this test, we check that the popup will hide if we navigate to another
+ * page.
+ */
+add_task(async function () {
+ incrementTest();
+ let uri =
+ getDocHeader() +
+ "<form target='t' action='data:text/html,'><input id='i' required><input id='s' type='submit'></form>" +
+ getDocFooter();
+ let browser = await openNewTab(uri);
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popupshown"
+ );
+ let anchorRect = await clickChildElement(browser);
+ await popupShownPromise;
+
+ checkPopupShow(anchorRect);
+ await checkChildFocus(
+ browser,
+ gInvalidFormPopup.firstElementChild.textContent
+ );
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popuphidden"
+ );
+ BrowserTestUtils.loadURIString(browser, "data:text/html,<div>hello!</div>");
+ await BrowserTestUtils.browserLoaded(browser);
+
+ await popupHiddenPromise;
+
+ gBrowser.removeCurrentTab();
+});
+
+/**
+ * In this test, we check that the message is correctly updated when it changes.
+ */
+add_task(async function () {
+ incrementTest();
+ let uri =
+ getDocHeader() +
+ "<form target='t' action='data:text/html,'><input type='email' required id='i'><input id='s' type='submit'></form>" +
+ getDocFooter();
+ let browser = await openNewTab(uri);
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popupshown"
+ );
+ let anchorRect = await clickChildElement(browser);
+ await popupShownPromise;
+
+ checkPopupShow(anchorRect);
+ await checkChildFocus(
+ browser,
+ gInvalidFormPopup.firstElementChild.textContent
+ );
+
+ let inputPromise = BrowserTestUtils.waitForContentEvent(browser, "input");
+ EventUtils.sendString("f");
+ await inputPromise;
+
+ // Now, the element suffers from another error, the message should have
+ // been updated.
+ await new Promise((resolve, reject) => {
+ // XXXndeakin This isn't really going to work when the content is another process
+ executeSoon(function () {
+ checkChildFocus(browser, gInvalidFormPopup.firstElementChild.textContent);
+ resolve();
+ });
+ });
+
+ gBrowser.removeCurrentTab();
+});
+
+/**
+ * In this test, we reload the page while the form validation popup is visible. The validation
+ * popup should hide.
+ */
+add_task(async function () {
+ incrementTest();
+ let uri =
+ getDocHeader() +
+ "<form target='t' action='data:text/html,'><input id='i' required><input id='s' type='submit'></form>" +
+ getDocFooter();
+ let browser = await openNewTab(uri);
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popupshown"
+ );
+ let anchorRect = await clickChildElement(browser);
+ await popupShownPromise;
+
+ checkPopupShow(anchorRect);
+ await checkChildFocus(
+ browser,
+ gInvalidFormPopup.firstElementChild.textContent
+ );
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popuphidden"
+ );
+ BrowserReloadSkipCache();
+ await popupHiddenPromise;
+
+ gBrowser.removeCurrentTab();
+});
diff --git a/browser/modules/test/browser/formValidation/browser_validation_iframe.js b/browser/modules/test/browser/formValidation/browser_validation_iframe.js
new file mode 100644
index 0000000000..454c972f32
--- /dev/null
+++ b/browser/modules/test/browser/formValidation/browser_validation_iframe.js
@@ -0,0 +1,67 @@
+/**
+ * Make sure that the form validation error message shows even if the form is in an iframe.
+ */
+add_task(async function test_iframe() {
+ let uri =
+ "data:text/html;charset=utf-8," +
+ escape(
+ "<iframe src=\"data:text/html,<iframe name='t'></iframe><form target='t' action='data:text/html,'><input required id='i'><input id='s' type='submit'></form>\" height=\"600\"></iframe>"
+ );
+
+ var gInvalidFormPopup =
+ gBrowser.selectedBrowser.browsingContext.currentWindowGlobal
+ .getActor("FormValidation")
+ ._getAndMaybeCreatePanel(document);
+ ok(
+ gInvalidFormPopup,
+ "The browser should have a popup to show when a form is invalid"
+ );
+
+ await BrowserTestUtils.withNewTab(uri, async function checkTab(browser) {
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ gInvalidFormPopup,
+ "popupshown"
+ );
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ content.document
+ .getElementsByTagName("iframe")[0]
+ .contentDocument.getElementById("s")
+ .click();
+ });
+ await popupShownPromise;
+
+ let anchorBottom = await SpecialPowers.spawn(
+ browser,
+ [],
+ async function () {
+ let childdoc =
+ content.document.getElementsByTagName("iframe")[0].contentDocument;
+ Assert.equal(
+ childdoc.activeElement,
+ childdoc.getElementById("i"),
+ "First invalid element should be focused"
+ );
+ return (
+ childdoc.defaultView.mozInnerScreenY +
+ childdoc.getElementById("i").getBoundingClientRect().bottom
+ );
+ }
+ );
+
+ function isWithinHalfPixel(a, b) {
+ return Math.abs(a - b) <= 0.5;
+ }
+
+ is(
+ isWithinHalfPixel(gInvalidFormPopup.screenY),
+ isWithinHalfPixel(anchorBottom),
+ "popup top"
+ );
+
+ ok(
+ gInvalidFormPopup.state == "showing" || gInvalidFormPopup.state == "open",
+ "The invalid form popup should be shown"
+ );
+ });
+});
diff --git a/browser/modules/test/browser/formValidation/browser_validation_invisible.js b/browser/modules/test/browser/formValidation/browser_validation_invisible.js
new file mode 100644
index 0000000000..9383ad773b
--- /dev/null
+++ b/browser/modules/test/browser/formValidation/browser_validation_invisible.js
@@ -0,0 +1,67 @@
+"use strict";
+
+var gInvalidFormPopup =
+ gBrowser.selectedBrowser.browsingContext.currentWindowGlobal
+ .getActor("FormValidation")
+ ._getAndMaybeCreatePanel(document);
+
+function checkPopupHide() {
+ ok(
+ gInvalidFormPopup.state != "showing" && gInvalidFormPopup.state != "open",
+ "[Test " + testId + "] The invalid form popup should not be shown"
+ );
+}
+
+var testId = 0;
+
+function incrementTest() {
+ testId++;
+ info("Starting next part of test");
+}
+
+/**
+ * In this test, we check that no popup appears if the element display is none.
+ */
+add_task(async function test_display_none() {
+ ok(
+ gInvalidFormPopup,
+ "The browser should have a popup to show when a form is invalid"
+ );
+
+ incrementTest();
+ let testPage =
+ "data:text/html;charset=utf-8," +
+ '<form target="t"><input type="url" placeholder="url" value="http://" style="display: none;"><input id="s" type="button" value="check"></form>';
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, testPage);
+ await BrowserTestUtils.synthesizeMouse(
+ "#s",
+ 0,
+ 0,
+ {},
+ gBrowser.selectedBrowser
+ );
+
+ checkPopupHide();
+ BrowserTestUtils.removeTab(tab);
+});
+
+/**
+ * In this test, we check that no popup appears if the element visibility is hidden.
+ */
+add_task(async function test_visibility_hidden() {
+ incrementTest();
+ let testPage =
+ "data:text/html;charset=utf-8," +
+ '<form target="t"><input type="url" placeholder="url" value="http://" style="visibility: hidden;"><input id="s" type="button" value="check"></form>';
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, testPage);
+ await BrowserTestUtils.synthesizeMouse(
+ "#s",
+ 0,
+ 0,
+ {},
+ gBrowser.selectedBrowser
+ );
+
+ checkPopupHide();
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/modules/test/browser/formValidation/browser_validation_navigation.js b/browser/modules/test/browser/formValidation/browser_validation_navigation.js
new file mode 100644
index 0000000000..4dd793b983
--- /dev/null
+++ b/browser/modules/test/browser/formValidation/browser_validation_navigation.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Make sure that the form validation message disappears if we navigate
+ * immediately.
+ */
+add_task(async function test_navigate() {
+ var gInvalidFormPopup =
+ gBrowser.selectedBrowser.browsingContext.currentWindowGlobal
+ .getActor("FormValidation")
+ ._getAndMaybeCreatePanel(document);
+ ok(
+ gInvalidFormPopup,
+ "The browser should have a popup to show when a form is invalid"
+ );
+
+ await BrowserTestUtils.withNewTab(
+ "data:text/html,<body contenteditable='true'><button>Click me",
+ async function checkTab(browser) {
+ let promiseExampleLoaded = BrowserTestUtils.waitForNewTab(
+ browser.getTabBrowser(),
+ "https://example.com/",
+ true
+ );
+ await SpecialPowers.spawn(browser, [], () => {
+ let doc = content.document;
+ let input = doc.createElement("select");
+ input.style.opacity = 0;
+ doc.body.append(input);
+ input.setCustomValidity("This message should not show up.");
+ content.eval(
+ `document.querySelector("button").setAttribute("onmousedown", "document.querySelector('select').reportValidity();window.open('https://example.com/');")`
+ );
+ });
+ await BrowserTestUtils.synthesizeMouseAtCenter("button", {}, browser);
+ let otherTab = await promiseExampleLoaded;
+ await BrowserTestUtils.waitForPopupEvent(gInvalidFormPopup, "hidden");
+ is(
+ gInvalidFormPopup.state,
+ "closed",
+ "Invalid form popup should go away."
+ );
+ BrowserTestUtils.removeTab(otherTab);
+ }
+ );
+});
diff --git a/browser/modules/test/browser/formValidation/browser_validation_other_popups.js b/browser/modules/test/browser/formValidation/browser_validation_other_popups.js
new file mode 100644
index 0000000000..320dff0b59
--- /dev/null
+++ b/browser/modules/test/browser/formValidation/browser_validation_other_popups.js
@@ -0,0 +1,123 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var gInvalidFormPopup =
+ gBrowser.selectedBrowser.browsingContext.currentWindowGlobal
+ .getActor("FormValidation")
+ ._getAndMaybeCreatePanel(document);
+
+add_task(async function test_other_popup_closes() {
+ ok(
+ gInvalidFormPopup,
+ "The browser should have a popup to show when a form is invalid"
+ );
+ await BrowserTestUtils.withNewTab(
+ "https://example.com/nothere",
+ async function checkTab(browser) {
+ let popupShown = BrowserTestUtils.waitForPopupEvent(
+ gInvalidFormPopup,
+ "shown"
+ );
+ await SpecialPowers.spawn(browser, [], () => {
+ let doc = content.document;
+ let input = doc.createElement("input");
+ doc.body.append(input);
+ input.setCustomValidity("This message should be hidden.");
+ content.eval(`document.querySelector('input').reportValidity();`);
+ });
+ let popupHidden = BrowserTestUtils.waitForPopupEvent(
+ gInvalidFormPopup,
+ "hidden"
+ );
+ await popupShown;
+ let notificationPopup = document.getElementById("notification-popup");
+ let notificationShown = BrowserTestUtils.waitForPopupEvent(
+ notificationPopup,
+ "shown"
+ );
+ let notificationHidden = BrowserTestUtils.waitForPopupEvent(
+ notificationPopup,
+ "hidden"
+ );
+ await SpecialPowers.spawn(browser, [], () => {
+ content.navigator.geolocation.getCurrentPosition(function () {});
+ });
+ await notificationShown;
+ // Should already be hidden at this point.
+ is(
+ gInvalidFormPopup.state,
+ "closed",
+ "Form validation popup should have closed"
+ );
+ // Close just in case.
+ if (gInvalidFormPopup.state != "closed") {
+ gInvalidFormPopup.hidePopup();
+ }
+ await popupHidden;
+ notificationPopup.hidePopup();
+ await notificationHidden;
+ }
+ );
+});
+
+add_task(async function test_dont_open_while_other_popup_open() {
+ ok(
+ gInvalidFormPopup,
+ "The browser should have a popup to show when a form is invalid"
+ );
+ await BrowserTestUtils.withNewTab(
+ "https://example.org/nothere",
+ async function checkTab(browser) {
+ let notificationPopup = document.getElementById("notification-popup");
+ let notificationShown = BrowserTestUtils.waitForPopupEvent(
+ notificationPopup,
+ "shown"
+ );
+ await SpecialPowers.spawn(browser, [], () => {
+ content.navigator.geolocation.getCurrentPosition(function () {});
+ });
+ await notificationShown;
+ let popupShown = BrowserTestUtils.waitForPopupEvent(
+ gInvalidFormPopup,
+ "shown"
+ );
+ is(
+ gInvalidFormPopup.state,
+ "closed",
+ "Form validation popup should be closed."
+ );
+ await SpecialPowers.spawn(browser, [], () => {
+ let doc = content.document;
+ let input = doc.createElement("input");
+ doc.body.append(input);
+ input.setCustomValidity("This message should be hidden.");
+ content.eval(`document.querySelector('input').reportValidity();`);
+ });
+ is(
+ gInvalidFormPopup.state,
+ "closed",
+ "Form validation popup should still be closed."
+ );
+ let notificationHidden = BrowserTestUtils.waitForPopupEvent(
+ notificationPopup,
+ "hidden"
+ );
+ notificationPopup
+ .querySelector(".popup-notification-secondary-button")
+ .click();
+ await notificationHidden;
+ await SpecialPowers.spawn(browser, [], () => {
+ content.eval(`document.querySelector('input').reportValidity();`);
+ });
+ await popupShown;
+ let popupHidden = BrowserTestUtils.waitForPopupEvent(
+ gInvalidFormPopup,
+ "hidden"
+ );
+ gInvalidFormPopup.hidePopup();
+ await popupHidden;
+ }
+ );
+});
diff --git a/browser/modules/test/browser/head.js b/browser/modules/test/browser/head.js
new file mode 100644
index 0000000000..f852cdd641
--- /dev/null
+++ b/browser/modules/test/browser/head.js
@@ -0,0 +1,331 @@
+ChromeUtils.defineESModuleGetters(this, {
+ PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+});
+
+const SINGLE_TRY_TIMEOUT = 100;
+const NUMBER_OF_TRIES = 30;
+
+function waitForConditionPromise(
+ condition,
+ timeoutMsg,
+ tryCount = NUMBER_OF_TRIES
+) {
+ return new Promise((resolve, reject) => {
+ let tries = 0;
+ function checkCondition() {
+ if (tries >= tryCount) {
+ reject(timeoutMsg);
+ }
+ var conditionPassed;
+ try {
+ conditionPassed = condition();
+ } catch (e) {
+ return reject(e);
+ }
+ if (conditionPassed) {
+ return resolve();
+ }
+ tries++;
+ setTimeout(checkCondition, SINGLE_TRY_TIMEOUT);
+ return undefined;
+ }
+ setTimeout(checkCondition, SINGLE_TRY_TIMEOUT);
+ });
+}
+
+function waitForCondition(condition, nextTest, errorMsg) {
+ waitForConditionPromise(condition, errorMsg).then(nextTest, reason => {
+ ok(false, reason + (reason.stack ? "\n" + reason.stack : ""));
+ });
+}
+
+/**
+ * An utility function to write some text in the search input box
+ * in a content page.
+ * @param {Object} browser
+ * The browser that contains the content.
+ * @param {String} text
+ * The string to write in the search field.
+ * @param {String} fieldName
+ * The name of the field to write to.
+ */
+let typeInSearchField = async function (browser, text, fieldName) {
+ await SpecialPowers.spawn(
+ browser,
+ [[fieldName, text]],
+ async function ([contentFieldName, contentText]) {
+ // Put the focus on the search box.
+ let searchInput = content.document.getElementById(contentFieldName);
+ searchInput.focus();
+ searchInput.value = contentText;
+ }
+ );
+};
+
+/**
+ * Given a <xul:browser> at some non-internal web page,
+ * return something that resembles an nsIContentPermissionRequest,
+ * using the browsers currently loaded document to get a principal.
+ *
+ * @param browser (<xul:browser>)
+ * The browser that we'll create a nsIContentPermissionRequest
+ * for.
+ * @returns A nsIContentPermissionRequest-ish object.
+ */
+function makeMockPermissionRequest(browser) {
+ let type = {
+ options: Cc["@mozilla.org/array;1"].createInstance(Ci.nsIArray),
+ QueryInterface: ChromeUtils.generateQI(["nsIContentPermissionType"]),
+ };
+ let types = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
+ types.appendElement(type);
+ let principal = browser.contentPrincipal;
+ let result = {
+ types,
+ isHandlingUserInput: false,
+ principal,
+ topLevelPrincipal: principal,
+ requester: null,
+ _cancelled: false,
+ cancel() {
+ this._cancelled = true;
+ },
+ _allowed: false,
+ allow() {
+ this._allowed = true;
+ },
+ getDelegatePrincipal(aType) {
+ return principal;
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIContentPermissionRequest"]),
+ };
+
+ // In the e10s-case, nsIContentPermissionRequest will have
+ // element defined. window is defined otherwise.
+ if (browser.isRemoteBrowser) {
+ result.element = browser;
+ } else {
+ result.window = browser.contentWindow;
+ }
+
+ return result;
+}
+
+/**
+ * For an opened PopupNotification, clicks on the main action,
+ * and waits for the panel to fully close.
+ *
+ * @return {Promise}
+ * Resolves once the panel has fired the "popuphidden"
+ * event.
+ */
+function clickMainAction() {
+ let removePromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ let popupNotification = getPopupNotificationNode();
+ popupNotification.button.click();
+ return removePromise;
+}
+
+/**
+ * For an opened PopupNotification, clicks on the secondary action,
+ * and waits for the panel to fully close.
+ *
+ * @param actionIndex (Number)
+ * The index of the secondary action to be clicked. The default
+ * secondary action (the button shown directly in the panel) is
+ * treated as having index 0.
+ *
+ * @return {Promise}
+ * Resolves once the panel has fired the "popuphidden"
+ * event.
+ */
+function clickSecondaryAction(actionIndex) {
+ let removePromise = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ let popupNotification = getPopupNotificationNode();
+ if (!actionIndex) {
+ popupNotification.secondaryButton.click();
+ return removePromise;
+ }
+
+ return (async function () {
+ // Click the dropmarker arrow and wait for the menu to show up.
+ let dropdownPromise = BrowserTestUtils.waitForEvent(
+ popupNotification.menupopup,
+ "popupshown"
+ );
+ await EventUtils.synthesizeMouseAtCenter(popupNotification.menubutton, {});
+ await dropdownPromise;
+
+ // The menuitems in the dropdown are accessible as direct children of the panel,
+ // because they are injected into a <children> node in the XBL binding.
+ // The target action is the menuitem at index actionIndex - 1, because the first
+ // secondary action (index 0) is the button shown directly in the panel.
+ let actionMenuItem =
+ popupNotification.querySelectorAll("menuitem")[actionIndex - 1];
+ await EventUtils.synthesizeMouseAtCenter(actionMenuItem, {});
+ await removePromise;
+ })();
+}
+
+/**
+ * Makes sure that 1 (and only 1) <xul:popupnotification> is being displayed
+ * by PopupNotification, and then returns that <xul:popupnotification>.
+ *
+ * @return {<xul:popupnotification>}
+ */
+function getPopupNotificationNode() {
+ // PopupNotification is a bit overloaded here, so to be
+ // clear, popupNotifications is a list of <xul:popupnotification>
+ // nodes.
+ let popupNotifications = PopupNotifications.panel.childNodes;
+ Assert.equal(
+ popupNotifications.length,
+ 1,
+ "Should be showing a <xul:popupnotification>"
+ );
+ return popupNotifications[0];
+}
+
+/**
+ * Disable non-release page actions (that are tested elsewhere).
+ *
+ * @return void
+ */
+async function disableNonReleaseActions() {
+ if (!["release", "esr"].includes(AppConstants.MOZ_UPDATE_CHANNEL)) {
+ SpecialPowers.Services.prefs.setBoolPref(
+ "extensions.webcompat-reporter.enabled",
+ false
+ );
+ }
+}
+
+function assertActivatedPageActionPanelHidden() {
+ Assert.ok(
+ !document.getElementById(BrowserPageActions._activatedActionPanelID)
+ );
+}
+
+function promiseOpenPageActionPanel() {
+ let dwu = window.windowUtils;
+ return TestUtils.waitForCondition(() => {
+ // Wait for the main page action button to become visible. It's hidden for
+ // some URIs, so depending on when this is called, it may not yet be quite
+ // visible. It's up to the caller to make sure it will be visible.
+ info("Waiting for main page action button to have non-0 size");
+ let bounds = dwu.getBoundsWithoutFlushing(
+ BrowserPageActions.mainButtonNode
+ );
+ return bounds.width > 0 && bounds.height > 0;
+ })
+ .then(() => {
+ // Wait for the panel to become open, by clicking the button if necessary.
+ info("Waiting for main page action panel to be open");
+ if (BrowserPageActions.panelNode.state == "open") {
+ return Promise.resolve();
+ }
+ let shownPromise = promisePageActionPanelShown();
+ EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {});
+ return shownPromise;
+ })
+ .then(() => {
+ // Wait for items in the panel to become visible.
+ return promisePageActionViewChildrenVisible(
+ BrowserPageActions.mainViewNode
+ );
+ });
+}
+
+function promisePageActionPanelShown() {
+ return promisePanelShown(BrowserPageActions.panelNode);
+}
+
+function promisePageActionPanelHidden() {
+ return promisePanelHidden(BrowserPageActions.panelNode);
+}
+
+function promisePanelShown(panelIDOrNode) {
+ return promisePanelEvent(panelIDOrNode, "popupshown");
+}
+
+function promisePanelHidden(panelIDOrNode) {
+ return promisePanelEvent(panelIDOrNode, "popuphidden");
+}
+
+function promisePanelEvent(panelIDOrNode, eventType) {
+ return new Promise(resolve => {
+ let panel = panelIDOrNode;
+ if (typeof panel == "string") {
+ panel = document.getElementById(panelIDOrNode);
+ if (!panel) {
+ throw new Error(`Panel with ID "${panelIDOrNode}" does not exist.`);
+ }
+ }
+ if (
+ (eventType == "popupshown" && panel.state == "open") ||
+ (eventType == "popuphidden" && panel.state == "closed")
+ ) {
+ executeSoon(resolve);
+ return;
+ }
+ panel.addEventListener(
+ eventType,
+ () => {
+ executeSoon(resolve);
+ },
+ { once: true }
+ );
+ });
+}
+
+function promisePageActionViewShown() {
+ info("promisePageActionViewShown waiting for ViewShown");
+ return BrowserTestUtils.waitForEvent(
+ BrowserPageActions.panelNode,
+ "ViewShown"
+ ).then(async event => {
+ let panelViewNode = event.originalTarget;
+ await promisePageActionViewChildrenVisible(panelViewNode);
+ return panelViewNode;
+ });
+}
+
+async function promisePageActionViewChildrenVisible(panelViewNode) {
+ info(
+ "promisePageActionViewChildrenVisible waiting for a child node to be visible"
+ );
+ await new Promise(requestAnimationFrame);
+ let dwu = window.windowUtils;
+ return TestUtils.waitForCondition(() => {
+ let bodyNode = panelViewNode.firstElementChild;
+ for (let childNode of bodyNode.children) {
+ let bounds = dwu.getBoundsWithoutFlushing(childNode);
+ if (bounds.width > 0 && bounds.height > 0) {
+ return true;
+ }
+ }
+ return false;
+ });
+}
+
+async function initPageActionsTest() {
+ await disableNonReleaseActions();
+
+ // Ensure screenshots is really disabled (bug 1498738)
+ const addon = await AddonManager.getAddonByID("screenshots@mozilla.org");
+ await addon.disable({ allowSystemAddons: true });
+
+ // Make the main button visible. It's not unless the window is narrow. This
+ // test isn't concerned with that behavior. We have other tests for that.
+ BrowserPageActions.mainButtonNode.style.visibility = "visible";
+ registerCleanupFunction(() => {
+ BrowserPageActions.mainButtonNode.style.removeProperty("visibility");
+ });
+}
diff --git a/browser/modules/test/browser/search-engines/basic/manifest.json b/browser/modules/test/browser/search-engines/basic/manifest.json
new file mode 100644
index 0000000000..96b29935cf
--- /dev/null
+++ b/browser/modules/test/browser/search-engines/basic/manifest.json
@@ -0,0 +1,19 @@
+{
+ "name": "basic",
+ "manifest_version": 2,
+ "version": "1.0",
+ "description": "basic",
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "basic@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "basic",
+ "search_url": "https://mochi.test:8888/browser/browser/components/search/test/browser/?search={searchTerms}&foo=1",
+ "suggest_url": "https://mochi.test:8888/browser/browser/modules/test/browser/usageTelemetrySearchSuggestions.sjs?{searchTerms}"
+ }
+ }
+}
diff --git a/browser/modules/test/browser/search-engines/engines.json b/browser/modules/test/browser/search-engines/engines.json
new file mode 100644
index 0000000000..0311630bba
--- /dev/null
+++ b/browser/modules/test/browser/search-engines/engines.json
@@ -0,0 +1,28 @@
+{
+ "data": [
+ {
+ "webExtension": {
+ "id": "basic@search.mozilla.org"
+ },
+ "telemetryId": "telemetry",
+ "appliesTo": [
+ {
+ "included": { "everywhere": true },
+ "default": "yes",
+ "sendAttributionRequest": true
+ }
+ ]
+ },
+ {
+ "webExtension": {
+ "id": "simple@search.mozilla.org"
+ },
+ "appliesTo": [
+ {
+ "included": { "everywhere": true },
+ "default": "yes"
+ }
+ ]
+ }
+ ]
+}
diff --git a/browser/modules/test/browser/search-engines/simple/manifest.json b/browser/modules/test/browser/search-engines/simple/manifest.json
new file mode 100644
index 0000000000..67d2974753
--- /dev/null
+++ b/browser/modules/test/browser/search-engines/simple/manifest.json
@@ -0,0 +1,29 @@
+{
+ "name": "Simple Engine",
+ "manifest_version": 2,
+ "version": "1.0",
+ "description": "Simple engine with a different name from the WebExtension id prefix",
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "simple@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Simple Engine",
+ "search_url": "https://example.com",
+ "params": [
+ {
+ "name": "sourceId",
+ "value": "Mozilla-search"
+ },
+ {
+ "name": "search",
+ "value": "{searchTerms}"
+ }
+ ],
+ "suggest_url": "https://example.com?search={searchTerms}"
+ }
+ }
+}
diff --git a/browser/modules/test/browser/testEngine_chromeicon.xml b/browser/modules/test/browser/testEngine_chromeicon.xml
new file mode 100644
index 0000000000..3ce80bcaea
--- /dev/null
+++ b/browser/modules/test/browser/testEngine_chromeicon.xml
@@ -0,0 +1,12 @@
+<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/"
+ xmlns:moz="http://www.mozilla.org/2006/browser/search/">
+ <ShortName>FooChromeIcon</ShortName>
+ <Description>Foo Chrome Icon Search</Description>
+ <InputEncoding>utf-8</InputEncoding>
+ <Image width="16" height="16">chrome://browser/skin/info.svg</Image>
+ <Url type="text/html" method="GET" template="http://mochi.test:8888/browser/browser/components/search/test/browser/?search">
+ <Param name="test" value="{searchTerms}"/>
+ </Url>
+ <moz:SearchForm>http://mochi.test:8888/browser/browser/components/search/test/browser/</moz:SearchForm>
+ <moz:Alias>foochromeiconalias</moz:Alias>
+</OpenSearchDescription>
diff --git a/browser/modules/test/unit/test_E10SUtils_nested_URIs.js b/browser/modules/test/unit/test_E10SUtils_nested_URIs.js
new file mode 100644
index 0000000000..5ebcac114f
--- /dev/null
+++ b/browser/modules/test/unit/test_E10SUtils_nested_URIs.js
@@ -0,0 +1,90 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+
+const { E10SUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/E10SUtils.sys.mjs"
+);
+
+var TEST_PREFERRED_REMOTE_TYPES = [
+ E10SUtils.WEB_REMOTE_TYPE,
+ E10SUtils.NOT_REMOTE,
+ "fakeRemoteType",
+];
+
+// These test cases give a nestedURL and a plainURL that should always load in
+// the same remote type. By making these tests comparisons, they should work
+// with any pref combination.
+var TEST_CASES = [
+ {
+ nestedURL: "jar:file:///some.file!/",
+ plainURL: "file:///some.file",
+ },
+ {
+ nestedURL: "jar:jar:file:///some.file!/!/",
+ plainURL: "file:///some.file",
+ },
+ {
+ nestedURL: "jar:http://some.site/file!/",
+ plainURL: "http://some.site/file",
+ },
+ {
+ nestedURL: "view-source:http://some.site",
+ plainURL: "http://some.site",
+ },
+ {
+ nestedURL: "view-source:file:///some.file",
+ plainURL: "file:///some.file",
+ },
+ {
+ nestedURL: "view-source:about:home",
+ plainURL: "about:home",
+ },
+ {
+ nestedURL: "view-source:about:robots",
+ plainURL: "about:robots",
+ },
+ {
+ nestedURL: "view-source:pcast:http://some.site",
+ plainURL: "http://some.site",
+ },
+];
+
+function run_test() {
+ for (let testCase of TEST_CASES) {
+ for (let preferredRemoteType of TEST_PREFERRED_REMOTE_TYPES) {
+ let plainUri = Services.io.newURI(testCase.plainURL);
+ let plainRemoteType = E10SUtils.getRemoteTypeForURIObject(plainUri, {
+ multiProcess: true,
+ remoteSubFrames: false,
+ preferredRemoteType,
+ });
+
+ let nestedUri = Services.io.newURI(testCase.nestedURL);
+ let nestedRemoteType = E10SUtils.getRemoteTypeForURIObject(nestedUri, {
+ multiProcess: true,
+ remoteSubFrames: false,
+ preferredRemoteType,
+ });
+
+ let nestedStr = nestedUri.scheme + ":";
+ do {
+ nestedUri = nestedUri.QueryInterface(Ci.nsINestedURI).innerURI;
+ if (nestedUri.scheme == "about") {
+ nestedStr += nestedUri.spec;
+ break;
+ }
+
+ nestedStr += nestedUri.scheme + ":";
+ } while (nestedUri instanceof Ci.nsINestedURI);
+
+ let plainStr =
+ plainUri.scheme == "about" ? plainUri.spec : plainUri.scheme + ":";
+ equal(
+ nestedRemoteType,
+ plainRemoteType,
+ `Check that ${nestedStr} loads in same remote type as ${plainStr}` +
+ ` with preferred remote type: ${preferredRemoteType}`
+ );
+ }
+ }
+}
diff --git a/browser/modules/test/unit/test_HomePage.js b/browser/modules/test/unit/test_HomePage.js
new file mode 100644
index 0000000000..0fb1030f1d
--- /dev/null
+++ b/browser/modules/test/unit/test_HomePage.js
@@ -0,0 +1,92 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ sinon: "resource://testing-common/Sinon.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ HomePage: "resource:///modules/HomePage.jsm",
+});
+
+const HOMEPAGE_IGNORELIST = "homepage-urls";
+
+/**
+ * Provides a basic set of remote settings for use in tests.
+ */
+async function setupRemoteSettings() {
+ const settings = await RemoteSettings("hijack-blocklists");
+ sinon.stub(settings, "get").returns([
+ {
+ id: HOMEPAGE_IGNORELIST,
+ matches: ["ignore=me"],
+ _status: "synced",
+ },
+ ]);
+}
+
+add_task(async function setup() {
+ await setupRemoteSettings();
+});
+
+add_task(function test_HomePage() {
+ Assert.ok(
+ !HomePage.overridden,
+ "Homepage should not be overriden by default."
+ );
+ let newvalue = "about:blank|about:newtab";
+ HomePage.safeSet(newvalue);
+ Assert.ok(HomePage.overridden, "Homepage should be overriden after set()");
+ Assert.equal(HomePage.get(), newvalue, "Homepage should be ${newvalue}");
+ Assert.notEqual(
+ HomePage.getDefault(),
+ newvalue,
+ "Homepage should be ${newvalue}"
+ );
+ HomePage.reset();
+ Assert.ok(
+ !HomePage.overridden,
+ "Homepage should not be overriden by after reset."
+ );
+ Assert.equal(
+ HomePage.get(),
+ HomePage.getDefault(),
+ "Homepage and default should be equal after reset."
+ );
+});
+
+add_task(function test_readLocalizedHomepage() {
+ let newvalue = "data:text/plain,browser.startup.homepage%3Dabout%3Alocalized";
+ let complexvalue = Cc["@mozilla.org/pref-localizedstring;1"].createInstance(
+ Ci.nsIPrefLocalizedString
+ );
+ complexvalue.data = newvalue;
+ Services.prefs
+ .getDefaultBranch(null)
+ .setComplexValue(
+ "browser.startup.homepage",
+ Ci.nsIPrefLocalizedString,
+ complexvalue
+ );
+ Assert.ok(!HomePage.overridden, "Complex value only works as default");
+ Assert.equal(HomePage.get(), "about:localized", "Get value from bundle");
+});
+
+add_task(function test_recoverEmptyHomepage() {
+ Assert.ok(
+ !HomePage.overridden,
+ "Homepage should not be overriden by default."
+ );
+ Services.prefs.setStringPref("browser.startup.homepage", "");
+ Assert.ok(HomePage.overridden, "Homepage is overriden with empty string.");
+ Assert.equal(HomePage.get(), HomePage.getDefault(), "Recover is default");
+ Assert.ok(!HomePage.overridden, "Recover should have set default");
+});
diff --git a/browser/modules/test/unit/test_HomePage_ignore.js b/browser/modules/test/unit/test_HomePage_ignore.js
new file mode 100644
index 0000000000..26d384bea7
--- /dev/null
+++ b/browser/modules/test/unit/test_HomePage_ignore.js
@@ -0,0 +1,135 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+ sinon: "resource://testing-common/Sinon.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ HomePage: "resource:///modules/HomePage.jsm",
+});
+
+const HOMEPAGE_IGNORELIST = "homepage-urls";
+
+/**
+ * Provides a basic set of remote settings for use in tests.
+ */
+async function setupRemoteSettings() {
+ const settings = await RemoteSettings("hijack-blocklists");
+ sinon.stub(settings, "get").returns([
+ {
+ id: HOMEPAGE_IGNORELIST,
+ matches: ["ignore=me", "ignoreCASE=ME"],
+ _status: "synced",
+ },
+ ]);
+}
+
+add_task(async function setup() {
+ await setupRemoteSettings();
+});
+
+add_task(async function test_initWithIgnoredPageCausesReset() {
+ // Set the preference direct as the set() would block us.
+ Services.prefs.setStringPref(
+ "browser.startup.homepage",
+ "http://bad/?ignore=me"
+ );
+ Assert.ok(HomePage.overridden, "Should have overriden the homepage");
+
+ await HomePage.delayedStartup();
+
+ Assert.ok(
+ !HomePage.overridden,
+ "Should no longer be overriding the homepage."
+ );
+ Assert.equal(
+ HomePage.get(),
+ HomePage.getDefault(),
+ "Should have reset to the default preference"
+ );
+
+ TelemetryTestUtils.assertEvents(
+ [{ object: "ignore", value: "saved_reset" }],
+ {
+ category: "homepage",
+ method: "preference",
+ }
+ );
+});
+
+add_task(async function test_updateIgnoreListCausesReset() {
+ Services.prefs.setStringPref(
+ "browser.startup.homepage",
+ "http://bad/?new=ignore"
+ );
+ Assert.ok(HomePage.overridden, "Should have overriden the homepage");
+
+ // Simulate an ignore list update.
+ await RemoteSettings("hijack-blocklists").emit("sync", {
+ data: {
+ current: [
+ {
+ id: HOMEPAGE_IGNORELIST,
+ schema: 1553857697843,
+ last_modified: 1553859483588,
+ matches: ["ignore=me", "ignoreCASE=ME", "new=ignore"],
+ },
+ ],
+ },
+ });
+
+ Assert.ok(
+ !HomePage.overridden,
+ "Should no longer be overriding the homepage."
+ );
+ Assert.equal(
+ HomePage.get(),
+ HomePage.getDefault(),
+ "Should have reset to the default preference"
+ );
+ TelemetryTestUtils.assertEvents(
+ [{ object: "ignore", value: "saved_reset" }],
+ {
+ category: "homepage",
+ method: "preference",
+ }
+ );
+});
+
+async function testSetIgnoredUrl(url) {
+ Assert.ok(!HomePage.overriden, "Should not be overriding the homepage");
+
+ await HomePage.set(url);
+
+ Assert.equal(
+ HomePage.get(),
+ HomePage.getDefault(),
+ "Should still have the default homepage."
+ );
+ Assert.ok(!HomePage.overriden, "Should not be overriding the homepage.");
+ TelemetryTestUtils.assertEvents(
+ [{ object: "ignore", value: "set_blocked" }],
+ {
+ category: "homepage",
+ method: "preference",
+ }
+ );
+}
+
+add_task(async function test_setIgnoredUrl() {
+ await testSetIgnoredUrl("http://bad/?ignore=me");
+});
+
+add_task(async function test_setIgnoredUrl_case() {
+ await testSetIgnoredUrl("http://bad/?Ignorecase=me");
+});
diff --git a/browser/modules/test/unit/test_InstallationTelemetry.js b/browser/modules/test/unit/test_InstallationTelemetry.js
new file mode 100644
index 0000000000..64d9964cbe
--- /dev/null
+++ b/browser/modules/test/unit/test_InstallationTelemetry.js
@@ -0,0 +1,284 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const { AttributionIOUtils } = ChromeUtils.importESModule(
+ "resource:///modules/AttributionCode.sys.mjs"
+);
+const { BrowserUsageTelemetry } = ChromeUtils.import(
+ "resource:///modules/BrowserUsageTelemetry.jsm"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+});
+
+const TIMESTAMP_PREF = "app.installation.timestamp";
+
+function encodeUtf16(str) {
+ const buf = new ArrayBuffer(str.length * 2);
+ const utf16 = new Uint16Array(buf);
+ for (let i = 0; i < str.length; i++) {
+ utf16[i] = str.charCodeAt(i);
+ }
+ return new Uint8Array(buf);
+}
+
+// Returns Promise
+function writeJsonUtf16(fileName, obj) {
+ const str = JSON.stringify(obj);
+ return IOUtils.write(fileName, encodeUtf16(str));
+}
+
+async function runReport(
+ dataFile,
+ installType,
+ { clearTS, setTS, assertRejects, expectExtra, expectTS, msixPrefixes }
+) {
+ // Setup timestamp
+ if (clearTS) {
+ Services.prefs.clearUserPref(TIMESTAMP_PREF);
+ }
+ if (typeof setTS == "string") {
+ Services.prefs.setStringPref(TIMESTAMP_PREF, setTS);
+ }
+
+ // Init events
+ Services.telemetry.clearEvents();
+
+ // Exercise reportInstallationTelemetry
+ if (typeof assertRejects != "undefined") {
+ await Assert.rejects(
+ BrowserUsageTelemetry.reportInstallationTelemetry(dataFile),
+ assertRejects
+ );
+ } else if (!msixPrefixes) {
+ await BrowserUsageTelemetry.reportInstallationTelemetry(dataFile);
+ } else {
+ await BrowserUsageTelemetry.reportInstallationTelemetry(
+ dataFile,
+ msixPrefixes
+ );
+ }
+
+ // Check events
+ TelemetryTestUtils.assertEvents(
+ expectExtra
+ ? [{ object: installType, value: null, extra: expectExtra }]
+ : [],
+ { category: "installation", method: "first_seen" },
+ { clear: false }
+ );
+ // Provenance Data is currently only supported on Windows.
+ if (AppConstants.platform == "win") {
+ let provenanceExtra = {
+ data_exists: "true",
+ file_system: "NTFS",
+ ads_exists: "true",
+ security_zone: "3",
+ refer_url_exist: "true",
+ refer_url_moz: "true",
+ host_url_exist: "true",
+ host_url_moz: "true",
+ };
+ TelemetryTestUtils.assertEvents(
+ expectExtra
+ ? [{ object: installType, value: null, extra: provenanceExtra }]
+ : [],
+ { category: "installation", method: "first_seen_prov_ext" }
+ );
+ } else {
+ TelemetryTestUtils.assertEvents(
+ expectExtra ? [{ object: installType, value: null, extra: {} }] : [],
+ { category: "installation", method: "first_seen_prov_ext" }
+ );
+ }
+
+ // Check timestamp
+ if (typeof expectTS == "string") {
+ Assert.equal(expectTS, Services.prefs.getStringPref(TIMESTAMP_PREF));
+ }
+}
+
+add_setup(function setup() {
+ let origReadUTF8 = AttributionIOUtils.readUTF8;
+ registerCleanupFunction(() => {
+ AttributionIOUtils.readUTF8 = origReadUTF8;
+ });
+ AttributionIOUtils.readUTF8 = async path => {
+ return `
+[Mozilla]
+fileSystem=NTFS
+zoneIdFileSize=194
+zoneIdBufferLargeEnough=true
+zoneIdTruncated=false
+
+[MozillaZoneIdentifierStartSentinel]
+[ZoneTransfer]
+ZoneId=3
+ReferrerUrl=https://mozilla.org/
+HostUrl=https://download-installer.cdn.mozilla.net/pub/firefox/nightly/latest-mozilla-central-l10n/Firefox%20Installer.en-US.exe
+`;
+ };
+});
+
+let condition = {
+ skip_if: () =>
+ AppConstants.platform !== "win" ||
+ !Services.sysinfo.getProperty("hasWinPackageId"),
+};
+add_task(condition, async function testInstallationTelemetryMSIX() {
+ // Unfortunately, we have no way to inject different installation ping data
+ // into the system in a way that doesn't just completely override the code
+ // under test - so other than a basic test of the happy path, there's
+ // nothing we can do here.
+ let msixExtra = {
+ version: AppConstants.MOZ_APP_VERSION,
+ build_id: AppConstants.MOZ_BULIDID,
+ admin_user: "false",
+ from_msi: "false",
+ silent: "false",
+ default_path: "true",
+ install_existed: "false",
+ other_inst: "false",
+ other_msix_inst: "false",
+ profdir_existed: "false",
+ };
+
+ await runReport("fake", "msix", {
+ expectExtra: msixExtra,
+ });
+});
+condition = {
+ skip_if: () =>
+ AppConstants.platform === "win" &&
+ Services.sysinfo.getProperty("hasWinPackageId"),
+};
+add_task(condition, async function testInstallationTelemetry() {
+ let dataFilePath = await IOUtils.createUniqueFile(
+ Services.dirsvc.get("TmpD", Ci.nsIFile).path,
+ "installation-telemetry-test-data" + Math.random() + ".json"
+ );
+ let dataFile = new FileUtils.File(dataFilePath);
+
+ registerCleanupFunction(async () => {
+ try {
+ await IOUtils.remove(dataFilePath);
+ } catch (ex) {
+ // Ignore remove failure, file may not exist by now
+ }
+
+ Services.prefs.clearUserPref(TIMESTAMP_PREF);
+ });
+
+ // Test with normal stub data
+ let stubData = {
+ version: "99.0abc",
+ build_id: "123",
+ installer_type: "stub",
+ admin_user: true,
+ install_existed: false,
+ profdir_existed: false,
+ install_timestamp: "0",
+ };
+ let stubExtra = {
+ version: "99.0abc",
+ build_id: "123",
+ admin_user: "true",
+ install_existed: "false",
+ other_inst: "false",
+ other_msix_inst: "false",
+ profdir_existed: "false",
+ };
+
+ await writeJsonUtf16(dataFilePath, stubData);
+ await runReport(dataFile, "stub", {
+ clearTS: true,
+ expectExtra: stubExtra,
+ expectTS: "0",
+ });
+
+ // Check that it doesn't generate another event when the timestamp is unchanged
+ await runReport(dataFile, "stub", { expectTS: "0" });
+
+ // New timestamp
+ stubData.install_timestamp = "1";
+ await writeJsonUtf16(dataFilePath, stubData);
+ await runReport(dataFile, "stub", {
+ expectExtra: stubExtra,
+ expectTS: "1",
+ });
+
+ // Test with normal full data
+ let fullData = {
+ version: "99.0abc",
+ build_id: "123",
+ installer_type: "full",
+ admin_user: false,
+ install_existed: true,
+ profdir_existed: true,
+ silent: false,
+ from_msi: false,
+ default_path: true,
+
+ install_timestamp: "1",
+ };
+ let fullExtra = {
+ version: "99.0abc",
+ build_id: "123",
+ admin_user: "false",
+ install_existed: "true",
+ other_inst: "false",
+ other_msix_inst: "false",
+ profdir_existed: "true",
+ silent: "false",
+ from_msi: "false",
+ default_path: "true",
+ };
+
+ await writeJsonUtf16(dataFilePath, fullData);
+ await runReport(dataFile, "full", {
+ clearTS: true,
+ expectExtra: fullExtra,
+ expectTS: "1",
+ });
+
+ // Check that it doesn't generate another event when the timestamp is unchanged
+ await runReport(dataFile, "full", { expectTS: "1" });
+
+ // New timestamp and a check to make sure we can find installed MSIX packages
+ // by overriding the prefixes a bit further down.
+ fullData.install_timestamp = "2";
+ // This check only works on Windows 10 and above
+ if (AppConstants.isPlatformAndVersionAtLeast("win", "10")) {
+ fullExtra.other_msix_inst = "true";
+ }
+ await writeJsonUtf16(dataFilePath, fullData);
+ await runReport(dataFile, "full", {
+ expectExtra: fullExtra,
+ expectTS: "2",
+ msixPrefixes: ["Microsoft"],
+ });
+
+ // Missing field
+ delete fullData.install_existed;
+ fullData.install_timestamp = "3";
+ await writeJsonUtf16(dataFilePath, fullData);
+ await runReport(dataFile, "full", { assertRejects: /install_existed/ });
+
+ // Malformed JSON
+ await IOUtils.write(dataFilePath, encodeUtf16("hello"));
+ await runReport(dataFile, "stub", {
+ assertRejects: /unexpected character/,
+ });
+
+ // Missing file, should return with no exception
+ await IOUtils.remove(dataFilePath);
+ await runReport(dataFile, "stub", { setTS: "3", expectTS: "3" });
+});
diff --git a/browser/modules/test/unit/test_LaterRun.js b/browser/modules/test/unit/test_LaterRun.js
new file mode 100644
index 0000000000..d78fde2414
--- /dev/null
+++ b/browser/modules/test/unit/test_LaterRun.js
@@ -0,0 +1,242 @@
+"use strict";
+
+const kEnabledPref = "browser.laterrun.enabled";
+const kPagePrefRoot = "browser.laterrun.pages.";
+const kSessionCountPref = "browser.laterrun.bookkeeping.sessionCount";
+const kProfileCreationTime = "browser.laterrun.bookkeeping.profileCreationTime";
+
+const { LaterRun } = ChromeUtils.import("resource:///modules/LaterRun.jsm");
+
+Services.prefs.setBoolPref(kEnabledPref, true);
+const { updateAppInfo } = ChromeUtils.importESModule(
+ "resource://testing-common/AppInfo.sys.mjs"
+);
+updateAppInfo();
+
+add_task(async function test_page_applies() {
+ Services.prefs.setCharPref(
+ kPagePrefRoot + "test_LaterRun_unittest.url",
+ "https://www.mozilla.org/%VENDOR%/%NAME%/%ID%/%VERSION%/"
+ );
+ Services.prefs.setIntPref(
+ kPagePrefRoot + "test_LaterRun_unittest.minimumHoursSinceInstall",
+ 10
+ );
+ Services.prefs.setIntPref(
+ kPagePrefRoot + "test_LaterRun_unittest.minimumSessionCount",
+ 3
+ );
+
+ let pages = LaterRun.readPages();
+ // We have to filter the pages because it's possible Firefox ships with other URLs
+ // that get included in this test.
+ pages = pages.filter(
+ page => page.pref == kPagePrefRoot + "test_LaterRun_unittest."
+ );
+ Assert.equal(pages.length, 1, "Got 1 page");
+ let page = pages[0];
+ Assert.equal(
+ page.pref,
+ kPagePrefRoot + "test_LaterRun_unittest.",
+ "Should know its own pref"
+ );
+ Assert.equal(
+ page.minimumHoursSinceInstall,
+ 10,
+ "Needs to have 10 hours since install"
+ );
+ Assert.equal(page.minimumSessionCount, 3, "Needs to have 3 sessions");
+ Assert.equal(page.requireBoth, false, "Either requirement is enough");
+ let expectedURL =
+ "https://www.mozilla.org/" +
+ Services.appinfo.vendor +
+ "/" +
+ Services.appinfo.name +
+ "/" +
+ Services.appinfo.ID +
+ "/" +
+ Services.appinfo.version +
+ "/";
+ Assert.equal(page.url, expectedURL, "URL is stored correctly");
+
+ Assert.ok(
+ page.applies({ hoursSinceInstall: 1, sessionCount: 3 }),
+ "Applies when session count has been met."
+ );
+ Assert.ok(
+ page.applies({ hoursSinceInstall: 1, sessionCount: 4 }),
+ "Applies when session count has been exceeded."
+ );
+ Assert.ok(
+ page.applies({ hoursSinceInstall: 10, sessionCount: 2 }),
+ "Applies when total session time has been met."
+ );
+ Assert.ok(
+ page.applies({ hoursSinceInstall: 20, sessionCount: 2 }),
+ "Applies when total session time has been exceeded."
+ );
+ Assert.ok(
+ page.applies({ hoursSinceInstall: 10, sessionCount: 3 }),
+ "Applies when both time and session count have been met."
+ );
+ Assert.ok(
+ !page.applies({ hoursSinceInstall: 1, sessionCount: 1 }),
+ "Does not apply when neither time and session count have been met."
+ );
+
+ page.requireBoth = true;
+
+ Assert.ok(
+ !page.applies({ hoursSinceInstall: 1, sessionCount: 3 }),
+ "Does not apply when only session count has been met."
+ );
+ Assert.ok(
+ !page.applies({ hoursSinceInstall: 1, sessionCount: 4 }),
+ "Does not apply when only session count has been exceeded."
+ );
+ Assert.ok(
+ !page.applies({ hoursSinceInstall: 10, sessionCount: 2 }),
+ "Does not apply when only total session time has been met."
+ );
+ Assert.ok(
+ !page.applies({ hoursSinceInstall: 20, sessionCount: 2 }),
+ "Does not apply when only total session time has been exceeded."
+ );
+ Assert.ok(
+ page.applies({ hoursSinceInstall: 10, sessionCount: 3 }),
+ "Applies when both time and session count have been met."
+ );
+ Assert.ok(
+ !page.applies({ hoursSinceInstall: 1, sessionCount: 1 }),
+ "Does not apply when neither time and session count have been met."
+ );
+
+ // Check that pages that have run never apply:
+ Services.prefs.setBoolPref(
+ kPagePrefRoot + "test_LaterRun_unittest.hasRun",
+ true
+ );
+ page.requireBoth = false;
+
+ Assert.ok(
+ !page.applies({ hoursSinceInstall: 1, sessionCount: 3 }),
+ "Does not apply when page has already run (sessionCount equal)."
+ );
+ Assert.ok(
+ !page.applies({ hoursSinceInstall: 1, sessionCount: 4 }),
+ "Does not apply when page has already run (sessionCount exceeding)."
+ );
+ Assert.ok(
+ !page.applies({ hoursSinceInstall: 10, sessionCount: 2 }),
+ "Does not apply when page has already run (hoursSinceInstall equal)."
+ );
+ Assert.ok(
+ !page.applies({ hoursSinceInstall: 20, sessionCount: 2 }),
+ "Does not apply when page has already run (hoursSinceInstall exceeding)."
+ );
+ Assert.ok(
+ !page.applies({ hoursSinceInstall: 10, sessionCount: 3 }),
+ "Does not apply when page has already run (both criteria equal)."
+ );
+ Assert.ok(
+ !page.applies({ hoursSinceInstall: 1, sessionCount: 1 }),
+ "Does not apply when page has already run (both criteria insufficient anyway)."
+ );
+
+ clearAllPagePrefs();
+});
+
+add_task(async function test_get_URL() {
+ Services.prefs.setIntPref(
+ kProfileCreationTime,
+ Math.floor((Date.now() - 11 * 60 * 60 * 1000) / 1000)
+ );
+ Services.prefs.setCharPref(
+ kPagePrefRoot + "test_LaterRun_unittest.url",
+ "https://www.mozilla.org/"
+ );
+ Services.prefs.setIntPref(
+ kPagePrefRoot + "test_LaterRun_unittest.minimumHoursSinceInstall",
+ 10
+ );
+ Services.prefs.setIntPref(
+ kPagePrefRoot + "test_LaterRun_unittest.minimumSessionCount",
+ 3
+ );
+ let pages = LaterRun.readPages();
+ // We have to filter the pages because it's possible Firefox ships with other URLs
+ // that get included in this test.
+ pages = pages.filter(
+ page => page.pref == kPagePrefRoot + "test_LaterRun_unittest."
+ );
+ Assert.equal(pages.length, 1, "Should only be 1 matching page");
+ let page = pages[0];
+ let url;
+ do {
+ url = LaterRun.getURL();
+ // We have to loop because it's possible Firefox ships with other URLs that get triggered by
+ // this test.
+ } while (url && url != "https://www.mozilla.org/");
+ Assert.equal(
+ url,
+ "https://www.mozilla.org/",
+ "URL should be as expected when prefs are set."
+ );
+ Assert.ok(
+ Services.prefs.prefHasUserValue(
+ kPagePrefRoot + "test_LaterRun_unittest.hasRun"
+ ),
+ "Should have set pref"
+ );
+ Assert.ok(
+ Services.prefs.getBoolPref(kPagePrefRoot + "test_LaterRun_unittest.hasRun"),
+ "Should have set pref to true"
+ );
+ Assert.ok(page.hasRun, "Other page objects should know it has run, too.");
+
+ clearAllPagePrefs();
+});
+
+add_task(async function test_insecure_urls() {
+ Services.prefs.setCharPref(
+ kPagePrefRoot + "test_LaterRun_unittest.url",
+ "http://www.mozilla.org/"
+ );
+ Services.prefs.setIntPref(
+ kPagePrefRoot + "test_LaterRun_unittest.minimumHoursSinceInstall",
+ 10
+ );
+ Services.prefs.setIntPref(
+ kPagePrefRoot + "test_LaterRun_unittest.minimumSessionCount",
+ 3
+ );
+ let pages = LaterRun.readPages();
+ // We have to filter the pages because it's possible Firefox ships with other URLs
+ // that get triggered in this test.
+ pages = pages.filter(
+ page => page.pref == kPagePrefRoot + "test_LaterRun_unittest."
+ );
+ Assert.equal(pages.length, 0, "URL with non-https scheme should get ignored");
+ clearAllPagePrefs();
+});
+
+add_task(async function test_dynamic_pref_getter_setter() {
+ delete LaterRun._sessionCount;
+ Services.prefs.setIntPref(kSessionCountPref, 0);
+ Assert.equal(LaterRun.sessionCount, 0, "Should start at 0");
+
+ LaterRun.sessionCount++;
+ Assert.equal(LaterRun.sessionCount, 1, "Should increment.");
+ Assert.equal(
+ Services.prefs.getIntPref(kSessionCountPref),
+ 1,
+ "Should update pref"
+ );
+});
+
+function clearAllPagePrefs() {
+ let allChangedPrefs = Services.prefs.getChildList(kPagePrefRoot);
+ for (let pref of allChangedPrefs) {
+ Services.prefs.clearUserPref(pref);
+ }
+}
diff --git a/browser/modules/test/unit/test_PartnerLinkAttribution.js b/browser/modules/test/unit/test_PartnerLinkAttribution.js
new file mode 100644
index 0000000000..79053fbb07
--- /dev/null
+++ b/browser/modules/test/unit/test_PartnerLinkAttribution.js
@@ -0,0 +1,54 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { PartnerLinkAttribution, CONTEXTUAL_SERVICES_PING_TYPES } =
+ ChromeUtils.importESModule(
+ "resource:///modules/PartnerLinkAttribution.sys.mjs"
+ );
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+const FAKE_PING = { tile_id: 1, position: 1 };
+
+let sandbox;
+let stub;
+
+add_task(function setup() {
+ sandbox = sinon.createSandbox();
+ stub = sandbox.stub(
+ PartnerLinkAttribution._pingCentre,
+ "sendStructuredIngestionPing"
+ );
+ stub.returns(200);
+
+ registerCleanupFunction(() => {
+ sandbox.restore();
+ });
+});
+
+add_task(function test_sendContextualService_success() {
+ for (const type of Object.values(CONTEXTUAL_SERVICES_PING_TYPES)) {
+ PartnerLinkAttribution.sendContextualServicesPing(FAKE_PING, type);
+
+ Assert.ok(stub.calledOnce, `Should send the ping for ${type}`);
+
+ const [payload, endpoint] = stub.firstCall.args;
+ Assert.ok(!!payload.context_id, "Should add context_id to the payload");
+ Assert.ok(
+ endpoint.includes(type),
+ "Should include the ping type in the endpoint URL"
+ );
+ stub.resetHistory();
+ }
+});
+
+add_task(function test_rejectUnknownPingType() {
+ PartnerLinkAttribution.sendContextualServicesPing(FAKE_PING, "unknown-type");
+
+ Assert.ok(stub.notCalled, "Should not send the ping with unknown ping type");
+});
diff --git a/browser/modules/test/unit/test_PingCentre.js b/browser/modules/test/unit/test_PingCentre.js
new file mode 100644
index 0000000000..b022030196
--- /dev/null
+++ b/browser/modules/test/unit/test_PingCentre.js
@@ -0,0 +1,194 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { PingCentre, PingCentreConstants } = ChromeUtils.import(
+ "resource:///modules/PingCentre.jsm"
+);
+const { TelemetryEnvironment } = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryEnvironment.sys.mjs"
+);
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const { UpdateUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/UpdateUtils.sys.mjs"
+);
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+const FAKE_PING = { event: "fake_event", value: "fake_value", locale: "en-US" };
+const FAKE_ENDPOINT = "https://www.test.com";
+
+let pingCentre;
+let sandbox;
+let fogInitd = false;
+
+function _setUp() {
+ Services.prefs.setBoolPref(PingCentreConstants.TELEMETRY_PREF, true);
+ Services.prefs.setBoolPref(PingCentreConstants.FHR_UPLOAD_ENABLED_PREF, true);
+ Services.prefs.setBoolPref(PingCentreConstants.LOGGING_PREF, true);
+ sandbox.restore();
+ if (fogInitd) {
+ Services.fog.testResetFOG();
+ }
+}
+
+add_setup(function setup() {
+ sandbox = sinon.createSandbox();
+ _setUp();
+ pingCentre = new PingCentre({ topic: "test_topic" });
+
+ registerCleanupFunction(() => {
+ sandbox.restore();
+ Services.prefs.clearUserPref(PingCentreConstants.TELEMETRY_PREF);
+ Services.prefs.clearUserPref(PingCentreConstants.FHR_UPLOAD_ENABLED_PREF);
+ Services.prefs.clearUserPref(PingCentreConstants.LOGGING_PREF);
+ });
+
+ // On Android, FOG is set up through head.js
+ if (AppConstants.platform != "android") {
+ do_get_profile();
+ Services.fog.initializeFOG();
+ fogInitd = true;
+ }
+});
+
+add_task(function test_enabled() {
+ _setUp();
+ Assert.ok(pingCentre.enabled, "Telemetry should be on");
+});
+
+add_task(function test_disabled_by_pingCentre() {
+ _setUp();
+ Services.prefs.setBoolPref(PingCentreConstants.TELEMETRY_PREF, false);
+
+ Assert.ok(!pingCentre.enabled, "Telemetry should be off");
+});
+
+add_task(function test_disabled_by_FirefoxHealthReport() {
+ _setUp();
+ Services.prefs.setBoolPref(
+ PingCentreConstants.FHR_UPLOAD_ENABLED_PREF,
+ false
+ );
+
+ Assert.ok(!pingCentre.enabled, "Telemetry should be off");
+});
+
+add_task(function test_logging() {
+ _setUp();
+ Assert.ok(pingCentre.logging, "Logging should be on");
+
+ Services.prefs.setBoolPref(PingCentreConstants.LOGGING_PREF, false);
+
+ Assert.ok(!pingCentre.logging, "Logging should be off");
+});
+
+add_task(function test_createExperimentsPayload() {
+ _setUp();
+ const activeExperiments = {
+ exp1: { branch: "foo", enrollmentID: "SOME_RANDON_ID" },
+ exp2: { branch: "bar", type: "PrefStudy" },
+ exp3: {},
+ };
+ sandbox
+ .stub(TelemetryEnvironment, "getActiveExperiments")
+ .returns(activeExperiments);
+ const expected = {
+ exp1: { branch: "foo" },
+ exp2: { branch: "bar" },
+ };
+
+ const experiments = pingCentre._createExperimentsPayload();
+
+ Assert.deepEqual(
+ experiments,
+ expected,
+ "Should create experiments with all the required context"
+ );
+});
+
+add_task(function test_createExperimentsPayload_without_active_experiments() {
+ _setUp();
+ sandbox.stub(TelemetryEnvironment, "getActiveExperiments").returns({});
+ const experiments = pingCentre._createExperimentsPayload({});
+
+ Assert.deepEqual(experiments, {}, "Should send an empty object");
+});
+
+add_task(function test_createStructuredIngestionPing() {
+ _setUp();
+ sandbox
+ .stub(TelemetryEnvironment, "getActiveExperiments")
+ .returns({ exp1: { branch: "foo" } });
+ const ping = pingCentre._createStructuredIngestionPing(FAKE_PING);
+ const expected = {
+ experiments: { exp1: { branch: "foo" } },
+ locale: "en-US",
+ version: AppConstants.MOZ_APP_VERSION,
+ release_channel: UpdateUtils.getUpdateChannel(false),
+ ...FAKE_PING,
+ };
+
+ Assert.deepEqual(ping, expected, "Should create a valid ping");
+});
+
+add_task(function test_sendStructuredIngestionPing_disabled() {
+ _setUp();
+ sandbox.stub(PingCentre, "_sendStandalonePing").resolves();
+ Services.prefs.setBoolPref(PingCentreConstants.TELEMETRY_PREF, false);
+ pingCentre.sendStructuredIngestionPing(FAKE_PING, FAKE_ENDPOINT);
+
+ Assert.ok(PingCentre._sendStandalonePing.notCalled, "Should not be sent");
+});
+
+add_task(async function test_sendStructuredIngestionPing_success() {
+ _setUp();
+ sandbox.stub(PingCentre, "_sendStandalonePing").resolves();
+ await pingCentre.sendStructuredIngestionPing(
+ FAKE_PING,
+ FAKE_ENDPOINT,
+ "messaging-system"
+ );
+
+ Assert.equal(PingCentre._sendStandalonePing.callCount, 1, "Should be sent");
+ Assert.equal(
+ 1,
+ Glean.pingCentre.sendSuccessesByNamespace.messaging_system.testGetValue()
+ );
+
+ // Test an unknown namespace
+ await pingCentre.sendStructuredIngestionPing(
+ FAKE_PING,
+ FAKE_ENDPOINT,
+ "different-system"
+ );
+
+ Assert.equal(PingCentre._sendStandalonePing.callCount, 2, "Should be sent");
+ Assert.equal(
+ 1,
+ Glean.pingCentre.sendSuccessesByNamespace.__other__.testGetValue()
+ );
+});
+
+add_task(async function test_sendStructuredIngestionPing_failure() {
+ _setUp();
+ sandbox.stub(PingCentre, "_sendStandalonePing").rejects();
+ Assert.equal(undefined, Glean.pingCentre.sendFailures.testGetValue());
+ await pingCentre.sendStructuredIngestionPing(
+ FAKE_PING,
+ FAKE_ENDPOINT,
+ "activity-stream"
+ );
+
+ Assert.equal(1, Glean.pingCentre.sendFailures.testGetValue());
+ Assert.equal(
+ 1,
+ Glean.pingCentre.sendFailuresByNamespace.activity_stream.testGetValue()
+ );
+});
diff --git a/browser/modules/test/unit/test_ProfileCounter.js b/browser/modules/test/unit/test_ProfileCounter.js
new file mode 100644
index 0000000000..e8b3179f34
--- /dev/null
+++ b/browser/modules/test/unit/test_ProfileCounter.js
@@ -0,0 +1,239 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { BrowserUsageTelemetry } = ChromeUtils.import(
+ "resource:///modules/BrowserUsageTelemetry.jsm"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const PROFILE_COUNT_SCALAR = "browser.engagement.profile_count";
+// Largest possible uint32_t value represents an error.
+const SCALAR_ERROR_VALUE = 0;
+
+const FILE_OPEN_OPERATION = "open";
+const ERROR_FILE_NOT_FOUND = "NotFoundError";
+const ERROR_ACCESS_DENIED = "NotAllowedError";
+
+// We will redirect I/O to/from the profile counter file to read/write this
+// variable instead. That makes it easier for us to:
+// - avoid interference from any pre-existing file
+// - read and change the values in the file.
+// - clean up changes made to the file
+// We will translate a null value stored here to a File Not Found error.
+var gFakeProfileCounterFile = null;
+// We will use this to check that the profile counter code doesn't try to write
+// to multiple files (since this test will malfunction in that case due to
+// gFakeProfileCounterFile only being setup to accommodate a single file).
+var gProfileCounterFilePath = null;
+
+// Storing a value here lets us test the behavior when we encounter an error
+// reading or writing to the file. A null value means that no error will
+// be simulated (other than possibly a NotFoundError).
+var gNextReadExceptionReason = null;
+var gNextWriteExceptionReason = null;
+
+// Nothing will actually be stored in this directory, so it's not important that
+// it be valid, but the leafname should be unique to this test in order to be
+// sure of preventing name conflicts with the pref:
+// `browser.engagement.profileCounted.${hash}`
+function getDummyUpdateDirectory() {
+ const testName = "test_ProfileCounter";
+ let dir = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ dir.initWithPath(`C:\\foo\\bar\\${testName}`);
+ return dir;
+}
+
+// We aren't going to bother generating anything looking like a real client ID
+// for this. The only real requirements for client ids is that they not repeat
+// and that they be strings. So we'll just return an integer as a string and
+// increment it when we want a new client id.
+var gDummyTelemetryClientId = 0;
+function getDummyTelemetryClientId() {
+ return gDummyTelemetryClientId.toString();
+}
+function setNewDummyTelemetryClientId() {
+ ++gDummyTelemetryClientId;
+}
+
+// Returns null if the (fake) profile count file hasn't been created yet.
+function getProfileCount() {
+ // Strict equality to ensure distinguish properly between a non-existent
+ // file and an empty one.
+ if (gFakeProfileCounterFile === null) {
+ return null;
+ }
+ let saveData = JSON.parse(gFakeProfileCounterFile);
+ return saveData.profileTelemetryIds.length;
+}
+
+// Resets the state to the original state, before the profile count file has
+// even been written.
+// If resetFile is specified as false, this will reset everything except for the
+// file itself. This allows us to sort of pretend that another installation
+// wrote the file.
+function reset(resetFile = true) {
+ if (resetFile) {
+ gFakeProfileCounterFile = null;
+ }
+ gNextReadExceptionReason = null;
+ gNextWriteExceptionReason = null;
+ setNewDummyTelemetryClientId();
+}
+
+function setup() {
+ reset();
+ // FOG needs a profile directory to put its data in.
+ do_get_profile();
+ // Initialize FOG so we can test the FOG version of profile count
+ Services.fog.initializeFOG();
+ Services.fog.testResetFOG();
+
+ BrowserUsageTelemetry.Policy.readProfileCountFile = async path => {
+ if (!gProfileCounterFilePath) {
+ gProfileCounterFilePath = path;
+ } else {
+ // We've only got one mock-file variable. Make sure we are always
+ // accessing the same file or this will cause problems.
+ Assert.equal(
+ gProfileCounterFilePath,
+ path,
+ "Only one file should be accessed"
+ );
+ }
+ // Strict equality to ensure distinguish properly between null and 0.
+ if (gNextReadExceptionReason !== null) {
+ let ex = new DOMException(FILE_OPEN_OPERATION, gNextReadExceptionReason);
+ gNextReadExceptionReason = null;
+ throw ex;
+ }
+ // Strict equality to ensure distinguish properly between a non-existent
+ // file and an empty one.
+ if (gFakeProfileCounterFile === null) {
+ throw new DOMException(FILE_OPEN_OPERATION, ERROR_FILE_NOT_FOUND);
+ }
+ return gFakeProfileCounterFile;
+ };
+ BrowserUsageTelemetry.Policy.writeProfileCountFile = async (path, data) => {
+ if (!gProfileCounterFilePath) {
+ gProfileCounterFilePath = path;
+ } else {
+ // We've only got one mock-file variable. Make sure we are always
+ // accessing the same file or this will cause problems.
+ Assert.equal(
+ gProfileCounterFilePath,
+ path,
+ "Only one file should be accessed"
+ );
+ }
+ // Strict equality to ensure distinguish properly between null and 0.
+ if (gNextWriteExceptionReason !== null) {
+ let ex = new DOMException(FILE_OPEN_OPERATION, gNextWriteExceptionReason);
+ gNextWriteExceptionReason = null;
+ throw ex;
+ }
+ gFakeProfileCounterFile = data;
+ };
+ BrowserUsageTelemetry.Policy.getUpdateDirectory = getDummyUpdateDirectory;
+ BrowserUsageTelemetry.Policy.getTelemetryClientId = getDummyTelemetryClientId;
+}
+
+// Checks that the number of profiles reported is the number expected. Because
+// of bucketing, the raw count may be different than the reported count.
+function checkSuccess(profilesReported, rawCount = profilesReported) {
+ Assert.equal(rawCount, getProfileCount());
+ const scalars = TelemetryTestUtils.getProcessScalars("parent");
+ TelemetryTestUtils.assertScalar(
+ scalars,
+ PROFILE_COUNT_SCALAR,
+ profilesReported,
+ "The value reported to telemetry should be the expected profile count"
+ );
+ Assert.equal(
+ profilesReported,
+ Glean.browserEngagement.profileCount.testGetValue()
+ );
+}
+
+function checkError() {
+ const scalars = TelemetryTestUtils.getProcessScalars("parent");
+ TelemetryTestUtils.assertScalar(
+ scalars,
+ PROFILE_COUNT_SCALAR,
+ SCALAR_ERROR_VALUE,
+ "The value reported to telemetry should be the error value"
+ );
+}
+
+add_task(async function testProfileCounter() {
+ setup();
+
+ info("Testing basic functionality, single install");
+ await BrowserUsageTelemetry.reportProfileCount();
+ checkSuccess(1);
+ await BrowserUsageTelemetry.reportProfileCount();
+ checkSuccess(1);
+
+ // Fake another installation by resetting everything except for the profile
+ // count file.
+ reset(false);
+
+ info("Testing basic functionality, faking a second install");
+ await BrowserUsageTelemetry.reportProfileCount();
+ checkSuccess(2);
+
+ // Check if we properly handle the case where we cannot read from the file
+ // and we have already set its contents. This should report an error.
+ info("Testing read error after successful write");
+ gNextReadExceptionReason = ERROR_ACCESS_DENIED;
+ await BrowserUsageTelemetry.reportProfileCount();
+ checkError();
+
+ reset();
+
+ // A read error should cause an error to be reported, but should also write
+ // to the file in an attempt to fix it. So the next (successful) read should
+ // result in the correct telemetry.
+ info("Testing read error self-correction");
+ gNextReadExceptionReason = ERROR_ACCESS_DENIED;
+ await BrowserUsageTelemetry.reportProfileCount();
+ checkError();
+
+ await BrowserUsageTelemetry.reportProfileCount();
+ checkSuccess(1);
+
+ reset();
+
+ // If the file is malformed. We should report an error and fix it, then report
+ // the correct profile count next time.
+ info("Testing with malformed profile count file");
+ gFakeProfileCounterFile = "<malformed file data>";
+ await BrowserUsageTelemetry.reportProfileCount();
+ checkError();
+
+ await BrowserUsageTelemetry.reportProfileCount();
+ checkSuccess(1);
+
+ reset();
+
+ // If we haven't yet written to the file, a write error should cause an error
+ // to be reported.
+ info("Testing write error before the first write");
+ gNextWriteExceptionReason = ERROR_ACCESS_DENIED;
+ await BrowserUsageTelemetry.reportProfileCount();
+ checkError();
+
+ reset();
+
+ info("Testing bucketing");
+ // Fake 15 installations to drive the raw profile count up to 15.
+ for (let i = 0; i < 15; i++) {
+ reset(false);
+ await BrowserUsageTelemetry.reportProfileCount();
+ }
+ // With bucketing, values from 10-99 should all be reported as 10.
+ checkSuccess(10, 15);
+});
diff --git a/browser/modules/test/unit/test_Sanitizer_interrupted.js b/browser/modules/test/unit/test_Sanitizer_interrupted.js
new file mode 100644
index 0000000000..c8e7130ac0
--- /dev/null
+++ b/browser/modules/test/unit/test_Sanitizer_interrupted.js
@@ -0,0 +1,139 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+do_get_profile();
+
+// Test that interrupted sanitizations are properly tracked.
+
+add_task(async function () {
+ const { Sanitizer } = ChromeUtils.importESModule(
+ "resource:///modules/Sanitizer.sys.mjs"
+ );
+
+ Services.prefs.setBoolPref(Sanitizer.PREF_NEWTAB_SEGREGATION, false);
+
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(Sanitizer.PREF_SANITIZE_ON_SHUTDOWN);
+ Services.prefs.clearUserPref(Sanitizer.PREF_SHUTDOWN_BRANCH + "formdata");
+ Services.prefs.clearUserPref(Sanitizer.PREF_NEWTAB_SEGREGATION);
+ });
+ Services.prefs.setBoolPref(Sanitizer.PREF_SANITIZE_ON_SHUTDOWN, true);
+ Services.prefs.setBoolPref(Sanitizer.PREF_SHUTDOWN_BRANCH + "formdata", true);
+
+ await Sanitizer.onStartup();
+ Assert.ok(Sanitizer.shouldSanitizeOnShutdown, "Should sanitize on shutdown");
+
+ let pendingSanitizations = JSON.parse(
+ Services.prefs.getStringPref(Sanitizer.PREF_PENDING_SANITIZATIONS, "[]")
+ );
+ Assert.equal(
+ pendingSanitizations.length,
+ 1,
+ "Should have 1 pending sanitization"
+ );
+ Assert.equal(
+ pendingSanitizations[0].id,
+ "shutdown",
+ "Should be the shutdown sanitization"
+ );
+ Assert.ok(
+ pendingSanitizations[0].itemsToClear.includes("formdata"),
+ "Pref has been setup"
+ );
+ Assert.ok(
+ !pendingSanitizations[0].options.isShutdown,
+ "Shutdown option is not present"
+ );
+
+ // Check the preference listeners.
+ Services.prefs.setBoolPref(Sanitizer.PREF_SANITIZE_ON_SHUTDOWN, false);
+ pendingSanitizations = JSON.parse(
+ Services.prefs.getStringPref(Sanitizer.PREF_PENDING_SANITIZATIONS, "[]")
+ );
+ Assert.equal(
+ pendingSanitizations.length,
+ 0,
+ "Should not have pending sanitizations"
+ );
+ Assert.ok(
+ !Sanitizer.shouldSanitizeOnShutdown,
+ "Should not sanitize on shutdown"
+ );
+ Services.prefs.setBoolPref(Sanitizer.PREF_SANITIZE_ON_SHUTDOWN, true);
+ pendingSanitizations = JSON.parse(
+ Services.prefs.getStringPref(Sanitizer.PREF_PENDING_SANITIZATIONS, "[]")
+ );
+ Assert.equal(
+ pendingSanitizations.length,
+ 1,
+ "Should have 1 pending sanitization"
+ );
+ Assert.equal(
+ pendingSanitizations[0].id,
+ "shutdown",
+ "Should be the shutdown sanitization"
+ );
+
+ Assert.ok(
+ pendingSanitizations[0].itemsToClear.includes("formdata"),
+ "Pending sanitizations should include formdata"
+ );
+ Services.prefs.setBoolPref(
+ Sanitizer.PREF_SHUTDOWN_BRANCH + "formdata",
+ false
+ );
+ pendingSanitizations = JSON.parse(
+ Services.prefs.getStringPref(Sanitizer.PREF_PENDING_SANITIZATIONS, "[]")
+ );
+ Assert.equal(
+ pendingSanitizations.length,
+ 1,
+ "Should have 1 pending sanitization"
+ );
+ Assert.ok(
+ !pendingSanitizations[0].itemsToClear.includes("formdata"),
+ "Pending sanitizations should have been updated"
+ );
+
+ // Check a sanitization properly rebuilds the pref.
+ await Sanitizer.sanitize(["formdata"]);
+ pendingSanitizations = JSON.parse(
+ Services.prefs.getStringPref(Sanitizer.PREF_PENDING_SANITIZATIONS, "[]")
+ );
+ Assert.equal(
+ pendingSanitizations.length,
+ 1,
+ "Should have 1 pending sanitization"
+ );
+ Assert.equal(
+ pendingSanitizations[0].id,
+ "shutdown",
+ "Should be the shutdown sanitization"
+ );
+
+ // Startup should run the pending one and setup a new shutdown sanitization.
+ Services.prefs.setBoolPref(
+ Sanitizer.PREF_SHUTDOWN_BRANCH + "formdata",
+ false
+ );
+ await Sanitizer.onStartup();
+ pendingSanitizations = JSON.parse(
+ Services.prefs.getStringPref(Sanitizer.PREF_PENDING_SANITIZATIONS, "[]")
+ );
+ Assert.equal(
+ pendingSanitizations.length,
+ 1,
+ "Should have 1 pending sanitization"
+ );
+ Assert.equal(
+ pendingSanitizations[0].id,
+ "shutdown",
+ "Should be the shutdown sanitization"
+ );
+ Assert.ok(
+ !pendingSanitizations[0].itemsToClear.includes("formdata"),
+ "Pref has been setup"
+ );
+});
diff --git a/browser/modules/test/unit/test_SiteDataManager.js b/browser/modules/test/unit/test_SiteDataManager.js
new file mode 100644
index 0000000000..adceb64ca4
--- /dev/null
+++ b/browser/modules/test/unit/test_SiteDataManager.js
@@ -0,0 +1,277 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { SiteDataManager } = ChromeUtils.import(
+ "resource:///modules/SiteDataManager.jsm"
+);
+const { SiteDataTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/SiteDataTestUtils.sys.mjs"
+);
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+const EXAMPLE_ORIGIN = "https://www.example.com";
+const EXAMPLE_ORIGIN_2 = "https://example.org";
+const EXAMPLE_ORIGIN_3 = "http://localhost:8000";
+
+let p =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ EXAMPLE_ORIGIN
+ );
+let partitionKey = `(${p.scheme},${p.baseDomain})`;
+let EXAMPLE_ORIGIN_2_PARTITIONED =
+ Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(EXAMPLE_ORIGIN_2),
+ {
+ partitionKey,
+ }
+ ).origin;
+
+add_task(function setup() {
+ do_get_profile();
+});
+
+add_task(async function testGetSites() {
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN,
+ name: "foo1",
+ value: "bar1",
+ });
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN,
+ name: "foo2",
+ value: "bar2",
+ });
+
+ // Cookie of EXAMPLE_ORIGIN_2 partitioned under EXAMPLE_ORIGIN.
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN_2_PARTITIONED,
+ name: "foo3",
+ value: "bar3",
+ });
+ // IndexedDB storage of EXAMPLE_ORIGIN_2 partitioned under EXAMPLE_ORIGIN.
+ await SiteDataTestUtils.addToIndexedDB(EXAMPLE_ORIGIN, 4096);
+ await SiteDataTestUtils.addToIndexedDB(EXAMPLE_ORIGIN_2_PARTITIONED, 4096);
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN_2,
+ name: "foo",
+ value: "bar",
+ });
+ await SiteDataTestUtils.addToIndexedDB(EXAMPLE_ORIGIN_2, 2048);
+ await SiteDataTestUtils.persist(EXAMPLE_ORIGIN_2);
+
+ await SiteDataManager.updateSites();
+
+ let sites = await SiteDataManager.getSites();
+
+ let site1 = sites.find(site => site.baseDomain == "example.com");
+ let site2 = sites.find(site => site.baseDomain == "example.org");
+
+ Assert.equal(
+ site1.baseDomain,
+ "example.com",
+ "Has the correct base domain for example.com"
+ );
+ // 4096 partitioned + 4096 unpartitioned.
+ Assert.greater(site1.usage, 4096 * 2, "Has correct usage for example.com");
+ Assert.equal(site1.persisted, false, "example.com is not persisted");
+ Assert.equal(
+ site1.cookies.length,
+ 3, // 2 top level, 1 partitioned.
+ "Has correct number of cookies for example.com"
+ );
+ Assert.ok(
+ typeof site1.lastAccessed.getDate == "function",
+ "lastAccessed for example.com is a Date"
+ );
+ Assert.ok(
+ site1.lastAccessed > Date.now() - 60 * 1000,
+ "lastAccessed for example.com happened recently"
+ );
+
+ Assert.equal(
+ site2.baseDomain,
+ "example.org",
+ "Has the correct base domain for example.org"
+ );
+ Assert.greater(site2.usage, 2048, "Has correct usage for example.org");
+ Assert.equal(site2.persisted, true, "example.org is persisted");
+ Assert.equal(
+ site2.cookies.length,
+ 1,
+ "Has correct number of cookies for example.org"
+ );
+ Assert.ok(
+ typeof site2.lastAccessed.getDate == "function",
+ "lastAccessed for example.org is a Date"
+ );
+ Assert.ok(
+ site2.lastAccessed > Date.now() - 60 * 1000,
+ "lastAccessed for example.org happened recently"
+ );
+
+ await SiteDataTestUtils.clear();
+});
+
+add_task(async function testGetTotalUsage() {
+ await SiteDataManager.updateSites();
+ let sites = await SiteDataManager.getSites();
+ Assert.equal(sites.length, 0, "SiteDataManager is empty");
+
+ await SiteDataTestUtils.addToIndexedDB(EXAMPLE_ORIGIN, 4096);
+ await SiteDataTestUtils.addToIndexedDB(EXAMPLE_ORIGIN_2, 2048);
+
+ await SiteDataManager.updateSites();
+
+ let usage = await SiteDataManager.getTotalUsage();
+
+ Assert.greater(usage, 4096 + 2048, "Has the correct total usage.");
+
+ await SiteDataTestUtils.clear();
+});
+
+add_task(async function testRemove() {
+ await SiteDataManager.updateSites();
+
+ let uri = Services.io.newURI(EXAMPLE_ORIGIN);
+ PermissionTestUtils.add(uri, "camera", Services.perms.ALLOW_ACTION);
+
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN,
+ name: "foo1",
+ value: "bar1",
+ });
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN,
+ name: "foo2",
+ value: "bar2",
+ });
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN_2_PARTITIONED,
+ name: "foo3",
+ value: "bar3",
+ });
+ await SiteDataTestUtils.addToIndexedDB(EXAMPLE_ORIGIN, 4096);
+ await SiteDataTestUtils.addToIndexedDB(EXAMPLE_ORIGIN_2_PARTITIONED, 4096);
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN_2,
+ name: "foo",
+ value: "bar",
+ });
+ await SiteDataTestUtils.addToIndexedDB(EXAMPLE_ORIGIN_2, 2048);
+ await SiteDataTestUtils.persist(EXAMPLE_ORIGIN_2);
+ await SiteDataTestUtils.addToIndexedDB(EXAMPLE_ORIGIN_3, 2048);
+
+ await SiteDataManager.updateSites();
+
+ let sites = await SiteDataManager.getSites();
+
+ Assert.equal(sites.length, 3, "Has three sites.");
+
+ await SiteDataManager.remove("localhost");
+
+ sites = await SiteDataManager.getSites();
+
+ Assert.equal(sites.length, 2, "Has two sites.");
+
+ await SiteDataManager.remove(["www.example.com"]);
+
+ sites = await SiteDataManager.getSites();
+
+ Assert.equal(sites.length, 1, "Has one site.");
+ Assert.equal(
+ sites[0].baseDomain,
+ "example.org",
+ "Has not cleared data for example.org"
+ );
+
+ let usage = await SiteDataTestUtils.getQuotaUsage(EXAMPLE_ORIGIN);
+ Assert.equal(usage, 0, "Has cleared quota usage for example.com");
+
+ let cookies = Services.cookies.countCookiesFromHost("example.com");
+ Assert.equal(cookies, 0, "Has cleared cookies for example.com");
+
+ let perm = PermissionTestUtils.testPermission(uri, "persistent-storage");
+ Assert.equal(
+ perm,
+ Services.perms.UNKNOWN_ACTION,
+ "Cleared the persistent-storage permission."
+ );
+ perm = PermissionTestUtils.testPermission(uri, "camera");
+ Assert.equal(
+ perm,
+ Services.perms.ALLOW_ACTION,
+ "Did not clear other permissions."
+ );
+
+ PermissionTestUtils.remove(uri, "camera");
+});
+
+add_task(async function testRemoveSiteData() {
+ let uri = Services.io.newURI(EXAMPLE_ORIGIN);
+ PermissionTestUtils.add(uri, "camera", Services.perms.ALLOW_ACTION);
+
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN,
+ name: "foo1",
+ value: "bar1",
+ });
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN,
+ name: "foo2",
+ value: "bar2",
+ });
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN_2_PARTITIONED,
+ name: "foo3",
+ value: "bar3",
+ });
+ await SiteDataTestUtils.addToIndexedDB(EXAMPLE_ORIGIN, 4096);
+ await SiteDataTestUtils.addToIndexedDB(EXAMPLE_ORIGIN_2_PARTITIONED, 4096);
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN_2,
+ name: "foo",
+ value: "bar",
+ });
+ await SiteDataTestUtils.addToIndexedDB(EXAMPLE_ORIGIN_2, 2048);
+ await SiteDataTestUtils.persist(EXAMPLE_ORIGIN_2);
+
+ await SiteDataManager.updateSites();
+
+ let sites = await SiteDataManager.getSites();
+
+ Assert.equal(sites.length, 2, "Has two sites.");
+
+ await SiteDataManager.removeSiteData();
+
+ sites = await SiteDataManager.getSites();
+
+ Assert.equal(sites.length, 0, "Has no sites.");
+
+ let usage = await SiteDataTestUtils.getQuotaUsage(EXAMPLE_ORIGIN);
+ Assert.equal(usage, 0, "Has cleared quota usage for example.com");
+
+ usage = await SiteDataTestUtils.getQuotaUsage(EXAMPLE_ORIGIN_2);
+ Assert.equal(usage, 0, "Has cleared quota usage for example.org");
+
+ let cookies = Services.cookies.countCookiesFromHost("example.org");
+ Assert.equal(cookies, 0, "Has cleared cookies for example.org");
+
+ let perm = PermissionTestUtils.testPermission(uri, "persistent-storage");
+ Assert.equal(
+ perm,
+ Services.perms.UNKNOWN_ACTION,
+ "Cleared the persistent-storage permission."
+ );
+ perm = PermissionTestUtils.testPermission(uri, "camera");
+ Assert.equal(
+ perm,
+ Services.perms.ALLOW_ACTION,
+ "Did not clear other permissions."
+ );
+
+ PermissionTestUtils.remove(uri, "camera");
+});
diff --git a/browser/modules/test/unit/test_SiteDataManagerContainers.js b/browser/modules/test/unit/test_SiteDataManagerContainers.js
new file mode 100644
index 0000000000..d083c41414
--- /dev/null
+++ b/browser/modules/test/unit/test_SiteDataManagerContainers.js
@@ -0,0 +1,140 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { SiteDataManager } = ChromeUtils.import(
+ "resource:///modules/SiteDataManager.jsm"
+);
+const { SiteDataTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/SiteDataTestUtils.sys.mjs"
+);
+
+const EXAMPLE_ORIGIN = "https://www.example.com";
+const EXAMPLE_ORIGIN_2 = "https://example.org";
+
+add_task(function setup() {
+ do_get_profile();
+});
+
+add_task(async function testGetSitesByContainers() {
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN,
+ name: "foo1",
+ value: "bar1",
+ originAttributes: { userContextId: "1" },
+ });
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN,
+ name: "foo2",
+ value: "bar2",
+ originAttributes: { userContextId: "2" },
+ });
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN,
+ name: "foo3",
+ value: "bar3",
+ originAttributes: { userContextId: "2" },
+ });
+ SiteDataTestUtils.addToCookies({
+ origin: EXAMPLE_ORIGIN_2,
+ name: "foo",
+ value: "bar",
+ originAttributes: { userContextId: "3" },
+ });
+
+ await SiteDataTestUtils.addToIndexedDB(
+ EXAMPLE_ORIGIN + "^userContextId=1",
+ 4096
+ );
+ await SiteDataTestUtils.addToIndexedDB(
+ EXAMPLE_ORIGIN_2 + "^userContextId=3",
+ 2048
+ );
+
+ await SiteDataManager.updateSites();
+
+ let sites = await SiteDataManager.getSites();
+
+ let site1Container1 = sites
+ .find(site => site.baseDomain == "example.com")
+ .containersData.get(1);
+
+ let site1Container2 = sites
+ .find(site => site.baseDomain == "example.com")
+ .containersData.get(2);
+
+ let site2Container3 = sites
+ .find(site => site.baseDomain == "example.org")
+ .containersData.get(3);
+
+ Assert.equal(
+ sites.reduce(
+ (accumulator, site) => accumulator + site.containersData.size,
+ 0
+ ),
+ 3,
+ "Has the correct number of sites by containers"
+ );
+
+ Assert.equal(
+ site1Container1.cookiesBlocked,
+ 1,
+ "Has the correct number of cookiesBlocked by containers"
+ );
+
+ Assert.greater(
+ site1Container1.quotaUsage,
+ 4096,
+ "Has correct usage for example.com^userContextId=1"
+ );
+
+ Assert.ok(
+ typeof site1Container1.lastAccessed.getDate == "function",
+ "lastAccessed for example.com^userContextId=1 is a Date"
+ );
+ Assert.ok(
+ site1Container1.lastAccessed > Date.now() - 60 * 1000,
+ "lastAccessed for example.com^userContextId=1 happened recently"
+ );
+
+ Assert.equal(
+ site1Container2.cookiesBlocked,
+ 2,
+ "Has the correct number of cookiesBlocked by containers"
+ );
+
+ Assert.equal(
+ site1Container2.quotaUsage,
+ 0,
+ "Has correct usage for example.org^userContextId=2"
+ );
+
+ Assert.ok(
+ typeof site1Container2.lastAccessed.getDate == "function",
+ "lastAccessed for example.com^userContextId=2 is a Date"
+ );
+
+ Assert.equal(
+ site2Container3.cookiesBlocked,
+ 1,
+ "Has the correct number of cookiesBlocked by containers"
+ );
+
+ Assert.greater(
+ site2Container3.quotaUsage,
+ 2048,
+ "Has correct usage for example.org^userContextId=3"
+ );
+
+ Assert.ok(
+ typeof site2Container3.lastAccessed.getDate == "function",
+ "lastAccessed for example.org^userContextId=3 is a Date"
+ );
+ Assert.ok(
+ site2Container3.lastAccessed > Date.now() - 60 * 1000,
+ "lastAccessed for example.org^userContextId=3 happened recently"
+ );
+
+ await SiteDataTestUtils.clear();
+});
diff --git a/browser/modules/test/unit/test_SitePermissions.js b/browser/modules/test/unit/test_SitePermissions.js
new file mode 100644
index 0000000000..e982cf6e99
--- /dev/null
+++ b/browser/modules/test/unit/test_SitePermissions.js
@@ -0,0 +1,401 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { SitePermissions } = ChromeUtils.importESModule(
+ "resource:///modules/SitePermissions.sys.mjs"
+);
+
+const RESIST_FINGERPRINTING_ENABLED = Services.prefs.getBoolPref(
+ "privacy.resistFingerprinting"
+);
+const MIDI_ENABLED = Services.prefs.getBoolPref("dom.webmidi.enabled");
+
+const EXT_PROTOCOL_ENABLED = Services.prefs.getBoolPref(
+ "security.external_protocol_requires_permission"
+);
+
+const SPEAKER_SELECTION_ENABLED = Services.prefs.getBoolPref(
+ "media.setsinkid.enabled"
+);
+
+add_task(async function testPermissionsListing() {
+ let expectedPermissions = [
+ "autoplay-media",
+ "camera",
+ "cookie",
+ "desktop-notification",
+ "focus-tab-by-prompt",
+ "geo",
+ "install",
+ "microphone",
+ "popup",
+ "screen",
+ "shortcuts",
+ "persistent-storage",
+ "storage-access",
+ "xr",
+ "3rdPartyStorage",
+ ];
+ if (RESIST_FINGERPRINTING_ENABLED) {
+ // Canvas permission should be hidden unless privacy.resistFingerprinting
+ // is true.
+ expectedPermissions.push("canvas");
+ }
+ if (MIDI_ENABLED) {
+ // Should remove this checking and add it as default after it is fully pref'd-on.
+ expectedPermissions.push("midi");
+ expectedPermissions.push("midi-sysex");
+ }
+ if (EXT_PROTOCOL_ENABLED) {
+ expectedPermissions.push("open-protocol-handler");
+ }
+ if (SPEAKER_SELECTION_ENABLED) {
+ expectedPermissions.push("speaker");
+ }
+ Assert.deepEqual(
+ SitePermissions.listPermissions().sort(),
+ expectedPermissions.sort(),
+ "Correct list of all permissions"
+ );
+});
+
+add_task(async function testGetAllByPrincipal() {
+ // check that it returns an empty array on an invalid principal
+ // like a principal with an about URI, which doesn't support site permissions
+ let wrongPrincipal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "about:config"
+ );
+ Assert.deepEqual(SitePermissions.getAllByPrincipal(wrongPrincipal), []);
+
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "https://example.com"
+ );
+ Assert.deepEqual(SitePermissions.getAllByPrincipal(principal), []);
+
+ SitePermissions.setForPrincipal(principal, "camera", SitePermissions.ALLOW);
+ Assert.deepEqual(SitePermissions.getAllByPrincipal(principal), [
+ {
+ id: "camera",
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ ]);
+
+ SitePermissions.setForPrincipal(
+ principal,
+ "microphone",
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_SESSION
+ );
+ SitePermissions.setForPrincipal(
+ principal,
+ "desktop-notification",
+ SitePermissions.BLOCK
+ );
+
+ Assert.deepEqual(SitePermissions.getAllByPrincipal(principal), [
+ {
+ id: "camera",
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ {
+ id: "microphone",
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_SESSION,
+ },
+ {
+ id: "desktop-notification",
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ ]);
+
+ SitePermissions.removeFromPrincipal(principal, "microphone");
+ Assert.deepEqual(SitePermissions.getAllByPrincipal(principal), [
+ {
+ id: "camera",
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ {
+ id: "desktop-notification",
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ ]);
+
+ SitePermissions.removeFromPrincipal(principal, "camera");
+ SitePermissions.removeFromPrincipal(principal, "desktop-notification");
+ Assert.deepEqual(SitePermissions.getAllByPrincipal(principal), []);
+
+ Assert.equal(Services.prefs.getIntPref("permissions.default.shortcuts"), 0);
+ SitePermissions.setForPrincipal(
+ principal,
+ "shortcuts",
+ SitePermissions.BLOCK
+ );
+
+ // Customized preference should have been enabled, but the default should not.
+ Assert.equal(Services.prefs.getIntPref("permissions.default.shortcuts"), 0);
+ Assert.deepEqual(SitePermissions.getAllByPrincipal(principal), [
+ {
+ id: "shortcuts",
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ ]);
+
+ SitePermissions.removeFromPrincipal(principal, "shortcuts");
+ Services.prefs.clearUserPref("permissions.default.shortcuts");
+});
+
+add_task(async function testGetAvailableStates() {
+ Assert.deepEqual(SitePermissions.getAvailableStates("camera"), [
+ SitePermissions.UNKNOWN,
+ SitePermissions.ALLOW,
+ SitePermissions.BLOCK,
+ ]);
+
+ // Test available states with a default permission set.
+ Services.prefs.setIntPref(
+ "permissions.default.camera",
+ SitePermissions.ALLOW
+ );
+ Assert.deepEqual(SitePermissions.getAvailableStates("camera"), [
+ SitePermissions.PROMPT,
+ SitePermissions.ALLOW,
+ SitePermissions.BLOCK,
+ ]);
+ Services.prefs.clearUserPref("permissions.default.camera");
+
+ Assert.deepEqual(SitePermissions.getAvailableStates("cookie"), [
+ SitePermissions.ALLOW,
+ SitePermissions.ALLOW_COOKIES_FOR_SESSION,
+ SitePermissions.BLOCK,
+ ]);
+
+ Assert.deepEqual(SitePermissions.getAvailableStates("popup"), [
+ SitePermissions.ALLOW,
+ SitePermissions.BLOCK,
+ ]);
+});
+
+add_task(async function testExactHostMatch() {
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "https://example.com"
+ );
+ let subPrincipal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "https://test1.example.com"
+ );
+
+ let exactHostMatched = [
+ "autoplay-media",
+ "desktop-notification",
+ "focus-tab-by-prompt",
+ "camera",
+ "microphone",
+ "screen",
+ "geo",
+ "xr",
+ "persistent-storage",
+ ];
+ if (RESIST_FINGERPRINTING_ENABLED) {
+ // Canvas permission should be hidden unless privacy.resistFingerprinting
+ // is true.
+ exactHostMatched.push("canvas");
+ }
+ if (MIDI_ENABLED) {
+ // WebMIDI is only pref'd on in nightly.
+ // Should remove this checking and add it as default after it is fully pref-on.
+ exactHostMatched.push("midi");
+ exactHostMatched.push("midi-sysex");
+ }
+ if (EXT_PROTOCOL_ENABLED) {
+ exactHostMatched.push("open-protocol-handler");
+ }
+ if (SPEAKER_SELECTION_ENABLED) {
+ exactHostMatched.push("speaker");
+ }
+ let nonExactHostMatched = [
+ "cookie",
+ "popup",
+ "install",
+ "shortcuts",
+ "storage-access",
+ "3rdPartyStorage",
+ ];
+
+ let permissions = SitePermissions.listPermissions();
+ for (let permission of permissions) {
+ SitePermissions.setForPrincipal(
+ principal,
+ permission,
+ SitePermissions.ALLOW
+ );
+
+ if (exactHostMatched.includes(permission)) {
+ // Check that the sub-origin does not inherit the permission from its parent.
+ Assert.equal(
+ SitePermissions.getForPrincipal(subPrincipal, permission).state,
+ SitePermissions.getDefault(permission),
+ `${permission} should exact-host match`
+ );
+ } else if (nonExactHostMatched.includes(permission)) {
+ // Check that the sub-origin does inherit the permission from its parent.
+ Assert.equal(
+ SitePermissions.getForPrincipal(subPrincipal, permission).state,
+ SitePermissions.ALLOW,
+ `${permission} should not exact-host match`
+ );
+ } else {
+ Assert.ok(
+ false,
+ `Found an unknown permission ${permission} in exact host match test.` +
+ "Please add new permissions from SitePermissions.sys.mjs to this test."
+ );
+ }
+
+ // Check that the permission can be made specific to the sub-origin.
+ SitePermissions.setForPrincipal(
+ subPrincipal,
+ permission,
+ SitePermissions.PROMPT
+ );
+ Assert.equal(
+ SitePermissions.getForPrincipal(subPrincipal, permission).state,
+ SitePermissions.PROMPT
+ );
+ Assert.equal(
+ SitePermissions.getForPrincipal(principal, permission).state,
+ SitePermissions.ALLOW
+ );
+
+ SitePermissions.removeFromPrincipal(subPrincipal, permission);
+ SitePermissions.removeFromPrincipal(principal, permission);
+ }
+});
+
+add_task(async function testDefaultPrefs() {
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "https://example.com"
+ );
+
+ // Check that without a pref the default return value is UNKNOWN.
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, "camera"), {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+
+ // Check that the default return value changed after setting the pref.
+ Services.prefs.setIntPref(
+ "permissions.default.camera",
+ SitePermissions.BLOCK
+ );
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, "camera"), {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+
+ // Check that other permissions still return UNKNOWN.
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, "microphone"), {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+
+ // Check that the default return value changed after changing the pref.
+ Services.prefs.setIntPref(
+ "permissions.default.camera",
+ SitePermissions.ALLOW
+ );
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, "camera"), {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+
+ // Check that the preference is ignored if there is a value.
+ SitePermissions.setForPrincipal(principal, "camera", SitePermissions.BLOCK);
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, "camera"), {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+
+ // The preference should be honored again, after resetting the permissions.
+ SitePermissions.removeFromPrincipal(principal, "camera");
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, "camera"), {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+
+ // Should be UNKNOWN after clearing the pref.
+ Services.prefs.clearUserPref("permissions.default.camera");
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, "camera"), {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+});
+
+add_task(async function testCanvasPermission() {
+ let resistFingerprinting = Services.prefs.getBoolPref(
+ "privacy.resistFingerprinting",
+ false
+ );
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "https://example.com"
+ );
+
+ SitePermissions.setForPrincipal(principal, "canvas", SitePermissions.ALLOW);
+
+ // Canvas permission is hidden when privacy.resistFingerprinting is false.
+ Services.prefs.setBoolPref("privacy.resistFingerprinting", false);
+ Assert.equal(SitePermissions.listPermissions().indexOf("canvas"), -1);
+ Assert.equal(
+ SitePermissions.getAllByPrincipal(principal).filter(
+ permission => permission.id === "canvas"
+ ).length,
+ 0
+ );
+
+ // Canvas permission is visible when privacy.resistFingerprinting is true.
+ Services.prefs.setBoolPref("privacy.resistFingerprinting", true);
+ Assert.notEqual(SitePermissions.listPermissions().indexOf("canvas"), -1);
+ Assert.notEqual(
+ SitePermissions.getAllByPrincipal(principal).filter(
+ permission => permission.id === "canvas"
+ ).length,
+ 0
+ );
+
+ SitePermissions.removeFromPrincipal(principal, "canvas");
+ Services.prefs.setBoolPref(
+ "privacy.resistFingerprinting",
+ resistFingerprinting
+ );
+});
+
+add_task(async function testFilePermissions() {
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "file:///example.js"
+ );
+ Assert.deepEqual(SitePermissions.getAllByPrincipal(principal), []);
+
+ SitePermissions.setForPrincipal(principal, "camera", SitePermissions.ALLOW);
+ Assert.deepEqual(SitePermissions.getAllByPrincipal(principal), [
+ {
+ id: "camera",
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ ]);
+ SitePermissions.removeFromPrincipal(principal, "camera");
+ Assert.deepEqual(SitePermissions.getAllByPrincipal(principal), []);
+});
diff --git a/browser/modules/test/unit/test_SitePermissions_temporary.js b/browser/modules/test/unit/test_SitePermissions_temporary.js
new file mode 100644
index 0000000000..a91b1b8bd8
--- /dev/null
+++ b/browser/modules/test/unit/test_SitePermissions_temporary.js
@@ -0,0 +1,710 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { SitePermissions } = ChromeUtils.importESModule(
+ "resource:///modules/SitePermissions.sys.mjs"
+);
+
+const TemporaryPermissions = SitePermissions._temporaryPermissions;
+
+const PERM_A = "foo";
+const PERM_B = "bar";
+const PERM_C = "foobar";
+
+const BROWSER_A = createDummyBrowser("https://example.com/foo");
+const BROWSER_B = createDummyBrowser("https://example.org/foo");
+
+const EXPIRY_MS_A = 1000000;
+const EXPIRY_MS_B = 1000001;
+
+function createDummyBrowser(spec) {
+ let uri = Services.io.newURI(spec);
+ return {
+ currentURI: uri,
+ contentPrincipal: Services.scriptSecurityManager.createContentPrincipal(
+ uri,
+ {}
+ ),
+ dispatchEvent: () => {},
+ ownerGlobal: {
+ CustomEvent: class CustomEvent {},
+ },
+ };
+}
+
+function navigateDummyBrowser(browser, uri) {
+ // Callers may pass in either uri strings or nsIURI objects.
+ if (typeof uri == "string") {
+ uri = Services.io.newURI(uri);
+ }
+ browser.currentURI = uri;
+ browser.contentPrincipal =
+ Services.scriptSecurityManager.createContentPrincipal(
+ browser.currentURI,
+ {}
+ );
+}
+
+/**
+ * Tests that temporary permissions with different block states are stored
+ * (set, overwrite, delete) correctly.
+ */
+add_task(async function testAllowBlock() {
+ // Set two temporary permissions on the same browser.
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_A,
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_A,
+ EXPIRY_MS_A
+ );
+
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_B,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_A,
+ EXPIRY_MS_A
+ );
+
+ // Test that the permissions have been set correctly.
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_A, BROWSER_A),
+ {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ "SitePermissions returns expected permission state for perm A."
+ );
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_B, BROWSER_A),
+ {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ "SitePermissions returns expected permission state for perm B."
+ );
+
+ Assert.deepEqual(
+ TemporaryPermissions.get(BROWSER_A, PERM_A),
+ {
+ id: PERM_A,
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ "TemporaryPermissions returns expected permission state for perm A."
+ );
+
+ Assert.deepEqual(
+ TemporaryPermissions.get(BROWSER_A, PERM_B),
+ {
+ id: PERM_B,
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ "TemporaryPermissions returns expected permission state for perm B."
+ );
+
+ // Test internal data structure of TemporaryPermissions.
+ let entry = TemporaryPermissions._stateByBrowser.get(BROWSER_A);
+ ok(entry, "Should have an entry for browser A");
+ ok(
+ !TemporaryPermissions._stateByBrowser.has(BROWSER_B),
+ "Should have no entry for browser B"
+ );
+
+ let { browser, uriToPerm } = entry;
+ Assert.equal(
+ browser?.get(),
+ BROWSER_A,
+ "Entry should have a weak reference to the browser."
+ );
+
+ ok(uriToPerm, "Entry should have uriToPerm object.");
+ Assert.equal(Object.keys(uriToPerm).length, 2, "uriToPerm has 2 entries.");
+
+ let permissionsA = uriToPerm[BROWSER_A.contentPrincipal.origin];
+ let permissionsB =
+ uriToPerm[Services.eTLD.getBaseDomain(BROWSER_A.currentURI)];
+
+ ok(permissionsA, "Allow should be keyed under origin");
+ ok(permissionsB, "Block should be keyed under baseDomain");
+
+ let permissionA = permissionsA[PERM_A];
+ let permissionB = permissionsB[PERM_B];
+
+ Assert.equal(
+ permissionA.state,
+ SitePermissions.ALLOW,
+ "Should have correct state"
+ );
+ let expireTimeoutA = permissionA.expireTimeout;
+ Assert.ok(
+ Number.isInteger(expireTimeoutA),
+ "Should have valid expire timeout"
+ );
+
+ Assert.equal(
+ permissionB.state,
+ SitePermissions.BLOCK,
+ "Should have correct state"
+ );
+ let expireTimeoutB = permissionB.expireTimeout;
+ Assert.ok(
+ Number.isInteger(expireTimeoutB),
+ "Should have valid expire timeout"
+ );
+
+ // Overwrite permission A.
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_A,
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_A,
+ EXPIRY_MS_B
+ );
+
+ Assert.ok(
+ permissionsA[PERM_A].expireTimeout != expireTimeoutA,
+ "Overwritten permission A should have new timer"
+ );
+
+ // Overwrite permission B - this time with a non-block state which means it
+ // should be keyed by origin now.
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_B,
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_A,
+ EXPIRY_MS_A
+ );
+
+ let baseDomainEntry =
+ uriToPerm[Services.eTLD.getBaseDomain(BROWSER_A.currentURI)];
+ Assert.ok(
+ !baseDomainEntry || !baseDomainEntry[PERM_B],
+ "Should not longer have baseDomain permission entry"
+ );
+
+ permissionsB = uriToPerm[BROWSER_A.contentPrincipal.origin];
+ permissionB = permissionsB[PERM_B];
+ Assert.ok(
+ permissionsB && permissionB,
+ "Overwritten permission should be keyed under origin"
+ );
+ Assert.equal(
+ permissionB.state,
+ SitePermissions.ALLOW,
+ "Should have correct updated state"
+ );
+ Assert.ok(
+ permissionB.expireTimeout != expireTimeoutB,
+ "Overwritten permission B should have new timer"
+ );
+
+ // Remove permissions
+ SitePermissions.removeFromPrincipal(null, PERM_A, BROWSER_A);
+ SitePermissions.removeFromPrincipal(null, PERM_B, BROWSER_A);
+
+ // Test that permissions have been removed correctly
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_A, BROWSER_A),
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ "SitePermissions returns UNKNOWN state for A."
+ );
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_B, BROWSER_A),
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ "SitePermissions returns UNKNOWN state for B."
+ );
+
+ Assert.equal(
+ TemporaryPermissions.get(BROWSER_A, PERM_A),
+ null,
+ "TemporaryPermissions returns null for perm A."
+ );
+
+ Assert.equal(
+ TemporaryPermissions.get(BROWSER_A, PERM_B),
+ null,
+ "TemporaryPermissions returns null for perm B."
+ );
+});
+
+/**
+ * Tests TemporaryPermissions#getAll.
+ */
+add_task(async function testGetAll() {
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_A,
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_A,
+ EXPIRY_MS_A
+ );
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_B,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_B,
+ EXPIRY_MS_A
+ );
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_C,
+ SitePermissions.PROMPT,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_B,
+ EXPIRY_MS_A
+ );
+
+ Assert.deepEqual(TemporaryPermissions.getAll(BROWSER_A), [
+ {
+ id: PERM_A,
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ ]);
+
+ let permsBrowserB = TemporaryPermissions.getAll(BROWSER_B);
+ Assert.equal(
+ permsBrowserB.length,
+ 2,
+ "There should be 2 permissions set for BROWSER_B"
+ );
+
+ let permB;
+ let permC;
+
+ if (permsBrowserB[0].id == PERM_B) {
+ permB = permsBrowserB[0];
+ permC = permsBrowserB[1];
+ } else {
+ permB = permsBrowserB[1];
+ permC = permsBrowserB[0];
+ }
+
+ Assert.deepEqual(permB, {
+ id: PERM_B,
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ });
+ Assert.deepEqual(permC, {
+ id: PERM_C,
+ state: SitePermissions.PROMPT,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ });
+});
+
+/**
+ * Tests SitePermissions#clearTemporaryBlockPermissions and
+ * TemporaryPermissions#clear.
+ */
+add_task(async function testClear() {
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_A,
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_A,
+ EXPIRY_MS_A
+ );
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_B,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_A,
+ EXPIRY_MS_A
+ );
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_C,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_B,
+ EXPIRY_MS_A
+ );
+
+ let stateByBrowser = SitePermissions._temporaryPermissions._stateByBrowser;
+
+ Assert.ok(stateByBrowser.has(BROWSER_A), "Browser map should have BROWSER_A");
+ Assert.ok(stateByBrowser.has(BROWSER_B), "Browser map should have BROWSER_B");
+
+ SitePermissions.clearTemporaryBlockPermissions(BROWSER_A);
+
+ // We only clear block permissions, so we should still see PERM_A.
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_A, BROWSER_A),
+ {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ "SitePermissions returns ALLOW state for PERM_A."
+ );
+ // We don't clear BROWSER_B so it should still be there.
+ Assert.ok(stateByBrowser.has(BROWSER_B), "Should still have BROWSER_B.");
+
+ // Now clear allow permissions for A explicitly.
+ SitePermissions._temporaryPermissions.clear(BROWSER_A, SitePermissions.ALLOW);
+
+ Assert.ok(!stateByBrowser.has(BROWSER_A), "Should no longer have BROWSER_A.");
+ let browser = stateByBrowser.get(BROWSER_B);
+ Assert.ok(browser, "Should still have BROWSER_B");
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_A, BROWSER_A),
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ "SitePermissions returns UNKNOWN state for PERM_A."
+ );
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_B, BROWSER_A),
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ "SitePermissions returns UNKNOWN state for PERM_B."
+ );
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_C, BROWSER_B),
+ {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ "SitePermissions returns BLOCK state for PERM_C."
+ );
+
+ SitePermissions._temporaryPermissions.clear(BROWSER_B);
+
+ Assert.ok(!stateByBrowser.has(BROWSER_B), "Should no longer have BROWSER_B.");
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_C, BROWSER_B),
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ "SitePermissions returns UNKNOWN state for PERM_C."
+ );
+});
+
+/**
+ * Tests that the temporary permissions setter calls the callback on permission
+ * expire with the associated browser.
+ */
+add_task(async function testCallbackOnExpiry() {
+ let promiseExpireA = new Promise(resolve => {
+ TemporaryPermissions.set(
+ BROWSER_A,
+ PERM_A,
+ SitePermissions.BLOCK,
+ 100,
+ undefined,
+ resolve
+ );
+ });
+ let promiseExpireB = new Promise(resolve => {
+ TemporaryPermissions.set(
+ BROWSER_B,
+ PERM_A,
+ SitePermissions.BLOCK,
+ 100,
+ BROWSER_B.contentPrincipal,
+ resolve
+ );
+ });
+
+ let [browserA, browserB] = await Promise.all([
+ promiseExpireA,
+ promiseExpireB,
+ ]);
+ Assert.equal(
+ browserA,
+ BROWSER_A,
+ "Should get callback with browser on expiry for A"
+ );
+ Assert.equal(
+ browserB,
+ BROWSER_B,
+ "Should get callback with browser on expiry for B"
+ );
+});
+
+/**
+ * Tests that the temporary permissions setter calls the callback on permission
+ * expire with the associated browser if the browser associated browser has
+ * changed after setting the permission.
+ */
+add_task(async function testCallbackOnExpiryUpdatedBrowser() {
+ let promiseExpire = new Promise(resolve => {
+ TemporaryPermissions.set(
+ BROWSER_A,
+ PERM_A,
+ SitePermissions.BLOCK,
+ 200,
+ undefined,
+ resolve
+ );
+ });
+
+ TemporaryPermissions.copy(BROWSER_A, BROWSER_B);
+
+ let browser = await promiseExpire;
+ Assert.equal(
+ browser,
+ BROWSER_B,
+ "Should get callback with updated browser on expiry."
+ );
+});
+
+/**
+ * Tests that the permission setter throws an exception if an invalid expiry
+ * time is passed.
+ */
+add_task(async function testInvalidExpiryTime() {
+ let expectedError = /expireTime must be a positive integer/;
+ Assert.throws(() => {
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_A,
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_A,
+ null
+ );
+ }, expectedError);
+ Assert.throws(() => {
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_A,
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_A,
+ 0
+ );
+ }, expectedError);
+ Assert.throws(() => {
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_A,
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_A,
+ -100
+ );
+ }, expectedError);
+});
+
+/**
+ * Tests that we block by base domain but allow by origin.
+ */
+add_task(async function testTemporaryPermissionScope() {
+ let states = {
+ strict: {
+ same: [
+ "https://example.com",
+ "https://example.com/sub/path",
+ "https://example.com:443",
+ "https://name:password@example.com",
+ ],
+ different: [
+ "https://example.com",
+ "https://test1.example.com",
+ "http://example.com",
+ "http://example.org",
+ "file:///tmp/localPageA.html",
+ "file:///tmp/localPageB.html",
+ ],
+ },
+ nonStrict: {
+ same: [
+ "https://example.com",
+ "https://example.com/sub/path",
+ "https://example.com:443",
+ "https://test1.example.com",
+ "http://test2.test1.example.com",
+ "https://name:password@example.com",
+ "http://example.com",
+ ],
+ different: [
+ "https://example.com",
+ "https://example.org",
+ "http://example.net",
+ ],
+ },
+ };
+
+ for (let state of [SitePermissions.BLOCK, SitePermissions.ALLOW]) {
+ let matchStrict = state != SitePermissions.BLOCK;
+
+ let lists = matchStrict ? states.strict : states.nonStrict;
+
+ Object.entries(lists).forEach(([type, list]) => {
+ let expectSet = type == "same";
+
+ for (let uri of list) {
+ let browser = createDummyBrowser(uri);
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_A,
+ state,
+ SitePermissions.SCOPE_TEMPORARY,
+ browser,
+ EXPIRY_MS_A
+ );
+
+ ok(true, "origin:" + browser.contentPrincipal.origin);
+
+ for (let otherUri of list) {
+ if (uri == otherUri) {
+ continue;
+ }
+ navigateDummyBrowser(browser, otherUri);
+ ok(true, "new origin:" + browser.contentPrincipal.origin);
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_A, browser),
+ {
+ state: expectSet ? state : SitePermissions.UNKNOWN,
+ scope: expectSet
+ ? SitePermissions.SCOPE_TEMPORARY
+ : SitePermissions.SCOPE_PERSISTENT,
+ },
+ `${
+ state == SitePermissions.BLOCK ? "Block" : "Allow"
+ } Permission originally set for ${uri} should ${
+ expectSet ? "not" : "also"
+ } be set for ${otherUri}.`
+ );
+ }
+
+ SitePermissions._temporaryPermissions.clear(browser);
+ }
+ });
+ }
+});
+
+/**
+ * Tests that we can override the principal to use for keying temporary
+ * permissions.
+ */
+add_task(async function testOverrideBrowserURI() {
+ let testBrowser = createDummyBrowser("https://old.example.com/foo");
+ let overrideURI = Services.io.newURI("https://test.example.org/test/path");
+ SitePermissions.setForPrincipal(
+ Services.scriptSecurityManager.createContentPrincipal(overrideURI, {}),
+ PERM_A,
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_TEMPORARY,
+ testBrowser,
+ EXPIRY_MS_A
+ );
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_A, testBrowser),
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ "Permission should not be set for old URI."
+ );
+
+ // "Navigate" to new URI
+ navigateDummyBrowser(testBrowser, overrideURI);
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_A, testBrowser),
+ {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ "Permission should be set for new URI."
+ );
+
+ SitePermissions._temporaryPermissions.clear(testBrowser);
+});
+
+/**
+ * Tests that TemporaryPermissions does not throw for incompatible URI or
+ * browser.currentURI.
+ */
+add_task(async function testPermissionUnsupportedScheme() {
+ let aboutURI = Services.io.newURI("about:blank");
+
+ // Incompatible override URI should not throw or store any permissions.
+ SitePermissions.setForPrincipal(
+ Services.scriptSecurityManager.createContentPrincipal(aboutURI, {}),
+ PERM_A,
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_TEMPORARY,
+ BROWSER_A,
+ EXPIRY_MS_B
+ );
+ Assert.ok(
+ SitePermissions._temporaryPermissions._stateByBrowser.has(BROWSER_A),
+ "Should not have stored permission for unsupported URI scheme."
+ );
+
+ let browser = createDummyBrowser("https://example.com/");
+ // Set a permission so we get an entry in the browser map.
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_B,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ browser
+ );
+
+ // Change browser URI to about:blank.
+ navigateDummyBrowser(browser, aboutURI);
+
+ // Setting permission for browser with unsupported URI should not throw.
+ SitePermissions.setForPrincipal(
+ null,
+ PERM_A,
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_TEMPORARY,
+ browser
+ );
+ Assert.ok(true, "Set should not throw for unsupported URI");
+
+ SitePermissions.removeFromPrincipal(null, PERM_A, browser);
+ Assert.ok(true, "Remove should not throw for unsupported URI");
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, PERM_A, browser),
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ "Should return no permission set for unsupported URI."
+ );
+ Assert.ok(true, "Get should not throw for unsupported URI");
+
+ // getAll should not throw, but return empty permissions array.
+ let permissions = SitePermissions.getAllForBrowser(browser);
+ Assert.ok(
+ Array.isArray(permissions) && !permissions.length,
+ "Should return empty array for browser on about:blank"
+ );
+
+ SitePermissions._temporaryPermissions.clear(browser);
+});
diff --git a/browser/modules/test/unit/test_TabUnloader.js b/browser/modules/test/unit/test_TabUnloader.js
new file mode 100644
index 0000000000..2177fe14e2
--- /dev/null
+++ b/browser/modules/test/unit/test_TabUnloader.js
@@ -0,0 +1,449 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { TabUnloader } = ChromeUtils.import(
+ "resource:///modules/TabUnloader.jsm"
+);
+
+let TestTabUnloaderMethods = {
+ isNonDiscardable(tab, weight) {
+ return /\bselected\b/.test(tab.keywords) ? weight : 0;
+ },
+
+ isParentProcess(tab, weight) {
+ return /\bparent\b/.test(tab.keywords) ? weight : 0;
+ },
+
+ isPinned(tab, weight) {
+ return /\bpinned\b/.test(tab.keywords) ? weight : 0;
+ },
+
+ isLoading(tab, weight) {
+ return /\bloading\b/.test(tab.keywords) ? weight : 0;
+ },
+
+ usingPictureInPicture(tab, weight) {
+ return /\bpictureinpicture\b/.test(tab.keywords) ? weight : 0;
+ },
+
+ playingMedia(tab, weight) {
+ return /\bmedia\b/.test(tab.keywords) ? weight : 0;
+ },
+
+ usingWebRTC(tab, weight) {
+ return /\bwebrtc\b/.test(tab.keywords) ? weight : 0;
+ },
+
+ isPrivate(tab, weight) {
+ return /\bprivate\b/.test(tab.keywords) ? weight : 0;
+ },
+
+ getMinTabCount() {
+ // Use a low number for testing.
+ return 3;
+ },
+
+ getNow() {
+ return 100;
+ },
+
+ *iterateProcesses(tab) {
+ for (let process of tab.process.split(",")) {
+ yield Number(process);
+ }
+ },
+
+ async calculateMemoryUsage(processMap, tabs) {
+ let memory = tabs[0].memory;
+ for (let pid of processMap.keys()) {
+ processMap.get(pid).memory = memory ? memory[pid - 1] : 1;
+ }
+ },
+};
+
+let unloadTests = [
+ // Each item in the array represents one test. The test is a subarray
+ // containing an element per tab. This is a string of keywords that
+ // identify which criteria apply. The first part of the string may contain
+ // a number that represents the last visit time, where higher numbers
+ // are later. The last element in the subarray is special and identifies
+ // the expected order of the tabs sorted by weight. The first tab in
+ // this list is the one that is expected to selected to be discarded.
+ { tabs: ["1 selected", "2", "3"], result: "1,2,0" },
+ { tabs: ["1", "2 selected", "3"], result: "0,2,1" },
+ { tabs: ["1 selected", "2", "3"], process: ["1", "2", "3"], result: "1,2,0" },
+ {
+ tabs: ["1 selected", "2 selected", "3 selected"],
+ process: ["1", "2", "3"],
+ result: "0,1,2",
+ },
+ {
+ tabs: ["1 selected", "2", "3"],
+ process: ["1,2,3", "2", "3"],
+ result: "1,2,0",
+ },
+ {
+ tabs: ["9", "8", "6", "5 selected", "2", "3", "4", "1"],
+ result: "7,4,5,6,2,1,0,3",
+ },
+ {
+ tabs: ["9", "8 pinned", "6", "5 selected", "2", "3 pinned", "4", "1"],
+ result: "7,4,6,2,0,5,1,3",
+ },
+ {
+ tabs: [
+ "9",
+ "8 pinned",
+ "6",
+ "5 selected pinned",
+ "2",
+ "3 pinned",
+ "4",
+ "1",
+ ],
+ result: "7,4,6,2,0,5,1,3",
+ },
+ {
+ tabs: [
+ "9",
+ "8 pinned",
+ "6",
+ "5 selected pinned",
+ "2",
+ "3 selected pinned",
+ "4",
+ "1",
+ ],
+ result: "7,4,6,2,0,1,5,3",
+ },
+ {
+ tabs: ["1", "2 selected", "3", "4 media", "5", "6"],
+ result: "0,2,4,5,1,3",
+ },
+ {
+ tabs: ["1 media", "2 selected media", "3", "4 media", "5", "6"],
+ result: "2,4,5,0,3,1",
+ },
+ {
+ tabs: ["1 media", "2 media pinned", "3", "4 media", "5 pinned", "6"],
+ result: "2,5,4,0,3,1",
+ },
+ {
+ tabs: [
+ "1 media",
+ "2 media pinned",
+ "3",
+ "4 media",
+ "5 media pinned",
+ "6 selected",
+ ],
+ result: "2,0,3,5,1,4",
+ },
+ {
+ tabs: [
+ "10 selected",
+ "20 private",
+ "30 webrtc",
+ "40 pictureinpicture",
+ "50 loading pinned",
+ "60",
+ ],
+ result: "5,4,0,1,2,3",
+ },
+ {
+ // Since TestTabUnloaderMethods.getNow() returns 100 and the test
+ // passes minInactiveDuration = 0 to TabUnloader.getSortedTabs(),
+ // tab 200 and 300 are excluded from the result.
+ tabs: ["300", "10", "50", "100", "200"],
+ result: "1,2,3",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6"],
+ process: ["1", "2", "1", "1", "1", "1"],
+ result: "1,0,2,3,4,5",
+ },
+ {
+ tabs: ["1", "2 selected", "3", "4", "5", "6"],
+ process: ["1", "2", "1", "1", "1", "1"],
+ result: "0,2,3,4,5,1",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6"],
+ process: ["1", "2", "2", "1", "1", "1"],
+ result: "0,1,2,3,4,5",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6"],
+ process: ["1", "2", "3", "1", "1", "1"],
+ result: "1,0,2,3,4,5",
+ },
+ {
+ tabs: ["1", "2 media", "3", "4", "5", "6"],
+ process: ["1", "2", "3", "1", "1", "1"],
+ result: "2,0,3,4,5,1",
+ },
+ {
+ tabs: ["1", "2 media", "3", "4", "5", "6"],
+ process: ["1", "2", "3", "1", "1,2,3", "1"],
+ result: "0,2,3,4,5,1",
+ },
+ {
+ tabs: ["1", "2 media", "3", "4", "5", "6"],
+ process: ["1", "2", "3", "1", "1,4,5", "1"],
+ result: "2,0,3,4,5,1",
+ },
+ {
+ tabs: ["1", "2 media", "3 media", "4", "5 media", "6"],
+ process: ["1", "2", "3", "1", "1,4,5", "1"],
+ result: "0,3,5,1,2,4",
+ },
+ {
+ tabs: ["1", "2 media", "3 media", "4", "5 media", "6"],
+ process: ["1", "1", "3", "1", "1,4,5", "1"],
+ result: "0,3,5,1,2,4",
+ },
+ {
+ tabs: ["1", "2 media", "3 media", "4", "5 media", "6"],
+ process: ["1", "2", "3", "4", "1,4,5", "5"],
+ result: "0,3,5,1,2,4",
+ },
+ {
+ tabs: ["1", "2 media", "3 media", "4", "5 media", "6"],
+ process: ["1", "1", "3", "4", "1,4,5", "5"],
+ result: "0,3,5,1,2,4",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6"],
+ process: ["1", "1", "1", "2", "1,3,4,5,6,7,8", "1"],
+ result: "0,1,2,3,4,5",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6", "7", "8"],
+ process: ["1", "1", "1", "2", "1,3,4,5,6,7,8", "1", "1", "1"],
+ result: "4,0,3,1,2,5,6,7",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5 selected", "6"],
+ process: ["1", "1", "1", "2", "1,3,4,5,6,7,8", "1"],
+ result: "0,1,2,3,5,4",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5 media", "6"],
+ process: ["1", "1", "1", "2", "1,3,4,5,6,7,8", "1"],
+ result: "0,1,2,3,5,4",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5 media", "6", "7", "8"],
+ process: ["1", "1", "1", "2", "1,3,4,5,6,7,8", "1", "1", "1"],
+ result: "0,3,1,2,5,6,7,4",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5 media", "6", "7", "8"],
+ process: ["1", "1,3,4,5,6,7,8", "1", "1", "1", "1", "1", "1"],
+ result: "1,0,2,3,5,6,7,4",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5 media", "6", "7", "8"],
+ process: ["1", "1", "1,3,4,5,6,7,8", "1", "1", "1", "1", "1"],
+ result: "2,0,1,3,5,6,7,4",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5 media", "6", "7", "8"],
+ process: ["1", "1", "1,1,1,1,1,1,1", "1", "1", "1", "1,1,1,1,1", "1"],
+ result: "0,1,2,3,5,6,7,4",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5 media", "6", "7", "8"],
+ process: ["1", "1", "1,2,3,4,5", "1", "1", "1", "1,2,3,4,5", "1"],
+ result: "0,1,2,3,5,6,7,4",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5 media", "6", "7", "8"],
+ process: ["1", "1", "1,6", "1", "1", "1", "1,2,3,4,5", "1"],
+ result: "0,2,1,3,5,6,7,4",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5 media", "6", "7", "8"],
+ process: ["1", "1", "1,6", "1,7", "1,8", "1,9", "1,2,3,4,5", "1"],
+ result: "2,3,0,5,1,6,7,4",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5 media", "6", "7", "8"],
+ process: ["1,10,11", "1", "1,2", "1,7", "1,8", "1,9", "1,2,3,4,5", "1"],
+ result: "0,3,1,5,2,6,7,4",
+ },
+ {
+ tabs: [
+ "1 media",
+ "2 media",
+ "3 media",
+ "4 media",
+ "5 media",
+ "6",
+ "7",
+ "8",
+ ],
+ process: ["1,10,11", "1", "1,2", "1,7", "1,8", "1,9", "1,2,3,4,5", "1"],
+ result: "6,5,7,0,1,2,3,4",
+ },
+ {
+ tabs: ["1", "2", "3"],
+ process: ["1", "2", "3"],
+ memory: ["100", "200", "300"],
+ result: "0,1,2",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
+ process: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
+ memory: [
+ "100",
+ "200",
+ "300",
+ "400",
+ "500",
+ "600",
+ "700",
+ "800",
+ "900",
+ "1000",
+ ],
+ result: "0,1,2,3,4,5,6,7,8,9",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
+ process: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
+ memory: [
+ "100",
+ "900",
+ "300",
+ "500",
+ "400",
+ "700",
+ "600",
+ "1000",
+ "200",
+ "200",
+ ],
+ result: "1,0,2,3,5,4,6,7,8,9",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
+ process: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
+ memory: [
+ "1000",
+ "900",
+ "300",
+ "500",
+ "400",
+ "1000",
+ "600",
+ "1000",
+ "200",
+ "200",
+ ],
+ result: "0,1,2,3,5,4,6,7,8,9",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6"],
+ process: ["1", "2,7", "3", "4", "5", "6"],
+ memory: ["100", "200", "300", "400", "500", "600", "700"],
+ result: "1,0,2,3,4,5",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6", "7", "8"],
+ process: ["1,6", "2,7", "3,8", "4,1,2", "5", "6", "7", "8"],
+ memory: ["100", "200", "300", "400", "500", "600", "700", "800"],
+ result: "2,3,0,1,4,5,6,7",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6", "7", "8"],
+ process: ["1", "1", "1", "2", "1", "1", "1", "1"],
+ memory: ["700", "1000"],
+ result: "0,3,1,2,4,5,6,7",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6", "7", "8"],
+ process: ["1", "1", "1", "1", "2,1", "2,1", "3", "3"],
+ memory: ["1000", "2000", "3000"],
+ result: "0,1,2,4,3,5,6,7",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6", "7", "8"],
+ process: ["2", "2", "2", "2", "2,1", "2,1", "3", "3"],
+ memory: ["1000", "600", "1000"],
+ result: "0,1,2,4,3,5,6,7",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6", "7", "8"],
+ process: ["1", "1", "1", "2", "2,1,1,1", "2,1", "3", "3"],
+ memory: ["1000", "1800", "1000"],
+ result: "0,1,3,2,4,5,6,7",
+ },
+ {
+ tabs: ["1", "2", "3", "4", "5", "6", "7", "8"],
+ process: ["1", "1", "1", "2", "2,1,1,1", "2,1", "3", "3"],
+ memory: ["4000", "1800", "1000"],
+ result: "0,1,2,4,3,5,6,7",
+ },
+ {
+ // The tab "1" contains 4 frames, but its uniqueCount is 1 because
+ // all of those frames are backed by the process "1". As a result,
+ // TabUnloader puts the tab "1" first based on the last access time.
+ tabs: ["1", "2", "3", "4", "5"],
+ process: ["1,1,1,1", "2", "3", "3", "3"],
+ memory: ["100", "100", "100"],
+ result: "0,1,2,3,4",
+ },
+ {
+ // The uniqueCount of the tab "1", "2", and "3" is 1, 2, and 3,
+ // respectively. As a result the first three tabs are sorted as 2,1,0.
+ tabs: ["1", "2", "3", "4", "5", "6"],
+ process: ["1,7,1,7,1,1,7,1", "7,3,7,2", "4,5,7,4,6,7", "7", "7", "7"],
+ memory: ["100", "100", "100", "100", "100", "100", "100"],
+ result: "2,1,0,3,4,5",
+ },
+];
+
+let globalBrowser = {
+ discardBrowser() {
+ return true;
+ },
+};
+
+add_task(async function doTests() {
+ for (let test of unloadTests) {
+ function* iterateTabs() {
+ let tabs = test.tabs;
+ for (let t = 0; t < tabs.length; t++) {
+ let tab = {
+ tab: {
+ originalIndex: t,
+ lastAccessed: Number(/^[0-9]+/.exec(tabs[t])[0]),
+ keywords: tabs[t],
+ process: "process" in test ? test.process[t] : "1",
+ },
+ memory: test.memory,
+ gBrowser: globalBrowser,
+ };
+ yield tab;
+ }
+ }
+ TestTabUnloaderMethods.iterateTabs = iterateTabs;
+
+ let expectedOrder = "";
+ const sortedTabs = await TabUnloader.getSortedTabs(
+ 0,
+ TestTabUnloaderMethods
+ );
+ for (let tab of sortedTabs) {
+ if (expectedOrder) {
+ expectedOrder += ",";
+ }
+ expectedOrder += tab.tab.originalIndex;
+ }
+
+ Assert.equal(expectedOrder, test.result);
+ }
+});
diff --git a/browser/modules/test/unit/test_discovery.js b/browser/modules/test/unit/test_discovery.js
new file mode 100644
index 0000000000..7237b78c20
--- /dev/null
+++ b/browser/modules/test/unit/test_discovery.js
@@ -0,0 +1,138 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+// ClientID fails without...
+do_get_profile();
+
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+const { ClientID } = ChromeUtils.importESModule(
+ "resource://gre/modules/ClientID.sys.mjs"
+);
+const { Discovery } = ChromeUtils.import("resource:///modules/Discovery.jsm");
+const { ContextualIdentityService } = ChromeUtils.importESModule(
+ "resource://gre/modules/ContextualIdentityService.sys.mjs"
+);
+
+const TAAR_COOKIE_NAME = "taarId";
+
+add_task(async function test_discovery() {
+ let uri = Services.io.newURI("https://example.com/foobar");
+
+ // Ensure the prefs we need
+ Services.prefs.setBoolPref("browser.discovery.enabled", true);
+ Services.prefs.setBoolPref("browser.discovery.containers.enabled", true);
+ Services.prefs.setBoolPref("datareporting.healthreport.uploadEnabled", true);
+ Services.prefs.setCharPref("browser.discovery.sites", uri.host);
+
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("browser.discovery.enabled");
+ Services.prefs.clearUserPref("browser.discovery.containers.enabled");
+ Services.prefs.clearUserPref("browser.discovery.sites");
+ Services.prefs.clearUserPref("datareporting.healthreport.uploadEnabled");
+ });
+
+ // This is normally initialized by telemetry, force id creation. This results
+ // in Discovery setting the cookie.
+ await ClientID.getClientID();
+ await Discovery.update();
+
+ ok(
+ Services.cookies.cookieExists(uri.host, "/", TAAR_COOKIE_NAME, {}),
+ "cookie exists"
+ );
+ ok(
+ !Services.cookies.cookieExists(uri.host, "/", TAAR_COOKIE_NAME, {
+ privateBrowsingId: 1,
+ }),
+ "no private cookie exists"
+ );
+ ContextualIdentityService.getPublicIdentities().forEach(identity => {
+ let { userContextId } = identity;
+ equal(
+ Services.cookies.cookieExists(uri.host, "/", TAAR_COOKIE_NAME, {
+ userContextId,
+ }),
+ identity.public,
+ "cookie exists"
+ );
+ });
+
+ // Test the addition of a new container.
+ let changed = TestUtils.topicObserved("cookie-changed", (subject, data) => {
+ let cookie = subject.QueryInterface(Ci.nsICookie);
+ equal(cookie.name, TAAR_COOKIE_NAME, "taar cookie exists");
+ equal(cookie.host, uri.host, "cookie exists for host");
+ equal(
+ cookie.originAttributes.userContextId,
+ container.userContextId,
+ "cookie userContextId is correct"
+ );
+ return true;
+ });
+ let container = ContextualIdentityService.create(
+ "New Container",
+ "Icon",
+ "Color"
+ );
+ await changed;
+
+ // Test disabling
+ Discovery.enabled = false;
+ // Wait for the update to remove the cookie.
+ await TestUtils.waitForCondition(() => {
+ return !Services.cookies.cookieExists(uri.host, "/", TAAR_COOKIE_NAME, {});
+ });
+
+ ContextualIdentityService.getPublicIdentities().forEach(identity => {
+ let { userContextId } = identity;
+ ok(
+ !Services.cookies.cookieExists(uri.host, "/", TAAR_COOKIE_NAME, {
+ userContextId,
+ }),
+ "no cookie exists"
+ );
+ });
+
+ // turn off containers
+ Services.prefs.setBoolPref("browser.discovery.containers.enabled", false);
+
+ Discovery.enabled = true;
+ await TestUtils.waitForCondition(() => {
+ return Services.cookies.cookieExists(uri.host, "/", TAAR_COOKIE_NAME, {});
+ });
+ // make sure we did not set cookies on containers
+ ContextualIdentityService.getPublicIdentities().forEach(identity => {
+ let { userContextId } = identity;
+ ok(
+ !Services.cookies.cookieExists(uri.host, "/", TAAR_COOKIE_NAME, {
+ userContextId,
+ }),
+ "no cookie exists"
+ );
+ });
+
+ // Make sure clientId changes update discovery
+ changed = TestUtils.topicObserved("cookie-changed", (subject, data) => {
+ if (data !== "added") {
+ return false;
+ }
+ let cookie = subject.QueryInterface(Ci.nsICookie);
+ equal(cookie.name, TAAR_COOKIE_NAME, "taar cookie exists");
+ equal(cookie.host, uri.host, "cookie exists for host");
+ return true;
+ });
+ await ClientID.removeClientID();
+ await ClientID.getClientID();
+ await changed;
+
+ // Make sure disabling telemetry disables discovery.
+ Services.prefs.setBoolPref("datareporting.healthreport.uploadEnabled", false);
+ await TestUtils.waitForCondition(() => {
+ return !Services.cookies.cookieExists(uri.host, "/", TAAR_COOKIE_NAME, {});
+ });
+});
diff --git a/browser/modules/test/unit/xpcshell.ini b/browser/modules/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..d7bda83c77
--- /dev/null
+++ b/browser/modules/test/unit/xpcshell.ini
@@ -0,0 +1,23 @@
+[DEFAULT]
+head =
+firefox-appdir = browser
+skip-if = toolkit == 'android' # bug 1730213
+
+[test_E10SUtils_nested_URIs.js]
+[test_HomePage.js]
+[test_HomePage_ignore.js]
+[test_Sanitizer_interrupted.js]
+[test_SitePermissions.js]
+[test_SitePermissions_temporary.js]
+[test_SiteDataManager.js]
+[test_SiteDataManagerContainers.js]
+[test_TabUnloader.js]
+[test_LaterRun.js]
+[test_discovery.js]
+[test_PingCentre.js]
+[test_ProfileCounter.js]
+skip-if = os != 'win' # Test of a Windows-specific feature
+[test_InstallationTelemetry.js]
+skip-if =
+ os != 'win' # Test of a Windows-specific feature
+[test_PartnerLinkAttribution.js]
diff --git a/browser/modules/webrtcUI.jsm b/browser/modules/webrtcUI.jsm
new file mode 100644
index 0000000000..92b43a34f7
--- /dev/null
+++ b/browser/modules/webrtcUI.jsm
@@ -0,0 +1,1296 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = [
+ "webrtcUI",
+ "showStreamSharingMenu",
+ "MacOSWebRTCStatusbarIndicator",
+];
+
+const { EventEmitter } = ChromeUtils.importESModule(
+ "resource:///modules/syncedtabs/EventEmitter.sys.mjs"
+);
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "BrowserWindowTracker",
+ "resource:///modules/BrowserWindowTracker.jsm"
+);
+ChromeUtils.defineESModuleGetters(lazy, {
+ SitePermissions: "resource:///modules/SitePermissions.sys.mjs",
+});
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "syncL10n",
+ () => new Localization(["browser/webrtcIndicator.ftl"], true)
+);
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "listFormat",
+ () => new Services.intl.ListFormat(undefined)
+);
+
+const SHARING_L10NID_BY_TYPE = new Map([
+ [
+ "Camera",
+ [
+ "webrtc-indicator-menuitem-sharing-camera-with",
+ "webrtc-indicator-menuitem-sharing-camera-with-n-tabs",
+ ],
+ ],
+ [
+ "Microphone",
+ [
+ "webrtc-indicator-menuitem-sharing-microphone-with",
+ "webrtc-indicator-menuitem-sharing-microphone-with-n-tabs",
+ ],
+ ],
+ [
+ "Application",
+ [
+ "webrtc-indicator-menuitem-sharing-application-with",
+ "webrtc-indicator-menuitem-sharing-application-with-n-tabs",
+ ],
+ ],
+ [
+ "Screen",
+ [
+ "webrtc-indicator-menuitem-sharing-screen-with",
+ "webrtc-indicator-menuitem-sharing-screen-with-n-tabs",
+ ],
+ ],
+ [
+ "Window",
+ [
+ "webrtc-indicator-menuitem-sharing-window-with",
+ "webrtc-indicator-menuitem-sharing-window-with-n-tabs",
+ ],
+ ],
+ [
+ "Browser",
+ [
+ "webrtc-indicator-menuitem-sharing-browser-with",
+ "webrtc-indicator-menuitem-sharing-browser-with-n-tabs",
+ ],
+ ],
+]);
+
+// These identifiers are defined in MediaStreamTrack.webidl
+const MEDIA_SOURCE_L10NID_BY_TYPE = new Map([
+ ["camera", "webrtc-item-camera"],
+ ["screen", "webrtc-item-screen"],
+ ["application", "webrtc-item-application"],
+ ["window", "webrtc-item-window"],
+ ["browser", "webrtc-item-browser"],
+ ["microphone", "webrtc-item-microphone"],
+ ["audioCapture", "webrtc-item-audio-capture"],
+]);
+
+var webrtcUI = {
+ initialized: false,
+
+ peerConnectionBlockers: new Set(),
+ emitter: new EventEmitter(),
+
+ init() {
+ if (!this.initialized) {
+ Services.obs.addObserver(this, "browser-delayed-startup-finished");
+ this.initialized = true;
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "useLegacyGlobalIndicator",
+ "privacy.webrtc.legacyGlobalIndicator",
+ true
+ );
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "deviceGracePeriodTimeoutMs",
+ "privacy.webrtc.deviceGracePeriodTimeoutMs"
+ );
+
+ Services.telemetry.setEventRecordingEnabled("webrtc.ui", true);
+ }
+ },
+
+ uninit() {
+ if (this.initialized) {
+ Services.obs.removeObserver(this, "browser-delayed-startup-finished");
+ this.initialized = false;
+ }
+ },
+
+ observe(subject, topic, data) {
+ if (topic == "browser-delayed-startup-finished") {
+ if (webrtcUI.showGlobalIndicator) {
+ showOrCreateMenuForWindow(subject);
+ }
+ }
+ },
+
+ SHARING_NONE: 0,
+ SHARING_WINDOW: 1,
+ SHARING_SCREEN: 2,
+
+ // Set of browser windows that are being shared over WebRTC.
+ sharedBrowserWindows: new WeakSet(),
+
+ // True if one or more screens is being shared.
+ sharingScreen: false,
+
+ allowedSharedBrowsers: new WeakSet(),
+ allowTabSwitchesForSession: false,
+ tabSwitchCountForSession: 0,
+
+ // True if a window or screen is being shared.
+ sharingDisplay: false,
+
+ // The session ID is used to try to differentiate between instances
+ // where the user is sharing their display somehow. If the user
+ // transitions from a state of not sharing their display, to sharing a
+ // display, we bump the ID.
+ sharingDisplaySessionId: 0,
+
+ // Map of browser elements to indicator data.
+ perTabIndicators: new Map(),
+ activePerms: new Map(),
+
+ get showGlobalIndicator() {
+ for (let [, indicators] of this.perTabIndicators) {
+ if (
+ indicators.showCameraIndicator ||
+ indicators.showMicrophoneIndicator ||
+ indicators.showScreenSharingIndicator
+ ) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ get showCameraIndicator() {
+ for (let [, indicators] of this.perTabIndicators) {
+ if (indicators.showCameraIndicator) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ get showMicrophoneIndicator() {
+ for (let [, indicators] of this.perTabIndicators) {
+ if (indicators.showMicrophoneIndicator) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ get showScreenSharingIndicator() {
+ let list = [""];
+ for (let [, indicators] of this.perTabIndicators) {
+ if (indicators.showScreenSharingIndicator) {
+ list.push(indicators.showScreenSharingIndicator);
+ }
+ }
+
+ let precedence = ["Screen", "Window", "Application", "Browser", ""];
+
+ list.sort((a, b) => {
+ return precedence.indexOf(a) - precedence.indexOf(b);
+ });
+
+ return list[0];
+ },
+
+ _streams: [],
+ // The boolean parameters indicate which streams should be included in the result.
+ getActiveStreams(aCamera, aMicrophone, aScreen, aWindow = false) {
+ return webrtcUI._streams
+ .filter(aStream => {
+ let state = aStream.state;
+ return (
+ (aCamera && state.camera) ||
+ (aMicrophone && state.microphone) ||
+ (aScreen && state.screen) ||
+ (aWindow && state.window)
+ );
+ })
+ .map(aStream => {
+ let state = aStream.state;
+ let types = {
+ camera: state.camera,
+ microphone: state.microphone,
+ screen: state.screen,
+ window: state.window,
+ };
+ let browser = aStream.topBrowsingContext.embedderElement;
+ // browser can be null when we are in the process of closing a tab
+ // and our stream list hasn't been updated yet.
+ // gBrowser will be null if a stream is used outside a tabbrowser window.
+ let tab = browser?.ownerGlobal.gBrowser?.getTabForBrowser(browser);
+ return {
+ uri: state.documentURI,
+ tab,
+ browser,
+ types,
+ devices: state.devices,
+ };
+ });
+ },
+
+ /**
+ * Returns true if aBrowser has an active WebRTC stream.
+ */
+ browserHasStreams(aBrowser) {
+ for (let stream of this._streams) {
+ if (stream.topBrowsingContext.embedderElement == aBrowser) {
+ return true;
+ }
+ }
+
+ return false;
+ },
+
+ /**
+ * Determine the combined state of all the active streams associated with
+ * the specified top-level browsing context.
+ */
+ getCombinedStateForBrowser(aTopBrowsingContext) {
+ function combine(x, y) {
+ if (
+ x == Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED ||
+ y == Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED
+ ) {
+ return Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED;
+ }
+ if (
+ x == Ci.nsIMediaManagerService.STATE_CAPTURE_DISABLED ||
+ y == Ci.nsIMediaManagerService.STATE_CAPTURE_DISABLED
+ ) {
+ return Ci.nsIMediaManagerService.STATE_CAPTURE_DISABLED;
+ }
+ return Ci.nsIMediaManagerService.STATE_NOCAPTURE;
+ }
+
+ let camera, microphone, screen, window, browser;
+ for (let stream of this._streams) {
+ if (stream.topBrowsingContext == aTopBrowsingContext) {
+ camera = combine(stream.state.camera, camera);
+ microphone = combine(stream.state.microphone, microphone);
+ screen = combine(stream.state.screen, screen);
+ window = combine(stream.state.window, window);
+ browser = combine(stream.state.browser, browser);
+ }
+ }
+
+ let tabState = { camera, microphone };
+ if (screen == Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED) {
+ tabState.screen = "Screen";
+ } else if (window == Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED) {
+ tabState.screen = "Window";
+ } else if (browser == Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED) {
+ tabState.screen = "Browser";
+ } else if (screen == Ci.nsIMediaManagerService.STATE_CAPTURE_DISABLED) {
+ tabState.screen = "ScreenPaused";
+ } else if (window == Ci.nsIMediaManagerService.STATE_CAPTURE_DISABLED) {
+ tabState.screen = "WindowPaused";
+ } else if (browser == Ci.nsIMediaManagerService.STATE_CAPTURE_DISABLED) {
+ tabState.screen = "BrowserPaused";
+ }
+
+ let screenEnabled = tabState.screen && !tabState.screen.includes("Paused");
+ let cameraEnabled =
+ tabState.camera == Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED;
+ let microphoneEnabled =
+ tabState.microphone == Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED;
+
+ // tabState.sharing controls which global indicator should be shown
+ // for the tab. It should always be set to the _enabled_ device which
+ // we consider most intrusive (screen > camera > microphone).
+ if (screenEnabled) {
+ tabState.sharing = "screen";
+ } else if (cameraEnabled) {
+ tabState.sharing = "camera";
+ } else if (microphoneEnabled) {
+ tabState.sharing = "microphone";
+ } else if (tabState.screen) {
+ tabState.sharing = "screen";
+ } else if (tabState.camera) {
+ tabState.sharing = "camera";
+ } else if (tabState.microphone) {
+ tabState.sharing = "microphone";
+ }
+
+ // The stream is considered paused when we're sharing something
+ // but all devices are off or set to disabled.
+ tabState.paused =
+ tabState.sharing &&
+ !screenEnabled &&
+ !cameraEnabled &&
+ !microphoneEnabled;
+
+ if (
+ tabState.camera == Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED ||
+ tabState.camera == Ci.nsIMediaManagerService.STATE_CAPTURE_DISABLED
+ ) {
+ tabState.showCameraIndicator = true;
+ }
+ if (
+ tabState.microphone == Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED ||
+ tabState.microphone == Ci.nsIMediaManagerService.STATE_CAPTURE_DISABLED
+ ) {
+ tabState.showMicrophoneIndicator = true;
+ }
+
+ tabState.showScreenSharingIndicator = "";
+ if (tabState.screen) {
+ if (tabState.screen.startsWith("Screen")) {
+ tabState.showScreenSharingIndicator = "Screen";
+ } else if (tabState.screen.startsWith("Window")) {
+ if (tabState.showScreenSharingIndicator != "Screen") {
+ tabState.showScreenSharingIndicator = "Window";
+ }
+ } else if (tabState.screen.startsWith("Browser")) {
+ if (!tabState.showScreenSharingIndicator) {
+ tabState.showScreenSharingIndicator = "Browser";
+ }
+ }
+ }
+
+ return tabState;
+ },
+
+ /*
+ * Indicate that a stream has been added or removed from the given
+ * browsing context. If it has been added, aData specifies the
+ * specific indicator types it uses. If aData is null or has no
+ * documentURI assigned, then the stream has been removed.
+ */
+ streamAddedOrRemoved(aBrowsingContext, aData) {
+ this.init();
+
+ let index;
+ for (index = 0; index < webrtcUI._streams.length; ++index) {
+ let stream = this._streams[index];
+ if (stream.browsingContext == aBrowsingContext) {
+ break;
+ }
+ }
+ // The update is a removal of the stream, triggered by the
+ // recording-window-ended notification.
+ if (aData.remove) {
+ if (index < this._streams.length) {
+ this._streams.splice(index, 1);
+ }
+ } else {
+ this._streams[index] = {
+ browsingContext: aBrowsingContext,
+ topBrowsingContext: aBrowsingContext.top,
+ state: aData,
+ };
+ }
+
+ let wasSharingDisplay = this.sharingDisplay;
+
+ // Reset our internal notion of whether or not we're sharing
+ // a screen or browser window. Now we'll go through the shared
+ // devices and re-determine what's being shared.
+ let sharingBrowserWindow = false;
+ let sharedWindowRawDeviceIds = new Set();
+ this.sharingDisplay = false;
+ this.sharingScreen = false;
+ let suppressNotifications = false;
+
+ // First, go through the streams and collect the counts on things
+ // like the total number of shared windows, and whether or not we're
+ // sharing screens.
+ for (let stream of this._streams) {
+ let { state } = stream;
+ suppressNotifications |= state.suppressNotifications;
+
+ for (let device of state.devices) {
+ let mediaSource = device.mediaSource;
+
+ if (mediaSource == "window" || mediaSource == "screen") {
+ this.sharingDisplay = true;
+ }
+
+ if (!device.scary) {
+ continue;
+ }
+
+ if (mediaSource == "window") {
+ sharedWindowRawDeviceIds.add(device.rawId);
+ } else if (mediaSource == "screen") {
+ this.sharingScreen = true;
+ }
+
+ // If the user has granted a particular site the ability
+ // to get a stream from a window or screen, we will
+ // presume that it's exempt from the tab switch warning.
+ //
+ // We use the permanentKey here so that the allowing of
+ // the tab survives tab tear-in and tear-out. We ignore
+ // browsers that don't have permanentKey, since those aren't
+ // tabbrowser browsers.
+ let browser = stream.topBrowsingContext.embedderElement;
+ if (browser.permanentKey) {
+ this.allowedSharedBrowsers.add(browser.permanentKey);
+ }
+ }
+ }
+
+ // Next, go through the list of shared windows, and map them
+ // to our browser windows so that we know which ones are shared.
+ this.sharedBrowserWindows = new WeakSet();
+
+ for (let win of lazy.BrowserWindowTracker.orderedWindows) {
+ let rawDeviceId;
+ try {
+ rawDeviceId = win.windowUtils.webrtcRawDeviceId;
+ } catch (e) {
+ // This can theoretically throw if some of the underlying
+ // window primitives don't exist. In that case, we can skip
+ // to the next window.
+ continue;
+ }
+ if (sharedWindowRawDeviceIds.has(rawDeviceId)) {
+ this.sharedBrowserWindows.add(win);
+
+ // If we've shared a window, then the initially selected tab
+ // in that window should be exempt from tab switch warnings,
+ // since it's already been shared.
+ let selectedBrowser = win.gBrowser.selectedBrowser;
+ this.allowedSharedBrowsers.add(selectedBrowser.permanentKey);
+
+ sharingBrowserWindow = true;
+ }
+ }
+
+ // If we weren't sharing a window or screen, and now are, bump
+ // the sharingDisplaySessionId. We use this ID for Event
+ // telemetry, and consider a transition from no shared displays
+ // to some shared displays as a new session.
+ if (!wasSharingDisplay && this.sharingDisplay) {
+ this.sharingDisplaySessionId++;
+ }
+
+ // If we were adding a new display stream, record some Telemetry for
+ // it with the most recent sharedDisplaySessionId. We do this separately
+ // from the loops above because those take into account the pre-existing
+ // streams that might already have been shared.
+ if (aData.devices) {
+ // The mixture of camelCase with under_score notation here is due to
+ // an unfortunate collision of conventions between this file and
+ // Event Telemetry.
+ let silence_notifs = suppressNotifications ? "true" : "false";
+ for (let device of aData.devices) {
+ if (device.mediaSource == "screen") {
+ this.recordEvent("share_display", "screen", {
+ silence_notifs,
+ });
+ } else if (device.mediaSource == "window") {
+ if (device.scary) {
+ this.recordEvent("share_display", "browser_window", {
+ silence_notifs,
+ });
+ } else {
+ this.recordEvent("share_display", "window", {
+ silence_notifs,
+ });
+ }
+ }
+ }
+ }
+
+ // Since we're not sharing a screen or browser window,
+ // we can clear these state variables, which are used
+ // to warn users on tab switching when sharing. These
+ // are safe to reset even if we hadn't been sharing
+ // the screen or browser window already.
+ if (!this.sharingScreen && !sharingBrowserWindow) {
+ this.allowedSharedBrowsers = new WeakSet();
+ this.allowTabSwitchesForSession = false;
+ this.tabSwitchCountForSession = 0;
+ }
+
+ this._setSharedData();
+ if (
+ Services.prefs.getBoolPref(
+ "privacy.webrtc.allowSilencingNotifications",
+ false
+ )
+ ) {
+ let alertsService = Cc["@mozilla.org/alerts-service;1"]
+ .getService(Ci.nsIAlertsService)
+ .QueryInterface(Ci.nsIAlertsDoNotDisturb);
+ alertsService.suppressForScreenSharing = suppressNotifications;
+ }
+ },
+
+ /**
+ * Remove all the streams associated with a given
+ * browsing context.
+ */
+ forgetStreamsFromBrowserContext(aBrowsingContext) {
+ for (let index = 0; index < webrtcUI._streams.length; ) {
+ let stream = this._streams[index];
+ if (stream.browsingContext == aBrowsingContext) {
+ this._streams.splice(index, 1);
+ } else {
+ index++;
+ }
+ }
+
+ // Remove the per-tab indicator if it no longer needs to be displayed.
+ let topBC = aBrowsingContext.top;
+ if (this.perTabIndicators.has(topBC)) {
+ let tabState = this.getCombinedStateForBrowser(topBC);
+ if (
+ !tabState.showCameraIndicator &&
+ !tabState.showMicrophoneIndicator &&
+ !tabState.showScreenSharingIndicator
+ ) {
+ this.perTabIndicators.delete(topBC);
+ }
+ }
+
+ this.updateGlobalIndicator();
+ this._setSharedData();
+ },
+
+ /**
+ * Given some set of streams, stops device access for those streams.
+ * Optionally, it's possible to stop a subset of the devices on those
+ * streams by passing in optional arguments.
+ *
+ * Once the streams have been stopped, this method will also find the
+ * newest stream's <xul:browser> and window, focus the window, and
+ * select the browser.
+ *
+ * For camera and microphone streams, this will also revoke any associated
+ * permissions from SitePermissions.
+ *
+ * @param {Array<Object>} activeStreams - An array of streams obtained via webrtcUI.getActiveStreams.
+ * @param {boolean} stopCameras - True to stop the camera streams (defaults to true)
+ * @param {boolean} stopMics - True to stop the microphone streams (defaults to true)
+ * @param {boolean} stopScreens - True to stop the screen streams (defaults to true)
+ * @param {boolean} stopWindows - True to stop the window streams (defaults to true)
+ */
+ stopSharingStreams(
+ activeStreams,
+ stopCameras = true,
+ stopMics = true,
+ stopScreens = true,
+ stopWindows = true
+ ) {
+ if (!activeStreams.length) {
+ return;
+ }
+
+ let ids = [];
+ if (stopCameras) {
+ ids.push("camera");
+ }
+ if (stopMics) {
+ ids.push("microphone");
+ }
+ if (stopScreens || stopWindows) {
+ ids.push("screen");
+ }
+
+ for (let stream of activeStreams) {
+ let { browser } = stream;
+
+ let gBrowser = browser.getTabBrowser();
+ if (!gBrowser) {
+ console.error("Can't stop sharing stream - cannot find gBrowser.");
+ continue;
+ }
+
+ let tab = gBrowser.getTabForBrowser(browser);
+ if (!tab) {
+ console.error("Can't stop sharing stream - cannot find tab.");
+ continue;
+ }
+
+ this.clearPermissionsAndStopSharing(ids, tab);
+ }
+
+ // Switch to the newest stream's browser.
+ let mostRecentStream = activeStreams[activeStreams.length - 1];
+ let { browser: browserToSelect } = mostRecentStream;
+
+ let window = browserToSelect.ownerGlobal;
+ let gBrowser = browserToSelect.getTabBrowser();
+ let tab = gBrowser.getTabForBrowser(browserToSelect);
+ window.focus();
+ gBrowser.selectedTab = tab;
+ },
+
+ /**
+ * Clears permissions and stops sharing (if active) for a list of device types
+ * and a specific tab.
+ * @param {("camera"|"microphone"|"screen")[]} types - Device types to stop
+ * and clear permissions for.
+ * @param tab - Tab of the devices to stop and clear permissions.
+ */
+ clearPermissionsAndStopSharing(types, tab) {
+ let invalidTypes = types.filter(
+ type => !["camera", "screen", "microphone", "speaker"].includes(type)
+ );
+ if (invalidTypes.length) {
+ throw new Error(`Invalid device types ${invalidTypes.join(",")}`);
+ }
+ let browser = tab.linkedBrowser;
+ let sharingState = tab._sharingState?.webRTC;
+
+ // If we clear a WebRTC permission we need to remove all permissions of
+ // the same type across device ids. We also need to stop active WebRTC
+ // devices related to the permission.
+ let perms = lazy.SitePermissions.getAllForBrowser(browser);
+
+ // If capturing, don't revoke one of camera/microphone without the other.
+ let sharingCameraOrMic =
+ (sharingState?.camera || sharingState?.microphone) &&
+ (types.includes("camera") || types.includes("microphone"));
+
+ perms
+ .filter(perm => {
+ let [id] = perm.id.split(lazy.SitePermissions.PERM_KEY_DELIMITER);
+ if (sharingCameraOrMic && (id == "camera" || id == "microphone")) {
+ return true;
+ }
+ return types.includes(id);
+ })
+ .forEach(perm => {
+ lazy.SitePermissions.removeFromPrincipal(
+ browser.contentPrincipal,
+ perm.id,
+ browser
+ );
+ });
+
+ if (!sharingState?.windowId) {
+ return;
+ }
+
+ // If the device of the permission we're clearing is currently active,
+ // tell the WebRTC implementation to stop sharing it.
+ let { windowId } = sharingState;
+
+ let windowIds = [];
+ if (types.includes("screen") && sharingState.screen) {
+ windowIds.push(`screen:${windowId}`);
+ }
+ if (sharingCameraOrMic) {
+ windowIds.push(windowId);
+ }
+
+ if (!windowIds.length) {
+ return;
+ }
+
+ let actor =
+ sharingState.browsingContext.currentWindowGlobal.getActor("WebRTC");
+
+ // Delete activePerms for all outerWindowIds under the current browser. We
+ // need to do this prior to sending the stopSharing message, so WebRTCParent
+ // can skip adding grace periods for these devices.
+ webrtcUI.forgetActivePermissionsFromBrowser(browser);
+
+ windowIds.forEach(id => actor.sendAsyncMessage("webrtc:StopSharing", id));
+ },
+
+ updateIndicators(aTopBrowsingContext) {
+ let tabState = this.getCombinedStateForBrowser(aTopBrowsingContext);
+
+ let indicators;
+ if (this.perTabIndicators.has(aTopBrowsingContext)) {
+ indicators = this.perTabIndicators.get(aTopBrowsingContext);
+ } else {
+ indicators = {};
+ this.perTabIndicators.set(aTopBrowsingContext, indicators);
+ }
+
+ indicators.showCameraIndicator = tabState.showCameraIndicator;
+ indicators.showMicrophoneIndicator = tabState.showMicrophoneIndicator;
+ indicators.showScreenSharingIndicator = tabState.showScreenSharingIndicator;
+ this.updateGlobalIndicator();
+
+ return tabState;
+ },
+
+ swapBrowserForNotification(aOldBrowser, aNewBrowser) {
+ for (let stream of this._streams) {
+ if (stream.browser == aOldBrowser) {
+ stream.browser = aNewBrowser;
+ }
+ }
+ },
+
+ /**
+ * Remove all entries from the activePerms map for a browser, including all
+ * child frames.
+ * Note: activePerms is an internal WebRTC UI permission map and does not
+ * reflect the PermissionManager or SitePermissions state.
+ * @param aBrowser - Browser to clear active permissions for.
+ */
+ forgetActivePermissionsFromBrowser(aBrowser) {
+ let browserWindowIds = aBrowser.browsingContext
+ .getAllBrowsingContextsInSubtree()
+ .map(bc => bc.currentWindowGlobal?.outerWindowId)
+ .filter(id => id != null);
+ browserWindowIds.push(aBrowser.outerWindowId);
+ browserWindowIds.forEach(id => this.activePerms.delete(id));
+ },
+
+ /**
+ * Shows the Permission Panel for the tab associated with the provided
+ * active stream.
+ * @param aActiveStream - The stream that the user wants to see permissions for.
+ * @param aEvent - The user input event that is invoking the panel. This can be
+ * undefined / null if no such event exists.
+ */
+ showSharingDoorhanger(aActiveStream, aEvent) {
+ let browserWindow = aActiveStream.browser.ownerGlobal;
+ if (aActiveStream.tab) {
+ browserWindow.gBrowser.selectedTab = aActiveStream.tab;
+ } else {
+ aActiveStream.browser.focus();
+ }
+ browserWindow.focus();
+
+ if (AppConstants.platform == "macosx" && !Services.focus.activeWindow) {
+ browserWindow.addEventListener(
+ "activate",
+ function () {
+ Services.tm.dispatchToMainThread(function () {
+ browserWindow.gPermissionPanel.openPopup(aEvent);
+ });
+ },
+ { once: true }
+ );
+ Cc["@mozilla.org/widget/macdocksupport;1"]
+ .getService(Ci.nsIMacDockSupport)
+ .activateApplication(true);
+ return;
+ }
+ browserWindow.gPermissionPanel.openPopup(aEvent);
+ },
+
+ updateWarningLabel(aMenuList) {
+ let type = aMenuList.selectedItem.getAttribute("devicetype");
+ let document = aMenuList.ownerDocument;
+ document.getElementById("webRTC-all-windows-shared").hidden =
+ type != "screen";
+ },
+
+ // Add-ons can override stock permission behavior by doing:
+ //
+ // webrtcUI.addPeerConnectionBlocker(function(aParams) {
+ // // new permission checking logic
+ // }));
+ //
+ // The blocking function receives an object with origin, callID, and windowID
+ // parameters. If it returns the string "deny" or a Promise that resolves
+ // to "deny", the connection is immediately blocked. With any other return
+ // value (though the string "allow" is suggested for consistency), control
+ // is passed to other registered blockers. If no registered blockers block
+ // the connection (or of course if there are no registered blockers), then
+ // the connection is allowed.
+ //
+ // Add-ons may also use webrtcUI.on/off to listen to events without
+ // blocking anything:
+ // peer-request-allowed is emitted when a new peer connection is
+ // established (and not blocked).
+ // peer-request-blocked is emitted when a peer connection request is
+ // blocked by some blocking connection handler.
+ // peer-request-cancel is emitted when a peer-request connection request
+ // is canceled. (This would typically be used in
+ // conjunction with a blocking handler to cancel
+ // a user prompt or other work done by the handler)
+ addPeerConnectionBlocker(aCallback) {
+ this.peerConnectionBlockers.add(aCallback);
+ },
+
+ removePeerConnectionBlocker(aCallback) {
+ this.peerConnectionBlockers.delete(aCallback);
+ },
+
+ on(...args) {
+ return this.emitter.on(...args);
+ },
+
+ off(...args) {
+ return this.emitter.off(...args);
+ },
+
+ getHostOrExtensionName(uri, href) {
+ let host;
+ try {
+ if (!uri) {
+ uri = Services.io.newURI(href);
+ }
+
+ let addonPolicy = WebExtensionPolicy.getByURI(uri);
+ host = addonPolicy?.name ?? uri.hostPort;
+ } catch (ex) {}
+
+ if (!host) {
+ if (uri && uri.scheme.toLowerCase() == "about") {
+ // For about URIs, just use the full spec, without any #hash parts.
+ host = uri.specIgnoringRef;
+ } else {
+ // This is unfortunate, but we should display *something*...
+ host = lazy.syncL10n.formatValueSync(
+ "webrtc-sharing-menuitem-unknown-host"
+ );
+ }
+ }
+ return host;
+ },
+
+ updateGlobalIndicator() {
+ for (let chromeWin of Services.wm.getEnumerator("navigator:browser")) {
+ if (this.showGlobalIndicator) {
+ showOrCreateMenuForWindow(chromeWin);
+ } else {
+ let doc = chromeWin.document;
+ let existingMenu = doc.getElementById("tabSharingMenu");
+ if (existingMenu) {
+ existingMenu.hidden = true;
+ }
+ if (AppConstants.platform == "macosx") {
+ let separator = doc.getElementById("tabSharingSeparator");
+ if (separator) {
+ separator.hidden = true;
+ }
+ }
+ }
+ }
+
+ if (this.showGlobalIndicator) {
+ if (!gIndicatorWindow) {
+ gIndicatorWindow = getGlobalIndicator();
+ } else {
+ try {
+ gIndicatorWindow.updateIndicatorState();
+ } catch (err) {
+ console.error(
+ `error in gIndicatorWindow.updateIndicatorState(): ${err.message}`
+ );
+ }
+ }
+ } else if (gIndicatorWindow) {
+ if (
+ !webrtcUI.useLegacyGlobalIndicator &&
+ gIndicatorWindow.closingInternally
+ ) {
+ // Before calling .close(), we call .closingInternally() to allow us to
+ // differentiate between situations where the indicator closes because
+ // we no longer want to show the indicator (this case), and cases where
+ // the user has found a way to close the indicator via OS window control
+ // mechanisms.
+ gIndicatorWindow.closingInternally();
+ }
+ gIndicatorWindow.close();
+ gIndicatorWindow = null;
+ }
+ },
+
+ getWindowShareState(window) {
+ if (this.sharingScreen) {
+ return this.SHARING_SCREEN;
+ } else if (this.sharedBrowserWindows.has(window)) {
+ return this.SHARING_WINDOW;
+ }
+ return this.SHARING_NONE;
+ },
+
+ tabAddedWhileSharing(tab) {
+ this.allowedSharedBrowsers.add(tab.linkedBrowser.permanentKey);
+ },
+
+ shouldShowSharedTabWarning(tab) {
+ if (!tab || !tab.linkedBrowser) {
+ return false;
+ }
+
+ let browser = tab.linkedBrowser;
+ // We want the user to be able to switch to one tab after starting
+ // to share their window or screen. The presumption here is that
+ // most users will have a single window with multiple tabs, where
+ // the selected tab will be the one with the screen or window
+ // sharing web application, and it's most likely that the contents
+ // that the user wants to share are in another tab that they'll
+ // switch to immediately upon sharing. These presumptions are based
+ // on research that our user research team did with users using
+ // video conferencing web applications.
+ if (!this.tabSwitchCountForSession) {
+ this.allowedSharedBrowsers.add(browser.permanentKey);
+ }
+
+ this.tabSwitchCountForSession++;
+ let shouldShow =
+ !this.allowTabSwitchesForSession &&
+ !this.allowedSharedBrowsers.has(browser.permanentKey);
+
+ return shouldShow;
+ },
+
+ allowSharedTabSwitch(tab, allowForSession) {
+ let browser = tab.linkedBrowser;
+ let gBrowser = browser.getTabBrowser();
+ this.allowedSharedBrowsers.add(browser.permanentKey);
+ gBrowser.selectedTab = tab;
+ this.allowTabSwitchesForSession = allowForSession;
+ },
+
+ recordEvent(type, object, args = {}) {
+ Services.telemetry.recordEvent(
+ "webrtc.ui",
+ type,
+ object,
+ this.sharingDisplaySessionId.toString(),
+ args
+ );
+ },
+
+ /**
+ * Updates the sharedData structure to reflect shared screen and window
+ * state. This sets the following key: data pairs on sharedData.
+ * - "webrtcUI:isSharingScreen": a boolean value reflecting
+ * this.sharingScreen.
+ * - "webrtcUI:sharedTopInnerWindowIds": a set containing the inner window
+ * ids of each top level browser window that is in sharedBrowserWindows.
+ */
+ _setSharedData() {
+ let sharedTopInnerWindowIds = new Set();
+ for (let win of lazy.BrowserWindowTracker.orderedWindows) {
+ if (this.sharedBrowserWindows.has(win)) {
+ sharedTopInnerWindowIds.add(
+ win.browsingContext.currentWindowGlobal.innerWindowId
+ );
+ }
+ }
+ Services.ppmm.sharedData.set(
+ "webrtcUI:isSharingScreen",
+ this.sharingScreen
+ );
+ Services.ppmm.sharedData.set(
+ "webrtcUI:sharedTopInnerWindowIds",
+ sharedTopInnerWindowIds
+ );
+ },
+};
+
+function getGlobalIndicator() {
+ if (!webrtcUI.useLegacyGlobalIndicator) {
+ const INDICATOR_CHROME_URI =
+ "chrome://browser/content/webrtcIndicator.xhtml";
+ let features = "chrome,titlebar=no,alwaysontop,minimizable,dialog";
+
+ return Services.ww.openWindow(
+ null,
+ INDICATOR_CHROME_URI,
+ "_blank",
+ features,
+ null
+ );
+ }
+
+ if (AppConstants.platform != "macosx") {
+ const LEGACY_INDICATOR_CHROME_URI =
+ "chrome://browser/content/webrtcLegacyIndicator.xhtml";
+ const features = "chrome,dialog=yes,titlebar=no,popup=yes";
+
+ return Services.ww.openWindow(
+ null,
+ LEGACY_INDICATOR_CHROME_URI,
+ "_blank",
+ features,
+ null
+ );
+ }
+
+ return new MacOSWebRTCStatusbarIndicator();
+}
+
+/**
+ * Add a localized stream sharing menu to the event target
+ *
+ * @param {Window} win - The parent `window`
+ * @param {Event} event - The popupshowing event for the <menu>.
+ * @param {boolean} inclWindow - Should the window stream be included in the active streams.
+ */
+function showStreamSharingMenu(win, event, inclWindow = false) {
+ win.MozXULElement.insertFTLIfNeeded("browser/webrtcIndicator.ftl");
+ const doc = win.document;
+ const menu = event.target;
+
+ let type = menu.getAttribute("type");
+ let activeStreams;
+ if (type == "Camera") {
+ activeStreams = webrtcUI.getActiveStreams(true, false, false);
+ } else if (type == "Microphone") {
+ activeStreams = webrtcUI.getActiveStreams(false, true, false);
+ } else if (type == "Screen") {
+ activeStreams = webrtcUI.getActiveStreams(false, false, true, inclWindow);
+ type = webrtcUI.showScreenSharingIndicator;
+ }
+
+ if (!activeStreams.length) {
+ event.preventDefault();
+ return;
+ }
+
+ const l10nIds = SHARING_L10NID_BY_TYPE.get(type) ?? [];
+ if (activeStreams.length == 1) {
+ let stream = activeStreams[0];
+
+ const sharingItem = doc.createXULElement("menuitem");
+ const streamTitle = stream.browser.contentTitle || stream.uri;
+ doc.l10n.setAttributes(sharingItem, l10nIds[0], { streamTitle });
+ sharingItem.setAttribute("disabled", "true");
+ menu.appendChild(sharingItem);
+
+ const controlItem = doc.createXULElement("menuitem");
+ doc.l10n.setAttributes(
+ controlItem,
+ "webrtc-indicator-menuitem-control-sharing"
+ );
+ controlItem.stream = stream;
+ controlItem.addEventListener("command", this);
+
+ menu.appendChild(controlItem);
+ } else {
+ // We show a different menu when there are several active streams.
+ const sharingItem = doc.createXULElement("menuitem");
+ doc.l10n.setAttributes(sharingItem, l10nIds[1], {
+ tabCount: activeStreams.length,
+ });
+ sharingItem.setAttribute("disabled", "true");
+ menu.appendChild(sharingItem);
+
+ for (let stream of activeStreams) {
+ const controlItem = doc.createXULElement("menuitem");
+ const streamTitle = stream.browser.contentTitle || stream.uri;
+ doc.l10n.setAttributes(
+ controlItem,
+ "webrtc-indicator-menuitem-control-sharing-on",
+ { streamTitle }
+ );
+ controlItem.stream = stream;
+ controlItem.addEventListener("command", this);
+ menu.appendChild(controlItem);
+ }
+ }
+}
+
+/**
+ * Controls the visibility of screen, camera and microphone sharing indicators
+ * in the macOS global menu bar. This class should only ever be instantiated
+ * on macOS.
+ *
+ * The public methods on this class intentionally match the interface for the
+ * WebRTC global sharing indicator, because the MacOSWebRTCStatusbarIndicator
+ * acts as the indicator when in the legacy indicator configuration.
+ */
+class MacOSWebRTCStatusbarIndicator {
+ constructor() {
+ this._camera = null;
+ this._microphone = null;
+ this._screen = null;
+
+ this._hiddenDoc = Services.appShell.hiddenDOMWindow.document;
+ this._statusBar = Cc["@mozilla.org/widget/systemstatusbar;1"].getService(
+ Ci.nsISystemStatusBar
+ );
+
+ this.updateIndicatorState();
+ }
+
+ /**
+ * Public method that will determine the most appropriate
+ * set of indicators to show, and then show them or hide
+ * them as necessary.
+ */
+ updateIndicatorState() {
+ this._setIndicatorState("Camera", webrtcUI.showCameraIndicator);
+ this._setIndicatorState("Microphone", webrtcUI.showMicrophoneIndicator);
+ this._setIndicatorState("Screen", webrtcUI.showScreenSharingIndicator);
+ }
+
+ /**
+ * Public method that will hide all indicators.
+ */
+ close() {
+ this._setIndicatorState("Camera", false);
+ this._setIndicatorState("Microphone", false);
+ this._setIndicatorState("Screen", false);
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "popupshowing": {
+ this._popupShowing(event);
+ break;
+ }
+ case "popuphiding": {
+ this._popupHiding(event);
+ break;
+ }
+ case "command": {
+ this._command(event);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Handler for command events fired by the <menuitem> elements
+ * inside any of the indicator <menu>'s.
+ *
+ * @param {Event} aEvent - The command event for the <menuitem>.
+ */
+ _command(aEvent) {
+ webrtcUI.showSharingDoorhanger(aEvent.target.stream, aEvent);
+ }
+
+ /**
+ * Handler for the popupshowing event for one of the status
+ * bar indicator menus.
+ *
+ * @param {Event} aEvent - The popupshowing event for the <menu>.
+ */
+ _popupShowing(aEvent) {
+ const menu = aEvent.target;
+ showStreamSharingMenu(menu.ownerGlobal, aEvent);
+ return true;
+ }
+
+ /**
+ * Handler for the popuphiding event for one of the status
+ * bar indicator menus.
+ *
+ * @param {Event} aEvent - The popuphiding event for the <menu>.
+ */
+ _popupHiding(aEvent) {
+ let menu = aEvent.target;
+ while (menu.firstChild) {
+ menu.firstChild.remove();
+ }
+ }
+
+ /**
+ * Updates the status bar to show or hide a screen, camera or
+ * microphone indicator.
+ *
+ * @param {String} aName - One of the following: "screen", "camera",
+ * "microphone"
+ * @param {boolean} aState - True to show the indicator for the aName
+ * type of stream, false ot hide it.
+ */
+ _setIndicatorState(aName, aState) {
+ let field = "_" + aName.toLowerCase();
+ if (aState && !this[field]) {
+ let menu = this._hiddenDoc.createXULElement("menu");
+ menu.setAttribute("id", "webRTC-sharing" + aName + "-menu");
+
+ // The CSS will only be applied if the menu is actually inserted in the DOM.
+ this._hiddenDoc.documentElement.appendChild(menu);
+
+ this._statusBar.addItem(menu);
+
+ let menupopup = this._hiddenDoc.createXULElement("menupopup");
+ menupopup.setAttribute("type", aName);
+ menupopup.addEventListener("popupshowing", this);
+ menupopup.addEventListener("popuphiding", this);
+ menupopup.addEventListener("command", this);
+ menu.appendChild(menupopup);
+
+ this[field] = menu;
+ } else if (this[field] && !aState) {
+ this._statusBar.removeItem(this[field]);
+ this[field].remove();
+ this[field] = null;
+ }
+ }
+}
+
+function onTabSharingMenuPopupShowing(e) {
+ const streams = webrtcUI.getActiveStreams(true, true, true, true);
+ for (let streamInfo of streams) {
+ const names = streamInfo.devices.map(({ mediaSource }) => {
+ const l10nId = MEDIA_SOURCE_L10NID_BY_TYPE.get(mediaSource);
+ return l10nId ? lazy.syncL10n.formatValueSync(l10nId) : mediaSource;
+ });
+
+ const doc = e.target.ownerDocument;
+ const menuitem = doc.createXULElement("menuitem");
+ doc.l10n.setAttributes(menuitem, "webrtc-sharing-menuitem", {
+ origin: webrtcUI.getHostOrExtensionName(null, streamInfo.uri),
+ itemList: lazy.listFormat.format(names),
+ });
+ menuitem.stream = streamInfo;
+ menuitem.addEventListener("command", onTabSharingMenuPopupCommand);
+ e.target.appendChild(menuitem);
+ }
+}
+
+function onTabSharingMenuPopupHiding(e) {
+ while (this.lastChild) {
+ this.lastChild.remove();
+ }
+}
+
+function onTabSharingMenuPopupCommand(e) {
+ webrtcUI.showSharingDoorhanger(e.target.stream, e);
+}
+
+function showOrCreateMenuForWindow(aWindow) {
+ let document = aWindow.document;
+ let menu = document.getElementById("tabSharingMenu");
+ if (!menu) {
+ menu = document.createXULElement("menu");
+ menu.id = "tabSharingMenu";
+ document.l10n.setAttributes(menu, "webrtc-sharing-menu");
+
+ let container, insertionPoint;
+ if (AppConstants.platform == "macosx") {
+ container = document.getElementById("menu_ToolsPopup");
+ insertionPoint = document.getElementById("devToolsSeparator");
+ let separator = document.createXULElement("menuseparator");
+ separator.id = "tabSharingSeparator";
+ container.insertBefore(separator, insertionPoint);
+ } else {
+ container = document.getElementById("main-menubar");
+ insertionPoint = document.getElementById("helpMenu");
+ }
+ let popup = document.createXULElement("menupopup");
+ popup.id = "tabSharingMenuPopup";
+ popup.addEventListener("popupshowing", onTabSharingMenuPopupShowing);
+ popup.addEventListener("popuphiding", onTabSharingMenuPopupHiding);
+ menu.appendChild(popup);
+ container.insertBefore(menu, insertionPoint);
+ } else {
+ menu.hidden = false;
+ if (AppConstants.platform == "macosx") {
+ document.getElementById("tabSharingSeparator").hidden = false;
+ }
+ }
+}
+
+var gIndicatorWindow = null;