Bug 1185460 - Implements AddonManager.installAddonFromSources to install addon from sources permanently. r=mossop draft
authorAlexandre Poirot <poirot.alex@gmail.com>
Tue, 27 Sep 2016 02:36:23 -0700
changeset 420141 39d18f8946b9adcbd5a199914ccbd7a1359c6d9c
parent 419914 7c576fe3279d87543f0a03b844eba7bc215e17f1
child 532732 dc11bec4e6241ce10f5f1aafe2b4cd6a6c5f4351
push id31107
push userbmo:poirot.alex@gmail.com
push dateMon, 03 Oct 2016 14:06:37 +0000
reviewersmossop
bugs1185460
milestone52.0a1
Bug 1185460 - Implements AddonManager.installAddonFromSources to install addon from sources permanently. r=mossop MozReview-Commit-ID: LYEFnVUPhtX
toolkit/mozapps/extensions/AddonManager.jsm
toolkit/mozapps/extensions/internal/XPIProvider.jsm
toolkit/mozapps/extensions/test/xpcshell/test_install_from_sources.js
toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
--- a/toolkit/mozapps/extensions/AddonManager.jsm
+++ b/toolkit/mozapps/extensions/AddonManager.jsm
@@ -2326,16 +2326,29 @@ var AddonManagerInternal = {
     if (!(aFile instanceof Ci.nsIFile))
       throw Components.Exception("aFile must be a nsIFile",
                                  Cr.NS_ERROR_INVALID_ARG);
 
     return AddonManagerInternal._getProviderByName("XPIProvider")
                                .installTemporaryAddon(aFile);
   },
 
+  installAddonFromSources: function(aFile) {
+    if (!gStarted)
+      throw Components.Exception("AddonManager is not initialized",
+                                 Cr.NS_ERROR_NOT_INITIALIZED);
+
+    if (!(aFile instanceof Ci.nsIFile))
+      throw Components.Exception("aFile must be a nsIFile",
+                                 Cr.NS_ERROR_INVALID_ARG);
+
+    return AddonManagerInternal._getProviderByName("XPIProvider")
+                               .installAddonFromSources(aFile);
+  },
+
   /**
    * Returns an Addon corresponding to an instance ID.
    * @param aInstanceID
    *        An Addon Instance ID symbol
    * @return {Promise}
    * @resolves The found Addon or null if no such add-on exists.
    * @rejects  Never
    * @throws if the aInstanceID argument is not specified
@@ -3502,16 +3515,20 @@ this.AddonManager = {
                                                   aInstallingPrincipal,
                                                   aInstalls);
   },
 
   installTemporaryAddon: function(aDirectory) {
     return AddonManagerInternal.installTemporaryAddon(aDirectory);
   },
 
+  installAddonFromSources: function(aDirectory) {
+    return AddonManagerInternal.installAddonFromSources(aDirectory);
+  },
+
   getAddonByInstanceID: function(aInstanceID) {
     return AddonManagerInternal.getAddonByInstanceID(aInstanceID);
   },
 
   addManagerListener: function(aListener) {
     AddonManagerInternal.addManagerListener(aListener);
   },
 
--- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
@@ -163,16 +163,17 @@ const KEY_APP_FEATURES                = 
 const KEY_APP_PROFILE                 = "app-profile";
 const KEY_APP_SYSTEM_ADDONS           = "app-system-addons";
 const KEY_APP_SYSTEM_DEFAULTS         = "app-system-defaults";
 const KEY_APP_GLOBAL                  = "app-global";
 const KEY_APP_SYSTEM_LOCAL            = "app-system-local";
 const KEY_APP_SYSTEM_SHARE            = "app-system-share";
 const KEY_APP_SYSTEM_USER             = "app-system-user";
 const KEY_APP_TEMPORARY               = "app-temporary";
+const KEY_APP_SOURCE                  = "app-source";
 
 const NOTIFICATION_FLUSH_PERMISSIONS  = "flush-pending-permissions";
 const XPI_PERMISSION                  = "install";
 
 const RDFURI_INSTALL_MANIFEST_ROOT    = "urn:mozilla:install-manifest";
 const PREFIX_NS_EM                    = "http://www.mozilla.org/2004/em-rdf#";
 
 const TOOLKIT_ID                      = "toolkit@mozilla.org";
@@ -1402,17 +1403,17 @@ var loadManifestFromDir = Task.async(fun
   }
 
   let uri = Services.io.newFileURI(file).QueryInterface(Ci.nsIFileURL);
 
   let addon;
   if (file.leafName == FILE_WEB_MANIFEST) {
     addon = yield loadManifestFromWebManifest(uri);
     if (!addon.id) {
-      if (aInstallLocation == TemporaryInstallLocation) {
+      if (aInstallLocation == TemporaryInstallLocation || aInstallLocation == SourceInstallLocation) {
         addon.id = generateTemporaryInstallID(aDir);
       } else {
         addon.id = aDir.leafName;
       }
     }
   } else {
     addon = yield loadFromRDF(uri);
   }
@@ -2721,16 +2722,19 @@ this.XPIProvider = {
                                           AddonManager.SCOPE_ALL);
 
       // These must be in order of priority, highest to lowest,
       // for processFileChanges etc. to work
 
       XPIProvider.installLocations.push(TemporaryInstallLocation);
       XPIProvider.installLocationsByName[TemporaryInstallLocation.name] =
         TemporaryInstallLocation;
+      XPIProvider.installLocations.push(SourceInstallLocation);
+      XPIProvider.installLocationsByName[SourceInstallLocation.name] =
+        SourceInstallLocation;
 
       // The profile location is always enabled
       addDirectoryInstallLocation(KEY_APP_PROFILE, KEY_PROFILEDIR,
                                   [DIR_EXTENSIONS],
                                   AddonManager.SCOPE_PROFILE, false);
 
       addSystemAddonInstallLocation(KEY_APP_SYSTEM_ADDONS, KEY_PROFILEDIR,
                                     [DIR_SYSTEM_ADDONS],
@@ -4009,25 +4013,43 @@ this.XPIProvider = {
   /**
    * Temporarily installs add-on from a local XPI file or directory.
    * As this is intended for development, the signature is not checked and
    * the add-on does not persist on application restart.
    *
    * @param aFile
    *        An nsIFile for the unpacked add-on directory or XPI file.
    *
+   * @return See installAddonFromSource return value.
+   */
+  installTemporaryAddon: function(aFile) {
+    return this.installAddonFromSources(aFile, TemporaryInstallLocation);
+  },
+
+  /**
+   * Installs add-on from a local XPI file or directory.
+   *
+   * @param aFile
+   *        An nsIFile for the unpacked add-on directory or XPI file.
+   * @param aInstallLocation
+   *        Define a custom install location object, other than default one,
+   *        "SourceInstallLocation" which maintains a list of local addons over
+   *        firefox restarts.
+   *
    * @return a Promise that resolves to an Addon object on success, or rejects
    *         if the add-on is not a valid restartless add-on or if the
-   *         same ID is already temporarily installed
-   */
-  installTemporaryAddon: Task.async(function*(aFile) {
+   *         same ID is already installed.
+   */
+  installAddonFromSources: Task.async(function*(aFile, aInstallLocation = SourceInstallLocation) {
     if (aFile.exists() && aFile.isFile()) {
       flushJarCache(aFile);
     }
-    let addon = yield loadManifestFromFile(aFile, TemporaryInstallLocation);
+    let addon = yield loadManifestFromFile(aFile, aInstallLocation);
+
+    aInstallLocation.installAddon(addon.id, aFile, null, null);
 
     if (addon.appDisabled) {
       let message = `Add-on ${addon.id} is not compatible with application version.`;
 
       let app = addon.matchingTargetApplication;
       if (app) {
         if (app.minVersion) {
           message += ` add-on minVersion: ${app.minVersion}.`;
@@ -4036,16 +4058,94 @@ this.XPIProvider = {
           message += ` add-on maxVersion: ${app.maxVersion}.`;
         }
       }
       throw new Error(message);
     }
 
     if (!addon.bootstrap) {
       throw new Error("Only restartless (bootstrap) add-ons"
+                    + " can be installed from sources:", addon.id);
+    }
+    let installReason = BOOTSTRAP_REASONS.ADDON_INSTALL;
+    let oldAddon = yield new Promise(
+                   resolve => XPIDatabase.getVisibleAddonForID(addon.id, resolve));
+    if (oldAddon) {
+      if (!oldAddon.bootstrap) {
+        logger.warn("Non-restartless Add-on is already installed", addon.id);
+        throw new Error("Non-restartless add-on with ID "
+                        + oldAddon.id + " is already installed");
+      }
+      else {
+        logger.warn("Addon with ID " + oldAddon.id + " already installed,"
+                    + " older version will be disabled");
+
+        let existingAddonID = oldAddon.id;
+        let existingAddon = oldAddon._sourceBundle;
+
+        // We'll be replacing a currently active bootstrapped add-on so
+        // call its uninstall method
+        let newVersion = addon.version;
+        let oldVersion = oldAddon.version;
+        if (Services.vc.compare(newVersion, oldVersion) >= 0) {
+          installReason = BOOTSTRAP_REASONS.ADDON_UPGRADE;
+        } else {
+          installReason = BOOTSTRAP_REASONS.ADDON_DOWNGRADE;
+        }
+        let uninstallReason = installReason;
+
+        if (oldAddon.active) {
+          XPIProvider.callBootstrapMethod(oldAddon, existingAddon,
+                                          "shutdown", uninstallReason,
+                                          { newVersion });
+        }
+        this.callBootstrapMethod(oldAddon, existingAddon,
+                                 "uninstall", uninstallReason, { newVersion });
+        this.unloadBootstrapScope(existingAddonID);
+        flushChromeCaches();
+      }
+    }
+
+    let file = addon._sourceBundle;
+
+    XPIProvider._addURIMapping(addon.id, file);
+    XPIProvider.callBootstrapMethod(addon, file, "install", installReason);
+    addon.state = AddonManager.STATE_INSTALLED;
+    logger.debug("Install of temporary addon in " + aFile.path + " completed.");
+    addon.visible = true;
+    addon.enabled = true;
+    addon.active = true;
+
+    addon = XPIDatabase.addAddonMetadata(addon, file.persistentDescriptor);
+
+    XPIStates.addAddon(addon);
+    XPIDatabase.saveChanges();
+    //XPIStates.save();
+
+    AddonManagerPrivate.callAddonListeners("onInstalling", addon.wrapper,
+                                           false);
+    XPIProvider.callBootstrapMethod(addon, file, "startup",
+                                    BOOTSTRAP_REASONS.ADDON_ENABLE);
+    AddonManagerPrivate.callInstallListeners("onExternalInstall",
+                                             null, addon.wrapper,
+                                             oldAddon ? oldAddon.wrapper : null,
+                                             false);
+    AddonManagerPrivate.callAddonListeners("onInstalled", addon.wrapper);
+
+    return addon.wrapper;
+  }),
+
+/*
+  installAddonFromSources: Task.async(function*(aFile) {
+    let addon = yield loadManifestFromFile(aFile, SourceInstallLocation);
+
+    SourceInstallLocation.installAddon(addon.id, aFile, null, null);
+
+    if (!addon.bootstrap) {
+      throw new Error("Only restartless (bootstrap) add-ons"
                     + " can be temporarily installed:", addon.id);
     }
     let installReason = BOOTSTRAP_REASONS.ADDON_INSTALL;
     let oldAddon = yield new Promise(
                    resolve => XPIDatabase.getVisibleAddonForID(addon.id, resolve));
     if (oldAddon) {
       if (!oldAddon.bootstrap) {
         logger.warn("Non-restartless Add-on is already installed", addon.id);
@@ -4091,29 +4191,30 @@ this.XPIProvider = {
     addon.visible = true;
     addon.enabled = true;
     addon.active = true;
 
     addon = XPIDatabase.addAddonMetadata(addon, file.persistentDescriptor);
 
     XPIStates.addAddon(addon);
     XPIDatabase.saveChanges();
+    XPIStates.save();
 
     AddonManagerPrivate.callAddonListeners("onInstalling", addon.wrapper,
                                            false);
     XPIProvider.callBootstrapMethod(addon, file, "startup",
                                     BOOTSTRAP_REASONS.ADDON_ENABLE);
     AddonManagerPrivate.callInstallListeners("onExternalInstall",
                                              null, addon.wrapper,
                                              oldAddon ? oldAddon.wrapper : null,
                                              false);
     AddonManagerPrivate.callAddonListeners("onInstalled", addon.wrapper);
 
     return addon.wrapper;
-  }),
+  }),*/
 
   /**
    * Returns an Addon corresponding to an instance ID.
    * @param aInstanceID
    *        An Addon Instance ID
    * @return {Promise}
    * @resolves The found Addon or null if no such add-on exists.
    * @rejects  Never
@@ -5148,17 +5249,18 @@ this.XPIProvider = {
 
     let wasPending = aAddon.pendingUninstall;
 
     if (makePending) {
       // We create an empty directory in the staging directory to indicate
       // that an uninstall is necessary on next startup. Temporary add-ons are
       // automatically uninstalled on shutdown anyway so there is no need to
       // do this for them.
-      if (aAddon._installLocation.name != KEY_APP_TEMPORARY) {
+      if (aAddon._installLocation.name != KEY_APP_TEMPORARY &&
+          aAddon._installLocation.name != KEY_APP_SOURCE) {
         let stage = aAddon._installLocation.getStagingDir();
         stage.append(aAddon.id);
         if (!stage.exists())
           stage.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
       }
 
       XPIDatabase.setAddonProperties(aAddon, {
         pendingUninstall: true
@@ -8733,16 +8835,56 @@ const TemporaryInstallLocation = {
   scope: AddonManager.SCOPE_TEMPORARY,
   getAddonLocations: () => [],
   isLinkedAddon: () => false,
   installAddon: () => {},
   uninstallAddon: (aAddon) => {},
   getStagingDir: () => {},
 }
 
+const SourceInstallLocation = {
+  locked: false,
+  name: KEY_APP_SOURCE,
+  scope: AddonManager.SCOPE_TEMPORARY,
+  getAddonLocations: () => {
+    let locations = new Map();
+    let addons = SourceInstallLocation._addons;
+    for (let id in addons) {
+      locations.set(id, new nsIFile(addons[id]));
+    }
+    return locations;
+  },
+  isLinkedAddon: () => false,
+  installAddon: (id, file) => {
+    SourceInstallLocation._addons[id] = file.path;
+    SourceInstallLocation._save();
+  },
+  uninstallAddon: (id) => {
+    delete SourceInstallLocation._addons[id];
+    SourceInstallLocation._save();
+  },
+  getStagingDir: () => {},
+  get _addons() {
+    let addons = {};
+    try {
+      addons = JSON.parse(Services.prefs.getCharPref("extensions.addonFromSources"));
+    } catch(e) {}
+    if (typeof(addons) != "object") {
+      addons = {};
+    }
+    delete this._addons;
+    this._addons = addons;
+    return this._addons;
+  },
+  _save: () => {
+    Services.prefs.setCharPref("extensions.addonFromSources", JSON.stringify(SourceInstallLocation._addons));
+    Services.prefs.savePrefFile(null);
+  },
+};
+
 /**
  * An object that identifies a registry install location for add-ons. The location
  * consists of a registry key which contains string values mapping ID to the
  * path where an add-on is installed
  *
  * @param  aName
  *         The string identifier of this Install Location.
  * @param  aRootKey
@@ -8805,19 +8947,17 @@ WinRegInstallLocation.prototype = {
    * @param  key
    *         The key that contains the ID to path mapping
    */
   _readAddons: function(aKey) {
     let count = aKey.valueCount;
     for (let i = 0; i < count; ++i) {
       let id = aKey.getValueName(i);
 
-      let file = Cc["@mozilla.org/file/local;1"].
-                createInstance(Ci.nsIFile);
-      file.initWithPath(aKey.readStringValue(id));
+      let file = new nsIFile(aKey.readStringValue(id));
 
       if (!file.exists()) {
         logger.warn("Ignoring missing add-on in " + file.path);
         continue;
       }
 
       this._IDToFileMap[id] = file;
       XPIProvider._addURIMapping(id, file);
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_install_from_sources.js
@@ -0,0 +1,92 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const ID = "bootstrap1@tests.mozilla.org";
+const sampleRDFManifest = {
+  id: ID,
+  version: "1.0",
+  bootstrap: true,
+  targetApplications: [{
+    id: "xpcshell@tests.mozilla.org",
+    minVersion: "1",
+    maxVersion: "1"
+  }],
+  name: "Test Bootstrap 1 (temporary)",
+};
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+startupManager();
+
+BootstrapMonitor.init();
+
+// Partial list of bootstrap reasons from XPIProvider.jsm
+const BOOTSTRAP_REASONS = {
+  ADDON_INSTALL: 5,
+  ADDON_UPGRADE: 7,
+  ADDON_DOWNGRADE: 8,
+};
+
+// Install an unsigned add-on with no existing add-on present.
+// Restart and make sure it is still around.
+add_task(function*() {
+  let extInstallCalled = false;
+  AddonManager.addInstallListener({
+    onExternalInstall: (aInstall) => {
+      do_check_eq(aInstall.id, ID);
+      do_check_eq(aInstall.version, "1.0");
+      extInstallCalled = true;
+    },
+  });
+
+  let installingCalled = false;
+  let installedCalled = false;
+  AddonManager.addAddonListener({
+    onInstalling: (aInstall) => {
+      do_check_eq(aInstall.id, ID);
+      do_check_eq(aInstall.version, "1.0");
+      installingCalled = true;
+    },
+    onInstalled: (aInstall) => {
+      do_check_eq(aInstall.id, ID);
+      do_check_eq(aInstall.version, "1.0");
+      installedCalled = true;
+    },
+    onInstallStarted: (aInstall) => {
+      do_throw("onInstallStarted called unexpectedly");
+    }
+  });
+
+  yield AddonManager.installAddonFromSources(do_get_addon("test_bootstrap1_1"));
+
+  do_check_true(extInstallCalled);
+  do_check_true(installingCalled);
+  do_check_true(installedCalled);
+
+  let install = BootstrapMonitor.checkAddonInstalled(ID, "1.0");
+  equal(install.reason, BOOTSTRAP_REASONS.ADDON_INSTALL);
+  BootstrapMonitor.checkAddonStarted(ID, "1.0");
+
+  let addon = yield promiseAddonByID(ID);
+
+  do_check_neq(addon, null);
+  do_check_eq(addon.version, "1.0");
+  do_check_eq(addon.name, "Test Bootstrap 1");
+  do_check_true(addon.isCompatible);
+  do_check_false(addon.appDisabled);
+  do_check_true(addon.isActive);
+  do_check_eq(addon.type, "extension");
+  do_check_eq(addon.signedState, mozinfo.addon_signing ? AddonManager.SIGNEDSTATE_SIGNED : AddonManager.SIGNEDSTATE_NOT_REQUIRED);
+
+  yield promiseRestartManager();
+
+  install = BootstrapMonitor.checkAddonInstalled(ID, "1.0");
+  equal(install.reason, BOOTSTRAP_REASONS.ADDON_INSTALL);
+  BootstrapMonitor.checkAddonStarted(ID, "1.0");
+
+  addon = yield promiseAddonByID(ID);
+  do_check_neq(addon, null);
+
+  yield promiseRestartManager();
+});
+
--- a/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
@@ -29,16 +29,17 @@ skip-if = appname != "firefox"
 [test_ProductAddonChecker.js]
 [test_shutdown.js]
 [test_system_update.js]
 [test_system_reset.js]
 [test_XPIcancel.js]
 [test_XPIStates.js]
 [test_temporary.js]
 tags = webextensions
+[test_install_from_sources.js]
 [test_proxies.js]
 [test_proxy.js]
 [test_pass_symbol.js]
 [test_delay_update.js]
 [test_nodisable_hidden.js]
 [test_delay_update_webextension.js]
 skip-if = appname == "thunderbird"
 tags = webextensions