Bug 1336908 implement management APIs needed for theme management, r?aswan draft
authorShane Caraveo <scaraveo@mozilla.com>
Thu, 04 May 2017 11:40:02 -0700
changeset 572764 5837e125c928d38ed24d0402b293d32901af2d3b
parent 572730 0b255199db9d6a6f189b89b7906f99155bde3726
child 627127 1e359e5c566dfe3e3023fe946b719be3ba1ec2d4
push id57186
push usermixedpuppy@gmail.com
push dateThu, 04 May 2017 18:41:27 +0000
reviewersaswan
bugs1336908
milestone55.0a1
Bug 1336908 implement management APIs needed for theme management, r?aswan MozReview-Commit-ID: 8tZpCE3nXGr
browser/components/extensions/test/mochitest/mochitest.ini
mobile/android/components/extensions/test/mochitest/mochitest.ini
toolkit/components/extensions/ext-management.js
toolkit/components/extensions/schemas/management.json
toolkit/components/extensions/test/browser/browser.ini
toolkit/components/extensions/test/browser/browser_ext_management_themes.js
toolkit/components/extensions/test/xpcshell/test_ext_management.js
--- a/browser/components/extensions/test/mochitest/mochitest.ini
+++ b/browser/components/extensions/test/mochitest/mochitest.ini
@@ -1,6 +1,7 @@
 [DEFAULT]
 support-files =
   ../../../../../toolkit/components/extensions/test/mochitest/test_ext_all_apis.js
+  ../../../../../toolkit/components/extensions/test/mochitest/file_sample.html
 tags = webextensions
 
 [test_ext_all_apis.html]
--- a/mobile/android/components/extensions/test/mochitest/mochitest.ini
+++ b/mobile/android/components/extensions/test/mochitest/mochitest.ini
@@ -1,11 +1,12 @@
 [DEFAULT]
 support-files =
   ../../../../../../toolkit/components/extensions/test/mochitest/test_ext_all_apis.js
+  ../../../../../../toolkit/components/extensions/test/mochitest/file_sample.html
   ../../../../../../toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js
   context.html
   context_tabs_onUpdated_iframe.html
   context_tabs_onUpdated_page.html
   file_bypass_cache.sjs
   file_dummy.html
   file_iframe_document.html
   file_iframe_document.sjs
--- a/toolkit/components/extensions/ext-management.js
+++ b/toolkit/components/extensions/ext-management.js
@@ -6,16 +6,27 @@ XPCOMUtils.defineLazyGetter(this, "strBu
   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");
+XPCOMUtils.defineLazyModuleGetter(this, "EventEmitter",
+                                  "resource://devtools/shared/event-emitter.js");
+
+XPCOMUtils.defineLazyGetter(this, "GlobalManager", () => {
+  const {GlobalManager} = Cu.import("resource://gre/modules/Extension.jsm", {});
+  return GlobalManager;
+});
+
+var {
+  ExtensionError,
+} = ExtensionUtils;
 
 function _(key, ...args) {
   if (args.length) {
     return strBundle.formatStringFromName(key, args, args.length);
   }
   return strBundle.GetStringFromName(key);
 }
 
@@ -25,85 +36,218 @@ function installType(addon) {
   } else if (addon.foreignInstall) {
     return "sideload";
   } else if (addon.isSystem) {
     return "other";
   }
   return "normal";
 }
 
+function getExtensionInfoForAddon(extension, addon) {
+  let extInfo = {
+    id: addon.id,
+    name: addon.name,
+    description: addon.description || "",
+    version: addon.version,
+    mayDisable: !!(addon.permissions & AddonManager.PERM_CAN_DISABLE),
+    enabled: addon.isActive,
+    optionsUrl: addon.optionsURL || "",
+    installType: installType(addon),
+    type: addon.type,
+  };
+
+  if (extension) {
+    let m = extension.manifest;
+    extInfo.permissions = Array.from(extension.permissions).filter(perm => {
+      return !extension.whiteListedHosts.pat.includes(perm);
+    });
+    extInfo.hostPermissions = extension.whiteListedHosts.pat;
+    extInfo.shortName = m.short_name || "";
+    if (m.icons) {
+      extInfo.icons = Object.keys(m.icons).map(key => {
+        return {size: Number(key), url: m.icons[key]};
+      });
+    }
+  }
+
+  if (!addon.isActive) {
+    extInfo.disabledReason = "unknown";
+  }
+  if (addon.homepageURL) {
+    extInfo.homepageUrl = addon.homepageURL;
+  }
+  if (addon.updateURL) {
+    extInfo.updateUrl = addon.updateURL;
+  }
+  return extInfo;
+}
+
+const listenerMap = new WeakMap();
+// Some management APIs are intentionally limited.
+const allowedTypes = ["theme"];
+
+class AddonListener {
+  constructor() {
+    AddonManager.addAddonListener(this);
+    EventEmitter.decorate(this);
+  }
+
+  release() {
+    AddonManager.removeAddonListener(this);
+  }
+
+  getExtensionInfo(addon) {
+    let ext = addon.isWebExtension && GlobalManager.extensionMap.get(addon.id);
+    return getExtensionInfoForAddon(ext, addon);
+  }
+
+  onEnabled(addon) {
+    if (!allowedTypes.includes(addon.type)) {
+      return;
+    }
+    this.emit("onEnabled", this.getExtensionInfo(addon));
+  }
+
+  onDisabled(addon) {
+    if (!allowedTypes.includes(addon.type)) {
+      return;
+    }
+    this.emit("onDisabled", this.getExtensionInfo(addon));
+  }
+
+  onInstalled(addon) {
+    if (!allowedTypes.includes(addon.type)) {
+      return;
+    }
+    this.emit("onInstalled", this.getExtensionInfo(addon));
+  }
+
+  onUninstalled(addon) {
+    if (!allowedTypes.includes(addon.type)) {
+      return;
+    }
+    this.emit("onUninstalled", this.getExtensionInfo(addon));
+  }
+}
+
+let addonListener;
+
+function getListener(extension, context) {
+  if (!listenerMap.has(extension)) {
+    if (!addonListener) {
+      addonListener = new AddonListener();
+    }
+    listenerMap.set(extension, {});
+    context.callOnClose({
+      close: () => {
+        listenerMap.delete(extension);
+        if (listenerMap.length === 0) {
+          addonListener.release();
+          addonListener = null;
+        }
+      },
+    });
+  }
+  return addonListener;
+}
+
 this.management = class extends ExtensionAPI {
   getAPI(context) {
     let {extension} = context;
     return {
       management: {
-        getSelf: function() {
-          return new Promise((resolve, reject) => AddonManager.getAddonByID(extension.id, addon => {
-            try {
-              let m = extension.manifest;
-              let extInfo = {
-                id: extension.id,
-                name: addon.name,
-                shortName: m.short_name || "",
-                description: addon.description || "",
-                version: addon.version,
-                mayDisable: !!(addon.permissions & AddonManager.PERM_CAN_DISABLE),
-                enabled: addon.isActive,
-                optionsUrl: addon.optionsURL || "",
-                permissions: Array.from(extension.permissions).filter(perm => {
-                  return !extension.whiteListedHosts.pat.includes(perm);
-                }),
-                hostPermissions: extension.whiteListedHosts.pat,
-                installType: installType(addon),
-              };
-              if (addon.homepageURL) {
-                extInfo.homepageUrl = addon.homepageURL;
-              }
-              if (addon.updateURL) {
-                extInfo.updateUrl = addon.updateURL;
-              }
-              if (m.icons) {
-                extInfo.icons = Object.keys(m.icons).map(key => {
-                  return {size: Number(key), url: m.icons[key]};
-                });
-              }
+        async getAll() {
+          let addons = await AddonManager.getAddonsByTypes(allowedTypes);
+          return addons.map(addon => {
+            // If the extension is enabled get it and use it for more data.
+            let ext = addon.isWebExtension && GlobalManager.extensionMap.get(addon.id);
+            return getExtensionInfoForAddon(ext, addon);
+          });
+        },
+
+        async getSelf() {
+          let addon = await AddonManager.getAddonByID(extension.id);
+          return getExtensionInfoForAddon(extension, addon);
+        },
 
-              resolve(extInfo);
-            } catch (err) {
-              reject(err);
+        async uninstallSelf(options) {
+          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) {
+              throw new ExtensionError("User cancelled uninstall of extension");
+            }
+          }
+          let addon = await AddonManager.getAddonByID(extension.id);
+          let canUninstall = Boolean(addon.permissions & AddonManager.PERM_CAN_UNINSTALL);
+          if (!canUninstall) {
+            throw new ExtensionError("The add-on cannot be uninstalled");
+          }
+          addon.uninstall();
         },
 
-        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);
-              }
-            });
-          });
+        async setEnabled(id, enabled) {
+          let addon = await AddonManager.getAddonByID(id);
+          if (!addon) {
+            throw new ExtensionError(`No such addon ${id}`);
+          }
+          if (!allowedTypes.includes(addon.type)) {
+            throw new ExtensionError("setEnabled applies only to theme addons");
+          }
+          addon.userDisabled = !enabled;
         },
+
+        onDisabled: new SingletonEventManager(context, "management.onDisabled", fire => {
+          let listener = (event, data) => {
+            fire.async(data);
+          };
+
+          getListener(extension, context).on("onDisabled", listener);
+          return () => {
+            getListener(extension, context).off("onDisabled", listener);
+          };
+        }).api(),
+
+        onEnabled: new SingletonEventManager(context, "management.onEnabled", fire => {
+          let listener = (event, data) => {
+            fire.async(data);
+          };
+
+          getListener(extension, context).on("onEnabled", listener);
+          return () => {
+            getListener(extension, context).off("onEnabled", listener);
+          };
+        }).api(),
+
+        onInstalled: new SingletonEventManager(context, "management.onInstalled", fire => {
+          let listener = (event, data) => {
+            fire.async(data);
+          };
+
+          getListener(extension, context).on("onInstalled", listener);
+          return () => {
+            getListener(extension, context).off("onInstalled", listener);
+          };
+        }).api(),
+
+        onUninstalled: new SingletonEventManager(context, "management.onUninstalled", fire => {
+          let listener = (event, data) => {
+            fire.async(data);
+          };
+
+          getListener(extension, context).on("onUninstalled", listener);
+          return () => {
+            getListener(extension, context).off("onUninstalled", listener);
+          };
+        }).api(),
+
       },
     };
   }
 };
--- a/toolkit/components/extensions/schemas/management.json
+++ b/toolkit/components/extensions/schemas/management.json
@@ -64,17 +64,18 @@
             "type": "string"
           },
           "name": {
             "description": "The name of this extension.",
             "type": "string"
           },
           "shortName": {
             "description": "A short version of the name of this extension.",
-            "type": "string"
+            "type": "string",
+            "optional": true
           },
           "description": {
             "description": "The description of this extension.",
             "type": "string"
           },
           "version": {
             "description": "The <a href='manifest/version'>version</a> of this extension.",
             "type": "string"
@@ -121,40 +122,41 @@
             "optional": true,
             "items": {
               "$ref": "IconInfo"
             }
           },
           "permissions": {
             "description": "Returns a list of API based permissions.",
             "type": "array",
+            "optional": true,
             "items" : {
               "type": "string"
             }
           },
           "hostPermissions": {
             "description": "Returns a list of host based permissions.",
             "type": "array",
+            "optional": true,
             "items" : {
               "type": "string"
             }
           },
           "installType": {
             "description": "How the extension was installed.",
             "$ref": "ExtensionInstallType"
           }
         }
       }
     ],
     "functions": [
       {
         "name": "getAll",
         "type": "function",
         "permissions": ["management"],
-        "unsupported": true,
         "description": "Returns a list of information about installed extensions.",
         "async": "callback",
         "parameters": [
           {
             "name": "callback",
             "type": "function",
             "optional": true,
             "parameters": [
@@ -239,12 +241,87 @@
           },
           {
             "name": "callback",
             "type": "function",
             "optional": true,
             "parameters": []
           }
         ]
+      },
+      {
+        "name": "setEnabled",
+        "type": "function",
+        "permissions": ["management"],
+        "description": "Enables or disables the given add-on.",
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "id",
+            "type": "string",
+            "description": "ID of the add-on to enable/disable."
+          },
+          {
+            "name": "enabled",
+            "type": "boolean",
+            "description": "Whether to enable or disable the add-on."
+          },
+          {
+            "name": "callback",
+            "type": "function",
+            "optional": true,
+            "parameters": []
+          }
+        ]
+      }
+    ],
+    "events": [
+      {
+        "name": "onDisabled",
+        "type": "function",
+        "permissions": ["management"],
+        "description": "Fired when an addon has been disabled.",
+        "parameters": [
+          {
+            "name": "info",
+            "$ref": "ExtensionInfo"
+          }
+        ]
+      },
+      {
+        "name": "onEnabled",
+        "type": "function",
+        "permissions": ["management"],
+        "description": "Fired when an addon has been enabled.",
+        "parameters": [
+          {
+            "name": "info",
+            "$ref": "ExtensionInfo"
+          }
+        ]
+      },
+      {
+        "name": "onInstalled",
+        "type": "function",
+        "permissions": ["management"],
+        "description": "Fired when an addon has been installed.",
+        "parameters": [
+          {
+            "name": "info",
+            "$ref": "ExtensionInfo"
+          }
+        ]
+      },
+      {
+        "name": "onUninstalled",
+        "type": "function",
+        "permissions": ["management"],
+        "description": "Fired when an addon has been uninstalled.",
+        "parameters": [
+          {
+            "name": "info",
+            "$ref": "ExtensionInfo"
+          }
+        ]
       }
     ]
   }
 ]
--- a/toolkit/components/extensions/test/browser/browser.ini
+++ b/toolkit/components/extensions/test/browser/browser.ini
@@ -2,8 +2,9 @@
 support-files =
   head.js
 
 [browser_ext_themes_chromeparity.js]
 [browser_ext_themes_dynamic_updates.js]
 [browser_ext_themes_lwtsupport.js]
 [browser_ext_themes_multiple_backgrounds.js]
 [browser_ext_themes_persistence.js]
+[browser_ext_management_themes.js]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_management_themes.js
@@ -0,0 +1,133 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const {LightweightThemeManager} = Cu.import("resource://gre/modules/LightweightThemeManager.jsm", {});
+
+add_task(async function setup() {
+  await SpecialPowers.pushPrefEnv({
+    set: [["extensions.webextensions.themes.enabled", true]],
+  });
+});
+
+add_task(async function test_management_themes() {
+  let theme = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "name": "Simple theme test",
+      "version": "1.0",
+      "description": "test theme",
+      "theme": {
+        "images": {
+          "headerURL": "image1.png",
+        },
+      },
+    },
+    files: {
+      "image1.png": BACKGROUND,
+    },
+    useAddonManager: "temporary",
+  });
+
+  async function background() {
+    browser.management.onInstalled.addListener(info => {
+      browser.test.log(`${info.name} was installed`);
+      browser.test.assertEq(info.type, "theme", "addon is theme");
+      browser.test.sendMessage("onInstalled", info.name);
+    });
+    browser.management.onDisabled.addListener(info => {
+      browser.test.log(`${info.name} was disabled`);
+      browser.test.assertEq(info.type, "theme", "addon is theme");
+      browser.test.sendMessage("onDisabled", info.name);
+    });
+    browser.management.onEnabled.addListener(info => {
+      browser.test.log(`${info.name} was enabled`);
+      browser.test.assertEq(info.type, "theme", "addon is theme");
+      browser.test.sendMessage("onEnabled", info.name);
+    });
+    browser.management.onUninstalled.addListener(info => {
+      browser.test.log(`${info.name} was uninstalled`);
+      browser.test.assertEq(info.type, "theme", "addon is theme");
+      browser.test.sendMessage("onUninstalled", info.name);
+    });
+
+    async function getAddon(type) {
+      let addons = await browser.management.getAll();
+      // We get the 3 built-in themes plus the lwt and our addon.
+      browser.test.assertEq(5, addons.length, "got expected addons");
+      let found;
+      for (let addon of addons) {
+        browser.test.assertEq(addon.type, "theme", "addon is theme");
+        if (type == "theme" && addon.id.includes("temporary-addon")) {
+          found = addon;
+        } else if (type == "enabled" && addon.enabled) {
+          found = addon;
+        }
+      }
+      return found;
+    }
+
+    browser.test.onMessage.addListener(async (msg) => {
+      let theme = await getAddon("theme");
+      browser.test.assertEq(theme.description, "test theme", "description is correct");
+      browser.test.assertTrue(theme.enabled, "theme is enabled");
+      await browser.management.setEnabled(theme.id, false);
+
+      theme = await getAddon("theme");
+
+      browser.test.assertTrue(!theme.enabled, "theme is disabled");
+      let addon = getAddon("enabled");
+      browser.test.assertTrue(addon, "another theme was enabled");
+
+      await browser.management.setEnabled(theme.id, true);
+      theme = await getAddon("theme");
+      addon = await getAddon("enabled");
+      browser.test.assertEq(theme.id, addon.id, "theme is enabled");
+
+      browser.test.sendMessage("done");
+    });
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      permissions: ["management"],
+    },
+    background,
+  });
+  await extension.startup();
+
+  // Test LWT
+  LightweightThemeManager.currentTheme = {
+    id: "lwt@personas.mozilla.org",
+    version: "1",
+    name: "Bling",
+    description: "SO MUCH BLING!",
+    author: "Pixel Pusher",
+    homepageURL: "http://mochi.test:8888/data/index.html",
+    headerURL: "http://mochi.test:8888/data/header.png",
+    previewURL: "http://mochi.test:8888/data/preview.png",
+    iconURL: "http://mochi.test:8888/data/icon.png",
+    textcolor: Math.random().toString(),
+    accentcolor: Math.random().toString(),
+  };
+  is(await extension.awaitMessage("onInstalled"), "Bling", "LWT installed");
+  is(await extension.awaitMessage("onDisabled"), "Default", "default disabled");
+  is(await extension.awaitMessage("onEnabled"), "Bling", "LWT enabled");
+
+  await theme.startup();
+  is(await extension.awaitMessage("onInstalled"), "Simple theme test", "webextension theme installed");
+  is(await extension.awaitMessage("onDisabled"), "Bling", "LWT disabled");
+  // no enabled event when installed.
+
+  extension.sendMessage("test");
+  is(await extension.awaitMessage("onEnabled"), "Default", "default enabled");
+  is(await extension.awaitMessage("onDisabled"), "Simple theme test", "addon disabled");
+  is(await extension.awaitMessage("onEnabled"), "Simple theme test", "addon enabled");
+  is(await extension.awaitMessage("onDisabled"), "Default", "default disabled");
+  await extension.awaitMessage("done");
+
+  await Promise.all([
+    theme.unload(),
+    extension.awaitMessage("onUninstalled"),
+  ]);
+  await extension.unload();
+});
--- a/toolkit/components/extensions/test/xpcshell/test_ext_management.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_management.js
@@ -1,20 +1,30 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 "use strict";
 
+XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
+                                  "resource://gre/modules/AddonManager.jsm");
+
+add_task(function* setup() {
+  yield ExtensionTestUtils.startAddonManager();
+});
+
 add_task(function* test_management_schema() {
-  function background() {
+  async function background() {
     browser.test.assertTrue(browser.management, "browser.management API exists");
+    let self = await browser.management.getSelf();
+    browser.test.assertEq(browser.runtime.id, self.id, "got self");
     browser.test.notifyPass("management-schema");
   }
 
   let extension = ExtensionTestUtils.loadExtension({
     manifest: {
       permissions: ["management"],
     },
     background: `(${background})()`,
+    useAddonManager: "temporary",
   });
   yield extension.startup();
   yield extension.awaitFinish("management-schema");
   yield extension.unload();
 });