ext-devtools-panels.js (23136B)
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 var { ExtensionParent } = ChromeUtils.importESModule( 10 "resource://gre/modules/ExtensionParent.sys.mjs" 11 ); 12 13 ChromeUtils.defineESModuleGetters(this, { 14 BroadcastConduit: "resource://gre/modules/ConduitsParent.sys.mjs", 15 }); 16 17 var { watchExtensionProxyContextLoad } = ExtensionParent; 18 19 const WEBEXT_PANELS_URL = "chrome://browser/content/webext-panels.xhtml"; 20 21 class BaseDevToolsPanel { 22 constructor(context, panelOptions) { 23 const toolbox = context.devToolsToolbox; 24 if (!toolbox) { 25 // This should never happen when this constructor is called with a valid 26 // devtools extension context. 27 throw Error("Missing mandatory toolbox"); 28 } 29 30 this.context = context; 31 this.extension = context.extension; 32 this.toolbox = toolbox; 33 this.viewType = "devtools_panel"; 34 this.panelOptions = panelOptions; 35 this.id = panelOptions.id; 36 37 this.unwatchExtensionProxyContextLoad = null; 38 39 // References to the panel browser XUL element and the toolbox window global which 40 // contains the devtools panel UI. 41 this.browser = null; 42 this.browserContainerWindow = null; 43 } 44 45 async createBrowserElement(window) { 46 const { toolbox } = this; 47 const { extension } = this.context; 48 const { url } = this.panelOptions || { url: "about:blank" }; 49 50 this.browser = await window.getBrowser({ 51 extension, 52 extensionUrl: url, 53 browserStyle: false, 54 viewType: "devtools_panel", 55 browserInsertedData: { 56 devtoolsToolboxInfo: { 57 toolboxPanelId: this.id, 58 inspectedWindowTabId: getTargetTabIdForToolbox(toolbox), 59 }, 60 }, 61 }); 62 63 let hasTopLevelContext = false; 64 65 // Listening to new proxy contexts. 66 this.unwatchExtensionProxyContextLoad = watchExtensionProxyContextLoad( 67 this, 68 context => { 69 // Keep track of the toolbox and target associated to the context, which is 70 // needed by the API methods implementation. 71 context.devToolsToolbox = toolbox; 72 73 if (!hasTopLevelContext) { 74 hasTopLevelContext = true; 75 76 // Resolve the promise when the root devtools_panel context has been created. 77 if (this._resolveTopLevelContext) { 78 this._resolveTopLevelContext(context); 79 } 80 } 81 } 82 ); 83 84 this.syncToolboxZoom(); 85 this.toolbox.win.browsingContext.embedderElement.addEventListener( 86 "FullZoomChange", 87 this 88 ); 89 90 this.browser.fixupAndLoadURIString(url, { 91 triggeringPrincipal: this.context.principal, 92 }); 93 } 94 95 handleEvent(event) { 96 switch (event.type) { 97 case "FullZoomChange": { 98 this.syncToolboxZoom(); 99 break; 100 } 101 } 102 } 103 104 /** 105 * The remote `<browser>` that loads the panel content does not inherit 106 * the zoom level of the `<browser>` it's nested inside of. 107 * 108 * about:devtools-toolbox <browser> (zoom level applied here) 109 * - ... 110 * - webext-panels.xhtml <iframe> (inherits zoom) 111 * - <browser remote="true"> (doesn't inherit zoom) 112 * 113 * To work around this, we manually synchronize the zoom levels. 114 */ 115 syncToolboxZoom() { 116 if (!this.browser) { 117 return; 118 } 119 120 this.browser.fullZoom = this.toolbox.win.browsingContext.fullZoom; 121 } 122 123 destroyBrowserElement() { 124 const { browser, unwatchExtensionProxyContextLoad } = this; 125 if (unwatchExtensionProxyContextLoad) { 126 this.unwatchExtensionProxyContextLoad = null; 127 unwatchExtensionProxyContextLoad(); 128 } 129 130 if (this.toolbox) { 131 this.toolbox.win.browsingContext.embedderElement.removeEventListener( 132 "FullZoomChange", 133 this 134 ); 135 } 136 137 if (browser) { 138 browser.remove(); 139 this.browser = null; 140 } 141 } 142 } 143 144 /** 145 * Represents an addon devtools panel in the main process. 146 */ 147 class ParentDevToolsPanel extends BaseDevToolsPanel { 148 /** 149 * @param {DevToolsExtensionPageContextParent} context 150 * A devtools extension proxy context running in a main process. 151 * @param {object} panelOptions 152 * @param {string} panelOptions.id 153 * The id of the addon devtools panel. 154 * @param {string} panelOptions.icon 155 * The icon of the addon devtools panel. 156 * @param {string} panelOptions.title 157 * The title of the addon devtools panel. 158 * @param {string} panelOptions.url 159 * The url of the addon devtools panel, relative to the extension base URL. 160 */ 161 constructor(context, panelOptions) { 162 super(context, panelOptions); 163 164 this.visible = false; 165 this.destroyed = false; 166 167 this.context.callOnClose(this); 168 169 this.conduit = new BroadcastConduit(this, { 170 id: `${this.id}-parent`, 171 send: ["PanelHidden", "PanelShown"], 172 }); 173 174 this.onToolboxPanelSelect = this.onToolboxPanelSelect.bind(this); 175 this.onToolboxHostWillChange = this.onToolboxHostWillChange.bind(this); 176 this.onToolboxHostChanged = this.onToolboxHostChanged.bind(this); 177 178 this.waitTopLevelContext = new Promise(resolve => { 179 this._resolveTopLevelContext = resolve; 180 }); 181 182 this.panelAdded = false; 183 this.addPanel(); 184 } 185 186 addPanel() { 187 const { icon, title } = this.panelOptions; 188 const extensionName = this.context.extension.name; 189 190 this.toolbox.addAdditionalTool({ 191 id: this.id, 192 extensionId: this.context.extension.id, 193 url: WEBEXT_PANELS_URL, 194 icon: icon, 195 label: title, 196 // panelLabel is used to set the aria-label attribute (See Bug 1570645). 197 panelLabel: title, 198 tooltip: `DevTools Panel added by "${extensionName}" add-on.`, 199 isToolSupported: toolbox => toolbox.commands.descriptorFront.isLocalTab, 200 build: (window, toolbox) => { 201 if (toolbox !== this.toolbox) { 202 throw new Error( 203 "Unexpected toolbox received on addAdditionalTool build property" 204 ); 205 } 206 207 const destroy = this.buildPanel(window); 208 209 return { toolbox, destroy }; 210 }, 211 }); 212 213 this.panelAdded = true; 214 } 215 216 buildPanel(window) { 217 const { toolbox } = this; 218 219 this.createBrowserElement(window); 220 221 // Store the last panel's container element (used to restore it when the toolbox 222 // host is switched between docked and undocked). 223 this.browserContainerWindow = window; 224 225 toolbox.on("select", this.onToolboxPanelSelect); 226 toolbox.on("host-will-change", this.onToolboxHostWillChange); 227 toolbox.on("host-changed", this.onToolboxHostChanged); 228 229 // Return a cleanup method that is when the panel is destroyed, e.g. 230 // - when addon devtool panel has been disabled by the user from the toolbox preferences, 231 // its ParentDevToolsPanel instance is still valid, but the built devtools panel is removed from 232 // the toolbox (and re-built again if the user re-enables it from the toolbox preferences panel) 233 // - when the creator context has been destroyed, the ParentDevToolsPanel close method is called, 234 // it removes the tool definition from the toolbox, which will call this destroy method. 235 return () => { 236 this.destroyBrowserElement(); 237 this.browserContainerWindow = null; 238 toolbox.off("select", this.onToolboxPanelSelect); 239 toolbox.off("host-will-change", this.onToolboxHostWillChange); 240 toolbox.off("host-changed", this.onToolboxHostChanged); 241 }; 242 } 243 244 onToolboxHostWillChange() { 245 // NOTE: Using a content iframe here breaks the devtools panel 246 // switching between docked and undocked mode, 247 // because of a swapFrameLoader exception (see bug 1075490), 248 // destroy the browser and recreate it after the toolbox host has been 249 // switched is a reasonable workaround to fix the issue on release and beta 250 // Firefox versions (at least until the underlying bug can be fixed). 251 if (this.browser) { 252 // Fires a panel.onHidden event before destroying the browser element because 253 // the toolbox hosts is changing. 254 if (this.visible) { 255 this.conduit.sendPanelHidden(this.id); 256 } 257 258 this.destroyBrowserElement(); 259 } 260 } 261 262 async onToolboxHostChanged() { 263 if (this.browserContainerWindow) { 264 this.createBrowserElement(this.browserContainerWindow); 265 266 // Fires a panel.onShown event once the browser element has been recreated 267 // after the toolbox hosts has been changed (needed to provide the new window 268 // object to the extension page that has created the devtools panel). 269 if (this.visible) { 270 await this.waitTopLevelContext; 271 this.conduit.sendPanelShown(this.id); 272 } 273 } 274 } 275 276 async onToolboxPanelSelect(id) { 277 if (!this.waitTopLevelContext || !this.panelAdded) { 278 return; 279 } 280 281 // Wait that the panel is fully loaded and emit show. 282 await this.waitTopLevelContext; 283 284 if (!this.visible && id === this.id) { 285 this.visible = true; 286 this.conduit.sendPanelShown(this.id); 287 } else if (this.visible && id !== this.id) { 288 this.visible = false; 289 this.conduit.sendPanelHidden(this.id); 290 } 291 } 292 293 close() { 294 const { toolbox } = this; 295 296 if (!toolbox) { 297 throw new Error("Unable to destroy a closed devtools panel"); 298 } 299 300 this.conduit.close(); 301 302 // Explicitly remove the panel if it is registered and the toolbox is not 303 // closing itself. 304 if (this.panelAdded && toolbox.isToolRegistered(this.id)) { 305 this.destroyBrowserElement(); 306 toolbox.removeAdditionalTool(this.id); 307 } 308 309 this.waitTopLevelContext = null; 310 this._resolveTopLevelContext = null; 311 this.context = null; 312 this.toolbox = null; 313 this.browser = null; 314 this.browserContainerWindow = null; 315 } 316 317 destroyBrowserElement() { 318 super.destroyBrowserElement(); 319 320 // If the panel has been removed or disabled (e.g. from the toolbox preferences 321 // or during the toolbox switching between docked and undocked), 322 // we need to re-initialize the waitTopLevelContext Promise. 323 this.waitTopLevelContext = new Promise(resolve => { 324 this._resolveTopLevelContext = resolve; 325 }); 326 } 327 } 328 329 class DevToolsSelectionObserver extends EventEmitter { 330 constructor(context) { 331 if (!context.devToolsToolbox) { 332 // This should never happen when this constructor is called with a valid 333 // devtools extension context. 334 throw Error("Missing mandatory toolbox"); 335 } 336 337 super(); 338 context.callOnClose(this); 339 340 this.toolbox = context.devToolsToolbox; 341 this.onSelected = this.onSelected.bind(this); 342 this.initialized = false; 343 } 344 345 on(...args) { 346 this.lazyInit(); 347 super.on.apply(this, args); 348 } 349 350 once(...args) { 351 this.lazyInit(); 352 super.once.apply(this, args); 353 } 354 355 async lazyInit() { 356 if (!this.initialized) { 357 this.initialized = true; 358 this.toolbox.on("selection-changed", this.onSelected); 359 } 360 } 361 362 close() { 363 if (this.destroyed) { 364 throw new Error("Unable to close a destroyed DevToolsSelectionObserver"); 365 } 366 367 if (this.initialized) { 368 this.toolbox.off("selection-changed", this.onSelected); 369 } 370 371 this.toolbox = null; 372 this.destroyed = true; 373 } 374 375 onSelected() { 376 this.emit("selectionChanged"); 377 } 378 } 379 380 /** 381 * Represents an addon devtools inspector sidebar in the main process. 382 */ 383 class ParentDevToolsInspectorSidebar extends BaseDevToolsPanel { 384 /** 385 * @param {DevToolsExtensionPageContextParent} context 386 * A devtools extension proxy context running in a main process. 387 * @param {object} panelOptions 388 * @param {string} panelOptions.id 389 * The id of the addon devtools sidebar. 390 * @param {string} panelOptions.title 391 * The title of the addon devtools sidebar. 392 */ 393 constructor(context, panelOptions) { 394 super(context, panelOptions); 395 396 this.visible = false; 397 this.destroyed = false; 398 399 this.context.callOnClose(this); 400 401 this.conduit = new BroadcastConduit(this, { 402 id: `${this.id}-parent`, 403 send: ["InspectorSidebarHidden", "InspectorSidebarShown"], 404 }); 405 406 this.onSidebarSelect = this.onSidebarSelect.bind(this); 407 this.onSidebarCreated = this.onSidebarCreated.bind(this); 408 this.onExtensionPageMount = this.onExtensionPageMount.bind(this); 409 this.onExtensionPageUnmount = this.onExtensionPageUnmount.bind(this); 410 this.onToolboxHostWillChange = this.onToolboxHostWillChange.bind(this); 411 this.onToolboxHostChanged = this.onToolboxHostChanged.bind(this); 412 413 this.toolbox.once( 414 `extension-sidebar-created-${this.id}`, 415 this.onSidebarCreated 416 ); 417 this.toolbox.on("inspector-sidebar-select", this.onSidebarSelect); 418 this.toolbox.on("host-will-change", this.onToolboxHostWillChange); 419 this.toolbox.on("host-changed", this.onToolboxHostChanged); 420 421 // Set by setObject if the sidebar has not been created yet. 422 this._initializeSidebar = null; 423 424 // Set by _updateLastExpressionResult to keep track of the last 425 // object value grip (to release the previous selected actor 426 // on the remote debugging server when the actor changes). 427 this._lastExpressionResult = null; 428 429 this.toolbox.registerInspectorExtensionSidebar(this.id, { 430 title: panelOptions.title, 431 }); 432 } 433 434 close() { 435 if (this.destroyed) { 436 throw new Error("Unable to close a destroyed DevToolsSelectionObserver"); 437 } 438 439 this.conduit.close(); 440 441 if (this.extensionSidebar) { 442 this.extensionSidebar.off( 443 "extension-page-mount", 444 this.onExtensionPageMount 445 ); 446 this.extensionSidebar.off( 447 "extension-page-unmount", 448 this.onExtensionPageUnmount 449 ); 450 } 451 452 if (this.browser) { 453 this.destroyBrowserElement(); 454 this.browser = null; 455 this.containerEl = null; 456 } 457 458 this.toolbox.off( 459 `extension-sidebar-created-${this.id}`, 460 this.onSidebarCreated 461 ); 462 this.toolbox.off("inspector-sidebar-select", this.onSidebarSelect); 463 this.toolbox.off("host-changed", this.onToolboxHostChanged); 464 this.toolbox.off("host-will-change", this.onToolboxHostWillChange); 465 466 this.toolbox.unregisterInspectorExtensionSidebar(this.id); 467 this.extensionSidebar = null; 468 this._lazySidebarInit = null; 469 470 this.destroyed = true; 471 } 472 473 onToolboxHostWillChange() { 474 if (this.browser) { 475 this.destroyBrowserElement(); 476 } 477 } 478 479 onToolboxHostChanged() { 480 if (this.containerEl && this.panelOptions.url) { 481 this.createBrowserElement(this.containerEl.contentWindow); 482 } 483 } 484 485 onExtensionPageMount(containerEl) { 486 this.containerEl = containerEl; 487 488 // Wait the webext-panel.xhtml page to have been loaded in the 489 // inspector sidebar panel. 490 const onLoaded = () => { 491 this.createBrowserElement(containerEl.contentWindow); 492 }; 493 // ExtensionUtils.promiseDocumentLoaded would attach a load listener to the 494 // container window, which will be replaced during the load (Bug 1955324). 495 const doc = containerEl.contentDocument; 496 if (doc.readyState == "complete" && doc.location.href != "about:blank") { 497 onLoaded(); 498 } else { 499 containerEl.addEventListener("load", onLoaded, { once: true }); 500 } 501 } 502 503 onExtensionPageUnmount() { 504 this.containerEl = null; 505 this.destroyBrowserElement(); 506 } 507 508 onSidebarCreated(sidebar) { 509 this.extensionSidebar = sidebar; 510 511 sidebar.on("extension-page-mount", this.onExtensionPageMount); 512 sidebar.on("extension-page-unmount", this.onExtensionPageUnmount); 513 514 const { _lazySidebarInit } = this; 515 this._lazySidebarInit = null; 516 517 if (typeof _lazySidebarInit === "function") { 518 _lazySidebarInit(); 519 } 520 } 521 522 onSidebarSelect(id) { 523 if (!this.extensionSidebar) { 524 return; 525 } 526 527 if (!this.visible && id === this.id) { 528 this.visible = true; 529 this.conduit.sendInspectorSidebarShown(this.id); 530 } else if (this.visible && id !== this.id) { 531 this.visible = false; 532 this.conduit.sendInspectorSidebarHidden(this.id); 533 } 534 } 535 536 setPage(extensionPageURL) { 537 this.panelOptions.url = extensionPageURL; 538 539 if (this.extensionSidebar) { 540 if (this.browser) { 541 // Just load the new extension page url in the existing browser, if 542 // it already exists. 543 this.browser.fixupAndLoadURIString(this.panelOptions.url, { 544 triggeringPrincipal: this.context.extension.principal, 545 }); 546 } else { 547 // The browser element doesn't exist yet, but the sidebar has been 548 // already created (e.g. because the inspector was already selected 549 // in a open toolbox and the extension has been installed/reloaded/updated). 550 this.extensionSidebar.setExtensionPage(WEBEXT_PANELS_URL); 551 } 552 } else { 553 // Defer the sidebar.setExtensionPage call. 554 this._setLazySidebarInit(() => 555 this.extensionSidebar.setExtensionPage(WEBEXT_PANELS_URL) 556 ); 557 } 558 } 559 560 setObject(object, rootTitle) { 561 delete this.panelOptions.url; 562 563 this._updateLastExpressionResult(null); 564 565 // Nest the object inside an object, as the value of the `rootTitle` property. 566 if (rootTitle) { 567 object = { [rootTitle]: object }; 568 } 569 570 if (this.extensionSidebar) { 571 this.extensionSidebar.setObject(object); 572 } else { 573 // Defer the sidebar.setObject call. 574 this._setLazySidebarInit(() => this.extensionSidebar.setObject(object)); 575 } 576 } 577 578 _setLazySidebarInit(cb) { 579 this._lazySidebarInit = cb; 580 } 581 582 setExpressionResult(expressionResult, rootTitle) { 583 delete this.panelOptions.url; 584 585 this._updateLastExpressionResult(expressionResult); 586 587 if (this.extensionSidebar) { 588 this.extensionSidebar.setExpressionResult(expressionResult, rootTitle); 589 } else { 590 // Defer the sidebar.setExpressionResult call. 591 this._setLazySidebarInit(() => { 592 this.extensionSidebar.setExpressionResult(expressionResult, rootTitle); 593 }); 594 } 595 } 596 597 _updateLastExpressionResult(newExpressionResult = null) { 598 const { _lastExpressionResult } = this; 599 600 this._lastExpressionResult = newExpressionResult; 601 602 const oldActor = _lastExpressionResult && _lastExpressionResult.actorID; 603 const newActor = newExpressionResult && newExpressionResult.actorID; 604 605 // Release the previously active actor on the remote debugging server. 606 if ( 607 oldActor && 608 oldActor !== newActor && 609 typeof _lastExpressionResult.release === "function" 610 ) { 611 _lastExpressionResult.release(); 612 } 613 } 614 } 615 616 const sidebarsById = new Map(); 617 618 this.devtools_panels = class extends ExtensionAPI { 619 getAPI(context) { 620 // TODO - Bug 1448878: retrieve a more detailed callerInfo object, 621 // like the filename and lineNumber of the actual extension called 622 // in the child process. 623 const callerInfo = { 624 addonId: context.extension.id, 625 url: context.extension.baseURI.spec, 626 }; 627 628 // An incremental "per context" id used in the generated devtools panel id. 629 let nextPanelId = 0; 630 631 const toolboxSelectionObserver = new DevToolsSelectionObserver(context); 632 633 function newBasePanelId() { 634 return `${context.extension.id}-${context.contextId}-${nextPanelId++}`; 635 } 636 637 return { 638 devtools: { 639 panels: { 640 elements: { 641 onSelectionChanged: new EventManager({ 642 context, 643 name: "devtools.panels.elements.onSelectionChanged", 644 register: fire => { 645 const listener = () => { 646 fire.async(); 647 }; 648 toolboxSelectionObserver.on("selectionChanged", listener); 649 return () => { 650 toolboxSelectionObserver.off("selectionChanged", listener); 651 }; 652 }, 653 }).api(), 654 createSidebarPane(title) { 655 const id = `devtools-inspector-sidebar-${makeWidgetId( 656 newBasePanelId() 657 )}`; 658 659 const parentSidebar = new ParentDevToolsInspectorSidebar( 660 context, 661 { title, id } 662 ); 663 sidebarsById.set(id, parentSidebar); 664 665 context.callOnClose({ 666 close() { 667 sidebarsById.delete(id); 668 }, 669 }); 670 671 // Resolved to the devtools sidebar id into the child addon process, 672 // where it will be used to identify the messages related 673 // to the panel API onShown/onHidden events. 674 return Promise.resolve(id); 675 }, 676 // The following methods are used internally to allow the sidebar API 677 // piece that is running in the child process to asks the parent process 678 // to execute the sidebar methods. 679 Sidebar: { 680 setPage(sidebarId, extensionPageURL) { 681 const sidebar = sidebarsById.get(sidebarId); 682 return sidebar.setPage(extensionPageURL); 683 }, 684 setObject(sidebarId, jsonObject, rootTitle) { 685 const sidebar = sidebarsById.get(sidebarId); 686 return sidebar.setObject(jsonObject, rootTitle); 687 }, 688 async setExpression(sidebarId, evalExpression, rootTitle) { 689 const sidebar = sidebarsById.get(sidebarId); 690 691 const toolboxEvalOptions = await getToolboxEvalOptions(context); 692 693 const commands = await context.getDevToolsCommands(); 694 const target = commands.targetCommand.targetFront; 695 const consoleFront = await target.getFront("console"); 696 toolboxEvalOptions.consoleFront = consoleFront; 697 698 const evalResult = await commands.inspectedWindowCommand.eval( 699 callerInfo, 700 evalExpression, 701 toolboxEvalOptions 702 ); 703 704 let jsonObject; 705 706 if (evalResult.exceptionInfo) { 707 jsonObject = evalResult.exceptionInfo; 708 709 return sidebar.setObject(jsonObject, rootTitle); 710 } 711 712 return sidebar.setExpressionResult(evalResult, rootTitle); 713 }, 714 }, 715 }, 716 create(title, icon, url) { 717 // Get a fallback icon from the manifest data. 718 if (icon === "") { 719 icon = context.extension.getPreferredIcon(128); 720 } 721 722 icon = context.extension.baseURI.resolve(icon); 723 url = context.extension.baseURI.resolve(url); 724 725 const id = `webext-devtools-panel-${makeWidgetId( 726 newBasePanelId() 727 )}`; 728 729 new ParentDevToolsPanel(context, { title, icon, url, id }); 730 731 // Resolved to the devtools panel id into the child addon process, 732 // where it will be used to identify the messages related 733 // to the panel API onShown/onHidden events. 734 return Promise.resolve(id); 735 }, 736 }, 737 }, 738 }; 739 } 740 };