Home | History | Annotate | Download | only in inspect
      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 var MIN_VERSION_TAB_CLOSE = 25;
      6 var MIN_VERSION_TARGET_ID = 26;
      7 var MIN_VERSION_NEW_TAB = 29;
      8 var MIN_VERSION_TAB_ACTIVATE = 30;
      9 
     10 var queryParamsObject = {};
     11 
     12 (function() {
     13 var queryParams = window.location.search;
     14 if (!queryParams)
     15     return;
     16 var params = queryParams.substring(1).split('&');
     17 for (var i = 0; i < params.length; ++i) {
     18     var pair = params[i].split('=');
     19     queryParamsObject[pair[0]] = pair[1];
     20 }
     21 
     22 })();
     23 
     24 function sendCommand(command, args) {
     25   chrome.send(command, Array.prototype.slice.call(arguments, 1));
     26 }
     27 
     28 function sendTargetCommand(command, target) {
     29   sendCommand(command, target.source, target.id);
     30 }
     31 
     32 function sendServiceWorkerCommand(action, worker) {
     33   $('serviceworker-internals').contentWindow.postMessage({
     34     'action': action,
     35     'worker': worker
     36   },'chrome://serviceworker-internals');
     37 }
     38 
     39 function removeChildren(element_id) {
     40   var element = $(element_id);
     41   element.textContent = '';
     42 }
     43 
     44 function onload() {
     45   var tabContents = document.querySelectorAll('#content > div');
     46   for (var i = 0; i != tabContents.length; i++) {
     47     var tabContent = tabContents[i];
     48     var tabName = tabContent.querySelector('.content-header').textContent;
     49 
     50     var tabHeader = document.createElement('div');
     51     tabHeader.className = 'tab-header';
     52     var button = document.createElement('button');
     53     button.textContent = tabName;
     54     tabHeader.appendChild(button);
     55     tabHeader.addEventListener('click', selectTab.bind(null, tabContent.id));
     56     $('navigation').appendChild(tabHeader);
     57   }
     58   onHashChange();
     59   initSettings();
     60   sendCommand('init-ui');
     61   window.addEventListener('message', onMessage.bind(this), false);
     62 }
     63 
     64 function onMessage(event) {
     65   if (event.origin != 'chrome://serviceworker-internals') {
     66     return;
     67   }
     68   populateServiceWorkers(event.data.partition_id,
     69                          event.data.workers);
     70 }
     71 
     72 function onHashChange() {
     73   var hash = window.location.hash.slice(1).toLowerCase();
     74   if (!selectTab(hash))
     75     selectTab('devices');
     76 }
     77 
     78 /**
     79  * @param {string} id Tab id.
     80  * @return {boolean} True if successful.
     81  */
     82 function selectTab(id) {
     83   closePortForwardingConfig();
     84 
     85   var tabContents = document.querySelectorAll('#content > div');
     86   var tabHeaders = $('navigation').querySelectorAll('.tab-header');
     87   var found = false;
     88   for (var i = 0; i != tabContents.length; i++) {
     89     var tabContent = tabContents[i];
     90     var tabHeader = tabHeaders[i];
     91     if (tabContent.id == id) {
     92       tabContent.classList.add('selected');
     93       tabHeader.classList.add('selected');
     94       found = true;
     95     } else {
     96       tabContent.classList.remove('selected');
     97       tabHeader.classList.remove('selected');
     98     }
     99   }
    100   if (!found)
    101     return false;
    102   window.location.hash = id;
    103   return true;
    104 }
    105 
    106 function populateServiceWorkers(partition_id, workers) {
    107   var list = $('service-workers-list-' + partition_id);
    108   if (workers.length == 0) {
    109     if (list) {
    110         list.parentNode.removeChild(list);
    111     }
    112     return;
    113   }
    114   if (list) {
    115     list.textContent = '';
    116   } else {
    117     list = document.createElement('div');
    118     list.id = 'service-workers-list-' + partition_id;
    119     list.className = 'list';
    120     $('service-workers-list').appendChild(list);
    121   }
    122   for (var i = 0; i < workers.length; i++) {
    123     var worker = workers[i];
    124     worker.hasCustomInspectAction = true;
    125     var row = addTargetToList(worker, list, ['scope', 'url']);
    126     addActionLink(
    127         row,
    128         'inspect',
    129         sendServiceWorkerCommand.bind(null, 'inspect', worker),
    130         false);
    131     addActionLink(
    132         row,
    133         'terminate',
    134         sendServiceWorkerCommand.bind(null, 'stop', worker),
    135         false);
    136   }
    137 }
    138 
    139 function populateTargets(source, data) {
    140   if (source == 'renderers')
    141     populateWebContentsTargets(data);
    142   else if (source == 'workers')
    143     populateWorkerTargets(data);
    144   else if (source == 'adb')
    145     populateRemoteTargets(data);
    146   else
    147     console.error('Unknown source type: ' + source);
    148 }
    149 
    150 function populateWebContentsTargets(data) {
    151   removeChildren('pages-list');
    152   removeChildren('extensions-list');
    153   removeChildren('apps-list');
    154   removeChildren('others-list');
    155 
    156   for (var i = 0; i < data.length; i++) {
    157     if (data[i].type === 'page')
    158       addToPagesList(data[i]);
    159     else if (data[i].type === 'background_page')
    160       addToExtensionsList(data[i]);
    161     else if (data[i].type === 'app')
    162       addToAppsList(data[i]);
    163     else
    164       addToOthersList(data[i]);
    165   }
    166 }
    167 
    168 function populateWorkerTargets(data) {
    169   removeChildren('workers-list');
    170 
    171   for (var i = 0; i < data.length; i++)
    172     addToWorkersList(data[i]);
    173 }
    174 
    175 function showIncognitoWarning() {
    176   $('devices-incognito').hidden = false;
    177 }
    178 
    179 function populateRemoteTargets(devices) {
    180   if (!devices)
    181     return;
    182 
    183   if (window.modal) {
    184     window.holdDevices = devices;
    185     return;
    186   }
    187 
    188   function alreadyDisplayed(element, data) {
    189     var json = JSON.stringify(data);
    190     if (element.cachedJSON == json)
    191       return true;
    192     element.cachedJSON = json;
    193     return false;
    194   }
    195 
    196   function insertChildSortedById(parent, child) {
    197     for (var sibling = parent.firstElementChild;
    198                      sibling;
    199                      sibling = sibling.nextElementSibling) {
    200       if (sibling.id > child.id) {
    201         parent.insertBefore(child, sibling);
    202         return;
    203       }
    204     }
    205     parent.appendChild(child);
    206   }
    207 
    208   var deviceList = $('devices-list');
    209   if (alreadyDisplayed(deviceList, devices))
    210     return;
    211 
    212   function removeObsolete(validIds, section) {
    213     if (validIds.indexOf(section.id) < 0)
    214       section.remove();
    215   }
    216 
    217   var newDeviceIds = devices.map(function(d) { return d.id });
    218   Array.prototype.forEach.call(
    219       deviceList.querySelectorAll('.device'),
    220       removeObsolete.bind(null, newDeviceIds));
    221 
    222   $('devices-help').hidden = !!devices.length;
    223 
    224   for (var d = 0; d < devices.length; d++) {
    225     var device = devices[d];
    226 
    227     var deviceSection = $(device.id);
    228     if (!deviceSection) {
    229       deviceSection = document.createElement('div');
    230       deviceSection.id = device.id;
    231       deviceSection.className = 'device';
    232       deviceList.appendChild(deviceSection);
    233 
    234       var deviceHeader = document.createElement('div');
    235       deviceHeader.className = 'device-header';
    236       deviceSection.appendChild(deviceHeader);
    237 
    238       var deviceName = document.createElement('div');
    239       deviceName.className = 'device-name';
    240       deviceHeader.appendChild(deviceName);
    241 
    242       var deviceSerial = document.createElement('div');
    243       deviceSerial.className = 'device-serial';
    244       deviceSerial.textContent = '#' + device.adbSerial.toUpperCase();
    245       deviceHeader.appendChild(deviceSerial);
    246 
    247       var devicePorts = document.createElement('div');
    248       devicePorts.className = 'device-ports';
    249       deviceHeader.appendChild(devicePorts);
    250 
    251       var browserList = document.createElement('div');
    252       browserList.className = 'browsers';
    253       deviceSection.appendChild(browserList);
    254 
    255       var authenticating = document.createElement('div');
    256       authenticating.className = 'device-auth';
    257       deviceSection.appendChild(authenticating);
    258     }
    259 
    260     if (alreadyDisplayed(deviceSection, device))
    261       continue;
    262 
    263     deviceSection.querySelector('.device-name').textContent = device.adbModel;
    264     deviceSection.querySelector('.device-auth').textContent =
    265         device.adbConnected ? '' : 'Pending authentication: please accept ' +
    266           'debugging session on the device.';
    267 
    268     var browserList = deviceSection.querySelector('.browsers');
    269     var newBrowserIds =
    270         device.browsers.map(function(b) { return b.id });
    271     Array.prototype.forEach.call(
    272         browserList.querySelectorAll('.browser'),
    273         removeObsolete.bind(null, newBrowserIds));
    274 
    275     for (var b = 0; b < device.browsers.length; b++) {
    276       var browser = device.browsers[b];
    277 
    278       var majorChromeVersion = browser.adbBrowserChromeVersion;
    279 
    280       var incompatibleVersion = browser.hasOwnProperty('compatibleVersion') &&
    281                                 !browser.compatibleVersion;
    282       var pageList;
    283       var browserSection = $(browser.id);
    284       if (browserSection) {
    285         pageList = browserSection.querySelector('.pages');
    286       } else {
    287         browserSection = document.createElement('div');
    288         browserSection.id = browser.id;
    289         browserSection.className = 'browser';
    290         insertChildSortedById(browserList, browserSection);
    291 
    292         var browserHeader = document.createElement('div');
    293         browserHeader.className = 'browser-header';
    294 
    295         var browserName = document.createElement('div');
    296         browserName.className = 'browser-name';
    297         browserHeader.appendChild(browserName);
    298         browserName.textContent = browser.adbBrowserName;
    299         if (browser.adbBrowserVersion)
    300           browserName.textContent += ' (' + browser.adbBrowserVersion + ')';
    301         browserSection.appendChild(browserHeader);
    302 
    303         if (incompatibleVersion) {
    304           var warningSection = document.createElement('div');
    305           warningSection.className = 'warning';
    306           warningSection.textContent =
    307             'You may need a newer version of desktop Chrome. ' +
    308             'Please try Chrome ' + browser.adbBrowserVersion + ' or later.';
    309           browserHeader.appendChild(warningSection);
    310         } else if (majorChromeVersion >= MIN_VERSION_NEW_TAB) {
    311           var newPage = document.createElement('div');
    312           newPage.className = 'open';
    313 
    314           var newPageUrl = document.createElement('input');
    315           newPageUrl.type = 'text';
    316           newPageUrl.placeholder = 'Open tab with url';
    317           newPage.appendChild(newPageUrl);
    318 
    319           var openHandler = function(sourceId, browserId, input) {
    320             sendCommand(
    321                 'open', sourceId, browserId, input.value || 'about:blank');
    322             input.value = '';
    323           }.bind(null, browser.source, browser.id, newPageUrl);
    324           newPageUrl.addEventListener('keyup', function(handler, event) {
    325             if (event.keyIdentifier == 'Enter' && event.target.value)
    326               handler();
    327           }.bind(null, openHandler), true);
    328 
    329           var newPageButton = document.createElement('button');
    330           newPageButton.textContent = 'Open';
    331           newPage.appendChild(newPageButton);
    332           newPageButton.addEventListener('click', openHandler, true);
    333 
    334           browserHeader.appendChild(newPage);
    335         }
    336 
    337         var browserInspector;
    338         var browserInspectorTitle;
    339         if ('trace' in queryParamsObject || 'tracing' in queryParamsObject) {
    340           browserInspector = 'chrome://tracing';
    341           browserInspectorTitle = 'trace';
    342         } else {
    343           browserInspector = queryParamsObject['browser-inspector'];
    344           browserInspectorTitle = 'inspect';
    345         }
    346         if (browserInspector) {
    347           var link = document.createElement('span');
    348           link.classList.add('action');
    349           link.setAttribute('tabindex', 1);
    350           link.textContent = browserInspectorTitle;
    351           browserHeader.appendChild(link);
    352           link.addEventListener(
    353               'click',
    354               sendCommand.bind(null, 'inspect-browser', browser.source,
    355                   browser.id, browserInspector), false);
    356         }
    357 
    358         pageList = document.createElement('div');
    359         pageList.className = 'list pages';
    360         browserSection.appendChild(pageList);
    361       }
    362 
    363       if (incompatibleVersion || alreadyDisplayed(browserSection, browser))
    364         continue;
    365 
    366       pageList.textContent = '';
    367       for (var p = 0; p < browser.pages.length; p++) {
    368         var page = browser.pages[p];
    369         // Attached targets have no unique id until Chrome 26. For such targets
    370         // it is impossible to activate existing DevTools window.
    371         page.hasNoUniqueId = page.attached &&
    372             (majorChromeVersion && majorChromeVersion < MIN_VERSION_TARGET_ID);
    373         var row = addTargetToList(page, pageList, ['name', 'url']);
    374         if (page['description'])
    375           addWebViewDetails(row, page);
    376         else
    377           addFavicon(row, page);
    378         if (majorChromeVersion >= MIN_VERSION_TAB_ACTIVATE) {
    379           addActionLink(row, 'focus tab',
    380               sendTargetCommand.bind(null, 'activate', page), false);
    381         }
    382         if (majorChromeVersion) {
    383           addActionLink(row, 'reload',
    384               sendTargetCommand.bind(null, 'reload', page), page.attached);
    385         }
    386         if (majorChromeVersion >= MIN_VERSION_TAB_CLOSE) {
    387           addActionLink(row, 'close',
    388               sendTargetCommand.bind(null, 'close', page), false);
    389         }
    390       }
    391     }
    392   }
    393 }
    394 
    395 function addToPagesList(data) {
    396   var row = addTargetToList(data, $('pages-list'), ['name', 'url']);
    397   addFavicon(row, data);
    398   if (data.guests)
    399     addGuestViews(row, data.guests);
    400 }
    401 
    402 function addToExtensionsList(data) {
    403   var row = addTargetToList(data, $('extensions-list'), ['name', 'url']);
    404   addFavicon(row, data);
    405   if (data.guests)
    406     addGuestViews(row, data.guests);
    407 }
    408 
    409 function addToAppsList(data) {
    410   var row = addTargetToList(data, $('apps-list'), ['name', 'url']);
    411   addFavicon(row, data);
    412   if (data.guests)
    413     addGuestViews(row, data.guests);
    414 }
    415 
    416 function addGuestViews(row, guests) {
    417   Array.prototype.forEach.call(guests, function(guest) {
    418     var guestRow = addTargetToList(guest, row, ['name', 'url']);
    419     guestRow.classList.add('guest');
    420     addFavicon(guestRow, guest);
    421   });
    422 }
    423 
    424 function addToWorkersList(data) {
    425   var row =
    426       addTargetToList(data, $('workers-list'), ['name', 'description', 'url']);
    427   addActionLink(row, 'terminate',
    428       sendTargetCommand.bind(null, 'close', data), false);
    429 }
    430 
    431 function addToOthersList(data) {
    432   addTargetToList(data, $('others-list'), ['url']);
    433 }
    434 
    435 function formatValue(data, property) {
    436   var value = data[property];
    437 
    438   if (property == 'name' && value == '') {
    439     value = 'untitled';
    440   }
    441 
    442   var text = value ? String(value) : '';
    443   if (text.length > 100)
    444     text = text.substring(0, 100) + '\u2026';
    445 
    446   var div = document.createElement('div');
    447   div.textContent = text;
    448   div.className = property;
    449   return div;
    450 }
    451 
    452 function addFavicon(row, data) {
    453   var favicon = document.createElement('img');
    454   if (data['faviconUrl'])
    455     favicon.src = data['faviconUrl'];
    456   var propertiesBox = row.querySelector('.properties-box');
    457   propertiesBox.insertBefore(favicon, propertiesBox.firstChild);
    458 }
    459 
    460 function addWebViewDetails(row, data) {
    461   var webview;
    462   try {
    463     webview = JSON.parse(data['description']);
    464   } catch (e) {
    465     return;
    466   }
    467   addWebViewDescription(row, webview);
    468   if (data.adbScreenWidth && data.adbScreenHeight)
    469     addWebViewThumbnail(
    470         row, webview, data.adbScreenWidth, data.adbScreenHeight);
    471 }
    472 
    473 function addWebViewDescription(row, webview) {
    474   var viewStatus = { visibility: '', position: '', size: '' };
    475   if (!webview.empty) {
    476     if (webview.attached && !webview.visible)
    477       viewStatus.visibility = 'hidden';
    478     else if (!webview.attached)
    479       viewStatus.visibility = 'detached';
    480     viewStatus.size = 'size ' + webview.width + ' \u00d7 ' + webview.height;
    481   } else {
    482     viewStatus.visibility = 'empty';
    483   }
    484   if (webview.attached) {
    485       viewStatus.position =
    486         'at (' + webview.screenX + ', ' + webview.screenY + ')';
    487   }
    488 
    489   var subRow = document.createElement('div');
    490   subRow.className = 'subrow webview';
    491   if (webview.empty || !webview.attached || !webview.visible)
    492     subRow.className += ' invisible-view';
    493   if (viewStatus.visibility)
    494     subRow.appendChild(formatValue(viewStatus, 'visibility'));
    495   if (viewStatus.position)
    496     subRow.appendChild(formatValue(viewStatus, 'position'));
    497   subRow.appendChild(formatValue(viewStatus, 'size'));
    498   var subrowBox = row.querySelector('.subrow-box');
    499   subrowBox.insertBefore(subRow, row.querySelector('.actions'));
    500 }
    501 
    502 function addWebViewThumbnail(row, webview, screenWidth, screenHeight) {
    503   var maxScreenRectSize = 50;
    504   var screenRectWidth;
    505   var screenRectHeight;
    506 
    507   var aspectRatio = screenWidth / screenHeight;
    508   if (aspectRatio < 1) {
    509     screenRectWidth = Math.round(maxScreenRectSize * aspectRatio);
    510     screenRectHeight = maxScreenRectSize;
    511   } else {
    512     screenRectWidth = maxScreenRectSize;
    513     screenRectHeight = Math.round(maxScreenRectSize / aspectRatio);
    514   }
    515 
    516   var thumbnail = document.createElement('div');
    517   thumbnail.className = 'webview-thumbnail';
    518   var thumbnailWidth = 3 * screenRectWidth;
    519   var thumbnailHeight = 60;
    520   thumbnail.style.width = thumbnailWidth + 'px';
    521   thumbnail.style.height = thumbnailHeight + 'px';
    522 
    523   var screenRect = document.createElement('div');
    524   screenRect.className = 'screen-rect';
    525   screenRect.style.left = screenRectWidth + 'px';
    526   screenRect.style.top = (thumbnailHeight - screenRectHeight) / 2 + 'px';
    527   screenRect.style.width = screenRectWidth + 'px';
    528   screenRect.style.height = screenRectHeight + 'px';
    529   thumbnail.appendChild(screenRect);
    530 
    531   if (!webview.empty && webview.attached) {
    532     var viewRect = document.createElement('div');
    533     viewRect.className = 'view-rect';
    534     if (!webview.visible)
    535       viewRect.classList.add('hidden');
    536     function percent(ratio) {
    537       return ratio * 100 + '%';
    538     }
    539     viewRect.style.left = percent(webview.screenX / screenWidth);
    540     viewRect.style.top = percent(webview.screenY / screenHeight);
    541     viewRect.style.width = percent(webview.width / screenWidth);
    542     viewRect.style.height = percent(webview.height / screenHeight);
    543     screenRect.appendChild(viewRect);
    544   }
    545 
    546   var propertiesBox = row.querySelector('.properties-box');
    547   propertiesBox.insertBefore(thumbnail, propertiesBox.firstChild);
    548 }
    549 
    550 function addTargetToList(data, list, properties) {
    551   var row = document.createElement('div');
    552   row.className = 'row';
    553 
    554   var propertiesBox = document.createElement('div');
    555   propertiesBox.className = 'properties-box';
    556   row.appendChild(propertiesBox);
    557 
    558   var subrowBox = document.createElement('div');
    559   subrowBox.className = 'subrow-box';
    560   propertiesBox.appendChild(subrowBox);
    561 
    562   var subrow = document.createElement('div');
    563   subrow.className = 'subrow';
    564   subrowBox.appendChild(subrow);
    565 
    566   for (var j = 0; j < properties.length; j++)
    567     subrow.appendChild(formatValue(data, properties[j]));
    568 
    569   var actionBox = document.createElement('div');
    570   actionBox.className = 'actions';
    571   subrowBox.appendChild(actionBox);
    572 
    573   if (!data.hasCustomInspectAction) {
    574     addActionLink(row, 'inspect', sendTargetCommand.bind(null, 'inspect', data),
    575         data.hasNoUniqueId || data.adbAttachedForeign);
    576   }
    577 
    578   list.appendChild(row);
    579   return row;
    580 }
    581 
    582 function addActionLink(row, text, handler, opt_disabled) {
    583   var link = document.createElement('span');
    584   link.classList.add('action');
    585   link.setAttribute('tabindex', 1);
    586   if (opt_disabled)
    587     link.classList.add('disabled');
    588   else
    589     link.classList.remove('disabled');
    590 
    591   link.textContent = text;
    592   link.addEventListener('click', handler, true);
    593   function handleKey(e) {
    594     if (e.keyIdentifier == 'Enter' || e.keyIdentifier == 'U+0020') {
    595       e.preventDefault();
    596       handler();
    597     }
    598   }
    599   link.addEventListener('keydown', handleKey, true);
    600   row.querySelector('.actions').appendChild(link);
    601 }
    602 
    603 
    604 function initSettings() {
    605   $('discover-usb-devices-enable').addEventListener('change',
    606                                                     enableDiscoverUsbDevices);
    607 
    608   $('port-forwarding-enable').addEventListener('change', enablePortForwarding);
    609   $('port-forwarding-config-open').addEventListener(
    610       'click', openPortForwardingConfig);
    611   $('port-forwarding-config-close').addEventListener(
    612       'click', closePortForwardingConfig);
    613   $('port-forwarding-config-done').addEventListener(
    614       'click', commitPortForwardingConfig.bind(true));
    615 }
    616 
    617 function enableDiscoverUsbDevices(event) {
    618   sendCommand('set-discover-usb-devices-enabled', event.target.checked);
    619 }
    620 
    621 function enablePortForwarding(event) {
    622   sendCommand('set-port-forwarding-enabled', event.target.checked);
    623 }
    624 
    625 function handleKey(event) {
    626   switch (event.keyCode) {
    627     case 13:  // Enter
    628       if (event.target.nodeName == 'INPUT') {
    629         var line = event.target.parentNode;
    630         if (!line.classList.contains('fresh') ||
    631             line.classList.contains('empty')) {
    632           commitPortForwardingConfig(true);
    633         } else {
    634           commitFreshLineIfValid(true /* select new line */);
    635           commitPortForwardingConfig(false);
    636         }
    637       } else {
    638         commitPortForwardingConfig(true);
    639       }
    640       break;
    641 
    642     case 27:
    643       commitPortForwardingConfig(true);
    644       break;
    645   }
    646 }
    647 
    648 function setModal(dialog) {
    649   dialog.deactivatedNodes = Array.prototype.filter.call(
    650       document.querySelectorAll('*'),
    651       function(n) {
    652         return n != dialog && !dialog.contains(n) && n.tabIndex >= 0;
    653       });
    654 
    655   dialog.tabIndexes = dialog.deactivatedNodes.map(
    656     function(n) { return n.getAttribute('tabindex'); });
    657 
    658   dialog.deactivatedNodes.forEach(function(n) { n.tabIndex = -1; });
    659   window.modal = dialog;
    660 }
    661 
    662 function unsetModal(dialog) {
    663   for (var i = 0; i < dialog.deactivatedNodes.length; i++) {
    664     var node = dialog.deactivatedNodes[i];
    665     if (dialog.tabIndexes[i] === null)
    666       node.removeAttribute('tabindex');
    667     else
    668       node.setAttribute('tabindex', dialog.tabIndexes[i]);
    669   }
    670 
    671   if (window.holdDevices) {
    672     populateRemoteTargets(window.holdDevices);
    673     delete window.holdDevices;
    674   }
    675 
    676   delete dialog.deactivatedNodes;
    677   delete dialog.tabIndexes;
    678   delete window.modal;
    679 }
    680 
    681 function openPortForwardingConfig() {
    682   loadPortForwardingConfig(window.portForwardingConfig);
    683 
    684   $('port-forwarding-overlay').classList.add('open');
    685   document.addEventListener('keyup', handleKey);
    686 
    687   var freshPort = document.querySelector('.fresh .port');
    688   if (freshPort)
    689     freshPort.focus();
    690   else
    691     $('port-forwarding-config-done').focus();
    692 
    693   setModal($('port-forwarding-overlay'));
    694 }
    695 
    696 function closePortForwardingConfig() {
    697   if (!$('port-forwarding-overlay').classList.contains('open'))
    698     return;
    699 
    700   $('port-forwarding-overlay').classList.remove('open');
    701   document.removeEventListener('keyup', handleKey);
    702   unsetModal($('port-forwarding-overlay'));
    703 }
    704 
    705 function loadPortForwardingConfig(config) {
    706   var list = $('port-forwarding-config-list');
    707   list.textContent = '';
    708   for (var port in config)
    709     list.appendChild(createConfigLine(port, config[port]));
    710   list.appendChild(createEmptyConfigLine());
    711 }
    712 
    713 function commitPortForwardingConfig(closeConfig) {
    714   if (closeConfig)
    715     closePortForwardingConfig();
    716 
    717   commitFreshLineIfValid();
    718   var lines = document.querySelectorAll('.port-forwarding-pair');
    719   var config = {};
    720   for (var i = 0; i != lines.length; i++) {
    721     var line = lines[i];
    722     var portInput = line.querySelector('.port');
    723     var locationInput = line.querySelector('.location');
    724 
    725     var port = portInput.classList.contains('invalid') ?
    726                portInput.lastValidValue :
    727                portInput.value;
    728 
    729     var location = locationInput.classList.contains('invalid') ?
    730                    locationInput.lastValidValue :
    731                    locationInput.value;
    732 
    733     if (port && location)
    734       config[port] = location;
    735   }
    736   sendCommand('set-port-forwarding-config', config);
    737 }
    738 
    739 function updateDiscoverUsbDevicesEnabled(enabled) {
    740   var checkbox = $('discover-usb-devices-enable');
    741   checkbox.checked = !!enabled;
    742   checkbox.disabled = false;
    743 }
    744 
    745 function updatePortForwardingEnabled(enabled) {
    746   var checkbox = $('port-forwarding-enable');
    747   checkbox.checked = !!enabled;
    748   checkbox.disabled = false;
    749 }
    750 
    751 function updatePortForwardingConfig(config) {
    752   window.portForwardingConfig = config;
    753   $('port-forwarding-config-open').disabled = !config;
    754 }
    755 
    756 function createConfigLine(port, location) {
    757   var line = document.createElement('div');
    758   line.className = 'port-forwarding-pair';
    759 
    760   var portInput = createConfigField(port, 'port', 'Port', validatePort);
    761   line.appendChild(portInput);
    762 
    763   var locationInput = createConfigField(
    764       location, 'location', 'IP address and port', validateLocation);
    765   line.appendChild(locationInput);
    766   locationInput.addEventListener('keydown', function(e) {
    767     if (e.keyIdentifier == 'U+0009' &&  // Tab
    768         !e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey &&
    769         line.classList.contains('fresh') &&
    770         !line.classList.contains('empty')) {
    771       // Tabbing forward on the fresh line, try create a new empty one.
    772       if (commitFreshLineIfValid(true))
    773         e.preventDefault();
    774     }
    775   });
    776 
    777   var lineDelete = document.createElement('div');
    778   lineDelete.className = 'close-button';
    779   lineDelete.addEventListener('click', function() {
    780     var newSelection = line.nextElementSibling;
    781     line.parentNode.removeChild(line);
    782     selectLine(newSelection);
    783   });
    784   line.appendChild(lineDelete);
    785 
    786   line.addEventListener('click', selectLine.bind(null, line));
    787   line.addEventListener('focus', selectLine.bind(null, line));
    788 
    789   checkEmptyLine(line);
    790 
    791   return line;
    792 }
    793 
    794 function validatePort(input) {
    795   var match = input.value.match(/^(\d+)$/);
    796   if (!match)
    797     return false;
    798   var port = parseInt(match[1]);
    799   if (port < 1024 || 65535 < port)
    800     return false;
    801 
    802   var inputs = document.querySelectorAll('input.port:not(.invalid)');
    803   for (var i = 0; i != inputs.length; ++i) {
    804     if (inputs[i] == input)
    805       break;
    806     if (parseInt(inputs[i].value) == port)
    807       return false;
    808   }
    809   return true;
    810 }
    811 
    812 function validateLocation(input) {
    813   var match = input.value.match(/^([a-zA-Z0-9\.\-_]+):(\d+)$/);
    814   if (!match)
    815     return false;
    816   var port = parseInt(match[2]);
    817   return port <= 65535;
    818 }
    819 
    820 function createEmptyConfigLine() {
    821   var line = createConfigLine('', '');
    822   line.classList.add('fresh');
    823   return line;
    824 }
    825 
    826 function createConfigField(value, className, hint, validate) {
    827   var input = document.createElement('input');
    828   input.className = className;
    829   input.type = 'text';
    830   input.placeholder = hint;
    831   input.value = value;
    832   input.lastValidValue = value;
    833 
    834   function checkInput() {
    835     if (validate(input))
    836       input.classList.remove('invalid');
    837     else
    838       input.classList.add('invalid');
    839     if (input.parentNode)
    840       checkEmptyLine(input.parentNode);
    841   }
    842   checkInput();
    843 
    844   input.addEventListener('keyup', checkInput);
    845   input.addEventListener('focus', function() {
    846     selectLine(input.parentNode);
    847   });
    848 
    849   input.addEventListener('blur', function() {
    850     if (validate(input))
    851       input.lastValidValue = input.value;
    852   });
    853 
    854   return input;
    855 }
    856 
    857 function checkEmptyLine(line) {
    858   var inputs = line.querySelectorAll('input');
    859   var empty = true;
    860   for (var i = 0; i != inputs.length; i++) {
    861     if (inputs[i].value != '')
    862       empty = false;
    863   }
    864   if (empty)
    865     line.classList.add('empty');
    866   else
    867     line.classList.remove('empty');
    868 }
    869 
    870 function selectLine(line) {
    871   if (line.classList.contains('selected'))
    872     return;
    873   unselectLine();
    874   line.classList.add('selected');
    875 }
    876 
    877 function unselectLine() {
    878   var line = document.querySelector('.port-forwarding-pair.selected');
    879   if (!line)
    880     return;
    881   line.classList.remove('selected');
    882   commitFreshLineIfValid();
    883 }
    884 
    885 function commitFreshLineIfValid(opt_selectNew) {
    886   var line = document.querySelector('.port-forwarding-pair.fresh');
    887   if (line.querySelector('.invalid'))
    888     return false;
    889   line.classList.remove('fresh');
    890   var freshLine = createEmptyConfigLine();
    891   line.parentNode.appendChild(freshLine);
    892   if (opt_selectNew)
    893     freshLine.querySelector('.port').focus();
    894   return true;
    895 }
    896 
    897 function populatePortStatus(devicesStatusMap) {
    898   for (var deviceId in devicesStatusMap) {
    899     if (!devicesStatusMap.hasOwnProperty(deviceId))
    900       continue;
    901     var deviceStatusMap = devicesStatusMap[deviceId];
    902 
    903     var deviceSection = $(deviceId);
    904     if (!deviceSection)
    905       continue;
    906 
    907     var devicePorts = deviceSection.querySelector('.device-ports');
    908     devicePorts.textContent = '';
    909     for (var port in deviceStatusMap) {
    910       if (!deviceStatusMap.hasOwnProperty(port))
    911         continue;
    912 
    913       var status = deviceStatusMap[port];
    914       var portIcon = document.createElement('div');
    915       portIcon.className = 'port-icon';
    916       // status === 0 is the default (connected) state.
    917       // Positive values correspond to the tunnelling connection count
    918       // (in DEBUG_DEVTOOLS mode).
    919       if (status > 0)
    920         portIcon.classList.add('connected');
    921       else if (status === -1 || status === -2)
    922         portIcon.classList.add('transient');
    923       else if (status < 0)
    924         portIcon.classList.add('error');
    925       devicePorts.appendChild(portIcon);
    926 
    927       var portNumber = document.createElement('div');
    928       portNumber.className = 'port-number';
    929       portNumber.textContent = ':' + port;
    930       if (status > 0)
    931         portNumber.textContent += '(' + status + ')';
    932       devicePorts.appendChild(portNumber);
    933     }
    934   }
    935 
    936   function clearPorts(deviceSection) {
    937     if (deviceSection.id in devicesStatusMap)
    938       return;
    939     deviceSection.querySelector('.device-ports').textContent = '';
    940   }
    941 
    942   Array.prototype.forEach.call(
    943       document.querySelectorAll('.device'), clearPorts);
    944 }
    945 
    946 document.addEventListener('DOMContentLoaded', onload);
    947 
    948 window.addEventListener('hashchange', onHashChange);
    949