toolbox-options.js (20529B)
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 const { 9 gDevTools, 10 } = require("resource://devtools/client/framework/devtools.js"); 11 12 const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); 13 const L10N = new LocalizationHelper( 14 "devtools/client/locales/toolbox.properties" 15 ); 16 17 loader.lazyRequireGetter( 18 this, 19 "openDocLink", 20 "resource://devtools/client/shared/link.js", 21 true 22 ); 23 24 function GetPref(name) { 25 const type = Services.prefs.getPrefType(name); 26 switch (type) { 27 case Services.prefs.PREF_STRING: 28 return Services.prefs.getCharPref(name); 29 case Services.prefs.PREF_INT: 30 return Services.prefs.getIntPref(name); 31 case Services.prefs.PREF_BOOL: 32 return Services.prefs.getBoolPref(name); 33 default: 34 throw new Error("Unknown type"); 35 } 36 } 37 38 function SetPref(name, value) { 39 const type = Services.prefs.getPrefType(name); 40 switch (type) { 41 case Services.prefs.PREF_STRING: 42 return Services.prefs.setCharPref(name, value); 43 case Services.prefs.PREF_INT: 44 return Services.prefs.setIntPref(name, value); 45 case Services.prefs.PREF_BOOL: 46 return Services.prefs.setBoolPref(name, value); 47 default: 48 throw new Error("Unknown type"); 49 } 50 } 51 52 function InfallibleGetBoolPref(key) { 53 try { 54 return Services.prefs.getBoolPref(key); 55 } catch (ex) { 56 return true; 57 } 58 } 59 60 /** 61 * Represents the Options Panel in the Toolbox. 62 */ 63 class OptionsPanel extends EventEmitter { 64 constructor(iframeWindow, toolbox, commands) { 65 super(); 66 67 this.panelDoc = iframeWindow.document; 68 this.panelWin = iframeWindow; 69 70 this.toolbox = toolbox; 71 this.commands = commands; 72 this.telemetry = toolbox.telemetry; 73 74 this.setupToolsList = this.setupToolsList.bind(this); 75 76 this.disableJSNode = this.panelDoc.getElementById( 77 "devtools-disable-javascript" 78 ); 79 80 this.#addListeners(); 81 } 82 83 get target() { 84 return this.toolbox.target; 85 } 86 87 async open() { 88 this.setupToolsList(); 89 this.setupToolbarButtonsList(); 90 this.setupThemeList(); 91 this.setupAdditionalOptions(); 92 await this.populatePreferences(); 93 return this; 94 } 95 96 #addListeners() { 97 Services.prefs.addObserver("devtools.cache.disabled", this.#prefChanged); 98 Services.prefs.addObserver("devtools.theme", this.#prefChanged); 99 Services.prefs.addObserver( 100 "devtools.source-map.client-service.enabled", 101 this.#prefChanged 102 ); 103 Services.prefs.addObserver( 104 "devtools.toolbox.splitconsole.enabled", 105 this.#prefChanged 106 ); 107 gDevTools.on("theme-registered", this.#themeRegistered); 108 gDevTools.on("theme-unregistered", this.#themeUnregistered); 109 110 // Refresh the tools list when a new tool or webextension has been 111 // registered to the toolbox. 112 this.toolbox.on("tool-registered", this.setupToolsList); 113 this.toolbox.on("webextension-registered", this.setupToolsList); 114 // Refresh the tools list when a new tool or webextension has been 115 // unregistered from the toolbox. 116 this.toolbox.on("tool-unregistered", this.setupToolsList); 117 this.toolbox.on("webextension-unregistered", this.setupToolsList); 118 } 119 120 #removeListeners() { 121 Services.prefs.removeObserver("devtools.cache.disabled", this.#prefChanged); 122 Services.prefs.removeObserver("devtools.theme", this.#prefChanged); 123 Services.prefs.removeObserver( 124 "devtools.source-map.client-service.enabled", 125 this.#prefChanged 126 ); 127 Services.prefs.removeObserver( 128 "devtools.toolbox.splitconsole.enabled", 129 this.#prefChanged 130 ); 131 132 this.toolbox.off("tool-registered", this.setupToolsList); 133 this.toolbox.off("tool-unregistered", this.setupToolsList); 134 this.toolbox.off("webextension-registered", this.setupToolsList); 135 this.toolbox.off("webextension-unregistered", this.setupToolsList); 136 137 gDevTools.off("theme-registered", this.#themeRegistered); 138 gDevTools.off("theme-unregistered", this.#themeUnregistered); 139 } 140 141 #prefChanged = (subject, topic, prefName) => { 142 if (prefName === "devtools.cache.disabled") { 143 const cacheDisabled = GetPref(prefName); 144 const cbx = this.panelDoc.getElementById("devtools-disable-cache"); 145 cbx.checked = cacheDisabled; 146 } else if (prefName === "devtools.theme") { 147 this.updateCurrentTheme(); 148 } else if (prefName === "devtools.source-map.client-service.enabled") { 149 this.updateSourceMapPref(); 150 } else if (prefName === "devtools.toolbox.splitconsole.enabled") { 151 this.toolbox.updateIsSplitConsoleEnabled(); 152 } 153 }; 154 155 #themeRegistered = () => { 156 this.setupThemeList(); 157 }; 158 159 #themeUnregistered = theme => { 160 const themeBox = this.panelDoc.getElementById("devtools-theme-box"); 161 const themeInput = themeBox.querySelector(`[value=${theme.id}]`); 162 163 if (themeInput) { 164 themeInput.parentNode.remove(); 165 } 166 }; 167 168 async setupToolbarButtonsList() { 169 // Ensure the toolbox is open, and the buttons are all set up. 170 await this.toolbox.isOpen; 171 172 const enabledToolbarButtonsBox = this.panelDoc.getElementById( 173 "enabled-toolbox-buttons-box" 174 ); 175 176 const toolbarButtons = this.toolbox.toolbarButtons; 177 178 if (!toolbarButtons) { 179 console.warn("The command buttons weren't initiated yet."); 180 return; 181 } 182 183 const onCheckboxClick = checkbox => { 184 const commandButton = toolbarButtons.filter( 185 toggleableButton => toggleableButton.id === checkbox.id 186 )[0]; 187 188 Services.prefs.setBoolPref( 189 commandButton.visibilityswitch, 190 checkbox.checked 191 ); 192 this.toolbox.updateToolboxButtonsVisibility(); 193 }; 194 195 const createCommandCheckbox = button => { 196 const checkboxLabel = this.panelDoc.createElement("label"); 197 const checkboxSpanLabel = this.panelDoc.createElement("span"); 198 checkboxSpanLabel.textContent = button.description; 199 const checkboxInput = this.panelDoc.createElement("input"); 200 checkboxInput.setAttribute("type", "checkbox"); 201 checkboxInput.setAttribute("id", button.id); 202 203 if (Services.prefs.getBoolPref(button.visibilityswitch, true)) { 204 checkboxInput.setAttribute("checked", true); 205 } 206 checkboxInput.addEventListener( 207 "change", 208 onCheckboxClick.bind(this, checkboxInput) 209 ); 210 211 checkboxLabel.appendChild(checkboxInput); 212 checkboxLabel.appendChild(checkboxSpanLabel); 213 214 return checkboxLabel; 215 }; 216 217 for (const button of toolbarButtons) { 218 if (!button.isToolSupported(this.toolbox)) { 219 continue; 220 } 221 222 enabledToolbarButtonsBox.appendChild(createCommandCheckbox(button)); 223 } 224 } 225 226 setupToolsList() { 227 const defaultToolsBox = this.panelDoc.getElementById("default-tools-box"); 228 const additionalToolsBox = this.panelDoc.getElementById( 229 "additional-tools-box" 230 ); 231 const toolsNotSupportedLabel = this.panelDoc.getElementById( 232 "tools-not-supported-label" 233 ); 234 let atleastOneToolNotSupported = false; 235 236 // Signal tool registering/unregistering globally (for the tools registered 237 // globally) and per toolbox (for the tools registered to a single toolbox). 238 // This event handler expect this to be binded to the related checkbox element. 239 const onCheckboxClick = function (telemetry, tool) { 240 // Set the kill switch pref boolean to true 241 Services.prefs.setBoolPref(tool.visibilityswitch, this.checked); 242 243 if (!tool.isWebExtension) { 244 gDevTools.emit( 245 this.checked ? "tool-registered" : "tool-unregistered", 246 tool.id 247 ); 248 // Record which tools were registered and unregistered. 249 Glean.devtoolsTool.registered[tool.id].set(this.checked); 250 } 251 }; 252 253 const createToolCheckbox = tool => { 254 const checkboxLabel = this.panelDoc.createElement("label"); 255 const checkboxInput = this.panelDoc.createElement("input"); 256 checkboxInput.setAttribute("type", "checkbox"); 257 checkboxInput.setAttribute("id", tool.id); 258 checkboxInput.setAttribute("title", tool.tooltip || ""); 259 260 const checkboxSpanLabel = this.panelDoc.createElement("span"); 261 if (tool.isToolSupported(this.toolbox)) { 262 checkboxSpanLabel.textContent = tool.label; 263 } else { 264 atleastOneToolNotSupported = true; 265 checkboxSpanLabel.textContent = L10N.getFormatStr( 266 "options.toolNotSupportedMarker", 267 tool.label 268 ); 269 checkboxInput.setAttribute("data-unsupported", "true"); 270 checkboxInput.setAttribute("disabled", "true"); 271 } 272 273 if (InfallibleGetBoolPref(tool.visibilityswitch)) { 274 checkboxInput.setAttribute("checked", "true"); 275 } 276 277 checkboxInput.addEventListener( 278 "change", 279 onCheckboxClick.bind(checkboxInput, this.telemetry, tool) 280 ); 281 282 checkboxLabel.appendChild(checkboxInput); 283 checkboxLabel.appendChild(checkboxSpanLabel); 284 285 // We shouldn't have deprecated tools anymore, but we might have one in the future, 286 // when migrating the storage inspector to the application panel (Bug 1681059). 287 // Let's keep this code for now so we keep the l10n property around and avoid 288 // unnecessary translation work if we need it again in the future. 289 if (tool.deprecated) { 290 const deprecationURL = this.panelDoc.createElement("a"); 291 deprecationURL.title = deprecationURL.href = tool.deprecationURL; 292 deprecationURL.textContent = L10N.getStr("options.deprecationNotice"); 293 // Cannot use a real link when we are in the Browser Toolbox. 294 deprecationURL.addEventListener("click", e => { 295 e.preventDefault(); 296 openDocLink(tool.deprecationURL, { relatedToCurrent: true }); 297 }); 298 299 const checkboxSpanDeprecated = this.panelDoc.createElement("span"); 300 checkboxSpanDeprecated.className = "deprecation-notice"; 301 checkboxLabel.appendChild(checkboxSpanDeprecated); 302 checkboxSpanDeprecated.appendChild(deprecationURL); 303 } 304 305 return checkboxLabel; 306 }; 307 308 // Clean up any existent default tools content. 309 for (const label of defaultToolsBox.querySelectorAll("label")) { 310 label.remove(); 311 } 312 313 // Populating the default tools lists 314 const toggleableTools = gDevTools.getDefaultTools().filter(tool => { 315 return tool.visibilityswitch && !tool.hiddenInOptions; 316 }); 317 318 const fragment = this.panelDoc.createDocumentFragment(); 319 for (const tool of toggleableTools) { 320 fragment.appendChild(createToolCheckbox(tool)); 321 } 322 323 const toolsNotSupportedLabelNode = this.panelDoc.getElementById( 324 "tools-not-supported-label" 325 ); 326 defaultToolsBox.insertBefore(fragment, toolsNotSupportedLabelNode); 327 328 // Clean up any existent additional tools content. 329 for (const label of additionalToolsBox.querySelectorAll("label")) { 330 label.remove(); 331 } 332 333 // Populating the additional tools list. 334 let atleastOneAddon = false; 335 for (const tool of gDevTools.getAdditionalTools()) { 336 atleastOneAddon = true; 337 additionalToolsBox.appendChild(createToolCheckbox(tool)); 338 } 339 340 // Populating the additional tools that came from the installed WebExtension add-ons. 341 for (const { uuid, name, pref } of this.toolbox.listWebExtensions()) { 342 atleastOneAddon = true; 343 344 additionalToolsBox.appendChild( 345 createToolCheckbox({ 346 isWebExtension: true, 347 348 // Use the preference as the unified webextensions tool id. 349 id: `webext-${uuid}`, 350 tooltip: name, 351 label: name, 352 // Disable the devtools extension using the given pref name: 353 // the toolbox options for the WebExtensions are not related to a single 354 // tool (e.g. a devtools panel created from the extension devtools_page) 355 // but to the entire devtools part of a webextension which is enabled 356 // by the Addon Manager (but it may be disabled by its related 357 // devtools about:config preference), and so the following 358 visibilityswitch: pref, 359 360 // Only local tabs are currently supported as targets. 361 isToolSupported: toolbox => 362 toolbox.commands.descriptorFront.isLocalTab, 363 }) 364 ); 365 } 366 367 if (!atleastOneAddon) { 368 additionalToolsBox.style.display = "none"; 369 } else { 370 additionalToolsBox.style.display = ""; 371 } 372 373 if (!atleastOneToolNotSupported) { 374 toolsNotSupportedLabel.style.display = "none"; 375 } else { 376 toolsNotSupportedLabel.style.display = ""; 377 } 378 379 this.panelWin.focus(); 380 } 381 382 setupThemeList() { 383 const themeBox = this.panelDoc.getElementById("devtools-theme-box"); 384 const themeLabels = themeBox.querySelectorAll("label"); 385 for (const label of themeLabels) { 386 label.remove(); 387 } 388 389 const createThemeOption = theme => { 390 const inputLabel = this.panelDoc.createElement("label"); 391 const inputRadio = this.panelDoc.createElement("input"); 392 inputRadio.setAttribute("type", "radio"); 393 inputRadio.setAttribute("value", theme.id); 394 inputRadio.setAttribute("name", "devtools-theme-item"); 395 inputRadio.addEventListener("change", function (e) { 396 SetPref(themeBox.getAttribute("data-pref"), e.target.value); 397 }); 398 399 const inputSpanLabel = this.panelDoc.createElement("span"); 400 inputSpanLabel.textContent = theme.label; 401 inputLabel.appendChild(inputRadio); 402 inputLabel.appendChild(inputSpanLabel); 403 404 return inputLabel; 405 }; 406 407 // Populating the default theme list 408 themeBox.appendChild( 409 createThemeOption({ 410 id: "auto", 411 label: L10N.getStr("options.autoTheme.label"), 412 }) 413 ); 414 415 const themes = gDevTools.getThemeDefinitionArray(); 416 for (const theme of themes) { 417 themeBox.appendChild(createThemeOption(theme)); 418 } 419 420 this.updateCurrentTheme(); 421 } 422 423 /** 424 * Add extra checkbox options bound to a boolean preference. 425 */ 426 setupAdditionalOptions() { 427 const prefDefinitions = [ 428 { 429 pref: "devtools.custom-formatters.enabled", 430 l10nLabelId: "options-enable-custom-formatters-label", 431 l10nTooltipId: "options-enable-custom-formatters-tooltip", 432 id: "devtools-custom-formatters", 433 parentId: "context-options", 434 }, 435 ]; 436 437 const createPreferenceOption = ({ 438 pref, 439 label, 440 l10nLabelId, 441 l10nTooltipId, 442 id, 443 onChange, 444 }) => { 445 const inputLabel = this.panelDoc.createElement("label"); 446 if (l10nTooltipId) { 447 this.panelDoc.l10n.setAttributes(inputLabel, l10nTooltipId); 448 } 449 const checkbox = this.panelDoc.createElement("input"); 450 checkbox.setAttribute("type", "checkbox"); 451 if (GetPref(pref)) { 452 checkbox.setAttribute("checked", "checked"); 453 } 454 checkbox.setAttribute("id", id); 455 checkbox.addEventListener("change", e => { 456 SetPref(pref, e.target.checked); 457 if (onChange) { 458 onChange(e.target.checked); 459 } 460 }); 461 462 const inputSpanLabel = this.panelDoc.createElement("span"); 463 if (l10nLabelId) { 464 this.panelDoc.l10n.setAttributes(inputSpanLabel, l10nLabelId); 465 } else if (label) { 466 inputSpanLabel.textContent = label; 467 } 468 inputLabel.appendChild(checkbox); 469 inputLabel.appendChild(inputSpanLabel); 470 471 return inputLabel; 472 }; 473 474 for (const prefDefinition of prefDefinitions) { 475 const parent = this.panelDoc.getElementById(prefDefinition.parentId); 476 // We want to insert the new definition after the last existing 477 // definition, but before any other element. 478 // For example in the "Advanced Settings" column there's indeed a <span> 479 // text at the end, and we want that it stays at the end. 480 // The reference element can be `null` if there's no label or if there's 481 // no element after the last label. But that's OK and it will do what we 482 // want. 483 const referenceElement = parent.querySelector("label:last-of-type + *"); 484 parent.insertBefore( 485 createPreferenceOption(prefDefinition), 486 referenceElement 487 ); 488 } 489 } 490 491 async populatePreferences() { 492 const prefCheckboxes = this.panelDoc.querySelectorAll( 493 "input[type=checkbox][data-pref]" 494 ); 495 for (const prefCheckbox of prefCheckboxes) { 496 if (GetPref(prefCheckbox.getAttribute("data-pref"))) { 497 prefCheckbox.setAttribute("checked", true); 498 } 499 prefCheckbox.addEventListener("change", e => { 500 const checkbox = e.target; 501 SetPref(checkbox.getAttribute("data-pref"), checkbox.checked); 502 if (checkbox.hasAttribute("data-force-reload")) { 503 this.commands.targetCommand.reloadTopLevelTarget(); 504 } 505 }); 506 } 507 // Themes radio inputs are handled in setupThemeList 508 const prefRadiogroups = this.panelDoc.querySelectorAll( 509 ".radiogroup[data-pref]:not(#devtools-theme-box)" 510 ); 511 for (const radioGroup of prefRadiogroups) { 512 const selectedValue = GetPref(radioGroup.getAttribute("data-pref")); 513 514 for (const radioInput of radioGroup.querySelectorAll( 515 "input[type=radio]" 516 )) { 517 if (radioInput.getAttribute("value") == selectedValue) { 518 radioInput.setAttribute("checked", true); 519 } 520 521 radioInput.addEventListener("change", function (e) { 522 SetPref(radioGroup.getAttribute("data-pref"), e.target.value); 523 }); 524 } 525 } 526 const prefSelects = this.panelDoc.querySelectorAll("select[data-pref]"); 527 for (const prefSelect of prefSelects) { 528 const pref = GetPref(prefSelect.getAttribute("data-pref")); 529 const options = [...prefSelect.options]; 530 options.some(function (option) { 531 const value = option.value; 532 // non strict check to allow int values. 533 if (value == pref) { 534 prefSelect.selectedIndex = options.indexOf(option); 535 return true; 536 } 537 return false; 538 }); 539 540 prefSelect.addEventListener("change", function (e) { 541 const select = e.target; 542 SetPref( 543 select.getAttribute("data-pref"), 544 select.options[select.selectedIndex].value 545 ); 546 }); 547 } 548 549 if (this.commands.descriptorFront.isTabDescriptor) { 550 const isJavascriptEnabled = 551 await this.commands.targetConfigurationCommand.isJavascriptEnabled(); 552 this.disableJSNode.checked = !isJavascriptEnabled; 553 this.disableJSNode.addEventListener("click", this.#disableJSClicked); 554 } else { 555 // Hide the checkbox and label 556 this.disableJSNode.parentNode.style.display = "none"; 557 } 558 } 559 560 updateCurrentTheme() { 561 const currentTheme = GetPref("devtools.theme"); 562 const themeBox = this.panelDoc.getElementById("devtools-theme-box"); 563 const themeRadioInput = themeBox.querySelector(`[value=${currentTheme}]`); 564 565 if (themeRadioInput) { 566 themeRadioInput.checked = true; 567 } else { 568 // If the current theme does not exist anymore, switch to auto theme 569 const autoThemeInputRadio = themeBox.querySelector("[value=auto]"); 570 autoThemeInputRadio.checked = true; 571 } 572 } 573 574 updateSourceMapPref() { 575 const prefName = "devtools.source-map.client-service.enabled"; 576 const enabled = GetPref(prefName); 577 const box = this.panelDoc.querySelector(`[data-pref="${prefName}"]`); 578 box.checked = enabled; 579 } 580 581 /** 582 * Disables JavaScript for the currently loaded tab. We force a page refresh 583 * here because setting browsingContext.allowJavascript to true fails to block 584 * JS execution from event listeners added using addEventListener(), AJAX 585 * calls and timers. The page refresh prevents these things from being added 586 * in the first place. 587 * 588 * @param {Event} event 589 * The event sent by checking / unchecking the disable JS checkbox. 590 */ 591 #disableJSClicked = event => { 592 const checked = event.target.checked; 593 594 this.commands.targetConfigurationCommand.updateConfiguration({ 595 javascriptEnabled: !checked, 596 }); 597 }; 598 599 destroy() { 600 if (this.destroyed) { 601 return; 602 } 603 this.destroyed = true; 604 605 this.#removeListeners(); 606 607 this.disableJSNode.removeEventListener("click", this.#disableJSClicked); 608 609 this.panelWin = this.panelDoc = this.disableJSNode = this.toolbox = null; 610 } 611 } 612 613 exports.OptionsPanel = OptionsPanel;