BrowserWindowTracker.sys.mjs (15935B)
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 tracks each browser window and informs network module 7 * the current selected tab's content outer window ID. 8 */ 9 10 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; 11 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 12 13 const lazy = {}; 14 15 // Lazy getters 16 17 XPCOMUtils.defineLazyServiceGetters(lazy, { 18 BrowserHandler: ["@mozilla.org/browser/clh;1", Ci.nsIBrowserHandler], 19 }); 20 21 ChromeUtils.defineESModuleGetters(lazy, { 22 AIWindow: 23 "moz-src:///browser/components/aiwindow/ui/modules/AIWindow.sys.mjs", 24 HomePage: "resource:///modules/HomePage.sys.mjs", 25 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", 26 }); 27 28 XPCOMUtils.defineLazyPreferenceGetter( 29 lazy, 30 "gPreferWindowsOnCurrentVirtualDesktop", 31 "widget.prefer_windows_on_current_virtual_desktop" 32 ); 33 34 // Constants 35 const TAB_EVENTS = ["TabBrowserInserted", "TabSelect"]; 36 const WINDOW_EVENTS = ["activate", "unload"]; 37 const DEBUG = false; 38 39 // Variables 40 let _lastCurrentBrowserId = 0; 41 let _trackedWindows = []; 42 43 // Global methods 44 function debug(s) { 45 if (DEBUG) { 46 dump("-*- UpdateBrowserIDHelper: " + s + "\n"); 47 } 48 } 49 50 function _updateCurrentBrowserId(browser) { 51 if ( 52 !browser.browserId || 53 browser.browserId === _lastCurrentBrowserId || 54 browser.ownerGlobal != _trackedWindows[0] 55 ) { 56 return; 57 } 58 59 // Guard on DEBUG here because materializing a long data URI into 60 // a JS string for concatenation is not free. 61 if (DEBUG) { 62 debug( 63 `Current window uri=${browser.currentURI?.spec} browser id=${browser.browserId}` 64 ); 65 } 66 67 _lastCurrentBrowserId = browser.browserId; 68 let idWrapper = Cc["@mozilla.org/supports-PRUint64;1"].createInstance( 69 Ci.nsISupportsPRUint64 70 ); 71 idWrapper.data = _lastCurrentBrowserId; 72 Services.obs.notifyObservers(idWrapper, "net:current-browser-id"); 73 } 74 75 function _handleEvent(event) { 76 switch (event.type) { 77 case "TabBrowserInserted": 78 if ( 79 event.target.ownerGlobal.gBrowser.selectedBrowser === 80 event.target.linkedBrowser 81 ) { 82 _updateCurrentBrowserId(event.target.linkedBrowser); 83 } 84 break; 85 case "TabSelect": 86 _updateCurrentBrowserId(event.target.linkedBrowser); 87 break; 88 case "activate": 89 WindowHelper.onActivate(event.target); 90 break; 91 case "unload": 92 WindowHelper.removeWindow(event.currentTarget); 93 break; 94 } 95 } 96 97 function _trackWindowOrder(window) { 98 if (window.windowState == window.STATE_MINIMIZED) { 99 let firstMinimizedWindow = _trackedWindows.findIndex( 100 w => w.windowState == w.STATE_MINIMIZED 101 ); 102 if (firstMinimizedWindow == -1) { 103 firstMinimizedWindow = _trackedWindows.length; 104 } 105 _trackedWindows.splice(firstMinimizedWindow, 0, window); 106 } else { 107 _trackedWindows.unshift(window); 108 } 109 } 110 111 function _untrackWindowOrder(window) { 112 let idx = _trackedWindows.indexOf(window); 113 if (idx >= 0) { 114 _trackedWindows.splice(idx, 1); 115 } 116 } 117 118 function topicObserved(observeTopic, checkFn) { 119 return new Promise((resolve, reject) => { 120 function observer(subject, topic, data) { 121 try { 122 if (checkFn && !checkFn(subject, data)) { 123 return; 124 } 125 Services.obs.removeObserver(observer, topic); 126 checkFn = null; 127 resolve([subject, data]); 128 } catch (ex) { 129 Services.obs.removeObserver(observer, topic); 130 checkFn = null; 131 reject(ex); 132 } 133 } 134 Services.obs.addObserver(observer, observeTopic); 135 }); 136 } 137 138 // Methods that impact a window. Put into single object for organization. 139 var WindowHelper = { 140 addWindow(window) { 141 // Add event listeners 142 TAB_EVENTS.forEach(function (event) { 143 window.gBrowser.tabContainer.addEventListener(event, _handleEvent); 144 }); 145 WINDOW_EVENTS.forEach(function (event) { 146 window.addEventListener(event, _handleEvent); 147 }); 148 149 _trackWindowOrder(window); 150 151 // Update the selected tab's content outer window ID. 152 _updateCurrentBrowserId(window.gBrowser.selectedBrowser); 153 }, 154 155 removeWindow(window) { 156 _untrackWindowOrder(window); 157 158 // Remove the event listeners 159 TAB_EVENTS.forEach(function (event) { 160 window.gBrowser.tabContainer.removeEventListener(event, _handleEvent); 161 }); 162 WINDOW_EVENTS.forEach(function (event) { 163 window.removeEventListener(event, _handleEvent); 164 }); 165 }, 166 167 onActivate(window) { 168 // If this window was the last focused window, we don't need to do anything 169 if (window == _trackedWindows[0]) { 170 return; 171 } 172 173 _untrackWindowOrder(window); 174 _trackWindowOrder(window); 175 176 _updateCurrentBrowserId(window.gBrowser.selectedBrowser); 177 }, 178 }; 179 180 export const BrowserWindowTracker = { 181 pendingWindows: new Map(), 182 183 /** 184 * Get the most recent browser window. 185 * Note that with the default options this may return null on Windows if 186 * there are no open windows in the current virtual desktop. To prevent this, 187 * set `options.allowFromInactiveWorkspace` to true. 188 * 189 * @param {object} options - An object accepting the arguments for the search. 190 * @param {boolean} [options.private] 191 * true to only search for private windows. 192 * false to restrict the search to non-private windows. 193 * If the property is not provided, search for either. If permanent private 194 * browsing is enabled this option will be ignored! 195 * @param {boolean} [options.allowPopups]: true if popup windows are 196 * permitted. 197 * @param {boolean} [options.allowTaskbarTabs] true if taskbar tab windows 198 * are permitted. 199 * @param {boolean} [options.allowFromInactiveWorkspace] true if window is allowed to 200 * be from a different virtual desktop (what Windows calls workspaces). 201 * Only has an effect on Windows. 202 * 203 * @returns {Window | null} The current top/selected window. 204 * Can return null on MacOS when there is no open window. 205 */ 206 getTopWindow(options = {}) { 207 let cloakedWin = null; 208 for (let win of _trackedWindows) { 209 if ( 210 !win.closed && 211 (options.allowPopups || win.toolbar.visible) && 212 (options.allowTaskbarTabs || 213 !win.document.documentElement.hasAttribute("taskbartab")) && 214 (!("private" in options) || 215 lazy.PrivateBrowsingUtils.permanentPrivateBrowsing || 216 lazy.PrivateBrowsingUtils.isWindowPrivate(win) == options.private) 217 ) { 218 // On Windows, windows on a different virtual desktop (what Windows calls 219 // workspaces) are cloaked. 220 if (win.isCloaked && lazy.gPreferWindowsOnCurrentVirtualDesktop) { 221 // Even if we allow from an inactive workspace, prefer windows that 222 // are not cloaked, so that we don't switch workspaces unnecessarily. 223 if (!cloakedWin && options.allowFromInactiveWorkspace) { 224 cloakedWin = win; 225 } 226 continue; 227 } 228 return win; 229 } 230 } 231 // If we didn't find a non-cloaked window, return the cloaked one if it exists and 232 // the options allow us to do so. 233 return cloakedWin; 234 }, 235 236 /** 237 * Get a window that is in the process of loading. Only supports windows 238 * opened via the `openWindow` function in this module or that have been 239 * registered with the `registerOpeningWindow` function. 240 * 241 * @param {object} options 242 * Options for the search. 243 * @param {boolean} [options.private] 244 * true to restrict the search to private windows only, false to restrict 245 * the search to non-private only. Omit the property to search in both 246 * groups. 247 * 248 * @returns {Promise<Window> | null} 249 */ 250 getPendingWindow(options = {}) { 251 for (let pending of this.pendingWindows.values()) { 252 if ( 253 !("private" in options) || 254 lazy.PrivateBrowsingUtils.permanentPrivateBrowsing || 255 pending.isPrivate == options.private 256 ) { 257 return pending.deferred.promise; 258 } 259 } 260 return null; 261 }, 262 263 /** 264 * Registers a browser window that is in the process of opening. Normally it 265 * would be preferable to use the standard method for opening the window from 266 * this module. 267 * 268 * @param {Window} window 269 * The opening window. 270 * @param {boolean} isPrivate 271 * Whether the opening window is a private browsing window. 272 */ 273 registerOpeningWindow(window, isPrivate) { 274 let deferred = Promise.withResolvers(); 275 276 this.pendingWindows.set(window, { 277 isPrivate, 278 deferred, 279 }); 280 281 // Prevent leaks in case the window closes before we track it as an open 282 // window. 283 const topic = "browsing-context-discarded"; 284 const observer = aSubject => { 285 if (window.browsingContext == aSubject) { 286 let pending = this.pendingWindows.get(window); 287 if (pending) { 288 this.pendingWindows.delete(window); 289 pending.deferred.resolve(window); 290 } 291 Services.obs.removeObserver(observer, topic); 292 } 293 }; 294 Services.obs.addObserver(observer, topic); 295 }, 296 297 /** 298 * A standard function for opening a new browser window. 299 * 300 * @param {object} [options] 301 * Options for the new window. 302 * @param {Window} [options.openerWindow] 303 * An existing browser window to open the new one from. 304 * @param {boolean} [options.private] 305 * True to make the window a private browsing window. 306 * @param {boolean} [options.aiWindow] 307 * True to make the window an AI browsing window. 308 * @param {string} [options.features] 309 * Additional window features to give the new window. 310 * @param {boolean} [options.all] 311 * True if "all" should be included as a window feature. If omitted, defaults 312 * to true. 313 * @param {nsIArray | nsISupportsString} [options.args] 314 * Arguments to pass to the new window. 315 * @param {boolean} [options.remote] 316 * A boolean indicating if the window should run remote browser tabs or 317 * not. If omitted, the window will choose the profile default state. 318 * @param {boolean} [options.fission] 319 * A boolean indicating if the window should run with fission enabled or 320 * not. If omitted, the window will choose the profile default state. 321 * 322 * @returns {Window} 323 */ 324 openWindow(options = {}) { 325 let { 326 openerWindow = undefined, 327 private: isPrivate = false, 328 aiWindow = false, 329 features = undefined, 330 all = true, 331 args = null, 332 remote = undefined, 333 fission = undefined, 334 } = options; 335 336 args = lazy.AIWindow.handleAIWindowOptions(options); 337 338 let windowFeatures = "chrome,dialog=no"; 339 if (all) { 340 windowFeatures += ",all"; 341 } 342 if (features) { 343 windowFeatures += `,${features}`; 344 } 345 let loadURIString; 346 if (isPrivate && lazy.PrivateBrowsingUtils.enabled) { 347 windowFeatures += ",private"; 348 if ( 349 (!args && !lazy.PrivateBrowsingUtils.permanentPrivateBrowsing) || 350 args?.private === "no-home" 351 ) { 352 // Force the new window to load about:privatebrowsing instead of the 353 // default home page. 354 loadURIString = "about:privatebrowsing"; 355 } 356 } else { 357 windowFeatures += ",non-private"; 358 } 359 if (aiWindow) { 360 windowFeatures += ",ai-window"; 361 } 362 if (!args) { 363 loadURIString ??= lazy.BrowserHandler.defaultArgs; 364 args = Cc["@mozilla.org/supports-string;1"].createInstance( 365 Ci.nsISupportsString 366 ); 367 args.data = loadURIString; 368 } 369 370 if (remote) { 371 windowFeatures += ",remote"; 372 } else if (remote === false) { 373 windowFeatures += ",non-remote"; 374 } 375 376 if (fission) { 377 windowFeatures += ",fission"; 378 } else if (fission === false) { 379 windowFeatures += ",non-fission"; 380 } 381 382 // If the opener window is maximized, we want to skip the animation, since 383 // we're going to be taking up most of the screen anyways, and we want to 384 // optimize for showing the user a useful window as soon as possible. 385 if (openerWindow?.windowState == openerWindow?.STATE_MAXIMIZED) { 386 windowFeatures += ",suppressanimation"; 387 } 388 389 let win = Services.ww.openWindow( 390 openerWindow, 391 AppConstants.BROWSER_CHROME_URL, 392 "_blank", 393 windowFeatures, 394 args 395 ); 396 this.registerOpeningWindow(win, isPrivate); 397 398 win.addEventListener( 399 "MozAfterPaint", 400 () => { 401 if ( 402 Services.prefs.getIntPref("browser.startup.page") == 1 && 403 loadURIString == lazy.HomePage.get() 404 ) { 405 // A notification for when a user has triggered their homepage. This 406 // is used to display a doorhanger explaining that an extension has 407 // modified the homepage, if necessary. 408 Services.obs.notifyObservers(win, "browser-open-homepage-start"); 409 } 410 }, 411 { once: true } 412 ); 413 414 return win; 415 }, 416 417 /** 418 * Async version of `openWindow` waiting for delayed startup of the new 419 * window before returning. 420 * 421 * @param {object} [options] 422 * Options for the new window. See `openWindow` for details. 423 * 424 * @returns {Window} 425 */ 426 async promiseOpenWindow(options) { 427 let win = this.openWindow(options); 428 await topicObserved( 429 "browser-delayed-startup-finished", 430 subject => subject == win 431 ); 432 return win; 433 }, 434 435 /** 436 * Number of currently open browser windows. 437 */ 438 get windowCount() { 439 return _trackedWindows.length; 440 }, 441 442 get orderedWindows() { 443 return this.getOrderedWindows(); 444 }, 445 446 /** 447 * Array of browser windows ordered by z-index, in reverse order. 448 * This means that the top-most browser window will be the first item. 449 * 450 * @param {object} options 451 * @param {boolean} [options.private] 452 * If set, returns only windows with the specified privateness. i.e. `true` 453 * will return only private windows. The default value, `null`, will return 454 * all windows. 455 */ 456 getOrderedWindows({ private: isPrivate = undefined } = {}) { 457 // Clone the windows array immediately as it may change during iteration. 458 // We'd rather have an outdated order than skip/revisit windows. 459 const windows = [..._trackedWindows]; 460 if ( 461 typeof isPrivate !== "boolean" || 462 (isPrivate && lazy.PrivateBrowsingUtils.permanentPrivateBrowsing) 463 ) { 464 return windows; 465 } 466 467 return windows.filter( 468 w => lazy.PrivateBrowsingUtils.isWindowPrivate(w) === isPrivate 469 ); 470 }, 471 472 getAllVisibleTabs() { 473 let tabs = []; 474 for (let win of BrowserWindowTracker.orderedWindows) { 475 for (let tab of win.gBrowser.visibleTabs) { 476 // Only use tabs which are not discarded / unrestored 477 if (tab.linkedPanel) { 478 let { contentTitle, browserId } = tab.linkedBrowser; 479 tabs.push({ contentTitle, browserId }); 480 } 481 } 482 } 483 return tabs; 484 }, 485 486 track(window) { 487 let pending = this.pendingWindows.get(window); 488 if (pending) { 489 this.pendingWindows.delete(window); 490 // Waiting for delayed startup to complete ensures that this new window 491 // has started loading its initial urls. 492 window.delayedStartupPromise.then(() => pending.deferred.resolve(window)); 493 } 494 495 return WindowHelper.addWindow(window); 496 }, 497 498 getBrowserById(browserId) { 499 for (let win of BrowserWindowTracker.orderedWindows) { 500 for (let tab of win.gBrowser.visibleTabs) { 501 if (tab.linkedPanel && tab.linkedBrowser.browserId === browserId) { 502 return tab.linkedBrowser; 503 } 504 } 505 } 506 return null; 507 }, 508 509 // For tests only, this function will remove this window from the list of 510 // tracked windows. Please don't forget to add it back at the end of your 511 // tests using BrowserWindowTracker.track(window)! 512 untrackForTestsOnly(window) { 513 return WindowHelper.removeWindow(window); 514 }, 515 };