Bug 1312690: Lazily initialize extension APIs. r?aswan draft
authorKris Maglione <maglione.k@gmail.com>
Tue, 01 Nov 2016 17:24:03 -0700
changeset 432813 95afa0fbed8c8dd1d9f40a3123392be2a60a872e
parent 432418 401d4c882f96ab701991593fa98239cde2e02ca5
child 432814 a91bb22b40fe35baa8088d229d41052d81321a0d
push id34435
push usermaglione.k@gmail.com
push dateWed, 02 Nov 2016 20:58:46 +0000
reviewersaswan
bugs1312690
milestone52.0a1
Bug 1312690: Lazily initialize extension APIs. r?aswan MozReview-Commit-ID: 2ofzT6wPvus
browser/components/extensions/ext-utils.js
browser/components/extensions/test/browser/browser_ext_popup_shutdown.js
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionChild.jsm
toolkit/components/extensions/ExtensionContent.jsm
toolkit/components/extensions/ExtensionUtils.jsm
toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js
--- a/browser/components/extensions/ext-utils.js
+++ b/browser/components/extensions/ext-utils.js
@@ -73,35 +73,28 @@ XPCOMUtils.defineLazyGetter(this, "stand
     stylesheets.push("chrome://browser/content/extension-mac-panel.css");
   }
   if (AppConstants.platform === "win") {
     stylesheets.push("chrome://browser/content/extension-win-panel.css");
   }
   return stylesheets;
 });
 
-/* eslint-disable mozilla/balanced-listeners */
-extensions.on("page-shutdown", (type, context) => {
-  if (context.viewType == "popup" && context.active) {
-    // TODO(robwu): This is not webext-oop compatible.
-    context.xulBrowser.contentWindow.close();
-  }
-});
-/* eslint-enable mozilla/balanced-listeners */
-
 class BasePopup {
   constructor(extension, viewNode, popupURL, browserStyle, fixedWidth = false) {
     this.extension = extension;
     this.popupURL = popupURL;
     this.viewNode = viewNode;
     this.browserStyle = browserStyle;
     this.window = viewNode.ownerGlobal;
     this.destroyed = false;
     this.fixedWidth = fixedWidth;
 
+    extension.callOnClose(this);
+
     this.contentReady = new Promise(resolve => {
       this._resolveContentReady = resolve;
     });
 
     this.viewNode.addEventListener(this.DESTROY_EVENT, this);
 
     let doc = viewNode.ownerDocument;
     let arrowContent = doc.getAnonymousElementByAttribute(this.panel, "class", "panel-arrowcontent");
@@ -115,17 +108,23 @@ class BasePopup {
 
     BasePopup.instances.get(this.window).set(extension, this);
   }
 
   static for(extension, window) {
     return BasePopup.instances.get(window).get(extension);
   }
 
+  close() {
+    this.closePopup();
+  }
+
   destroy() {
+    this.extension.forgetOnClose(this);
+
     this.destroyed = true;
     this.browserLoadedDeferred.reject(new Error("Popup destroyed"));
     return this.browserReady.then(() => {
       this.destroyBrowser(this.browser);
       this.browser.remove();
 
       this.viewNode.removeEventListener(this.DESTROY_EVENT, this);
       this.viewNode.style.maxHeight = "";
--- a/browser/components/extensions/test/browser/browser_ext_popup_shutdown.js
+++ b/browser/components/extensions/test/browser/browser_ext_popup_shutdown.js
@@ -69,12 +69,10 @@ add_task(function* testPageAction() {
   yield extension.awaitMessage("pageAction ready");
 
   clickPageAction(extension);
   let browser = yield awaitExtensionPanel(extension);
   let panel = getPanelForNode(browser);
 
   yield extension.unload();
 
-  yield new Promise(resolve => setTimeout(resolve, 0));
-
   is(panel.parentNode, null, "Panel should be removed from the document");
 });
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -78,16 +78,17 @@ let schemaURLs = new Set();
 
 if (!AppConstants.RELEASE_OR_BETA) {
   schemaURLs.add("chrome://extensions/content/schemas/experiments.json");
 }
 
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 var {
   BaseContext,
+  defineLazyGetter,
   EventEmitter,
   SchemaAPIManager,
   LocaleData,
   instanceOf,
   LocalAPIImplementation,
   flushJarCache,
 } = ExtensionUtils;
 
@@ -284,32 +285,26 @@ class ProxyContext extends BaseContext {
 
     // This message manager is used by ParentAPIManager to send messages and to
     // close the ProxyContext if the underlying message manager closes. This
     // message manager object may change when `xulBrowser` swaps docshells, e.g.
     // when a tab is moved to a different window.
     this.currentMessageManager = xulBrowser.messageManager;
     this._docShellTracker = new BrowserDocshellFollower(xulBrowser,
         this.onBrowserChange.bind(this));
-    this.principal_ = principal;
 
-    this.apiObj = {};
-    GlobalManager.injectInObject(this, false, this.apiObj);
+    Object.defineProperty(this, "principal", {
+      value: principal, enumerable: true, configurable: true,
+    });
 
     this.listenerProxies = new Map();
 
-    this.sandbox = Cu.Sandbox(principal, {});
-
     Management.emit("proxy-context-load", this);
   }
 
-  get principal() {
-    return this.principal_;
-  }
-
   get cloneScope() {
     return this.sandbox;
   }
 
   onBrowserChange(browser) {
     // Make sure that the message manager is set. Otherwise the ProxyContext may
     // never be destroyed because the ParentAPIManager would fail to detect that
     // the message manager is closed.
@@ -329,16 +324,26 @@ class ProxyContext extends BaseContext {
       return;
     }
     this._docShellTracker.destroy();
     super.unload();
     Management.emit("proxy-context-unload", this);
   }
 }
 
+defineLazyGetter(ProxyContext.prototype, "apiObj", function() {
+  let obj = {};
+  GlobalManager.injectInObject(this, false, obj);
+  return obj;
+});
+
+defineLazyGetter(ProxyContext.prototype, "sandbox", function() {
+  return Cu.Sandbox(this.principal);
+});
+
 // The parent ProxyContext of an ExtensionContext in ExtensionChild.jsm.
 class ExtensionChildProxyContext extends ProxyContext {
   constructor(envType, extension, params, xulBrowser) {
     super(envType, extension, params, xulBrowser, extension.principal);
 
     this.viewType = params.viewType;
     // WARNING: The xulBrowser may change when docShells are swapped, e.g. when
     // the tab moves to a different window.
--- a/toolkit/components/extensions/ExtensionChild.jsm
+++ b/toolkit/components/extensions/ExtensionChild.jsm
@@ -29,16 +29,17 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 
 const CATEGORY_EXTENSION_SCRIPTS_ADDON = "webextension-scripts-addon";
 
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 var {
   getInnerWindowID,
   BaseContext,
   ChildAPIManager,
+  defineLazyGetter,
   LocalAPIImplementation,
   Messenger,
   SchemaAPIManager,
 } = ExtensionUtils;
 
 // There is a circular dependency between Extension.jsm and us.
 // Long-term this file should not reference Extension.jsm (because they would
 // live in different processes), but for now use lazy getters.
@@ -164,43 +165,32 @@ class ExtensionContext extends BaseConte
     let sender = {id: extension.uuid};
     if (viewType == "tab") {
       sender.tabId = tabId;
       this.tabId = tabId;
     }
     if (uri) {
       sender.url = uri.spec;
     }
+    this.sender = sender;
 
-    let filter = {extensionId: extension.id};
-    let optionalFilter = {};
-    // Addon-generated messages (not necessarily from the same process as the
-    // addon itself) are sent to the main process, which forwards them via the
-    // parent process message manager. Specific replies can be sent to the frame
-    // message manager.
-    this.messenger = new Messenger(this, [Services.cpmm, this.messageManager], sender, filter, optionalFilter);
+    Schemas.exportLazyGetter(contentWindow, "browser", () => {
+      let browserObj = Cu.createObjectIn(contentWindow);
+      Schemas.inject(browserObj, this.childManager);
+      return browserObj;
+    });
 
-    let localApis = {};
-    apiManager.generateAPIs(this, localApis);
-    this.childManager = new WannabeChildAPIManager(this, this.messageManager, localApis, {
-      envType: "addon_parent",
-      viewType,
-      url: uri.spec,
+    Schemas.exportLazyGetter(contentWindow, "chrome", () => {
+      let chromeApiWrapper = Object.create(this.childManager);
+      chromeApiWrapper.isChromeCompat = true;
+
+      let chromeObj = Cu.createObjectIn(contentWindow);
+      Schemas.inject(chromeObj, chromeApiWrapper);
+      return chromeObj;
     });
-    let chromeApiWrapper = Object.create(this.childManager);
-    chromeApiWrapper.isChromeCompat = true;
-
-    let browserObj = Cu.createObjectIn(contentWindow, {defineAs: "browser"});
-    let chromeObj = Cu.createObjectIn(contentWindow, {defineAs: "chrome"});
-    Schemas.inject(browserObj, this.childManager);
-    Schemas.inject(chromeObj, chromeApiWrapper);
-
-    if (viewType == "background") {
-      apiManager.global.initializeBackgroundPage(contentWindow);
-    }
 
     this.extension.views.add(this);
   }
 
   get cloneScope() {
     return this.contentWindow;
   }
 
@@ -225,22 +215,55 @@ class ExtensionContext extends BaseConte
   unload() {
     // Note that without this guard, we end up running unload code
     // multiple times for tab pages closed by the "page-unload" handlers
     // triggered below.
     if (this.unloaded) {
       return;
     }
 
+    if (this.contentWindow) {
+      this.contentWindow.close();
+    }
+
     super.unload();
-    this.childManager.close();
     this.extension.views.delete(this);
   }
 }
 
+defineLazyGetter(ExtensionContext.prototype, "messenger", function() {
+  let filter = {extensionId: this.extension.id};
+  let optionalFilter = {};
+  // Addon-generated messages (not necessarily from the same process as the
+  // addon itself) are sent to the main process, which forwards them via the
+  // parent process message manager. Specific replies can be sent to the frame
+  // message manager.
+  return new Messenger(this, [Services.cpmm, this.messageManager], this.sender,
+                       filter, optionalFilter);
+});
+
+defineLazyGetter(ExtensionContext.prototype, "childManager", function() {
+  let localApis = {};
+  apiManager.generateAPIs(this, localApis);
+
+  if (this.viewType == "background") {
+    apiManager.global.initializeBackgroundPage(this.contentWindow);
+  }
+
+  let childManager = new WannabeChildAPIManager(this, this.messageManager, localApis, {
+    envType: "addon_parent",
+    viewType: this.viewType,
+    url: this.uri.spec,
+  });
+
+  this.callOnClose(childManager);
+
+  return childManager;
+});
+
 // All subframes in a tab, background page, popup, etc. have the same view type.
 // This class keeps track of such global state.
 // Note that this is created even for non-extension tabs because at present we
 // do not have a way to distinguish regular tabs from extension tabs at the
 // initialization of a frame script.
 class ContentGlobal {
   /**
    * @param {nsIContentFrameMessageManager} global The frame script's global.
--- a/toolkit/components/extensions/ExtensionContent.jsm
+++ b/toolkit/components/extensions/ExtensionContent.jsm
@@ -44,16 +44,17 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyModuleGetter(this, "WebNavigationFrames",
                                   "resource://gre/modules/WebNavigationFrames.jsm");
 
 Cu.import("resource://gre/modules/ExtensionChild.jsm");
 
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 var {
   runSafeSyncWithoutClone,
+  defineLazyGetter,
   BaseContext,
   LocaleData,
   Messenger,
   flushJarCache,
   getInnerWindowID,
   promiseDocumentReady,
   ChildAPIManager,
   SchemaAPIManager,
@@ -264,32 +265,32 @@ class ExtensionContext extends BaseConte
 
     this.setContentWindow(contentWindow);
 
     let frameId = WebNavigationFrames.getFrameId(contentWindow);
     this.frameId = frameId;
 
     this.scripts = [];
 
-    let prin;
     let contentPrincipal = contentWindow.document.nodePrincipal;
     let ssm = Services.scriptSecurityManager;
 
     // copy origin attributes from the content window origin attributes to
     // preserve the user context id. overwrite the addonId.
     let attrs = contentPrincipal.originAttributes;
     attrs.addonId = this.extension.id;
     let extensionPrincipal = ssm.createCodebasePrincipal(this.extension.baseURI, attrs);
 
+    let principal;
     if (ssm.isSystemPrincipal(contentPrincipal)) {
       // Make sure we don't hand out the system principal by accident.
       // also make sure that the null principal has the right origin attributes
-      prin = ssm.createNullPrincipal(attrs);
+      principal = ssm.createNullPrincipal(attrs);
     } else {
-      prin = [contentPrincipal, extensionPrincipal];
+      principal = [contentPrincipal, extensionPrincipal];
     }
 
     if (isExtensionPage) {
       if (ExtensionManagement.getAddonIdForWindow(this.contentWindow) != this.extension.id) {
         throw new Error("Invalid target window for this extension context");
       }
       // This is an iframe with content script API enabled and its principal should be the
       // contentWindow itself. (we create a sandbox with the contentWindow as principal and with X-rays disabled
@@ -304,17 +305,17 @@ class ExtensionContext extends BaseConte
       // This metadata is required by the Developer Tools, in order for
       // the content script to be associated with both the extension and
       // the tab holding the content page.
       let metadata = {
         "inner-window-id": this.innerWindowID,
         addonId: attrs.addonId,
       };
 
-      this.sandbox = Cu.Sandbox(prin, {
+      this.sandbox = Cu.Sandbox(principal, {
         metadata,
         sandboxPrototype: contentWindow,
         wantXrays: true,
         isWebExtensionContentScript: true,
         wantExportHelpers: true,
         wantGlobalProperties: ["XMLHttpRequest", "fetch"],
         originAttributes: attrs,
       });
@@ -327,42 +328,34 @@ class ExtensionContext extends BaseConte
     }
 
     Object.defineProperty(this, "principal", {
       value: Cu.getObjectPrincipal(this.sandbox),
       enumerable: true,
       configurable: true,
     });
 
-    let url = contentWindow.location.href;
-    // The |sender| parameter is passed directly to the extension.
-    let sender = {id: this.extension.uuid, frameId, url};
-    let filter = {extensionId: this.extension.id};
-    let optionalFilter = {frameId};
-    this.messenger = new Messenger(this, [this.messageManager], sender, filter, optionalFilter);
-
-    this.chromeObj = Cu.createObjectIn(this.sandbox, {defineAs: "browser"});
+    this.url = contentWindow.location.href;
 
-    // Sandboxes don't get Xrays for some weird compatibility
-    // reason. However, we waive here anyway in case that changes.
-    Cu.waiveXrays(this.sandbox).chrome = this.chromeObj;
+    defineLazyGetter(this, "chromeObj", () => {
+      let chromeObj = Cu.createObjectIn(this.sandbox);
 
-    let localApis = {};
-    apiManager.generateAPIs(this, localApis);
-    this.childManager = new ChildAPIManager(this, this.messageManager, localApis, {
-      envType: "content_parent",
-      url,
+      Schemas.inject(chromeObj, this.childManager);
+      return chromeObj;
     });
 
-    Schemas.inject(this.chromeObj, this.childManager);
+    Schemas.exportLazyGetter(this.sandbox, "browser", () => this.chromeObj);
+    Schemas.exportLazyGetter(this.sandbox, "chrome", () => this.chromeObj);
 
-    // This is an iframe with content script API enabled. (See Bug 1214658 for rationale)
+    // This is an iframe with content script API enabled (bug 1214658)
     if (isExtensionPage) {
-      Cu.waiveXrays(this.contentWindow).chrome = this.chromeObj;
-      Cu.waiveXrays(this.contentWindow).browser = this.chromeObj;
+      Schemas.exportLazyGetter(this.contentWindow,
+                               "browser", () => this.chromeObj);
+      Schemas.exportLazyGetter(this.contentWindow,
+                               "chrome", () => this.chromeObj);
     }
   }
 
   get cloneScope() {
     return this.sandbox;
   }
 
   execute(script, shouldRun) {
@@ -388,18 +381,16 @@ class ExtensionContext extends BaseConte
       // Don't bother saving scripts after document_idle.
       this.scripts = this.scripts.filter(script => script.requiresCleanup);
     }
   }
 
   close() {
     super.unload();
 
-    this.childManager.close();
-
     if (this.contentWindow) {
       for (let script of this.scripts) {
         if (script.requiresCleanup) {
           script.cleanup(this.contentWindow);
         }
       }
 
       // Overwrite the content script APIs with an empty object if the APIs objects are still
@@ -409,16 +400,39 @@ class ExtensionContext extends BaseConte
         Cu.createObjectIn(this.contentWindow, {defineAs: "chrome"});
       }
     }
     Cu.nukeSandbox(this.sandbox);
     this.sandbox = null;
   }
 }
 
+defineLazyGetter(ExtensionContext.prototype, "messenger", function() {
+  // The |sender| parameter is passed directly to the extension.
+  let sender = {id: this.extension.uuid, frameId: this.frameId, url: this.url};
+  let filter = {extensionId: this.extension.id};
+  let optionalFilter = {frameId: this.frameId};
+
+  return new Messenger(this, [this.messageManager], sender, filter, optionalFilter);
+});
+
+defineLazyGetter(ExtensionContext.prototype, "childManager", function() {
+  let localApis = {};
+  apiManager.generateAPIs(this, localApis);
+
+  let childManager = new ChildAPIManager(this, this.messageManager, localApis, {
+    envType: "content_parent",
+    url: this.url,
+  });
+
+  this.callOnClose(childManager);
+
+  return childManager;
+});
+
 // Responsible for creating ExtensionContexts and injecting content
 // scripts into them when new documents are created.
 DocumentManager = {
   extensionCount: 0,
 
   // Map[windowId -> Map[extensionId -> ExtensionContext]]
   contentScriptWindows: new Map(),
 
@@ -662,31 +676,29 @@ DocumentManager = {
 
     this.extensionCount--;
     if (this.extensionCount == 0) {
       this.uninit();
     }
   },
 
   trigger(when, window) {
-    let state = this.getWindowState(window);
-
-    if (state == "document_start") {
+    if (when == "document_start") {
       for (let extension of ExtensionManager.extensions.values()) {
         for (let script of extension.scripts) {
           if (script.matches(window)) {
             let context = this.getContentScriptContext(extension, window);
             context.addScript(script);
           }
         }
       }
     } else {
       let contexts = this.contentScriptWindows.get(getInnerWindowID(window)) || new Map();
       for (let context of contexts.values()) {
-        context.triggerScripts(state);
+        context.triggerScripts(when);
       }
     }
   },
 };
 
 // Represents a browser extension in the content process.
 function BrowserExtensionContent(data) {
   this.id = data.id;
--- a/toolkit/components/extensions/ExtensionUtils.jsm
+++ b/toolkit/components/extensions/ExtensionUtils.jsm
@@ -199,16 +199,18 @@ class BaseContext {
     let {document} = contentWindow;
     let docShell = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
                                 .getInterface(Ci.nsIDocShell);
 
     this.innerWindowID = getInnerWindowID(contentWindow);
     this.messageManager = docShell.QueryInterface(Ci.nsIInterfaceRequestor)
                                   .getInterface(Ci.nsIContentFrameMessageManager);
 
+    MessageChannel.setupMessageManagers([this.messageManager]);
+
     let onPageShow = event => {
       if (!event || event.target === document) {
         this.docShell = docShell;
         this.contentWindow = contentWindow;
         this.active = true;
       }
     };
     let onPageHide = event => {
@@ -1434,18 +1436,16 @@ function getMessageManager(target) {
  *     `optionalFilter` and `recipient` are applied to filter incoming messages.
  */
 function Messenger(context, messageManagers, sender, filter, optionalFilter) {
   this.context = context;
   this.messageManagers = messageManagers;
   this.sender = sender;
   this.filter = filter;
   this.optionalFilter = optionalFilter;
-
-  MessageChannel.setupMessageManagers(messageManagers);
 }
 
 Messenger.prototype = {
   _sendMessage(messageManager, message, data, recipient) {
     let options = {
       recipient,
       sender: this.sender,
       responseType: MessageChannel.RESPONSE_FIRST,
@@ -2165,17 +2165,59 @@ function normalizeTime(date) {
                         ? parseInt(date, 10) : date);
 }
 
 const stylesheetMap = new DefaultMap(url => {
   let uri = NetUtil.newURI(url);
   return styleSheetService.preloadSheet(uri, styleSheetService.AGENT_SHEET);
 });
 
+/**
+ * Defines a lazy getter for the given property on the given object. The
+ * first time the property is accessed, the return value of the getter
+ * is defined on the current `this` object with the given property name.
+ * Importantly, this means that a lazy getter defined on an object
+ * prototype will be invoked separately for each object instance that
+ * it's accessed on.
+ *
+ * @param {object} object
+ *        The object on which to define the getter.
+ * @param {string|Symbol} prop
+ *        The property name for which to define the getter.
+ * @param {function} getter
+ *        The function to call in order to generate the final property
+ *        value.
+ */
+function defineLazyGetter(object, prop, getter) {
+  let redefine = (obj, value) => {
+    Object.defineProperty(obj, prop, {
+      enumerable: true,
+      configurable: true,
+      writable: true,
+      value,
+    });
+    return value;
+  };
+
+  Object.defineProperty(object, prop, {
+    enumerable: true,
+    configurable: true,
+
+    get() {
+      return redefine(this, getter.call(this));
+    },
+
+    set(value) {
+      redefine(this, value);
+    },
+  });
+}
+
 this.ExtensionUtils = {
+  defineLazyGetter,
   detectLanguage,
   extend,
   flushJarCache,
   getConsole,
   getInnerWindowID,
   ignoreEvent,
   injectAPI,
   instanceOf,
--- a/toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js
@@ -9,45 +9,56 @@ function getNextContext() {
       Management.off("proxy-context-load", listener);
       resolve(context);
     });
   });
 }
 
 add_task(function* test_storage_api_without_permissions() {
   let extension = ExtensionTestUtils.loadExtension({
-    background() {},
+    background() {
+      // Force API initialization.
+      void browser.storage;
+    },
 
     manifest: {
       permissions: [],
     },
   });
 
   let contextPromise = getNextContext();
   yield extension.startup();
 
   let context = yield contextPromise;
 
+  // Force API initialization.
+  void context.apiObj;
+
   ok(!("storage" in context._unwrappedAPIs),
      "The storage API should not be initialized");
 
   yield extension.unload();
 });
 
 add_task(function* test_storage_api_with_permissions() {
   let extension = ExtensionTestUtils.loadExtension({
-    background() {},
+    background() {
+      void browser.storage;
+    },
 
     manifest: {
       permissions: ["storage"],
     },
   });
 
   let contextPromise = getNextContext();
   yield extension.startup();
 
   let context = yield contextPromise;
 
+  // Force API initialization.
+  void context.apiObj;
+
   equal(typeof context._unwrappedAPIs.storage, "object",
         "The storage API should be initialized");
 
   yield extension.unload();
 });