diff options
Diffstat (limited to 'toolkit/components/extensions/ExtensionShortcuts.sys.mjs')
-rw-r--r-- | toolkit/components/extensions/ExtensionShortcuts.sys.mjs | 517 |
1 files changed, 517 insertions, 0 deletions
diff --git a/toolkit/components/extensions/ExtensionShortcuts.sys.mjs b/toolkit/components/extensions/ExtensionShortcuts.sys.mjs new file mode 100644 index 0000000000..25e580139b --- /dev/null +++ b/toolkit/components/extensions/ExtensionShortcuts.sys.mjs @@ -0,0 +1,517 @@ +/* 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/. */ + +import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs"; + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", + ExtensionSettingsStore: + "resource://gre/modules/ExtensionSettingsStore.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs", +}); + +/** + * These properties cannot be lazy getters otherwise they + * get defined on first use, at a time when some modules + * may not have been loaded. In that case, the getter would + * become undefined until next app restart. + */ +Object.defineProperties(lazy, { + windowTracker: { + get() { + return lazy.ExtensionParent.apiManager.global.windowTracker; + }, + }, + browserActionFor: { + get() { + return lazy.ExtensionParent.apiManager.global.browserActionFor; + }, + }, + pageActionFor: { + get() { + return lazy.ExtensionParent.apiManager.global.pageActionFor; + }, + }, + sidebarActionFor: { + get() { + return lazy.ExtensionParent.apiManager.global.sidebarActionFor; + }, + }, +}); + +const { ExtensionError, DefaultMap } = ExtensionUtils; +const { makeWidgetId } = ExtensionCommon; + +const EXECUTE_SIDEBAR_ACTION = "_execute_sidebar_action"; + +function normalizeShortcut(shortcut) { + return shortcut ? shortcut.replace(/\s+/g, "") : ""; +} + +export class ExtensionShortcutKeyMap extends DefaultMap { + async buildForAddonIds(addonIds) { + this.clear(); + for (const addonId of addonIds) { + const policy = WebExtensionPolicy.getByID(addonId); + if (policy?.extension?.shortcuts) { + const { shortcuts } = policy.extension; + for (const command of await shortcuts.allCommands()) { + this.recordShortcut(command.shortcut, policy.name, command.name); + } + } + } + } + + recordShortcut(shortcutString, addonName, commandName) { + if (!shortcutString) { + return; + } + + const valueSet = this.get(shortcutString); + valueSet.add({ addonName, commandName }); + } + + removeShortcut(shortcutString, addonName, commandName) { + if (!this.has(shortcutString)) { + return; + } + + const valueSet = this.get(shortcutString); + for (const entry of valueSet.values()) { + if (entry.addonName === addonName && entry.commandName === commandName) { + valueSet.delete(entry); + } + } + if (valueSet.size === 0) { + this.delete(shortcutString); + } + } + + getFirstAddonName(shortcutString) { + if (this.has(shortcutString)) { + return this.get(shortcutString).values().next().value.addonName; + } + return null; + } + + has(shortcutString) { + const platformShortcut = this.getPlatformShortcutString(shortcutString); + return super.has(platformShortcut) && super.get(platformShortcut).size > 0; + } + + // Class internals. + + constructor() { + super(); + + // Overridden in some unit test to make it easier to cover some + // platform specific behaviors (in particular the platform specific. + // normalization of the shortcuts using the Ctrl modifier on macOS). + this._os = lazy.ExtensionParent.PlatformInfo.os; + } + + defaultConstructor() { + return new Set(); + } + + getPlatformShortcutString(shortcutString) { + if (this._os == "mac") { + // when running on macos, make sure to also track in the shortcutKeyMap + // (which is used to check for duplicated shortcuts) a shortcut string + // that replace the `Ctrl` modifiers with the `Command` modified: + // they are going to be the same accel in the key element generated, + // by tracking both of them shortcut string value would confuse the about:addons "Manager Shortcuts" + // view and make it unable to correctly catch conflicts on mac + // (See bug 1565854). + shortcutString = shortcutString + .split("+") + .map(p => (p === "Ctrl" ? "Command" : p)) + .join("+"); + } + + return shortcutString; + } + + get(shortcutString) { + const platformShortcut = this.getPlatformShortcutString(shortcutString); + return super.get(platformShortcut); + } + + add(shortcutString, addonCommandValue) { + const setValue = this.get(shortcutString); + setValue.add(addonCommandValue); + } + + delete(shortcutString) { + const platformShortcut = this.getPlatformShortcutString(shortcutString); + super.delete(platformShortcut); + } +} + +/** + * An instance of this class is assigned to the shortcuts property of each + * active webextension that has commands defined. + * + * It manages loading any updated shortcuts along with the ones defined in + * the manifest and registering them to a browser window. It also provides + * the list, update and reset APIs for the browser.commands interface and + * the about:addons manage shortcuts page. + */ +export class ExtensionShortcuts { + static async removeCommandsFromStorage(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 lazy.ExtensionSettingsStore.initialize(); + lazy.ExtensionSettingsStore.getAllForExtension( + extensionId, + "commands" + ).forEach(key => { + lazy.ExtensionSettingsStore.removeSetting(extensionId, "commands", key); + }); + } + + constructor({ extension, onCommand, onShortcutChanged }) { + this.keysetsMap = new WeakMap(); + this.windowOpenListener = null; + this.extension = extension; + this.onCommand = onCommand; + this.onShortcutChanged = onShortcutChanged; + 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 lazy.ExtensionSettingsStore.getSetting( + "commands", + name, + extension.id + ); + let commandUpdates = (previousUpdates && previousUpdates.value) || {}; + + if (description && description != command.description) { + commandUpdates.description = description; + command.description = description; + } + + let oldShortcut = command.shortcut; + + if (shortcut != null && shortcut != command.shortcut) { + shortcut = normalizeShortcut(shortcut); + commandUpdates.shortcut = shortcut; + command.shortcut = shortcut; + } + + await lazy.ExtensionSettingsStore.addSetting( + extension.id, + "commands", + name, + commandUpdates + ); + + this.registerKeys(commands); + + if (command.shortcut !== oldShortcut) { + this.onShortcutChanged({ + name, + newShortcut: command.shortcut, + oldShortcut, + }); + } + } + + 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 = lazy.ExtensionSettingsStore.getSetting( + "commands", + name, + extension.id + ); + + if (storedCommand && storedCommand.value) { + commands.set(name, { ...manifestCommands.get(name) }); + lazy.ExtensionSettingsStore.removeSetting(extension.id, "commands", name); + this.registerKeys(commands); + } + } + + loadCommands() { + let { extension } = this; + + // Map[{String} commandName -> {Object} commandProperties] + this.manifestCommands = this.loadCommandsFromManifest(extension.manifest); + + this.commands = (async () => { + // 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); + } + }); + + return commands; + })(); + } + + registerKeys(commands) { + for (let window of lazy.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); + } + }; + + lazy.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 lazy.windowTracker.browserWindows()) { + if (this.keysetsMap.has(window)) { + this.keysetsMap.get(window).remove(); + } + } + + lazy.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 } = lazy.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 lazy.ExtensionSettingsStore.initialize(); + let names = lazy.ExtensionSettingsStore.getAllForExtension( + extensionId, + "commands" + ); + return names.reduce((map, name) => { + let command = lazy.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) { + if ( + !this.extension.privateBrowsingAllowed && + lazy.PrivateBrowsingUtils.isWindowPrivate(window) + ) { + return; + } + + 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(); + } + let sidebarKey; + 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); + 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; + let _execute_action = + this.extension.manifestVersion < 3 + ? "_execute_browser_action" + : "_execute_action"; + + let actionFor = { + [_execute_action]: lazy.browserActionFor, + _execute_page_action: lazy.pageActionFor, + _execute_sidebar_action: lazy.sidebarActionFor, + }[name]; + + if (actionFor) { + action = actionFor(this.extension); + let win = event.target.ownerGlobal; + action.triggerAction(win); + } else { + this.extension.tabManager.addActiveTabPermission(); + this.onCommand(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 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", + lazy.ShortcutUtils.getModifiersAttribute(parts) + ); + + // A keyElement with key "NumpadX" is created above and isn't from the + // manifest. The id will be set on the keyElement with key "X" only. + if (name == EXECUTE_SIDEBAR_ACTION && !chromeKey.startsWith("Numpad")) { + let id = `ext-key-id-${this.id}-sidebar-action`; + keyElement.setAttribute("id", id); + } + + let [attribute, value] = lazy.ShortcutUtils.getKeyAttribute(chromeKey); + keyElement.setAttribute(attribute, value); + if (attribute == "keycode") { + keyElement.setAttribute("event", "keydown"); + } + + return keyElement; + } +} |