Bug 1386018 - Tell users that the default search engine was set by an extension r=aswan,bsilverberg,jaws
authorMark Striemer <mstriemer@mozilla.com>
Wed, 18 Oct 2017 14:54:54 -0500
changeset 443172 1253b19c7200ca8552a57c46c0335171eab2280b
parent 443171 1c1e25c461a0feae7ddef655e70dfbd85fd5766f
child 443173 4ecf1d9c1975c76b41a5f9fbf564e8693b26fcd5
push id1618
push userCallek@gmail.com
push dateThu, 11 Jan 2018 17:45:48 +0000
treeherdermozilla-release@882ca853e05a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersaswan, bsilverberg, jaws
bugs1386018
milestone58.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1386018 - Tell users that the default search engine was set by an extension r=aswan,bsilverberg,jaws 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.");