bug 1378459 parts 2 through N: implement persistent event listeners draft
authorAndrew Swan <aswan@mozilla.com>
Mon, 19 Mar 2018 17:05:50 -0700
changeset 769735 dfe1c274fe8bcf1ad9a4f0c1836072615a11787e
parent 769734 df6aa6dfcc55c46599fcc50bb8d73c28789b192c
push id103213
push useraswan@mozilla.com
push dateTue, 20 Mar 2018 01:22:00 +0000
bugs1378459
milestone61.0a1
bug 1378459 parts 2 through N: implement persistent event listeners MozReview-Commit-ID: IUbaydJVKPc
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionCommon.jsm
toolkit/components/extensions/ext-backgroundPage.js
toolkit/components/extensions/ext-webRequest.js
toolkit/mozapps/extensions/AddonManager.jsm
toolkit/mozapps/extensions/internal/XPIProvider.jsm
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -1156,16 +1156,17 @@ class Extension extends ExtensionData {
 
     if (addonData.cleanupFile) {
       Services.obs.addObserver(this, "xpcom-shutdown");
       this.cleanupFile = addonData.cleanupFile || null;
       delete addonData.cleanupFile;
     }
 
     this.addonData = addonData;
+    this.startupData = addonData.startupData || {};
     this.startupReason = startupReason;
 
     if (["ADDON_UPGRADE", "ADDON_DOWNGRADE"].includes(startupReason)) {
       StartupCache.clearAddonData(addonData.id);
     }
 
     this.remote = !WebExtensionPolicy.isExtensionProcess;
 
@@ -1350,16 +1351,20 @@ class Extension extends ExtensionData {
              this.addonData.temporarilyInstalled));
   }
 
   get experimentsAllowed() {
     return (AddonSettings.ALLOW_LEGACY_EXTENSIONS ||
             this.isPrivileged);
   }
 
+  saveStartupData() {
+    AddonManagerPrivate.setStartupData(this.id, this.startupData);
+  }
+
   async _parseManifest() {
     let manifest = await super.parseManifest();
     if (manifest && manifest.permissions.has("mozillaAddons") &&
         !this.isPrivileged) {
       Cu.reportError(`Stripping mozillaAddons permission from ${this.id}`);
       manifest.permissions.delete("mozillaAddons");
       let i = manifest.manifest.permissions.indexOf("mozillaAddons");
       if (i >= 0) {
--- a/toolkit/components/extensions/ExtensionCommon.jsm
+++ b/toolkit/components/extensions/ExtensionCommon.jsm
@@ -18,16 +18,17 @@ var EXPORTED_SYMBOLS = ["ExtensionCommon
 Cu.importGlobalProperties(["fetch"]);
 
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   ConsoleAPI: "resource://gre/modules/Console.jsm",
   MessageChannel: "resource://gre/modules/MessageChannel.jsm",
+  ObjectUtils: "resource://gre/modules/ObjectUtils.jsm",
   PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
   Schemas: "resource://gre/modules/Schemas.jsm",
   SchemaRoot: "resource://gre/modules/Schemas.jsm",
 });
 
 XPCOMUtils.defineLazyServiceGetter(this, "styleSheetService",
                                    "@mozilla.org/content/style-sheet-service;1",
                                    "nsIStyleSheetService");
@@ -1766,23 +1767,101 @@ class EventManager {
    *        An object representing the extension instance using this event.
    * @param {string} params.name
    *        A name used only for debugging.
    * @param {functon} params.register
    *        A function called whenever a new listener is added.
    * @param {boolean} [params.inputHandling=false]
    *        If true, the "handling user input" flag is set while handlers
    *        for this event are executing.
+   * @param {object} [params.persistent]
+   *        Details for persistent event listeners
+   * @param {string} params.persistent.module
+   *        The name of the module in which this event is defined.
+   * @param {string} params.persistent.event
+   *        The name of this event.
    */
-  constructor({context, name, register, inputHandling = false}) {
+  constructor({context, name, register, inputHandling = false, persistent = null}) {
     this.context = context;
     this.name = name;
     this.register = register;
+    this.inputHandling = inputHandling;
+    this.persistent = persistent;
+
+    // XXX DEBUG only?  XXX validate that persistent has exactly module and event
+    if (this.persistent && context.envType !== "addon_parent") {
+      throw new Error("Persistent event managers can only be created for addon_parent");
+    }
+
     this.unregister = new Map();
-    this.inputHandling = inputHandling;
+  }
+
+  // Set up "primed" event listeners for any saved event listeners
+  // in the provided extension's startup data.
+  // XXX write a better comment
+  static primeListeners(extension) {
+    extension.primedEvents = {};
+
+    let listeners = extension.startupData.persistentListeners;
+    if (!listeners) {
+      return;
+    }
+
+    for (let [module, entry] of Object.entries(listeners)) {
+      extension.primedEvents[module] = {};
+      let api = extension.apiManager.getAPI(module, extension, "addon_parent");
+      for (let [event, paramlists] of Object.entries(entry)) {
+        dump(`have saved listener for ${module}.${event}\n`);
+
+        extension.primedEvents[module][event] = new Set(paramlists.map(params => {
+          let info = {params};
+
+          let wakeup = (...args) => {
+            return new Promise((resolve, reject) => {
+              if (!info.pendingEvents) {
+                info.pendingEvents = [];
+              }
+              info.pendingEvents.push({args, resolve, reject});
+
+              extension.emit("start-background-page");
+            });
+          };
+
+          let fire = {
+            sync: wakeup,
+            async: wakeup,
+          };
+
+          let {unregister, setFire} = api.primeListener(extension, event, fire, params);
+          Object.assign(info, {unregister, setFire});
+          return info;
+        }));
+      }
+    }
+  }
+
+  // Remove any primed listeners created from primeListeners()
+  // that were not re-created.
+  // This function is called after the background page is started and
+  // all event listeners have been registered.
+  // XXX write a better comment
+  static clearPrimedListeners(extension) {
+    for (let entry1 of Object.values(extension.primedEvents)) {
+      for (let entry2 of Object.values(entry1)) {
+        for (let info of entry2) {
+          if (info.pendingEvents) {
+            for (let evt of info.pendingEvents) {
+              evt.reject(new Error("listener not re-registered"));
+            }
+          }
+          // XXX remove from startupData
+          info.unregister();
+        }
+      }
+    }
   }
 
   addListener(callback, ...args) {
     if (this.unregister.has(callback)) {
       return;
     }
 
     let shouldFire = () => {
@@ -1819,19 +1898,77 @@ class EventManager {
         return Promise.resolve().then(() => {
           if (shouldFire()) {
             return this.context.applySafeWithoutClone(callback, args);
           }
         });
       },
     };
 
-    let unregister = this.register(fire, ...args);
+    let {extension} = this.context;
+
+    let unregister = null;
+    let recordStartupData = false;
+
+    // If this is a persistent event, check for a listener that was already
+    // created during startup.  If there is one, use it and don't create a
+    // new one.
+    if (this.persistent) {
+      recordStartupData = true;
+      let {module, event} = this.persistent;
+      if (extension.primedEvents &&
+          extension.primedEvents[module] &&
+          extension.primedEvents[module][event]) {
+        let listeners = extension.primedEvents[module][event];
+        for (let listener of listeners) {
+          if (ObjectUtils.deepEqual(args, listener.params)) {
+            listener.setFire(fire);
+            unregister = listener.unregister;
+
+            if (listener.pendingEvents) {
+              for (let evt of listener.pendingEvents) {
+                evt.resolve(fire.async(...evt.args));
+              }
+            }
+
+            listeners.delete(listener);
+            recordStartupData = false;
+            break;
+          }
+        }
+      }
+    }
+
+    if (!unregister) {
+      unregister = this.register(fire, ...args);
+    }
+
     this.unregister.set(callback, unregister);
     this.context.callOnClose(this);
+
+    // If this is a new listener for a persistent event, record
+    // the details for subsequent startups.
+    if (recordStartupData) {
+      let {module, event} = this.persistent;
+
+      if (!extension.startupData.persistentListeners) {
+        extension.startupData.persistentListeners = {};
+      }
+      if (!extension.startupData.persistentListeners[module]) {
+        extension.startupData.persistentListeners[module] = {};
+      }
+      if (!extension.startupData.persistentListeners[module][event]) {
+        extension.startupData.persistentListeners[module][event] = [];
+      }
+      extension.startupData.persistentListeners[module][event].push(args);
+
+      dump(`set startupData to ${JSON.stringify(extension.startupData)}\n`);
+
+      extension.saveStartupData();
+    }
   }
 
   removeListener(callback) {
     if (!this.unregister.has(callback)) {
       return;
     }
 
     let unregister = this.unregister.get(callback);
@@ -1839,16 +1976,18 @@ class EventManager {
     try {
       unregister();
     } catch (e) {
       Cu.reportError(e);
     }
     if (this.unregister.size == 0) {
       this.context.forgetOnClose(this);
     }
+
+    // XXX if persistent, remove from startupData
   }
 
   hasListener(callback) {
     return this.unregister.has(callback);
   }
 
   revoke() {
     for (let callback of this.unregister.keys()) {
--- a/toolkit/components/extensions/ext-backgroundPage.js
+++ b/toolkit/components/extensions/ext-backgroundPage.js
@@ -20,16 +20,17 @@ class BackgroundPage extends HiddenExten
     if (this.page) {
       this.url = this.extension.baseURI.resolve(this.page);
     } else if (this.isGenerated) {
       this.url = this.extension.baseURI.resolve("_generated_background_page.html");
     }
   }
 
   async build() {
+    dump(`building background page for ${this.extension.id}\n`);
     TelemetryStopwatch.start("WEBEXT_BACKGROUND_PAGE_LOAD_MS", this);
     await this.createBrowserElement();
     this.extension._backgroundPageFrameLoader = this.browser.frameLoader;
 
     extensions.emit("extension-browser-inserted", this.browser);
 
     this.browser.loadURIWithFlags(this.url, {triggeringPrincipal: this.extension.principal});
 
@@ -49,19 +50,31 @@ class BackgroundPage extends HiddenExten
   shutdown() {
     this.extension._backgroundPageFrameLoader = null;
     super.shutdown();
   }
 }
 
 this.backgroundPage = class extends ExtensionAPI {
   onManifestEntry(entryName) {
-    let {manifest} = this.extension;
+    let {extension} = this;
+    let {manifest} = extension;
+
+    this.bgPage = new BackgroundPage(extension, manifest.background);
+    if (extension.startupReason != "APP_STARTUP") {
+      this.bgPage.build();
+      return;
+    }
 
-    this.bgPage = new BackgroundPage(this.extension, manifest.background);
+    EventManager.primeListeners(extension);
 
-    return this.bgPage.build();
+    extension.once("start-background-page", async () => {
+      await this.bgPage.build();
+      EventManager.clearPrimedListeners(extension);
+    });
+
+    // XXX listen for an appropriate startup event and start background pages
   }
 
   onShutdown() {
     this.bgPage.shutdown();
   }
 };
--- a/toolkit/components/extensions/ext-webRequest.js
+++ b/toolkit/components/extensions/ext-webRequest.js
@@ -5,105 +5,123 @@
 
 // This file expectes tabTracker to be defined in the global scope (e.g.
 // by ext-utils.js).
 /* global tabTracker */
 
 ChromeUtils.defineModuleGetter(this, "WebRequest",
                                "resource://gre/modules/WebRequest.jsm");
 
-// EventManager-like class specifically for WebRequest. Inherits from
-// EventManager. Takes care of converting |details| parameter
-// when invoking listeners.
-function WebRequestEventManager(context, eventName) {
-  let name = `webRequest.${eventName}`;
-  let register = (fire, filter, info) => {
-    let listener = data => {
-      let browserData = {tabId: -1, windowId: -1};
-      if (data.browser) {
-        browserData = tabTracker.getBrowserData(data.browser);
-      }
-      if (filter.tabId != null && browserData.tabId != filter.tabId) {
-        return;
-      }
-      if (filter.windowId != null && browserData.windowId != filter.windowId) {
-        return;
-      }
+// The guts of a WebRequest event handler.  Takes care of converting
+// |details| parameter when invoking listeners.
+function registerEvent(extension, eventName, fire, filter, info, tabParent = null) {
+  let listener = data => {
+    let browserData = {tabId: -1, windowId: -1};
+    if (data.browser) {
+      browserData = tabTracker.getBrowserData(data.browser);
+    }
+    if (filter.tabId != null && browserData.tabId != filter.tabId) {
+      return;
+    }
+    if (filter.windowId != null && browserData.windowId != filter.windowId) {
+      return;
+    }
+
+    let event = data.serialize(eventName);
+    event.tabId = browserData.tabId;
+
+    return fire.sync(event);
+  };
+
+  let filter2 = {};
+  if (filter.urls) {
+    let perms = new MatchPatternSet([...extension.whiteListedHosts.patterns,
+                                     ...extension.optionalOrigins.patterns]);
 
-      let event = data.serialize(eventName);
-      event.tabId = browserData.tabId;
-
-      return fire.sync(event);
-    };
+    filter2.urls = new MatchPatternSet(filter.urls);
 
-    let filter2 = {};
-    if (filter.urls) {
-      let perms = new MatchPatternSet([...context.extension.whiteListedHosts.patterns,
-                                       ...context.extension.optionalOrigins.patterns]);
+    if (!perms.overlapsAll(filter2.urls)) {
+      Cu.reportError("The webRequest.addListener filter doesn't overlap with host permissions.");
+    }
+  }
+  if (filter.types) {
+    filter2.types = filter.types;
+  }
+  if (filter.tabId) {
+    filter2.tabId = filter.tabId;
+  }
+  if (filter.windowId) {
+    filter2.windowId = filter.windowId;
+  }
 
-      filter2.urls = new MatchPatternSet(filter.urls);
+  let blockingAllowed = extension.hasPermission("webRequestBlocking");
 
-      if (!perms.overlapsAll(filter2.urls)) {
-        Cu.reportError("The webRequest.addListener filter doesn't overlap with host permissions.");
+  let info2 = [];
+  if (info) {
+    for (let desc of info) {
+      if (desc == "blocking" && !blockingAllowed) {
+        Cu.reportError("Using webRequest.addListener with the blocking option " +
+                       "requires the 'webRequestBlocking' permission.");
+      } else {
+        info2.push(desc);
       }
     }
-    if (filter.types) {
-      filter2.types = filter.types;
-    }
-    if (filter.tabId) {
-      filter2.tabId = filter.tabId;
-    }
-    if (filter.windowId) {
-      filter2.windowId = filter.windowId;
-    }
-
-    let blockingAllowed = context.extension.hasPermission("webRequestBlocking");
+  }
 
-    let info2 = [];
-    if (info) {
-      for (let desc of info) {
-        if (desc == "blocking" && !blockingAllowed) {
-          Cu.reportError("Using webRequest.addListener with the blocking option " +
-                         "requires the 'webRequestBlocking' permission.");
-        } else {
-          info2.push(desc);
-        }
-      }
-    }
-
-    let listenerDetails = {
-      addonId: context.extension.id,
-      extension: context.extension.policy,
-      blockingAllowed,
-      tabParent: context.xulBrowser.frameLoader.tabParent,
-    };
-
-    WebRequest[eventName].addListener(
-      listener, filter2, info2,
-      listenerDetails);
-    return () => {
-      WebRequest[eventName].removeListener(listener);
-    };
+  let listenerDetails = {
+    addonId: extension.id,
+    extension: extension.policy,
+    blockingAllowed,
   };
 
-  return new EventManager({context, name, register}).api();
+  if (tabParent) {
+    listenerDetails.tabParent = tabParent;
+  }
+
+  WebRequest[eventName].addListener(
+    listener, filter2, info2,
+    listenerDetails);
+
+  return {
+    unregister: () => { WebRequest[eventName].removeListener(listener); },
+    setFire: _fire => { fire = _fire; },
+  };
+}
+
+function makeWebRequestEvent(context, name) {
+  return new EventManager({
+    context,
+    name: `webRequest.${name}`,
+    persistent: {
+      module: "webRequest",
+      event: name,
+    },
+    register: (fire, filter, info) => {
+      return registerEvent(context.extension, name, fire, filter, info,
+                           context.xulBrowser.frameLoader.tabParent).unregister;
+    },
+  }).api();
 }
 
 this.webRequest = class extends ExtensionAPI {
+  primeListener(extension, event, fire, params) {
+    return registerEvent(extension, event, fire, ...params);
+  }
+
   getAPI(context) {
     return {
       webRequest: {
-        onBeforeRequest: WebRequestEventManager(context, "onBeforeRequest"),
-        onBeforeSendHeaders: WebRequestEventManager(context, "onBeforeSendHeaders"),
-        onSendHeaders: WebRequestEventManager(context, "onSendHeaders"),
-        onHeadersReceived: WebRequestEventManager(context, "onHeadersReceived"),
-        onAuthRequired: WebRequestEventManager(context, "onAuthRequired"),
-        onBeforeRedirect: WebRequestEventManager(context, "onBeforeRedirect"),
-        onResponseStarted: WebRequestEventManager(context, "onResponseStarted"),
-        onErrorOccurred: WebRequestEventManager(context, "onErrorOccurred"),
-        onCompleted: WebRequestEventManager(context, "onCompleted"),
+        onBeforeRequest: makeWebRequestEvent(context, "onBeforeRequest"),
+        onBeforeSendHeaders: makeWebRequestEvent(context, "onBeforeSendHeaders"),
+        onSendHeaders: makeWebRequestEvent(context, "onSendHeaders"),
+        onHeadersReceived: makeWebRequestEvent(context, "onHeadersReceived"),
+        onAuthRequired: makeWebRequestEvent(context, "onAuthRequired"),
+        onBeforeRedirect: makeWebRequestEvent(context, "onBeforeRedirect"),
+        onResponseStarted: makeWebRequestEvent(context, "onResponseStarted"),
+        onErrorOccurred: makeWebRequestEvent(context, "onErrorOccurred"),
+        onCompleted: makeWebRequestEvent(context, "onCompleted"),
         handlerBehaviorChanged: function() {
           // TODO: Flush all caches.
         },
       },
     };
   }
 };
--- a/toolkit/mozapps/extensions/AddonManager.jsm
+++ b/toolkit/mozapps/extensions/AddonManager.jsm
@@ -3137,16 +3137,28 @@ var AddonManagerPrivate = {
     return AddonManagerInternal._getProviderByName("XPIProvider")
                                .isTemporaryInstallID(extensionId);
   },
 
   isDBLoaded() {
     let provider = AddonManagerInternal._getProviderByName("XPIProvider");
     return provider ? provider.isDBLoaded : false;
   },
+
+  /**
+   * XXX document me
+   */
+  setStartupData(aID, aData) {
+    if (!gStarted)
+      throw Components.Exception("AddonManager is not initialized",
+                                 Cr.NS_ERROR_NOT_INITIALIZED);
+
+    return AddonManagerInternal._getProviderByName("XPIProvider")
+                               .setStartupData(aID, aData);
+  },
 };
 
 /**
  * This is the public API that UI and developers should be calling. All methods
  * just forward to AddonManagerInternal.
  * @class
  */
 var AddonManager = {
--- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
@@ -3619,16 +3619,26 @@ var XPIProvider = {
     // Notify providers that a new theme has been enabled.
     if (isTheme(addon.type))
       AddonManagerPrivate.notifyAddonChanged(addon.id, addon.type, false);
 
     return addon.wrapper;
   },
 
   /**
+   * XXX document me
+   */
+  setStartupData(aID, aData) {
+    dump(`write startup data ${JSON.stringify(aData)} for ${aID}\n`);
+    let state = XPIStates.findAddon(aID);
+    state.startupData = aData;
+    XPIStates.save();
+  },
+
+  /**
    * Returns an Addon corresponding to an instance ID.
    * @param aInstanceID
    *        An Addon Instance ID
    * @return {Promise}
    * @resolves The found Addon or null if no such add-on exists.
    * @rejects  Never
    * @throws if the aInstanceID argument is not specified
    */