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 = ' + JSON.stringify(pendingCallbacks);
258 * Checks that we run in a wrapped callback.
260 function checkInWrappedCallback() {
261 if (!isInWrappedCallback) {
262 reportError(buildErrorWithMessageForServer(
263 'Not in instrumented callback'));
268 * Adds error processing to an API callback.
269 * @param {Function} callback Callback to instrument.
270 * @param {boolean=} opt_isEventListener True if the callback is a listener to
271 * a Chrome API event.
272 * @return {Function} Instrumented callback.
274 function wrapCallback(callback, opt_isEventListener) {
275 var callbackId = nextCallbackId++;
277 if (!opt_isEventListener) {
278 checkInWrappedCallback();
279 pendingCallbacks[callbackId] = new Error().stack;
282 // wrapperPluginFactory may be null before task manager is built, and in
284 var wrapperPluginInstance = wrapperPluginFactory && wrapperPluginFactory();
287 // This is the wrapper for the callback.
289 verify(!isInWrappedCallback, 'Re-entering instrumented callback');
290 isInWrappedCallback = true;
292 if (!opt_isEventListener)
293 delete pendingCallbacks[callbackId];
295 if (wrapperPluginInstance)
296 wrapperPluginInstance.prologue();
298 // Call the original callback.
299 callback.apply(null, arguments);
301 if (wrapperPluginInstance)
302 wrapperPluginInstance.epilogue();
304 verify(isInWrappedCallback,
305 'Instrumented callback is not instrumented upon exit');
306 isInWrappedCallback = false;
314 * Returns an instrumented function.
315 * @param {!Array.<string>} functionIdentifierParts Path to the chrome.*
317 * @param {string} functionName Name of the chrome API function.
318 * @param {number} callbackParameter Index of the callback parameter to this
320 * @return {Function} An instrumented function.
322 function createInstrumentedFunction(
323 functionIdentifierParts,
327 // This is the wrapper for the API function. Pass the wrapped callback to
328 // the original function.
329 var callback = arguments[callbackParameter];
330 if (typeof callback != 'function') {
331 reportError(buildErrorWithMessageForServer(
332 'Argument ' + callbackParameter + ' of ' +
333 functionIdentifierParts.join('.') + '.' + functionName +
334 ' is not a function'));
336 arguments[callbackParameter] = wrapCallback(
337 callback, functionName == 'addListener');
339 var chromeContainer = chrome;
340 functionIdentifierParts.forEach(function(fragment) {
341 chromeContainer = chromeContainer[fragment];
343 return chromeContainer[functionName].
344 apply(chromeContainer, arguments);
349 * Instruments an API function to add error processing to its user
350 * code-provided callback.
351 * @param {string} functionIdentifier Full identifier of the function without
352 * the 'chrome.' portion.
353 * @param {number} callbackParameter Index of the callback parameter to this
356 function instrumentChromeApiFunction(functionIdentifier, callbackParameter) {
357 var functionIdentifierParts = functionIdentifier.split('.');
358 var functionName = functionIdentifierParts.pop();
359 var chromeContainer = chrome;
360 var instrumentedContainer = instrumented;
361 functionIdentifierParts.forEach(function(fragment) {
362 chromeContainer = chromeContainer[fragment];
363 if (!chromeContainer) {
364 reportError(buildErrorWithMessageForServer(
365 'Cannot instrument ' + functionIdentifier));
368 if (!(fragment in instrumentedContainer))
369 instrumentedContainer[fragment] = {};
371 instrumentedContainer = instrumentedContainer[fragment];
374 var targetFunction = chromeContainer[functionName];
375 if (!targetFunction) {
376 reportError(buildErrorWithMessageForServer(
377 'Cannot instrument ' + functionIdentifier));
380 instrumentedContainer[functionName] = createInstrumentedFunction(
381 functionIdentifierParts,
386 instrumentChromeApiFunction('runtime.onSuspend.addListener', 0);
388 instrumented.runtime.onSuspend.addListener(function() {
389 var stringifiedPendingCallbacks = JSON.stringify(pendingCallbacks);
391 stringifiedPendingCallbacks == '{}',
392 'Pending callbacks when unloading event page:' +
393 stringifiedPendingCallbacks);
397 wrapCallback: wrapCallback,
398 instrumentChromeApiFunction: instrumentChromeApiFunction,
399 registerWrapperPluginFactory: registerWrapperPluginFactory,
400 checkInWrappedCallback: checkInWrappedCallback,
401 debugGetStateString: debugGetStateString
405 wrapper.instrumentChromeApiFunction('alarms.get', 1);
406 wrapper.instrumentChromeApiFunction('alarms.onAlarm.addListener', 0);
407 wrapper.instrumentChromeApiFunction('identity.getAuthToken', 1);
408 wrapper.instrumentChromeApiFunction('identity.onSignInChanged.addListener', 0);
409 wrapper.instrumentChromeApiFunction('identity.removeCachedAuthToken', 1);
410 wrapper.instrumentChromeApiFunction('webstorePrivate.getBrowserLogin', 0);
413 * Builds the object to manage tasks (mutually exclusive chains of events).
414 * @param {function(string, string): boolean} areConflicting Function that
415 * checks if a new task can't be added to a task queue that contains an
417 * @return {Object} Task manager interface.
419 function buildTaskManager(areConflicting) {
421 * Queue of scheduled tasks. The first element, if present, corresponds to the
422 * currently running task.
423 * @type {Array.<Object.<string, function()>>}
428 * Count of unfinished callbacks of the current task.
431 var taskPendingCallbackCount = 0;
434 * True if currently executed code is a part of a task.
437 var isInTask = false;
440 * Starts the first queued task.
442 function startFirst() {
443 verify(queue.length >= 1, 'startFirst: queue is empty');
444 verify(!isInTask, 'startFirst: already in task');
447 // Start the oldest queued task, but don't remove it from the queue.
449 taskPendingCallbackCount == 0,
450 'tasks.startFirst: still have pending task callbacks: ' +
451 taskPendingCallbackCount +
452 ', queue = ' + JSON.stringify(queue) + ', ' +
453 wrapper.debugGetStateString());
454 var entry = queue[0];
455 console.log('Starting task ' + entry.name);
459 verify(isInTask, 'startFirst: not in task at exit');
461 if (taskPendingCallbackCount == 0)
466 * Checks if a new task can be added to the task queue.
467 * @param {string} taskName Name of the new task.
468 * @return {boolean} Whether the new task can be added.
470 function canQueue(taskName) {
471 for (var i = 0; i < queue.length; ++i) {
472 if (areConflicting(taskName, queue[i].name)) {
473 console.log('Conflict: new=' + taskName +
474 ', scheduled=' + queue[i].name);
483 * Adds a new task. If another task is not running, runs the task immediately.
484 * If any task in the queue is not compatible with the task, ignores the new
485 * task. Otherwise, stores the task for future execution.
486 * @param {string} taskName Name of the task.
487 * @param {function()} task Function to run.
489 function add(taskName, task) {
490 wrapper.checkInWrappedCallback();
491 console.log('Adding task ' + taskName);
492 if (!canQueue(taskName))
495 queue.push({name: taskName, task: task});
497 if (queue.length == 1) {
503 * Completes the current task and starts the next queued task if available.
506 verify(queue.length >= 1,
507 'tasks.finish: The task queue is empty');
508 console.log('Finishing task ' + queue[0].name);
511 if (queue.length >= 1)
515 instrumented.runtime.onSuspend.addListener(function() {
518 'Incomplete task when unloading event page,' +
519 ' queue = ' + JSON.stringify(queue) + ', ' +
520 wrapper.debugGetStateString());
525 * Wrapper plugin for tasks.
528 function TasksWrapperPlugin() {
529 this.isTaskCallback = isInTask;
530 if (this.isTaskCallback)
531 ++taskPendingCallbackCount;
534 TasksWrapperPlugin.prototype = {
536 * Plugin code to be executed before invoking the original callback.
538 prologue: function() {
539 if (this.isTaskCallback) {
540 verify(!isInTask, 'TasksWrapperPlugin.prologue: already in task');
546 * Plugin code to be executed after invoking the original callback.
548 epilogue: function() {
549 if (this.isTaskCallback) {
550 verify(isInTask, 'TasksWrapperPlugin.epilogue: not in task at exit');
552 if (--taskPendingCallbackCount == 0)
558 wrapper.registerWrapperPluginFactory(function() {
559 return new TasksWrapperPlugin();
568 * Builds an object to manage retrying activities with exponential backoff.
569 * @param {string} name Name of this attempt manager.
570 * @param {function()} attempt Activity that the manager retries until it
571 * calls 'stop' method.
572 * @param {number} initialDelaySeconds Default first delay until first retry.
573 * @param {number} maximumDelaySeconds Maximum delay between retries.
574 * @return {Object} Attempt manager interface.
576 function buildAttemptManager(
577 name, attempt, initialDelaySeconds, maximumDelaySeconds) {
578 var alarmName = 'attempt-scheduler-' + name;
579 var currentDelayStorageKey = 'current-delay-' + name;
582 * Creates an alarm for the next attempt. The alarm is repeating for the case
583 * when the next attempt crashes before registering next alarm.
584 * @param {number} delaySeconds Delay until next retry.
586 function createAlarm(delaySeconds) {
588 delayInMinutes: delaySeconds / 60,
589 periodInMinutes: maximumDelaySeconds / 60
591 chrome.alarms.create(alarmName, alarmInfo);
595 * Indicates if this attempt manager has started.
596 * @param {function(boolean)} callback The function's boolean parameter is
597 * true if the attempt manager has started, false otherwise.
599 function isRunning(callback) {
600 instrumented.alarms.get(alarmName, function(alarmInfo) {
601 callback(!!alarmInfo);
606 * Schedules next attempt.
607 * @param {number=} opt_previousDelaySeconds Previous delay in a sequence of
608 * retry attempts, if specified. Not specified for scheduling first retry
609 * in the exponential sequence.
611 function scheduleNextAttempt(opt_previousDelaySeconds) {
612 var base = opt_previousDelaySeconds ? opt_previousDelaySeconds * 2 :
614 var newRetryDelaySeconds =
615 Math.min(base * (1 + 0.2 * Math.random()), maximumDelaySeconds);
617 createAlarm(newRetryDelaySeconds);
620 items[currentDelayStorageKey] = newRetryDelaySeconds;
621 chrome.storage.local.set(items);
625 * Starts repeated attempts.
626 * @param {number=} opt_firstDelaySeconds Time until the first attempt, if
627 * specified. Otherwise, initialDelaySeconds will be used for the first
630 function start(opt_firstDelaySeconds) {
631 if (opt_firstDelaySeconds) {
632 createAlarm(opt_firstDelaySeconds);
633 chrome.storage.local.remove(currentDelayStorageKey);
635 scheduleNextAttempt();
640 * Stops repeated attempts.
643 chrome.alarms.clear(alarmName);
644 chrome.storage.local.remove(currentDelayStorageKey);
648 * Plans for the next attempt.
649 * @param {function()} callback Completion callback. It will be invoked after
650 * the planning is done.
652 function planForNext(callback) {
653 instrumented.storage.local.get(currentDelayStorageKey, function(items) {
656 items[currentDelayStorageKey] = maximumDelaySeconds;
658 console.log('planForNext-get-storage ' + JSON.stringify(items));
659 scheduleNextAttempt(items[currentDelayStorageKey]);
664 instrumented.alarms.onAlarm.addListener(function(alarm) {
665 if (alarm.name == alarmName)
666 isRunning(function(running) {
674 planForNext: planForNext,
680 // TODO(robliao): Use signed-in state change watch API when it's available.
682 * Wraps chrome.identity to provide limited listening support for
683 * the sign in state by polling periodically for the auth token.
684 * @return {Object} The Authentication Manager interface.
686 function buildAuthenticationManager() {
687 var alarmName = 'sign-in-alarm';
690 * Gets an OAuth2 access token.
691 * @param {function(string=)} callback Called on completion.
692 * The string contains the token. It's undefined if there was an error.
694 function getAuthToken(callback) {
695 instrumented.identity.getAuthToken({interactive: false}, function(token) {
696 token = chrome.runtime.lastError ? undefined : token;
702 * Determines whether there is an account attached to the profile.
703 * @param {function(boolean)} callback Called on completion.
705 function isSignedIn(callback) {
706 instrumented.webstorePrivate.getBrowserLogin(function(accountInfo) {
707 callback(!!accountInfo.login);
712 * Removes the specified cached token.
713 * @param {string} token Authentication Token to remove from the cache.
714 * @param {function} callback Called on completion.
716 function removeToken(token, callback) {
717 instrumented.identity.removeCachedAuthToken({token: token}, function() {
718 // Let Chrome now about a possible problem with the token.
719 getAuthToken(function() {});
727 * Registers a listener that gets called back when the signed in state
728 * is found to be changed.
729 * @param {function} callback Called when the answer to isSignedIn changes.
731 function addListener(callback) {
732 listeners.push(callback);
736 * Checks if the last signed in state matches the current one.
737 * If it doesn't, it notifies the listeners of the change.
739 function checkAndNotifyListeners() {
740 isSignedIn(function(signedIn) {
741 instrumented.storage.local.get('lastSignedInState', function(items) {
743 if (items.lastSignedInState != signedIn) {
744 chrome.storage.local.set(
745 {lastSignedInState: signedIn});
746 if (items.lastSignedInState != undefined) {
747 listeners.forEach(function(callback) {
756 instrumented.identity.onSignInChanged.addListener(function() {
757 checkAndNotifyListeners();
760 instrumented.alarms.onAlarm.addListener(function(alarm) {
761 if (alarm.name == alarmName)
762 checkAndNotifyListeners();
765 // Poll for the sign in state every hour.
766 // One hour is just an arbitrary amount of time chosen.
767 chrome.alarms.create(alarmName, {periodInMinutes: 60});
770 addListener: addListener,
771 getAuthToken: getAuthToken,
772 isSignedIn: isSignedIn,
773 removeToken: removeToken