Upstream version 7.36.149.0
[platform/framework/web/crosswalk.git] / src / chrome / renderer / resources / extensions / event.js
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 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;