Bug 1870926 [wpt PR 43734] - Remove experimental ::details-summary pseudo-element...
[gecko.git] / toolkit / components / extensions / ExtensionPreferencesManager.sys.mjs
blobb2b7bbac968cb045dd568b3535655ce962e1cb42
1 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
2 /* vim: set sts=2 sw=2 et tw=80: */
3 /* This Source Code Form is subject to the terms of the Mozilla Public
4  * License, v. 2.0. If a copy of the MPL was not distributed with this
5  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
7 /**
8  * @file
9  * This module is used for managing preferences from WebExtension APIs.
10  * It takes care of the precedence chain and decides whether a preference
11  * needs to be updated when a change is requested by an API.
12  *
13  * It deals with preferences via settings objects, which are objects with
14  * the following properties:
15  *
16  * prefNames:   An array of strings, each of which is a preference on
17  *              which the setting depends.
18  * setCallback: A function that returns an object containing properties and
19  *              values that correspond to the prefs to be set.
20  */
22 export let ExtensionPreferencesManager;
24 import { Management } from "resource://gre/modules/Extension.sys.mjs";
26 const lazy = {};
28 ChromeUtils.defineESModuleGetters(lazy, {
29   ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs",
30   ExtensionSettingsStore:
31     "resource://gre/modules/ExtensionSettingsStore.sys.mjs",
32   Preferences: "resource://gre/modules/Preferences.sys.mjs",
33 });
34 import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs";
36 const { ExtensionError } = ExtensionUtils;
38 ChromeUtils.defineLazyGetter(lazy, "defaultPreferences", function () {
39   return new lazy.Preferences({ defaultBranch: true });
40 });
42 /* eslint-disable mozilla/balanced-listeners */
43 Management.on("uninstall", async (type, { id }) => {
44   // Ensure managed preferences are cleared if they were
45   // not cleared at the module level.
46   await Management.asyncLoadSettingsModules();
47   return ExtensionPreferencesManager.removeAll(id);
48 });
50 Management.on("disable", async (type, id) => {
51   await Management.asyncLoadSettingsModules();
52   return ExtensionPreferencesManager.disableAll(id);
53 });
55 Management.on("enabling", async (type, id) => {
56   await Management.asyncLoadSettingsModules();
57   return ExtensionPreferencesManager.enableAll(id);
58 });
60 Management.on("change-permissions", (type, change) => {
61   // Called for added or removed, but we only care about removed here.
62   if (!change.removed) {
63     return;
64   }
65   ExtensionPreferencesManager.removeSettingsForPermissions(
66     change.extensionId,
67     change.removed.permissions
68   );
69 });
71 /* eslint-enable mozilla/balanced-listeners */
73 const STORE_TYPE = "prefs";
75 // Definitions of settings, each of which correspond to a different API.
76 let settingsMap = new Map();
78 /**
79  * This function is passed into the ExtensionSettingsStore to determine the
80  * initial value of the setting. It reads an array of preference names from
81  * the this scope, which gets bound to a settings object.
82  *
83  * @returns {object}
84  *          An object with one property per preference, which holds the current
85  *          value of that preference.
86  */
87 function initialValueCallback() {
88   let initialValue = {};
89   for (let pref of this.prefNames) {
90     // If there is a prior user-set value, get it.
91     if (lazy.Preferences.isSet(pref)) {
92       initialValue[pref] = lazy.Preferences.get(pref);
93     }
94   }
95   return initialValue;
98 /**
99  * Updates the initialValue stored to exclude any values that match
100  * default preference values.
102  * @param {object} initialValue Initial Value data from settings store.
103  * @returns {object}
104  *          The initialValue object after updating the values.
105  */
106 function settingsUpdate(initialValue) {
107   for (let pref of this.prefNames) {
108     try {
109       if (
110         initialValue[pref] !== undefined &&
111         initialValue[pref] === lazy.defaultPreferences.get(pref)
112       ) {
113         initialValue[pref] = undefined;
114       }
115     } catch (e) {
116       // Exception thrown if a default value doesn't exist.  We
117       // presume that this pref had a user-set value initially.
118     }
119   }
120   return initialValue;
124  * Loops through a set of prefs, either setting or resetting them.
126  * @param {string} name
127  *        The api name of the setting.
128  * @param {object} setting
129  *        An object that represents a setting, which will have a setCallback
130  *        property. If a onPrefsChanged function is provided it will be called
131  *        with item when the preferences change.
132  * @param {object} item
133  *        An object that represents an item handed back from the setting store
134  *        from which the new pref values can be calculated.
135  */
136 function setPrefs(name, setting, item) {
137   let prefs = item.initialValue || setting.setCallback(item.value);
138   let changed = false;
139   for (let pref of setting.prefNames) {
140     if (prefs[pref] === undefined) {
141       if (lazy.Preferences.isSet(pref)) {
142         changed = true;
143         lazy.Preferences.reset(pref);
144       }
145     } else if (lazy.Preferences.get(pref) != prefs[pref]) {
146       lazy.Preferences.set(pref, prefs[pref]);
147       changed = true;
148     }
149   }
150   if (changed && typeof setting.onPrefsChanged == "function") {
151     setting.onPrefsChanged(item);
152   }
153   Management.emit(`extension-setting-changed:${name}`);
157  * Commits a change to a setting and conditionally sets preferences.
159  * If the change to the setting causes a different extension to gain
160  * control of the pref (or removes all extensions with control over the pref)
161  * then the prefs should be updated, otherwise they should not be.
162  * In addition, if the current value of any of the prefs does not
163  * match what we expect the value to be (which could be the result of a
164  * user manually changing the pref value), then we do not change any
165  * of the prefs.
167  * @param {string} id
168  *        The id of the extension for which a setting is being modified.  Also
169  *        see selectSetting.
170  * @param {string} name
171  *        The name of the setting being processed.
172  * @param {string} action
173  *        The action that is being performed. Will be one of disable, enable
174  *        or removeSetting.
176  * @returns {Promise}
177  *          Resolves to true if preferences were set as a result and to false
178  *          if preferences were not set.
180 async function processSetting(id, name, action) {
181   await lazy.ExtensionSettingsStore.initialize();
182   let expectedItem = lazy.ExtensionSettingsStore.getSetting(STORE_TYPE, name);
183   let item = lazy.ExtensionSettingsStore[action](id, STORE_TYPE, name);
184   if (item) {
185     let setting = settingsMap.get(name);
186     let expectedPrefs =
187       expectedItem.initialValue || setting.setCallback(expectedItem.value);
188     if (
189       Object.keys(expectedPrefs).some(
190         pref =>
191           expectedPrefs[pref] &&
192           lazy.Preferences.get(pref) != expectedPrefs[pref]
193       )
194     ) {
195       return false;
196     }
197     setPrefs(name, setting, item);
198     return true;
199   }
200   return false;
203 ExtensionPreferencesManager = {
204   /**
205    * Adds a setting to the settingsMap. This is how an API tells the
206    * preferences manager what its setting object is. The preferences
207    * manager needs to know this when settings need to be removed
208    * automatically.
209    *
210    * @param {string} name The unique id of the setting.
211    * @param {object} setting
212    *        A setting object that should have properties for
213    *        prefNames, getCallback and setCallback.
214    */
215   addSetting(name, setting) {
216     settingsMap.set(name, setting);
217   },
219   /**
220    * Gets the default value for a preference.
221    *
222    * @param {string} prefName The name of the preference.
223    *
224    * @returns {string|number|boolean} The default value of the preference.
225    */
226   getDefaultValue(prefName) {
227     return lazy.defaultPreferences.get(prefName);
228   },
230   /**
231    * Returns a map of prefName to setting Name for use in about:config, about:preferences or
232    * other areas of Firefox that need to know whether a specific pref is controlled by an
233    * extension.
234    *
235    * Given a prefName, you can get the settingName.  Call EPM.getSetting(settingName) to
236    * get the details of the setting, including which id if any is in control of the
237    * setting.
238    *
239    * @returns {Promise}
240    *          Resolves to a Map of prefName->settingName
241    */
242   async getManagedPrefDetails() {
243     await Management.asyncLoadSettingsModules();
244     let prefs = new Map();
245     settingsMap.forEach((setting, name) => {
246       for (let prefName of setting.prefNames) {
247         prefs.set(prefName, name);
248       }
249     });
250     return prefs;
251   },
253   /**
254    * Indicates that an extension would like to change the value of a previously
255    * defined setting.
256    *
257    * @param {string} id
258    *        The id of the extension for which a setting is being set.
259    * @param {string} name
260    *        The unique id of the setting.
261    * @param {any} value
262    *        The value to be stored in the settings store for this
263    *        group of preferences.
264    *
265    * @returns {Promise}
266    *          Resolves to true if the preferences were changed and to false if
267    *          the preferences were not changed.
268    */
269   async setSetting(id, name, value) {
270     let setting = settingsMap.get(name);
271     await lazy.ExtensionSettingsStore.initialize();
272     let item = await lazy.ExtensionSettingsStore.addSetting(
273       id,
274       STORE_TYPE,
275       name,
276       value,
277       initialValueCallback.bind(setting),
278       name,
279       settingsUpdate.bind(setting)
280     );
281     if (item) {
282       setPrefs(name, setting, item);
283       return true;
284     }
285     return false;
286   },
288   /**
289    * Indicates that this extension wants to temporarily cede control over the
290    * given setting.
291    *
292    * @param {string} id
293    *        The id of the extension for which a preference setting is being disabled.
294    * @param {string} name
295    *        The unique id of the setting.
296    *
297    * @returns {Promise}
298    *          Resolves to true if the preferences were changed and to false if
299    *          the preferences were not changed.
300    */
301   disableSetting(id, name) {
302     return processSetting(id, name, "disable");
303   },
305   /**
306    * Enable a setting that has been disabled.
307    *
308    * @param {string} id
309    *        The id of the extension for which a setting is being enabled.
310    * @param {string} name
311    *        The unique id of the setting.
312    *
313    * @returns {Promise}
314    *          Resolves to true if the preferences were changed and to false if
315    *          the preferences were not changed.
316    */
317   enableSetting(id, name) {
318     return processSetting(id, name, "enable");
319   },
321   /**
322    * Specifically select an extension, the user, or the precedence order that will
323    * be in control of this setting.
324    *
325    * @param {string | null} id
326    *        The id of the extension for which a setting is being selected, or
327    *        ExtensionSettingStore.SETTING_USER_SET (null).
328    * @param {string} name
329    *        The unique id of the setting.
330    *
331    * @returns {Promise}
332    *          Resolves to true if the preferences were changed and to false if
333    *          the preferences were not changed.
334    */
335   selectSetting(id, name) {
336     return processSetting(id, name, "select");
337   },
339   /**
340    * Indicates that this extension no longer wants to set the given setting.
341    *
342    * @param {string} id
343    *        The id of the extension for which a preference setting is being removed.
344    * @param {string} name
345    *        The unique id of the setting.
346    *
347    * @returns {Promise}
348    *          Resolves to true if the preferences were changed and to false if
349    *          the preferences were not changed.
350    */
351   removeSetting(id, name) {
352     return processSetting(id, name, "removeSetting");
353   },
355   /**
356    * Disables all previously set settings for an extension. This can be called when
357    * an extension is being disabled, for example.
358    *
359    * @param {string} id
360    *        The id of the extension for which all settings are being unset.
361    */
362   async disableAll(id) {
363     await lazy.ExtensionSettingsStore.initialize();
364     let settings = lazy.ExtensionSettingsStore.getAllForExtension(
365       id,
366       STORE_TYPE
367     );
368     let disablePromises = [];
369     for (let name of settings) {
370       disablePromises.push(this.disableSetting(id, name));
371     }
372     await Promise.all(disablePromises);
373   },
375   /**
376    * Enables all disabled settings for an extension. This can be called when
377    * an extension has finished updating or is being re-enabled, for example.
378    *
379    * @param {string} id
380    *        The id of the extension for which all settings are being enabled.
381    */
382   async enableAll(id) {
383     await lazy.ExtensionSettingsStore.initialize();
384     let settings = lazy.ExtensionSettingsStore.getAllForExtension(
385       id,
386       STORE_TYPE
387     );
388     let enablePromises = [];
389     for (let name of settings) {
390       enablePromises.push(this.enableSetting(id, name));
391     }
392     await Promise.all(enablePromises);
393   },
395   /**
396    * Removes all previously set settings for an extension. This can be called when
397    * an extension is being uninstalled, for example.
398    *
399    * @param {string} id
400    *        The id of the extension for which all settings are being unset.
401    */
402   async removeAll(id) {
403     await lazy.ExtensionSettingsStore.initialize();
404     let settings = lazy.ExtensionSettingsStore.getAllForExtension(
405       id,
406       STORE_TYPE
407     );
408     let removePromises = [];
409     for (let name of settings) {
410       removePromises.push(this.removeSetting(id, name));
411     }
412     await Promise.all(removePromises);
413   },
415   /**
416    * Removes a set of settings that are available under certain addon permissions.
417    *
418    * @param {string} id
419    *        The extension id.
420    * @param {Array<string>} permissions
421    *        The permission name from the extension manifest.
422    * @returns {Promise}
423    *        A promise that resolves when all related settings are removed.
424    */
425   async removeSettingsForPermissions(id, permissions) {
426     if (!permissions || !permissions.length) {
427       return;
428     }
429     await Management.asyncLoadSettingsModules();
430     let removePromises = [];
431     settingsMap.forEach((setting, name) => {
432       if (permissions.includes(setting.permission)) {
433         removePromises.push(this.removeSetting(id, name));
434       }
435     });
436     return Promise.all(removePromises);
437   },
439   /**
440    * Return the currently active value for a setting.
441    *
442    * @param {string} name
443    *        The unique id of the setting.
444    *
445    * @returns {Promise<object>} The current setting object.
446    */
447   async getSetting(name) {
448     await lazy.ExtensionSettingsStore.initialize();
449     return lazy.ExtensionSettingsStore.getSetting(STORE_TYPE, name);
450   },
452   /**
453    * Return the levelOfControl for a setting / extension combo.
454    * This queries the levelOfControl from the ExtensionSettingsStore and also
455    * takes into account whether any of the setting's preferences are locked.
456    *
457    * @param {string} id
458    *        The id of the extension for which levelOfControl is being requested.
459    * @param {string} name
460    *        The unique id of the setting.
461    * @param {string} storeType
462    *        The name of the store in ExtensionSettingsStore.
463    *        Defaults to STORE_TYPE.
464    *
465    * @returns {Promise}
466    *          Resolves to the level of control of the extension over the setting.
467    */
468   async getLevelOfControl(id, name, storeType = STORE_TYPE) {
469     // This could be called for a setting that isn't defined to the PreferencesManager,
470     // in which case we simply defer to the SettingsStore.
471     if (storeType === STORE_TYPE) {
472       let setting = settingsMap.get(name);
473       if (!setting) {
474         return "not_controllable";
475       }
476       for (let prefName of setting.prefNames) {
477         if (lazy.Preferences.locked(prefName)) {
478           return "not_controllable";
479         }
480       }
481     }
482     await lazy.ExtensionSettingsStore.initialize();
483     return lazy.ExtensionSettingsStore.getLevelOfControl(id, storeType, name);
484   },
486   /**
487    * Returns an API object with get/set/clear used for a setting.
488    *
489    * @param {string|object} extensionId or params object
490    * @param {string} name
491    *          The unique id of the setting.
492    * @param {Function} callback
493    *          The function that retreives the current setting from prefs.
494    * @param {string} storeType
495    *          The name of the store in ExtensionSettingsStore.
496    *          Defaults to STORE_TYPE.
497    * @param {boolean} readOnly
498    * @param {Function} validate
499    *          Utility function for any specific validation, such as checking
500    *          for supported platform.  Function should throw an error if necessary.
501    *
502    * @returns {object} API object with get/set/clear methods
503    */
504   getSettingsAPI(
505     extensionId,
506     name,
507     callback,
508     storeType,
509     readOnly = false,
510     validate
511   ) {
512     if (arguments.length > 1) {
513       Services.console.logStringMessage(
514         `ExtensionPreferencesManager.getSettingsAPI for ${name} should be updated to use a single paramater object.`
515       );
516     }
517     return ExtensionPreferencesManager._getInternalSettingsAPI(
518       arguments.length === 1
519         ? extensionId
520         : {
521             extensionId,
522             name,
523             callback,
524             storeType,
525             readOnly,
526             validate,
527           }
528     ).api;
529   },
531   /**
532    * getPrimedSettingsListener returns a function used to create
533    * a primed event listener.
534    *
535    * If a module overrides onChange then it must provide it's own
536    * persistent listener logic.  See homepage_override in browserSettings
537    * for an example.
538    *
539    * addSetting must be called prior to priming listeners.
540    *
541    * @param {object} config see getSettingsAPI
542    *        {Extension} extension, passed through to validate and used for extensionId
543    *        {string} name
544    *          The unique id of the settings api in the module, e.g. "settings"
545    * @returns {object} prime listener object
546    */
547   getPrimedSettingsListener(config) {
548     let { name, extension } = config;
549     if (!name || !extension) {
550       throw new Error(
551         `name and extension are required for getPrimedSettingListener`
552       );
553     }
554     if (!settingsMap.get(name)) {
555       throw new Error(
556         `addSetting must be called prior to getPrimedSettingListener`
557       );
558     }
559     return ExtensionPreferencesManager._getInternalSettingsAPI({
560       name,
561       extension,
562     }).registerEvent;
563   },
565   /**
566    * Returns an object with a public API containing get/set/clear used for a setting,
567    * and a registerEvent function used for registering the event listener.
568    *
569    * @param {object} params The params object contains the following:
570    *        {BaseContext} context
571    *        {Extension} extension, optional, passed through to validate and used for extensionId
572    *        {string} extensionId, optional to support old API
573    *        {string} module
574    *          The name of the api module, e.g. "proxy"
575    *        {string} name
576    *          The unique id of the settings api in the module, e.g. "settings"
577    *          "name" should match the name given in the addSetting call.
578    *        {Function} callback
579    *          The function that retreives the current setting from prefs.
580    *        {string} storeType
581    *          The name of the store in ExtensionSettingsStore.
582    *          Defaults to STORE_TYPE.
583    *        {boolean} readOnly
584    *        {Function} validate
585    *          Utility function for any specific validation, such as checking
586    *          for supported platform.  Function should throw an error if necessary.
587    *
588    * @returns {object} internal API object with
589    *          {object} api
590    *            the public api available to extensions
591    *          {Function} registerEvent
592    *            the registration function used for priming events
593    */
594   _getInternalSettingsAPI(params) {
595     let {
596       extensionId,
597       context,
598       extension,
599       module,
600       name,
601       callback,
602       storeType,
603       readOnly = false,
604       onChange,
605       validate,
606     } = params;
607     if (context) {
608       extension = context.extension;
609     }
610     if (!extensionId && extension) {
611       extensionId = extension.id;
612     }
614     const checkScope = details => {
615       let { scope } = details;
616       if (scope && scope !== "regular") {
617         throw new ExtensionError(
618           `Firefox does not support the ${scope} settings scope.`
619         );
620       }
621     };
623     // Check the setting for anything we may need.
624     let setting = settingsMap.get(name);
625     readOnly = readOnly || !!setting?.readOnly;
626     validate = validate || setting?.validate || (() => {});
627     let getValue = callback || setting?.getCallback;
628     if (!getValue || typeof getValue !== "function") {
629       throw new Error(`Invalid get callback for setting ${name} in ${module}`);
630     }
632     let settingsAPI = {
633       async get(details) {
634         validate(extension);
635         let levelOfControl = details.incognito
636           ? "not_controllable"
637           : await ExtensionPreferencesManager.getLevelOfControl(
638               extensionId,
639               name,
640               storeType
641             );
642         levelOfControl =
643           readOnly && levelOfControl === "controllable_by_this_extension"
644             ? "not_controllable"
645             : levelOfControl;
646         return {
647           levelOfControl,
648           value: await getValue(),
649         };
650       },
651       set(details) {
652         validate(extension);
653         checkScope(details);
654         if (!readOnly) {
655           return ExtensionPreferencesManager.setSetting(
656             extensionId,
657             name,
658             details.value
659           );
660         }
661         return false;
662       },
663       clear(details) {
664         validate(extension);
665         checkScope(details);
666         if (!readOnly) {
667           return ExtensionPreferencesManager.removeSetting(extensionId, name);
668         }
669         return false;
670       },
671       onChange,
672     };
673     let registerEvent = fire => {
674       let listener = async () => {
675         fire.async(await settingsAPI.get({}));
676       };
677       Management.on(`extension-setting-changed:${name}`, listener);
678       return {
679         unregister: () => {
680           Management.off(`extension-setting-changed:${name}`, listener);
681         },
682         convert(_fire) {
683           fire = _fire;
684         },
685       };
686     };
688     // Any caller using the old call signature will not have passed
689     // context to us.  This should only be experimental addons in the
690     // wild.
691     if (onChange === undefined && context) {
692       // Some settings that are read-only may not have called addSetting, in
693       // which case we have no way to listen on the pref changes.
694       if (setting) {
695         settingsAPI.onChange = new lazy.ExtensionCommon.EventManager({
696           context,
697           module,
698           event: name,
699           name: `${name}.onChange`,
700           register: fire => {
701             return registerEvent(fire).unregister;
702           },
703         }).api();
704       } else {
705         Services.console.logStringMessage(
706           `ExtensionPreferencesManager API ${name} created but addSetting was not called.`
707         );
708       }
709     }
710     return { api: settingsAPI, registerEvent };
711   },