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