Bug 1805526 - Refactor extension.startup() permissions setup, r=robwu
[gecko.git] / toolkit / components / extensions / ExtensionShortcuts.jsm
blobc592b914f044bbeaab2dd57b21e7c394dd5968fd
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/. */
4 "use strict";
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"
16 const lazy = {};
18 ChromeUtils.defineESModuleGetters(lazy, {
19   PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
20   ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs",
21 });
23 ChromeUtils.defineModuleGetter(
24   lazy,
25   "ExtensionParent",
26   "resource://gre/modules/ExtensionParent.jsm"
28 ChromeUtils.defineModuleGetter(
29   lazy,
30   "ExtensionSettingsStore",
31   "resource://gre/modules/ExtensionSettingsStore.jsm"
34 /**
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.
39  */
40 Object.defineProperties(lazy, {
41   windowTracker: {
42     get() {
43       return lazy.ExtensionParent.apiManager.global.windowTracker;
44     },
45   },
46   browserActionFor: {
47     get() {
48       return lazy.ExtensionParent.apiManager.global.browserActionFor;
49     },
50   },
51   pageActionFor: {
52     get() {
53       return lazy.ExtensionParent.apiManager.global.pageActionFor;
54     },
55   },
56   sidebarActionFor: {
57     get() {
58       return lazy.ExtensionParent.apiManager.global.sidebarActionFor;
59     },
60   },
61 });
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) {
74     this.clear();
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);
81         }
82       }
83     }
84   }
86   recordShortcut(shortcutString, addonName, commandName) {
87     if (!shortcutString) {
88       return;
89     }
91     const valueSet = this.get(shortcutString);
92     valueSet.add({ addonName, commandName });
93   }
95   removeShortcut(shortcutString, addonName, commandName) {
96     if (!this.has(shortcutString)) {
97       return;
98     }
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);
104       }
105     }
106     if (valueSet.size === 0) {
107       this.delete(shortcutString);
108     }
109   }
111   getFirstAddonName(shortcutString) {
112     if (this.has(shortcutString)) {
113       return this.get(shortcutString)
114         .values()
115         .next().value.addonName;
116     }
117     return null;
118   }
120   has(shortcutString) {
121     const platformShortcut = this.getPlatformShortcutString(shortcutString);
122     return super.has(platformShortcut) && super.get(platformShortcut).size > 0;
123   }
125   // Class internals.
127   constructor() {
128     super();
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;
134   }
136   defaultConstructor() {
137     return new Set();
138   }
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
150         .split("+")
151         .map(p => (p === "Ctrl" ? "Command" : p))
152         .join("+");
153     }
155     return shortcutString;
156   }
158   get(shortcutString) {
159     const platformShortcut = this.getPlatformShortcutString(shortcutString);
160     return super.get(platformShortcut);
161   }
163   add(shortcutString, addonCommandValue) {
164     const setValue = this.get(shortcutString);
165     setValue.add(addonCommandValue);
166   }
168   delete(shortcutString) {
169     const platformShortcut = this.getPlatformShortcutString(shortcutString);
170     super.delete(platformShortcut);
171   }
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.
182  */
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(
191       extensionId,
192       "commands"
193     ).forEach(key => {
194       lazy.ExtensionSettingsStore.removeSetting(extensionId, "commands", key);
195     });
196   }
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);
204   }
206   async allCommands() {
207     let commands = await this.commands;
208     return Array.from(commands, ([name, command]) => {
209       return {
210         name,
211         description: command.description,
212         shortcut: command.shortcut,
213       };
214     });
215   }
217   async updateCommand({ name, description, shortcut }) {
218     let { extension } = this;
219     let commands = await this.commands;
220     let command = commands.get(name);
222     if (!command) {
223       throw new ExtensionError(`Unknown command "${name}"`);
224     }
226     // Only store the updates so manifest changes can take precedence
227     // later.
228     let previousUpdates = await lazy.ExtensionSettingsStore.getSetting(
229       "commands",
230       name,
231       extension.id
232     );
233     let commandUpdates = (previousUpdates && previousUpdates.value) || {};
235     if (description && description != command.description) {
236       commandUpdates.description = description;
237       command.description = description;
238     }
240     if (shortcut != null && shortcut != command.shortcut) {
241       shortcut = normalizeShortcut(shortcut);
242       commandUpdates.shortcut = shortcut;
243       command.shortcut = shortcut;
244     }
246     await lazy.ExtensionSettingsStore.addSetting(
247       extension.id,
248       "commands",
249       name,
250       commandUpdates
251     );
253     this.registerKeys(commands);
254   }
256   async resetCommand(name) {
257     let { extension, manifestCommands } = this;
258     let commands = await this.commands;
259     let command = commands.get(name);
261     if (!command) {
262       throw new ExtensionError(`Unknown command "${name}"`);
263     }
265     let storedCommand = lazy.ExtensionSettingsStore.getSetting(
266       "commands",
267       name,
268       extension.id
269     );
271     if (storedCommand && storedCommand.value) {
272       commands.set(name, { ...manifestCommands.get(name) });
273       lazy.ExtensionSettingsStore.removeSetting(extension.id, "commands", name);
274       this.registerKeys(commands);
275     }
276   }
278   loadCommands() {
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 });
290       });
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);
297         if (command) {
298           // We will only update commands, not add them.
299           Object.assign(command, update);
300         }
301       });
303       return commands;
304     })();
305   }
307   registerKeys(commands) {
308     for (let window of lazy.windowTracker.browserWindows()) {
309       this.registerKeysToDocument(window, commands);
310     }
311   }
313   /**
314    * Registers the commands to all open windows and to any which
315    * are later created.
316    */
317   async register() {
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);
324       }
325     };
327     lazy.windowTracker.addOpenListener(this.windowOpenListener);
328   }
330   /**
331    * Unregisters the commands from all open windows and stops commands
332    * from being registered to windows which are later created.
333    */
334   unregister() {
335     for (let window of lazy.windowTracker.browserWindows()) {
336       if (this.keysetsMap.has(window)) {
337         this.keysetsMap.get(window).remove();
338       }
339     }
341     lazy.windowTracker.removeOpenListener(this.windowOpenListener);
342   }
344   /**
345    * Creates a Map from commands for each command in the manifest.commands object.
346    *
347    * @param {object} manifest The manifest JSON object.
348    * @returns {Map<string, object>}
349    */
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
360       );
361       commands.set(name, {
362         description: command.description,
363         shortcut,
364       });
365     }
366     return commands;
367   }
369   async loadCommandsFromStorage(extensionId) {
370     await lazy.ExtensionSettingsStore.initialize();
371     let names = lazy.ExtensionSettingsStore.getAllForExtension(
372       extensionId,
373       "commands"
374     );
375     return names.reduce((map, name) => {
376       let command = lazy.ExtensionSettingsStore.getSetting(
377         "commands",
378         name,
379         extensionId
380       ).value;
381       return map.set(name, command);
382     }, new Map());
383   }
385   /**
386    * Registers the commands to a document.
387    *
388    * @param {ChromeWindow} window The XUL window to insert the Keyset.
389    * @param {Map} commands The commands to be set.
390    */
391   registerKeysToDocument(window, commands) {
392     if (
393       !this.extension.privateBrowsingAllowed &&
394       lazy.PrivateBrowsingUtils.isWindowPrivate(window)
395     ) {
396       return;
397     }
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();
404     }
405     let sidebarKey;
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(
415             /[0-9]$/,
416             "Numpad$&"
417           );
418           let numpadKeyElement = this.buildKey(doc, name, shortcutWithNumpad);
419           keyset.appendChild(numpadKeyElement);
420         }
422         let keyElement = this.buildKey(doc, name, command.shortcut);
423         keyset.appendChild(keyElement);
424         if (name == EXECUTE_SIDEBAR_ACTION) {
425           sidebarKey = keyElement;
426         }
427       }
428     });
429     doc.documentElement.appendChild(keyset);
430     if (sidebarKey) {
431       window.SidebarUI.updateShortcut({ key: sidebarKey });
432     }
433     this.keysetsMap.set(window, keyset);
434   }
436   /**
437    * Builds a XUL Key element and attaches an onCommand listener which
438    * emits a command event with the provided name when fired.
439    *
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
444    *
445    * @returns {Document} The newly created Key element.
446    */
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 => {
458       let action;
459       let _execute_action =
460         this.extension.manifestVersion < 3
461           ? "_execute_browser_action"
462           : "_execute_action";
464       let actionFor = {
465         [_execute_action]: lazy.browserActionFor,
466         _execute_page_action: lazy.pageActionFor,
467         _execute_sidebar_action: lazy.sidebarActionFor,
468       }[name];
470       if (actionFor) {
471         action = actionFor(this.extension);
472         let win = event.target.ownerGlobal;
473         action.triggerAction(win);
474       } else {
475         this.extension.tabManager.addActiveTabPermission();
476         this.onCommand(name);
477       }
478     });
479     /* eslint-enable mozilla/balanced-listeners */
481     return keyElement;
482   }
484   /**
485    * Builds a XUL Key element from the provided shortcut.
486    *
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.
490    *
491    * @see https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XUL/key
492    * @returns {Document} The newly created Key element.
493    */
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(
504       "modifiers",
505       lazy.ShortcutUtils.getModifiersAttribute(parts)
506     );
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);
513     }
515     let [attribute, value] = lazy.ShortcutUtils.getKeyAttribute(chromeKey);
516     keyElement.setAttribute(attribute, value);
517     if (attribute == "keycode") {
518       keyElement.setAttribute("event", "keydown");
519     }
521     return keyElement;
522   }