chromium/chrome/browser/resources/inspect/inspect.js

// Copyright 2012 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {assert} from 'chrome://resources/js/assert.js';

function $(id) {
  // Disable getElementById restriction here, because this UI uses non valid
  // selectors that don't work with querySelector().
  // eslint-disable-next-line no-restricted-properties
  const el = document.getElementById(id);
  if (!el) {
    return null;
  }
  assert(el instanceof HTMLElement);
  return el;
}

const MIN_VERSION_TAB_CLOSE = 25;
const MIN_VERSION_TARGET_ID = 26;
const MIN_VERSION_NEW_TAB = 29;
const MIN_VERSION_TAB_ACTIVATE = 30;
const WEBRTC_SERIAL = 'WEBRTC';
let HOST_CHROME_VERSION;

const queryParamsObject = {};
let browserInspector = 'chrome://tracing';
let browserInspectorTitle = 'trace';

(function() {
const queryParams = window.location.search;
if (!queryParams) {
  return;
}
const params = queryParams.substring(1).split('&');
for (let i = 0; i < params.length; ++i) {
  const pair = params[i].split('=');
  queryParamsObject[pair[0]] = pair[1];
}

if ('browser-inspector' in queryParamsObject) {
  browserInspector = queryParamsObject['browser-inspector'];
  browserInspectorTitle = 'inspect';
}
})();

function isVersionNewerThanHost(version) {
  if (!HOST_CHROME_VERSION) {
    return false;
  }
  version = version.split('.').map(s => Number(s) || 0);
  for (let i = 0; i < HOST_CHROME_VERSION.length; i++) {
    if (i > version.length) {
      return false;
    }
    if (HOST_CHROME_VERSION[i] > version[i]) {
      return false;
    }
    if (HOST_CHROME_VERSION[i] < version[i]) {
      return true;
    }
  }
  return false;
}

function sendCommand(command, args) {
  chrome.send(command, Array.prototype.slice.call(arguments, 1));
}

function sendTargetCommand(command, target) {
  sendCommand(command, target.source, target.id);
}

function removeChildren(element_id) {
  const element = $(element_id);
  element.textContent = '';
}

function onload() {
  const tabContents = document.querySelectorAll('#content > div');
  for (let i = 0; i !== tabContents.length; i++) {
    const tabContent = tabContents[i];
    const tabName = tabContent.querySelector('.content-header').textContent;

    const tabHeader = document.createElement('div');
    tabHeader.className = 'tab-header';
    tabHeader.id = 'tab-'.concat(tabContent.id);
    const button = document.createElement('button');
    button.textContent = tabName;
    tabHeader.appendChild(button);
    tabHeader.addEventListener('click', selectTab.bind(null, tabContent.id));
    $('navigation').appendChild(tabHeader);
  }
  $('tab-native-ui').hidden = true;
  onHashChange();
  initSettings();
  sendCommand('init-ui');
}

function onHashChange() {
  const hash = window.location.hash.slice(1).toLowerCase();
  if (!selectTab(hash)) {
    selectTab('devices');
  }
}

/**
 * @param {string} id Tab id.
 * @return {boolean} True if successful.
 */
function selectTab(id) {
  const tabContents = document.querySelectorAll('#content > div');
  const tabHeaders = $('navigation').querySelectorAll('.tab-header');
  let found = false;
  for (let i = 0; i !== tabContents.length; i++) {
    const tabContent = tabContents[i];
    const tabHeader = tabHeaders[i];
    if (tabContent.id === id) {
      tabContent.classList.add('selected');
      tabHeader.classList.add('selected');
      found = true;
    } else {
      tabContent.classList.remove('selected');
      tabHeader.classList.remove('selected');
    }
  }
  if (!found) {
    return false;
  }
  window.location.hash = id;
  return true;
}

function populateTargets(source, data) {
  if (source === 'local') {
    populateLocalTargets(data);
  } else if (source === 'remote') {
    populateRemoteTargets(data);
  } else {
    console.error('Unknown source type: ' + source);
  }
}

function populateNativeUITargets(data) {
  removeChildren('native-ui-list');
  for (let i = 0; i < data.length; i++) {
    addToNativeUIList(data[i]);
  }
}

function showNativeUILaunchButton(enabled) {
  $('native-ui').hidden = false;
  $('tab-native-ui').hidden = false;
  $('launch-ui-devtools').hidden = false;
  $('launch-ui-devtools').disabled = !enabled;
  $('ui-devtools-disabled-text').hidden = enabled;
  $('ui-devtools-enabled-text').hidden = !enabled;
}

function setHostVersion(version) {
  HOST_CHROME_VERSION = version;
}

function populateLocalTargets(data) {
  removeChildren('pages-list');
  removeChildren('extensions-list');
  removeChildren('apps-list');
  removeChildren('workers-list');
  removeChildren('service-workers-list');
  removeChildren('shared-storage-worklets-list');
  removeChildren('others-list');

  data.sort((a, b) => a.name.localeCompare(b.name));

  for (let i = 0; i < data.length; i++) {
    if (data[i].type === 'page') {
      addToPagesList(data[i]);
    } else if (data[i].type === 'background_page') {
      addToExtensionsList(data[i]);
    } else if (data[i].type === 'app') {
      addToAppsList(data[i]);
    } else if (data[i].type === 'shared_worker') {
      addToWorkersList(data[i]);
    } else if (data[i].type === 'service_worker') {
      addToServiceWorkersList(data[i]);
    } else if (data[i].type === 'shared_storage_worklet') {
      addToSharedStorageWorkletsList(data[i]);
    } else {
      addToOthersList(data[i]);
    }
  }
}

function showIncognitoWarning() {
  $('devices-incognito').hidden = false;
}

function alreadyDisplayed(element, data) {
  const json = JSON.stringify(data);
  if (element.cachedJSON === json) {
    return true;
  }
  element.cachedJSON = json;
  return false;
}

function updateBrowserVisibility(browserSection) {
  const icon = browserSection.querySelector('.used-for-port-forwarding');
  browserSection.hidden = !browserSection.querySelector('.open') &&
      !browserSection.querySelector('.row') && (!icon || icon.hidden);
}

function updateUsernameVisibility(deviceSection) {
  const users = new Set();
  const browsers = deviceSection.querySelectorAll('.browser');

  Array.prototype.forEach.call(browsers, function(browserSection) {
    if (!browserSection.hidden) {
      const browserUser = browserSection.querySelector('.browser-user');
      if (browserUser) {
        users.add(browserUser.textContent);
      }
    }
  });
  const hasSingleUser = users.size <= 1;

  Array.prototype.forEach.call(browsers, function(browserSection) {
    const browserUser = browserSection.querySelector('.browser-user');
    if (browserUser) {
      browserUser.hidden = hasSingleUser;
    }
  });
}

function populateRemoteTargets(devices) {
  if (!devices) {
    return;
  }

  if ($('config-dialog').open) {
    window.holdDevices = devices;
    return;
  }

  function browserCompare(a, b) {
    if (a.adbBrowserName !== b.adbBrowserName) {
      return a.adbBrowserName < b.adbBrowserName;
    }
    if (a.adbBrowserVersion !== b.adbBrowserVersion) {
      return a.adbBrowserVersion < b.adbBrowserVersion;
    }
    return a.id < b.id;
  }

  function insertBrowser(browserList, browser) {
    for (let sibling = browserList.firstElementChild; sibling;
         sibling = sibling.nextElementSibling) {
      if (browserCompare(browser, sibling)) {
        browserList.insertBefore(browser, sibling);
        return;
      }
    }
    browserList.appendChild(browser);
  }

  const deviceList = $('devices-list');
  if (alreadyDisplayed(deviceList, devices)) {
    return;
  }

  function removeObsolete(validIds, section) {
    if (validIds.indexOf(section.id) < 0) {
      section.remove();
    }
  }

  const newDeviceIds = devices.map(function(d) {
    return d.id;
  });
  Array.prototype.forEach.call(
      deviceList.querySelectorAll('.device'),
      removeObsolete.bind(null, newDeviceIds));

  $('devices-help').hidden = !!devices.length;

  for (let d = 0; d < devices.length; d++) {
    const device = devices[d];

    let deviceSection = $(device.id);
    if (!deviceSection) {
      deviceSection = document.createElement('div');
      deviceSection.id = device.id;
      deviceSection.className = 'device';
      deviceList.appendChild(deviceSection);

      const deviceHeader = document.createElement('div');
      deviceHeader.className = 'device-header';
      deviceSection.appendChild(deviceHeader);

      const deviceName = document.createElement('div');
      deviceName.className = 'device-name';
      deviceHeader.appendChild(deviceName);

      const deviceSerial = document.createElement('div');
      deviceSerial.className = 'device-serial';
      const serial = device.adbSerial.toUpperCase();
      deviceSerial.textContent = '#' + serial;
      deviceHeader.appendChild(deviceSerial);

      if (serial === WEBRTC_SERIAL) {
        deviceHeader.classList.add('hidden');
      }

      const devicePorts = document.createElement('div');
      devicePorts.className = 'device-ports';
      deviceHeader.appendChild(devicePorts);

      const browserList = document.createElement('div');
      browserList.className = 'browsers';
      deviceSection.appendChild(browserList);

      const authenticating = document.createElement('div');
      authenticating.className = 'device-auth';
      deviceSection.appendChild(authenticating);
    }

    if (alreadyDisplayed(deviceSection, device)) {
      continue;
    }

    deviceSection.querySelector('.device-name').textContent = device.adbModel;
    deviceSection.querySelector('.device-auth').textContent =
        device.adbConnected ? '' :
                              'Pending authentication: please accept ' +
            'debugging session on the device.';

    const browserList = deviceSection.querySelector('.browsers');
    const newBrowserIds = device.browsers.map(function(b) {
      return b.id;
    });
    Array.prototype.forEach.call(
        browserList.querySelectorAll('.browser'),
        removeObsolete.bind(null, newBrowserIds));

    for (let b = 0; b < device.browsers.length; b++) {
      const browser = device.browsers[b];
      const majorChromeVersion = browser.adbBrowserChromeVersion;
      let pageList;
      let browserSection = $(browser.id);
      const browserNeedsFallback =
          isVersionNewerThanHost(browser.adbBrowserVersion);
      if (browserSection) {
        pageList = browserSection.querySelector('.pages');
      } else {
        browserSection = document.createElement('div');
        browserSection.id = browser.id;
        browserSection.className = 'browser';
        insertBrowser(browserList, browserSection);

        const browserHeader = document.createElement('div');
        browserHeader.className = 'browser-header';

        const browserName = document.createElement('div');
        browserName.className = 'browser-name';
        browserHeader.appendChild(browserName);
        browserName.textContent = browser.adbBrowserName;
        if (browser.adbBrowserVersion) {
          browserName.textContent += ' (' + browser.adbBrowserVersion + ')';
        }
        if (browser.adbBrowserUser) {
          const browserUser = document.createElement('div');
          browserUser.className = 'browser-user';
          browserUser.textContent = browser.adbBrowserUser;
          browserHeader.appendChild(browserUser);
        }
        browserSection.appendChild(browserHeader);

        if (browserNeedsFallback) {
          const browserFallbackNote = document.createElement('div');
          browserFallbackNote.className = 'browser-fallback-note';
          browserFallbackNote.textContent =
              '\u26A0 Remote browser is newer than client browser. ' +
              'Try `inspect fallback` if inspection fails.';
          browserSection.appendChild(browserFallbackNote);
        }

        if (majorChromeVersion >= MIN_VERSION_NEW_TAB) {
          const newPage = document.createElement('div');
          newPage.className = 'open';

          const newPageUrl = document.createElement('input');
          newPageUrl.type = 'text';
          newPageUrl.placeholder = 'Open tab with url';
          newPage.appendChild(newPageUrl);

          const openHandler = function(sourceId, browserId, input) {
            sendCommand(
                'open', sourceId, browserId, input.value || 'about:blank');
            input.value = '';
          }.bind(null, browser.source, browser.id, newPageUrl);
          newPageUrl.addEventListener('keyup', function(handler, event) {
            if (event.key === 'Enter' && event.target.value) {
              handler();
            }
          }.bind(null, openHandler), true);

          const newPageButton = document.createElement('button');
          newPageButton.textContent = 'Open';
          newPage.appendChild(newPageButton);
          newPageButton.addEventListener('click', openHandler, true);

          browserHeader.appendChild(newPage);
        }

        const portForwardingInfo = document.createElement('div');
        portForwardingInfo.className = 'used-for-port-forwarding';
        portForwardingInfo.hidden = true;
        portForwardingInfo.title = 'This browser is used for port ' +
            'forwarding. Closing it will drop current connections.';
        browserHeader.appendChild(portForwardingInfo);

        const link = document.createElement('span');
        link.classList.add('action');
        link.setAttribute('tabindex', 1);
        link.textContent = browserInspectorTitle;
        browserHeader.appendChild(link);
        link.addEventListener(
            'click',
            sendCommand.bind(
                null, 'inspect-browser', browser.source, browser.id,
                browserInspector),
            false);

        pageList = document.createElement('div');
        pageList.className = 'list pages';
        browserSection.appendChild(pageList);
      }

      if (!alreadyDisplayed(browserSection, browser)) {
        pageList.textContent = '';
        for (let p = 0; p < browser.pages.length; p++) {
          const page = browser.pages[p];
          // Attached targets have no unique id until Chrome 26. For such
          // targets it is impossible to activate existing DevTools window.
          page.hasNoUniqueId = page.attached && majorChromeVersion &&
              majorChromeVersion < MIN_VERSION_TARGET_ID;
          const row = addTargetToList(page, pageList, ['name', 'url']);
          if (page['description']) {
            addWebViewDetails(row, page);
          } else {
            addFavicon(row, page);
          }
          if (majorChromeVersion >= MIN_VERSION_TAB_ACTIVATE) {
            addActionLink(
                row, 'focus tab',
                sendTargetCommand.bind(null, 'activate', page), false);
          }
          if (majorChromeVersion) {
            addActionLink(
                row, 'reload', sendTargetCommand.bind(null, 'reload', page),
                page.attached);
          }
          if (majorChromeVersion >= MIN_VERSION_TAB_CLOSE) {
            addActionLink(
                row, 'close', sendTargetCommand.bind(null, 'close', page),
                false);
          }
          if (browserNeedsFallback) {
            addActionLink(
                row, 'inspect fallback',
                sendTargetCommand.bind(null, 'inspect-fallback', page),
                page.hasNoUniqueId || page.adbAttachedForeign);
          }
        }
      }
      updateBrowserVisibility(browserSection);
    }
    updateUsernameVisibility(deviceSection);
  }
}

function addToPagesList(data) {
  const row = addTargetToList(data, $('pages-list'), ['name', 'url']);
  addFavicon(row, data);
  if (data.guests) {
    addGuestViews(row, data.guests);
  }
}

function addToExtensionsList(data) {
  const row = addTargetToList(data, $('extensions-list'), ['name', 'url']);
  addFavicon(row, data);
  if (data.guests) {
    addGuestViews(row, data.guests);
  }
}

function addToAppsList(data) {
  const row = addTargetToList(data, $('apps-list'), ['name', 'url']);
  addFavicon(row, data);
  if (data.guests) {
    addGuestViews(row, data.guests);
  }
}

function addGuestViews(row, guests) {
  Array.prototype.forEach.call(guests, function(guest) {
    const guestRow = addTargetToList(guest, row, ['name', 'url']);
    guestRow.classList.add('guest');
    addFavicon(guestRow, guest);
  });
}

function addToWorkersList(data) {
  const row =
      addTargetToList(data, $('workers-list'), ['name', 'description', 'url']);
  addActionLink(
      row, 'terminate', sendTargetCommand.bind(null, 'close', data), false);
}

function addToServiceWorkersList(data) {
  const row = addTargetToList(
      data, $('service-workers-list'), ['name', 'description', 'url']);
  addActionLink(
      row, 'terminate', sendTargetCommand.bind(null, 'close', data), false);
}

function addToSharedStorageWorkletsList(data) {
  const row = addTargetToList(
      data, $('shared-storage-worklets-list'), ['name', 'description', 'url']);
  // TODO(yaoxia): add the "terminate" link when the backend supports it
}

function addToOthersList(data) {
  addTargetToList(data, $('others-list'), ['url']);
}

function addToNativeUIList(data) {
  addTargetToList(data, $('native-ui-list'), ['name', 'url']);
}

function formatValue(data, property) {
  let value = data[property];

  if (property === 'name' && value === '') {
    value = 'untitled';
  }

  let text = value ? String(value) : '';
  if (text.length > 100) {
    text = text.substring(0, 100) + '\u2026';
  }

  const div = document.createElement('div');
  div.textContent = text;
  div.className = property;
  return div;
}

function addFavicon(row, data) {
  const favicon = document.createElement('img');
  if (data['faviconUrl']) {
    favicon.src = data['faviconUrl'];
  }
  const propertiesBox = row.querySelector('.properties-box');
  propertiesBox.insertBefore(favicon, propertiesBox.firstChild);
}

function addWebViewDetails(row, data) {
  let webview;
  try {
    webview = JSON.parse(data['description']);
  } catch (e) {
    return;
  }
  addWebViewDescription(row, webview);
  if (data.adbScreenWidth && data.adbScreenHeight) {
    addWebViewThumbnail(
        row, webview, data.adbScreenWidth, data.adbScreenHeight);
  }
}

function addWebViewDescription(row, webview) {
  const viewStatus = {visibility: '', position: '', size: ''};
  if (!webview.empty) {
    if (webview.attached && !webview.visible) {
      viewStatus.visibility = 'hidden';
    } else if (!webview.attached) {
      viewStatus.visibility = 'detached';
    }
    viewStatus.size = 'size ' + webview.width + ' \u00d7 ' + webview.height;
  } else {
    viewStatus.visibility = 'empty';
  }
  if (webview.never_attached) {
    viewStatus.visibility += ' never-attached';
  }
  if (webview.attached) {
    viewStatus.position =
        'at (' + webview.screenX + ', ' + webview.screenY + ')';
  }

  const subRow = document.createElement('div');
  subRow.className = 'subrow webview';
  if (webview.empty || !webview.attached || !webview.visible) {
    subRow.className += ' invisible-view';
  }
  if (viewStatus.visibility) {
    subRow.appendChild(formatValue(viewStatus, 'visibility'));
  }
  if (viewStatus.position) {
    subRow.appendChild(formatValue(viewStatus, 'position'));
  }
  subRow.appendChild(formatValue(viewStatus, 'size'));
  const subrowBox = row.querySelector('.subrow-box');
  subrowBox.insertBefore(subRow, row.querySelector('.actions'));
}

function addWebViewThumbnail(row, webview, screenWidth, screenHeight) {
  const maxScreenRectSize = 50;
  let screenRectWidth;
  let screenRectHeight;

  const aspectRatio = screenWidth / screenHeight;
  if (aspectRatio < 1) {
    screenRectWidth = Math.round(maxScreenRectSize * aspectRatio);
    screenRectHeight = maxScreenRectSize;
  } else {
    screenRectWidth = maxScreenRectSize;
    screenRectHeight = Math.round(maxScreenRectSize / aspectRatio);
  }

  const thumbnail = document.createElement('div');
  thumbnail.className = 'webview-thumbnail';
  const thumbnailWidth = 3 * screenRectWidth;
  const thumbnailHeight = 60;
  thumbnail.style.width = thumbnailWidth + 'px';
  thumbnail.style.height = thumbnailHeight + 'px';

  const screenRect = document.createElement('div');
  screenRect.className = 'screen-rect';
  screenRect.style.left = screenRectWidth + 'px';
  screenRect.style.top = (thumbnailHeight - screenRectHeight) / 2 + 'px';
  screenRect.style.width = screenRectWidth + 'px';
  screenRect.style.height = screenRectHeight + 'px';
  thumbnail.appendChild(screenRect);

  if (!webview.empty && webview.attached) {
    const viewRect = document.createElement('div');
    viewRect.className = 'view-rect';
    if (!webview.visible) {
      viewRect.classList.add('hidden');
    }
    function percent(ratio) {
      return ratio * 100 + '%';
    }
    viewRect.style.left = percent(webview.screenX / screenWidth);
    viewRect.style.top = percent(webview.screenY / screenHeight);
    viewRect.style.width = percent(webview.width / screenWidth);
    viewRect.style.height = percent(webview.height / screenHeight);
    screenRect.appendChild(viewRect);
  }

  const propertiesBox = row.querySelector('.properties-box');
  propertiesBox.insertBefore(thumbnail, propertiesBox.firstChild);
}

function addTargetToList(data, list, properties) {
  const row = document.createElement('div');
  row.className = 'row';
  row.targetId = data.id;

  const propertiesBox = document.createElement('div');
  propertiesBox.className = 'properties-box';
  row.appendChild(propertiesBox);

  const subrowBox = document.createElement('div');
  subrowBox.className = 'subrow-box';
  propertiesBox.appendChild(subrowBox);

  const subrow = document.createElement('div');
  subrow.className = 'subrow';
  subrowBox.appendChild(subrow);

  for (let j = 0; j < properties.length; j++) {
    subrow.appendChild(formatValue(data, properties[j]));
  }

  const actionBox = document.createElement('div');
  actionBox.className = 'actions';
  subrowBox.appendChild(actionBox);

  if (!data.isNative && !data.hasCustomInspectAction &&
      data.type !== 'iframe') {
    addActionLink(
        row, 'inspect', sendTargetCommand.bind(null, 'inspect', data),
        data.hasNoUniqueId || data.adbAttachedForeign);
    if (data.type === 'page') {
      addActionLink(
          row, 'pause', sendTargetCommand.bind(null, 'pause', data),
          data.hasNoUniqueId || data.adbAttachedForeign);
    }
  }

  list.appendChild(row);
  return row;
}

function addActionLink(row, text, handler, opt_disabled) {
  const link = document.createElement('span');
  link.classList.add('action');
  link.setAttribute('tabindex', 1);
  if (opt_disabled) {
    link.classList.add('disabled');
  } else {
    link.classList.remove('disabled');
  }

  link.textContent = text;
  link.addEventListener('click', handler, true);
  function handleKey(e) {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      handler();
    }
  }
  link.addEventListener('keydown', handleKey, true);
  row.querySelector('.actions').appendChild(link);
}

function initSettings() {
  checkboxSendsCommand(
      'discover-usb-devices-enable', 'set-discover-usb-devices-enabled');
  checkboxSendsCommand('port-forwarding-enable', 'set-port-forwarding-enabled');
  checkboxSendsCommand(
      'discover-tcp-devices-enable', 'set-discover-tcp-targets-enabled');

  $('launch-ui-devtools')
      .addEventListener('click', sendCommand.bind(null, 'launch-ui-devtools'));
  checkboxSendsCommand('bubble-locking-checkbox', 'set-bubble-locking');

  $('port-forwarding-config-open')
      .addEventListener('click', openPortForwardingConfig);
  $('tcp-discovery-config-open').addEventListener('click', openTargetsConfig);
  $('config-dialog-close').addEventListener('click', function() {
    $('config-dialog').commit(true);
  });
  $('node-frontend')
      .addEventListener('click', sendCommand.bind(null, 'open-node-frontend'));
}

function checkboxHandler(command, event) {
  sendCommand(command, event.target.checked);
}

function checkboxSendsCommand(id, command) {
  $(id).addEventListener('change', checkboxHandler.bind(null, command));
}

function handleKey(event) {
  switch (event.keyCode) {
    case 13:  // Enter
      const dialog = $('config-dialog');
      if (event.target.nodeName === 'INPUT') {
        const line = event.target.parentNode;
        if (!line.classList.contains('fresh') ||
            line.classList.contains('empty')) {
          dialog.commit(true);
        } else {
          commitFreshLineIfValid(true /* select new line */);
          dialog.commit(false);
        }
      } else {
        dialog.commit(true);
      }
      break;
  }
}

function commitDialog(commitHandler, shouldClose) {
  const element = $('config-dialog');
  if (element.open && shouldClose) {
    element.onclose = null;
    element.close();
    document.removeEventListener('keyup', handleKey);
    if (window.holdDevices) {
      populateRemoteTargets(window.holdDevices);
      delete window.holdDevices;
    }
  }
  commitFreshLineIfValid();
  commitHandler();
}

function openConfigDialog(dialogClass, commitHandler, lineFactory, data) {
  const dialog = $('config-dialog');
  if (dialog.open) {
    return;
  }

  dialog.className = dialogClass;
  dialog.classList.add('config');

  document.addEventListener('keyup', handleKey);
  dialog.commit = commitDialog.bind(null, commitHandler);
  dialog.onclose = commitDialog.bind(null, commitHandler, true);
  $('button-done').onclick = dialog.onclose;

  const list = $('config-dialog').querySelector('.list');
  list.textContent = '';

  list.createRow = appendRow.bind(null, list, lineFactory);
  for (const key in data) {
    list.createRow(key, data[key]);
  }
  list.createRow(null, null);

  dialog.showModal();
  const defaultFocus = dialog.querySelector('.fresh .preselected');
  if (defaultFocus) {
    defaultFocus.focus();
  } else {
    doneButton.focus();
  }
}

function openPortForwardingConfig() {
  function createPortForwardingConfigLine(port, location) {
    const line = document.createElement('div');
    line.className = 'port-forwarding-pair config-list-row';

    const portInput =
        createConfigField(port, 'port preselected', 'Port', validatePort);
    line.appendChild(portInput);

    const locationInput = createConfigField(
        location, 'location', 'IP address and port', validateLocation);
    locationInput.classList.add('primary');
    line.appendChild(locationInput);
    return line;
  }

  function commitPortForwardingConfig() {
    const config = {};
    filterList(['.port', '.location'], function(port, location) {
      config[port] = location;
    });
    sendCommand('set-port-forwarding-config', config);
  }

  openConfigDialog(
      'port-forwarding', commitPortForwardingConfig,
      createPortForwardingConfigLine, window.portForwardingConfig);
}

function openTargetsConfig() {
  function createTargetDiscoveryConfigLine(index, targetDiscovery) {
    const line = document.createElement('div');
    line.className = 'target-discovery-line config-list-row';

    const locationInput = createConfigField(
        targetDiscovery, 'location preselected', 'IP address and port',
        validateLocation);
    locationInput.classList.add('primary');
    line.appendChild(locationInput);
    return line;
  }

  function commitTargetDiscoveryConfig() {
    const entries = [];
    filterList(['.location'], function(location) {
      entries.push(location);
    });
    sendCommand('set-tcp-discovery-config', entries);
  }

  openConfigDialog(
      'target-discovery', commitTargetDiscoveryConfig,
      createTargetDiscoveryConfigLine, window.targetDiscoveryConfig);
}

function filterList(fieldSelectors, callback) {
  const lines = $('config-dialog').querySelectorAll('.config-list-row');
  for (let i = 0; i !== lines.length; i++) {
    const line = lines[i];
    const values = [];
    for (const selector of fieldSelectors) {
      const input = line.querySelector(selector);
      const value = input.classList.contains('invalid') ? input.lastValidValue :
                                                          input.value;
      if (!value) {
        break;
      }
      values.push(value);
    }
    if (values.length === fieldSelectors.length) {
      callback.apply(null, values);
    }
  }
}

function updateCheckbox(id, enabled) {
  const checkbox = $(id);
  checkbox.checked = !!enabled;
  checkbox.disabled = false;
}

function updateDiscoverUsbDevicesEnabled(enabled) {
  updateCheckbox('discover-usb-devices-enable', enabled);
}

function updatePortForwardingEnabled(enabled) {
  updateCheckbox('port-forwarding-enable', enabled);
  $('infobar').classList.toggle('show', enabled);
  $('infobar').scrollIntoView();
}

function updatePortForwardingConfig(config) {
  window.portForwardingConfig = config;
  $('port-forwarding-config-open').disabled = !config;
}

function updateTCPDiscoveryEnabled(enabled) {
  updateCheckbox('discover-tcp-devices-enable', enabled);
}

function updateTCPDiscoveryConfig(config) {
  window.targetDiscoveryConfig = config;
  $('tcp-discovery-config-open').disabled = !config;
}

function updateBubbleLockingCheckbox(enabled) {
  updateCheckbox('bubble-locking-checkbox', enabled);
}

function appendRow(list, lineFactory, key, value) {
  const line = lineFactory(key, value);
  line.lastElementChild.addEventListener('keydown', function(e) {
    if (e.key === 'Tab' && !hasKeyModifiers(e) &&
        line.classList.contains('fresh') && !line.classList.contains('empty')) {
      // Tabbing forward on the fresh line, try create a new empty one.
      if (commitFreshLineIfValid(true)) {
        e.preventDefault();
      }
    }
  });

  const lineDelete = document.createElement('div');
  lineDelete.className = 'close-button';
  lineDelete.addEventListener('click', function() {
    const newSelection = line.nextElementSibling || line.previousElementSibling;
    selectLine(newSelection, true);
    line.parentNode.removeChild(line);
    $('config-dialog').commit(false);
  });
  line.appendChild(lineDelete);

  line.addEventListener('click', selectLine.bind(null, line, true));
  line.addEventListener('focus', selectLine.bind(null, line, true));
  checkEmptyLine(line);

  if (!key && !value) {
    line.classList.add('fresh');
  }

  return list.appendChild(line);
}

function validatePort(input) {
  const match = input.value.match(/^(\d+)$/);
  if (!match) {
    return false;
  }
  const port = parseInt(match[1]);
  if (port < 1024 || 65535 < port) {
    return false;
  }

  const inputs = document.querySelectorAll('input.port:not(.invalid)');
  for (let i = 0; i !== inputs.length; ++i) {
    if (inputs[i] === input) {
      break;
    }
    if (parseInt(inputs[i].value) === port) {
      return false;
    }
  }
  return true;
}

function validateLocation(input) {
  const match = input.value.match(/^([a-zA-Z0-9\.\-_]+):(\d+)$/);
  if (!match) {
    return false;
  }
  const port = parseInt(match[2]);
  return port <= 65535;
}

function createConfigField(value, className, hint, validate) {
  const input = document.createElement('input');
  input.className = className;
  input.type = 'text';
  input.placeholder = hint;
  input.value = value || '';
  input.lastValidValue = value || '';

  function checkInput() {
    if (validate(input)) {
      input.classList.remove('invalid');
    } else {
      input.classList.add('invalid');
    }
    if (input.parentNode) {
      checkEmptyLine(input.parentNode);
    }
  }
  checkInput();

  input.addEventListener('keyup', checkInput);
  input.addEventListener('focus', function() {
    selectLine(input.parentNode);
  });

  input.addEventListener('blur', function() {
    if (validate(input)) {
      input.lastValidValue = input.value;
    }
  });

  return input;
}

function checkEmptyLine(line) {
  const inputs = line.querySelectorAll('input');
  let empty = true;
  for (let i = 0; i !== inputs.length; i++) {
    if (inputs[i].value !== '') {
      empty = false;
    }
  }
  if (empty) {
    line.classList.add('empty');
  } else {
    line.classList.remove('empty');
  }
}

function selectLine(line, opt_focusInput) {
  if (line.classList.contains('selected')) {
    return;
  }
  const selected =
      line.parentElement && line.parentElement.querySelector('.selected');
  if (selected) {
    selected.classList.remove('selected');
  }
  line.classList.add('selected');
  if (opt_focusInput) {
    const el = line.querySelector('.preselected');
    if (el) {
      line.firstChild.select();
      line.firstChild.focus();
    }
  }
}

function commitFreshLineIfValid(opt_selectNew) {
  const line = $('config-dialog').querySelector('.config-list-row.fresh');
  if (line.querySelector('.invalid')) {
    return false;
  }
  line.classList.remove('fresh');
  const freshLine = line.parentElement.createRow();
  if (opt_selectNew) {
    freshLine.querySelector('.preselected').focus();
  }
  return true;
}

function populatePortStatus(devicesStatusMap) {
  for (const deviceId in devicesStatusMap) {
    if (!devicesStatusMap.hasOwnProperty(deviceId)) {
      continue;
    }
    const deviceStatus = devicesStatusMap[deviceId];
    const deviceStatusMap = deviceStatus.ports;

    const deviceSection = $(deviceId);
    if (!deviceSection) {
      continue;
    }

    const devicePorts = deviceSection.querySelector('.device-ports');
    if (alreadyDisplayed(devicePorts, deviceStatus)) {
      continue;
    }

    devicePorts.textContent = '';
    for (const port in deviceStatusMap) {
      if (!deviceStatusMap.hasOwnProperty(port)) {
        continue;
      }

      const status = deviceStatusMap[port];
      const portIcon = document.createElement('div');
      portIcon.className = 'port-icon';
      // status === 0 is the default (connected) state.
      if (status === -1 || status === -2) {
        portIcon.classList.add('transient');
        portIcon.title = 'Attempting to forward port';
      } else if (status < 0) {
        portIcon.classList.add('error');
        portIcon.title = 'Port forwarding failed';
      } else {
        portIcon.title = 'Successfully forwarded port';
      }
      devicePorts.appendChild(portIcon);

      const portNumber = document.createElement('div');
      portNumber.className = 'port-number';
      portNumber.textContent = ':' + port;
      devicePorts.appendChild(portNumber);
    }

    function updatePortForwardingInfo(browserSection) {
      const icon = browserSection.querySelector('.used-for-port-forwarding');
      if (icon) {
        icon.hidden = (browserSection.id !== deviceStatus.browserId);
      }
      updateBrowserVisibility(browserSection);
    }

    Array.prototype.forEach.call(
        deviceSection.querySelectorAll('.browser'), updatePortForwardingInfo);

    updateUsernameVisibility(deviceSection);
  }

  function clearBrowserPorts(browserSection) {
    const icon = browserSection.querySelector('.used-for-port-forwarding');
    if (icon) {
      icon.hidden = true;
    }
    updateBrowserVisibility(browserSection);
  }

  function clearPorts(deviceSection) {
    if (deviceSection.id in devicesStatusMap) {
      return;
    }
    const devicePorts = deviceSection.querySelector('.device-ports');
    devicePorts.textContent = '';
    delete devicePorts.cachedJSON;

    Array.prototype.forEach.call(
        deviceSection.querySelectorAll('.browser'), clearBrowserPorts);
  }

  Array.prototype.forEach.call(
      document.querySelectorAll('.device'), clearPorts);
}

// Expose functions on |window| since they are called from C++ by name.
Object.assign(window, {
  updateDiscoverUsbDevicesEnabled,
  updatePortForwardingEnabled,
  updatePortForwardingConfig,
  updateTCPDiscoveryEnabled,
  updateTCPDiscoveryConfig,
  updateBubbleLockingCheckbox,
  populateNativeUITargets,
  populateTargets,
  populatePortStatus,
  showIncognitoWarning,
  showNativeUILaunchButton,
  setHostVersion,
});

document.addEventListener('DOMContentLoaded', onload);
window.addEventListener('hashchange', onHashChange);