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