diff options
Diffstat (limited to '')
-rw-r--r-- | browser/components/extensions/ExtensionPopups.sys.mjs | 741 |
1 files changed, 741 insertions, 0 deletions
diff --git a/browser/components/extensions/ExtensionPopups.sys.mjs b/browser/components/extensions/ExtensionPopups.sys.mjs new file mode 100644 index 0000000000..5dbb36fa0d --- /dev/null +++ b/browser/components/extensions/ExtensionPopups.sys.mjs @@ -0,0 +1,741 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs"; +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +var { DefaultWeakMap, promiseEvent } = ExtensionUtils; + +const { makeWidgetId } = ExtensionCommon; + +const POPUP_LOAD_TIMEOUT_MS = 200; + +function promisePopupShown(popup) { + return new Promise(resolve => { + if (popup.state == "open") { + resolve(); + } else { + popup.addEventListener( + "popupshown", + function (event) { + resolve(); + }, + { once: true } + ); + } + }); +} + +XPCOMUtils.defineLazyGetter(lazy, "standaloneStylesheets", () => { + let stylesheets = []; + + if (AppConstants.platform === "macosx") { + stylesheets.push("chrome://browser/content/extension-mac-panel.css"); + } else if (AppConstants.platform === "win") { + stylesheets.push("chrome://browser/content/extension-win-panel.css"); + } else if (AppConstants.platform === "linux") { + stylesheets.push("chrome://browser/content/extension-linux-panel.css"); + } + return stylesheets; +}); + +const REMOTE_PANEL_ID = "webextension-remote-preload-panel"; + +export class BasePopup { + constructor( + extension, + viewNode, + popupURL, + browserStyle, + fixedWidth = false, + blockParser = false + ) { + this.extension = extension; + this.popupURL = popupURL; + this.viewNode = viewNode; + this.browserStyle = browserStyle; + this.window = viewNode.ownerGlobal; + this.destroyed = false; + this.fixedWidth = fixedWidth; + this.blockParser = blockParser; + + extension.callOnClose(this); + + this.contentReady = new Promise(resolve => { + this._resolveContentReady = resolve; + }); + + this.window.addEventListener("unload", this); + this.viewNode.addEventListener(this.DESTROY_EVENT, this); + this.panel.addEventListener("popuppositioned", this, { + once: true, + capture: true, + }); + + 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.window.removeEventListener("unload", this); + + this.destroyed = true; + this.browserLoadedDeferred.reject(new Error("Popup destroyed")); + // Ignore unhandled rejections if the "attach" method is not called. + this.browserLoaded.catch(() => {}); + + BasePopup.instances.get(this.window).delete(this.extension); + + return this.browserReady.then(() => { + if (this.browser) { + this.destroyBrowser(this.browser, true); + this.browser.parentNode.remove(); + } + if (this.stack) { + this.stack.remove(); + } + + if (this.viewNode) { + this.viewNode.removeEventListener(this.DESTROY_EVENT, this); + delete this.viewNode.customRectGetter; + } + + let { panel } = this; + if (panel) { + panel.removeEventListener("popuppositioned", this, { capture: true }); + } + if (panel && panel.id !== REMOTE_PANEL_ID) { + panel.style.removeProperty("--arrowpanel-background"); + panel.style.removeProperty("--arrowpanel-border-color"); + panel.removeAttribute("remote"); + } + + this.browser = null; + this.stack = null; + this.viewNode = null; + }); + } + + destroyBrowser(browser, finalize = false) { + 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, so + // just replace our receiveMessage method with a stub. + if (mm) { + mm.removeMessageListener("Extension:BrowserBackgroundChanged", this); + mm.removeMessageListener("Extension:BrowserContentLoaded", this); + mm.removeMessageListener("Extension:BrowserResized", this); + } else if (finalize) { + this.receiveMessage = () => {}; + } + browser.removeEventListener("pagetitlechanged", this); + browser.removeEventListener("DOMWindowClose", this); + browser.removeEventListener("DoZoomEnlargeBy10", this); + browser.removeEventListener("DoZoomReduceBy10", 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(...lazy.ExtensionParent.extensionStylesheets); + } + if (!this.fixedWidth) { + sheets.push(...lazy.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 "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; + } + } + + handleEvent(event) { + switch (event.type) { + case "unload": + case this.DESTROY_EVENT: + if (!this.destroyed) { + this.destroy(); + } + break; + case "popuppositioned": + if (!this.destroyed) { + this.browserLoaded + .then(() => { + if (this.destroyed) { + return; + } + // Wait the reflow before asking the popup panel to grab the focus, otherwise + // `nsFocusManager::SetFocus` may ignore out request because the panel view + // visibility is still set to `ViewVisibility::Hide` (waiting the document + // to be fully flushed makes us sure that when the popup panel grabs the focus + // nsMenuPopupFrame::LayoutPopup has already been colled and set the frame + // visibility to `ViewVisibility::Show`). + this.browser.ownerGlobal.promiseDocumentFlushed(() => { + if (this.destroyed) { + return; + } + this.browser.messageManager.sendAsyncMessage( + "Extension:GrabFocus", + {} + ); + }); + }) + .catch(() => { + // If the panel closes too fast an exception is raised here and tests will fail. + }); + } + break; + + case "pagetitlechanged": + this.viewNode.setAttribute("aria-label", this.browser.contentTitle); + break; + + case "DOMWindowClose": + this.closePopup(); + break; + + case "DoZoomEnlargeBy10": { + const browser = event.target; + let { ZoomManager } = browser.ownerGlobal; + let zoom = this.browser.fullZoom; + zoom += 0.1; + if (zoom > ZoomManager.MAX) { + zoom = ZoomManager.MAX; + } + browser.fullZoom = zoom; + break; + } + + case "DoZoomReduceBy10": { + const browser = event.target; + let { ZoomManager } = browser.ownerGlobal; + let zoom = browser.fullZoom; + zoom -= 0.1; + if (zoom < ZoomManager.MIN) { + zoom = ZoomManager.MIN; + } + browser.fullZoom = zoom; + break; + } + } + } + + createBrowser(viewNode, popupURL = null) { + let document = viewNode.ownerDocument; + + let stack = document.createXULElement("stack"); + stack.setAttribute("class", "webextension-popup-stack"); + + let browser = document.createXULElement("browser"); + browser.setAttribute("type", "content"); + browser.setAttribute("disableglobalhistory", "true"); + browser.setAttribute("messagemanagergroup", "webext-browsers"); + browser.setAttribute("class", "webextension-popup-browser"); + browser.setAttribute("webextension-view-type", "popup"); + browser.setAttribute("tooltip", "aHTMLTooltip"); + browser.setAttribute("contextmenu", "contentAreaContextMenu"); + browser.setAttribute("autocompletepopup", "PopupAutoComplete"); + browser.setAttribute("constrainpopups", "false"); + + // Ensure the browser will initially load in the same group as other + // browsers from the same extension. + browser.setAttribute( + "initialBrowsingContextGroupId", + this.extension.policy.browsingContextGroupId + ); + + if (this.extension.remote) { + browser.setAttribute("remote", "true"); + browser.setAttribute("remoteType", this.extension.remoteType); + browser.setAttribute("maychangeremoteness", "true"); + } + + // 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. + browser.setAttribute("flex", "1"); + stack.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. + + this.browser = browser; + this.stack = stack; + + let readyPromise; + if (this.extension.remote) { + readyPromise = promiseEvent(browser, "XULFrameLoaderCreated"); + } else { + readyPromise = promiseEvent(browser, "load"); + } + + stack.appendChild(browser); + viewNode.appendChild(stack); + + if (!this.extension.remote) { + // FIXME: bug 1494029 - this code used to rely on the browser binding + // accessing browser.contentWindow. This is a stopgap to continue doing + // that, but we should get rid of it in the long term. + browser.contentWindow; // eslint-disable-line no-unused-expressions + } + + let setupBrowser = browser => { + let mm = browser.messageManager; + mm.addMessageListener("Extension:BrowserBackgroundChanged", this); + mm.addMessageListener("Extension:BrowserContentLoaded", this); + mm.addMessageListener("Extension:BrowserResized", this); + browser.addEventListener("pagetitlechanged", this); + browser.addEventListener("DOMWindowClose", this); + browser.addEventListener("DoZoomEnlargeBy10", this, true); // eslint-disable-line mozilla/balanced-listeners + browser.addEventListener("DoZoomReduceBy10", this, true); // eslint-disable-line mozilla/balanced-listeners + + lazy.ExtensionParent.apiManager.emit( + "extension-browser-inserted", + browser + ); + return browser; + }; + + const initBrowser = () => { + setupBrowser(browser); + let mm = browser.messageManager; + + mm.loadFrameScript( + "chrome://extensions/content/ext-browser-content.js", + false, + true + ); + + mm.sendAsyncMessage("Extension:InitBrowser", { + allowScriptsToClose: true, + blockParser: this.blockParser, + fixedWidth: this.fixedWidth, + maxWidth: 800, + maxHeight: 600, + stylesheets: this.STYLESHEETS, + }); + }; + + browser.addEventListener("DidChangeBrowserRemoteness", initBrowser); // eslint-disable-line mozilla/balanced-listeners + + if (!popupURL) { + // For remote browsers, we can't do any setup until the frame loader is + // created. Non-remote browsers get a message manager immediately, so + // there's no need to wait for the load event. + if (this.extension.remote) { + return readyPromise.then(() => setupBrowser(browser)); + } + return setupBrowser(browser); + } + + return readyPromise.then(() => { + initBrowser(); + browser.fixupAndLoadURIString(popupURL, { + triggeringPrincipal: this.extension.principal, + }); + }); + } + + unblockParser() { + this.browserReady.then(browser => { + if (this.destroyed) { + return; + } + // Only block the parser for the preloaded browser, initBrowser will be + // called again when the browserAction popup is navigated and we should + // not block the parser in that case, otherwise the navigating the popup + // to another extension page will never complete and the popup will + // stay stuck on the previous extension page. See Bug 1747813. + this.blockParser = false; + this.browser.messageManager.sendAsyncMessage("Extension:UnblockParser"); + }); + } + + 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`; + + // Used by the panelmultiview code to figure out sizing without reparenting + // (which would destroy the browser and break us). + this.lastCalculatedInViewHeight = Math.max(height, this.viewHeight); + } else { + this.browser.style.width = `${width}px`; + this.browser.style.minWidth = `${width}px`; + this.browser.style.height = `${height}px`; + this.browser.style.minHeight = `${height}px`; + } + + let event = new this.window.CustomEvent("WebExtPopupResized", { detail }); + this.browser.dispatchEvent(event); + } + + setBackground(background) { + // Panels inherit the applied theme (light, dark, etc) and there is a high + // likelihood that most extension authors will not have tested with a dark theme. + // If they have not set a background-color, we force it to white to ensure visibility + // of the extension content. Passing `null` should be treated the same as no argument, + // which is why we can't use default parameters here. + if (!background) { + background = "#fff"; + } + if (this.panel.id != "widget-overflow") { + this.panel.style.setProperty("--arrowpanel-background", background); + } + if (background == "#fff") { + // Set a usable default color that work with the default background-color. + this.panel.style.setProperty( + "--arrowpanel-border-color", + "hsla(210,4%,10%,.15)" + ); + } + this.background = background; + } +} + +/** + * A map of active popups for a given browser window. + * + * WeakMap[window -> WeakMap[Extension -> BasePopup]] + */ +BasePopup.instances = new DefaultWeakMap(() => new WeakMap()); + +export class PanelPopup extends BasePopup { + constructor(extension, document, popupURL, browserStyle) { + let panel = document.createXULElement("panel"); + panel.setAttribute("id", makeWidgetId(extension.id) + "-panel"); + panel.setAttribute("class", "browser-extension-panel panel-no-padding"); + panel.setAttribute("tabspecific", "true"); + panel.setAttribute("type", "arrow"); + panel.setAttribute("role", "group"); + if (extension.remote) { + panel.setAttribute("remote", "true"); + } + panel.setAttribute("neverhidden", "true"); + + document.getElementById("mainPopupSet").appendChild(panel); + + panel.addEventListener( + "popupshowing", + () => { + let event = new this.window.CustomEvent("WebExtPopupLoaded", { + bubbles: true, + detail: { extension }, + }); + this.browser.dispatchEvent(event); + }, + { once: true } + ); + + super(extension, panel, popupURL, browserStyle); + } + + get DESTROY_EVENT() { + return "popuphidden"; + } + + destroy() { + super.destroy(); + this.viewNode.remove(); + this.viewNode = null; + } + + closePopup() { + promisePopupShown(this.viewNode).then(() => { + // Make sure we're not already destroyed, or removed from the DOM. + if (this.viewNode && this.viewNode.hidePopup) { + this.viewNode.hidePopup(); + } + }); + } +} + +export class ViewPopup extends BasePopup { + constructor( + extension, + window, + popupURL, + browserStyle, + fixedWidth, + blockParser + ) { + let document = window.document; + + let createPanel = remote => { + let panel = document.createXULElement("panel"); + panel.setAttribute("type", "arrow"); + if (remote) { + panel.setAttribute("remote", "true"); + } + panel.setAttribute("neverhidden", "true"); + + document.getElementById("mainPopupSet").appendChild(panel); + return panel; + }; + + // 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. For remote + // extensions, this popup is shared between all extensions. + let panel; + if (extension.remote) { + panel = document.getElementById(REMOTE_PANEL_ID); + if (!panel) { + panel = createPanel(true); + panel.id = REMOTE_PANEL_ID; + } + } else { + panel = createPanel(); + } + + super(extension, panel, popupURL, browserStyle, fixedWidth, blockParser); + + this.ignoreResizes = true; + + this.attached = false; + this.shown = false; + this.tempPanel = panel; + this.tempBrowser = this.browser; + + // NOTE: this class is added to the preload browser and never removed because + // the preload browser is then switched with a new browser once we are about to + // make the popup visible (this class is not actually used anywhere but it may + // be useful to keep it around to be able to identify the preload buffer while + // investigating issues). + 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<boolean>} + * 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. + */ + async attach(viewNode) { + if (this.destroyed) { + return false; + } + this.viewNode.removeEventListener(this.DESTROY_EVENT, this); + this.panel.removeEventListener("popuppositioned", this, { + once: true, + capture: true, + }); + + this.viewNode = viewNode; + this.viewNode.addEventListener(this.DESTROY_EVENT, this); + this.viewNode.setAttribute("closemenu", "none"); + + this.panel.addEventListener("popuppositioned", this, { + once: true, + capture: true, + }); + if (this.extension.remote) { + this.panel.setAttribute("remote", "true"); + } + + // 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. + await 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 => lazy.setTimeout(resolve, POPUP_LOAD_TIMEOUT_MS)), + ]), + ]); + + const { panel } = this; + + if (!this.destroyed && !panel) { + this.destroy(); + } + + if (this.destroyed) { + lazy.CustomizableUI.hidePanelForNode(viewNode); + return false; + } + + this.attached = true; + + this.setBackground(this.background); + + let flushPromise = this.window.promiseDocumentFlushed(() => { + let win = this.window; + + // 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 = panel.getBoundingClientRect(); + let screenBottom = win.screen.availTop + win.screen.availHeight; + let popupBottom = win.mozInnerScreenY + popupRect.bottom; + let popupTop = win.mozInnerScreenY + popupRect.top; + + // 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 = viewNode.getBoundingClientRect().height; + + 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; + await this.createBrowser(this.viewNode); + + this.browser.swapDocShells(browser); + this.destroyBrowser(browser); + + await flushPromise; + + // Check if the popup has been destroyed while we were waiting for the + // document flush promise to be resolve. + if (this.destroyed) { + this.closePopup(); + this.destroy(); + return false; + } + + if (this.dimensions) { + if (this.fixedWidth) { + delete this.dimensions.width; + } + this.resizeBrowser(this.dimensions); + } + + this.ignoreResizes = false; + + this.viewNode.customRectGetter = () => { + return { height: this.lastCalculatedInViewHeight || this.viewHeight }; + }; + + this.removeTempPanel(); + + this.shown = true; + + if (this.destroyed) { + this.closePopup(); + this.destroy(); + return false; + } + + let event = new this.window.CustomEvent("WebExtPopupLoaded", { + bubbles: true, + detail: { extension: this.extension }, + }); + this.browser.dispatchEvent(event); + + return true; + } + + removeTempPanel() { + if (this.tempPanel) { + if (this.tempPanel.id !== REMOTE_PANEL_ID) { + this.tempPanel.remove(); + } + this.tempPanel = null; + } + if (this.tempBrowser) { + this.tempBrowser.parentNode.remove(); + this.tempBrowser = null; + } + } + + destroy() { + return super.destroy().then(() => { + this.removeTempPanel(); + }); + } + + get DESTROY_EVENT() { + return "ViewHiding"; + } + + closePopup() { + if (this.shown) { + lazy.CustomizableUI.hidePanelForNode(this.viewNode); + } else if (this.attached) { + this.destroyed = true; + } else { + this.destroy(); + } + } +} |