/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const { DeferredTask } = ChromeUtils.importESModule(
"resource://gre/modules/DeferredTask.sys.mjs"
);
const { Preferences } = ChromeUtils.importESModule(
"resource://gre/modules/Preferences.sys.mjs"
);
const SEARCH_TIMEOUT_MS = 100;
const SEARCH_AUTO_MIN_CRARACTERS = 3;
const GETTERS_BY_PREF_TYPE = {
[Ci.nsIPrefBranch.PREF_BOOL]:
"getBoolPref",
[Ci.nsIPrefBranch.PREF_INT]:
"getIntPref",
[Ci.nsIPrefBranch.PREF_STRING]:
"getStringPref",
};
const STRINGS_ADD_BY_TYPE = {
Boolean:
"about-config-pref-add-type-boolean",
Number:
"about-config-pref-add-type-number",
String:
"about-config-pref-add-type-string",
};
// Fluent limits the maximum length of placeables.
const MAX_PLACEABLE_LENGTH = 2500;
let gDefaultBranch = Services.prefs.getDefaultBranch(
"");
let gFilterPrefsTask =
new DeferredTask(
() => filterPrefs(),
SEARCH_TIMEOUT_MS,
0
);
/**
* Maps the name of each preference in the back-end to its PrefRow object,
* separating the preferences that actually exist. This is as an optimization to
* avoid querying the preferences service each time the list is filtered.
*/
let gExistingPrefs =
new Map();
let gDeletedPrefs =
new Map();
/**
* Also cache several values to improve the performance of common use cases.
*/
let gSortedExistingPrefs =
null;
let gSearchInput =
null;
let gShowOnlyModifiedCheckbox =
null;
let gPrefsTable =
null;
/**
* Reference to the PrefRow currently being edited, if any.
*/
let gPrefInEdit =
null;
/**
* Lowercase substring that should be contained in the preference name.
*/
let gFilterString =
null;
/**
* RegExp that should be matched to the preference name.
*/
let gFilterPattern =
null;
/**
* True if we were requested to show all preferences.
*/
let gFilterShowAll =
false;
class PrefRow {
constructor(name, opts) {
this.name = name;
this.value =
true;
this.hidden =
false;
this.odd =
false;
this.editing =
false;
this.isAddRow = opts && opts.isAddRow;
this.refreshValue();
}
refreshValue() {
let prefType = Services.prefs.getPrefType(
this.name);
// If this preference has been deleted, we keep its last known value.
if (prefType == Ci.nsIPrefBranch.PREF_INVALID) {
this.hasDefaultValue =
false;
this.hasUserValue =
false;
this.isLocked =
false;
if (gExistingPrefs.has(
this.name)) {
gExistingPrefs.
delete(
this.name);
gSortedExistingPrefs =
null;
}
gDeletedPrefs.set(
this.name,
this);
return;
}
if (!gExistingPrefs.has(
this.name)) {
gExistingPrefs.set(
this.name,
this);
gSortedExistingPrefs =
null;
}
gDeletedPrefs.
delete(
this.name);
try {
this.value = gDefaultBranch[GETTERS_BY_PREF_TYPE[prefType]](
this.name);
this.hasDefaultValue =
true;
}
catch (ex) {
this.hasDefaultValue =
false;
}
this.hasUserValue = Services.prefs.prefHasUserValue(
this.name);
this.isLocked = Services.prefs.prefIsLocked(
this.name);
try {
if (
this.hasUserValue) {
// This can throw for locked preferences without a default value.
this.value = Services.prefs[GETTERS_BY_PREF_TYPE[prefType]](
this.name);
}
else if (/^chrome:\/\/.+\/locale\/.+\.properties/.test(
this.value)) {
// We don't know which preferences should be read using getComplexValue,
// so we use a heuristic to determine if this is a localized preference.
// This can throw if there is no value in the localized files.
this.value = Services.prefs.getComplexValue(
this.name,
Ci.nsIPrefLocalizedString
).data;
}
}
catch (ex) {
this.value =
"";
}
}
get type() {
return this.value.constructor.name;
}
get exists() {
return this.hasDefaultValue ||
this.hasUserValue;
}
get matchesFilter() {
if (!
this.matchesModifiedFilter) {
return false;
}
return (
gFilterShowAll ||
(gFilterPattern && gFilterPattern.test(
this.name)) ||
(gFilterString &&
this.name.toLowerCase().includes(gFilterString))
);
}
get matchesModifiedFilter() {
const onlyShowModified = gShowOnlyModifiedCheckbox.checked;
return !onlyShowModified ||
this.hasUserValue;
}
/**
* Returns a reference to the table row element to be added to the document,
* constructing and initializing it the first time this method is called.
*/
getElement() {
if (
this._element) {
return this._element;
}
this._element = document.createElement(
"tr");
this._element._pref =
this;
let nameCell = document.createElement(
"th");
let nameCellSpan = document.createElement(
"span");
nameCell.appendChild(nameCellSpan);
this._element.append(
nameCell,
(
this.valueCell = document.createElement(
"td")),
(
this.editCell = document.createElement(
"td")),
(
this.resetCell = document.createElement(
"td"))
);
this.editCell.appendChild(
(
this.editButton = document.createElement(
"button"))
);
delete this.resetButton;
nameCell.setAttribute(
"scope",
"row");
this.valueCell.className =
"cell-value";
this.editCell.className =
"cell-edit";
this.resetCell.className =
"cell-reset";
// Add <wbr> behind dots to prevent line breaking in random mid-word places.
let parts =
this.name.split(
".");
for (let i = 0; i < parts.length - 1; i++) {
nameCellSpan.append(parts[i] +
".", document.createElement(
"wbr"));
}
nameCellSpan.append(parts[parts.length - 1]);
this.refreshElement();
return this._element;
}
refreshElement() {
if (!
this._element) {
// No need to update if this preference was never added to the table.
return;
}
if (
this.exists && !
this.editing) {
// We need to place the text inside a "span" element to ensure that the
// text copied to the clipboard includes all whitespace.
let span = document.createElement(
"span");
span.textContent =
this.value;
// We additionally need to wrap this with another "span" element to convey
// the state to screen readers without affecting the visual presentation.
span.setAttribute(
"aria-hidden",
"true");
let outerSpan = document.createElement(
"span");
if (
this.type ==
"String" &&
this.value.length > MAX_PLACEABLE_LENGTH) {
// If the value is too long for localization, don't include the state.
// Since the preferences system is designed to store short values, this
// case happens very rarely, thus we keep the same DOM structure for
// consistency even though we could avoid the extra "span" element.
outerSpan.setAttribute(
"aria-label",
this.value);
}
else {
let spanL10nId =
this.hasUserValue
?
"about-config-pref-accessible-value-custom"
:
"about-config-pref-accessible-value-default";
document.l10n.setAttributes(outerSpan, spanL10nId, {
value:
"" +
this.value,
});
}
outerSpan.appendChild(span);
this.valueCell.textContent =
"";
this.valueCell.append(outerSpan);
if (
this.type ==
"Boolean") {
document.l10n.setAttributes(
this.editButton,
"about-config-pref-toggle-button"
);
this.editButton.className =
"button-toggle semi-transparent";
}
else {
document.l10n.setAttributes(
this.editButton,
"about-config-pref-edit-button"
);
this.editButton.className =
"button-edit semi-transparent";
}
this.editButton.removeAttribute(
"form");
delete this.inputField;
}
else {
this.valueCell.textContent =
"";
// The form is needed for the validation report to appear, but we need to
// prevent the associated button from reloading the page.
let form = document.createElement(
"form");
form.addEventListener(
"submit", event => event.preventDefault());
form.id =
"form-edit";
if (
this.editing) {
this.inputField = document.createElement(
"input");
this.inputField.value =
this.value;
this.inputField.ariaLabel =
this.name;
if (
this.type ==
"Number") {
this.inputField.type =
"number";
this.inputField.required =
true;
this.inputField.min = -2147483648;
this.inputField.max = 2147483647;
}
else {
this.inputField.type =
"text";
}
form.appendChild(
this.inputField);
document.l10n.setAttributes(
this.editButton,
"about-config-pref-save-button"
);
this.editButton.className =
"primary button-save semi-transparent";
}
else {
delete this.inputField;
for (let type of [
"Boolean",
"Number",
"String"]) {
let radio = document.createElement(
"input");
radio.type =
"radio";
radio.name =
"type";
radio.value = type;
radio.checked =
this.type == type;
let radioSpan = document.createElement(
"span");
document.l10n.setAttributes(radioSpan, STRINGS_ADD_BY_TYPE[type]);
let radioLabel = document.createElement(
"label");
radioLabel.append(radio, radioSpan);
form.appendChild(radioLabel);
}
form.addEventListener(
"click", event => {
if (event.target.name !=
"type") {
return;
}
let type = event.target.value;
if (
this.type != type) {
if (type ==
"Boolean") {
this.value =
true;
}
else if (type ==
"Number") {
this.value = 0;
}
else {
this.value =
"";
}
}
});
document.l10n.setAttributes(
this.editButton,
"about-config-pref-add-button"
);
this.editButton.className =
"button-add semi-transparent";
}
this.valueCell.appendChild(form);
this.editButton.setAttribute(
"form",
"form-edit");
}
this.editButton.disabled =
this.isLocked;
if (!
this.isLocked &&
this.hasUserValue) {
if (!
this.resetButton) {
this.resetButton = document.createElement(
"button");
this.resetCell.appendChild(
this.resetButton);
}
if (!
this.hasDefaultValue) {
document.l10n.setAttributes(
this.resetButton,
"about-config-pref-delete-button"
);
this.resetButton.className =
"button-delete ghost-button semi-transparent";
}
else {
document.l10n.setAttributes(
this.resetButton,
"about-config-pref-reset-button"
);
this.resetButton.className =
"button-reset ghost-button semi-transparent";
}
}
else if (
this.resetButton) {
this.resetButton.remove();
delete this.resetButton;
}
this.refreshClass();
}
refreshClass() {
if (!
this._element) {
// No need to update if this preference was never added to the table.
return;
}
let className;
if (
this.hidden) {
className =
"hidden";
}
else {
className =
(
this.hasUserValue ?
"has-user-value " :
"") +
(
this.isLocked ?
"locked " :
"") +
(
this.exists ||
this.isAddRow ?
"" :
"deleted ") +
(
this.isAddRow ?
"add " :
"") +
(
this.odd ?
"odd " :
"");
}
if (
this._lastClassName !== className) {
this._element.className =
this._lastClassName = className;
}
}
edit() {
if (gPrefInEdit) {
gPrefInEdit.endEdit();
}
gPrefInEdit =
this;
this.editing =
true;
this.refreshElement();
// The type=number input isn't selected unless it's focused first.
this.inputField.focus();
this.inputField.select();
}
toggle() {
Services.prefs.setBoolPref(
this.name, !
this.value);
}
editOrToggle() {
if (
this.type ==
"Boolean") {
this.toggle();
}
else {
this.edit();
}
}
save() {
if (
this.type ==
"Number") {
if (!
this.inputField.reportValidity()) {
return;
}
Services.prefs.setIntPref(
this.name, parseInt(
this.inputField.value));
}
else {
Services.prefs.setStringPref(
this.name,
this.inputField.value);
}
this.refreshValue();
this.endEdit();
this.editButton.focus();
}
endEdit() {
this.editing =
false;
this.refreshElement();
gPrefInEdit =
null;
}
}
let gPrefObserverRegistered =
false;
let gPrefObserver = {
observe(subject, topic, data) {
let pref = gExistingPrefs.get(data) || gDeletedPrefs.get(data);
if (pref) {
pref.refreshValue();
if (!pref.editing) {
pref.refreshElement();
}
return;
}
let newPref =
new PrefRow(data);
if (newPref.matchesFilter) {
document.getElementById(
"prefs").appendChild(newPref.getElement());
}
},
};
if (!Preferences.get(
"browser.aboutConfig.showWarning")) {
// When showing the filtered preferences directly, remove the warning elements
// immediately to prevent flickering, but wait to filter the preferences until
// the value of the textbox has been restored from previous sessions.
document.addEventListener(
"DOMContentLoaded", loadPrefs, { once:
true });
window.addEventListener(
"load",
() => {
if (document.getElementById(
"about-config-search").value) {
filterPrefs();
}
},
{ once:
true }
);
}
else {
document.addEventListener(
"DOMContentLoaded",
function () {
let warningButton = document.getElementById(
"warningButton");
warningButton.addEventListener(
"click", onWarningButtonClick);
warningButton.focus({ focusVisible:
false });
});
}
function onWarningButtonClick() {
Services.prefs.setBoolPref(
"browser.aboutConfig.showWarning",
document.getElementById(
"showWarningNextTime").checked
);
loadPrefs();
}
function loadPrefs() {
[...document.styleSheets].find(s => s.title ==
"infop").disabled =
true;
let { content } = document.getElementById(
"main");
document.body.textContent =
"";
document.body.appendChild(content);
let search = (gSearchInput = document.getElementById(
"about-config-search"));
let prefs = (gPrefsTable = document.getElementById(
"prefs"));
let showAll = document.getElementById(
"show-all");
gShowOnlyModifiedCheckbox = document.getElementById(
"about-config-show-only-modified"
);
search.focus();
gShowOnlyModifiedCheckbox.checked =
false;
for (let name of Services.prefs.getChildList(
"")) {
new PrefRow(name);
}
search.addEventListener(
"keypress", event => {
if (event.key ==
"Escape") {
// The ESC key returns immediately to the initial empty page.
search.value =
"";
gFilterPrefsTask.disarm();
filterPrefs();
}
else if (event.key ==
"Enter") {
// The Enter key filters immediately even if the search string is short.
gFilterPrefsTask.disarm();
filterPrefs({ shortString:
true });
}
});
search.addEventListener(
"input", () => {
// We call "disarm" to restart the timer at every input.
gFilterPrefsTask.disarm();
if (search.value.trim().length < SEARCH_AUTO_MIN_CRARACTERS) {
// Return immediately to the empty page if the search string is short.
filterPrefs();
}
else {
gFilterPrefsTask.arm();
}
});
gShowOnlyModifiedCheckbox.addEventListener(
"change", () => {
// This checkbox:
// - Filters results to only modified prefs when search query is entered
// - Shows all modified prefs, in show all mode, and after initial checkbox click
let tableHidden = !document.body.classList.contains(
"table-shown");
filterPrefs({
showAll:
gFilterShowAll || (gShowOnlyModifiedCheckbox.checked && tableHidden),
});
});
showAll.addEventListener(
"click", () => {
search.focus();
search.value =
"";
gFilterPrefsTask.disarm();
filterPrefs({ showAll:
true });
});
function shouldBeginEdit(event) {
if (
event.target.localName !=
"button" &&
event.target.localName !=
"input"
) {
let row = event.target.closest(
"tr");
return row && row._pref.exists;
}
return false;
}
// Disable double/triple-click text selection since that triggers edit/toggle.
prefs.addEventListener(
"mousedown", event => {
if (event.detail > 1 && shouldBeginEdit(event)) {
event.preventDefault();
}
});
prefs.addEventListener(
"click", event => {
if (event.detail == 2 && shouldBeginEdit(event)) {
event.target.closest(
"tr")._pref.editOrToggle();
return;
}
if (event.target.localName !=
"button") {
return;
}
let pref = event.target.closest(
"tr")._pref;
let button = event.target.closest(
"button");
if (button.classList.contains(
"button-add")) {
pref.isAddRow =
false;
Preferences.set(pref.name, pref.value);
if (pref.type ==
"Boolean") {
pref.refreshClass();
}
else {
pref.edit();
}
}
else if (
button.classList.contains(
"button-toggle") ||
button.classList.contains(
"button-edit")
) {
pref.editOrToggle();
}
else if (button.classList.contains(
"button-save")) {
pref.save();
}
else {
// This is "button-reset" or "button-delete".
pref.editing =
false;
Services.prefs.clearUserPref(pref.name);
pref.editButton.focus();
}
});
window.addEventListener(
"keypress", event => {
if (event.target != search && event.key ==
"Escape" && gPrefInEdit) {
gPrefInEdit.endEdit();
}
});
}
function filterPrefs(options = {}) {
if (gPrefInEdit) {
gPrefInEdit.endEdit();
}
gDeletedPrefs.clear();
let searchName = gSearchInput.value.trim();
if (searchName.length < SEARCH_AUTO_MIN_CRARACTERS && !options.shortString) {
searchName =
"";
}
gFilterString = searchName.toLowerCase();
gFilterShowAll = !!options.showAll;
gFilterPattern =
null;
if (gFilterString.includes(
"*")) {
gFilterPattern =
new RegExp(gFilterString.replace(/\*+/g,
".*"),
"i");
gFilterString =
"";
}
let showResults = gFilterString || gFilterPattern || gFilterShowAll;
document.body.classList.toggle(
"table-shown", showResults);
let prefArray = [];
if (showResults) {
if (!gSortedExistingPrefs) {
gSortedExistingPrefs = [...gExistingPrefs.values()];
gSortedExistingPrefs.sort((a, b) => a.name > b.name);
}
prefArray = gSortedExistingPrefs;
}
// The slowest operations tend to be the addition and removal of DOM nodes, so
// this algorithm tries to reduce removals by hiding nodes instead. This
// happens frequently when the set narrows while typing preference names. We
// iterate the nodes already in the table in parallel to those we want to
// show, because the two lists are sorted and they will often match already.
let fragment =
null;
let indexInArray = 0;
let elementInTable = gPrefsTable.firstElementChild;
let odd =
false;
let hasVisiblePrefs =
false;
while (indexInArray < prefArray.length || elementInTable) {
// For efficiency, filter the array while we are iterating.
let prefInArray = prefArray[indexInArray];
if (prefInArray) {
if (!prefInArray.matchesFilter) {
indexInArray++;
continue;
}
prefInArray.hidden =
false;
prefInArray.odd = odd;
}
let prefInTable = elementInTable && elementInTable._pref;
if (!prefInTable) {
// We're at the end of the table, we just have to insert all the matching
// elements that remain in the array. We can use a fragment to make the
// insertions faster, which is useful during the initial filtering.
if (!fragment) {
fragment = document.createDocumentFragment();
}
fragment.appendChild(prefInArray.getElement());
}
else if (prefInTable == prefInArray) {
// We got two matching elements, we just need to update the visibility.
elementInTable = elementInTable.nextElementSibling;
}
else if (prefInArray && prefInArray.name < prefInTable.name) {
// The iteration in the table is ahead of the iteration in the array.
// Insert or move the array element, and advance the array index.
gPrefsTable.insertBefore(prefInArray.getElement(), elementInTable);
}
else {
// The iteration in the array is ahead of the iteration in the table.
// Hide the element in the table, and advance to the next element.
let nextElementInTable = elementInTable.nextElementSibling;
if (!prefInTable.exists) {
// Remove rows for deleted preferences, or temporary addition rows.
elementInTable.remove();
}
else {
// Keep the element for the next filtering if the preference exists.
prefInTable.hidden =
true;
prefInTable.refreshClass();
}
elementInTable = nextElementInTable;
continue;
}
prefInArray.refreshClass();
odd = !odd;
indexInArray++;
hasVisiblePrefs =
true;
}
if (fragment) {
gPrefsTable.appendChild(fragment);
}
gPrefsTable.toggleAttribute(
"has-visible-prefs", hasVisiblePrefs);
if (searchName && !gExistingPrefs.has(searchName)) {
let addPrefRow =
new PrefRow(searchName, { isAddRow:
true });
addPrefRow.odd = odd;
gPrefsTable.appendChild(addPrefRow.getElement());
}
// We only start observing preference changes after the first search is done,
// so that newly added preferences won't appear while the page is still empty.
if (!gPrefObserverRegistered) {
gPrefObserverRegistered =
true;
Services.prefs.addObserver(
"", gPrefObserver);
window.addEventListener(
"unload",
() => {
Services.prefs.removeObserver(
"", gPrefObserver);
},
{ once:
true }
);
}
}