/* 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 lazy = {}; ChromeUtils.defineLazyGetter(lazy, "logConsole", () => console.createInstance({ prefix: "MiniWindowManager", maxLogLevel: Services.prefs.getBoolPref("browser.mini-window.log", false) ? "Debug" : "Error", }) ); ChromeUtils.defineESModuleGetters(lazy, { MiniWindow: "moz-src:///browser/components/miniwindow/MiniWindow.sys.mjs", }); /** * Cross-window manager for Mini Windows. Owns the registry of * open mini windows, the per-origin lifecycle observers, and SessionStore integration. */ export const MiniWindowManager = new (class { /** @type {Set} live MiniWindow instances. */ _miniwindows = new Set(); /** @type {WeakMap>} originWin -> its live mini windows. */ #originWinToMinis = new WeakMap(); /** * @type {WeakMap} origin windows with lifecycle * hooks installed, mapped to the controller that removes them. */ #originWinHooks = new WeakMap(); #observing = false; get _enabled() { return Services.prefs.getBoolPref("browser.mini-window.enabled", false); } get _log() { return lazy.logConsole; } /** * Pop a cropped region of `tab` into a new mini window. * * TODO (later in the stack): the user-facing entry points that call this land * in a later commit; for now it is reached programmatically and by tests. * * @param {MozTabbrowserTab} tab - the tab to move. * @param {object} cropInfo - see MiniWindow. * @param {string} entryPoint - where the pop was initiated. * @returns {Promise} the MiniWindow, or null. */ async popRegion(tab, cropInfo, entryPoint) { return this.#pop(tab, cropInfo, entryPoint); } /** * Pop the whole `tab` into a new always-on-top window. * * @param {MozTabbrowserTab} tab - the tab to move. * @param {string} entryPoint - where the pop was initiated. * @returns {Promise} the MiniWindow, or null. */ async popTab(tab, entryPoint) { return this.#pop(tab, null, entryPoint); } /** * Opens a tab in a new Mini Window. Optionally, pop's just a region * that is passed in for the tab. * * @param {MozTabbrowserTab} tab - the tab to move. * @param {object|null} cropInfo - the region to frame (see MiniWindow), * or null for a full-tab mini window. * @param {string} entryPoint - where the pop was initiated. * @returns {Promise} the MiniWindow, or null. */ async #pop(tab, cropInfo, entryPoint) { let browser = tab.linkedBrowser; this._log.debug("pop: We're about to pop a mini window out: ", { url: browser.currentURI?.spec, crop: cropInfo, }); if (!this._enabled) { return null; } this.#ensureObservers(); let originWin = browser.documentGlobal; let wasLastTab = originWin.gBrowser.tabs.length === 1; // Instantiate the per-window controller let miniwindow = new lazy.MiniWindow(this, originWin, tab, cropInfo); // Register before the multi-tick open() so the SSWindowClosing/quit // teardown below already knows about this popup if the origin window // starts closing while the popup is still opening. // // We want to move originWin's popped tabs home before SessionStore // collects it, whether it closes on its own or as part of quitting. Setup // the event listeners to move them back. this._miniwindows.add(miniwindow); let minisForOriginWin = this.#originWinToMinis.get(originWin); if (!minisForOriginWin) { minisForOriginWin = new Set(); this.#originWinToMinis.set(originWin, minisForOriginWin); } minisForOriginWin.add(miniwindow); if (!this.#originWinHooks.has(originWin)) { this.#hookOriginWin(originWin); } let win = await miniwindow.open(); if (!win) { this._log.debug("pop: open failed, returning null"); this._unregister(miniwindow); return null; } this._log.debug("pop: mini window opened"); Glean.miniWindow.created.record({ type: cropInfo ? "fragment" : "full_tab", entry_point: entryPoint, was_last_tab: wasLastTab, concurrent_open: this._miniwindows.size, }); return miniwindow; } /** * @param {XULBrowserElement} browser * @returns {object|undefined} the mini window hosting `browser`, if any. Keyed on * the mini window's element. */ _miniWindowForBrowser(browser) { for (let mini of this._miniwindows) { if (mini.browser === browser) { return mini; } } return undefined; } _unregister(miniwindow) { this._miniwindows.delete(miniwindow); let minisForOriginWin = this.#originWinToMinis.get(miniwindow.originWin); if (!minisForOriginWin) { this._log.debug("_unregister", { remaining: this._miniwindows.size }); return; } minisForOriginWin.delete(miniwindow); if (!minisForOriginWin.size) { this.#originWinToMinis.delete(miniwindow.originWin); this.#unhookOriginWin(miniwindow.originWin); } this._log.debug("_unregister", { remaining: this._miniwindows.size }); } /** Register the shutdown observer. */ #ensureObservers() { if (this.#observing) { return; } this.#observing = true; Services.obs.addObserver(this, "quit-application-granted"); } observe(_subject, topic) { if (topic === "quit-application-granted") { this._log.debug( "observe: quit-application-granted, returning tabs home", { count: this._miniwindows.size, } ); // Preserve the popped tabs by putting them back in their origin window. for (let mini of [...this._miniwindows]) { mini.returnToOriginWin(false, "origin_window_closed"); } } } /** * Install the per-origin-window lifecycle hooks. Every listener added here * must pass the controller's signal so #unhookOriginWin can drop them all. * * @param {Window} originWin */ #hookOriginWin(originWin) { let abortController = new AbortController(); originWin.addEventListener( "SSWindowClosing", () => this.#putBackMinisForOriginWins(originWin), { signal: abortController.signal } ); this.#originWinHooks.set(originWin, abortController); } /** * Undo #hookOriginWin. No-op while the origin window still has mini windows - * they depend on the SSWindowClosing teardown, so removing it early would * leak them if the window closed. * * @param {Window} originWin */ #unhookOriginWin(originWin) { if (this.#originWinToMinis.get(originWin)?.size) { return; } let abortController = this.#originWinHooks.get(originWin); if (!abortController) { return; } this.#originWinHooks.delete(originWin); abortController.abort(); } /** * Moves the oldest popped tab back to the origin window. * * @param {Window} originWin * @returns {boolean} true if a popped tab was moved home. */ maybeMoveOldestMiniWindow(originWin) { // The per-origin-window set preserves insertion order. let oldest = this.#originWinToMinis.get(originWin)?.values().next().value; if (!oldest) { // Looks like there's no more to put back. return false; } oldest.returnToOriginWin(true, "auto_return_guardrail"); return true; } #putBackMinisForOriginWins(originWin) { let minis = this.#minisForOriginWin(originWin); this._log.debug("#putBackMinisForOriginWins", { count: minis.length }); for (let miniWin of minis) { miniWin.returnToOriginWin(false, "origin_window_closed"); } } /** * @param {Window} originWin * @returns {object[]} live popups whose originWin is `originWin` (a copy, so * callers can iterate while teardown removes popups from the live set). */ #minisForOriginWin(originWin) { let minis = this.#originWinToMinis.get(originWin); return minis ? [...minis] : []; } })();