chromium/chrome/browser/resources/extensions/manager.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_drawer/cr_drawer.js';
import 'chrome://resources/cr_elements/cr_lazy_render/cr_lazy_render.js';
import 'chrome://resources/cr_elements/cr_toast/cr_toast_manager.js';
import 'chrome://resources/cr_elements/cr_toolbar/cr_toolbar.js';
import 'chrome://resources/cr_elements/cr_view_manager/cr_view_manager.js';
import 'chrome://resources/cr_elements/cr_hidden_style.css.js';
import 'chrome://resources/cr_elements/cr_shared_vars.css.js';
import './activity_log/activity_log.js';
import './detail_view.js';
import './drop_overlay.js';
import './error_page.js';
import './install_warnings_dialog.js';
import './item_list.js';
import './item_util.js';
import './keyboard_shortcuts.js';
import './load_error.js';
import './options_dialog.js';
import './shared_vars.css.js';
import './sidebar.js';
import './site_permissions/site_permissions.js';
import './site_permissions/site_permissions_by_site.js';
import './toolbar.js';

import {CrContainerShadowMixin} from 'chrome://resources/cr_elements/cr_container_shadow_mixin.js';
import type {CrViewManagerElement} from 'chrome://resources/cr_elements/cr_view_manager/cr_view_manager.js';
import {assert, assertNotReached} from 'chrome://resources/js/assert.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {PromiseResolver} from 'chrome://resources/js/promise_resolver.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import type {ActivityLogExtensionPlaceholder} from './activity_log/activity_log.js';
import type {ExtensionsDetailViewElement} from './detail_view.js';
import type {ExtensionsItemListElement} from './item_list.js';
import {getTemplate} from './manager.html.js';
import type {PageState} from './navigation_helper.js';
import {Dialog, navigation, Page} from './navigation_helper.js';
import {Service} from './service.js';
import type {ExtensionsToolbarElement} from './toolbar.js';

/**
 * Compares two extensions to determine which should come first in the list.
 */
function compareExtensions(
    a: chrome.developerPrivate.ExtensionInfo,
    b: chrome.developerPrivate.ExtensionInfo): number {
  function compare(x: string, y: string): number {
    return x < y ? -1 : (x > y ? 1 : 0);
  }
  function compareLocation(
      x: chrome.developerPrivate.ExtensionInfo,
      y: chrome.developerPrivate.ExtensionInfo): number {
    if (x.location === y.location) {
      return 0;
    }
    if (x.location === chrome.developerPrivate.Location.UNPACKED) {
      return -1;
    }
    if (y.location === chrome.developerPrivate.Location.UNPACKED) {
      return 1;
    }
    return 0;
  }
  return compareLocation(a, b) ||
      compare(a.name.toLowerCase(), b.name.toLowerCase()) ||
      compare(a.id, b.id);
}

declare global {
  interface HTMLElementEventMap {
    'load-error': CustomEvent<Error|chrome.developerPrivate.LoadError>;
  }
}

export interface ExtensionsManagerElement {
  $: {
    toolbar: ExtensionsToolbarElement,
    viewManager: CrViewManagerElement,
    'items-list': ExtensionsItemListElement,
  };
}

// TODO(crbug.com/40270029): Always show a top shadow for the DETAILS, ERRORS and
// SITE_PERMISSIONS_ALL_SITES pages.
const ExtensionsManagerElementBase = CrContainerShadowMixin(PolymerElement);

export class ExtensionsManagerElement extends ExtensionsManagerElementBase {
  static get is() {
    return 'extensions-manager';
  }

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

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

      delegate: {
        type: Object,
        value() {
          return Service.getInstance();
        },
      },

      inDevMode: {
        type: Boolean,
        value: () => loadTimeData.getBoolean('inDevMode'),
      },

      isMv2DeprecationNoticeDismissed: {
        type: Boolean,
        value: () => loadTimeData.getBoolean('MV2DeprecationNoticeDismissed'),
      },

      showActivityLog: {
        type: Boolean,
        value: () => loadTimeData.getBoolean('showActivityLog'),
      },

      enableEnhancedSiteControls: {
        type: Boolean,
        value: () => loadTimeData.getBoolean('enableEnhancedSiteControls'),
      },

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

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

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

      filter: {
        type: String,
        value: '',
      },

      /**
       * The item currently displayed in the error subpage. We use a separate
       * item for different pages (rather than a single subpageItem_ property)
       * so that hidden subpages don't update when an item updates. That is, we
       * don't want the details view subpage to update when the item shown in
       * the errors page updates, and vice versa.
       */
      errorPageItem_: Object,

      /**
       * The item currently displayed in the details view subpage. See also
       * errorPageItem_.
       */
      detailViewItem_: Object,

      /**
       * The item that provides some information about the current extension
       * for the activity log view subpage. See also errorPageItem_.
       */
      activityLogItem_: Object,

      extensions_: Array,

      apps_: Array,

      /**
       * Prevents page content from showing before data is first loaded.
       */
      didInitPage_: {
        type: Boolean,
        value: false,
      },

      narrow_: {
        type: Boolean,
        observer: 'onNarrowChanged_',
      },

      showDrawer_: Boolean,

      showLoadErrorDialog_: Boolean,

      showInstallWarningsDialog_: Boolean,

      installWarnings_: Array,

      showOptionsDialog_: Boolean,

      /**
       * Whether the last page the user navigated from was the activity log
       * page.
       */
      fromActivityLog_: Boolean,
    };
  }

  canLoadUnpacked: boolean;
  delegate: Service;
  inDevMode: boolean;
  isMv2DeprecationNoticeDismissed: boolean;
  showActivityLog: boolean;
  enableEnhancedSiteControls: boolean;
  devModeControlledByPolicy: boolean;
  private isChildAccount_: boolean;
  private incognitoAvailable_: boolean;
  filter: string;
  private errorPageItem_?: chrome.developerPrivate.ExtensionInfo;
  private detailViewItem_?: chrome.developerPrivate.ExtensionInfo;
  private activityLogItem_?: chrome.developerPrivate.ExtensionInfo|
      ActivityLogExtensionPlaceholder;
  private extensions_: chrome.developerPrivate.ExtensionInfo[];
  private apps_: chrome.developerPrivate.ExtensionInfo[];
  private didInitPage_: boolean;
  private narrow_: boolean;
  private showDrawer_: boolean;
  private showLoadErrorDialog_: boolean;
  private showInstallWarningsDialog_: boolean;
  private installWarnings_: string[]|null;
  private showOptionsDialog_: boolean;
  private fromActivityLog_: boolean;
  private pageInitializedResolver_: PromiseResolver<void>;

  private currentPage_: PageState|null;
  private navigationListener_: number|null = null;

  constructor() {
    super();

    /**
     * The current page being shown. Default to null, and initPage_ will figure
     * out the initial page based on url.
     */
    this.currentPage_ = null;

    /**
     * The ID of the listener on |navigation|. Stored so that the
     * listener can be removed when this element is detached (happens in tests).
     */
    this.navigationListener_ = null;

    /**
     * A promise resolver for any external files waiting for initPage_ to be
     * called after the extensions info has been fetched.
     */
    this.pageInitializedResolver_ = new PromiseResolver();
  }

  override ready() {
    super.ready();

    this.addEventListener('load-error', this.onLoadError_);
    this.addEventListener('view-enter-start', this.onViewEnterStart_);
    this.addEventListener('view-exit-start', this.onViewExitStart_);
    this.addEventListener('view-exit-finish', this.onViewExitFinish_);

    const service = Service.getInstance();

    const onProfileStateChanged =
        (profileInfo: chrome.developerPrivate.ProfileInfo) => {
          this.isChildAccount_ = profileInfo.isChildAccount;
          this.incognitoAvailable_ = profileInfo.isIncognitoAvailable;
          this.devModeControlledByPolicy =
              profileInfo.isDeveloperModeControlledByPolicy;
          this.inDevMode = profileInfo.inDeveloperMode;
          this.canLoadUnpacked = profileInfo.canLoadUnpacked;
          this.isMv2DeprecationNoticeDismissed =
              profileInfo.isMv2DeprecationNoticeDismissed;
        };
    service.getProfileStateChangedTarget().addListener(onProfileStateChanged);
    service.getProfileConfiguration().then(onProfileStateChanged);

    service.getExtensionsInfo().then(extensionsAndApps => {
      this.initExtensionsAndApps_(extensionsAndApps);
      this.initPage_();

      service.getItemStateChangedTarget().addListener(
          this.onItemStateChanged_.bind(this));
    });
  }

  override connectedCallback() {
    super.connectedCallback();

    document.documentElement.classList.remove('loading');
    // https://github.com/microsoft/TypeScript/issues/13569
    (document as any).fonts.load('bold 12px Roboto');

    this.navigationListener_ = navigation.addListener(newPage => {
      this.changePage_(newPage);
    });
  }

  override disconnectedCallback() {
    super.disconnectedCallback();
    assert(this.navigationListener_);
    assert(navigation.removeListener(this.navigationListener_));
    this.navigationListener_ = null;
  }

  /**
   * @return the promise of `pageInitializedResolver_` so tests can wait for the
   * page to be initialized.
   */
  whenPageInitializedForTest(): Promise<void> {
    return this.pageInitializedResolver_.promise;
  }

  /**
   * Initializes the page to reflect what's specified in the url so that if
   * the user visits chrome://extensions/?id=..., we land on the proper page.
   */
  private initPage_() {
    this.didInitPage_ = true;
    this.changePage_(navigation.getCurrentPage());
    this.pageInitializedResolver_.resolve();
  }

  private onNarrowChanged_() {
    const drawer = this.shadowRoot!.querySelector('cr-drawer');
    if (!this.narrow_ && drawer && drawer.open) {
      drawer.close();
    }

    // TODO(crbug.com/c/1451985): Handle changing focus if focus is on the
    // sidebar or menu when it's about to disappear when `this.narrow_` changes.
  }

  private onItemStateChanged_(eventData: chrome.developerPrivate.EventData) {
    const EventType = chrome.developerPrivate.EventType;
    switch (eventData.event_type) {
      case EventType.VIEW_REGISTERED:
      case EventType.VIEW_UNREGISTERED:
      case EventType.INSTALLED:
      case EventType.LOADED:
      case EventType.UNLOADED:
      case EventType.ERROR_ADDED:
      case EventType.ERRORS_REMOVED:
      case EventType.PREFS_CHANGED:
      case EventType.WARNINGS_CHANGED:
      case EventType.COMMAND_ADDED:
      case EventType.COMMAND_REMOVED:
      case EventType.PERMISSIONS_CHANGED:
      case EventType.SERVICE_WORKER_STARTED:
      case EventType.SERVICE_WORKER_STOPPED:
      case EventType.PINNED_ACTIONS_CHANGED:
        // |extensionInfo| can be undefined in the case of an extension
        // being unloaded right before uninstallation. There's nothing to do
        // here.
        if (!eventData.extensionInfo) {
          break;
        }

        if (this.delegate.shouldIgnoreUpdate(
                eventData.extensionInfo.id, eventData.event_type)) {
          break;
        }

        const listId = this.getListId_(eventData.extensionInfo);
        const currentIndex = this.get(listId).findIndex(
            (item: chrome.developerPrivate.ExtensionInfo) =>
                item.id === eventData.extensionInfo!.id);

        if (currentIndex >= 0) {
          this.updateItem_(listId, currentIndex, eventData.extensionInfo);
        } else {
          this.addItem_(listId, eventData.extensionInfo);
        }
        break;
      case EventType.UNINSTALLED:
        this.removeItem_(eventData.item_id);
        break;
      case EventType.CONFIGURATION_CHANGED:
        const index = this.getIndexInList_('extensions_', eventData.item_id);
        this.updateItem_(
            'extensions_', index,
            Object.assign({}, this.getData_(eventData.item_id), {
              didAcknowledgeMV2DeprecationNotice:
                  eventData.extensionInfo?.didAcknowledgeMV2DeprecationNotice,
              safetyCheckText: eventData.extensionInfo?.safetyCheckText,
            }));
        break;
      default:
        assertNotReached();
    }
  }

  private onFilterChanged_(event: CustomEvent<string>) {
    if (this.currentPage_!.page !== Page.LIST) {
      navigation.navigateTo({page: Page.LIST});
    }
    this.filter = event.detail;
  }

  private onMenuButtonClick_() {
    this.showDrawer_ = true;
    setTimeout(() => {
      this.shadowRoot!.querySelector('cr-drawer')!.openDrawer();
    }, 0);
  }

  /**
   * @return The ID of the list that the item belongs in.
   */
  private getListId_(item: chrome.developerPrivate.ExtensionInfo): string {
    const ExtensionType = chrome.developerPrivate.ExtensionType;
    switch (item.type) {
      case ExtensionType.HOSTED_APP:
      case ExtensionType.LEGACY_PACKAGED_APP:
      case ExtensionType.PLATFORM_APP:
        return 'apps_';
      case ExtensionType.EXTENSION:
      case ExtensionType.SHARED_MODULE:
        return 'extensions_';
      case ExtensionType.THEME:
        assertNotReached('Don\'t send themes to the chrome://extensions page');
      default:
        assertNotReached();
    }
  }

  /**
   * @param listId The list to look for the item in.
   * @param itemId The id of the item to look for.
   * @return The index of the item in the list, or -1 if not found.
   */
  private getIndexInList_(listId: string, itemId: string): number {
    return this.get(listId).findIndex(function(
        item: chrome.developerPrivate.ExtensionInfo) {
      return item.id === itemId;
    });
  }

  private getData_(id: string): chrome.developerPrivate.ExtensionInfo
      |undefined {
    return this.extensions_[this.getIndexInList_('extensions_', id)] ||
        this.apps_[this.getIndexInList_('apps_', id)];
  }

  /**
   * Categorizes |extensionsAndApps| to apps and extensions and initializes
   * those lists.
   */
  private initExtensionsAndApps_(extensionsAndApps:
                                     chrome.developerPrivate.ExtensionInfo[]) {
    extensionsAndApps.sort(compareExtensions);
    const apps: chrome.developerPrivate.ExtensionInfo[] = [];
    const extensions: chrome.developerPrivate.ExtensionInfo[] = [];
    for (const i of extensionsAndApps) {
      const list = this.getListId_(i) === 'apps_' ? apps : extensions;
      list.push(i);
    }

    this.apps_ = apps;
    this.extensions_ = extensions;
  }

  /**
   * Creates and adds a new extensions-item element to the list, inserting it
   * into its sorted position in the relevant section.
   * @param item The extension the new element is representing.
   */
  private addItem_(
      listId: string, item: chrome.developerPrivate.ExtensionInfo) {
    // We should never try and add an existing item.
    assert(this.getIndexInList_(listId, item.id) === -1);
    let insertBeforeChild = this.get(listId).findIndex(function(
        listEl: chrome.developerPrivate.ExtensionInfo) {
      return compareExtensions(listEl, item) > 0;
    });
    if (insertBeforeChild === -1) {
      insertBeforeChild = this.get(listId).length;
    }
    this.splice(listId, insertBeforeChild, 0, item);
  }

  /**
   * @param item The data for the item to update.
   */
  private updateItem_(
      listId: string, index: number,
      item: chrome.developerPrivate.ExtensionInfo) {
    // We should never try and update a non-existent item.
    assert(index >= 0);
    this.set([listId, index], item);

    // Update the subpage if it is open and displaying the item. If it's not
    // open, we don't update the data even if it's displaying that item. We'll
    // set the item correctly before opening the page. It's a little weird
    // that the DOM will have stale data, but there's no point in causing the
    // extra work.
    if (this.detailViewItem_ && this.detailViewItem_.id === item.id &&
        this.currentPage_!.page === Page.DETAILS) {
      this.detailViewItem_ = item;
    } else if (
        this.errorPageItem_ && this.errorPageItem_.id === item.id &&
        this.currentPage_!.page === Page.ERRORS) {
      this.errorPageItem_ = item;
    } else if (
        this.activityLogItem_ && this.activityLogItem_.id === item.id &&
        this.currentPage_!.page === Page.ACTIVITY_LOG) {
      this.activityLogItem_ = item;
    }
  }

  // When an item is removed while on the 'item list' page, move focus to the
  // next item in the list with `listId` if available. If no items are in that
  // list, focus to the search bar as a fallback.
  // This is a fix for crbug.com/1416324 which causes focus to linger on a
  // deleted element, which is then read by the screen reader.
  private focusAfterItemRemoved_(listId: string, index: number) {
    // A timeout is used so elements are focused after the DOM is updated.
    setTimeout(() => {
      if (this.get(listId).length) {
        const focusIndex = Math.min(this.get(listId).length - 1, index);
        const itemToFocusId = this.get([listId, focusIndex])!.id;

        // In the rare case where the item cannot be focused despite existing,
        // focus the search bar.
        if (!this.$['items-list'].focusItemButton(itemToFocusId)) {
          this.$.toolbar.focusSearchInput();
        }
      } else {
        this.$.toolbar.focusSearchInput();
      }
    }, 0);
  }

  /**
   * @param itemId The id of item to remove.
   */
  private removeItem_(itemId: string) {
    // Search for the item to be deleted in `extensions_`.
    let listId = 'extensions_';
    let index = this.getIndexInList_(listId, itemId);
    if (index === -1) {
      // If not in `extensions_` it must be in `apps_`.
      listId = 'apps_';
      index = this.getIndexInList_(listId, itemId);
    }

    // We should never try and remove a non-existent item.
    assert(index >= 0);
    this.splice(listId, index, 1);
    if (this.currentPage_!.page === Page.LIST) {
      this.focusAfterItemRemoved_(listId, index);
    } else if (
        (this.currentPage_!.page === Page.ACTIVITY_LOG ||
         this.currentPage_!.page === Page.DETAILS ||
         this.currentPage_!.page === Page.ERRORS) &&
        this.currentPage_!.extensionId === itemId) {
      // Leave the details page (the 'item list' page is a fine choice).
      navigation.replaceWith({page: Page.LIST});
    }
  }

  private onLoadError_(
      e: CustomEvent<Error|chrome.developerPrivate.LoadError>) {
    this.showLoadErrorDialog_ = true;
    setTimeout(() => {
      const dialog = this.shadowRoot!.querySelector('extensions-load-error')!;
      dialog.loadError = e.detail;
      dialog.show();
    }, 0);
  }

  /**
   * Changes the active page selection.
   */
  private changePage_(newPage: PageState) {
    this.onCloseDrawer_();

    const optionsDialog =
        this.shadowRoot!.querySelector('extensions-options-dialog');
    if (optionsDialog && optionsDialog.open) {
      this.showOptionsDialog_ = false;
    }

    const fromPage = this.currentPage_ ? this.currentPage_.page : null;
    const toPage = newPage.page;
    let data: chrome.developerPrivate.ExtensionInfo|undefined;
    let activityLogPlaceholder;
    if (toPage === Page.LIST) {
      // Dismiss menu notifications for extensions module of Safety Hub.
      this.delegate.dismissSafetyHubExtensionsMenuNotification();
    }
    if (newPage.extensionId) {
      data = this.getData_(newPage.extensionId);
      if (!data) {
        // Allow the user to navigate to the activity log page even if the
        // extension ID is not valid. This enables the use case of seeing an
        // extension's install-time activities by navigating to an extension's
        // activity log page, then installing the extension.
        if (this.showActivityLog && toPage === Page.ACTIVITY_LOG) {
          activityLogPlaceholder = {
            id: newPage.extensionId,
            isPlaceholder: true,
          };
        } else {
          // Attempting to view an invalid (removed?) app or extension ID.
          navigation.replaceWith({page: Page.LIST});
          return;
        }
      }
    }

    if (toPage === Page.DETAILS) {
      this.detailViewItem_ = data;
    } else if (toPage === Page.ERRORS) {
      this.errorPageItem_ = data;
    } else if (toPage === Page.ACTIVITY_LOG) {
      if (!this.showActivityLog) {
        // Redirect back to the details page if we try to view the
        // activity log of an extension but the flag is not set.
        navigation.replaceWith(
            {page: Page.DETAILS, extensionId: newPage.extensionId});
        return;
      }

      this.activityLogItem_ = data || activityLogPlaceholder;
    } else if (
        (toPage === Page.SITE_PERMISSIONS ||
         toPage === Page.SITE_PERMISSIONS_ALL_SITES) &&
        !this.enableEnhancedSiteControls) {
      // Redirect back to the main page if we try to view the new site
      // permissions page but the flag is not set.
      navigation.replaceWith({page: Page.LIST});
      return;
    }

    if (fromPage !== toPage) {
      this.$.viewManager.switchView(toPage, 'no-animation', 'no-animation');
    }

    if (newPage.subpage) {
      assert(newPage.subpage === Dialog.OPTIONS);
      assert(newPage.extensionId);
      this.showOptionsDialog_ = true;
      setTimeout(() => {
        this.shadowRoot!.querySelector('extensions-options-dialog')!.show(
            data!,
        );
      }, 0);
    }

    document.title = toPage === Page.DETAILS ?
        `${loadTimeData.getString('title')} - ${this.detailViewItem_!.name}` :
        loadTimeData.getString('title');
    this.currentPage_ = newPage;
  }

  /**
   * This method detaches the drawer dialog completely. Should only be
   * triggered by the dialog's 'close' event.
   */
  private onDrawerClose_() {
    this.showDrawer_ = false;
  }

  /**
   * This method animates the closing of the drawer.
   */
  private onCloseDrawer_() {
    const drawer = this.shadowRoot!.querySelector('cr-drawer');
    if (drawer && drawer.open) {
      drawer.close();
    }
  }

  private onLoadErrorDialogClose_() {
    this.showLoadErrorDialog_ = false;
  }

  private onOptionsDialogClose_() {
    this.showOptionsDialog_ = false;
    this.shadowRoot!.querySelector(
                        'extensions-detail-view')!.focusOptionsButton();
  }

  private onViewEnterStart_() {
    this.fromActivityLog_ = false;
  }

  private onViewExitStart_(e: Event) {
    const viewType = (e.composedPath()[0] as HTMLElement).tagName;
    this.fromActivityLog_ = viewType === 'EXTENSIONS-ACTIVITY-LOG';
  }

  private onViewExitFinish_(e: Event) {
    const viewType = (e.composedPath()[0] as HTMLElement).tagName;
    if (viewType === 'EXTENSIONS-ITEM-LIST' ||
        viewType === 'EXTENSIONS-KEYBOARD-SHORTCUTS' ||
        viewType === 'EXTENSIONS-ACTIVITY-LOG' ||
        viewType === 'EXTENSIONS-SITE-PERMISSIONS' ||
        viewType === 'EXTENSIONS-SITE-PERMISSIONS-BY-SITE') {
      return;
    }

    const extensionId =
        (e.composedPath()[0] as ExtensionsDetailViewElement).data.id;
    const list = this.shadowRoot!.querySelector('extensions-item-list')!;
    const button = viewType === 'EXTENSIONS-DETAIL-VIEW' ?
        list.getDetailsButton(extensionId) :
        list.getErrorsButton(extensionId);

    // The button will not exist, when returning from a details page
    // because the corresponding extension/app was deleted.
    if (button) {
      button.focus();
    }
  }

  private onShowInstallWarnings_(e: CustomEvent<string[]>) {
    // Leverage Polymer data bindings instead of just assigning the
    // installWarnings on the dialog since the dialog hasn't been stamped
    // in the DOM yet.
    this.installWarnings_ = e.detail;
    this.showInstallWarningsDialog_ = true;
  }

  private onInstallWarningsDialogClose_() {
    this.installWarnings_ = null;
    this.showInstallWarningsDialog_ = false;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'extensions-manager': ExtensionsManagerElement;
  }
}

customElements.define(ExtensionsManagerElement.is, ExtensionsManagerElement);