Bug 1386018 - Tell users that the default search engine was set by an extension r?jaws r?aswan draft
authorMark Striemer <mstriemer@mozilla.com>
Wed, 18 Oct 2017 14:54:54 -0500
changeset 692150 01abd97b6450c6c76ef2365b86251caa4502e7bd
parent 692149 58e3cc92a9e49a92ce07affb1c427df354bd913a
child 738679 a25f57a5c824f6e4c3e6f533a87786e30bcc4f37
push id87414
push userbmo:mstriemer@mozilla.com
push dateThu, 02 Nov 2017 16:36:21 +0000
reviewersjaws, aswan
bugs1386018
milestone58.0a1
Bug 1386018 - Tell users that the default search engine was set by an extension r?jaws r?aswan MozReview-Commit-ID: A7uJ2lN0cLF
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
--- 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;
@@ -2584,85 +2578,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);
-  let addon = controllingExtensionId
-    && await AddonManager.getAddonByID(controllingExtensionId);
-
-  // Sometimes the ExtensionSettingsStore gets in a bad state where it thinks
-  // an extension is controlling a setting but the extension has been uninstalled
-  // outside of the regular lifecycle. If the extension isn't currently installed
-  // then we should treat the setting as not being controlled.
-  // See https://bugzilla.mozilla.org/show_bug.cgi?id=1411046 for an example.
-  if (addon) {
-    showControllingExtension(settingName, addon);
-  } else {
-    hideControllingExtension(settingName);
-  }
-
-  return !!addon;
-}
-
-async function showControllingExtension(settingName, addon) {
-  // Tell the user what extension is controlling the setting.
-  let extensionControlledContent = getControllingExtensionEl(settingName);
-  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,93 @@ 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 addon = info && info.id
+    && await AddonManager.getAddonByID(info.id);
+
+  // Sometimes the ExtensionSettingsStore gets in a bad state where it thinks
+  // an extension is controlling a setting but the extension has been uninstalled
+  // outside of the regular lifecycle. If the extension isn't currently installed
+  // then we should treat the setting as not being controlled.
+  // See https://bugzilla.mozilla.org/show_bug.cgi?id=1411046 for an example.
+  if (addon) {
+    showControllingExtension(settingName, addon);
+  } else {
+    hideControllingExtension(settingName);
+  }
+
+  return !!addon;
+}
+
+async function showControllingExtension(settingName, addon) {
+  // Tell the user what extension is controlling the setting.
+  let extensionControlledContent = getControllingExtensionEl(settingName);
+  extensionControlledContent.classList.remove("extension-controlled-disabled");
+  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
@@ -270,23 +270,118 @@ 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);
   let addon = await AddonManager.getAddonByID("@set_newtab");
   addon.uninstall();
 });
 
+add_task(async function testExtensionControlledDefaultSearch() {
+  await openPreferencesViaOpenPreferencesAPI("paneSearch", {leaveOpen: true});
+  // eslint-disable-next-line mozilla/no-cpows-in-tests
+  let doc = gBrowser.contentDocument;
+  let extensionId = "@set_default_search";
+  let manifest = {
+    manifest_version: 2,
+    name: "set_default_search",
+    applications: {gecko: {id: extensionId}},
+    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,
+      },
+    }
+  };
+
+  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 originalExtension = ExtensionTestUtils.loadExtension({
+    useAddonManager: "permanent",
+    manifest: Object.assign({}, manifest, {version: "1.0"}),
+  });
+
+  let messageShown = waitForMessageShown("browserDefaultSearchExtensionContent");
+  await originalExtension.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 updatedExtension = ExtensionTestUtils.loadExtension({
+    useAddonManager: "permanent",
+    manifest: Object.assign({}, manifest, {version: "2.0"}),
+  });
+  await updatedExtension.startup();
+  addon = await AddonManager.getAddonByID(extensionId);
+
+  // 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 originalExtension.unload();
+  await updatedExtension.unload();
+  await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
 add_task(async function testExtensionControlledHomepageUninstalledAddon() {
   async function checkHomepageEnabled() {
     await openPreferencesViaOpenPreferencesAPI("paneGeneral", {leaveOpen: true});
     // eslint-disable-next-line mozilla/no-cpows-in-tests
     let doc = gBrowser.contentDocument;
     is(gBrowser.currentURI.spec, "about:preferences#general",
       "#general should be in the URI for about:preferences");
     let controlledContent = doc.getElementById("browserHomePageExtensionContent");
--- a/browser/locales/en-US/chrome/browser/preferences/preferences.properties
+++ b/browser/locales/en-US/chrome/browser/preferences/preferences.properties
@@ -271,12 +271,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
@@ -354,16 +354,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) {
@@ -449,26 +470,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.
    *
    * @param   {boolean} finalize
    *          When false, skip finalizing the store (writing current state to file).
--- 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.");