Bug 1245571: Allow AMO to be able to query details about an add-on. r=rhelmer draft
authorDave Townsend <dtownsend@oxymoronical.com>
Thu, 10 Mar 2016 09:50:07 -0800
changeset 349113 6206982c687a8e1733ef323488fc2710a4967688
parent 349112 fe64953c2fde99a56868314edef1982156dadada
child 349548 30469ed487b8a6d6586186d75bb4735ef6790a2b
push id14992
push userdtownsend@mozilla.com
push dateFri, 08 Apr 2016 22:59:13 +0000
reviewersrhelmer
bugs1245571
milestone48.0a1
Bug 1245571: Allow AMO to be able to query details about an add-on. r=rhelmer This adds a bunch of structure supporting a promise-based API on the AddonManager object that is exposed to webpages and adds the first example, getAddonByID. MozReview-Commit-ID: CCEFl4R1o81
browser/installer/package-manifest.in
toolkit/mozapps/extensions/AddonManager.jsm
toolkit/mozapps/extensions/addonManager.js
toolkit/mozapps/extensions/amWebAPI.js
toolkit/mozapps/extensions/test/browser/browser.ini
toolkit/mozapps/extensions/test/browser/browser_webapi.js
--- a/browser/installer/package-manifest.in
+++ b/browser/installer/package-manifest.in
@@ -400,16 +400,17 @@
 @RESPATH@/components/nsHelperAppDlg.manifest
 @RESPATH@/components/nsHelperAppDlg.js
 @RESPATH@/components/NetworkGeolocationProvider.manifest
 @RESPATH@/components/NetworkGeolocationProvider.js
 @RESPATH@/components/extensions.manifest
 @RESPATH@/components/addonManager.js
 @RESPATH@/components/amContentHandler.js
 @RESPATH@/components/amInstallTrigger.js
+@RESPATH@/components/amWebAPI.js
 @RESPATH@/components/amWebInstallListener.js
 @RESPATH@/components/nsBlocklistService.js
 @RESPATH@/components/nsBlocklistServiceContent.js
 #ifdef MOZ_UPDATER
 @RESPATH@/components/nsUpdateService.manifest
 @RESPATH@/components/nsUpdateService.js
 @RESPATH@/components/nsUpdateServiceStub.js
 #endif
--- a/toolkit/mozapps/extensions/AddonManager.jsm
+++ b/toolkit/mozapps/extensions/AddonManager.jsm
@@ -302,16 +302,37 @@ function getLocale() {
   try {
     return Services.prefs.getCharPref(PREF_SELECTED_LOCALE);
   }
   catch (e) { }
 
   return "en-US";
 }
 
+function webAPIForAddon(addon) {
+  if (!addon) {
+    return null;
+  }
+
+  let result = {};
+
+  // By default just pass through any plain property, the webidl will control
+  // access.
+  for (let prop in addon) {
+    if (typeof(addon[prop]) != "function") {
+      result[prop] = addon[prop];
+    }
+  }
+
+  // A few properties are computed for a nicer API
+  result.isEnabled = !addon.userDisabled;
+
+  return result;
+}
+
 /**
  * A helper class to repeatedly call a listener with each object in an array
  * optionally checking whether the object has a method in it.
  *
  * @param  aObjects
  *         The array of objects to iterate through
  * @param  aMethod
  *         An optional method name, if not null any objects without this method
@@ -2756,16 +2777,26 @@ var AddonManagerInternal = {
     if (aValue != gUpdateEnabled)
       Services.prefs.setBoolPref(PREF_EM_UPDATE_ENABLED, aValue);
     return aValue;
   },
 
   get hotfixID() {
     return gHotfixID;
   },
+
+  webAPI: {
+    getAddonByID(id) {
+      return new Promise(resolve => {
+        AddonManager.getAddonByID(id, (addon) => {
+          resolve(webAPIForAddon(addon));
+        });
+      });
+    }
+  },
 };
 
 /**
  * Should not be used outside of core Mozilla code. This is a private API for
  * the startup and platform integration code to use. Refer to the methods on
  * AddonManagerInternal for documentation however note that these methods are
  * subject to change at any time.
  */
@@ -3337,16 +3368,20 @@ this.AddonManager = {
   escapeAddonURI: function(aAddon, aUri, aAppVersion) {
     return AddonManagerInternal.escapeAddonURI(aAddon, aUri, aAppVersion);
   },
 
   getPreferredIconURL: function(aAddon, aSize, aWindow = undefined) {
     return AddonManagerInternal.getPreferredIconURL(aAddon, aSize, aWindow);
   },
 
+  get webAPI() {
+    return AddonManagerInternal.webAPI;
+  },
+
   get shutdown() {
     return gShutdownBarrier.client;
   },
 };
 
 // load the timestamps module into AddonManagerInternal
 Cu.import("resource://gre/modules/TelemetryTimestamps.jsm", AddonManagerInternal);
 Object.freeze(AddonManagerInternal);
--- a/toolkit/mozapps/extensions/addonManager.js
+++ b/toolkit/mozapps/extensions/addonManager.js
@@ -19,38 +19,40 @@ const USER_CANCELLED    = -210;
 const DOWNLOAD_ERROR    = -228;
 const UNSUPPORTED_TYPE  = -244;
 const SUCCESS           = 0;
 
 const MSG_INSTALL_ENABLED  = "WebInstallerIsInstallEnabled";
 const MSG_INSTALL_ADDONS   = "WebInstallerInstallAddonsFromWebpage";
 const MSG_INSTALL_CALLBACK = "WebInstallerInstallCallback";
 
+const MSG_PROMISE_REQUEST  = "WebAPIPromiseRequest";
+const MSG_PROMISE_RESULT   = "WebAPIPromiseResult";
+
 const CHILD_SCRIPT = "resource://gre/modules/addons/Content.js";
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 
 var gSingleton = null;
 
 var gParentMM = null;
 
 
 function amManager() {
   Cu.import("resource://gre/modules/AddonManager.jsm");
   /*globals AddonManagerPrivate*/
 
-  let globalMM = Cc["@mozilla.org/globalmessagemanager;1"]
-                 .getService(Ci.nsIMessageListenerManager);
+  let globalMM = Services.mm;
   globalMM.loadFrameScript(CHILD_SCRIPT, true);
   globalMM.addMessageListener(MSG_INSTALL_ADDONS, this);
 
-  gParentMM = Cc["@mozilla.org/parentprocessmessagemanager;1"]
-                 .getService(Ci.nsIMessageListenerManager);
+  gParentMM = Services.ppmm;
   gParentMM.addMessageListener(MSG_INSTALL_ENABLED, this);
+  gParentMM.addMessageListener(MSG_PROMISE_REQUEST, this);
 
   // Needed so receiveMessage can be called directly by JS callers
   this.wrappedJSObject = this;
 }
 
 amManager.prototype = {
   observe: function(aSubject, aTopic, aData) {
     if (aTopic == "addons-startup")
@@ -169,16 +171,40 @@ amManager.prototype = {
             },
           };
         }
 
         return this.installAddonsFromWebpage(payload.mimetype,
           aMessage.target, payload.triggeringPrincipal, payload.uris,
           payload.hashes, payload.names, payload.icons, callback);
       }
+
+      case MSG_PROMISE_REQUEST: {
+        let resolve = (value) => {
+          aMessage.target.sendAsyncMessage(MSG_PROMISE_RESULT, {
+            callbackID: payload.callbackID,
+            resolve: value
+          });
+        }
+        let reject = (value) => {
+          aMessage.target.sendAsyncMessage(MSG_PROMISE_RESULT, {
+            callbackID: payload.callbackID,
+            reject: value
+          });
+        }
+
+        let API = AddonManager.webAPI;
+        if (payload.type in API) {
+          API[payload.type](...payload.args).then(resolve, reject);
+        }
+        else {
+          reject("Unknown Add-on API request.");
+        }
+        break;
+      }
     }
     return undefined;
   },
 
   classID: Components.ID("{4399533d-08d1-458c-a87a-235f74451cfa}"),
   _xpcom_factory: {
     createInstance: function(aOuter, aIid) {
       if (aOuter != null)
--- a/toolkit/mozapps/extensions/amWebAPI.js
+++ b/toolkit/mozapps/extensions/amWebAPI.js
@@ -5,26 +5,104 @@
 "use strict";
 
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
 
+const MSG_PROMISE_REQUEST  = "WebAPIPromiseRequest";
+const MSG_PROMISE_RESULT   = "WebAPIPromiseResult";
+
+const APIBroker = {
+  _nextID: 0,
+
+  init() {
+    this._promises = new Map();
+
+    Services.cpmm.addMessageListener(MSG_PROMISE_RESULT, this);
+  },
+
+  receiveMessage(message) {
+    let payload = message.data;
+
+    switch (message.name) {
+      case MSG_PROMISE_RESULT: {
+        if (!this._promises.has(payload.callbackID)) {
+          return;
+        }
+
+        let { resolve, reject } = this._promises.get(payload.callbackID);
+        this._promises.delete(payload.callbackID);
+
+        if ("resolve" in payload)
+          resolve(payload.resolve);
+        else
+          reject(payload.reject);
+        break;
+      }
+    }
+  },
+
+  sendRequest: function(type, ...args) {
+    return new Promise((resolve, reject) => {
+      let callbackID = this._nextID++;
+
+      this._promises.set(callbackID, { resolve, reject });
+      Services.cpmm.sendAsyncMessage(MSG_PROMISE_REQUEST, { type, callbackID, args });
+    });
+  },
+};
+
+APIBroker.init();
+
+function Addon(properties) {
+  // We trust the webidl binding to broker access to our properties.
+  for (let key of Object.keys(properties)) {
+    this[key] = properties[key];
+  }
+}
+
+/**
+ * API methods should return promises from the page, this is a simple wrapper
+ * to make sure of that. It also automatically wraps objects when necessary.
+ */
+function WebAPITask(generator) {
+  let task = Task.async(generator);
+
+  return function(...args) {
+    let win = this.window;
+
+    let wrapForContent = (obj) => {
+      if (obj instanceof Addon) {
+        return win.Addon._create(win, obj);
+      }
+
+      return obj;
+    }
+
+    return new win.Promise((resolve, reject) => {
+      task(...args).then(wrapForContent)
+                   .then(resolve, reject);
+    });
+  }
+}
+
 function WebAPI() {
 }
 
 WebAPI.prototype = {
   init(window) {
     this.window = window;
   },
 
-  getAddonByID(id) {
-    return this.window.Promise.reject("Not yet implemented");
-  },
+  getAddonByID: WebAPITask(function*(id) {
+    let addonInfo = yield APIBroker.sendRequest("getAddonByID", id);
+    return addonInfo ? new Addon(addonInfo) : null;
+  }),
 
   classID: Components.ID("{8866d8e3-4ea5-48b7-a891-13ba0ac15235}"),
   contractID: "@mozilla.org/addon-web-api/manager;1",
   QueryInterface: XPCOMUtils.generateQI([Ci.nsISupports, Ci.nsIDOMGlobalPropertyInitializer])
 };
 
 this.NSGetFactory = XPCOMUtils.generateNSGetFactory([WebAPI]);
--- a/toolkit/mozapps/extensions/test/browser/browser.ini
+++ b/toolkit/mozapps/extensions/test/browser/browser.ini
@@ -57,11 +57,12 @@ support-files =
 [browser_hotfix.js]
 # Verifies the old style of signing hotfixes
 skip-if = require_signing
 [browser_installssl.js]
 [browser_newaddon.js]
 [browser_updatessl.js]
 [browser_task_next_test.js]
 [browser_discovery_install.js]
+[browser_webapi.js]
 [browser_webapi_access.js]
 
 [include:browser-common.ini]
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_webapi.js
@@ -0,0 +1,92 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const TESTPAGE = `${SECURE_TESTROOT}webapi_checkavailable.html`;
+
+Services.prefs.setBoolPref("extensions.webapi.testing", true);
+registerCleanupFunction(() => {
+  Services.prefs.clearUserPref("extensions.webapi.testing");
+});
+
+function testWithAPI(task) {
+  return function*() {
+    yield BrowserTestUtils.withNewTab(TESTPAGE, task);
+  }
+}
+
+let gProvider = new MockProvider();
+
+gProvider.createAddons([{
+  id: "addon1@tests.mozilla.org",
+  name: "Test add-on 1",
+  version: "2.1",
+  description: "Short description",
+  type: "extension",
+  userDisabled: false,
+  isActive: true,
+}, {
+  id: "addon2@tests.mozilla.org",
+  name: "Test add-on 2",
+  version: "5.3.7ab",
+  description: null,
+  type: "theme",
+  userDisabled: false,
+  isActive: false,
+}, {
+  id: "addon3@tests.mozilla.org",
+  name: "Test add-on 3",
+  version: "1",
+  description: "Longer description",
+  type: "extension",
+  userDisabled: true,
+  isActive: false,
+}]);
+
+function API_getAddonByID(browser, id) {
+  return ContentTask.spawn(browser, id, function*(id) {
+    let addon = yield content.navigator.mozAddonManager.getAddonByID(id);
+
+    // We can't send native objects back so clone its properties.
+    let result = {};
+    for (let prop in addon) {
+      result[prop] = addon[prop];
+    }
+
+    return result;
+  });
+}
+
+add_task(testWithAPI(function*(browser) {
+  function compareObjects(web, real) {
+    for (let prop of Object.keys(web)) {
+      let webVal = web[prop];
+      let realVal = real[prop];
+
+      switch (prop) {
+        case "isEnabled":
+          realVal = !real.userDisabled;
+          break;
+      }
+
+      // null and undefined don't compare well so stringify them first
+      if (realVal === null || realVal === undefined) {
+        realVal = `${realVal}`;
+        webVal = `${webVal}`;
+      }
+
+      is(webVal, realVal, `Property ${prop} should have the right value in add-on ${real.id}`);
+    }
+  }
+
+  let [a1, a2, a3] = yield promiseAddonsByIDs(["addon1@tests.mozilla.org",
+                                               "addon2@tests.mozilla.org",
+                                               "addon3@tests.mozilla.org"]);
+  let w1 = yield API_getAddonByID(browser, "addon1@tests.mozilla.org");
+  let w2 = yield API_getAddonByID(browser, "addon2@tests.mozilla.org");
+  let w3 = yield API_getAddonByID(browser, "addon3@tests.mozilla.org");
+
+  compareObjects(w1, a1);
+  compareObjects(w2, a2);
+  compareObjects(w3, a3);
+}));