Bug 1303384 - Extract extension commands management to a module draft
authorMark Striemer <mstriemer@mozilla.com>
Wed, 13 Jun 2018 15:26:28 -0700
changeset 819346 a8b97e4b687ed950229e7d226773841370888914
parent 819095 2ec3a289251a9acc44ff855cfc51c8b93a5b94f6
child 819347 9f5708d280e2dadc7b7233b40e62166de42120b9
push id116524
push userbmo:mstriemer@mozilla.com
push dateTue, 17 Jul 2018 17:54:26 +0000
bugs1303384
milestone63.0a1
Bug 1303384 - Extract extension commands management to a module MozReview-Commit-ID: E1RTINVtrbQ
browser/components/extensions/parent/ext-browserAction.js
browser/components/extensions/parent/ext-commands.js
browser/components/extensions/parent/ext-pageAction.js
browser/components/extensions/parent/ext-sidebarAction.js
toolkit/modules/ExtensionShortcuts.jsm
toolkit/modules/moz.build
--- a/browser/components/extensions/parent/ext-browserAction.js
+++ b/browser/components/extensions/parent/ext-browserAction.js
@@ -62,16 +62,17 @@ this.browserAction = class extends Exten
     this.viewId = `PanelUI-webext-${widgetId}-browser-action-view`;
     this.widget = null;
 
     this.pendingPopup = null;
     this.pendingPopupTimeout = null;
     this.eventQueue = [];
 
     this.tabManager = extension.tabManager;
+    extension.browserAction = this;
 
     this.defaults = {
       enabled: true,
       title: options.default_title || extension.name,
       badgeText: "",
       badgeBackgroundColor: null,
       badgeTextColor: null,
       popup: options.default_popup || "",
--- a/browser/components/extensions/parent/ext-commands.js
+++ b/browser/components/extensions/parent/ext-commands.js
@@ -1,367 +1,48 @@
 /* -*- 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;
-
-var XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
-
-const EXECUTE_PAGE_ACTION = "_execute_page_action";
-const EXECUTE_BROWSER_ACTION = "_execute_browser_action";
-const EXECUTE_SIDEBAR_ACTION = "_execute_sidebar_action";
-
-function normalizeShortcut(shortcut) {
-  return shortcut ? shortcut.replace(/\s+/g, "") : null;
-}
+ChromeUtils.defineModuleGetter(this, "ExtensionShortcuts",
+                               "resource://gre/modules/ExtensionShortcuts.jsm");
 
 this.commands = class extends ExtensionAPI {
-  static async onUninstall(extensionId) {
+  static 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);
-      });
+    return ExtensionShortcuts.removeCommandsFromStorage(extensionId);
   }
 
   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);
+    this.shortcuts = new ExtensionShortcuts({
+      extension: this.extension,
+      onCommand: (name) => this.emit("command", name),
     });
-
-    // WeakMap[Window -> <xul:keyset>]
-    this.keysetsMap = new WeakMap();
-
-    await this.register();
+    this.extension.shortcuts = this.shortcuts;
+    await this.shortcuts.loadAndRegister();
   }
 
   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.createElementNS(XUL_NS, "keyset");
-    keyset.id = `ext-keyset-id-${this.id}`;
-    if (this.keysetsMap.has(window)) {
-      this.keysetsMap.get(window).remove();
-    }
-    let sidebarKey;
-    commands.forEach((command, name) => {
-      if (command.shortcut) {
-        let keyElement = this.buildKey(doc, name, command.shortcut);
-        keyset.appendChild(keyElement);
-        if (name == EXECUTE_SIDEBAR_ACTION) {
-          sidebarKey = keyElement;
-        }
-      }
-    });
-    doc.documentElement.appendChild(keyset);
-    if (sidebarKey) {
-      window.SidebarUI.updateShortcut({key: sidebarKey});
-    }
-    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_PAGE_ACTION) {
-        action = pageActionFor(this.extension);
-      } else if (name == EXECUTE_BROWSER_ACTION) {
-        action = browserActionFor(this.extension);
-      } else if (name == EXECUTE_SIDEBAR_ACTION) {
-        action = sidebarActionFor(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.createElementNS(XUL_NS, "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 (name == EXECUTE_SIDEBAR_ACTION) {
-      let id = `ext-key-id-${this.id}-sidebar-action`;
-      keyElement.setAttribute("id", id);
-    }
-
-    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(" ");
+    this.shortcuts.unregister();
   }
 
   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,
-            });
-          });
+        getAll: () => {
+          return this.shortcuts.allCommands();
         },
-        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);
+        update: (args) => {
+          return this.shortcuts.updateCommand(args);
         },
-        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);
-          }
+        reset: (name) => {
+          return this.shortcuts.resetCommand(name);
         },
         onCommand: new EventManager({
           context,
           name: "commands.onCommand",
           register: fire => {
             let listener = (eventName, commandName) => {
               fire.async(commandName);
             };
--- a/browser/components/extensions/parent/ext-pageAction.js
+++ b/browser/components/extensions/parent/ext-pageAction.js
@@ -34,16 +34,17 @@ this.pageAction = class extends Extensio
   async onManifestEntry(entryName) {
     let {extension} = this;
     let options = extension.manifest.page_action;
 
     let widgetId = makeWidgetId(extension.id);
     this.id = widgetId + "-page-action";
 
     this.tabManager = extension.tabManager;
+    extension.pageAction = this;
 
     // `show` can have three different values:
     // - `false`. This means the page action is not shown.
     //   It's set as default if show_matches is empty. Can also be set in a tab via
     //   `pageAction.hide(tabId)`, e.g. in order to override show_matches.
     // - `true`. This means the page action is shown.
     //   It's never set as default because <all_urls> doesn't really match all URLs
     //   (e.g. "about:" URLs). But can be set in a tab via `pageAction.show(tabId)`.
--- a/browser/components/extensions/parent/ext-sidebarAction.js
+++ b/browser/components/extensions/parent/ext-sidebarAction.js
@@ -25,16 +25,19 @@ const sidebarURL = "chrome://browser/con
  */
 this.sidebarAction = class extends ExtensionAPI {
   static for(extension) {
     return sidebarActionMap.get(extension);
   }
 
   onManifestEntry(entryName) {
     let {extension} = this;
+    // FIXME: This is probably bad.
+    // TODO: Listen to an EXECUTE_SIDEBAR_ACTION event to execute action.
+    extension.sidebarAction = this;
 
     extension.once("ready", this.onReady.bind(this));
 
     let options = extension.manifest.sidebar_action;
 
     // Add the extension to the sidebar menu.  The sidebar widget will copy
     // from that when it is viewed, so we shouldn't need to update that.
     let widgetId = makeWidgetId(extension.id);
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/ExtensionShortcuts.jsm
@@ -0,0 +1,364 @@
+ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+ChromeUtils.import("resource://gre/modules/ExtensionCommon.jsm");
+
+ChromeUtils.defineModuleGetter(this, "ExtensionParent",
+                               "resource://gre/modules/ExtensionParent.jsm");
+ChromeUtils.defineModuleGetter(this, "ExtensionSettingsStore",
+                               "resource://gre/modules/ExtensionSettingsStore.jsm");
+ChromeUtils.defineModuleGetter(this, "ExtensionUtils",
+                               "resource://gre/modules/ExtensionUtils.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "windowTracker", () => {
+  return ExtensionParent.apiManager.global.windowTracker;
+});
+
+const {
+  chromeModifierKeyMap,
+  ExtensionError,
+} = ExtensionUtils;
+const { makeWidgetId } = ExtensionCommon;
+
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
+const EXECUTE_PAGE_ACTION = "_execute_page_action";
+const EXECUTE_BROWSER_ACTION = "_execute_browser_action";
+const EXECUTE_SIDEBAR_ACTION = "_execute_sidebar_action";
+const EXPORTED_SYMBOLS = ["ExtensionShortcuts"];
+
+function normalizeShortcut(shortcut) {
+  return shortcut ? shortcut.replace(/\s+/g, "") : null;
+}
+
+class ExtensionShortcuts {
+  static async removeCommandsFromStorage(extensionId) {
+    await ExtensionSettingsStore.initialize();
+    ExtensionSettingsStore
+      .getAllForExtension(extensionId, "commands")
+      .forEach(key => {
+        ExtensionSettingsStore.removeSetting(extensionId, "commands", key);
+      });
+  }
+
+  constructor({extension, onCommand}) {
+    this.keysetsMap = new WeakMap();
+    this.windowOpenListener = null;
+    this.extension = extension;
+    this.onCommand = onCommand;
+    this.id = makeWidgetId(extension.id);
+  }
+
+  async allCommands() {
+    let commands = await this.commands;
+    return Array.from(commands, ([name, command]) => {
+      return ({
+        name,
+        description: command.description,
+        shortcut: command.shortcut,
+      });
+    });
+  }
+
+  async updateCommand({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);
+  }
+
+  async resetCommand(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);
+    }
+  }
+
+  async loadCommands() {
+    let {extension} = this;
+
+    // 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);
+    });
+  }
+
+  async loadAndRegister() {
+    await this.loadCommands();
+    return this.register();
+  }
+
+  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.createElementNS(XUL_NS, "keyset");
+    keyset.id = `ext-keyset-id-${this.id}`;
+    if (this.keysetsMap.has(window)) {
+      this.keysetsMap.get(window).remove();
+    }
+    let sidebarKey;
+    commands.forEach((command, name) => {
+      if (command.shortcut) {
+        let keyElement = this.buildKey(doc, name, command.shortcut);
+        keyset.appendChild(keyElement);
+        if (name == EXECUTE_SIDEBAR_ACTION) {
+          sidebarKey = keyElement;
+        }
+      }
+    });
+    doc.documentElement.appendChild(keyset);
+    if (sidebarKey) {
+      window.SidebarUI.updateShortcut({key: sidebarKey});
+    }
+    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_PAGE_ACTION) {
+        action = this.extension.pageAction;
+      } else if (name == EXECUTE_BROWSER_ACTION) {
+        action = this.extension.browserAction;
+      } else if (name == EXECUTE_SIDEBAR_ACTION) {
+        action = this.extension.sidebarAction;
+      } else {
+        this.extension.tabManager
+            .addActiveTabPermission();
+        this.onCommand(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.createElementNS(XUL_NS, "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 (name == EXECUTE_SIDEBAR_ACTION) {
+      let id = `ext-key-id-${this.id}-sidebar-action`;
+      keyElement.setAttribute("id", id);
+    }
+
+    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(" ");
+  }
+}
--- a/toolkit/modules/moz.build
+++ b/toolkit/modules/moz.build
@@ -5,16 +5,19 @@
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 with Files('**'):
     BUG_COMPONENT = ('Toolkit', 'General')
 
 with Files('addons/**'):
     BUG_COMPONENT = ('WebExtensions', 'General')
 
+with Files('ExtensionShortcuts.jsm'):
+    BUG_COMPONENT = ('Toolkit', 'WebExtensions: General')
+
 with Files('docs/**'):
     BUG_COMPONENT = ('Toolkit', 'Async Tooling')
 
 with Files('subprocess/**'):
     BUG_COMPONENT = ('Toolkit', 'Async Tooling')
 
 with Files('tests/browser/*AsyncPrefs*'):
     BUG_COMPONENT = ('Core', 'Security: Process Sandboxing')
@@ -193,16 +196,17 @@ EXTRA_JS_MODULES += [
     'CreditCard.jsm',
     'css-selector.js',
     'DateTimePickerContent.jsm',
     'DateTimePickerParent.jsm',
     'DeferredTask.jsm',
     'Deprecated.jsm',
     'E10SUtils.jsm',
     'EventEmitter.jsm',
+    'ExtensionShortcuts.jsm',
     'FileUtils.jsm',
     'Finder.jsm',
     'FinderHighlighter.jsm',
     'FinderIterator.jsm',
     'FormLikeFactory.jsm',
     'Geometry.jsm',
     'GMPExtractorWorker.js',
     'GMPInstallManager.jsm',