toolbox-host-manager.js (12967B)
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 { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); 8 const L10N = new LocalizationHelper( 9 "devtools/client/locales/toolbox.properties" 10 ); 11 const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); 12 const { DOMHelpers } = require("resource://devtools/shared/dom-helpers.js"); 13 14 // The min-width of toolbox and browser toolbox. 15 const WIDTH_CHEVRON_AND_MEATBALL = 50; 16 const WIDTH_CHEVRON_AND_MEATBALL_AND_CLOSE = 74; 17 const ZOOM_VALUE_PREF = "devtools.toolbox.zoomValue"; 18 19 loader.lazyRequireGetter( 20 this, 21 "Toolbox", 22 "resource://devtools/client/framework/toolbox.js", 23 true 24 ); 25 loader.lazyRequireGetter( 26 this, 27 "Hosts", 28 "resource://devtools/client/framework/toolbox-hosts.js", 29 true 30 ); 31 32 /** 33 * Implement a wrapper on the chrome side to setup a Toolbox within Firefox UI. 34 * 35 * This component handles iframe creation within Firefox, in which we are loading 36 * the toolbox document. Then both the chrome and the toolbox document communicate 37 * via "message" events. 38 * 39 * Messages sent by the toolbox to the chrome: 40 * - switch-host: 41 * Order to display the toolbox in another host (side, bottom, window, or the 42 * previously used one) 43 * - raise-host: 44 * Focus the tools 45 * - set-host-title: 46 * When using the window host, update the window title 47 * 48 * Messages sent by the chrome to the toolbox: 49 * - switched-host: 50 * The `switch-host` command sent by the toolbox is done 51 */ 52 53 const LAST_HOST = "devtools.toolbox.host"; 54 const PREVIOUS_HOST = "devtools.toolbox.previousHost"; 55 let ID_COUNTER = 1; 56 57 class ToolboxHostManager { 58 constructor(commands, hostType, hostOptions) { 59 this.commands = commands; 60 61 // When debugging a local tab, we keep a reference of the current tab into which the toolbox is displayed. 62 // This will only change from the descriptor's localTab when we start debugging popups (i.e. window.open). 63 this.currentTab = this.commands.descriptorFront.localTab; 64 65 // Keep the previously instantiated Host for all tabs where we displayed the Toolbox. 66 // This will only be useful when we start debugging popups (i.e. window.open). 67 // This is used to re-use the previous host instance when we re-select the original tab 68 // we were debugging before the popup opened. 69 this.hostPerTab = new Map(); 70 71 this.frameId = ID_COUNTER++; 72 73 if (!hostType) { 74 hostType = Services.prefs.getCharPref(LAST_HOST); 75 if (!Hosts[hostType]) { 76 // If the preference value is unexpected, restore to the default value. 77 Services.prefs.clearUserPref(LAST_HOST); 78 hostType = Services.prefs.getCharPref(LAST_HOST); 79 } 80 } 81 this.eventController = new AbortController(); 82 this.host = this.createHost(hostType, hostOptions); 83 this.hostType = hostType; 84 // List of event which are collected when a new host is created for a popup 85 // from `switchHostToTab` method. 86 this.collectPendingMessages = null; 87 this.setMinWidthWithZoom = this.setMinWidthWithZoom.bind(this); 88 this._onMessage = this._onMessage.bind(this); 89 Services.prefs.addObserver(ZOOM_VALUE_PREF, this.setMinWidthWithZoom); 90 } 91 /** 92 * Create a Toolbox 93 * 94 * @param {string} toolId 95 * The id of the tool to show 96 * @param {object} toolOptions 97 * Options that will be passed to the tool init function 98 * @returns {Toolbox} 99 */ 100 async create(toolId, toolOptions) { 101 await this.host.create(); 102 if (this.currentTab) { 103 this.hostPerTab.set(this.currentTab, this.host); 104 } 105 106 this.host.frame.setAttribute("aria-label", L10N.getStr("toolbox.label")); 107 this.host.frame.ownerDocument.defaultView.addEventListener( 108 "message", 109 this._onMessage, 110 { signal: this.eventController.signal } 111 ); 112 113 const toolbox = new Toolbox({ 114 commands: this.commands, 115 selectedTool: toolId, 116 selectedToolOptions: toolOptions, 117 hostType: this.host.type, 118 contentWindow: this.host.frame.contentWindow, 119 frameId: this.frameId, 120 }); 121 toolbox.once("destroyed", this._onToolboxDestroyed.bind(this)); 122 123 // Prevent reloading the toolbox when loading the tools in a tab 124 // (e.g. from about:debugging) 125 const location = this.host.frame.contentWindow.location; 126 if (!location.href.startsWith("about:devtools-toolbox")) { 127 this.host.frame.setAttribute("src", "about:devtools-toolbox"); 128 } 129 130 this.setMinWidthWithZoom(); 131 return toolbox; 132 } 133 134 setMinWidthWithZoom() { 135 const zoomValue = parseFloat(Services.prefs.getCharPref(ZOOM_VALUE_PREF)); 136 137 if (isNaN(zoomValue)) { 138 return; 139 } 140 141 if ( 142 this.hostType === Toolbox.HostType.LEFT || 143 this.hostType === Toolbox.HostType.RIGHT 144 ) { 145 this.host.frame.style.minWidth = 146 WIDTH_CHEVRON_AND_MEATBALL_AND_CLOSE * zoomValue + "px"; 147 } else if ( 148 this.hostType === Toolbox.HostType.WINDOW || 149 this.hostType === Toolbox.HostType.PAGE || 150 this.hostType === Toolbox.HostType.BROWSERTOOLBOX 151 ) { 152 this.host.frame.style.minWidth = 153 WIDTH_CHEVRON_AND_MEATBALL * zoomValue + "px"; 154 } 155 } 156 157 _onToolboxDestroyed() { 158 // Delay self-destruction to let the debugger complete async destruction. 159 // Otherwise it throws when running browser_dbg-breakpoints-in-evaled-sources.js 160 // because the promise middleware delay each promise action using setTimeout... 161 DevToolsUtils.executeSoon(() => { 162 this.destroy(); 163 }); 164 } 165 166 _onMessage(event) { 167 if (!event.data) { 168 return; 169 } 170 const msg = event.data; 171 // Toolbox document is still chrome and disallow identifying message 172 // origin via event.source as it is null. So use a custom id. 173 if (msg.frameId != this.frameId) { 174 return; 175 } 176 if (this.collectPendingMessages) { 177 this.collectPendingMessages.push(event); 178 return; 179 } 180 switch (msg.name) { 181 case "switch-host": 182 this.switchHost(msg.hostType); 183 break; 184 case "switch-host-to-tab": 185 this.switchHostToTab(msg.tabBrowsingContextID); 186 break; 187 case "raise-host": 188 this.host.raise(); 189 this.postMessage({ 190 name: "host-raised", 191 }); 192 break; 193 case "set-host-title": 194 this.host.setTitle(msg.title); 195 break; 196 } 197 } 198 199 postMessage(data) { 200 const window = this.host.frame.contentWindow; 201 window.postMessage(data, "*"); 202 } 203 204 destroy() { 205 Services.prefs.removeObserver(ZOOM_VALUE_PREF, this.setMinWidthWithZoom); 206 this.eventController.abort(); 207 this.eventController = null; 208 this.destroyHost(); 209 // When we are debugging popup, we created host for each popup opened 210 // in some other tabs. Ensure destroying them here. 211 for (const host of this.hostPerTab.values()) { 212 host.destroy(); 213 } 214 this.hostPerTab.clear(); 215 this.host = null; 216 this.hostType = null; 217 this.commands = null; 218 } 219 220 /** 221 * Create a host object based on the given host type. 222 * 223 * Warning: bottom and sidebar hosts require that the toolbox target provides 224 * a reference to the attached tab. Not all Targets have a tab property - 225 * make sure you correctly mix and match hosts and targets. 226 * 227 * @param {string} hostType 228 * The host type of the new host object 229 * 230 * @return {Host} host 231 * The created host object 232 */ 233 createHost(hostType, options) { 234 if (!Hosts[hostType]) { 235 throw new Error("Unknown hostType: " + hostType); 236 } 237 const newHost = new Hosts[hostType](this.currentTab, options); 238 return newHost; 239 } 240 241 /** 242 * Migrate the toolbox to a new host, while keeping it fully functional. 243 * The toolbox's iframe will be moved as-is to the new host. 244 * 245 * @param {string} hostType 246 * The new type of host to spawn 247 * @param {boolean} destroyPreviousHost 248 * Defaults to true. If false is passed, we will avoid destroying 249 * the previous host. This is helpful for popup debugging, 250 * where we migrate the toolbox between two tabs. In this scenario 251 * we are reusing previously instantiated hosts. This is especially 252 * useful when we close the current tab and have to have an 253 * already instantiated host to migrate to. If we don't have one, 254 * the toolbox iframe will already be destroyed before we have a chance 255 * to migrate it. 256 */ 257 async switchHost(hostType, destroyPreviousHost = true) { 258 if (hostType == "previous") { 259 // Switch to the last used host for the toolbox UI. 260 // This is determined by the devtools.toolbox.previousHost pref. 261 hostType = Services.prefs.getCharPref(PREVIOUS_HOST); 262 263 // Handle the case where the previous host happens to match the current 264 // host. If so, switch to bottom if it's not already used, and right side if not. 265 if (hostType === this.hostType) { 266 if (hostType === Toolbox.HostType.BOTTOM) { 267 hostType = Toolbox.HostType.RIGHT; 268 } else { 269 hostType = Toolbox.HostType.BOTTOM; 270 } 271 } 272 } 273 const iframe = this.host.frame; 274 const newHost = this.createHost(hostType); 275 const newIframe = await newHost.create(); 276 277 // Load a blank document in the host frame. The new iframe must have a valid 278 // document before using swapFrameLoaders(). 279 await new Promise(resolve => { 280 newIframe.setAttribute("src", "about:blank"); 281 DOMHelpers.onceDOMReady(newIframe.contentWindow, resolve); 282 }); 283 284 // change toolbox document's parent to the new host 285 newIframe.swapFrameLoaders(iframe); 286 287 // swapFrameLoaders ends up disabling the new frame activeness, 288 // so ensure we set the expected state at the end of this method 289 iframe.docShellIsActive = false; 290 newIframe.docShellIsActive = true; 291 292 if (destroyPreviousHost) { 293 this.destroyHost(); 294 } 295 296 if ( 297 this.hostType !== Toolbox.HostType.BROWSERTOOLBOX && 298 this.hostType !== Toolbox.HostType.PAGE 299 ) { 300 Services.prefs.setCharPref(PREVIOUS_HOST, this.hostType); 301 } 302 this.host = newHost; 303 if (this.currentTab) { 304 this.hostPerTab.set(this.currentTab, newHost); 305 } 306 this.hostType = hostType; 307 this.host.setTitle(this.host.frame.contentWindow.document.title); 308 this.host.frame.ownerDocument.defaultView.addEventListener( 309 "message", 310 this._onMessage, 311 { signal: this.eventController.signal } 312 ); 313 314 this.setMinWidthWithZoom(); 315 316 if ( 317 hostType !== Toolbox.HostType.BROWSERTOOLBOX && 318 hostType !== Toolbox.HostType.PAGE 319 ) { 320 Services.prefs.setCharPref(LAST_HOST, hostType); 321 } 322 323 // Tell the toolbox the host changed 324 this.postMessage({ 325 name: "switched-host", 326 hostType, 327 }); 328 } 329 330 /** 331 * When we are debugging popup, we are moving around the toolbox between original tab 332 * and popup tabs. This method will only move the host to a new tab, while 333 * keeping the same host type. 334 * 335 * @param {string} tabBrowsingContextID 336 * The ID of the browsing context of the tab we want to move to. 337 */ 338 async switchHostToTab(tabBrowsingContextID) { 339 const { gBrowser } = this.host.frame.ownerDocument.defaultView; 340 341 const previousTab = this.currentTab; 342 const newTab = gBrowser.tabs.find( 343 tab => tab.linkedBrowser.browsingContext.id == tabBrowsingContextID 344 ); 345 // Note that newTab will be undefined when the popup opens in a new top level window. 346 if (newTab && newTab != previousTab) { 347 this.currentTab = newTab; 348 const newHost = this.hostPerTab.get(this.currentTab); 349 if (newHost) { 350 newHost.frame.swapFrameLoaders(this.host.frame); 351 this.host = newHost; 352 } else { 353 // Ensure collecting any message sent by the toolbox in order to emit them 354 // on the newly created host, which is created asynchronously 355 const pendingMessages = []; 356 this.collectPendingMessages = pendingMessages; 357 await this.switchHost(this.hostType, false); 358 this.collectPendingMessages = null; 359 for (const message of pendingMessages) { 360 this._onMessage(message); 361 } 362 } 363 previousTab.addEventListener( 364 "TabSelect", 365 event => { 366 this.switchHostToTab(event.target.linkedBrowser.browsingContext.id); 367 }, 368 { once: true, signal: this.eventController.signal } 369 ); 370 } 371 372 this.postMessage({ 373 name: "switched-host-to-tab", 374 browsingContextID: tabBrowsingContextID, 375 }); 376 } 377 378 /** 379 * Destroy the current host, and remove event listeners from its frame. 380 * 381 * @return {promise} to be resolved when the host is destroyed. 382 */ 383 destroyHost() { 384 return this.host.destroy(); 385 } 386 } 387 388 exports.ToolboxHostManager = ToolboxHostManager;