summaryrefslogtreecommitdiffstats
path: root/toolkit/components/extensions/ExtensionSettingsStore.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/extensions/ExtensionSettingsStore.sys.mjs')
-rw-r--r--toolkit/components/extensions/ExtensionSettingsStore.sys.mjs681
1 files changed, 681 insertions, 0 deletions
diff --git a/toolkit/components/extensions/ExtensionSettingsStore.sys.mjs b/toolkit/components/extensions/ExtensionSettingsStore.sys.mjs
new file mode 100644
index 0000000000..69108ef63b
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionSettingsStore.sys.mjs
@@ -0,0 +1,681 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* 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/. */
+
+/**
+ * @file
+ * This module is used for storing changes to settings that are
+ * requested by extensions, and for finding out what the current value
+ * of a setting should be, based on the precedence chain.
+ *
+ * When multiple extensions request to make a change to a particular
+ * setting, the most recently installed extension will be given
+ * precedence.
+ *
+ * This precedence chain of settings is stored in JSON format,
+ * without indentation, using UTF-8 encoding.
+ * With indentation applied, the file would look like this:
+ *
+ * {
+ * type: { // The type of settings being stored in this object, i.e., prefs.
+ * key: { // The unique key for the setting.
+ * initialValue, // The initial value of the setting.
+ * precedenceList: [
+ * {
+ * id, // The id of the extension requesting the setting.
+ * installDate, // The install date of the extension, stored as a number.
+ * value, // The value of the setting requested by the extension.
+ * enabled // Whether the setting is currently enabled.
+ * }
+ * ],
+ * },
+ * key: {
+ * // ...
+ * }
+ * }
+ * }
+ *
+ */
+
+import { ExtensionParent } from "resource://gre/modules/ExtensionParent.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
+});
+
+// Defined for readability of precedence and selection code. keyInfo.selected will be
+// one of these defines, or the id of an extension if an extension has been explicitly
+// selected.
+const SETTING_USER_SET = null;
+const SETTING_PRECEDENCE_ORDER = undefined;
+
+const JSON_FILE_NAME = "extension-settings.json";
+const JSON_FILE_VERSION = 3;
+const STORE_PATH = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ JSON_FILE_NAME
+);
+
+let _initializePromise;
+let _store = {};
+
+// Processes the JSON data when read from disk to convert string dates into numbers.
+function dataPostProcessor(json) {
+ if (json.version !== JSON_FILE_VERSION) {
+ for (let storeType in json) {
+ for (let setting in json[storeType]) {
+ for (let extData of json[storeType][setting].precedenceList) {
+ if (setting == "overrideContentColorScheme" && extData.value > 2) {
+ extData.value = 2;
+ }
+ if (typeof extData.installDate != "number") {
+ extData.installDate = new Date(extData.installDate).valueOf();
+ }
+ }
+ }
+ }
+ json.version = JSON_FILE_VERSION;
+ }
+ return json;
+}
+
+// Loads the data from the JSON file into memory.
+function initialize() {
+ if (!_initializePromise) {
+ _store = new lazy.JSONFile({
+ path: STORE_PATH,
+ dataPostProcessor,
+ });
+ _initializePromise = _store.load();
+ }
+ return _initializePromise;
+}
+
+// Test-only method to force reloading of the JSON file.
+async function reloadFile(saveChanges) {
+ if (!saveChanges) {
+ // Disarm the saver so that the current changes are dropped.
+ _store._saver.disarm();
+ }
+ await _store.finalize();
+ _initializePromise = null;
+ return initialize();
+}
+
+// Checks that the store is ready and that the requested type exists.
+function ensureType(type) {
+ if (!_store.dataReady) {
+ throw new Error(
+ "The ExtensionSettingsStore was accessed before the initialize promise resolved."
+ );
+ }
+
+ // Ensure a property exists for the given type.
+ if (!_store.data[type]) {
+ _store.data[type] = {};
+ }
+}
+
+/**
+ * Return an object with properties for key, value|initialValue, id|null, or
+ * null if no setting has been stored for that key.
+ *
+ * If no id is passed then return the highest priority item for the key.
+ *
+ * @param {string} type
+ * The type of setting to be retrieved.
+ * @param {string} key
+ * A string that uniquely identifies the setting.
+ * @param {string} id
+ * The id of the extension for which the item is being retrieved.
+ * If no id is passed, then the highest priority item for the key
+ * is returned.
+ *
+ * @returns {object | null}
+ * Either an object with properties for key and value, or
+ * null if no key is found.
+ */
+function getItem(type, key, id) {
+ ensureType(type);
+
+ let keyInfo = _store.data[type][key];
+ if (!keyInfo) {
+ return null;
+ }
+
+ // If no id was provided, the selected entry will have precedence.
+ if (!id && keyInfo.selected) {
+ id = keyInfo.selected;
+ }
+ if (id) {
+ // Return the item that corresponds to the extension with id of id.
+ let item = keyInfo.precedenceList.find(item => item.id === id);
+ return item ? { key, value: item.value, id } : null;
+ }
+
+ // Find the highest precedence, enabled setting, if it has not been
+ // user set.
+ if (keyInfo.selected === SETTING_PRECEDENCE_ORDER) {
+ for (let item of keyInfo.precedenceList) {
+ if (item.enabled) {
+ return { key, value: item.value, id: item.id };
+ }
+ }
+ }
+
+ // Nothing found in the precedenceList or the setting is user-set,
+ // return the initialValue.
+ return { key, initialValue: keyInfo.initialValue };
+}
+
+/**
+ * Return an array of objects with properties for key, value, id, and enabled
+ * or an empty array if no settings have been stored for that key.
+ *
+ * @param {string} type
+ * The type of setting to be retrieved.
+ * @param {string} key
+ * A string that uniquely identifies the setting.
+ *
+ * @returns {Array} an array of objects with properties for key, value, id, and enabled
+ */
+function getAllItems(type, key) {
+ ensureType(type);
+
+ let keyInfo = _store.data[type][key];
+ if (!keyInfo) {
+ return [];
+ }
+
+ let items = keyInfo.precedenceList;
+ return items
+ ? items.map(item => ({
+ key,
+ value: item.value,
+ id: item.id,
+ enabled: item.enabled,
+ }))
+ : [];
+}
+
+// Comparator used when sorting the precedence list.
+function precedenceComparator(a, b) {
+ if (a.enabled && !b.enabled) {
+ return -1;
+ }
+ if (b.enabled && !a.enabled) {
+ return 1;
+ }
+ return b.installDate - a.installDate;
+}
+
+/**
+ * Helper method that alters a setting, either by changing its enabled status
+ * or by removing it.
+ *
+ * @param {string|null} id
+ * The id of the extension for which a setting is being altered, may also
+ * be SETTING_USER_SET (null).
+ * @param {string} type
+ * The type of setting to be altered.
+ * @param {string} key
+ * A string that uniquely identifies the setting.
+ * @param {string} action
+ * The action to perform on the setting.
+ * Will be one of remove|enable|disable.
+ *
+ * @returns {object | null}
+ * Either an object with properties for key and value, which
+ * corresponds to the current top precedent setting, or null if
+ * the current top precedent setting has not changed.
+ */
+function alterSetting(id, type, key, action) {
+ let returnItem = null;
+ ensureType(type);
+
+ let keyInfo = _store.data[type][key];
+ if (!keyInfo) {
+ if (action === "remove") {
+ return null;
+ }
+ throw new Error(
+ `Cannot alter the setting for ${type}:${key} as it does not exist.`
+ );
+ }
+
+ let foundIndex = keyInfo.precedenceList.findIndex(item => item.id == id);
+
+ if (foundIndex === -1 && (action !== "select" || id !== SETTING_USER_SET)) {
+ if (action === "remove") {
+ return null;
+ }
+ throw new Error(
+ `Cannot alter the setting for ${type}:${key} as ${id} does not exist.`
+ );
+ }
+
+ let selected = keyInfo.selected;
+ switch (action) {
+ case "select":
+ if (foundIndex >= 0 && !keyInfo.precedenceList[foundIndex].enabled) {
+ throw new Error(
+ `Cannot select the setting for ${type}:${key} as ${id} is disabled.`
+ );
+ }
+ keyInfo.selected = id;
+ keyInfo.selectedDate = Date.now();
+ break;
+
+ case "remove":
+ // Removing a user-set setting reverts to precedence order.
+ if (id === keyInfo.selected) {
+ keyInfo.selected = SETTING_PRECEDENCE_ORDER;
+ delete keyInfo.selectedDate;
+ }
+ keyInfo.precedenceList.splice(foundIndex, 1);
+ break;
+
+ case "enable":
+ keyInfo.precedenceList[foundIndex].enabled = true;
+ keyInfo.precedenceList.sort(precedenceComparator);
+ // Enabling a setting does not change a user-set setting, so we
+ // save and bail early.
+ if (keyInfo.selected !== SETTING_PRECEDENCE_ORDER) {
+ _store.saveSoon();
+ return null;
+ }
+ foundIndex = keyInfo.precedenceList.findIndex(item => item.id == id);
+ break;
+
+ case "disable":
+ // Disabling a user-set setting reverts to precedence order.
+ if (keyInfo.selected === id) {
+ keyInfo.selected = SETTING_PRECEDENCE_ORDER;
+ delete keyInfo.selectedDate;
+ }
+ keyInfo.precedenceList[foundIndex].enabled = false;
+ keyInfo.precedenceList.sort(precedenceComparator);
+ break;
+
+ default:
+ throw new Error(`${action} is not a valid action for alterSetting.`);
+ }
+
+ if (selected !== keyInfo.selected || foundIndex === 0) {
+ returnItem = getItem(type, key);
+ }
+
+ if (action === "remove" && keyInfo.precedenceList.length === 0) {
+ delete _store.data[type][key];
+ }
+
+ _store.saveSoon();
+ ExtensionParent.apiManager.emit("extension-setting-changed", {
+ action,
+ id,
+ type,
+ key,
+ item: returnItem,
+ });
+ return returnItem;
+}
+
+export var ExtensionSettingsStore = {
+ SETTING_USER_SET,
+
+ /**
+ * Loads the JSON file for the SettingsStore into memory.
+ * The promise this returns must be resolved before asking the SettingsStore
+ * to perform any other operations.
+ *
+ * @returns {Promise}
+ * A promise that resolves when the Store is ready to be accessed.
+ */
+ initialize() {
+ return initialize();
+ },
+
+ /**
+ * Adds a setting to the store, returning the new setting if it changes.
+ *
+ * @param {string} id
+ * The id of the extension for which a setting is being added.
+ * @param {string} type
+ * The type of setting to be stored.
+ * @param {string} key
+ * A string that uniquely identifies the setting.
+ * @param {string} value
+ * The value to be stored in the setting.
+ * @param {Function} initialValueCallback
+ * A function to be called to determine the initial value for the
+ * setting. This will be passed the value in the callbackArgument
+ * argument. If omitted the initial value will be undefined.
+ * @param {any} callbackArgument
+ * The value to be passed into the initialValueCallback. It defaults to
+ * the value of the key argument.
+ * @param {Function} settingDataUpdate
+ * A function to be called to modify the initial value if necessary.
+ *
+ * @returns {object | null} Either an object with properties for key and
+ * value, which corresponds to the item that was
+ * just added, or null if the item that was just
+ * added does not need to be set because it is not
+ * selected or at the top of the precedence list.
+ */
+ async addSetting(
+ id,
+ type,
+ key,
+ value,
+ initialValueCallback = () => undefined,
+ callbackArgument = key,
+ settingDataUpdate = val => val
+ ) {
+ if (typeof initialValueCallback != "function") {
+ throw new Error("initialValueCallback must be a function.");
+ }
+
+ ensureType(type);
+
+ if (!_store.data[type][key]) {
+ // The setting for this key does not exist. Set the initial value.
+ let initialValue = await initialValueCallback(callbackArgument);
+ _store.data[type][key] = {
+ initialValue,
+ precedenceList: [],
+ };
+ }
+ let keyInfo = _store.data[type][key];
+
+ // Allow settings to upgrade the initial value if necessary.
+ keyInfo.initialValue = settingDataUpdate(keyInfo.initialValue);
+
+ // Check for this item in the precedenceList.
+ let foundIndex = keyInfo.precedenceList.findIndex(item => item.id == id);
+ let newInstall = false;
+ if (foundIndex === -1) {
+ // No item for this extension, so add a new one.
+ let addon = await lazy.AddonManager.getAddonByID(id);
+ keyInfo.precedenceList.push({
+ id,
+ installDate: addon.installDate.valueOf(),
+ value,
+ enabled: true,
+ });
+ newInstall = addon.installDate.valueOf() > keyInfo.selectedDate;
+ } else {
+ // Item already exists or this extension, so update it.
+ let item = keyInfo.precedenceList[foundIndex];
+ item.value = value;
+ // Ensure the item is enabled.
+ item.enabled = true;
+ }
+
+ // Sort the list.
+ keyInfo.precedenceList.sort(precedenceComparator);
+ foundIndex = keyInfo.precedenceList.findIndex(item => item.id == id);
+
+ // If our new setting is top of precedence, then reset the selected entry.
+ if (foundIndex === 0 && newInstall) {
+ keyInfo.selected = SETTING_PRECEDENCE_ORDER;
+ delete keyInfo.selectedDate;
+ }
+
+ _store.saveSoon();
+
+ // Check whether this is currently selected item if one is
+ // selected, otherwise the top item has precedence.
+ if (
+ keyInfo.selected !== SETTING_USER_SET &&
+ (keyInfo.selected === id || foundIndex === 0)
+ ) {
+ return { id, key, value };
+ }
+ return null;
+ },
+
+ /**
+ * Removes a setting from the store, returning the new setting if it changes.
+ *
+ * @param {string} id
+ * The id of the extension for which a setting is being removed.
+ * @param {string} type
+ * The type of setting to be removed.
+ * @param {string} key
+ * A string that uniquely identifies the setting.
+ *
+ * @returns {object | null}
+ * Either an object with properties for key and value if the setting changes, or null.
+ */
+ removeSetting(id, type, key) {
+ return alterSetting(id, type, key, "remove");
+ },
+
+ /**
+ * Enables a setting in the store, returning the new setting if it changes.
+ *
+ * @param {string} id
+ * The id of the extension for which a setting is being enabled.
+ * @param {string} type
+ * The type of setting to be enabled.
+ * @param {string} key
+ * A string that uniquely identifies the setting.
+ *
+ * @returns {object | null}
+ * Either an object with properties for key and value if the setting changes, or null.
+ */
+ enable(id, type, key) {
+ return alterSetting(id, type, key, "enable");
+ },
+
+ /**
+ * Disables a setting in the store, returning the new setting if it changes.
+ *
+ * @param {string} id
+ * The id of the extension for which a setting is being disabled.
+ * @param {string} type
+ * The type of setting to be disabled.
+ * @param {string} key
+ * A string that uniquely identifies the setting.
+ *
+ * @returns {object | null}
+ * Either an object with properties for key and value if the setting changes, or null.
+ */
+ disable(id, type, key) {
+ return alterSetting(id, type, key, "disable");
+ },
+
+ /**
+ * Specifically select an extension, or no extension, that will be in control of
+ * this setting.
+ *
+ * To select a specific extension that controls this setting, pass the extension id.
+ *
+ * To select as user-set pass SETTING_USER_SET as the id. In this case, no extension
+ * will have control of the setting.
+ *
+ * Once a specific selection is made, precedence order will not be used again unless the selected
+ * extension is disabled, removed, or a new extension takes control of the setting.
+ *
+ * @param {string | null} id
+ * The id of the extension being selected or SETTING_USER_SET (null).
+ * @param {string} type
+ * The type of setting to be selected.
+ * @param {string} key
+ * A string that uniquely identifies the setting.
+ *
+ * @returns {object | null}
+ * Either an object with properties for key and value if the setting changes, or null.
+ */
+ select(id, type, key) {
+ return alterSetting(id, type, key, "select");
+ },
+
+ /**
+ * Retrieves all settings from the store for a given extension.
+ *
+ * @param {string} id
+ * The id of the extension for which a settings are being retrieved.
+ * @param {string} type
+ * The type of setting to be returned.
+ *
+ * @returns {Array}
+ * A list of settings which have been stored for the extension.
+ */
+ getAllForExtension(id, type) {
+ ensureType(type);
+
+ let keysObj = _store.data[type];
+ let items = [];
+ for (let key in keysObj) {
+ if (keysObj[key].precedenceList.find(item => item.id == id)) {
+ items.push(key);
+ }
+ }
+ return items;
+ },
+
+ /**
+ * Retrieves a setting from the store, either for a specific extension,
+ * or current top precedent setting for the key.
+ *
+ * @param {string} type The type of setting to be returned.
+ * @param {string} key A string that uniquely identifies the setting.
+ * @param {string} id
+ * The id of the extension for which the setting is being retrieved.
+ * Defaults to undefined, in which case the top setting is returned.
+ *
+ * @returns {object} An object with properties for key, value and id.
+ */
+ getSetting(type, key, id) {
+ return getItem(type, key, id);
+ },
+
+ /**
+ * Retrieves an array of objects representing extensions attempting to control the specified setting
+ * or an empty array if no settings have been stored for that key.
+ *
+ * @param {string} type
+ * The type of setting to be retrieved.
+ * @param {string} key
+ * A string that uniquely identifies the setting.
+ *
+ * @returns {Array} an array of objects with properties for key, value, id, and enabled
+ */
+ getAllSettings(type, key) {
+ return getAllItems(type, key);
+ },
+
+ /**
+ * Returns whether an extension currently has a stored setting for a given
+ * key.
+ *
+ * @param {string} id The id of the extension which is being checked.
+ * @param {string} type The type of setting to be checked.
+ * @param {string} key A string that uniquely identifies the setting.
+ *
+ * @returns {boolean} Whether the extension currently has a stored setting.
+ */
+ hasSetting(id, type, key) {
+ return this.getAllForExtension(id, type).includes(key);
+ },
+
+ /**
+ * Return the levelOfControl for a key / extension combo.
+ * levelOfControl is required by Google's ChromeSetting prototype which
+ * in turn is used by the privacy API among others.
+ *
+ * It informs a caller of the state of a setting with respect to the current
+ * extension, and can be one of the following values:
+ *
+ * controlled_by_other_extensions: controlled by extensions with higher precedence
+ * controllable_by_this_extension: can be controlled by this extension
+ * controlled_by_this_extension: controlled by this extension
+ *
+ * @param {string} id
+ * The id of the extension for which levelOfControl is being requested.
+ * @param {string} type
+ * The type of setting to be returned. For example `pref`.
+ * @param {string} key
+ * A string that uniquely identifies the setting, for example, a
+ * preference name.
+ *
+ * @returns {string}
+ * The level of control of the extension over the key.
+ */
+ async getLevelOfControl(id, type, key) {
+ ensureType(type);
+
+ let keyInfo = _store.data[type][key];
+ if (!keyInfo || !keyInfo.precedenceList.length) {
+ return "controllable_by_this_extension";
+ }
+
+ if (keyInfo.selected !== SETTING_PRECEDENCE_ORDER) {
+ if (id === keyInfo.selected) {
+ return "controlled_by_this_extension";
+ }
+ // When user set, the setting is never "controllable" unless the installDate
+ // is later than the user date.
+ let addon = await lazy.AddonManager.getAddonByID(id);
+ return !addon || keyInfo.selectedDate > addon.installDate.valueOf()
+ ? "not_controllable"
+ : "controllable_by_this_extension";
+ }
+
+ let enabledItems = keyInfo.precedenceList.filter(item => item.enabled);
+ if (!enabledItems.length) {
+ return "controllable_by_this_extension";
+ }
+
+ let topItem = enabledItems[0];
+ if (topItem.id == id) {
+ return "controlled_by_this_extension";
+ }
+
+ let addon = await lazy.AddonManager.getAddonByID(id);
+ return !addon || topItem.installDate > addon.installDate.valueOf()
+ ? "controlled_by_other_extensions"
+ : "controllable_by_this_extension";
+ },
+
+ /**
+ * Test-only method to force reloading of the JSON file.
+ *
+ * Note that this method simply clears the local variable that stores the
+ * file, so the next time the file is accessed it will be reloaded.
+ *
+ * @param {boolean} saveChanges
+ * When false, discard any changes that have been made since the last
+ * time the store was saved.
+ * @returns {Promise}
+ * A promise that resolves once the settings store has been cleared.
+ */
+ _reloadFile(saveChanges = true) {
+ return reloadFile(saveChanges);
+ },
+};
+
+// eslint-disable-next-line mozilla/balanced-listeners
+ExtensionParent.apiManager.on("uninstall-complete", async (type, { id }) => {
+ // Catch any settings that were not properly removed during "uninstall".
+ await ExtensionSettingsStore.initialize();
+ for (let type in _store.data) {
+ // prefs settings must be handled by ExtensionPreferencesManager.
+ if (type === "prefs") {
+ continue;
+ }
+ let items = ExtensionSettingsStore.getAllForExtension(id, type);
+ for (let key of items) {
+ ExtensionSettingsStore.removeSetting(id, type, key);
+ Services.console.logStringMessage(
+ `Post-Uninstall removal of addon settings for ${id}, type: ${type} key: ${key}`
+ );
+ }
+ }
+});