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