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/. */
5 import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs";
7 import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs";
11 ChromeUtils.defineESModuleGetters(lazy, {
12 ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
13 ExtensionSettingsStore:
14 "resource://gre/modules/ExtensionSettingsStore.sys.mjs",
15 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
16 ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs",
20 * These properties cannot be lazy getters otherwise they
21 * get defined on first use, at a time when some modules
22 * may not have been loaded. In that case, the getter would
23 * become undefined until next app restart.
25 Object.defineProperties(lazy, {
28 return lazy.ExtensionParent.apiManager.global.windowTracker;
33 return lazy.ExtensionParent.apiManager.global.browserActionFor;
38 return lazy.ExtensionParent.apiManager.global.pageActionFor;
43 return lazy.ExtensionParent.apiManager.global.sidebarActionFor;
48 const { ExtensionError, DefaultMap } = ExtensionUtils;
49 const { makeWidgetId } = ExtensionCommon;
51 const EXECUTE_SIDEBAR_ACTION = "_execute_sidebar_action";
53 function normalizeShortcut(shortcut) {
54 return shortcut ? shortcut.replace(/\s+/g, "") : "";
57 export class ExtensionShortcutKeyMap extends DefaultMap {
58 async buildForAddonIds(addonIds) {
60 for (const addonId of addonIds) {
61 const policy = WebExtensionPolicy.getByID(addonId);
62 if (policy?.extension?.shortcuts) {
63 const { shortcuts } = policy.extension;
64 for (const command of await shortcuts.allCommands()) {
65 this.recordShortcut(command.shortcut, policy.name, command.name);
71 recordShortcut(shortcutString, addonName, commandName) {
72 if (!shortcutString) {
76 const valueSet = this.get(shortcutString);
77 valueSet.add({ addonName, commandName });
80 removeShortcut(shortcutString, addonName, commandName) {
81 if (!this.has(shortcutString)) {
85 const valueSet = this.get(shortcutString);
86 for (const entry of valueSet.values()) {
87 if (entry.addonName === addonName && entry.commandName === commandName) {
88 valueSet.delete(entry);
91 if (valueSet.size === 0) {
92 this.delete(shortcutString);
96 getFirstAddonName(shortcutString) {
97 if (this.has(shortcutString)) {
98 return this.get(shortcutString).values().next().value.addonName;
103 has(shortcutString) {
104 const platformShortcut = this.getPlatformShortcutString(shortcutString);
105 return super.has(platformShortcut) && super.get(platformShortcut).size > 0;
111 super(() => new Set());
113 // Overridden in some unit test to make it easier to cover some
114 // platform specific behaviors (in particular the platform specific.
115 // normalization of the shortcuts using the Ctrl modifier on macOS).
116 this._os = lazy.ExtensionParent.PlatformInfo.os;
119 getPlatformShortcutString(shortcutString) {
120 if (this._os == "mac") {
121 // when running on macos, make sure to also track in the shortcutKeyMap
122 // (which is used to check for duplicated shortcuts) a shortcut string
123 // that replace the `Ctrl` modifiers with the `Command` modified:
124 // they are going to be the same accel in the key element generated,
125 // by tracking both of them shortcut string value would confuse the about:addons "Manager Shortcuts"
126 // view and make it unable to correctly catch conflicts on mac
127 // (See bug 1565854).
128 shortcutString = shortcutString
130 .map(p => (p === "Ctrl" ? "Command" : p))
134 return shortcutString;
137 get(shortcutString) {
138 const platformShortcut = this.getPlatformShortcutString(shortcutString);
139 return super.get(platformShortcut);
142 add(shortcutString, addonCommandValue) {
143 const setValue = this.get(shortcutString);
144 setValue.add(addonCommandValue);
147 delete(shortcutString) {
148 const platformShortcut = this.getPlatformShortcutString(shortcutString);
149 return super.delete(platformShortcut);
154 * An instance of this class is assigned to the shortcuts property of each
155 * active webextension that has commands defined.
157 * It manages loading any updated shortcuts along with the ones defined in
158 * the manifest and registering them to a browser window. It also provides
159 * the list, update and reset APIs for the browser.commands interface and
160 * the about:addons manage shortcuts page.
162 export class ExtensionShortcuts {
163 static async removeCommandsFromStorage(extensionId) {
164 // Cleanup the updated commands. In some cases the extension is installed
165 // and uninstalled so quickly that `this.commands` hasn't loaded yet. To
166 // handle that we need to make sure ExtensionSettingsStore is initialized
167 // before we clean it up.
168 await lazy.ExtensionSettingsStore.initialize();
169 lazy.ExtensionSettingsStore.getAllForExtension(
173 lazy.ExtensionSettingsStore.removeSetting(extensionId, "commands", key);
177 constructor({ extension, onCommand, onShortcutChanged }) {
178 this.keysetsMap = new WeakMap();
179 this.windowOpenListener = null;
180 this.extension = extension;
181 this.onCommand = onCommand;
182 this.onShortcutChanged = onShortcutChanged;
183 this.id = makeWidgetId(extension.id);
186 async allCommands() {
187 let commands = await this.commands;
188 return Array.from(commands, ([name, command]) => {
191 description: command.description,
192 shortcut: command.shortcut,
197 async updateCommand({ name, description, shortcut }) {
198 let { extension } = this;
199 let commands = await this.commands;
200 let command = commands.get(name);
203 throw new ExtensionError(`Unknown command "${name}"`);
206 // Only store the updates so manifest changes can take precedence
208 let previousUpdates = await lazy.ExtensionSettingsStore.getSetting(
213 let commandUpdates = (previousUpdates && previousUpdates.value) || {};
215 if (description && description != command.description) {
216 commandUpdates.description = description;
217 command.description = description;
220 let oldShortcut = command.shortcut;
222 if (shortcut != null && shortcut != command.shortcut) {
223 shortcut = normalizeShortcut(shortcut);
224 commandUpdates.shortcut = shortcut;
225 command.shortcut = shortcut;
228 await lazy.ExtensionSettingsStore.addSetting(
235 this.registerKeys(commands);
237 if (command.shortcut !== oldShortcut) {
238 this.onShortcutChanged({
240 newShortcut: command.shortcut,
246 async resetCommand(name) {
247 let { extension, manifestCommands } = this;
248 let commands = await this.commands;
249 let command = commands.get(name);
252 throw new ExtensionError(`Unknown command "${name}"`);
255 let storedCommand = lazy.ExtensionSettingsStore.getSetting(
261 if (storedCommand && storedCommand.value) {
262 commands.set(name, { ...manifestCommands.get(name) });
263 lazy.ExtensionSettingsStore.removeSetting(extension.id, "commands", name);
264 this.registerKeys(commands);
269 let { extension } = this;
271 // Map[{String} commandName -> {Object} commandProperties]
272 this.manifestCommands = this.loadCommandsFromManifest(extension.manifest);
274 this.commands = (async () => {
275 // Deep copy the manifest commands to commands so we can keep the original
276 // manifest commands and update commands as needed.
277 let commands = new Map();
278 this.manifestCommands.forEach((command, name) => {
279 commands.set(name, { ...command });
282 // Update the manifest commands with the persisted updates from
283 // browser.commands.update().
284 let savedCommands = await this.loadCommandsFromStorage(extension.id);
285 savedCommands.forEach((update, name) => {
286 let command = commands.get(name);
288 // We will only update commands, not add them.
289 Object.assign(command, update);
297 registerKeys(commands) {
298 for (let window of lazy.windowTracker.browserWindows()) {
299 this.registerKeysToDocument(window, commands);
304 * Registers the commands to all open windows and to any which
308 let commands = await this.commands;
309 this.registerKeys(commands);
311 this.windowOpenListener = window => {
312 if (!this.keysetsMap.has(window)) {
313 this.registerKeysToDocument(window, commands);
317 lazy.windowTracker.addOpenListener(this.windowOpenListener);
321 * Unregisters the commands from all open windows and stops commands
322 * from being registered to windows which are later created.
325 for (let window of lazy.windowTracker.browserWindows()) {
326 if (this.keysetsMap.has(window)) {
327 this.keysetsMap.get(window).remove();
331 lazy.windowTracker.removeOpenListener(this.windowOpenListener);
335 * Creates a Map from commands for each command in the manifest.commands object.
337 * @param {object} manifest The manifest JSON object.
338 * @returns {Map<string, object>}
340 loadCommandsFromManifest(manifest) {
341 let commands = new Map();
342 // For Windows, chrome.runtime expects 'win' while chrome.commands
343 // expects 'windows'. We can special case this for now.
344 let { PlatformInfo } = lazy.ExtensionParent;
345 let os = PlatformInfo.os == "win" ? "windows" : PlatformInfo.os;
346 for (let [name, command] of Object.entries(manifest.commands)) {
347 let suggested_key = command.suggested_key || {};
348 let shortcut = normalizeShortcut(
349 suggested_key[os] || suggested_key.default
352 description: command.description,
359 async loadCommandsFromStorage(extensionId) {
360 await lazy.ExtensionSettingsStore.initialize();
361 let names = lazy.ExtensionSettingsStore.getAllForExtension(
365 return names.reduce((map, name) => {
366 let command = lazy.ExtensionSettingsStore.getSetting(
371 return map.set(name, command);
376 * Registers the commands to a document.
378 * @param {ChromeWindow} window The XUL window to insert the Keyset.
379 * @param {Map} commands The commands to be set.
381 registerKeysToDocument(window, commands) {
383 !this.extension.privateBrowsingAllowed &&
384 lazy.PrivateBrowsingUtils.isWindowPrivate(window)
389 let doc = window.document;
390 let keyset = doc.createXULElement("keyset");
391 keyset.id = `ext-keyset-id-${this.id}`;
392 if (this.keysetsMap.has(window)) {
393 this.keysetsMap.get(window).remove();
396 for (let [name, command] of commands) {
397 if (command.shortcut) {
398 let parts = command.shortcut.split("+");
400 // The key is always the last element.
401 let key = parts.pop();
403 if (/^[0-9]$/.test(key)) {
404 let shortcutWithNumpad = command.shortcut.replace(
408 let numpadKeyElement = this.buildKey(doc, name, shortcutWithNumpad);
409 keyset.appendChild(numpadKeyElement);
412 let keyElement = this.buildKey(doc, name, command.shortcut);
413 keyset.appendChild(keyElement);
414 if (name == EXECUTE_SIDEBAR_ACTION) {
415 sidebarKey = keyElement;
419 doc.documentElement.appendChild(keyset);
421 window.SidebarUI.updateShortcut({ keyId: sidebarKey.id });
423 this.keysetsMap.set(window, keyset);
427 * Builds a XUL Key element and attaches an onCommand listener which
428 * emits a command event with the provided name when fired.
430 * @param {Document} doc The XUL document.
431 * @param {string} name The name of the command.
432 * @param {string} shortcut The shortcut provided in the manifest.
433 * @see https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XUL/key
435 * @returns {Element} The newly created Key element.
437 buildKey(doc, name, shortcut) {
438 let keyElement = this.buildKeyFromShortcut(doc, name, shortcut);
440 // We need to have the attribute "oncommand" for the "command" listener to fire,
441 // and it is currently ignored when set to the empty string.
442 keyElement.setAttribute("oncommand", "//");
444 /* eslint-disable mozilla/balanced-listeners */
445 // We remove all references to the key elements when the extension is shutdown,
446 // therefore the listeners for these elements will be garbage collected.
447 keyElement.addEventListener("command", event => {
449 let _execute_action =
450 this.extension.manifestVersion < 3
451 ? "_execute_browser_action"
455 [_execute_action]: lazy.browserActionFor,
456 _execute_page_action: lazy.pageActionFor,
457 _execute_sidebar_action: lazy.sidebarActionFor,
461 action = actionFor(this.extension);
462 let win = event.target.ownerGlobal;
463 action.triggerAction(win);
465 this.extension.tabManager.addActiveTabPermission();
466 this.onCommand(name);
469 /* eslint-enable mozilla/balanced-listeners */
475 * Builds a XUL Key element from the provided shortcut.
477 * @param {Document} doc The XUL document.
478 * @param {string} name The name of the shortcut.
479 * @param {string} shortcut The shortcut provided in the manifest.
481 * @see https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XUL/key
482 * @returns {Element} The newly created Key element.
484 buildKeyFromShortcut(doc, name, shortcut) {
485 let keyElement = doc.createXULElement("key");
487 let parts = shortcut.split("+");
489 // The key is always the last element.
490 let chromeKey = parts.pop();
492 // The modifiers are the remaining elements.
493 keyElement.setAttribute(
495 lazy.ShortcutUtils.getModifiersAttribute(parts)
498 // A keyElement with key "NumpadX" is created above and isn't from the
499 // manifest. The id will be set on the keyElement with key "X" only.
500 if (name == EXECUTE_SIDEBAR_ACTION && !chromeKey.startsWith("Numpad")) {
501 let id = `ext-key-id-${this.id}-sidebar-action`;
502 keyElement.setAttribute("id", id);
505 let [attribute, value] = lazy.ShortcutUtils.getKeyAttribute(chromeKey);
506 keyElement.setAttribute(attribute, value);
507 if (attribute == "keycode") {
508 keyElement.setAttribute("event", "keydown");