chromium/chrome/browser/resources/ash/settings/crostini_page/crostini_extra_containers.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-extra-containers' is the settings extras containers 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_toggle/cr_toggle.js';
import 'chrome://resources/polymer/v3_0/iron-collapse/iron-collapse.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';
import './crostini_extra_containers_create_dialog.js';
import '../settings_shared.css.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 {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 {hexColorToSkColor} from 'chrome://resources/js/color_utils.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {PrefsState} from '../common/types.js';
import {ContainerInfo, GuestId, ShareableDevices, VM_DEVICE_MICROPHONE} from '../guest_os/guest_os_browser_proxy.js';
import {equalContainerId} from '../guest_os/guest_os_container_select.js';

import {CrostiniBrowserProxy, CrostiniBrowserProxyImpl, DEFAULT_CROSTINI_CONTAINER, DEFAULT_CROSTINI_VM} from './crostini_browser_proxy.js';
import {getTemplate} from './crostini_extra_containers.html.js';

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

export interface ExtraContainersElement {
  $: {
    containerMenu: CrLazyRenderElement<CrActionMenuElement>,
  };
}

interface SharedVmDevices {
  id: GuestId;
  vmDevices: ShareableDevices;
}

interface CrostiniContainerInfo extends ContainerInfo {
  detailsExpanded: boolean;
}

const ExtraContainersElementBase = WebUiListenerMixin(PolymerElement);

export class ExtraContainersElement extends ExtraContainersElementBase {
  static get is() {
    return 'settings-crostini-extra-containers';
  }

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

  static get properties() {
    return {
      prefs: {
        type: Object,
        notify: true,
      },

      showCreateContainerDialog_: {
        type: Boolean,
        value: false,
      },

      allContainers_: {
        type: Array,
        notify: true,
        value() {
          return [];
        },
      },

      allSharedVmDevices_: {
        type: Array,
        value() {
          return [];
        },
      },

      allVms_: {
        type: Array,
        value() {
          return [];
        },
      },

      lastMenuContainerInfo_: {
        type: Object,
      },

      /**
       * Whether the export import buttons should be enabled. Initially false
       * until status has been confirmed.
       */
      enableButtons_: {
        type: Boolean,
        computed:
            'isEnabledButtons_(installerShowing_, exportImportInProgress_)',
      },

      installerShowing_: {
        type: Boolean,
        value: false,
      },

      // TODO(b/231890242): Disable delete and stop buttons when a container is
      // being exported or imported.
      exportImportInProgress_: {
        type: Boolean,
        value: false,
      },
    };
  }

  prefs: PrefsState;
  private allContainers_: CrostiniContainerInfo[];
  private allSharedVmDevices_: SharedVmDevices[];
  private allVms_: string[];
  private browserProxy_: CrostiniBrowserProxy;
  private exportImportInProgress_: boolean;
  private installerShowing_: boolean;
  private lastMenuContainerInfo_: CrostiniContainerInfo|null;
  private showCreateContainerDialog_: boolean;

  constructor() {
    super();
    /**
     * Tracks the last container that was selected for delete.
     */
    this.lastMenuContainerInfo_ = null;

    this.browserProxy_ = CrostiniBrowserProxyImpl.getInstance();
  }

  override ready(): void {
    super.ready();
    this.addWebUiListener(
        'crostini-container-info',
        (infos: ContainerInfo[]) => this.onContainerInfo_(infos));
    this.addWebUiListener(
        'crostini-shared-vmdevices',
        (sharedVmDevices: SharedVmDevices[]) =>
            this.onSharedVmDevices_(sharedVmDevices));
    this.browserProxy_.requestContainerInfo();
    this.browserProxy_.requestSharedVmDevices();
  }

  override connectedCallback(): void {
    super.connectedCallback();
    this.addWebUiListener(
        'crostini-export-import-operation-status-changed',
        (inProgress: boolean) => {
          this.exportImportInProgress_ = inProgress;
        });
    this.addWebUiListener(
        'crostini-installer-status-changed', (installerShowing: boolean) => {
          this.installerShowing_ = installerShowing;
        });

    this.browserProxy_.requestCrostiniExportImportOperationStatus();
    this.browserProxy_.requestCrostiniInstallerStatus();
  }

  private setMicrophoneToggle_(id: GuestId, checked: boolean): void {
    const crToggle: CrToggleElement|null =
        this.shadowRoot!.querySelector<CrToggleElement>(
            `#microphone-${id.vm_name}-${id.container_name}`);
    if (!crToggle) {
      // The toggles may not yet have been added to the DOM.
      return;
    }
    if (crToggle.checked !== checked) {
      crToggle.set('checked', checked);
    }
  }

  private onSharedVmDevices_(sharedVmDevices: SharedVmDevices[]): void {
    this.set('allSharedVmDevices_', sharedVmDevices);
    for (const sharing of sharedVmDevices) {
      this.setMicrophoneToggle_(
          sharing.id, sharing.vmDevices[VM_DEVICE_MICROPHONE]);
    }
  }

  private async updateSharedVmDevices_(id: GuestId): Promise<void> {
    let idx = this.allSharedVmDevices_.findIndex(
        sharing => equalContainerId(sharing.id, id));

    if (idx < 0) {
      idx = this.allSharedVmDevices_.push(
                {id: id, vmDevices: {[VM_DEVICE_MICROPHONE]: false}}) -
          1;
    }
    const result: boolean =
        await this.browserProxy_.isVmDeviceShared(id, VM_DEVICE_MICROPHONE);

    this.allSharedVmDevices_[idx].vmDevices[VM_DEVICE_MICROPHONE] = result;
    this.setMicrophoneToggle_(id, result);
  }

  private onContainerInfo_(containerInfos: ContainerInfo[]): void {
    const vmNames: Set<string> = new Set();
    const crostiniContainerInfos = containerInfos as CrostiniContainerInfo[];
    for (const info of crostiniContainerInfos) {
      vmNames.add(info.id.vm_name);
      const oldContainerInfo = this.allContainers_.find(
          container => equalContainerId(container.id, info.id));
      info.detailsExpanded = (oldContainerInfo !== undefined) ?
          oldContainerInfo.detailsExpanded :
          false;
    }
    this.set('allVms_', Array.from(vmNames.values()));
    this.set('allContainers_', crostiniContainerInfos);
  }

  private onCreateClick_(): void {
    this.showCreateContainerDialog_ = true;
  }

  private onCreateContainerDialogClose_(): void {
    this.showCreateContainerDialog_ = false;
  }

  private onContainerMenuClick_(event: Event): void {
    const target = event.currentTarget as HtmlElementWithData;
    const containerId = target['dataContainerId'];
    this.lastMenuContainerInfo_ =
        this.allContainers_.find(
            e => e.id.vm_name === containerId.vm_name &&
                e.id.container_name === containerId.container_name) ||
        null;
    this.getContainerMenu_().showAt(target);
  }

  private onDeleteContainerClick_(): void {
    if (this.lastMenuContainerInfo_) {
      this.browserProxy_.deleteContainer(this.lastMenuContainerInfo_.id);
    }
    this.closeContainerMenu_();
  }

  private onStopContainerClick_(): void {
    if (this.lastMenuContainerInfo_) {
      this.browserProxy_.stopContainer(this.lastMenuContainerInfo_.id);
    }
    this.closeContainerMenu_();
  }

  private onExportContainerClick_(): void {
    if (this.lastMenuContainerInfo_) {
      this.browserProxy_.exportCrostiniContainer(
          this.lastMenuContainerInfo_.id);
    }
    this.closeContainerMenu_();
  }

  private onImportContainerClick_(): void {
    if (this.lastMenuContainerInfo_) {
      this.browserProxy_.importCrostiniContainer(
          this.lastMenuContainerInfo_.id);
    }
    this.closeContainerMenu_();
  }

  private onContainerColorChange_(event: Event): void {
    const target = event.currentTarget as HtmlElementWithData<HTMLInputElement>;
    const containerId = target['dataContainerId'];
    const hexColor = target.value;

    this.browserProxy_.setContainerBadgeColor(
        containerId, hexColorToSkColor(hexColor));
  }

  private shouldDisableDeleteContainer_(info: ContainerInfo): boolean {
    return info && info.id.vm_name === DEFAULT_CROSTINI_VM &&
        info.id.container_name === DEFAULT_CROSTINI_CONTAINER;
  }

  private shouldDisableStopContainer_(info: ContainerInfo): boolean {
    return !info || !info.ipv4;
  }

  private getContainerMenu_(): CrActionMenuElement {
    return this.$.containerMenu.get();
  }

  private closeContainerMenu_(): void {
    const menu = this.getContainerMenu_();
    assert(menu.open && this.lastMenuContainerInfo_);
    menu.close();
    this.lastMenuContainerInfo_ = null;
  }

  private isEnabledButtons_(
      installerShowing: boolean, exportImportInProgress: boolean): boolean {
    return !(installerShowing || exportImportInProgress);
  }

  private byNameWithDefault_(name1: string, name2: string, defaultName: string):
      number {
    if (name1 === name2) {
      return 0;
    }
    // defaultName sorts first.
    if (name1 === defaultName) {
      return -1;
    }
    if (name2 === defaultName) {
      return 1;
    }
    return name1 < name2 ? -1 : 1;
  }

  private byVmName_(name1: string, name2: string): number {
    return this.byNameWithDefault_(name1, name2, DEFAULT_CROSTINI_VM);
  }

  private byGuestId_(id1: GuestId, id2: GuestId): number {
    const result = this.byVmName_(id1.vm_name, id2.vm_name);
    if (result !== 0) {
      return result;
    }
    return this.byNameWithDefault_(
        id1.container_name, id2.container_name, DEFAULT_CROSTINI_CONTAINER);
  }

  private infoHasVmName_(vmName: string): (info: ContainerInfo) => boolean {
    return info => vmName === info.id.vm_name;
  }

  private isMicrophoneShared_(id: GuestId): boolean {
    const deviceSharing: SharedVmDevices|undefined =
        this.allSharedVmDevices_.find(
            (sharing: SharedVmDevices) => equalContainerId(sharing.id, id));
    if (!deviceSharing) {
      return false;
    }
    return deviceSharing.vmDevices[VM_DEVICE_MICROPHONE];
  }

  private async onMicrophoneSharingChanged_(event: Event): Promise<void> {
    const target = event.currentTarget as HtmlElementWithData<HTMLInputElement>;
    const id = target['dataContainerId'];
    const shared = target.checked;

    await this.browserProxy_.setVmDeviceShared(
        id, VM_DEVICE_MICROPHONE, shared);
    await this.updateSharedVmDevices_(id);
  }

  private showIp_(info: ContainerInfo): boolean {
    return !!info.ipv4 && info.ipv4.length > 0;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'settings-crostini-extra-containers': ExtraContainersElement;
  }
}

customElements.define(ExtraContainersElement.is, ExtraContainersElement);