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