441 lines
13 KiB
JavaScript
441 lines
13 KiB
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 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",
|
|
"sizemodechange",
|
|
"TabAttrModified",
|
|
"TabClose",
|
|
"TabOpen",
|
|
"TabPinned",
|
|
"TabUnpinned",
|
|
"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(),
|
|
};
|
|
#sourceEventsByType = {
|
|
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(() => 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();
|
|
}
|
|
for (let sourceEvents of Object.values(this.#sourceEventsByType)) {
|
|
sourceEvents.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);
|
|
win.addEventListener("sizemodechange", this);
|
|
|
|
this.#scheduleEventDispatch("TabChange", {
|
|
sourceWindowId: win.windowGlobalChild.innerWindowId,
|
|
sourceEvent: "watchWindow",
|
|
});
|
|
this.#scheduleEventDispatch("TabRecencyChange", {
|
|
sourceWindowId: win.windowGlobalChild.innerWindowId,
|
|
sourceEvent: "watchWindow",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
win.removeEventListener("sizemodechange", this);
|
|
|
|
this.#scheduleEventDispatch("TabChange", {
|
|
sourceWindowId: win.windowGlobalChild.innerWindowId,
|
|
sourceEvent: "unwatchWindow",
|
|
});
|
|
this.#scheduleEventDispatch("TabRecencyChange", {
|
|
sourceWindowId: win.windowGlobalChild.innerWindowId,
|
|
sourceEvent: "unwatchWindow",
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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, sourceEvent } = {}) {
|
|
if (!this.haveListenersForEvent(eventType)) {
|
|
return;
|
|
}
|
|
|
|
this.#sourceEventsByType[eventType].add(sourceEvent);
|
|
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
|
|
)) {
|
|
let sourceEvents = this.#sourceEventsByType[eventType];
|
|
if (this.haveListenersForEvent(eventType) && changedWindowIds.size) {
|
|
let changeEvent = new CustomEvent(eventType, {
|
|
detail: {
|
|
windowIds: [...changedWindowIds],
|
|
sourceEvents: [...sourceEvents],
|
|
},
|
|
});
|
|
this.dispatchEvent(changeEvent);
|
|
changedWindowIds.clear();
|
|
}
|
|
sourceEvents?.clear();
|
|
}
|
|
}
|
|
|
|
/*
|
|
* @param {Window} win
|
|
* @param {boolean} sortByRecency
|
|
* @returns {Array<Tab>}
|
|
* The list of visible tabs for the browser window
|
|
*/
|
|
getTabsForWindow(win, sortByRecency = false) {
|
|
if (this.currentWindows.includes(win)) {
|
|
const tabs = win.gBrowser.openTabs.filter(tab => !tab.hidden);
|
|
return sortByRecency ? tabs.toSorted(lastSeenActiveSort) : tabs;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Get an aggregated list of tabs from all the same-privateness browser windows.
|
|
*
|
|
* @returns {MozTabbrowserTab[]}
|
|
*/
|
|
getAllTabs() {
|
|
return this.currentWindows.flatMap(win => this.getTabsForWindow(win));
|
|
}
|
|
|
|
/*
|
|
* @returns {Array<Tab>}
|
|
* A by-recency-sorted, aggregated list of tabs from all the same-privateness browser windows.
|
|
*/
|
|
getRecentTabs() {
|
|
return this.getAllTabs().sort(lastSeenActiveSort);
|
|
}
|
|
|
|
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,
|
|
sourceEvent: type,
|
|
});
|
|
}
|
|
if (TAB_CHANGE_EVENTS.includes(type)) {
|
|
this.#scheduleEventDispatch("TabChange", {
|
|
sourceWindowId: win.windowGlobalChild.innerWindowId,
|
|
sourceEvent: type,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const gExclusiveWindows = new (class {
|
|
perWindowInstances = new WeakMap();
|
|
constructor() {
|
|
Services.obs.addObserver(this, "domwindowclosed");
|
|
}
|
|
observe(subject) {
|
|
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 };
|