// Copyright 2016 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_link_row/cr_link_row.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/icons.html.js';
import 'chrome://resources/cr_elements/policy/cr_tooltip_icon.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 '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 'chrome://resources/polymer/v3_0/paper-spinner/paper-spinner-lite.js';
import './host_permissions_toggle_list.js';
import './runtime_host_permissions.js';
import './shared_style.css.js';
import './shared_vars.css.js';
import './strings.m.js';
import './toggle_row.js';
import {AnchorAlignment} from 'chrome://resources/cr_elements/cr_action_menu/cr_action_menu.js';
import type {CrActionMenuElement} from 'chrome://resources/cr_elements/cr_action_menu/cr_action_menu.js';
import type {CrLinkRowElement} from 'chrome://resources/cr_elements/cr_link_row/cr_link_row.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 type {CrTooltipIconElement} from 'chrome://resources/cr_elements/policy/cr_tooltip_icon.js';
import {assert, assertNotReached} from 'chrome://resources/js/assert.js';
import {focusWithoutInk} from 'chrome://resources/js/focus_without_ink.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import type {DomRepeatEvent} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {afterNextRender, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {getTemplate} from './detail_view.html.js';
import type {ItemDelegate} from './item.js';
import {ItemMixin} from './item_mixin.js';
import {computeInspectableViewLabel, convertSafetyCheckReason, EnableControl, getEnableControl, getEnableToggleAriaLabel, getEnableToggleTooltipText, getItemSource, getItemSourceString, isEnabled, SAFETY_HUB_EXTENSION_KEPT_HISTOGRAM_NAME, SAFETY_HUB_EXTENSION_REMOVED_HISTOGRAM_NAME, SAFETY_HUB_WARNING_REASON_MAX_SIZE, sortViews, userCanChangeEnablement} from './item_util.js';
import type {Mv2DeprecationDelegate} from './mv2_deprecation_delegate.js';
import {getMv2ExperimentStage, Mv2ExperimentStage} from './mv2_deprecation_util.js';
import {navigation, Page} from './navigation_helper.js';
import type {ExtensionsToggleRowElement} from './toggle_row.js';
export interface ExtensionsDetailViewElement {
$: {
actionMenu: CrActionMenuElement,
closeButton: HTMLElement,
description: HTMLElement,
enableToggle: CrToggleElement,
extensionsActivityLogLink: HTMLElement,
extensionsOptions: CrLinkRowElement,
parentDisabledPermissionsToolTip: CrTooltipIconElement,
safetyCheckWarningContainer: HTMLElement,
source: HTMLElement,
};
}
const ExtensionsDetailViewElementBase = I18nMixin(ItemMixin(PolymerElement));
export class ExtensionsDetailViewElement extends
ExtensionsDetailViewElementBase {
static get is() {
return 'extensions-detail-view';
}
static get template() {
return getTemplate();
}
static get properties() {
return {
/**
* The underlying ExtensionInfo for the details being displayed.
*/
data: Object,
size_: String,
delegate: Object,
/** Whether the user has enabled the UI's developer mode. */
inDevMode: Boolean,
/**
* Whether enhanced site controls have been enabled (through a feature
* flag). For this page, there are some changes to the site permissions
* section.
*/
enableEnhancedSiteControls: Boolean,
/** Whether "allow in incognito" option should be shown. */
incognitoAvailable: Boolean,
/** Whether "View Activity Log" link should be shown. */
showActivityLog: Boolean,
/** Whether the user navigated to this page from the activity log page. */
fromActivityLog: Boolean,
/** Inspectable views sorted to put background/service workers first */
sortedViews_: {
type: Array,
computed: 'computeSortedViews_(data.views)',
},
/** Whether the extensions safety check warning is shown. */
showSafetyCheck_: {
type: Boolean,
computed: 'computeShowSafetyCheck_(data.safetyCheckText)',
observer: 'onShowSafetyCheckChanged_',
},
/** Whether the mv2 deprecation message is shown. */
showMv2DeprecationMessage_: {
type: Boolean,
computed: 'computeShowMv2DeprecationMessage_(' +
'mv2ExperimentStage_, data.isAffectedByMV2Deprecation, ' +
'data.didAcknowledgeMV2DeprecationNotice, ' +
'data.disableReasons.unsupportedManifestVersion)',
},
/**
* Whether the find alternative button in the mv2 deprecation message is
* shown.
*/
showMv2DeprecationFindAlternativeButton_: {
type: Boolean,
computed: 'computeShowMv2DeprecationFindAlternativeButton_(' +
'mv2ExperimentStage_, data.recommendationsUrl)',
},
/** Whether the remove button in the mv2 deprecation message is shown. */
showMv2DeprecationRemoveButton_: {
type: Boolean,
computed: 'computeShowMv2DeprecationRemoveButton_(mv2ExperimentStage_)',
},
/** Whether the action menu in the mv2 deprecation message is shown. */
showMv2DeprecationActionMenu_: {
type: Boolean,
computed: 'computeShowMv2DeprecationActionMenu_(mv2ExperimentStage_)',
},
/** Whether the extensions blocklist text is shown. */
showBlocklistText_: {
type: Boolean,
computed: 'computeShowBlocklistText_(data.blocklistText)',
},
/**
* Current Manifest V2 experiment stage.
*/
mv2ExperimentStage_: {
type: Number,
value: () => getMv2ExperimentStage(
loadTimeData.getInteger('MV2ExperimentStage')),
},
// <if expr="chromeos_ash">
/** Whether Lacros is enabled. */
isLacrosEnabled_: {
type: Boolean,
readOnly: true,
value: () => loadTimeData.getBoolean('isLacrosEnabled'),
},
// </if>
};
}
static get observers() {
return ['onItemIdChanged_(data.id, delegate)'];
}
data: chrome.developerPrivate.ExtensionInfo;
delegate: ItemDelegate&Mv2DeprecationDelegate;
inDevMode: boolean;
enableEnhancedSiteControls: boolean;
incognitoAvailable: boolean;
showActivityLog: boolean;
fromActivityLog: boolean;
private showSafetyCheck_: boolean;
private showMv2DeprecationMessage_: boolean;
private showMv2DeprecationFindAlternativeButton_: boolean;
private showMv2DeprecationRemoveButton_: boolean;
private showMv2DeprecationActionMenu_: boolean;
private showBlocklistText_: boolean;
private size_: string;
private sortedViews_: chrome.developerPrivate.ExtensionView[];
private mv2ExperimentStage_: Mv2ExperimentStage;
// <if expr="chromeos_ash">
private readonly isLacrosEnabled_: boolean;
// </if>
override ready() {
super.ready();
this.addEventListener('view-enter-start', this.onViewEnterStart_);
}
private fire_(eventName: string, detail?: any) {
this.dispatchEvent(
new CustomEvent(eventName, {bubbles: true, composed: true, detail}));
}
/**
* Focuses the extensions options button. This should be used after the
* dialog closes.
*/
focusOptionsButton() {
this.$.extensionsOptions.focus();
}
/**
* Focuses the back button when page is loaded.
*/
private onViewEnterStart_() {
const elementToFocus = this.fromActivityLog ?
this.$.extensionsActivityLogLink :
this.$.closeButton;
afterNextRender(this, () => focusWithoutInk(elementToFocus));
}
private onItemIdChanged_() {
// Clear the size, since this view is reused, such that no obsolete size
// is displayed.:
this.size_ = '';
this.delegate.getExtensionSize(this.data.id).then(size => {
this.size_ = size;
});
}
private onActivityLogClick_() {
navigation.navigateTo({page: Page.ACTIVITY_LOG, extensionId: this.data.id});
}
private getDescription_(description: string, fallback: string): string {
return description || fallback;
}
private getBackButtonAriaLabel_(): string {
return loadTimeData.getStringF(
'itemDetailsBackButtonAriaLabel', this.data.name);
}
private getBackButtonAriaRoleDescription_(): string {
return loadTimeData.getStringF(
'itemDetailsBackButtonRoleDescription', this.data.name);
}
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 onCloseButtonClick_() {
navigation.navigateTo({page: Page.LIST});
}
private isEnabled_(): boolean {
return isEnabled(this.data.state);
}
private isEnableToggleEnabled_(): boolean {
return userCanChangeEnablement(this.data);
}
private hasDependentExtensions_(): boolean {
return this.data.dependentExtensions.length > 0;
}
private hasSevereWarnings_(): boolean {
return this.data.disableReasons.corruptInstall ||
this.data.disableReasons.suspiciousInstall ||
this.data.disableReasons.updateRequired || !!this.data.blocklistText ||
this.data.disableReasons.publishedInStoreRequired ||
this.data.runtimeWarnings.length > 0;
}
private computeDevReloadButtonHidden_(): boolean {
return !this.canReloadItem();
}
private computeEnabledStyle_(): string {
return this.isEnabled_() ? 'enabled-text' : '';
}
private computeEnabledText_(
state: chrome.developerPrivate.ExtensionState, onText: string,
offText: string): string {
// TODO(devlin): Get the full spectrum of these strings from bettes.
return isEnabled(state) ? onText : offText;
}
private computeSortedViews_(): chrome.developerPrivate.ExtensionView[] {
return sortViews(this.data.views);
}
private computeInspectLabel_(view: chrome.developerPrivate.ExtensionView):
string {
return computeInspectableViewLabel(view);
}
private shouldShowOptionsLink_(): boolean {
return !!this.data.optionsPage;
}
private shouldShowOptionsSection_(): boolean {
return this.canPinToToolbar_() || this.data.incognitoAccess.isEnabled ||
this.data.fileAccess.isEnabled || this.data.errorCollection.isEnabled;
}
private canPinToToolbar_(): boolean {
return this.data.pinnedToToolbar !== undefined;
}
private shouldShowIncognitoOption_(): boolean {
return this.data.incognitoAccess.isEnabled && this.incognitoAvailable;
}
private onEnableToggleChange_() {
this.delegate.setItemEnabled(this.data.id, this.$.enableToggle.checked);
this.$.enableToggle.checked = this.isEnabled_();
}
private onInspectClick_(
e: DomRepeatEvent<chrome.developerPrivate.ExtensionView>) {
this.delegate.inspectItemView(this.data.id, e.model.item);
}
private onExtensionOptionsClick_() {
this.delegate.showItemOptionsPage(this.data);
}
private onReloadClick_() {
this.reloadItem().catch((loadError) => this.fire_('load-error', loadError));
}
private onRemoveClick_() {
if (this.showSafetyCheck_) {
chrome.metricsPrivate.recordUserAction('SafetyCheck.DetailRemoveClicked');
chrome.metricsPrivate.recordEnumerationValue(
SAFETY_HUB_EXTENSION_REMOVED_HISTOGRAM_NAME,
convertSafetyCheckReason(this.data.safetyCheckWarningReason),
SAFETY_HUB_WARNING_REASON_MAX_SIZE);
}
this.delegate.deleteItem(this.data.id);
}
private onKeepClick_() {
if (this.showSafetyCheck_) {
chrome.metricsPrivate.recordUserAction('SafetyCheck.DetailKeepClicked');
chrome.metricsPrivate.recordEnumerationValue(
SAFETY_HUB_EXTENSION_KEPT_HISTOGRAM_NAME,
convertSafetyCheckReason(this.data.safetyCheckWarningReason),
SAFETY_HUB_WARNING_REASON_MAX_SIZE);
}
this.delegate.setItemSafetyCheckWarningAcknowledged(
this.data.id, this.data.safetyCheckWarningReason);
}
/**
* Opens a URL in the Web Store with extensions recommendations for the
* extension.
*/
private onFindAlternativeButtonClick_(): void {
chrome.metricsPrivate.recordUserAction(
'Extensions.Mv2Deprecation.Warning.FindAlternativeForExtension.Entry');
const recommendationsUrl: string|undefined = this.data.recommendationsUrl;
assert(!!recommendationsUrl);
this.delegate.openUrl(recommendationsUrl);
}
/**
* Triggers the extension's removal.
*/
private onRemoveButtonClick_(): void {
chrome.metricsPrivate.recordUserAction(
'Extensions.Mv2Deprecation.DisableWithReEnable.Remove');
this.delegate.deleteItem(this.data.id);
}
private onRepairClick_() {
this.delegate.repairItem(this.data.id);
}
private onLoadPathClick_() {
this.delegate.showInFolder(this.data.id);
}
private onPinnedToToolbarChange_() {
this.delegate.setItemPinnedToToolbar(
this.data.id,
this.shadowRoot!
.querySelector<ExtensionsToggleRowElement>(
'#pin-to-toolbar')!.checked);
}
private onAllowIncognitoChange_() {
this.delegate.setItemAllowedIncognito(
this.data.id,
this.shadowRoot!
.querySelector<ExtensionsToggleRowElement>(
'#allow-incognito')!.checked);
}
private onAllowOnFileUrlsChange_() {
this.delegate.setItemAllowedOnFileUrls(
this.data.id,
this.shadowRoot!
.querySelector<ExtensionsToggleRowElement>(
'#allow-on-file-urls')!.checked);
}
private onCollectErrorsChange_() {
this.delegate.setItemCollectsErrors(
this.data.id,
this.shadowRoot!
.querySelector<ExtensionsToggleRowElement>(
'#collect-errors')!.checked);
}
private onExtensionWebSiteClick_() {
this.delegate.openUrl(this.data.manifestHomePageUrl);
}
private onSiteSettingsClick_() {
this.delegate.openUrl(
`chrome://settings/content/siteDetails?site=chrome-extension://${
this.data.id}`);
}
private onViewInStoreClick_() {
this.delegate.openUrl(this.data.webStoreUrl);
}
private computeDependentEntry_(
item: chrome.developerPrivate.DependentExtension): string {
return loadTimeData.getStringF('itemDependentEntry', item.name, item.id);
}
private computeSourceString_(): string {
return this.data.locationText ||
getItemSourceString(getItemSource(this.data));
}
private hasPermissions_(): boolean {
return this.data.permissions.simplePermissions.length > 0 ||
this.hasRuntimeHostPermissions_();
}
private getNoPermissionsString_(): string {
const showPermissionsAndSiteAccessStrings =
this.enableEnhancedSiteControls && !this.showSiteAccessContent_();
return loadTimeData.getString(
showPermissionsAndSiteAccessStrings ?
'itemPermissionsAndSiteAccessEmpty' :
'itemPermissionsEmpty');
}
private hasRuntimeHostPermissions_(): boolean {
return !!this.data.permissions.runtimeHostPermissions;
}
// Returns whether the site access section should be shown. This includes the
// "no site access" message shown in the section if
// |enableEnhancedSiteControls| is not enabled.
private showSiteAccessSection_(): boolean {
return !this.enableEnhancedSiteControls || this.showSiteAccessContent_();
}
private showSiteAccessContent_(): boolean {
return this.showFreeformRuntimeHostPermissions_() ||
this.showHostPermissionsToggleList_();
}
private showFreeformRuntimeHostPermissions_(): boolean {
return this.hasRuntimeHostPermissions_() &&
this.data.permissions.runtimeHostPermissions!.hasAllHosts;
}
private showHostPermissionsToggleList_(): boolean {
return this.hasRuntimeHostPermissions_() &&
!this.data.permissions.runtimeHostPermissions!.hasAllHosts;
}
private showEnableAccessRequestsToggle_(): boolean {
return this.showSiteAccessContent_() && this.enableEnhancedSiteControls;
}
private onShowAccessRequestsChange_() {
const showAccessRequestsToggle =
this.shadowRoot!.querySelector<ExtensionsToggleRowElement>(
'#show-access-requests-toggle');
assert(showAccessRequestsToggle);
this.delegate.setShowAccessRequestsInToolbar(
this.data.id, showAccessRequestsToggle.checked);
}
private showReloadButton_(): boolean {
return getEnableControl(this.data) === EnableControl.RELOAD;
}
private computeShowSafetyCheck_(): boolean {
if (!loadTimeData.getBoolean('safetyCheckShowReviewPanel')) {
return false;
}
const ExtensionType = chrome.developerPrivate.ExtensionType;
// Check to make sure this is an extension and not a Chrome app.
if (!(this.data.type === ExtensionType.EXTENSION ||
this.data.type === ExtensionType.SHARED_MODULE)) {
return false;
}
return !!(
this.data.safetyCheckText && this.data.safetyCheckText.detailString);
}
/**
* Returns whether the mv2 deprecation message should be displayed.
*/
private computeShowMv2DeprecationMessage_(): boolean {
switch (this.mv2ExperimentStage_) {
case Mv2ExperimentStage.NONE:
return false;
case Mv2ExperimentStage.WARNING:
return this.data.isAffectedByMV2Deprecation;
case Mv2ExperimentStage.DISABLE_WITH_REENABLE:
return this.data.isAffectedByMV2Deprecation &&
this.data.disableReasons.unsupportedManifestVersion &&
!this.data.didAcknowledgeMV2DeprecationNotice;
default:
return false;
}
}
/**
* Returns whether the find alternative button in the mv2 deprecation message
* should be displayed.
*/
private computeShowMv2DeprecationFindAlternativeButton_(): boolean {
return this.mv2ExperimentStage_ === Mv2ExperimentStage.WARNING &&
!!this.data.recommendationsUrl;
}
/**
* Returns whether the remove button in the mv2 deprecation message should be
* displayed.
*/
private computeShowMv2DeprecationRemoveButton_(): boolean {
return this.mv2ExperimentStage_ ===
Mv2ExperimentStage.DISABLE_WITH_REENABLE;
}
/**
* Returns whether the remove button in the mv2 deprecation message should be
* displayed.
*/
private computeShowMv2DeprecationActionMenu_(): boolean {
return this.mv2ExperimentStage_ ===
Mv2ExperimentStage.DISABLE_WITH_REENABLE;
}
private onShowSafetyCheckChanged_() {
if (this.showSafetyCheck_) {
chrome.metricsPrivate.recordUserAction('SafetyCheck.DetailWarningShown');
}
}
private computeShowBlocklistText_(): boolean {
return !this.showSafetyCheck_ && !!this.data.blocklistText;
}
private showRepairButton_(): boolean {
return getEnableControl(this.data) === EnableControl.REPAIR;
}
private showEnableToggle_(): boolean {
const enableControl = getEnableControl(this.data);
// We still show the toggle even if we also show the repair button in the
// detail view, because the repair button appears just beneath it.
return enableControl === EnableControl.ENABLE_TOGGLE ||
enableControl === EnableControl.REPAIR;
}
private showAllowlistWarning_(): boolean {
// Only show the allowlist warning if there is no blocklist warning. It
// would be redundant since all blocklisted items are necessarily not
// included in the Safe Browsing allowlist.
return this.data.showSafeBrowsingAllowlistWarning &&
!this.data.blocklistText;
}
/** Opens the action menu for the extension. */
private onActionMenuButtonClick_(event: MouseEvent): void {
this.$.actionMenu.showAt(
event.target as HTMLElement,
{anchorAlignmentY: AnchorAlignment.AFTER_END});
}
/**
* Opens a URL in the Web Store with extensions recommendations for the
* extension.
*/
private onFindAlternativeActionClick_(): void {
chrome.metricsPrivate.recordUserAction(
'Extensions.Mv2Deprecation.Disabled.FindAlternativeForExtension');
this.$.actionMenu.close();
const recommendationsUrl: string|undefined = this.data.recommendationsUrl;
assert(!!recommendationsUrl);
this.delegate.openUrl(recommendationsUrl);
}
/**
* Dismisses the notice for a given extension in the disable experiment stage.
* It will not be shown again during this stage.
*/
private onKeepActionClick_(): void {
assert(
this.mv2ExperimentStage_ === Mv2ExperimentStage.DISABLE_WITH_REENABLE);
chrome.metricsPrivate.recordUserAction(
'Extensions.Mv2Deprecation.Disabled.DismissedForExtension.DetailPage');
this.$.actionMenu.close();
this.delegate.dismissMv2DeprecationNoticeForExtension(this.data.id);
}
/**
* Returns the Manifest V2 deprecation message header.
*/
private getMv2DeprecationMessageHeader_(): string {
switch (this.mv2ExperimentStage_) {
case Mv2ExperimentStage.NONE:
return '';
case Mv2ExperimentStage.WARNING:
return this.i18n('mv2DeprecationMessageWarningHeader');
case Mv2ExperimentStage.DISABLE_WITH_REENABLE:
return this.i18n('mv2DeprecationMessageDisabledHeader');
default:
assertNotReached();
}
}
/**
* Returns the HTML representation of the Manifest V2 deprecation message
* subtitle string. We need the HTML representation instead of the string
* since the string holds a link.
*/
private getMv2DeprecationMessageSubtitle_(): TrustedHTML|string {
switch (this.mv2ExperimentStage_) {
case Mv2ExperimentStage.NONE:
return '';
case Mv2ExperimentStage.WARNING:
return this.i18nAdvanced('mv2DeprecationMessageWarningSubtitle', {
substitutions:
['https://chromewebstore.google.com/category/extensions'],
});
case Mv2ExperimentStage.DISABLE_WITH_REENABLE:
return this.i18nAdvanced('mv2DeprecationMessageDisabledSubtitle', {
substitutions: [
'https://support.google.com/chrome_webstore' +
'?p=unsupported_extensions',
],
});
default:
assertNotReached();
}
}
/**
* Returns the Manifest V2 deprecation message icon.
*/
private getMv2DeprecationMessageIcon_(): string {
switch (this.mv2ExperimentStage_) {
case Mv2ExperimentStage.NONE:
case Mv2ExperimentStage.WARNING:
return 'extensions-icons:my_extensions';
case Mv2ExperimentStage.DISABLE_WITH_REENABLE:
return 'extensions-icons:extension_off';
default:
assertNotReached();
}
}
/** Returns the accessible label for the action menu button */
private getActionMenuButtonLabel_(): string {
return this.i18n(
'mv2DeprecationPanelExtensionActionMenuLabel', this.data.name);
}
}
declare global {
interface HTMLElementTagNameMap {
'extensions-detail-view': ExtensionsDetailViewElement;
}
}
customElements.define(
ExtensionsDetailViewElement.is, ExtensionsDetailViewElement);