Bug 1870926 [wpt PR 43734] - Remove experimental ::details-summary pseudo-element...
[gecko.git] / toolkit / components / extensions / ExtensionShortcuts.sys.mjs
blob7b30fa2cdfa7349b28330909eb63ab32d6c0e851
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";
9 const lazy = {};
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",
17 });
19 /**
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.
24  */
25 Object.defineProperties(lazy, {
26   windowTracker: {
27     get() {
28       return lazy.ExtensionParent.apiManager.global.windowTracker;
29     },
30   },
31   browserActionFor: {
32     get() {
33       return lazy.ExtensionParent.apiManager.global.browserActionFor;
34     },
35   },
36   pageActionFor: {
37     get() {
38       return lazy.ExtensionParent.apiManager.global.pageActionFor;
39     },
40   },
41   sidebarActionFor: {
42     get() {
43       return lazy.ExtensionParent.apiManager.global.sidebarActionFor;
44     },
45   },
46 });
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) {
59     this.clear();
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);
66         }
67       }
68     }
69   }
71   recordShortcut(shortcutString, addonName, commandName) {
72     if (!shortcutString) {
73       return;
74     }
76     const valueSet = this.get(shortcutString);
77     valueSet.add({ addonName, commandName });
78   }
80   removeShortcut(shortcutString, addonName, commandName) {
81     if (!this.has(shortcutString)) {
82       return;
83     }
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);
89       }
90     }
91     if (valueSet.size === 0) {
92       this.delete(shortcutString);
93     }
94   }
96   getFirstAddonName(shortcutString) {
97     if (this.has(shortcutString)) {
98       return this.get(shortcutString).values().next().value.addonName;
99     }
100     return null;
101   }
103   has(shortcutString) {
104     const platformShortcut = this.getPlatformShortcutString(shortcutString);
105     return super.has(platformShortcut) && super.get(platformShortcut).size > 0;
106   }
108   // Class internals.
110   constructor() {
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;
117   }
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
129         .split("+")
130         .map(p => (p === "Ctrl" ? "Command" : p))
131         .join("+");
132     }
134     return shortcutString;
135   }
137   get(shortcutString) {
138     const platformShortcut = this.getPlatformShortcutString(shortcutString);
139     return super.get(platformShortcut);
140   }
142   add(shortcutString, addonCommandValue) {
143     const setValue = this.get(shortcutString);
144     setValue.add(addonCommandValue);
145   }
147   delete(shortcutString) {
148     const platformShortcut = this.getPlatformShortcutString(shortcutString);
149     return super.delete(platformShortcut);
150   }
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.
161  */
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(
170       extensionId,
171       "commands"
172     ).forEach(key => {
173       lazy.ExtensionSettingsStore.removeSetting(extensionId, "commands", key);
174     });
175   }
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);
184   }
186   async allCommands() {
187     let commands = await this.commands;
188     return Array.from(commands, ([name, command]) => {
189       return {
190         name,
191         description: command.description,
192         shortcut: command.shortcut,
193       };
194     });
195   }
197   async updateCommand({ name, description, shortcut }) {
198     let { extension } = this;
199     let commands = await this.commands;
200     let command = commands.get(name);
202     if (!command) {
203       throw new ExtensionError(`Unknown command "${name}"`);
204     }
206     // Only store the updates so manifest changes can take precedence
207     // later.
208     let previousUpdates = await lazy.ExtensionSettingsStore.getSetting(
209       "commands",
210       name,
211       extension.id
212     );
213     let commandUpdates = (previousUpdates && previousUpdates.value) || {};
215     if (description && description != command.description) {
216       commandUpdates.description = description;
217       command.description = description;
218     }
220     let oldShortcut = command.shortcut;
222     if (shortcut != null && shortcut != command.shortcut) {
223       shortcut = normalizeShortcut(shortcut);
224       commandUpdates.shortcut = shortcut;
225       command.shortcut = shortcut;
226     }
228     await lazy.ExtensionSettingsStore.addSetting(
229       extension.id,
230       "commands",
231       name,
232       commandUpdates
233     );
235     this.registerKeys(commands);
237     if (command.shortcut !== oldShortcut) {
238       this.onShortcutChanged({
239         name,
240         newShortcut: command.shortcut,
241         oldShortcut,
242       });
243     }
244   }
246   async resetCommand(name) {
247     let { extension, manifestCommands } = this;
248     let commands = await this.commands;
249     let command = commands.get(name);
251     if (!command) {
252       throw new ExtensionError(`Unknown command "${name}"`);
253     }
255     let storedCommand = lazy.ExtensionSettingsStore.getSetting(
256       "commands",
257       name,
258       extension.id
259     );
261     if (storedCommand && storedCommand.value) {
262       commands.set(name, { ...manifestCommands.get(name) });
263       lazy.ExtensionSettingsStore.removeSetting(extension.id, "commands", name);
264       this.registerKeys(commands);
265     }
266   }
268   loadCommands() {
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 });
280       });
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);
287         if (command) {
288           // We will only update commands, not add them.
289           Object.assign(command, update);
290         }
291       });
293       return commands;
294     })();
295   }
297   registerKeys(commands) {
298     for (let window of lazy.windowTracker.browserWindows()) {
299       this.registerKeysToDocument(window, commands);
300     }
301   }
303   /**
304    * Registers the commands to all open windows and to any which
305    * are later created.
306    */
307   async register() {
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);
314       }
315     };
317     lazy.windowTracker.addOpenListener(this.windowOpenListener);
318   }
320   /**
321    * Unregisters the commands from all open windows and stops commands
322    * from being registered to windows which are later created.
323    */
324   unregister() {
325     for (let window of lazy.windowTracker.browserWindows()) {
326       if (this.keysetsMap.has(window)) {
327         this.keysetsMap.get(window).remove();
328       }
329     }
331     lazy.windowTracker.removeOpenListener(this.windowOpenListener);
332   }
334   /**
335    * Creates a Map from commands for each command in the manifest.commands object.
336    *
337    * @param {object} manifest The manifest JSON object.
338    * @returns {Map<string, object>}
339    */
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
350       );
351       commands.set(name, {
352         description: command.description,
353         shortcut,
354       });
355     }
356     return commands;
357   }
359   async loadCommandsFromStorage(extensionId) {
360     await lazy.ExtensionSettingsStore.initialize();
361     let names = lazy.ExtensionSettingsStore.getAllForExtension(
362       extensionId,
363       "commands"
364     );
365     return names.reduce((map, name) => {
366       let command = lazy.ExtensionSettingsStore.getSetting(
367         "commands",
368         name,
369         extensionId
370       ).value;
371       return map.set(name, command);
372     }, new Map());
373   }
375   /**
376    * Registers the commands to a document.
377    *
378    * @param {ChromeWindow} window The XUL window to insert the Keyset.
379    * @param {Map} commands The commands to be set.
380    */
381   registerKeysToDocument(window, commands) {
382     if (
383       !this.extension.privateBrowsingAllowed &&
384       lazy.PrivateBrowsingUtils.isWindowPrivate(window)
385     ) {
386       return;
387     }
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();
394     }
395     let sidebarKey;
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(
405             /[0-9]$/,
406             "Numpad$&"
407           );
408           let numpadKeyElement = this.buildKey(doc, name, shortcutWithNumpad);
409           keyset.appendChild(numpadKeyElement);
410         }
412         let keyElement = this.buildKey(doc, name, command.shortcut);
413         keyset.appendChild(keyElement);
414         if (name == EXECUTE_SIDEBAR_ACTION) {
415           sidebarKey = keyElement;
416         }
417       }
418     }
419     doc.documentElement.appendChild(keyset);
420     if (sidebarKey) {
421       window.SidebarUI.updateShortcut({ keyId: sidebarKey.id });
422     }
423     this.keysetsMap.set(window, keyset);
424   }
426   /**
427    * Builds a XUL Key element and attaches an onCommand listener which
428    * emits a command event with the provided name when fired.
429    *
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
434    *
435    * @returns {Element} The newly created Key element.
436    */
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 => {
448       let action;
449       let _execute_action =
450         this.extension.manifestVersion < 3
451           ? "_execute_browser_action"
452           : "_execute_action";
454       let actionFor = {
455         [_execute_action]: lazy.browserActionFor,
456         _execute_page_action: lazy.pageActionFor,
457         _execute_sidebar_action: lazy.sidebarActionFor,
458       }[name];
460       if (actionFor) {
461         action = actionFor(this.extension);
462         let win = event.target.ownerGlobal;
463         action.triggerAction(win);
464       } else {
465         this.extension.tabManager.addActiveTabPermission();
466         this.onCommand(name);
467       }
468     });
469     /* eslint-enable mozilla/balanced-listeners */
471     return keyElement;
472   }
474   /**
475    * Builds a XUL Key element from the provided shortcut.
476    *
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.
480    *
481    * @see https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XUL/key
482    * @returns {Element} The newly created Key element.
483    */
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(
494       "modifiers",
495       lazy.ShortcutUtils.getModifiersAttribute(parts)
496     );
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);
503     }
505     let [attribute, value] = lazy.ShortcutUtils.getKeyAttribute(chromeKey);
506     keyElement.setAttribute(attribute, value);
507     if (attribute == "keycode") {
508       keyElement.setAttribute("event", "keydown");
509     }
511     return keyElement;
512   }