tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 };