Bug 554780: Make plugins provider correctly handle plugins being added and removed through detection at runtime. r=Unfocused
authorDave Townsend <dtownsend@oxymoronical.com>
Fri, 15 Feb 2013 10:12:44 -0600
changeset 122040 4b19fa00a8aac5774e82e9617d2c7de708bdedaf
parent 122039 3af9909a49e4c7dc393fcb2fe83bc18d2b9c0d4c
child 122041 26c3dd6332881233a07a6a85fc020b9cb666118c
push id24315
push userryanvm@gmail.com
push dateFri, 15 Feb 2013 21:34:37 +0000
treeherdermozilla-central@7bd555e2acfa [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersUnfocused
bugs554780
milestone21.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 554780: Make plugins provider correctly handle plugins being added and removed through detection at runtime. r=Unfocused
toolkit/mozapps/extensions/PluginProvider.jsm
toolkit/mozapps/extensions/test/xpcshell/test_pluginchange.js
toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
--- a/toolkit/mozapps/extensions/PluginProvider.jsm
+++ b/toolkit/mozapps/extensions/PluginProvider.jsm
@@ -9,16 +9,17 @@ const Ci = Components.interfaces;
 
 this.EXPORTED_SYMBOLS = [];
 
 Components.utils.import("resource://gre/modules/AddonManager.jsm");
 Components.utils.import("resource://gre/modules/Services.jsm");
 
 const URI_EXTENSION_STRINGS  = "chrome://mozapps/locale/extensions/extensions.properties";
 const STRING_TYPE_NAME       = "type.%ID%.name";
+const LIST_UPDATED_TOPIC     = "plugins-list-updated";
 
 for (let name of ["LOG", "WARN", "ERROR"]) {
   this.__defineGetter__(name, function() {
     Components.utils.import("resource://gre/modules/AddonLogging.jsm");
 
     LogManager.getLogger("addons.plugins", this);
     return this[name];
   });
@@ -47,68 +48,82 @@ function getIDHashForString(aStr) {
                hash.substr(20) + "}";
 }
 
 var PluginProvider = {
   // A dictionary mapping IDs to names and descriptions
   plugins: null,
 
   startup: function PL_startup() {
+    Services.obs.addObserver(this, LIST_UPDATED_TOPIC, false);
     Services.obs.addObserver(this, AddonManager.OPTIONS_NOTIFICATION_DISPLAYED, false);
   },
 
   /**
    * Called when the application is shutting down. Only necessary for tests
    * to be able to simulate a shutdown.
    */
   shutdown: function PL_shutdown() {
     this.plugins = null;
     Services.obs.removeObserver(this, AddonManager.OPTIONS_NOTIFICATION_DISPLAYED);
+    Services.obs.removeObserver(this, LIST_UPDATED_TOPIC);
   },
 
   observe: function(aSubject, aTopic, aData) {
-    this.getAddonByID(aData, function(plugin) {
-      if (!plugin)
-        return;
+    switch (aTopic) {
+    case AddonManager.OPTIONS_NOTIFICATION_DISPLAYED:
+      this.getAddonByID(aData, function PL_displayPluginInfo(plugin) {
+        if (!plugin)
+          return;
 
-      let libLabel = aSubject.getElementById("pluginLibraries");
-      libLabel.textContent = plugin.pluginLibraries.join(", ");
+        let libLabel = aSubject.getElementById("pluginLibraries");
+        libLabel.textContent = plugin.pluginLibraries.join(", ");
 
-      let typeLabel = aSubject.getElementById("pluginMimeTypes"), types = [];
-      for (let type of plugin.pluginMimeTypes) {
-        let extras = [type.description.trim(), type.suffixes].
-                     filter(function(x) x).join(": ");
-        types.push(type.type + (extras ? " (" + extras + ")" : ""));
-      }
-      typeLabel.textContent = types.join(",\n");
-    });
+        let typeLabel = aSubject.getElementById("pluginMimeTypes"), types = [];
+        for (let type of plugin.pluginMimeTypes) {
+          let extras = [type.description.trim(), type.suffixes].
+                       filter(function(x) x).join(": ");
+          types.push(type.type + (extras ? " (" + extras + ")" : ""));
+        }
+        typeLabel.textContent = types.join(",\n");
+      });
+      break;
+    case LIST_UPDATED_TOPIC:
+      if (this.plugins)
+        this.updatePluginList();
+      break;
+    }
+  },
+
+  /**
+   * Creates a PluginWrapper for a plugin object.
+   */
+  buildWrapper: function PL_buildWrapper(aPlugin) {
+    return new PluginWrapper(aPlugin.id,
+                             aPlugin.name,
+                             aPlugin.description,
+                             aPlugin.tags);
   },
 
   /**
    * Called to get an Addon with a particular ID.
    *
    * @param  aId
    *         The ID of the add-on to retrieve
    * @param  aCallback
    *         A callback to pass the Addon to
    */
   getAddonByID: function PL_getAddon(aId, aCallback) {
     if (!this.plugins)
       this.buildPluginList();
 
-    if (aId in this.plugins) {
-      let name = this.plugins[aId].name;
-      let description = this.plugins[aId].description;
-      let tags = this.plugins[aId].tags;
-
-      aCallback(new PluginWrapper(aId, name, description, tags));
-    }
-    else {
+    if (aId in this.plugins)
+      aCallback(this.buildWrapper(this.plugins[aId]));
+    else
       aCallback(null);
-    }
   },
 
   /**
    * Called to get Addons of a particular type.
    *
    * @param  aTypes
    *         An array of types to fetch. Can be null to get all types.
    * @param  callback
@@ -153,42 +168,115 @@ var PluginProvider = {
    *         An array of types or null to get all types
    * @param  aCallback
    *         A callback to pass the array of AddonInstalls to
    */
   getInstallsByTypes: function PL_getInstallsByTypes(aTypes, aCallback) {
     aCallback([]);
   },
 
-  buildPluginList: function PL_buildPluginList() {
+  /**
+   * Builds a list of the current plugins reported by the plugin host
+   *
+   * @return a dictionary of plugins indexed by our generated ID
+   */
+  getPluginList: function PL_getPluginList() {
     let tags = Cc["@mozilla.org/plugin/host;1"].
                getService(Ci.nsIPluginHost).
                getPluginTags({});
 
-    this.plugins = {};
-    let plugins = {};
+    let list = {};
+    let seenPlugins = {};
     for (let tag of tags) {
-      if (!(tag.name in plugins))
-        plugins[tag.name] = {};
-      if (!(tag.description in plugins[tag.name])) {
+      if (!(tag.name in seenPlugins))
+        seenPlugins[tag.name] = {};
+      if (!(tag.description in seenPlugins[tag.name])) {
         let plugin = {
+          id: getIDHashForString(tag.name + tag.description),
           name: tag.name,
           description: tag.description,
           tags: [tag]
         };
 
-        let id = getIDHashForString(tag.name + tag.description);
-
-        plugins[tag.name][tag.description] = plugin;
-        this.plugins[id] = plugin;
+        seenPlugins[tag.name][tag.description] = plugin;
+        list[plugin.id] = plugin;
       }
       else {
-        plugins[tag.name][tag.description].tags.push(tag);
+        seenPlugins[tag.name][tag.description].tags.push(tag);
       }
     }
+
+    return list;
+  },
+
+  /**
+   * Builds the list of known plugins from the plugin host
+   */
+  buildPluginList: function PL_buildPluginList() {
+    this.plugins = this.getPluginList();
+  },
+
+  /**
+   * Updates the plugins from the plugin host by comparing the current plugins
+   * to the last known list sending out any necessary API notifications for
+   * changes.
+   */
+  updatePluginList: function PL_updatePluginList() {
+    let newList = this.getPluginList();
+
+    let lostPlugins = [this.buildWrapper(this.plugins[id])
+                       for each (id in Object.keys(this.plugins)) if (!(id in newList))];
+    let newPlugins = [this.buildWrapper(newList[id])
+                      for each (id in Object.keys(newList)) if (!(id in this.plugins))];
+    let matchedIDs = [id for each (id in Object.keys(newList)) if (id in this.plugins)];
+
+    // The plugin host generates new tags for every plugin after a scan and
+    // if the plugin's filename has changed then the disabled state won't have
+    // been carried across, send out notifications for anything that has
+    // changed (see bug 830267).
+    let changedWrappers = [];
+    for (let id of matchedIDs) {
+      let oldWrapper = this.buildWrapper(this.plugins[id]);
+      let newWrapper = this.buildWrapper(newList[id]);
+
+      if (newWrapper.isActive != oldWrapper.isActive) {
+        AddonManagerPrivate.callAddonListeners(newWrapper.isActive ?
+                                               "onEnabling" : "onDisabling",
+                                               newWrapper, false);
+        changedWrappers.push(newWrapper);
+      }
+    }
+
+    // Notify about new installs
+    for (let plugin of newPlugins) {
+      AddonManagerPrivate.callInstallListeners("onExternalInstall", null,
+                                               plugin, null, false);
+      AddonManagerPrivate.callAddonListeners("onInstalling", plugin, false);
+    }
+
+    // Notify for any plugins that have vanished.
+    for (let plugin of lostPlugins)
+      AddonManagerPrivate.callAddonListeners("onUninstalling", plugin, false);
+
+    this.plugins = newList;
+
+    // Signal that new installs are complete
+    for (let plugin of newPlugins)
+      AddonManagerPrivate.callAddonListeners("onInstalled", plugin);
+
+    // Signal that enables/disables are complete
+    for (let wrapper of changedWrappers) {
+      AddonManagerPrivate.callAddonListeners(wrapper.isActive ?
+                                             "onEnabled" : "onDisabled",
+                                             wrapper);
+    }
+
+    // Signal that uninstalls are complete
+    for (let plugin of lostPlugins)
+      AddonManagerPrivate.callAddonListeners("onUninstalled", plugin);
   }
 };
 
 /**
  * The PluginWrapper wraps a set of nsIPluginTags to provide the data visible to
  * public callers through the API.
  */
 function PluginWrapper(aId, aName, aDescription, aTags) {
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_pluginchange.js
@@ -0,0 +1,290 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const LIST_UPDATED_TOPIC     = "plugins-list-updated";
+
+// We need to use the same algorithm for generating IDs for plugins
+var { getIDHashForString } = Components.utils.import("resource://gre/modules/PluginProvider.jsm");
+
+function PluginTag(name, description) {
+  this.name = name;
+  this.description = description;
+}
+
+PluginTag.prototype = {
+  name: null,
+  description: null,
+  version: "1.0",
+  filename: null,
+  fullpath: null,
+  disabled: false,
+  blocklisted: false,
+  clicktoplay: false,
+
+  mimeTypes: [],
+
+  getMimeTypes: function(count) {
+    count.value = this.mimeTypes.length;
+    return this.mimeTypes;
+  }
+};
+
+PLUGINS = [
+  // A standalone plugin
+  new PluginTag("Java", "A mock Java plugin"),
+
+  // A plugin made up of two plugin files
+  new PluginTag("Flash", "A mock Flash plugin"),
+  new PluginTag("Flash", "A mock Flash plugin")
+];
+
+gPluginHost = {
+  // nsIPluginHost
+  getPluginTags: function(count) {
+    count.value = PLUGINS.length;
+    return PLUGINS;
+  },
+
+  QueryInterface: XPCOMUtils.generateQI([AM_Ci.nsIPluginHost])
+};
+
+var PluginHostFactory = {
+  createInstance: function (outer, iid) {
+    if (outer != null)
+      throw Components.results.NS_ERROR_NO_AGGREGATION;
+    return gPluginHost.QueryInterface(iid);
+  }
+};
+
+var registrar = Components.manager.QueryInterface(AM_Ci.nsIComponentRegistrar);
+registrar.registerFactory(Components.ID("{aa6f9fef-cbe2-4d55-a2fa-dcf5482068b9}"), "PluginHost",
+                          "@mozilla.org/plugin/host;1", PluginHostFactory);
+
+// This verifies that when the list of plugins changes the add-ons manager
+// correctly updates
+function run_test() {
+  do_test_pending();
+  createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+  startupManager();
+  AddonManager.addAddonListener(AddonListener);
+  AddonManager.addInstallListener(InstallListener);
+
+  run_test_1();
+}
+
+function end_test() {
+  do_test_finished();
+}
+
+function sortAddons(addons) {
+  addons.sort(function(a, b) {
+    return a.name.localeCompare(b.name);
+  });
+}
+
+// Basic check that the mock object works
+function run_test_1() {
+  AddonManager.getAddonsByTypes(["plugin"], function(addons) {
+    sortAddons(addons);
+
+    do_check_eq(addons.length, 2);
+
+    do_check_eq(addons[0].name, "Flash");
+    do_check_false(addons[0].userDisabled);
+    do_check_eq(addons[1].name, "Java");
+    do_check_false(addons[1].userDisabled);
+
+    run_test_2();
+  });
+}
+
+// No change to the list should not trigger any events or changes in the API
+function run_test_2() {
+  // Reorder the list a bit
+  let tag = PLUGINS[0];
+  PLUGINS[0] = PLUGINS[2];
+  PLUGINS[2] = PLUGINS[1];
+  PLUGINS[1] = tag;
+
+  Services.obs.notifyObservers(null, LIST_UPDATED_TOPIC, null);
+
+  AddonManager.getAddonsByTypes(["plugin"], function(addons) {
+    sortAddons(addons);
+
+    do_check_eq(addons.length, 2);
+
+    do_check_eq(addons[0].name, "Flash");
+    do_check_false(addons[0].userDisabled);
+    do_check_eq(addons[1].name, "Java");
+    do_check_false(addons[1].userDisabled);
+
+    run_test_3();
+  });
+}
+
+// Tests that a newly detected plugin shows up in the API and sends out events
+function run_test_3() {
+  let tag = new PluginTag("Quicktime", "A mock Quicktime plugin");
+  PLUGINS.push(tag);
+  let id = getIDHashForString(tag.name + tag.description);
+
+  let test_params = {};
+  test_params[id] = [
+    ["onInstalling", false],
+    "onInstalled"
+  ];
+
+  prepare_test(test_params, [
+    "onExternalInstall"
+  ]);
+
+  Services.obs.notifyObservers(null, LIST_UPDATED_TOPIC, null);
+
+  ensure_test_completed();
+
+  AddonManager.getAddonsByTypes(["plugin"], function(addons) {
+    sortAddons(addons);
+
+    do_check_eq(addons.length, 3);
+
+    do_check_eq(addons[0].name, "Flash");
+    do_check_false(addons[0].userDisabled);
+    do_check_eq(addons[1].name, "Java");
+    do_check_false(addons[1].userDisabled);
+    do_check_eq(addons[2].name, "Quicktime");
+    do_check_false(addons[2].userDisabled);
+
+    run_test_4();
+  });
+}
+
+// Tests that a removed plugin disappears from in the API and sends out events
+function run_test_4() {
+  let tag = PLUGINS.splice(1, 1)[0];
+  let id = getIDHashForString(tag.name + tag.description);
+
+  let test_params = {};
+  test_params[id] = [
+    ["onUninstalling", false],
+    "onUninstalled"
+  ];
+
+  prepare_test(test_params);
+
+  Services.obs.notifyObservers(null, LIST_UPDATED_TOPIC, null);
+
+  ensure_test_completed();
+
+  AddonManager.getAddonsByTypes(["plugin"], function(addons) {
+    sortAddons(addons);
+
+    do_check_eq(addons.length, 2);
+
+    do_check_eq(addons[0].name, "Flash");
+    do_check_false(addons[0].userDisabled);
+    do_check_eq(addons[1].name, "Quicktime");
+    do_check_false(addons[1].userDisabled);
+
+    run_test_5();
+  });
+}
+
+// Removing part of the flash plugin should have no effect
+function run_test_5() {
+  PLUGINS.splice(0, 1);
+
+  Services.obs.notifyObservers(null, LIST_UPDATED_TOPIC, null);
+
+  ensure_test_completed();
+
+  AddonManager.getAddonsByTypes(["plugin"], function(addons) {
+    sortAddons(addons);
+
+    do_check_eq(addons.length, 2);
+
+    do_check_eq(addons[0].name, "Flash");
+    do_check_false(addons[0].userDisabled);
+    do_check_eq(addons[1].name, "Quicktime");
+    do_check_false(addons[1].userDisabled);
+
+    run_test_6();
+  });
+}
+
+// Replacing flash should be detected
+function run_test_6() {
+  let oldTag = PLUGINS.splice(0, 1)[0];
+  let newTag = new PluginTag("Flash 2", "A new crash-free Flash!");
+  newTag.disabled = true;
+  PLUGINS.push(newTag);
+
+  let test_params = {};
+  test_params[getIDHashForString(oldTag.name + oldTag.description)] = [
+    ["onUninstalling", false],
+    "onUninstalled"
+  ];
+  test_params[getIDHashForString(newTag.name + newTag.description)] = [
+    ["onInstalling", false],
+    "onInstalled"
+  ];
+
+  prepare_test(test_params, [
+    "onExternalInstall"
+  ]);
+
+  Services.obs.notifyObservers(null, LIST_UPDATED_TOPIC, null);
+
+  ensure_test_completed();
+
+  AddonManager.getAddonsByTypes(["plugin"], function(addons) {
+    sortAddons(addons);
+
+    do_check_eq(addons.length, 2);
+
+    do_check_eq(addons[0].name, "Flash 2");
+    do_check_true(addons[0].userDisabled);
+    do_check_eq(addons[1].name, "Quicktime");
+    do_check_false(addons[1].userDisabled);
+
+    run_test_7();
+  });
+}
+
+// If new tags are detected and the disabled state changes then we should send
+// out appropriate notifications
+function run_test_7() {
+  PLUGINS[0] = new PluginTag("Quicktime", "A mock Quicktime plugin");
+  PLUGINS[0].disabled = true;
+  PLUGINS[1] = new PluginTag("Flash 2", "A new crash-free Flash!");
+
+  let test_params = {};
+  test_params[getIDHashForString(PLUGINS[0].name + PLUGINS[0].description)] = [
+    ["onDisabling", false],
+    "onDisabled"
+  ];
+  test_params[getIDHashForString(PLUGINS[1].name + PLUGINS[1].description)] = [
+    ["onEnabling", false],
+    "onEnabled"
+  ];
+
+  prepare_test(test_params);
+
+  Services.obs.notifyObservers(null, LIST_UPDATED_TOPIC, null);
+
+  ensure_test_completed();
+
+  AddonManager.getAddonsByTypes(["plugin"], function(addons) {
+    sortAddons(addons);
+
+    do_check_eq(addons.length, 2);
+
+    do_check_eq(addons[0].name, "Flash 2");
+    do_check_false(addons[0].userDisabled);
+    do_check_eq(addons[1].name, "Quicktime");
+    do_check_true(addons[1].userDisabled);
+
+    end_test();
+  });
+}
--- a/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
@@ -191,16 +191,17 @@ skip-if = os == "android"
 [test_migrate2.js]
 [test_migrate3.js]
 [test_migrate4.js]
 [test_migrate5.js]
 [test_migrateAddonRepository.js]
 [test_onPropertyChanged_appDisabled.js]
 [test_permissions.js]
 [test_plugins.js]
+[test_pluginchange.js]
 [test_pluginBlocklistCtp.js]
 # Bug 676992: test consistently fails on Android
 fail-if = os == "android"
 [test_pref_properties.js]
 [test_registry.js]
 [test_safemode.js]
 [test_shutdown.js]
 [test_startup.js]