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