Bug 1798651 Part 1: Make SynchronousTask accept a wait interval, and return result...
[gecko.git] / toolkit / content / preferencesBindings.js
blobe2e6494f9a758d474612fd8cf49390c6c370806a
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 file,
3    - You can obtain one at http://mozilla.org/MPL/2.0/. */
5 "use strict";
7 // We attach Preferences to the window object so other contexts (tests, JSMs)
8 // have access to it.
9 const Preferences = (window.Preferences = (function() {
10   const { EventEmitter } = ChromeUtils.importESModule(
11     "resource://gre/modules/EventEmitter.sys.mjs"
12   );
14   const lazy = {};
15   ChromeUtils.defineESModuleGetters(lazy, {
16     DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
17   });
19   function getElementsByAttribute(name, value) {
20     // If we needed to defend against arbitrary values, we would escape
21     // double quotes (") and escape characters (\) in them, i.e.:
22     //   ${value.replace(/["\\]/g, '\\$&')}
23     return value
24       ? document.querySelectorAll(`[${name}="${value}"]`)
25       : document.querySelectorAll(`[${name}]`);
26   }
28   const domContentLoadedPromise = new Promise(resolve => {
29     window.addEventListener("DOMContentLoaded", resolve, {
30       capture: true,
31       once: true,
32     });
33   });
35   const Preferences = {
36     _all: {},
38     _add(prefInfo) {
39       if (this._all[prefInfo.id]) {
40         throw new Error(`preference with id '${prefInfo.id}' already added`);
41       }
42       const pref = new Preference(prefInfo);
43       this._all[pref.id] = pref;
44       domContentLoadedPromise.then(() => {
45         if (!this.updateQueued) {
46           pref.updateElements();
47         }
48       });
49       return pref;
50     },
52     add(prefInfo) {
53       const pref = this._add(prefInfo);
54       return pref;
55     },
57     addAll(prefInfos) {
58       prefInfos.map(prefInfo => this._add(prefInfo));
59     },
61     get(id) {
62       return this._all[id] || null;
63     },
65     getAll() {
66       return Object.values(this._all);
67     },
69     defaultBranch: Services.prefs.getDefaultBranch(""),
71     get type() {
72       return document.documentElement.getAttribute("type") || "";
73     },
75     get instantApply() {
76       // The about:preferences page forces instantApply.
77       // TODO: Remove forceEnableInstantApply in favor of always applying in a
78       // parent and never applying in a child (bug 1775386).
79       if (this._instantApplyForceEnabled) {
80         return true;
81       }
83       // Dialogs of type="child" are never instantApply.
84       return this.type !== "child";
85     },
87     _instantApplyForceEnabled: false,
89     // Override the computed value of instantApply for this window.
90     forceEnableInstantApply() {
91       this._instantApplyForceEnabled = true;
92     },
94     observe(subject, topic, data) {
95       const pref = this._all[data];
96       if (pref) {
97         pref.value = pref.valueFromPreferences;
98       }
99     },
101     updateQueued: false,
103     queueUpdateOfAllElements() {
104       if (this.updateQueued) {
105         return;
106       }
108       this.updateQueued = true;
110       Services.tm.dispatchToMainThread(() => {
111         let startTime = performance.now();
113         const elements = getElementsByAttribute("preference");
114         for (const element of elements) {
115           const id = element.getAttribute("preference");
116           let preference = this.get(id);
117           if (!preference) {
118             console.error(`Missing preference for ID ${id}`);
119             continue;
120           }
122           preference.setElementValue(element);
123         }
125         ChromeUtils.addProfilerMarker(
126           "Preferences",
127           { startTime },
128           `updateAllElements: ${elements.length} preferences updated`
129         );
131         this.updateQueued = false;
132       });
133     },
135     onUnload() {
136       Services.prefs.removeObserver("", this);
137     },
139     QueryInterface: ChromeUtils.generateQI(["nsITimerCallback", "nsIObserver"]),
141     _deferredValueUpdateElements: new Set(),
143     writePreferences(aFlushToDisk) {
144       // Write all values to preferences.
145       if (this._deferredValueUpdateElements.size) {
146         this._finalizeDeferredElements();
147       }
149       const preferences = Preferences.getAll();
150       for (const preference of preferences) {
151         preference.batching = true;
152         preference.valueFromPreferences = preference.value;
153         preference.batching = false;
154       }
155       if (aFlushToDisk) {
156         Services.prefs.savePrefFile(null);
157       }
158     },
160     getPreferenceElement(aStartElement) {
161       let temp = aStartElement;
162       while (
163         temp &&
164         temp.nodeType == Node.ELEMENT_NODE &&
165         !temp.hasAttribute("preference")
166       ) {
167         temp = temp.parentNode;
168       }
169       return temp && temp.nodeType == Node.ELEMENT_NODE ? temp : aStartElement;
170     },
172     _deferredValueUpdate(aElement) {
173       delete aElement._deferredValueUpdateTask;
174       const prefID = aElement.getAttribute("preference");
175       const preference = Preferences.get(prefID);
176       const prefVal = preference.getElementValue(aElement);
177       preference.value = prefVal;
178       this._deferredValueUpdateElements.delete(aElement);
179     },
181     _finalizeDeferredElements() {
182       for (const el of this._deferredValueUpdateElements) {
183         if (el._deferredValueUpdateTask) {
184           el._deferredValueUpdateTask.finalize();
185         }
186       }
187     },
189     userChangedValue(aElement) {
190       const element = this.getPreferenceElement(aElement);
191       if (element.hasAttribute("preference")) {
192         if (element.getAttribute("delayprefsave") != "true") {
193           const preference = Preferences.get(
194             element.getAttribute("preference")
195           );
196           const prefVal = preference.getElementValue(element);
197           preference.value = prefVal;
198         } else {
199           if (!element._deferredValueUpdateTask) {
200             element._deferredValueUpdateTask = new lazy.DeferredTask(
201               this._deferredValueUpdate.bind(this, element),
202               1000
203             );
204             this._deferredValueUpdateElements.add(element);
205           } else {
206             // Each time the preference is changed, restart the delay.
207             element._deferredValueUpdateTask.disarm();
208           }
209           element._deferredValueUpdateTask.arm();
210         }
211       }
212     },
214     onCommand(event) {
215       // This "command" event handler tracks changes made to preferences by
216       // the user in this window.
217       if (event.sourceEvent) {
218         event = event.sourceEvent;
219       }
220       this.userChangedValue(event.target);
221     },
223     onChange(event) {
224       // This "change" event handler tracks changes made to preferences by
225       // the user in this window.
226       this.userChangedValue(event.target);
227     },
229     onInput(event) {
230       // This "input" event handler tracks changes made to preferences by
231       // the user in this window.
232       this.userChangedValue(event.target);
233     },
235     _fireEvent(aEventName, aTarget) {
236       try {
237         const event = new CustomEvent(aEventName, {
238           bubbles: true,
239           cancelable: true,
240         });
241         return aTarget.dispatchEvent(event);
242       } catch (e) {
243         Cu.reportError(e);
244       }
245       return false;
246     },
248     onDialogAccept(event) {
249       let dialog = document.querySelector("dialog");
250       if (!this._fireEvent("beforeaccept", dialog)) {
251         event.preventDefault();
252         return false;
253       }
254       this.writePreferences(true);
255       return true;
256     },
258     close(event) {
259       if (Preferences.instantApply) {
260         window.close();
261       }
262       event.stopPropagation();
263       event.preventDefault();
264     },
266     handleEvent(event) {
267       switch (event.type) {
268         case "change":
269           return this.onChange(event);
270         case "command":
271           return this.onCommand(event);
272         case "dialogaccept":
273           return this.onDialogAccept(event);
274         case "input":
275           return this.onInput(event);
276         case "unload":
277           return this.onUnload(event);
278         default:
279           return undefined;
280       }
281     },
283     _syncFromPrefListeners: new WeakMap(),
284     _syncToPrefListeners: new WeakMap(),
286     addSyncFromPrefListener(aElement, callback) {
287       this._syncFromPrefListeners.set(aElement, callback);
288       if (this.updateQueued) {
289         return;
290       }
291       // Make sure elements are updated correctly with the listener attached.
292       let elementPref = aElement.getAttribute("preference");
293       if (elementPref) {
294         let pref = this.get(elementPref);
295         if (pref) {
296           pref.updateElements();
297         }
298       }
299     },
301     addSyncToPrefListener(aElement, callback) {
302       this._syncToPrefListeners.set(aElement, callback);
303       if (this.updateQueued) {
304         return;
305       }
306       // Make sure elements are updated correctly with the listener attached.
307       let elementPref = aElement.getAttribute("preference");
308       if (elementPref) {
309         let pref = this.get(elementPref);
310         if (pref) {
311           pref.updateElements();
312         }
313       }
314     },
316     removeSyncFromPrefListener(aElement) {
317       this._syncFromPrefListeners.delete(aElement);
318     },
320     removeSyncToPrefListener(aElement) {
321       this._syncToPrefListeners.delete(aElement);
322     },
323   };
325   Services.prefs.addObserver("", Preferences);
326   window.addEventListener("change", Preferences);
327   window.addEventListener("command", Preferences);
328   window.addEventListener("dialogaccept", Preferences);
329   window.addEventListener("input", Preferences);
330   window.addEventListener("select", Preferences);
331   window.addEventListener("unload", Preferences, { once: true });
333   class Preference extends EventEmitter {
334     constructor({ id, type, inverted }) {
335       super();
336       this.on("change", this.onChange.bind(this));
338       this._value = null;
339       this.readonly = false;
340       this._useDefault = false;
341       this.batching = false;
343       this.id = id;
344       this.type = type;
345       this.inverted = !!inverted;
347       // In non-instant apply mode, we must try and use the last saved state
348       // from any previous opens of a child dialog instead of the value from
349       // preferences, to pick up any edits a user may have made.
351       if (
352         Preferences.type == "child" &&
353         window.opener &&
354         window.opener.Preferences &&
355         window.opener.document.nodePrincipal.isSystemPrincipal
356       ) {
357         // Try to find the preference in the parent window.
358         const preference = window.opener.Preferences.get(this.id);
360         // Don't use the value setter here, we don't want updateElements to be
361         // prematurely fired.
362         this._value = preference ? preference.value : this.valueFromPreferences;
363       } else {
364         this._value = this.valueFromPreferences;
365       }
366     }
368     reset() {
369       // defer reset until preference update
370       this.value = undefined;
371     }
373     _reportUnknownType() {
374       const msg = `Preference with id=${this.id} has unknown type ${this.type}.`;
375       Services.console.logStringMessage(msg);
376     }
378     setElementValue(aElement) {
379       if (this.locked) {
380         aElement.disabled = true;
381       }
383       if (!this.isElementEditable(aElement)) {
384         return;
385       }
387       let rv = undefined;
389       if (Preferences._syncFromPrefListeners.has(aElement)) {
390         rv = Preferences._syncFromPrefListeners.get(aElement)(aElement);
391       }
392       let val = rv;
393       if (val === undefined) {
394         val = Preferences.instantApply ? this.valueFromPreferences : this.value;
395       }
396       // if the preference is marked for reset, show default value in UI
397       if (val === undefined) {
398         val = this.defaultValue;
399       }
401       /**
402        * Initialize a UI element property with a value. Handles the case
403        * where an element has not yet had a XBL binding attached for it and
404        * the property setter does not yet exist by setting the same attribute
405        * on the XUL element using DOM apis and assuming the element's
406        * constructor or property getters appropriately handle this state.
407        */
408       function setValue(element, attribute, value) {
409         if (attribute in element) {
410           element[attribute] = value;
411         } else if (attribute === "checked") {
412           // The "checked" attribute can't simply be set to the specified value;
413           // it has to be set if the value is true and removed if the value
414           // is false in order to be interpreted correctly by the element.
415           if (value) {
416             // In theory we can set it to anything; however xbl implementation
417             // of `checkbox` only works with "true".
418             element.setAttribute(attribute, "true");
419           } else {
420             element.removeAttribute(attribute);
421           }
422         } else {
423           element.setAttribute(attribute, value);
424         }
425       }
426       if (
427         aElement.localName == "checkbox" ||
428         (aElement.localName == "input" && aElement.type == "checkbox")
429       ) {
430         setValue(aElement, "checked", val);
431       } else {
432         setValue(aElement, "value", val);
433       }
434     }
436     getElementValue(aElement) {
437       if (Preferences._syncToPrefListeners.has(aElement)) {
438         try {
439           const rv = Preferences._syncToPrefListeners.get(aElement)(aElement);
440           if (rv !== undefined) {
441             return rv;
442           }
443         } catch (e) {
444           Cu.reportError(e);
445         }
446       }
448       /**
449        * Read the value of an attribute from an element, assuming the
450        * attribute is a property on the element's node API. If the property
451        * is not present in the API, then assume its value is contained in
452        * an attribute, as is the case before a binding has been attached.
453        */
454       function getValue(element, attribute) {
455         if (attribute in element) {
456           return element[attribute];
457         }
458         return element.getAttribute(attribute);
459       }
460       let value;
461       if (
462         aElement.localName == "checkbox" ||
463         (aElement.localName == "input" && aElement.type == "checkbox")
464       ) {
465         value = getValue(aElement, "checked");
466       } else {
467         value = getValue(aElement, "value");
468       }
470       switch (this.type) {
471         case "int":
472           return parseInt(value, 10) || 0;
473         case "bool":
474           return typeof value == "boolean" ? value : value == "true";
475       }
476       return value;
477     }
479     isElementEditable(aElement) {
480       switch (aElement.localName) {
481         case "checkbox":
482         case "input":
483         case "radiogroup":
484         case "textarea":
485         case "menulist":
486           return true;
487       }
488       return false;
489     }
491     updateElements() {
492       let startTime = performance.now();
494       if (!this.id) {
495         return;
496       }
498       const elements = getElementsByAttribute("preference", this.id);
499       for (const element of elements) {
500         this.setElementValue(element);
501       }
503       ChromeUtils.addProfilerMarker(
504         "Preferences",
505         { startTime, captureStack: true },
506         `updateElements for ${this.id}`
507       );
508     }
510     onChange() {
511       this.updateElements();
512     }
514     get value() {
515       return this._value;
516     }
518     set value(val) {
519       if (this.value !== val) {
520         this._value = val;
521         if (Preferences.instantApply) {
522           this.valueFromPreferences = val;
523         }
524         this.emit("change");
525       }
526     }
528     get locked() {
529       return Services.prefs.prefIsLocked(this.id);
530     }
532     updateControlDisabledState(val) {
533       if (!this.id) {
534         return;
535       }
537       val = val || this.locked;
539       const elements = getElementsByAttribute("preference", this.id);
540       for (const element of elements) {
541         element.disabled = val;
543         const labels = getElementsByAttribute("control", element.id);
544         for (const label of labels) {
545           label.disabled = val;
546         }
547       }
548     }
550     get hasUserValue() {
551       return (
552         Services.prefs.prefHasUserValue(this.id) && this.value !== undefined
553       );
554     }
556     get defaultValue() {
557       this._useDefault = true;
558       const val = this.valueFromPreferences;
559       this._useDefault = false;
560       return val;
561     }
563     get _branch() {
564       return this._useDefault ? Preferences.defaultBranch : Services.prefs;
565     }
567     get valueFromPreferences() {
568       try {
569         // Force a resync of value with preferences.
570         switch (this.type) {
571           case "int":
572             return this._branch.getIntPref(this.id);
573           case "bool": {
574             const val = this._branch.getBoolPref(this.id);
575             return this.inverted ? !val : val;
576           }
577           case "wstring":
578             return this._branch.getComplexValue(
579               this.id,
580               Ci.nsIPrefLocalizedString
581             ).data;
582           case "string":
583           case "unichar":
584             return this._branch.getStringPref(this.id);
585           case "fontname": {
586             const family = this._branch.getStringPref(this.id);
587             const fontEnumerator = Cc[
588               "@mozilla.org/gfx/fontenumerator;1"
589             ].createInstance(Ci.nsIFontEnumerator);
590             return fontEnumerator.getStandardFamilyName(family);
591           }
592           case "file": {
593             const f = this._branch.getComplexValue(this.id, Ci.nsIFile);
594             return f;
595           }
596           default:
597             this._reportUnknownType();
598         }
599       } catch (e) {}
600       return null;
601     }
603     set valueFromPreferences(val) {
604       // Exit early if nothing to do.
605       if (this.readonly || this.valueFromPreferences == val) {
606         return;
607       }
609       // The special value undefined means 'reset preference to default'.
610       if (val === undefined) {
611         Services.prefs.clearUserPref(this.id);
612         return;
613       }
615       // Force a resync of preferences with value.
616       switch (this.type) {
617         case "int":
618           Services.prefs.setIntPref(this.id, val);
619           break;
620         case "bool":
621           Services.prefs.setBoolPref(this.id, this.inverted ? !val : val);
622           break;
623         case "wstring": {
624           const pls = Cc["@mozilla.org/pref-localizedstring;1"].createInstance(
625             Ci.nsIPrefLocalizedString
626           );
627           pls.data = val;
628           Services.prefs.setComplexValue(
629             this.id,
630             Ci.nsIPrefLocalizedString,
631             pls
632           );
633           break;
634         }
635         case "string":
636         case "unichar":
637         case "fontname":
638           Services.prefs.setStringPref(this.id, val);
639           break;
640         case "file": {
641           let lf;
642           if (typeof val == "string") {
643             lf = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
644             lf.persistentDescriptor = val;
645             if (!lf.exists()) {
646               lf.initWithPath(val);
647             }
648           } else {
649             lf = val.QueryInterface(Ci.nsIFile);
650           }
651           Services.prefs.setComplexValue(this.id, Ci.nsIFile, lf);
652           break;
653         }
654         default:
655           this._reportUnknownType();
656       }
657       if (!this.batching) {
658         Services.prefs.savePrefFile(null);
659       }
660     }
661   }
663   return Preferences;
664 })());