chromium/chrome/browser/resources/ash/settings/crostini_page/crostini_port_forwarding.ts

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

/**
 * @fileoverview
 * 'crostini-port-forwarding' is the settings port forwarding subpage for
 * Crostini.
 */
import 'chrome://resources/ash/common/cr_elements/icons.html.js';
import 'chrome://resources/ash/common/cr_elements/cr_action_menu/cr_action_menu.js';
import 'chrome://resources/ash/common/cr_elements/cr_lazy_render/cr_lazy_render.js';
import 'chrome://resources/ash/common/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_toast/cr_toast.js';
import 'chrome://resources/ash/common/cr_elements/cr_toggle/cr_toggle.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';
import '../controls/settings_toggle_button.js';
import '../os_settings_page/settings_card.js';
import '../settings_shared.css.js';
import './crostini_port_forwarding_add_port_dialog.js';

import {PrefsMixin} from '/shared/settings/prefs/prefs_mixin.js';
import {CrActionMenuElement} from 'chrome://resources/ash/common/cr_elements/cr_action_menu/cr_action_menu.js';
import {CrLazyRenderElement} from 'chrome://resources/ash/common/cr_elements/cr_lazy_render/cr_lazy_render.js';
import {CrToastElement} from 'chrome://resources/ash/common/cr_elements/cr_toast/cr_toast.js';
import {CrToggleElement} from 'chrome://resources/ash/common/cr_elements/cr_toggle/cr_toggle.js';
import {WebUiListenerMixin} from 'chrome://resources/ash/common/cr_elements/web_ui_listener_mixin.js';
import {assert} from 'chrome://resources/js/assert.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {ContainerInfo, GuestId} from '../guest_os/guest_os_browser_proxy.js';
import {containerLabel, equalContainerId} from '../guest_os/guest_os_container_select.js';

import {CrostiniBrowserProxy, CrostiniBrowserProxyImpl, CrostiniPortActiveSetting, CrostiniPortSetting, DEFAULT_CROSTINI_CONTAINER, DEFAULT_CROSTINI_GUEST_ID, DEFAULT_CROSTINI_VM} from './crostini_browser_proxy.js';
import {getTemplate} from './crostini_port_forwarding.html.js';

type HtmlElementWithData<T extends HTMLElement = HTMLElement> = T&{
  'dataContainerId': GuestId,
};

export interface CrostiniPortForwardingElement {
  $: {
    errorToast: CrToastElement,
    removeAllPortsMenu: CrLazyRenderElement<CrActionMenuElement>,
  };
}

const CrostiniPortForwardingBase =
    PrefsMixin(WebUiListenerMixin(PolymerElement));

export class CrostiniPortForwardingElement extends CrostiniPortForwardingBase {
  static get is() {
    return 'settings-crostini-port-forwarding';
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      showAddPortDialog_: {
        type: Boolean,
        value: false,
      },

      /**
       * The forwarded ports for display in the UI.
       */
      allPorts_: {
        type: Array,
        notify: true,
        value() {
          return [];
        },
      },

      /**
       * The known ContainerIds for display in the UI.
       */
      allContainers_: {
        type: Array,
        notify: true,
        value() {
          return [];
        },
      },

      /**
       * Current active network interface to be displayed.
       */
      activeInterface_: {
        type: String,
        value: '',
      },

      /**
       * Current active network IP to be displayed.
       */
      activeIpAddress_: {
        type: String,
        value: '',
      },
    };
  }

  static get observers() {
    return [
      'onCrostiniPortsChanged_(prefs.crostini.port_forwarding.ports.value)',
    ];
  }

  private activePorts_: CrostiniPortActiveSetting[];
  private allContainers_: ContainerInfo[];
  private allPorts_: CrostiniPortSetting[];
  private activeInterface_: string;
  private activeIpAddress_: string;
  private browserProxy_: CrostiniBrowserProxy;
  private lastMenuOpenedPort_: CrostiniPortActiveSetting|null;
  private showAddPortDialog_: boolean;

  constructor() {
    super();
    /**
     * List of ports are currently being forwarded.
     */
    this.activePorts_ = [];

    /**
     * Tracks the last port that was selected for removal.
     */
    this.lastMenuOpenedPort_ = null;

    this.browserProxy_ = CrostiniBrowserProxyImpl.getInstance();
  }

  override connectedCallback(): void {
    super.connectedCallback();
    this.addWebUiListener(
        'crostini-port-forwarder-active-ports-changed',
        (ports: CrostiniPortActiveSetting[]) =>
            this.onCrostiniPortsActiveStateChanged_(ports));
    this.addWebUiListener(
        'crostini-container-info',
        (infos: ContainerInfo[]) => this.onContainerInfo_(infos));
    this.addWebUiListener(
        'crostini-active-network-info',
        (iface: string, ipAddress: string) =>
            this.onCrostiniActiveNetworkInfo_([iface, ipAddress]));
    this.browserProxy_.getCrostiniActivePorts().then(
        (ports) => this.onCrostiniPortsActiveStateChanged_(ports));
    this.browserProxy_.getCrostiniActiveNetworkInfo().then(
        (networkInfo: string[]) =>
            this.onCrostiniActiveNetworkInfo_(networkInfo));
    this.browserProxy_.requestContainerInfo();
  }

  private onCrostiniActiveNetworkInfo_(networkInfo: string[]): void {
    this.set('activeInterface_', networkInfo[0]);
    this.set('activeIpAddress_', networkInfo[1]);
  }

  private onContainerInfo_(containerInfos: ContainerInfo[]): void {
    this.set('allContainers_', containerInfos);
  }

  private onCrostiniPortsChanged_(ports: CrostiniPortSetting[]): void {
    this.splice('allPorts_', 0, this.allPorts_.length);
    for (const port of ports) {
      port.is_active = this.activePorts_.some(
          activePort => activePort.port_number === port.port_number &&
              activePort.protocol_type === port.protocol_type);
      port.container_id = port.container_id || {
        vm_name: port['vm_name'] || DEFAULT_CROSTINI_VM,
        container_name: port['container_name'] || DEFAULT_CROSTINI_CONTAINER,
      };
      this.push('allPorts_', port);
    }
    this.notifyPath('allContainers_');
  }

  private onCrostiniPortsActiveStateChanged_(
      ports: CrostiniPortActiveSetting[]): void {
    this.activePorts_ = ports;
    for (let i = 0; i < this.allPorts_.length; i++) {
      this.set(
          `allPorts_.${i}.${'is_active'}`,
          this.activePorts_.some(
              activePort =>
                  activePort.port_number === this.allPorts_[i].port_number &&
                  activePort.protocol_type ===
                      this.allPorts_[i].protocol_type));
    }
  }

  private onAddPortClick_(): void {
    this.showAddPortDialog_ = true;
  }

  private onAddPortDialogClose_(): void {
    this.showAddPortDialog_ = false;
  }

  private onShowRemoveAllPortsMenuClick_(event: Event): void {
    const menu = this.$.removeAllPortsMenu.get();
    menu.showAt(event.target as HTMLElement);
  }

  private onRemoveSinglePortClick_(event: Event): void {
    const target = event.currentTarget as HtmlElementWithData;
    const containerId = target['dataContainerId'];
    const portNumber = Number(target.dataset['portNumber']);
    const protocolType = Number(target.dataset['protocolType']);
    this.browserProxy_
        .removeCrostiniPortForward(containerId, portNumber, protocolType)
        .then((_result) => {
          // TODO(crbug.com/41391957): Error handling for result
        });
  }

  private onRemoveAllPortsClick_(): void {
    const menu = this.$.removeAllPortsMenu.get();
    assert(menu.open);
    for (const container of this.allContainers_) {
      this.browserProxy_.removeAllCrostiniPortForwards(container.id);
    }
    menu.close();
  }

  private onPortActivationChange_(event: Event): void {
    const target = event.currentTarget as HtmlElementWithData<CrToggleElement>;
    const containerId = target['dataContainerId'];
    const portNumber = Number(target.dataset['portNumber']);
    const protocolType = Number(target.dataset['protocolType']);

    if (target.checked) {
      target.checked = false;
      this.browserProxy_
          .activateCrostiniPortForward(containerId, portNumber, protocolType)
          .then(result => {
            if (!result) {
              this.$.errorToast.show();
            }
            // TODO(crbug.com/41391957): Elaborate on error handling for result
          });
    } else {
      this.browserProxy_
          .deactivateCrostiniPortForward(containerId, portNumber, protocolType)
          .then(
              (_result) => {
                  // TODO(crbug.com/41391957): Error handling for result
              });
    }
  }

  private showContainerId_(allPorts: CrostiniPortSetting[], id: GuestId):
      boolean {
    return allPorts.some(port => equalContainerId(port.container_id, id)) &&
        allPorts.some(
            port => !equalContainerId(
                port.container_id, DEFAULT_CROSTINI_GUEST_ID));
  }

  private containerLabel_(id: GuestId): string {
    return containerLabel(id, DEFAULT_CROSTINI_VM);
  }

  private hasContainerPorts_(allPorts: CrostiniPortSetting[], id: GuestId):
      boolean {
    return allPorts.some(port => equalContainerId(port.container_id, id));
  }

  private byContainerId_(id: GuestId): (port: CrostiniPortSetting) => boolean {
    return port => equalContainerId(port.container_id, id);
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'settings-crostini-port-forwarding': CrostiniPortForwardingElement;
  }
}

customElements.define(
    CrostiniPortForwardingElement.is, CrostiniPortForwardingElement);