1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 /* exported ExtensionShortcuts */
7 const EXPORTED_SYMBOLS = ["ExtensionShortcuts", "ExtensionShortcutKeyMap"];
9 const { ExtensionCommon } = ChromeUtils.import(
10 "resource://gre/modules/ExtensionCommon.jsm"
12 const { ExtensionUtils } = ChromeUtils.import(
13 "resource://gre/modules/ExtensionUtils.jsm"
18 ChromeUtils.defineESModuleGetters(lazy, {
19 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
20 ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs",
23 ChromeUtils.defineModuleGetter(
26 "resource://gre/modules/ExtensionParent.jsm"
28 ChromeUtils.defineModuleGetter(
30 "ExtensionSettingsStore",
31 "resource://gre/modules/ExtensionSettingsStore.jsm"
35 * These properties cannot be lazy getters otherwise they
36 * get defined on first use, at a time when some modules
37 * may not have been loaded. In that case, the getter would
38 * become undefined until next app restart.
40 Object.defineProperties(lazy, {
43 return lazy.ExtensionParent.apiManager.global.windowTracker;
48 return lazy.ExtensionParent.apiManager.global.browserActionFor;
53 return lazy.ExtensionParent.apiManager.global.pageActionFor;
58 return lazy.ExtensionParent.apiManager.global.sidebarActionFor;
63 const { ExtensionError, DefaultMap } = ExtensionUtils;
64 const { makeWidgetId } = ExtensionCommon;
66 const EXECUTE_SIDEBAR_ACTION = "_execute_sidebar_action";
68 function normalizeShortcut(shortcut) {
69 return shortcut ? shortcut.replace(/\s+/g, "") : "";
72 class ExtensionShortcutKeyMap extends DefaultMap {
73 async buildForAddonIds(addonIds) {
75 for (const addonId of addonIds) {
76 const policy = WebExtensionPolicy.getByID(addonId);
77 if (policy?.extension?.shortcuts) {
78 const { shortcuts } = policy.extension;
79 for (const command of await shortcuts.allCommands()) {
80 this.recordShortcut(command.shortcut, policy.name, command.name);
86 recordShortcut(shortcutString, addonName, commandName) {
87 if (!shortcutString) {
91 const valueSet = this.get(shortcutString);
92 valueSet.add({ addonName, commandName });
95 removeShortcut(shortcutString, addonName, commandName) {
96 if (!this.has(shortcutString)) {
100 const valueSet = this.get(shortcutString);
101 for (const entry of valueSet.values()) {
102 if (entry.addonName === addonName && entry.commandName === commandName) {
103 valueSet.delete(entry);
106 if (valueSet.size === 0) {
107 this.delete(shortcutString);
111 getFirstAddonName(shortcutString) {
112 if (this.has(shortcutString)) {
113 return this.get(shortcutString)
115 .next().value.addonName;
120 has(shortcutString) {
121 const platformShortcut = this.getPlatformShortcutString(shortcutString);
122 return super.has(platformShortcut) && super.get(platformShortcut).size > 0;
130 // Overridden in some unit test to make it easier to cover some
131 // platform specific behaviors (in particular the platform specific.
132 // normalization of the shortcuts using the Ctrl modifier on macOS).
133 this._os = lazy.ExtensionParent.PlatformInfo.os;
136 defaultConstructor() {
140 getPlatformShortcutString(shortcutString) {
141 if (this._os == "mac") {
142 // when running on macos, make sure to also track in the shortcutKeyMap
143 // (which is used to check for duplicated shortcuts) a shortcut string
144 // that replace the `Ctrl` modifiers with the `Command` modified:
145 // they are going to be the same accel in the key element generated,
146 // by tracking both of them shortcut string value would confuse the about:addons "Manager Shortcuts"
147 // view and make it unable to correctly catch conflicts on mac
148 // (See bug 1565854).
149 shortcutString = shortcutString
151 .map(p => (p === "Ctrl" ? "Command" : p))
155 return shortcutString;
158 get(shortcutString) {
159 const platformShortcut = this.getPlatformShortcutString(shortcutString);
160 return super.get(platformShortcut);
163 add(shortcutString, addonCommandValue) {
164 const setValue = this.get(shortcutString);
165 setValue.add(addonCommandValue);
168 delete(shortcutString) {
169 const platformShortcut = this.getPlatformShortcutString(shortcutString);
170 super.delete(platformShortcut);
175 * An instance of this class is assigned to the shortcuts property of each
176 * active webextension that has commands defined.
178 * It manages loading any updated shortcuts along with the ones defined in
179 * the manifest and registering them to a browser window. It also provides
180 * the list, update and reset APIs for the browser.commands interface and
181 * the about:addons manage shortcuts page.
183 class ExtensionShortcuts {
184 static async removeCommandsFromStorage(extensionId) {
185 // Cleanup the updated commands. In some cases the extension is installed
186 // and uninstalled so quickly that `this.commands` hasn't loaded yet. To
187 // handle that we need to make sure ExtensionSettingsStore is initialized
188 // before we clean it up.
189 await lazy.ExtensionSettingsStore.initialize();
190 lazy.ExtensionSettingsStore.getAllForExtension(
194 lazy.ExtensionSettingsStore.removeSetting(extensionId, "commands", key);
198 constructor({ extension, onCommand }) {
199 this.keysetsMap = new WeakMap();
200 this.windowOpenListener = null;
201 this.extension = extension;
202 this.onCommand = onCommand;
203 this.id = makeWidgetId(extension.id);
206 async allCommands() {
207 let commands = await this.commands;
208 return Array.from(commands, ([name, command]) => {
211 description: command.description,
212 shortcut: command.shortcut,
217 async updateCommand({ name, description, shortcut }) {
218 let { extension } = this;
219 let commands = await this.commands;
220 let command = commands.get(name);
223 throw new ExtensionError(`Unknown command "${name}"`);
226 // Only store the updates so manifest changes can take precedence
228 let previousUpdates = await lazy.ExtensionSettingsStore.getSetting(
233 let commandUpdates = (previousUpdates && previousUpdates.value) || {};
235 if (description && description != command.description) {
236 commandUpdates.description = description;
237 command.description = description;
240 if (shortcut != null && shortcut != command.shortcut) {
241 shortcut = normalizeShortcut(shortcut);
242 commandUpdates.shortcut = shortcut;
243 command.shortcut = shortcut;
246 await lazy.ExtensionSettingsStore.addSetting(
253 this.registerKeys(commands);
256 async resetCommand(name) {
257 let { extension, manifestCommands } = this;
258 let commands = await this.commands;
259 let command = commands.get(name);
262 throw new ExtensionError(`Unknown command "${name}"`);
265 let storedCommand = lazy.ExtensionSettingsStore.getSetting(
271 if (storedCommand && storedCommand.value) {
272 commands.set(name, { ...manifestCommands.get(name) });
273 lazy.ExtensionSettingsStore.removeSetting(extension.id, "commands", name);
274 this.registerKeys(commands);
279 let { extension } = this;
281 // Map[{String} commandName -> {Object} commandProperties]
282 this.manifestCommands = this.loadCommandsFromManifest(extension.manifest);
284 this.commands = (async () => {
285 // Deep copy the manifest commands to commands so we can keep the original
286 // manifest commands and update commands as needed.
287 let commands = new Map();
288 this.manifestCommands.forEach((command, name) => {
289 commands.set(name, { ...command });
292 // Update the manifest commands with the persisted updates from
293 // browser.commands.update().
294 let savedCommands = await this.loadCommandsFromStorage(extension.id);
295 savedCommands.forEach((update, name) => {
296 let command = commands.get(name);
298 // We will only update commands, not add them.
299 Object.assign(command, update);
307 registerKeys(commands) {
308 for (let window of lazy.windowTracker.browserWindows()) {
309 this.registerKeysToDocument(window, commands);
314 * Registers the commands to all open windows and to any which
318 let commands = await this.commands;
319 this.registerKeys(commands);
321 this.windowOpenListener = window => {
322 if (!this.keysetsMap.has(window)) {
323 this.registerKeysToDocument(window, commands);
327 lazy.windowTracker.addOpenListener(this.windowOpenListener);
331 * Unregisters the commands from all open windows and stops commands
332 * from being registered to windows which are later created.
335 for (let window of lazy.windowTracker.browserWindows()) {
336 if (this.keysetsMap.has(window)) {
337 this.keysetsMap.get(window).remove();
341 lazy.windowTracker.removeOpenListener(this.windowOpenListener);
345 * Creates a Map from commands for each command in the manifest.commands object.
347 * @param {object} manifest The manifest JSON object.
348 * @returns {Map<string, object>}
350 loadCommandsFromManifest(manifest) {
351 let commands = new Map();
352 // For Windows, chrome.runtime expects 'win' while chrome.commands
353 // expects 'windows'. We can special case this for now.
354 let { PlatformInfo } = lazy.ExtensionParent;
355 let os = PlatformInfo.os == "win" ? "windows" : PlatformInfo.os;
356 for (let [name, command] of Object.entries(manifest.commands)) {
357 let suggested_key = command.suggested_key || {};
358 let shortcut = normalizeShortcut(
359 suggested_key[os] || suggested_key.default
362 description: command.description,
369 async loadCommandsFromStorage(extensionId) {
370 await lazy.ExtensionSettingsStore.initialize();
371 let names = lazy.ExtensionSettingsStore.getAllForExtension(
375 return names.reduce((map, name) => {
376 let command = lazy.ExtensionSettingsStore.getSetting(
381 return map.set(name, command);
386 * Registers the commands to a document.
388 * @param {ChromeWindow} window The XUL window to insert the Keyset.
389 * @param {Map} commands The commands to be set.
391 registerKeysToDocument(window, commands) {
393 !this.extension.privateBrowsingAllowed &&
394 lazy.PrivateBrowsingUtils.isWindowPrivate(window)
399 let doc = window.document;
400 let keyset = doc.createXULElement("keyset");
401 keyset.id = `ext-keyset-id-${this.id}`;
402 if (this.keysetsMap.has(window)) {
403 this.keysetsMap.get(window).remove();
406 commands.forEach((command, name) => {
407 if (command.shortcut) {
408 let parts = command.shortcut.split("+");
410 // The key is always the last element.
411 let key = parts.pop();
413 if (/^[0-9]$/.test(key)) {
414 let shortcutWithNumpad = command.shortcut.replace(
418 let numpadKeyElement = this.buildKey(doc, name, shortcutWithNumpad);
419 keyset.appendChild(numpadKeyElement);
422 let keyElement = this.buildKey(doc, name, command.shortcut);
423 keyset.appendChild(keyElement);
424 if (name == EXECUTE_SIDEBAR_ACTION) {
425 sidebarKey = keyElement;
429 doc.documentElement.appendChild(keyset);
431 window.SidebarUI.updateShortcut({ key: sidebarKey });
433 this.keysetsMap.set(window, keyset);
437 * Builds a XUL Key element and attaches an onCommand listener which
438 * emits a command event with the provided name when fired.
440 * @param {Document} doc The XUL document.
441 * @param {string} name The name of the command.
442 * @param {string} shortcut The shortcut provided in the manifest.
443 * @see https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XUL/key
445 * @returns {Document} The newly created Key element.
447 buildKey(doc, name, shortcut) {
448 let keyElement = this.buildKeyFromShortcut(doc, name, shortcut);
450 // We need to have the attribute "oncommand" for the "command" listener to fire,
451 // and it is currently ignored when set to the empty string.
452 keyElement.setAttribute("oncommand", "//");
454 /* eslint-disable mozilla/balanced-listeners */
455 // We remove all references to the key elements when the extension is shutdown,
456 // therefore the listeners for these elements will be garbage collected.
457 keyElement.addEventListener("command", event => {
459 let _execute_action =
460 this.extension.manifestVersion < 3
461 ? "_execute_browser_action"
465 [_execute_action]: lazy.browserActionFor,
466 _execute_page_action: lazy.pageActionFor,
467 _execute_sidebar_action: lazy.sidebarActionFor,
471 action = actionFor(this.extension);
472 let win = event.target.ownerGlobal;
473 action.triggerAction(win);
475 this.extension.tabManager.addActiveTabPermission();
476 this.onCommand(name);
479 /* eslint-enable mozilla/balanced-listeners */
485 * Builds a XUL Key element from the provided shortcut.
487 * @param {Document} doc The XUL document.
488 * @param {string} name The name of the shortcut.
489 * @param {string} shortcut The shortcut provided in the manifest.
491 * @see https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XUL/key
492 * @returns {Document} The newly created Key element.
494 buildKeyFromShortcut(doc, name, shortcut) {
495 let keyElement = doc.createXULElement("key");
497 let parts = shortcut.split("+");
499 // The key is always the last element.
500 let chromeKey = parts.pop();
502 // The modifiers are the remaining elements.
503 keyElement.setAttribute(
505 lazy.ShortcutUtils.getModifiersAttribute(parts)
508 // A keyElement with key "NumpadX" is created above and isn't from the
509 // manifest. The id will be set on the keyElement with key "X" only.
510 if (name == EXECUTE_SIDEBAR_ACTION && !chromeKey.startsWith("Numpad")) {
511 let id = `ext-key-id-${this.id}-sidebar-action`;
512 keyElement.setAttribute("id", id);
515 let [attribute, value] = lazy.ShortcutUtils.getKeyAttribute(chromeKey);
516 keyElement.setAttribute(attribute, value);
517 if (attribute == "keycode") {
518 keyElement.setAttribute("event", "keydown");