Source code

Revision control

Copy as Markdown

Other Tools

/* -*- 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 {Promise<object?>} 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 {Promise<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}`
);
}
}
});