Bug 1000315 - Part 3: Uninstall prompting on Desktop and WebRT. r=myk
authorTed Clancy (:tedders1) <tclancy@mozilla.com>
Wed, 11 Jun 2014 14:23:18 -0700
changeset 223011 8e2086458237c7030fab5eaae310b1e7e7e0eb60
parent 223010 87faeece51b80d07ea1285adef102962ec7b0248
child 223012 1a78ae9f8b9e162af78fd63c9cf62cca3fa184f9
push id3979
push userraliiev@mozilla.com
push dateMon, 13 Oct 2014 16:35:44 +0000
treeherdermozilla-beta@30f2cc610691 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmyk
bugs1000315
milestone34.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1000315 - Part 3: Uninstall prompting on Desktop and WebRT. r=myk
browser/locales/en-US/chrome/browser/browser.properties
browser/modules/WebappManager.jsm
dom/apps/src/Webapps.js
dom/apps/src/Webapps.jsm
webapprt/WebappManager.jsm
webapprt/locales/en-US/webapprt/webapp.properties
--- a/browser/locales/en-US/chrome/browser/browser.properties
+++ b/browser/locales/en-US/chrome/browser/browser.properties
@@ -439,16 +439,22 @@ dataReportingNotification.button.accessK
 
 # Webapps notification popup
 webapps.install = Install
 webapps.install.accesskey = I
 #LOCALIZATION NOTE (webapps.requestInstall) %1$S is the web app name, %2$S is the site from which the web app is installed
 webapps.requestInstall = Do you want to install "%1$S" from this site (%2$S)?
 webapps.install.success = Application Installed
 webapps.install.inprogress = Installation in progress
+webapps.uninstall = Uninstall
+webapps.uninstall.accesskey = U
+webapps.doNotUninstall = Don't Uninstall
+webapps.doNotUninstall.accesskey = D
+#LOCALIZATION NOTE (webapps.requestUninstall) %1$S is the web app name
+webapps.requestUninstall = Do you want to uninstall "%1$S"?
 
 # LOCALIZATION NOTE (fullscreen.entered): displayed when we enter HTML5 fullscreen mode, %S is the domain name of the focused website (e.g. mozilla.com).
 fullscreen.entered=%S is now fullscreen.
 # LOCALIZATION NOTE (fullscreen.rememberDecision): displayed when we enter HTML5 fullscreen mode, %S is the domain name of the focused website (e.g. mozilla.com).
 fullscreen.rememberDecision=Remember decision for %S
 
 # LOCALIZATION NOTE (fullscreenButton.tooltip): %S is the keyboard shortcut for full screen
 fullscreenButton.tooltip=Display the window in full screen (%S)
--- a/browser/modules/WebappManager.jsm
+++ b/browser/modules/WebappManager.jsm
@@ -26,25 +26,27 @@ XPCOMUtils.defineLazyServiceGetter(this,
                                    "nsIMessageSender");
 
 this.WebappManager = {
   // List of promises for in-progress installations
   installations: {},
 
   init: function() {
     Services.obs.addObserver(this, "webapps-ask-install", false);
+    Services.obs.addObserver(this, "webapps-ask-uninstall", false);
     Services.obs.addObserver(this, "webapps-launch", false);
     Services.obs.addObserver(this, "webapps-uninstall", false);
     cpmm.addMessageListener("Webapps:Install:Return:OK", this);
     cpmm.addMessageListener("Webapps:Install:Return:KO", this);
     cpmm.addMessageListener("Webapps:UpdateState", this);
   },
 
   uninit: function() {
     Services.obs.removeObserver(this, "webapps-ask-install");
+    Services.obs.removeObserver(this, "webapps-ask-uninstall");
     Services.obs.removeObserver(this, "webapps-launch");
     Services.obs.removeObserver(this, "webapps-uninstall");
     cpmm.removeMessageListener("Webapps:Install:Return:OK", this);
     cpmm.removeMessageListener("Webapps:Install:Return:KO", this);
     cpmm.removeMessageListener("Webapps:UpdateState", this);
   },
 
   receiveMessage: function(aMessage) {
@@ -76,23 +78,30 @@ this.WebappManager = {
       this.installations[manifestURL].reject(data.error);
     }
   },
 
   observe: function(aSubject, aTopic, aData) {
     let data = JSON.parse(aData);
     data.mm = aSubject;
 
+    let win;
     switch(aTopic) {
       case "webapps-ask-install":
-        let win = this._getWindowForId(data.oid);
+        win = this._getWindowForId(data.oid);
         if (win && win.location.href == data.from) {
           this.doInstall(data, win);
         }
         break;
+      case "webapps-ask-uninstall":
+        win = this._getWindowForId(data.windowId);
+        if (win && win.location.href == data.from) {
+          this.doUninstall(data, win);
+        }
+        break;
       case "webapps-launch":
         WebappOSUtils.launch(data);
         break;
       case "webapps-uninstall":
         WebappOSUtils.uninstall(data);
         break;
     }
   },
@@ -188,16 +197,59 @@ this.WebappManager = {
                                             [manifest.name, host], 2);
 
     notification = chromeWin.PopupNotifications.show(browser,
                                                      "webapps-install",
                                                      message,
                                                      "webapps-notification-icon",
                                                      mainAction);
 
+  },
+
+  doUninstall: function(aData, aWindow) {
+    let browser = aWindow.QueryInterface(Ci.nsIInterfaceRequestor)
+                         .getInterface(Ci.nsIWebNavigation)
+                         .QueryInterface(Ci.nsIDocShell)
+                         .chromeEventHandler;
+    let chromeDoc = browser.ownerDocument;
+    let chromeWin = chromeDoc.defaultView;
+
+    let bundle = chromeWin.gNavigatorBundle;
+    let jsonManifest = aData.app.manifest;
+
+    let notification;
+
+    let mainAction = {
+      label: bundle.getString("webapps.uninstall"),
+      accessKey: bundle.getString("webapps.uninstall.accesskey"),
+      callback: () => {
+        notification.remove();
+        DOMApplicationRegistry.confirmUninstall(aData);
+      }
+    };
+
+    let secondaryAction = {
+      label: bundle.getString("webapps.doNotUninstall"),
+      accessKey: bundle.getString("webapps.doNotUninstall.accesskey"),
+      callback: () => {
+        notification.remove();
+        DOMApplicationRegistry.denyUninstall(aData, "USER_DECLINED");
+      }
+    };
+
+    let manifest = new ManifestHelper(jsonManifest, aData.app.origin,
+                                      aData.app.manifestURL);
+
+    let message = bundle.getFormattedString("webapps.requestUninstall",
+                                            [manifest.name]);
+
+    notification = chromeWin.PopupNotifications.show(
+                     browser, "webapps-uninstall", message,
+                     "webapps-notification-icon",
+                     mainAction, [secondaryAction]);
   }
 }
 
 function notifyInstallSuccess(aApp, aNativeApp, aBundle) {
   let launcher = {
     observe: function(aSubject, aTopic) {
       if (aTopic == "alertclickcallback") {
         WebappOSUtils.launch(aApp);
--- a/dom/apps/src/Webapps.js
+++ b/dom/apps/src/Webapps.js
@@ -210,16 +210,17 @@ WebappsRegistry.prototype = {
     if (!this.hasMgmtPrivilege) {
       return null;
     }
 
     if (!this._mgmt) {
       let mgmt = Cc["@mozilla.org/webapps/manager;1"]
                    .createInstance(Ci.nsISupports);
       mgmt.wrappedJSObject.init(this._window);
+      mgmt.wrappedJSObject._windowId = this._id;
       this._mgmt = mgmt.__DOM_IMPL__
         ? mgmt.__DOM_IMPL__
         : this._window.DOMApplicationsManager._create(this._window, mgmt.wrappedJSObject);
     }
     return this._mgmt;
   },
 
   uninit: function() {
@@ -716,20 +717,25 @@ WebappsApplicationMgmt.prototype = {
     }
 
     cpmm.sendAsyncMessage("Webapps:ApplyDownload",
                           { manifestURL: aApp.manifestURL });
   },
 
   uninstall: function(aApp) {
     let request = this.createRequest();
-    cpmm.sendAsyncMessage("Webapps:Uninstall", { origin: aApp.origin,
-                                                 manifestURL: aApp.manifestURL,
-                                                 oid: this._id,
-                                                 requestID: this.getRequestId(request) });
+
+    cpmm.sendAsyncMessage("Webapps:Uninstall", {
+      origin: aApp.origin,
+      manifestURL: aApp.manifestURL,
+      oid: this._id,
+      from: this._window.location.href,
+      windowId: this._windowId,
+      requestID: this.getRequestId(request)
+    });
     return request;
   },
 
   getAll: function() {
     let request = this.createRequest();
     let window = this._window;
     DOMApplicationRegistry.getAll((aApps) => {
       Services.DOMRequest.fireSuccessAsync(request,
--- a/dom/apps/src/Webapps.jsm
+++ b/dom/apps/src/Webapps.jsm
@@ -157,16 +157,17 @@ this.DOMApplicationRegistry = {
   get kHostedAppcache() "hosted-appcache",
 
   // Path to the webapps.json file where we store the registry data.
   appsFile: null,
   webapps: { },
   children: [ ],
   allAppsLaunchable: false,
   _updateHandlers: [ ],
+  _pendingUninstalls: {},
 
   init: function() {
     this.messages = ["Webapps:Install", "Webapps:Uninstall",
                      "Webapps:GetSelf", "Webapps:CheckInstalled",
                      "Webapps:GetInstalled", "Webapps:GetNotInstalled",
                      "Webapps:Launch",
                      "Webapps:InstallPackage",
                      "Webapps:GetList", "Webapps:RegisterForMessages",
@@ -542,17 +543,17 @@ this.DOMApplicationRegistry = {
       "https://" + app.manifestURL.substring("http://".length);
 
     // This will uninstall the http apps and remove any data hold by this
     // app. Bug 948105 tracks data migration from http to https apps.
     for (let id in this.webapps) {
        if (this.webapps[id].manifestURL === httpsManifestURL) {
          debug("Found a http/https match: " + app.manifestURL + " / " +
                this.webapps[id].manifestURL);
-         this.uninstall(app.manifestURL, function() {}, function() {});
+         this.uninstall(app.manifestURL);
          return;
        }
     }
 #endif
   },
 
   // Implements the core of bug 787439
   // if at first run, go through these steps:
@@ -2633,17 +2634,17 @@ this.DOMApplicationRegistry = {
       if (supportUseCurrentProfile()) {
         PermissionsInstaller.installPermissions(
           {
             origin: appObject.origin,
             manifestURL: appObject.manifestURL,
             manifest: jsonManifest
           },
           isReinstall,
-          this.uninstall.bind(this, aData, aData.mm)
+          this.doUninstall.bind(this, aData, aData.mm)
         );
       }
 
       this.updateDataStore(this.webapps[id].localId,  this.webapps[id].origin,
                            this.webapps[id].manifestURL, jsonManifest);
     }
 
     for each (let prop in ["installState", "downloadAvailable", "downloading",
@@ -3670,89 +3671,124 @@ this.DOMApplicationRegistry = {
       this.broadcastMessage("Webapps:FireEvent", {
         eventType: "downloaderror",
         manifestURL:  aNewApp.manifestURL
       });
     });
     AppDownloadManager.remove(aNewApp.manifestURL);
   },
 
-  doUninstall: function(aData, aMm) {
-    this.uninstall(aData.manifestURL,
-      function onsuccess() {
-        aMm.sendAsyncMessage("Webapps:Uninstall:Return:OK", aData);
-      },
-      function onfailure() {
-        // Fall-through, fails to uninstall the desired app because:
-        //   - we cannot find the app to be uninstalled.
-        //   - the app to be uninstalled is not removable.
-        aMm.sendAsyncMessage("Webapps:Uninstall:Return:KO", aData);
+  doUninstall: Task.async(function*(aData, aMm) {
+    // The yields here could get stuck forever, so we only hold
+    // a weak reference to the message manager while yielding, to avoid
+    // leaking the whole page associationed with the message manager.
+    aMm = Cu.getWeakReference(aMm);
+
+    let response = "Webapps:Uninstall:Return:OK";
+
+    try {
+      aData.app = yield this._getAppWithManifest(aData.manifestURL);
+
+      let prefName = "dom.mozApps.auto_confirm_uninstall";
+      if (Services.prefs.prefHasUserValue(prefName) &&
+          Services.prefs.getBoolPref(prefName)) {
+        yield this._uninstallApp(aData.app);
+      } else {
+        yield this._promptForUninstall(aData);
       }
-    );
+    } catch (error) {
+      aData.error = error;
+      response = "Webapps:Uninstall:Return:KO";
+    }
+
+    if (aMm = aMm.get()) {
+      aMm.sendAsyncMessage(response, aData);
+    }
+  }),
+
+  uninstall: function(aManifestURL) {
+    return this._getAppWithManifest(aManifestURL)
+      .then(this._uninstallApp.bind(this));
   },
 
-  uninstall: function(aManifestURL, aOnSuccess, aOnFailure) {
-    debug("uninstall " + aManifestURL);
-
-    let app = this.getAppByManifestURL(aManifestURL);
-    if (!app) {
-      aOnFailure("NO_SUCH_APP");
-      return;
+  _uninstallApp: Task.async(function*(aApp) {
+    if (!aApp.removable) {
+      debug("Error: cannot uninstall a non-removable app.");
+      throw new Error("NON_REMOVABLE_APP");
     }
-    let id = app.id;
-
-    if (!app.removable) {
-      debug("Error: cannot uninstall a non-removable app.");
-      aOnFailure("NON_REMOVABLE_APP");
-      return;
-    }
+
+    let id = aApp.id;
 
     // Check if we are downloading something for this app, and cancel the
     // download if needed.
-    this.cancelDownload(app.manifestURL);
+    this.cancelDownload(aApp.manifestURL);
 
     // Clean up the deprecated manifest cache if needed.
     if (id in this._manifestCache) {
       delete this._manifestCache[id];
     }
 
     // Clear private data first.
-    this._clearPrivateData(app.localId, false);
+    this._clearPrivateData(aApp.localId, false);
 
     // Then notify observers.
-    // We have to clone the app object as nsIDOMApplication objects are
-    // stringified as an empty object. (see bug 830376)
-    let appClone = AppsUtils.cloneAppObject(app);
-    Services.obs.notifyObservers(null, "webapps-uninstall", JSON.stringify(appClone));
+    Services.obs.notifyObservers(null, "webapps-uninstall", JSON.stringify(aApp));
 
     if (supportSystemMessages()) {
-      this._readManifests([{ id: id }]).then((aResult) => {
-        this._unregisterActivities(aResult[0].manifest, app);
-      });
+      this._unregisterActivities(aApp.manifest, aApp);
     }
 
     let dir = this._getAppDir(id);
     try {
       dir.remove(true);
     } catch (e) {}
 
     delete this.webapps[id];
 
-    this._saveApps().then(() => {
-      this.broadcastMessage("Webapps:Uninstall:Broadcast:Return:OK", appClone);
-      this.broadcastMessage("Webapps:RemoveApp", { id: id });
-      try {
-        if (aOnSuccess) {
-          aOnSuccess();
-        }
-      } catch(ex) {
-        Cu.reportError("DOMApplicationRegistry: Exception on app uninstall: " +
-                       ex + "\n" + ex.stack);
-      }
-    });
+    yield this._saveApps();
+
+    this.broadcastMessage("Webapps:Uninstall:Broadcast:Return:OK", aApp);
+    this.broadcastMessage("Webapps:RemoveApp", { id: id });
+
+    return aApp;
+  }),
+
+  _promptForUninstall: function(aData) {
+    let deferred = Promise.defer();
+    this._pendingUninstalls[aData.requestID] = deferred;
+    Services.obs.notifyObservers(null, "webapps-ask-uninstall",
+                                 JSON.stringify(aData));
+    return deferred.promise;
+  },
+
+  confirmUninstall: function(aData) {
+    let pending = this._pendingUninstalls[aData.requestID];
+    if (pending) {
+      delete this._pendingUninstalls[aData.requestID];
+      return this._uninstallApp(aData.app).then(() => {
+        pending.resolve();
+        return aData.app;
+      });
+    }
+    return Promise.reject(new Error("PENDING_UNINSTALL_NOT_FOUND"));
+  },
+
+  denyUninstall: function(aData, aReason = "ERROR_UNKNOWN_FAILURE") {
+    // Fails to uninstall the desired app because:
+    //   - we cannot find the app to be uninstalled.
+    //   - the app to be uninstalled is not removable.
+    //   - the user declined the confirmation
+    debug("Failed to uninstall app: " + aReason);
+    let pending = this._pendingUninstalls[aData.requestID];
+    if (pending) {
+      delete this._pendingUninstalls[aData.requestID];
+      pending.reject(new Error(aReason));
+      return Promise.resolve();
+    }
+    return Promise.reject(new Error("PENDING_UNINSTALL_NOT_FOUND"));
   },
 
   getSelf: function(aData, aMm) {
     aData.apps = [];
 
     if (aData.appId == Ci.nsIScriptSecurityManager.NO_APP_ID ||
         aData.appId == Ci.nsIScriptSecurityManager.UNKNOWN_APP_ID) {
       aMm.sendAsyncMessage("Webapps:GetSelf:Return:OK", aData);
@@ -4062,16 +4098,27 @@ this.DOMApplicationRegistry = {
       return aResult[0].manifest;
     });
   },
 
   getAppByManifestURL: function(aManifestURL) {
     return AppsUtils.getAppByManifestURL(this.webapps, aManifestURL);
   },
 
+  _getAppWithManifest: Task.async(function*(aManifestURL) {
+    let app = this.getAppByManifestURL(aManifestURL);
+    if (!app) {
+      throw new Error("NO_SUCH_APP");
+    }
+
+    app.manifest = ( yield this._readManifests([{ id: app.id }]) )[0].manifest;
+
+    return app;
+  }),
+
   getCSPByLocalId: function(aLocalId) {
     debug("getCSPByLocalId:" + aLocalId);
     return AppsUtils.getCSPByLocalId(this.webapps, aLocalId);
   },
 
   getAppLocalIdByStoreId: function(aStoreId) {
     debug("getAppLocalIdByStoreId:" + aStoreId);
     return AppsUtils.getAppLocalIdByStoreId(this.webapps, aStoreId);
--- a/webapprt/WebappManager.jsm
+++ b/webapprt/WebappManager.jsm
@@ -14,26 +14,33 @@ Cu.import("resource://gre/modules/XPCOMU
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Webapps.jsm");
 Cu.import("resource://gre/modules/AppsUtils.jsm");
 Cu.import("resource://gre/modules/NativeApp.jsm");
 Cu.import("resource://gre/modules/WebappOSUtils.jsm");
 Cu.import("resource://webapprt/modules/WebappRT.jsm");
 
 this.WebappManager = {
-  observe: function(subject, topic, data) {
-    data = JSON.parse(data);
-    data.mm = subject;
+  observe: function(aSubject, aTopic, aData) {
+    let data = JSON.parse(aData);
+    data.mm = aSubject;
 
-    switch (topic) {
+    let chromeWin;
+    switch (aTopic) {
       case "webapps-ask-install":
-        let chromeWin = Services.wm.getOuterWindowWithId(data.oid);
+        chromeWin = Services.wm.getOuterWindowWithId(data.oid);
         if (chromeWin)
           this.doInstall(data, chromeWin);
         break;
+      case "webapps-ask-uninstall":
+        chromeWin = Services.wm.getOuterWindowWithId(data.windowId);
+        if (chromeWin) {
+          this.doUninstall(data, chromeWin);
+        }
+        break;
       case "webapps-launch":
         WebappOSUtils.launch(data);
         break;
       case "webapps-uninstall":
         WebappOSUtils.uninstall(data);
         break;
     }
   },
@@ -70,32 +77,64 @@ this.WebappManager = {
     if (choice == 0) {
       let nativeApp = new NativeApp(data.app, jsonManifest,
                                     data.app.categories,
                                     WebappRT.config.registryDir);
       let localDir;
       try {
         localDir = nativeApp.createProfile();
       } catch (ex) {
-        DOMApplicationRegistry.denyInstall(aData);
+        DOMApplicationRegistry.denyInstall(data);
         return;
       }
 
       DOMApplicationRegistry.confirmInstall(data, localDir,
         Task.async(function*(aApp, aManifest, aZipPath) {
           yield nativeApp.install(aApp, aManifest, aZipPath);
         })
       );
     } else {
       DOMApplicationRegistry.denyInstall(data);
     }
   },
 
+  doUninstall: function(aData, aWindow) {
+    let jsonManifest = aData.isPackage ? aData.app.updateManifest : aData.app.manifest;
+    let manifest = new ManifestHelper(jsonManifest, aData.app.origin,
+                                      aData.app.manifestURL);
+    let name = manifest.name;
+    let bundle = Services.strings.createBundle("chrome://webapprt/locale/webapp.properties");
+
+    let choice = Services.prompt.confirmEx(
+      aWindow,
+      bundle.formatStringFromName("webapps.uninstall.title", [name], 1),
+      bundle.formatStringFromName("webapps.uninstall.description", [name], 1),
+      // Set both buttons to strings with the cancel button being default
+      Ci.nsIPromptService.BUTTON_POS_1_DEFAULT |
+        Ci.nsIPromptService.BUTTON_TITLE_IS_STRING * Ci.nsIPromptService.BUTTON_POS_0 |
+        Ci.nsIPromptService.BUTTON_TITLE_IS_STRING * Ci.nsIPromptService.BUTTON_POS_1,
+      bundle.GetStringFromName("webapps.uninstall.uninstall"),
+      bundle.GetStringFromName("webapps.uninstall.dontuninstall"),
+      null,
+      null,
+      {});
+
+    // Perform the uninstall if the user allows it
+    if (choice == 0) {
+      DOMApplicationRegistry.confirmUninstall(aData).then((aApp) => {
+        WebappOSUtils.uninstall(aApp);
+      });
+    } else {
+      DOMApplicationRegistry.denyUninstall(aData);
+    }
+  },
+
   QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver,
                                          Ci.nsISupportsWeakReference])
 };
 
 Services.obs.addObserver(WebappManager, "webapps-ask-install", false);
+Services.obs.addObserver(WebappManager, "webapps-ask-uninstall", false);
 Services.obs.addObserver(WebappManager, "webapps-launch", false);
 Services.obs.addObserver(WebappManager, "webapps-uninstall", false);
 Services.obs.addObserver(WebappManager, "webapps-update", false);
 
 DOMApplicationRegistry.registerUpdateHandler(WebappManager.update);
--- a/webapprt/locales/en-US/webapprt/webapp.properties
+++ b/webapprt/locales/en-US/webapprt/webapp.properties
@@ -35,11 +35,19 @@ desktop-notification.remember=Remember m
 # LOCALIZATION NOTE (webapps.install.title): %S will be replaced with the name
 # of the webapp being installed.
 webapps.install.title=Install %S
 # LOCALIZATION NOTE (webapps.install.description): %S will be replaced with the
 # name of the webapp being installed.
 webapps.install.description=Do you want to install %S?
 webapps.install.install=Install App
 webapps.install.dontinstall=Don't Install
+# LOCALIZATION NOTE (webapps.uninstall.title): %S will be replaced with the name
+# of the webapp being uninstalled.
+webapps.uninstall.title=Uninstall %S
+# LOCALIZATION NOTE (webapps.uninstall.description): %S will be replaced with the
+# name of the webapp being uninstalled.
+webapps.uninstall.description=Do you want to uninstall %S?
+webapps.uninstall.uninstall=Uninstall App
+webapps.uninstall.dontuninstall=Don't Uninstall
 
 paymentDialog.title=Payment
 paymentDialog.message=Which payment provider do you want to use?