chromium/chrome/browser/resources/extensions/item.ts

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

import 'chrome://resources/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/cr_elements/cr_icons.css.js';
import 'chrome://resources/cr_elements/cr_toggle/cr_toggle.js';
import 'chrome://resources/cr_elements/cr_tooltip/cr_tooltip.js';
import 'chrome://resources/cr_elements/cr_hidden_style.css.js';
import 'chrome://resources/cr_elements/icons.html.js';
import 'chrome://resources/cr_elements/cr_shared_style.css.js';
import 'chrome://resources/cr_elements/cr_shared_vars.css.js';
import 'chrome://resources/js/action_link.js';
import 'chrome://resources/cr_elements/action_link.css.js';
import './icons.html.js';
import './shared_style.css.js';
import './shared_vars.css.js';
import './strings.m.js';
import 'chrome://resources/polymer/v3_0/iron-flex-layout/iron-flex-layout-classes.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';

import type {ChromeEvent} from '/tools/typescript/definitions/chrome_event.js';
import type {CrToggleElement} from 'chrome://resources/cr_elements/cr_toggle/cr_toggle.js';
import {I18nMixin} from 'chrome://resources/cr_elements/i18n_mixin.js';
import {assert, assertNotReached} from 'chrome://resources/js/assert.js';
import {flush, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {getTemplate} from './item.html.js';
import {ItemMixin} from './item_mixin.js';
import {computeInspectableViewLabel, EnableControl, getEnableControl, getEnableToggleAriaLabel, getEnableToggleTooltipText, getItemSource, getItemSourceString, isEnabled, sortViews, SourceType, userCanChangeEnablement} from './item_util.js';
import {navigation, Page} from './navigation_helper.js';

export interface ItemDelegate {
  deleteItem(id: string): void;
  deleteItems(ids: string[]): Promise<void>;
  uninstallItem(id: string): Promise<void>;
  setItemEnabled(id: string, isEnabled: boolean): void;
  setItemAllowedIncognito(id: string, isAllowedIncognito: boolean): void;
  setItemAllowedOnFileUrls(id: string, isAllowedOnFileUrls: boolean): void;
  setItemHostAccess(id: string, hostAccess: chrome.developerPrivate.HostAccess):
      void;
  setItemCollectsErrors(id: string, collectsErrors: boolean): void;
  inspectItemView(id: string, view: chrome.developerPrivate.ExtensionView):
      void;
  openUrl(url: string): void;
  reloadItem(id: string): Promise<void>;
  repairItem(id: string): void;
  showItemOptionsPage(extension: chrome.developerPrivate.ExtensionInfo): void;
  showInFolder(id: string): void;
  getExtensionSize(id: string): Promise<string>;
  addRuntimeHostPermission(id: string, host: string): Promise<void>;
  removeRuntimeHostPermission(id: string, host: string): Promise<void>;
  setItemSafetyCheckWarningAcknowledged(
      id: string,
      reason: chrome.developerPrivate.SafetyCheckWarningReason): void;
  setShowAccessRequestsInToolbar(id: string, showRequests: boolean): void;
  setItemPinnedToToolbar(id: string, pinnedToToolbar: boolean): void;

  // TODO(tjudkins): This function is not specific to items, so should be pulled
  // out to a more generic place when we need to access it from elsewhere.
  recordUserAction(metricName: string): void;
  getItemStateChangedTarget():
      ChromeEvent<(data: chrome.developerPrivate.EventData) => void>;
}

export interface ExtensionsItemElement {
  $: {
    a11yAssociation: HTMLElement,
    detailsButton: HTMLElement,
    enableToggle: CrToggleElement,
    name: HTMLElement,
    removeButton: HTMLElement,
  };
}

const ExtensionsItemElementBase = I18nMixin(ItemMixin(PolymerElement));

export class ExtensionsItemElement extends ExtensionsItemElementBase {
  static get is() {
    return 'extensions-item';
  }

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

  static get properties() {
    return {
      // The item's delegate, or null.
      delegate: Object,

      // Whether or not dev mode is enabled.
      inDevMode: {
        type: Boolean,
        value: false,
      },

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

      // The underlying ExtensionInfo itself. Public for use in declarative
      // bindings.
      data: Object,

      // Whether or not the expanded view of the item is shown.
      showingDetails_: {
        type: Boolean,
        value: false,
      },

      // First inspectable view after sorting.
      firstInspectView_: {
        type: Object,
        computed: 'computeFirstInspectView_(data.views)',
      },
    };
  }

  static get observers() {
    return ['observeIdVisibility_(inDevMode, showingDetails_, data.id)'];
  }

  delegate: ItemDelegate;
  inDevMode: boolean;
  safetyCheckShowing: boolean;
  data: chrome.developerPrivate.ExtensionInfo;
  private showingDetails_: boolean;
  private firstInspectView_: chrome.developerPrivate.ExtensionView;

  private fire_(eventName: string, detail?: any) {
    this.dispatchEvent(
        new CustomEvent(eventName, {bubbles: true, composed: true, detail}));
  }

  /** @return The "Details" button. */
  getDetailsButton(): HTMLElement {
    return this.$.detailsButton;
  }

  /** @return The "Remove" button, if it exists. */
  getRemoveButton(): HTMLElement|null {
    return this.data.mustRemainInstalled ? null : this.$.removeButton;
  }

  /** @return The "Errors" button, if it exists. */
  getErrorsButton(): HTMLElement|null {
    return this.shadowRoot!.querySelector('#errors-button');
  }

  private getEnableToggleAriaLabel_(): string {
    return getEnableToggleAriaLabel(
        this.isEnabled_(), this.data.type, this.i18n('appEnabled'),
        this.i18n('extensionEnabled'), this.i18n('itemOff'));
  }

  private getEnableToggleTooltipText_(): string {
    return getEnableToggleTooltipText(this.data);
  }

  private observeIdVisibility_() {
    flush();
    const idElement = this.shadowRoot!.querySelector('#extension-id');
    if (idElement) {
      assert(this.data);
      idElement.textContent = this.i18n('itemId', this.data.id);
    }
  }

  private shouldShowErrorsButton_(): boolean {
    // When the error console is disabled (happens when
    // --disable-error-console command line flag is used or when in the
    // Stable/Beta channel), |installWarnings| is populated.
    if (this.data.installWarnings && this.data.installWarnings.length > 0) {
      return true;
    }

    // When error console is enabled |installedWarnings| is not populated.
    // Instead |manifestErrors| and |runtimeErrors| are used.
    return this.data.manifestErrors.length > 0 ||
        this.data.runtimeErrors.length > 0;
  }

  private onRemoveClick_() {
    if (this.safetyCheckShowing) {
      const actionToRecord = this.data.safetyCheckText ?
          'SafetyCheck.ReviewPanelRemoveClicked' :
          'SafetyCheck.NonTriggeringExtensionRemoved';
      chrome.metricsPrivate.recordUserAction(actionToRecord);
    }
    this.delegate.deleteItem(this.data.id);
  }

  private onEnableToggleChange_() {
    this.delegate.setItemEnabled(this.data.id, this.$.enableToggle.checked);
    this.$.enableToggle.checked = this.isEnabled_();
  }

  private onErrorsClick_() {
    if (this.data.installWarnings && this.data.installWarnings.length > 0) {
      this.fire_('show-install-warnings', this.data.installWarnings);
      return;
    }

    navigation.navigateTo({page: Page.ERRORS, extensionId: this.data.id});
  }

  private onDetailsClick_() {
    navigation.navigateTo({page: Page.DETAILS, extensionId: this.data.id});
  }

  private computeFirstInspectView_(): chrome.developerPrivate.ExtensionView {
    return sortViews(this.data.views)[0];
  }

  private onInspectClick_() {
    this.delegate.inspectItemView(this.data.id, this.firstInspectView_);
  }

  private onExtraInspectClick_() {
    navigation.navigateTo({page: Page.DETAILS, extensionId: this.data.id});
  }

  private onReloadClick_() {
    this.reloadItem().catch((loadError) => this.fire_('load-error', loadError));
  }

  private onRepairClick_() {
    this.delegate.repairItem(this.data.id);
  }

  private isEnabled_(): boolean {
    return isEnabled(this.data.state);
  }

  private isEnableToggleEnabled_(): boolean {
    return userCanChangeEnablement(this.data);
  }

  /** @return Whether the reload button should be shown. */
  private showReloadButton_(): boolean {
    return getEnableControl(this.data) === EnableControl.RELOAD;
  }

  /** @return Whether the repair button should be shown. */
  private showRepairButton_(): boolean {
    return getEnableControl(this.data) === EnableControl.REPAIR;
  }


  /** @return Whether the enable toggle should be shown. */
  private showEnableToggle_(): boolean {
    return getEnableControl(this.data) === EnableControl.ENABLE_TOGGLE;
  }

  private computeClasses_(): string {
    let classes = this.isEnabled_() ? 'enabled' : 'disabled';
    if (this.inDevMode) {
      classes += ' dev-mode';
    }
    return classes;
  }

  private computeSourceIndicatorIcon_(): string {
    switch (getItemSource(this.data)) {
      case SourceType.POLICY:
        return 'extensions-icons:business';
      case SourceType.SIDELOADED:
        return 'extensions-icons:input';
      case SourceType.UNKNOWN:
        // TODO(dpapad): Ask UX for a better icon for this case.
        return 'extensions-icons:input';
      case SourceType.UNPACKED:
        return 'extensions-icons:unpacked';
      case SourceType.WEBSTORE:
      case SourceType.INSTALLED_BY_DEFAULT:
        return '';
      default:
        assertNotReached();
    }
  }

  private computeSourceIndicatorText_(): string {
    if (this.data.locationText) {
      return this.data.locationText;
    }

    const sourceType = getItemSource(this.data);
    return sourceType === SourceType.WEBSTORE ? '' :
                                                getItemSourceString(sourceType);
  }

  private computeInspectViewsHidden_(): boolean {
    return !this.data.views || this.data.views.length === 0;
  }

  private computeFirstInspectTitle_(): string {
    // Note: theoretically, this wouldn't be called without any inspectable
    // views (because it's in a dom-if="!computeInspectViewsHidden_()").
    // However, due to the recycling behavior of iron list, it seems that
    // sometimes it can. Even when it is, the UI behaves properly, but we
    // need to handle the case gracefully.
    return this.data.views.length > 0 ?
        computeInspectableViewLabel(this.firstInspectView_) :
        '';
  }

  private computeFirstInspectLabel_(): string {
    const label = this.computeFirstInspectTitle_();
    return label && this.data.views.length > 1 ? label + ',' : label;
  }

  private computeExtraViewsHidden_(): boolean {
    return this.data.views.length <= 1;
  }

  private computeDevReloadButtonHidden_(): boolean {
    return !this.canReloadItem();
  }

  private computeExtraInspectLabel_(): string {
    return this.i18n(
        'itemInspectViewsExtra', (this.data.views.length - 1).toString());
  }

  /**
   * @return Whether the extension has severe warnings. Doesn't determine the
   *     warning's visibility.
   */
  private hasSevereWarnings_(): boolean {
    return this.data.disableReasons.corruptInstall ||
        this.data.disableReasons.suspiciousInstall ||
        this.data.runtimeWarnings.length > 0 || !!this.data.blocklistText;
  }

  /**
   * @return Whether the extension has an MV2 warning. Doesn't determine the
   *     warning's visibility.
   */
  private hasMv2DeprecationWarning_(): boolean {
    return this.data.disableReasons.unsupportedManifestVersion;
  }

  /**
   * @return Whether the extension has an allowlist warning. Doesn't determine
   *     the warning's visibility.
   */
  private hasAllowlistWarning_(): boolean {
    return this.data.showSafeBrowsingAllowlistWarning;
  }

  private showDescription_(): boolean {
    // Description is only visible iff no warnings are visible.
    return !this.hasSevereWarnings_() && !this.hasMv2DeprecationWarning_() &&
        !this.hasAllowlistWarning_();
  }

  private showSevereWarnings(): boolean {
    // Severe warning are always visible, if they exist.
    return this.hasSevereWarnings_();
  }

  private showMv2DeprecationWarning_(): boolean {
    // MV2 deprecation warning is visible, if existent, if there are no severe
    // warnings visible.
    // Note: The item card has a fixed height and the content might get cropped
    // if too many warnings are displayed.
    return this.hasMv2DeprecationWarning_() && !this.hasSevereWarnings_();
  }

  private showAllowlistWarning_(): boolean {
    // Allowlist warning is visible, if existent, if there are no severe
    // warnings or mv2 deprecation warnings visible.
    // Note: The item card has a fixed height and the content might get cropped
    // if too many warnings are displayed. This should be a rare edge case and
    // the allowlist warning will still be shown in the item detail view.
    return this.hasAllowlistWarning_() && !this.hasSevereWarnings_() &&
        !this.hasMv2DeprecationWarning_();
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'extensions-item': ExtensionsItemElement;
  }
}

customElements.define(ExtensionsItemElement.is, ExtensionsItemElement);