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.
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 location change 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.
25 // TODO(vadimt): Use server name in the manifest.
28 * Notification server URL.
30 var NOTIFICATION_CARDS_URL = 'https://www.googleapis.com/chromenow/v1';
32 var DEBUG_MODE = localStorage['debug_mode'];
35 * Initializes for debug or release modes of operation.
37 function initializeDebug() {
39 NOTIFICATION_CARDS_URL =
40 localStorage['server_url'] || NOTIFICATION_CARDS_URL;
47 * Location Card Storage.
49 if (localStorage['locationCardsShown'] === undefined)
50 localStorage['locationCardsShown'] = 0;
53 * Builds an error object with a message that may be sent to the server.
54 * @param {string} message Error message. This message may be sent to the
56 * @return {Error} Error object.
58 function buildErrorWithMessageForServer(message) {
59 var error = new Error(message);
60 error.canSendMessageToServer = true;
65 * Checks for internal errors.
66 * @param {boolean} condition Condition that must be true.
67 * @param {string} message Diagnostic message for the case when the condition is
70 function verify(condition, message) {
72 throw buildErrorWithMessageForServer('ASSERT: ' + message);
76 * Builds a request to the notification server.
77 * @param {string} method Request method.
78 * @param {string} handlerName Server handler to send the request to.
79 * @param {string=} contentType Value for the Content-type header.
80 * @return {XMLHttpRequest} Server request.
82 function buildServerRequest(method, handlerName, contentType) {
83 var request = new XMLHttpRequest();
85 request.responseType = 'text';
86 request.open(method, NOTIFICATION_CARDS_URL + '/' + handlerName, true);
88 request.setRequestHeader('Content-type', contentType);
94 * Sends an error report to the server.
95 * @param {Error} error Error to send.
97 function sendErrorReport(error) {
98 // Don't remove 'error.stack.replace' below!
99 var filteredStack = error.canSendMessageToServer ?
100 error.stack : error.stack.replace(/.*\n/, '(message removed)\n');
103 var topFrameLineMatch = filteredStack.match(/\n at .*\n/);
104 var topFrame = topFrameLineMatch && topFrameLineMatch[0];
106 // Examples of a frame:
107 // 1. '\n at someFunction (chrome-extension://
108 // pafkbggdmjlpgkdkcbjmhmfcdpncadgh/background.js:915:15)\n'
109 // 2. '\n at chrome-extension://pafkbggdmjlpgkdkcbjmhmfcdpncadgh/
110 // utility.js:269:18\n'
111 // 3. '\n at Function.target.(anonymous function) (extensions::
112 // SafeBuiltins:19:14)\n'
113 // 4. '\n at Event.dispatchToListener (event_bindings:382:22)\n'
115 // Find the the parentheses at the end of the line, if any.
116 var parenthesesMatch = topFrame.match(/\(.*\)\n/);
117 if (parenthesesMatch && parenthesesMatch[0]) {
119 parenthesesMatch[0].substring(1, parenthesesMatch[0].length - 2);
121 errorLocation = topFrame;
124 var topFrameElements = errorLocation.split(':');
125 // topFrameElements is an array that ends like:
126 // [N-3] //pafkbggdmjlpgkdkcbjmhmfcdpncadgh/utility.js
129 if (topFrameElements.length >= 3) {
130 file = topFrameElements[topFrameElements.length - 3];
131 line = topFrameElements[topFrameElements.length - 2];
135 var errorText = error.name;
136 if (error.canSendMessageToServer)
137 errorText = errorText + ': ' + error.message;
146 var request = buildServerRequest('POST', 'jserrors', 'application/json');
147 request.onloadend = function(event) {
148 console.log('sendErrorReport status: ' + request.status);
151 chrome.identity.getAuthToken({interactive: false}, function(token) {
153 request.setRequestHeader('Authorization', 'Bearer ' + token);
154 request.send(JSON.stringify(errorObject));
159 // Limiting 1 error report per background page load.
160 var errorReported = false;
163 * Reports an error to the server and the user, as appropriate.
164 * @param {Error} error Error to report.
166 function reportError(error) {
167 var message = 'Critical error:\n' + error.stack;
168 console.error(message);
169 if (!errorReported) {
170 errorReported = true;
171 chrome.metricsPrivate.getIsCrashReportingEnabled(function(isEnabled) {
173 sendErrorReport(error);
180 // Partial mirror of chrome.* for all instrumented functions.
181 var instrumented = {};
184 * Wrapper plugin. These plugins extend instrumentation added by
185 * wrapper.wrapCallback by adding code that executes before and after the call
186 * to the original callback provided by the extension.
189 * prologue: function (),
190 * epilogue: function ()
196 * Wrapper for callbacks. Used to add error handling and other services to
197 * callbacks for HTML and Chrome functions and events.
199 var wrapper = (function() {
201 * Factory for wrapper plugins. If specified, it's used to generate an
202 * instance of WrapperPlugin each time we wrap a callback (which corresponds
203 * to addListener call for Chrome events, and to every API call that specifies
204 * a callback). WrapperPlugin's lifetime ends when the callback for which it
205 * was generated, exits. It's possible to have several instances of
206 * WrapperPlugin at the same time.
207 * An instance of WrapperPlugin can have state that can be shared by its
208 * constructor, prologue() and epilogue(). Also WrapperPlugins can change
209 * state of other objects, for example, to do refcounting.
210 * @type {?function(): WrapperPlugin}
212 var wrapperPluginFactory = null;
215 * Registers a wrapper plugin factory.
216 * @param {function(): WrapperPlugin} factory Wrapper plugin factory.
218 function registerWrapperPluginFactory(factory) {
219 if (wrapperPluginFactory) {
220 reportError(buildErrorWithMessageForServer(
221 'registerWrapperPluginFactory: factory is already registered.'));
224 wrapperPluginFactory = factory;
228 * True if currently executed code runs in a callback or event handler that
229 * was instrumented by wrapper.wrapCallback() call.
232 var isInWrappedCallback = false;
235 * Required callbacks that are not yet called. Includes both task and non-task
236 * callbacks. This is a map from unique callback id to the stack at the moment
237 * when the callback was wrapped. This stack identifies the callback.
238 * Used only for diagnostics.
239 * @type {Object.<number, string>}
241 var pendingCallbacks = {};
244 * Unique ID of the next callback.
247 var nextCallbackId = 0;
250 * Gets diagnostic string with the status of the wrapper.
251 * @return {string} Diagnostic string.
253 function debugGetStateString() {
254 return 'pendingCallbacks @' + Date.now() + ' = ' +
255 JSON.stringify(pendingCallbacks);
259 * Checks that we run in a wrapped callback.
261 function checkInWrappedCallback() {
262 if (!isInWrappedCallback) {
263 reportError(buildErrorWithMessageForServer(
264 'Not in instrumented callback'));
269 * Adds error processing to an API callback.
270 * @param {Function} callback Callback to instrument.
271 * @param {boolean=} opt_isEventListener True if the callback is a listener to
272 * a Chrome API event.
273 * @return {Function} Instrumented callback.
275 function wrapCallback(callback, opt_isEventListener) {
276 var callbackId = nextCallbackId++;
278 if (!opt_isEventListener) {
279 checkInWrappedCallback();
280 pendingCallbacks[callbackId] = new Error().stack + ' @' + Date.now();
283 // wrapperPluginFactory may be null before task manager is built, and in
285 var wrapperPluginInstance = wrapperPluginFactory && wrapperPluginFactory();
288 // This is the wrapper for the callback.
290 verify(!isInWrappedCallback, 'Re-entering instrumented callback');
291 isInWrappedCallback = true;
293 if (!opt_isEventListener)
294 delete pendingCallbacks[callbackId];
296 if (wrapperPluginInstance)
297 wrapperPluginInstance.prologue();
299 // Call the original callback.
300 callback.apply(null, arguments);
302 if (wrapperPluginInstance)
303 wrapperPluginInstance.epilogue();
305 verify(isInWrappedCallback,
306 'Instrumented callback is not instrumented upon exit');
307 isInWrappedCallback = false;
315 * Returns an instrumented function.
316 * @param {!Array.<string>} functionIdentifierParts Path to the chrome.*
318 * @param {string} functionName Name of the chrome API function.
319 * @param {number} callbackParameter Index of the callback parameter to this
321 * @return {Function} An instrumented function.
323 function createInstrumentedFunction(
324 functionIdentifierParts,
328 // This is the wrapper for the API function. Pass the wrapped callback to
329 // the original function.
330 var callback = arguments[callbackParameter];
331 if (typeof callback != 'function') {
332 reportError(buildErrorWithMessageForServer(
333 'Argument ' + callbackParameter + ' of ' +
334 functionIdentifierParts.join('.') + '.' + functionName +
335 ' is not a function'));
337 arguments[callbackParameter] = wrapCallback(
338 callback, functionName == 'addListener');
340 var chromeContainer = chrome;
341 functionIdentifierParts.forEach(function(fragment) {
342 chromeContainer = chromeContainer[fragment];
344 return chromeContainer[functionName].
345 apply(chromeContainer, arguments);
350 * Instruments an API function to add error processing to its user
351 * code-provided callback.
352 * @param {string} functionIdentifier Full identifier of the function without
353 * the 'chrome.' portion.
354 * @param {number} callbackParameter Index of the callback parameter to this
357 function instrumentChromeApiFunction(functionIdentifier, callbackParameter) {
358 var functionIdentifierParts = functionIdentifier.split('.');
359 var functionName = functionIdentifierParts.pop();
360 var chromeContainer = chrome;
361 var instrumentedContainer = instrumented;
362 functionIdentifierParts.forEach(function(fragment) {
363 chromeContainer = chromeContainer[fragment];
364 if (!chromeContainer) {
365 reportError(buildErrorWithMessageForServer(
366 'Cannot instrument ' + functionIdentifier));
369 if (!(fragment in instrumentedContainer))
370 instrumentedContainer[fragment] = {};
372 instrumentedContainer = instrumentedContainer[fragment];
375 var targetFunction = chromeContainer[functionName];
376 if (!targetFunction) {
377 reportError(buildErrorWithMessageForServer(
378 'Cannot instrument ' + functionIdentifier));
381 instrumentedContainer[functionName] = createInstrumentedFunction(
382 functionIdentifierParts,
387 instrumentChromeApiFunction('runtime.onSuspend.addListener', 0);
389 instrumented.runtime.onSuspend.addListener(function() {
390 var stringifiedPendingCallbacks = JSON.stringify(pendingCallbacks);
392 stringifiedPendingCallbacks == '{}',
393 'Pending callbacks when unloading event page @' + Date.now() + ':' +
394 stringifiedPendingCallbacks);
398 wrapCallback: wrapCallback,
399 instrumentChromeApiFunction: instrumentChromeApiFunction,
400 registerWrapperPluginFactory: registerWrapperPluginFactory,
401 checkInWrappedCallback: checkInWrappedCallback,
402 debugGetStateString: debugGetStateString
406 wrapper.instrumentChromeApiFunction('alarms.get', 1);
407 wrapper.instrumentChromeApiFunction('alarms.onAlarm.addListener', 0);
408 wrapper.instrumentChromeApiFunction('identity.getAuthToken', 1);
409 wrapper.instrumentChromeApiFunction('identity.onSignInChanged.addListener', 0);
410 wrapper.instrumentChromeApiFunction('identity.removeCachedAuthToken', 1);
411 wrapper.instrumentChromeApiFunction('webstorePrivate.getBrowserLogin', 0);
414 * Builds the object to manage tasks (mutually exclusive chains of events).
415 * @param {function(string, string): boolean} areConflicting Function that
416 * checks if a new task can't be added to a task queue that contains an
418 * @return {Object} Task manager interface.
420 function buildTaskManager(areConflicting) {
422 * Queue of scheduled tasks. The first element, if present, corresponds to the
423 * currently running task.
424 * @type {Array.<Object.<string, function()>>}
429 * Count of unfinished callbacks of the current task.
432 var taskPendingCallbackCount = 0;
435 * True if currently executed code is a part of a task.
438 var isInTask = false;
441 * Starts the first queued task.
443 function startFirst() {
444 verify(queue.length >= 1, 'startFirst: queue is empty');
445 verify(!isInTask, 'startFirst: already in task');
448 // Start the oldest queued task, but don't remove it from the queue.
450 taskPendingCallbackCount == 0,
451 'tasks.startFirst: still have pending task callbacks: ' +
452 taskPendingCallbackCount +
453 ', queue = ' + JSON.stringify(queue) + ', ' +
454 wrapper.debugGetStateString());
455 var entry = queue[0];
456 console.log('Starting task ' + entry.name);
460 verify(isInTask, 'startFirst: not in task at exit');
462 if (taskPendingCallbackCount == 0)
467 * Checks if a new task can be added to the task queue.
468 * @param {string} taskName Name of the new task.
469 * @return {boolean} Whether the new task can be added.
471 function canQueue(taskName) {
472 for (var i = 0; i < queue.length; ++i) {
473 if (areConflicting(taskName, queue[i].name)) {
474 console.log('Conflict: new=' + taskName +
475 ', scheduled=' + queue[i].name);
484 * Adds a new task. If another task is not running, runs the task immediately.
485 * If any task in the queue is not compatible with the task, ignores the new
486 * task. Otherwise, stores the task for future execution.
487 * @param {string} taskName Name of the task.
488 * @param {function()} task Function to run.
490 function add(taskName, task) {
491 wrapper.checkInWrappedCallback();
492 console.log('Adding task ' + taskName);
493 if (!canQueue(taskName))
496 queue.push({name: taskName, task: task});
498 if (queue.length == 1) {
504 * Completes the current task and starts the next queued task if available.
507 verify(queue.length >= 1,
508 'tasks.finish: The task queue is empty');
509 console.log('Finishing task ' + queue[0].name);
512 if (queue.length >= 1)
516 instrumented.runtime.onSuspend.addListener(function() {
519 'Incomplete task when unloading event page,' +
520 ' queue = ' + JSON.stringify(queue) + ', ' +
521 wrapper.debugGetStateString());
526 * Wrapper plugin for tasks.
529 function TasksWrapperPlugin() {
530 this.isTaskCallback = isInTask;
531 if (this.isTaskCallback)
532 ++taskPendingCallbackCount;
535 TasksWrapperPlugin.prototype = {
537 * Plugin code to be executed before invoking the original callback.
539 prologue: function() {
540 if (this.isTaskCallback) {
541 verify(!isInTask, 'TasksWrapperPlugin.prologue: already in task');
547 * Plugin code to be executed after invoking the original callback.
549 epilogue: function() {
550 if (this.isTaskCallback) {
551 verify(isInTask, 'TasksWrapperPlugin.epilogue: not in task at exit');
553 if (--taskPendingCallbackCount == 0)
559 wrapper.registerWrapperPluginFactory(function() {
560 return new TasksWrapperPlugin();
569 * Builds an object to manage retrying activities with exponential backoff.
570 * @param {string} name Name of this attempt manager.
571 * @param {function()} attempt Activity that the manager retries until it
572 * calls 'stop' method.
573 * @param {number} initialDelaySeconds Default first delay until first retry.
574 * @param {number} maximumDelaySeconds Maximum delay between retries.
575 * @return {Object} Attempt manager interface.
577 function buildAttemptManager(
578 name, attempt, initialDelaySeconds, maximumDelaySeconds) {
579 var alarmName = 'attempt-scheduler-' + name;
580 var currentDelayStorageKey = 'current-delay-' + name;
583 * Creates an alarm for the next attempt. The alarm is repeating for the case
584 * when the next attempt crashes before registering next alarm.
585 * @param {number} delaySeconds Delay until next retry.
587 function createAlarm(delaySeconds) {
589 delayInMinutes: delaySeconds / 60,
590 periodInMinutes: maximumDelaySeconds / 60
592 chrome.alarms.create(alarmName, alarmInfo);
596 * Indicates if this attempt manager has started.
597 * @param {function(boolean)} callback The function's boolean parameter is
598 * true if the attempt manager has started, false otherwise.
600 function isRunning(callback) {
601 instrumented.alarms.get(alarmName, function(alarmInfo) {
602 callback(!!alarmInfo);
607 * Schedules next attempt.
608 * @param {number=} opt_previousDelaySeconds Previous delay in a sequence of
609 * retry attempts, if specified. Not specified for scheduling first retry
610 * in the exponential sequence.
612 function scheduleNextAttempt(opt_previousDelaySeconds) {
613 var base = opt_previousDelaySeconds ? opt_previousDelaySeconds * 2 :
615 var newRetryDelaySeconds =
616 Math.min(base * (1 + 0.2 * Math.random()), maximumDelaySeconds);
618 createAlarm(newRetryDelaySeconds);
621 items[currentDelayStorageKey] = newRetryDelaySeconds;
622 chrome.storage.local.set(items);
626 * Starts repeated attempts.
627 * @param {number=} opt_firstDelaySeconds Time until the first attempt, if
628 * specified. Otherwise, initialDelaySeconds will be used for the first
631 function start(opt_firstDelaySeconds) {
632 if (opt_firstDelaySeconds) {
633 createAlarm(opt_firstDelaySeconds);
634 chrome.storage.local.remove(currentDelayStorageKey);
636 scheduleNextAttempt();
641 * Stops repeated attempts.
644 chrome.alarms.clear(alarmName);
645 chrome.storage.local.remove(currentDelayStorageKey);
649 * Plans for the next attempt.
650 * @param {function()} callback Completion callback. It will be invoked after
651 * the planning is done.
653 function planForNext(callback) {
654 instrumented.storage.local.get(currentDelayStorageKey, function(items) {
657 items[currentDelayStorageKey] = maximumDelaySeconds;
659 console.log('planForNext-get-storage ' + JSON.stringify(items));
660 scheduleNextAttempt(items[currentDelayStorageKey]);
665 instrumented.alarms.onAlarm.addListener(function(alarm) {
666 if (alarm.name == alarmName)
667 isRunning(function(running) {
675 planForNext: planForNext,
681 // TODO(robliao): Use signed-in state change watch API when it's available.
683 * Wraps chrome.identity to provide limited listening support for
684 * the sign in state by polling periodically for the auth token.
685 * @return {Object} The Authentication Manager interface.
687 function buildAuthenticationManager() {
688 var alarmName = 'sign-in-alarm';
691 * Gets an OAuth2 access token.
692 * @param {function(string=)} callback Called on completion.
693 * The string contains the token. It's undefined if there was an error.
695 function getAuthToken(callback) {
696 instrumented.identity.getAuthToken({interactive: false}, function(token) {
697 token = chrome.runtime.lastError ? undefined : token;
703 * Determines whether there is an account attached to the profile.
704 * @param {function(boolean)} callback Called on completion.
706 function isSignedIn(callback) {
707 instrumented.webstorePrivate.getBrowserLogin(function(accountInfo) {
708 callback(!!accountInfo.login);
713 * Removes the specified cached token.
714 * @param {string} token Authentication Token to remove from the cache.
715 * @param {function()} callback Called on completion.
717 function removeToken(token, callback) {
718 instrumented.identity.removeCachedAuthToken({token: token}, function() {
719 // Let Chrome now about a possible problem with the token.
720 getAuthToken(function() {});
728 * Registers a listener that gets called back when the signed in state
729 * is found to be changed.
730 * @param {function()} callback Called when the answer to isSignedIn changes.
732 function addListener(callback) {
733 listeners.push(callback);
737 * Checks if the last signed in state matches the current one.
738 * If it doesn't, it notifies the listeners of the change.
740 function checkAndNotifyListeners() {
741 isSignedIn(function(signedIn) {
742 instrumented.storage.local.get('lastSignedInState', function(items) {
744 if (items.lastSignedInState != signedIn) {
745 chrome.storage.local.set(
746 {lastSignedInState: signedIn});
747 listeners.forEach(function(callback) {
755 instrumented.identity.onSignInChanged.addListener(function() {
756 checkAndNotifyListeners();
759 instrumented.alarms.onAlarm.addListener(function(alarm) {
760 if (alarm.name == alarmName)
761 checkAndNotifyListeners();
764 // Poll for the sign in state every hour.
765 // One hour is just an arbitrary amount of time chosen.
766 chrome.alarms.create(alarmName, {periodInMinutes: 60});
769 addListener: addListener,
770 getAuthToken: getAuthToken,
771 isSignedIn: isSignedIn,
772 removeToken: removeToken