Bug 1410412 implement browser setting onChange event r=zombie
☠☠ backed out by fbf7d8f9ba38 ☠ ☠
authorShane Caraveo <scaraveo@mozilla.com>
Tue, 19 Nov 2019 03:40:21 +0000
changeset 502530 4d11ccc1252922ab6ec9090468e844ab5fe96ee7
parent 502529 8ae9d7dc86f2e723b73d3b7cdc1cea4c821ad9f0
child 502531 9a50611200cfb50a2cbca9f063a364fb7d20fd4c
push id114172
push userdluca@mozilla.com
push dateTue, 19 Nov 2019 11:31:10 +0000
treeherdermozilla-inbound@b5c5ba07d3db [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerszombie
bugs1410412
milestone72.0a1
Bug 1410412 implement browser setting onChange event r=zombie Differential Revision: https://phabricator.services.mozilla.com/D51324
browser/components/extensions/parent/ext-urlbar.js
toolkit/components/extensions/ExtensionPreferencesManager.jsm
toolkit/components/extensions/parent/ext-browserSettings.js
toolkit/components/extensions/parent/ext-privacy.js
toolkit/components/extensions/parent/ext-proxy.js
toolkit/components/extensions/schemas/types.json
toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js
--- a/browser/components/extensions/parent/ext-urlbar.js
+++ b/browser/components/extensions/parent/ext-urlbar.js
@@ -220,27 +220,27 @@ this.urlbar = class extends ExtensionAPI
               return fire.async(resultPayload).catch(error => {
                 throw context.normalizeError(error);
               });
             });
             return () => provider.setEventListener("resultPicked", null);
           },
         }).api(),
 
-        openViewOnFocus: getSettingsAPI(
-          context.extension.id,
-          "openViewOnFocus",
-          () => UrlbarPrefs.get("openViewOnFocus")
-        ),
+        openViewOnFocus: getSettingsAPI({
+          context,
+          name: "openViewOnFocus",
+          callback: () => UrlbarPrefs.get("openViewOnFocus"),
+        }),
 
-        engagementTelemetry: getSettingsAPI(
-          context.extension.id,
-          "engagementTelemetry",
-          () => UrlbarPrefs.get("eventTelemetry.enabled")
-        ),
+        engagementTelemetry: getSettingsAPI({
+          context,
+          name: "engagementTelemetry",
+          callback: () => UrlbarPrefs.get("eventTelemetry.enabled"),
+        }),
 
         contextualTip: {
           /**
            * Sets the contextual tip's icon,
            * title, button title, and link title.
            *
            * @param {object} details
            *   If null, then the contextual tip will be hidden.
--- a/toolkit/components/extensions/ExtensionPreferencesManager.jsm
+++ b/toolkit/components/extensions/ExtensionPreferencesManager.jsm
@@ -17,16 +17,18 @@
  * prefNames:   An array of strings, each of which is a preference on
  *              which the setting depends.
  * setCallback: A function that returns an object containing properties and
  *              values that correspond to the prefs to be set.
  */
 
 var EXPORTED_SYMBOLS = ["ExtensionPreferencesManager"];
 
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
 const { Management } = ChromeUtils.import(
   "resource://gre/modules/Extension.jsm",
   null
 );
 
 const { XPCOMUtils } = ChromeUtils.import(
   "resource://gre/modules/XPCOMUtils.jsm"
 );
@@ -36,33 +38,44 @@ ChromeUtils.defineModuleGetter(
   "ExtensionSettingsStore",
   "resource://gre/modules/ExtensionSettingsStore.jsm"
 );
 ChromeUtils.defineModuleGetter(
   this,
   "Preferences",
   "resource://gre/modules/Preferences.jsm"
 );
+ChromeUtils.defineModuleGetter(
+  this,
+  "ExtensionCommon",
+  "resource://gre/modules/ExtensionCommon.jsm"
+);
+
+const { ExtensionUtils } = ChromeUtils.import(
+  "resource://gre/modules/ExtensionUtils.jsm"
+);
+
+const { ExtensionError } = ExtensionUtils;
 
 XPCOMUtils.defineLazyGetter(this, "defaultPreferences", function() {
   return new Preferences({ defaultBranch: true });
 });
 
 /* eslint-disable mozilla/balanced-listeners */
 Management.on("uninstall", (type, { id }) => {
   ExtensionPreferencesManager.removeAll(id);
 });
 
 Management.on("disable", (type, id) => {
-  this.ExtensionPreferencesManager.disableAll(id);
+  ExtensionPreferencesManager.disableAll(id);
 });
 
 Management.on("startup", async (type, extension) => {
   if (extension.startupReason == "ADDON_ENABLE") {
-    this.ExtensionPreferencesManager.enableAll(extension.id);
+    ExtensionPreferencesManager.enableAll(extension.id);
   }
 });
 /* eslint-enable mozilla/balanced-listeners */
 
 const STORE_TYPE = "prefs";
 
 // Definitions of settings, each of which correspond to a different API.
 let settingsMap = new Map();
@@ -110,41 +123,44 @@ function settingsUpdate(initialValue) {
     }
   }
   return initialValue;
 }
 
 /**
  * Loops through a set of prefs, either setting or resetting them.
  *
+ * @param {string} name
+ *        The api name of the setting.
  * @param {Object} setting
  *        An object that represents a setting, which will have a setCallback
  *        property. If a onPrefsChanged function is provided it will be called
  *        with item when the preferences change.
  * @param {Object} item
  *        An object that represents an item handed back from the setting store
  *        from which the new pref values can be calculated.
  */
-function setPrefs(setting, item) {
+function setPrefs(name, setting, item) {
   let prefs = item.initialValue || setting.setCallback(item.value);
   let changed = false;
   for (let pref of setting.prefNames) {
     if (prefs[pref] === undefined) {
       if (Preferences.isSet(pref)) {
         changed = true;
         Preferences.reset(pref);
       }
     } else if (Preferences.get(pref) != prefs[pref]) {
       Preferences.set(pref, prefs[pref]);
       changed = true;
     }
   }
   if (changed && typeof setting.onPrefsChanged == "function") {
     setting.onPrefsChanged(item);
   }
+  Management.emit(`extension-setting-changed:${name}`);
 }
 
 /**
  * Commits a change to a setting and conditionally sets preferences.
  *
  * If the change to the setting causes a different extension to gain
  * control of the pref (or removes all extensions with control over the pref)
  * then the prefs should be updated, otherwise they should not be.
@@ -176,17 +192,17 @@ async function processSetting(id, name, 
     if (
       Object.keys(expectedPrefs).some(
         pref =>
           expectedPrefs[pref] && Preferences.get(pref) != expectedPrefs[pref]
       )
     ) {
       return false;
     }
-    setPrefs(setting, item);
+    setPrefs(name, setting, item);
     return true;
   }
   return false;
 }
 
 this.ExtensionPreferencesManager = {
   /**
    * Adds a setting to the settingsMap. This is how an API tells the
@@ -238,17 +254,17 @@ this.ExtensionPreferencesManager = {
       STORE_TYPE,
       name,
       value,
       initialValueCallback.bind(setting),
       name,
       settingsUpdate.bind(setting)
     );
     if (item) {
-      setPrefs(setting, item);
+      setPrefs(name, setting, item);
       return true;
     }
     return false;
   },
 
   /**
    * Indicates that this extension wants to temporarily cede control over the
    * given setting.
@@ -394,40 +410,102 @@ this.ExtensionPreferencesManager = {
     }
     await ExtensionSettingsStore.initialize();
     return ExtensionSettingsStore.getLevelOfControl(id, storeType, name);
   },
 
   /**
    * Returns an API object with get/set/clear used for a setting.
    *
-   * @param {string} extensionId
+   * @param {string|object} extensionId or params object
    * @param {string} name
-   *        The unique id of the setting.
+   *          The unique id of the setting.
    * @param {Function} callback
-   *        The function that retreives the current setting from prefs.
+   *          The function that retreives the current setting from prefs.
    * @param {string} storeType
-   *        The name of the store in ExtensionSettingsStore.
-   *        Defaults to STORE_TYPE.
+   *          The name of the store in ExtensionSettingsStore.
+   *          Defaults to STORE_TYPE.
    * @param {boolean} readOnly
    * @param {Function} validate
-   *        Utility function for any specific validation, such as checking
-   *        for supported platform.  Function should throw an error if necessary.
+   *          Utility function for any specific validation, such as checking
+   *          for supported platform.  Function should throw an error if necessary.
    *
    * @returns {object} API object with get/set/clear methods
    */
   getSettingsAPI(
     extensionId,
     name,
     callback,
     storeType,
     readOnly = false,
     validate = () => {}
   ) {
-    return {
+    if (arguments.length > 1) {
+      Services.console.logStringMessage(
+        `ExtensionPreferencesManager.getSettingsAPI for ${name} should be updated to use a single paramater object.`
+      );
+    }
+    return ExtensionPreferencesManager._getSettingsAPI(
+      arguments.length === 1
+        ? extensionId
+        : {
+            extensionId,
+            name,
+            storeType,
+            readOnly,
+            validate,
+          }
+    );
+  },
+
+  /**
+   * Returns an API object with get/set/clear used for a setting.
+   *
+   * @param {object} params The params object contains the following:
+   *        {BaseContext} context
+   *        {string} extensionId, optional to support old API
+   *        {string} name
+   *          The unique id of the setting.
+   *        {Function} callback
+   *          The function that retreives the current setting from prefs.
+   *        {string} storeType
+   *          The name of the store in ExtensionSettingsStore.
+   *          Defaults to STORE_TYPE.
+   *        {boolean} readOnly
+   *        {Function} validate
+   *          Utility function for any specific validation, such as checking
+   *          for supported platform.  Function should throw an error if necessary.
+   *
+   * @returns {object} API object with get/set/clear methods
+   */
+  _getSettingsAPI(params) {
+    let {
+      extensionId,
+      context,
+      name,
+      callback,
+      storeType,
+      readOnly = false,
+      onChange,
+      validate = () => {},
+    } = params;
+    if (!extensionId) {
+      extensionId = context.extension.id;
+    }
+
+    const checkScope = details => {
+      let { scope } = details;
+      if (scope && scope !== "regular") {
+        throw new ExtensionError(
+          `Firefox does not support the ${scope} settings scope.`
+        );
+      }
+    };
+
+    let settingsAPI = {
       async get(details) {
         validate();
         let levelOfControl = details.incognito
           ? "not_controllable"
           : await ExtensionPreferencesManager.getLevelOfControl(
               extensionId,
               name,
               storeType
@@ -438,27 +516,61 @@ this.ExtensionPreferencesManager = {
             : levelOfControl;
         return {
           levelOfControl,
           value: await callback(),
         };
       },
       set(details) {
         validate();
+        checkScope(details);
         if (!readOnly) {
           return ExtensionPreferencesManager.setSetting(
             extensionId,
             name,
             details.value
           );
         }
         return false;
       },
       clear(details) {
         validate();
+        checkScope(details);
         if (!readOnly) {
           return ExtensionPreferencesManager.removeSetting(extensionId, name);
         }
         return false;
       },
+      onChange,
     };
+    // Any caller using the old call signature will not have passed
+    // context to us.  This should only be experimental addons in the
+    // wild.
+    if (onChange === undefined && context) {
+      // Some settings that are read-only may not have called addSetting, in
+      // which case we have no way to listen on the pref changes.
+      let setting = settingsMap.get(name);
+      if (!setting) {
+        Services.console.logStringMessage(
+          `ExtensionPreferencesManager API ${name} created but addSetting was not called.`
+        );
+        return settingsAPI;
+      }
+
+      settingsAPI.onChange = new ExtensionCommon.EventManager({
+        context,
+        name: `${name}.onChange`,
+        register: fire => {
+          let listener = async () => {
+            fire.async({
+              details: await settingsAPI.get({}),
+            });
+          };
+          Management.on(`extension-setting-changed:${name}`, listener);
+          return () => {
+            Management.off(`extension-setting-changed:${name}`, listener);
+          };
+        },
+      }).api();
+    }
+    return settingsAPI;
   },
 };
--- a/toolkit/components/extensions/parent/ext-browserSettings.js
+++ b/toolkit/components/extensions/parent/ext-browserSettings.js
@@ -73,16 +73,24 @@ ExtensionPreferencesManager.addSetting("
 ExtensionPreferencesManager.addSetting("contextMenuShowEvent", {
   prefNames: ["ui.context_menus.after_mouseup"],
 
   setCallback(value) {
     return { [this.prefNames[0]]: value === "mouseup" };
   },
 });
 
+ExtensionPreferencesManager.addSetting(HOMEPAGE_OVERRIDE_SETTING, {
+  prefNames: [HOMEPAGE_URL_PREF],
+
+  setCallback() {
+    throw new Error("Unable to set read-only setting");
+  },
+});
+
 ExtensionPreferencesManager.addSetting("ftpProtocolEnabled", {
   prefNames: ["network.ftp.enabled"],
 
   setCallback(value) {
     return { [this.prefNames[0]]: value };
   },
 });
 
@@ -156,57 +164,63 @@ ExtensionPreferencesManager.addSetting("
   },
 });
 
 this.browserSettings = class extends ExtensionAPI {
   getAPI(context) {
     let { extension } = context;
     return {
       browserSettings: {
-        allowPopupsForUserEvents: getSettingsAPI(
-          extension.id,
-          "allowPopupsForUserEvents",
-          () => {
+        allowPopupsForUserEvents: getSettingsAPI({
+          context,
+          name: "allowPopupsForUserEvents",
+          callback() {
             return Services.prefs.getCharPref("dom.popup_allowed_events") != "";
-          }
-        ),
-        cacheEnabled: getSettingsAPI(extension.id, "cacheEnabled", () => {
-          return (
-            Services.prefs.getBoolPref("browser.cache.disk.enable") &&
-            Services.prefs.getBoolPref("browser.cache.memory.enable")
-          );
+          },
         }),
-        closeTabsByDoubleClick: getSettingsAPI(
-          extension.id,
-          "closeTabsByDoubleClick",
-          () => {
+        cacheEnabled: getSettingsAPI({
+          context,
+          name: "cacheEnabled",
+          callback() {
+            return (
+              Services.prefs.getBoolPref("browser.cache.disk.enable") &&
+              Services.prefs.getBoolPref("browser.cache.memory.enable")
+            );
+          },
+        }),
+        closeTabsByDoubleClick: getSettingsAPI({
+          context,
+          name: "closeTabsByDoubleClick",
+          callback() {
             return Services.prefs.getBoolPref(
               "browser.tabs.closeTabByDblclick"
             );
           },
-          undefined,
-          false,
-          () => {
+          validate() {
             if (AppConstants.platform == "android") {
               throw new ExtensionError(
                 `android is not a supported platform for the closeTabsByDoubleClick setting.`
               );
             }
-          }
-        ),
+          },
+        }),
         contextMenuShowEvent: Object.assign(
-          getSettingsAPI(extension.id, "contextMenuShowEvent", () => {
-            if (AppConstants.platform === "win") {
-              return "mouseup";
-            }
-            let prefValue = Services.prefs.getBoolPref(
-              "ui.context_menus.after_mouseup",
-              null
-            );
-            return prefValue ? "mouseup" : "mousedown";
+          getSettingsAPI({
+            context,
+            name: "contextMenuShowEvent",
+            callback() {
+              if (AppConstants.platform === "win") {
+                return "mouseup";
+              }
+              let prefValue = Services.prefs.getBoolPref(
+                "ui.context_menus.after_mouseup",
+                null
+              );
+              return prefValue ? "mouseup" : "mousedown";
+            },
           }),
           {
             set: details => {
               if (!["mouseup", "mousedown"].includes(details.value)) {
                 throw new ExtensionError(
                   `${
                     details.value
                   } is not a valid value for contextMenuShowEvent.`
@@ -222,104 +236,131 @@ this.browserSettings = class extends Ext
               return ExtensionPreferencesManager.setSetting(
                 extension.id,
                 "contextMenuShowEvent",
                 details.value
               );
             },
           }
         ),
-        ftpProtocolEnabled: getSettingsAPI(
-          extension.id,
-          "ftpProtocolEnabled",
-          () => {
+        ftpProtocolEnabled: getSettingsAPI({
+          context,
+          name: "ftpProtocolEnabled",
+          callback() {
             return Services.prefs.getBoolPref("network.ftp.enabled");
-          }
-        ),
-        homepageOverride: getSettingsAPI(
-          extension.id,
-          HOMEPAGE_OVERRIDE_SETTING,
-          () => {
+          },
+        }),
+        homepageOverride: getSettingsAPI({
+          context,
+          name: HOMEPAGE_OVERRIDE_SETTING,
+          callback() {
             return Services.prefs.getStringPref(HOMEPAGE_URL_PREF);
           },
-          undefined,
-          true
-        ),
-        imageAnimationBehavior: getSettingsAPI(
-          extension.id,
-          "imageAnimationBehavior",
-          () => {
+          readOnly: true,
+        }),
+        imageAnimationBehavior: getSettingsAPI({
+          context,
+          name: "imageAnimationBehavior",
+          callback() {
             return Services.prefs.getCharPref("image.animation_mode");
-          }
-        ),
-        newTabPosition: getSettingsAPI(extension.id, "newTabPosition", () => {
-          if (Services.prefs.getBoolPref("browser.tabs.insertAfterCurrent")) {
-            return "afterCurrent";
-          }
-          if (
-            Services.prefs.getBoolPref("browser.tabs.insertRelatedAfterCurrent")
-          ) {
-            return "relatedAfterCurrent";
-          }
-          return "atEnd";
+          },
         }),
-        newTabPageOverride: getSettingsAPI(
-          extension.id,
-          NEW_TAB_OVERRIDE_SETTING,
-          () => {
+        newTabPosition: getSettingsAPI({
+          context,
+          name: "newTabPosition",
+          callback() {
+            if (Services.prefs.getBoolPref("browser.tabs.insertAfterCurrent")) {
+              return "afterCurrent";
+            }
+            if (
+              Services.prefs.getBoolPref(
+                "browser.tabs.insertRelatedAfterCurrent"
+              )
+            ) {
+              return "relatedAfterCurrent";
+            }
+            return "atEnd";
+          },
+        }),
+        newTabPageOverride: getSettingsAPI({
+          context,
+          name: NEW_TAB_OVERRIDE_SETTING,
+          callback() {
             return aboutNewTabService.newTabURL;
           },
-          URL_STORE_TYPE,
-          true
-        ),
-        openBookmarksInNewTabs: getSettingsAPI(
-          extension.id,
-          "openBookmarksInNewTabs",
-          () => {
+          storeType: URL_STORE_TYPE,
+          readOnly: true,
+          onChange: new ExtensionCommon.EventManager({
+            context,
+            name: `${NEW_TAB_OVERRIDE_SETTING}.onChange`,
+            register: fire => {
+              let listener = (text, id) => {
+                fire.async({
+                  details: {
+                    levelOfControl: "not_controllable",
+                    value: aboutNewTabService.newTabURL,
+                  },
+                });
+              };
+              Services.obs.addObserver(listener, "newtab-url-changed");
+              return () => {
+                Services.obs.removeObserver(listener, "newtab-url-changed");
+              };
+            },
+          }).api(),
+        }),
+        openBookmarksInNewTabs: getSettingsAPI({
+          context,
+          name: "openBookmarksInNewTabs",
+          callback() {
             return Services.prefs.getBoolPref(
               "browser.tabs.loadBookmarksInTabs"
             );
-          }
-        ),
-        openSearchResultsInNewTabs: getSettingsAPI(
-          extension.id,
-          "openSearchResultsInNewTabs",
-          () => {
+          },
+        }),
+        openSearchResultsInNewTabs: getSettingsAPI({
+          context,
+          name: "openSearchResultsInNewTabs",
+          callback() {
             return Services.prefs.getBoolPref("browser.search.openintab");
-          }
-        ),
-        openUrlbarResultsInNewTabs: getSettingsAPI(
-          extension.id,
-          "openUrlbarResultsInNewTabs",
-          () => {
+          },
+        }),
+        openUrlbarResultsInNewTabs: getSettingsAPI({
+          context,
+          name: "openUrlbarResultsInNewTabs",
+          callback() {
             return Services.prefs.getBoolPref("browser.urlbar.openintab");
-          }
-        ),
-        webNotificationsDisabled: getSettingsAPI(
-          extension.id,
-          "webNotificationsDisabled",
-          () => {
+          },
+        }),
+        webNotificationsDisabled: getSettingsAPI({
+          context,
+          name: "webNotificationsDisabled",
+          callback() {
             let prefValue = Services.prefs.getIntPref(
               "permissions.default.desktop-notification",
               null
             );
             return prefValue === PERM_DENY_ACTION;
-          }
-        ),
+          },
+        }),
         overrideDocumentColors: Object.assign(
-          getSettingsAPI(extension.id, "overrideDocumentColors", () => {
-            let prefValue = Services.prefs.getIntPref(
-              "browser.display.document_color_use"
-            );
-            if (prefValue === 1) {
-              return "never";
-            } else if (prefValue === 2) {
-              return "always";
-            }
-            return "high-contrast-only";
+          getSettingsAPI({
+            context,
+            name: "overrideDocumentColors",
+            callback() {
+              let prefValue = Services.prefs.getIntPref(
+                "browser.display.document_color_use"
+              );
+              if (prefValue === 1) {
+                return "never";
+              } else if (prefValue === 2) {
+                return "always";
+              }
+              return "high-contrast-only";
+            },
           }),
           {
             set: details => {
               if (
                 !["never", "always", "high-contrast-only"].includes(
                   details.value
                 )
               ) {
@@ -339,22 +380,26 @@ this.browserSettings = class extends Ext
                 extension.id,
                 "overrideDocumentColors",
                 prefValue
               );
             },
           }
         ),
         useDocumentFonts: Object.assign(
-          getSettingsAPI(extension.id, "useDocumentFonts", () => {
-            return (
-              Services.prefs.getIntPref(
-                "browser.display.use_document_fonts"
-              ) !== 0
-            );
+          getSettingsAPI({
+            context,
+            name: "useDocumentFonts",
+            callback() {
+              return (
+                Services.prefs.getIntPref(
+                  "browser.display.use_document_fonts"
+                ) !== 0
+              );
+            },
           }),
           {
             set: details => {
               if (typeof details.value !== "boolean") {
                 throw new ExtensionError(
                   `${details.value} is not a valid value for useDocumentFonts.`
                 );
               }
--- a/toolkit/components/extensions/parent/ext-privacy.js
+++ b/toolkit/components/extensions/parent/ext-privacy.js
@@ -10,65 +10,28 @@ ChromeUtils.defineModuleGetter(
   this,
   "Preferences",
   "resource://gre/modules/Preferences.jsm"
 );
 
 var { ExtensionPreferencesManager } = ChromeUtils.import(
   "resource://gre/modules/ExtensionPreferencesManager.jsm"
 );
-
-var { ExtensionError } = ExtensionUtils;
+var { getSettingsAPI } = ExtensionPreferencesManager;
 
 const cookieSvc = Ci.nsICookieService;
 
 const cookieBehaviorValues = new Map([
   ["allow_all", cookieSvc.BEHAVIOR_ACCEPT],
   ["reject_third_party", cookieSvc.BEHAVIOR_REJECT_FOREIGN],
   ["reject_all", cookieSvc.BEHAVIOR_REJECT],
   ["allow_visited", cookieSvc.BEHAVIOR_LIMIT_FOREIGN],
   ["reject_trackers", cookieSvc.BEHAVIOR_REJECT_TRACKER],
 ]);
 
-const checkScope = scope => {
-  if (scope && scope !== "regular") {
-    throw new ExtensionError(
-      `Firefox does not support the ${scope} settings scope.`
-    );
-  }
-};
-
-const getPrivacyAPI = (extension, name, callback) => {
-  return {
-    async get(details) {
-      return {
-        levelOfControl: details.incognito
-          ? "not_controllable"
-          : await ExtensionPreferencesManager.getLevelOfControl(
-              extension.id,
-              name
-            ),
-        value: await callback(),
-      };
-    },
-    set(details) {
-      checkScope(details.scope);
-      return ExtensionPreferencesManager.setSetting(
-        extension.id,
-        name,
-        details.value
-      );
-    },
-    clear(details) {
-      checkScope(details.scope);
-      return ExtensionPreferencesManager.removeSetting(extension.id, name);
-    },
-  };
-};
-
 // Add settings objects for supported APIs to the preferences manager.
 ExtensionPreferencesManager.addSetting("network.networkPredictionEnabled", {
   prefNames: [
     "network.predictor.enabled",
     "network.prefetch-next",
     "network.http.speculative-parallel-limit",
     "network.dns.disablePrefetch",
   ],
@@ -212,44 +175,43 @@ ExtensionPreferencesManager.addSetting("
     }
 
     return prefs;
   },
 });
 
 this.privacy = class extends ExtensionAPI {
   getAPI(context) {
-    let { extension } = context;
     return {
       privacy: {
         network: {
-          networkPredictionEnabled: getPrivacyAPI(
-            extension,
-            "network.networkPredictionEnabled",
-            () => {
+          networkPredictionEnabled: getSettingsAPI({
+            context,
+            name: "network.networkPredictionEnabled",
+            callback() {
               return (
                 Preferences.get("network.predictor.enabled") &&
                 Preferences.get("network.prefetch-next") &&
                 Preferences.get("network.http.speculative-parallel-limit") >
                   0 &&
                 !Preferences.get("network.dns.disablePrefetch")
               );
-            }
-          ),
-          peerConnectionEnabled: getPrivacyAPI(
-            extension,
-            "network.peerConnectionEnabled",
-            () => {
+            },
+          }),
+          peerConnectionEnabled: getSettingsAPI({
+            context,
+            name: "network.peerConnectionEnabled",
+            callback() {
               return Preferences.get("media.peerconnection.enabled");
-            }
-          ),
-          webRTCIPHandlingPolicy: getPrivacyAPI(
-            extension,
-            "network.webRTCIPHandlingPolicy",
-            () => {
+            },
+          }),
+          webRTCIPHandlingPolicy: getSettingsAPI({
+            context,
+            name: "network.webRTCIPHandlingPolicy",
+            callback() {
               if (Preferences.get("media.peerconnection.ice.proxy_only")) {
                 return "proxy_only";
               }
 
               let default_address_only = Preferences.get(
                 "media.peerconnection.ice.default_address_only"
               );
               if (default_address_only) {
@@ -265,85 +227,85 @@ this.privacy = class extends ExtensionAP
                     return "disable_non_proxied_udp";
                   }
                   return "default_public_interface_only";
                 }
                 return "default_public_and_private_interfaces";
               }
 
               return "default";
-            }
-          ),
+            },
+          }),
         },
 
         services: {
-          passwordSavingEnabled: getPrivacyAPI(
-            extension,
-            "services.passwordSavingEnabled",
-            () => {
+          passwordSavingEnabled: getSettingsAPI({
+            context,
+            name: "services.passwordSavingEnabled",
+            callback() {
               return Preferences.get("signon.rememberSignons");
-            }
-          ),
+            },
+          }),
         },
 
         websites: {
-          cookieConfig: getPrivacyAPI(
-            extension,
-            "websites.cookieConfig",
-            () => {
+          cookieConfig: getSettingsAPI({
+            context,
+            name: "websites.cookieConfig",
+            callback() {
               let prefValue = Preferences.get("network.cookie.cookieBehavior");
               return {
                 behavior: Array.from(cookieBehaviorValues.entries()).find(
                   entry => entry[1] === prefValue
                 )[0],
                 nonPersistentCookies:
                   Preferences.get("network.cookie.lifetimePolicy") ===
                   cookieSvc.ACCEPT_SESSION,
               };
-            }
-          ),
-          firstPartyIsolate: getPrivacyAPI(
-            extension,
-            "websites.firstPartyIsolate",
-            () => {
+            },
+          }),
+          firstPartyIsolate: getSettingsAPI({
+            context,
+            name: "websites.firstPartyIsolate",
+            callback() {
               return Preferences.get("privacy.firstparty.isolate");
-            }
-          ),
-          hyperlinkAuditingEnabled: getPrivacyAPI(
-            extension,
-            "websites.hyperlinkAuditingEnabled",
-            () => {
+            },
+          }),
+          hyperlinkAuditingEnabled: getSettingsAPI({
+            context,
+            name: "websites.hyperlinkAuditingEnabled",
+            callback() {
               return Preferences.get("browser.send_pings");
-            }
-          ),
-          referrersEnabled: getPrivacyAPI(
-            extension,
-            "websites.referrersEnabled",
-            () => {
+            },
+          }),
+          referrersEnabled: getSettingsAPI({
+            context,
+            name: "websites.referrersEnabled",
+            callback() {
               return Preferences.get("network.http.sendRefererHeader") !== 0;
-            }
-          ),
-          resistFingerprinting: getPrivacyAPI(
-            extension,
-            "websites.resistFingerprinting",
-            () => {
+            },
+          }),
+          resistFingerprinting: getSettingsAPI({
+            context,
+            name: "websites.resistFingerprinting",
+            callback() {
               return Preferences.get("privacy.resistFingerprinting");
-            }
-          ),
-          trackingProtectionMode: getPrivacyAPI(
-            extension,
-            "websites.trackingProtectionMode",
-            () => {
+            },
+          }),
+          trackingProtectionMode: getSettingsAPI({
+            context,
+            name: "websites.trackingProtectionMode",
+            callback() {
               if (Preferences.get("privacy.trackingprotection.enabled")) {
                 return "always";
               } else if (
                 Preferences.get("privacy.trackingprotection.pbmode.enabled")
               ) {
                 return "private_browsing";
               }
               return "never";
-            }
-          ),
+            },
+          }),
         },
       },
     };
   }
 };
--- a/toolkit/components/extensions/parent/ext-proxy.js
+++ b/toolkit/components/extensions/parent/ext-proxy.js
@@ -168,20 +168,20 @@ this.proxy = class extends ExtensionAPI 
             extension.on("proxy-error", listener);
             return () => {
               extension.off("proxy-error", listener);
             };
           },
         }).api(),
 
         settings: Object.assign(
-          getSettingsAPI(
-            extension.id,
-            "proxy.settings",
-            () => {
+          getSettingsAPI({
+            context,
+            name: "proxy.settings",
+            callback() {
               let prefValue = Services.prefs.getIntPref("network.proxy.type");
               let proxyConfig = {
                 proxyType: Array.from(PROXY_TYPES_MAP.entries()).find(
                   entry => entry[1] === prefValue
                 )[0],
                 autoConfigUrl: Services.prefs.getCharPref(
                   "network.proxy.autoconfig_url"
                 ),
@@ -209,26 +209,24 @@ this.proxy = class extends ExtensionAPI 
                   `network.proxy.${prop}_port`
                 );
                 proxyConfig[prop] = port ? `${host}:${port}` : host;
               }
 
               return proxyConfig;
             },
             // proxy.settings is unsupported on android.
-            undefined,
-            false,
-            () => {
+            validate() {
               if (AppConstants.platform == "android") {
                 throw new ExtensionError(
                   `proxy.settings is not supported on android.`
                 );
               }
-            }
-          ),
+            },
+          }),
           {
             set: details => {
               if (AppConstants.platform === "android") {
                 throw new ExtensionError(
                   "proxy.settings is not supported on android."
                 );
               }
 
--- a/toolkit/components/extensions/schemas/types.json
+++ b/toolkit/components/extensions/schemas/types.json
@@ -128,17 +128,16 @@
             ]
           }
         ],
         "events": [
           {
             "name": "onChange",
             "type": "function",
             "description": "Fired after the setting changes.",
-            "unsupported": true,
             "parameters": [
               {
                 "type": "object",
                 "name": "details",
                 "properties": {
                   "value": {
                     "description": "The value of the setting after the change.",
                     "type": "any"
--- a/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js
@@ -35,18 +35,30 @@ add_task(async function test_browser_set
     "browser.search.openintab": false,
     "browser.tabs.insertRelatedAfterCurrent": true,
     "browser.tabs.insertAfterCurrent": false,
     "browser.display.document_color_use": 1,
     "browser.display.use_document_fonts": 1,
   };
 
   async function background() {
+    let listeners = new Set([]);
     browser.test.onMessage.addListener(async (msg, apiName, value) => {
       let apiObj = browser.browserSettings[apiName];
+      // Don't add more than one listner per apiName.  We leave the
+      // listener to ensure we do not get more calls than we expect.
+      if (!listeners.has(apiName)) {
+        apiObj.onChange.addListener(details => {
+          browser.test.sendMessage("onChange", {
+            details: details.details,
+            setting: apiName,
+          });
+        });
+        listeners.add(apiName);
+      }
       let result = await apiObj.set({ value });
       if (msg === "set") {
         browser.test.assertTrue(result, "set returns true.");
         browser.test.sendMessage("settingData", await apiObj.get({}));
       } else {
         browser.test.assertFalse(result, "set returns false for a no-op.");
         browser.test.sendMessage("no-op set");
       }
@@ -74,16 +86,23 @@ add_task(async function test_browser_set
   });
 
   await promiseStartupManager();
   await extension.startup();
 
   async function testSetting(setting, value, expected, expectedValue = value) {
     extension.sendMessage("set", setting, value);
     let data = await extension.awaitMessage("settingData");
+    let dataChange = await extension.awaitMessage("onChange");
+    equal(setting, dataChange.setting, "onChange fired");
+    equal(
+      data.value,
+      dataChange.details.value,
+      "onChange fired with correct value"
+    );
     deepEqual(
       data.value,
       expectedValue,
       `The ${setting} setting has the expected value.`
     );
     equal(
       data.levelOfControl,
       "controlled_by_this_extension",
@@ -169,22 +188,22 @@ add_task(async function test_browser_set
     await testSetting("closeTabsByDoubleClick", true, {
       "browser.tabs.closeTabByDblclick": true,
     });
     await testSetting("closeTabsByDoubleClick", false, {
       "browser.tabs.closeTabByDblclick": false,
     });
   }
 
+  await testSetting("ftpProtocolEnabled", false, {
+    "network.ftp.enabled": false,
+  });
   await testSetting("ftpProtocolEnabled", true, {
     "network.ftp.enabled": true,
   });
-  await testSetting("ftpProtocolEnabled", false, {
-    "network.ftp.enabled": false,
-  });
 
   await testSetting("newTabPosition", "afterCurrent", {
     "browser.tabs.insertRelatedAfterCurrent": false,
     "browser.tabs.insertAfterCurrent": true,
   });
   await testSetting("newTabPosition", "atEnd", {
     "browser.tabs.insertRelatedAfterCurrent": false,
     "browser.tabs.insertAfterCurrent": false,