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