From 22fd2a0fc9405706c8fcf497b2556cbf8c121b51 Mon Sep 17 00:00:00 2001 From: Matthew Wein Date: Mon, 29 Feb 2016 19:04:03 -0800 Subject: [PATCH] Bug 1246029 Implement chrome.commands.onCommand. r=kmag --- browser/components/extensions/ext-commands.js | 219 ++++++++++++++++-- .../extensions/schemas/commands.json | 1 - .../extensions/test/browser/browser.ini | 3 +- ...ands.js => browser_ext_commands_getAll.js} | 0 .../browser/browser_ext_commands_onCommand.js | 74 ++++++ .../components/extensions/ExtensionUtils.jsm | 18 +- 6 files changed, 285 insertions(+), 30 deletions(-) rename browser/components/extensions/test/browser/{browser_ext_commands.js => browser_ext_commands_getAll.js} (100%) create mode 100644 browser/components/extensions/test/browser/browser_ext_commands_onCommand.js diff --git a/browser/components/extensions/ext-commands.js b/browser/components/extensions/ext-commands.js index a00e49603c13..b9abf2209f37 100644 --- a/browser/components/extensions/ext-commands.js +++ b/browser/components/extensions/ext-commands.js @@ -2,36 +2,208 @@ /* vim: set sts=2 sw=2 et tw=80: */ "use strict"; +Cu.import("resource://devtools/shared/event-emitter.js"); Cu.import("resource://gre/modules/ExtensionUtils.jsm"); var { - PlatformInfo, + EventManager, + PlatformInfo, } = ExtensionUtils; -// WeakMap[Extension -> Map[name => Command]] +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +// WeakMap[Extension -> CommandList] var commandsMap = new WeakMap(); -function Command(description, shortcut) { - this.description = description; - this.shortcut = shortcut; +function CommandList(commandsObj, extensionID) { + this.commands = this.loadCommandsFromManifest(commandsObj); + this.keysetID = `ext-keyset-id-${makeWidgetId(extensionID)}`; + this.windowOpenListener = null; + this.register(); + EventEmitter.decorate(this); } +CommandList.prototype = { + /** + * Registers the commands to all open windows and to any which + * are later created. + */ + register() { + for (let window of WindowListManager.browserWindows()) { + this.registerKeysToDocument(window.document); + } + + this.windowOpenListener = (window) => { + this.registerKeysToDocument(window.document); + }; + + WindowListManager.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 WindowListManager.browserWindows()) { + let keyset = window.document.getElementById(this.keysetID); + if (keyset) { + keyset.remove(); + } + } + + WindowListManager.removeOpenListener(this.windowOpenListener); + }, + + /** + * Creates a Map from commands for each command in the manifest.commands object. + * @param {Object} commandsObj The manifest.commands JSON object. + */ + loadCommandsFromManifest(commandsObj) { + let commands = new Map(); + // For Windows, chrome.runtime expects 'win' while chrome.commands + // expects 'windows'. We can special case this for now. + let os = PlatformInfo.os == "win" ? "windows" : PlatformInfo.os; + for (let name of Object.keys(commandsObj)) { + let command = commandsObj[name]; + commands.set(name, { + description: command.description, + shortcut: command.suggested_key[os] || command.suggested_key.default, + }); + } + return commands; + }, + + /** + * Registers the commands to a document. + * @param {Document} doc The XUL document to insert the Keyset. + */ + registerKeysToDocument(doc) { + let keyset = doc.createElementNS(XUL_NS, "keyset"); + keyset.id = this.keysetID; + this.commands.forEach((command, name) => { + let keyElement = this.buildKey(doc, name, command.shortcut); + keyset.appendChild(keyElement); + }); + doc.documentElement.appendChild(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, 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) => { + this.emit("command", name); + }); + /* 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 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. + */ + buildKeyFromShortcut(doc, 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 (/^[A-Z0-9]$/.test(chromeKey)) { + // We use the key attribute for all single digits and characters. + keyElement.setAttribute("key", chromeKey); + } else { + keyElement.setAttribute("keycode", this.getKeycodeAttribute(chromeKey)); + } + + 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} key The chrome key (e.g. "PageUp", "Space", ...) + * @return The constructed value for the Key's 'keycode' attribute. + */ + getKeycodeAttribute(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. + * @return The constructed value for the Key's 'modifiers' attribute. + */ + getModifiersAttribute(chromeModifiers) { + let modifiersMap = { + "Alt" : "alt", + "Command" : "accel", + "Ctrl" : "accel", + "MacCtrl" : "control", + "Shift" : "shift", + }; + return Array.from(chromeModifiers, modifier => { + return modifiersMap[modifier]; + }).join(" "); + }, +}; + + /* eslint-disable mozilla/balanced-listeners */ extensions.on("manifest_commands", (type, directive, extension, manifest) => { - let commands = new Map(); - for (let name of Object.keys(manifest.commands)) { - let os = PlatformInfo.os == "win" ? "windows" : PlatformInfo.os; - let manifestCommand = manifest.commands[name]; - let description = manifestCommand.description; - let shortcut = manifestCommand.suggested_key[os] || manifestCommand.suggested_key.default; - let command = new Command(description, shortcut); - commands.set(name, command); - } - commandsMap.set(extension, commands); + commandsMap.set(extension, new CommandList(manifest.commands, extension.id)); }); extensions.on("shutdown", (type, extension) => { - commandsMap.delete(extension); + let commandsList = commandsMap.get(extension); + if (commandsList) { + commandsList.unregister(); + commandsMap.delete(extension); + } }); /* eslint-enable mozilla/balanced-listeners */ @@ -39,15 +211,24 @@ extensions.registerSchemaAPI("commands", null, (extension, context) => { return { commands: { getAll() { - let commands = Array.from(commandsMap.get(extension), ([name, command]) => { + let commands = commandsMap.get(extension).commands; + return Promise.resolve(Array.from(commands, ([name, command]) => { return ({ name, description: command.description, shortcut: command.shortcut, }); - }); - return Promise.resolve(commands); + })); }, + onCommand: new EventManager(context, "commands.onCommand", fire => { + let listener = (event, name) => { + fire(name); + }; + commandsMap.get(extension).on("command", listener); + return () => { + commandsMap.get(extension).off("command", listener); + }; + }).api(), }, }; }); diff --git a/browser/components/extensions/schemas/commands.json b/browser/components/extensions/schemas/commands.json index 361c623ca277..0b3afe480ee0 100644 --- a/browser/components/extensions/schemas/commands.json +++ b/browser/components/extensions/schemas/commands.json @@ -107,7 +107,6 @@ "events": [ { "name": "onCommand", - "unsupported": true, "description": "Fired when a registered command is activated using a keyboard shortcut.", "type": "function", "parameters": [ diff --git a/browser/components/extensions/test/browser/browser.ini b/browser/components/extensions/test/browser/browser.ini index 76aee78f0397..852cbe8d5dc5 100644 --- a/browser/components/extensions/test/browser/browser.ini +++ b/browser/components/extensions/test/browser/browser.ini @@ -11,7 +11,6 @@ support-files = file_iframe_document.sjs [browser_ext_simple.js] -[browser_ext_commands.js] [browser_ext_currentWindow.js] [browser_ext_browserAction_simple.js] [browser_ext_browserAction_pageAction_icon.js] @@ -22,6 +21,8 @@ support-files = [browser_ext_browserAction_popup.js] [browser_ext_popup_api_injection.js] [browser_ext_contextMenus.js] +[browser_ext_commands_getAll.js] +[browser_ext_commands_onCommand.js] [browser_ext_getViews.js] [browser_ext_lastError.js] [browser_ext_runtime_setUninstallURL.js] diff --git a/browser/components/extensions/test/browser/browser_ext_commands.js b/browser/components/extensions/test/browser/browser_ext_commands_getAll.js similarity index 100% rename from browser/components/extensions/test/browser/browser_ext_commands.js rename to browser/components/extensions/test/browser/browser_ext_commands_getAll.js diff --git a/browser/components/extensions/test/browser/browser_ext_commands_onCommand.js b/browser/components/extensions/test/browser/browser_ext_commands_onCommand.js new file mode 100644 index 000000000000..227e311da60b --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_commands_onCommand.js @@ -0,0 +1,74 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(function* () { + // Create a window before the extension is loaded. + let win1 = yield BrowserTestUtils.openNewBrowserWindow(); + yield BrowserTestUtils.loadURI(win1.gBrowser.selectedBrowser, "about:robots"); + yield BrowserTestUtils.browserLoaded(win1.gBrowser.selectedBrowser); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + "name": "Commands Extension", + "commands": { + "toggle-feature-using-alt-shift-3": { + "suggested_key": { + "default": "Alt+Shift+3", + }, + }, + "toggle-feature-using-alt-shift-comma": { + "suggested_key": { + "default": "Alt+Shift+Comma", + }, + }, + }, + }, + + background: function() { + browser.commands.onCommand.addListener((message) => { + browser.test.sendMessage("oncommand", message); + }); + browser.test.sendMessage("ready"); + }, + }); + + yield extension.startup(); + yield extension.awaitMessage("ready"); + + // Create another window after the extension is loaded. + let win2 = yield BrowserTestUtils.openNewBrowserWindow(); + yield BrowserTestUtils.loadURI(win2.gBrowser.selectedBrowser, "about:config"); + yield BrowserTestUtils.browserLoaded(win2.gBrowser.selectedBrowser); + + // Confirm the keysets have been added to both windows. + let keysetID = `ext-keyset-id-${makeWidgetId(extension.id)}`; + let keyset = win1.document.getElementById(keysetID); + is(keyset.childNodes.length, 2, "Expected keyset to exist and have 2 children"); + + keyset = win2.document.getElementById(keysetID); + is(keyset.childNodes.length, 2, "Expected keyset to exist and have 2 children"); + + // Confirm that the commands are registered to both windows. + yield focusWindow(win1); + EventUtils.synthesizeKey("3", {altKey: true, shiftKey: true}); + let message = yield extension.awaitMessage("oncommand"); + is(message, "toggle-feature-using-alt-shift-3", "Expected onCommand listener to fire with correct message"); + + yield focusWindow(win2); + EventUtils.synthesizeKey("VK_COMMA", {altKey: true, shiftKey: true}); + message = yield extension.awaitMessage("oncommand"); + is(message, "toggle-feature-using-alt-shift-comma", "Expected onCommand listener to fire with correct message"); + + yield extension.unload(); + + // Confirm that the keysets have been removed from both windows after the extension is unloaded. + keyset = win1.document.getElementById(keysetID); + is(keyset, null, "Expected keyset to be removed from the window"); + + keyset = win2.document.getElementById(keysetID); + is(keyset, null, "Expected keyset to be removed from the window"); + + yield BrowserTestUtils.closeWindow(win1); + yield BrowserTestUtils.closeWindow(win2); +}); diff --git a/toolkit/components/extensions/ExtensionUtils.jsm b/toolkit/components/extensions/ExtensionUtils.jsm index f6756a5e9f52..7fbddd185e6d 100644 --- a/toolkit/components/extensions/ExtensionUtils.jsm +++ b/toolkit/components/extensions/ExtensionUtils.jsm @@ -986,23 +986,23 @@ function detectLanguage(text) { } this.ExtensionUtils = { - runSafeWithoutClone, - runSafeSyncWithoutClone, + detectLanguage, + extend, + flushJarCache, + ignoreEvent, + injectAPI, + instanceOf, runSafe, runSafeSync, + runSafeSyncWithoutClone, + runSafeWithoutClone, BaseContext, DefaultWeakMap, EventManager, LocaleData, - SingletonEventManager, - ignoreEvent, - injectAPI, MessageBroker, Messenger, PlatformInfo, + SingletonEventManager, SpreadArgs, - extend, - flushJarCache, - instanceOf, - detectLanguage, };