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