/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ /* vim: set sts=2 sw=2 et tw=80: */ "use strict"; XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI", "resource:///modules/CustomizableUI.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", "resource://gre/modules/NetUtil.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", "resource://gre/modules/PrivateBrowsingUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "setTimeout", "resource://gre/modules/Timer.jsm"); XPCOMUtils.defineLazyServiceGetter(this, "styleSheetService", "@mozilla.org/content/style-sheet-service;1", "nsIStyleSheetService"); Cu.import("resource://gre/modules/ExtensionUtils.jsm"); Cu.import("resource://gre/modules/AppConstants.jsm"); const POPUP_LOAD_TIMEOUT_MS = 200; const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; var { DefaultWeakMap, EventManager, promiseEvent, } = ExtensionUtils; // This file provides some useful code for the |tabs| and |windows| // modules. All of the code is installed on |global|, which is a scope // shared among the different ext-*.js scripts. global.makeWidgetId = id => { id = id.toLowerCase(); // FIXME: This allows for collisions. return id.replace(/[^a-z0-9_-]/g, "_"); }; function promisePopupShown(popup) { return new Promise(resolve => { if (popup.state == "open") { resolve(); } else { popup.addEventListener("popupshown", function onPopupShown(event) { popup.removeEventListener("popupshown", onPopupShown); resolve(); }); } }); } XPCOMUtils.defineLazyGetter(this, "popupStylesheets", () => { let stylesheets = ["chrome://browser/content/extension.css"]; if (AppConstants.platform === "macosx") { stylesheets.push("chrome://browser/content/extension-mac.css"); } return stylesheets; }); XPCOMUtils.defineLazyGetter(this, "standaloneStylesheets", () => { let stylesheets = []; if (AppConstants.platform === "macosx") { stylesheets.push("chrome://browser/content/extension-mac-panel.css"); } if (AppConstants.platform === "win") { stylesheets.push("chrome://browser/content/extension-win-panel.css"); } return stylesheets; }); class BasePopup { constructor(extension, viewNode, popupURL, browserStyle, fixedWidth = false) { this.extension = extension; this.popupURL = popupURL; this.viewNode = viewNode; this.browserStyle = browserStyle; this.window = viewNode.ownerGlobal; this.destroyed = false; this.fixedWidth = fixedWidth; extension.callOnClose(this); this.contentReady = new Promise(resolve => { this._resolveContentReady = resolve; }); this.viewNode.addEventListener(this.DESTROY_EVENT, this); let doc = viewNode.ownerDocument; let arrowContent = doc.getAnonymousElementByAttribute(this.panel, "class", "panel-arrowcontent"); this.borderColor = doc.defaultView.getComputedStyle(arrowContent).borderTopColor; this.browser = null; this.browserLoaded = new Promise((resolve, reject) => { this.browserLoadedDeferred = {resolve, reject}; }); this.browserReady = this.createBrowser(viewNode, popupURL); BasePopup.instances.get(this.window).set(extension, this); } static for(extension, window) { return BasePopup.instances.get(window).get(extension); } close() { this.closePopup(); } destroy() { this.extension.forgetOnClose(this); this.destroyed = true; this.browserLoadedDeferred.reject(new Error("Popup destroyed")); return this.browserReady.then(() => { this.destroyBrowser(this.browser); this.browser.remove(); this.viewNode.removeEventListener(this.DESTROY_EVENT, this); this.viewNode.style.maxHeight = ""; if (this.panel) { this.panel.style.removeProperty("--arrowpanel-background"); this.panel.style.removeProperty("--panel-arrow-image-vertical"); } BasePopup.instances.get(this.window).delete(this.extension); this.browser = null; this.viewNode = null; }); } destroyBrowser(browser) { let mm = browser.messageManager; // If the browser has already been removed from the document, because the // popup was closed externally, there will be no message manager here. if (mm) { mm.removeMessageListener("DOMTitleChanged", this); mm.removeMessageListener("Extension:BrowserBackgroundChanged", this); mm.removeMessageListener("Extension:BrowserContentLoaded", this); mm.removeMessageListener("Extension:BrowserResized", this); mm.removeMessageListener("Extension:DOMWindowClose", this); } } // Returns the name of the event fired on `viewNode` when the popup is being // destroyed. This must be implemented by every subclass. get DESTROY_EVENT() { throw new Error("Not implemented"); } get STYLESHEETS() { let sheets = []; if (this.browserStyle) { sheets.push(...popupStylesheets); } if (!this.fixedWidth) { sheets.push(...standaloneStylesheets); } return sheets; } get panel() { let panel = this.viewNode; while (panel && panel.localName != "panel") { panel = panel.parentNode; } return panel; } receiveMessage({name, data}) { switch (name) { case "DOMTitleChanged": this.viewNode.setAttribute("aria-label", this.browser.contentTitle); break; case "Extension:BrowserBackgroundChanged": this.setBackground(data.background); break; case "Extension:BrowserContentLoaded": this.browserLoadedDeferred.resolve(); break; case "Extension:BrowserResized": this._resolveContentReady(); if (this.ignoreResizes) { this.dimensions = data; } else { this.resizeBrowser(data); } break; case "Extension:DOMWindowClose": this.closePopup(); break; } } handleEvent(event) { switch (event.type) { case this.DESTROY_EVENT: this.destroy(); break; } } createBrowser(viewNode, popupURL = null) { let document = viewNode.ownerDocument; this.browser = document.createElementNS(XUL_NS, "browser"); this.browser.setAttribute("type", "content"); this.browser.setAttribute("disableglobalhistory", "true"); this.browser.setAttribute("transparent", "true"); this.browser.setAttribute("class", "webextension-popup-browser"); this.browser.setAttribute("tooltip", "aHTMLTooltip"); // We only need flex sizing for the sake of the slide-in sub-views of the // main menu panel, so that the browser occupies the full width of the view, // and also takes up any extra height that's available to it. this.browser.setAttribute("flex", "1"); // Note: When using noautohide panels, the popup manager will add width and // height attributes to the panel, breaking our resize code, if the browser // starts out smaller than 30px by 10px. This isn't an issue now, but it // will be if and when we popup debugging. viewNode.appendChild(this.browser); extensions.emit("extension-browser-inserted", this.browser); let windowId = WindowManager.getId(this.browser.ownerGlobal); this.browser.messageManager.sendAsyncMessage("Extension:InitExtensionView", { viewType: "popup", windowId, }); // TODO(robwu): Rework this to use the Extension:ExtensionViewLoaded message // to detect loads and so on. And definitely move this content logic inside // a file in the child process. let initBrowser = browser => { let mm = browser.messageManager; mm.addMessageListener("DOMTitleChanged", this); mm.addMessageListener("Extension:BrowserBackgroundChanged", this); mm.addMessageListener("Extension:BrowserContentLoaded", this); mm.addMessageListener("Extension:BrowserResized", this); mm.addMessageListener("Extension:DOMWindowClose", this, true); }; if (!popupURL) { initBrowser(this.browser); return this.browser; } return promiseEvent(this.browser, "load").then(() => { initBrowser(this.browser); let mm = this.browser.messageManager; mm.loadFrameScript( "chrome://extensions/content/ext-browser-content.js", false); mm.sendAsyncMessage("Extension:InitBrowser", { allowScriptsToClose: true, fixedWidth: this.fixedWidth, maxWidth: 800, maxHeight: 600, stylesheets: this.STYLESHEETS, }); this.browser.setAttribute("src", popupURL); }); } resizeBrowser({width, height, detail}) { if (this.fixedWidth) { // Figure out how much extra space we have on the side of the panel // opposite the arrow. let side = this.panel.getAttribute("side") == "top" ? "bottom" : "top"; let maxHeight = this.viewHeight + this.extraHeight[side]; height = Math.min(height, maxHeight); this.browser.style.height = `${height}px`; // Set a maximum height on the element to our preferred // maximum height, so that the PanelUI resizing code can make an accurate // calculation. If we don't do this, the flex sizing logic will prevent us // from ever reporting a preferred size smaller than the height currently // available to us in the panel. height = Math.max(height, this.viewHeight); this.viewNode.style.maxHeight = `${height}px`; } else { this.browser.style.width = `${width}px`; this.browser.style.height = `${height}px`; } let event = new this.window.CustomEvent("WebExtPopupResized", {detail}); this.browser.dispatchEvent(event); } setBackground(background) { let panelBackground = ""; let panelArrow = ""; if (background) { let borderColor = this.borderColor || background; panelBackground = background; panelArrow = `url("data:image/svg+xml,${encodeURIComponent(` `)}")`; } this.panel.style.setProperty("--arrowpanel-background", panelBackground); this.panel.style.setProperty("--panel-arrow-image-vertical", panelArrow); this.background = background; } } /** * A map of active popups for a given browser window. * * WeakMap[window -> WeakMap[Extension -> BasePopup]] */ BasePopup.instances = new DefaultWeakMap(() => new WeakMap()); class PanelPopup extends BasePopup { constructor(extension, imageNode, popupURL, browserStyle) { let document = imageNode.ownerDocument; let panel = document.createElement("panel"); panel.setAttribute("id", makeWidgetId(extension.id) + "-panel"); panel.setAttribute("class", "browser-extension-panel"); panel.setAttribute("tabspecific", "true"); panel.setAttribute("type", "arrow"); panel.setAttribute("role", "group"); document.getElementById("mainPopupSet").appendChild(panel); super(extension, panel, popupURL, browserStyle); this.contentReady.then(() => { panel.openPopup(imageNode, "bottomcenter topright", 0, 0, false, false); let event = new this.window.CustomEvent("WebExtPopupLoaded", { bubbles: true, detail: {extension}, }); this.browser.dispatchEvent(event); }); } get DESTROY_EVENT() { return "popuphidden"; } destroy() { super.destroy(); this.viewNode.remove(); } closePopup() { promisePopupShown(this.viewNode).then(() => { // Make sure we're not already destroyed. if (this.viewNode) { this.viewNode.hidePopup(); } }); } } class ViewPopup extends BasePopup { constructor(extension, window, popupURL, browserStyle, fixedWidth) { let document = window.document; // Create a temporary panel to hold the browser while it pre-loads its // content. This panel will never be shown, but the browser's docShell will // be swapped with the browser in the real panel when it's ready. let panel = document.createElement("panel"); panel.setAttribute("type", "arrow"); document.getElementById("mainPopupSet").appendChild(panel); super(extension, panel, popupURL, browserStyle, fixedWidth); this.ignoreResizes = true; this.attached = false; this.tempPanel = panel; this.browser.classList.add("webextension-preload-browser"); } /** * Attaches the pre-loaded browser to the given view node, and reserves a * promise which resolves when the browser is ready. * * @param {Element} viewNode * The node to attach the browser to. * @returns {Promise} * Resolves when the browser is ready. Resolves to `false` if the * browser was destroyed before it was fully loaded, and the popup * should be closed, or `true` otherwise. */ attach(viewNode) { return Task.spawn(function* () { this.viewNode = viewNode; this.viewNode.addEventListener(this.DESTROY_EVENT, this); // Wait until the browser element is fully initialized, and give it at least // a short grace period to finish loading its initial content, if necessary. // // In practice, the browser that was created by the mousdown handler should // nearly always be ready by this point. yield Promise.all([ this.browserReady, Promise.race([ // This promise may be rejected if the popup calls window.close() // before it has fully loaded. this.browserLoaded.catch(() => {}), new Promise(resolve => setTimeout(resolve, POPUP_LOAD_TIMEOUT_MS)), ]), ]); if (!this.destroyed && !this.panel) { this.destroy(); } if (this.destroyed) { return false; } this.attached = true; // Store the initial height of the view, so that we never resize menu panel // sub-views smaller than the initial height of the menu. this.viewHeight = this.viewNode.boxObject.height; // Calculate the extra height available on the screen above and below the // menu panel. Use that to calculate the how much the sub-view may grow. let popupRect = this.panel.getBoundingClientRect(); this.setBackground(this.background); let win = this.window; let popupBottom = win.mozInnerScreenY + popupRect.bottom; let popupTop = win.mozInnerScreenY + popupRect.top; let screenBottom = win.screen.availTop + win.screen.availHeight; this.extraHeight = { bottom: Math.max(0, screenBottom - popupBottom), top: Math.max(0, popupTop - win.screen.availTop), }; // Create a new browser in the real popup. let browser = this.browser; this.createBrowser(this.viewNode); this.browser.swapDocShells(browser); this.destroyBrowser(browser); this.ignoreResizes = false; if (this.dimensions) { this.resizeBrowser(this.dimensions); } this.tempPanel.remove(); this.tempPanel = null; let event = new this.window.CustomEvent("WebExtPopupLoaded", { bubbles: true, detail: {extension: this.extension}, }); this.browser.dispatchEvent(event); return true; }.bind(this)); } destroy() { return super.destroy().then(() => { if (this.tempPanel) { this.tempPanel.remove(); this.tempPanel = null; } }); } get DESTROY_EVENT() { return "ViewHiding"; } closePopup() { if (this.attached) { CustomizableUI.hidePanelForNode(this.viewNode); } else { this.destroy(); } } } Object.assign(global, {PanelPopup, ViewPopup}); // Manages tab-specific context data, and dispatching tab select events // across all windows. global.TabContext = function TabContext(getDefaults, extension) { this.extension = extension; this.getDefaults = getDefaults; this.tabData = new WeakMap(); this.lastLocation = new WeakMap(); AllWindowEvents.addListener("progress", this); AllWindowEvents.addListener("TabSelect", this); EventEmitter.decorate(this); }; TabContext.prototype = { get(tab) { if (!this.tabData.has(tab)) { this.tabData.set(tab, this.getDefaults(tab)); } return this.tabData.get(tab); }, clear(tab) { this.tabData.delete(tab); }, handleEvent(event) { if (event.type == "TabSelect") { let tab = event.target; this.emit("tab-select", tab); this.emit("location-change", tab); } }, onStateChange(browser, webProgress, request, stateFlags, statusCode) { let flags = Ci.nsIWebProgressListener; if (!(~stateFlags & (flags.STATE_IS_WINDOW | flags.STATE_START) || this.lastLocation.has(browser))) { this.lastLocation.set(browser, request.URI); } }, onLocationChange(browser, webProgress, request, locationURI, flags) { let gBrowser = browser.ownerGlobal.gBrowser; let lastLocation = this.lastLocation.get(browser); if (browser === gBrowser.selectedBrowser && !(lastLocation && lastLocation.equalsExceptRef(browser.currentURI))) { let tab = gBrowser.getTabForBrowser(browser); this.emit("location-change", tab, true); } this.lastLocation.set(browser, browser.currentURI); }, shutdown() { AllWindowEvents.removeListener("progress", this); AllWindowEvents.removeListener("TabSelect", this); }, }; // Manages tab mappings and permissions for a specific extension. function ExtensionTabManager(extension) { this.extension = extension; // A mapping of tab objects to the inner window ID the extension currently has // the active tab permission for. The active permission for a given tab is // valid only for the inner window that was active when the permission was // granted. If the tab navigates, the inner window ID changes, and the // permission automatically becomes stale. // // WeakMap[tab => inner-window-id] this.hasTabPermissionFor = new WeakMap(); } ExtensionTabManager.prototype = { addActiveTabPermission(tab = TabManager.activeTab) { if (this.extension.hasPermission("activeTab")) { // Note that, unlike Chrome, we don't currently clear this permission with // the tab navigates. If the inner window is revived from BFCache before // we've granted this permission to a new inner window, the extension // maintains its permissions for it. this.hasTabPermissionFor.set(tab, tab.linkedBrowser.innerWindowID); } }, revokeActiveTabPermission(tab = TabManager.activeTab) { this.hasTabPermissionFor.delete(tab); }, // Returns true if the extension has the "activeTab" permission for this tab. // This is somewhat more permissive than the generic "tabs" permission, as // checked by |hasTabPermission|, in that it also allows programmatic script // injection without an explicit host permission. hasActiveTabPermission(tab) { // This check is redundant with addTabPermission, but cheap. if (this.extension.hasPermission("activeTab")) { return (this.hasTabPermissionFor.has(tab) && this.hasTabPermissionFor.get(tab) === tab.linkedBrowser.innerWindowID); } return false; }, hasTabPermission(tab) { return this.extension.hasPermission("tabs") || this.hasActiveTabPermission(tab); }, convert(tab) { let window = tab.ownerGlobal; let browser = tab.linkedBrowser; let mutedInfo = {muted: tab.muted}; if (tab.muteReason === null) { mutedInfo.reason = "user"; } else if (tab.muteReason) { mutedInfo.reason = "extension"; mutedInfo.extensionId = tab.muteReason; } let result = { id: TabManager.getId(tab), index: tab._tPos, windowId: WindowManager.getId(window), selected: tab.selected, highlighted: tab.selected, active: tab.selected, pinned: tab.pinned, status: TabManager.getStatus(tab), incognito: WindowManager.isBrowserPrivate(browser), width: browser.frameLoader.lazyWidth || browser.clientWidth, height: browser.frameLoader.lazyHeight || browser.clientHeight, audible: tab.soundPlaying, mutedInfo, }; if (this.extension.hasPermission("cookies")) { result.cookieStoreId = getCookieStoreIdForTab(result, tab); } if (this.hasTabPermission(tab)) { result.url = browser.currentURI.spec; let title = browser.contentTitle || tab.label; if (title) { result.title = title; } let icon = window.gBrowser.getIcon(tab); if (icon) { result.favIconUrl = icon; } } return result; }, // Converts tabs returned from SessionStore.getClosedTabData and // SessionStore.getClosedWindowData into API tab objects convertFromSessionStoreClosedData(tab, window) { let result = { sessionId: String(tab.closedId), index: tab.pos ? tab.pos : 0, windowId: WindowManager.getId(window), selected: false, highlighted: false, active: false, pinned: false, incognito: Boolean(tab.state && tab.state.isPrivate), }; if (this.hasTabPermission(tab)) { let entries = tab.state ? tab.state.entries : tab.entries; result.url = entries[0].url; result.title = entries[0].title; if (tab.image) { result.favIconUrl = tab.image; } } return result; }, getTabs(window) { return Array.from(window.gBrowser.tabs) .filter(tab => !tab.closing) .map(tab => this.convert(tab)); }, }; // Sends the tab and windowId upon request. This is primarily used to support // the synchronous `browser.extension.getViews` API. let onGetTabAndWindowId = { receiveMessage({name, target, sync}) { let {gBrowser} = target.ownerGlobal; let tab = gBrowser && gBrowser.getTabForBrowser(target); if (tab) { let reply = { tabId: TabManager.getId(tab), windowId: WindowManager.getId(tab.ownerGlobal), }; if (sync) { return reply; } target.messageManager.sendAsyncMessage("Extension:SetTabAndWindowId", reply); } }, }; /* eslint-disable mozilla/balanced-listeners */ Services.mm.addMessageListener("Extension:GetTabAndWindowId", onGetTabAndWindowId); /* eslint-enable mozilla/balanced-listeners */ // Manages global mappings between XUL tabs and extension tab IDs. global.TabManager = { _tabs: new WeakMap(), _nextId: 1, _initialized: false, // We begin listening for TabOpen and TabClose events once we've started // assigning IDs to tabs, so that we can remap the IDs of tabs which are moved // between windows. initListener() { if (this._initialized) { return; } AllWindowEvents.addListener("TabOpen", this); AllWindowEvents.addListener("TabClose", this); WindowListManager.addOpenListener(this.handleWindowOpen.bind(this)); this._initialized = true; }, handleEvent(event) { if (event.type == "TabOpen") { let {adoptedTab} = event.detail; if (adoptedTab) { // This tab is being created to adopt a tab from a different window. // Copy the ID from the old tab to the new. let tab = event.target; this._tabs.set(tab, this.getId(adoptedTab)); tab.linkedBrowser.messageManager.sendAsyncMessage("Extension:SetTabAndWindowId", { windowId: WindowManager.getId(tab.ownerGlobal), }); } } else if (event.type == "TabClose") { let {adoptedBy} = event.detail; if (adoptedBy) { // This tab is being closed because it was adopted by a new window. // Copy its ID to the new tab, in case it was created as the first tab // of a new window, and did not have an `adoptedTab` detail when it was // opened. this._tabs.set(adoptedBy, this.getId(event.target)); adoptedBy.linkedBrowser.messageManager.sendAsyncMessage("Extension:SetTabAndWindowId", { windowId: WindowManager.getId(adoptedBy), }); } } }, handleWindowOpen(window) { if (window.arguments && window.arguments[0] instanceof window.XULElement) { // If the first window argument is a XUL element, it means the // window is about to adopt a tab from another window to replace its // initial tab. let adoptedTab = window.arguments[0]; this._tabs.set(window.gBrowser.tabs[0], this.getId(adoptedTab)); } }, getId(tab) { if (this._tabs.has(tab)) { return this._tabs.get(tab); } this.initListener(); let id = this._nextId++; this._tabs.set(tab, id); return id; }, getBrowserId(browser) { let gBrowser = browser.ownerGlobal.gBrowser; // Some non-browser windows have gBrowser but not // getTabForBrowser! if (gBrowser && gBrowser.getTabForBrowser) { let tab = gBrowser.getTabForBrowser(browser); if (tab) { return this.getId(tab); } } return -1; }, /** * Returns the XUL element associated with the given tab ID. If no tab * with the given ID exists, and no default value is provided, an error is * raised, belonging to the scope of the given context. * * @param {integer} tabId * The ID of the tab to retrieve. * @param {ExtensionContext} context * The context of the caller. * This value may be omitted if `default_` is not `undefined`. * @param {*} default_ * The value to return if no tab exists with the given ID. * @returns {Element} * A XUL element. */ getTab(tabId, context, default_ = undefined) { // FIXME: Speed this up without leaking memory somehow. for (let window of WindowListManager.browserWindows()) { if (!window.gBrowser) { continue; } for (let tab of window.gBrowser.tabs) { if (this.getId(tab) == tabId) { return tab; } } } if (default_ !== undefined) { return default_; } throw new context.cloneScope.Error(`Invalid tab ID: ${tabId}`); }, get activeTab() { let window = WindowManager.topWindow; if (window && window.gBrowser) { return window.gBrowser.selectedTab; } return null; }, getStatus(tab) { return tab.getAttribute("busy") == "true" ? "loading" : "complete"; }, convert(extension, tab) { return TabManager.for(extension).convert(tab); }, }; // WeakMap[Extension -> ExtensionTabManager] let tabManagers = new WeakMap(); // Returns the extension-specific tab manager for the given extension, or // creates one if it doesn't already exist. TabManager.for = function(extension) { if (!tabManagers.has(extension)) { tabManagers.set(extension, new ExtensionTabManager(extension)); } return tabManagers.get(extension); }; /* eslint-disable mozilla/balanced-listeners */ extensions.on("shutdown", (type, extension) => { tabManagers.delete(extension); }); /* eslint-enable mozilla/balanced-listeners */ function memoize(fn) { let weakMap = new DefaultWeakMap(fn); return weakMap.get.bind(weakMap); } // Manages mapping between XUL windows and extension window IDs. global.WindowManager = { _windows: new WeakMap(), _nextId: 0, // Note: These must match the values in windows.json. WINDOW_ID_NONE: -1, WINDOW_ID_CURRENT: -2, get topWindow() { return Services.wm.getMostRecentWindow("navigator:browser"); }, windowType(window) { // TODO: Make this work. let {chromeFlags} = window.QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIDocShell) .treeOwner.QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIXULWindow); if (chromeFlags & Ci.nsIWebBrowserChrome.CHROME_OPENAS_DIALOG) { return "popup"; } return "normal"; }, updateGeometry(window, options) { if (options.left !== null || options.top !== null) { let left = options.left !== null ? options.left : window.screenX; let top = options.top !== null ? options.top : window.screenY; window.moveTo(left, top); } if (options.width !== null || options.height !== null) { let width = options.width !== null ? options.width : window.outerWidth; let height = options.height !== null ? options.height : window.outerHeight; window.resizeTo(width, height); } }, isBrowserPrivate: memoize(browser => { return PrivateBrowsingUtils.isBrowserPrivate(browser); }), getId(window) { if (this._windows.has(window)) { return this._windows.get(window); } let id = this._nextId++; this._windows.set(window, id); return id; }, getWindow(id, context) { if (id == this.WINDOW_ID_CURRENT) { return currentWindow(context); } for (let window of WindowListManager.browserWindows(true)) { if (this.getId(window) == id) { return window; } } return null; }, getState(window) { const STATES = { [window.STATE_MAXIMIZED]: "maximized", [window.STATE_MINIMIZED]: "minimized", [window.STATE_NORMAL]: "normal", }; let state = STATES[window.windowState]; if (window.fullScreen) { state = "fullscreen"; } return state; }, setState(window, state) { if (state != "fullscreen" && window.fullScreen) { window.fullScreen = false; } switch (state) { case "maximized": window.maximize(); break; case "minimized": case "docked": window.minimize(); break; case "normal": // Restore sometimes returns the window to its previous state, rather // than to the "normal" state, so it may need to be called anywhere from // zero to two times. window.restore(); if (window.windowState != window.STATE_NORMAL) { window.restore(); } if (window.windowState != window.STATE_NORMAL) { // And on OS-X, where normal vs. maximized is basically a heuristic, // we need to cheat. window.sizeToContent(); } break; case "fullscreen": window.fullScreen = true; break; default: throw new Error(`Unexpected window state: ${state}`); } }, convert(extension, window, getInfo) { let xulWindow = window.QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIDocShell) .treeOwner.QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIXULWindow); let result = { id: this.getId(window), focused: window.document.hasFocus(), top: window.screenY, left: window.screenX, width: window.outerWidth, height: window.outerHeight, incognito: PrivateBrowsingUtils.isWindowPrivate(window), type: this.windowType(window), state: this.getState(window), alwaysOnTop: xulWindow.zLevel >= Ci.nsIXULWindow.raisedZ, }; if (getInfo && getInfo.populate) { result.tabs = TabManager.for(extension).getTabs(window); } return result; }, // Converts windows returned from SessionStore.getClosedWindowData // into API window objects convertFromSessionStoreClosedData(window, extension) { let result = { sessionId: String(window.closedId), focused: false, incognito: false, type: "normal", // this is always "normal" for a closed window state: this.getState(window), alwaysOnTop: false, }; if (window.tabs.length) { result.tabs = []; window.tabs.forEach((tab, index) => { result.tabs.push(TabManager.for(extension).convertFromSessionStoreClosedData(tab, window, index)); }); } return result; }, }; // Manages listeners for window opening and closing. A window is // considered open when the "load" event fires on it. A window is // closed when a "domwindowclosed" notification fires for it. global.WindowListManager = { _openListeners: new Set(), _closeListeners: new Set(), // Returns an iterator for all browser windows. Unless |includeIncomplete| is // true, only fully-loaded windows are returned. * browserWindows(includeIncomplete = false) { // The window type parameter is only available once the window's document // element has been created. This means that, when looking for incomplete // browser windows, we need to ignore the type entirely for windows which // haven't finished loading, since we would otherwise skip browser windows // in their early loading stages. // This is particularly important given that the "domwindowcreated" event // fires for browser windows when they're in that in-between state, and just // before we register our own "domwindowcreated" listener. let e = Services.wm.getEnumerator(""); while (e.hasMoreElements()) { let window = e.getNext(); let ok = includeIncomplete; if (window.document.readyState == "complete") { ok = window.document.documentElement.getAttribute("windowtype") == "navigator:browser"; } if (ok) { yield window; } } }, addOpenListener(listener) { if (this._openListeners.size == 0 && this._closeListeners.size == 0) { Services.ww.registerNotification(this); } this._openListeners.add(listener); for (let window of this.browserWindows(true)) { if (window.document.readyState != "complete") { window.addEventListener("load", this); } } }, removeOpenListener(listener) { this._openListeners.delete(listener); if (this._openListeners.size == 0 && this._closeListeners.size == 0) { Services.ww.unregisterNotification(this); } }, addCloseListener(listener) { if (this._openListeners.size == 0 && this._closeListeners.size == 0) { Services.ww.registerNotification(this); } this._closeListeners.add(listener); }, removeCloseListener(listener) { this._closeListeners.delete(listener); if (this._openListeners.size == 0 && this._closeListeners.size == 0) { Services.ww.unregisterNotification(this); } }, handleEvent(event) { event.currentTarget.removeEventListener(event.type, this); let window = event.target.defaultView; if (window.document.documentElement.getAttribute("windowtype") != "navigator:browser") { return; } for (let listener of this._openListeners) { listener(window); } }, observe(window, topic, data) { if (topic == "domwindowclosed") { if (window.document.documentElement.getAttribute("windowtype") != "navigator:browser") { return; } window.removeEventListener("load", this); for (let listener of this._closeListeners) { listener(window); } } else { window.addEventListener("load", this); } }, }; // Provides a facility to listen for DOM events across all XUL windows. global.AllWindowEvents = { _listeners: new Map(), // If |type| is a normal event type, invoke |listener| each time // that event fires in any open window. If |type| is "progress", add // a web progress listener that covers all open windows. addListener(type, listener) { if (type == "domwindowopened") { return WindowListManager.addOpenListener(listener); } else if (type == "domwindowclosed") { return WindowListManager.addCloseListener(listener); } if (this._listeners.size == 0) { WindowListManager.addOpenListener(this.openListener); } if (!this._listeners.has(type)) { this._listeners.set(type, new Set()); } let list = this._listeners.get(type); list.add(listener); // Register listener on all existing windows. for (let window of WindowListManager.browserWindows()) { this.addWindowListener(window, type, listener); } }, removeListener(eventType, listener) { if (eventType == "domwindowopened") { return WindowListManager.removeOpenListener(listener); } else if (eventType == "domwindowclosed") { return WindowListManager.removeCloseListener(listener); } let listeners = this._listeners.get(eventType); listeners.delete(listener); if (listeners.size == 0) { this._listeners.delete(eventType); if (this._listeners.size == 0) { WindowListManager.removeOpenListener(this.openListener); } } // Unregister listener from all existing windows. let useCapture = eventType === "focus" || eventType === "blur"; for (let window of WindowListManager.browserWindows()) { if (eventType == "progress") { window.gBrowser.removeTabsProgressListener(listener); } else { window.removeEventListener(eventType, listener, useCapture); } } }, /* eslint-disable mozilla/balanced-listeners */ addWindowListener(window, eventType, listener) { let useCapture = eventType === "focus" || eventType === "blur"; if (eventType == "progress") { window.gBrowser.addTabsProgressListener(listener); } else { window.addEventListener(eventType, listener, useCapture); } }, /* eslint-enable mozilla/balanced-listeners */ // Runs whenever the "load" event fires for a new window. openListener(window) { for (let [eventType, listeners] of AllWindowEvents._listeners) { for (let listener of listeners) { this.addWindowListener(window, eventType, listener); } } }, }; AllWindowEvents.openListener = AllWindowEvents.openListener.bind(AllWindowEvents); // Subclass of EventManager where we just need to call // add/removeEventListener on each XUL window. global.WindowEventManager = function(context, name, event, listener) { EventManager.call(this, context, name, fire => { let listener2 = (...args) => listener(fire, ...args); AllWindowEvents.addListener(event, listener2); return () => { AllWindowEvents.removeListener(event, listener2); }; }); }; WindowEventManager.prototype = Object.create(EventManager.prototype);