Bug 1509744 - WebExtension Commands API. r=darktrojan
authorPhilipp Kewisch <mozilla@kewis.ch>
Mon, 19 Nov 2018 21:08:25 +0100
changeset 33162 714714d80d62
parent 33161 e756111cbbd0
child 33163 16d14cf843e2
push id2368
push userclokep@gmail.com
push dateMon, 28 Jan 2019 21:12:50 +0000
treeherdercomm-beta@56d23c07d815 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdarktrojan
bugs1509744
Bug 1509744 - WebExtension Commands API. r=darktrojan
mail/components/extensions/ext-mail.json
mail/components/extensions/jar.mn
mail/components/extensions/parent/.eslintrc.js
mail/components/extensions/parent/ext-browserAction.js
mail/components/extensions/parent/ext-commands.js
mail/components/extensions/schemas/commands.json
--- a/mail/components/extensions/ext-mail.json
+++ b/mail/components/extensions/ext-mail.json
@@ -20,16 +20,26 @@
     "url": "chrome://messenger/content/parent/ext-cloudFile.js",
     "schema": "chrome://messenger/content/schemas/cloudFile.json",
     "scopes": ["addon_parent", "content_parent"],
     "manifest": ["cloud_file"],
     "paths": [
       ["cloudFile"]
     ]
   },
+  "commands": {
+    "url": "chrome://messenger/content/parent/ext-commands.js",
+    "schema": "chrome://messenger/content/schemas/commands.json",
+    "scopes": ["addon_parent"],
+    "events": ["uninstall"],
+    "manifest": ["commands"],
+    "paths": [
+      ["commands"]
+    ]
+  },
   "composeAction": {
     "url": "chrome://messenger/content/parent/ext-composeAction.js",
     "schema": "chrome://messenger/content/schemas/composeAction.json",
     "scopes": ["addon_parent"],
     "manifest": ["compose_action"],
     "paths": [
       ["composeAction"]
     ]
--- a/mail/components/extensions/jar.mn
+++ b/mail/components/extensions/jar.mn
@@ -7,21 +7,23 @@ messenger.jar:
     content/messenger/extension.svg                (extension.svg)
 
     content/messenger/child/ext-mail.js            (child/ext-mail.js)
     content/messenger/child/ext-tabs.js            (child/ext-tabs.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-cloudFile.js      (parent/ext-cloudFile.js)
+    content/messenger/parent/ext-commands.js       (parent/ext-commands.js)
     content/messenger/parent/ext-composeAction.js  (parent/ext-composeAction.js)
     content/messenger/parent/ext-legacy.js         (parent/ext-legacy.js)
     content/messenger/parent/ext-mail.js           (parent/ext-mail.js)
     content/messenger/parent/ext-tabs.js           (parent/ext-tabs.js)
     content/messenger/parent/ext-windows.js        (parent/ext-windows.js)
 
     content/messenger/schemas/addressBook.json     (schemas/addressBook.json)
     content/messenger/schemas/browserAction.json   (schemas/browserAction.json)
     content/messenger/schemas/cloudFile.json       (schemas/cloudFile.json)
+    content/messenger/schemas/commands.json        (schemas/commands.json)
     content/messenger/schemas/composeAction.json   (schemas/composeAction.json)
     content/messenger/schemas/legacy.json          (schemas/legacy.json)
     content/messenger/schemas/tabs.json            (schemas/tabs.json)
     content/messenger/schemas/windows.json         (schemas/windows.json)
--- a/mail/components/extensions/parent/.eslintrc.js
+++ b/mail/components/extensions/parent/.eslintrc.js
@@ -28,10 +28,13 @@ module.exports = {
     "Tab": true,
     "Window": true,
     "WindowEventManager": true,
     "getTabBrowser": true,
     "makeWidgetId": true,
     "tabGetSender": true,
     "tabTracker": true,
     "windowTracker": true,
+
+    // ext-browserAction.js
+    "browserActionFor": true,
   },
 };
--- a/mail/components/extensions/parent/ext-browserAction.js
+++ b/mail/components/extensions/parent/ext-browserAction.js
@@ -1,16 +1,34 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 "use strict";
 
 ChromeUtils.defineModuleGetter(this, "ToolbarButtonAPI", "resource:///modules/ExtensionToolbarButtons.jsm");
 
+const browserActionMap = new WeakMap();
+
 this.browserAction = class extends ToolbarButtonAPI {
+  static for(extension) {
+    return browserActionMap.get(extension);
+  }
+
+  async onManifestEntry(entryName) {
+    await super.onManifestEntry(entryName);
+    browserActionMap.set(this.extension, this);
+  }
+
+  onShutdown(reason) {
+    super.onShutdown(reason);
+    browserActionMap.delete(this.extension);
+  }
+
   constructor(extension) {
     super(extension);
     this.manifest_name = "browser_action";
     this.manifestName = "browserAction";
     this.windowURLs = ["chrome://messenger/content/messenger.xul"];
     this.toolboxId = "mail-toolbox";
     this.toolbarId = "mail-bar3";
   }
 };
+
+global.browserActionFor = this.browserAction.for;
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/parent/ext-commands.js
@@ -0,0 +1,370 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+ChromeUtils.defineModuleGetter(this, "ExtensionParent",
+                               "resource://gre/modules/ExtensionParent.jsm");
+ChromeUtils.defineModuleGetter(this, "ExtensionSettingsStore",
+                               "resource://gre/modules/ExtensionSettingsStore.jsm");
+
+var {
+  chromeModifierKeyMap,
+  ExtensionError,
+} = ExtensionUtils;
+
+const EXECUTE_BROWSER_ACTION = "_execute_browser_action";
+
+function normalizeShortcut(shortcut) {
+  return shortcut ? shortcut.replace(/\s+/g, "") : null;
+}
+
+this.commands = class extends ExtensionAPI {
+  static async onUninstall(extensionId) {
+    // Cleanup the updated commands. In some cases the extension is installed
+    // and uninstalled so quickly that `this.commands` hasn't loaded yet. To
+    // handle that we need to make sure ExtensionSettingsStore is initialized
+    // before we clean it up.
+    await ExtensionSettingsStore.initialize();
+    ExtensionSettingsStore
+      .getAllForExtension(extensionId, "commands")
+      .forEach(key => {
+        ExtensionSettingsStore.removeSetting(extensionId, "commands", key);
+      });
+  }
+
+  async onManifestEntry(entryName) {
+    let {extension} = this;
+
+    this.id = makeWidgetId(extension.id);
+    this.windowOpenListener = null;
+
+    // Map[{String} commandName -> {Object} commandProperties]
+    this.manifestCommands = this.loadCommandsFromManifest(extension.manifest);
+
+    this.commands = new Promise(async (resolve) => {
+      // Deep copy the manifest commands to commands so we can keep the original
+      // manifest commands and update commands as needed.
+      let commands = new Map();
+      this.manifestCommands.forEach((command, name) => {
+        commands.set(name, {...command});
+      });
+
+      // Update the manifest commands with the persisted updates from
+      // browser.commands.update().
+      let savedCommands = await this.loadCommandsFromStorage(extension.id);
+      savedCommands.forEach((update, name) => {
+        let command = commands.get(name);
+        if (command) {
+          // We will only update commands, not add them.
+          Object.assign(command, update);
+        }
+      });
+
+      resolve(commands);
+    });
+
+    // WeakMap[Window -> <xul:keyset>]
+    this.keysetsMap = new WeakMap();
+
+    await this.register();
+  }
+
+  onShutdown(reason) {
+    this.unregister();
+  }
+
+  registerKeys(commands) {
+    for (let window of windowTracker.browserWindows()) {
+      this.registerKeysToDocument(window, commands);
+    }
+  }
+
+  /**
+   * Registers the commands to all open windows and to any which
+   * are later created.
+   */
+  async register() {
+    let commands = await this.commands;
+    this.registerKeys(commands);
+
+    this.windowOpenListener = (window) => {
+      if (!this.keysetsMap.has(window)) {
+        this.registerKeysToDocument(window, commands);
+      }
+    };
+
+    windowTracker.addOpenListener(this.windowOpenListener);
+  }
+
+  /**
+   * Unregisters the commands from all open windows and stops commands
+   * from being registered to windows which are later created.
+   */
+  unregister() {
+    for (let window of windowTracker.browserWindows()) {
+      if (this.keysetsMap.has(window)) {
+        this.keysetsMap.get(window).remove();
+      }
+    }
+
+    windowTracker.removeOpenListener(this.windowOpenListener);
+  }
+
+  /**
+   * Creates a Map from commands for each command in the manifest.commands object.
+   *
+   * @param {Object} manifest The manifest JSON object.
+   * @returns {Map<string, object>}
+   */
+  loadCommandsFromManifest(manifest) {
+    let commands = new Map();
+    // For Windows, chrome.runtime expects 'win' while chrome.commands
+    // expects 'windows'.  We can special case this for now.
+    let {PlatformInfo} = ExtensionParent;
+    let os = PlatformInfo.os == "win" ? "windows" : PlatformInfo.os;
+    for (let [name, command] of Object.entries(manifest.commands)) {
+      let suggested_key = command.suggested_key || {};
+      let shortcut = normalizeShortcut(suggested_key[os] || suggested_key.default);
+      commands.set(name, {
+        description: command.description,
+        shortcut,
+      });
+    }
+    return commands;
+  }
+
+  async loadCommandsFromStorage(extensionId) {
+    await ExtensionSettingsStore.initialize();
+    let names = ExtensionSettingsStore.getAllForExtension(extensionId, "commands");
+    return names.reduce((map, name) => {
+      let command = ExtensionSettingsStore.getSetting(
+        "commands", name, extensionId).value;
+      return map.set(name, command);
+    }, new Map());
+  }
+
+  /**
+   * Registers the commands to a document.
+   * @param {ChromeWindow} window The XUL window to insert the Keyset.
+   * @param {Map} commands The commands to be set.
+   */
+  registerKeysToDocument(window, commands) {
+    let doc = window.document;
+    let keyset = doc.createXULElement("keyset");
+    keyset.id = `ext-keyset-id-${this.id}`;
+    if (this.keysetsMap.has(window)) {
+      this.keysetsMap.get(window).remove();
+    }
+    commands.forEach((command, name) => {
+      if (command.shortcut) {
+        let parts = command.shortcut.split("+");
+
+        // The key is always the last element.
+        let key = parts.pop();
+
+        if (/^[0-9]$/.test(key)) {
+          let shortcutWithNumpad = command.shortcut.replace(/[0-9]$/, "Numpad$&");
+          let numpadKeyElement = this.buildKey(doc, name, shortcutWithNumpad);
+          keyset.appendChild(numpadKeyElement);
+        }
+
+        let keyElement = this.buildKey(doc, name, command.shortcut);
+        keyset.appendChild(keyElement);
+      }
+    });
+    doc.documentElement.appendChild(keyset);
+    this.keysetsMap.set(window, keyset);
+  }
+
+  /**
+   * Builds a XUL Key element and attaches an onCommand listener which
+   * emits a command event with the provided name when fired.
+   *
+   * @param {Document} doc The XUL document.
+   * @param {string} name The name of the command.
+   * @param {string} shortcut The shortcut provided in the manifest.
+   * @see https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XUL/key
+   *
+   * @returns {Document} The newly created Key element.
+   */
+  buildKey(doc, name, shortcut) {
+    let keyElement = this.buildKeyFromShortcut(doc, name, shortcut);
+
+    // We need to have the attribute "oncommand" for the "command" listener to fire,
+    // and it is currently ignored when set to the empty string.
+    keyElement.setAttribute("oncommand", "//");
+
+    /* eslint-disable mozilla/balanced-listeners */
+    // We remove all references to the key elements when the extension is shutdown,
+    // therefore the listeners for these elements will be garbage collected.
+    keyElement.addEventListener("command", (event) => {
+      let action;
+      if (name == EXECUTE_BROWSER_ACTION) {
+        action = browserActionFor(this.extension);
+      } else {
+        this.extension.tabManager
+            .addActiveTabPermission();
+        this.emit("command", name);
+        return;
+      }
+      if (action) {
+        let win = event.target.ownerGlobal;
+        action.triggerAction(win);
+      }
+    });
+    /* eslint-enable mozilla/balanced-listeners */
+
+    return keyElement;
+  }
+
+  /**
+   * Builds a XUL Key element from the provided shortcut.
+   *
+   * @param {Document} doc The XUL document.
+   * @param {string} name The name of the shortcut.
+   * @param {string} shortcut The shortcut provided in the manifest.
+   *
+   * @see https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XUL/key
+   * @returns {Document} The newly created Key element.
+   */
+  buildKeyFromShortcut(doc, name, shortcut) {
+    let keyElement = doc.createXULElement("key");
+
+    let parts = shortcut.split("+");
+
+    // The key is always the last element.
+    let chromeKey = parts.pop();
+
+    // The modifiers are the remaining elements.
+    keyElement.setAttribute("modifiers", this.getModifiersAttribute(parts));
+
+    if (/^[A-Z]$/.test(chromeKey)) {
+      // We use the key attribute for all single digits and characters.
+      keyElement.setAttribute("key", chromeKey);
+    } else {
+      keyElement.setAttribute("keycode", this.getKeycodeAttribute(chromeKey));
+      keyElement.setAttribute("event", "keydown");
+    }
+
+    return keyElement;
+  }
+
+  /**
+   * Determines the corresponding XUL keycode from the given chrome key.
+   *
+   * For example:
+   *
+   *    input     |  output
+   *    ---------------------------------------
+   *    "PageUP"  |  "VK_PAGE_UP"
+   *    "Delete"  |  "VK_DELETE"
+   *
+   * @param {string} chromeKey The chrome key (e.g. "PageUp", "Space", ...)
+   * @returns {string} The constructed value for the Key's 'keycode' attribute.
+   */
+  getKeycodeAttribute(chromeKey) {
+    if (/^[0-9]$/.test(chromeKey)) {
+      return `VK_${chromeKey}`;
+    }
+    return `VK${chromeKey.replace(/([A-Z])/g, "_$&").toUpperCase()}`;
+  }
+
+  /**
+   * Determines the corresponding XUL modifiers from the chrome modifiers.
+   *
+   * For example:
+   *
+   *    input             |   output
+   *    ---------------------------------------
+   *    ["Ctrl", "Shift"] |   "accel shift"
+   *    ["MacCtrl"]       |   "control"
+   *
+   * @param {Array} chromeModifiers The array of chrome modifiers.
+   * @returns {string} The constructed value for the Key's 'modifiers' attribute.
+   */
+  getModifiersAttribute(chromeModifiers) {
+    return Array.from(chromeModifiers, modifier => {
+      return chromeModifierKeyMap[modifier];
+    }).join(" ");
+  }
+
+  getAPI(context) {
+    return {
+      commands: {
+        getAll: async () => {
+          let commands = await this.commands;
+          return Array.from(commands, ([name, command]) => {
+            return ({
+              name,
+              description: command.description,
+              shortcut: command.shortcut,
+            });
+          });
+        },
+        update: async ({name, description, shortcut}) => {
+          let {extension} = this;
+          let commands = await this.commands;
+          let command = commands.get(name);
+
+          if (!command) {
+            throw new ExtensionError(`Unknown command "${name}"`);
+          }
+
+          // Only store the updates so manifest changes can take precedence
+          // later.
+          let previousUpdates = await ExtensionSettingsStore.getSetting(
+            "commands", name, extension.id);
+          let commandUpdates = (previousUpdates && previousUpdates.value) || {};
+
+          if (description && description != command.description) {
+            commandUpdates.description = description;
+            command.description = description;
+          }
+
+          if (shortcut && shortcut != command.shortcut) {
+            shortcut = normalizeShortcut(shortcut);
+            commandUpdates.shortcut = shortcut;
+            command.shortcut = shortcut;
+          }
+
+          await ExtensionSettingsStore.addSetting(
+            extension.id, "commands", name, commandUpdates);
+
+          this.registerKeys(commands);
+        },
+        reset: async (name) => {
+          let {extension, manifestCommands} = this;
+          let commands = await this.commands;
+          let command = commands.get(name);
+
+          if (!command) {
+            throw new ExtensionError(`Unknown command "${name}"`);
+          }
+
+          let storedCommand = ExtensionSettingsStore.getSetting(
+            "commands", name, extension.id);
+
+          if (storedCommand && storedCommand.value) {
+            commands.set(name, {...manifestCommands.get(name)});
+            ExtensionSettingsStore.removeSetting(extension.id, "commands", name);
+            this.registerKeys(commands);
+          }
+        },
+        onCommand: new EventManager({
+          context,
+          name: "commands.onCommand",
+          inputHandling: true,
+          register: fire => {
+            let listener = (eventName, commandName) => {
+              fire.async(commandName);
+            };
+            this.on("command", listener);
+            return () => {
+              this.off("command", listener);
+            };
+          },
+        }).api(),
+      },
+    };
+  }
+};
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/schemas/commands.json
@@ -0,0 +1,181 @@
+// Copyright (c) 2012 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+[
+  {
+    "namespace": "manifest",
+    "types": [
+     {
+        "id": "KeyName",
+        "type": "string",
+        "format": "manifestShortcutKey"
+      },
+      {
+        "$extend": "WebExtensionManifest",
+        "properties": {
+          "commands": {
+            "type": "object",
+            "optional": true,
+            "additionalProperties": {
+              "type": "object",
+              "additionalProperties": { "$ref": "UnrecognizedProperty" },
+              "properties": {
+                "suggested_key": {
+                  "type": "object",
+                  "optional": true,
+                  "properties": {
+                    "default": {
+                      "$ref": "KeyName",
+                      "optional": true
+                    },
+                    "mac": {
+                      "$ref": "KeyName",
+                      "optional": true
+                    },
+                    "linux": {
+                      "$ref": "KeyName",
+                      "optional": true
+                    },
+                    "windows": {
+                      "$ref": "KeyName",
+                      "optional": true
+                    },
+                    "chromeos": {
+                      "type": "string",
+                      "optional": true
+                    },
+                    "android": {
+                      "type": "string",
+                      "optional": true
+                    },
+                    "ios": {
+                      "type": "string",
+                      "optional": true
+                    },
+                    "additionalProperties": {
+                      "type": "string",
+                      "deprecated": "Unknown platform name",
+                      "optional": true
+                    }
+                  }
+                },
+                "description": {
+                  "type": "string",
+                  "optional": true
+                }
+              }
+            }
+          }
+        }
+      }
+    ]
+  },
+  {
+    "namespace": "commands",
+    "description": "Use the commands API to add keyboard shortcuts that trigger actions in your extension, for example, an action to open the browser action or send a command to the xtension.",
+    "permissions": ["manifest:commands"],
+    "types": [
+      {
+        "id": "Command",
+        "type": "object",
+        "properties": {
+          "name":        {
+            "type": "string",
+            "optional": true,
+            "description": "The name of the Extension Command"
+          },
+          "description": {
+            "type": "string",
+            "optional": true,
+            "description": "The Extension Command description"
+          },
+          "shortcut": {
+            "type": "string",
+            "optional": true,
+            "description": "The shortcut active for this command, or blank if not active."
+          }
+        }
+      }
+    ],
+    "events": [
+      {
+        "name": "onCommand",
+        "description": "Fired when a registered command is activated using a keyboard shortcut.",
+        "type": "function",
+        "parameters": [
+          {
+            "name": "command",
+            "type": "string"
+          }
+        ]
+      }
+    ],
+    "functions": [
+      {
+        "name": "update",
+        "type": "function",
+        "async": true,
+        "description": "Update the details of an already defined command.",
+        "parameters": [
+          {
+            "type": "object",
+            "name": "detail",
+            "description": "The new description for the command.",
+            "properties": {
+              "name": {
+                "type": "string",
+                "description": "The name of the command."
+                },
+              "description": {
+                "type": "string",
+                "optional": true,
+                "description": "The new description for the command."
+              },
+              "shortcut": {
+                "$ref": "manifest.KeyName",
+                "optional": true
+              }
+            }
+          }
+        ]
+      },
+      {
+        "name": "reset",
+        "type": "function",
+        "async": true,
+        "description": "Reset a command's details to what is specified in the manifest.",
+        "parameters": [
+          {
+            "type": "string",
+            "name": "name",
+            "description": "The name of the command."
+          }
+        ]
+      },
+      {
+        "name": "getAll",
+        "type": "function",
+        "async": "callback",
+        "description": "Returns all the registered extension commands for this extension and their shortcut (if active).",
+        "parameters": [
+          {
+            "type": "function",
+            "name": "callback",
+            "optional": true,
+            "parameters": [
+              {
+                "name": "commands",
+                "type": "array",
+                "items": {
+                  "$ref": "Command"
+                }
+              }
+            ],
+            "description": "Called to return the registered commands."
+          }
+        ]
+      }
+    ]
+  }
+]