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