Bug 777989 - Move add-on helper functions out of add-ons engine; r=rnewman
authorGregory Szorc <gps@mozilla.com>
Mon, 30 Jul 2012 17:05:33 -0700
changeset 100853 cc797e43345c767d71144397d783980d170b084b
parent 100852 cb8e301cc81d327dcf9871cd75a75176d55fead7
child 100970 6b3df1b77985e4a3888fa80bbb265675f8981378
push id601
push usergszorc@mozilla.com
push dateTue, 31 Jul 2012 00:05:47 +0000
treeherderservices-central@cc797e43345c [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrnewman
bugs777989
milestone17.0a1
Bug 777989 - Move add-on helper functions out of add-ons engine; r=rnewman
services/sync/modules/addonutils.js
services/sync/modules/engines/addons.js
services/sync/tests/unit/test_addon_utils.js
services/sync/tests/unit/test_addons_store.js
services/sync/tests/unit/test_load_modules.js
services/sync/tests/unit/xpcshell.ini
new file mode 100644
--- /dev/null
+++ b/services/sync/modules/addonutils.js
@@ -0,0 +1,472 @@
+/* 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/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["AddonUtils"];
+
+const {interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://services-common/log4moz.js");
+
+XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
+  "resource://gre/modules/AddonManager.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AddonRepository",
+  "resource://gre/modules/AddonRepository.jsm");
+
+function AddonUtilsInternal() {
+  this._log = Log4Moz.repository.getLogger("Sync.AddonUtils");
+}
+AddonUtilsInternal.prototype = {
+  /**
+   * Obtain an AddonInstall object from an AddonSearchResult instance.
+   *
+   * The callback will be invoked with the result of the operation. The
+   * callback receives 2 arguments, error and result. Error will be falsy
+   * on success or some kind of error value otherwise. The result argument
+   * will be an AddonInstall on success or null on failure. It is possible
+   * for the error to be falsy but result to be null. This could happen if
+   * an install was not found.
+   *
+   * @param addon
+   *        AddonSearchResult to obtain install from.
+   * @param cb
+   *        Function to be called with result of operation.
+   */
+  getInstallFromSearchResult:
+    function getInstallFromSearchResult(addon, cb, requireSecureURI=true) {
+
+    this._log.debug("Obtaining install for " + addon.id);
+
+    // Verify that the source URI uses TLS. We don't allow installs from
+    // insecure sources for security reasons. The Addon Manager ensures that
+    // cert validation, etc is performed.
+    if (requireSecureURI) {
+      let scheme = addon.sourceURI.scheme;
+      if (scheme != "https") {
+        cb(new Error("Insecure source URI scheme: " + scheme), addon.install);
+        return;
+      }
+    }
+
+    // We should theoretically be able to obtain (and use) addon.install if
+    // it is available. However, the addon.sourceURI rewriting won't be
+    // reflected in the AddonInstall, so we can't use it. If we ever get rid
+    // of sourceURI rewriting, we can avoid having to reconstruct the
+    // AddonInstall.
+    AddonManager.getInstallForURL(
+      addon.sourceURI.spec,
+      function handleInstall(install) {
+        cb(null, install);
+      },
+      "application/x-xpinstall",
+      undefined,
+      addon.name,
+      addon.iconURL,
+      addon.version
+    );
+  },
+
+  /**
+   * Installs an add-on from an AddonSearchResult instance.
+   *
+   * The options argument defines extra options to control the install.
+   * Recognized keys in this map are:
+   *
+   *   syncGUID - Sync GUID to use for the new add-on.
+   *   enabled - Boolean indicating whether the add-on should be enabled upon
+   *             install.
+   *   requireSecureURI - Boolean indicating whether to require a secure
+   *     URI to install from. This defaults to true.
+   *
+   * When complete it calls a callback with 2 arguments, error and result.
+   *
+   * If error is falsy, result is an object. If error is truthy, result is
+   * null.
+   *
+   * The result object has the following keys:
+   *
+   *   id      ID of add-on that was installed.
+   *   install AddonInstall that was installed.
+   *   addon   Addon that was installed.
+   *
+   * @param addon
+   *        AddonSearchResult to install add-on from.
+   * @param options
+   *        Object with additional metadata describing how to install add-on.
+   * @param cb
+   *        Function to be invoked with result of operation.
+   */
+  installAddonFromSearchResult:
+    function installAddonFromSearchResult(addon, options, cb) {
+    this._log.info("Trying to install add-on from search result: " + addon.id);
+
+    if (options.requireSecureURI === undefined) {
+      options.requireSecureURI = true;
+    }
+
+    this.getInstallFromSearchResult(addon, function onResult(error, install) {
+      if (error) {
+        cb(error, null);
+        return;
+      }
+
+      if (!install) {
+        cb(new Error("AddonInstall not available: " + addon.id), null);
+        return;
+      }
+
+      try {
+        this._log.info("Installing " + addon.id);
+        let log = this._log;
+
+        let listener = {
+          onInstallStarted: function onInstallStarted(install) {
+            if (!options) {
+              return;
+            }
+
+            if (options.syncGUID) {
+              log.info("Setting syncGUID of " + install.name  +": " +
+                       options.syncGUID);
+              install.addon.syncGUID = options.syncGUID;
+            }
+
+            // We only need to change userDisabled if it is disabled because
+            // enabled is the default.
+            if ("enabled" in options && !options.enabled) {
+              log.info("Marking add-on as disabled for install: " +
+                       install.name);
+              install.addon.userDisabled = true;
+            }
+          },
+          onInstallEnded: function(install, addon) {
+            install.removeListener(listener);
+
+            cb(null, {id: addon.id, install: install, addon: addon});
+          },
+          onInstallFailed: function(install) {
+            install.removeListener(listener);
+
+            cb(new Error("Install failed: " + install.error), null);
+          },
+          onDownloadFailed: function(install) {
+            install.removeListener(listener);
+
+            cb(new Error("Download failed: " + install.error), null);
+          }
+        };
+        install.addListener(listener);
+        install.install();
+      }
+      catch (ex) {
+        this._log.error("Error installing add-on: " + Utils.exceptionstr(ex));
+        cb(ex, null);
+      }
+    }.bind(this), options.requireSecureURI);
+  },
+
+  /**
+   * Uninstalls the Addon instance and invoke a callback when it is done.
+   *
+   * @param addon
+   *        Addon instance to uninstall.
+   * @param cb
+   *        Function to be invoked when uninstall has finished. It receives a
+   *        truthy value signifying error and the add-on which was uninstalled.
+   */
+  uninstallAddon: function uninstallAddon(addon, cb) {
+    let listener = {
+      onUninstalling: function(uninstalling, needsRestart) {
+        if (addon.id != uninstalling.id) {
+          return;
+        }
+
+        // We assume restartless add-ons will send the onUninstalled event
+        // soon.
+        if (!needsRestart) {
+          return;
+        }
+
+        // For non-restartless add-ons, we issue the callback on uninstalling
+        // because we will likely never see the uninstalled event.
+        AddonManager.removeAddonListener(listener);
+        cb(null, addon);
+      },
+      onUninstalled: function(uninstalled) {
+        if (addon.id != uninstalled.id) {
+          return;
+        }
+
+        AddonManager.removeAddonListener(listener);
+        cb(null, addon);
+      }
+    };
+    AddonManager.addAddonListener(listener);
+    addon.uninstall();
+  },
+
+  /**
+   * Installs multiple add-ons specified by metadata.
+   *
+   * The first argument is an array of objects. Each object must have the
+   * following keys:
+   *
+   *   id - public ID of the add-on to install.
+   *   syncGUID - syncGUID for new add-on.
+   *   enabled - boolean indicating whether the add-on should be enabled.
+   *   requireSecureURI - Boolean indicating whether to require a secure
+   *     URI when installing from a remote location. This defaults to
+   *     true.
+   *
+   * The callback will be called when activity on all add-ons is complete. The
+   * callback receives 2 arguments, error and result.
+   *
+   * If error is truthy, it contains a string describing the overall error.
+   *
+   * The 2nd argument to the callback is always an object with details on the
+   * overall execution state. It contains the following keys:
+   *
+   *   installedIDs  Array of add-on IDs that were installed.
+   *   installs      Array of AddonInstall instances that were installed.
+   *   addons        Array of Addon instances that were installed.
+   *   errors        Array of errors encountered. Only has elements if error is
+   *                 truthy.
+   *
+   * @param installs
+   *        Array of objects describing add-ons to install.
+   * @param cb
+   *        Function to be called when all actions are complete.
+   */
+  installAddons: function installAddons(installs, cb) {
+    if (!cb) {
+      throw new Error("Invalid argument: cb is not defined.");
+    }
+
+    let ids = [];
+    for each (let addon in installs) {
+      ids.push(addon.id);
+    }
+
+    AddonRepository.getAddonsByIDs(ids, {
+      searchSucceeded: function searchSucceeded(addons, addonsLength, total) {
+        this._log.info("Found " + addonsLength + "/" + ids.length +
+                       " add-ons during repository search.");
+
+        let ourResult = {
+          installedIDs: [],
+          installs:     [],
+          addons:       [],
+          errors:       []
+        };
+
+        if (!addonsLength) {
+          cb(null, ourResult);
+          return;
+        }
+
+        let expectedInstallCount = 0;
+        let finishedCount = 0;
+        let installCallback = function installCallback(error, result) {
+          finishedCount++;
+
+          if (error) {
+            ourResult.errors.push(error);
+          } else {
+            ourResult.installedIDs.push(result.id);
+            ourResult.installs.push(result.install);
+            ourResult.addons.push(result.addon);
+          }
+
+          if (finishedCount >= expectedInstallCount) {
+            if (ourResult.errors.length > 0) {
+              cb(new Error("1 or more add-ons failed to install"), ourResult);
+            } else {
+              cb(null, ourResult);
+            }
+          }
+        }.bind(this);
+
+        let toInstall = [];
+
+        // Rewrite the "src" query string parameter of the source URI to note
+        // that the add-on was installed by Sync and not something else so
+        // server-side metrics aren't skewed (bug 708134). The server should
+        // ideally send proper URLs, but this solution was deemed too
+        // complicated at the time the functionality was implemented.
+        for each (let addon in addons) {
+          // sourceURI presence isn't enforced by AddonRepository. So, we skip
+          // add-ons without a sourceURI.
+          if (!addon.sourceURI) {
+            this._log.info("Skipping install of add-on because missing " +
+                           "sourceURI: " + addon.id);
+            continue;
+          }
+
+          toInstall.push(addon);
+
+          // We should always be able to QI the nsIURI to nsIURL. If not, we
+          // still try to install the add-on, but we don't rewrite the URL,
+          // potentially skewing metrics.
+          try {
+            addon.sourceURI.QueryInterface(Ci.nsIURL);
+          } catch (ex) {
+            this._log.warn("Unable to QI sourceURI to nsIURL: " +
+                           addon.sourceURI.spec);
+            continue;
+          }
+
+          let params = addon.sourceURI.query.split("&").map(
+            function rewrite(param) {
+
+            if (param.indexOf("src=") == 0) {
+              return "src=sync";
+            } else {
+              return param;
+            }
+          });
+
+          addon.sourceURI.query = params.join("&");
+        }
+
+        expectedInstallCount = toInstall.length;
+
+        if (!expectedInstallCount) {
+          cb(null, ourResult);
+          return;
+        }
+
+        // Start all the installs asynchronously. They will report back to us
+        // as they finish, eventually triggering the global callback.
+        for each (let addon in toInstall) {
+          let options = {};
+          for each (let install in installs) {
+            if (install.id == addon.id) {
+              options = install;
+              break;
+            }
+          }
+
+          this.installAddonFromSearchResult(addon, options, installCallback);
+        }
+
+      }.bind(this),
+
+      searchFailed: function searchFailed() {
+        cb(new Error("AddonRepository search failed"), null);
+      },
+    });
+  },
+
+  /**
+   * Update the user disabled flag for an add-on.
+   *
+   * The supplied callback will ba called when the operation is
+   * complete. If the new flag matches the existing or if the add-on
+   * isn't currently active, the function will fire the callback
+   * immediately. Else, the callback is invoked when the AddonManager
+   * reports the change has taken effect or has been registered.
+   *
+   * The callback receives as arguments:
+   *
+   *   (Error) Encountered error during operation or null on success.
+   *   (Addon) The add-on instance being operated on.
+   *
+   * @param addon
+   *        (Addon) Add-on instance to operate on.
+   * @param value
+   *        (bool) New value for add-on's userDisabled property.
+   * @param cb
+   *        (function) Callback to be invoked on completion.
+   */
+  updateUserDisabled: function updateUserDisabled(addon, value, cb) {
+    if (addon.userDisabled == value) {
+      cb(null, addon);
+      return;
+    }
+
+    let listener = {
+      onEnabling: function onEnabling(wrapper, needsRestart) {
+        this._log.debug("onEnabling: " + wrapper.id);
+        if (wrapper.id != addon.id) {
+          return;
+        }
+
+        // We ignore the restartless case because we'll get onEnabled shortly.
+        if (!needsRestart) {
+          return;
+        }
+
+        AddonManager.removeAddonListener(listener);
+        cb(null, wrapper);
+      }.bind(this),
+
+      onEnabled: function onEnabled(wrapper) {
+        this._log.debug("onEnabled: " + wrapper.id);
+        if (wrapper.id != addon.id) {
+          return;
+        }
+
+        AddonManager.removeAddonListener(listener);
+        cb(null, wrapper);
+      }.bind(this),
+
+      onDisabling: function onDisabling(wrapper, needsRestart) {
+        this._log.debug("onDisabling: " + wrapper.id);
+        if (wrapper.id != addon.id) {
+          return;
+        }
+
+        if (!needsRestart) {
+          return;
+        }
+
+        AddonManager.removeAddonListener(listener);
+        cb(null, wrapper);
+      }.bind(this),
+
+      onDisabled: function onDisabled(wrapper) {
+        this._log.debug("onDisabled: " + wrapper.id);
+        if (wrapper.id != addon.id) {
+          return;
+        }
+
+        AddonManager.removeAddonListener(listener);
+        cb(null, wrapper);
+      }.bind(this),
+
+      onOperationCancelled: function onOperationCancelled(wrapper) {
+        this._log.debug("onOperationCancelled: " + wrapper.id);
+        if (wrapper.id != addon.id) {
+          return;
+        }
+
+        AddonManager.removeAddonListener(listener);
+        cb(new Error("Operation cancelled"), wrapper);
+      }.bind(this)
+    };
+
+    // The add-on listeners are only fired if the add-on is active. If not, the
+    // change is silently updated and made active when/if the add-on is active.
+
+    if (!addon.appDisabled) {
+      AddonManager.addAddonListener(listener);
+    }
+
+    this._log.info("Updating userDisabled flag: " + addon.id + " -> " + value);
+    addon.userDisabled = !!value;
+
+    if (!addon.appDisabled) {
+      cb(null, addon);
+      return;
+    }
+    // Else the listener will handle invoking the callback.
+  },
+
+};
+
+XPCOMUtils.defineLazyGetter(this, "AddonUtils", function() {
+  return new AddonUtilsInternal();
+});
--- a/services/sync/modules/engines/addons.js
+++ b/services/sync/modules/engines/addons.js
@@ -30,16 +30,17 @@
  *  - services.sync.addons.trustedSourceHostnames
  *
  * See the documentation in services-sync.js for the behavior of these prefs.
  */
 "use strict";
 
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
+Cu.import("resource://services-sync/addonutils.js");
 Cu.import("resource://services-sync/addonsreconciler.js");
 Cu.import("resource://services-sync/engines.js");
 Cu.import("resource://services-sync/record.js");
 Cu.import("resource://services-sync/util.js");
 Cu.import("resource://services-sync/constants.js");
 Cu.import("resource://services-common/async.js");
 Cu.import("resource://services-common/preferences.js");
 
@@ -286,20 +287,21 @@ AddonsStore.prototype = {
   },
 
 
   /**
    * Provides core Store API to create/install an add-on from a record.
    */
   create: function create(record) {
     let cb = Async.makeSpinningCallback();
-    this.installAddons([{
-      id:       record.addonID,
-      syncGUID: record.id,
-      enabled:  record.enabled
+    AddonUtils.installAddons([{
+      id:               record.addonID,
+      syncGUID:         record.id,
+      enabled:          record.enabled,
+      requireSecureURI: !Svc.Prefs.get("addons.ignoreRepositoryChecking", false),
     }], cb);
 
     // This will throw if there was an error. This will get caught by the sync
     // engine and the record will try to be applied later.
     let results = cb.wait();
 
     let addon;
     for each (let a in results.addons) {
@@ -327,17 +329,17 @@ AddonsStore.prototype = {
       // We don't throw because if the add-on could not be found then we assume
       // it has already been uninstalled and there is nothing for this function
       // to do.
       return;
     }
 
     this._log.info("Uninstalling add-on: " + addon.id);
     let cb = Async.makeSpinningCallback();
-    this.uninstallAddon(addon, cb);
+    AddonUtils.uninstallAddon(addon, cb);
     cb.wait();
   },
 
   /**
    * Provides core Store API to update an add-on from a record.
    */
   update: function update(record) {
     let addon = this.getAddonByID(record.addonID);
@@ -603,195 +605,16 @@ AddonsStore.prototype = {
       this._log.debug("Source hostname not trusted: " + uri.host);
       return false;
     }
 
     return true;
   },
 
   /**
-   * Obtain an AddonInstall object from an AddonSearchResult instance.
-   *
-   * The callback will be invoked with the result of the operation. The
-   * callback receives 2 arguments, error and result. Error will be falsy
-   * on success or some kind of error value otherwise. The result argument
-   * will be an AddonInstall on success or null on failure. It is possible
-   * for the error to be falsy but result to be null. This could happen if
-   * an install was not found.
-   *
-   * @param addon
-   *        AddonSearchResult to obtain install from.
-   * @param cb
-   *        Function to be called with result of operation.
-   */
-  getInstallFromSearchResult: function getInstallFromSearchResult(addon, cb) {
-    // We should theoretically be able to obtain (and use) addon.install if
-    // it is available. However, the addon.sourceURI rewriting won't be
-    // reflected in the AddonInstall, so we can't use it. If we ever get rid
-    // of sourceURI rewriting, we can avoid having to reconstruct the
-    // AddonInstall.
-    this._log.debug("Obtaining install for " + addon.id);
-
-    // Verify that the source URI uses TLS. We don't allow installs from
-    // insecure sources for security reasons. The Addon Manager ensures that
-    // cert validation, etc is performed.
-    if (!Svc.Prefs.get("addons.ignoreRepositoryChecking", false)) {
-      let scheme = addon.sourceURI.scheme;
-      if (scheme != "https") {
-        cb(new Error("Insecure source URI scheme: " + scheme), addon.install);
-      }
-    }
-
-    AddonManager.getInstallForURL(
-      addon.sourceURI.spec,
-      function handleInstall(install) {
-        cb(null, install);
-      },
-      "application/x-xpinstall",
-      undefined,
-      addon.name,
-      addon.iconURL,
-      addon.version
-    );
-  },
-
-  /**
-   * Installs an add-on from an AddonSearchResult instance.
-   *
-   * The options argument defines extra options to control the install.
-   * Recognized keys in this map are:
-   *
-   *   syncGUID - Sync GUID to use for the new add-on.
-   *   enabled - Boolean indicating whether the add-on should be enabled upon
-   *             install.
-   *
-   * When complete it calls a callback with 2 arguments, error and result.
-   *
-   * If error is falsy, result is an object. If error is truthy, result is
-   * null.
-   *
-   * The result object has the following keys:
-   *
-   *   id      ID of add-on that was installed.
-   *   install AddonInstall that was installed.
-   *   addon   Addon that was installed.
-   *
-   * @param addon
-   *        AddonSearchResult to install add-on from.
-   * @param options
-   *        Object with additional metadata describing how to install add-on.
-   * @param cb
-   *        Function to be invoked with result of operation.
-   */
-  installAddonFromSearchResult:
-    function installAddonFromSearchResult(addon, options, cb) {
-    this._log.info("Trying to install add-on from search result: " + addon.id);
-
-    this.getInstallFromSearchResult(addon, function(error, install) {
-      if (error) {
-        cb(error, null);
-        return;
-      }
-
-      if (!install) {
-        cb(new Error("AddonInstall not available: " + addon.id), null);
-        return;
-      }
-
-      try {
-        this._log.info("Installing " + addon.id);
-        let log = this._log;
-
-        let listener = {
-          onInstallStarted: function(install) {
-            if (!options) {
-              return;
-            }
-
-            if (options.syncGUID) {
-              log.info("Setting syncGUID of " + install.name  +": " +
-                       options.syncGUID);
-              install.addon.syncGUID = options.syncGUID;
-            }
-
-            // We only need to change userDisabled if it is disabled because
-            // enabled is the default.
-            if ("enabled" in options && !options.enabled) {
-              log.info("Marking add-on as disabled for install: " +
-                       install.name);
-              install.addon.userDisabled = true;
-            }
-          },
-          onInstallEnded: function(install, addon) {
-            install.removeListener(listener);
-
-            cb(null, {id: addon.id, install: install, addon: addon});
-          },
-          onInstallFailed: function(install) {
-            install.removeListener(listener);
-
-            cb(new Error("Install failed: " + install.error), null);
-          },
-          onDownloadFailed: function(install) {
-            install.removeListener(listener);
-
-            cb(new Error("Download failed: " + install.error), null);
-          }
-        };
-        install.addListener(listener);
-        install.install();
-      }
-      catch (ex) {
-        this._log.error("Error installing add-on: " + Utils.exceptionstr(ex));
-        cb(ex, null);
-      }
-    }.bind(this));
-  },
-
-  /**
-   * Uninstalls the Addon instance and invoke a callback when it is done.
-   *
-   * @param addon
-   *        Addon instance to uninstall.
-   * @param callback
-   *        Function to be invoked when uninstall has finished. It receives a
-   *        truthy value signifying error and the add-on which was uninstalled.
-   */
-  uninstallAddon: function uninstallAddon(addon, callback) {
-    let listener = {
-      onUninstalling: function(uninstalling, needsRestart) {
-        if (addon.id != uninstalling.id) {
-          return;
-        }
-
-        // We assume restartless add-ons will send the onUninstalled event
-        // soon.
-        if (!needsRestart) {
-          return;
-        }
-
-        // For non-restartless add-ons, we issue the callback on uninstalling
-        // because we will likely never see the uninstalled event.
-        AddonManager.removeAddonListener(listener);
-        callback(null, addon);
-      },
-      onUninstalled: function(uninstalled) {
-        if (addon.id != uninstalled.id) {
-          return;
-        }
-
-        AddonManager.removeAddonListener(listener);
-        callback(null, addon);
-      }
-    };
-    AddonManager.addAddonListener(listener);
-    addon.uninstall();
-  },
-
-  /**
    * Update the userDisabled flag on an add-on.
    *
    * This will enable or disable an add-on and call the supplied callback when
    * the action is complete. If no action is needed, the callback gets called
    * immediately.
    *
    * @param addon
    *        Addon instance to manipulate.
@@ -811,242 +634,18 @@ AddonsStore.prototype = {
     // A pref allows changes to the enabled flag to be ignored.
     if (Svc.Prefs.get("addons.ignoreUserEnabledChanges", false)) {
       this._log.info("Ignoring enabled state change due to preference: " +
                      addon.id);
       callback(null, addon);
       return;
     }
 
-    let listener = {
-      onEnabling: function onEnabling(wrapper, needsRestart) {
-        this._log.debug("onEnabling: " + wrapper.id);
-        if (wrapper.id != addon.id) {
-          return;
-        }
-
-        // We ignore the restartless case because we'll get onEnabled shortly.
-        if (!needsRestart) {
-          return;
-        }
-
-        AddonManager.removeAddonListener(listener);
-        callback(null, wrapper);
-      }.bind(this),
-
-      onEnabled: function onEnabled(wrapper) {
-        this._log.debug("onEnabled: " + wrapper.id);
-        if (wrapper.id != addon.id) {
-          return;
-        }
-
-        AddonManager.removeAddonListener(listener);
-        callback(null, wrapper);
-      }.bind(this),
-
-      onDisabling: function onDisabling(wrapper, needsRestart) {
-        this._log.debug("onDisabling: " + wrapper.id);
-        if (wrapper.id != addon.id) {
-          return;
-        }
-
-        if (!needsRestart) {
-          return;
-        }
-
-        AddonManager.removeAddonListener(listener);
-        callback(null, wrapper);
-      }.bind(this),
-
-      onDisabled: function onDisabled(wrapper) {
-        this._log.debug("onDisabled: " + wrapper.id);
-        if (wrapper.id != addon.id) {
-          return;
-        }
-
-        AddonManager.removeAddonListener(listener);
-        callback(null, wrapper);
-      }.bind(this),
-
-      onOperationCancelled: function onOperationCancelled(wrapper) {
-        this._log.debug("onOperationCancelled: " + wrapper.id);
-        if (wrapper.id != addon.id) {
-          return;
-        }
-
-        AddonManager.removeAddonListener(listener);
-        callback(new Error("Operation cancelled"), wrapper);
-      }.bind(this)
-    };
-
-    // The add-on listeners are only fired if the add-on is active. If not, the
-    // change is silently updated and made active when/if the add-on is active.
-
-    if (!addon.appDisabled) {
-      AddonManager.addAddonListener(listener);
-    }
-
-    this._log.info("Updating userDisabled flag: " + addon.id + " -> " + value);
-    addon.userDisabled = !!value;
-
-    if (!addon.appDisabled) {
-      callback(null, addon);
-      return;
-    }
-    // Else the listener will handle invoking the callback.
+    AddonUtils.updateUserDisabled(addon, value, callback);
   },
-
-  /**
-   * Installs multiple add-ons specified by metadata.
-   *
-   * The first argument is an array of objects. Each object must have the
-   * following keys:
-   *
-   *   id - public ID of the add-on to install.
-   *   syncGUID - syncGUID for new add-on.
-   *   enabled - boolean indicating whether the add-on should be enabled.
-   *
-   * The callback will be called when activity on all add-ons is complete. The
-   * callback receives 2 arguments, error and result.
-   *
-   * If error is truthy, it contains a string describing the overall error.
-   *
-   * The 2nd argument to the callback is always an object with details on the
-   * overall execution state. It contains the following keys:
-   *
-   *   installedIDs  Array of add-on IDs that were installed.
-   *   installs      Array of AddonInstall instances that were installed.
-   *   addons        Array of Addon instances that were installed.
-   *   errors        Array of errors encountered. Only has elements if error is
-   *                 truthy.
-   *
-   * @param installs
-   *        Array of objects describing add-ons to install.
-   * @param cb
-   *        Function to be called when all actions are complete.
-   */
-  installAddons: function installAddons(installs, cb) {
-    if (!cb) {
-      throw new Error("Invalid argument: cb is not defined.");
-    }
-
-    let ids = [];
-    for each (let addon in installs) {
-      ids.push(addon.id);
-    }
-
-    AddonRepository.getAddonsByIDs(ids, {
-      searchSucceeded: function searchSucceeded(addons, addonsLength, total) {
-        this._log.info("Found " + addonsLength + "/" + ids.length +
-                       " add-ons during repository search.");
-
-        let ourResult = {
-          installedIDs: [],
-          installs:     [],
-          addons:       [],
-          errors:       []
-        };
-
-        if (!addonsLength) {
-          cb(null, ourResult);
-          return;
-        }
-
-        let expectedInstallCount = 0;
-        let finishedCount = 0;
-        let installCallback = function installCallback(error, result) {
-          finishedCount++;
-
-          if (error) {
-            ourResult.errors.push(error);
-          } else {
-            ourResult.installedIDs.push(result.id);
-            ourResult.installs.push(result.install);
-            ourResult.addons.push(result.addon);
-          }
-
-          if (finishedCount >= expectedInstallCount) {
-            if (ourResult.errors.length > 0) {
-              cb(new Error("1 or more add-ons failed to install"), ourResult);
-            } else {
-              cb(null, ourResult);
-            }
-          }
-        }.bind(this);
-
-        let toInstall = [];
-
-        // Rewrite the "src" query string parameter of the source URI to note
-        // that the add-on was installed by Sync and not something else so
-        // server-side metrics aren't skewed (bug 708134). The server should
-        // ideally send proper URLs, but this solution was deemed too
-        // complicated at the time the functionality was implemented.
-        for each (let addon in addons) {
-          // sourceURI presence isn't enforced by AddonRepository. So, we skip
-          // add-ons without a sourceURI.
-          if (!addon.sourceURI) {
-            this._log.info("Skipping install of add-on because missing " +
-                           "sourceURI: " + addon.id);
-            continue;
-          }
-
-          toInstall.push(addon);
-
-          // We should always be able to QI the nsIURI to nsIURL. If not, we
-          // still try to install the add-on, but we don't rewrite the URL,
-          // potentially skewing metrics.
-          try {
-            addon.sourceURI.QueryInterface(Ci.nsIURL);
-          } catch (ex) {
-            this._log.warn("Unable to QI sourceURI to nsIURL: " +
-                           addon.sourceURI.spec);
-            continue;
-          }
-
-          let params = addon.sourceURI.query.split("&").map(
-            function rewrite(param) {
-
-            if (param.indexOf("src=") == 0) {
-              return "src=sync";
-            } else {
-              return param;
-            }
-          });
-
-          addon.sourceURI.query = params.join("&");
-        }
-
-        expectedInstallCount = toInstall.length;
-
-        if (!expectedInstallCount) {
-          cb(null, ourResult);
-          return;
-        }
-
-        // Start all the installs asynchronously. They will report back to us
-        // as they finish, eventually triggering the global callback.
-        for each (let addon in toInstall) {
-          let options = {};
-          for each (let install in installs) {
-            if (install.id == addon.id) {
-              options = install;
-              break;
-            }
-          }
-
-          this.installAddonFromSearchResult(addon, options, installCallback);
-        }
-
-      }.bind(this),
-
-      searchFailed: function searchFailed() {
-        cb(new Error("AddonRepository search failed"), null);
-      }.bind(this)
-    });
-  }
 };
 
 /**
  * The add-ons tracker keeps track of real-time changes to add-ons.
  *
  * It hooks up to the reconciler and receives notifications directly from it.
  */
 function AddonsTracker(name) {
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/unit/test_addon_utils.js
@@ -0,0 +1,161 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://services-sync/addonutils.js");
+Cu.import("resource://services-common/preferences.js");
+
+const HTTP_PORT = 8888;
+const SERVER_ADDRESS = "http://127.0.0.1:8888";
+
+let prefs = new Preferences();
+
+prefs.set("extensions.getAddons.get.url",
+          SERVER_ADDRESS + "/search/guid:%IDS%");
+
+loadAddonTestFunctions();
+startupManager();
+
+function createAndStartHTTPServer(port=HTTP_PORT) {
+  try {
+    let server = new HttpServer();
+
+    let bootstrap1XPI = ExtensionsTestPath("/addons/test_bootstrap1_1.xpi");
+
+    server.registerFile("/search/guid:missing-sourceuri%40tests.mozilla.org",
+                        do_get_file("missing-sourceuri.xml"));
+
+    server.registerFile("/search/guid:rewrite%40tests.mozilla.org",
+                        do_get_file("rewrite-search.xml"));
+
+    server.start(port);
+
+    return server;
+  } catch (ex) {
+    _("Got exception starting HTTP server on port " + port);
+    _("Error: " + Utils.exceptionStr(ex));
+    do_throw(ex);
+  }
+}
+
+function run_test() {
+  initTestLogging("Trace");
+
+  run_next_test();
+}
+
+add_test(function test_handle_empty_source_uri() {
+  _("Ensure that search results without a sourceURI are properly ignored.");
+
+  let server = createAndStartHTTPServer();
+
+  const ID = "missing-sourceuri@tests.mozilla.org";
+
+  let cb = Async.makeSpinningCallback();
+  AddonUtils.installAddons([{id: ID, requireSecureURI: false}], cb);
+  let result = cb.wait();
+
+  do_check_true("installedIDs" in result);
+  do_check_eq(0, result.installedIDs.length);
+
+  server.stop(run_next_test);
+});
+
+add_test(function test_ignore_untrusted_source_uris() {
+  _("Ensures that source URIs from insecure schemes are rejected.");
+
+  let ioService = Cc["@mozilla.org/network/io-service;1"]
+                  .getService(Ci.nsIIOService);
+
+  const bad = ["http://example.com/foo.xpi",
+               "ftp://example.com/foo.xpi",
+               "silly://example.com/foo.xpi"];
+
+  const good = ["https://example.com/foo.xpi"];
+
+  for (let s of bad) {
+    let sourceURI = ioService.newURI(s, null, null);
+    let addon = {sourceURI: sourceURI, name: "bad", id: "bad"};
+
+    try {
+      let cb = Async.makeSpinningCallback();
+      AddonUtils.getInstallFromSearchResult(addon, cb, true);
+      cb.wait();
+    } catch (ex) {
+      do_check_neq(null, ex);
+      do_check_eq(0, ex.message.indexOf("Insecure source URI"));
+      continue;
+    }
+
+    // We should never get here if an exception is thrown.
+    do_check_true(false);
+  }
+
+  let count = 0;
+  for (let s of good) {
+    let sourceURI = ioService.newURI(s, null, null);
+    let addon = {sourceURI: sourceURI, name: "good", id: "good"};
+
+    // Despite what you might think, we don't get an error in the callback.
+    // The install won't work because the underlying Addon instance wasn't
+    // proper. But, that just results in an AddonInstall that is missing
+    // certain values. We really just care that the callback is being invoked
+    // anyway.
+    let callback = function onInstall(error, install) {
+      do_check_null(error);
+      do_check_neq(null, install);
+      do_check_eq(sourceURI.spec, install.sourceURI.spec);
+
+      count += 1;
+
+      if (count >= good.length) {
+        run_next_test();
+      }
+    };
+
+    AddonUtils.getInstallFromSearchResult(addon, callback, true);
+  }
+});
+
+add_test(function test_source_uri_rewrite() {
+  _("Ensure that a 'src=api' query string is rewritten to 'src=sync'");
+
+  // This tests for conformance with bug 708134 so server-side metrics aren't
+  // skewed.
+
+  Svc.Prefs.set("addons.ignoreRepositoryChecking", true);
+
+  // We resort to monkeypatching because of the API design.
+  let oldFunction = AddonUtils.__proto__.installAddonFromSearchResult;
+
+  let installCalled = false;
+  AddonUtils.__proto__.installAddonFromSearchResult =
+    function testInstallAddon(addon, metadata, cb) {
+
+    do_check_eq(SERVER_ADDRESS + "/require.xpi?src=sync",
+                addon.sourceURI.spec);
+
+    installCalled = true;
+
+    AddonUtils.getInstallFromSearchResult(addon, function (error, install) {
+      do_check_null(error);
+      do_check_eq(SERVER_ADDRESS + "/require.xpi?src=sync",
+                  install.sourceURI.spec);
+
+      cb(null, {id: addon.id, addon: addon, install: install});
+    }, false);
+  };
+
+  let server = createAndStartHTTPServer();
+
+  let installCallback = Async.makeSpinningCallback();
+  AddonUtils.installAddons([{id: "rewrite@tests.mozilla.org"}], installCallback);
+
+  installCallback.wait();
+  do_check_true(installCalled);
+  AddonUtils.__proto__.installAddonFromSearchResult = oldFunction;
+
+  Svc.Prefs.reset("addons.ignoreRepositoryChecking");
+  server.stop(run_next_test);
+});
--- a/services/sync/tests/unit/test_addons_store.js
+++ b/services/sync/tests/unit/test_addons_store.js
@@ -1,15 +1,16 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
+Cu.import("resource://services-common/preferences.js");
+Cu.import("resource://services-sync/addonutils.js");
 Cu.import("resource://services-sync/engines/addons.js");
-Cu.import("resource://services-common/preferences.js");
 
 const HTTP_PORT = 8888;
 
 let prefs = new Preferences();
 
 Svc.Prefs.set("addons.ignoreRepositoryChecking", true);
 prefs.set("extensions.getAddons.get.url", "http://localhost:8888/search/guid:%IDS%");
 loadAddonTestFunctions();
@@ -48,22 +49,16 @@ function createAndStartHTTPServer(port) 
 
     server.registerFile("/search/guid:bootstrap1%40tests.mozilla.org",
                         do_get_file("bootstrap1-search.xml"));
     server.registerFile("/bootstrap1.xpi", do_get_file(bootstrap1XPI));
 
     server.registerFile("/search/guid:missing-xpi%40tests.mozilla.org",
                         do_get_file("missing-xpi-search.xml"));
 
-    server.registerFile("/search/guid:rewrite%40tests.mozilla.org",
-                        do_get_file("rewrite-search.xml"));
-
-    server.registerFile("/search/guid:missing-sourceuri%40tests.mozilla.org",
-                        do_get_file("missing-sourceuri.xml"));
-
     server.start(port);
 
     return server;
   } catch (ex) {
     _("Got exception starting HTTP server on port " + port);
     _("Error: " + Utils.exceptionStr(ex));
     do_throw(ex);
   }
@@ -404,142 +399,23 @@ add_test(function test_ignore_hotfixes()
   uninstallAddon(addon);
 
   Svc.Prefs.reset("addons.ignoreRepositoryChecking");
   prefs.reset("hotfix.id");
 
   run_next_test();
 });
 
-add_test(function test_ignore_untrusted_source_uris() {
-  _("Ensures that source URIs from insecure schemes are rejected.");
-
-  Svc.Prefs.set("addons.ignoreRepositoryChecking", false);
-
-  let ioService = Cc["@mozilla.org/network/io-service;1"]
-                  .getService(Ci.nsIIOService);
-
-  const bad = ["http://example.com/foo.xpi",
-               "ftp://example.com/foo.xpi",
-               "silly://example.com/foo.xpi"];
-
-  const good = ["https://example.com/foo.xpi"];
-
-  for each (let s in bad) {
-    let sourceURI = ioService.newURI(s, null, null);
-    let addon = {sourceURI: sourceURI, name: "foo"};
-
-    try {
-      let cb = Async.makeSpinningCallback();
-      store.getInstallFromSearchResult(addon, cb);
-      cb.wait();
-    } catch (ex) {
-      do_check_neq(null, ex);
-      do_check_eq(0, ex.message.indexOf("Insecure source URI"));
-      continue;
-    }
-
-    // We should never get here if an exception is thrown.
-    do_check_true(false);
-  }
-
-  let count = 0;
-  for each (let s in good) {
-    let sourceURI = ioService.newURI(s, null, null);
-    let addon = {sourceURI: sourceURI, name: "foo", id: "foo"};
-
-    // Despite what you might think, we don't get an error in the callback.
-    // The install won't work because the underlying Addon instance wasn't
-    // proper. But, that just results in an AddonInstall that is missing
-    // certain values. We really just care that the callback is being invoked
-    // anyway.
-    let callback = function(error, install) {
-      do_check_eq(null, error);
-      do_check_neq(null, install);
-      do_check_eq(sourceURI.spec, install.sourceURI.spec);
-
-      count += 1;
-
-      if (count >= good.length) {
-        run_next_test();
-      }
-    };
-
-    store.getInstallFromSearchResult(addon, callback);
-  }
-});
-
 add_test(function test_wipe() {
   _("Ensures that wiping causes add-ons to be uninstalled.");
 
   let addon1 = installAddon("test_bootstrap1_1");
 
   Svc.Prefs.set("addons.ignoreRepositoryChecking", true);
   store.wipe();
 
   let addon = getAddonFromAddonManagerByID(addon1.id);
   do_check_eq(null, addon);
 
   Svc.Prefs.reset("addons.ignoreRepositoryChecking");
 
   run_next_test();
 });
-
-add_test(function test_source_uri_rewrite() {
-  _("Ensure that a 'src=api' query string is rewritten to 'src=sync'");
-
-  // This tests for conformance with bug 708134 so server-side metrics aren't
-  // skewed.
-
-  Svc.Prefs.set("addons.ignoreRepositoryChecking", true);
-
-  // We resort to monkeypatching because of the API design.
-  let oldFunction = store.__proto__.installAddonFromSearchResult;
-
-  let installCalled = false;
-  store.__proto__.installAddonFromSearchResult =
-    function testInstallAddon(addon, metadata, cb) {
-
-    do_check_eq("http://127.0.0.1:8888/require.xpi?src=sync",
-                addon.sourceURI.spec);
-
-    installCalled = true;
-
-    store.getInstallFromSearchResult(addon, function (error, install) {
-      do_check_eq("http://127.0.0.1:8888/require.xpi?src=sync",
-                  install.sourceURI.spec);
-
-      cb(null, {id: addon.id, addon: addon, install: install});
-    });
-  };
-
-  let server = createAndStartHTTPServer(HTTP_PORT);
-
-  let installCallback = Async.makeSpinningCallback();
-  store.installAddons([{id: "rewrite@tests.mozilla.org"}], installCallback);
-
-  installCallback.wait();
-  do_check_true(installCalled);
-  store.__proto__.installAddonFromSearchResult = oldFunction;
-
-  Svc.Prefs.reset("addons.ignoreRepositoryChecking");
-  server.stop(run_next_test);
-});
-
-add_test(function test_handle_empty_source_uri() {
-  _("Ensure that search results without a sourceURI are properly ignored.");
-
-  Svc.Prefs.set("addons.ignoreRepositoryChecking", true);
-
-  let server = createAndStartHTTPServer(HTTP_PORT);
-
-  const ID = "missing-sourceuri@tests.mozilla.org";
-
-  let cb = Async.makeSpinningCallback();
-  store.installAddons([{id: ID}], cb);
-  let result = cb.wait();
-
-  do_check_true("installedIDs" in result);
-  do_check_eq(0, result.installedIDs.length);
-
-  Svc.Prefs.reset("addons.ignoreRepositoryChecking");
-  server.stop(run_next_test);
-});
--- a/services/sync/tests/unit/test_load_modules.js
+++ b/services/sync/tests/unit/test_load_modules.js
@@ -1,32 +1,33 @@
 const modules = [
-                 "addonsreconciler.js",
-                 "constants.js",
-                 "engines/addons.js",
-                 "engines/bookmarks.js",
-                 "engines/clients.js",
-                 "engines/forms.js",
-                 "engines/history.js",
-                 "engines/passwords.js",
-                 "engines/prefs.js",
-                 "engines/tabs.js",
-                 "engines.js",
-                 "identity.js",
-                 "jpakeclient.js",
-                 "keys.js",
-                 "main.js",
-                 "notifications.js",
-                 "policies.js",
-                 "record.js",
-                 "resource.js",
-                 "rest.js",
-                 "service.js",
-                 "status.js",
-                 "util.js",
+  "addonutils.js",
+  "addonsreconciler.js",
+  "constants.js",
+  "engines/addons.js",
+  "engines/bookmarks.js",
+  "engines/clients.js",
+  "engines/forms.js",
+  "engines/history.js",
+  "engines/passwords.js",
+  "engines/prefs.js",
+  "engines/tabs.js",
+  "engines.js",
+  "identity.js",
+  "jpakeclient.js",
+  "keys.js",
+  "main.js",
+  "notifications.js",
+  "policies.js",
+  "record.js",
+  "resource.js",
+  "rest.js",
+  "service.js",
+  "status.js",
+  "util.js",
 ];
 
 function run_test() {
   for each (let m in modules) {
     _("Attempting to load resource://services-sync/" + m);
     Cu.import("resource://services-sync/" + m, {});
   }
 }
--- a/services/sync/tests/unit/xpcshell.ini
+++ b/services/sync/tests/unit/xpcshell.ini
@@ -20,16 +20,17 @@ tail =
 [test_utils_getIcon.js]
 [test_utils_lazyStrings.js]
 [test_utils_lock.js]
 [test_utils_makeGUID.js]
 [test_utils_notify.js]
 [test_utils_passphrase.js]
 
 # We have a number of other libraries that are pretty much standalone.
+[test_addon_utils.js]
 [test_httpd_sync_server.js]
 [test_jpakeclient.js]
 # Bug 618233: this test produces random failures on Windows 7.
 # Bug 676978: test hangs on Android (see also testing/xpcshell/xpcshell.ini)
 skip-if = os == "win" || os == "android"
 
 # HTTP layers.
 [test_resource.js]