Home | History | Annotate | Download | only in extensions
      1 // Copyright (c) 2012 The Chromium Authors. All rights reserved.
      2 // Use of this source code is governed by a BSD-style license that can be
      3 // found in the LICENSE file.
      4 
      5   var eventNatives = requireNative('event_natives');
      6   var logging = requireNative('logging');
      7   var schemaRegistry = requireNative('schema_registry');
      8   var sendRequest = require('sendRequest').sendRequest;
      9   var utils = require('utils');
     10   var validate = require('schemaUtils').validate;
     11   var unloadEvent = require('unload_event');
     12 
     13   // Schemas for the rule-style functions on the events API that
     14   // only need to be generated occasionally, so populate them lazily.
     15   var ruleFunctionSchemas = {
     16     // These values are set lazily:
     17     // addRules: {},
     18     // getRules: {},
     19     // removeRules: {}
     20   };
     21 
     22   // This function ensures that |ruleFunctionSchemas| is populated.
     23   function ensureRuleSchemasLoaded() {
     24     if (ruleFunctionSchemas.addRules)
     25       return;
     26     var eventsSchema = schemaRegistry.GetSchema("events");
     27     var eventType = utils.lookup(eventsSchema.types, 'id', 'events.Event');
     28 
     29     ruleFunctionSchemas.addRules =
     30         utils.lookup(eventType.functions, 'name', 'addRules');
     31     ruleFunctionSchemas.getRules =
     32         utils.lookup(eventType.functions, 'name', 'getRules');
     33     ruleFunctionSchemas.removeRules =
     34         utils.lookup(eventType.functions, 'name', 'removeRules');
     35   }
     36 
     37   // A map of event names to the event object that is registered to that name.
     38   var attachedNamedEvents = {};
     39 
     40   // An array of all attached event objects, used for detaching on unload.
     41   var allAttachedEvents = [];
     42 
     43   // A map of functions that massage event arguments before they are dispatched.
     44   // Key is event name, value is function.
     45   var eventArgumentMassagers = {};
     46 
     47   // An attachment strategy for events that aren't attached to the browser.
     48   // This applies to events with the "unmanaged" option and events without
     49   // names.
     50   var NullAttachmentStrategy = function(event) {
     51     this.event_ = event;
     52   };
     53   NullAttachmentStrategy.prototype.onAddedListener =
     54       function(listener) {
     55   };
     56   NullAttachmentStrategy.prototype.onRemovedListener =
     57       function(listener) {
     58   };
     59   NullAttachmentStrategy.prototype.detach = function(manual) {
     60   };
     61   NullAttachmentStrategy.prototype.getListenersByIDs = function(ids) {
     62     // |ids| is for filtered events only.
     63     return this.event_.listeners_;
     64   };
     65 
     66   // Handles adding/removing/dispatching listeners for unfiltered events.
     67   var UnfilteredAttachmentStrategy = function(event) {
     68     this.event_ = event;
     69   };
     70 
     71   UnfilteredAttachmentStrategy.prototype.onAddedListener =
     72       function(listener) {
     73     // Only attach / detach on the first / last listener removed.
     74     if (this.event_.listeners_.length == 0)
     75       eventNatives.AttachEvent(privates(this.event_).eventName);
     76   };
     77 
     78   UnfilteredAttachmentStrategy.prototype.onRemovedListener =
     79       function(listener) {
     80     if (this.event_.listeners_.length == 0)
     81       this.detach(true);
     82   };
     83 
     84   UnfilteredAttachmentStrategy.prototype.detach = function(manual) {
     85     eventNatives.DetachEvent(privates(this.event_).eventName, manual);
     86   };
     87 
     88   UnfilteredAttachmentStrategy.prototype.getListenersByIDs = function(ids) {
     89     // |ids| is for filtered events only.
     90     return this.event_.listeners_;
     91   };
     92 
     93   var FilteredAttachmentStrategy = function(event) {
     94     this.event_ = event;
     95     this.listenerMap_ = {};
     96   };
     97 
     98   FilteredAttachmentStrategy.idToEventMap = {};
     99 
    100   FilteredAttachmentStrategy.prototype.onAddedListener = function(listener) {
    101     var id = eventNatives.AttachFilteredEvent(privates(this.event_).eventName,
    102                                               listener.filters || {});
    103     if (id == -1)
    104       throw new Error("Can't add listener");
    105     listener.id = id;
    106     this.listenerMap_[id] = listener;
    107     FilteredAttachmentStrategy.idToEventMap[id] = this.event_;
    108   };
    109 
    110   FilteredAttachmentStrategy.prototype.onRemovedListener = function(listener) {
    111     this.detachListener(listener, true);
    112   };
    113 
    114   FilteredAttachmentStrategy.prototype.detachListener =
    115       function(listener, manual) {
    116     if (listener.id == undefined)
    117       throw new Error("listener.id undefined - '" + listener + "'");
    118     var id = listener.id;
    119     delete this.listenerMap_[id];
    120     delete FilteredAttachmentStrategy.idToEventMap[id];
    121     eventNatives.DetachFilteredEvent(id, manual);
    122   };
    123 
    124   FilteredAttachmentStrategy.prototype.detach = function(manual) {
    125     for (var i in this.listenerMap_)
    126       this.detachListener(this.listenerMap_[i], manual);
    127   };
    128 
    129   FilteredAttachmentStrategy.prototype.getListenersByIDs = function(ids) {
    130     var result = [];
    131     for (var i = 0; i < ids.length; i++)
    132       $Array.push(result, this.listenerMap_[ids[i]]);
    133     return result;
    134   };
    135 
    136   function parseEventOptions(opt_eventOptions) {
    137     function merge(dest, src) {
    138       for (var k in src) {
    139         if (!$Object.hasOwnProperty(dest, k)) {
    140           dest[k] = src[k];
    141         }
    142       }
    143     }
    144 
    145     var options = opt_eventOptions || {};
    146     merge(options, {
    147       // Event supports adding listeners with filters ("filtered events"), for
    148       // example as used in the webNavigation API.
    149       //
    150       // event.addListener(listener, [filter1, filter2]);
    151       supportsFilters: false,
    152 
    153       // Events supports vanilla events. Most APIs use these.
    154       //
    155       // event.addListener(listener);
    156       supportsListeners: true,
    157 
    158       // Event supports adding rules ("declarative events") rather than
    159       // listeners, for example as used in the declarativeWebRequest API.
    160       //
    161       // event.addRules([rule1, rule2]);
    162       supportsRules: false,
    163 
    164       // Event is unmanaged in that the browser has no knowledge of its
    165       // existence; it's never invoked, doesn't keep the renderer alive, and
    166       // the bindings system has no knowledge of it.
    167       //
    168       // Both events created by user code (new chrome.Event()) and messaging
    169       // events are unmanaged, though in the latter case the browser *does*
    170       // interact indirectly with them via IPCs written by hand.
    171       unmanaged: false,
    172     });
    173     return options;
    174   };
    175 
    176   // Event object.  If opt_eventName is provided, this object represents
    177   // the unique instance of that named event, and dispatching an event
    178   // with that name will route through this object's listeners. Note that
    179   // opt_eventName is required for events that support rules.
    180   //
    181   // Example:
    182   //   var Event = require('event_bindings').Event;
    183   //   chrome.tabs.onChanged = new Event("tab-changed");
    184   //   chrome.tabs.onChanged.addListener(function(data) { alert(data); });
    185   //   Event.dispatch("tab-changed", "hi");
    186   // will result in an alert dialog that says 'hi'.
    187   //
    188   // If opt_eventOptions exists, it is a dictionary that contains the boolean
    189   // entries "supportsListeners" and "supportsRules".
    190   // If opt_webViewInstanceId exists, it is an integer uniquely identifying a
    191   // <webview> tag within the embedder. If it does not exist, then this is an
    192   // extension event rather than a <webview> event.
    193   var Event = function(opt_eventName, opt_argSchemas, opt_eventOptions,
    194                        opt_webViewInstanceId) {
    195     privates(this).eventName = opt_eventName;
    196     this.argSchemas_ = opt_argSchemas;
    197     this.listeners_ = [];
    198     this.eventOptions_ = parseEventOptions(opt_eventOptions);
    199     this.webViewInstanceId_ = opt_webViewInstanceId || 0;
    200 
    201     if (!privates(this).eventName) {
    202       if (this.eventOptions_.supportsRules)
    203         throw new Error("Events that support rules require an event name.");
    204       // Events without names cannot be managed by the browser by definition
    205       // (the browser has no way of identifying them).
    206       this.eventOptions_.unmanaged = true;
    207     }
    208 
    209     // Track whether the event has been destroyed to help track down the cause
    210     // of http://crbug.com/258526.
    211     // This variable will eventually hold the stack trace of the destroy call.
    212     // TODO(kalman): Delete this and replace with more sound logic that catches
    213     // when events are used without being *attached*.
    214     this.destroyed_ = null;
    215 
    216     if (this.eventOptions_.unmanaged)
    217       this.attachmentStrategy_ = new NullAttachmentStrategy(this);
    218     else if (this.eventOptions_.supportsFilters)
    219       this.attachmentStrategy_ = new FilteredAttachmentStrategy(this);
    220     else
    221       this.attachmentStrategy_ = new UnfilteredAttachmentStrategy(this);
    222   };
    223 
    224   // callback is a function(args, dispatch). args are the args we receive from
    225   // dispatchEvent(), and dispatch is a function(args) that dispatches args to
    226   // its listeners.
    227   function registerArgumentMassager(name, callback) {
    228     if (eventArgumentMassagers[name])
    229       throw new Error("Massager already registered for event: " + name);
    230     eventArgumentMassagers[name] = callback;
    231   }
    232 
    233   // Dispatches a named event with the given argument array. The args array is
    234   // the list of arguments that will be sent to the event callback.
    235   function dispatchEvent(name, args, filteringInfo) {
    236     var listenerIDs = [];
    237 
    238     if (filteringInfo)
    239       listenerIDs = eventNatives.MatchAgainstEventFilter(name, filteringInfo);
    240 
    241     var event = attachedNamedEvents[name];
    242     if (!event)
    243       return;
    244 
    245     var dispatchArgs = function(args) {
    246       var result = event.dispatch_(args, listenerIDs);
    247       if (result)
    248         logging.DCHECK(!result.validationErrors, result.validationErrors);
    249       return result;
    250     };
    251 
    252     if (eventArgumentMassagers[name])
    253       eventArgumentMassagers[name](args, dispatchArgs);
    254     else
    255       dispatchArgs(args);
    256   }
    257 
    258   // Registers a callback to be called when this event is dispatched.
    259   Event.prototype.addListener = function(cb, filters) {
    260     if (!this.eventOptions_.supportsListeners)
    261       throw new Error("This event does not support listeners.");
    262     if (this.eventOptions_.maxListeners &&
    263         this.getListenerCount() >= this.eventOptions_.maxListeners) {
    264       throw new Error("Too many listeners for " + privates(this).eventName);
    265     }
    266     if (filters) {
    267       if (!this.eventOptions_.supportsFilters)
    268         throw new Error("This event does not support filters.");
    269       if (filters.url && !(filters.url instanceof Array))
    270         throw new Error("filters.url should be an array.");
    271       if (filters.serviceType &&
    272           !(typeof filters.serviceType === 'string')) {
    273         throw new Error("filters.serviceType should be a string.")
    274       }
    275     }
    276     var listener = {callback: cb, filters: filters};
    277     this.attach_(listener);
    278     $Array.push(this.listeners_, listener);
    279   };
    280 
    281   Event.prototype.attach_ = function(listener) {
    282     this.attachmentStrategy_.onAddedListener(listener);
    283 
    284     if (this.listeners_.length == 0) {
    285       allAttachedEvents[allAttachedEvents.length] = this;
    286       if (privates(this).eventName) {
    287         if (attachedNamedEvents[privates(this).eventName]) {
    288           throw new Error("Event '" + privates(this).eventName +
    289                           "' is already attached.");
    290         }
    291         attachedNamedEvents[privates(this).eventName] = this;
    292       }
    293     }
    294   };
    295 
    296   // Unregisters a callback.
    297   Event.prototype.removeListener = function(cb) {
    298     if (!this.eventOptions_.supportsListeners)
    299       throw new Error("This event does not support listeners.");
    300 
    301     var idx = this.findListener_(cb);
    302     if (idx == -1)
    303       return;
    304 
    305     var removedListener = $Array.splice(this.listeners_, idx, 1)[0];
    306     this.attachmentStrategy_.onRemovedListener(removedListener);
    307 
    308     if (this.listeners_.length == 0) {
    309       var i = $Array.indexOf(allAttachedEvents, this);
    310       if (i >= 0)
    311         delete allAttachedEvents[i];
    312       if (privates(this).eventName) {
    313         if (!attachedNamedEvents[privates(this).eventName]) {
    314           throw new Error(
    315               "Event '" + privates(this).eventName + "' is not attached.");
    316         }
    317         delete attachedNamedEvents[privates(this).eventName];
    318       }
    319     }
    320   };
    321 
    322   // Test if the given callback is registered for this event.
    323   Event.prototype.hasListener = function(cb) {
    324     if (!this.eventOptions_.supportsListeners)
    325       throw new Error("This event does not support listeners.");
    326     return this.findListener_(cb) > -1;
    327   };
    328 
    329   // Test if any callbacks are registered for this event.
    330   Event.prototype.hasListeners = function() {
    331     return this.getListenerCount() > 0;
    332   };
    333 
    334   // Return the number of listeners on this event.
    335   Event.prototype.getListenerCount = function() {
    336     if (!this.eventOptions_.supportsListeners)
    337       throw new Error("This event does not support listeners.");
    338     return this.listeners_.length;
    339   };
    340 
    341   // Returns the index of the given callback if registered, or -1 if not
    342   // found.
    343   Event.prototype.findListener_ = function(cb) {
    344     for (var i = 0; i < this.listeners_.length; i++) {
    345       if (this.listeners_[i].callback == cb) {
    346         return i;
    347       }
    348     }
    349 
    350     return -1;
    351   };
    352 
    353   Event.prototype.dispatch_ = function(args, listenerIDs) {
    354     if (this.destroyed_) {
    355       throw new Error(privates(this).eventName + ' was already destroyed at: ' +
    356                       this.destroyed_);
    357     }
    358     if (!this.eventOptions_.supportsListeners)
    359       throw new Error("This event does not support listeners.");
    360 
    361     if (this.argSchemas_ && logging.DCHECK_IS_ON()) {
    362       try {
    363         validate(args, this.argSchemas_);
    364       } catch (e) {
    365         e.message += ' in ' + privates(this).eventName;
    366         throw e;
    367       }
    368     }
    369 
    370     // Make a copy of the listeners in case the listener list is modified
    371     // while dispatching the event.
    372     var listeners = $Array.slice(
    373         this.attachmentStrategy_.getListenersByIDs(listenerIDs));
    374 
    375     var results = [];
    376     for (var i = 0; i < listeners.length; i++) {
    377       try {
    378         var result = this.dispatchToListener(listeners[i].callback, args);
    379         if (result !== undefined)
    380           $Array.push(results, result);
    381       } catch (e) {
    382         console.error(
    383           'Error in event handler for ' +
    384           (privates(this).eventName ? privates(this).eventName : '(unknown)') +
    385           ': ' + e.stack);
    386       }
    387     }
    388     if (results.length)
    389       return {results: results};
    390   }
    391 
    392   // Can be overridden to support custom dispatching.
    393   Event.prototype.dispatchToListener = function(callback, args) {
    394     return $Function.apply(callback, null, args);
    395   }
    396 
    397   // Dispatches this event object to all listeners, passing all supplied
    398   // arguments to this function each listener.
    399   Event.prototype.dispatch = function(varargs) {
    400     return this.dispatch_($Array.slice(arguments), undefined);
    401   };
    402 
    403   // Detaches this event object from its name.
    404   Event.prototype.detach_ = function() {
    405     this.attachmentStrategy_.detach(false);
    406   };
    407 
    408   Event.prototype.destroy_ = function() {
    409     this.listeners_.length = 0;
    410     this.detach_();
    411     this.destroyed_ = new Error().stack;
    412   };
    413 
    414   Event.prototype.addRules = function(rules, opt_cb) {
    415     if (!this.eventOptions_.supportsRules)
    416       throw new Error("This event does not support rules.");
    417 
    418     // Takes a list of JSON datatype identifiers and returns a schema fragment
    419     // that verifies that a JSON object corresponds to an array of only these
    420     // data types.
    421     function buildArrayOfChoicesSchema(typesList) {
    422       return {
    423         'type': 'array',
    424         'items': {
    425           'choices': typesList.map(function(el) {return {'$ref': el};})
    426         }
    427       };
    428     };
    429 
    430     // Validate conditions and actions against specific schemas of this
    431     // event object type.
    432     // |rules| is an array of JSON objects that follow the Rule type of the
    433     // declarative extension APIs. |conditions| is an array of JSON type
    434     // identifiers that are allowed to occur in the conditions attribute of each
    435     // rule. Likewise, |actions| is an array of JSON type identifiers that are
    436     // allowed to occur in the actions attribute of each rule.
    437     function validateRules(rules, conditions, actions) {
    438       var conditionsSchema = buildArrayOfChoicesSchema(conditions);
    439       var actionsSchema = buildArrayOfChoicesSchema(actions);
    440       $Array.forEach(rules, function(rule) {
    441         validate([rule.conditions], [conditionsSchema]);
    442         validate([rule.actions], [actionsSchema]);
    443       });
    444     };
    445 
    446     if (!this.eventOptions_.conditions || !this.eventOptions_.actions) {
    447       throw new Error('Event ' + privates(this).eventName + ' misses ' +
    448                       'conditions or actions in the API specification.');
    449     }
    450 
    451     validateRules(rules,
    452                   this.eventOptions_.conditions,
    453                   this.eventOptions_.actions);
    454 
    455     ensureRuleSchemasLoaded();
    456     // We remove the first parameter from the validation to give the user more
    457     // meaningful error messages.
    458     validate([this.webViewInstanceId_, rules, opt_cb],
    459              $Array.splice(
    460                  $Array.slice(ruleFunctionSchemas.addRules.parameters), 1));
    461     sendRequest(
    462       "events.addRules",
    463       [privates(this).eventName, this.webViewInstanceId_, rules,  opt_cb],
    464       ruleFunctionSchemas.addRules.parameters);
    465   }
    466 
    467   Event.prototype.removeRules = function(ruleIdentifiers, opt_cb) {
    468     if (!this.eventOptions_.supportsRules)
    469       throw new Error("This event does not support rules.");
    470     ensureRuleSchemasLoaded();
    471     // We remove the first parameter from the validation to give the user more
    472     // meaningful error messages.
    473     validate([this.webViewInstanceId_, ruleIdentifiers, opt_cb],
    474              $Array.splice(
    475                  $Array.slice(ruleFunctionSchemas.removeRules.parameters), 1));
    476     sendRequest("events.removeRules",
    477                 [privates(this).eventName,
    478                  this.webViewInstanceId_,
    479                  ruleIdentifiers,
    480                  opt_cb],
    481                 ruleFunctionSchemas.removeRules.parameters);
    482   }
    483 
    484   Event.prototype.getRules = function(ruleIdentifiers, cb) {
    485     if (!this.eventOptions_.supportsRules)
    486       throw new Error("This event does not support rules.");
    487     ensureRuleSchemasLoaded();
    488     // We remove the first parameter from the validation to give the user more
    489     // meaningful error messages.
    490     validate([this.webViewInstanceId_, ruleIdentifiers, cb],
    491              $Array.splice(
    492                  $Array.slice(ruleFunctionSchemas.getRules.parameters), 1));
    493 
    494     sendRequest(
    495       "events.getRules",
    496       [privates(this).eventName, this.webViewInstanceId_, ruleIdentifiers, cb],
    497       ruleFunctionSchemas.getRules.parameters);
    498   }
    499 
    500   unloadEvent.addListener(function() {
    501     for (var i = 0; i < allAttachedEvents.length; ++i) {
    502       var event = allAttachedEvents[i];
    503       if (event)
    504         event.detach_();
    505     }
    506   });
    507 
    508   // NOTE: Event is (lazily) exposed as chrome.Event from dispatcher.cc.
    509   exports.Event = Event;
    510 
    511   exports.dispatchEvent = dispatchEvent;
    512   exports.parseEventOptions = parseEventOptions;
    513   exports.registerArgumentMassager = registerArgumentMassager;
    514