OpenTabs.sys.mjs (13566B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 /** 6 * This module provides the means to monitor and query for tab collections against open 7 * browser windows and allow listeners to be notified of changes to those collections. 8 */ 9 10 const lazy = {}; 11 12 ChromeUtils.defineESModuleGetters(lazy, { 13 DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", 14 EveryWindow: "resource:///modules/EveryWindow.sys.mjs", 15 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", 16 }); 17 18 const TAB_ATTRS_TO_WATCH = Object.freeze([ 19 "attention", 20 "image", 21 "label", 22 "muted", 23 "soundplaying", 24 "titlechanged", 25 ]); 26 const TAB_CHANGE_EVENTS = Object.freeze([ 27 "TabAttrModified", 28 "TabClose", 29 "TabMove", 30 "TabOpen", 31 "TabPinned", 32 "TabUnpinned", 33 "SplitViewCreated", 34 "SplitViewRemoved", 35 ]); 36 const TAB_RECENCY_CHANGE_EVENTS = Object.freeze([ 37 "activate", 38 "sizemodechange", 39 "TabAttrModified", 40 "TabClose", 41 "TabOpen", 42 "TabPinned", 43 "TabUnpinned", 44 "TabSelect", 45 "TabAttrModified", 46 ]); 47 48 // Debounce tab/tab recency changes and dispatch max once per frame at 60fps 49 const CHANGES_DEBOUNCE_MS = 1000 / 60; 50 51 /** 52 * A sort function used to order tabs by most-recently seen and active. 53 */ 54 export function lastSeenActiveSort(a, b) { 55 let dt = b.lastSeenActive - a.lastSeenActive; 56 if (dt) { 57 return dt; 58 } 59 // try to break a deadlock by sorting the selected tab higher 60 if (!(a.selected || b.selected)) { 61 return 0; 62 } 63 return a.selected ? -1 : 1; 64 } 65 66 /** 67 * Provides a object capable of monitoring and accessing tab collections for either 68 * private or non-private browser windows. As the class extends EventTarget, consumers 69 * should add event listeners for the change events. 70 * 71 * @param {boolean} options.usePrivateWindows 72 Constrain to only windows that match this privateness. Defaults to false. 73 * @param {Window | null} options.exclusiveWindow 74 * Constrain to only a specific window. 75 */ 76 class OpenTabsTarget extends EventTarget { 77 #changedWindowsByType = { 78 TabChange: new Set(), 79 TabRecencyChange: new Set(), 80 }; 81 #sourceEventsByType = { 82 TabChange: new Set(), 83 TabRecencyChange: new Set(), 84 }; 85 #dispatchChangesTask; 86 #started = false; 87 #watchedWindows = new Set(); 88 89 #exclusiveWindowWeakRef = null; 90 usePrivateWindows = false; 91 92 constructor(options = {}) { 93 super(); 94 this.usePrivateWindows = !!options.usePrivateWindows; 95 96 if (options.exclusiveWindow) { 97 this.exclusiveWindow = options.exclusiveWindow; 98 this.everyWindowCallbackId = `opentabs-${this.exclusiveWindow.windowGlobalChild.innerWindowId}`; 99 } else { 100 this.everyWindowCallbackId = `opentabs-${ 101 this.usePrivateWindows ? "private" : "non-private" 102 }`; 103 } 104 } 105 106 get exclusiveWindow() { 107 return this.#exclusiveWindowWeakRef?.get(); 108 } 109 set exclusiveWindow(newValue) { 110 if (newValue) { 111 this.#exclusiveWindowWeakRef = Cu.getWeakReference(newValue); 112 } else { 113 this.#exclusiveWindowWeakRef = null; 114 } 115 } 116 117 includeWindowFilter(win) { 118 if (this.#exclusiveWindowWeakRef) { 119 return win == this.exclusiveWindow; 120 } 121 return ( 122 win.gBrowser && 123 !win.closed && 124 this.usePrivateWindows == lazy.PrivateBrowsingUtils.isWindowPrivate(win) 125 ); 126 } 127 128 get currentWindows() { 129 return lazy.EveryWindow.readyWindows.filter(win => 130 this.includeWindowFilter(win) 131 ); 132 } 133 134 /** 135 * A promise that resolves to all matched windows once their delayedStartupPromise resolves 136 */ 137 get readyWindowsPromise() { 138 let windowList = Array.from( 139 Services.wm.getEnumerator("navigator:browser") 140 ).filter(win => { 141 // avoid waiting for windows we definitely don't care about 142 if (this.#exclusiveWindowWeakRef) { 143 return this.exclusiveWindow == win; 144 } 145 return ( 146 this.usePrivateWindows == lazy.PrivateBrowsingUtils.isWindowPrivate(win) 147 ); 148 }); 149 return Promise.allSettled( 150 windowList.map(win => win.delayedStartupPromise) 151 ).then(() => { 152 // re-filter the list as properties might have changed in the interim 153 return windowList.filter(() => this.includeWindowFilter); 154 }); 155 } 156 157 haveListenersForEvent(eventType) { 158 switch (eventType) { 159 case "TabChange": 160 return Services.els.hasListenersFor(this, "TabChange"); 161 case "TabRecencyChange": 162 return Services.els.hasListenersFor(this, "TabRecencyChange"); 163 default: 164 return false; 165 } 166 } 167 168 get haveAnyListeners() { 169 return ( 170 this.haveListenersForEvent("TabChange") || 171 this.haveListenersForEvent("TabRecencyChange") 172 ); 173 } 174 175 /** 176 * @param {string} type 177 * Either "TabChange" or "TabRecencyChange" 178 * @param {object | Function} listener 179 * @param {object} [options] 180 */ 181 addEventListener(type, listener, options) { 182 let hadListeners = this.haveAnyListeners; 183 super.addEventListener(type, listener, options); 184 185 // if this is the first listener, start up all the window & tab monitoring 186 if (!hadListeners && this.haveAnyListeners) { 187 this.start(); 188 } 189 } 190 191 /** 192 * @param {string} type 193 * Either "TabChange" or "TabRecencyChange" 194 * @param {object | Function} listener 195 */ 196 removeEventListener(type, listener) { 197 let hadListeners = this.haveAnyListeners; 198 super.removeEventListener(type, listener); 199 200 // if this was the last listener, we can stop all the window & tab monitoring 201 if (hadListeners && !this.haveAnyListeners) { 202 this.stop(); 203 } 204 } 205 206 /** 207 * Begin watching for tab-related events from all browser windows matching the instance's private property 208 */ 209 start() { 210 if (this.#started) { 211 return; 212 } 213 // EveryWindow will call #watchWindow for each open window once its delayedStartupPromise resolves. 214 lazy.EveryWindow.registerCallback( 215 this.everyWindowCallbackId, 216 win => this.#watchWindow(win), 217 win => this.#unwatchWindow(win) 218 ); 219 this.#started = true; 220 } 221 222 /** 223 * Stop watching for tab-related events from all browser windows and clean up. 224 */ 225 stop() { 226 if (this.#started) { 227 lazy.EveryWindow.unregisterCallback(this.everyWindowCallbackId); 228 this.#started = false; 229 } 230 for (let changedWindows of Object.values(this.#changedWindowsByType)) { 231 changedWindows.clear(); 232 } 233 for (let sourceEvents of Object.values(this.#sourceEventsByType)) { 234 sourceEvents.clear(); 235 } 236 this.#watchedWindows.clear(); 237 this.#dispatchChangesTask?.disarm(); 238 } 239 240 /** 241 * Add listeners for tab-related events from the given window. The consumer's 242 * listeners will always be notified at least once for newly-watched window. 243 */ 244 #watchWindow(win) { 245 if (!this.includeWindowFilter(win)) { 246 return; 247 } 248 this.#watchedWindows.add(win); 249 const { tabContainer } = win.gBrowser; 250 tabContainer.addEventListener("TabAttrModified", this); 251 tabContainer.addEventListener("TabClose", this); 252 tabContainer.addEventListener("TabMove", this); 253 tabContainer.addEventListener("TabOpen", this); 254 tabContainer.addEventListener("TabPinned", this); 255 tabContainer.addEventListener("TabUnpinned", this); 256 tabContainer.addEventListener("TabSelect", this); 257 tabContainer.addEventListener("SplitViewCreated", this); 258 tabContainer.addEventListener("SplitViewRemoved", this); 259 win.addEventListener("activate", this); 260 win.addEventListener("sizemodechange", this); 261 262 this.#scheduleEventDispatch("TabChange", { 263 sourceWindowId: win.windowGlobalChild.innerWindowId, 264 sourceEvent: "watchWindow", 265 }); 266 this.#scheduleEventDispatch("TabRecencyChange", { 267 sourceWindowId: win.windowGlobalChild.innerWindowId, 268 sourceEvent: "watchWindow", 269 }); 270 } 271 272 /** 273 * Remove all listeners for tab-related events from the given window. 274 * Consumers will always be notified at least once for unwatched window. 275 */ 276 #unwatchWindow(win) { 277 // We check the window is in our watchedWindows collection rather than currentWindows 278 // as the unwatched window may not match the criteria we used to watch it anymore, 279 // and we need to unhook our event listeners regardless. 280 if (this.#watchedWindows.has(win)) { 281 this.#watchedWindows.delete(win); 282 283 const { tabContainer } = win.gBrowser; 284 tabContainer.removeEventListener("TabAttrModified", this); 285 tabContainer.removeEventListener("TabClose", this); 286 tabContainer.removeEventListener("TabMove", this); 287 tabContainer.removeEventListener("TabOpen", this); 288 tabContainer.removeEventListener("TabPinned", this); 289 tabContainer.removeEventListener("TabSelect", this); 290 tabContainer.removeEventListener("TabUnpinned", this); 291 tabContainer.removeEventListener("SplitViewCreated", this); 292 tabContainer.removeEventListener("SplitViewRemoved", this); 293 win.removeEventListener("activate", this); 294 win.removeEventListener("sizemodechange", this); 295 296 this.#scheduleEventDispatch("TabChange", { 297 sourceWindowId: win.windowGlobalChild.innerWindowId, 298 sourceEvent: "unwatchWindow", 299 }); 300 this.#scheduleEventDispatch("TabRecencyChange", { 301 sourceWindowId: win.windowGlobalChild.innerWindowId, 302 sourceEvent: "unwatchWindow", 303 }); 304 } 305 } 306 307 /** 308 * Flag the need to notify all our consumers of a change to open tabs. 309 * Repeated calls within approx 16ms will be consolidated 310 * into one event dispatch. 311 */ 312 #scheduleEventDispatch(eventType, { sourceWindowId, sourceEvent } = {}) { 313 if (!this.haveListenersForEvent(eventType)) { 314 return; 315 } 316 317 this.#sourceEventsByType[eventType].add(sourceEvent); 318 this.#changedWindowsByType[eventType].add(sourceWindowId); 319 // Queue up an event dispatch - we use a deferred task to make this less noisy by 320 // consolidating multiple change events into one. 321 if (!this.#dispatchChangesTask) { 322 this.#dispatchChangesTask = new lazy.DeferredTask(() => { 323 this.#dispatchChanges(); 324 }, CHANGES_DEBOUNCE_MS); 325 } 326 this.#dispatchChangesTask.arm(); 327 } 328 329 #dispatchChanges() { 330 this.#dispatchChangesTask?.disarm(); 331 for (let [eventType, changedWindowIds] of Object.entries( 332 this.#changedWindowsByType 333 )) { 334 let sourceEvents = this.#sourceEventsByType[eventType]; 335 if (this.haveListenersForEvent(eventType) && changedWindowIds.size) { 336 let changeEvent = new CustomEvent(eventType, { 337 detail: { 338 windowIds: [...changedWindowIds], 339 sourceEvents: [...sourceEvents], 340 }, 341 }); 342 this.dispatchEvent(changeEvent); 343 changedWindowIds.clear(); 344 } 345 sourceEvents?.clear(); 346 } 347 } 348 349 /** 350 * @param {Window} win 351 * @param {boolean} sortByRecency 352 * @returns {Array<Tab>} 353 * The list of visible tabs for the browser window 354 */ 355 getTabsForWindow(win, sortByRecency = false) { 356 if (this.currentWindows.includes(win)) { 357 const tabs = win.gBrowser.openTabs.filter(tab => !tab.hidden); 358 return sortByRecency ? tabs.toSorted(lastSeenActiveSort) : tabs; 359 } 360 return []; 361 } 362 363 /** 364 * Get an aggregated list of tabs from all the same-privateness browser windows. 365 * 366 * @returns {MozTabbrowserTab[]} 367 */ 368 getAllTabs() { 369 return this.currentWindows.flatMap(win => this.getTabsForWindow(win)); 370 } 371 372 /** 373 * @returns {Array<Tab>} 374 * A by-recency-sorted, aggregated list of tabs from all the same-privateness browser windows. 375 */ 376 getRecentTabs() { 377 return this.getAllTabs().sort(lastSeenActiveSort); 378 } 379 380 handleEvent({ detail, target, type }) { 381 const win = target.ownerGlobal; 382 // NOTE: we already filtered on privateness by not listening for those events 383 // from private/not-private windows 384 if ( 385 type == "TabAttrModified" && 386 !detail.changed.some(attr => TAB_ATTRS_TO_WATCH.includes(attr)) 387 ) { 388 return; 389 } 390 391 if (TAB_RECENCY_CHANGE_EVENTS.includes(type)) { 392 this.#scheduleEventDispatch("TabRecencyChange", { 393 sourceWindowId: win.windowGlobalChild.innerWindowId, 394 sourceEvent: type, 395 }); 396 } 397 if (TAB_CHANGE_EVENTS.includes(type)) { 398 this.#scheduleEventDispatch("TabChange", { 399 sourceWindowId: win.windowGlobalChild.innerWindowId, 400 sourceEvent: type, 401 }); 402 } 403 } 404 } 405 406 const gExclusiveWindows = new (class { 407 perWindowInstances = new WeakMap(); 408 constructor() { 409 Services.obs.addObserver(this, "domwindowclosed"); 410 } 411 observe(subject) { 412 let win = subject; 413 let winTarget = this.perWindowInstances.get(win); 414 if (winTarget) { 415 winTarget.stop(); 416 this.perWindowInstances.delete(win); 417 } 418 } 419 })(); 420 421 /** 422 * Get an OpenTabsTarget instance constrained to a specific window. 423 * 424 * @param {Window} exclusiveWindow 425 * @returns {OpenTabsTarget} 426 */ 427 const getTabsTargetForWindow = function (exclusiveWindow) { 428 let instance = gExclusiveWindows.perWindowInstances.get(exclusiveWindow); 429 if (instance) { 430 return instance; 431 } 432 instance = new OpenTabsTarget({ 433 exclusiveWindow, 434 }); 435 gExclusiveWindows.perWindowInstances.set(exclusiveWindow, instance); 436 return instance; 437 }; 438 439 const NonPrivateTabs = new OpenTabsTarget({ 440 usePrivateWindows: false, 441 }); 442 443 const PrivateTabs = new OpenTabsTarget({ 444 usePrivateWindows: true, 445 }); 446 447 export { NonPrivateTabs, PrivateTabs, getTabsTargetForWindow };