Bug 1870926 [wpt PR 43734] - Remove experimental ::details-summary pseudo-element...
[gecko.git] / toolkit / components / extensions / ExtensionSettingsStore.sys.mjs
blobed139bcf12ca5c3dae37339552367f6dee7478a4
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 storing changes to settings that are
10  * requested by extensions, and for finding out what the current value
11  * of a setting should be, based on the precedence chain.
12  *
13  * When multiple extensions request to make a change to a particular
14  * setting, the most recently installed extension will be given
15  * precedence.
16  *
17  * This precedence chain of settings is stored in JSON format,
18  * without indentation, using UTF-8 encoding.
19  * With indentation applied, the file would look like this:
20  *
21  * {
22  *   type: { // The type of settings being stored in this object, i.e., prefs.
23  *     key: { // The unique key for the setting.
24  *       initialValue, // The initial value of the setting.
25  *       precedenceList: [
26  *         {
27  *           id, // The id of the extension requesting the setting.
28  *           installDate, // The install date of the extension, stored as a number.
29  *           value, // The value of the setting requested by the extension.
30  *           enabled // Whether the setting is currently enabled.
31  *         }
32  *       ],
33  *     },
34  *     key: {
35  *       // ...
36  *     }
37  *   }
38  * }
39  *
40  */
42 import { ExtensionParent } from "resource://gre/modules/ExtensionParent.sys.mjs";
44 const lazy = {};
46 ChromeUtils.defineESModuleGetters(lazy, {
47   AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
48   JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
49 });
51 // Defined for readability of precedence and selection code.  keyInfo.selected will be
52 // one of these defines, or the id of an extension if an extension has been explicitly
53 // selected.
54 const SETTING_USER_SET = null;
55 const SETTING_PRECEDENCE_ORDER = undefined;
57 const JSON_FILE_NAME = "extension-settings.json";
58 const JSON_FILE_VERSION = 3;
59 const STORE_PATH = PathUtils.join(
60   Services.dirsvc.get("ProfD", Ci.nsIFile).path,
61   JSON_FILE_NAME
64 let _initializePromise;
65 let _store = {};
67 // Processes the JSON data when read from disk to convert string dates into numbers.
68 function dataPostProcessor(json) {
69   if (json.version !== JSON_FILE_VERSION) {
70     for (let storeType in json) {
71       for (let setting in json[storeType]) {
72         for (let extData of json[storeType][setting].precedenceList) {
73           if (setting == "overrideContentColorScheme" && extData.value > 2) {
74             extData.value = 2;
75           }
76           if (typeof extData.installDate != "number") {
77             extData.installDate = new Date(extData.installDate).valueOf();
78           }
79         }
80       }
81     }
82     json.version = JSON_FILE_VERSION;
83   }
84   return json;
87 // Loads the data from the JSON file into memory.
88 function initialize() {
89   if (!_initializePromise) {
90     _store = new lazy.JSONFile({
91       path: STORE_PATH,
92       dataPostProcessor,
93     });
94     _initializePromise = _store.load();
95   }
96   return _initializePromise;
99 // Test-only method to force reloading of the JSON file.
100 async function reloadFile(saveChanges) {
101   if (!saveChanges) {
102     // Disarm the saver so that the current changes are dropped.
103     _store._saver.disarm();
104   }
105   await _store.finalize();
106   _initializePromise = null;
107   return initialize();
110 // Checks that the store is ready and that the requested type exists.
111 function ensureType(type) {
112   if (!_store.dataReady) {
113     throw new Error(
114       "The ExtensionSettingsStore was accessed before the initialize promise resolved."
115     );
116   }
118   // Ensure a property exists for the given type.
119   if (!_store.data[type]) {
120     _store.data[type] = {};
121   }
125  * Return an object with properties for key, value|initialValue, id|null, or
126  * null if no setting has been stored for that key.
128  * If no id is passed then return the highest priority item for the key.
130  * @param {string} type
131  *        The type of setting to be retrieved.
132  * @param {string} key
133  *        A string that uniquely identifies the setting.
134  * @param {string} [id]
135  *        The id of the extension for which the item is being retrieved.
136  *        If no id is passed, then the highest priority item for the key
137  *        is returned.
139  * @returns {object | null}
140  *          Either an object with properties for key and value, or
141  *          null if no key is found.
142  */
143 function getItem(type, key, id) {
144   ensureType(type);
146   let keyInfo = _store.data[type][key];
147   if (!keyInfo) {
148     return null;
149   }
151   // If no id was provided, the selected entry will have precedence.
152   if (!id && keyInfo.selected) {
153     id = keyInfo.selected;
154   }
155   if (id) {
156     // Return the item that corresponds to the extension with id of id.
157     let item = keyInfo.precedenceList.find(item => item.id === id);
158     return item ? { key, value: item.value, id } : null;
159   }
161   // Find the highest precedence, enabled setting, if it has not been
162   // user set.
163   if (keyInfo.selected === SETTING_PRECEDENCE_ORDER) {
164     for (let item of keyInfo.precedenceList) {
165       if (item.enabled) {
166         return { key, value: item.value, id: item.id };
167       }
168     }
169   }
171   // Nothing found in the precedenceList or the setting is user-set,
172   // return the initialValue.
173   return { key, initialValue: keyInfo.initialValue };
177  * Return an array of objects with properties for key, value, id, and enabled
178  * or an empty array if no settings have been stored for that key.
180  * @param {string} type
181  *        The type of setting to be retrieved.
182  * @param {string} key
183  *        A string that uniquely identifies the setting.
185  * @returns {Array} an array of objects with properties for key, value, id, and enabled
186  */
187 function getAllItems(type, key) {
188   ensureType(type);
190   let keyInfo = _store.data[type][key];
191   if (!keyInfo) {
192     return [];
193   }
195   let items = keyInfo.precedenceList;
196   return items
197     ? items.map(item => ({
198         key,
199         value: item.value,
200         id: item.id,
201         enabled: item.enabled,
202       }))
203     : [];
206 // Comparator used when sorting the precedence list.
207 function precedenceComparator(a, b) {
208   if (a.enabled && !b.enabled) {
209     return -1;
210   }
211   if (b.enabled && !a.enabled) {
212     return 1;
213   }
214   return b.installDate - a.installDate;
218  * Helper method that alters a setting, either by changing its enabled status
219  * or by removing it.
221  * @param {string|null} id
222  *        The id of the extension for which a setting is being altered, may also
223  *        be SETTING_USER_SET (null).
224  * @param {string} type
225  *        The type of setting to be altered.
226  * @param {string} key
227  *        A string that uniquely identifies the setting.
228  * @param {string} action
229  *        The action to perform on the setting.
230  *        Will be one of remove|enable|disable.
232  * @returns {object | null}
233  *          Either an object with properties for key and value, which
234  *          corresponds to the current top precedent setting, or null if
235  *          the current top precedent setting has not changed.
236  */
237 function alterSetting(id, type, key, action) {
238   let returnItem = null;
239   ensureType(type);
241   let keyInfo = _store.data[type][key];
242   if (!keyInfo) {
243     if (action === "remove") {
244       return null;
245     }
246     throw new Error(
247       `Cannot alter the setting for ${type}:${key} as it does not exist.`
248     );
249   }
251   let foundIndex = keyInfo.precedenceList.findIndex(item => item.id == id);
253   if (foundIndex === -1 && (action !== "select" || id !== SETTING_USER_SET)) {
254     if (action === "remove") {
255       return null;
256     }
257     throw new Error(
258       `Cannot alter the setting for ${type}:${key} as ${id} does not exist.`
259     );
260   }
262   let selected = keyInfo.selected;
263   switch (action) {
264     case "select":
265       if (foundIndex >= 0 && !keyInfo.precedenceList[foundIndex].enabled) {
266         throw new Error(
267           `Cannot select the setting for ${type}:${key} as ${id} is disabled.`
268         );
269       }
270       keyInfo.selected = id;
271       keyInfo.selectedDate = Date.now();
272       break;
274     case "remove":
275       // Removing a user-set setting reverts to precedence order.
276       if (id === keyInfo.selected) {
277         keyInfo.selected = SETTING_PRECEDENCE_ORDER;
278         delete keyInfo.selectedDate;
279       }
280       keyInfo.precedenceList.splice(foundIndex, 1);
281       break;
283     case "enable":
284       keyInfo.precedenceList[foundIndex].enabled = true;
285       keyInfo.precedenceList.sort(precedenceComparator);
286       // Enabling a setting does not change a user-set setting, so we
287       // save and bail early.
288       if (keyInfo.selected !== SETTING_PRECEDENCE_ORDER) {
289         _store.saveSoon();
290         return null;
291       }
292       foundIndex = keyInfo.precedenceList.findIndex(item => item.id == id);
293       break;
295     case "disable":
296       // Disabling a user-set setting reverts to precedence order.
297       if (keyInfo.selected === id) {
298         keyInfo.selected = SETTING_PRECEDENCE_ORDER;
299         delete keyInfo.selectedDate;
300       }
301       keyInfo.precedenceList[foundIndex].enabled = false;
302       keyInfo.precedenceList.sort(precedenceComparator);
303       break;
305     default:
306       throw new Error(`${action} is not a valid action for alterSetting.`);
307   }
309   if (selected !== keyInfo.selected || foundIndex === 0) {
310     returnItem = getItem(type, key);
311   }
313   if (action === "remove" && keyInfo.precedenceList.length === 0) {
314     delete _store.data[type][key];
315   }
317   _store.saveSoon();
318   ExtensionParent.apiManager.emit("extension-setting-changed", {
319     action,
320     id,
321     type,
322     key,
323     item: returnItem,
324   });
325   return returnItem;
328 export var ExtensionSettingsStore = {
329   SETTING_USER_SET,
331   /**
332    * Loads the JSON file for the SettingsStore into memory.
333    * The promise this returns must be resolved before asking the SettingsStore
334    * to perform any other operations.
335    *
336    * @returns {Promise}
337    *          A promise that resolves when the Store is ready to be accessed.
338    */
339   initialize() {
340     return initialize();
341   },
343   /**
344    * Adds a setting to the store, returning the new setting if it changes.
345    *
346    * @param {string} id
347    *        The id of the extension for which a setting is being added.
348    * @param {string} type
349    *        The type of setting to be stored.
350    * @param {string} key
351    *        A string that uniquely identifies the setting.
352    * @param {string} value
353    *        The value to be stored in the setting.
354    * @param {Function} initialValueCallback
355    *        A function to be called to determine the initial value for the
356    *        setting. This will be passed the value in the callbackArgument
357    *        argument. If omitted the initial value will be undefined.
358    * @param {any} callbackArgument
359    *        The value to be passed into the initialValueCallback. It defaults to
360    *        the value of the key argument.
361    * @param {Function} settingDataUpdate
362    *        A function to be called to modify the initial value if necessary.
363    *
364    * @returns {Promise<object?>} Either an object with properties for key and
365    *                          value, which corresponds to the item that was
366    *                          just added, or null if the item that was just
367    *                          added does not need to be set because it is not
368    *                          selected or at the top of the precedence list.
369    */
370   async addSetting(
371     id,
372     type,
373     key,
374     value,
375     initialValueCallback = () => undefined,
376     callbackArgument = key,
377     settingDataUpdate = val => val
378   ) {
379     if (typeof initialValueCallback != "function") {
380       throw new Error("initialValueCallback must be a function.");
381     }
383     ensureType(type);
385     if (!_store.data[type][key]) {
386       // The setting for this key does not exist. Set the initial value.
387       let initialValue = await initialValueCallback(callbackArgument);
388       _store.data[type][key] = {
389         initialValue,
390         precedenceList: [],
391       };
392     }
393     let keyInfo = _store.data[type][key];
395     // Allow settings to upgrade the initial value if necessary.
396     keyInfo.initialValue = settingDataUpdate(keyInfo.initialValue);
398     // Check for this item in the precedenceList.
399     let foundIndex = keyInfo.precedenceList.findIndex(item => item.id == id);
400     let newInstall = false;
401     if (foundIndex === -1) {
402       // No item for this extension, so add a new one.
403       let addon = await lazy.AddonManager.getAddonByID(id);
404       keyInfo.precedenceList.push({
405         id,
406         installDate: addon.installDate.valueOf(),
407         value,
408         enabled: true,
409       });
410       newInstall = addon.installDate.valueOf() > keyInfo.selectedDate;
411     } else {
412       // Item already exists or this extension, so update it.
413       let item = keyInfo.precedenceList[foundIndex];
414       item.value = value;
415       // Ensure the item is enabled.
416       item.enabled = true;
417     }
419     // Sort the list.
420     keyInfo.precedenceList.sort(precedenceComparator);
421     foundIndex = keyInfo.precedenceList.findIndex(item => item.id == id);
423     // If our new setting is top of precedence, then reset the selected entry.
424     if (foundIndex === 0 && newInstall) {
425       keyInfo.selected = SETTING_PRECEDENCE_ORDER;
426       delete keyInfo.selectedDate;
427     }
429     _store.saveSoon();
431     // Check whether this is currently selected item if one is
432     // selected, otherwise the top item has precedence.
433     if (
434       keyInfo.selected !== SETTING_USER_SET &&
435       (keyInfo.selected === id || foundIndex === 0)
436     ) {
437       return { id, key, value };
438     }
439     return null;
440   },
442   /**
443    * Removes a setting from the store, returning the new setting if it changes.
444    *
445    * @param {string} id
446    *        The id of the extension for which a setting is being removed.
447    * @param {string} type
448    *        The type of setting to be removed.
449    * @param {string} key
450    *        A string that uniquely identifies the setting.
451    *
452    * @returns {object | null}
453    *          Either an object with properties for key and value if the setting changes, or null.
454    */
455   removeSetting(id, type, key) {
456     return alterSetting(id, type, key, "remove");
457   },
459   /**
460    * Enables a setting in the store, returning the new setting if it changes.
461    *
462    * @param {string} id
463    *        The id of the extension for which a setting is being enabled.
464    * @param {string} type
465    *        The type of setting to be enabled.
466    * @param {string} key
467    *        A string that uniquely identifies the setting.
468    *
469    * @returns {object | null}
470    *          Either an object with properties for key and value if the setting changes, or null.
471    */
472   enable(id, type, key) {
473     return alterSetting(id, type, key, "enable");
474   },
476   /**
477    * Disables a setting in the store, returning the new setting if it changes.
478    *
479    * @param {string} id
480    *        The id of the extension for which a setting is being disabled.
481    * @param {string} type
482    *        The type of setting to be disabled.
483    * @param {string} key
484    *        A string that uniquely identifies the setting.
485    *
486    * @returns {object | null}
487    *          Either an object with properties for key and value if the setting changes, or null.
488    */
489   disable(id, type, key) {
490     return alterSetting(id, type, key, "disable");
491   },
493   /**
494    * Specifically select an extension, or no extension, that will be in control of
495    * this setting.
496    *
497    * To select a specific extension that controls this setting, pass the extension id.
498    *
499    * To select as user-set  pass SETTING_USER_SET as the id.  In this case, no extension
500    * will have control of the setting.
501    *
502    * Once a specific selection is made, precedence order will not be used again unless the selected
503    * extension is disabled, removed, or a new extension takes control of the setting.
504    *
505    * @param {string | null} id
506    *        The id of the extension being selected or SETTING_USER_SET (null).
507    * @param {string} type
508    *        The type of setting to be selected.
509    * @param {string} key
510    *        A string that uniquely identifies the setting.
511    *
512    * @returns {object | null}
513    *          Either an object with properties for key and value if the setting changes, or null.
514    */
515   select(id, type, key) {
516     return alterSetting(id, type, key, "select");
517   },
519   /**
520    * Retrieves all settings from the store for a given extension.
521    *
522    * @param {string} id
523    *        The id of the extension for which a settings are being retrieved.
524    * @param {string} type
525    *        The type of setting to be returned.
526    *
527    * @returns {Array}
528    *          A list of settings which have been stored for the extension.
529    */
530   getAllForExtension(id, type) {
531     ensureType(type);
533     let keysObj = _store.data[type];
534     let items = [];
535     for (let key in keysObj) {
536       if (keysObj[key].precedenceList.find(item => item.id == id)) {
537         items.push(key);
538       }
539     }
540     return items;
541   },
543   /**
544    * Retrieves a setting from the store, either for a specific extension,
545    * or current top precedent setting for the key.
546    *
547    * @param {string} type The type of setting to be returned.
548    * @param {string} key A string that uniquely identifies the setting.
549    * @param {string} id
550    *        The id of the extension for which the setting is being retrieved.
551    *        Defaults to undefined, in which case the top setting is returned.
552    *
553    * @returns {object} An object with properties for key, value and id.
554    */
555   getSetting(type, key, id) {
556     return getItem(type, key, id);
557   },
559   /**
560    * Retrieves an array of objects representing extensions attempting to control the specified setting
561    * or an empty array if no settings have been stored for that key.
562    *
563    * @param {string} type
564    *        The type of setting to be retrieved.
565    * @param {string} key
566    *        A string that uniquely identifies the setting.
567    *
568    * @returns {Array} an array of objects with properties for key, value, id, and enabled
569    */
570   getAllSettings(type, key) {
571     return getAllItems(type, key);
572   },
574   /**
575    * Returns whether an extension currently has a stored setting for a given
576    * key.
577    *
578    * @param {string} id The id of the extension which is being checked.
579    * @param {string} type The type of setting to be checked.
580    * @param {string} key A string that uniquely identifies the setting.
581    *
582    * @returns {boolean} Whether the extension currently has a stored setting.
583    */
584   hasSetting(id, type, key) {
585     return this.getAllForExtension(id, type).includes(key);
586   },
588   /**
589    * Return the levelOfControl for a key / extension combo.
590    * levelOfControl is required by Google's ChromeSetting prototype which
591    * in turn is used by the privacy API among others.
592    *
593    * It informs a caller of the state of a setting with respect to the current
594    * extension, and can be one of the following values:
595    *
596    * controlled_by_other_extensions: controlled by extensions with higher precedence
597    * controllable_by_this_extension: can be controlled by this extension
598    * controlled_by_this_extension: controlled by this extension
599    *
600    * @param {string} id
601    *        The id of the extension for which levelOfControl is being requested.
602    * @param {string} type
603    *        The type of setting to be returned. For example `pref`.
604    * @param {string} key
605    *        A string that uniquely identifies the setting, for example, a
606    *        preference name.
607    *
608    * @returns {Promise<string>}
609    *          The level of control of the extension over the key.
610    */
611   async getLevelOfControl(id, type, key) {
612     ensureType(type);
614     let keyInfo = _store.data[type][key];
615     if (!keyInfo || !keyInfo.precedenceList.length) {
616       return "controllable_by_this_extension";
617     }
619     if (keyInfo.selected !== SETTING_PRECEDENCE_ORDER) {
620       if (id === keyInfo.selected) {
621         return "controlled_by_this_extension";
622       }
623       // When user set, the setting is never "controllable" unless the installDate
624       // is later than the user date.
625       let addon = await lazy.AddonManager.getAddonByID(id);
626       return !addon || keyInfo.selectedDate > addon.installDate.valueOf()
627         ? "not_controllable"
628         : "controllable_by_this_extension";
629     }
631     let enabledItems = keyInfo.precedenceList.filter(item => item.enabled);
632     if (!enabledItems.length) {
633       return "controllable_by_this_extension";
634     }
636     let topItem = enabledItems[0];
637     if (topItem.id == id) {
638       return "controlled_by_this_extension";
639     }
641     let addon = await lazy.AddonManager.getAddonByID(id);
642     return !addon || topItem.installDate > addon.installDate.valueOf()
643       ? "controlled_by_other_extensions"
644       : "controllable_by_this_extension";
645   },
647   /**
648    * Test-only method to force reloading of the JSON file.
649    *
650    * Note that this method simply clears the local variable that stores the
651    * file, so the next time the file is accessed it will be reloaded.
652    *
653    * @param   {boolean} saveChanges
654    *          When false, discard any changes that have been made since the last
655    *          time the store was saved.
656    * @returns {Promise}
657    *          A promise that resolves once the settings store has been cleared.
658    */
659   _reloadFile(saveChanges = true) {
660     return reloadFile(saveChanges);
661   },
664 // eslint-disable-next-line mozilla/balanced-listeners
665 ExtensionParent.apiManager.on("uninstall-complete", async (type, { id }) => {
666   // Catch any settings that were not properly removed during "uninstall".
667   await ExtensionSettingsStore.initialize();
668   for (let type in _store.data) {
669     // prefs settings must be handled by ExtensionPreferencesManager.
670     if (type === "prefs") {
671       continue;
672     }
673     let items = ExtensionSettingsStore.getAllForExtension(id, type);
674     for (let key of items) {
675       ExtensionSettingsStore.removeSetting(id, type, key);
676       Services.console.logStringMessage(
677         `Post-Uninstall removal of addon settings for ${id}, type: ${type} key: ${key}`
678       );
679     }
680   }