/* -*- 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}` ); } } });