Bug 552965 and bug 553455: Notify the application when add-on installs triggered by webpages fail or complete. r=robstrong
authorDave Townsend <dtownsend@oxymoronical.com>
Thu, 01 Jul 2010 10:56:48 -0700
changeset 47131 dc446291a715cbb4994c299426139a8f7f70b768
parent 47130 a5f7f9e82281ef5c713c2ed0d902236fe8c5e2e2
child 47132 b1b50d483f1e5bee48c57a06c1d9cb6c80c3bcd3
push idunknown
push userunknown
push dateunknown
reviewersrobstrong
bugs552965, 553455
milestone2.0b2pre
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 552965 and bug 553455: Notify the application when add-on installs triggered by webpages fail or complete. r=robstrong
toolkit/locales/en-US/chrome/mozapps/extensions/extensions.properties
toolkit/mozapps/extensions/XPIProvider.jsm
toolkit/mozapps/extensions/amWebInstallListener.js
toolkit/mozapps/extensions/test/xpinstall/Makefile.in
toolkit/mozapps/extensions/test/xpinstall/browser_cancel.js
toolkit/mozapps/extensions/test/xpinstall/head.js
--- a/toolkit/locales/en-US/chrome/mozapps/extensions/extensions.properties
+++ b/toolkit/locales/en-US/chrome/mozapps/extensions/extensions.properties
@@ -25,20 +25,16 @@ uninstallNotice=%S has been removed.
 numReviews=#1 review;#1 reviews
 
 #LOCALIZATION NOTE (dateUpdated) %S is the date the addon was last updated
 dateUpdated=Updated %S
 
 #LOCALIZATION NOTE (incompatibleWith) %1$S is brand name, %2$S is application version
 incompatibleWith=Incompatible with %1$S %2$S
 
-incompatibleTitle2=Incompatible add-on
-#LOCALIZATION NOTE (incompatibleMessage2) %1$S is add-on name, %2$% is add-on version, %3$% is application name, %4$% is application version
-incompatibleMessage2=%1$S %2$S could not be installed because it is not compatible with %3$S %4$S.
-
 installDownloading=Downloading
 installDownloaded=Downloaded
 installDownloadFailed=Error downloading
 installVerifying=Verifying
 installInstalling=Installing
 installInstallPending=Ready to install
 installUpdatePending=Ready to update
 installEnablePending=Restart to enable
--- a/toolkit/mozapps/extensions/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/XPIProvider.jsm
@@ -546,18 +546,17 @@ function extractFiles(aZipFile, aDir) {
       let target = getTargetFile(aDir, entryName);
       if (!target.exists()) {
         try {
           target.create(Ci.nsILocalFile.DIRECTORY_TYPE,
                         FileUtils.PERMS_DIRECTORY);
         }
         catch (e) {
           ERROR("extractFiles: failed to create target directory for " +
-                "extraction file = " + target.path + ", exception = " + e +
-                "\n");
+                "extraction file = " + target.path + ", exception = " + e);
         }
       }
     }
 
     entries = zipReader.findEntries(null);
     while (entries.hasMore()) {
       let entryName = entries.getNext();
       let target = getTargetFile(aDir, entryName);
@@ -4118,17 +4117,17 @@ AddonInstall.prototype = {
    * Notify listeners that the download failed.
    *
    * @param  aReason
    *         Something to log about the failure
    * @param  error
    *         The error code to pass to the listeners
    */
   downloadFailed: function(aReason, aError) {
-    WARN("Download failed: " + aError + "\n");
+    WARN("Download failed: " + aError);
     this.state = AddonManager.STATE_DOWNLOAD_FAILED;
     this.error = aReason;
     XPIProvider.removeActiveInstall(this);
     AddonManagerPrivate.callInstallListeners("onDownloadFailed", this.listeners,
                                              this.wrapper);
     try {
       this.file.remove(true);
     }
--- a/toolkit/mozapps/extensions/amWebInstallListener.js
+++ b/toolkit/mozapps/extensions/amWebInstallListener.js
@@ -56,141 +56,199 @@ Components.utils.import("resource://gre/
   this.__defineGetter__(aName, function() {
     Components.utils.import("resource://gre/modules/AddonLogging.jsm");
 
     LogManager.getLogger("addons.weblistener", this);
     return this[aName];
   });
 }, this);
 
+function notifyObservers(aTopic, aWindow, aUri, aInstalls) {
+  let info = {
+    originatingWindow: aWindow,
+    originatingURI: aUri,
+    installs: aInstalls,
+
+    QueryInterface: XPCOMUtils.generateQI([Ci.amIWebInstallInfo])
+  };
+  Services.obs.notifyObservers(info, aTopic, null);
+}
+
 /**
  * Creates a new installer to monitor downloads and prompt to install when
  * ready
  *
  * @param  aWindow
  *         The window that started the installations
  * @param  aUrl
  *         The URL that started the installations
  * @param  aInstalls
  *         An array of AddonInstalls
  */
 function Installer(aWindow, aUrl, aInstalls) {
   this.window = aWindow;
   this.url = aUrl;
   this.downloads = aInstalls;
-  this.installs = [];
+  this.installed = [];
 
-  this.bundle = Cc["@mozilla.org/intl/stringbundle;1"].
-                getService(Ci.nsIStringBundleService).
-                createBundle("chrome://mozapps/locale/extensions/extensions.properties");
+  notifyObservers("addon-install-started", aWindow, aUrl, aInstalls);
 
-  this.count = aInstalls.length;
   aInstalls.forEach(function(aInstall) {
     aInstall.addListener(this);
 
-    // Might already be a local file
-    if (aInstall.state == AddonManager.STATE_DOWNLOADED)
-      this.onDownloadEnded(aInstall);
-    else if (aInstall.state == AddonManager.STATE_DOWNLOAD_FAILED)
-      this.onDownloadFailed(aInstall);
-    else
+    // Start downloading if it hasn't already begun
+    if (aInstall.state == AddonManager.STATE_AVAILABLE)
       aInstall.install();
   }, this);
+
+  this.checkAllDownloaded();
 }
 
 Installer.prototype = {
   window: null,
   downloads: null,
-  installs: null,
-  count: null,
+  installed: null,
+  isDownloading: true,
 
   /**
    * Checks if all downloads are now complete and if so prompts to install.
    */
   checkAllDownloaded: function() {
-    if (--this.count > 0)
+    // Prevent re-entrancy caused by the confirmation dialog cancelling unwanted
+    // installs.
+    if (!this.isDownloading)
       return;
 
-    // Maybe none of the downloads were sucessful
-    if (this.installs.length == 0)
+    var failed = [];
+    var installs = [];
+
+    for (let i = 0; i < this.downloads.length; i++) {
+      let install = this.downloads[i];
+      switch (install.state) {
+      case AddonManager.STATE_AVAILABLE:
+      case AddonManager.STATE_DOWNLOADING:
+        // Exit early if any add-ons haven't started downloading yet or are
+        // still downloading
+        return;
+      case AddonManager.STATE_DOWNLOAD_FAILED:
+        failed.push(install);
+        break;
+      case AddonManager.STATE_DOWNLOADED:
+        // App disabled items are not compatible and so fail to install
+        if (install.addon.appDisabled)
+          failed.push(install);
+        else
+          installs.push(install);
+        break;
+      default:
+        WARN("Download of " + install.sourceURL + " in unexpected state " +
+             install.state);
+      }
+    }
+
+    this.isDownloading = false;
+    this.downloads = installs;
+
+    if (failed.length > 0) {
+      // Stop listening and cancel any installs that are failed because of
+      // compatibility reasons.
+      failed.forEach(function(aInstall) {
+        if (aInstall.state == AddonManager.STATE_DOWNLOADED) {
+          aInstall.removeListener(this);
+          aInstall.cancel();
+        }
+      }, this);
+      notifyObservers("addon-install-failed", this.window, this.url, failed);
+    }
+
+    // If none of the downloads were successful then exit early
+    if (this.downloads.length == 0)
       return;
 
+    // Check for a custom installation prompt that may be provided by the
+    // applicaton
     if ("@mozilla.org/addons/web-install-prompt;1" in Cc) {
       try {
         let prompt = Cc["@mozilla.org/addons/web-install-prompt;1"].
                      getService(Ci.amIWebInstallPrompt);
-        prompt.confirm(this.window, this.url, this.installs, this.installs.length);
+        prompt.confirm(this.window, this.url, this.downloads, this.downloads.length);
         return;
       }
       catch (e) {}
     }
 
     let args = {};
     args.url = this.url;
-    args.installs = this.installs;
+    args.installs = this.downloads;
     args.wrappedJSObject = args;
 
     Services.ww.openWindow(this.window, "chrome://mozapps/content/xpinstall/xpinstallConfirm.xul",
                            null, "chrome,modal,centerscreen", args);
   },
 
+  /**
+   * Checks if all installs are now complete and if so notifies observers.
+   */
+  checkAllInstalled: function() {
+    var failed = [];
+
+    for (let i = 0; i < this.downloads.length; i++) {
+      let install = this.downloads[i];
+      switch(install.state) {
+      case AddonManager.STATE_DOWNLOADED:
+      case AddonManager.STATE_INSTALLING:
+        // Exit early if any add-ons haven't started installing yet or are
+        // still installing
+        return;
+      case AddonManager.STATE_INSTALL_FAILED:
+        failed.push(install);
+        break;
+      }
+    }
+
+    this.downloads = null;
+
+    if (failed.length > 0)
+      notifyObservers("addon-install-failed", this.window, this.url, failed);
+
+    if (this.installed.length > 0)
+      notifyObservers("addon-install-complete", this.window, this.url, this.installed);
+    this.installed = null;
+  },
+
   onDownloadCancelled: function(aInstall) {
     aInstall.removeListener(this);
-
     this.checkAllDownloaded();
   },
 
   onDownloadFailed: function(aInstall) {
     aInstall.removeListener(this);
-
-    // TODO show some better error
-    Services.prompt.alert(this.window, "Download Failed", "The download of " +
-                          aInstall.sourceURL + " failed: " + aInstall.error);
     this.checkAllDownloaded();
   },
 
   onDownloadEnded: function(aInstall) {
-    aInstall.removeListener(this);
-
-    if (aInstall.addon.appDisabled) {
-      // App disabled items are not compatible
-      aInstall.cancel();
-
-      let title = null;
-      let text = null;
-
-      let problems = "";
-      if (!aInstall.addon.isCompatible)
-        problems += "incompatible, ";
-      if (!aInstall.addon.providesUpdatesSecurely)
-        problems += "insecure updates, ";
-      if (aInstall.addon.blocklistState == Ci.nsIBlocklistService.STATE_BLOCKED) {
-        problems += "blocklisted, ";
-        title = bundle.GetStringFromName("blocklistedInstallTitle2");
-        text = this.bundle.formatStringFromName("blocklistedInstallMsg2",
-                                                [install.addon.name], 1);
-      }
-      problems = problems.substring(0, problems.length - 2);
-      WARN("Not installing " + aInstall.addon.id + " because of the following: " + problems);
-
-      title = this.bundle.GetStringFromName("incompatibleTitle2", 1);
-      text = this.bundle.formatStringFromName("incompatibleMessage2",
-                                              [aInstall.addon.name,
-                                               aInstall.addon.version,
-                                               Services.appinfo.name,
-                                               Services.appinfo.version], 4);
-      Services.prompt.alert(this.window, title, text);
-    }
-    else {
-      this.installs.push(aInstall);
-    }
-
     this.checkAllDownloaded();
     return false;
   },
+
+  onInstallCancelled: function(aInstall) {
+    aInstall.removeListener(this);
+    this.checkAllInstalled();
+  },
+
+  onInstallFailed: function(aInstall) {
+    aInstall.removeListener(this);
+    this.checkAllInstalled();
+  },
+
+  onInstallEnded: function(aInstall) {
+    aInstall.removeListener(this);
+    this.installed.push(aInstall);
+    this.checkAllInstalled();
+  }
 };
 
 function extWebInstallListener() {
 }
 
 extWebInstallListener.prototype = {
   /**
    * @see amIWebInstallListener.idl
--- a/toolkit/mozapps/extensions/test/xpinstall/Makefile.in
+++ b/toolkit/mozapps/extensions/test/xpinstall/Makefile.in
@@ -77,23 +77,26 @@ include $(topsrcdir)/config/rules.mk
                  browser_auth.js \
                  browser_auth2.js \
                  browser_auth3.js \
                  browser_offline.js \
                  browser_navigateaway.js \
                  browser_navigateaway2.js \
                  browser_bug540558.js \
                  browser_relative.js \
+                 browser_cancel.js \
                  unsigned.xpi \
                  signed.xpi \
                  signed2.xpi \
                  signed-no-o.xpi \
                  signed-no-cn.xpi \
                  signed-untrusted.xpi \
                  signed-tampered.xpi \
+                 restartless.xpi \
+                 incompatible.xpi \
                  empty.xpi \
                  corrupt.xpi \
                  enabled.html \
                  installtrigger.html \
                  startsoftwareupdate.html \
                  installchrome.html \
                  authRedirect.sjs \
                  cookieRedirect.sjs \
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_cancel.js
@@ -0,0 +1,61 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// ----------------------------------------------------------------------------
+// Tests that cancelling multiple installs doesn't fail
+function test() {
+  Harness.installConfirmCallback = confirm_install;
+  Harness.installEndedCallback = install_ended;
+  Harness.installsCompletedCallback = finish_test;
+  Harness.setup();
+
+  var pm = Services.perms;
+  pm.add(makeURI("http://example.com/"), "install", pm.ALLOW_ACTION);
+
+  var triggers = encodeURIComponent(JSON.stringify({
+    "Signed XPI": TESTROOT + "signed.xpi",
+    "Signed XPI 2": TESTROOT + "signed2.xpi",
+  }));
+  gBrowser.selectedTab = gBrowser.addTab();
+  gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
+}
+
+function get_item(items, url) {
+  for (let i = 0; i < items.length; i++) {
+    if (items[i].url == url)
+      return items[i];
+  }
+  ok(false, "Item for " + url + " was not listed");
+}
+
+function confirm_install(window) {
+  items = window.document.getElementById("itemList").childNodes;
+  is(items.length, 2, "Should be 2 items listed in the confirmation dialog");
+  let item = get_item(items, TESTROOT + "signed.xpi");
+  if (item) {
+    is(item.name, "Signed XPI Test", "Should have seen the name from the trigger list");
+    is(item.cert, "(Object Signer)", "Should have seen the signer");
+    is(item.signed, "true", "Should have listed the item as signed");
+  }
+  item = get_item(items, TESTROOT + "signed2.xpi");
+  if (item) {
+    is(item.name, "Signed XPI Test", "Should have seen the name from the trigger list");
+    is(item.cert, "(Object Signer)", "Should have seen the signer");
+    is(item.signed, "true", "Should have listed the item as signed");
+  }
+  return false;
+}
+
+function install_ended(install, addon) {
+  ok(false, "Should not have seen installs complete");
+}
+
+function finish_test(count) {
+  is(count, 0, "No add-ons should have been successfully installed");
+
+  Services.perms.remove("example.com", "install");
+
+  gBrowser.removeCurrentTab();
+  Harness.finish();
+}
--- a/toolkit/mozapps/extensions/test/xpinstall/head.js
+++ b/toolkit/mozapps/extensions/test/xpinstall/head.js
@@ -47,66 +47,82 @@ var Harness = {
   // installation.
   installEndedCallback: null,
   // If set will be called when all triggered items are installed or the install
   // is canceled.
   installsCompletedCallback: null,
 
   pendingCount: null,
   installCount: null,
+  runningInstalls: null,
 
   // Setup and tear down functions
   setup: function() {
     waitForExplicitFinish();
     Services.prefs.setBoolPref(PREF_LOGGING_ENABLED, true);
+    Services.obs.addObserver(this, "addon-install-started", false);
     Services.obs.addObserver(this, "addon-install-blocked", false);
+    Services.obs.addObserver(this, "addon-install-failed", false);
+    Services.obs.addObserver(this, "addon-install-complete", false);
     Services.wm.addListener(this);
 
     AddonManager.addInstallListener(this);
     this.installCount = 0;
     this.pendingCount = 0;
+    this.runningInstalls = [];
 
     var self = this;
     registerCleanupFunction(function() {
       Services.prefs.clearUserPref(PREF_LOGGING_ENABLED);
+      Services.obs.removeObserver(self, "addon-install-started");
       Services.obs.removeObserver(self, "addon-install-blocked");
+      Services.obs.removeObserver(self, "addon-install-failed");
+      Services.obs.removeObserver(self, "addon-install-complete");
       Services.wm.removeListener(self);
 
       AddonManager.removeInstallListener(self);
     });
   },
 
   finish: function() {
     AddonManager.getAllInstalls(function(installs) {
       is(installs.length, 0, "Should be no active installs at the end of the test");
       finish();
     });
   },
 
   endTest: function() {
     // Defer the final notification to allow things like the InstallTrigger
     // callback to complete
-    let callback = this.installsCompletedCallback;
-    let count = this.installCount;
+    var self = this;
     executeSoon(function() {
+      let callback = self.installsCompletedCallback;
+      let count = self.installCount;
+
+      is(self.runningInstalls.length, 0, "Should be no running installs left");
+      self.runningInstalls.forEach(function(aInstall) {
+        info("Install for " + aInstall.sourceURL + " is in state " + aInstall.state);
+      });
+
+      self.installBlockedCallback = null;
+      self.authenticationCallback = null;
+      self.installConfirmCallback = null;
+      self.downloadStartedCallback = null;
+      self.downloadProgressCallback = null;
+      self.downloadCancelledCallback = null;
+      self.downloadFailedCallback = null;
+      self.downloadEndedCallback = null;
+      self.installStartedCallback = null;
+      self.installFailedCallback = null;
+      self.installEndedCallback = null;
+      self.installsCompletedCallback = null;
+      self.runningInstalls = null;
+
       callback(count);
     });
-
-    this.installBlockedCallback = null;
-    this.authenticationCallback = null;
-    this.installConfirmCallback = null;
-    this.downloadStartedCallback = null;
-    this.downloadProgressCallback = null;
-    this.downloadCancelledCallback = null;
-    this.downloadFailedCallback = null;
-    this.downloadEndedCallback = null;
-    this.installStartedCallback = null;
-    this.installFailedCallback = null;
-    this.installEndedCallback = null;
-    this.installsCompletedCallback = null;
   },
 
   // Window open handling
   windowLoad: function(window) {
     // Allow any other load handlers to execute
     var self = this;
     executeSoon(function() { self.windowReady(window); } );
   },
@@ -196,16 +212,20 @@ var Harness = {
     }, false);
   },
 
   onCloseWindow: function(window) {
   },
 
   // Addon Install Listener
 
+  onNewInstall: function(install) {
+    this.runningInstalls.push(install);
+  },
+
   onDownloadStarted: function(install) {
     this.pendingCount++;
     if (this.downloadStartedCallback)
       this.downloadStartedCallback(install);
   },
 
   onDownloadProgress: function(install) {
     if (this.downloadProgressCallback)
@@ -213,16 +233,20 @@ var Harness = {
   },
 
   onDownloadEnded: function(install) {
     if (this.downloadEndedCallback)
       this.downloadEndedCallback(install);
   },
 
   onDownloadCancelled: function(install) {
+    isnot(this.runningInstalls.indexOf(install), -1,
+          "Should only see cancelations for started installs");
+    this.runningInstalls.splice(this.runningInstalls.indexOf(install), 1);
+
     if (this.downloadCancelledCallback)
       this.downloadCancelledCallback(install);
     this.checkTestEnded();
   },
 
   onDownloadFailed: function(install) {
     if (this.downloadFailedCallback)
       this.downloadFailedCallback(install);
@@ -243,26 +267,61 @@ var Harness = {
 
   onInstallFailed: function(install) {
     if (this.installFailedCallback)
       this.installFailedCallback(install);
     this.checkTestEnded();
   },
 
   checkTestEnded: function() {
-    dump("checkTestPending " + this.pendingCount + "\n");
     if (--this.pendingCount == 0)
       this.endTest();
   },
 
   // nsIObserver
 
   observe: function(subject, topic, data) {
     var installInfo = subject.QueryInterface(Components.interfaces.amIWebInstallInfo);
-    this.installBlocked(installInfo);
+    switch (topic) {
+    case "addon-install-started":
+      is(this.runningInstalls.length, installInfo.installs.length,
+         "Should have seen the expected number of installs started");
+      break;
+    case "addon-install-blocked":
+      this.installBlocked(installInfo);
+      break;
+    case "addon-install-failed":
+      installInfo.installs.forEach(function(aInstall) {
+        isnot(this.runningInstalls.indexOf(aInstall), -1,
+              "Should only see failures for started installs");
+
+        ok(aInstall.error != 0 || aInstall.addon.appDisabled,
+           "Failed installs should have an error or be appDisabled");
+
+        this.runningInstalls.splice(this.runningInstalls.indexOf(aInstall), 1);
+      }, this);
+      break;
+    case "addon-install-complete":
+      installInfo.installs.forEach(function(aInstall) {
+        isnot(this.runningInstalls.indexOf(aInstall), -1,
+              "Should only see completed events for started installs");
+
+        is(aInstall.error, 0, "Completed installs should have no error");
+        ok(!aInstall.appDisabled, "Completed installs should not be appDisabled");
+
+        // Complete installs are either in the INSTALLED or CANCELLED state
+        // since the test may cancel installs the moment they complete.
+        ok(aInstall.state == AddonManager.STATE_INSTALLED ||
+           aInstall.state == AddonManager.STATE_CANCELLED,
+           "Completed installs should be in the right state");
+
+        this.runningInstalls.splice(this.runningInstalls.indexOf(aInstall), 1);
+      }, this);
+      break;
+    }
   },
 
   QueryInterface: function(iid) {
     if (iid.equals(Components.interfaces.nsIObserver) ||
         iid.equals(Components.interfaces.nsIWindowMediatorListener) ||
         iid.equals(Components.interfaces.nsISupports))
       return this;