/* - 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/. */
"use strict";
// We attach Preferences to the window object so other contexts (tests, JSMs)
// have access to it.
const Preferences = (window.Preferences = (
function () {
const { EventEmitter } = ChromeUtils.importESModule(
"resource://gre/modules/EventEmitter.sys.mjs"
);
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
DeferredTask:
"resource://gre/modules/DeferredTask.sys.mjs",
});
function getElementsByAttribute(name, value) {
// If we needed to defend against arbitrary values, we would escape
// double quotes (") and escape characters (\) in them, i.e.:
// ${value.replace(/["\\]/g, '\\$&')}
return value
? document.querySelectorAll(`[${name}=
"${value}"]`)
: document.querySelectorAll(`[${name}]`);
}
const domContentLoadedPromise =
new Promise(resolve => {
window.addEventListener(
"DOMContentLoaded", resolve, {
capture:
true,
once:
true,
});
});
const Preferences = {
_all: {},
_add(prefInfo) {
if (
this._all[prefInfo.id]) {
throw new Error(`preference with id
'${prefInfo.id}' already added`);
}
const pref =
new Preference(prefInfo);
this._all[pref.id] = pref;
domContentLoadedPromise.then(() => {
if (!
this.updateQueued) {
pref.updateElements();
}
});
return pref;
},
add(prefInfo) {
const pref =
this._add(prefInfo);
return pref;
},
addAll(prefInfos) {
prefInfos.map(prefInfo =>
this._add(prefInfo));
},
get(id) {
return this._all[id] ||
null;
},
getAll() {
return Object.values(
this._all);
},
defaultBranch: Services.prefs.getDefaultBranch(
""),
get type() {
return document.documentElement.getAttribute(
"type") ||
"";
},
get instantApply() {
// The about:preferences page forces instantApply.
// TODO: Remove forceEnableInstantApply in favor of always applying in a
// parent and never applying in a child (bug 1775386).
if (
this._instantApplyForceEnabled) {
return true;
}
// Dialogs of type="child" are never instantApply.
return this.type !==
"child";
},
_instantApplyForceEnabled:
false,
// Override the computed value of instantApply for this window.
forceEnableInstantApply() {
this._instantApplyForceEnabled =
true;
},
observe(subject, topic, data) {
const pref =
this._all[data];
if (pref) {
pref.value = pref.valueFromPreferences;
}
},
updateQueued:
false,
queueUpdateOfAllElements() {
if (
this.updateQueued) {
return;
}
this.updateQueued =
true;
Services.tm.dispatchToMainThread(() => {
let startTime = performance.now();
const elements = getElementsByAttribute(
"preference");
for (
const element of elements) {
const id = element.getAttribute(
"preference");
let preference =
this.get(id);
if (!preference) {
console.error(`Missing preference
for ID ${id}`);
continue;
}
preference.setElementValue(element);
}
ChromeUtils.addProfilerMarker(
"Preferences",
{ startTime },
`updateAllElements: ${elements.length} preferences updated`
);
this.updateQueued =
false;
});
},
onUnload() {
Services.prefs.removeObserver(
"",
this);
},
QueryInterface: ChromeUtils.generateQI([
"nsITimerCallback",
"nsIObserver"]),
_deferredValueUpdateElements:
new Set(),
writePreferences(aFlushToDisk) {
// Write all values to preferences.
if (
this._deferredValueUpdateElements.size) {
this._finalizeDeferredElements();
}
const preferences = Preferences.getAll();
for (
const preference of preferences) {
preference.batching =
true;
preference.valueFromPreferences = preference.value;
preference.batching =
false;
}
if (aFlushToDisk) {
Services.prefs.savePrefFile(
null);
}
},
getPreferenceElement(aStartElement) {
let temp = aStartElement;
while (
temp &&
temp.nodeType == Node.ELEMENT_NODE &&
!temp.hasAttribute(
"preference")
) {
temp = temp.parentNode;
}
return temp && temp.nodeType == Node.ELEMENT_NODE ? temp : aStartElement;
},
_deferredValueUpdate(aElement) {
delete aElement._deferredValueUpdateTask;
const prefID = aElement.getAttribute(
"preference");
const preference = Preferences.get(prefID);
const prefVal = preference.getElementValue(aElement);
preference.value = prefVal;
this._deferredValueUpdateElements.
delete(aElement);
},
_finalizeDeferredElements() {
for (
const el of
this._deferredValueUpdateElements) {
if (el._deferredValueUpdateTask) {
el._deferredValueUpdateTask.finalize();
}
}
},
userChangedValue(aElement) {
const element =
this.getPreferenceElement(aElement);
if (element.hasAttribute(
"preference")) {
if (element.getAttribute(
"delayprefsave") !=
"true") {
const preference = Preferences.get(
element.getAttribute(
"preference")
);
const prefVal = preference.getElementValue(element);
preference.value = prefVal;
}
else {
if (!element._deferredValueUpdateTask) {
element._deferredValueUpdateTask =
new lazy.DeferredTask(
this._deferredValueUpdate.bind(
this, element),
1000
);
this._deferredValueUpdateElements.add(element);
}
else {
// Each time the preference is changed, restart the delay.
element._deferredValueUpdateTask.disarm();
}
element._deferredValueUpdateTask.arm();
}
}
},
onCommand(event) {
// This "command" event handler tracks changes made to preferences by
// the user in this window.
if (event.sourceEvent) {
event = event.sourceEvent;
}
this.userChangedValue(event.target);
},
onChange(event) {
// This "change" event handler tracks changes made to preferences by
// the user in this window.
this.userChangedValue(event.target);
},
onInput(event) {
// This "input" event handler tracks changes made to preferences by
// the user in this window.
this.userChangedValue(event.target);
},
_fireEvent(aEventName, aTarget) {
try {
const event =
new CustomEvent(aEventName, {
bubbles:
true,
cancelable:
true,
});
return aTarget.dispatchEvent(event);
}
catch (e) {
console.error(e);
}
return false;
},
onDialogAccept(event) {
let dialog = document.querySelector(
"dialog");
if (!
this._fireEvent(
"beforeaccept", dialog)) {
event.preventDefault();
return false;
}
this.writePreferences(
true);
return true;
},
close(event) {
if (Preferences.instantApply) {
window.close();
}
event.stopPropagation();
event.preventDefault();
},
handleEvent(event) {
switch (event.type) {
case "toggle":
case "change":
return this.onChange(event);
case "command":
return this.onCommand(event);
case "dialogaccept":
return this.onDialogAccept(event);
case "input":
return this.onInput(event);
case "unload":
return this.onUnload(event);
default:
return undefined;
}
},
_syncFromPrefListeners:
new WeakMap(),
_syncToPrefListeners:
new WeakMap(),
addSyncFromPrefListener(aElement, callback) {
this._syncFromPrefListeners.set(aElement, callback);
if (
this.updateQueued) {
return;
}
// Make sure elements are updated correctly with the listener attached.
let elementPref = aElement.getAttribute(
"preference");
if (elementPref) {
let pref =
this.get(elementPref);
if (pref) {
pref.updateElements();
}
}
},
addSyncToPrefListener(aElement, callback) {
this._syncToPrefListeners.set(aElement, callback);
if (
this.updateQueued) {
return;
}
// Make sure elements are updated correctly with the listener attached.
let elementPref = aElement.getAttribute(
"preference");
if (elementPref) {
let pref =
this.get(elementPref);
if (pref) {
pref.updateElements();
}
}
},
removeSyncFromPrefListener(aElement) {
this._syncFromPrefListeners.
delete(aElement);
},
removeSyncToPrefListener(aElement) {
this._syncToPrefListeners.
delete(aElement);
},
};
Services.prefs.addObserver(
"", Preferences);
window.addEventListener(
"toggle", Preferences);
window.addEventListener(
"change", Preferences);
window.addEventListener(
"command", Preferences);
window.addEventListener(
"dialogaccept", Preferences);
window.addEventListener(
"input", Preferences);
window.addEventListener(
"select", Preferences);
window.addEventListener(
"unload", Preferences, { once:
true });
class Preference
extends EventEmitter {
constructor({ id, type, inverted }) {
super();
this.on(
"change",
this.onChange.bind(
this));
this._value =
null;
this.readonly =
false;
this._useDefault =
false;
this.batching =
false;
this.id = id;
this.type = type;
this.inverted = !!inverted;
// In non-instant apply mode, we must try and use the last saved state
// from any previous opens of a child dialog instead of the value from
// preferences, to pick up any edits a user may have made.
if (
Preferences.type ==
"child" &&
window.opener &&
window.opener.Preferences &&
window.opener.document.nodePrincipal.isSystemPrincipal
) {
// Try to find the preference in the parent window.
const preference = window.opener.Preferences.get(
this.id);
// Don't use the value setter here, we don't want updateElements to be
// prematurely fired.
this._value = preference ? preference.value :
this.valueFromPreferences;
}
else {
this._value =
this.valueFromPreferences;
}
}
reset() {
// defer reset until preference update
this.value = undefined;
}
_reportUnknownType() {
const msg = `Preference with id=${
this.id} has unknown type ${
this.type}.`;
Services.console.logStringMessage(msg);
}
setElementValue(aElement) {
if (
this.locked) {
aElement.disabled =
true;
}
if (aElement.labels?.length) {
for (let label of aElement.labels) {
label.toggleAttribute(
"disabled",
this.locked);
}
}
if (!
this.isElementEditable(aElement)) {
return;
}
let rv = undefined;
if (Preferences._syncFromPrefListeners.has(aElement)) {
rv = Preferences._syncFromPrefListeners.get(aElement)(aElement);
}
let val = rv;
if (val === undefined) {
val = Preferences.instantApply ?
this.valueFromPreferences :
this.value;
}
// if the preference is marked for reset, show default value in UI
if (val === undefined) {
val =
this.defaultValue;
}
/**
* Initialize a UI element property with a value. Handles the case
* where an element has not yet had a XBL binding attached for it and
* the property setter does not yet exist by setting the same attribute
* on the XUL element using DOM apis and assuming the element's
* constructor or property getters appropriately handle this state.
*/
function setValue(element, attribute, value) {
if (attribute in element) {
element[attribute] = value;
}
else if (attribute ===
"checked" || attribute ===
"pressed") {
// The "checked" attribute can't simply be set to the specified value;
// it has to be set if the value is true and removed if the value
// is false in order to be interpreted correctly by the element.
if (value) {
// In theory we can set it to anything; however xbl implementation
// of `checkbox` only works with "true".
element.setAttribute(attribute,
"true");
}
else {
element.removeAttribute(attribute);
}
}
else {
element.setAttribute(attribute, value);
}
}
if (
aElement.localName ==
"checkbox" ||
aElement.localName ==
"moz-checkbox" ||
(aElement.localName ==
"input" && aElement.type ==
"checkbox")
) {
setValue(aElement,
"checked", val);
}
else if (aElement.localName ==
"moz-toggle") {
setValue(aElement,
"pressed", val);
}
else {
setValue(aElement,
"value", val);
}
}
getElementValue(aElement) {
if (Preferences._syncToPrefListeners.has(aElement)) {
try {
const rv = Preferences._syncToPrefListeners.get(aElement)(aElement);
if (rv !== undefined) {
return rv;
}
}
catch (e) {
console.error(e);
}
}
/**
* Read the value of an attribute from an element, assuming the
* attribute is a property on the element's node API. If the property
* is not present in the API, then assume its value is contained in
* an attribute, as is the case before a binding has been attached.
*/
function getValue(element, attribute) {
if (attribute in element) {
return element[attribute];
}
return element.getAttribute(attribute);
}
let value;
if (
aElement.localName ==
"checkbox" ||
aElement.localName ==
"moz-checkbox" ||
(aElement.localName ==
"input" && aElement.type ==
"checkbox")
) {
value = getValue(aElement,
"checked");
}
else if (aElement.localName ==
"moz-toggle") {
value = getValue(aElement,
"pressed");
}
else {
value = getValue(aElement,
"value");
}
switch (
this.type) {
case "int":
return parseInt(value, 10) || 0;
case "bool":
return typeof value ==
"boolean" ? value : value ==
"true";
}
return value;
}
isElementEditable(aElement) {
switch (aElement.localName) {
case "checkbox":
case "input":
case "radiogroup":
case "textarea":
case "menulist":
case "moz-toggle":
case "moz-checkbox":
return true;
}
return false;
}
updateElements() {
let startTime = performance.now();
if (!
this.id) {
return;
}
const elements = getElementsByAttribute(
"preference",
this.id);
for (
const element of elements) {
this.setElementValue(element);
}
ChromeUtils.addProfilerMarker(
"Preferences",
{ startTime, captureStack:
true },
`updateElements
for ${
this.id}`
);
}
onChange() {
this.updateElements();
}
get value() {
return this._value;
}
set value(val) {
if (
this.value !== val) {
this._value = val;
if (Preferences.instantApply) {
this.valueFromPreferences = val;
}
this.emit(
"change");
}
}
get locked() {
return Services.prefs.prefIsLocked(
this.id);
}
updateControlDisabledState(val) {
if (!
this.id) {
return;
}
val = val ||
this.locked;
const elements = getElementsByAttribute(
"preference",
this.id);
for (
const element of elements) {
element.disabled = val;
const labels = getElementsByAttribute(
"control", element.id);
for (
const label of labels) {
label.disabled = val;
}
}
}
get hasUserValue() {
return (
Services.prefs.prefHasUserValue(
this.id) &&
this.value !== undefined
);
}
get defaultValue() {
this._useDefault =
true;
const val =
this.valueFromPreferences;
this._useDefault =
false;
return val;
}
get _branch() {
return this._useDefault ? Preferences.defaultBranch : Services.prefs;
}
get valueFromPreferences() {
try {
// Force a resync of value with preferences.
switch (
this.type) {
case "int":
return this._branch.getIntPref(
this.id);
case "bool": {
const val =
this._branch.getBoolPref(
this.id);
return this.inverted ? !val : val;
}
case "wstring":
return this._branch.getComplexValue(
this.id,
Ci.nsIPrefLocalizedString
).data;
case "string":
case "unichar":
return this._branch.getStringPref(
this.id);
case "fontname": {
const family =
this._branch.getStringPref(
this.id);
const fontEnumerator = Cc[
"@mozilla.org/gfx/fontenumerator;1"
].createInstance(Ci.nsIFontEnumerator);
return fontEnumerator.getStandardFamilyName(family);
}
case "file": {
const f =
this._branch.getComplexValue(
this.id, Ci.nsIFile);
return f;
}
default:
this._reportUnknownType();
}
}
catch (e) {}
return null;
}
set valueFromPreferences(val) {
// Exit early if nothing to do.
if (
this.readonly ||
this.valueFromPreferences == val) {
return;
}
// The special value undefined means 'reset preference to default'.
if (val === undefined) {
Services.prefs.clearUserPref(
this.id);
return;
}
// Force a resync of preferences with value.
switch (
this.type) {
case "int":
Services.prefs.setIntPref(
this.id, val);
break;
case "bool":
Services.prefs.setBoolPref(
this.id,
this.inverted ? !val : val);
break;
case "wstring": {
const pls = Cc[
"@mozilla.org/pref-localizedstring;1"].createInstance(
Ci.nsIPrefLocalizedString
);
pls.data = val;
Services.prefs.setComplexValue(
this.id,
Ci.nsIPrefLocalizedString,
pls
);
break;
}
case "string":
case "unichar":
case "fontname":
Services.prefs.setStringPref(
this.id, val);
break;
case "file": {
let lf;
if (
typeof val ==
"string") {
lf = Cc[
"@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
lf.persistentDescriptor = val;
if (!lf.exists()) {
lf.initWithPath(val);
}
}
else {
lf = val.QueryInterface(Ci.nsIFile);
}
Services.prefs.setComplexValue(
this.id, Ci.nsIFile, lf);
break;
}
default:
this._reportUnknownType();
}
if (!
this.batching) {
Services.prefs.savePrefFile(
null);
}
}
}
return Preferences;
})());