target-command.js (47850B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 8 9 const BROWSERTOOLBOX_SCOPE_PREF = "devtools.browsertoolbox.scope"; 10 // Possible values of the previous pref: 11 const BROWSERTOOLBOX_SCOPE_EVERYTHING = "everything"; 12 const BROWSERTOOLBOX_SCOPE_PARENTPROCESS = "parent-process"; 13 14 const SHOW_CONTENT_SCRIPTS_PREF = "devtools.debugger.show-content-scripts"; 15 16 // eslint-disable-next-line mozilla/reject-some-requires 17 const createStore = require("resource://devtools/client/shared/redux/create-store.js"); 18 const reducer = require("resource://devtools/shared/commands/target/reducers/targets.js"); 19 20 loader.lazyRequireGetter( 21 this, 22 ["refreshTargets", "registerTarget", "unregisterTarget"], 23 "resource://devtools/shared/commands/target/actions/targets.js", 24 true 25 ); 26 27 class TargetCommand extends EventEmitter { 28 #selectedTargetFront; 29 /** 30 * This class helps managing, iterating over and listening for Targets. 31 * 32 * It exposes: 33 * - the top level target, typically the main process target for the browser toolbox 34 * or the browsing context target for a regular web toolbox 35 * - target of remoted iframe, in case Fission is enabled and some <iframe> 36 * are running in a distinct process 37 * - target switching. If the top level target changes for a new one, 38 * all the targets are going to be declared as destroyed and the new ones 39 * will be notified to the user of this API. 40 * 41 * @fires target-tread-wrong-order-on-resume : An event that is emitted when resuming 42 * the thread throws with the "wrongOrder" error. 43 * 44 * @param {DescriptorFront} descriptorFront 45 * The context to inspector identified by this descriptor. 46 * @param {WatcherFront} watcherFront 47 * If available, a reference to the related Watcher Front. 48 * @param {object} commands 49 * The commands object with all interfaces defined from devtools/shared/commands/ 50 */ 51 constructor({ descriptorFront, watcherFront, commands }) { 52 super(); 53 54 this.commands = commands; 55 this.descriptorFront = descriptorFront; 56 this.watcherFront = watcherFront; 57 this.rootFront = descriptorFront.client.mainRoot; 58 59 this.store = createStore(reducer); 60 // Name of the store used when calling createProvider. 61 this.storeId = "target-store"; 62 63 this._updateBrowserToolboxScope = 64 this._updateBrowserToolboxScope.bind(this); 65 this._updateContentScriptListening = 66 this._updateContentScriptListening.bind(this); 67 68 Services.prefs.addObserver( 69 BROWSERTOOLBOX_SCOPE_PREF, 70 this._updateBrowserToolboxScope 71 ); 72 Services.prefs.addObserver( 73 SHOW_CONTENT_SCRIPTS_PREF, 74 this._updateContentScriptListening 75 ); 76 // Until Watcher actor notify about new top level target when navigating to another process 77 // we have to manually switch to a new target from the client side 78 this.onLocalTabRemotenessChange = 79 this.onLocalTabRemotenessChange.bind(this); 80 if (this.descriptorFront.isTabDescriptor) { 81 this.descriptorFront.on( 82 "remoteness-change", 83 this.onLocalTabRemotenessChange 84 ); 85 } 86 87 if (this.isServerTargetSwitchingEnabled()) { 88 // XXX: Will only be used for local tab server side target switching if 89 // the first target is generated from the server. 90 this._onFirstTarget = new Promise(r => (this._resolveOnFirstTarget = r)); 91 } 92 93 // Reports if we have at least one listener for the given target type 94 this._listenersStarted = new Set(); 95 96 // List of all the target fronts 97 this._targets = new Set(); 98 // {Map<Function, Set<targetFront>>} A Map keyed by `onAvailable` function passed to 99 // `watchTargets`, whose initial value is a Set of the existing target fronts at the 100 // time watchTargets is called. 101 this._pendingWatchTargetInitialization = new Map(); 102 103 // Listeners for target creation, destruction and selection 104 this._createListeners = new EventEmitter(); 105 this._destroyListeners = new EventEmitter(); 106 this._selectListeners = new EventEmitter(); 107 108 this._onTargetAvailable = this._onTargetAvailable.bind(this); 109 this._onTargetDestroyed = this._onTargetDestroyed.bind(this); 110 this._onTargetSelected = this._onTargetSelected.bind(this); 111 // Bug 1675763: Watcher actor is not available in all situations yet. 112 if (this.watcherFront) { 113 this.watcherFront.on("target-available", this._onTargetAvailable); 114 this.watcherFront.on("target-destroyed", this._onTargetDestroyed); 115 } 116 117 this.legacyImplementation = {}; 118 119 // Public flag to allow listening for workers even if the fission pref is off 120 // This allows listening for workers in the content toolbox outside of fission contexts 121 // For now, this is only toggled by tests. 122 this.listenForWorkers = 123 this.rootFront.traits.workerConsoleApiMessagesDispatchedToMainThread === 124 false; 125 this.listenForServiceWorkers = false; 126 this.listenForContentScripts = false; 127 128 // Tells us if we received the first top level target. 129 // If target switching is done on: 130 // * client side, this is done from startListening => _createFirstTarget 131 // and pull from the Descriptor front. 132 // * server side, this is also done from startListening, 133 // but we wait for the watcher actor to notify us about it 134 // via target-available-form avent. 135 this._gotFirstTopLevelTarget = false; 136 this._onResourceAvailable = this._onResourceAvailable.bind(this); 137 } 138 139 get selectedTargetFront() { 140 return this.#selectedTargetFront || this.targetFront; 141 } 142 143 /** 144 * Called fired when BROWSERTOOLBOX_SCOPE_PREF pref changes. 145 * This will enable/disable the full multiprocess debugging. 146 * When enabled we will watch for content process targets and debug all the processes. 147 * When disabled we will only watch for FRAME and WORKER and restrict ourself to parent process resources. 148 */ 149 _updateBrowserToolboxScope() { 150 const browserToolboxScope = Services.prefs.getCharPref( 151 BROWSERTOOLBOX_SCOPE_PREF 152 ); 153 if (browserToolboxScope == BROWSERTOOLBOX_SCOPE_EVERYTHING) { 154 // Force listening to new additional target types 155 this.startListening(); 156 } else if (browserToolboxScope == BROWSERTOOLBOX_SCOPE_PARENTPROCESS) { 157 const disabledTargetTypes = [ 158 TargetCommand.TYPES.FRAME, 159 TargetCommand.TYPES.PROCESS, 160 ]; 161 // Force unwatching for additional targets types 162 // (we keep listening to workers) 163 // The related targets will be destroyed by the server 164 // and reported as destroyed to the frontend. 165 for (const type of disabledTargetTypes) { 166 this.stopListeningForType(type, { 167 isTargetSwitching: false, 168 isModeSwitching: true, 169 }); 170 } 171 } 172 } 173 174 /** 175 * Toggle listening to CONTENT_SCRIPT target type based on SHOW_CONTENT_SCRIPTS_PREF pref changes. 176 */ 177 _updateContentScriptListening() { 178 const showContentScripts = Services.prefs.getBoolPref( 179 SHOW_CONTENT_SCRIPTS_PREF, 180 false 181 ); 182 if (showContentScripts) { 183 // `_computeTargetTypes`, which is used by `startListening` to compute the types to listen to, 184 // will read the pref and force listening to CONTENT_SCRIPT type 185 this.startListening(); 186 } else { 187 this.stopListeningForType(TargetCommand.TYPES.CONTENT_SCRIPT, { 188 isTargetSwitching: false, 189 isModeSwitching: false, 190 }); 191 } 192 } 193 194 // Called whenever a new Target front is available. 195 // Either because a target was already available as we started calling startListening 196 // or if it has just been created 197 // eslint-disable-next-line complexity 198 async _onTargetAvailable(targetFront) { 199 // We put the `commands` on the targetFront so it can be retrieved from any front easily. 200 // Without this, protocol.js fronts won't have any easy access to it. 201 // Ideally, Fronts would all be migrated to commands and we would no longer need this hack. 202 targetFront.commands = this.commands; 203 204 // If the new target is a top level target, we are target switching. 205 // Target-switching is only triggered for "local-tab" browsing-context 206 // targets which should always have the topLevelTarget flag initialized 207 // on the server. 208 const isTargetSwitching = targetFront.isTopLevel; 209 const isFirstTarget = 210 targetFront.isTopLevel && !this._gotFirstTopLevelTarget; 211 212 if (this._targets.has(targetFront)) { 213 // The top level target front can be reported via listProcesses in the 214 // case of the BrowserToolbox. For any other target, log an error if it is 215 // already registered. 216 if (targetFront != this.targetFront) { 217 console.error( 218 "Target is already registered in the TargetCommand", 219 targetFront.actorID 220 ); 221 } 222 return; 223 } 224 225 if (this.isDestroyed() || targetFront.isDestroyedOrBeingDestroyed()) { 226 return; 227 } 228 229 // Handle top level target switching (when debugging a tab, navigation of the tab to a new document) 230 if (targetFront.isTopLevel) { 231 // First report that all existing targets are destroyed 232 if (!isFirstTarget) { 233 this._destroyExistingTargetsOnTargetSwitching(); 234 } 235 236 // Update the reference to the memoized top level target 237 this.targetFront = targetFront; 238 this.descriptorFront.setTarget(targetFront); 239 240 // When reloading a Web Extension, the fallback document, which is the top level may be notified *after* the background page. 241 // So avoid resetting it being selected on late arrival of the fallback document. 242 if (!this.descriptorFront.isWebExtensionDescriptor) { 243 this.#selectedTargetFront = null; 244 } 245 246 if (isFirstTarget && this.isServerTargetSwitchingEnabled()) { 247 this._gotFirstTopLevelTarget = true; 248 this._resolveOnFirstTarget(); 249 } 250 } 251 252 this._targets.add(targetFront); 253 try { 254 await targetFront.attachAndInitThread(this); 255 } catch (e) { 256 console.error("Error when attaching target:", e); 257 this._targets.delete(targetFront); 258 return; 259 } 260 261 for (const targetFrontsSet of this._pendingWatchTargetInitialization.values()) { 262 targetFrontsSet.delete(targetFront); 263 } 264 265 if (this.isDestroyed() || targetFront.isDestroyedOrBeingDestroyed()) { 266 return; 267 } 268 269 this.store.dispatch(registerTarget(targetFront)); 270 271 // Then, once the target is attached, notify the target front creation listeners 272 await this._createListeners.emitAsync(targetFront.targetType, { 273 targetFront, 274 isTargetSwitching, 275 }); 276 277 // Re-register the listeners as the top level target changed 278 // and some targets are fetched from it 279 if (targetFront.isTopLevel && !isFirstTarget) { 280 await this.startListening({ isTargetSwitching: true }); 281 } 282 283 // These two events are used by tests using the production codepath (i.e. disabling flags.testing) 284 // To be consumed by tests triggering frame navigations, spawning workers... 285 this.emit("processed-available-target", targetFront); 286 287 if (isTargetSwitching) { 288 this.emit("switched-target", targetFront); 289 } 290 291 const autoSelectTarget = 292 // When we navigate to a new top level document, automatically select that new document's target, 293 // so that tools can start selecting and displaying this new document. 294 isTargetSwitching || 295 // When debugging Web Extension, workaround the fallback document by automatically selected any incoming target 296 // as soon as we are currently selecting that fallback document. 297 (this.descriptorFront.isWebExtensionDescriptor && 298 this.#selectedTargetFront?.isFallbackExtensionDocument); 299 300 if (autoSelectTarget) { 301 await this.selectTarget(targetFront); 302 } 303 } 304 305 _destroyExistingTargetsOnTargetSwitching() { 306 const destroyedTargets = []; 307 for (const target of this._targets) { 308 // We only consider the top level target to be switched 309 const isDestroyedTargetSwitching = target == this.targetFront; 310 const isServiceWorker = target.targetType === this.TYPES.SERVICE_WORKER; 311 const isPopup = target.targetForm.isPopup; 312 313 // Never destroy the popup targets when the top level target is destroyed 314 // as the popup follow a different lifecycle. 315 // Also avoid destroying service worker targets for similar reason. 316 if (!isPopup && !isServiceWorker) { 317 this._onTargetDestroyed(target, { 318 isTargetSwitching: isDestroyedTargetSwitching, 319 // Do not destroy service worker front as we may want to keep using it. 320 shouldDestroyTargetFront: !isServiceWorker, 321 }); 322 destroyedTargets.push(target); 323 } 324 } 325 326 // Stop listening to legacy listeners as we now have to listen 327 // on the new target. 328 this.stopListening({ isTargetSwitching: true }); 329 330 // Remove destroyed target from the cached target list. We don't simply clear the 331 // Map as SW targets might not have been destroyed. 332 for (const target of destroyedTargets) { 333 this._targets.delete(target); 334 } 335 } 336 337 /** 338 * Function fired everytime a target is destroyed. 339 * 340 * This is called either: 341 * - via target-destroyed event fired by the WatcherFront, 342 * event which is a simple translation of the target-destroyed-form emitted by the WatcherActor. 343 * Watcher Actor emits this is various condition when the debugged target is meant to be destroyed: 344 * - the related target context is destroyed (tab closed, worker shut down, content process destroyed, ...), 345 * - when the DevToolsServerConnection used on the server side to communicate to the client is closed. 346 347 * - by TargetCommand._onTargetAvailable, when a top level target switching happens and all previously 348 * registered target fronts should be destroyed. 349 350 * - by the legacy Targets listeners, calling this method directly. 351 * This usecase is meant to be removed someday when all target targets are supported by the Watcher. 352 * (bug 1687459) 353 * 354 * @param {TargetFront} targetFront 355 * The target that just got destroyed. 356 * @param {object} options 357 * @param {boolean} [options.isTargetSwitching] 358 * To be set to true when this is about the top level target which is being replaced 359 * by a new one. 360 * The passed target should be still the one store in TargetCommand.targetFront 361 * and will be replaced via a call to onTargetAvailable with a new target front. 362 * @param {boolean} [options.isModeSwitching] 363 * To be set to true when the target was destroyed was called as the result of a 364 * change to the devtools.browsertoolbox.scope pref. 365 * @param {boolean} [options.shouldDestroyTargetFront] 366 * By default, the passed target front will be destroyed. But in some cases like 367 * legacy listeners for service workers we want to keep the front alive. 368 */ 369 _onTargetDestroyed( 370 targetFront, 371 { 372 isModeSwitching = false, 373 isTargetSwitching = false, 374 shouldDestroyTargetFront = true, 375 } = {} 376 ) { 377 // The watcher actor may notify us about the destruction of the top level target. 378 // But second argument to this method, isTargetSwitching is only passed from the frontend. 379 // So automatically toggle the isTargetSwitching flag for server side destructions 380 // only if that's about the existing top level target. 381 if (targetFront == this.targetFront) { 382 isTargetSwitching = true; 383 } 384 this._destroyListeners.emit(targetFront.targetType, { 385 targetFront, 386 isTargetSwitching, 387 isModeSwitching, 388 }); 389 this._targets.delete(targetFront); 390 391 this.store.dispatch(unregisterTarget(targetFront)); 392 393 // If the destroyed target was the selected one, we need to do some cleanup 394 if (this.#selectedTargetFront == targetFront) { 395 // If we're doing a targetSwitch, simply nullify #selectedTargetFront 396 if (isTargetSwitching) { 397 this.#selectedTargetFront = null; 398 } else { 399 // Otherwise we want to select the top level target 400 let fallbackTarget = this.targetFront; 401 402 // When debugging Web Extension we don't want to immediately fallback to the top level target, which is the fallback document. 403 // Instead, try to lookup for the background page. 404 if (this.descriptorFront.isWebExtensionDescriptor) { 405 const backgroundPageTargetFront = [...this._targets].find( 406 target => !target.isFallbackExtensionDocument 407 ); 408 if (backgroundPageTargetFront) { 409 fallbackTarget = backgroundPageTargetFront; 410 } 411 } 412 413 this.selectTarget(fallbackTarget); 414 } 415 } 416 417 if (shouldDestroyTargetFront) { 418 // When calling targetFront.destroy(), we will first call TargetFrontMixin.destroy, 419 // which will try to call `detach` RDP method. 420 // Unfortunately, this request will never complete in some cases like bfcache navigations. 421 // Because of that, the target front will never be completely destroy as it will prevent 422 // calling super.destroy and Front.destroy. 423 // Workaround that by manually calling Front class destroy method: 424 targetFront.baseFrontClassDestroy(); 425 426 targetFront.destroy(); 427 428 // Delete the attribute we set from _onTargetAvailable so that we avoid leaking commands 429 // if any target front is leaked. 430 delete targetFront.commands; 431 } 432 } 433 434 /** 435 * 436 * @param {TargetFront} targetFront 437 */ 438 async _onTargetSelected(targetFront) { 439 if (this.#selectedTargetFront == targetFront) { 440 // Target is already selected, we can bail out. 441 return; 442 } 443 444 this.#selectedTargetFront = targetFront; 445 await this._selectListeners.emitAsync(targetFront.targetType, { 446 targetFront, 447 }); 448 } 449 450 _setListening(type, value) { 451 if (value) { 452 this._listenersStarted.add(type); 453 } else { 454 this._listenersStarted.delete(type); 455 } 456 } 457 458 _isListening(type) { 459 return this._listenersStarted.has(type); 460 } 461 462 /** 463 * Check if the watcher is currently supported. 464 * 465 * When no typeOrTrait is provided, we will only check that the watcher is 466 * available. 467 * 468 * When a typeOrTrait is provided, we will check for an explicit trait on the 469 * watcherFront that indicates either that: 470 * - a target type is supported 471 * - or that a custom trait is true 472 * 473 * @param {string} [targetTypeOrTrait] 474 * Optional target type or trait. 475 * @return {boolean} true if the watcher is available and supports the 476 * optional targetTypeOrTrait 477 */ 478 hasTargetWatcherSupport(targetTypeOrTrait) { 479 if (targetTypeOrTrait) { 480 // Target types are also exposed as traits, where resource types are 481 // exposed under traits.resources (cf hasResourceWatcherSupport 482 // implementation). 483 return !!this.watcherFront?.traits[targetTypeOrTrait]; 484 } 485 486 return !!this.watcherFront; 487 } 488 489 /** 490 * Start listening for targets from the server 491 * 492 * Interact with the actors in order to start listening for new types of targets. 493 * This will fire the _onTargetAvailable function for all already-existing targets, 494 * as well as the next one to be created. It will also call _onTargetDestroyed 495 * everytime a target is reported as destroyed by the actors. 496 * By the time this function resolves, all the already-existing targets will be 497 * reported to _onTargetAvailable. 498 * 499 * @param Object options 500 * @param Boolean options.isTargetSwitching 501 * Set to true when this is called while a target switching happens. In such case, 502 * we won't register listener set on the Watcher Actor, but still register listeners 503 * set via Legacy Listeners. 504 */ 505 async startListening({ isTargetSwitching = false } = {}) { 506 // The first time we call this method, we pull the current top level target from the descriptor 507 if ( 508 !this.isServerTargetSwitchingEnabled() && 509 !this._gotFirstTopLevelTarget 510 ) { 511 await this._createFirstTarget(); 512 } 513 514 // If no pref are set to true, nor is listenForWorkers set to true, 515 // we won't listen for any additional target. Only the top level target 516 // will be managed. We may still do target-switching. 517 const types = this._computeTargetTypes(); 518 519 for (const type of types) { 520 if (this._isListening(type)) { 521 continue; 522 } 523 this._setListening(type, true); 524 525 // Only a few top level targets support the watcher actor at the moment (see WatcherActor 526 // traits in the _form method). Bug 1675763 tracks watcher actor support for all targets. 527 if (this.hasTargetWatcherSupport(type)) { 528 // When we switch to a new top level target, we don't have to stop and restart 529 // Watcher listener as it is independant from the top level target. 530 // This isn't the case for some Legacy Listeners, which fetch targets from the top level target 531 if (!isTargetSwitching) { 532 await this.watcherFront.watchTargets(type); 533 } 534 } else if (LegacyTargetWatchers[type]) { 535 // Instantiate the legacy listener only once for each TargetCommand, and reuse it if we stop and restart listening 536 if (!this.legacyImplementation[type]) { 537 this.legacyImplementation[type] = new LegacyTargetWatchers[type]( 538 this, 539 this._onTargetAvailable, 540 this._onTargetDestroyed, 541 this.commands 542 ); 543 } 544 await this.legacyImplementation[type].listen(); 545 } else { 546 throw new Error(`Unsupported target type '${type}'`); 547 } 548 } 549 550 if (!this._watchingDocumentEvent && !this.isDestroyed()) { 551 // We want to watch DOCUMENT_EVENT in order to update the url and title of target fronts, 552 // as the initial value that is set in them might be erroneous (if the target was 553 // created so early that the document url is still pointing to about:blank and the 554 // html hasn't be parsed yet, so we can't know the <title> content). 555 556 this._watchingDocumentEvent = true; 557 await this.commands.resourceCommand.watchResources( 558 [this.commands.resourceCommand.TYPES.DOCUMENT_EVENT], 559 { 560 onAvailable: this._onResourceAvailable, 561 } 562 ); 563 } 564 565 if (this.isServerTargetSwitchingEnabled()) { 566 await this._onFirstTarget; 567 } 568 } 569 570 async _createFirstTarget() { 571 // Note that this is a public attribute, used outside of this class 572 // and helps knowing what is the current top level target we debug. 573 this.targetFront = await this.descriptorFront.getTarget(); 574 this.targetFront.setIsTopLevel(true); 575 this._gotFirstTopLevelTarget = true; 576 577 // See _onTargetAvailable. As this target isn't going through that method 578 // we have to replicate doing that here. 579 this.targetFront.commands = this.commands; 580 581 // Add the top-level target to the list of targets. 582 this._targets.add(this.targetFront); 583 this.store.dispatch(registerTarget(this.targetFront)); 584 } 585 586 _computeTargetTypes() { 587 let types = []; 588 589 // We also check for watcher support as some xpcshell tests uses legacy APIs and don't support frames. 590 if ( 591 this.descriptorFront.isTabDescriptor && 592 this.hasTargetWatcherSupport(TargetCommand.TYPES.FRAME) 593 ) { 594 types = [TargetCommand.TYPES.FRAME]; 595 const showContentScripts = Services.prefs.getBoolPref( 596 SHOW_CONTENT_SCRIPTS_PREF, 597 false 598 ); 599 if ( 600 showContentScripts && 601 this.hasTargetWatcherSupport(TargetCommand.TYPES.CONTENT_SCRIPT) 602 ) { 603 types.push(TargetCommand.TYPES.CONTENT_SCRIPT); 604 } 605 } else if ( 606 this.descriptorFront.isWebExtensionDescriptor && 607 this.descriptorFront.isServerTargetSwitchingEnabled() 608 ) { 609 types = [TargetCommand.TYPES.FRAME]; 610 } else if (this.descriptorFront.isBrowserProcessDescriptor) { 611 const browserToolboxScope = Services.prefs.getCharPref( 612 BROWSERTOOLBOX_SCOPE_PREF 613 ); 614 if (browserToolboxScope == BROWSERTOOLBOX_SCOPE_EVERYTHING) { 615 // Listen for all target types when the browser toolbox is in multiprocess mode. 616 // 617 // Except for CONTENT_SCRIPT targets, as their scripts are already debuggable 618 // via the content process targets. 619 if (!this.listenForContentScripts) { 620 types = TargetCommand.ALL_TYPES.filter( 621 t => t != TargetCommand.TYPES.CONTENT_SCRIPT 622 ); 623 } else { 624 types = TargetCommand.ALL_TYPES; 625 } 626 } 627 } 628 if (this.listenForWorkers && !types.includes(TargetCommand.TYPES.WORKER)) { 629 types.push(TargetCommand.TYPES.WORKER); 630 } 631 632 // Bug 1607778 - For now, shared workers are only displayed in the Browser Toolbox. 633 // The server doesn't expose them yet. See `getWatcherSupportedTargets()` and 634 // `WorkerTargetWatcherClass.shouldHandleWorker`. 635 if ( 636 this.listenForWorkers && 637 this.descriptorFront.isBrowserProcessDescriptor && 638 !types.includes(TargetCommand.TYPES.SHARED_WORKER) 639 ) { 640 types.push(TargetCommand.TYPES.SHARED_WORKER); 641 } 642 643 if ( 644 this.listenForServiceWorkers && 645 !types.includes(TargetCommand.TYPES.SERVICE_WORKER) 646 ) { 647 types.push(TargetCommand.TYPES.SERVICE_WORKER); 648 } 649 650 return types; 651 } 652 653 /** 654 * Stop listening for targets from the server 655 * 656 * @param Object options 657 * @param Boolean options.isTargetSwitching 658 * Set to true when this is called while a target switching happens. In such case, 659 * we won't unregister listener set on the Watcher Actor, but still unregister 660 * listeners set via Legacy Listeners. 661 */ 662 stopListening({ isTargetSwitching = false } = {}) { 663 // As DOCUMENT_EVENT isn't using legacy listener, 664 // there is no need to stop and restart it in case of target switching. 665 if (this._watchingDocumentEvent && !isTargetSwitching) { 666 this.commands.resourceCommand.unwatchResources( 667 [this.commands.resourceCommand.TYPES.DOCUMENT_EVENT], 668 { 669 onAvailable: this._onResourceAvailable, 670 } 671 ); 672 this._watchingDocumentEvent = false; 673 } 674 675 for (const type of TargetCommand.ALL_TYPES) { 676 this.stopListeningForType(type, { isTargetSwitching }); 677 } 678 } 679 680 /** 681 * Stop listening for targets of a given type from the server 682 * 683 * @param String type 684 * target type we want to stop listening for 685 * @param Object options 686 * @param Boolean options.isTargetSwitching 687 * Set to true when this is called while a target switching happens. In such case, 688 * we won't unregister listener set on the Watcher Actor, but still unregister 689 * listeners set via Legacy Listeners. 690 * @param Boolean options.isModeSwitching 691 * Set to true when this is called as the result of a change to the 692 * devtools.browsertoolbox.scope pref. 693 */ 694 stopListeningForType(type, { isTargetSwitching, isModeSwitching }) { 695 if (!this._isListening(type)) { 696 return; 697 } 698 this._setListening(type, false); 699 700 // Only a few top level targets support the watcher actor at the moment (see WatcherActor 701 // traits in the _form method). Bug 1675763 tracks watcher actor support for all targets. 702 if (this.hasTargetWatcherSupport(type)) { 703 // When we switch to a new top level target, we don't have to stop and restart 704 // Watcher listener as it is independant from the top level target. 705 // This isn't the case for some Legacy Listeners, which fetch targets from the top level target 706 // Also, TargetCommand.destroy may be called after the client is closed. 707 // So avoid calling the RDP method in that situation. 708 if (!isTargetSwitching && !this.watcherFront.isDestroyed()) { 709 this.watcherFront.unwatchTargets(type, { isModeSwitching }); 710 } 711 } else if (this.legacyImplementation[type]) { 712 this.legacyImplementation[type].unlisten({ 713 isTargetSwitching, 714 isModeSwitching, 715 }); 716 } else { 717 throw new Error(`Unsupported target type '${type}'`); 718 } 719 } 720 721 _matchTargetType(type, target) { 722 return type === target.targetType; 723 } 724 725 _onResourceAvailable(resources) { 726 for (const resource of resources) { 727 if ( 728 resource.resourceType === 729 this.commands.resourceCommand.TYPES.DOCUMENT_EVENT 730 ) { 731 const { targetFront } = resource; 732 if (resource.title !== undefined && targetFront?.setTitle) { 733 targetFront.setTitle(resource.title); 734 } 735 if (resource.url !== undefined && targetFront?.setUrl) { 736 targetFront.setUrl(resource.url); 737 } 738 if ( 739 !resource.isFrameSwitching && 740 // `url` is set on the targetFront when we receive dom-loading, and `title` when 741 // `dom-interactive` is received. Here we're only updating the window title in 742 // the "newer" event. 743 resource.name === "dom-interactive" 744 ) { 745 // We just updated the targetFront title and url, force a refresh 746 // so that the EvaluationContext selector update them. 747 this.store.dispatch(refreshTargets()); 748 } 749 } 750 } 751 } 752 753 /** 754 * Listen for the creation and/or destruction of target fronts matching one of the provided types. 755 * 756 * @param {object} options 757 * @param {Array<string>} options.types 758 * The type of target to listen for. Constant of TargetCommand.TYPES. 759 * @param {Function} options.onAvailable 760 * Mandatory callback fired when a target has been just created or was already available. 761 * The function is called with a single object argument containing the following properties: 762 * - {TargetFront} targetFront: The target Front 763 * - {Boolean} isTargetSwitching: Is this target relates to a navigation and 764 * this replaced a previously available target, this flag will be true 765 * @param {Function} options.onDestroyed 766 * Optional callback fired in case of target front destruction. 767 * The function is called with the same arguments than onAvailable. 768 * @param {Function} options.onSelected 769 * Optional callback fired for any new top level target (on startup and navigations), 770 * as well as when the user select a new target from the console context selector, 771 * debugger threads list and toolbox iframe dropdown. 772 * The function is called with a single object argument containing the following properties: 773 * - {TargetFront} targetFront: The target Front 774 */ 775 async watchTargets(options = {}) { 776 const availableOptions = [ 777 "types", 778 "onAvailable", 779 "onDestroyed", 780 "onSelected", 781 ]; 782 const unsupportedKeys = Object.keys(options).filter( 783 key => !availableOptions.includes(key) 784 ); 785 if (unsupportedKeys.length) { 786 throw new Error( 787 `TargetCommand.watchTargets does not expect the following options: ${unsupportedKeys.join( 788 ", " 789 )}` 790 ); 791 } 792 793 const { types, onAvailable, onDestroyed, onSelected } = options; 794 if (typeof onAvailable != "function") { 795 throw new Error( 796 "TargetCommand.watchTargets expects a function for the onAvailable option" 797 ); 798 } 799 800 for (const type of types) { 801 if (!this._isValidTargetType(type)) { 802 throw new Error( 803 `TargetCommand.watchTargets invoked with an unknown type: "${type}"` 804 ); 805 } 806 } 807 808 // Notify about already existing target of these types 809 const targetFronts = [...this._targets].filter(targetFront => 810 types.includes(targetFront.targetType) 811 ); 812 this._pendingWatchTargetInitialization.set( 813 onAvailable, 814 new Set(targetFronts) 815 ); 816 const promises = targetFronts.map(async targetFront => { 817 // Attach the targets that aren't attached yet (e.g. the initial top-level target), 818 // and wait for the other ones to be fully attached. 819 try { 820 await targetFront.attachAndInitThread(this); 821 } catch (e) { 822 console.error("Error when attaching target:", e); 823 return; 824 } 825 826 // It can happen that onAvailable was already called with this targetFront at 827 // this time (via _onTargetAvailable). If that's the case, we don't want to call 828 // onAvailable a second time. 829 if ( 830 this._pendingWatchTargetInitialization && 831 this._pendingWatchTargetInitialization.has(onAvailable) && 832 !this._pendingWatchTargetInitialization 833 .get(onAvailable) 834 .has(targetFront) 835 ) { 836 return; 837 } 838 839 try { 840 // Ensure waiting for eventual async create listeners 841 // which may setup things regarding the existing targets 842 // and listen callsite may care about the full initialization 843 await onAvailable({ 844 targetFront, 845 isTargetSwitching: false, 846 }); 847 } catch (e) { 848 // Prevent throwing when onAvailable handler throws on one target 849 // so that it can try to register the other targets 850 console.error( 851 "Exception when calling onAvailable handler", 852 e.message, 853 e 854 ); 855 } 856 }); 857 858 for (const type of types) { 859 this._createListeners.on(type, onAvailable); 860 if (onDestroyed) { 861 this._destroyListeners.on(type, onDestroyed); 862 } 863 if (onSelected) { 864 this._selectListeners.on(type, onSelected); 865 } 866 } 867 868 await Promise.all(promises); 869 this._pendingWatchTargetInitialization.delete(onAvailable); 870 871 try { 872 if ( 873 onSelected && 874 this.selectedTargetFront && 875 types.includes(this.selectedTargetFront.targetType) 876 ) { 877 await onSelected({ 878 targetFront: this.selectedTargetFront, 879 }); 880 } 881 } catch (e) { 882 // Prevent throwing when onSelected handler throws on one target 883 // (this may make test to fail when closing the toolbox quickly after opening) 884 console.error("Exception when calling onSelected handler", e.message, e); 885 } 886 } 887 888 /** 889 * Stop listening for the creation and/or destruction of a given type of target fronts. 890 * See `watchTargets()` for documentation of the arguments. 891 */ 892 unwatchTargets(options = {}) { 893 const availableOptions = [ 894 "types", 895 "onAvailable", 896 "onDestroyed", 897 "onSelected", 898 ]; 899 const unsupportedKeys = Object.keys(options).filter( 900 key => !availableOptions.includes(key) 901 ); 902 if (unsupportedKeys.length) { 903 throw new Error( 904 `TargetCommand.unwatchTargets does not expect the following options: ${unsupportedKeys.join( 905 ", " 906 )}` 907 ); 908 } 909 910 const { types, onAvailable, onDestroyed, onSelected } = options; 911 if (typeof onAvailable != "function") { 912 throw new Error( 913 "TargetCommand.unwatchTargets expects a function for the onAvailable option" 914 ); 915 } 916 917 for (const type of types) { 918 if (!this._isValidTargetType(type)) { 919 throw new Error( 920 `TargetCommand.unwatchTargets invoked with an unknown type: "${type}"` 921 ); 922 } 923 924 this._createListeners.off(type, onAvailable); 925 if (onDestroyed) { 926 this._destroyListeners.off(type, onDestroyed); 927 } 928 if (onSelected) { 929 this._selectListeners.off(type, onSelected); 930 } 931 } 932 this._pendingWatchTargetInitialization.delete(onAvailable); 933 } 934 935 /** 936 * Retrieve all the current target fronts of a given type. 937 * 938 * @param {Array<string>} types 939 * The types of target to retrieve. Array of TargetCommand.TYPES 940 * @return {Array<TargetFront>} Array of target fronts matching any of the 941 * provided types. 942 */ 943 getAllTargets(types) { 944 if (!types?.length) { 945 throw new Error("getAllTargets expects a non-empty array of types"); 946 } 947 948 const targets = [...this._targets].filter(target => 949 types.some(type => this._matchTargetType(type, target)) 950 ); 951 952 return targets; 953 } 954 955 /** 956 * Retrieve all the target fronts in the selected target tree (including the selected 957 * target itself). 958 * 959 * @param {Array<string>} types 960 * The types of target to retrieve. Array of TargetCommand.TYPES 961 * @return {Promise<Array<TargetFront>>} Promise that resolves to an array of target fronts. 962 */ 963 async getAllTargetsInSelectedTargetTree(types) { 964 const allTargets = this.getAllTargets(types); 965 if (this.isTopLevelTargetSelected()) { 966 return allTargets; 967 } 968 969 const targets = [this.selectedTargetFront]; 970 for (const target of allTargets) { 971 const isInSelectedTree = await target.isTargetAnAncestor( 972 this.selectedTargetFront 973 ); 974 975 if (isInSelectedTree) { 976 targets.push(target); 977 } 978 } 979 return targets; 980 } 981 982 /** 983 * For all the target fronts of given types, retrieve all the target-scoped fronts of the given types. 984 * 985 * @param {Array<string>} targetTypes 986 * The types of target to iterate over. Constant of TargetCommand.TYPES. 987 * @param {string} frontType 988 * The type of target-scoped front to retrieve. It can be "inspector", "console", "thread",... 989 * @param {object} options 990 * @param {boolean} options.onlyInSelectedTargetTree 991 * Set to true to only get the fronts for targets who are in the "targets tree" 992 * of the selected target. 993 */ 994 async getAllFronts( 995 targetTypes, 996 frontType, 997 { onlyInSelectedTargetTree = false } = {} 998 ) { 999 if (!Array.isArray(targetTypes) || !targetTypes?.length) { 1000 throw new Error("getAllFronts expects a non-empty array of target types"); 1001 } 1002 const promises = []; 1003 const targets = !onlyInSelectedTargetTree 1004 ? this.getAllTargets(targetTypes) 1005 : await this.getAllTargetsInSelectedTargetTree(targetTypes); 1006 for (const target of targets) { 1007 // For still-attaching worker targets, the thread or console front may not yet be available, 1008 // whereas TargetMixin.getFront will throw if the actorID isn't available in targetForm. 1009 // Also ignore destroyed targets. For some reason the previous methods fetching targets 1010 // can sometime return destroyed targets. 1011 if ( 1012 (frontType == "thread" && !target.targetForm.threadActor) || 1013 (frontType == "console" && !target.targetForm.consoleActor) || 1014 target.isDestroyed() 1015 ) { 1016 continue; 1017 } 1018 1019 promises.push(target.getFront(frontType)); 1020 } 1021 return Promise.all(promises); 1022 } 1023 1024 /** 1025 * This function is triggered by an event sent by the TabDescriptor when 1026 * the tab navigates to a distinct process. 1027 * 1028 * @param TargetFront targetFront 1029 * The WindowGlobalTargetFront instance that navigated to another process 1030 */ 1031 async onLocalTabRemotenessChange(targetFront) { 1032 if (this.isServerTargetSwitchingEnabled()) { 1033 // For server-side target switching, everything will be handled by the 1034 // _onTargetAvailable callback. 1035 return; 1036 } 1037 1038 // TabDescriptor may emit the event with a null targetFront, interpret that as if the previous target 1039 // has already been destroyed 1040 if (targetFront) { 1041 // Wait for the target to be destroyed so that LocalTabCommandsFactory clears its memoized target for this tab 1042 await targetFront.once("target-destroyed"); 1043 } 1044 1045 // Fetch the new target from the descriptor. 1046 const newTarget = await this.descriptorFront.getTarget(); 1047 1048 // If a navigation happens while we try to get the target for the page that triggered 1049 // the remoteness change, `getTarget` will return null. In such case, we'll get the 1050 // "next" target through onTargetAvailable so it's safe to bail here. 1051 if (!newTarget) { 1052 console.warn( 1053 `Couldn't get the target for descriptor ${this.descriptorFront.actorID}` 1054 ); 1055 return; 1056 } 1057 1058 this.switchToTarget(newTarget); 1059 } 1060 1061 /** 1062 * Reload the current top level target. 1063 * This only works for targets inheriting from WindowGlobalTarget. 1064 * 1065 * @param {boolean} bypassCache 1066 * If true, the reload will be forced to bypass any cache. 1067 */ 1068 async reloadTopLevelTarget(bypassCache = false) { 1069 if (!this.descriptorFront.traits.supportsReloadDescriptor) { 1070 throw new Error("The top level target doesn't support being reloaded"); 1071 } 1072 1073 // Wait for the next DOCUMENT_EVENT's dom-complete event 1074 // Wait for waitForNextResource completion before reloading, otherwise we might miss the dom-complete event. 1075 // This can happen if `ResourceCommand.watchResources` made by `waitForNextResource` is still pending 1076 // while the reload already started and finished loading the document early. 1077 const { onResource: onReloaded } = 1078 await this.commands.resourceCommand.waitForNextResource( 1079 this.commands.resourceCommand.TYPES.DOCUMENT_EVENT, 1080 { 1081 ignoreExistingResources: true, 1082 predicate(resource) { 1083 return resource.name == "dom-complete"; 1084 }, 1085 } 1086 ); 1087 1088 await this.descriptorFront.reloadDescriptor({ bypassCache }); 1089 1090 await onReloaded; 1091 } 1092 1093 /** 1094 * Navigate the top level document to a new URL. 1095 * 1096 * @param {string} url 1097 * @param {boolean} waitForLoad 1098 * Default to true and wait for the document to be fully loaded before resolving. 1099 * @return Promise 1100 * Promise resolved once the navigation has been proceeded by the remote runtime, 1101 * and if waitForLoad is true, resolved only once the target url is fully loaded. 1102 */ 1103 navigateTo(url, waitForLoad = true) { 1104 if (!this.descriptorFront.traits.supportsNavigation) { 1105 throw new Error("Descriptor doesn't support navigation"); 1106 } 1107 1108 return this.descriptorFront.navigateTo(url, waitForLoad); 1109 } 1110 1111 goBack() { 1112 if (!this.descriptorFront.traits.supportsNavigation) { 1113 throw new Error("Descriptor doesn't support navigation"); 1114 } 1115 1116 return this.descriptorFront.goBack(); 1117 } 1118 1119 goForward() { 1120 if (!this.descriptorFront.traits.supportsNavigation) { 1121 throw new Error("Descriptor doesn't support navigation"); 1122 } 1123 return this.descriptorFront.goForward(); 1124 } 1125 1126 /** 1127 * Called when the top level target is replaced by a new one. 1128 * Typically when we navigate to another domain which requires to be loaded in a distinct process. 1129 * 1130 * @param {TargetFront} newTarget 1131 * The new top level target to debug. 1132 */ 1133 async switchToTarget(newTarget) { 1134 // Notify about this new target to creation listeners 1135 // _onTargetAvailable will also destroy all previous target before notifying about this new one. 1136 await this._onTargetAvailable(newTarget); 1137 } 1138 1139 /** 1140 * Called when the user selects a frame in the iframe picker. 1141 * 1142 * @param {WindowGlobalTargetFront} targetFront 1143 * The target front we want the toolbox to focus on. 1144 */ 1145 async selectTarget(targetFront) { 1146 // Ignore any target which we may try to select, but is already being destroyed 1147 if (targetFront.isDestroyedOrBeingDestroyed()) { 1148 return; 1149 } 1150 await this._onTargetSelected(targetFront); 1151 } 1152 1153 /** 1154 * Returns true if the top-level frame is the selected one 1155 * 1156 * @returns {boolean} 1157 */ 1158 isTopLevelTargetSelected() { 1159 return this.selectedTargetFront === this.targetFront; 1160 } 1161 1162 /** 1163 * Returns true if a non top-level frame is the selected one in the iframe picker. 1164 * 1165 * @returns {boolean} 1166 */ 1167 isNonTopLevelTargetSelected() { 1168 return this.selectedTargetFront !== this.targetFront; 1169 } 1170 1171 isTargetRegistered(targetFront) { 1172 return this._targets.has(targetFront); 1173 } 1174 1175 getParentTarget(targetFront) { 1176 // Note that there are edgecases: 1177 // * Until bug 1741927 is fixed and we remove non-EFT codepath entirely, 1178 // we may receive a `parentInnerWindowId` that doesn't relate to any target. 1179 // This happens when the parent document of the targetFront is a document loaded in the 1180 // same process as its parent document. In such scenario, and only when EFT is disabled, 1181 // we won't instantiate a target for the parent document of the targetFront. 1182 // * `parentInnerWindowId` could be null in some case like for tabs in the MBT 1183 // we should report the top level target as parent. That's what `getParentWindowGlobalTarget` does. 1184 // Once we can stop using getParentWindowGlobalTarget for the other edgecase we will be able to 1185 // replace it with such fallback: `return this.targetFront;`. 1186 // browser_target_command_frames.js will help you get things right. 1187 const { parentInnerWindowId } = targetFront.targetForm; 1188 if (parentInnerWindowId) { 1189 const targets = this.getAllTargets([TargetCommand.TYPES.FRAME]); 1190 const parent = targets.find( 1191 target => target.innerWindowId == parentInnerWindowId 1192 ); 1193 // Until EFT is the only codepath supported (bug 1741927), we will fallback to `getParentWindowGlobalTarget` 1194 // as we may not have a target if the parent is an iframe running in the same process as its parent. 1195 if (parent) { 1196 return parent; 1197 } 1198 } 1199 1200 // Note that all callsites which care about FRAME additional target 1201 // should all have a toolbox using the watcher actor. 1202 // It should be: MBT, regular tab toolbox and web extension. 1203 // The others which still don't support watcher don't spawn FRAME targets: 1204 // browser content toolbox and service workers. 1205 1206 return this.watcherFront.getParentWindowGlobalTarget( 1207 targetFront.browsingContextID 1208 ); 1209 } 1210 1211 isDestroyed() { 1212 return this._isDestroyed; 1213 } 1214 1215 isServerTargetSwitchingEnabled() { 1216 if (this.descriptorFront.isServerTargetSwitchingEnabled) { 1217 return this.descriptorFront.isServerTargetSwitchingEnabled(); 1218 } 1219 return false; 1220 } 1221 1222 _isValidTargetType(type) { 1223 return this.ALL_TYPES.includes(type); 1224 } 1225 1226 destroy() { 1227 this.stopListening(); 1228 this._createListeners.off(); 1229 this._destroyListeners.off(); 1230 this._selectListeners.off(); 1231 1232 this.#selectedTargetFront = null; 1233 this._isDestroyed = true; 1234 1235 Services.prefs.removeObserver( 1236 BROWSERTOOLBOX_SCOPE_PREF, 1237 this._updateBrowserToolboxScope 1238 ); 1239 Services.prefs.removeObserver( 1240 SHOW_CONTENT_SCRIPTS_PREF, 1241 this._updateContentScriptListening 1242 ); 1243 } 1244 } 1245 1246 /** 1247 * All types of target: 1248 */ 1249 TargetCommand.TYPES = TargetCommand.prototype.TYPES = { 1250 PROCESS: "process", 1251 FRAME: "frame", 1252 WORKER: "worker", 1253 SHARED_WORKER: "shared_worker", 1254 SERVICE_WORKER: "service_worker", 1255 CONTENT_SCRIPT: "content_script", 1256 }; 1257 TargetCommand.ALL_TYPES = TargetCommand.prototype.ALL_TYPES = Object.values( 1258 TargetCommand.TYPES 1259 ); 1260 1261 const LegacyTargetWatchers = {}; 1262 loader.lazyRequireGetter( 1263 LegacyTargetWatchers, 1264 TargetCommand.TYPES.PROCESS, 1265 "resource://devtools/shared/commands/target/legacy-target-watchers/legacy-processes-watcher.js" 1266 ); 1267 loader.lazyRequireGetter( 1268 LegacyTargetWatchers, 1269 TargetCommand.TYPES.WORKER, 1270 "resource://devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js" 1271 ); 1272 loader.lazyRequireGetter( 1273 LegacyTargetWatchers, 1274 TargetCommand.TYPES.SHARED_WORKER, 1275 "resource://devtools/shared/commands/target/legacy-target-watchers/legacy-sharedworkers-watcher.js" 1276 ); 1277 loader.lazyRequireGetter( 1278 LegacyTargetWatchers, 1279 TargetCommand.TYPES.SERVICE_WORKER, 1280 "resource://devtools/shared/commands/target/legacy-target-watchers/legacy-serviceworkers-watcher.js" 1281 ); 1282 1283 module.exports = TargetCommand;