Bug 1320736 - Part 3: Create ExtensionPreferencesManager module, r?aswan draft
authorBob Silverberg <bsilverberg@mozilla.com>
Mon, 16 Jan 2017 17:30:47 -0500
changeset 485074 a238ff5d42b6cd7a8a4b5e254faee8315c257bc1
parent 485067 baa165855bab8ede4f0dd95b674f1b3de15871ba
child 485086 741775f075bb0a808b14c3f44510c5986972a10d
child 485414 ba88d9de7e90c473e7a29e84502a925f7ca8d3a9
child 486243 9c1b74255abee81dd3499b5858f49e5e4f5b2906
child 487322 a861ec44685e35854b33931f279b0f045df650ac
push id45619
push userbmo:bob.silverberg@gmail.com
push dateThu, 16 Feb 2017 02:26:27 +0000
reviewersaswan
bugs1320736
milestone54.0a1
Bug 1320736 - Part 3: Create ExtensionPreferencesManager module, r?aswan MozReview-Commit-ID: BiY8XikUSUV
toolkit/components/extensions/ExtensionPreferencesManager.jsm
toolkit/components/extensions/moz.build
toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js
toolkit/components/extensions/test/xpcshell/xpcshell.ini
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionPreferencesManager.jsm
@@ -0,0 +1,184 @@
+/* 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 managing preferences from WebExtension APIs.
+ * It takes care of the precedence chain and decides whether a preference
+ * needs to be updated when a change is requested by an API.
+ *
+ * It deals with preferences via settings objects, which are objects with
+ * the following properties:
+ *
+ * prefNames:   An array of strings, each of which is a preference on
+ *              which the setting depends.
+ * setCallback: A function that returns an object containing properties and
+ *              values that correspond to the prefs to be set.
+ */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["ExtensionPreferencesManager"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionSettingsStore",
+                                  "resource://gre/modules/ExtensionSettingsStore.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Preferences",
+                                  "resource://gre/modules/Preferences.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "defaultPreferences", function() {
+  return new Preferences({defaultBranch: true});
+});
+
+const STORE_TYPE = "prefs";
+
+// Definitions of settings, each of which correspond to a different API.
+let settingsMap = new Map();
+
+/**
+ * This function is passed into the ExtensionSettingsStore to determine the
+ * initial value of the setting. It reads an array of preference names from
+ * the this scope, which gets bound to a settings object.
+ *
+ * @returns {Object}
+ *          An object with one property per preference, which holds the current
+ *          value of that preference.
+ */
+function initialValueCallback() {
+  let initialValue = {};
+  for (let pref of this.prefNames) {
+    initialValue[pref] = Preferences.get(pref);
+  }
+  return initialValue;
+}
+
+/**
+ * Takes an object of preferenceName:value pairs and either sets or resets the
+ * preference to the value.
+ *
+ * @param {Object} prefsObject
+ *        An object with one property per preference, which holds the value to
+ *        store in that preference. If the value is undefined then the
+ *        preference is reset.
+ */
+function setPrefs(prefsObject) {
+  for (let pref in prefsObject) {
+    if (prefsObject[pref] === undefined) {
+      Preferences.reset(pref);
+    } else {
+      Preferences.set(pref, prefsObject[pref]);
+    }
+  }
+}
+
+this.ExtensionPreferencesManager = {
+  /**
+   * Adds a setting to the settingsMap. This is how an API tells the
+   * preferences manager what its setting object is. The preferences
+   * manager needs to know this when settings need to be removed
+   * automatically.
+   *
+   * @param {string} name The unique id of the setting.
+   * @param {Object} setting
+   *        A setting object that should have properties for
+   *        prefNames, getCallback and setCallback.
+   */
+  addSetting(name, setting) {
+    settingsMap.set(name, setting);
+  },
+
+  /**
+   * Gets the default value for a preference.
+   *
+   * @param {string} prefName The name of the preference.
+   *
+   * @returns {string|number|boolean} The default value of the preference.
+   */
+  getDefaultValue(prefName) {
+    return defaultPreferences.get(prefName);
+  },
+
+  /**
+   * Indicates that an extension would like to change the value of a previously
+   * defined setting.
+   *
+   * @param {Extension} extension
+   *        The extension for which a setting is being set.
+   * @param {string} name
+   *        The unique id of the setting.
+   * @param {any} value
+   *        The value to be stored in the settings store for this
+   *        group of preferences.
+   *
+   * @returns {Promise}
+   *          Resolves to true if the preferences were changed and to false if
+   *          the preferences were not changed.
+   */
+  async setSetting(extension, name, value) {
+    let setting = settingsMap.get(name);
+    let item = await ExtensionSettingsStore.addSetting(
+      extension, STORE_TYPE, name, value, initialValueCallback.bind(setting));
+    if (item) {
+      let prefs = await setting.setCallback(item.value);
+      setPrefs(prefs);
+      return true;
+    }
+    return false;
+  },
+
+  /**
+   * Indicates that this extension no longer wants to set the given preference.
+   *
+   * @param {Extension} extension
+   *        The extension for which a preference setting is being removed.
+   * @param {string} name
+   *        The unique id of the setting.
+   */
+  async unsetSetting(extension, name) {
+    let item = await ExtensionSettingsStore.removeSetting(
+      extension, STORE_TYPE, name);
+    if (item) {
+      let prefs = item.initialValue || await settingsMap.get(name).setCallback(item.value);
+      setPrefs(prefs);
+    }
+  },
+
+  /**
+   * Unsets all previously set settings for an extension. This can be called when
+   * an extension is being uninstalled or disabled, for example.
+   *
+   * @param {Extension} extension The extension for which all settings are being unset.
+   */
+  async unsetAll(extension) {
+    let settings = await ExtensionSettingsStore.getAllForExtension(extension, STORE_TYPE);
+    for (let name of settings) {
+      await this.unsetSetting(extension, name);
+    }
+  },
+
+  /**
+   * Return the levelOfControl for a setting / extension combo.
+   * This queries the levelOfControl from the ExtensionSettingsStore and also
+   * takes into account whether any of the setting's preferences are locked.
+   *
+   * @param {Extension} extension
+   *        The extension for which levelOfControl is being requested.
+   * @param {string} name
+   *        The unique id of the setting.
+   *
+   * @returns {Promise}
+   *          Resolves to the level of control of the extension over the setting.
+   */
+  async getLevelOfControl(extension, name) {
+    for (let prefName of settingsMap.get(name).prefNames) {
+      if (Preferences.locked(prefName)) {
+        return "not_controllable";
+      }
+    }
+    return await ExtensionSettingsStore.getLevelOfControl(extension, STORE_TYPE, name);
+  },
+};
--- a/toolkit/components/extensions/moz.build
+++ b/toolkit/components/extensions/moz.build
@@ -7,16 +7,17 @@
 EXTRA_JS_MODULES += [
     'Extension.jsm',
     'ExtensionAPI.jsm',
     'ExtensionChild.jsm',
     'ExtensionCommon.jsm',
     'ExtensionContent.jsm',
     'ExtensionManagement.jsm',
     'ExtensionParent.jsm',
+    'ExtensionPreferencesManager.jsm',
     'ExtensionSettingsStore.jsm',
     'ExtensionStorage.jsm',
     'ExtensionStorageSync.jsm',
     'ExtensionTabs.jsm',
     'ExtensionUtils.jsm',
     'LegacyExtensionsUtils.jsm',
     'MessageChannel.jsm',
     'NativeMessaging.jsm',
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js
@@ -0,0 +1,201 @@
+/* -*- 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, "ExtensionPreferencesManager",
+                                  "resource://gre/modules/ExtensionPreferencesManager.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionSettingsStore",
+                                  "resource://gre/modules/ExtensionSettingsStore.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Preferences",
+                                  "resource://gre/modules/Preferences.jsm");
+
+const {
+  createAppInfo,
+  promiseShutdownManager,
+  promiseStartupManager,
+} = AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+const STORE_TYPE = "prefs";
+
+// Test settings to use with the preferences manager.
+const SETTINGS = {
+  multiple_prefs: {
+    prefNames: ["my.pref.1", "my.pref.2", "my.pref.3"],
+
+    initalValues: ["value1", "value2", "value3"],
+
+    valueFn(pref, value) {
+      return `${pref}-${value}`;
+    },
+
+    setCallback(value) {
+      let prefs = {};
+      for (let pref of this.prefNames) {
+        prefs[pref] = this.valueFn(pref, value);
+      }
+      return prefs;
+    },
+  },
+
+  singlePref: {
+    prefNames: [
+      "my.single.pref",
+    ],
+
+    initalValues: ["value1"],
+
+    valueFn(pref, value) {
+      return value;
+    },
+
+    setCallback(value) {
+      return {[this.prefNames[0]]: this.valueFn(null, value)};
+    },
+  },
+};
+
+ExtensionPreferencesManager.addSetting("multiple_prefs", SETTINGS.multiple_prefs);
+ExtensionPreferencesManager.addSetting("singlePref", SETTINGS.singlePref);
+
+// Set initial values for prefs.
+for (let setting in SETTINGS) {
+  setting = SETTINGS[setting];
+  for (let i = 0; i < setting.prefNames.length; i++) {
+    Preferences.set(setting.prefNames[i], setting.initalValues[i]);
+  }
+}
+
+add_task(async function test_preference_manager() {
+  // Create an array of test framework extension wrappers to install.
+  let testExtensions = [
+    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);
+
+  for (let setting in SETTINGS) {
+    let settingObj = SETTINGS[setting];
+    let newValue1 = "newValue1";
+    let levelOfControl = await ExtensionPreferencesManager.getLevelOfControl(extensions[1], setting);
+    equal(levelOfControl, "controllable_by_this_extension",
+      "getLevelOfControl returns correct levelOfControl with no settings set.");
+    let settingSet = await ExtensionPreferencesManager.setSetting(extensions[1], setting, newValue1);
+    ok(settingSet, "setSetting returns true when the pref(s) have been set.");
+    for (let pref of settingObj.prefNames) {
+      equal(Preferences.get(pref), settingObj.valueFn(pref, newValue1),
+        "setSetting sets the prefs for the first extension.");
+    }
+    levelOfControl = await ExtensionPreferencesManager.getLevelOfControl(extensions[1], setting);
+    equal(
+      levelOfControl,
+      "controlled_by_this_extension",
+      "getLevelOfControl returns correct levelOfControl when a pref has been set.");
+
+    let newValue2 = "newValue2";
+    settingSet = await ExtensionPreferencesManager.setSetting(extensions[0], setting, newValue2);
+    ok(!settingSet, "setSetting returns false when the pref(s) have not been set.");
+    for (let pref of settingObj.prefNames) {
+      equal(Preferences.get(pref), settingObj.valueFn(pref, newValue1),
+        "setSetting does not set the pref(s) for an earlier extension.");
+    }
+
+    await ExtensionPreferencesManager.unsetSetting(extensions[0], setting);
+    for (let pref of settingObj.prefNames) {
+      equal(Preferences.get(pref), settingObj.valueFn(pref, newValue1),
+        "unsetSetting does not change the pref(s) for the non-top extension.");
+    }
+
+    await ExtensionPreferencesManager.setSetting(extensions[0], setting, newValue2);
+    for (let pref of settingObj.prefNames) {
+      equal(Preferences.get(pref), settingObj.valueFn(pref, newValue1),
+        "setSetting does not set the pref(s) for an earlier extension.");
+    }
+
+    await ExtensionPreferencesManager.unsetSetting(extensions[1], setting);
+    for (let pref of settingObj.prefNames) {
+      equal(Preferences.get(pref), settingObj.valueFn(pref, newValue2),
+        "unsetSetting sets the pref(s) to the next value when removing the top extension.");
+    }
+
+    await ExtensionPreferencesManager.unsetSetting(extensions[0], setting);
+    for (let i = 0; i < settingObj.prefNames.length; i++) {
+      equal(Preferences.get(settingObj.prefNames[i]), settingObj.initalValues[i],
+        "unsetSetting sets the pref(s) to the initial value(s) when removing the last extension.");
+    }
+  }
+
+  // Tests for unsetAll.
+  let newValue3 = "newValue3";
+  for (let setting in SETTINGS) {
+    let settingObj = SETTINGS[setting];
+    await ExtensionPreferencesManager.setSetting(extensions[0], setting, newValue3);
+    for (let pref of settingObj.prefNames) {
+      equal(Preferences.get(pref), settingObj.valueFn(pref, newValue3), `setSetting set the pref for ${pref}.`);
+    }
+  }
+
+  let setSettings = await ExtensionSettingsStore.getAllForExtension(extensions[0], STORE_TYPE);
+  deepEqual(setSettings, Object.keys(SETTINGS), "Expected settings were set for extension.");
+  await ExtensionPreferencesManager.unsetAll(extensions[0]);
+
+  for (let setting in SETTINGS) {
+    let settingObj = SETTINGS[setting];
+    for (let i = 0; i < settingObj.prefNames.length; i++) {
+      equal(Preferences.get(settingObj.prefNames[i]), settingObj.initalValues[i],
+        "unsetAll unset the pref.");
+    }
+  }
+
+  setSettings = await ExtensionSettingsStore.getAllForExtension(extensions[0], STORE_TYPE);
+  deepEqual(setSettings, [], "unsetAll removed all settings.");
+
+  // Test with an uninitialized pref.
+  let setting = "singlePref";
+  let settingObj = SETTINGS[setting];
+  let pref = settingObj.prefNames[0];
+  let newValue = "newValue";
+  Preferences.reset(pref);
+  await ExtensionPreferencesManager.setSetting(extensions[1], setting, newValue);
+  equal(Preferences.get(pref), settingObj.valueFn(pref, newValue),
+    "Uninitialized pref is set.");
+  await ExtensionPreferencesManager.unsetSetting(extensions[1], setting);
+  ok(!Preferences.has(pref), "unsetSetting removed the pref.");
+
+  // Test levelOfControl with a locked pref.
+  setting = "multiple_prefs";
+  let prefToLock = SETTINGS[setting].prefNames[0];
+  Preferences.lock(prefToLock, 1);
+  ok(Preferences.locked(prefToLock), `Preference ${prefToLock} is locked.`);
+  let levelOfControl = await ExtensionPreferencesManager.getLevelOfControl(extensions[1], setting);
+  equal(
+    levelOfControl,
+    "not_controllable",
+    "getLevelOfControl returns correct levelOfControl when a pref is locked.");
+
+  for (let extension of testExtensions) {
+    await extension.unload();
+  }
+
+  await promiseShutdownManager();
+});
--- a/toolkit/components/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini
@@ -31,16 +31,17 @@ skip-if = os == "android" # Containers a
 skip-if = os == "android"
 [test_ext_downloads_misc.js]
 skip-if = os == "android" || (os=='linux' && bits==32) # linux32: bug 1324870
 [test_ext_downloads_search.js]
 skip-if = os == "android"
 [test_ext_experiments.js]
 skip-if = release_or_beta
 [test_ext_extension.js]
+[test_ext_extensionPreferencesManager.js]
 [test_ext_extensionSettingsStore.js]
 [test_ext_idle.js]
 [test_ext_json_parser.js]
 [test_ext_localStorage.js]
 [test_ext_management.js]
 [test_ext_management_uninstall_self.js]
 [test_ext_manifest_content_security_policy.js]
 [test_ext_manifest_incognito.js]