Bug 1386018 - Tell users that the default search engine was set by an extension r=bsilverberg,jaws
☠☠ backed out by 9c1bf5ec6b26 ☠ ☠
authorMark Striemer <mstriemer@mozilla.com>
Wed, 18 Oct 2017 14:54:54 -0500
changeset 685825 ba4a39241953d5079dba48dddac6093f161745aa
parent 685824 6780969991ed697b03b7a8b2cc80d46fc41d9828
child 685826 1b371d5bea7b34fc6eb076d08d87c5659999de0c
push id86016
push userkgupta@mozilla.com
push dateWed, 25 Oct 2017 01:53:44 +0000
reviewersbsilverberg, jaws
bugs1386018
milestone58.0a1
Bug 1386018 - Tell users that the default search engine was set by an extension r=bsilverberg,jaws MozReview-Commit-ID: A7uJ2lN0cLF
browser/components/extensions/test/xpcshell/test_ext_url_overrides_newtab_update.js
browser/components/preferences/in-content/main.js
browser/components/preferences/in-content/preferences.js
browser/components/preferences/in-content/search.js
browser/components/preferences/in-content/search.xul
browser/components/preferences/in-content/tests/browser_extension_controlled.js
browser/locales/en-US/chrome/browser/preferences/preferences.properties
toolkit/components/extensions/ExtensionPreferencesManager.jsm
toolkit/components/extensions/ExtensionSettingsStore.jsm
toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js
toolkit/mozapps/extensions/internal/AddonTestUtils.jsm
--- a/browser/components/extensions/test/xpcshell/test_ext_url_overrides_newtab_update.js
+++ b/browser/components/extensions/test/xpcshell/test_ext_url_overrides_newtab_update.js
@@ -15,79 +15,57 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyServiceGetter(this, "aboutNewTabService",
                                    "@mozilla.org/browser/aboutnewtab-service;1",
                                    "nsIAboutNewTabService");
 
 Cu.import("resource://testing-common/AddonTestUtils.jsm");
 
 const {
   createAppInfo,
-  createTempWebExtensionFile,
   promiseCompleteAllInstalls,
   promiseFindAddonUpdates,
   promiseShutdownManager,
   promiseStartupManager,
+  UpdateServer,
 } = AddonTestUtils;
 
 AddonTestUtils.init(this);
 
 // Allow for unsigned addons.
 AddonTestUtils.overrideCertDB();
 
 createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
 
 add_task(async function test_url_overrides_newtab_update() {
   const EXTENSION_ID = "test_url_overrides_update@tests.mozilla.org";
   const NEWTAB_URI = "webext-newtab-1.html";
-  const PREF_EM_CHECK_UPDATE_SECURITY = "extensions.checkUpdateSecurity";
 
-  const testServer = createHttpServer();
-  const port = testServer.identity.primaryPort;
-
-  // The test extension uses an insecure update url.
-  Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
-
-  testServer.registerPathHandler("/test_update.json", (request, response) => {
-    response.write(`{
-      "addons": {
-        "${EXTENSION_ID}": {
-          "updates": [
-            {
-              "version": "2.0",
-              "update_link": "http://localhost:${port}/addons/test_url_overrides-2.0.xpi"
-            }
-          ]
-        }
-      }
-    }`);
-  });
-
-  let webExtensionFile = createTempWebExtensionFile({
+  let testServer = new UpdateServer();
+  do_register_cleanup(() => testServer.cleanup());
+  testServer.serveUpdate({
     manifest: {
       version: "2.0",
       applications: {
         gecko: {
           id: EXTENSION_ID,
         },
       },
     },
   });
 
-  testServer.registerFile("/addons/test_url_overrides-2.0.xpi", webExtensionFile);
-
   await promiseStartupManager();
 
   let extension = ExtensionTestUtils.loadExtension({
     useAddonManager: "permanent",
     manifest: {
       "version": "1.0",
       "applications": {
         "gecko": {
           "id": EXTENSION_ID,
-          "update_url": `http://localhost:${port}/test_update.json`,
+          "update_url": testServer.updateUrl,
         },
       },
       chrome_url_overrides: {newtab: NEWTAB_URI},
     },
   });
 
   let defaultNewTabURL = aboutNewTabService.newTabURL;
   equal(aboutNewTabService.newTabURL, defaultNewTabURL,
--- a/browser/components/preferences/in-content/main.js
+++ b/browser/components/preferences/in-content/main.js
@@ -1,21 +1,16 @@
 /* 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/. */
 
 /* import-globals-from preferences.js */
 /* import-globals-from ../../../../toolkit/mozapps/preferences/fontbuilder.js */
 /* import-globals-from ../../../base/content/aboutDialog-appUpdater.js */
 
-XPCOMUtils.defineLazyModuleGetter(this, "ExtensionSettingsStore",
-                                  "resource://gre/modules/ExtensionSettingsStore.jsm");
-XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
-                                  "resource://gre/modules/AddonManager.jsm");
-
 Components.utils.import("resource://gre/modules/Services.jsm");
 Components.utils.import("resource://gre/modules/Downloads.jsm");
 Components.utils.import("resource://gre/modules/FileUtils.jsm");
 Components.utils.import("resource:///modules/ShellService.jsm");
 Components.utils.import("resource:///modules/TransientPrefs.jsm");
 Components.utils.import("resource://gre/modules/AppConstants.jsm");
 Components.utils.import("resource://gre/modules/DownloadUtils.jsm");
 Components.utils.import("resource://gre/modules/LoadContextInfo.jsm");
@@ -36,16 +31,23 @@ const PREF_DISABLED_PLUGIN_TYPES = "plug
 // Pref for when containers is being controlled
 const PREF_CONTAINERS_EXTENSION = "privacy.userContext.extension";
 
 // Preferences that affect which entries to show in the list.
 const PREF_SHOW_PLUGINS_IN_LIST = "browser.download.show_plugins_in_list";
 const PREF_HIDE_PLUGINS_WITHOUT_EXTENSIONS =
   "browser.download.hide_plugins_without_extensions";
 
+// Strings to identify ExtensionSettingsStore overrides
+const PREF_SETTING_TYPE = "prefs";
+const CONTAINERS_KEY = "privacy.containers";
+const HOMEPAGE_OVERRIDE_KEY = "homepage_override";
+const URL_OVERRIDES_TYPE = "url_overrides";
+const NEW_TAB_KEY = "newTabURL";
+
 /*
  * Preferences where we store handling information about the feed type.
  *
  * browser.feeds.handler
  * - "bookmarks", "reader" (clarified further using the .default preference),
  *   or "ask" -- indicates the default handler being used to process feeds;
  *   "bookmarks" is obsolete; to specify that the handler is bookmarks,
  *   set browser.feeds.handler.default to "bookmarks";
@@ -213,20 +215,20 @@ var gMainPane = {
     this.updatePerformanceSettingsBox({ duringChangeEvent: false });
 
     // set up the "use current page" label-changing listener
     this._updateUseCurrentButton();
     window.addEventListener("focus", this._updateUseCurrentButton.bind(this));
 
     this.updateBrowserStartupLastSession();
 
-    handleControllingExtension("url_overrides", "newTabURL");
+    handleControllingExtension(URL_OVERRIDES_TYPE, NEW_TAB_KEY);
     let newTabObserver = {
       observe(subject, topic, data) {
-          handleControllingExtension("url_overrides", "newTabURL");
+          handleControllingExtension(URL_OVERRIDES_TYPE, NEW_TAB_KEY);
       },
     };
     Services.obs.addObserver(newTabObserver, "newtab-url-changed");
     window.addEventListener("unload", () => {
       Services.obs.removeObserver(newTabObserver, "newtab-url-changed");
     });
 
     if (AppConstants.platform == "win") {
@@ -255,21 +257,21 @@ var gMainPane = {
     }
     setEventListener("useCurrent", "command",
       gMainPane.setHomePageToCurrent);
     setEventListener("useBookmark", "command",
       gMainPane.setHomePageToBookmark);
     setEventListener("restoreDefaultHomePage", "command",
       gMainPane.restoreDefaultHomePage);
     setEventListener("disableHomePageExtension", "command",
-                     gMainPane.makeDisableControllingExtension("prefs", "homepage_override"));
+                     makeDisableControllingExtension(PREF_SETTING_TYPE, HOMEPAGE_OVERRIDE_KEY));
     setEventListener("disableContainersExtension", "command",
-                     gMainPane.makeDisableControllingExtension("prefs", "privacy.containers"));
+                     makeDisableControllingExtension(PREF_SETTING_TYPE, CONTAINERS_KEY));
     setEventListener("disableNewTabExtension", "command",
-                     gMainPane.makeDisableControllingExtension("url_overrides", "newTabURL"));
+                     makeDisableControllingExtension(URL_OVERRIDES_TYPE, NEW_TAB_KEY));
     setEventListener("chooseLanguage", "command",
       gMainPane.showLanguages);
     setEventListener("translationAttributionImage", "click",
       gMainPane.openTranslationProviderAttribution);
     setEventListener("translateButton", "command",
       gMainPane.showTranslationExceptions);
     setEventListener("font.language.group", "change",
       gMainPane._rebuildFonts);
@@ -479,17 +481,17 @@ var gMainPane = {
   readBrowserContainersCheckbox() {
     const pref = document.getElementById("privacy.userContext.enabled");
     const settings = document.getElementById("browserContainersSettings");
 
     settings.disabled = !pref.value;
     const containersEnabled = Services.prefs.getBoolPref("privacy.userContext.enabled");
     const containersCheckbox = document.getElementById("browserContainersCheckbox");
     containersCheckbox.checked = containersEnabled;
-    handleControllingExtension("prefs", "privacy.containers")
+    handleControllingExtension(PREF_SETTING_TYPE, CONTAINERS_KEY)
       .then((isControlled) => {
         containersCheckbox.disabled = isControlled;
       });
   },
 
   /**
    * Show the Containers UI depending on the privacy.userContext.ui.enabled pref.
    */
@@ -611,21 +613,21 @@ var gMainPane = {
         .forEach((element) => {
           let isLocked = document.getElementById(element.getAttribute("preference")).locked;
           element.disabled = isLocked || isControlled;
         });
     }
 
     if (homePref.locked) {
       // An extension can't control these settings if they're locked.
-      hideControllingExtension("homepage_override");
+      hideControllingExtension(HOMEPAGE_OVERRIDE_KEY);
       setInputDisabledStates(false);
     } else {
       // Asynchronously update the extension controlled UI.
-      handleControllingExtension("prefs", "homepage_override")
+      handleControllingExtension(PREF_SETTING_TYPE, HOMEPAGE_OVERRIDE_KEY)
         .then(setInputDisabledStates);
     }
 
     // If the pref is set to about:home or about:newtab, set the value to ""
     // to show the placeholder text (about:home title) rather than
     // exposing those URLs to users.
     let defaultBranch = Services.prefs.getDefaultBranch("");
     let defaultValue = defaultBranch.getComplexValue("browser.startup.homepage",
@@ -724,17 +726,17 @@ var gMainPane = {
     let tabs = this._getTabsForHomePage();
 
     if (tabs.length > 1)
       useCurrent.label = useCurrent.getAttribute("label2");
     else
       useCurrent.label = useCurrent.getAttribute("label1");
 
     // If the homepage is controlled by an extension then you can't use this.
-    if (await getControllingExtensionId("prefs", "homepage_override")) {
+    if (await getControllingExtensionInfo(PREF_SETTING_TYPE, HOMEPAGE_OVERRIDE_KEY)) {
       useCurrent.disabled = true;
       return;
     }
 
     // In this case, the button's disabled state is set by preferences.xml.
     let prefName = "pref.browser.homepage.disable_button.current_page";
     if (document.getElementById(prefName).locked)
       return;
@@ -770,24 +772,16 @@ var gMainPane = {
   /**
    * Restores the default home page as the user's home page.
    */
   restoreDefaultHomePage() {
     var homePage = document.getElementById("browser.startup.homepage");
     homePage.value = homePage.defaultValue;
   },
 
-  makeDisableControllingExtension(type, settingName) {
-    return async function disableExtension() {
-      let id = await getControllingExtensionId(type, settingName);
-      let addon = await AddonManager.getAddonByID(id);
-      addon.userDisabled = true;
-    };
-  },
-
   /**
    * Utility function to enable/disable the button specified by aButtonID based
    * on the value of the Boolean preference specified by aPreferenceID.
    */
   updateButtons(aButtonID, aPreferenceID) {
     var button = document.getElementById(aButtonID);
     var preference = document.getElementById(aPreferenceID);
     button.disabled = preference.value != true;
@@ -2592,79 +2586,16 @@ function getLocalHandlerApp(aFile) {
   var localHandlerApp = Cc["@mozilla.org/uriloader/local-handler-app;1"].
     createInstance(Ci.nsILocalHandlerApp);
   localHandlerApp.name = getFileDisplayName(aFile);
   localHandlerApp.executable = aFile;
 
   return localHandlerApp;
 }
 
-let extensionControlledContentIds = {
-  "privacy.containers": "browserContainersExtensionContent",
-  "homepage_override": "browserHomePageExtensionContent",
-  "newTabURL": "browserNewTabExtensionContent",
-};
-
-/**
-  * Check if a pref is being managed by an extension.
-  */
-async function getControllingExtensionId(type, settingName) {
-  await ExtensionSettingsStore.initialize();
-  return ExtensionSettingsStore.getTopExtensionId(type, settingName);
-}
-
-function getControllingExtensionEl(settingName) {
-  return document.getElementById(extensionControlledContentIds[settingName]);
-}
-
-async function handleControllingExtension(type, settingName) {
-  let controllingExtensionId = await getControllingExtensionId(type, settingName);
-
-  if (controllingExtensionId) {
-    showControllingExtension(settingName, controllingExtensionId);
-  } else {
-    hideControllingExtension(settingName);
-  }
-
-  return !!controllingExtensionId;
-}
-
-async function showControllingExtension(settingName, extensionId) {
-  let extensionControlledContent = getControllingExtensionEl(settingName);
-  // Tell the user what extension is controlling the setting.
-  let addon = await AddonManager.getAddonByID(extensionId);
-  const defaultIcon = "chrome://mozapps/skin/extensions/extensionGeneric.svg";
-  let stringParts = document
-    .getElementById("bundlePreferences")
-    .getString(`extensionControlled.${settingName}`)
-    .split("%S");
-  let description = extensionControlledContent.querySelector("description");
-
-  // Remove the old content from the description.
-  while (description.firstChild) {
-    description.firstChild.remove();
-  }
-
-  // Populate the description.
-  description.appendChild(document.createTextNode(stringParts[0]));
-  let image = document.createElement("image");
-  image.setAttribute("src", addon.iconURL || defaultIcon);
-  image.classList.add("extension-controlled-icon");
-  description.appendChild(image);
-  description.appendChild(document.createTextNode(` ${addon.name}`));
-  description.appendChild(document.createTextNode(stringParts[1]));
-
-  // Show the controlling extension row and hide the old label.
-  extensionControlledContent.hidden = false;
-}
-
-function hideControllingExtension(settingName) {
-  getControllingExtensionEl(settingName).hidden = true;
-}
-
 /**
  * An enumeration of items in a JS array.
  *
  * FIXME: use ArrayConverter once it lands (bug 380839).
  *
  * @constructor
  */
 function ArrayEnumerator(aItems) {
--- a/browser/components/preferences/in-content/preferences.js
+++ b/browser/components/preferences/in-content/preferences.js
@@ -18,16 +18,21 @@ var Cc = Components.classes;
 var Ci = Components.interfaces;
 var Cu = Components.utils;
 var Cr = Components.results;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/AppConstants.jsm");
 
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionSettingsStore",
+                                  "resource://gre/modules/ExtensionSettingsStore.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
+                                  "resource://gre/modules/AddonManager.jsm");
+
 var gLastHash = "";
 
 var gCategoryInits = new Map();
 function init_category_if_required(category) {
   let categoryInfo = gCategoryInits.get(category);
   if (!categoryInfo) {
     throw "Unknown in-content prefs category! Can't init " + category;
   }
@@ -317,8 +322,88 @@ function confirmRestartPrompt(aRestartTo
 function appendSearchKeywords(aId, keywords) {
   let element = document.getElementById(aId);
   let searchKeywords = element.getAttribute("searchkeywords");
   if (searchKeywords) {
     keywords.push(searchKeywords);
   }
   element.setAttribute("searchkeywords", keywords.join(" "));
 }
+
+let extensionControlledContentIds = {
+  "privacy.containers": "browserContainersExtensionContent",
+  "homepage_override": "browserHomePageExtensionContent",
+  "newTabURL": "browserNewTabExtensionContent",
+  "defaultSearch": "browserDefaultSearchExtensionContent",
+};
+
+/**
+  * Check if a pref is being managed by an extension.
+  */
+async function getControllingExtensionInfo(type, settingName) {
+  await ExtensionSettingsStore.initialize();
+  return ExtensionSettingsStore.getSetting(type, settingName);
+}
+
+function getControllingExtensionEl(settingName) {
+  return document.getElementById(extensionControlledContentIds[settingName]);
+}
+
+async function handleControllingExtension(type, settingName) {
+  let info = await getControllingExtensionInfo(type, settingName);
+  let isControlled = info && !!info.id;
+
+  if (isControlled) {
+    showControllingExtension(settingName, info.id);
+  } else {
+    hideControllingExtension(settingName);
+  }
+
+  return isControlled;
+}
+
+async function showControllingExtension(settingName, extensionId) {
+  let extensionControlledContent = getControllingExtensionEl(settingName);
+  extensionControlledContent.classList.remove("extension-controlled-disabled");
+  // Tell the user what extension is controlling the setting.
+  let addon = await AddonManager.getAddonByID(extensionId);
+  const defaultIcon = "chrome://mozapps/skin/extensions/extensionGeneric.svg";
+  let stringParts = document
+    .getElementById("bundlePreferences")
+    .getString(`extensionControlled.${settingName}`)
+    .split("%S");
+  let description = extensionControlledContent.querySelector("description");
+
+  // Remove the old content from the description.
+  while (description.firstChild) {
+    description.firstChild.remove();
+  }
+
+  // Populate the description.
+  description.appendChild(document.createTextNode(stringParts[0]));
+  let image = document.createElement("image");
+  image.setAttribute("src", addon.iconURL || defaultIcon);
+  image.classList.add("extension-controlled-icon");
+  description.appendChild(image);
+  description.appendChild(document.createTextNode(` ${addon.name}`));
+  description.appendChild(document.createTextNode(stringParts[1]));
+
+  let disableButton = extensionControlledContent.querySelector("button");
+  if (disableButton) {
+    disableButton.hidden = false;
+  }
+
+  // Show the controlling extension row and hide the old label.
+  extensionControlledContent.hidden = false;
+}
+
+function hideControllingExtension(settingName) {
+  getControllingExtensionEl(settingName).hidden = true;
+}
+
+
+function makeDisableControllingExtension(type, settingName) {
+  return async function disableExtension() {
+    let {id} = await getControllingExtensionInfo(type, settingName);
+    let addon = await AddonManager.getAddonByID(id);
+    addon.userDisabled = true;
+  };
+}
--- a/browser/components/preferences/in-content/search.js
+++ b/browser/components/preferences/in-content/search.js
@@ -2,18 +2,22 @@
  * 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/. */
 
 /* import-globals-from preferences.js */
 
 Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
                                   "resource://gre/modules/PlacesUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionSettingsStore",
+                                  "resource://gre/modules/ExtensionSettingsStore.jsm");
 
 const ENGINE_FLAVOR = "text/x-moz-search-engine";
+const SEARCH_TYPE = "default_search";
+const SEARCH_KEY = "defaultSearch";
 
 var gEngineView = null;
 
 var gSearchPane = {
 
   /**
    * Initialize autocomplete to ensure prefs are in sync.
    */
@@ -92,16 +96,27 @@ var gSearchPane = {
       item.setAttribute("class", "menuitem-iconic searchengine-menuitem menuitem-with-favicon");
       if (e.iconURI) {
         item.setAttribute("image", e.iconURI.spec);
       }
       item.engine = e;
       if (e.name == currentEngine)
         list.selectedItem = item;
     });
+
+    handleControllingExtension(SEARCH_TYPE, SEARCH_KEY);
+    let searchEngineListener = {
+      observe(subject, topic, data) {
+        handleControllingExtension(SEARCH_TYPE, SEARCH_KEY);
+      },
+    };
+    Services.obs.addObserver(searchEngineListener, "browser-search-engine-modified");
+    window.addEventListener("unload", () => {
+      Services.obs.removeObserver(searchEngineListener, "browser-search-engine-modified");
+    });
   },
 
   handleEvent(aEvent) {
     switch (aEvent.type) {
       case "click":
         if (aEvent.target.id != "engineChildren" &&
             !aEvent.target.classList.contains("searchEngineAction")) {
           let engineList = document.getElementById("engineList");
@@ -302,16 +317,17 @@ var gSearchPane = {
     }
     document.getElementById("browser.search.hiddenOneOffs").value =
       hiddenList.join(",");
   },
 
   setDefaultEngine() {
     Services.search.currentEngine =
       document.getElementById("defaultEngine").selectedItem.engine;
+    ExtensionSettingsStore.setByUser(SEARCH_TYPE, SEARCH_KEY);
   }
 };
 
 function onDragEngineStart(event) {
   var selectedIndex = gEngineView.selectedIndex;
   var tree = document.getElementById("engineList");
   var row = { }, col = { }, child = { };
   tree.treeBoxObject.getCellAt(event.clientX, event.clientY, row, col, child);
--- a/browser/components/preferences/in-content/search.xul
+++ b/browser/components/preferences/in-content/search.xul
@@ -39,24 +39,30 @@
         <image class="searchBarImage searchBarShownImage" role="presentation"/>
       </radiogroup>
     </groupbox>
 
     <!-- Default Search Engine -->
     <groupbox id="defaultEngineGroup" data-category="paneSearch">
       <caption><label>&defaultSearchEngine.label;</label></caption>
       <description>&chooseYourDefaultSearchEngine2.label;</description>
+
+      <hbox id="browserDefaultSearchExtensionContent" align="center" hidden="true">
+        <description control="disableDefaultSearchExtension" flex="1"/>
+      </hbox>
+
       <hbox>
         <!-- Please don't remove the wrapping hbox/vbox/box for these elements. It's used to properly compute the search tooltip position. -->
         <hbox>
           <menulist id="defaultEngine">
             <menupopup/>
           </menulist>
         </hbox>
       </hbox>
+
       <checkbox id="suggestionsInSearchFieldsCheckbox"
                 label="&provideSearchSuggestions.label;"
                 accesskey="&provideSearchSuggestions.accesskey;"
                 preference="browser.search.suggest.enabled"/>
       <vbox class="indent">
         <checkbox id="urlBarSuggestion" label="&showURLBarSuggestions2.label;"
                   accesskey="&showURLBarSuggestions2.accesskey;"
                   preference="browser.urlbar.suggest.searches"/>
--- a/browser/components/preferences/in-content/tests/browser_extension_controlled.js
+++ b/browser/components/preferences/in-content/tests/browser_extension_controlled.js
@@ -1,12 +1,29 @@
+XPCOMUtils.defineLazyModuleGetter(
+  this, "AddonTestUtils", "resource://testing-common/AddonTestUtils.jsm");
 XPCOMUtils.defineLazyServiceGetter(this, "aboutNewTabService",
                                    "@mozilla.org/browser/aboutnewtab-service;1",
                                    "nsIAboutNewTabService");
 
+const {Management} = Cu.import("resource://gre/modules/Extension.jsm", {});
+const {
+  createAppInfo,
+  promiseCompleteInstall,
+  promiseFindAddonUpdates,
+  UpdateServer,
+} = AddonTestUtils;
+
+AddonTestUtils.initMochitest(this);
+
+// Allow for unsigned addons.
+AddonTestUtils.overrideCertDB();
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
 const TEST_DIR = gTestPath.substr(0, gTestPath.lastIndexOf("/"));
 const CHROME_URL_ROOT = TEST_DIR + "/";
 
 function getSupportsFile(path) {
   let cr = Cc["@mozilla.org/chrome/chrome-registry;1"]
     .getService(Ci.nsIChromeRegistry);
   let uri = Services.io.newURI(CHROME_URL_ROOT + path);
   let fileurl = cr.convertChromeURL(uri);
@@ -264,13 +281,116 @@ add_task(async function testExtensionCon
   is(controlledContent.hidden, false, "The extension controlled row is hidden");
 
   // Disable the extension.
   doc.getElementById("disableNewTabExtension").click();
 
   await waitForMessageHidden("browserNewTabExtensionContent");
 
   ok(!aboutNewTabService.newTabURL.startsWith("moz-extension:"), "new tab page is set back to default");
-  is(controlledContent.hidden, true, "The extension controlled row is hidden");
+  is(controlledContent.hidden, true, "The extension controlled row is shown");
 
   await BrowserTestUtils.removeTab(gBrowser.selectedTab);
 });
 
+add_task(async function testExtensionControlledDefaultSearch() {
+  await openPreferencesViaOpenPreferencesAPI("paneSearch", {leaveOpen: true});
+  let doc = gBrowser.contentDocument;
+  let extensionId = "@set_default_search";
+  let testServer = new UpdateServer();
+  let manifest = {
+    manifest_version: 2,
+    name: "set_default_search",
+    applications: { gecko: { id: extensionId, update_url: testServer.updateUrl } },
+    description: "set_default_search description",
+    permissions: [],
+    chrome_settings_overrides: {
+      search_provider: {
+        name: "Yahoo",
+        search_url: "https://search.yahoo.com/yhs/search?p=%s&ei=UTF-8&hspart=mozilla&hsimp=yhs-002",
+        is_default: true,
+      },
+    }
+  };
+  testServer.serveUpdate({
+    manifest: Object.assign({}, manifest, {version: "2.0"}),
+  });
+
+  function setEngine(engine) {
+    doc.querySelector(`#defaultEngine menuitem[label="${engine.name}"]`)
+       .doCommand();
+  }
+
+  is(gBrowser.currentURI.spec, "about:preferences#search",
+     "#search should be in the URI for about:preferences");
+
+  let controlledContent = doc.getElementById("browserDefaultSearchExtensionContent");
+  let initialEngine = Services.search.currentEngine;
+
+  // Ensure the controlled content is hidden when not controlled.
+  is(controlledContent.hidden, true, "The extension controlled row is hidden");
+
+  // Install an extension that will set the default search engine.
+  let extension = ExtensionTestUtils.loadExtension({
+    useAddonManager: "permanent",
+    manifest: Object.assign({}, manifest, {version: "1.0"}),
+  });
+
+  let messageShown = waitForMessageShown("browserDefaultSearchExtensionContent");
+  await extension.startup();
+  await messageShown;
+
+  let addon = await AddonManager.getAddonByID(extensionId);
+  is(addon.version, "1.0", "The addon has the expected version.");
+
+  // The default search engine has been set by the extension and the user is notified.
+  let controlledLabel = controlledContent.querySelector("description");
+  let extensionEngine = Services.search.currentEngine;
+  ok(initialEngine != extensionEngine, "The default engine has changed.");
+  // There are two spaces before "set_default_search" because it's " <image /> set_default_search".
+  is(controlledLabel.textContent,
+     "An extension,  set_default_search, has set your default search engine.",
+     "The user is notified that an extension is controlling the default search engine");
+  is(controlledContent.hidden, false, "The extension controlled row is shown");
+
+  // Set the engine back to the initial one, ensure the message is hidden.
+  setEngine(initialEngine);
+  await waitForMessageHidden(controlledContent.id);
+
+  is(initialEngine, Services.search.currentEngine,
+     "default search engine is set back to default");
+  is(controlledContent.hidden, true, "The extension controlled row is hidden");
+
+  // Setting the engine back to the extension's engine does not show the message.
+  setEngine(extensionEngine);
+
+  is(extensionEngine, Services.search.currentEngine,
+     "default search engine is set back to extension");
+  is(controlledContent.hidden, true, "The extension controlled row is still hidden");
+
+  // Set the engine to the initial one and verify an upgrade doesn't change it.
+  setEngine(initialEngine);
+  await waitForMessageHidden(controlledContent.id);
+
+  // Update the extension and wait for "ready".
+  let update = await promiseFindAddonUpdates(addon);
+  let install = update.updateAvailable;
+  let awaitStartup = new Promise(resolve => {
+    Management.on("ready", function listener(type, readyAddon) {
+      if (readyAddon.id == addon.id) {
+        resolve(readyAddon);
+        Management.off("ready", listener);
+      }
+    });
+  });
+  await promiseCompleteInstall(install);
+  addon = await awaitStartup;
+
+  // Verify the extension is updated and search engine didn't change.
+  is(addon.version, "2.0", "The updated addon has the expected version");
+  is(controlledContent.hidden, true, "The extension controlled row is hidden after update");
+  is(initialEngine, Services.search.currentEngine,
+     "default search engine is still the initial engine after update");
+
+  await testServer.cleanup();
+  await extension.unload();
+  await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
--- a/browser/locales/en-US/chrome/browser/preferences/preferences.properties
+++ b/browser/locales/en-US/chrome/browser/preferences/preferences.properties
@@ -286,12 +286,17 @@ defaultContentProcessCount=%S (default)
 # LOCALIZATION NOTE (extensionControlled.homepage_override):
 # This string is shown to notify the user that their home page is being controlled by an extension.
 extensionControlled.homepage_override = An extension, %S, controls your home page.
 
 # LOCALIZATION NOTE (extensionControlled.newTabURL):
 # This string is shown to notify the user that their new tab page is being controlled by an extension.
 extensionControlled.newTabURL = An extension, %S, controls your New Tab page.
 
+# LOCALIZATION NOTE (extensionControlled.defaultSearch):
+# This string is shown to notify the user that the default search engine is being controlled
+# by an extension. %S is the icon and name of the extension.
+extensionControlled.defaultSearch = An extension, %S, has set your default search engine.
+
 # LOCALIZATION NOTE (extensionControlled.privacy.containers):
 # This string is shown to notify the user that Container Tabs are being enabled by an extension
 # %S is the container addon controlling it
 extensionControlled.privacy.containers = An extension, %S, requires Container Tabs.
--- a/toolkit/components/extensions/ExtensionPreferencesManager.jsm
+++ b/toolkit/components/extensions/ExtensionPreferencesManager.jsm
@@ -177,29 +177,16 @@ this.ExtensionPreferencesManager = {
    *
    * @returns {string|number|boolean} The default value of the preference.
    */
   getDefaultValue(prefName) {
     return defaultPreferences.get(prefName);
   },
 
   /**
-   * Gets the id of the extension controlling a preference or null if it isn't
-   * being controlled.
-   *
-   * @param {string} prefName The name of the preference.
-   *
-   * @returns {Promise} Resolves to the id of the extension, or null.
-   */
-  async getControllingExtensionId(prefName) {
-    await ExtensionSettingsStore.initialize();
-    return ExtensionSettingsStore.getTopExtensionId(STORE_TYPE, 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
--- a/toolkit/components/extensions/ExtensionSettingsStore.jsm
+++ b/toolkit/components/extensions/ExtensionSettingsStore.jsm
@@ -352,16 +352,37 @@ this.ExtensionSettingsStore = {
    *          corresponds to the current top precedent setting, or null if
    *          the current top precedent setting has not changed.
    */
   disable(extension, type, key) {
     return alterSetting(extension, type, key, "disable");
   },
 
   /**
+   * Mark a setting as being controlled by a user's choice. This will disable all of
+   * the extension defined values for the extension.
+   *
+   * @param {string} type The type of the setting.
+   * @param {string} key The key of the setting.
+   */
+  setByUser(type, key) {
+    let {precedenceList} = (_store.data[type] && _store.data[type][key]) || {};
+    if (!precedenceList) {
+      // The setting for this key does not exist. Nothing to do.
+      return;
+    }
+
+    for (let item of precedenceList) {
+      item.enabled = false;
+    }
+
+    _store.saveSoon();
+  },
+
+  /**
    * 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.
    */
   getAllForExtension(extension, type) {
@@ -447,26 +468,16 @@ this.ExtensionSettingsStore = {
     }
 
     let addon = await AddonManager.getAddonByID(id);
     return topItem.installDate > addon.installDate.valueOf() ?
       "controlled_by_other_extensions" :
       "controllable_by_this_extension";
   },
 
-  // Return the id of the controlling extension or null if no extension is
-  // controlling this setting.
-  getTopExtensionId(type, key) {
-    let item = getTopItem(type, key);
-    if (item) {
-      return item.id;
-    }
-    return null;
-  },
-
   /**
    * 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.
    *
    * @returns {Promise}
    *          A promise that resolves once the settings store has been cleared.
--- a/toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js
@@ -428,16 +428,111 @@ add_task(async function test_settings_st
 
   for (let extension of testExtensions) {
     await extension.unload();
   }
 
   await promiseShutdownManager();
 });
 
+add_task(async function test_settings_store_setByUser() {
+  await promiseStartupManager();
+
+  // Create an array of test framework extension wrappers to install.
+  let testExtensions = [
+    ExtensionTestUtils.loadExtension({
+      useAddonManager: "temporary",
+      manifest: {
+        applications: {gecko: {id: "@first"}},
+      },
+    }),
+    ExtensionTestUtils.loadExtension({
+      useAddonManager: "temporary",
+      manifest: {
+        applications: {gecko: {id: "@second"}},
+      },
+    }),
+    ExtensionTestUtils.loadExtension({
+      useAddonManager: "temporary",
+      manifest: {
+        applications: {gecko: {id: "@third"}},
+      },
+    }),
+  ];
+
+  let type = "some_type";
+  let key = "some_key";
+
+  for (let extension of testExtensions) {
+    await extension.startup();
+  }
+
+  // Create an array actual Extension objects which correspond to the
+  // test framework extension wrappers.
+  let [one, two, three] = testExtensions.map(extension => extension.extension);
+  let initialCallback = () => "initial";
+
+  // Initialize the SettingsStore.
+  await ExtensionSettingsStore.initialize();
+
+  equal(null, ExtensionSettingsStore.getSetting(type, key),
+        "getSetting is initially null");
+
+  let item = await ExtensionSettingsStore.addSetting(
+    one, type, key, "one", initialCallback);
+  deepEqual({key, value: "one", id: one.id}, item,
+            "addSetting returns the first set item");
+
+  item = await ExtensionSettingsStore.addSetting(
+    two, type, key, "two", initialCallback);
+  deepEqual({key, value: "two", id: two.id}, item,
+            "addSetting returns the second set item");
+
+  item = await ExtensionSettingsStore.addSetting(
+    three, type, key, "three", initialCallback);
+  deepEqual({key, value: "three", id: three.id}, item,
+            "addSetting returns the third set item");
+
+  deepEqual(item, ExtensionSettingsStore.getSetting(type, key),
+            "getSetting returns the third set item");
+
+  ExtensionSettingsStore.setByUser(type, key);
+  deepEqual({key, initialValue: "initial"}, ExtensionSettingsStore.getSetting(type, key),
+            "getSetting returns the initial value after being set by user");
+
+  item = ExtensionSettingsStore.enable(one, type, key);
+  deepEqual({key, value: "one", id: one.id}, item,
+            "enable returns the first set item after enable");
+
+  item = ExtensionSettingsStore.enable(three, type, key);
+  deepEqual({key, value: "three", id: three.id}, item,
+            "enable returns the third set item after enable");
+
+  item = ExtensionSettingsStore.enable(two, type, key);
+  deepEqual(undefined, item,
+            "enable returns undefined after enabling the second item");
+
+  item = ExtensionSettingsStore.getSetting(type, key);
+  deepEqual({key, value: "three", id: three.id}, item,
+            "getSetting returns the third set item after enabling the second item");
+
+  ExtensionSettingsStore.removeSetting(three, type, key);
+  ExtensionSettingsStore.removeSetting(two, type, key);
+  ExtensionSettingsStore.removeSetting(one, type, key);
+
+  equal(null, ExtensionSettingsStore.getSetting(type, key),
+        "getSetting returns null after removing all settings");
+
+  for (let extension of testExtensions) {
+    await extension.unload();
+  }
+
+  await promiseShutdownManager();
+});
+
 add_task(async function test_exceptions() {
   await ExtensionSettingsStore.initialize();
 
   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.");
--- a/toolkit/mozapps/extensions/internal/AddonTestUtils.jsm
+++ b/toolkit/mozapps/extensions/internal/AddonTestUtils.jsm
@@ -35,16 +35,18 @@ XPCOMUtils.defineLazyGetter(this, "Manag
 
 XPCOMUtils.defineLazyServiceGetter(this, "aomStartup",
                                    "@mozilla.org/addons/addon-manager-startup;1",
                                    "amIAddonManagerStartup");
 XPCOMUtils.defineLazyServiceGetter(this, "rdfService",
                                    "@mozilla.org/rdf/rdf-service;1", "nsIRDFService");
 XPCOMUtils.defineLazyServiceGetter(this, "uuidGen",
                                    "@mozilla.org/uuid-generator;1", "nsIUUIDGenerator");
+XPCOMUtils.defineLazyModuleGetter(this, "HttpServer",
+                                  "resource://testing-common/httpd.js");
 
 
 XPCOMUtils.defineLazyGetter(this, "AppInfo", () => {
   let AppInfo = {};
   Cu.import("resource://testing-common/AppInfo.jsm", AppInfo);
   return AppInfo;
 });
 
@@ -218,17 +220,17 @@ var AddonTestUtils = {
   appInfo: null,
   addonStartup: null,
   testUnpacked: false,
   useRealCertChecks: false,
   usePrivilegedSignatures: true,
   overrideEntry: null,
 
   init(testScope) {
-    this.testScope = testScope;
+    this.equal = testScope.equal;
 
     // Get the profile directory for tests to use.
     this.profileDir = testScope.do_get_profile();
 
     this.profileExtensions = this.profileDir.clone();
     this.profileExtensions.append("extensions");
 
     this.addonStartup = this.profileDir.clone();
@@ -356,16 +358,18 @@ var AddonTestUtils = {
     this.profileDir = FileUtils.getDir("ProfD", []);
 
     this.profileExtensions = FileUtils.getDir("ProfD", ["extensions"]);
 
     this.tempDir = FileUtils.getDir("TmpD", []);
     this.tempDir.append("addons-mochitest");
     this.tempDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
 
+    this.equal = testScope.is;
+
     testScope.registerCleanupFunction(() => {
       this.cleanupTempXPIs();
       try {
         this.tempDir.remove(true);
       } catch (e) {
         Cu.reportError(e);
       }
     });
@@ -1162,17 +1166,17 @@ var AddonTestUtils = {
    * complete. The value resolved will be an AddonInstall if a new version was
    * found.
    *
    * @param {object} addon The add-on to find updates for.
    * @param {integer} reason The type of update to find.
    * @return {Promise<object>} an object containing information about the update.
    */
   promiseFindAddonUpdates(addon, reason = AddonManager.UPDATE_WHEN_PERIODIC_UPDATE) {
-    let equal = this.testScope.equal;
+    let {equal} = this;
     return new Promise((resolve, reject) => {
       let result = {};
       addon.findUpdates({
         onNoCompatibilityUpdateAvailable(addon2) {
           if ("compatibilityUpdate" in result) {
             throw new Error("Saw multiple compatibility update events");
           }
           equal(addon, addon2, "onNoCompatibilityUpdateAvailable");
@@ -1295,17 +1299,65 @@ var AddonTestUtils = {
     let manifest = Services.io.newFileURI(file);
     await OS.File.writeAtomic(file.path,
       new TextEncoder().encode(JSON.stringify(data)));
     this.overrideEntry = aomStartup.registerChrome(manifest, [
       ["override", "chrome://browser/content/built_in_addons.json",
        Services.io.newFileURI(file).spec],
     ]);
     Services.prefs.setBoolPref(PREF_DISABLE_SECURITY, false);
-  }
+  },
+
+  UpdateServer: class UpdateServer {
+    constructor(do_register_cleanup) {
+      const PREF_EM_CHECK_UPDATE_SECURITY = "extensions.checkUpdateSecurity";
+
+      this.testServer = new HttpServer();
+      this.testServer.start(-1);
+      this.port = this.testServer.identity.primaryPort;
+      this.extensions = {};
+      this.testServer.registerPathHandler("/test_update.json", (request, response) => {
+        let responseData = {addons: {}};
+        Object.keys(this.extensions).forEach((id) => {
+          responseData.addons[id] = {updates: this.extensions[id]};
+        });
+        response.write(JSON.stringify(responseData));
+      });
+
+      // The test extension uses an insecure update url.
+      Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+    }
+
+    get updateUrl() {
+      return `${this.serverUrl}/test_update.json`;
+    }
+
+    get serverUrl() {
+      return `http://localhost:${this.port}`;
+    }
+
+    serveUpdate(extensionData) {
+      let {version} = extensionData.manifest;
+      let {id} = extensionData.manifest.applications.gecko;
+      let path = `/addons/${id}-${version}.xpi`;
+      if (!(id in this.extensions)) {
+        this.extensions[id] = [];
+      }
+      this.extensions[id].push({
+        version,
+        update_link: `${this.serverUrl}${path}`,
+      });
+      let webExtensionFile = AddonTestUtils.createTempWebExtensionFile(extensionData);
+      this.testServer.registerFile(path, webExtensionFile);
+    }
+
+    cleanup() {
+      return new Promise(resolve => this.testServer.stop(resolve));
+    }
+  },
 };
 
 for (let [key, val] of Object.entries(AddonTestUtils)) {
   if (typeof val == "function")
     AddonTestUtils[key] = val.bind(AddonTestUtils);
 }
 
 EventEmitter.decorate(AddonTestUtils);