1/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
2/* vim: set sts=2 sw=2 et tw=80: */
3/* This Source Code Form is subject to the terms of the Mozilla Public
4 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
5 * You can obtain one at http://mozilla.org/MPL/2.0/. */
6
7"use strict";
8
9/* exported PanelPopup, ViewPopup */
10
11var EXPORTED_SYMBOLS = ["BasePopup", "PanelPopup", "ViewPopup"];
12
13const { XPCOMUtils } = ChromeUtils.import(
14  "resource://gre/modules/XPCOMUtils.jsm"
15);
16
17ChromeUtils.defineModuleGetter(
18  this,
19  "CustomizableUI",
20  "resource:///modules/CustomizableUI.jsm"
21);
22ChromeUtils.defineModuleGetter(
23  this,
24  "ExtensionParent",
25  "resource://gre/modules/ExtensionParent.jsm"
26);
27ChromeUtils.defineModuleGetter(
28  this,
29  "setTimeout",
30  "resource://gre/modules/Timer.jsm"
31);
32
33const { AppConstants } = ChromeUtils.import(
34  "resource://gre/modules/AppConstants.jsm"
35);
36const { ExtensionCommon } = ChromeUtils.import(
37  "resource://gre/modules/ExtensionCommon.jsm"
38);
39const { ExtensionUtils } = ChromeUtils.import(
40  "resource://gre/modules/ExtensionUtils.jsm"
41);
42
43var { DefaultWeakMap, promiseEvent } = ExtensionUtils;
44
45const { makeWidgetId } = ExtensionCommon;
46
47const POPUP_LOAD_TIMEOUT_MS = 200;
48
49function promisePopupShown(popup) {
50  return new Promise(resolve => {
51    if (popup.state == "open") {
52      resolve();
53    } else {
54      popup.addEventListener(
55        "popupshown",
56        function(event) {
57          resolve();
58        },
59        { once: true }
60      );
61    }
62  });
63}
64
65XPCOMUtils.defineLazyGetter(this, "standaloneStylesheets", () => {
66  let stylesheets = [];
67
68  if (AppConstants.platform === "macosx") {
69    stylesheets.push("chrome://browser/content/extension-mac-panel.css");
70  }
71  if (AppConstants.platform === "win") {
72    stylesheets.push("chrome://browser/content/extension-win-panel.css");
73  }
74  return stylesheets;
75});
76
77const REMOTE_PANEL_ID = "webextension-remote-preload-panel";
78
79class BasePopup {
80  constructor(
81    extension,
82    viewNode,
83    popupURL,
84    browserStyle,
85    fixedWidth = false,
86    blockParser = false
87  ) {
88    this.extension = extension;
89    this.popupURL = popupURL;
90    this.viewNode = viewNode;
91    this.browserStyle = browserStyle;
92    this.window = viewNode.ownerGlobal;
93    this.destroyed = false;
94    this.fixedWidth = fixedWidth;
95    this.blockParser = blockParser;
96
97    extension.callOnClose(this);
98
99    this.contentReady = new Promise(resolve => {
100      this._resolveContentReady = resolve;
101    });
102
103    this.window.addEventListener("unload", this);
104    this.viewNode.addEventListener(this.DESTROY_EVENT, this);
105    this.panel.addEventListener("popuppositioned", this, {
106      once: true,
107      capture: true,
108    });
109
110    this.browser = null;
111    this.browserLoaded = new Promise((resolve, reject) => {
112      this.browserLoadedDeferred = { resolve, reject };
113    });
114    this.browserReady = this.createBrowser(viewNode, popupURL);
115
116    BasePopup.instances.get(this.window).set(extension, this);
117  }
118
119  static for(extension, window) {
120    return BasePopup.instances.get(window).get(extension);
121  }
122
123  close() {
124    this.closePopup();
125  }
126
127  destroy() {
128    this.extension.forgetOnClose(this);
129
130    this.window.removeEventListener("unload", this);
131
132    this.destroyed = true;
133    this.browserLoadedDeferred.reject(new Error("Popup destroyed"));
134    // Ignore unhandled rejections if the "attach" method is not called.
135    this.browserLoaded.catch(() => {});
136
137    BasePopup.instances.get(this.window).delete(this.extension);
138
139    return this.browserReady.then(() => {
140      if (this.browser) {
141        this.destroyBrowser(this.browser, true);
142        this.browser.parentNode.remove();
143      }
144      if (this.stack) {
145        this.stack.remove();
146      }
147
148      if (this.viewNode) {
149        this.viewNode.removeEventListener(this.DESTROY_EVENT, this);
150        delete this.viewNode.customRectGetter;
151      }
152
153      let { panel } = this;
154      if (panel) {
155        panel.removeEventListener("popuppositioned", this, { capture: true });
156      }
157      if (panel && panel.id !== REMOTE_PANEL_ID) {
158        panel.style.removeProperty("--arrowpanel-background");
159        panel.style.removeProperty("--arrowpanel-border-color");
160        panel.removeAttribute("remote");
161      }
162
163      this.browser = null;
164      this.stack = null;
165      this.viewNode = null;
166    });
167  }
168
169  destroyBrowser(browser, finalize = false) {
170    let mm = browser.messageManager;
171    // If the browser has already been removed from the document, because the
172    // popup was closed externally, there will be no message manager here, so
173    // just replace our receiveMessage method with a stub.
174    if (mm) {
175      mm.removeMessageListener("Extension:BrowserBackgroundChanged", this);
176      mm.removeMessageListener("Extension:BrowserContentLoaded", this);
177      mm.removeMessageListener("Extension:BrowserResized", this);
178    } else if (finalize) {
179      this.receiveMessage = () => {};
180    }
181    browser.removeEventListener("pagetitlechanged", this);
182    browser.removeEventListener("DOMWindowClose", this);
183  }
184
185  // Returns the name of the event fired on `viewNode` when the popup is being
186  // destroyed. This must be implemented by every subclass.
187  get DESTROY_EVENT() {
188    throw new Error("Not implemented");
189  }
190
191  get STYLESHEETS() {
192    let sheets = [];
193
194    if (this.browserStyle) {
195      sheets.push(...ExtensionParent.extensionStylesheets);
196    }
197    if (!this.fixedWidth) {
198      sheets.push(...standaloneStylesheets);
199    }
200
201    return sheets;
202  }
203
204  get panel() {
205    let panel = this.viewNode;
206    while (panel && panel.localName != "panel") {
207      panel = panel.parentNode;
208    }
209    return panel;
210  }
211
212  receiveMessage({ name, data }) {
213    switch (name) {
214      case "Extension:BrowserBackgroundChanged":
215        this.setBackground(data.background);
216        break;
217
218      case "Extension:BrowserContentLoaded":
219        this.browserLoadedDeferred.resolve();
220        break;
221
222      case "Extension:BrowserResized":
223        this._resolveContentReady();
224        if (this.ignoreResizes) {
225          this.dimensions = data;
226        } else {
227          this.resizeBrowser(data);
228        }
229        break;
230    }
231  }
232
233  handleEvent(event) {
234    switch (event.type) {
235      case "unload":
236      case this.DESTROY_EVENT:
237        if (!this.destroyed) {
238          this.destroy();
239        }
240        break;
241      case "popuppositioned":
242        if (!this.destroyed) {
243          this.browserLoaded
244            .then(() => {
245              if (this.destroyed) {
246                return;
247              }
248              this.browser.messageManager.sendAsyncMessage(
249                "Extension:GrabFocus",
250                {}
251              );
252            })
253            .catch(() => {
254              // If the panel closes too fast an exception is raised here and tests will fail.
255            });
256        }
257        break;
258
259      case "pagetitlechanged":
260        this.viewNode.setAttribute("aria-label", this.browser.contentTitle);
261        break;
262
263      case "DOMWindowClose":
264        this.closePopup();
265        break;
266    }
267  }
268
269  createBrowser(viewNode, popupURL = null) {
270    let document = viewNode.ownerDocument;
271
272    let stack = document.createXULElement("stack");
273    stack.setAttribute("class", "webextension-popup-stack");
274
275    let browser = document.createXULElement("browser");
276    browser.setAttribute("type", "content");
277    browser.setAttribute("disableglobalhistory", "true");
278    browser.setAttribute("transparent", "true");
279    browser.setAttribute("class", "webextension-popup-browser");
280    browser.setAttribute("webextension-view-type", "popup");
281    browser.setAttribute("tooltip", "aHTMLTooltip");
282    browser.setAttribute("contextmenu", "contentAreaContextMenu");
283    browser.setAttribute("autocompletepopup", "PopupAutoComplete");
284    browser.setAttribute("selectmenulist", "ContentSelectDropdown");
285    browser.setAttribute("selectmenuconstrained", "false");
286    browser.sameProcessAsFrameLoader = this.extension.groupFrameLoader;
287
288    if (this.extension.remote) {
289      browser.setAttribute("remote", "true");
290      browser.setAttribute("remoteType", this.extension.remoteType);
291    }
292
293    // We only need flex sizing for the sake of the slide-in sub-views of the
294    // main menu panel, so that the browser occupies the full width of the view,
295    // and also takes up any extra height that's available to it.
296    browser.setAttribute("flex", "1");
297    stack.setAttribute("flex", "1");
298
299    // Note: When using noautohide panels, the popup manager will add width and
300    // height attributes to the panel, breaking our resize code, if the browser
301    // starts out smaller than 30px by 10px. This isn't an issue now, but it
302    // will be if and when we popup debugging.
303
304    this.browser = browser;
305    this.stack = stack;
306
307    let readyPromise;
308    if (this.extension.remote) {
309      readyPromise = promiseEvent(browser, "XULFrameLoaderCreated");
310    } else {
311      readyPromise = promiseEvent(browser, "load");
312    }
313
314    stack.appendChild(browser);
315    viewNode.appendChild(stack);
316    if (!this.extension.remote) {
317      // FIXME: bug 1494029 - this code used to rely on the browser binding
318      // accessing browser.contentWindow. This is a stopgap to continue doing
319      // that, but we should get rid of it in the long term.
320      browser.contentWindow; // eslint-disable-line no-unused-expressions
321    }
322
323    ExtensionParent.apiManager.emit("extension-browser-inserted", browser);
324
325    let setupBrowser = browser => {
326      let mm = browser.messageManager;
327      mm.addMessageListener("Extension:BrowserBackgroundChanged", this);
328      mm.addMessageListener("Extension:BrowserContentLoaded", this);
329      mm.addMessageListener("Extension:BrowserResized", this);
330      browser.addEventListener("pagetitlechanged", this);
331      browser.addEventListener("DOMWindowClose", this);
332      return browser;
333    };
334
335    if (!popupURL) {
336      // For remote browsers, we can't do any setup until the frame loader is
337      // created. Non-remote browsers get a message manager immediately, so
338      // there's no need to wait for the load event.
339      if (this.extension.remote) {
340        return readyPromise.then(() => setupBrowser(browser));
341      }
342      return setupBrowser(browser);
343    }
344
345    return readyPromise.then(() => {
346      setupBrowser(browser);
347      let mm = browser.messageManager;
348
349      mm.loadFrameScript(
350        "chrome://extensions/content/ext-browser-content.js",
351        false,
352        true
353      );
354
355      mm.sendAsyncMessage("Extension:InitBrowser", {
356        allowScriptsToClose: true,
357        blockParser: this.blockParser,
358        fixedWidth: this.fixedWidth,
359        maxWidth: 800,
360        maxHeight: 600,
361        stylesheets: this.STYLESHEETS,
362      });
363
364      browser.loadURI(popupURL, {
365        triggeringPrincipal: this.extension.principal,
366      });
367    });
368  }
369
370  unblockParser() {
371    this.browserReady.then(browser => {
372      if (this.destroyed) {
373        return;
374      }
375      this.browser.messageManager.sendAsyncMessage("Extension:UnblockParser");
376    });
377  }
378
379  resizeBrowser({ width, height, detail }) {
380    if (this.fixedWidth) {
381      // Figure out how much extra space we have on the side of the panel
382      // opposite the arrow.
383      let side = this.panel.getAttribute("side") == "top" ? "bottom" : "top";
384      let maxHeight = this.viewHeight + this.extraHeight[side];
385
386      height = Math.min(height, maxHeight);
387      this.browser.style.height = `${height}px`;
388
389      // Used by the panelmultiview code to figure out sizing without reparenting
390      // (which would destroy the browser and break us).
391      this.lastCalculatedInViewHeight = Math.max(height, this.viewHeight);
392    } else {
393      this.browser.style.width = `${width}px`;
394      this.browser.style.minWidth = `${width}px`;
395      this.browser.style.height = `${height}px`;
396      this.browser.style.minHeight = `${height}px`;
397    }
398
399    let event = new this.window.CustomEvent("WebExtPopupResized", { detail });
400    this.browser.dispatchEvent(event);
401  }
402
403  setBackground(background) {
404    // Panels inherit the applied theme (light, dark, etc) and there is a high
405    // likelihood that most extension authors will not have tested with a dark theme.
406    // If they have not set a background-color, we force it to white to ensure visibility
407    // of the extension content. Passing `null` should be treated the same as no argument,
408    // which is why we can't use default parameters here.
409    if (!background) {
410      background = "#fff";
411    }
412    if (this.panel.id != "widget-overflow") {
413      this.panel.style.setProperty("--arrowpanel-background", background);
414    }
415    if (background == "#fff") {
416      // Set a usable default color that work with the default background-color.
417      this.panel.style.setProperty(
418        "--arrowpanel-border-color",
419        "hsla(210,4%,10%,.15)"
420      );
421    }
422    this.background = background;
423  }
424}
425
426/**
427 * A map of active popups for a given browser window.
428 *
429 * WeakMap[window -> WeakMap[Extension -> BasePopup]]
430 */
431BasePopup.instances = new DefaultWeakMap(() => new WeakMap());
432
433class PanelPopup extends BasePopup {
434  constructor(extension, document, popupURL, browserStyle) {
435    let panel = document.createXULElement("panel");
436    panel.setAttribute("id", makeWidgetId(extension.id) + "-panel");
437    panel.setAttribute("class", "browser-extension-panel panel-no-padding");
438    panel.setAttribute("tabspecific", "true");
439    panel.setAttribute("type", "arrow");
440    panel.setAttribute("role", "group");
441    if (extension.remote) {
442      panel.setAttribute("remote", "true");
443    }
444
445    document.getElementById("mainPopupSet").appendChild(panel);
446
447    panel.addEventListener(
448      "popupshowing",
449      () => {
450        let event = new this.window.CustomEvent("WebExtPopupLoaded", {
451          bubbles: true,
452          detail: { extension },
453        });
454        this.browser.dispatchEvent(event);
455      },
456      { once: true }
457    );
458
459    super(extension, panel, popupURL, browserStyle);
460  }
461
462  get DESTROY_EVENT() {
463    return "popuphidden";
464  }
465
466  destroy() {
467    super.destroy();
468    this.viewNode.remove();
469    this.viewNode = null;
470  }
471
472  closePopup() {
473    promisePopupShown(this.viewNode).then(() => {
474      // Make sure we're not already destroyed, or removed from the DOM.
475      if (this.viewNode && this.viewNode.hidePopup) {
476        this.viewNode.hidePopup();
477      }
478    });
479  }
480}
481
482class ViewPopup extends BasePopup {
483  constructor(
484    extension,
485    window,
486    popupURL,
487    browserStyle,
488    fixedWidth,
489    blockParser
490  ) {
491    let document = window.document;
492
493    let createPanel = remote => {
494      let panel = document.createXULElement("panel");
495      panel.setAttribute("type", "arrow");
496      if (remote) {
497        panel.setAttribute("remote", "true");
498      }
499
500      document.getElementById("mainPopupSet").appendChild(panel);
501      return panel;
502    };
503
504    // Create a temporary panel to hold the browser while it pre-loads its
505    // content. This panel will never be shown, but the browser's docShell will
506    // be swapped with the browser in the real panel when it's ready. For remote
507    // extensions, this popup is shared between all extensions.
508    let panel;
509    if (extension.remote) {
510      panel = document.getElementById(REMOTE_PANEL_ID);
511      if (!panel) {
512        panel = createPanel(true);
513        panel.id = REMOTE_PANEL_ID;
514      }
515    } else {
516      panel = createPanel();
517    }
518
519    super(extension, panel, popupURL, browserStyle, fixedWidth, blockParser);
520
521    this.ignoreResizes = true;
522
523    this.attached = false;
524    this.shown = false;
525    this.tempPanel = panel;
526    this.tempBrowser = this.browser;
527
528    this.browser.classList.add("webextension-preload-browser");
529  }
530
531  /**
532   * Attaches the pre-loaded browser to the given view node, and reserves a
533   * promise which resolves when the browser is ready.
534   *
535   * @param {Element} viewNode
536   *        The node to attach the browser to.
537   * @returns {Promise<boolean>}
538   *        Resolves when the browser is ready. Resolves to `false` if the
539   *        browser was destroyed before it was fully loaded, and the popup
540   *        should be closed, or `true` otherwise.
541   */
542  async attach(viewNode) {
543    if (this.destroyed) {
544      return false;
545    }
546    this.viewNode.removeEventListener(this.DESTROY_EVENT, this);
547    this.panel.removeEventListener("popuppositioned", this, {
548      once: true,
549      capture: true,
550    });
551
552    this.viewNode = viewNode;
553    this.viewNode.addEventListener(this.DESTROY_EVENT, this);
554    this.viewNode.setAttribute("closemenu", "none");
555
556    this.panel.addEventListener("popuppositioned", this, {
557      once: true,
558      capture: true,
559    });
560    if (this.extension.remote) {
561      this.panel.setAttribute("remote", "true");
562    }
563
564    // Wait until the browser element is fully initialized, and give it at least
565    // a short grace period to finish loading its initial content, if necessary.
566    //
567    // In practice, the browser that was created by the mousdown handler should
568    // nearly always be ready by this point.
569    await Promise.all([
570      this.browserReady,
571      Promise.race([
572        // This promise may be rejected if the popup calls window.close()
573        // before it has fully loaded.
574        this.browserLoaded.catch(() => {}),
575        new Promise(resolve => setTimeout(resolve, POPUP_LOAD_TIMEOUT_MS)),
576      ]),
577    ]);
578
579    const { panel } = this;
580
581    if (!this.destroyed && !panel) {
582      this.destroy();
583    }
584
585    if (this.destroyed) {
586      CustomizableUI.hidePanelForNode(viewNode);
587      return false;
588    }
589
590    this.attached = true;
591
592    this.setBackground(this.background);
593
594    let flushPromise = this.window.promiseDocumentFlushed(() => {
595      let win = this.window;
596
597      // Calculate the extra height available on the screen above and below the
598      // menu panel. Use that to calculate the how much the sub-view may grow.
599      let popupRect = panel.getBoundingClientRect();
600      let screenBottom = win.screen.availTop + win.screen.availHeight;
601      let popupBottom = win.mozInnerScreenY + popupRect.bottom;
602      let popupTop = win.mozInnerScreenY + popupRect.top;
603
604      // Store the initial height of the view, so that we never resize menu panel
605      // sub-views smaller than the initial height of the menu.
606      this.viewHeight = viewNode.getBoundingClientRect().height;
607
608      this.extraHeight = {
609        bottom: Math.max(0, screenBottom - popupBottom),
610        top: Math.max(0, popupTop - win.screen.availTop),
611      };
612    });
613
614    // Create a new browser in the real popup.
615    let browser = this.browser;
616    await this.createBrowser(this.viewNode);
617
618    this.browser.swapDocShells(browser);
619    this.destroyBrowser(browser);
620
621    await flushPromise;
622
623    // Check if the popup has been destroyed while we were waiting for the
624    // document flush promise to be resolve.
625    if (this.destroyed) {
626      this.closePopup();
627      this.destroy();
628      return false;
629    }
630
631    if (this.dimensions) {
632      if (this.fixedWidth) {
633        delete this.dimensions.width;
634      }
635      this.resizeBrowser(this.dimensions);
636    }
637
638    this.ignoreResizes = false;
639
640    this.viewNode.customRectGetter = () => {
641      return { height: this.lastCalculatedInViewHeight || this.viewHeight };
642    };
643
644    this.removeTempPanel();
645
646    this.shown = true;
647
648    if (this.destroyed) {
649      this.closePopup();
650      this.destroy();
651      return false;
652    }
653
654    let event = new this.window.CustomEvent("WebExtPopupLoaded", {
655      bubbles: true,
656      detail: { extension: this.extension },
657    });
658    this.browser.dispatchEvent(event);
659
660    return true;
661  }
662
663  removeTempPanel() {
664    if (this.tempPanel) {
665      if (this.tempPanel.id !== REMOTE_PANEL_ID) {
666        this.tempPanel.remove();
667      }
668      this.tempPanel = null;
669    }
670    if (this.tempBrowser) {
671      this.tempBrowser.parentNode.remove();
672      this.tempBrowser = null;
673    }
674  }
675
676  destroy() {
677    return super.destroy().then(() => {
678      this.removeTempPanel();
679    });
680  }
681
682  get DESTROY_EVENT() {
683    return "ViewHiding";
684  }
685
686  closePopup() {
687    if (this.shown) {
688      CustomizableUI.hidePanelForNode(this.viewNode);
689    } else if (this.attached) {
690      this.destroyed = true;
691    } else {
692      this.destroy();
693    }
694  }
695}
696