summaryrefslogtreecommitdiffstats
path: root/toolkit/content/preferencesBindings.js
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/content/preferencesBindings.js')
-rw-r--r--toolkit/content/preferencesBindings.js671
1 files changed, 671 insertions, 0 deletions
diff --git a/toolkit/content/preferencesBindings.js b/toolkit/content/preferencesBindings.js
new file mode 100644
index 0000000000..b2e4070cf5
--- /dev/null
+++ b/toolkit/content/preferencesBindings.js
@@ -0,0 +1,671 @@
+/* - 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 (!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 == "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 == "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":
+ 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;
+})());