diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
commit | 43a97878ce14b72f0981164f87f2e35e14151312 (patch) | |
tree | 620249daf56c0258faa40cbdcf9cfba06de2a846 /browser/modules | |
parent | Initial commit. (diff) | |
download | firefox-upstream.tar.xz firefox-upstream.zip |
Adding upstream version 110.0.1.upstream/110.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/modules')
97 files changed, 34167 insertions, 0 deletions
diff --git a/browser/modules/AboutNewTab.jsm b/browser/modules/AboutNewTab.jsm new file mode 100644 index 0000000000..cf7f67dc32 --- /dev/null +++ b/browser/modules/AboutNewTab.jsm @@ -0,0 +1,304 @@ +/* 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", + RemotePages: + "resource://gre/modules/remotepagemanager/RemotePageManagerParent.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, + + pageListener: null, + isPageListenerOverridden: 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 + * and creates the instance of Remote Page Manager which Activity Stream + * uses for message passing. + * + * @param {obj} pageListener - Optional argument. An existing instance of RemotePages + * which Activity Stream has previously made, and we + * would like to re-use. + */ + init(pageListener) { + 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; + + if (this.isPageListenerOverridden) { + return; + } + + // Since `init` can be called via `reset` at a later time with an existing + // pageListener, we want to only add the observer if we are initializing + // without this pageListener argument. This means it was the first call to `init` + if (!pageListener) { + Services.obs.addObserver(this, BROWSER_READY_NOTIFICATION); + } + + this.pageListener = + pageListener || + new lazy.RemotePages(["about:home", "about:newtab", "about:welcome"]); + }, + + /** + * 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, and destroys the pageListener + * if it exists. + */ + uninit() { + if (this.activityStream) { + this._unsubscribeFromActivityStream?.(); + this.activityStream.uninit(); + this.activityStream = null; + } + + if (this.pageListener) { + this.pageListener.destroy(); + this.pageListener = null; + } + this.initialized = false; + }, + + overridePageListener(shouldPassPageListener) { + this.isPageListenerOverridden = true; + + const pageListener = this.pageListener; + if (!pageListener) { + return null; + } + if (shouldPassPageListener) { + this.pageListener = null; + return pageListener; + } + this.uninit(); + return null; + }, + + reset(pageListener) { + this.isPageListenerOverridden = false; + this.init(pageListener); + }, + + 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..3ac4204edd --- /dev/null +++ b/browser/modules/AsyncTabSwitcher.jsm @@ -0,0 +1,1489 @@ +/* -*- 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 + ); + } + } + ChromeUtils.addProfilerMarker("AsyncTabSwitch:Composited"); + 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); + ChromeUtils.addProfilerMarker("AsyncTabSwitch:Start"); + } + + 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); + ChromeUtils.addProfilerMarker("AsyncTabSwitch:Finish"); + } + } + + 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 + ); + ChromeUtils.addProfilerMarker("AsyncTabSwitch:SpinnerShown"); + 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 + ); + ChromeUtils.addProfilerMarker("AsyncTabSwitch:SpinnerHidden"); + // we do not get a onPaint after displaying the spinner + this._loadTimerClearedBy = "none"; + } +} diff --git a/browser/modules/BackgroundTask_uninstall.sys.mjs b/browser/modules/BackgroundTask_uninstall.sys.mjs new file mode 100644 index 0000000000..17aa6968a3 --- /dev/null +++ b/browser/modules/BackgroundTask_uninstall.sys.mjs @@ -0,0 +1,38 @@ +/* -*- 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"; + +export async function runBackgroundTask(commandLine) { + if (AppConstants.platform !== "win") { + console.log("Not a Windows install, skipping `uninstall` background task."); + return; + } + console.log("Running BackgroundTask_uninstall."); + + removeNotifications(); +} + +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..9124b0b31b --- /dev/null +++ b/browser/modules/BrowserUIUtils.jsm @@ -0,0 +1,215 @@ +/* -*- 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; + }, + + /** + * Sets the --toolbarbutton-button-height CSS property on the closest + * toolbar to the provided element. Useful if you need to vertically + * center a position:absolute element within a toolbar that uses + * -moz-pack-align:stretch, and thus a height which is dependant on + * the font-size. + * + * @param element An element within the toolbar whose height is desired. + */ + async setToolbarButtonHeightProperty(element) { + let window = element.ownerGlobal; + let dwu = window.windowUtils; + let toolbarItem = element; + let urlBarContainer = element.closest("#urlbar-container"); + if (urlBarContainer) { + // The stop-reload-button, which is contained in #urlbar-container, + // needs to use #urlbar-container to calculate the bounds. + toolbarItem = urlBarContainer; + } + if (!toolbarItem) { + return; + } + let bounds = dwu.getBoundsWithoutFlushing(toolbarItem); + if (!bounds.height) { + await window.promiseDocumentFlushed(() => { + bounds = dwu.getBoundsWithoutFlushing(toolbarItem); + }); + } + if (bounds.height) { + toolbarItem.style.setProperty( + "--toolbarbutton-height", + bounds.height + "px" + ); + } + }, + + /** + * 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..7d720062f9 --- /dev/null +++ b/browser/modules/BrowserUsageTelemetry.jsm @@ -0,0 +1,1381 @@ +/* -*- 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", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs", + clearInterval: "resource://gre/modules/Timer.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setInterval: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + CustomizableUI: "resource:///modules/CustomizableUI.jsm", + PageActions: "resource:///modules/PageActions.jsm", + WindowsInstallsInfo: + "resource://gre/modules/components-utils/WindowsInstallsInfo.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 CONTENT_PROCESS_COUNT = "CONTENT_PROCESS_COUNT"; +const CONTENT_PROCESS_PRECISE_COUNT = "CONTENT_PROCESS_PRECISE_COUNT"; + +const MINIMUM_TAB_COUNT_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes, in ms +const CONTENT_PROCESS_COUNT_INTERVAL_MS = 5 * 60 * 1000; + +// 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", + + // This should appear last as some of the above are inside the nav bar. + "nav-bar": "nav-bar", +}; + +// 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", +]; + +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); + } + + // 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._recordContentProcessCountInterval = lazy.setInterval( + () => this._recordContentProcessCount(), + CONTENT_PROCESS_COUNT_INTERVAL_MS + ); + }, + + /** + * 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); + + lazy.clearInterval(this._recordContentProcessCountInterval); + }, + + 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(getOpenTabsAndWinsCounts()); + 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)) { + 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 types = [event.type]; + let sourceEvent = event; + while (sourceEvent.sourceEvent) { + sourceEvent = sourceEvent.sourceEvent; + types.push(sourceEvent.type); + } + + 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; + } + } + + 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); + } + } + }, + + /** + * 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. + * @param {Object} [counts] The counts returned by `getOpenTabsAndWindowCounts`. + */ + _onTabOpen({ tabCount, loadedTabCount }) { + // Update the "tab opened" count and its maximum. + Services.telemetry.scalarAdd(TAB_OPEN_EVENT_COUNT_SCALAR_NAME, 1); + 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") { + // This is currently a windows-only feature. + 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 + ); + }, + + /** + * 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; + } + + 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 + ); + }, + + /** + * Record the number of content processes. + */ + _recordContentProcessCount() { + // All DOM processes includes the parent. + const count = ChromeUtils.getAllDOMProcesses().length - 1; + + Services.telemetry.getHistogramById(CONTENT_PROCESS_COUNT).add(count); + Services.telemetry + .getHistogramById(CONTENT_PROCESS_PRECISE_COUNT) + .add(count); + }, +}; + +// 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..b3928e19fc --- /dev/null +++ b/browser/modules/BrowserWindowTracker.jsm @@ -0,0 +1,329 @@ +/* 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 +var _lastTopBrowsingContextID = 0; +var _trackedWindows = []; + +// Global methods +function debug(s) { + if (DEBUG) { + dump("-*- UpdateTopBrowsingContextIDHelper: " + s + "\n"); + } +} + +function _updateCurrentBrowsingContextID(browser) { + if ( + !browser.browsingContext || + browser.browsingContext.id === _lastTopBrowsingContextID || + browser.ownerGlobal != _trackedWindows[0] + ) { + return; + } + + debug( + "Current window uri=" + + (browser.currentURI && browser.currentURI.spec) + + " browsing context id=" + + browser.browsingContext.id + ); + + _lastTopBrowsingContextID = browser.browsingContext.id; + let idWrapper = Cc["@mozilla.org/supports-PRUint64;1"].createInstance( + Ci.nsISupportsPRUint64 + ); + idWrapper.data = _lastTopBrowsingContextID; + Services.obs.notifyObservers( + idWrapper, + "net:current-top-browsing-context-id" + ); +} + +function _handleEvent(event) { + switch (event.type) { + case "TabBrowserInserted": + if ( + event.target.ownerGlobal.gBrowser.selectedBrowser === + event.target.linkedBrowser + ) { + _updateCurrentBrowsingContextID(event.target.linkedBrowser); + } + break; + case "TabSelect": + _updateCurrentBrowsingContextID(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. + _updateCurrentBrowsingContextID(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); + + _updateCurrentBrowsingContextID(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; + }, + + windowCreated(browser) { + if (browser === browser.ownerGlobal.gBrowser.selectedBrowser) { + _updateCurrentBrowsingContextID(browser); + } + }, + + /** + * 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..a323f7bad3 --- /dev/null +++ b/browser/modules/ContentCrashHandlers.jsm @@ -0,0 +1,1145 @@ +/* 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, { + 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", + CrashSubmit: "resource://gre/modules/CrashSubmit.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..d9327f09f6 --- /dev/null +++ b/browser/modules/ExtensionsUI.jsm @@ -0,0 +1,705 @@ +/* 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, { + AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.jsm", + AddonManagerPrivate: "resource://gre/modules/AddonManager.jsm", + AMTelemetry: "resource://gre/modules/AddonManager.jsm", + ExtensionData: "resource://gre/modules/Extension.jsm", + ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.jsm", + OriginControls: "resource://gre/modules/ExtensionPermissions.jsm", +}); + +const DEFAULT_EXTENSION_ICON = + "chrome://mozapps/skin/extensions/extensionGeneric.svg"; + +const BROWSER_PROPERTIES = "chrome://browser/locale/browser.properties"; +const BRAND_PROPERTIES = "chrome://branding/locale/brand.properties"; + +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, + histogram: null, + + pendingNotifications: new WeakMap(), + + async init() { + this.histogram = Services.telemetry.getHistogramById( + "EXTENSION_INSTALL_PROMPT_RESULT" + ); + + 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, histkey) { + let global = tabbrowser.selectedBrowser.ownerGlobal; + return global + .BrowserOpenAddonsMgr("addons://list/extension") + .then(aomWin => { + let aomBrowser = aomWin.docShell.chromeEventHandler; + return this.showPermissionsPrompt(aomBrowser, strings, icon, histkey); + }); + }, + + 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, "sideload").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, + "update" + ).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; + + let histkey; + if (info.type == "sideload") { + histkey = "sideload"; + } else if (info.type == "update") { + histkey = "update"; + } else if (info.source == "AMO") { + histkey = "installAmo"; + } else if (info.source == "local") { + histkey = "installLocal"; + } else { + histkey = "installWeb"; + } + + 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, histkey).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; + + let bundle = Services.strings.createBundle(BROWSER_PROPERTIES); + + let strings = {}; + strings.acceptText = bundle.GetStringFromName( + "webext.defaultSearchYes.label" + ); + strings.acceptKey = bundle.GetStringFromName( + "webext.defaultSearchYes.accessKey" + ); + strings.cancelText = bundle.GetStringFromName( + "webext.defaultSearchNo.label" + ); + strings.cancelKey = bundle.GetStringFromName( + "webext.defaultSearchNo.accessKey" + ); + strings.addonName = name; + strings.text = bundle.formatStringFromName( + "webext.defaultSearch.description", + ["<>", currentEngine, newEngine] + ); + + this.showDefaultSearchPrompt(browser, strings, icon).then(respond); + } + }, + + // Create a set of formatted strings for a permission prompt + _buildStrings(info) { + let bundle = Services.strings.createBundle(BROWSER_PROPERTIES); + let brandBundle = Services.strings.createBundle(BRAND_PROPERTIES); + let appName = brandBundle.GetStringFromName("brandShortName"); + let info2 = Object.assign({ appName }, info); + + let strings = lazy.ExtensionData.formatPermissionStrings(info2, bundle, { + collapseOrigins: true, + }); + strings.addonName = info.addon.name; + strings.learnMore = bundle.GetStringFromName("webextPerms.learnMore2"); + return strings; + }, + + async showPermissionsPrompt(target, strings, icon, histkey) { + 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; + + 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, + }; + // 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: () => { + if (histkey) { + this.histogram.add(histkey + "Accepted"); + } + resolve(true); + }, + }; + let secondaryActions = [ + { + label: strings.cancelText, + accessKey: strings.cancelKey, + callback: () => { + if (histkey) { + this.histogram.add(histkey + "Rejected"); + } + resolve(false); + }, + }, + ]; + + if (browser.ownerGlobal.gUnifiedExtensions.isEnabled) { + options.popupOptions = { + position: "bottomright topright", + }; + } + + 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); + let bundle = window.gNavigatorBundle; + + let message = bundle.getFormattedString("addonPostInstall.message3", [ + "<>", + ]); + 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: [], + }; + + let value; + // 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); + value = "on"; + } else if (hasIncognito) { + await lazy.ExtensionPermissions.remove(addon.id, incognitoPermission); + value = "off"; + } + if (value !== undefined) { + lazy.AMTelemetry.recordActionEvent({ + addon, + object: "doorhanger", + action: "privateBrowsingAllowed", + view: "postInstall", + value, + }); + } + // 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); + if (!policy?.extension.originControls) { + return; + } + + let win = popup.ownerGlobal; + let uri = win.gBrowser.currentURI; + let state = lazy.OriginControls.getState(policy, uri); + + let doc = popup.ownerDocument; + let whenClicked, alwaysOn, allDomains; + let separator = doc.createXULElement("menuseparator"); + + let headerItem = doc.createXULElement("menuitem"); + headerItem.setAttribute("disabled", true); + + if (state.noAccess) { + 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 = () => items.forEach(item => item?.remove()); + popup.addEventListener("popuphidden", cleanup, { once: true }); + }, +}; + +EventEmitter.decorate(ExtensionsUI); diff --git a/browser/modules/FaviconLoader.jsm b/browser/modules/FaviconLoader.jsm new file mode 100644 index 0000000000..8e91dd1532 --- /dev/null +++ b/browser/modules/FaviconLoader.jsm @@ -0,0 +1,716 @@ +/* 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..582854896f --- /dev/null +++ b/browser/modules/FeatureCallout.sys.mjs @@ -0,0 +1,900 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/*eslint-env browser*/ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const TRANSITION_MS = 500; +const CONTAINER_ID = "root"; + +/** + * Feature Callout fetches messages relevant to a given source and displays them in + * the parent page pointing to the element they describe. + * @param {Window} Window in which messages will be rendered + * @param {String} Name of the pref used to track progress through a given feature tour + * @param {String} Optional string to pass as the source when checking for messages to show, + * defaults to this.doc.location.pathname.toLowerCase(). + * @param {Browser} browser + + */ +export class FeatureCallout { + constructor({ win, prefName, source, browser }) { + this.win = win || window; + this.doc = win.document; + this.browser = browser || this.win.docShell.chromeEventHandler; + this.config = null; + this.loadingConfig = false; + this.currentScreen = null; + this.renderObserver = null; + this.savedActiveElement = null; + this.ready = false; + this.listenersRegistered = false; + this.AWSetup = false; + this.source = source || this.doc.location.pathname.toLowerCase(); + this.focusHandler = this._focusHandler.bind(this); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "featureTourProgress", + prefName, + '{"screen":"","complete":true}', + this._handlePrefChange.bind(this), + val => JSON.parse(val) + ); + + 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 + + XPCOMUtils.defineLazyModuleGetters(this, { + AboutWelcomeParent: "resource:///actors/AboutWelcomeParent.jsm", + ASRouter: "resource://activity-stream/lib/ASRouter.jsm", + PageEventManager: "resource://activity-stream/lib/PageEventManager.jsm", + }); + + XPCOMUtils.defineLazyGetter(this, "pageEventManager", () => { + this.win.pageEventManager = new this.PageEventManager(this.doc); + return this.win.pageEventManager; + }); + + const inChrome = + this.win.location.toString() === "chrome://browser/content/browser.xhtml"; + // 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 (!inChrome) { + this.win.addEventListener( + "visibilitychange", + this._handlePrefChange.bind(this) + ); + } + + const positionCallout = this._positionCallout.bind(this); + + this._addPositionListeners = () => { + if (!this.listenersRegistered) { + this.win.addEventListener("resize", positionCallout); + const parentEl = this.doc.querySelector( + this.currentScreen?.parent_selector + ); + parentEl?.addEventListener("toggle", positionCallout); + this.listenersRegistered = true; + } + }; + + this._removePositionListeners = () => { + if (this.listenersRegistered) { + this.win.removeEventListener("resize", positionCallout); + const parentEl = this.doc.querySelector( + this.currentScreen?.parent_selector + ); + parentEl?.removeEventListener("toggle", positionCallout); + this.listenersRegistered = 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; + const container = this.doc.getElementById(CONTAINER_ID); + container?.classList.add("hidden"); + this.win.pageEventManager?.clear(); + // wait for fade out transition + this.win.setTimeout(async () => { + await this._loadConfig(); + container?.remove(); + this._removePositionListeners(); + await this._renderCallout(); + }, TRANSITION_MS); + } + } + _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", + "browser/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) { + return false; + } + + let container = this.doc.createElement("div"); + container.classList.add( + "onboardingContainer", + "featureCallout", + "callout-arrow", + "hidden" + ); + container.id = CONTAINER_ID; + container.setAttribute( + "aria-describedby", + `#${CONTAINER_ID} .welcome-text` + ); + container.tabIndex = 0; + this.doc.body.prepend(container); + return container; + } + + /** + * Set callout's position relative to parent element + */ + _positionCallout() { + const container = this.doc.getElementById(CONTAINER_ID); + 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"); + } + + _setupWindowFunctions() { + if (this.AWSetup) { + return; + } + const AWParent = new this.AboutWelcomeParent(); + this.win.addEventListener("unload", () => { + AWParent.didDestroy(); + }); + const receive = name => data => + AWParent.onContentMessage(`AWPage:${name}`, data, this.doc); + // Expose top level functions expected by the bundle. + this.win.AWGetFeatureConfig = () => this.config; + this.win.AWGetRegion = receive("GET_REGION"); + 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.AWSetup = true; + } + + _clearWindowFunctions() { + const windowFuncs = [ + "AWGetFeatureConfig", + "AWGetRegion", + "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 effect + // this.savedActiveElement + this.win.removeEventListener("focus", this.focusHandler, { + capture: true, + }); + this.win.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 + let container = this.doc.getElementById(CONTAINER_ID); + container?.classList.add("hidden"); + this._clearWindowFunctions(); + this.win.setTimeout( + () => { + container?.remove(); + this.renderObserver?.disconnect(); + // 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 + ); + } + + 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. + let bundleScript = this.doc.createElement("script"); + bundleScript.src = + "resource://activity-stream/aboutwelcome/aboutwelcome.bundle.js"; + this.doc.head.appendChild(bundleScript); + } + + _observeRender(container) { + this.renderObserver?.observe(container, { childList: true }); + } + + async _loadConfig() { + if (this.loadingConfig) { + return false; + } + this.loadingConfig = true; + await this.ASRouter.waitForInitialized; + let result = await this.ASRouter.sendTriggerMessage({ + browser: this.browser, + // triggerId and triggerContext + id: "featureCalloutCheck", + context: { source: this.source }, + }); + 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) { + this.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(); + } + } + + _focusHandler(e) { + let container = this.doc.getElementById(CONTAINER_ID); + if (!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 ( + e.target.id === CONTAINER_ID || + (Node.isInstance(e.target) && container.contains(e.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; + } + + /** + * 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.jsm + * @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.pageEventManager[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.doc.location.href; + 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._endTour(); + } + } + + /** + * 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.doc.querySelector(`#${CONTAINER_ID} .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._positionCallout(); + let container = this.doc.getElementById(CONTAINER_ID); + container.focus(); + this.win.addEventListener("focus", this.focusHandler, { + capture: true, // get the event before retargeting + }); + }); + }); + } + }); + + this.win.pageEventManager?.clear(); + this.ready = false; + const container = this.doc.getElementById(CONTAINER_ID); + 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(); + } +} diff --git a/browser/modules/HomePage.jsm b/browser/modules/HomePage.jsm new file mode 100644 index 0000000000..db8af26001 --- /dev/null +++ b/browser/modules/HomePage.jsm @@ -0,0 +1,366 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + IgnoreLists: "resource://gre/modules/IgnoreLists.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + CustomizableUI: "resource:///modules/CustomizableUI.jsm", + ExtensionParent: "resource://gre/modules/ExtensionParent.jsm", + ExtensionPreferencesManager: + "resource://gre/modules/ExtensionPreferencesManager.jsm", +}); + +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..f748e7e072 --- /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(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..d20933155f --- /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..4b8135aab7 --- /dev/null +++ b/browser/modules/PartnerLinkAttribution.sys.mjs @@ -0,0 +1,217 @@ +/* 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); + }, + + /** + * 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.jsm b/browser/modules/PermissionUI.jsm new file mode 100644 index 0000000000..b69e070098 --- /dev/null +++ b/browser/modules/PermissionUI.jsm @@ -0,0 +1,1498 @@ +/* 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 = ["PermissionUI"]; + +/** + * 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.PermissionPromptForRequestPrototype + * ); + * return permissionPrompt; + * }, + * }; + * Object.setPrototypeOf(soundCardObj, base); + * return soundCardObj; + * }; + * + * // Add-on startup: + * Integration.contentPermission.register(SoundCardIntegration); + * // ... + * // Add-on shutdown: + * Integration.contentPermission.unregister(SoundCardIntegration); + * + * Note that PermissionPromptForRequestPrototype 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 PermissionPromptPrototype can be + * imported, subclassed, and have prompt() called directly, without + * the caller having called into createPermissionPrompt. + */ +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "AddonManager", + "resource://gre/modules/AddonManager.jsm" +); +ChromeUtils.defineModuleGetter( + lazy, + "SitePermissions", + "resource:///modules/SitePermissions.jsm" +); +ChromeUtils.defineESModuleGetters(lazy, { + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.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" + ); +}); + +const { SITEPERMS_ADDON_PROVIDER_PREF } = ChromeUtils.importESModule( + "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs" +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "sitePermsAddonsProviderEnabled", + SITEPERMS_ADDON_PROVIDER_PREF, + false +); + +var PermissionUI = {}; + +/** + * PermissionPromptPrototype 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 + * PermissionPromptForRequestPrototype instead. + */ +var PermissionPromptPrototype = { + /** + * 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, + undefined, + this.temporaryPermissionURI + ); + } + + // 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, + undefined, + this.temporaryPermissionURI + ); + } + } + }, + }; + 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 + ); + } + }, +}; + +PermissionUI.PermissionPromptPrototype = PermissionPromptPrototype; + +/** + * A subclass of PermissionPromptPrototype 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 PermissionPromptPrototype. + */ +var PermissionPromptForRequestPrototype = { + 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); + }, +}; +Object.setPrototypeOf( + PermissionPromptForRequestPrototype, + PermissionPromptPrototype +); + +PermissionUI.PermissionPromptForRequestPrototype = PermissionPromptForRequestPrototype; + +/** + * A subclass of PermissionPromptForRequestPrototype that prompts + * for a Synthetic SitePermsAddon addon type and starts a synthetic + * addon install flow. + */ +var SitePermsAddonInstallRequestPrototype = { + prompt() { + // fallback to regular permission prompt for localhost, + // or when the SitePermsAddonProvider is not enabled. + if (this.principal.isLoopbackHost || !lazy.sitePermsAddonsProviderEnabled) { + PermissionPromptForRequestPrototype.prompt.call(this); + 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; + }, +}; +Object.setPrototypeOf( + SitePermsAddonInstallRequestPrototype, + PermissionPromptForRequestPrototype +); + +PermissionUI.SitePermsAddonInstallRequestPrototype = SitePermsAddonInstallRequestPrototype; + +/** + * Creates a PermissionPrompt for a nsIContentPermissionRequest for + * the GeoLocation API. + * + * @param request (nsIContentPermissionRequest) + * The request for a permission from content. + */ +function GeolocationPermissionPrompt(request) { + this.request = request; +} + +GeolocationPermissionPrompt.prototype = { + 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(), + }; + + if (this.principal.schemeIs("file")) { + options.checkbox = { show: false }; + } else { + // 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); + PermissionPromptForRequestPrototype.allow.apply(this, args); + }, + + cancel(...args) { + this._updateGeoSharing(false); + PermissionPromptForRequestPrototype.cancel.apply(this, args); + }, +}; +Object.setPrototypeOf( + GeolocationPermissionPrompt.prototype, + PermissionPromptForRequestPrototype +); + +PermissionUI.GeolocationPermissionPrompt = GeolocationPermissionPrompt; + +/** + * Creates a PermissionPrompt for a nsIContentPermissionRequest for + * the WebXR API. + * + * @param request (nsIContentPermissionRequest) + * The request for a permission from content. + */ +function XRPermissionPrompt(request) { + this.request = request; +} + +XRPermissionPrompt.prototype = { + 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(), + }; + + if (this.principal.schemeIs("file")) { + options.checkbox = { show: false }; + } else { + // 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); + PermissionPromptForRequestPrototype.allow.apply(this, args); + }, + + cancel(...args) { + this._updateXRSharing(false); + PermissionPromptForRequestPrototype.cancel.apply(this, args); + }, +}; +Object.setPrototypeOf( + XRPermissionPrompt.prototype, + PermissionPromptForRequestPrototype +); + +PermissionUI.XRPermissionPrompt = XRPermissionPrompt; + +/** + * 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) + */ +function DesktopNotificationPermissionPrompt(request) { + 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" + ); +} + +DesktopNotificationPermissionPrompt.prototype = { + 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; + }, +}; +Object.setPrototypeOf( + DesktopNotificationPermissionPrompt.prototype, + PermissionPromptForRequestPrototype +); + +PermissionUI.DesktopNotificationPermissionPrompt = DesktopNotificationPermissionPrompt; + +/** + * Creates a PermissionPrompt for a nsIContentPermissionRequest for + * the persistent-storage API. + * + * @param request (nsIContentPermissionRequest) + * The request for a permission from content. + */ +function PersistentStoragePermissionPrompt(request) { + this.request = request; +} + +PersistentStoragePermissionPrompt.prototype = { + 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, + }, + ]; + }, +}; +Object.setPrototypeOf( + PersistentStoragePermissionPrompt.prototype, + PermissionPromptForRequestPrototype +); + +PermissionUI.PersistentStoragePermissionPrompt = PersistentStoragePermissionPrompt; + +/** + * Creates a PermissionPrompt for a nsIContentPermissionRequest for + * the WebMIDI API. + * + * @param request (nsIContentPermissionRequest) + * The request for a permission from content. + */ +function MIDIPermissionPrompt(request) { + 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"; + } +} + +MIDIPermissionPrompt.prototype = { + 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(), + }; + + if (this.principal.schemeIs("file")) { + options.checkbox = { show: false }; + } else { + // 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`; + }, +}; +Object.setPrototypeOf( + MIDIPermissionPrompt.prototype, + SitePermsAddonInstallRequestPrototype +); + +PermissionUI.MIDIPermissionPrompt = MIDIPermissionPrompt; + +function StorageAccessPermissionPrompt(request) { + 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; + } +} + +StorageAccessPermissionPrompt.prototype = { + 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; + }, +}; +Object.setPrototypeOf( + StorageAccessPermissionPrompt.prototype, + PermissionPromptForRequestPrototype +); + +PermissionUI.StorageAccessPermissionPrompt = StorageAccessPermissionPrompt; diff --git a/browser/modules/PingCentre.jsm b/browser/modules/PingCentre.jsm new file mode 100644 index 0000000000..7d9bfb18ec --- /dev/null +++ b/browser/modules/PingCentre.jsm @@ -0,0 +1,154 @@ +/* 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 + */ + sendStructuredIngestionPing(data, endpoint) { + 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` + ); + } + + return PingCentre._sendStandalonePing(endpoint, payload).catch(event => { + Glean.pingCentre.sendFailures.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..508f88698f --- /dev/null +++ b/browser/modules/ProcessHangMonitor.jsm @@ -0,0 +1,697 @@ +/* -*- 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..2e8d50d210 --- /dev/null +++ b/browser/modules/Sanitizer.sys.mjs @@ -0,0 +1,1124 @@ +// -*- 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; + +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; + 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() { + 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, principalsForShutdownClearing }) { + 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 (principalsForShutdownClearing) { + 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, principalsForShutdownClearing }) { + // This is true if called by sanitizeOnShutdown. + // On shutdown we clear by principal to be able to honor the users exceptions + if (principalsForShutdownClearing) { + // Cleaning per principal to be able to consider the users exceptions + 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 }) { + 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. + let principalsCollector = new lazy.PrincipalsCollector(); + progress.step = "getAllPrincipals"; + let principals = await principalsCollector.getAllPrincipals(progress); + 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); + }; + + // When clearing on shutdown we clear by principal for certain cleaning categories, to consider the users exceptions + if (progress.clearHonoringExceptions) { + let principalsCollector = new lazy.PrincipalsCollector(); + let principals = await principalsCollector.getAllPrincipals(progress); + options.principalsForShutdownClearing = principals; + } + // 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"] }) + ) + .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); + } + + // In case the user has not activated sanitizeOnShutdown but has explicitely set exceptions + // to always clear particular origins, we clear those here + let principalsCollector = new lazy.PrincipalsCollector(); + + progress.advancement = "session-permission"; + + let exceptions = 0; + // 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. + let principals = await principalsCollector.getAllPrincipals(progress); + let selectedPrincipals = 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; + 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..6853baa788 --- /dev/null +++ b/browser/modules/SiteDataManager.jsm @@ -0,0 +1,664 @@ +/* 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.jsm b/browser/modules/SitePermissions.jsm new file mode 100644 index 0000000000..56f991f30f --- /dev/null +++ b/browser/modules/SitePermissions.jsm @@ -0,0 +1,1327 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var EXPORTED_SYMBOLS = ["SitePermissions"]; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "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|prePath>: { + // <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 prePaths 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 URI prePath. + _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 + * nsIURI.prePath, non-strict is URI baseDomain. + * @param {nsIURI} uri - URI 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 URI is undefined or no valid permission key can + * be generated. + */ + _getKeysFromURI(uri) { + return { strict: uri.prePath, nonStrict: this._uriToBaseDomain(uri) }; + }, + + /** + * Sets a new permission for the specified browser. + * @returns {boolean} whether the permission changed, effectively. + */ + set(browser, id, state, expireTimeMS, browserURI, expireCallback) { + if ( + !browser || + !browserURI || + !SitePermissions.isSupportedScheme(browserURI.scheme) + ) { + 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 URI prePath. + let { strict, nonStrict } = this._getKeysFromURI(browserURI); + 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 URI + // prePath 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 prePath 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 prePath 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.isSupportedScheme(browser.currentURI.scheme) || + !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._getKeysFromURI(browser.currentURI); + 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, browserURI = browser?.currentURI) { + if ( + !browser || + !browser.currentURI || + !SitePermissions.isSupportedScheme(browserURI.scheme) || + !this._stateByBrowser.has(browser) + ) { + return null; + } + let { uriToPerm } = this._stateByBrowser.get(browser); + + let { strict, nonStrict } = this._getKeysFromURI(browserURI); + 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, browserURI = browser?.currentURI) { + let permissions = []; + if ( + !SitePermissions.isSupportedScheme(browserURI.scheme) || + !this._stateByBrowser.has(browser) + ) { + return permissions; + } + let { uriToPerm } = this._stateByBrowser.get(browser); + + let { strict, nonStrict } = this._getKeysFromURI(browserURI); + 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 prePath = browser.currentURI.prePath; + if (!entry[prePath]) { + entry[prePath] = {}; + } + + if (entry[prePath][id]) { + return false; + } + entry[prePath][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. + 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, prePath); + browser.removeProgressListener(this); + } + }, + }, + Ci.nsIWebProgress.NOTIFY_LOCATION + ); + return true; + }, + + // Removes a permission with the specified id for the specified browser. + remove(browser, id, prePath = null) { + let entry = this._stateByBrowser.get(browser); + if (!prePath) { + prePath = browser.currentURI.prePath; + } + if (entry && entry[prePath]) { + delete entry[prePath][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 prePath = browser.currentURI.prePath; + if (entry && entry[prePath]) { + let timeStamps = entry[prePath]; + 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. + */ +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 (optional) + * The browser object to check for temporary permissions. + * @param {nsIURI} browserURI (optional) + * The URI to check for temporary permissions under. Defaults to + * browser's currentURI. + * + * @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, + browserURI = browser?.currentURI + ) { + 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, browserURI); + + 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. + * Note that this will be ignored if the scope is set to SCOPE_TEMPORARY + * @param {String} permissionID + * The id of the permission. + * @param {SitePermissions state} state + * The state of the permission. + * @param {SitePermissions scope} scope (optional) + * The scope of the permission. Defaults to SCOPE_PERSISTENT. + * @param {Browser} browser (optional) + * The browser object to set temporary permissions on. + * This needs to be provided if the scope is SCOPE_TEMPORARY! + * @param {number} expireTimeMS (optional) If setting a temporary permission, + * how many milliseconds it should be valid for. + * @param {nsIURI} browserURI (optional) Pass a custom URI for the + * temporary permission scope. This defaults to the current URI of the + * browser. + */ + setForPrincipal( + principal, + permissionID, + state, + scope = this.SCOPE_PERSISTENT, + browser = null, + expireTimeMS = SitePermissions.temporaryPermissionExpireTime, + browserURI = browser?.currentURI + ) { + 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, + browserURI, + // 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..694b0b9de9 --- /dev/null +++ b/browser/modules/TabsList.jsm @@ -0,0 +1,533 @@ +/* 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.defineModuleGetter( + lazy, + "PanelMultiView", + "resource:///modules/PanelMultiView.jsm" +); + +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; + } + } + + _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); + + 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); + + 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.hasAttribute("toggle-mute")) { + event.target.tab.toggleMuteAudio(); + 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", "right"); + button.tab = tab; + + row.appendChild(button); + + let secondaryButton = doc.createXULElement("toolbarbutton"); + secondaryButton.setAttribute( + "class", + "all-tabs-secondary-button subviewbutton subviewbutton-iconic" + ); + secondaryButton.setAttribute("closemenu", "none"); + secondaryButton.setAttribute("toggle-mute", "true"); + secondaryButton.tab = tab; + row.appendChild(secondaryButton); + + 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 secondaryButton = row.querySelector(".all-tabs-secondary-button"); + setAttributes(secondaryButton, { + muted: tab.muted, + soundplaying: tab.soundPlaying, + pictureinpicture: tab.pictureinpicture, + 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._getDragTargetRow(event); + if (!row) { + return; + } + + this.gBrowser.tabContainer.startTabDrag(event, row.firstElementChild.tab, { + fromTabList: true, + }); + } + + _getDragTargetRow(event) { + let row = event.target; + while (row && row.localName !== "toolbaritem") { + row = row.parentNode; + } + return row; + } + + _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._getDragTargetRow(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; + } + } +} 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/WindowsJumpLists.jsm b/browser/modules/WindowsJumpLists.jsm new file mode 100644 index 0000000000..885955c92c --- /dev/null +++ b/browser/modules/WindowsJumpLists.jsm @@ -0,0 +1,657 @@ +/* -*- 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(); + }, + + 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..24d7f80017 --- /dev/null +++ b/browser/modules/WindowsPreviewPerTab.jsm @@ -0,0 +1,913 @@ +/* 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.defineModuleGetter( + lazy, + "PageThumbs", + "resource://gre/modules/PageThumbs.jsm" +); + +// 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..a5ca8b0ecd --- /dev/null +++ b/browser/modules/ZoomUI.jsm @@ -0,0 +1,215 @@ +// -*- 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.defineModuleGetter( + lazy, + "PanelMultiView", + "resource:///modules/PanelMultiView.jsm" +); + +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.import( + "resource:///modules/CustomizableUI.jsm" +); +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..7679a2828e --- /dev/null +++ b/browser/modules/metrics.yaml @@ -0,0 +1,86 @@ +# 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 + data_sensitivity: + - interaction + notification_emails: + - loines@mozilla.com + expires: 112 + 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 + data_sensitivity: + - interaction + notification_emails: + - loines@mozilla.com + expires: 112 + send_in_pings: + - baseline + - metrics + no_lint: + - BASELINE_PING + +ping.centre: + send_failures: + type: counter + description: | + The number of PingCentre send failures. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1800079 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1800079 + data_sensitivity: + - technical + notification_emails: + - chutten@mozilla.com + expires: 115 diff --git a/browser/modules/moz.build b/browser/modules/moz.build new file mode 100644 index 0000000000..2c24670465 --- /dev/null +++ b/browser/modules/moz.build @@ -0,0 +1,163 @@ +# -*- 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.jsm"): + 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.jsm"): + 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.jsm", + "PingCentre.jsm", + "ProcessHangMonitor.jsm", + "Sanitizer.sys.mjs", + "SelectionChangedMenulist.jsm", + "SiteDataManager.jsm", + "SitePermissions.jsm", + "TabsList.jsm", + "TabUnloader.jsm", + "TransientPrefs.jsm", + "webrtcUI.jsm", + "ZoomUI.jsm", +] + +if CONFIG["MOZ_WIDGET_TOOLKIT"] == "windows": + EXTRA_JS_MODULES += [ + "WindowsJumpLists.jsm", + "WindowsPreviewPerTab.jsm", + ] + + EXTRA_JS_MODULES.backgroundtasks += [ + "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..8275a8676a --- /dev/null +++ b/browser/modules/test/browser/browser.ini @@ -0,0 +1,72 @@ +[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_preloading_tab_moving.js] +skip-if = + os == 'linux' && tsan # Bug 1720203 +[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_taskbar_preview.js] +skip-if = os != "win" || (os == "win" && bits == 64) # bug 1456807 +[browser_UnsubmittedCrashHandler.js] +run-if = crashreporter +[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 +[browser_UsageTelemetry.js] +https_first_disabled = true +[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_UsageTelemetry_content_aboutRestartRequired.js] +[browser_Telemetry_numberOfSiteOrigins.js] +support-files = + contain_iframe.html +[browser_Telemetry_numberOfSiteOriginsPerDocument.js] +support-files = + contain_iframe.html + blank_iframe.html diff --git a/browser/modules/test/browser/browser_BrowserWindowTracker.js b/browser/modules/test/browser/browser_BrowserWindowTracker.js new file mode 100644 index 0000000000..73892497c2 --- /dev/null +++ b/browser/modules/test/browser/browser_BrowserWindowTracker.js @@ -0,0 +1,241 @@ +"use strict"; + +const { BrowserWindowTracker } = ChromeUtils.import( + "resource:///modules/BrowserWindowTracker.jsm" +); +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +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..39f8f23909 --- /dev/null +++ b/browser/modules/test/browser/browser_ContentSearch.js @@ -0,0 +1,521 @@ +/* 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..9f299aec69 --- /dev/null +++ b/browser/modules/test/browser/browser_HomePage_add_button.js @@ -0,0 +1,160 @@ +/* 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..9fe0a3288f --- /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..05153986d9 --- /dev/null +++ b/browser/modules/test/browser/browser_PageActions_contextMenus.js @@ -0,0 +1,250 @@ +"use strict"; + +// This is a test for PageActions.jsm, specifically the context menus. + +XPCOMUtils.defineLazyModuleGetters(this, { + ExtensionCommon: "resource://gre/modules/ExtensionCommon.jsm", +}); + +// 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() { + Services.telemetry.clearEvents(); + + // 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; + let addonId = extension.id; + await extension.unload(); + Services.prompt = prompt; + + // Check the telemetry was collected properly. + let snapshot = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ); + ok( + snapshot.parent && !!snapshot.parent.length, + "Got parent telemetry events in the snapshot" + ); + let relatedEvents = snapshot.parent + .filter( + ([timestamp, category, method]) => + category == "addonsManager" && method == "action" + ) + .map(relatedEvent => relatedEvent.slice(3, 6)); + Assert.deepEqual(relatedEvents, [ + ["pageAction", null, { addonId, action: "manage" }], + ["pageAction", null, { addonId, action: "manage" }], + ["pageAction", "accepted", { addonId, action: "uninstall" }], + ]); + + // 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..6e23de62f7 --- /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..4ebf68e5b8 --- /dev/null +++ b/browser/modules/test/browser/browser_PartnerLinkAttribution.js @@ -0,0 +1,427 @@ +/* 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, { + Region: "resource://gre/modules/Region.sys.mjs", + SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs", + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + CustomizableUITestUtils: + "resource://testing-common/CustomizableUITestUtils.jsm", + 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..1d20bec55c --- /dev/null +++ b/browser/modules/test/browser/browser_PermissionUI.js @@ -0,0 +1,665 @@ +/** + * 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 { Integration } = ChromeUtils.importESModule( + "resource://gre/modules/Integration.sys.mjs" +); +const { PermissionUI } = ChromeUtils.import( + "resource:///modules/PermissionUI.jsm" +); +const { SitePermissions } = ChromeUtils.import( + "resource:///modules/SitePermissions.jsm" +); +const { PermissionTestUtils } = ChromeUtils.import( + "resource://testing-common/PermissionTestUtils.jsm" +); + +/** + * 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); + let TestPrompt = { + request: mockRequest, + notificationID: kTestNotificationID, + message: kTestMessage, + promptActions: [mainAction, secondaryAction], + }; + Object.setPrototypeOf( + TestPrompt, + PermissionUI.PermissionPromptForRequestPrototype + ); + + let shownPromise = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + 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); + let TestPrompt = { + request: mockRequest, + notificationID: kTestNotificationID, + message: kTestMessage, + promptActions: [mainAction, secondaryAction], + popupOptions: { + displayURI: false, + }, + }; + Object.setPrototypeOf( + TestPrompt, + PermissionUI.PermissionPromptForRequestPrototype + ); + + let shownPromise = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + 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); + }); + + let TestPrompt = { + request: mockRequest, + notificationID: kTestNotificationID, + permissionKey: kTestPermissionKey, + message: kTestMessage, + promptActions: [mainAction, secondaryAction], + popupOptions: { + checkbox: { + label: "Remember this decision", + show: true, + checked: true, + }, + }, + }; + Object.setPrototypeOf( + TestPrompt, + PermissionUI.PermissionPromptForRequestPrototype + ); + + let shownPromise = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + 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" + ); + 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" + ); + 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; + + let TestPrompt = { + request: mockRequest, + notificationID: kTestNotificationID, + message: kTestMessage, + promptActions: [mainAction], + onBeforeShow() { + beforeShown = true; + return true; + }, + }; + Object.setPrototypeOf( + TestPrompt, + PermissionUI.PermissionPromptForRequestPrototype + ); + + let shownPromise = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + 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; + + let TestPrompt = { + notificationID: kTestNotificationID, + principal, + browser, + message: kTestMessage, + promptActions: [mainAction, secondaryAction], + onBeforeShow() { + beforeShown = true; + return true; + }, + }; + Object.setPrototypeOf( + TestPrompt, + PermissionUI.PermissionPromptForRequestPrototype + ); + + let shownPromise = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + 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" + ); + 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); + + let TestPrompt = { + request: mockRequest, + notificationID: kTestNotificationID, + message: kTestMessage, + promptActions: [mainAction, secondaryAction], + }; + Object.setPrototypeOf( + TestPrompt, + PermissionUI.PermissionPromptForRequestPrototype + ); + + let shownPromise = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + 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" + ); + 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..b050395f3a --- /dev/null +++ b/browser/modules/test/browser/browser_PermissionUI_prompts.js @@ -0,0 +1,265 @@ +/** + * 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 { Integration } = ChromeUtils.importESModule( + "resource://gre/modules/Integration.sys.mjs" +); +const { PermissionUI } = ChromeUtils.import( + "resource:///modules/PermissionUI.jsm" +); +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 XRPermissionPrompt works as expected +add_task(async function test_xr_permission_prompt() { + await testPrompt(PermissionUI.XRPermissionPrompt); +}); + +// 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 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) { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "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..df9011767d --- /dev/null +++ b/browser/modules/test/browser/browser_ProcessHangNotifications.js @@ -0,0 +1,486 @@ +/* globals ProcessHangMonitor */ + +const { WebExtensionPolicy } = Cu.getGlobalForObject(Services); + +const { UpdateUtils } = ChromeUtils.importESModule( + "resource://gre/modules/UpdateUtils.sys.mjs" +); + +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..25623bb4b5 --- /dev/null +++ b/browser/modules/test/browser/browser_SitePermissions.js @@ -0,0 +1,229 @@ +/* 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 { SitePermissions } = ChromeUtils.import( + "resource:///modules/SitePermissions.jsm" +); + +// 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..651398abe7 --- /dev/null +++ b/browser/modules/test/browser/browser_SitePermissions_combinations.js @@ -0,0 +1,147 @@ +/* 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 { SitePermissions } = ChromeUtils.import( + "resource:///modules/SitePermissions.jsm" +); + +// 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..aba0a60ce2 --- /dev/null +++ b/browser/modules/test/browser/browser_SitePermissions_expiry.js @@ -0,0 +1,47 @@ +/* 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 { SitePermissions } = ChromeUtils.import( + "resource:///modules/SitePermissions.jsm" +); + +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..c2f4321f91 --- /dev/null +++ b/browser/modules/test/browser/browser_SitePermissions_tab_urls.js @@ -0,0 +1,132 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { SitePermissions } = ChromeUtils.import( + "resource:///modules/SitePermissions.jsm" +); + +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.loadURI(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.loadURI(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.loadURI(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.loadURI(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..4e23b6096d --- /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..1ac2d48586 --- /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..036c045717 --- /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.loadURI(tab.linkedBrowser, "about:blank"); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + // Navigate to another test page. + BrowserTestUtils.loadURI(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..eb66e03df1 --- /dev/null +++ b/browser/modules/test/browser/browser_UnsubmittedCrashHandler.js @@ -0,0 +1,801 @@ +"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 { FileUtils } = ChromeUtils.importESModule( + "resource://gre/modules/FileUtils.sys.mjs" +); +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..816a1e7694 --- /dev/null +++ b/browser/modules/test/browser/browser_UsageTelemetry.js @@ -0,0 +1,663 @@ +"use strict"; + +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 { SessionStore } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionStore.sys.mjs" +); + +// 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(); + 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(); + 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.loadURI(tab.linkedBrowser, "http://example.com/"); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + 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(); + 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.loadURI(tab.linkedBrowser, "http://example.com/"); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + 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.loadURI(tab.linkedBrowser, "http://example.com/"); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + 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. + checkTabCountHistogram( + tabCount.snapshot(), + { 1: 0, 2: 1, 3: 0 }, + "TAB_COUNT - new tab" + ); + checkTabCountHistogram( + loadedTabCount.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); + + 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.loadURI(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(); + + // 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..bcf1ead62c --- /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.loadURI(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.loadURI( + 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.loadURI( + 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.loadURI( + 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.loadURI( + 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.loadURI(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..da699e466a --- /dev/null +++ b/browser/modules/test/browser/browser_UsageTelemetry_interaction.js @@ -0,0 +1,681 @@ +/* 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. +function assertInteractionScalars(expectedAreas) { + let processScalars = + Services.telemetry.getSnapshotForKeyedScalars("main", true)?.parent ?? {}; + + for (let source of AREAS) { + let scalars = processScalars?.[`browser.ui.interaction.${source}`] ?? {}; + + let expected = expectedAreas[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}.` + ); + } + } +} + +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("http://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, + }, + }); + CustomizableUI.destroyWidget("12foo"); + }); +}); + +add_task(async function contextMenu() { + await BrowserTestUtils.withNewTab("http://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 appMenu() { + await BrowserTestUtils.withNewTab("http://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("http://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("http://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: ["http://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: ["http://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. + if (gUnifiedExtensions.isEnabled) { + 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("http://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, + }, + }); + }); +}); 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..5cf6b52087 --- /dev/null +++ b/browser/modules/test/browser/browser_UsageTelemetry_private_and_restore.js @@ -0,0 +1,156 @@ +"use strict"; +const { E10SUtils } = ChromeUtils.importESModule( + "resource://gre/modules/E10SUtils.sys.mjs" +); +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"; + +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, + }); + BrowserTestUtils.loadURI( + privateWin.gBrowser.selectedBrowser, + "http://example.com/" + ); + await BrowserTestUtils.browserLoaded(privateWin.gBrowser.selectedBrowser); + + // 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..c760a8dded --- /dev/null +++ b/browser/modules/test/browser/browser_UsageTelemetry_toolbars.js @@ -0,0 +1,542 @@ +/* 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", + + "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", + + "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", + + "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", + + "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", + + "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", + ]); + + 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", + + "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", + ]); + }); +}); 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..53a3dc9ae3 --- /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..02f40b8ded --- /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..b44a63de48 --- /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..411e11fc70 --- /dev/null +++ b/browser/modules/test/browser/formValidation/browser_form_validation.js @@ -0,0 +1,511 @@ +/** + * 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.loadURI(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.loadURI(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..e4e591a735 --- /dev/null +++ b/browser/modules/test/browser/formValidation/browser_validation_iframe.js @@ -0,0 +1,62 @@ +/** + * 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..0db647ecb9 --- /dev/null +++ b/browser/modules/test/browser/formValidation/browser_validation_invisible.js @@ -0,0 +1,66 @@ +"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..c2f05b2c88 --- /dev/null +++ b/browser/modules/test/browser/formValidation/browser_validation_navigation.js @@ -0,0 +1,48 @@ +/* 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..de2e7a4e32 --- /dev/null +++ b/browser/modules/test/browser/formValidation/browser_validation_other_popups.js @@ -0,0 +1,122 @@ +/* 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..397bcdb753 --- /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..01d79d9f5a --- /dev/null +++ b/browser/modules/test/browser/search-engines/engines.json @@ -0,0 +1,24 @@ +{ + "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..a077c7cd6c --- /dev/null +++ b/browser/modules/test/unit/test_E10SUtils_nested_URIs.js @@ -0,0 +1,92 @@ +/* -*- 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, + true, + false, + preferredRemoteType + ); + + let nestedUri = Services.io.newURI(testCase.nestedURL); + let nestedRemoteType = E10SUtils.getRemoteTypeForURIObject( + nestedUri, + true, + 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..5a6ec95f6e --- /dev/null +++ b/browser/modules/test/unit/test_HomePage.js @@ -0,0 +1,90 @@ +/* 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" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + HomePage: "resource:///modules/HomePage.jsm", + RemoteSettings: "resource://services-settings/remote-settings.js", + // RemoteSettingsClient: "resource://services-settings/RemoteSettingsClient.jsm", + sinon: "resource://testing-common/Sinon.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..d62d55738a --- /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, { + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + HomePage: "resource:///modules/HomePage.jsm", + RemoteSettings: "resource://services-settings/remote-settings.js", + sinon: "resource://testing-common/Sinon.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..c8915c75c3 --- /dev/null +++ b/browser/modules/test/unit/test_InstallationTelemetry.js @@ -0,0 +1,205 @@ +/* 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 { 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" } + ); + + // Check timestamp + if (typeof expectTS == "string") { + Assert.equal(expectTS, Services.prefs.getStringPref(TIMESTAMP_PREF)); + } +} + +add_task(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" }); + + // bug 1750581 tracks testing this when we're able to run tests in + // an MSIX package environment +}); 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..150516651c --- /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.import("resource://testing-common/Sinon.jsm"); + +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..a16a3f5149 --- /dev/null +++ b/browser/modules/test/unit/test_PingCentre.js @@ -0,0 +1,158 @@ +/* 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.import("resource://testing-common/Sinon.jsm"); + +const FAKE_PING = { event: "fake_event", value: "fake_value", locale: "en-US" }; +const FAKE_ENDPOINT = "https://www.test.com"; + +let pingCentre; +let sandbox; + +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(); +} + +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(); + } +}); + +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(function test_sendStructuredIngestionPing_success() { + _setUp(); + sandbox.stub(PingCentre, "_sendStandalonePing").resolves(); + pingCentre.sendStructuredIngestionPing(FAKE_PING, FAKE_ENDPOINT); + + Assert.equal(PingCentre._sendStandalonePing.callCount, 1, "Should be sent"); +}); + +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); + + Assert.equal(1, Glean.pingCentre.sendFailures.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..d2febe77a9 --- /dev/null +++ b/browser/modules/test/unit/test_ProfileCounter.js @@ -0,0 +1,230 @@ +/* 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(); + + 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" + ); +} + +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..bbec546d25 --- /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..f75f764a84 --- /dev/null +++ b/browser/modules/test/unit/test_SiteDataManager.js @@ -0,0 +1,275 @@ +/* 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.import( + "resource://testing-common/PermissionTestUtils.jsm" +); + +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..885bec3fee --- /dev/null +++ b/browser/modules/test/unit/test_SitePermissions.js @@ -0,0 +1,394 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ +"use strict"; + +const { SitePermissions } = ChromeUtils.import( + "resource:///modules/SitePermissions.jsm" +); + +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.jsm 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..d02811aeac --- /dev/null +++ b/browser/modules/test/unit/test_SitePermissions_temporary.js @@ -0,0 +1,693 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ +"use strict"; + +const { SitePermissions } = ChromeUtils.import( + "resource:///modules/SitePermissions.jsm" +); + +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 {}, + }, + }; +} + +/** + * 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.currentURI.prePath]; + let permissionsB = + uriToPerm[Services.eTLD.getBaseDomain(BROWSER_A.currentURI)]; + + ok(permissionsA, "Allow should be keyed under prePath"); + 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 URI prePath 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.currentURI.prePath]; + permissionB = permissionsB[PERM_B]; + Assert.ok( + permissionsB && permissionB, + "Overwritten permission should be keyed under prePath" + ); + 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, + BROWSER_A.currentURI, + resolve + ); + }); + let promiseExpireB = new Promise(resolve => { + TemporaryPermissions.set( + BROWSER_B, + PERM_A, + SitePermissions.BLOCK, + 100, + BROWSER_B.currentURI, + 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, + BROWSER_A.currentURI, + 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 prepath. + */ +add_task(async function testTemporaryPermissionScope() { + let states = { + strict: { + same: [ + "https://example.com", + "https://example.com/sub/path", + "https://example.com:443", + ], + different: [ + "https://example.com", + "https://name:password@example.com", + "https://test1.example.com", + "http://example.com", + "http://example.org", + ], + }, + 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 + ); + + for (let otherUri of list) { + if (uri == otherUri) { + continue; + } + browser.currentURI = Services.io.newURI(otherUri); + + 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 URI 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( + null, + PERM_A, + SitePermissions.ALLOW, + SitePermissions.SCOPE_TEMPORARY, + testBrowser, + EXPIRY_MS_A, + overrideURI + ); + + 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 + testBrowser.currentURI = 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( + null, + PERM_A, + SitePermissions.ALLOW, + SitePermissions.SCOPE_TEMPORARY, + BROWSER_A, + EXPIRY_MS_B, + aboutURI + ); + 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. + browser.currentURI = 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..4e5b0ea516 --- /dev/null +++ b/browser/modules/test/unit/xpcshell.ini @@ -0,0 +1,24 @@ +[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 + os == 'win' && msix # https://bugzilla.mozilla.org/show_bug.cgi?id=1807930 +[test_PartnerLinkAttribution.js] diff --git a/browser/modules/webrtcUI.jsm b/browser/modules/webrtcUI.jsm new file mode 100644 index 0000000000..de64af9301 --- /dev/null +++ b/browser/modules/webrtcUI.jsm @@ -0,0 +1,1304 @@ +/* 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.import( + "resource:///modules/syncedtabs/EventEmitter.jsm" +); +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.defineModuleGetter( + lazy, + "SitePermissions", + "resource:///modules/SitePermissions.jsm" +); +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=yes"; + + /* Don't use dialog on Gtk as it adds extra border and titlebar to indicator */ + if (!AppConstants.MOZ_WIDGET_GTK) { + features += ",dialog=yes"; + } + + return Services.ww.openWindow( + null, + INDICATOR_CHROME_URI, + "_blank", + features, + [] + ); + } + + 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, + [] + ); + } + + 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); + 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; |