/* 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.defineESModuleGetters(lazy, { BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", TabStateFlusher: "moz-src:///browser/components/sessionstore/TabStateFlusher.sys.mjs", MiniWindowUtils: "moz-src:///browser/components/miniwindow/MiniWindowUtils.sys.mjs", }); ChromeUtils.defineLazyGetter(lazy, "logConsole", () => console.createInstance({ prefix: "MiniWindow", maxLogLevel: Services.prefs.getBoolPref("browser.mini-window.log", false) ? "Debug" : "Error", }) ); export const MiniWindowState = { OPENING: "opening", FRAMED: "framed", ACTIVE: "active", RESTORING: "restoring", CLOSING: "closing", CLOSED: "closed", }; const WINDOW_EVENTS = ["unload"]; const TOOLBAR_HIDE_DELAY_FIRST_OPEN_MS = 2500; const TOOLBAR_HIDE_DELAY_MS = 2000; const TOOLBAR_HOVER_REVEAL_DELAY_MS = 100; // How deep the reveal strip along the top edge is. const TOOLBAR_EDGE_ZONE_PX = 8; /** * One always-on-top popup hosting a live moved tab. The tab keeps its * browsing context across the move, so the page is never reloaded, and * teardown adopts it back into its origin window rather than discarding it. */ export class MiniWindow { /** * The tab being popped out of its origin window into this popup. * * @type {MozTabbrowserTab} */ #sourceTab; /** * The index #sourceTab held in originWin. * * @type {number} */ #originTabIndex; /** * Whether this mini window frames a cropped region rather than the whole tab. * * @type {boolean} */ #cropped; /** * Session-history listener that returns the tab home when it navigates away * from the cropped page. * * @type {nsISHistoryListener|null} */ #historyListener = null; /** * URI the tab showed when the crop was taken. Snapshot, not live currentURI. * * @type {nsIURI|null} */ #tabURI = null; /** * The session history the history listener was attached to. * * @type {nsISHistory|null} */ #historyListenerSH = null; /** * Reveals the collapsed toolbar when the user hovers near the top of * the window. * * @type {object|null} */ #edgeListener = null; /** * Set when a scroll down hides the toolbar, so that a pointer already * resting in the top edge doesn't immediately bring it back. Cleared when * the pointer leaves the edge. * * @type {boolean} */ #edgeSuppressed = false; /** * Pending timer that re-hides the nav-bar after it's revealed. * * @type {number|null} */ #hideToolbarTimer = null; /** * Pending timer between the pointer reaching the top edge and the toolbar * revealing. * * @type {number|null} */ #hoverRevealTimer = null; /** * Reveals the toolbar when the popped tab navigates or reloads. * * @type {object|null} */ #progressListener = null; /** * Keeps the page's inset matched to the bar's height as it changes. * * @type {ResizeObserver|null} */ #toolbarHeightObserver = null; /** * Re-frames the crop when the window's size changes. * * @type {ResizeObserver|null} */ #frameResizeObserver = null; /** * Aborts all the toolbox listeners at once on teardown. * * @type {AbortController|null} */ #abortController = null; /** * When this mini window was created, for the closed event's duration. * * @type {number} */ #createdAt = ChromeUtils.now(); /** * How this mini window ended; see metrics.yaml. Set by whichever teardown * path runs first, and read by uninit(). * * @type {string|null} */ #closeMethod = null; /** * @param {object} manager - The MiniWindowManager singleton. * @param {ChromeWindow} originWin - The tab's source window. * @param {MozTabbrowserTab} sourceTab - The tab to move. * @param {object|null} [cropInfo] - The page region to frame, in content CSS * px. Carries left/top/width/height plus the viewport size and fullZoom it * was captured against; see MiniWindowUtils. Null makes a full-tab mini * window. */ constructor(manager, originWin, sourceTab, cropInfo = null) { this.manager = manager; this.originWin = originWin; this.#sourceTab = sourceTab; this.#cropped = !!cropInfo; let browser = sourceTab.linkedBrowser; if (cropInfo) { this._cropInfo = cropInfo; } else { // A full-tab mini window frames no crop, so width/height here are just // the size to open at. let { width, height } = lazy.MiniWindowUtils.fullTabSize(); this._cropInfo = { left: 0, top: 0, width, height, viewportWidth: browser.clientWidth, viewportHeight: browser.clientHeight, fullZoom: browser.fullZoom, }; } this.#originTabIndex = originWin.gBrowser.tabs.indexOf(sourceTab); this.miniWin = null; this._state = MiniWindowState.OPENING; this._crop = null; this._pageWidth = 0; this._pageHeight = 0; } /** * The current state of the Mini Window, one of MiniWindowState. * * @returns {string} */ get state() { return this._state; } /** * Whether this Mini Window frames a cropped region rather than the whole tab. * * @returns {boolean} */ get isCropped() { return this.#cropped; } /** * The browser for the current Mini Window, or undefined before the popup * window exists. * * @returns {XULBrowserElement|undefined} */ get browser() { return this.miniWin?.gBrowser.selectedBrowser; } /** * The tab that this Mini Window holds, or undefined before the popup window * exists. * * @returns {MozTabbrowserTab|undefined} */ get tab() { return this.miniWin?.gBrowser?.selectedTab; } /** * Moves the source tab out into a new always-on-top popup and frames the * crop in it. * * @returns {Promise} Resolves once the popup has finished * delayed startup, been framed and reached state ACTIVE. * Resolves with null without waiting if the tab could not be moved out, * in which case the Mini Window is left CLOSED. */ async open() { lazy.logConsole.debug("open", { crop: this._cropInfo }); let gBrowser = this.originWin.gBrowser; let rect = lazy.MiniWindowUtils.computeWindowRect( this.originWin, this._cropInfo ); // Pop the tab out like a normal move-to-window. let features = { alwaysontop: 1, lockaspectratio: 1, replaceLastTab: true, outerWidth: rect.width, outerHeight: rect.height, screenX: rect.left, screenY: rect.top, }; // Mark the tab being moved out to make sure styling is correct. this.#sourceTab.setAttribute("mini-window", "true"); if (this.#cropped) { this.#sourceTab.setAttribute("cropped-mini-window", "true"); } this.miniWin = gBrowser.replaceTabWithWindow(this.#sourceTab, features); if (!this.miniWin) { lazy.logConsole.error( "open: Something went horribly wrong when opening \ the mini window - replaceTabWithWindow returned null" ); this.#sourceTab.removeAttribute("mini-window"); this.#sourceTab.removeAttribute("cropped-mini-window"); this._state = MiniWindowState.CLOSED; return null; } await lazy.BrowserUtils.promiseObserved( "browser-delayed-startup-finished", subject => subject == this.miniWin ); if (this.#cropped) { // Only a crop needs the framing and the wheel panning that substitutes // for scrolling. await this.#frame(); } this._state = MiniWindowState.FRAMED; lazy.logConsole.debug("open: state -> FRAMED"); this.#abortController = new AbortController(); this.#wireNavBarButtons(); this.#wireToolbarReveal(); this.#restrictShortcuts(); for (let ev of WINDOW_EVENTS) { this.miniWin.addEventListener(ev, this); } this._state = MiniWindowState.ACTIVE; lazy.logConsole.debug("open: state -> ACTIVE"); // Now that the tab is settled in the mini window, watch for it navigating away. this.#attachHistoryListener(); return this.miniWin; } /** * Watch a cropped mini window's session history. A crop frames one specific * page, so any navigation away from it - a real load or an SPA `pushState` - * invalidates the crop. `replaceState` and same-page anchors are ignored. This * only applies to cropped mini windows. */ #attachHistoryListener() { if (!this.#cropped) { return; } let sessionHistory = this.browser?.browsingContext?.sessionHistory; if (!sessionHistory) { return; } this.#tabURI = this.browser.currentURI; this.#historyListener = { OnHistoryNewEntry: aNewURI => { if (this._state !== MiniWindowState.ACTIVE) { return; } // Ignore in-page anchors; if (this.#tabURI && aNewURI?.equalsExceptRef(this.#tabURI)) { return; } // Defer so we don't tear down while session history is mid-update. Services.tm.dispatchToMainThread(() => this.returnToOriginWin(true, "navigated_away") ); }, OnHistoryReload: () => true, OnHistoryGotoIndex: () => { if (this._state !== MiniWindowState.ACTIVE) { return; } Services.tm.dispatchToMainThread(() => this.returnToOriginWin(true, "navigated_away") ); }, OnHistoryPurge() {}, OnHistoryTruncate() {}, OnHistoryReplaceEntry() {}, OnHistoryCommit() {}, QueryInterface: ChromeUtils.generateQI([ "nsISHistoryListener", "nsISupportsWeakReference", ]), }; sessionHistory.addSHistoryListener(this.#historyListener); this.#historyListenerSH = sessionHistory; } #detachHistoryListener() { if (!this.#historyListener) { return; } try { // Detach from the session history the listener was attached to this.#historyListenerSH?.removeSHistoryListener(this.#historyListener); } catch (e) { lazy.logConsole.error("#detachHistoryListener failed", e); } this.#historyListener = null; this.#historyListenerSH = null; this.#tabURI = null; } /** * Restrict shortcuts allowed in the miniWin. */ #restrictShortcuts() { const ALLOWED_KEYS = new Set([ "key_close", "key_reload", "key_reload2", "key_reload_skip_cache", "key_reload_skip_cache2", "key_closeWindow", "key_toggleMute", ]); // A crop frames one specific page, so history navigation only makes sense // in a full-tab mini window. if (!this.#cropped) { for (let id of ["goBackKb", "goBackKb2", "goForwardKb", "goForwardKb2"]) { ALLOWED_KEYS.add(id); } } let keyset = this.miniWin.document.getElementById("mainKeyset"); for (let key of keyset ? [...keyset.children] : []) { if (key.localName === "key" && !ALLOWED_KEYS.has(key.id)) { key.setAttribute("disabled", "true"); } } const CLOSE_COMMAND_METHODS = { cmd_close: "kbd_close_tab", cmd_closeWindow: "kbd_close_window", }; let { signal } = this.#abortController; for (let [id, method] of Object.entries(CLOSE_COMMAND_METHODS)) { this.miniWin.document.getElementById(id)?.addEventListener( "command", () => { this.#closeMethod = method; }, { signal } ); } } /** * Reveal and wire the two mini-window controls that live in the reused * real nav-bar. */ #wireNavBarButtons() { let doc = this.miniWin.document; let { signal } = this.#abortController; let closeButton = doc.getElementById("mini-window-close-button"); let restoreButton = doc.getElementById("mini-window-restore-button"); closeButton?.removeAttribute("hidden"); restoreButton?.removeAttribute("hidden"); closeButton?.addEventListener("command", () => this.close(), { signal }); restoreButton?.addEventListener( "command", () => this.returnToOriginWin(true), { signal } ); this.#wireAudioButton(); } /** * Mirror the tab's sound state onto the nav-bar's mute button. * */ #wireAudioButton() { let { signal } = this.#abortController; let audioButton = this.miniWin.document.getElementById( "mini-window-audio-button" ); let tab = this.tab; if (!audioButton || !tab) { return; } let sync = () => { audioButton.toggleAttribute( "soundplaying", tab.hasAttribute("soundplaying") ); let muted = tab.hasAttribute("muted"); audioButton.toggleAttribute("muted", muted); audioButton.toggleAttribute("checked", muted); audioButton.setAttribute( "data-l10n-id", muted ? "mini-window-audio-unmute" : "mini-window-audio-mute" ); }; // The tab can already be playing when it is popped out, so start from its // current state rather than waiting for the next change. sync(); tab.addEventListener("TabAttrModified", sync, { signal }); audioButton.addEventListener("command", () => tab.toggleMuteAudio(), { signal, }); } /** * The reused nav-bar collapses away to stay out of the way of the framed * crop. Putting the pointer in a strip along the top edge reveals it. Every * reveal hides again after an idle delay. */ #wireToolbarReveal() { let { signal } = this.#abortController; let toolbox = this.miniWin.gNavToolbox; let win = this.miniWin; this.#edgeListener = { getMouseTargetRect() { // getBoundsWithoutFlushing so a mouse move never forces a reflow. let { width } = win.windowUtils.getBoundsWithoutFlushing( win.document.documentElement ); return { top: 0, bottom: TOOLBAR_EDGE_ZONE_PX, left: 0, right: width }; }, onMouseEnter: () => { // A scroll down dismissed the bar while the pointer was already at the // top edge; don't hand it straight back. if (this.#edgeSuppressed) { return; } this.#scheduleHoverReveal(); }, onMouseLeave: () => { this.#edgeSuppressed = false; this.#cancelHoverReveal(); if (toolbox.classList.contains("mini-window-revealed")) { this.#startHideCountdown(); } }, }; win.MousePosTracker.addListener(this.#edgeListener); this.#trackToolbarHeight(toolbox); // While the pointer or keyboard focus is on the toolbar, CSS holds it open let onToolbarLeave = event => { if (!toolbox.contains(event.relatedTarget)) { this.revealToolbar(); } }; toolbox.addEventListener("mouseout", onToolbarLeave, { signal }); toolbox.addEventListener("focusout", onToolbarLeave, { signal }); // Show the toolbar on navigation or reload, then let it idle away // A navigation also swaps in a fresh content actor, so scroll reporting // has to be re-armed. this.#progressListener = { onLocationChange: () => { this.revealToolbar(); this.#enableScrollReveal(); }, }; this.miniWin.gBrowser.addTabsProgressListener(this.#progressListener); this.#enableScrollReveal(); // A doorhanger or an infobar needs the toolbar it is anchored to. toolbox.addEventListener("AlertActive", () => this.revealToolbar(), { signal, }); this.#revealForFirstOpen(); } /** * The bar should already be on screen when the mini window appears, rather * than sliding in after it. Suppress the transition for the frame in which * it is revealed, then hand animation back for every later reveal. */ #revealForFirstOpen() { let root = this.miniWin.document.documentElement; root.setAttribute("mini-window-first-open", "true"); this.revealToolbar(TOOLBAR_HIDE_DELAY_FIRST_OPEN_MS); // Two frames: the first still has the suppressing attribute applied, so // removing it any earlier would let the reveal animate after all. this.miniWin.requestAnimationFrame(() => { this.miniWin?.requestAnimationFrame(() => { root.removeAttribute("mini-window-first-open"); }); }); } /** * The bar overlays the page, so the page has to move down by exactly the * bar's height or the bar covers content. * * @param {Element} toolbox */ #trackToolbarHeight(toolbox) { let setHeight = height => { if (!height) { return; } this.miniWin?.document.documentElement.style.setProperty( "--mini-window-toolbar-height", `${height}px` ); }; // getBoundsWithoutFlushing never forces a flush, so this can be stale setHeight( this.miniWin.windowUtils.getBoundsWithoutFlushing(toolbox).height ); this.#toolbarHeightObserver = new this.miniWin.ResizeObserver(entries => { setHeight(entries.at(-1)?.borderBoxSize?.[0]?.blockSize); }); this.#toolbarHeightObserver.observe(toolbox); } /** * Scrolling down dismisses the toolbar at once. */ hideToolbarOnScrollDown() { this.#hideToolbar(); this.#edgeSuppressed = true; } /** * Whether something in the toolbar still needs it on screen, checked each * time the idle countdown comes due. A hold has no "released" signal to * listen for - notifications in particular only announce their arrival. * * @returns {boolean} */ #toolbarHeld() { let doc = this.miniWin?.document; if (!doc) { return false; } // An open doorhanger, or an infobar in the toolbar's notification box. return !!( doc.getElementById("notification-popup")?.state === "open" || doc .getElementById("notifications-toolbar") ?.querySelector("notification-message") ); } /** Ask the content actor to report upward scrolls. */ #enableScrollReveal() { this.#getActor()?.sendAsyncMessage("EnableScrollReveal"); } /** * Show the toolbar and (re)start the countdown that hides it again. * * @param {number} hideDelay - how long the toolbar stays up. */ revealToolbar(hideDelay = TOOLBAR_HIDE_DELAY_MS) { this.#keepToolbarShown(); this.#startHideCountdown(hideDelay); } /** * (Re)start the countdown that hides the toolbar. * * @param {number} hideDelay - how long until the toolbar hides. */ #startHideCountdown(hideDelay = TOOLBAR_HIDE_DELAY_MS) { this.#clearHideTimer(); this.#hideToolbarTimer = this.miniWin.setTimeout(() => { if (this.#toolbarHeld()) { // Wait out another interval rather than hiding something the user is // still being asked about. this.#startHideCountdown(hideDelay); return; } this.#hideToolbar(); }, hideDelay); } /** Show the toolbar with no hide countdown (something needs it open). */ #keepToolbarShown() { this.#clearHideTimer(); this.miniWin?.gNavToolbox?.classList.add("mini-window-revealed"); } /** Hide the toolbar right away, dropping any pending countdown or dwell. */ #hideToolbar() { this.#clearHideTimer(); this.#cancelHoverReveal(); this.miniWin?.gNavToolbox?.classList.remove("mini-window-revealed"); } #clearHideTimer() { if (this.#hideToolbarTimer == null) { return; } this.miniWin.clearTimeout(this.#hideToolbarTimer); this.#hideToolbarTimer = null; } /** * Reveal after a short dwell on the top edge, so grazing it doesn't flash * the toolbar. Held open while the pointer stays in the zone. */ #scheduleHoverReveal() { if (this.miniWin?.gNavToolbox?.classList.contains("mini-window-revealed")) { this.#keepToolbarShown(); return; } if (this.#hoverRevealTimer === null) { this.#hoverRevealTimer = this.miniWin.setTimeout(() => { this.#hoverRevealTimer = null; this.#keepToolbarShown(); }, TOOLBAR_HOVER_REVEAL_DELAY_MS); } } #cancelHoverReveal() { if (this.#hoverRevealTimer == null) { // there is no hover reveal timer running return; } this.miniWin.clearTimeout(this.#hoverRevealTimer); this.#hoverRevealTimer = null; } #unwireToolbarReveal() { // The timers, the progress listener and the hover zone need explicit // cleanup. this.#clearHideTimer(); this.#cancelHoverReveal(); this.#toolbarHeightObserver?.disconnect(); this.#toolbarHeightObserver = null; this.#frameResizeObserver?.disconnect(); this.#frameResizeObserver = null; if (this.#progressListener) { this.miniWin?.gBrowser?.removeTabsProgressListener( this.#progressListener ); this.#progressListener = null; } if (this.#edgeListener) { this.miniWin?.MousePosTracker.removeListener(this.#edgeListener); this.#edgeListener = null; } } /** * @param {Event} event - A WINDOW_EVENTS event from the popup window. */ handleEvent(event) { switch (event.type) { case "unload": this.#onUnload(); break; } } /** * Lay the browser out at the full page size with scroll normalized to 0,0, * then frame the crop with a chrome-side CSS transform. * Page size comes from the content actor, clamped up to the crop's own viewport * so a crop can never exceed it. * * @returns {Promise} Resolves once the browser has been sized and the * transform applied. Waits on the content actor for the page size, and * falls back to the crop's viewport if the actor is gone or the query * fails, so this resolves rather than rejecting on a dead actor. */ async #frame() { let { browser, _cropInfo: cropInfo } = this; this._crop = { left: cropInfo.left, top: cropInfo.top, width: cropInfo.width, height: cropInfo.height, }; let actor = this.#getActor(); if (!actor) { lazy.logConsole.warn( "No MiniWindow actor available; falling back to cropInfo viewport size" ); } let size = await actor?.sendQuery("GetSize").catch(() => null); let zoom = cropInfo.fullZoom || 1; let box = lazy.MiniWindowUtils.computeFrameBox(cropInfo, size, zoom); this._pageWidth = box.pageWidth; this._pageHeight = box.pageHeight; lazy.logConsole.debug("#frame", { pageWidth: this._pageWidth, pageHeight: this._pageHeight, crop: this._crop, }); let container = browser.closest(".browserStack") || browser.parentNode; if (container) { container.style.overflow = "hidden"; } browser.style.minWidth = "0"; browser.style.minHeight = "0"; browser.style.width = box.boxWidth + "px"; browser.style.height = box.boxHeight + "px"; browser.style.transformOrigin = "0 0"; actor?.sendAsyncMessage("ScrollTo", { x: 0, y: 0 }); this.#applyTransform(); this.#trackWindowResize(); } /** * @returns {JSWindowActorParent|undefined} The moved tab's MiniWindow * actor, or undefined if its window global is gone. */ #getActor() { return this.browser?.browsingContext?.currentWindowGlobal?.getActor( "MiniWindow" ); } /** * Re-frame the crop against the popup's current width. A no-op until #frame * has computed the crop. * * @param {number} [innerWidth] The window's inner width, when the caller * already has it. Otherwise it is read without flushing layout. */ #applyTransform(innerWidth) { if (!this._crop) { return; } innerWidth ??= this.miniWin.windowUtils.getBoundsWithoutFlushing( this.miniWin.document.documentElement ).width; let { scale, tx, ty } = lazy.MiniWindowUtils.computeTransform( innerWidth, this._crop, this._cropInfo.fullZoom || 1 ); this.browser.style.transform = `scale(${scale}) translate(${tx}px, ${ty}px)`; } /** * Re-frame the crop whenever the window changes size. * */ #trackWindowResize() { let root = this.miniWin.document.documentElement; this.#frameResizeObserver = new this.miniWin.ResizeObserver(entries => { let width = entries.at(-1)?.contentBoxSize?.[0]?.inlineSize; if (width) { this.#applyTransform(width); } }); this.#frameResizeObserver.observe(root); } // TODO: Scrolling horizontally won't work as is right now, a later commit in the stack // handles this by introducing panning. /** * When unloading, we want to preserve the tab in the originWin's history. Instead of * closing, we send it back home (originWin). */ #onUnload() { if ( this._state !== MiniWindowState.RESTORING && this._state !== MiniWindowState.CLOSING && this._state !== MiniWindowState.CLOSED ) { lazy.logConsole.debug("#onUnload: falling through to returnToOriginWin"); this.returnToOriginWin(false, "unknown"); } } /** * Close the popup and discard the tab. We need to return the tab to its originWin * so that sessionStore can remember it in the originWin's history. * * This must only be invoked when the popped window is still alive - the unload path * should use #onUnload instead. Use returnToOriginWin to return the tab * instead of closing it. * * Returns as soon as the state is CLOSING, without waiting: the tab state * flush, the adopt back into originWin and the teardown all happen * afterwards, so the popup is still open when this returns. * * @param {string} [method] - how this was reached; see metrics.yaml. */ close(method = "close_button") { lazy.logConsole.debug("close", { state: this._state, method }); if ( this._state === MiniWindowState.CLOSED || this._state === MiniWindowState.CLOSING ) { return; } this._state = MiniWindowState.CLOSING; this.#closeMethod = method; this.#detachHistoryListener(); this.#abortController?.abort(); this.#unwireToolbarReveal(); let flushed = this.tab ? lazy.TabStateFlusher.flush(this.tab.linkedBrowser) : Promise.resolve(); flushed .then(() => { let { tab: adopted, win } = this.#returnTabToOrigin(false); if (adopted && win && !win.closed) { win.gBrowser.removeTab(adopted, { animate: false }); } }) .catch(e => lazy.logConsole.error("close: flush/adopt failed", e)) .finally(() => this.uninit()); } /** * Put the tab back into its original window and tear the mini window down. * * @param {boolean} focus - whether to focus the tab in originWin. * @param {string} [method] - how this was reached; see metrics.yaml. */ returnToOriginWin(focus, method = "put_tab_back") { lazy.logConsole.debug("returnToOriginWin", { state: this._state, focus, method, }); if ( this._state !== MiniWindowState.OPENING && this._state !== MiniWindowState.FRAMED && this._state !== MiniWindowState.ACTIVE ) { return; } this._state = MiniWindowState.RESTORING; this.#closeMethod ??= method; let { tab: adopted, win: targetWin } = this.#returnTabToOrigin(focus); this.uninit(); // Focus wherever the tab actually landed (originWin, or the fallback // window if originWin already closed). if (focus && adopted && targetWin && !targetWin.closed) { targetWin.focus(); // The page might've navigated without a user gesture - make sure the // user is aware where it went. targetWin.getAttention(); } } /** * Adopt the popup's tab back into originWin at its remembered original index. * * @param {boolean} selectTab - whether to focus the tab in the target window. * @returns {{tab: MozTabbrowserTab|null, win: Window|null}} the adopted tab * and the window it landed in, or nulls if it could not be adopted. */ #returnTabToOrigin(selectTab) { let tab = this.tab; let { originWin } = this; let target = originWin && !originWin.closed ? originWin : null; // Only a user-initiated restore falls back to another window when the // origin is already gone; automatic teardown (origin close, quit) lets // the tab die with its window as usual. if (!target && selectTab) { target = lazy.BrowserWindowTracker.getTopWindow(); } if (!tab || !target) { return { tab: null, win: null }; } let tabIndex = Math.min(this.#originTabIndex, target.gBrowser.tabs.length); return { tab: target.gBrowser.adoptTab(tab, { tabIndex, selectTab }), win: target, }; } /** * Remove any event listeners and close the window (if it already hasn't), * then unregister from the manager. Safe to call more than once. */ uninit() { lazy.logConsole.debug("uninit", { state: this._state }); if (this._state === MiniWindowState.CLOSED) { lazy.logConsole.debug("uninit: already closed, ignoring"); return; } // A throw during teardown must not strand this popup registered; try { let flavour = this.#cropped ? "fragment" : "full_tab"; let openDuration = Math.round(ChromeUtils.now() - this.#createdAt); Glean.miniWindow.closed.record({ type: flavour, method: this.#closeMethod ?? "unknown", duration_ms: openDuration, }); Glean.miniWindow.openDuration[flavour].accumulateSingleSample( openDuration ); this.#detachHistoryListener(); this.#abortController?.abort(); this.#unwireToolbarReveal(); for (let ev of WINDOW_EVENTS) { this.miniWin?.removeEventListener(ev, this); } if (this.miniWin && !this.miniWin.closed) { this.miniWin.close(); } } catch (e) { lazy.logConsole.error("uninit: teardown failed", e); } finally { this._state = MiniWindowState.CLOSED; this.manager._unregister(this); this.miniWin = null; } } }