Bug 1220136 - WebExtensions support chrome.management.uninstallSelf, r?kmag draft
authorBob Silverberg <bsilverberg@mozilla.com>
Wed, 07 Sep 2016 10:06:01 -0400
changeset 411216 b1e88e6bf3c3c70c8e0c3ec3fc0b2a7809353ee2
parent 410335 394f02edb7cb33ad70074c77b523451749c5ed0b
child 530691 1ba63c9f291359fc1b43da7d0c09628a0bdcc642
push id28855
push userbmo:bob.silverberg@gmail.com
push dateWed, 07 Sep 2016 19:18:12 +0000
reviewerskmag
bugs1220136
milestone51.0a1
Bug 1220136 - WebExtensions support chrome.management.uninstallSelf, r?kmag MozReview-Commit-ID: L80ynbuFr7U
toolkit/components/extensions/ext-management.js
toolkit/components/extensions/schemas/management.json
toolkit/components/extensions/test/xpcshell/test_ext_management_uninstall_self.js
toolkit/components/extensions/test/xpcshell/xpcshell.ini
toolkit/locales/en-US/chrome/global/extensions.properties
--- a/toolkit/components/extensions/ext-management.js
+++ b/toolkit/components/extensions/ext-management.js
@@ -1,14 +1,30 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 "use strict";
 
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+XPCOMUtils.defineLazyGetter(this, "strBundle", function() {
+  const stringSvc = Cc["@mozilla.org/intl/stringbundle;1"].getService(Ci.nsIStringBundleService);
+  return stringSvc.createBundle("chrome://global/locale/extensions.properties");
+});
 XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
                                   "resource://gre/modules/AddonManager.jsm");
+XPCOMUtils.defineLazyServiceGetter(this, "promptService",
+                                   "@mozilla.org/embedcomp/prompt-service;1",
+                                   "nsIPromptService");
+
+function _(key, ...args) {
+  if (args.length) {
+    return strBundle.formatStringFromName(key, args, args.length);
+  }
+  return strBundle.GetStringFromName(key);
+}
 
 function installType(addon) {
   if (addon.temporarilyInstalled) {
     return "development";
   } else if (addon.foreignInstall) {
     return "sideload";
   } else if (addon.isSystem) {
     return "other";
@@ -47,16 +63,47 @@ extensions.registerSchemaAPI("management
             }
             if (m.icons) {
               extInfo.icons = Object.keys(m.icons).map(key => {
                 return {size: Number(key), url: m.icons[key]};
               });
             }
 
             resolve(extInfo);
-          } catch (e) {
-            reject(e);
+          } catch (err) {
+            reject(err);
           }
         }));
       },
+
+      uninstallSelf: function(options) {
+        return new Promise((resolve, reject) => {
+          if (options && options.showConfirmDialog) {
+            let message = _("uninstall.confirmation.message", extension.name);
+            if (options.dialogMessage) {
+              message = `${options.dialogMessage}\n${message}`;
+            }
+            let title = _("uninstall.confirmation.title", extension.name);
+            let buttonFlags = promptService.BUTTON_POS_0 * promptService.BUTTON_TITLE_IS_STRING +
+                              promptService.BUTTON_POS_1 * promptService.BUTTON_TITLE_IS_STRING;
+            let button0Title = _("uninstall.confirmation.button-0.label");
+            let button1Title = _("uninstall.confirmation.button-1.label");
+            let response = promptService.confirmEx(null, title, message, buttonFlags, button0Title, button1Title, null, null, {value: 0});
+            if (response == 1) {
+              return reject({message: "User cancelled uninstall of extension"});
+            }
+          }
+          AddonManager.getAddonByID(extension.id, addon => {
+            let canUninstall = Boolean(addon.permissions & AddonManager.PERM_CAN_UNINSTALL);
+            if (!canUninstall) {
+              return reject({message: "The add-on cannot be uninstalled"});
+            }
+            try {
+              addon.uninstall();
+            } catch (err) {
+              return reject(err);
+            }
+          });
+        });
+      },
     },
   };
 });
--- a/toolkit/components/extensions/schemas/management.json
+++ b/toolkit/components/extensions/schemas/management.json
@@ -212,29 +212,33 @@
               }
             ]
           }
         ]
       },
       {
         "name": "uninstallSelf",
         "type": "function",
-        "unsupported": true,
         "description": "Uninstalls the calling extension. Note: This function can be used without requesting the 'management' permission in the manifest.",
         "async": "callback",
         "parameters": [
           {
             "type": "object",
             "name": "options",
             "optional": true,
             "properties": {
               "showConfirmDialog": {
                 "type": "boolean",
                 "optional": true,
                 "description": "Whether or not a confirm-uninstall dialog should prompt the user. Defaults to false."
+              },
+              "dialogMessage": {
+                "type": "string",
+                "optional": true,
+                "description": "The message to display to a user when being asked to confirm removal of the extension."
               }
             }
           },
           {
             "name": "callback",
             "type": "function",
             "optional": true,
             "parameters": []
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_management_uninstall_self.js
@@ -0,0 +1,137 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+Cu.import("resource://gre/modules/AddonManager.jsm");
+Cu.import("resource://testing-common/AddonTestUtils.jsm");
+Cu.import("resource://testing-common/MockRegistrar.jsm");
+
+const {promiseAddonByID} = AddonTestUtils;
+const id = "uninstall_self_test@tests.mozilla.com";
+
+const manifest = {
+  applications: {
+    gecko: {
+      id,
+    },
+  },
+  name: "test extension name",
+  version: "1.0",
+};
+
+const waitForUninstalled = new Promise(resolve => {
+  const listener = {
+    onUninstalled: (addon) => {
+      equal(addon.id, id, "The expected add-on has been uninstalled");
+      AddonManager.getAddonByID(addon.id, checkedAddon => {
+        equal(checkedAddon, null, "Add-on no longer exists");
+        AddonManager.removeAddonListener(listener);
+        resolve();
+      });
+    },
+  };
+  AddonManager.addAddonListener(listener);
+});
+
+let promptService = {
+  _response: null,
+  QueryInterface: XPCOMUtils.generateQI([Ci.nsIPromptService]),
+  confirmEx: function(...args) {
+    this._confirmExArgs = args;
+    return this._response;
+  },
+};
+
+add_task(function* setup() {
+  let fakePromptService = MockRegistrar.register("@mozilla.org/embedcomp/prompt-service;1", promptService);
+  do_register_cleanup(() => {
+    MockRegistrar.unregister(fakePromptService);
+  });
+  yield ExtensionTestUtils.startAddonManager();
+});
+
+add_task(function* test_management_uninstall_no_prompt() {
+  function background() {
+    browser.test.onMessage.addListener(msg => {
+      browser.management.uninstallSelf();
+    });
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest,
+    background,
+    useAddonManager: "temporary",
+  });
+
+  yield extension.startup();
+  let addon = yield promiseAddonByID(id);
+  notEqual(addon, null, "Add-on is installed");
+  extension.sendMessage("uninstall");
+  yield waitForUninstalled;
+  yield extension.markUnloaded();
+  Services.obs.notifyObservers(extension.extension.file, "flush-cache-entry", null);
+});
+
+add_task(function* test_management_uninstall_prompt_uninstall() {
+  promptService._response = 0;
+
+  function background() {
+    browser.test.onMessage.addListener(msg => {
+      browser.management.uninstallSelf({showConfirmDialog: true});
+    });
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest,
+    background,
+    useAddonManager: "temporary",
+  });
+
+  yield extension.startup();
+  let addon = yield promiseAddonByID(id);
+  notEqual(addon, null, "Add-on is installed");
+  extension.sendMessage("uninstall");
+  yield waitForUninstalled;
+  yield extension.markUnloaded();
+
+  // Test localization strings
+  equal(promptService._confirmExArgs[1], `Uninstall ${manifest.name}`);
+  equal(promptService._confirmExArgs[2],
+        `The extension “${manifest.name}” is requesting to be uninstalled. What would you like to do?`);
+  equal(promptService._confirmExArgs[4], "Uninstall");
+  equal(promptService._confirmExArgs[5], "Keep Installed");
+  Services.obs.notifyObservers(extension.extension.file, "flush-cache-entry", null);
+});
+
+add_task(function* test_management_uninstall_prompt_keep() {
+  promptService._response = 1;
+
+  function background() {
+    browser.test.onMessage.addListener(msg => {
+      browser.management.uninstallSelf({showConfirmDialog: true}).then(() => {
+        browser.test.fail("uninstallSelf rejects when user declines uninstall");
+      }, error => {
+        browser.test.assertEq("User cancelled uninstall of extension",
+                              error.message,
+                              "Expected rejection when user declines uninstall");
+        browser.test.sendMessage("uninstall-rejected");
+      });
+    });
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest,
+    background,
+    useAddonManager: "temporary",
+  });
+
+  yield extension.startup();
+  let addon = yield promiseAddonByID(id);
+  notEqual(addon, null, "Add-on is installed");
+  extension.sendMessage("uninstall");
+  yield extension.awaitMessage("uninstall-rejected");
+  addon = yield promiseAddonByID(id);
+  notEqual(addon, null, "Add-on remains installed");
+  yield extension.unload();
+  Services.obs.notifyObservers(extension.extension.file, "flush-cache-entry", null);
+});
--- a/toolkit/components/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini
@@ -33,16 +33,17 @@ skip-if = os == "android"
 skip-if = os == "android"
 [test_ext_experiments.js]
 skip-if = release_build
 [test_ext_extension.js]
 [test_ext_idle.js]
 [test_ext_json_parser.js]
 [test_ext_localStorage.js]
 [test_ext_management.js]
+[test_ext_management_uninstall_self.js]
 [test_ext_manifest_content_security_policy.js]
 [test_ext_manifest_incognito.js]
 [test_ext_manifest_minimum_chrome_version.js]
 [test_ext_onmessage_removelistener.js]
 [test_ext_runtime_connect_no_receiver.js]
 [test_ext_runtime_getPlatformInfo.js]
 [test_ext_runtime_sendMessage.js]
 [test_ext_runtime_sendMessage_errors.js]
--- a/toolkit/locales/en-US/chrome/global/extensions.properties
+++ b/toolkit/locales/en-US/chrome/global/extensions.properties
@@ -13,8 +13,17 @@ csp.error.illegal-protocol = ‘%1$S’ directive contains a forbidden %2$S: protocol source
 #LOCALIZATION NOTE (csp.error.missing-host) %2$S a protocol name, such as "http", which appears as "http:", as it would in a URL.
 csp.error.missing-host = %2$S: protocol requires a host in ‘%1$S’ directives
 
 #LOCALIZATION NOTE (csp.error.missing-source) %1$S is the name of a CSP directive, such as "script-src". %2$S is the name of a CSP source, usually 'self'.
 csp.error.missing-source = ‘%1$S’ must include the source %2$S
 
 #LOCALIZATION NOTE (csp.error.illegal-host-wildcard) %2$S a protocol name, such as "http", which appears as "http:", as it would in a URL.
 csp.error.illegal-host-wildcard = %2$S: wildcard sources in ‘%1$S’ directives must include at least one non-generic sub-domain (e.g., *.example.com rather than *.com)
+
+#LOCALIZATION NOTE (uninstall.confirmation.title) %S is the name of the extension which is about to be uninstalled.
+uninstall.confirmation.title = Uninstall %S
+
+#LOCALIZATION NOTE (uninstall.confirmation.message) %S is the name of the extension which is about to be uninstalled.
+uninstall.confirmation.message = The extension “%S” is requesting to be uninstalled. What would you like to do?
+
+uninstall.confirmation.button-0.label = Uninstall
+uninstall.confirmation.button-1.label = Keep Installed