Bug 1630786 part 1 - Support insertCSS/removeCSS/executeScript in compose windows. r=mkmelin
authorGeoff Lankow <geoff@darktrojan.net>
Mon, 20 Apr 2020 18:04:29 +1200
changeset 29407 9e23d0584abc41d2ea745363fe84de5a1f34147d
parent 29406 e7ab0d2b72908c0a66347f188f8e00d3b87e7b11
child 29408 263dae2cae3fb036f47bdf11fcb567d50b746066
push id17355
push usergeoff@darktrojan.net
push dateTue, 28 Apr 2020 05:08:04 +0000
treeherdercomm-central@263dae2cae3f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmkmelin
bugs1630786
Bug 1630786 part 1 - Support insertCSS/removeCSS/executeScript in compose windows. r=mkmelin
mail/components/compose/content/MsgComposeCommands.js
mail/components/extensions/parent/ext-mail.js
mail/components/extensions/parent/ext-mailTabs.js
mail/components/extensions/test/browser/browser.ini
mail/components/extensions/test/browser/browser_ext_composeScripts.js
--- a/mail/components/compose/content/MsgComposeCommands.js
+++ b/mail/components/compose/content/MsgComposeCommands.js
@@ -47,16 +47,19 @@ var { PluralForm } = ChromeUtils.import(
 );
 var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
 var { AppConstants } = ChromeUtils.import(
   "resource://gre/modules/AppConstants.jsm"
 );
 var { MailConstants } = ChromeUtils.import(
   "resource:///modules/MailConstants.jsm"
 );
+var { ExtensionParent } = ChromeUtils.import(
+  "resource://gre/modules/ExtensionParent.jsm"
+);
 
 var l10n = new Localization(
   ["messenger/messengercompose/messengercompose.ftl"],
   true
 );
 
 ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
 ChromeUtils.defineModuleGetter(
@@ -3742,16 +3745,21 @@ function ComposeLoad() {
   }
 
   if (gSMFields && !gSelectedTechnologyIsPGP) {
     gSMFields.requireEncryptMessage = gSendEncrypted;
     gSMFields.signMessage = gSendSigned;
   }
 
   setEncSigStatusUI();
+
+  ExtensionParent.apiManager.emit(
+    "extension-browser-inserted",
+    GetCurrentEditorElement()
+  );
 }
 
 function ComposeUnload() {
   // Send notification that the window is going away completely.
   document
     .getElementById("msgcomposeWindow")
     .dispatchEvent(
       new Event("compose-window-unload", { bubbles: false, cancelable: false })
--- a/mail/components/extensions/parent/ext-mail.js
+++ b/mail/components/extensions/parent/ext-mail.js
@@ -24,23 +24,27 @@ var { defineLazyGetter } = ExtensionComm
 XPCOMUtils.defineLazyModuleGetters(this, {
   AppConstants: "resource://gre/modules/AppConstants.jsm",
   ExtensionPageChild: "resource://gre/modules/ExtensionPageChild.jsm",
   ExtensionProcessScript: "resource://gre/modules/ExtensionProcessScript.jsm",
   ExtensionContent: "resource://gre/modules/ExtensionContent.jsm",
   Schemas: "resource://gre/modules/Schemas.jsm",
 });
 
+const COMPOSE_WINDOW_URI =
+  "chrome://messenger/content/messengercompose/messengercompose.xhtml";
+
 // Inject the |messenger| object as an alias to |browser| in all known contexts. This is a bit
 // fragile since it uses monkeypatching. If a test fails, the best way to debug is to search for
 // Schemas.exportLazyGetter where it does the injections, add |messenger| alias to those files until
 // the test passes again, and then find out why the monkeypatching is not catching it.
 (function() {
   let loadContentScript = ExtensionProcessScript.loadContentScript;
   let initExtensionContext = ExtensionContent.initExtensionContext;
+  let handleExtensionExecute = ExtensionContent.handleExtensionExecute;
   let initPageChildExtensionContext = ExtensionPageChild.initExtensionContext;
 
   // This patches constructor of ContentScriptContextChild adding the object to the sandbox
   ExtensionProcessScript.loadContentScript = function(contentScript, window) {
     let script = ExtensionContent.contentScripts.get(contentScript);
     let context = script.extension.getContext(window);
     Schemas.exportLazyGetter(
       context.sandbox,
@@ -55,16 +59,41 @@ XPCOMUtils.defineLazyModuleGetters(this,
   // points to a moz-extension:// page exposed via web_accessible_content
   ExtensionContent.initExtensionContext = function(extension, window) {
     let context = extension.getContext(window);
     Schemas.exportLazyGetter(window, "messenger", () => context.chromeObj);
 
     return initExtensionContext.apply(ExtensionContent, arguments);
   };
 
+  ExtensionContent.handleExtensionExecute = function(
+    global,
+    target,
+    options,
+    script
+  ) {
+    if (
+      script.extension.hasPermission("compose") &&
+      target.chromeOuterWindowID
+    ) {
+      let outerWindow = Services.wm.getOuterWindowWithId(
+        target.chromeOuterWindowID
+      );
+      if (outerWindow && outerWindow.location.href == COMPOSE_WINDOW_URI) {
+        script.matchesWindow = () => true;
+      }
+    }
+    return handleExtensionExecute.apply(ExtensionContent, [
+      global,
+      target,
+      options,
+      script,
+    ]);
+  };
+
   // This patches privileged pages such as the background script
   ExtensionPageChild.initExtensionContext = function(extension, window) {
     let retval = initPageChildExtensionContext.apply(
       ExtensionPageChild,
       arguments
     );
 
     let windowId = getInnerWindowID(window);
@@ -721,30 +750,38 @@ tabTracker = new TabTracker();
 windowTracker = new WindowTracker();
 Object.assign(global, { tabTracker, windowTracker });
 
 /**
  * Extension-specific wrapper around a Thunderbird tab.
  */
 class Tab extends TabBase {
   /** Returns true if this tab is a 3-pane tab. */
-  get mailTab() {
+  get isMailTab() {
     return false;
   }
 
+  /** Returns true if this tab is a compose window "tab". */
+  get isComposeTab() {
+    return (
+      this.nativeTab.location &&
+      this.nativeTab.location.href == COMPOSE_WINDOW_URI
+    );
+  }
+
   /** Overrides the matches function to enable querying for 3-pane tabs. */
   matches(queryInfo, context) {
     let result = super.matches(queryInfo, context);
-    return result && (!queryInfo.mailTab || this.mailTab);
+    return result && (!queryInfo.mailTab || this.isMailTab);
   }
 
   /** Adds the mailTab property and removes some useless properties from a tab object. */
   convert(fallback) {
     let result = super.convert(fallback);
-    result.mailTab = this.mailTab;
+    result.mailTab = this.isMailTab;
 
     // These properties are not useful to Thunderbird extensions and are not returned.
     for (let key of [
       "attention",
       "audible",
       "discarded",
       "hidden",
       "incognito",
@@ -764,28 +801,41 @@ class Tab extends TabBase {
 
   /** Always returns false. This feature doesn't exist in Thunderbird. */
   get _incognito() {
     return false;
   }
 
   /** Returns the XUL browser for the tab. */
   get browser() {
+    if (this.isComposeTab) {
+      return this.nativeTab.GetCurrentEditorElement();
+    }
     return null;
   }
 
+  get innerWindowID() {
+    if (this.isComposeTab) {
+      return this.browser.contentWindow.windowUtils.currentInnerWindowID;
+    }
+    return super.innerWindowID;
+  }
+
   /** Returns the frame loader for the tab. */
   get frameLoader() {
     // If we don't have a frameLoader yet, just return a dummy with no width and
     // height.
     return super.frameLoader || { lazyWidth: 0, lazyHeight: 0 };
   }
 
   /** Returns the current URL of this tab, without permission checks. */
   get _url() {
+    if (this.isComposeTab) {
+      return undefined;
+    }
     return this.browser ? this.browser.currentURI.spec : null;
   }
 
   /** Returns the current title of this tab, without permission checks. */
   get _title() {
     if (this.browser && this.browser.contentTitle) {
       return this.browser.contentTitle;
     }
@@ -923,17 +973,17 @@ class TabmailTab extends Tab {
   }
 
   /** Returns the tabmail element for the tab. */
   get tabmail() {
     return getTabTabmail(this.nativeTab);
   }
 
   /** Returns true if this tab is a 3-pane tab. */
-  get mailTab() {
+  get isMailTab() {
     return ["folder", "glodaList"].includes(this.nativeTab.mode.name);
   }
 
   /** Returns the tab index. */
   get index() {
     return this.tabmail.tabInfo.indexOf(this.nativeTab);
   }
 
--- a/mail/components/extensions/parent/ext-mailTabs.js
+++ b/mail/components/extensions/parent/ext-mailTabs.js
@@ -139,17 +139,17 @@ this.mailTabs = class extends ExtensionA
       let tab;
       if (tabId) {
         tab = tabManager.get(tabId);
       } else {
         tab = tabManager.wrapTab(tabTracker.activeTab);
         tabId = tab.id;
       }
 
-      if (tab && tab.mailTab) {
+      if (tab && tab.isMailTab) {
         return tab;
       }
       throw new ExtensionError(`Invalid mail tab ID: ${tabId}`);
     }
 
     return {
       mailTabs: {
         async query({ active, currentWindow, lastFocusedWindow, windowId }) {
--- a/mail/components/extensions/test/browser/browser.ini
+++ b/mail/components/extensions/test/browser/browser.ini
@@ -19,16 +19,17 @@ tags = addrbook
 [browser_ext_commands_getAll.js]
 [browser_ext_commands_onCommand.js]
 [browser_ext_commands_update.js]
 [browser_ext_compose_begin.js]
 [browser_ext_compose_details.js]
 [browser_ext_compose_onBeforeSend.js]
 [browser_ext_composeAction.js]
 [browser_ext_composeAction_properties.js]
+[browser_ext_composeScripts.js]
 [browser_ext_mailTabs.js]
 [browser_ext_menus.js]
 support-files = data/content.html
 [browser_ext_menus_replace_menu.js]
 [browser_ext_menus_replace_menu_context.js]
 [browser_ext_messageDisplay.js]
 [browser_ext_messageDisplayAction.js]
 [browser_ext_messageDisplayAction_properties.js]
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/test/browser/browser_ext_composeScripts.js
@@ -0,0 +1,247 @@
+/* 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) {
+  let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+  Assert.equal(composeWindows.length, 1);
+
+  let composeWindow = composeWindows[0];
+  await new Promise(resolve => composeWindow.setTimeout(resolve));
+
+  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);
+  }
+  if ("color" in expected) {
+    Assert.equal(computedStyle.color, expected.color);
+  }
+  if ("foo" in expected) {
+    Assert.equal(composeBody.getAttribute("foo"), expected.foo);
+  }
+  if ("textContent" in expected) {
+    Assert.equal(composeBody.textContent, expected.textContent);
+  }
+}
+
+// Functions for extensions to use, so that we avoid repeating ourselves.
+var utilityFunctions = () => {
+  this.sendMessageGetReply = function() {
+    return new Promise(resolve => {
+      browser.test.onMessage.addListener(function listener() {
+        browser.test.onMessage.removeListener(listener);
+        resolve();
+      });
+      browser.test.sendMessage();
+    });
+  };
+};
+
+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, {
+          code: "body { background-color: lime; }",
+        });
+        await this.sendMessageGetReply();
+
+        await browser.tabs.removeCSS(tab.id, {
+          code: "body { background-color: lime; }",
+        });
+        await this.sendMessageGetReply();
+
+        await browser.tabs.insertCSS(tab.id, { file: "test.css" });
+        await this.sendMessageGetReply();
+
+        await browser.tabs.removeCSS(tab.id, { file: "test.css" });
+        await this.sendMessageGetReply();
+
+        await browser.tabs.remove(tab.id);
+        browser.test.notifyPass("finished");
+      },
+      "test.css": "body { background-color: green; }",
+      "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)" });
+  extension.sendMessage();
+
+  await extension.awaitMessage();
+  await checkComposeBody({ backgroundColor: "rgb(0, 255, 0)" });
+  extension.sendMessage();
+
+  await extension.awaitMessage();
+  await checkComposeBody({ backgroundColor: "rgba(0, 0, 0, 0)" });
+  extension.sendMessage();
+
+  await extension.awaitMessage();
+  await checkComposeBody({ backgroundColor: "rgb(0, 128, 0)" });
+  extension.sendMessage();
+
+  await extension.awaitMessage();
+  await checkComposeBody({ backgroundColor: "rgba(0, 0, 0, 0)" });
+  extension.sendMessage();
+
+  await extension.awaitFinish("finished");
+  await extension.unload();
+});
+
+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, {
+            code: "body { background-color: darkred; }",
+          }),
+          /Missing host permission for the tab/,
+          "insertCSS without permission should throw"
+        );
+
+        await browser.test.assertRejects(
+          browser.tabs.insertCSS(tab.id, { file: "test.css" }),
+          /Missing host permission for the tab/,
+          "insertCSS without permission should throw"
+        );
+
+        await this.sendMessageGetReply();
+
+        await browser.tabs.remove(tab.id);
+        browser.test.notifyPass("finished");
+      },
+      "test.css": "body { background-color: red; }",
+      "utils.js": utilityFunctions,
+    },
+    manifest: {
+      background: { scripts: ["utils.js", "background.js"] },
+      permissions: [],
+    },
+  });
+
+  await extension.startup();
+
+  await extension.awaitMessage();
+  await checkComposeBody({
+    backgroundColor: "rgba(0, 0, 0, 0)",
+    textContent: "",
+  });
+  extension.sendMessage();
+
+  await extension.awaitFinish("finished");
+  await extension.unload();
+});
+
+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, {
+          code: `document.body.setAttribute("foo", "bar");`,
+        });
+        await this.sendMessageGetReply();
+
+        await browser.tabs.executeScript(tab.id, { file: "test.js" });
+        await this.sendMessageGetReply();
+
+        await browser.tabs.remove(tab.id);
+        browser.test.notifyPass("finished");
+      },
+      "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({ textContent: "" });
+  extension.sendMessage();
+
+  await extension.awaitMessage();
+  await checkComposeBody({ foo: "bar" });
+  extension.sendMessage();
+
+  await extension.awaitMessage();
+  await checkComposeBody({
+    foo: "bar",
+    textContent: "Hey look, the script ran!",
+  });
+  extension.sendMessage();
+
+  await extension.awaitFinish("finished");
+  await extension.unload();
+});
+
+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, {
+            code: `document.body.setAttribute("foo", "bar");`,
+          }),
+          /Missing host permission for the tab/,
+          "executeScript without permission should throw"
+        );
+
+        await browser.test.assertRejects(
+          browser.tabs.executeScript(tab.id, { file: "test.js" }),
+          /Missing host permission for the tab/,
+          "executeScript without permission should throw"
+        );
+
+        await this.sendMessageGetReply();
+
+        await browser.tabs.remove(tab.id);
+        browser.test.notifyPass("finished");
+      },
+      "test.js": () => {
+        document.body.textContent = "Hey look, the script ran!";
+      },
+      "utils.js": utilityFunctions,
+    },
+    manifest: {
+      background: { scripts: ["utils.js", "background.js"] },
+      permissions: [],
+    },
+  });
+
+  await extension.startup();
+
+  await extension.awaitMessage();
+  await checkComposeBody({ foo: null, textContent: "" });
+  extension.sendMessage();
+
+  await extension.awaitFinish("finished");
+  await extension.unload();
+});