Bug 1630786 part 2 - Allow registration of content scripts in the compose window. r=mkmelin
authorGeoff Lankow <geoff@darktrojan.net>
Thu, 16 Apr 2020 21:42:18 +1200
changeset 38946 263dae2cae3fb036f47bdf11fcb567d50b746066
parent 38945 9e23d0584abc41d2ea745363fe84de5a1f34147d
child 38947 fd16d22d04fa421ab6552ba8e6b7bde56235e9d4
push id401
push userclokep@gmail.com
push dateMon, 01 Jun 2020 20:41:59 +0000
reviewersmkmelin
bugs1630786
Bug 1630786 part 2 - Allow registration of content scripts in the compose window. r=mkmelin
mail/components/compose/content/MsgComposeCommands.js
mail/components/extensions/child/ext-composeScripts.js
mail/components/extensions/child/ext-mail.js
mail/components/extensions/ext-mail.json
mail/components/extensions/jar.mn
mail/components/extensions/parent/ext-composeScripts.js
mail/components/extensions/schemas/composeScripts.json
mail/components/extensions/test/browser/browser_ext_composeScripts.js
--- a/mail/components/compose/content/MsgComposeCommands.js
+++ b/mail/components/compose/content/MsgComposeCommands.js
@@ -8375,16 +8375,18 @@ function InitEditor() {
     }
   }
 
   // Run menubar initialization first, to avoid TabsInTitlebar code picking
   // up mutations from it and causing a reflow.
   if (AppConstants.platform != "macosx") {
     AutoHideMenubar.init();
   }
+
+  window.dispatchEvent(new CustomEvent("compose-editor-ready"));
 }
 
 // This is used as event listener to spellcheck-changed event to update
 // document language.
 function updateDocumentLanguage(e) {
   document.documentElement.setAttribute("lang", e.detail.dictionary);
 }
 
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/child/ext-composeScripts.js
@@ -0,0 +1,75 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionError } = ExtensionUtils;
+
+/**
+ * Represents (in the child extension process) a compose script registered
+ * programmatically (instead of being included in the addon manifest).
+ *
+ * @param {ExtensionPageContextChild} context
+ *        The extension context which has registered the compose script.
+ * @param {string} scriptId
+ *        An unique id that represents the registered compose script
+ *        (generated and used internally to identify it across the different processes).
+ */
+class ComposeScriptChild {
+  constructor(context, scriptId) {
+    this.context = context;
+    this.scriptId = scriptId;
+    this.unregistered = false;
+  }
+
+  async unregister() {
+    if (this.unregistered) {
+      throw new ExtensionError("compose script already unregistered");
+    }
+
+    this.unregistered = true;
+
+    await this.context.childManager.callParentAsyncFunction(
+      "composeScripts.unregister",
+      [this.scriptId]
+    );
+
+    this.context = null;
+  }
+
+  api() {
+    const { context } = this;
+
+    return {
+      unregister: () => {
+        return context.wrapPromise(this.unregister());
+      },
+    };
+  }
+}
+
+this.composeScripts = class extends ExtensionAPI {
+  getAPI(context) {
+    return {
+      composeScripts: {
+        register(options) {
+          return context.cloneScope.Promise.resolve().then(async () => {
+            const scriptId = await context.childManager.callParentAsyncFunction(
+              "composeScripts.register",
+              [options]
+            );
+
+            const registeredScript = new ComposeScriptChild(context, scriptId);
+
+            return Cu.cloneInto(registeredScript.api(), context.cloneScope, {
+              cloneFunctions: true,
+            });
+          });
+        },
+      },
+    };
+  }
+};
--- a/mail/components/extensions/child/ext-mail.js
+++ b/mail/components/extensions/child/ext-mail.js
@@ -1,15 +1,20 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 extensions.registerModules({
+  composeScripts: {
+    url: "chrome://messenger/content/child/ext-composeScripts.js",
+    scopes: ["addon_child"],
+    paths: [["composeScripts"]],
+  },
   menus: {
     url: "chrome://messenger/content/child/ext-menus.js",
     scopes: ["addon_child"],
     paths: [["menus"]],
   },
   tabs: {
     url: "chrome://messenger/content/child/ext-tabs.js",
     scopes: ["addon_child"],
--- a/mail/components/extensions/ext-mail.json
+++ b/mail/components/extensions/ext-mail.json
@@ -64,16 +64,24 @@
     "schema": "chrome://messenger/content/schemas/composeAction.json",
     "scopes": ["addon_parent"],
     "manifest": ["compose_action"],
     "events": ["uninstall"],
     "paths": [
       ["composeAction"]
     ]
   },
+  "composeScripts": {
+    "url": "chrome://messenger/content/parent/ext-composeScripts.js",
+    "schema": "chrome://messenger/content/schemas/composeScripts.json",
+    "scopes": ["addon_parent"],
+    "paths": [
+      ["composeScripts"]
+    ]
+  },
   "folders": {
     "url": "chrome://messenger/content/parent/ext-folders.js",
     "schema": "chrome://messenger/content/schemas/folders.json",
     "scopes": ["addon_parent"],
     "paths": [
       ["folders"]
     ]
   },
--- a/mail/components/extensions/jar.mn
+++ b/mail/components/extensions/jar.mn
@@ -1,28 +1,30 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 messenger.jar:
     content/messenger/ext-mail.json                (ext-mail.json)
     content/messenger/extension.svg                (extension.svg)
 
+    content/messenger/child/ext-composeScripts.js  (child/ext-composeScripts.js)
     content/messenger/child/ext-mail.js            (child/ext-mail.js)
     content/messenger/child/ext-menus.js           (child/ext-menus.js)
     content/messenger/child/ext-tabs.js            (child/ext-tabs.js)
 
     content/messenger/parent/ext-accounts.js       (parent/ext-accounts.js)
     content/messenger/parent/ext-addressBook.js    (parent/ext-addressBook.js)
     content/messenger/parent/ext-browserAction.js  (parent/ext-browserAction.js)
     content/messenger/parent/ext-chrome-settings-overrides.js      (parent/ext-chrome-settings-overrides.js)
     content/messenger/parent/ext-cloudFile.js      (parent/ext-cloudFile.js)
     content/messenger/parent/ext-commands.js       (../../../../browser/components/extensions/parent/ext-commands.js)
     content/messenger/parent/ext-compose.js        (parent/ext-compose.js)
     content/messenger/parent/ext-composeAction.js  (parent/ext-composeAction.js)
+    content/messenger/parent/ext-composeScripts.js (parent/ext-composeScripts.js)
     content/messenger/parent/ext-folders.js        (parent/ext-folders.js)
     content/messenger/parent/ext-mail.js           (parent/ext-mail.js)
     content/messenger/parent/ext-mailTabs.js       (parent/ext-mailTabs.js)
     content/messenger/parent/ext-menus.js          (parent/ext-menus.js)
     content/messenger/parent/ext-messageDisplay.js (parent/ext-messageDisplay.js)
     content/messenger/parent/ext-messageDisplayAction.js  (parent/ext-messageDisplayAction.js)
     content/messenger/parent/ext-messages.js       (parent/ext-messages.js)
     content/messenger/parent/ext-pkcs11.js         (../../../../browser/components/extensions/parent/ext-pkcs11.js)
@@ -32,16 +34,17 @@ messenger.jar:
     content/messenger/schemas/accounts.json        (schemas/accounts.json)
     content/messenger/schemas/addressBook.json     (schemas/addressBook.json)
     content/messenger/schemas/browserAction.json   (schemas/browserAction.json)
     content/messenger/schemas/chrome_settings_overrides.json       (schemas/chrome_settings_overrides.json)
     content/messenger/schemas/cloudFile.json       (schemas/cloudFile.json)
     content/messenger/schemas/commands.json        (../../../../browser/components/extensions/schemas/commands.json)
     content/messenger/schemas/compose.json         (schemas/compose.json)
     content/messenger/schemas/composeAction.json   (schemas/composeAction.json)
+    content/messenger/schemas/composeScripts.json  (schemas/composeScripts.json)
     content/messenger/schemas/folders.json         (schemas/folders.json)
     content/messenger/schemas/mailTabs.json        (schemas/mailTabs.json)
     content/messenger/schemas/menus.json           (schemas/menus.json)
     content/messenger/schemas/menus_child.json     (schemas/menus_child.json)
     content/messenger/schemas/messageDisplay.json  (schemas/messageDisplay.json)
     content/messenger/schemas/messageDisplayAction.json  (schemas/messageDisplayAction.json)
     content/messenger/schemas/messages.json        (schemas/messages.json)
     content/messenger/schemas/pkcs11.json          (../../../../browser/components/extensions/schemas/pkcs11.json)
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/parent/ext-composeScripts.js
@@ -0,0 +1,167 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+var { ExtensionSupport } = ChromeUtils.import(
+  "resource:///modules/ExtensionSupport.jsm"
+);
+var { ExtensionUtils } = ChromeUtils.import(
+  "resource://gre/modules/ExtensionUtils.jsm"
+);
+
+var { ExtensionError, getUniqueId } = ExtensionUtils;
+
+let scripts = new Set();
+
+ExtensionSupport.registerWindowListener("ext-composeScripts", {
+  chromeURLs: [
+    "chrome://messenger/content/messengercompose/messengercompose.xhtml",
+  ],
+  onLoadWindow: async window => {
+    await new Promise(resolve =>
+      window.addEventListener("compose-editor-ready", resolve, { once: true })
+    );
+    for (let script of scripts) {
+      script.addToWindow(window);
+    }
+  },
+});
+
+/**
+ * Represents (in the main browser process) a compose script registered
+ * programmatically (instead of being included in the addon manifest).
+ *
+ * @param {ProxyContextParent} context
+ *        The parent proxy context related to the extension context which
+ *        has registered the compose script.
+ * @param {RegisteredComposeScriptOptions} details
+ *        The options object related to the registered compose script
+ *        (which has the properties described in the compose_scripts.json
+ *        JSON API schema file).
+ */
+class ComposeScriptParent {
+  constructor({ context, details }) {
+    this.context = context;
+    this.scriptId = getUniqueId();
+
+    this.options = this._convertOptions(details);
+    context.callOnClose(this);
+
+    for (let window of Services.wm.getEnumerator("msgcompose")) {
+      this.addToWindow(window);
+    }
+    scripts.add(this);
+  }
+
+  close() {
+    this.destroy();
+  }
+
+  destroy() {
+    if (this.destroyed) {
+      throw new Error("Unable to destroy ComposeScriptParent twice");
+    }
+
+    scripts.delete(this);
+    for (let window of Services.wm.getEnumerator("msgcompose")) {
+      this.removeFromWindow(window);
+    }
+
+    this.destroyed = true;
+    this.context.forgetOnClose(this);
+    this.context = null;
+    this.options = null;
+  }
+
+  _convertOptions(details) {
+    const options = {
+      js: [],
+      css: [],
+    };
+
+    if (details.js && details.js.length) {
+      options.js = details.js.map(data => {
+        return {
+          code: data.code || null,
+          file: data.file || null,
+        };
+      });
+    }
+
+    if (details.css && details.css.length) {
+      options.css = details.css.map(data => {
+        return {
+          code: data.code || null,
+          file: data.file || null,
+        };
+      });
+    }
+
+    return options;
+  }
+
+  async addToWindow(window) {
+    let tabWrapper = this.context.extension.tabManager.getWrapper(window);
+    for (let css of this.options.css) {
+      await tabWrapper.insertCSS(this.context, css);
+    }
+    for (let js of this.options.js) {
+      await tabWrapper.executeScript(this.context, js);
+    }
+    window.dispatchEvent(new window.CustomEvent("compose-scripts-added"));
+  }
+
+  removeFromWindow(window) {
+    let tabWrapper = this.context.extension.tabManager.getWrapper(window);
+    for (let css of this.options.css) {
+      tabWrapper.removeCSS(this.context, css);
+    }
+  }
+}
+
+this.composeScripts = class extends ExtensionAPI {
+  getAPI(context) {
+    // Map of the compose script registered from the extension context.
+    //
+    // Map<scriptId -> ComposeScriptParent>
+    const parentScriptsMap = new Map();
+
+    // Unregister all the scriptId related to a context when it is closed.
+    context.callOnClose({
+      close() {
+        for (let composeScript of parentScriptsMap.values()) {
+          composeScript.destroy();
+        }
+        parentScriptsMap.clear();
+      },
+    });
+
+    return {
+      composeScripts: {
+        async register(details) {
+          const composeScript = new ComposeScriptParent({ context, details });
+          const { scriptId } = composeScript;
+
+          parentScriptsMap.set(scriptId, composeScript);
+          return scriptId;
+        },
+
+        // This method is not available to the extension code, the extension code
+        // doesn't have access to the internally used scriptId, on the contrary
+        // the extension code will call script.unregister on the script API object
+        // that is resolved from the register API method returned promise.
+        async unregister(scriptId) {
+          const composeScript = parentScriptsMap.get(scriptId);
+          if (!composeScript) {
+            Cu.reportError(new Error(`No such compose script ID: ${scriptId}`));
+
+            return;
+          }
+
+          parentScriptsMap.delete(scriptId);
+          composeScript.destroy();
+        },
+      },
+    };
+  }
+};
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/schemas/composeScripts.json
@@ -0,0 +1,65 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+[
+  {
+    "namespace": "composeScripts",
+    "permissions": [
+      "compose"
+    ],
+    "types": [
+      {
+        "id": "RegisteredComposeScriptOptions",
+        "type": "object",
+        "description": "Details of a compose script registered programmatically",
+        "properties": {
+          "css": {
+            "type": "array",
+            "optional": true,
+            "description": "The list of CSS files to inject",
+            "items": {
+              "$ref": "extensionTypes.ExtensionFileOrCode"
+            }
+          },
+          "js": {
+            "type": "array",
+            "optional": true,
+            "description": "The list of JavaScript files to inject",
+            "items": {
+              "$ref": "extensionTypes.ExtensionFileOrCode"
+            }
+          }
+        }
+      },
+      {
+        "id": "RegisteredComposeScript",
+        "type": "object",
+        "description": "An object that represents a compose script registered programmatically",
+        "functions": [
+          {
+            "name": "unregister",
+            "type": "function",
+            "description": "Unregister a compose script registered programmatically",
+            "async": true,
+            "parameters": []
+          }
+        ]
+      }
+    ],
+    "functions": [
+      {
+        "name": "register",
+        "type": "function",
+        "description": "Register a compose script programmatically",
+        "async": true,
+        "parameters": [
+          {
+            "name": "composeScriptOptions",
+            "$ref": "RegisteredComposeScriptOptions"
+          }
+        ]
+      }
+    ]
+  }
+]
--- a/mail/components/extensions/test/browser/browser_ext_composeScripts.js
+++ b/mail/components/extensions/test/browser/browser_ext_composeScripts.js
@@ -1,20 +1,22 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
 
 addIdentity(createAccount());
 
-async function checkComposeBody(expected) {
+async function checkComposeBody(expected, waitForEvent) {
   let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
   Assert.equal(composeWindows.length, 1);
 
   let composeWindow = composeWindows[0];
-  await new Promise(resolve => composeWindow.setTimeout(resolve));
+  if (waitForEvent) {
+    await BrowserTestUtils.waitForEvent(composeWindow, "compose-scripts-added");
+  }
 
   let composeEditor = composeWindow.GetCurrentEditorElement();
   let composeBody = composeEditor.contentDocument.body;
   let computedStyle = composeEditor.contentWindow.getComputedStyle(composeBody);
 
   if ("backgroundColor" in expected) {
     Assert.equal(computedStyle.backgroundColor, expected.backgroundColor);
   }
@@ -37,16 +39,17 @@ var utilityFunctions = () => {
         browser.test.onMessage.removeListener(listener);
         resolve();
       });
       browser.test.sendMessage();
     });
   };
 };
 
+/** Tests browser.tabs.insertCSS and browser.tabs.removeCSS. */
 add_task(async function testInsertRemoveCSS() {
   let extension = ExtensionTestUtils.loadExtension({
     files: {
       "background.js": async () => {
         let tab = await browser.compose.beginNew();
         await this.sendMessageGetReply();
 
         await browser.tabs.insertCSS(tab.id, {
@@ -98,16 +101,17 @@ add_task(async function testInsertRemove
   await extension.awaitMessage();
   await checkComposeBody({ backgroundColor: "rgba(0, 0, 0, 0)" });
   extension.sendMessage();
 
   await extension.awaitFinish("finished");
   await extension.unload();
 });
 
+/** Tests browser.tabs.insertCSS fails without the "compose" permission. */
 add_task(async function testInsertRemoveCSSNoPermissions() {
   let extension = ExtensionTestUtils.loadExtension({
     files: {
       "background.js": async () => {
         let tab = await browser.compose.beginNew();
 
         await browser.test.assertRejects(
           browser.tabs.insertCSS(tab.id, {
@@ -145,16 +149,17 @@ add_task(async function testInsertRemove
     textContent: "",
   });
   extension.sendMessage();
 
   await extension.awaitFinish("finished");
   await extension.unload();
 });
 
+/** Tests browser.tabs.executeScript. */
 add_task(async function testExecuteScript() {
   let extension = ExtensionTestUtils.loadExtension({
     files: {
       "background.js": async () => {
         let tab = await browser.compose.beginNew();
         await this.sendMessageGetReply();
 
         await browser.tabs.executeScript(tab.id, {
@@ -195,16 +200,17 @@ add_task(async function testExecuteScrip
     textContent: "Hey look, the script ran!",
   });
   extension.sendMessage();
 
   await extension.awaitFinish("finished");
   await extension.unload();
 });
 
+/** Tests browser.tabs.executeScript fails without the "compose" permission. */
 add_task(async function testExecuteScriptNoPermissions() {
   let extension = ExtensionTestUtils.loadExtension({
     files: {
       "background.js": async () => {
         let tab = await browser.compose.beginNew();
 
         await browser.test.assertRejects(
           browser.tabs.executeScript(tab.id, {
@@ -240,8 +246,147 @@ add_task(async function testExecuteScrip
 
   await extension.awaitMessage();
   await checkComposeBody({ foo: null, textContent: "" });
   extension.sendMessage();
 
   await extension.awaitFinish("finished");
   await extension.unload();
 });
+
+/**
+ * Tests browser.composeScripts.register correctly adds CSS and JavaScript to
+ * message composition windows opened after it was called. Also tests calling
+ * `unregister` on the returned object.
+ */
+add_task(async function testRegisterBeforeCompose() {
+  let extension = ExtensionTestUtils.loadExtension({
+    files: {
+      "background.js": async () => {
+        let registeredScript = await browser.composeScripts.register({
+          css: [{ code: "body { color: white }" }, { file: "test.css" }],
+          js: [
+            { code: `document.body.setAttribute("foo", "bar");` },
+            { file: "test.js" },
+          ],
+        });
+
+        let tab = await browser.compose.beginNew();
+        await this.sendMessageGetReply();
+
+        await registeredScript.unregister();
+        await this.sendMessageGetReply();
+
+        await browser.tabs.remove(tab.id);
+        browser.test.notifyPass("finished");
+      },
+      "test.css": "body { background-color: green; }",
+      "test.js": () => {
+        document.body.textContent = "Hey look, the script ran!";
+      },
+      "utils.js": utilityFunctions,
+    },
+    manifest: {
+      background: { scripts: ["utils.js", "background.js"] },
+      permissions: ["compose"],
+    },
+  });
+
+  await extension.startup();
+
+  await extension.awaitMessage();
+  await checkComposeBody(
+    {
+      backgroundColor: "rgb(0, 128, 0)",
+      color: "rgb(255, 255, 255)",
+      foo: "bar",
+      textContent: "Hey look, the script ran!",
+    },
+    true
+  );
+  extension.sendMessage();
+
+  await extension.awaitMessage();
+  await checkComposeBody({
+    backgroundColor: "rgba(0, 0, 0, 0)",
+    color: "rgb(0, 0, 0)",
+    foo: "bar",
+    textContent: "Hey look, the script ran!",
+  });
+  extension.sendMessage();
+
+  await extension.awaitFinish("finished");
+  await extension.unload();
+});
+
+/**
+ * Tests browser.composeScripts.register correctly adds CSS and JavaScript to
+ * message composition windows already open when it was called. Also tests
+ * calling `unregister` on the returned object.
+ */
+add_task(async function testRegisterDuringCompose() {
+  let extension = ExtensionTestUtils.loadExtension({
+    files: {
+      "background.js": async () => {
+        let tab = await browser.compose.beginNew();
+        await this.sendMessageGetReply();
+
+        let registeredScript = await browser.composeScripts.register({
+          css: [{ code: "body { color: white }" }, { file: "test.css" }],
+          js: [
+            { code: `document.body.setAttribute("foo", "bar");` },
+            { file: "test.js" },
+          ],
+        });
+
+        await this.sendMessageGetReply();
+
+        await registeredScript.unregister();
+        await this.sendMessageGetReply();
+
+        await browser.tabs.remove(tab.id);
+        browser.test.notifyPass("finished");
+      },
+      "test.css": "body { background-color: green; }",
+      "test.js": () => {
+        document.body.textContent = "Hey look, the script ran!";
+      },
+      "utils.js": utilityFunctions,
+    },
+    manifest: {
+      background: { scripts: ["utils.js", "background.js"] },
+      permissions: ["compose"],
+    },
+  });
+
+  await extension.startup();
+
+  await extension.awaitMessage();
+  await checkComposeBody({
+    backgroundColor: "rgba(0, 0, 0, 0)",
+    textContent: "",
+  });
+  extension.sendMessage();
+
+  await extension.awaitMessage();
+  await checkComposeBody(
+    {
+      backgroundColor: "rgb(0, 128, 0)",
+      color: "rgb(255, 255, 255)",
+      foo: "bar",
+      textContent: "Hey look, the script ran!",
+    },
+    true
+  );
+  extension.sendMessage();
+
+  await extension.awaitMessage();
+  await checkComposeBody({
+    backgroundColor: "rgba(0, 0, 0, 0)",
+    color: "rgb(0, 0, 0)",
+    foo: "bar",
+    textContent: "Hey look, the script ran!",
+  });
+  extension.sendMessage();
+
+  await extension.awaitFinish("finished");
+  await extension.unload();
+});