1 // Copyright (c) 2013 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 'use strict'; 6 7 /** 8 * @fileoverview Utility objects and functions for Google Now extension. 9 * Most important entities here: 10 * (1) 'wrapper' is a module used to add error handling and other services to 11 * callbacks for HTML and Chrome functions and Chrome event listeners. 12 * Chrome invokes extension code through event listeners. Once entered via 13 * an event listener, the extension may call a Chrome/HTML API method 14 * passing a callback (and so forth), and that callback must occur later, 15 * otherwise, we generate an error. Chrome may unload event pages waiting 16 * for an event. When the event fires, Chrome will reload the event page. We 17 * don't require event listeners to fire because they are generally not 18 * predictable (like a button clicked event). 19 * (2) Task Manager (built with buildTaskManager() call) provides controlling 20 * mutually excluding chains of callbacks called tasks. Task Manager uses 21 * WrapperPlugins to add instrumentation code to 'wrapper' to determine 22 * when a task completes. 23 */ 24 25 // TODO(vadimt): Use server name in the manifest. 26 27 /** 28 * Notification server URL. 29 */ 30 var NOTIFICATION_CARDS_URL = 'https://www.googleapis.com/chromenow/v1'; 31 32 /** 33 * Returns true if debug mode is enabled. 34 * localStorage returns items as strings, which means if we store a boolean, 35 * it returns a string. Use this function to compare against true. 36 * @return {boolean} Whether debug mode is enabled. 37 */ 38 function isInDebugMode() { 39 return localStorage.debug_mode === 'true'; 40 } 41 42 /** 43 * Initializes for debug or release modes of operation. 44 */ 45 function initializeDebug() { 46 if (isInDebugMode()) { 47 NOTIFICATION_CARDS_URL = 48 localStorage['server_url'] || NOTIFICATION_CARDS_URL; 49 } 50 } 51 52 initializeDebug(); 53 54 /** 55 * Conditionally allow console.log output based off of the debug mode. 56 */ 57 console.log = function() { 58 var originalConsoleLog = console.log; 59 return function() { 60 if (isInDebugMode()) { 61 originalConsoleLog.apply(console, arguments); 62 } 63 }; 64 }(); 65 66 /** 67 * Explanation Card Storage. 68 */ 69 if (localStorage['explanatoryCardsShown'] === undefined) 70 localStorage['explanatoryCardsShown'] = 0; 71 72 /** 73 * Location Card Count Cleanup. 74 */ 75 if (localStorage.locationCardsShown !== undefined) 76 localStorage.removeItem('locationCardsShown'); 77 78 /** 79 * Builds an error object with a message that may be sent to the server. 80 * @param {string} message Error message. This message may be sent to the 81 * server. 82 * @return {Error} Error object. 83 */ 84 function buildErrorWithMessageForServer(message) { 85 var error = new Error(message); 86 error.canSendMessageToServer = true; 87 return error; 88 } 89 90 /** 91 * Checks for internal errors. 92 * @param {boolean} condition Condition that must be true. 93 * @param {string} message Diagnostic message for the case when the condition is 94 * false. 95 */ 96 function verify(condition, message) { 97 if (!condition) 98 throw buildErrorWithMessageForServer('ASSERT: ' + message); 99 } 100 101 /** 102 * Builds a request to the notification server. 103 * @param {string} method Request method. 104 * @param {string} handlerName Server handler to send the request to. 105 * @param {string=} opt_contentType Value for the Content-type header. 106 * @return {XMLHttpRequest} Server request. 107 */ 108 function buildServerRequest(method, handlerName, opt_contentType) { 109 var request = new XMLHttpRequest(); 110 111 request.responseType = 'text'; 112 request.open(method, NOTIFICATION_CARDS_URL + '/' + handlerName, true); 113 if (opt_contentType) 114 request.setRequestHeader('Content-type', opt_contentType); 115 116 return request; 117 } 118 119 /** 120 * Sends an error report to the server. 121 * @param {Error} error Error to send. 122 */ 123 function sendErrorReport(error) { 124 // Don't remove 'error.stack.replace' below! 125 var filteredStack = error.canSendMessageToServer ? 126 error.stack : error.stack.replace(/.*\n/, '(message removed)\n'); 127 var file; 128 var line; 129 var topFrameLineMatch = filteredStack.match(/\n at .*\n/); 130 var topFrame = topFrameLineMatch && topFrameLineMatch[0]; 131 if (topFrame) { 132 // Examples of a frame: 133 // 1. '\n at someFunction (chrome-extension:// 134 // pafkbggdmjlpgkdkcbjmhmfcdpncadgh/background.js:915:15)\n' 135 // 2. '\n at chrome-extension://pafkbggdmjlpgkdkcbjmhmfcdpncadgh/ 136 // utility.js:269:18\n' 137 // 3. '\n at Function.target.(anonymous function) (extensions:: 138 // SafeBuiltins:19:14)\n' 139 // 4. '\n at Event.dispatchToListener (event_bindings:382:22)\n' 140 var errorLocation; 141 // Find the the parentheses at the end of the line, if any. 142 var parenthesesMatch = topFrame.match(/\(.*\)\n/); 143 if (parenthesesMatch && parenthesesMatch[0]) { 144 errorLocation = 145 parenthesesMatch[0].substring(1, parenthesesMatch[0].length - 2); 146 } else { 147 errorLocation = topFrame; 148 } 149 150 var topFrameElements = errorLocation.split(':'); 151 // topFrameElements is an array that ends like: 152 // [N-3] //pafkbggdmjlpgkdkcbjmhmfcdpncadgh/utility.js 153 // [N-2] 308 154 // [N-1] 19 155 if (topFrameElements.length >= 3) { 156 file = topFrameElements[topFrameElements.length - 3]; 157 line = topFrameElements[topFrameElements.length - 2]; 158 } 159 } 160 161 var errorText = error.name; 162 if (error.canSendMessageToServer) 163 errorText = errorText + ': ' + error.message; 164 165 var errorObject = { 166 message: errorText, 167 file: file, 168 line: line, 169 trace: filteredStack 170 }; 171 172 // We use relatively direct calls here because the instrumentation may be in 173 // a bad state. Wrappers and promises should not be involved in the reporting. 174 var request = buildServerRequest('POST', 'jserrors', 'application/json'); 175 request.onloadend = function(event) { 176 console.log('sendErrorReport status: ' + request.status); 177 }; 178 179 chrome.identity.getAuthToken({interactive: false}, function(token) { 180 if (token) { 181 request.setRequestHeader('Authorization', 'Bearer ' + token); 182 request.send(JSON.stringify(errorObject)); 183 } 184 }); 185 } 186 187 // Limiting 1 error report per background page load. 188 var errorReported = false; 189 190 /** 191 * Reports an error to the server and the user, as appropriate. 192 * @param {Error} error Error to report. 193 */ 194 function reportError(error) { 195 var message = 'Critical error:\n' + error.stack; 196 if (isInDebugMode()) 197 console.error(message); 198 199 if (!errorReported) { 200 errorReported = true; 201 chrome.metricsPrivate.getIsCrashReportingEnabled(function(isEnabled) { 202 if (isEnabled) 203 sendErrorReport(error); 204 if (isInDebugMode()) 205 alert(message); 206 }); 207 } 208 } 209 210 // Partial mirror of chrome.* for all instrumented functions. 211 var instrumented = {}; 212 213 /** 214 * Wrapper plugin. These plugins extend instrumentation added by 215 * wrapper.wrapCallback by adding code that executes before and after the call 216 * to the original callback provided by the extension. 217 * 218 * @typedef {{ 219 * prologue: function (), 220 * epilogue: function () 221 * }} 222 */ 223 var WrapperPlugin; 224 225 /** 226 * Wrapper for callbacks. Used to add error handling and other services to 227 * callbacks for HTML and Chrome functions and events. 228 */ 229 var wrapper = (function() { 230 /** 231 * Factory for wrapper plugins. If specified, it's used to generate an 232 * instance of WrapperPlugin each time we wrap a callback (which corresponds 233 * to addListener call for Chrome events, and to every API call that specifies 234 * a callback). WrapperPlugin's lifetime ends when the callback for which it 235 * was generated, exits. It's possible to have several instances of 236 * WrapperPlugin at the same time. 237 * An instance of WrapperPlugin can have state that can be shared by its 238 * constructor, prologue() and epilogue(). Also WrapperPlugins can change 239 * state of other objects, for example, to do refcounting. 240 * @type {?function(): WrapperPlugin} 241 */ 242 var wrapperPluginFactory = null; 243 244 /** 245 * Registers a wrapper plugin factory. 246 * @param {function(): WrapperPlugin} factory Wrapper plugin factory. 247 */ 248 function registerWrapperPluginFactory(factory) { 249 if (wrapperPluginFactory) { 250 reportError(buildErrorWithMessageForServer( 251 'registerWrapperPluginFactory: factory is already registered.')); 252 } 253 254 wrapperPluginFactory = factory; 255 } 256 257 /** 258 * True if currently executed code runs in a callback or event handler that 259 * was instrumented by wrapper.wrapCallback() call. 260 * @type {boolean} 261 */ 262 var isInWrappedCallback = false; 263 264 /** 265 * Required callbacks that are not yet called. Includes both task and non-task 266 * callbacks. This is a map from unique callback id to the stack at the moment 267 * when the callback was wrapped. This stack identifies the callback. 268 * Used only for diagnostics. 269 * @type {Object.<number, string>} 270 */ 271 var pendingCallbacks = {}; 272 273 /** 274 * Unique ID of the next callback. 275 * @type {number} 276 */ 277 var nextCallbackId = 0; 278 279 /** 280 * Gets diagnostic string with the status of the wrapper. 281 * @return {string} Diagnostic string. 282 */ 283 function debugGetStateString() { 284 return 'pendingCallbacks @' + Date.now() + ' = ' + 285 JSON.stringify(pendingCallbacks); 286 } 287 288 /** 289 * Checks that we run in a wrapped callback. 290 */ 291 function checkInWrappedCallback() { 292 if (!isInWrappedCallback) { 293 reportError(buildErrorWithMessageForServer( 294 'Not in instrumented callback')); 295 } 296 } 297 298 /** 299 * Adds error processing to an API callback. 300 * @param {Function} callback Callback to instrument. 301 * @param {boolean=} opt_isEventListener True if the callback is a listener to 302 * a Chrome API event. 303 * @return {Function} Instrumented callback. 304 */ 305 function wrapCallback(callback, opt_isEventListener) { 306 var callbackId = nextCallbackId++; 307 308 if (!opt_isEventListener) { 309 checkInWrappedCallback(); 310 pendingCallbacks[callbackId] = new Error().stack + ' @' + Date.now(); 311 } 312 313 // wrapperPluginFactory may be null before task manager is built, and in 314 // tests. 315 var wrapperPluginInstance = wrapperPluginFactory && wrapperPluginFactory(); 316 317 return function() { 318 // This is the wrapper for the callback. 319 try { 320 verify(!isInWrappedCallback, 'Re-entering instrumented callback'); 321 isInWrappedCallback = true; 322 323 if (!opt_isEventListener) 324 delete pendingCallbacks[callbackId]; 325 326 if (wrapperPluginInstance) 327 wrapperPluginInstance.prologue(); 328 329 // Call the original callback. 330 var returnValue = callback.apply(null, arguments); 331 332 if (wrapperPluginInstance) 333 wrapperPluginInstance.epilogue(); 334 335 verify(isInWrappedCallback, 336 'Instrumented callback is not instrumented upon exit'); 337 isInWrappedCallback = false; 338 339 return returnValue; 340 } catch (error) { 341 reportError(error); 342 } 343 }; 344 } 345 346 /** 347 * Returns an instrumented function. 348 * @param {!Array.<string>} functionIdentifierParts Path to the chrome.* 349 * function. 350 * @param {string} functionName Name of the chrome API function. 351 * @param {number} callbackParameter Index of the callback parameter to this 352 * API function. 353 * @return {Function} An instrumented function. 354 */ 355 function createInstrumentedFunction( 356 functionIdentifierParts, 357 functionName, 358 callbackParameter) { 359 return function() { 360 // This is the wrapper for the API function. Pass the wrapped callback to 361 // the original function. 362 var callback = arguments[callbackParameter]; 363 if (typeof callback != 'function') { 364 reportError(buildErrorWithMessageForServer( 365 'Argument ' + callbackParameter + ' of ' + 366 functionIdentifierParts.join('.') + '.' + functionName + 367 ' is not a function')); 368 } 369 arguments[callbackParameter] = wrapCallback( 370 callback, functionName == 'addListener'); 371 372 var chromeContainer = chrome; 373 functionIdentifierParts.forEach(function(fragment) { 374 chromeContainer = chromeContainer[fragment]; 375 }); 376 return chromeContainer[functionName]. 377 apply(chromeContainer, arguments); 378 }; 379 } 380 381 /** 382 * Instruments an API function to add error processing to its user 383 * code-provided callback. 384 * @param {string} functionIdentifier Full identifier of the function without 385 * the 'chrome.' portion. 386 * @param {number} callbackParameter Index of the callback parameter to this 387 * API function. 388 */ 389 function instrumentChromeApiFunction(functionIdentifier, callbackParameter) { 390 var functionIdentifierParts = functionIdentifier.split('.'); 391 var functionName = functionIdentifierParts.pop(); 392 var chromeContainer = chrome; 393 var instrumentedContainer = instrumented; 394 functionIdentifierParts.forEach(function(fragment) { 395 chromeContainer = chromeContainer[fragment]; 396 if (!chromeContainer) { 397 reportError(buildErrorWithMessageForServer( 398 'Cannot instrument ' + functionIdentifier)); 399 } 400 401 if (!(fragment in instrumentedContainer)) 402 instrumentedContainer[fragment] = {}; 403 404 instrumentedContainer = instrumentedContainer[fragment]; 405 }); 406 407 var targetFunction = chromeContainer[functionName]; 408 if (!targetFunction) { 409 reportError(buildErrorWithMessageForServer( 410 'Cannot instrument ' + functionIdentifier)); 411 } 412 413 instrumentedContainer[functionName] = createInstrumentedFunction( 414 functionIdentifierParts, 415 functionName, 416 callbackParameter); 417 } 418 419 instrumentChromeApiFunction('runtime.onSuspend.addListener', 0); 420 421 instrumented.runtime.onSuspend.addListener(function() { 422 var stringifiedPendingCallbacks = JSON.stringify(pendingCallbacks); 423 verify( 424 stringifiedPendingCallbacks == '{}', 425 'Pending callbacks when unloading event page @' + Date.now() + ':' + 426 stringifiedPendingCallbacks); 427 }); 428 429 return { 430 wrapCallback: wrapCallback, 431 instrumentChromeApiFunction: instrumentChromeApiFunction, 432 registerWrapperPluginFactory: registerWrapperPluginFactory, 433 checkInWrappedCallback: checkInWrappedCallback, 434 debugGetStateString: debugGetStateString 435 }; 436 })(); 437 438 wrapper.instrumentChromeApiFunction('alarms.get', 1); 439 wrapper.instrumentChromeApiFunction('alarms.onAlarm.addListener', 0); 440 wrapper.instrumentChromeApiFunction('identity.getAuthToken', 1); 441 wrapper.instrumentChromeApiFunction('identity.onSignInChanged.addListener', 0); 442 wrapper.instrumentChromeApiFunction('identity.removeCachedAuthToken', 1); 443 wrapper.instrumentChromeApiFunction('storage.local.get', 1); 444 wrapper.instrumentChromeApiFunction('webstorePrivate.getBrowserLogin', 0); 445 446 /** 447 * Promise adapter for all JS promises to the task manager. 448 */ 449 function registerPromiseAdapter() { 450 var originalThen = Promise.prototype.then; 451 var originalCatch = Promise.prototype.catch; 452 453 /** 454 * Takes a promise and adds the callback tracker to it. 455 * @param {object} promise Promise that receives the callback tracker. 456 */ 457 function instrumentPromise(promise) { 458 if (promise.__tracker === undefined) { 459 promise.__tracker = createPromiseCallbackTracker(promise); 460 } 461 } 462 463 Promise.prototype.then = function(onResolved, onRejected) { 464 instrumentPromise(this); 465 return this.__tracker.handleThen(onResolved, onRejected); 466 } 467 468 Promise.prototype.catch = function(onRejected) { 469 instrumentPromise(this); 470 return this.__tracker.handleCatch(onRejected); 471 } 472 473 /** 474 * Promise Callback Tracker. 475 * Handles coordination of 'then' and 'catch' callbacks in a task 476 * manager compatible way. For an individual promise, either the 'then' 477 * arguments or the 'catch' arguments will be processed, never both. 478 * 479 * Example: 480 * var p = new Promise([Function]); 481 * p.then([ThenA]); 482 * p.then([ThenB]); 483 * p.catch([CatchA]); 484 * On resolution, [ThenA] and [ThenB] will be used. [CatchA] is discarded. 485 * On rejection, vice versa. 486 * 487 * Clarification: 488 * Chained promises create a new promise that is tracked separately from 489 * the originaing promise, as the example below demonstrates: 490 * 491 * var p = new Promise([Function])); 492 * p.then([ThenA]).then([ThenB]).catch([CatchA]); 493 * ^ ^ ^ 494 * | | + Returns a new promise. 495 * | + Returns a new promise. 496 * + Returns a new promise. 497 * 498 * Four promises exist in the above statement, each with its own 499 * resolution and rejection state. However, by default, this state is 500 * chained to the previous promise's resolution or rejection 501 * state. 502 * 503 * If p resolves, then the 'then' calls will execute until all the 'then' 504 * clauses are executed. If the result of either [ThenA] or [ThenB] is a 505 * promise, then that execution state will guide the remaining chain. 506 * Similarly, if [CatchA] returns a promise, it can also guide the 507 * remaining chain. In this specific case, the chain ends, so there 508 * is nothing left to do. 509 * @param {object} promise Promise being tracked. 510 * @return {object} A promise callback tracker. 511 */ 512 function createPromiseCallbackTracker(promise) { 513 /** 514 * Callback Tracker. Holds an array of callbacks created for this promise. 515 * The indirection allows quick checks against the array and clearing the 516 * array without ugly splicing and copying. 517 * @typedef {{ 518 * callback: array.<Function>= 519 * }} 520 */ 521 var CallbackTracker; 522 523 /** @type {CallbackTracker} */ 524 var thenTracker = {callbacks: []}; 525 /** @type {CallbackTracker} */ 526 var catchTracker = {callbacks: []}; 527 528 /** 529 * Returns true if the specified value is callable. 530 * @param {*} value Value to check. 531 * @return {boolean} True if the value is a callable. 532 */ 533 function isCallable(value) { 534 return typeof value === 'function'; 535 } 536 537 /** 538 * Takes a tracker and clears its callbacks in a manner consistent with 539 * the task manager. For the task manager, it also calls all callbacks 540 * by no-oping them first and then calling them. 541 * @param {CallbackTracker} tracker Tracker to clear. 542 */ 543 function clearTracker(tracker) { 544 if (tracker.callbacks) { 545 var callbacksToClear = tracker.callbacks; 546 // No-ops all callbacks of this type. 547 tracker.callbacks = undefined; 548 // Do not wrap the promise then argument! 549 // It will call wrapped callbacks. 550 originalThen.call(Promise.resolve(), function() { 551 for (var i = 0; i < callbacksToClear.length; i++) { 552 callbacksToClear[i](); 553 } 554 }); 555 } 556 } 557 558 /** 559 * Takes the argument to a 'then' or 'catch' function and applies 560 * a wrapping to callables consistent to ECMA promises. 561 * @param {*} maybeCallback Argument to 'then' or 'catch'. 562 * @param {CallbackTracker} sameTracker Tracker for the call type. 563 * Example: If the argument is from a 'then' call, use thenTracker. 564 * @param {CallbackTracker} otherTracker Tracker for the opposing call type. 565 * Example: If the argument is from a 'then' call, use catchTracker. 566 * @return {*} Consumable argument with necessary wrapping applied. 567 */ 568 function registerAndWrapMaybeCallback( 569 maybeCallback, sameTracker, otherTracker) { 570 // If sameTracker.callbacks is undefined, we've reached an ending state 571 // that means this callback will never be called back. 572 // We will still forward this call on to let the promise system 573 // handle further processing, but since this promise is in an ending state 574 // we can be confident it will never be called back. 575 if (isCallable(maybeCallback) && 576 !maybeCallback.wrappedByPromiseTracker && 577 sameTracker.callbacks) { 578 var handler = wrapper.wrapCallback(function() { 579 if (sameTracker.callbacks) { 580 clearTracker(otherTracker); 581 return maybeCallback.apply(null, arguments); 582 } 583 }, false); 584 // Harmony promises' catch calls will call into handleThen, 585 // double-wrapping all catch callbacks. Regular promise catch calls do 586 // not call into handleThen. Setting an attribute on the wrapped 587 // function is compatible with both promise implementations. 588 handler.wrappedByPromiseTracker = true; 589 sameTracker.callbacks.push(handler); 590 return handler; 591 } else { 592 return maybeCallback; 593 } 594 } 595 596 /** 597 * Tracks then calls equivalent to Promise.prototype.then. 598 * @param {*} onResolved Argument to use if the promise is resolved. 599 * @param {*} onRejected Argument to use if the promise is rejected. 600 * @return {object} Promise resulting from the 'then' call. 601 */ 602 function handleThen(onResolved, onRejected) { 603 var resolutionHandler = 604 registerAndWrapMaybeCallback(onResolved, thenTracker, catchTracker); 605 var rejectionHandler = 606 registerAndWrapMaybeCallback(onRejected, catchTracker, thenTracker); 607 return originalThen.call(promise, resolutionHandler, rejectionHandler); 608 } 609 610 /** 611 * Tracks then calls equivalent to Promise.prototype.catch. 612 * @param {*} onRejected Argument to use if the promise is rejected. 613 * @return {object} Promise resulting from the 'catch' call. 614 */ 615 function handleCatch(onRejected) { 616 var rejectionHandler = 617 registerAndWrapMaybeCallback(onRejected, catchTracker, thenTracker); 618 return originalCatch.call(promise, rejectionHandler); 619 } 620 621 // Register at least one resolve and reject callback so we always receive 622 // a callback to update the task manager and clear the callbacks 623 // that will never occur. 624 // 625 // The then form is used to avoid reentrancy by handleCatch, 626 // which ends up calling handleThen. 627 handleThen(function() {}, function() {}); 628 629 return { 630 handleThen: handleThen, 631 handleCatch: handleCatch 632 }; 633 } 634 } 635 636 registerPromiseAdapter(); 637 638 /** 639 * Control promise rejection. 640 * @enum {number} 641 */ 642 var PromiseRejection = { 643 /** Disallow promise rejection */ 644 DISALLOW: 0, 645 /** Allow promise rejection */ 646 ALLOW: 1 647 }; 648 649 /** 650 * Provides the promise equivalent of instrumented.storage.local.get. 651 * @param {Object} defaultStorageObject Default storage object to fill. 652 * @param {PromiseRejection=} opt_allowPromiseRejection If 653 * PromiseRejection.ALLOW, allow promise rejection on errors, otherwise the 654 * default storage object is resolved. 655 * @return {Promise} A promise that fills the default storage object. On 656 * failure, if promise rejection is allowed, the promise is rejected, 657 * otherwise it is resolved to the default storage object. 658 */ 659 function fillFromChromeLocalStorage( 660 defaultStorageObject, 661 opt_allowPromiseRejection) { 662 return new Promise(function(resolve, reject) { 663 // We have to create a keys array because keys with a default value 664 // of undefined will cause that key to not be looked up! 665 var keysToGet = []; 666 for (var key in defaultStorageObject) { 667 keysToGet.push(key); 668 } 669 instrumented.storage.local.get(keysToGet, function(items) { 670 if (items) { 671 // Merge the result with the default storage object to ensure all keys 672 // requested have either the default value or the retrieved storage 673 // value. 674 var result = {}; 675 for (var key in defaultStorageObject) { 676 result[key] = (key in items) ? items[key] : defaultStorageObject[key]; 677 } 678 resolve(result); 679 } else if (opt_allowPromiseRejection === PromiseRejection.ALLOW) { 680 reject(); 681 } else { 682 resolve(defaultStorageObject); 683 } 684 }); 685 }); 686 } 687 688 /** 689 * Builds the object to manage tasks (mutually exclusive chains of events). 690 * @param {function(string, string): boolean} areConflicting Function that 691 * checks if a new task can't be added to a task queue that contains an 692 * existing task. 693 * @return {Object} Task manager interface. 694 */ 695 function buildTaskManager(areConflicting) { 696 /** 697 * Queue of scheduled tasks. The first element, if present, corresponds to the 698 * currently running task. 699 * @type {Array.<Object.<string, function()>>} 700 */ 701 var queue = []; 702 703 /** 704 * Count of unfinished callbacks of the current task. 705 * @type {number} 706 */ 707 var taskPendingCallbackCount = 0; 708 709 /** 710 * True if currently executed code is a part of a task. 711 * @type {boolean} 712 */ 713 var isInTask = false; 714 715 /** 716 * Starts the first queued task. 717 */ 718 function startFirst() { 719 verify(queue.length >= 1, 'startFirst: queue is empty'); 720 verify(!isInTask, 'startFirst: already in task'); 721 isInTask = true; 722 723 // Start the oldest queued task, but don't remove it from the queue. 724 verify( 725 taskPendingCallbackCount == 0, 726 'tasks.startFirst: still have pending task callbacks: ' + 727 taskPendingCallbackCount + 728 ', queue = ' + JSON.stringify(queue) + ', ' + 729 wrapper.debugGetStateString()); 730 var entry = queue[0]; 731 console.log('Starting task ' + entry.name); 732 733 entry.task(); 734 735 verify(isInTask, 'startFirst: not in task at exit'); 736 isInTask = false; 737 if (taskPendingCallbackCount == 0) 738 finish(); 739 } 740 741 /** 742 * Checks if a new task can be added to the task queue. 743 * @param {string} taskName Name of the new task. 744 * @return {boolean} Whether the new task can be added. 745 */ 746 function canQueue(taskName) { 747 for (var i = 0; i < queue.length; ++i) { 748 if (areConflicting(taskName, queue[i].name)) { 749 console.log('Conflict: new=' + taskName + 750 ', scheduled=' + queue[i].name); 751 return false; 752 } 753 } 754 755 return true; 756 } 757 758 /** 759 * Adds a new task. If another task is not running, runs the task immediately. 760 * If any task in the queue is not compatible with the task, ignores the new 761 * task. Otherwise, stores the task for future execution. 762 * @param {string} taskName Name of the task. 763 * @param {function()} task Function to run. 764 */ 765 function add(taskName, task) { 766 wrapper.checkInWrappedCallback(); 767 console.log('Adding task ' + taskName); 768 if (!canQueue(taskName)) 769 return; 770 771 queue.push({name: taskName, task: task}); 772 773 if (queue.length == 1) { 774 startFirst(); 775 } 776 } 777 778 /** 779 * Completes the current task and starts the next queued task if available. 780 */ 781 function finish() { 782 verify(queue.length >= 1, 783 'tasks.finish: The task queue is empty'); 784 console.log('Finishing task ' + queue[0].name); 785 queue.shift(); 786 787 if (queue.length >= 1) 788 startFirst(); 789 } 790 791 instrumented.runtime.onSuspend.addListener(function() { 792 verify( 793 queue.length == 0, 794 'Incomplete task when unloading event page,' + 795 ' queue = ' + JSON.stringify(queue) + ', ' + 796 wrapper.debugGetStateString()); 797 }); 798 799 800 /** 801 * Wrapper plugin for tasks. 802 * @constructor 803 */ 804 function TasksWrapperPlugin() { 805 this.isTaskCallback = isInTask; 806 if (this.isTaskCallback) 807 ++taskPendingCallbackCount; 808 } 809 810 TasksWrapperPlugin.prototype = { 811 /** 812 * Plugin code to be executed before invoking the original callback. 813 */ 814 prologue: function() { 815 if (this.isTaskCallback) { 816 verify(!isInTask, 'TasksWrapperPlugin.prologue: already in task'); 817 isInTask = true; 818 } 819 }, 820 821 /** 822 * Plugin code to be executed after invoking the original callback. 823 */ 824 epilogue: function() { 825 if (this.isTaskCallback) { 826 verify(isInTask, 'TasksWrapperPlugin.epilogue: not in task at exit'); 827 isInTask = false; 828 if (--taskPendingCallbackCount == 0) 829 finish(); 830 } 831 } 832 }; 833 834 wrapper.registerWrapperPluginFactory(function() { 835 return new TasksWrapperPlugin(); 836 }); 837 838 return { 839 add: add 840 }; 841 } 842 843 /** 844 * Builds an object to manage retrying activities with exponential backoff. 845 * @param {string} name Name of this attempt manager. 846 * @param {function()} attempt Activity that the manager retries until it 847 * calls 'stop' method. 848 * @param {number} initialDelaySeconds Default first delay until first retry. 849 * @param {number} maximumDelaySeconds Maximum delay between retries. 850 * @return {Object} Attempt manager interface. 851 */ 852 function buildAttemptManager( 853 name, attempt, initialDelaySeconds, maximumDelaySeconds) { 854 var alarmName = 'attempt-scheduler-' + name; 855 var currentDelayStorageKey = 'current-delay-' + name; 856 857 /** 858 * Creates an alarm for the next attempt. The alarm is repeating for the case 859 * when the next attempt crashes before registering next alarm. 860 * @param {number} delaySeconds Delay until next retry. 861 */ 862 function createAlarm(delaySeconds) { 863 var alarmInfo = { 864 delayInMinutes: delaySeconds / 60, 865 periodInMinutes: maximumDelaySeconds / 60 866 }; 867 chrome.alarms.create(alarmName, alarmInfo); 868 } 869 870 /** 871 * Indicates if this attempt manager has started. 872 * @param {function(boolean)} callback The function's boolean parameter is 873 * true if the attempt manager has started, false otherwise. 874 */ 875 function isRunning(callback) { 876 instrumented.alarms.get(alarmName, function(alarmInfo) { 877 callback(!!alarmInfo); 878 }); 879 } 880 881 /** 882 * Schedules the alarm with a random factor to reduce the chance that all 883 * clients will fire their timers at the same time. 884 * @param {number} durationSeconds Number of seconds before firing the alarm. 885 */ 886 function scheduleAlarm(durationSeconds) { 887 var randomizedRetryDuration = 888 Math.min(durationSeconds * (1 + 0.2 * Math.random()), 889 maximumDelaySeconds); 890 891 createAlarm(randomizedRetryDuration); 892 893 var items = {}; 894 items[currentDelayStorageKey] = randomizedRetryDuration; 895 chrome.storage.local.set(items); 896 } 897 898 /** 899 * Starts repeated attempts. 900 * @param {number=} opt_firstDelaySeconds Time until the first attempt, if 901 * specified. Otherwise, initialDelaySeconds will be used for the first 902 * attempt. 903 */ 904 function start(opt_firstDelaySeconds) { 905 if (opt_firstDelaySeconds) { 906 createAlarm(opt_firstDelaySeconds); 907 chrome.storage.local.remove(currentDelayStorageKey); 908 } else { 909 scheduleAlarm(initialDelaySeconds); 910 } 911 } 912 913 /** 914 * Stops repeated attempts. 915 */ 916 function stop() { 917 chrome.alarms.clear(alarmName); 918 chrome.storage.local.remove(currentDelayStorageKey); 919 } 920 921 /** 922 * Schedules an exponential backoff retry. 923 * @return {Promise} A promise to schedule the retry. 924 */ 925 function scheduleRetry() { 926 var request = {}; 927 request[currentDelayStorageKey] = undefined; 928 return fillFromChromeLocalStorage(request, PromiseRejection.ALLOW) 929 .catch(function() { 930 request[currentDelayStorageKey] = maximumDelaySeconds; 931 return Promise.resolve(request); 932 }) 933 .then(function(items) { 934 console.log('scheduleRetry-get-storage ' + JSON.stringify(items)); 935 var retrySeconds = initialDelaySeconds; 936 if (items[currentDelayStorageKey]) { 937 retrySeconds = items[currentDelayStorageKey] * 2; 938 } 939 scheduleAlarm(retrySeconds); 940 }); 941 } 942 943 instrumented.alarms.onAlarm.addListener(function(alarm) { 944 if (alarm.name == alarmName) 945 isRunning(function(running) { 946 if (running) 947 attempt(); 948 }); 949 }); 950 951 return { 952 start: start, 953 scheduleRetry: scheduleRetry, 954 stop: stop, 955 isRunning: isRunning 956 }; 957 } 958 959 // TODO(robliao): Use signed-in state change watch API when it's available. 960 /** 961 * Wraps chrome.identity to provide limited listening support for 962 * the sign in state by polling periodically for the auth token. 963 * @return {Object} The Authentication Manager interface. 964 */ 965 function buildAuthenticationManager() { 966 var alarmName = 'sign-in-alarm'; 967 968 /** 969 * Gets an OAuth2 access token. 970 * @return {Promise} A promise to get the authentication token. If there is 971 * no token, the request is rejected. 972 */ 973 function getAuthToken() { 974 return new Promise(function(resolve, reject) { 975 instrumented.identity.getAuthToken({interactive: false}, function(token) { 976 if (chrome.runtime.lastError || !token) { 977 reject(); 978 } else { 979 resolve(token); 980 } 981 }); 982 }); 983 } 984 985 /** 986 * Determines whether there is an account attached to the profile. 987 * @return {Promise} A promise to determine if there is an account attached 988 * to the profile. 989 */ 990 function isSignedIn() { 991 return new Promise(function(resolve) { 992 instrumented.webstorePrivate.getBrowserLogin(function(accountInfo) { 993 resolve(!!accountInfo.login); 994 }); 995 }); 996 } 997 998 /** 999 * Removes the specified cached token. 1000 * @param {string} token Authentication Token to remove from the cache. 1001 * @return {Promise} A promise that resolves on completion. 1002 */ 1003 function removeToken(token) { 1004 return new Promise(function(resolve) { 1005 instrumented.identity.removeCachedAuthToken({token: token}, function() { 1006 // Let Chrome know about a possible problem with the token. 1007 getAuthToken(); 1008 resolve(); 1009 }); 1010 }); 1011 } 1012 1013 var listeners = []; 1014 1015 /** 1016 * Registers a listener that gets called back when the signed in state 1017 * is found to be changed. 1018 * @param {function()} callback Called when the answer to isSignedIn changes. 1019 */ 1020 function addListener(callback) { 1021 listeners.push(callback); 1022 } 1023 1024 /** 1025 * Checks if the last signed in state matches the current one. 1026 * If it doesn't, it notifies the listeners of the change. 1027 */ 1028 function checkAndNotifyListeners() { 1029 isSignedIn().then(function(signedIn) { 1030 fillFromChromeLocalStorage({lastSignedInState: undefined}) 1031 .then(function(items) { 1032 if (items.lastSignedInState != signedIn) { 1033 chrome.storage.local.set( 1034 {lastSignedInState: signedIn}); 1035 listeners.forEach(function(callback) { 1036 callback(); 1037 }); 1038 } 1039 }); 1040 }); 1041 } 1042 1043 instrumented.identity.onSignInChanged.addListener(function() { 1044 checkAndNotifyListeners(); 1045 }); 1046 1047 instrumented.alarms.onAlarm.addListener(function(alarm) { 1048 if (alarm.name == alarmName) 1049 checkAndNotifyListeners(); 1050 }); 1051 1052 // Poll for the sign in state every hour. 1053 // One hour is just an arbitrary amount of time chosen. 1054 chrome.alarms.create(alarmName, {periodInMinutes: 60}); 1055 1056 return { 1057 addListener: addListener, 1058 getAuthToken: getAuthToken, 1059 isSignedIn: isSignedIn, 1060 removeToken: removeToken 1061 }; 1062 } 1063