chromium/chrome/browser/resources/extensions/service.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 type {ChromeEvent} from '/tools/typescript/definitions/chrome_event.js';
import {assert} from 'chrome://resources/js/assert.js';

import type {ActivityLogDelegate} from './activity_log/activity_log_history.js';
import type {ActivityLogEventDelegate} from './activity_log/activity_log_stream.js';
import type {ErrorPageDelegate} from './error_page.js';
import type {ItemDelegate} from './item.js';
import type {KeyboardShortcutDelegate} from './keyboard_shortcut_delegate.js';
import type {LoadErrorDelegate} from './load_error.js';
import type {Mv2DeprecationDelegate} from './mv2_deprecation_delegate.js';
import {Dialog, navigation, Page} from './navigation_helper.js';
import type {PackDialogDelegate} from './pack_dialog.js';
import type {SiteSettingsDelegate} from './site_permissions/site_settings_mixin.js';
import type {ToolbarDelegate} from './toolbar.js';

export interface ServiceInterface extends ActivityLogDelegate,
                                          ActivityLogEventDelegate,
                                          ErrorPageDelegate, ItemDelegate,
                                          KeyboardShortcutDelegate,
                                          LoadErrorDelegate,
                                          Mv2DeprecationDelegate,
                                          PackDialogDelegate,
                                          SiteSettingsDelegate,
                                          ToolbarDelegate {
  notifyDragInstallInProgress(): void;
  loadUnpackedFromDrag(): Promise<boolean>;
  installDroppedFile(): void;
  getProfileStateChangedTarget():
      ChromeEvent<(info: chrome.developerPrivate.ProfileInfo) => void>;
  getProfileConfiguration(): Promise<chrome.developerPrivate.ProfileInfo>;
  getExtensionsInfo(): Promise<chrome.developerPrivate.ExtensionInfo[]>;
  getExtensionSize(id: string): Promise<string>;
  dismissSafetyHubExtensionsMenuNotification(): void;
  dismissMv2DeprecationNotice(): void;
}

export class Service implements ServiceInterface {
  private isDeleting_: boolean = false;
  private eventsToIgnoreOnce_: Set<string> = new Set();

  getProfileConfiguration() {
    return chrome.developerPrivate.getProfileConfiguration();
  }

  getItemStateChangedTarget() {
    return chrome.developerPrivate.onItemStateChanged;
  }

  shouldIgnoreUpdate(
      extensionId: string,
      eventType: chrome.developerPrivate.EventType): boolean {
    return this.eventsToIgnoreOnce_.delete(`${extensionId}_${eventType}`);
  }

  ignoreNextEvent(
      extensionId: string, eventType: chrome.developerPrivate.EventType): void {
    this.eventsToIgnoreOnce_.add(`${extensionId}_${eventType}`);
  }

  getProfileStateChangedTarget() {
    return chrome.developerPrivate.onProfileStateChanged;
  }

  getExtensionsInfo() {
    return chrome.developerPrivate.getExtensionsInfo(
        {includeDisabled: true, includeTerminated: true});
  }

  getExtensionSize(id: string) {
    return chrome.developerPrivate.getExtensionSize(id);
  }

  addRuntimeHostPermission(id: string, host: string): Promise<void> {
    return chrome.developerPrivate.addHostPermission(id, host);
  }

  removeRuntimeHostPermission(id: string, host: string): Promise<void> {
    return chrome.developerPrivate.removeHostPermission(id, host);
  }

  recordUserAction(metricName: string): void {
    chrome.metricsPrivate.recordUserAction(metricName);
  }

  /**
   * Opens a file browser dialog for the user to select a file (or directory).
   * @return The promise to be resolved with the selected path.
   */
  private chooseFilePath_(
      selectType: chrome.developerPrivate.SelectType,
      fileType: chrome.developerPrivate.FileType): Promise<string> {
    return chrome.developerPrivate.choosePath(selectType, fileType)
        .catch(error => {
          if (error.message !== 'File selection was canceled.') {
            throw error;
          }
          return '';
        });
  }

  updateExtensionCommandKeybinding(
      extensionId: string, commandName: string, keybinding: string) {
    chrome.developerPrivate.updateExtensionCommand({
      extensionId: extensionId,
      commandName: commandName,
      keybinding: keybinding,
    });
  }

  updateExtensionCommandScope(
      extensionId: string, commandName: string,
      scope: chrome.developerPrivate.CommandScope): void {
    // The COMMAND_REMOVED event needs to be ignored since it is sent before
    // the command is added back with the updated scope but can be handled
    // after the COMMAND_ADDED event.
    this.ignoreNextEvent(
        extensionId, chrome.developerPrivate.EventType.COMMAND_REMOVED);
    chrome.developerPrivate.updateExtensionCommand({
      extensionId: extensionId,
      commandName: commandName,
      scope: scope,
    });
  }


  setShortcutHandlingSuspended(isCapturing: boolean) {
    chrome.developerPrivate.setShortcutHandlingSuspended(isCapturing);
  }

  /**
   * @return A signal that loading finished, rejected if any error occurred.
   */
  private loadUnpackedHelper_(extraOptions?:
                                  chrome.developerPrivate.LoadUnpackedOptions):
      Promise<boolean> {
    const options = Object.assign(
        {
          failQuietly: true,
          populateError: true,
        },
        extraOptions);
    return chrome.developerPrivate.loadUnpacked(options)
        .then(loadError => {
          if (loadError) {
            throw loadError;
          }
          // The load was successful if there's no loadError.
          return true;
        })
        .catch(error => {
          if (error.message !== 'File selection was canceled.') {
            throw error;
          }
          return false;
        });
  }

  deleteItem(id: string) {
    if (this.isDeleting_) {
      return;
    }
    chrome.metricsPrivate.recordUserAction('Extensions.RemoveExtensionClick');
    this.isDeleting_ = true;
    chrome.management.uninstall(id, {showConfirmDialog: true})
        .catch(
            _ => {
                // The error was almost certainly the user canceling the dialog.
                // Do nothing. We only check it so we don't get noisy logs.
            })
        .finally(() => {
          this.isDeleting_ = false;
        });
  }

  /**
   * Allows the consumer to call the API asynchronously.
   */
  uninstallItem(id: string): Promise<void> {
    chrome.metricsPrivate.recordUserAction('Extensions.RemoveExtensionClick');
    return chrome.management.uninstall(id, {showConfirmDialog: true});
  }

  deleteItems(ids: string[]): Promise<void> {
    this.isDeleting_ = true;
    return chrome.developerPrivate.removeMultipleExtensions(ids).finally(() => {
      this.isDeleting_ = false;
    });
  }

  setItemSafetyCheckWarningAcknowledged(
      id: string,
      reason: chrome.developerPrivate.SafetyCheckWarningReason): Promise<void> {
    return chrome.developerPrivate.updateExtensionConfiguration({
      extensionId: id,
      acknowledgeSafetyCheckWarningReason: reason,
    });
  }

  setItemEnabled(id: string, isEnabled: boolean) {
    chrome.metricsPrivate.recordUserAction(
        isEnabled ? 'Extensions.ExtensionEnabled' :
                    'Extensions.ExtensionDisabled');
    chrome.management.setEnabled(id, isEnabled);
  }

  setItemAllowedIncognito(id: string, isAllowedIncognito: boolean) {
    chrome.developerPrivate.updateExtensionConfiguration({
      extensionId: id,
      incognitoAccess: isAllowedIncognito,
    });
  }

  setItemAllowedOnFileUrls(id: string, isAllowedOnFileUrls: boolean) {
    chrome.developerPrivate.updateExtensionConfiguration({
      extensionId: id,
      fileAccess: isAllowedOnFileUrls,
    });
  }

  setItemHostAccess(id: string, hostAccess: chrome.developerPrivate.HostAccess):
      void {
    chrome.developerPrivate.updateExtensionConfiguration({
      extensionId: id,
      hostAccess: hostAccess,
    });
  }

  setItemCollectsErrors(id: string, collectsErrors: boolean): void {
    chrome.developerPrivate.updateExtensionConfiguration({
      extensionId: id,
      errorCollection: collectsErrors,
    });
  }

  setItemPinnedToToolbar(id: string, pinnedToToolbar: boolean) {
    chrome.developerPrivate.updateExtensionConfiguration({
      extensionId: id,
      pinnedToToolbar,
    });
  }

  inspectItemView(id: string, view: chrome.developerPrivate.ExtensionView):
      void {
    chrome.developerPrivate.openDevTools({
      extensionId: id,
      renderProcessId: view.renderProcessId,
      renderViewId: view.renderViewId,
      incognito: view.incognito,
      isServiceWorker: view.type === 'EXTENSION_SERVICE_WORKER_BACKGROUND',
    });
  }

  openUrl(url: string): void {
    window.open(url);
  }

  reloadItem(id: string): Promise<void> {
    return chrome.developerPrivate
        .reload(id, {failQuietly: true, populateErrorForUnpacked: true})
        .then(loadError => {
          if (loadError) {
            throw loadError;
          }
        });
  }

  repairItem(id: string): void {
    chrome.developerPrivate.repairExtension(id);
  }

  showItemOptionsPage(extension: chrome.developerPrivate.ExtensionInfo): void {
    assert(extension && extension.optionsPage);
    if (extension.optionsPage!.openInTab) {
      chrome.developerPrivate.showOptions(extension.id);
    } else {
      navigation.navigateTo({
        page: Page.DETAILS,
        subpage: Dialog.OPTIONS,
        extensionId: extension.id,
      });
    }
  }

  setProfileInDevMode(inDevMode: boolean) {
    chrome.developerPrivate.updateProfileConfiguration(
        {inDeveloperMode: inDevMode});
  }

  loadUnpacked(): Promise<boolean> {
    return this.loadUnpackedHelper_();
  }

  retryLoadUnpacked(retryGuid?: string): Promise<boolean> {
    // Attempt to load an unpacked extension, optionally as another attempt at
    // a previously-specified load.
    return this.loadUnpackedHelper_({retryGuid});
  }

  choosePackRootDirectory(): Promise<string> {
    return this.chooseFilePath_(
        chrome.developerPrivate.SelectType.FOLDER,
        chrome.developerPrivate.FileType.LOAD);
  }

  choosePrivateKeyPath(): Promise<string> {
    return this.chooseFilePath_(
        chrome.developerPrivate.SelectType.FILE,
        chrome.developerPrivate.FileType.PEM);
  }

  packExtension(rootPath: string, keyPath: string, flag?: number):
      Promise<chrome.developerPrivate.PackDirectoryResponse> {
    return chrome.developerPrivate.packDirectory(rootPath, keyPath, flag);
  }

  updateAllExtensions(extensions: chrome.developerPrivate.ExtensionInfo[]) {
    /**
     * Attempt to reload local extensions. If an extension fails to load, the
     * user is prompted to try updating the broken extension using loadUnpacked
     * and we skip reloading the remaining local extensions.
     */
    return chrome.developerPrivate.autoUpdate().then(
        () => {
          chrome.metricsPrivate.recordUserAction('Options_UpdateExtensions');
          return new Promise<void>((resolve, reject) => {
            const loadLocalExtensions = async () => {
              for (const extension of extensions) {
                if (extension.location === 'UNPACKED') {
                  try {
                    await this.reloadItem(extension.id);
                  } catch (loadError) {
                    reject(loadError);
                    break;
                  }
                }
              }
              resolve();
            };
            loadLocalExtensions();
          });
        });
  }

  deleteErrors(
      extensionId: string, errorIds?: number[],
      type?: chrome.developerPrivate.ErrorType) {
    chrome.developerPrivate.deleteExtensionErrors({
      extensionId: extensionId,
      errorIds: errorIds,
      type: type,
    });
  }

  requestFileSource(args: chrome.developerPrivate.RequestFileSourceProperties):
      Promise<chrome.developerPrivate.RequestFileSourceResponse> {
    return chrome.developerPrivate.requestFileSource(args);
  }

  showInFolder(id: string) {
    chrome.developerPrivate.showPath(id);
  }

  getExtensionActivityLog(extensionId: string):
      Promise<chrome.activityLogPrivate.ActivityResultSet> {
    return chrome.activityLogPrivate.getExtensionActivities(
        {
          activityType: chrome.activityLogPrivate.ExtensionActivityFilter.ANY,
          extensionId: extensionId,
        },
    );
  }

  getFilteredExtensionActivityLog(extensionId: string, searchTerm: string) {
    const anyType = chrome.activityLogPrivate.ExtensionActivityFilter.ANY;

    // Construct one filter for each API call we will make: one for substring
    // search by api call, one for substring search by page URL, and one for
    // substring search by argument URL. % acts as a wildcard.
    const activityLogFilters = [
      {
        activityType: anyType,
        extensionId: extensionId,
        apiCall: `%${searchTerm}%`,
      },
      {
        activityType: anyType,
        extensionId: extensionId,
        pageUrl: `%${searchTerm}%`,
      },
      {
        activityType: anyType,
        extensionId: extensionId,
        argUrl: `%${searchTerm}%`,
      },
    ];

    const promises:
        Array<Promise<chrome.activityLogPrivate.ActivityResultSet>> =
            activityLogFilters.map(
                filter =>
                    chrome.activityLogPrivate.getExtensionActivities(filter));

    return Promise.all(promises).then(results => {
      // We may have results that are present in one or more searches, so
      // we merge them here. We also assume that every distinct activity
      // id corresponds to exactly one activity.
      const activitiesById = new Map();
      for (const result of results) {
        for (const activity of result.activities) {
          activitiesById.set(activity.activityId, activity);
        }
      }

      return {activities: Array.from(activitiesById.values())};
    });
  }

  deleteActivitiesById(activityIds: string[]): Promise<void> {
    return chrome.activityLogPrivate.deleteActivities(activityIds);
  }

  deleteActivitiesFromExtension(extensionId: string): Promise<void> {
    return chrome.activityLogPrivate.deleteActivitiesByExtension(extensionId);
  }

  getOnExtensionActivity(): ChromeEvent<
      (activity: chrome.activityLogPrivate.ExtensionActivity) => void> {
    return chrome.activityLogPrivate.onExtensionActivity;
  }

  downloadActivities(rawActivityData: string, fileName: string) {
    const blob = new Blob([rawActivityData], {type: 'application/json'});
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = fileName;
    a.click();
  }

  /**
   * Attempts to load an unpacked extension via a drag-n-drop gesture.
   * @return {!Promise}
   */
  loadUnpackedFromDrag() {
    return this.loadUnpackedHelper_({useDraggedPath: true});
  }

  installDroppedFile() {
    chrome.developerPrivate.installDroppedFile();
  }

  notifyDragInstallInProgress() {
    chrome.developerPrivate.notifyDragInstallInProgress();
  }

  getUserSiteSettings(): Promise<chrome.developerPrivate.UserSiteSettings> {
    return chrome.developerPrivate.getUserSiteSettings();
  }

  addUserSpecifiedSites(
      siteSet: chrome.developerPrivate.SiteSet,
      hosts: string[]): Promise<void> {
    return chrome.developerPrivate.addUserSpecifiedSites({siteSet, hosts});
  }

  removeUserSpecifiedSites(
      siteSet: chrome.developerPrivate.SiteSet,
      hosts: string[]): Promise<void> {
    return chrome.developerPrivate.removeUserSpecifiedSites({siteSet, hosts});
  }

  getUserAndExtensionSitesByEtld():
      Promise<chrome.developerPrivate.SiteGroup[]> {
    return chrome.developerPrivate.getUserAndExtensionSitesByEtld();
  }

  getMatchingExtensionsForSite(site: string):
      Promise<chrome.developerPrivate.MatchingExtensionInfo[]> {
    return chrome.developerPrivate.getMatchingExtensionsForSite(site);
  }

  getUserSiteSettingsChangedTarget() {
    return chrome.developerPrivate.onUserSiteSettingsChanged;
  }

  setShowAccessRequestsInToolbar(id: string, showRequests: boolean) {
    chrome.developerPrivate.updateExtensionConfiguration({
      extensionId: id,
      showAccessRequestsInToolbar: showRequests,
    });
  }

  updateSiteAccess(
      site: string,
      updates: chrome.developerPrivate.ExtensionSiteAccessUpdate[]):
      Promise<void> {
    return chrome.developerPrivate.updateSiteAccess(site, updates);
  }

  dismissSafetyHubExtensionsMenuNotification() {
    chrome.developerPrivate.dismissSafetyHubExtensionsMenuNotification();
  }

  dismissMv2DeprecationNotice(): void {
    chrome.developerPrivate.updateProfileConfiguration(
        {isMv2DeprecationNoticeDismissed: true});
  }

  dismissMv2DeprecationNoticeForExtension(id: string): Promise<void> {
    return chrome.developerPrivate.dismissMv2DeprecationNoticeForExtension(id);
  }

  static getInstance(): ServiceInterface {
    return instance || (instance = new Service());
  }

  static setInstance(obj: ServiceInterface) {
    instance = obj;
  }
}

let instance: ServiceInterface|null = null;