summaryrefslogtreecommitdiffstats
path: root/browser/components/extensions/ExtensionPopups.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--browser/components/extensions/ExtensionPopups.sys.mjs741
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();
+ }
+ }
+}