ext-menus.js (10690B)
1 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ 2 /* vim: set sts=2 sw=2 et tw=80: */ 3 /* This Source Code Form is subject to the terms of the Mozilla Public 4 * License, v. 2.0. If a copy of the MPL was not distributed with this file, 5 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 6 7 "use strict"; 8 9 var { withHandlingUserInput } = ExtensionCommon; 10 11 var { ExtensionError } = ExtensionUtils; 12 13 // If id is not specified for an item we use an integer. 14 // This ID need only be unique within a single addon. Since all addon code that 15 // can use this API runs in the same process, this local variable suffices. 16 var gNextMenuItemID = 0; 17 18 // Map[Extension -> Map[string or id, ContextMenusClickPropHandler]] 19 var gPropHandlers = new Map(); 20 21 // The contextMenus API supports an "onclick" attribute in the create/update 22 // methods to register a callback. This class manages these onclick properties. 23 class ContextMenusClickPropHandler { 24 constructor(context) { 25 this.context = context; 26 // Map[string or integer -> callback] 27 this.onclickMap = new Map(); 28 this.dispatchEvent = this.dispatchEvent.bind(this); 29 } 30 31 // A listener on contextMenus.onClicked that forwards the event to the only 32 // listener, if any. 33 dispatchEvent(info, tab) { 34 let onclick = this.onclickMap.get(info.menuItemId); 35 if (onclick) { 36 // No need for runSafe or anything because we are already being run inside 37 // an event handler -- the event is just being forwarded to the actual 38 // handler. 39 withHandlingUserInput(this.context.contentWindow, () => 40 onclick(info, tab) 41 ); 42 } 43 } 44 45 // Sets the `onclick` handler for the given menu item. 46 // The `onclick` function MUST be owned by `this.context`. 47 setListener(id, onclick) { 48 if (this.onclickMap.size === 0) { 49 this.context.childManager 50 .getParentEvent("menusInternal.onClicked") 51 .addListener(this.dispatchEvent); 52 this.context.callOnClose(this); 53 } 54 this.onclickMap.set(id, onclick); 55 56 let propHandlerMap = gPropHandlers.get(this.context.extension); 57 if (!propHandlerMap) { 58 propHandlerMap = new Map(); 59 } else { 60 // If the current callback was created in a different context, remove it 61 // from the other context. 62 let propHandler = propHandlerMap.get(id); 63 if (propHandler && propHandler !== this) { 64 propHandler.unsetListener(id); 65 } 66 } 67 propHandlerMap.set(id, this); 68 gPropHandlers.set(this.context.extension, propHandlerMap); 69 } 70 71 // Deletes the `onclick` handler for the given menu item. 72 // The `onclick` function MUST be owned by `this.context`. 73 unsetListener(id) { 74 if (!this.onclickMap.delete(id)) { 75 return; 76 } 77 if (this.onclickMap.size === 0) { 78 this.context.childManager 79 .getParentEvent("menusInternal.onClicked") 80 .removeListener(this.dispatchEvent); 81 this.context.forgetOnClose(this); 82 } 83 let propHandlerMap = gPropHandlers.get(this.context.extension); 84 propHandlerMap.delete(id); 85 if (propHandlerMap.size === 0) { 86 gPropHandlers.delete(this.context.extension); 87 } 88 } 89 90 // Deletes the `onclick` handler for the given menu item, if any, regardless 91 // of the context where it was created. 92 unsetListenerFromAnyContext(id) { 93 let propHandlerMap = gPropHandlers.get(this.context.extension); 94 let propHandler = propHandlerMap && propHandlerMap.get(id); 95 if (propHandler) { 96 propHandler.unsetListener(id); 97 } 98 } 99 100 // Remove all `onclick` handlers of the extension. 101 deleteAllListenersFromExtension() { 102 let propHandlerMap = gPropHandlers.get(this.context.extension); 103 if (propHandlerMap) { 104 for (let [id, propHandler] of propHandlerMap) { 105 propHandler.unsetListener(id); 106 } 107 } 108 } 109 110 // Removes all `onclick` handlers from this context. 111 close() { 112 for (let id of this.onclickMap.keys()) { 113 this.unsetListener(id); 114 } 115 } 116 } 117 118 this.menusInternal = class extends ExtensionAPI { 119 getAPI(context) { 120 let { extension } = context; 121 let onClickedProp = new ContextMenusClickPropHandler(context); 122 let pendingMenuEvent; 123 124 let api = { 125 menus: { 126 create(createProperties, callback) { 127 let caller = context.getCaller(); 128 129 if (extension.persistentBackground && createProperties.id === null) { 130 createProperties.id = ++gNextMenuItemID; 131 } 132 let { onclick } = createProperties; 133 if (onclick && !context.extension.persistentBackground) { 134 throw new ExtensionError( 135 `Property "onclick" cannot be used in menus.create, replace with an "onClicked" event listener.` 136 ); 137 } 138 delete createProperties.onclick; 139 context.childManager 140 .callParentAsyncFunction("menusInternal.create", [createProperties]) 141 .then(() => { 142 if (onclick) { 143 onClickedProp.setListener(createProperties.id, onclick); 144 } 145 if (callback) { 146 context.runSafeWithoutClone(callback); 147 } 148 }) 149 .catch(error => { 150 context.withLastError(error, caller, () => { 151 if (callback) { 152 context.runSafeWithoutClone(callback); 153 } 154 }); 155 }); 156 return createProperties.id; 157 }, 158 159 update(id, updateProperties) { 160 let { onclick } = updateProperties; 161 if (onclick && !context.extension.persistentBackground) { 162 throw new ExtensionError( 163 `Property "onclick" cannot be used in menus.update, replace with an "onClicked" event listener.` 164 ); 165 } 166 delete updateProperties.onclick; 167 return context.childManager 168 .callParentAsyncFunction("menusInternal.update", [ 169 id, 170 updateProperties, 171 ]) 172 .then(() => { 173 if (onclick) { 174 onClickedProp.setListener(id, onclick); 175 } else if (onclick === null) { 176 onClickedProp.unsetListenerFromAnyContext(id); 177 } 178 // else onclick is not set so it should not be changed. 179 }); 180 }, 181 182 remove(id) { 183 onClickedProp.unsetListenerFromAnyContext(id); 184 return context.childManager.callParentAsyncFunction( 185 "menusInternal.remove", 186 [id] 187 ); 188 }, 189 190 removeAll() { 191 onClickedProp.deleteAllListenersFromExtension(); 192 193 return context.childManager.callParentAsyncFunction( 194 "menusInternal.removeAll", 195 [] 196 ); 197 }, 198 199 overrideContext(contextOptions) { 200 let checkValidArg = (contextType, propKey) => { 201 if (contextOptions.context !== contextType) { 202 if (contextOptions[propKey]) { 203 throw new ExtensionError( 204 `Property "${propKey}" can only be used with context "${contextType}"` 205 ); 206 } 207 return false; 208 } 209 if (contextOptions.showDefaults) { 210 throw new ExtensionError( 211 `Property "showDefaults" cannot be used with context "${contextType}"` 212 ); 213 } 214 if (!contextOptions[propKey]) { 215 throw new ExtensionError( 216 `Property "${propKey}" is required for context "${contextType}"` 217 ); 218 } 219 return true; 220 }; 221 if (checkValidArg("tab", "tabId")) { 222 if (!context.extension.hasPermission("tabs")) { 223 throw new ExtensionError( 224 `The "tab" context requires the "tabs" permission.` 225 ); 226 } 227 } 228 if (checkValidArg("bookmark", "bookmarkId")) { 229 if (!context.extension.hasPermission("bookmarks")) { 230 throw new ExtensionError( 231 `The "bookmark" context requires the "bookmarks" permission.` 232 ); 233 } 234 } 235 236 let webExtContextData = { 237 extensionId: context.extension.id, 238 showDefaults: contextOptions.showDefaults, 239 overrideContext: contextOptions.context, 240 bookmarkId: contextOptions.bookmarkId, 241 tabId: contextOptions.tabId, 242 }; 243 244 if (pendingMenuEvent) { 245 // overrideContext is called more than once during the same event. 246 pendingMenuEvent.webExtContextData = webExtContextData; 247 return; 248 } 249 pendingMenuEvent = { 250 webExtContextData, 251 observe(subject) { 252 pendingMenuEvent = null; 253 Services.obs.removeObserver(this, "on-prepare-contextmenu"); 254 subject = subject.wrappedJSObject; 255 if (context.principal.subsumes(subject.principal)) { 256 subject.setWebExtContextData(this.webExtContextData); 257 } 258 }, 259 run() { 260 // "on-prepare-contextmenu" is expected to be observed before the 261 // end of the "contextmenu" event dispatch. This task is queued 262 // in case that does not happen, e.g. when the menu is not shown. 263 // ... or if the method was not called during a contextmenu event. 264 if (pendingMenuEvent === this) { 265 pendingMenuEvent = null; 266 Services.obs.removeObserver(this, "on-prepare-contextmenu"); 267 } 268 }, 269 }; 270 Services.obs.addObserver(pendingMenuEvent, "on-prepare-contextmenu"); 271 Services.tm.dispatchToMainThread(pendingMenuEvent); 272 }, 273 274 onClicked: new EventManager({ 275 context, 276 name: "menus.onClicked", 277 // Parent event already resets idle if needed, no need to do it here. 278 resetIdleOnEvent: false, 279 register: fire => { 280 let listener = (info, tab) => { 281 withHandlingUserInput(context.contentWindow, () => 282 fire.sync(info, tab) 283 ); 284 }; 285 286 let event = context.childManager.getParentEvent( 287 "menusInternal.onClicked" 288 ); 289 event.addListener(listener); 290 return () => { 291 event.removeListener(listener); 292 }; 293 }, 294 }).api(), 295 }, 296 }; 297 298 const result = {}; 299 if (context.extension.hasPermission("menus")) { 300 result.menus = api.menus; 301 } 302 if (context.extension.hasPermission("contextMenus")) { 303 result.contextMenus = api.menus; 304 } 305 return result; 306 } 307 };