ext-devtools.js (16256B)
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 /** 10 * This module provides helpers used by the other specialized `ext-devtools-*.js` modules 11 * and the implementation of the `devtools_page`. 12 */ 13 14 ChromeUtils.defineESModuleGetters(this, { 15 DevToolsShim: "chrome://devtools-startup/content/DevToolsShim.sys.mjs", 16 }); 17 18 var { ExtensionParent } = ChromeUtils.importESModule( 19 "resource://gre/modules/ExtensionParent.sys.mjs" 20 ); 21 22 var { HiddenExtensionPage, watchExtensionProxyContextLoad } = ExtensionParent; 23 24 // Get the devtools preference given the extension id. 25 function getDevToolsPrefBranchName(extensionId) { 26 return `devtools.webextensions.${extensionId}`; 27 } 28 29 /** 30 * Retrieve the tabId for the given devtools toolbox. 31 * 32 * @param {Toolbox} toolbox 33 * A devtools toolbox instance. 34 * 35 * @returns {number} 36 * The corresponding WebExtensions tabId. 37 */ 38 global.getTargetTabIdForToolbox = toolbox => { 39 let { descriptorFront } = toolbox.commands; 40 41 if (!descriptorFront.isLocalTab) { 42 throw new Error( 43 "Unexpected target type: only local tabs are currently supported." 44 ); 45 } 46 47 let parentWindow = descriptorFront.localTab.linkedBrowser.ownerGlobal; 48 let tab = parentWindow.gBrowser.getTabForBrowser( 49 descriptorFront.localTab.linkedBrowser 50 ); 51 52 return tabTracker.getId(tab); 53 }; 54 55 // Get the WebExtensionInspectedWindowActor eval options (needed to provide the $0 and inspect 56 // binding provided to the evaluated js code). 57 global.getToolboxEvalOptions = async function (context) { 58 const options = {}; 59 const toolbox = context.devToolsToolbox; 60 const selectedNode = toolbox.selection; 61 62 if (selectedNode && selectedNode.nodeFront) { 63 // If there is a selected node in the inspector, we hand over 64 // its actor id to the eval request in order to provide the "$0" binding. 65 options.toolboxSelectedNodeActorID = selectedNode.nodeFront.actorID; 66 } 67 68 // Provide the console actor ID to implement the "inspect" binding. 69 const consoleFront = await toolbox.target.getFront("console"); 70 options.toolboxConsoleActorID = consoleFront.actor; 71 72 return options; 73 }; 74 75 /** 76 * The DevToolsPage represents the "devtools_page" related to a particular 77 * Toolbox and WebExtension. 78 * 79 * The devtools_page contexts are invisible WebExtensions contexts, similar to the 80 * background page, associated to a single developer toolbox (e.g. If an add-on 81 * registers a devtools_page and the user opens 3 developer toolbox in 3 webpages, 82 * 3 devtools_page contexts will be created for that add-on). 83 * 84 * @param {Extension} extension 85 * The extension that owns the devtools_page. 86 * @param {object} options 87 * @param {Toolbox} options.toolbox 88 * The developer toolbox instance related to this devtools_page. 89 * @param {string} options.url 90 * The path to the devtools page html page relative to the extension base URL. 91 * @param {DevToolsPageDefinition} options.devToolsPageDefinition 92 * The instance of the devToolsPageDefinition class related to this DevToolsPage. 93 */ 94 class DevToolsPage extends HiddenExtensionPage { 95 constructor(extension, options) { 96 super(extension, "devtools_page"); 97 98 this.url = extension.baseURI.resolve(options.url); 99 this.toolbox = options.toolbox; 100 this.devToolsPageDefinition = options.devToolsPageDefinition; 101 102 this.unwatchExtensionProxyContextLoad = null; 103 104 this.waitForTopLevelContext = new Promise(resolve => { 105 this.resolveTopLevelContext = resolve; 106 }); 107 } 108 109 async build() { 110 await this.createBrowserElement(); 111 112 // Listening to new proxy contexts. 113 this.unwatchExtensionProxyContextLoad = watchExtensionProxyContextLoad( 114 this, 115 context => { 116 // Keep track of the toolbox and target associated to the context, which is 117 // needed by the API methods implementation. 118 context.devToolsToolbox = this.toolbox; 119 120 if (!this.topLevelContext) { 121 this.topLevelContext = context; 122 123 // Ensure this devtools page is destroyed, when the top level context proxy is 124 // closed. 125 this.topLevelContext.callOnClose(this); 126 127 this.resolveTopLevelContext(context); 128 } 129 } 130 ); 131 132 extensions.emit("extension-browser-inserted", this.browser, { 133 devtoolsToolboxInfo: { 134 inspectedWindowTabId: getTargetTabIdForToolbox(this.toolbox), 135 themeName: DevToolsShim.getTheme(), 136 }, 137 }); 138 139 this.browser.fixupAndLoadURIString(this.url, { 140 triggeringPrincipal: this.extension.principal, 141 }); 142 143 await this.waitForTopLevelContext; 144 } 145 146 close() { 147 if (this.closed) { 148 throw new Error("Unable to shutdown a closed DevToolsPage instance"); 149 } 150 151 this.closed = true; 152 153 // Unregister the devtools page instance from the devtools page definition. 154 this.devToolsPageDefinition.forgetForToolbox(this.toolbox); 155 156 // Unregister it from the resources to cleanup when the context has been closed. 157 if (this.topLevelContext) { 158 this.topLevelContext.forgetOnClose(this); 159 } 160 161 // Stop watching for any new proxy contexts from the devtools page. 162 if (this.unwatchExtensionProxyContextLoad) { 163 this.unwatchExtensionProxyContextLoad(); 164 this.unwatchExtensionProxyContextLoad = null; 165 } 166 167 super.shutdown(); 168 } 169 } 170 171 /** 172 * The DevToolsPageDefinitions class represents the "devtools_page" manifest property 173 * of a WebExtension. 174 * 175 * A DevToolsPageDefinition instance is created automatically when a WebExtension 176 * which contains the "devtools_page" manifest property has been loaded, and it is 177 * automatically destroyed when the related WebExtension has been unloaded, 178 * and so there will be at most one DevtoolsPageDefinition per add-on. 179 * 180 * Every time a developer tools toolbox is opened, the DevToolsPageDefinition creates 181 * and keep track of a DevToolsPage instance (which represents the actual devtools_page 182 * instance related to that particular toolbox). 183 * 184 * @param {Extension} extension 185 * The extension that owns the devtools_page. 186 * @param {string} url 187 * The path to the devtools page html page relative to the extension base URL. 188 */ 189 class DevToolsPageDefinition { 190 constructor(extension, url) { 191 this.url = url; 192 this.extension = extension; 193 194 // Map[Toolbox -> DevToolsPage] 195 this.devtoolsPageForToolbox = new Map(); 196 } 197 198 onThemeChanged(themeName) { 199 Services.ppmm.broadcastAsyncMessage("Extension:DevToolsThemeChanged", { 200 themeName, 201 }); 202 } 203 204 buildForToolbox(toolbox) { 205 if ( 206 !this.extension.canAccessWindow( 207 toolbox.commands.descriptorFront.localTab.ownerGlobal 208 ) 209 ) { 210 // We should never create a devtools page for a toolbox related to a private browsing window 211 // if the extension is not allowed to access it. 212 return; 213 } 214 215 if (this.devtoolsPageForToolbox.has(toolbox)) { 216 return Promise.reject( 217 new Error("DevtoolsPage has been already created for this toolbox") 218 ); 219 } 220 221 const devtoolsPage = new DevToolsPage(this.extension, { 222 toolbox, 223 url: this.url, 224 devToolsPageDefinition: this, 225 }); 226 227 // If this is the first DevToolsPage, subscribe to the theme-changed event 228 if (this.devtoolsPageForToolbox.size === 0) { 229 DevToolsShim.on("theme-changed", this.onThemeChanged); 230 } 231 this.devtoolsPageForToolbox.set(toolbox, devtoolsPage); 232 233 return devtoolsPage.build(); 234 } 235 236 shutdownForToolbox(toolbox) { 237 if (this.devtoolsPageForToolbox.has(toolbox)) { 238 const devtoolsPage = this.devtoolsPageForToolbox.get(toolbox); 239 devtoolsPage.close(); 240 241 // `devtoolsPage.close()` should remove the instance from the map, 242 // raise an exception if it is still there. 243 if (this.devtoolsPageForToolbox.has(toolbox)) { 244 throw new Error( 245 `Leaked DevToolsPage instance for target "${toolbox.commands.descriptorFront.url}", extension "${this.extension.policy.debugName}"` 246 ); 247 } 248 249 // If this was the last DevToolsPage, unsubscribe from the theme-changed event 250 if (this.devtoolsPageForToolbox.size === 0) { 251 DevToolsShim.off("theme-changed", this.onThemeChanged); 252 } 253 this.extension.emit("devtools-page-shutdown", toolbox); 254 } 255 } 256 257 forgetForToolbox(toolbox) { 258 this.devtoolsPageForToolbox.delete(toolbox); 259 } 260 261 /** 262 * Build the devtools_page instances for all the existing toolboxes 263 * (if the toolbox target is supported). 264 */ 265 build() { 266 // Iterate over the existing toolboxes and create the devtools page for them 267 // (if the toolbox target is supported). 268 for (let toolbox of DevToolsShim.getToolboxes()) { 269 if ( 270 // Skip toolboxes in the middle of their destroy sequence (fully 271 // destroyed will not be returned by getToolboxes()). 272 toolbox.isDestroying() || 273 // Skip remote / non-web toolboxes (ie. target is not a local tab). 274 !toolbox.commands.descriptorFront.isLocalTab || 275 // Skip private browsing windows if the extension is not allowed to 276 // access them. 277 !this.extension.canAccessWindow( 278 toolbox.commands.descriptorFront.localTab.ownerGlobal 279 ) 280 ) { 281 continue; 282 } 283 284 // Ensure that the WebExtension is listed in the toolbox options. 285 toolbox.registerWebExtension(this.extension.uuid, { 286 name: this.extension.name, 287 pref: `${getDevToolsPrefBranchName(this.extension.id)}.enabled`, 288 }); 289 290 this.buildForToolbox(toolbox); 291 } 292 } 293 294 /** 295 * Shutdown all the devtools_page instances. 296 */ 297 shutdown() { 298 for (let toolbox of this.devtoolsPageForToolbox.keys()) { 299 this.shutdownForToolbox(toolbox); 300 } 301 302 if (this.devtoolsPageForToolbox.size > 0) { 303 throw new Error( 304 `Leaked ${this.devtoolsPageForToolbox.size} DevToolsPage instances in devtoolsPageForToolbox Map` 305 ); 306 } 307 } 308 } 309 310 this.devtools = class extends ExtensionAPI { 311 constructor(extension) { 312 super(extension); 313 314 this._initialized = false; 315 316 // DevToolsPageDefinition instance (created in onManifestEntry). 317 this.pageDefinition = null; 318 319 this.onToolboxReady = this.onToolboxReady.bind(this); 320 this.onToolboxDestroy = this.onToolboxDestroy.bind(this); 321 322 /* eslint-disable mozilla/balanced-listeners */ 323 extension.on("add-permissions", (ignoreEvent, permissions) => { 324 if (permissions.permissions.includes("devtools")) { 325 Services.prefs.setBoolPref( 326 `${getDevToolsPrefBranchName(extension.id)}.enabled`, 327 true 328 ); 329 330 this._initialize(); 331 } 332 }); 333 334 extension.on("remove-permissions", (ignoreEvent, permissions) => { 335 if (permissions.permissions.includes("devtools")) { 336 Services.prefs.setBoolPref( 337 `${getDevToolsPrefBranchName(extension.id)}.enabled`, 338 false 339 ); 340 341 this._uninitialize(); 342 } 343 }); 344 } 345 346 onManifestEntry() { 347 this._initialize(); 348 } 349 350 static onUninstall(extensionId) { 351 // Remove the preference branch on uninstall. 352 const prefBranch = Services.prefs.getBranch( 353 `${getDevToolsPrefBranchName(extensionId)}.` 354 ); 355 356 prefBranch.deleteBranch(""); 357 } 358 359 _initialize() { 360 const { extension } = this; 361 362 if (!extension.hasPermission("devtools") || this._initialized) { 363 return; 364 } 365 366 this.initDevToolsPref(); 367 368 // Create the devtools_page definition. 369 this.pageDefinition = new DevToolsPageDefinition( 370 extension, 371 extension.manifest.devtools_page 372 ); 373 374 // Build the extension devtools_page on all existing toolboxes (if the extension 375 // devtools_page is not disabled by the related preference). 376 if (!this.isDevToolsPageDisabled()) { 377 this.pageDefinition.build(); 378 } 379 380 DevToolsShim.on("toolbox-ready", this.onToolboxReady); 381 DevToolsShim.on("toolbox-destroy", this.onToolboxDestroy); 382 this._initialized = true; 383 } 384 385 _uninitialize() { 386 // devtoolsPrefBranch is set in onManifestEntry, and nullified 387 // later in onShutdown. If it isn't set, then onManifestEntry 388 // did not initialize devtools for the extension. 389 if (!this._initialized) { 390 return; 391 } 392 393 DevToolsShim.off("toolbox-ready", this.onToolboxReady); 394 DevToolsShim.off("toolbox-destroy", this.onToolboxDestroy); 395 396 // Shutdown the extension devtools_page from all existing toolboxes. 397 this.pageDefinition.shutdown(); 398 this.pageDefinition = null; 399 400 // Iterate over the existing toolboxes and unlist the devtools webextension from them. 401 for (let toolbox of DevToolsShim.getToolboxes()) { 402 toolbox.unregisterWebExtension(this.extension.uuid); 403 } 404 405 this.uninitDevToolsPref(); 406 this._initialized = false; 407 } 408 409 onShutdown() { 410 this._uninitialize(); 411 } 412 413 getAPI() { 414 return { 415 devtools: {}, 416 }; 417 } 418 419 onToolboxReady(toolbox) { 420 if ( 421 !toolbox.commands.descriptorFront.isLocalTab || 422 !this.extension.canAccessWindow( 423 toolbox.commands.descriptorFront.localTab.ownerGlobal 424 ) 425 ) { 426 // Skip any non-local (as remote tabs are not yet supported, see Bug 1304378 for additional details 427 // related to remote targets support), and private browsing windows if the extension 428 // is not allowed to access them. 429 return; 430 } 431 432 // Ensure that the WebExtension is listed in the toolbox options. 433 toolbox.registerWebExtension(this.extension.uuid, { 434 name: this.extension.name, 435 pref: `${getDevToolsPrefBranchName(this.extension.id)}.enabled`, 436 }); 437 438 // Do not build the devtools page if the extension has been disabled 439 // (e.g. based on the devtools preference). 440 if (toolbox.isWebExtensionEnabled(this.extension.uuid)) { 441 this.pageDefinition.buildForToolbox(toolbox); 442 } 443 } 444 445 onToolboxDestroy(toolbox) { 446 if (!toolbox.commands.descriptorFront.isLocalTab) { 447 // Only local tabs are currently supported (See Bug 1304378 for additional details 448 // related to remote targets support). 449 return; 450 } 451 452 this.pageDefinition.shutdownForToolbox(toolbox); 453 } 454 455 /** 456 * Initialize the DevTools preferences branch for the extension and 457 * start to observe it for changes on the "enabled" preference. 458 */ 459 initDevToolsPref() { 460 const prefBranch = Services.prefs.getBranch( 461 `${getDevToolsPrefBranchName(this.extension.id)}.` 462 ); 463 464 // Initialize the devtools extension preference if it doesn't exist yet. 465 if (prefBranch.getPrefType("enabled") === prefBranch.PREF_INVALID) { 466 prefBranch.setBoolPref("enabled", true); 467 } 468 469 this.devtoolsPrefBranch = prefBranch; 470 this.devtoolsPrefBranch.addObserver("enabled", this); 471 } 472 473 /** 474 * Stop from observing the DevTools preferences branch for the extension. 475 */ 476 uninitDevToolsPref() { 477 this.devtoolsPrefBranch.removeObserver("enabled", this); 478 this.devtoolsPrefBranch = null; 479 } 480 481 /** 482 * Test if the extension's devtools_page has been disabled using the 483 * DevTools preference. 484 * 485 * @returns {boolean} 486 * true if the devtools_page for this extension is disabled. 487 */ 488 isDevToolsPageDisabled() { 489 return !this.devtoolsPrefBranch.getBoolPref("enabled", false); 490 } 491 492 /** 493 * Observes the changed preferences on the DevTools preferences branch 494 * related to the extension. 495 * 496 * @param {nsIPrefBranch} subject The observed preferences branch. 497 * @param {string} topic The notified topic. 498 * @param {string} prefName The changed preference name. 499 */ 500 observe(subject, topic, prefName) { 501 // We are currently interested only in the "enabled" preference from the 502 // WebExtension devtools preferences branch. 503 if (subject !== this.devtoolsPrefBranch || prefName !== "enabled") { 504 return; 505 } 506 507 // Shutdown or build the devtools_page on any existing toolbox. 508 if (this.isDevToolsPageDisabled()) { 509 this.pageDefinition.shutdown(); 510 } else { 511 this.pageDefinition.build(); 512 } 513 } 514 };