Bug 1404584 Part 2: Convert ext-chrome-settings-overrides to use update and uninstall events, r=aswan,mkaply
authorBob Silverberg <bsilverberg@mozilla.com>
Wed, 01 Nov 2017 09:50:03 -0400
changeset 437045 91aec40e17a315b223f1c4e61e307dcdc7d3f30e
parent 437044 18e5e565af571abcecb7de72c65f8ca380581266
child 437046 ec69c7fcd213bde18fd4fc6850589bbe1e8dda87
push id117
push userfmarier@mozilla.com
push dateTue, 28 Nov 2017 20:17:16 +0000
reviewersaswan, mkaply
bugs1404584
milestone59.0a1
Bug 1404584 Part 2: Convert ext-chrome-settings-overrides to use update and uninstall events, r=aswan,mkaply MozReview-Commit-ID: KtagRbDz19S
browser/components/extensions/ext-browser.json
browser/components/extensions/ext-chrome-settings-overrides.js
browser/components/extensions/test/browser/browser_ext_settings_overrides_default_search.js
browser/components/extensions/test/browser/browser_ext_settings_overrides_search.js
browser/components/extensions/test/xpcshell/test_ext_chrome_settings_overrides_update.js
browser/components/extensions/test/xpcshell/xpcshell-common.ini
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionParent.jsm
toolkit/components/extensions/ExtensionSettingsStore.jsm
toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js
--- a/browser/components/extensions/ext-browser.json
+++ b/browser/components/extensions/ext-browser.json
@@ -22,16 +22,17 @@
     "scopes": ["addon_parent"],
     "paths": [
       ["browsingData"]
     ]
   },
   "chrome_settings_overrides": {
     "url": "chrome://browser/content/ext-chrome-settings-overrides.js",
     "scopes": [],
+    "events": ["update", "uninstall"],
     "schema": "chrome://browser/content/schemas/chrome_settings_overrides.json",
     "manifest": ["chrome_settings_overrides"]
   },
   "commands": {
     "url": "chrome://browser/content/ext-commands.js",
     "schema": "chrome://browser/content/schemas/commands.json",
     "scopes": ["addon_parent"],
     "manifest": ["commands"],
--- a/browser/components/extensions/ext-chrome-settings-overrides.js
+++ b/browser/components/extensions/ext-chrome-settings-overrides.js
@@ -10,17 +10,22 @@ const {classes: Cc, interfaces: Ci, util
 
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionPreferencesManager",
                                   "resource://gre/modules/ExtensionPreferencesManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionSettingsStore",
                                   "resource://gre/modules/ExtensionSettingsStore.jsm");
 
 const DEFAULT_SEARCH_STORE_TYPE = "default_search";
 const DEFAULT_SEARCH_SETTING_NAME = "defaultSearch";
+const ENGINE_ADDED_SETTING_NAME = "engineAdded";
 
+// This promise is used to wait for the search service to be initialized.
+// None of the code in this module requests that initialization. It is assumed
+// that it is started at some point. If tests start to fail because this
+// promise never resolves, that's likely the cause.
 const searchInitialized = () => {
   if (Services.search.isInitialized) {
     return;
   }
   return new Promise(resolve => {
     const SEARCH_SERVICE_TOPIC = "browser-search-service";
     Services.obs.addObserver(function observer(subject, topic, data) {
       if (data != "init-complete") {
@@ -29,94 +34,118 @@ const searchInitialized = () => {
 
       Services.obs.removeObserver(observer, SEARCH_SERVICE_TOPIC);
       resolve();
     }, SEARCH_SERVICE_TOPIC);
   });
 };
 
 this.chrome_settings_overrides = class extends ExtensionAPI {
-  processDefaultSearchSetting(action) {
-    let {extension} = this;
+  static async processDefaultSearchSetting(action, id) {
+    await ExtensionSettingsStore.initialize();
     let item = ExtensionSettingsStore.getSetting(DEFAULT_SEARCH_STORE_TYPE, DEFAULT_SEARCH_SETTING_NAME);
     if (!item) {
       return;
     }
     if (Services.search.currentEngine.name != item.value &&
         Services.search.currentEngine.name != item.initialValue) {
       // The current engine is not the same as the value that the ExtensionSettingsStore has.
       // This means that the user changed the engine, so we shouldn't control it anymore.
       // Do nothing and remove our entry from the ExtensionSettingsStore.
-      ExtensionSettingsStore.removeSetting(extension.id, DEFAULT_SEARCH_STORE_TYPE, DEFAULT_SEARCH_SETTING_NAME);
+      ExtensionSettingsStore.removeSetting(id, DEFAULT_SEARCH_STORE_TYPE, DEFAULT_SEARCH_SETTING_NAME);
       return;
     }
-    item = ExtensionSettingsStore[action](extension.id, DEFAULT_SEARCH_STORE_TYPE, DEFAULT_SEARCH_SETTING_NAME);
+    item = ExtensionSettingsStore[action](id, DEFAULT_SEARCH_STORE_TYPE, DEFAULT_SEARCH_SETTING_NAME);
     if (item) {
       try {
         let engine = Services.search.getEngineByName(item.value || item.initialValue);
         if (engine) {
           Services.search.currentEngine = engine;
         }
       } catch (e) {
         Components.utils.reportError(e);
       }
     }
   }
 
+  static async removeEngine(id) {
+    await ExtensionSettingsStore.initialize();
+    let item = await ExtensionSettingsStore.getSetting(
+      DEFAULT_SEARCH_STORE_TYPE, ENGINE_ADDED_SETTING_NAME, id);
+    if (item) {
+      ExtensionSettingsStore.removeSetting(
+        id, DEFAULT_SEARCH_STORE_TYPE, ENGINE_ADDED_SETTING_NAME);
+      await searchInitialized();
+      let engine = Services.search.getEngineByName(item.value);
+      try {
+        Services.search.removeEngine(engine);
+      } catch (e) {
+        Components.utils.reportError(e);
+      }
+    }
+  }
+
+  static removeSearchSettings(id) {
+    this.processDefaultSearchSetting("removeSetting", id);
+    this.removeEngine(id);
+  }
+
+  static onUninstall(id) {
+    // Note: We do not have to deal with homepage here as it is managed by
+    // the ExtensionPreferencesManager.
+    this.removeSearchSettings(id);
+  }
+
+  static onUpdate(id, manifest) {
+    let haveHomepage = manifest && manifest.chrome_settings_overrides &&
+                       manifest.chrome_settings_overrides.homepage;
+    if (!haveHomepage) {
+      ExtensionPreferencesManager.removeSetting(id, "homepage_override");
+    }
+
+    let haveSearchProvider = manifest && manifest.chrome_settings_overrides &&
+                             manifest.chrome_settings_overrides.search_provider;
+    if (!haveSearchProvider) {
+      this.removeSearchSettings(id);
+    }
+  }
+
   async onManifestEntry(entryName) {
     let {extension} = this;
     let {manifest} = extension;
 
     await ExtensionSettingsStore.initialize();
     if (manifest.chrome_settings_overrides.homepage) {
       ExtensionPreferencesManager.setSetting(extension.id, "homepage_override",
                                              manifest.chrome_settings_overrides.homepage);
     }
     if (manifest.chrome_settings_overrides.search_provider) {
       await searchInitialized();
       extension.callOnClose({
         close: () => {
-          if (extension.shutdownReason == "ADDON_DISABLE" ||
-              extension.shutdownReason == "ADDON_UNINSTALL") {
-            switch (extension.shutdownReason) {
-              case "ADDON_DISABLE":
-                this.processDefaultSearchSetting("disable");
-                break;
-
-              case "ADDON_UNINSTALL":
-                this.processDefaultSearchSetting("removeSetting");
-                break;
-            }
-            // We shouldn't need to wait for search initialized here
-            // because the search service should be ready to go.
-            let engines = Services.search.getEnginesByExtensionID(extension.id);
-            for (let engine of engines) {
-              try {
-                Services.search.removeEngine(engine);
-              } catch (e) {
-                Components.utils.reportError(e);
-              }
-            }
+          if (extension.shutdownReason == "ADDON_DISABLE") {
+            chrome_settings_overrides.processDefaultSearchSetting("disable", extension.id);
+            chrome_settings_overrides.removeEngine(extension.id);
           }
         },
       });
 
       let searchProvider = manifest.chrome_settings_overrides.search_provider;
       let engineName = searchProvider.name.trim();
       if (searchProvider.is_default) {
         let engine = Services.search.getEngineByName(engineName);
         if (engine && Services.search.getDefaultEngines().includes(engine)) {
           // Needs to be called every time to handle reenabling, but
           // only sets default for install or enable.
           await this.setDefault(engineName);
           // For built in search engines, we don't do anything further
           return;
         }
       }
-      this.addSearchEngine(searchProvider);
+      await this.addSearchEngine(searchProvider);
       if (searchProvider.is_default) {
         if (extension.startupReason === "ADDON_INSTALL") {
           // Don't ask if it already the current engine
           let engine = Services.search.getEngineByName(engineName);
           if (Services.search.currentEngine != engine) {
             let allow = await new Promise(resolve => {
               let subject = {
                 wrappedJSObject: {
@@ -139,36 +168,35 @@ this.chrome_settings_overrides = class e
           }
         }
         // Needs to be called every time to handle reenabling, but
         // only sets default for install or enable.
         await this.setDefault(engineName);
       } else if (ExtensionSettingsStore.hasSetting(
                 extension.id, DEFAULT_SEARCH_STORE_TYPE, DEFAULT_SEARCH_SETTING_NAME)) {
         // is_default has been removed, but we still have a setting. Remove it.
-        // This won't cover the case where the entire search_provider is removed.
-        this.processDefaultSearchSetting("removeSetting");
+        chrome_settings_overrides.processDefaultSearchSetting("removeSetting", extension.id);
       }
     }
   }
 
   async setDefault(engineName) {
     let {extension} = this;
     if (extension.startupReason === "ADDON_INSTALL") {
       let item = await ExtensionSettingsStore.addSetting(
         extension.id, DEFAULT_SEARCH_STORE_TYPE, DEFAULT_SEARCH_SETTING_NAME, engineName, () => {
           return Services.search.currentEngine.name;
         });
       Services.search.currentEngine = Services.search.getEngineByName(item.value);
     } else if (extension.startupReason === "ADDON_ENABLE") {
-      this.processDefaultSearchSetting("enable");
+      chrome_settings_overrides.processDefaultSearchSetting("enable", extension.id);
     }
   }
 
-  addSearchEngine(searchProvider) {
+  async addSearchEngine(searchProvider) {
     let {extension} = this;
     let isCurrent = false;
     let index = -1;
     if (extension.startupReason === "ADDON_UPGRADE") {
       let engines = Services.search.getEnginesByExtensionID(extension.id);
       if (engines.length > 0) {
         // There can be only one engine right now
         isCurrent = Services.search.currentEngine == engines[0];
@@ -182,16 +210,19 @@ this.chrome_settings_overrides = class e
         template: searchProvider.search_url,
         iconURL: searchProvider.favicon_url,
         alias: searchProvider.keyword,
         extensionID: extension.id,
         suggestURL: searchProvider.suggest_url,
         queryCharset: "UTF-8",
       };
       Services.search.addEngineWithDetails(searchProvider.name.trim(), params);
+      await ExtensionSettingsStore.addSetting(
+        extension.id, DEFAULT_SEARCH_STORE_TYPE, ENGINE_ADDED_SETTING_NAME,
+        searchProvider.name.trim());
       if (extension.startupReason === "ADDON_UPGRADE") {
         let engine = Services.search.getEngineByName(searchProvider.name.trim());
         if (isCurrent) {
           Services.search.currentEngine = engine;
         }
         if (index != -1) {
           Services.search.moveEngine(engine, index);
         }
--- a/browser/components/extensions/test/browser/browser_ext_settings_overrides_default_search.js
+++ b/browser/components/extensions/test/browser/browser_ext_settings_overrides_default_search.js
@@ -24,20 +24,26 @@ function awaitEvent(eventName, id) {
         resolve(...args);
       }
     };
 
     Management.on(eventName, listener);
   });
 }
 
+let defaultEngineName = Services.search.currentEngine.name;
+
+function restoreDefaultEngine() {
+  let engine = Services.search.getEngineByName(defaultEngineName);
+  Services.search.currentEngine = engine;
+}
+registerCleanupFunction(restoreDefaultEngine);
+
 /* This tests setting a default engine. */
 add_task(async function test_extension_setting_default_engine() {
-  let defaultEngineName = Services.search.currentEngine.name;
-
   let ext1 = ExtensionTestUtils.loadExtension({
     manifest: {
       "chrome_settings_overrides": {
         "search_provider": {
           "name": "DuckDuckGo",
           "search_url": "https://example.com/?q={searchTerms}",
           "is_default": true,
         },
@@ -53,17 +59,16 @@ add_task(async function test_extension_s
   await ext1.unload();
 
   is(Services.search.currentEngine.name, defaultEngineName, `Default engine is ${defaultEngineName}`);
 });
 
 /* This tests that uninstalling add-ons maintains the proper
  * search default. */
 add_task(async function test_extension_setting_multiple_default_engine() {
-  let defaultEngineName = Services.search.currentEngine.name;
   let ext1 = ExtensionTestUtils.loadExtension({
     manifest: {
       "chrome_settings_overrides": {
         "search_provider": {
           "name": "DuckDuckGo",
           "search_url": "https://example.com/?q={searchTerms}",
           "is_default": true,
         },
@@ -100,17 +105,16 @@ add_task(async function test_extension_s
   await ext1.unload();
 
   is(Services.search.currentEngine.name, defaultEngineName, `Default engine is ${defaultEngineName}`);
 });
 
 /* This tests that uninstalling add-ons in reverse order maintains the proper
  * search default. */
 add_task(async function test_extension_setting_multiple_default_engine_reversed() {
-  let defaultEngineName = Services.search.currentEngine.name;
   let ext1 = ExtensionTestUtils.loadExtension({
     manifest: {
       "chrome_settings_overrides": {
         "search_provider": {
           "name": "DuckDuckGo",
           "search_url": "https://example.com/?q={searchTerms}",
           "is_default": true,
         },
@@ -170,16 +174,17 @@ add_task(async function test_user_changi
   is(Services.search.currentEngine.name, "DuckDuckGo", "Default engine is DuckDuckGo");
 
   let engine = Services.search.getEngineByName("Twitter");
   Services.search.currentEngine = engine;
 
   await ext1.unload();
 
   is(Services.search.currentEngine.name, "Twitter", "Default engine is Twitter");
+  restoreDefaultEngine();
 });
 
 /* This tests that when the user changes the search engine while it is
  * disabled, user choice is maintained when the add-on is reenabled. */
 add_task(async function test_user_change_with_disabling() {
   let ext1 = ExtensionTestUtils.loadExtension({
     manifest: {
       applications: {
@@ -215,23 +220,23 @@ add_task(async function test_user_change
   is(Services.search.currentEngine.name, "Twitter", "Default engine is Twitter");
 
   let enabledPromise = awaitEvent("ready", EXTENSION1_ID);
   addon.userDisabled = false;
   await enabledPromise;
 
   is(Services.search.currentEngine.name, "Twitter", "Default engine is Twitter");
   await ext1.unload();
+  restoreDefaultEngine();
 });
 
 /* This tests that when two add-ons are installed that change default
  * search and the first one is disabled, before the second one is installed,
  * when the first one is reenabled, the second add-on keeps the search. */
 add_task(async function test_two_addons_with_first_disabled_before_second() {
-  let defaultEngineName = Services.search.currentEngine.name;
   let ext1 = ExtensionTestUtils.loadExtension({
     manifest: {
       applications: {
         gecko: {
           id: EXTENSION1_ID,
         },
       },
       "chrome_settings_overrides": {
@@ -290,17 +295,16 @@ add_task(async function test_two_addons_
 
   is(Services.search.currentEngine.name, defaultEngineName, `Default engine is ${defaultEngineName}`);
 });
 
 /* This tests that when two add-ons are installed that change default
  * search and the first one is disabled, the second one maintains
  * the search. */
 add_task(async function test_two_addons_with_first_disabled() {
-  let defaultEngineName = Services.search.currentEngine.name;
   let ext1 = ExtensionTestUtils.loadExtension({
     manifest: {
       applications: {
         gecko: {
           id: EXTENSION1_ID,
         },
       },
       "chrome_settings_overrides": {
@@ -359,17 +363,16 @@ add_task(async function test_two_addons_
 
   is(Services.search.currentEngine.name, defaultEngineName, `Default engine is ${defaultEngineName}`);
 });
 
 /* This tests that when two add-ons are installed that change default
  * search and the second one is disabled, the first one properly
  * gets the search. */
 add_task(async function test_two_addons_with_second_disabled() {
-  let defaultEngineName = Services.search.currentEngine.name;
   let ext1 = ExtensionTestUtils.loadExtension({
     manifest: {
       applications: {
         gecko: {
           id: EXTENSION1_ID,
         },
       },
       "chrome_settings_overrides": {
--- a/browser/components/extensions/test/browser/browser_ext_settings_overrides_search.js
+++ b/browser/components/extensions/test/browser/browser_ext_settings_overrides_search.js
@@ -122,9 +122,8 @@ add_task(async function test_upgrade_def
   is(Services.search.getEngines().indexOf(engine), 1, "Engine is in position 1");
 
   await ext2.unload();
   await ext1.unload();
 
   engine = Services.search.getEngineByName("MozSearch");
   ok(!engine, "Engine should not exist");
 });
-
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/xpcshell/test_ext_chrome_settings_overrides_update.js
@@ -0,0 +1,102 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+Cu.import("resource://testing-common/AddonTestUtils.jsm");
+
+const {
+  createAppInfo,
+  promiseShutdownManager,
+  promiseStartupManager,
+} = AddonTestUtils;
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+add_task(async function test_overrides_update_removal() {
+  /* This tests the scenario where the manifest key for homepage and/or
+   * search_provider are removed between updates and therefore the
+   * settings are expected to revert. */
+
+  const EXTENSION_ID = "test_overrides_update@tests.mozilla.org";
+  const HOMEPAGE_URI = "webext-homepage-1.html";
+
+  const HOMEPAGE_URL_PREF = "browser.startup.homepage";
+
+  const getHomePageURL = () => {
+    return Services.prefs.getComplexValue(
+      HOMEPAGE_URL_PREF, Ci.nsIPrefLocalizedString).data;
+  };
+
+  function promisePrefChanged(value) {
+    return new Promise((resolve, reject) => {
+      Services.prefs.addObserver(HOMEPAGE_URL_PREF, function observer() {
+        if (getHomePageURL().endsWith(value)) {
+          Services.prefs.removeObserver(HOMEPAGE_URL_PREF, observer);
+          resolve();
+        }
+      });
+    });
+  }
+
+  await promiseStartupManager();
+
+  let extensionInfo = {
+    useAddonManager: "permanent",
+    manifest: {
+      "version": "1.0",
+      "applications": {
+        "gecko": {
+          "id": EXTENSION_ID,
+        },
+      },
+      "chrome_settings_overrides": {
+        "homepage": HOMEPAGE_URI,
+        "search_provider": {
+          "name": "DuckDuckGo",
+          "search_url": "https://example.com/?q={searchTerms}",
+          "is_default": true,
+        },
+      },
+    },
+  };
+  let extension = ExtensionTestUtils.loadExtension(extensionInfo);
+
+  let defaultHomepageURL = getHomePageURL();
+  let defaultEngineName = Services.search.currentEngine.name;
+
+  let prefPromise = promisePrefChanged(HOMEPAGE_URI);
+  await extension.startup();
+  await prefPromise;
+
+  equal(extension.version, "1.0", "The installed addon has the expected version.");
+  ok(getHomePageURL().endsWith(HOMEPAGE_URI),
+    "Home page url is overriden by the extension.");
+  equal(Services.search.currentEngine.name, "DuckDuckGo",
+    "Default engine is overriden by the extension");
+
+  extensionInfo.manifest = {
+    "version": "2.0",
+    "applications": {
+      "gecko": {
+        "id": EXTENSION_ID,
+      },
+    },
+  };
+
+  prefPromise = promisePrefChanged(defaultHomepageURL);
+  await extension.upgrade(extensionInfo);
+  await prefPromise;
+
+  equal(extension.version, "2.0", "The updated addon has the expected version.");
+  equal(getHomePageURL(), defaultHomepageURL,
+    "Home page url reverted to the default after update.");
+  equal(Services.search.currentEngine.name, defaultEngineName,
+    "Default engine reverted to the default after update.");
+
+  await extension.unload();
+
+  await promiseShutdownManager();
+});
--- a/browser/components/extensions/test/xpcshell/xpcshell-common.ini
+++ b/browser/components/extensions/test/xpcshell/xpcshell-common.ini
@@ -1,11 +1,12 @@
 [test_ext_bookmarks.js]
 [test_ext_browsingData.js]
 [test_ext_browsingData_cookies_cache.js]
 [test_ext_browsingData_downloads.js]
 [test_ext_browsingData_passwords.js]
 [test_ext_browsingData_settings.js]
+[test_ext_chrome_settings_overrides_update.js]
 [test_ext_geckoProfiler_control.js]
 [test_ext_history.js]
 [test_ext_url_overrides_newtab.js]
 [test_ext_url_overrides_newtab_update.js]
 
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -968,17 +968,17 @@ const shutdownPromises = new Map();
 
 class BootstrapScope {
   install(data, reason) {}
   uninstall(data, reason) {
     Management.emit("uninstall", {id: data.id});
   }
 
   update(data, reason) {
-    Management.emit("update", {id: data.id});
+    Management.emit("update", {id: data.id, resourceURI: data.resourceURI});
   }
 
   startup(data, reason) {
     this.extension = new Extension(data, this.BOOTSTRAP_REASON_TO_STRING_MAP[reason]);
     return this.extension.startup();
   }
 
   shutdown(data, reason) {
--- a/toolkit/components/extensions/ExtensionParent.jsm
+++ b/toolkit/components/extensions/ExtensionParent.jsm
@@ -18,16 +18,17 @@ this.EXPORTED_SYMBOLS = ["ExtensionParen
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   AppConstants: "resource://gre/modules/AppConstants.jsm",
   AsyncShutdown: "resource://gre/modules/AsyncShutdown.jsm",
   DeferredTask: "resource://gre/modules/DeferredTask.jsm",
   E10SUtils: "resource:///modules/E10SUtils.jsm",
+  ExtensionData: "resource://gre/modules/Extension.jsm",
   MessageChannel: "resource://gre/modules/MessageChannel.jsm",
   OS: "resource://gre/modules/osfile.jsm",
   NativeApp: "resource://gre/modules/NativeMessaging.jsm",
   PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
   Schemas: "resource://gre/modules/Schemas.jsm",
 });
 
 XPCOMUtils.defineLazyServiceGetters(this, {
@@ -72,28 +73,53 @@ let StartupCache;
 const global = this;
 
 // This object loads the ext-*.js scripts that define the extension API.
 let apiManager = new class extends SchemaAPIManager {
   constructor() {
     super("main");
     this.initialized = null;
 
-    this.on("startup", (event, extension) => { // eslint-disable-line mozilla/balanced-listeners
+    /* eslint-disable mozilla/balanced-listeners */
+    this.on("startup", (e, extension) => {
       let promises = [];
       for (let apiName of this.eventModules.get("startup")) {
         promises.push(this.asyncGetAPI(apiName, extension).then(api => {
           if (api) {
-            api.onStartup(extension.startupReason);
+            api.onStartup();
           }
         }));
       }
 
       return Promise.all(promises);
     });
+
+    this.on("update", async (e, {id, resourceURI}) => {
+      let modules = this.eventModules.get("update");
+      if (modules.size == 0) {
+        return;
+      }
+
+      let extension = new ExtensionData(resourceURI);
+      await extension.loadManifest();
+
+      return Promise.all(Array.from(modules).map(async apiName => {
+        let module = await this.asyncLoadModule(apiName);
+        module.onUpdate(id, extension.manifest);
+      }));
+    });
+
+    this.on("uninstall", (e, {id}) => {
+      let modules = this.eventModules.get("uninstall");
+      return Promise.all(Array.from(modules).map(async apiName => {
+        let module = await this.asyncLoadModule(apiName);
+        module.onUninstall(id);
+      }));
+    });
+    /* eslint-enable mozilla/balanced-listeners */
   }
 
   getModuleJSONURLs() {
     return Array.from(XPCOMUtils.enumerateCategoryEntries(CATEGORY_EXTENSION_MODULES),
                       ([name, url]) => url);
   }
 
   // Loads all the ext-*.js scripts currently registered.
--- a/toolkit/components/extensions/ExtensionSettingsStore.jsm
+++ b/toolkit/components/extensions/ExtensionSettingsStore.jsm
@@ -105,26 +105,49 @@ function ensureType(type) {
   }
 
   // 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.
-function getTopItem(type, key) {
+/**
+ * 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 (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.
   for (let item of keyInfo.precedenceList) {
     if (item.enabled) {
       return {key, value: item.value, id: item.id};
     }
   }
 
   // Nothing found in the precedenceList, return the initialValue.
@@ -200,17 +223,17 @@ function alterSetting(id, type, key, act
       keyInfo.precedenceList.sort(precedenceComparator);
       break;
 
     default:
       throw new Error(`${action} is not a valid action for alterSetting.`);
   }
 
   if (foundIndex === 0) {
-    returnItem = getTopItem(type, key);
+    returnItem = getItem(type, key);
   }
 
   if (action === "remove" && keyInfo.precedenceList.length === 0) {
     delete _store.data[type][key];
   }
 
   _store.saveSoon();
 
@@ -238,30 +261,30 @@ this.ExtensionSettingsStore = {
    *        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
-   *        An function to be called to determine the initial value for the
+   *        A function to be called to determine the initial value for the
    *        setting. This will be passed the value in the callbackArgument
-   *        argument.
+   *        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.
    *
    * @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(id, type, key, value, initialValueCallback, callbackArgument = key) {
+  async addSetting(id, type, key, value, initialValueCallback = () => undefined, callbackArgument = key) {
     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.
@@ -397,26 +420,29 @@ this.ExtensionSettingsStore = {
       if (keysObj[key].precedenceList.find(item => item.id == id)) {
         items.push(key);
       }
     }
     return items;
   },
 
   /**
-   * Retrieves a setting from the store, returning the current top precedent
-   * setting for the key.
+   * 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 and value.
+   * @returns {object} An object with properties for key, value and id.
    */
-  getSetting(type, key) {
-    return getTopItem(type, key);
+  getSetting(type, key, id) {
+    return getItem(type, key, id);
   },
 
   /**
    * 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.
--- a/toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js
@@ -109,16 +109,21 @@ add_task(async function test_settings_st
       "getSetting returns correct item with only one item in the list.");
     levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[extensionIndex].id, TEST_TYPE, key);
     equal(
       levelOfControl,
       "controlled_by_this_extension",
       "getLevelOfControl returns correct levelOfControl with only one item in the list.");
     ok(ExtensionSettingsStore.hasSetting(extensions[extensionIndex].id, TEST_TYPE, key),
        "hasSetting returns the correct value when an extension has a setting set.");
+    item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key, extensions[extensionIndex].id);
+    deepEqual(
+      item,
+      itemToAdd,
+      "getSetting with id returns correct item 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].id, TEST_TYPE, itemToAdd.key, itemToAdd.value, initialValue);
@@ -131,16 +136,21 @@ add_task(async function test_settings_st
       item,
       ITEMS[key][1],
       "getSetting returns correct item with more than one item in the list.");
     let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[extensionIndex].id, TEST_TYPE, key);
     equal(
       levelOfControl,
       "controlled_by_other_extensions",
       "getLevelOfControl returns correct levelOfControl when another extension is in control.");
+    item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key, extensions[extensionIndex].id);
+    deepEqual(
+      item,
+      itemToAdd,
+      "getSetting with id returns correct item with more than one item in the list.");
   }
 
   // Reload the settings store to emulate a browser restart.
   await ExtensionSettingsStore._reloadFile();
 
   // Add a setting for the newest extension.
   for (let key of KEY_LIST) {
     let extensionIndex = 2;
@@ -154,23 +164,28 @@ add_task(async function test_settings_st
       extensions[extensionIndex].id, 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],
+      itemToAdd,
       "getSetting returns correct item with more than one item in the list.");
     levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[extensionIndex].id, TEST_TYPE, key);
     equal(
       levelOfControl,
       "controlled_by_this_extension",
       "getLevelOfControl returns correct levelOfControl when this extension is in control.");
+    item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key, extensions[extensionIndex].id);
+    deepEqual(
+      item,
+      itemToAdd,
+      "getSetting with id returns correct item with more than one item in the list.");
   }
 
   for (let extension of extensions) {
     let items = await ExtensionSettingsStore.getAllForExtension(extension.id, TEST_TYPE);
     deepEqual(items, KEY_LIST, "getAllForExtension returns expected keys.");
   }
 
   // Attempting to remove a setting that has not been set should *not* throw an exception.