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/. */
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.
13 * It deals with preferences via settings objects, which are objects with
14 * the following properties:
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.
22 export let ExtensionPreferencesManager;
24 import { Management } from "resource://gre/modules/Extension.sys.mjs";
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",
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 });
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);
50 Management.on("disable", async (type, id) => {
51 await Management.asyncLoadSettingsModules();
52 return ExtensionPreferencesManager.disableAll(id);
55 Management.on("enabling", async (type, id) => {
56 await Management.asyncLoadSettingsModules();
57 return ExtensionPreferencesManager.enableAll(id);
60 Management.on("change-permissions", (type, change) => {
61 // Called for added or removed, but we only care about removed here.
62 if (!change.removed) {
65 ExtensionPreferencesManager.removeSettingsForPermissions(
67 change.removed.permissions
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();
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.
84 * An object with one property per preference, which holds the current
85 * value of that preference.
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);
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.
104 * The initialValue object after updating the values.
106 function settingsUpdate(initialValue) {
107 for (let pref of this.prefNames) {
110 initialValue[pref] !== undefined &&
111 initialValue[pref] === lazy.defaultPreferences.get(pref)
113 initialValue[pref] = undefined;
116 // Exception thrown if a default value doesn't exist. We
117 // presume that this pref had a user-set value initially.
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.
136 function setPrefs(name, setting, item) {
137 let prefs = item.initialValue || setting.setCallback(item.value);
139 for (let pref of setting.prefNames) {
140 if (prefs[pref] === undefined) {
141 if (lazy.Preferences.isSet(pref)) {
143 lazy.Preferences.reset(pref);
145 } else if (lazy.Preferences.get(pref) != prefs[pref]) {
146 lazy.Preferences.set(pref, prefs[pref]);
150 if (changed && typeof setting.onPrefsChanged == "function") {
151 setting.onPrefsChanged(item);
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
168 * The id of the extension for which a setting is being modified. Also
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
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);
185 let setting = settingsMap.get(name);
187 expectedItem.initialValue || setting.setCallback(expectedItem.value);
189 Object.keys(expectedPrefs).some(
191 expectedPrefs[pref] &&
192 lazy.Preferences.get(pref) != expectedPrefs[pref]
197 setPrefs(name, setting, item);
203 ExtensionPreferencesManager = {
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
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.
215 addSetting(name, setting) {
216 settingsMap.set(name, setting);
220 * Gets the default value for a preference.
222 * @param {string} prefName The name of the preference.
224 * @returns {string|number|boolean} The default value of the preference.
226 getDefaultValue(prefName) {
227 return lazy.defaultPreferences.get(prefName);
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
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
240 * Resolves to a Map of prefName->settingName
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);
254 * Indicates that an extension would like to change the value of a previously
258 * The id of the extension for which a setting is being set.
259 * @param {string} name
260 * The unique id of the setting.
262 * The value to be stored in the settings store for this
263 * group of preferences.
266 * Resolves to true if the preferences were changed and to false if
267 * the preferences were not changed.
269 async setSetting(id, name, value) {
270 let setting = settingsMap.get(name);
271 await lazy.ExtensionSettingsStore.initialize();
272 let item = await lazy.ExtensionSettingsStore.addSetting(
277 initialValueCallback.bind(setting),
279 settingsUpdate.bind(setting)
282 setPrefs(name, setting, item);
289 * Indicates that this extension wants to temporarily cede control over the
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.
298 * Resolves to true if the preferences were changed and to false if
299 * the preferences were not changed.
301 disableSetting(id, name) {
302 return processSetting(id, name, "disable");
306 * Enable a setting that has been disabled.
309 * The id of the extension for which a setting is being enabled.
310 * @param {string} name
311 * The unique id of the setting.
314 * Resolves to true if the preferences were changed and to false if
315 * the preferences were not changed.
317 enableSetting(id, name) {
318 return processSetting(id, name, "enable");
322 * Specifically select an extension, the user, or the precedence order that will
323 * be in control of this setting.
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.
332 * Resolves to true if the preferences were changed and to false if
333 * the preferences were not changed.
335 selectSetting(id, name) {
336 return processSetting(id, name, "select");
340 * Indicates that this extension no longer wants to set the given setting.
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.
348 * Resolves to true if the preferences were changed and to false if
349 * the preferences were not changed.
351 removeSetting(id, name) {
352 return processSetting(id, name, "removeSetting");
356 * Disables all previously set settings for an extension. This can be called when
357 * an extension is being disabled, for example.
360 * The id of the extension for which all settings are being unset.
362 async disableAll(id) {
363 await lazy.ExtensionSettingsStore.initialize();
364 let settings = lazy.ExtensionSettingsStore.getAllForExtension(
368 let disablePromises = [];
369 for (let name of settings) {
370 disablePromises.push(this.disableSetting(id, name));
372 await Promise.all(disablePromises);
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.
380 * The id of the extension for which all settings are being enabled.
382 async enableAll(id) {
383 await lazy.ExtensionSettingsStore.initialize();
384 let settings = lazy.ExtensionSettingsStore.getAllForExtension(
388 let enablePromises = [];
389 for (let name of settings) {
390 enablePromises.push(this.enableSetting(id, name));
392 await Promise.all(enablePromises);
396 * Removes all previously set settings for an extension. This can be called when
397 * an extension is being uninstalled, for example.
400 * The id of the extension for which all settings are being unset.
402 async removeAll(id) {
403 await lazy.ExtensionSettingsStore.initialize();
404 let settings = lazy.ExtensionSettingsStore.getAllForExtension(
408 let removePromises = [];
409 for (let name of settings) {
410 removePromises.push(this.removeSetting(id, name));
412 await Promise.all(removePromises);
416 * Removes a set of settings that are available under certain addon permissions.
420 * @param {Array<string>} permissions
421 * The permission name from the extension manifest.
423 * A promise that resolves when all related settings are removed.
425 async removeSettingsForPermissions(id, permissions) {
426 if (!permissions || !permissions.length) {
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));
436 return Promise.all(removePromises);
440 * Return the currently active value for a setting.
442 * @param {string} name
443 * The unique id of the setting.
445 * @returns {Promise<object>} The current setting object.
447 async getSetting(name) {
448 await lazy.ExtensionSettingsStore.initialize();
449 return lazy.ExtensionSettingsStore.getSetting(STORE_TYPE, name);
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.
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.
466 * Resolves to the level of control of the extension over the setting.
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);
474 return "not_controllable";
476 for (let prefName of setting.prefNames) {
477 if (lazy.Preferences.locked(prefName)) {
478 return "not_controllable";
482 await lazy.ExtensionSettingsStore.initialize();
483 return lazy.ExtensionSettingsStore.getLevelOfControl(id, storeType, name);
487 * Returns an API object with get/set/clear used for a setting.
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.
502 * @returns {object} API object with get/set/clear methods
512 if (arguments.length > 1) {
513 Services.console.logStringMessage(
514 `ExtensionPreferencesManager.getSettingsAPI for ${name} should be updated to use a single paramater object.`
517 return ExtensionPreferencesManager._getInternalSettingsAPI(
518 arguments.length === 1
532 * getPrimedSettingsListener returns a function used to create
533 * a primed event listener.
535 * If a module overrides onChange then it must provide it's own
536 * persistent listener logic. See homepage_override in browserSettings
539 * addSetting must be called prior to priming listeners.
541 * @param {object} config see getSettingsAPI
542 * {Extension} extension, passed through to validate and used for extensionId
544 * The unique id of the settings api in the module, e.g. "settings"
545 * @returns {object} prime listener object
547 getPrimedSettingsListener(config) {
548 let { name, extension } = config;
549 if (!name || !extension) {
551 `name and extension are required for getPrimedSettingListener`
554 if (!settingsMap.get(name)) {
556 `addSetting must be called prior to getPrimedSettingListener`
559 return ExtensionPreferencesManager._getInternalSettingsAPI({
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.
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
574 * The name of the api module, e.g. "proxy"
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.
581 * The name of the store in ExtensionSettingsStore.
582 * Defaults to STORE_TYPE.
584 * {Function} validate
585 * Utility function for any specific validation, such as checking
586 * for supported platform. Function should throw an error if necessary.
588 * @returns {object} internal API object with
590 * the public api available to extensions
591 * {Function} registerEvent
592 * the registration function used for priming events
594 _getInternalSettingsAPI(params) {
608 extension = context.extension;
610 if (!extensionId && extension) {
611 extensionId = extension.id;
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.`
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}`);
635 let levelOfControl = details.incognito
637 : await ExtensionPreferencesManager.getLevelOfControl(
643 readOnly && levelOfControl === "controllable_by_this_extension"
648 value: await getValue(),
655 return ExtensionPreferencesManager.setSetting(
667 return ExtensionPreferencesManager.removeSetting(extensionId, name);
673 let registerEvent = fire => {
674 let listener = async () => {
675 fire.async(await settingsAPI.get({}));
677 Management.on(`extension-setting-changed:${name}`, listener);
680 Management.off(`extension-setting-changed:${name}`, listener);
688 // Any caller using the old call signature will not have passed
689 // context to us. This should only be experimental addons in the
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.
695 settingsAPI.onChange = new lazy.ExtensionCommon.EventManager({
699 name: `${name}.onChange`,
701 return registerEvent(fire).unregister;
705 Services.console.logStringMessage(
706 `ExtensionPreferencesManager API ${name} created but addSetting was not called.`
710 return { api: settingsAPI, registerEvent };