/* 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 provides the means to monitor and query for tab collections against open * browser windows and allow listeners to be notified of changes to those collections. */ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", EveryWindow: "resource:///modules/EveryWindow.sys.mjs", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", }); const TAB_ATTRS_TO_WATCH = Object.freeze([ "attention", "image", "label", "muted", "soundplaying", "titlechanged", ]); const TAB_CHANGE_EVENTS = Object.freeze([ "TabAttrModified", "TabClose", "TabMove", "TabOpen", "TabPinned", "TabUnpinned", ]); const TAB_RECENCY_CHANGE_EVENTS = Object.freeze([ "activate", "TabAttrModified", "TabClose", "TabOpen", "TabSelect", "TabAttrModified", ]); // Debounce tab/tab recency changes and dispatch max once per frame at 60fps const CHANGES_DEBOUNCE_MS = 1000 / 60; /** * A sort function used to order tabs by most-recently seen and active. */ export function lastSeenActiveSort(a, b) { let dt = b.lastSeenActive - a.lastSeenActive; if (dt) { return dt; } // try to break a deadlock by sorting the selected tab higher if (!(a.selected || b.selected)) { return 0; } return a.selected ? -1 : 1; } /** * Provides a object capable of monitoring and accessing tab collections for either * private or non-private browser windows. As the class extends EventTarget, consumers * should add event listeners for the change events. * * @param {boolean} options.usePrivateWindows Constrain to only windows that match this privateness. Defaults to false. * @param {Window | null} options.exclusiveWindow * Constrain to only a specific window. */ class OpenTabsTarget extends EventTarget { #changedWindowsByType = { TabChange: new Set(), TabRecencyChange: new Set(), }; #dispatchChangesTask; #started = false; #watchedWindows = new Set(); #exclusiveWindowWeakRef = null; usePrivateWindows = false; constructor(options = {}) { super(); this.usePrivateWindows = !!options.usePrivateWindows; if (options.exclusiveWindow) { this.exclusiveWindow = options.exclusiveWindow; this.everyWindowCallbackId = `opentabs-${this.exclusiveWindow.windowGlobalChild.innerWindowId}`; } else { this.everyWindowCallbackId = `opentabs-${ this.usePrivateWindows ? "private" : "non-private" }`; } } get exclusiveWindow() { return this.#exclusiveWindowWeakRef?.get(); } set exclusiveWindow(newValue) { if (newValue) { this.#exclusiveWindowWeakRef = Cu.getWeakReference(newValue); } else { this.#exclusiveWindowWeakRef = null; } } includeWindowFilter(win) { if (this.#exclusiveWindowWeakRef) { return win == this.exclusiveWindow; } return ( win.gBrowser && !win.closed && this.usePrivateWindows == lazy.PrivateBrowsingUtils.isWindowPrivate(win) ); } get currentWindows() { return lazy.EveryWindow.readyWindows.filter(win => this.includeWindowFilter(win) ); } /** * A promise that resolves to all matched windows once their delayedStartupPromise resolves */ get readyWindowsPromise() { let windowList = Array.from( Services.wm.getEnumerator("navigator:browser") ).filter(win => { // avoid waiting for windows we definitely don't care about if (this.#exclusiveWindowWeakRef) { return this.exclusiveWindow == win; } return ( this.usePrivateWindows == lazy.PrivateBrowsingUtils.isWindowPrivate(win) ); }); return Promise.allSettled( windowList.map(win => win.delayedStartupPromise) ).then(() => { // re-filter the list as properties might have changed in the interim return windowList.filter(win => this.includeWindowFilter); }); } haveListenersForEvent(eventType) { switch (eventType) { case "TabChange": return Services.els.hasListenersFor(this, "TabChange"); case "TabRecencyChange": return Services.els.hasListenersFor(this, "TabRecencyChange"); default: return false; } } get haveAnyListeners() { return ( this.haveListenersForEvent("TabChange") || this.haveListenersForEvent("TabRecencyChange") ); } /* * @param {string} type * Either "TabChange" or "TabRecencyChange" * @param {Object|Function} listener * @param {Object} [options] */ addEventListener(type, listener, options) { let hadListeners = this.haveAnyListeners; super.addEventListener(type, listener, options); // if this is the first listener, start up all the window & tab monitoring if (!hadListeners && this.haveAnyListeners) { this.start(); } } /* * @param {string} type * Either "TabChange" or "TabRecencyChange" * @param {Object|Function} listener */ removeEventListener(type, listener) { let hadListeners = this.haveAnyListeners; super.removeEventListener(type, listener); // if this was the last listener, we can stop all the window & tab monitoring if (hadListeners && !this.haveAnyListeners) { this.stop(); } } /** * Begin watching for tab-related events from all browser windows matching the instance's private property */ start() { if (this.#started) { return; } // EveryWindow will call #watchWindow for each open window once its delayedStartupPromise resolves. lazy.EveryWindow.registerCallback( this.everyWindowCallbackId, win => this.#watchWindow(win), win => this.#unwatchWindow(win) ); this.#started = true; } /** * Stop watching for tab-related events from all browser windows and clean up. */ stop() { if (this.#started) { lazy.EveryWindow.unregisterCallback(this.everyWindowCallbackId); this.#started = false; } for (let changedWindows of Object.values(this.#changedWindowsByType)) { changedWindows.clear(); } this.#watchedWindows.clear(); this.#dispatchChangesTask?.disarm(); } /** * Add listeners for tab-related events from the given window. The consumer's * listeners will always be notified at least once for newly-watched window. */ #watchWindow(win) { if (!this.includeWindowFilter(win)) { return; } this.#watchedWindows.add(win); const { tabContainer } = win.gBrowser; tabContainer.addEventListener("TabAttrModified", this); tabContainer.addEventListener("TabClose", this); tabContainer.addEventListener("TabMove", this); tabContainer.addEventListener("TabOpen", this); tabContainer.addEventListener("TabPinned", this); tabContainer.addEventListener("TabUnpinned", this); tabContainer.addEventListener("TabSelect", this); win.addEventListener("activate", this); this.#scheduleEventDispatch("TabChange", {}); this.#scheduleEventDispatch("TabRecencyChange", {}); } /** * Remove all listeners for tab-related events from the given window. * Consumers will always be notified at least once for unwatched window. */ #unwatchWindow(win) { // We check the window is in our watchedWindows collection rather than currentWindows // as the unwatched window may not match the criteria we used to watch it anymore, // and we need to unhook our event listeners regardless. if (this.#watchedWindows.has(win)) { this.#watchedWindows.delete(win); const { tabContainer } = win.gBrowser; tabContainer.removeEventListener("TabAttrModified", this); tabContainer.removeEventListener("TabClose", this); tabContainer.removeEventListener("TabMove", this); tabContainer.removeEventListener("TabOpen", this); tabContainer.removeEventListener("TabPinned", this); tabContainer.removeEventListener("TabSelect", this); tabContainer.removeEventListener("TabUnpinned", this); win.removeEventListener("activate", this); this.#scheduleEventDispatch("TabChange", {}); this.#scheduleEventDispatch("TabRecencyChange", {}); } } /** * Flag the need to notify all our consumers of a change to open tabs. * Repeated calls within approx 16ms will be consolidated * into one event dispatch. */ #scheduleEventDispatch(eventType, { sourceWindowId } = {}) { if (!this.haveListenersForEvent(eventType)) { return; } this.#changedWindowsByType[eventType].add(sourceWindowId); // Queue up an event dispatch - we use a deferred task to make this less noisy by // consolidating multiple change events into one. if (!this.#dispatchChangesTask) { this.#dispatchChangesTask = new lazy.DeferredTask(() => { this.#dispatchChanges(); }, CHANGES_DEBOUNCE_MS); } this.#dispatchChangesTask.arm(); } #dispatchChanges() { this.#dispatchChangesTask?.disarm(); for (let [eventType, changedWindowIds] of Object.entries( this.#changedWindowsByType )) { if (this.haveListenersForEvent(eventType) && changedWindowIds.size) { this.dispatchEvent( new CustomEvent(eventType, { detail: { windowIds: [...changedWindowIds], }, }) ); changedWindowIds.clear(); } } } /* * @param {Window} win * @param {boolean} sortByRecency * @returns {Array} * The list of visible tabs for the browser window */ getTabsForWindow(win, sortByRecency = false) { if (this.currentWindows.includes(win)) { const { visibleTabs } = win.gBrowser; return sortByRecency ? visibleTabs.toSorted(lastSeenActiveSort) : [...visibleTabs]; } return []; } /* * @returns {Array} * A by-recency-sorted, aggregated list of tabs from all the same-privateness browser windows. */ getRecentTabs() { const tabs = []; for (let win of this.currentWindows) { tabs.push(...this.getTabsForWindow(win)); } tabs.sort(lastSeenActiveSort); return tabs; } handleEvent({ detail, target, type }) { const win = target.ownerGlobal; // NOTE: we already filtered on privateness by not listening for those events // from private/not-private windows if ( type == "TabAttrModified" && !detail.changed.some(attr => TAB_ATTRS_TO_WATCH.includes(attr)) ) { return; } if (TAB_RECENCY_CHANGE_EVENTS.includes(type)) { this.#scheduleEventDispatch("TabRecencyChange", { sourceWindowId: win.windowGlobalChild.innerWindowId, }); } if (TAB_CHANGE_EVENTS.includes(type)) { this.#scheduleEventDispatch("TabChange", { sourceWindowId: win.windowGlobalChild.innerWindowId, }); } } } const gExclusiveWindows = new (class { perWindowInstances = new WeakMap(); constructor() { Services.obs.addObserver(this, "domwindowclosed"); } observe(subject, topic, data) { let win = subject; let winTarget = this.perWindowInstances.get(win); if (winTarget) { winTarget.stop(); this.perWindowInstances.delete(win); } } })(); /** * Get an OpenTabsTarget instance constrained to a specific window. * * @param {Window} exclusiveWindow * @returns {OpenTabsTarget} */ const getTabsTargetForWindow = function (exclusiveWindow) { let instance = gExclusiveWindows.perWindowInstances.get(exclusiveWindow); if (instance) { return instance; } instance = new OpenTabsTarget({ exclusiveWindow, }); gExclusiveWindows.perWindowInstances.set(exclusiveWindow, instance); return instance; }; const NonPrivateTabs = new OpenTabsTarget({ usePrivateWindows: false, }); const PrivateTabs = new OpenTabsTarget({ usePrivateWindows: true, }); export { NonPrivateTabs, PrivateTabs, getTabsTargetForWindow };