1// Copyright (c) 2012 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 7 * Class representing the host-list portion of the home screen UI. 8 */ 9 10'use strict'; 11 12/** @suppress {duplicate} */ 13var remoting = remoting || {}; 14 15/** 16 * Create a host list consisting of the specified HTML elements, which should 17 * have a common parent that contains only host-list UI as it will be hidden 18 * if the host-list is empty. 19 * 20 * @constructor 21 * @param {Element} table The HTML <div> to contain host-list. 22 * @param {Element} noHosts The HTML <div> containing the "no hosts" message. 23 * @param {Element} errorMsg The HTML <div> to display error messages. 24 * @param {Element} errorButton The HTML <button> to display the error 25 * resolution action. 26 * @param {HTMLElement} loadingIndicator The HTML <span> to update while the 27 * host list is being loaded. The first element of this span should be 28 * the reload button. 29 */ 30remoting.HostList = function(table, noHosts, errorMsg, errorButton, 31 loadingIndicator) { 32 /** 33 * @type {Element} 34 * @private 35 */ 36 this.table_ = table; 37 /** 38 * @type {Element} 39 * @private 40 * TODO(jamiewalch): This should be doable using CSS's sibling selector, 41 * but it doesn't work right now (crbug.com/135050). 42 */ 43 this.noHosts_ = noHosts; 44 /** 45 * @type {Element} 46 * @private 47 */ 48 this.errorMsg_ = errorMsg; 49 /** 50 * @type {Element} 51 * @private 52 */ 53 this.errorButton_ = errorButton; 54 /** 55 * @type {HTMLElement} 56 * @private 57 */ 58 this.loadingIndicator_ = loadingIndicator; 59 /** 60 * @type {Array.<remoting.HostTableEntry>} 61 * @private 62 */ 63 this.hostTableEntries_ = []; 64 /** 65 * @type {Array.<remoting.Host>} 66 * @private 67 */ 68 this.hosts_ = []; 69 /** 70 * @type {string} 71 * @private 72 */ 73 this.lastError_ = ''; 74 /** 75 * @type {remoting.Host?} 76 * @private 77 */ 78 this.localHost_ = null; 79 /** 80 * @type {remoting.HostController.State} 81 * @private 82 */ 83 this.localHostState_ = remoting.HostController.State.UNKNOWN; 84 85 /** 86 * @type {number} 87 * @private 88 */ 89 this.webappMajorVersion_ = parseInt(chrome.runtime.getManifest().version, 10); 90 91 this.errorButton_.addEventListener('click', 92 this.onErrorClick_.bind(this), 93 false); 94 var reloadButton = this.loadingIndicator_.firstElementChild; 95 /** @type {remoting.HostList} */ 96 var that = this; 97 /** @param {Event} event */ 98 function refresh(event) { 99 event.preventDefault(); 100 that.refresh(that.display.bind(that)); 101 }; 102 reloadButton.addEventListener('click', refresh, false); 103}; 104 105/** 106 * Load the host-list asynchronously from local storage. 107 * 108 * @param {function():void} onDone Completion callback. 109 */ 110remoting.HostList.prototype.load = function(onDone) { 111 // Load the cache of the last host-list, if present. 112 /** @type {remoting.HostList} */ 113 var that = this; 114 /** @param {Object.<string>} items */ 115 var storeHostList = function(items) { 116 if (items[remoting.HostList.HOSTS_KEY]) { 117 var cached = jsonParseSafe(items[remoting.HostList.HOSTS_KEY]); 118 if (cached) { 119 that.hosts_ = /** @type {Array} */ cached; 120 } else { 121 console.error('Invalid value for ' + remoting.HostList.HOSTS_KEY); 122 } 123 } 124 onDone(); 125 }; 126 chrome.storage.local.get(remoting.HostList.HOSTS_KEY, storeHostList); 127}; 128 129/** 130 * Search the host list for a host with the specified id. 131 * 132 * @param {string} hostId The unique id of the host. 133 * @return {remoting.Host?} The host, if any. 134 */ 135remoting.HostList.prototype.getHostForId = function(hostId) { 136 for (var i = 0; i < this.hosts_.length; ++i) { 137 if (this.hosts_[i].hostId == hostId) { 138 return this.hosts_[i]; 139 } 140 } 141 return null; 142}; 143 144/** 145 * Get the host id corresponding to the specified host name. 146 * 147 * @param {string} hostName The name of the host. 148 * @return {string?} The host id, if a host with the given name exists. 149 */ 150remoting.HostList.prototype.getHostIdForName = function(hostName) { 151 for (var i = 0; i < this.hosts_.length; ++i) { 152 if (this.hosts_[i].hostName == hostName) { 153 return this.hosts_[i].hostId; 154 } 155 } 156 return null; 157}; 158 159/** 160 * Query the Remoting Directory for the user's list of hosts. 161 * 162 * @param {function(boolean):void} onDone Callback invoked with true on success 163 * or false on failure. 164 * @return {void} Nothing. 165 */ 166remoting.HostList.prototype.refresh = function(onDone) { 167 this.loadingIndicator_.classList.add('loading'); 168 /** @param {XMLHttpRequest} xhr The response from the server. */ 169 var parseHostListResponse = this.parseHostListResponse_.bind(this, onDone); 170 /** @type {remoting.HostList} */ 171 var that = this; 172 /** @param {string} token The OAuth2 token. */ 173 var getHosts = function(token) { 174 var headers = { 'Authorization': 'OAuth ' + token }; 175 remoting.xhr.get( 176 remoting.settings.DIRECTORY_API_BASE_URL + '/@me/hosts', 177 parseHostListResponse, '', headers); 178 }; 179 /** @param {remoting.Error} error */ 180 var onError = function(error) { 181 that.lastError_ = error; 182 onDone(false); 183 }; 184 remoting.identity.callWithToken(getHosts, onError); 185}; 186 187/** 188 * Handle the results of the host list request. A success response will 189 * include a JSON-encoded list of host descriptions, which we display if we're 190 * able to successfully parse it. 191 * 192 * @param {function(boolean):void} onDone The callback passed to |refresh|. 193 * @param {XMLHttpRequest} xhr The XHR object for the host list request. 194 * @return {void} Nothing. 195 * @private 196 */ 197remoting.HostList.prototype.parseHostListResponse_ = function(onDone, xhr) { 198 this.lastError_ = ''; 199 try { 200 if (xhr.status == 200) { 201 var response = 202 /** @type {{data: {items: Array}}} */ jsonParseSafe(xhr.responseText); 203 if (response && response.data) { 204 if (response.data.items) { 205 this.hosts_ = response.data.items; 206 /** 207 * @param {remoting.Host} a 208 * @param {remoting.Host} b 209 */ 210 var cmp = function(a, b) { 211 if (a.status < b.status) { 212 return 1; 213 } else if (b.status < a.status) { 214 return -1; 215 } else if (a.hostName.toLocaleLowerCase() < 216 b.hostName.toLocaleLowerCase()) { 217 return -1; 218 } else if (a.hostName.toLocaleLowerCase() > 219 b.hostName.toLocaleLowerCase()) { 220 return 1; 221 } 222 return 0; 223 }; 224 this.hosts_ = /** @type {Array} */ this.hosts_.sort(cmp); 225 } else { 226 this.hosts_ = []; 227 } 228 } else { 229 this.lastError_ = remoting.Error.UNEXPECTED; 230 console.error('Invalid "hosts" response from server.'); 231 } 232 } else { 233 // Some other error. 234 console.error('Bad status on host list query: ', xhr); 235 if (xhr.status == 0) { 236 this.lastError_ = remoting.Error.NETWORK_FAILURE; 237 } else if (xhr.status == 401) { 238 this.lastError_ = remoting.Error.AUTHENTICATION_FAILED; 239 } else if (xhr.status == 502 || xhr.status == 503) { 240 this.lastError_ = remoting.Error.SERVICE_UNAVAILABLE; 241 } else { 242 this.lastError_ = remoting.Error.UNEXPECTED; 243 } 244 } 245 } catch (er) { 246 var typed_er = /** @type {Object} */ (er); 247 console.error('Error processing response: ', xhr, typed_er); 248 this.lastError_ = remoting.Error.UNEXPECTED; 249 } 250 this.save_(); 251 this.loadingIndicator_.classList.remove('loading'); 252 onDone(this.lastError_ == ''); 253}; 254 255/** 256 * Display the list of hosts or error condition. 257 * 258 * @return {void} Nothing. 259 */ 260remoting.HostList.prototype.display = function() { 261 this.table_.innerText = ''; 262 this.errorMsg_.innerText = ''; 263 this.hostTableEntries_ = []; 264 265 var noHostsRegistered = (this.hosts_.length == 0); 266 this.table_.hidden = noHostsRegistered; 267 this.noHosts_.hidden = !noHostsRegistered; 268 269 if (this.lastError_ != '') { 270 l10n.localizeElementFromTag(this.errorMsg_, this.lastError_); 271 if (this.lastError_ == remoting.Error.AUTHENTICATION_FAILED) { 272 l10n.localizeElementFromTag(this.errorButton_, 273 /*i18n-content*/'SIGN_IN_BUTTON'); 274 } else { 275 l10n.localizeElementFromTag(this.errorButton_, 276 /*i18n-content*/'RETRY'); 277 } 278 } else { 279 for (var i = 0; i < this.hosts_.length; ++i) { 280 /** @type {remoting.Host} */ 281 var host = this.hosts_[i]; 282 // Validate the entry to make sure it has all the fields we expect and is 283 // not the local host (which is displayed separately). NB: if the host has 284 // never sent a heartbeat, then there will be no jabberId. 285 if (host.hostName && host.hostId && host.status && host.publicKey && 286 (!this.localHost_ || host.hostId != this.localHost_.hostId)) { 287 var hostTableEntry = new remoting.HostTableEntry( 288 host, this.webappMajorVersion_, 289 this.renameHost_.bind(this), this.deleteHost_.bind(this)); 290 hostTableEntry.createDom(); 291 this.hostTableEntries_[i] = hostTableEntry; 292 this.table_.appendChild(hostTableEntry.tableRow); 293 } 294 } 295 } 296 297 this.errorMsg_.parentNode.hidden = (this.lastError_ == ''); 298 299 // The local host cannot be stopped or started if the host controller is not 300 // implemented for this platform. Additionally, it cannot be started if there 301 // is an error (in many error states, the start operation will fail anyway, 302 // but even if it succeeds, the chance of a related but hard-to-diagnose 303 // future error is high). 304 var state = this.localHostState_; 305 var enabled = (state == remoting.HostController.State.STARTING) || 306 (state == remoting.HostController.State.STARTED); 307 var canChangeLocalHostState = 308 (state != remoting.HostController.State.NOT_IMPLEMENTED) && 309 (state != remoting.HostController.State.UNKNOWN) && 310 (state != remoting.HostController.State.NOT_INSTALLED || 311 remoting.isMe2MeInstallable()) && 312 (enabled || this.lastError_ == ''); 313 314 remoting.updateModalUi(enabled ? 'enabled' : 'disabled', 'data-daemon-state'); 315 var element = document.getElementById('daemon-control'); 316 element.hidden = !canChangeLocalHostState; 317 element = document.getElementById('host-list-empty-hosting-supported'); 318 element.hidden = !canChangeLocalHostState; 319 element = document.getElementById('host-list-empty-hosting-unsupported'); 320 element.hidden = canChangeLocalHostState; 321}; 322 323/** 324 * Remove a host from the list, and deregister it. 325 * @param {remoting.HostTableEntry} hostTableEntry The host to be removed. 326 * @return {void} Nothing. 327 * @private 328 */ 329remoting.HostList.prototype.deleteHost_ = function(hostTableEntry) { 330 this.table_.removeChild(hostTableEntry.tableRow); 331 var index = this.hostTableEntries_.indexOf(hostTableEntry); 332 if (index != -1) { 333 this.hostTableEntries_.splice(index, 1); 334 } 335 remoting.HostList.unregisterHostById(hostTableEntry.host.hostId); 336}; 337 338/** 339 * Prepare a host for renaming by replacing its name with an edit box. 340 * @param {remoting.HostTableEntry} hostTableEntry The host to be renamed. 341 * @return {void} Nothing. 342 * @private 343 */ 344remoting.HostList.prototype.renameHost_ = function(hostTableEntry) { 345 for (var i = 0; i < this.hosts_.length; ++i) { 346 if (this.hosts_[i].hostId == hostTableEntry.host.hostId) { 347 this.hosts_[i].hostName = hostTableEntry.host.hostName; 348 break; 349 } 350 } 351 this.save_(); 352 353 /** @param {string?} token */ 354 var renameHost = function(token) { 355 if (token) { 356 var headers = { 357 'Authorization': 'OAuth ' + token, 358 'Content-type' : 'application/json; charset=UTF-8' 359 }; 360 var newHostDetails = { data: { 361 hostId: hostTableEntry.host.hostId, 362 hostName: hostTableEntry.host.hostName, 363 publicKey: hostTableEntry.host.publicKey 364 } }; 365 remoting.xhr.put( 366 remoting.settings.DIRECTORY_API_BASE_URL + '/@me/hosts/' + 367 hostTableEntry.host.hostId, 368 function(xhr) {}, 369 JSON.stringify(newHostDetails), 370 headers); 371 } else { 372 console.error('Could not rename host. Authentication failure.'); 373 } 374 } 375 remoting.identity.callWithToken(renameHost, remoting.showErrorMessage); 376}; 377 378/** 379 * Unregister a host. 380 * @param {string} hostId The id of the host to be removed. 381 * @return {void} Nothing. 382 */ 383remoting.HostList.unregisterHostById = function(hostId) { 384 /** @param {string} token The OAuth2 token. */ 385 var deleteHost = function(token) { 386 var headers = { 'Authorization': 'OAuth ' + token }; 387 remoting.xhr.remove( 388 remoting.settings.DIRECTORY_API_BASE_URL + '/@me/hosts/' + hostId, 389 function() {}, '', headers); 390 } 391 remoting.identity.callWithToken(deleteHost, remoting.showErrorMessage); 392}; 393 394/** 395 * Set tool-tips for the 'connect' action. We can't just set this on the 396 * parent element because the button has no tool-tip, and therefore would 397 * inherit connectStr. 398 * 399 * @return {void} Nothing. 400 * @private 401 */ 402remoting.HostList.prototype.setTooltips_ = function() { 403 var connectStr = ''; 404 if (this.localHost_) { 405 chrome.i18n.getMessage(/*i18n-content*/'TOOLTIP_CONNECT', 406 this.localHost_.hostName); 407 } 408 document.getElementById('this-host-name').title = connectStr; 409 document.getElementById('this-host-icon').title = connectStr; 410}; 411 412/** 413 * Set the state of the local host and localHostId if any. 414 * 415 * @param {remoting.HostController.State} state State of the local host. 416 * @param {string?} hostId ID of the local host, or null. 417 * @return {void} Nothing. 418 */ 419remoting.HostList.prototype.setLocalHostStateAndId = function(state, hostId) { 420 this.localHostState_ = state; 421 this.setLocalHost_(hostId ? this.getHostForId(hostId) : null); 422} 423 424/** 425 * Set the host object that corresponds to the local computer, if any. 426 * 427 * @param {remoting.Host?} host The host, or null if not registered. 428 * @return {void} Nothing. 429 * @private 430 */ 431remoting.HostList.prototype.setLocalHost_ = function(host) { 432 this.localHost_ = host; 433 this.setTooltips_(); 434 /** @type {remoting.HostList} */ 435 var that = this; 436 if (host) { 437 /** @param {remoting.HostTableEntry} host */ 438 var renameHost = function(host) { 439 that.renameHost_(host); 440 that.setTooltips_(); 441 }; 442 if (!this.localHostTableEntry_) { 443 /** @type {remoting.HostTableEntry} @private */ 444 this.localHostTableEntry_ = new remoting.HostTableEntry( 445 host, this.webappMajorVersion_, renameHost); 446 this.localHostTableEntry_.init( 447 document.getElementById('this-host-connect'), 448 document.getElementById('this-host-warning'), 449 document.getElementById('this-host-name'), 450 document.getElementById('this-host-rename')); 451 } else { 452 // TODO(jamiewalch): This is hack to prevent multiple click handlers being 453 // registered for the same DOM elements if this method is called more than 454 // once. A better solution would be to let HostTable create the daemon row 455 // like it creates the rows for non-local hosts. 456 this.localHostTableEntry_.host = host; 457 } 458 } else { 459 this.localHostTableEntry_ = null; 460 } 461} 462 463/** 464 * Called by the HostControlled after the local host has been started. 465 * 466 * @param {string} hostName Host name. 467 * @param {string} hostId ID of the local host. 468 * @param {string} publicKey Public key. 469 * @return {void} Nothing. 470 */ 471remoting.HostList.prototype.onLocalHostStarted = function( 472 hostName, hostId, publicKey) { 473 // Create a dummy remoting.Host instance to represent the local host. 474 // Refreshing the list is no good in general, because the directory 475 // information won't be in sync for several seconds. We don't know the 476 // host JID, but it can be missing from the cache with no ill effects. 477 // It will be refreshed if the user tries to connect to the local host, 478 // and we hope that the directory will have been updated by that point. 479 var localHost = new remoting.Host(); 480 localHost.hostName = hostName; 481 // Provide a version number to avoid warning about this dummy host being 482 // out-of-date. 483 localHost.hostVersion = String(this.webappMajorVersion_) + ".x" 484 localHost.hostId = hostId; 485 localHost.publicKey = publicKey; 486 localHost.status = 'ONLINE'; 487 this.hosts_.push(localHost); 488 this.save_(); 489 this.setLocalHost_(localHost); 490}; 491 492/** 493 * Called when the user clicks the button next to the error message. The action 494 * depends on the error. 495 * 496 * @private 497 */ 498remoting.HostList.prototype.onErrorClick_ = function() { 499 if (this.lastError_ == remoting.Error.AUTHENTICATION_FAILED) { 500 remoting.oauth2.doAuthRedirect(); 501 } else { 502 this.refresh(remoting.updateLocalHostState); 503 } 504}; 505 506/** 507 * Save the host list to local storage. 508 */ 509remoting.HostList.prototype.save_ = function() { 510 var items = {}; 511 items[remoting.HostList.HOSTS_KEY] = JSON.stringify(this.hosts_); 512 chrome.storage.local.set(items); 513}; 514 515/** 516 * Key name under which Me2Me hosts are cached. 517 */ 518remoting.HostList.HOSTS_KEY = 'me2me-cached-hosts'; 519 520/** @type {remoting.HostList} */ 521remoting.hostList = null; 522