1 // Copyright (c) 2011 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 /** 6 * @fileoverview NTP Standalone hack 7 * This file contains the code necessary to make the Touch NTP work 8 * as a stand-alone application (as opposed to being embedded into chrome). 9 * This is useful for rapid development and testing, but does not actually form 10 * part of the product. 11 * 12 * Note that, while the product portion of the touch NTP is designed to work 13 * just in the latest version of Chrome, this hack attempts to add some support 14 * for working in older browsers to enable testing and demonstration on 15 * existing tablet platforms. In particular, this code has been tested to work 16 * on Mobile Safari in iOS 4.2. The goal is that the need to support any other 17 * browser should not leak out of this file - and so we will hack global JS 18 * objects as necessary here to present the illusion of running on the latest 19 * version of Chrome. 20 */ 21 22 // Note that this file never gets concatenated and embeded into Chrome, so we 23 // can enable strict mode for the whole file just like normal. 24 'use strict'; 25 26 27 /** 28 * For non-Chrome browsers, create a dummy chrome object 29 */ 30 if (!window.chrome) { 31 var chrome = {}; 32 } 33 34 35 /** 36 * A replacement chrome.send method that supplies static data for the 37 * key APIs used by the NTP. 38 * 39 * Note that the real chrome object also supplies data for most-viewed and 40 * recently-closed pages, but the tangent NTP doesn't use that data so we 41 * don't bother simulating it here. 42 * 43 * We create this object by applying an anonymous function so that we can have 44 * local variables (avoid polluting the global object) 45 */ 46 chrome.send = (function() { 47 var apps = [{ 48 app_launch_index: 2, 49 description: 'The prickly puzzle game where popping balloons has ' + 50 'never been so much fun!', 51 icon_big: 'standalone/poppit-icon.png', 52 icon_small: 'standalone/poppit-favicon.png', 53 id: 'mcbkbpnkkkipelfledbfocopglifcfmi', 54 launch_container: 2, 55 launch_type: 1, 56 launch_url: 'http://poppit.pogo.com/hd/PoppitHD.html', 57 name: 'Poppit', 58 options_url: '' 59 }, 60 { 61 app_launch_index: 1, 62 description: 'Fast, searchable email with less spam.', 63 icon_big: 'standalone/gmail-icon.png', 64 icon_small: 'standalone/gmail-favicon.png', 65 id: 'pjkljhegncpnkpknbcohdijeoejaedia', 66 launch_container: 2, 67 launch_type: 1, 68 launch_url: 'https://mail.google.com/', 69 name: 'Gmail', 70 options_url: 'https://mail.google.com/mail/#settings' 71 }, 72 { 73 app_launch_index: 3, 74 description: 'Read over 3 million Google eBooks on the web.', 75 icon_big: 'standalone/googlebooks-icon.png', 76 icon_small: 'standalone/googlebooks-favicon.png', 77 id: 'mmimngoggfoobjdlefbcabngfnmieonb', 78 launch_container: 2, 79 launch_type: 1, 80 launch_url: 'http://books.google.com/ebooks?source=chrome-app', 81 name: 'Google Books', 82 options_url: '' 83 }, 84 { 85 app_launch_index: 4, 86 description: 'Find local business information, directions, and ' + 87 'street-level imagery around the world with Google Maps.', 88 icon_big: 'standalone/googlemaps-icon.png', 89 icon_small: 'standalone/googlemaps-favicon.png', 90 id: 'lneaknkopdijkpnocmklfnjbeapigfbh', 91 launch_container: 2, 92 launch_type: 1, 93 launch_url: 'http://maps.google.com/', 94 name: 'Google Maps', 95 options_url: '' 96 }, 97 { 98 app_launch_index: 5, 99 description: 'Create the longest path possible and challenge your ' + 100 'friends in the game of Entanglement.', 101 icon_big: 'standalone/entaglement-icon.png', 102 id: 'aciahcmjmecflokailenpkdchphgkefd', 103 launch_container: 2, 104 launch_type: 1, 105 launch_url: 'http://entanglement.gopherwoodstudios.com/', 106 name: 'Entanglement', 107 options_url: '' 108 }, 109 { 110 name: 'NYTimes', 111 app_launch_index: 6, 112 description: 'The New York Times App for the Chrome Web Store.', 113 icon_big: 'standalone/nytimes-icon.png', 114 id: 'ecmphppfkcfflgglcokcbdkofpfegoel', 115 launch_container: 2, 116 launch_type: 1, 117 launch_url: 'http://www.nytimes.com/chrome/', 118 options_url: '', 119 page_index: 2 120 }, 121 { 122 app_launch_index: 7, 123 description: 'The world\'s most popular online video community.', 124 id: 'blpcfgokakmgnkcojhhkbfbldkacnbeo', 125 icon_big: 'standalone/youtube-icon.png', 126 launch_container: 2, 127 launch_type: 1, 128 launch_url: 'http://www.youtube.com/', 129 name: 'YouTube', 130 options_url: '', 131 page_index: 3 132 }]; 133 134 // For testing 135 apps = spamApps(apps); 136 137 /** 138 * Invoke the getAppsCallback function with a snapshot of the current app 139 * database. 140 */ 141 function sendGetAppsCallback() 142 { 143 // We don't want to hand out our array directly because the NTP will 144 // assume it owns the array and is free to modify it. For now we make a 145 // one-level deep copy of the array (since cloning the whole thing is 146 // more work and unnecessary at the moment). 147 var appsData = { 148 showPromo: false, 149 showLauncher: true, 150 apps: apps.slice(0) 151 }; 152 getAppsCallback(appsData); 153 } 154 155 /** 156 * To make testing real-world scenarios easier, this expands our list of 157 * apps by duplicating them a number of times 158 */ 159 function spamApps(apps) 160 { 161 // Create an object that extends another object 162 // This is an easy/efficient way to make slightly modified copies of our 163 // app objects without having to do a deep copy 164 function createObject(proto) { 165 /** @constructor */ 166 var F = function() {}; 167 F.prototype = proto; 168 return new F(); 169 } 170 171 var newApps = []; 172 var pages = Math.floor(Math.random() * 8) + 1; 173 var idx = 1; 174 for (var p = 0; p < pages; p++) { 175 var count = Math.floor(Math.random() * 18) + 1; 176 for (var a = 0; a < count; a++) { 177 var i = Math.floor(Math.random() * apps.length); 178 var newApp = createObject(apps[i]); 179 newApp.page_index = p; 180 newApp.app_launch_index = idx; 181 // Uniqify the ID 182 newApp.id = apps[i].id + '-' + idx; 183 idx++; 184 newApps.push(newApp); 185 } 186 } 187 return newApps; 188 } 189 190 /** 191 * Like Array.prototype.indexOf but calls a predicate to test for match 192 * 193 * @param {Array} array The array to search. 194 * @param {function(Object): boolean} predicate The function to invoke on 195 * each element. 196 * @return {number} First index at which predicate returned true, or -1. 197 */ 198 function indexOfPred(array, predicate) { 199 for (var i = 0; i < array.length; i++) { 200 if (predicate(array[i])) 201 return i; 202 } 203 return -1; 204 } 205 206 /** 207 * Get index into apps of an application object 208 * Requires the specified app to be present 209 * 210 * @param {string} id The ID of the application to locate. 211 * @return {number} The index in apps for an object with the specified ID. 212 */ 213 function getAppIndex(id) { 214 var i = indexOfPred(apps, function(e) { return e.id === id;}); 215 if (i == -1) 216 alert('Error: got unexpected App ID'); 217 return i; 218 } 219 220 /** 221 * Get an application object given the application ID 222 * Requires 223 * @param {string} id The application ID to search for. 224 * @return {Object} The corresponding application object. 225 */ 226 function getApp(id) { 227 return apps[getAppIndex(id)]; 228 } 229 230 /** 231 * Simlulate the launching of an application 232 * 233 * @param {string} id The ID of the application to launch. 234 */ 235 function launchApp(id) { 236 // Note that we don't do anything with the icon location. 237 // That's used by Chrome only on Windows to animate the icon during 238 // launch. 239 var app = getApp(id); 240 switch (parseInt(app.launch_type, 10)) { 241 case 0: // pinned 242 case 1: // regular 243 // Replace the current tab with the app. 244 // Pinned seems to omit the tab title, but I doubt it's 245 // possible for us to do that here 246 window.location = (app.launch_url); 247 break; 248 249 case 2: // fullscreen 250 case 3: // window 251 // attempt to launch in a new window 252 window.close(); 253 window.open(app.launch_url, app.name, 254 'resizable=yes,scrollbars=yes,status=yes'); 255 break; 256 257 default: 258 alert('Unexpected launch type: ' + app.launch_type); 259 } 260 } 261 262 /** 263 * Simulate uninstall of an app 264 * @param {string} id The ID of the application to uninstall. 265 */ 266 function uninstallApp(id) { 267 var i = getAppIndex(id); 268 // This confirmation dialog doesn't look exactly the same as the 269 // standard NTP one, but it's close enough. 270 if (window.confirm('Uninstall \"' + apps[i].name + '\"?')) { 271 apps.splice(i, 1); 272 sendGetAppsCallback(); 273 } 274 } 275 276 /** 277 * Update the app_launch_index of all apps 278 * @param {Array.<string>} appIds All app IDs in their desired order. 279 */ 280 function reorderApps(movedAppId, appIds) { 281 assert(apps.length == appIds.length, 'Expected all apps in reorderApps'); 282 283 // Clear the launch indicies so we can easily verify no dups 284 apps.forEach(function(a) { 285 a.app_launch_index = -1; 286 }); 287 288 for (var i = 0; i < appIds.length; i++) { 289 var a = getApp(appIds[i]); 290 assert(a.app_launch_index == -1, 291 'Found duplicate appId in reorderApps'); 292 a.app_launch_index = i; 293 } 294 sendGetAppsCallback(); 295 } 296 297 /** 298 * Update the page number of an app 299 * @param {string} id The ID of the application to move. 300 * @param {number} page The page index to place the app. 301 */ 302 function setPageIndex(id, page) { 303 var app = getApp(id); 304 app.page_index = page; 305 } 306 307 // The 'send' function 308 /** 309 * The chrome server communication entrypoint. 310 * 311 * @param {string} command Name of the command to send. 312 * @param {Array} args Array of command-specific arguments. 313 */ 314 return function(command, args) { 315 // Chrome API is async 316 window.setTimeout(function() { 317 switch (command) { 318 // called to populate the list of applications 319 case 'getApps': 320 sendGetAppsCallback(); 321 break; 322 323 // Called when an app is launched 324 // Ignore additional arguments - they've been changing over time and 325 // we don't use them in our NTP anyway. 326 case 'launchApp': 327 launchApp(args[0]); 328 break; 329 330 // Called when an app is uninstalled 331 case 'uninstallApp': 332 uninstallApp(args[0]); 333 break; 334 335 // Called when an app is repositioned in the touch NTP 336 case 'reorderApps': 337 reorderApps(args[0], args[1]); 338 break; 339 340 // Called when an app is moved to a different page 341 case 'setPageIndex': 342 setPageIndex(args[0], parseInt(args[1], 10)); 343 break; 344 345 default: 346 throw new Error('Unexpected chrome command: ' + command); 347 break; 348 } 349 }, 0); 350 }; 351 })(); 352 353 /* A static templateData with english resources */ 354 var templateData = { 355 title: 'Standalone New Tab', 356 web_store_title: 'Web Store', 357 web_store_url: 'https://chrome.google.com/webstore?hl=en-US' 358 }; 359 360 /* Hook construction of chrome://theme URLs */ 361 function themeUrlMapper(resourceName) { 362 if (resourceName == 'IDR_WEBSTORE_ICON') { 363 return 'standalone/webstore_icon.png'; 364 } 365 return undefined; 366 } 367 368 /* 369 * On iOS we need a hack to avoid spurious click events 370 * In particular, if the user delays briefly between first touching and starting 371 * to drag, when the user releases a click event will be generated. 372 * Note that this seems to happen regardless of whether we do preventDefault on 373 * touchmove events. 374 */ 375 if (/iPhone|iPod|iPad/.test(navigator.userAgent) && 376 !(/Chrome/.test(navigator.userAgent))) { 377 // We have a real iOS device (no a ChromeOS device pretending to be iOS) 378 (function() { 379 // True if a gesture is occuring that should cause clicks to be swallowed 380 var gestureActive = false; 381 382 // The position a touch was last started 383 var lastTouchStartPosition; 384 385 // Distance which a touch needs to move to be considered a drag 386 var DRAG_DISTANCE = 3; 387 388 document.addEventListener('touchstart', function(event) { 389 lastTouchStartPosition = { 390 x: event.touches[0].clientX, 391 y: event.touches[0].clientY 392 }; 393 // A touchstart ALWAYS preceeds a click (valid or not), so cancel any 394 // outstanding gesture. Also, any multi-touch is a gesture that should 395 // prevent clicks. 396 gestureActive = event.touches.length > 1; 397 }, true); 398 399 document.addEventListener('touchmove', function(event) { 400 // When we see a move, measure the distance from the last touchStart 401 // If this is a multi-touch then the work here is irrelevant 402 // (gestureActive is already true) 403 var t = event.touches[0]; 404 if (Math.abs(t.clientX - lastTouchStartPosition.x) > DRAG_DISTANCE || 405 Math.abs(t.clientY - lastTouchStartPosition.y) > DRAG_DISTANCE) { 406 gestureActive = true; 407 } 408 }, true); 409 410 document.addEventListener('click', function(event) { 411 // If we got here without gestureActive being set then it means we had 412 // a touchStart without any real dragging before touchEnd - we can allow 413 // the click to proceed. 414 if (gestureActive) { 415 event.preventDefault(); 416 event.stopPropagation(); 417 } 418 }, true); 419 })(); 420 } 421 422 /* Hack to add Element.classList to older browsers that don't yet support it. 423 From https://developer.mozilla.org/en/DOM/element.classList. 424 */ 425 if (typeof Element !== 'undefined' && 426 !Element.prototype.hasOwnProperty('classList')) { 427 (function() { 428 var classListProp = 'classList', 429 protoProp = 'prototype', 430 elemCtrProto = Element[protoProp], 431 objCtr = Object, 432 strTrim = String[protoProp].trim || function() { 433 return this.replace(/^\s+|\s+$/g, ''); 434 }, 435 arrIndexOf = Array[protoProp].indexOf || function(item) { 436 for (var i = 0, len = this.length; i < len; i++) { 437 if (i in this && this[i] === item) { 438 return i; 439 } 440 } 441 return -1; 442 }, 443 // Vendors: please allow content code to instantiate DOMExceptions 444 /** @constructor */ 445 DOMEx = function(type, message) { 446 this.name = type; 447 this.code = DOMException[type]; 448 this.message = message; 449 }, 450 checkTokenAndGetIndex = function(classList, token) { 451 if (token === '') { 452 throw new DOMEx( 453 'SYNTAX_ERR', 454 'An invalid or illegal string was specified' 455 ); 456 } 457 if (/\s/.test(token)) { 458 throw new DOMEx( 459 'INVALID_CHARACTER_ERR', 460 'String contains an invalid character' 461 ); 462 } 463 return arrIndexOf.call(classList, token); 464 }, 465 /** @constructor 466 * @extends {Array} */ 467 ClassList = function(elem) { 468 var trimmedClasses = strTrim.call(elem.className), 469 classes = trimmedClasses ? trimmedClasses.split(/\s+/) : []; 470 471 for (var i = 0, len = classes.length; i < len; i++) { 472 this.push(classes[i]); 473 } 474 this._updateClassName = function() { 475 elem.className = this.toString(); 476 }; 477 }, 478 classListProto = ClassList[protoProp] = [], 479 classListGetter = function() { 480 return new ClassList(this); 481 }; 482 483 // Most DOMException implementations don't allow calling DOMException's 484 // toString() on non-DOMExceptions. Error's toString() is sufficient here. 485 DOMEx[protoProp] = Error[protoProp]; 486 classListProto.item = function(i) { 487 return this[i] || null; 488 }; 489 classListProto.contains = function(token) { 490 token += ''; 491 return checkTokenAndGetIndex(this, token) !== -1; 492 }; 493 classListProto.add = function(token) { 494 token += ''; 495 if (checkTokenAndGetIndex(this, token) === -1) { 496 this.push(token); 497 this._updateClassName(); 498 } 499 }; 500 classListProto.remove = function(token) { 501 token += ''; 502 var index = checkTokenAndGetIndex(this, token); 503 if (index !== -1) { 504 this.splice(index, 1); 505 this._updateClassName(); 506 } 507 }; 508 classListProto.toggle = function(token) { 509 token += ''; 510 if (checkTokenAndGetIndex(this, token) === -1) { 511 this.add(token); 512 } else { 513 this.remove(token); 514 } 515 }; 516 classListProto.toString = function() { 517 return this.join(' '); 518 }; 519 520 if (objCtr.defineProperty) { 521 var classListDescriptor = { 522 get: classListGetter, 523 enumerable: true, 524 configurable: true 525 }; 526 objCtr.defineProperty(elemCtrProto, classListProp, classListDescriptor); 527 } else if (objCtr[protoProp].__defineGetter__) { 528 elemCtrProto.__defineGetter__(classListProp, classListGetter); 529 } 530 }()); 531 } 532 533 /* Hack to add Function.bind to older browsers that don't yet support it. From: 534 https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Function/bind 535 */ 536 if (!Function.prototype.bind) { 537 /** 538 * @param {Object} selfObj Specifies the object which |this| should 539 * point to when the function is run. If the value is null or undefined, 540 * it will default to the global object. 541 * @param {...*} var_args Additional arguments that are partially 542 * applied to the function. 543 * @return {!Function} A partially-applied form of the function bind() was 544 * invoked as a method of. 545 * @suppress {duplicate} 546 */ 547 Function.prototype.bind = function(selfObj, var_args) { 548 var slice = [].slice, 549 args = slice.call(arguments, 1), 550 self = this, 551 /** @constructor */ 552 nop = function() {}, 553 bound = function() { 554 return self.apply(this instanceof nop ? this : (selfObj || {}), 555 args.concat(slice.call(arguments))); 556 }; 557 nop.prototype = self.prototype; 558 bound.prototype = new nop(); 559 return bound; 560 }; 561 } 562 563