new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionSettingsStore.jsm
@@ -0,0 +1,281 @@
+/* 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/. */
+
+/**
+ * @fileOverview
+ * 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.
+ * value // The value of the setting requested by the extension.
+ * }
+ * ],
+ * },
+ * key: {
+ * // ...
+ * }
+ * }
+ * }
+ *
+ */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["ExtensionSettingsStore"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/osfile.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
+ "resource://gre/modules/AddonManager.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "JSONFile",
+ "resource://gre/modules/JSONFile.jsm");
+
+const JSON_FILE_NAME = "extension-settings.json";
+const STORE_PATH = OS.Path.join(Services.dirsvc.get("ProfD", Ci.nsIFile).path, JSON_FILE_NAME);
+
+let _store;
+
+// Get the internal settings store, which is persisted in a JSON file.
+async function getStore(type) {
+ if (!_store) {
+ _store = new JSONFile({
+ path: STORE_PATH,
+ });
+ await _store.load();
+ }
+ _store.ensureDataReady();
+
+ // Ensure a property exists for the given type.
+ if (!_store.data[type]) {
+ _store.data[type] = {};
+ }
+
+ return _store;
+}
+
+// Return an object with properties for key and value|initialValue, or null
+// if no setting has been stored for that key.
+async function getTopItem(type, key) {
+ let store = await getStore(type);
+
+ let keyInfo = store.data[type][key];
+ if (!keyInfo) {
+ return null;
+ }
+
+ if (!keyInfo.precedenceList.length) {
+ return {key, initialValue: keyInfo.initialValue};
+ }
+
+ return {key, value: keyInfo.precedenceList[0].value};
+}
+
+this.ExtensionSettingsStore = {
+ /**
+ * Adds a setting to the store, possibly returning the current top precedent
+ * setting.
+ *
+ * @param {Extension} extension
+ * 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
+ * An function to be called to determine the initial value for the
+ * setting. This will be passed the value in the callbackArgument
+ * argument.
+ * @param {any} callbackArgument
+ * The value to be passed into the initialValueCallback. It defaults to
+ * the value of the key argument.
+ *
+ * @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
+ * at the top of the precedence list.
+ */
+ async addSetting(extension, type, key, value, initialValueCallback, callbackArgument = key) {
+ if (typeof initialValueCallback != "function") {
+ throw new Error("initialValueCallback must be a function.");
+ }
+
+ let id = extension.id;
+ let store = await getStore(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];
+ // Check for this item in the precedenceList.
+ let foundIndex = keyInfo.precedenceList.findIndex(item => item.id == id);
+ if (foundIndex == -1) {
+ // No item for this extension, so add a new one.
+ let addon = await AddonManager.getAddonByID(id);
+ keyInfo.precedenceList.push({id, installDate: addon.installDate, value});
+ } else {
+ // Item already exists or this extension, so update it.
+ keyInfo.precedenceList[foundIndex].value = value;
+ }
+
+ // Sort the list.
+ keyInfo.precedenceList.sort((a, b) => {
+ return b.installDate - a.installDate;
+ });
+
+ store.saveSoon();
+
+ // Check whether this is currently the top item.
+ if (keyInfo.precedenceList[0].id == id) {
+ return {key, value};
+ }
+ return null;
+ },
+
+ /**
+ * Removes a setting from the store, returning the current top precedent
+ * setting.
+ *
+ * @param {Extension} extension 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, which corresponds to the current top
+ * precedent setting, or null if the current top
+ * precedent setting has not changed.
+ */
+ async removeSetting(extension, type, key) {
+ let returnItem;
+ let store = await getStore(type);
+
+ let keyInfo = store.data[type][key];
+ if (!keyInfo) {
+ throw new Error(
+ `Cannot remove setting for ${type}:${key} as it does not exist.`);
+ }
+
+ let id = extension.id;
+ let foundIndex = keyInfo.precedenceList.findIndex(item => item.id == id);
+
+ if (foundIndex == -1) {
+ throw new Error(
+ `Cannot remove setting for ${type}:${key} as it does not exist.`);
+ }
+
+ keyInfo.precedenceList.splice(foundIndex, 1);
+
+ if (foundIndex == 0) {
+ returnItem = await getTopItem(type, key);
+ }
+
+ if (keyInfo.precedenceList.length == 0) {
+ delete store.data[type][key];
+ }
+ store.saveSoon();
+
+ return returnItem;
+ },
+
+ /**
+ * Retrieves all settings from the store for a given extension.
+ *
+ * @param {Extension} extension 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.
+ */
+ async getAllForExtension(extension, type) {
+ let store = await getStore(type);
+
+ let keysObj = store.data[type];
+ let items = [];
+ for (let key in keysObj) {
+ if (keysObj[key].precedenceList.find(item => item.id == extension.id)) {
+ items.push(key);
+ }
+ }
+ return items;
+ },
+
+ /**
+ * Retrieves a setting from the store, returning the 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.
+ *
+ * @returns {object} An object with properties for key and value.
+ */
+ async getSetting(type, key) {
+ return await getTopItem(type, 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 {Extension} extension 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(extension, type, key) {
+ let store = await getStore(type);
+
+ let keyInfo = store.data[type][key];
+ if (!keyInfo || !keyInfo.precedenceList.length) {
+ return "controllable_by_this_extension";
+ }
+
+ let id = extension.id;
+ let topItem = keyInfo.precedenceList[0];
+ if (topItem.id == id) {
+ return "controlled_by_this_extension";
+ }
+
+ let addon = await AddonManager.getAddonByID(id);
+ return topItem.installDate > addon.installDate ?
+ "controlled_by_other_extensions" :
+ "controllable_by_this_extension";
+ },
+};
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js
@@ -0,0 +1,311 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
+ "resource://gre/modules/AddonManager.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionSettingsStore",
+ "resource://gre/modules/ExtensionSettingsStore.jsm");
+
+const {
+ createAppInfo,
+ promiseShutdownManager,
+ promiseStartupManager,
+} = AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+// Allow for unsigned addons.
+AddonTestUtils.overrideCertDB();
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+const ITEMS = {
+ key1: [
+ {key: "key1", value: "val1"},
+ {key: "key1", value: "val2"},
+ {key: "key1", value: "val3"},
+ ],
+ key2: [
+ {key: "key2", value: "val1-2"},
+ {key: "key2", value: "val2-2"},
+ {key: "key2", value: "val3-2"},
+ ],
+};
+const KEY_LIST = Object.keys(ITEMS);
+const TEST_TYPE = "myType";
+
+let callbackCount = 0;
+
+function initialValue(key) {
+ callbackCount++;
+ return `key:${key}`;
+}
+
+add_task(async function test_settings_store() {
+ // Create an array of test framework extension wrappers to install.
+ let testExtensions = [
+ ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {},
+ }),
+ ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {},
+ }),
+ ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {},
+ }),
+ ];
+
+ await promiseStartupManager();
+
+ for (let extension of testExtensions) {
+ await extension.startup();
+ }
+
+ // Create an array actual Extension objects which correspond to the
+ // test framework extension wrappers.
+ let extensions = testExtensions.map(extension => extension.extension._extension);
+
+ let expectedCallbackCount = 0;
+
+ // Add a setting for the second oldest extension, where it is the only setting for a key.
+ for (let key of KEY_LIST) {
+ let extensionIndex = 1;
+ let itemToAdd = ITEMS[key][extensionIndex];
+ let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[extensionIndex], TEST_TYPE, key);
+ equal(
+ levelOfControl,
+ "controllable_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl with no settings set for a key.");
+ let item = await ExtensionSettingsStore.addSetting(
+ extensions[extensionIndex], TEST_TYPE, itemToAdd.key, itemToAdd.value, initialValue);
+ expectedCallbackCount++;
+ equal(callbackCount,
+ expectedCallbackCount,
+ "initialValueCallback called the expected number of times.");
+ deepEqual(item, itemToAdd, "Adding initial item for a key returns that item.");
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ itemToAdd,
+ "getSetting returns correct item with only one item in the list.");
+ levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[extensionIndex], TEST_TYPE, key);
+ equal(
+ levelOfControl,
+ "controlled_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl with only one item in the list.");
+ }
+
+ // Add a setting for the oldest extension.
+ for (let key of KEY_LIST) {
+ let extensionIndex = 0;
+ let itemToAdd = ITEMS[key][extensionIndex];
+ let item = await ExtensionSettingsStore.addSetting(
+ extensions[extensionIndex], TEST_TYPE, itemToAdd.key, itemToAdd.value, initialValue);
+ equal(callbackCount,
+ expectedCallbackCount,
+ "initialValueCallback called the expected number of times.");
+ equal(item, null, "An older extension adding a setting for a key returns null");
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ ITEMS[key][1],
+ "getSetting returns correct item with more than one item in the list.");
+ let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[extensionIndex], TEST_TYPE, key);
+ equal(
+ levelOfControl,
+ "controlled_by_other_extensions",
+ "getLevelOfControl returns correct levelOfControl when another extension is in control.");
+ }
+
+ // Add a setting for the newest extension.
+ for (let key of KEY_LIST) {
+ let extensionIndex = 2;
+ let itemToAdd = ITEMS[key][extensionIndex];
+ let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[extensionIndex], TEST_TYPE, key);
+ equal(
+ levelOfControl,
+ "controllable_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl for a more recent extension.");
+ let item = await ExtensionSettingsStore.addSetting(
+ extensions[extensionIndex], TEST_TYPE, itemToAdd.key, itemToAdd.value, initialValue);
+ equal(callbackCount,
+ expectedCallbackCount,
+ "initialValueCallback called the expected number of times.");
+ deepEqual(item, itemToAdd, "Adding item for most recent extension returns that item.");
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ ITEMS[key][2],
+ "getSetting returns correct item with more than one item in the list.");
+ levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[extensionIndex], TEST_TYPE, key);
+ equal(
+ levelOfControl,
+ "controlled_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl when this extension is in control.");
+ }
+
+ for (let extension of extensions) {
+ let items = await ExtensionSettingsStore.getAllForExtension(extension, TEST_TYPE);
+ deepEqual(items, KEY_LIST, "getAllForExtension returns expected keys.");
+ }
+
+ // Attempting to remove a setting that has not been set should throw an exception.
+ await Assert.rejects(
+ ExtensionSettingsStore.removeSetting(
+ extensions[0], "myType", "unset_key"),
+ /Cannot remove setting for myType:unset_key as it does not exist/,
+ "removeSetting rejects with an unset key.");
+
+ let expectedKeys = KEY_LIST;
+ // Remove the non-top item for a key.
+ for (let key of KEY_LIST) {
+ let extensionIndex = 0;
+ let item = await ExtensionSettingsStore.addSetting(
+ extensions[extensionIndex], TEST_TYPE, key, "new value", initialValue);
+ equal(callbackCount,
+ expectedCallbackCount,
+ "initialValueCallback called the expected number of times.");
+ equal(item, null, "Updating non-top item for a key returns null");
+ item = await ExtensionSettingsStore.removeSetting(extensions[extensionIndex], TEST_TYPE, key);
+ equal(item, null, "Removing non-top item for a key returns null.");
+ expectedKeys = expectedKeys.filter(expectedKey => expectedKey != key);
+ let allForExtension = await ExtensionSettingsStore.getAllForExtension(extensions[extensionIndex], TEST_TYPE);
+ deepEqual(allForExtension, expectedKeys, "getAllForExtension returns expected keys after a removal.");
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ ITEMS[key][2],
+ "getSetting returns correct item after a removal.");
+ let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[extensionIndex], TEST_TYPE, key);
+ equal(
+ levelOfControl,
+ "controlled_by_other_extensions",
+ "getLevelOfControl returns correct levelOfControl after removal of non-top item.");
+ }
+
+ for (let key of KEY_LIST) {
+ // Remove the top item for a key.
+ let item = await ExtensionSettingsStore.removeSetting(extensions[2], TEST_TYPE, key);
+ deepEqual(
+ item,
+ ITEMS[key][1],
+ "Removing top item for a key returns the new top item.");
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ ITEMS[key][1],
+ "getSetting returns correct item after a removal.");
+ let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[2], TEST_TYPE, key);
+ equal(
+ levelOfControl,
+ "controllable_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl after removal of top item.");
+
+ // Add a setting for the current top item.
+ let itemToAdd = {key, value: `new-${key}`};
+ item = await ExtensionSettingsStore.addSetting(
+ extensions[1], TEST_TYPE, itemToAdd.key, itemToAdd.value, initialValue);
+ equal(callbackCount,
+ expectedCallbackCount,
+ "initialValueCallback called the expected number of times.");
+ deepEqual(
+ item,
+ itemToAdd,
+ "Updating top item for a key returns that item.");
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ itemToAdd,
+ "getSetting returns correct item after updating.");
+ levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[1], TEST_TYPE, key);
+ equal(
+ levelOfControl,
+ "controlled_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl after updating.");
+
+ // Remove the last remaining item for a key.
+ let expectedItem = {key, initialValue: initialValue(key)};
+ // We're using the callback to set the expected value, so we need to increment the
+ // expectedCallbackCount.
+ expectedCallbackCount++;
+ item = await ExtensionSettingsStore.removeSetting(extensions[1], TEST_TYPE, key);
+ deepEqual(
+ item,
+ expectedItem,
+ "Removing last item for a key returns the initial value.");
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ null,
+ "getSetting returns null after all are removed.");
+ levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[1], TEST_TYPE, key);
+ equal(
+ levelOfControl,
+ "controllable_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl after all are removed.");
+
+ // Attempting to remove a setting that has had all extensions removed should throw an exception.
+ let expectedMessage = new RegExp(`Cannot remove setting for ${TEST_TYPE}:${key} as it does not exist`);
+ await Assert.rejects(
+ ExtensionSettingsStore.removeSetting(
+ extensions[1], TEST_TYPE, key),
+ expectedMessage,
+ "removeSetting rejects with an key that has all records removed.");
+ }
+
+ // Test adding a setting with a value in callbackArgument.
+ let extensionIndex = 0;
+ let testKey = "callbackArgumentKey";
+ let callbackArgumentValue = Date.now();
+ // Add the setting.
+ let item = await ExtensionSettingsStore.addSetting(
+ extensions[extensionIndex], TEST_TYPE, testKey, 1, initialValue, callbackArgumentValue);
+ expectedCallbackCount++;
+ equal(callbackCount,
+ expectedCallbackCount,
+ "initialValueCallback called the expected number of times.");
+ // Remove the setting which should return the initial value.
+ let expectedItem = {key: testKey, initialValue: initialValue(callbackArgumentValue)};
+ // We're using the callback to set the expected value, so we need to increment the
+ // expectedCallbackCount.
+ expectedCallbackCount++;
+ item = await ExtensionSettingsStore.removeSetting(extensions[extensionIndex], TEST_TYPE, testKey);
+ deepEqual(
+ item,
+ expectedItem,
+ "Removing last item for a key returns the initial value.");
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, testKey);
+ deepEqual(
+ item,
+ null,
+ "getSetting returns null after all are removed.");
+
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, "not a key");
+ equal(
+ item,
+ null,
+ "getSetting returns a null item if the setting does not have any records.");
+ let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[1], TEST_TYPE, "not a key");
+ equal(
+ levelOfControl,
+ "controllable_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl if the setting does not have any records.");
+
+ for (let extension of testExtensions) {
+ await extension.unload();
+ }
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_exceptions() {
+ await Assert.rejects(
+ ExtensionSettingsStore.addSetting(
+ 1, TEST_TYPE, "key_not_a_function", "val1", "not a function"),
+ /initialValueCallback must be a function/,
+ "addSetting rejects with a callback that is not a function.");
+});