| // 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 './sidebar.js'; |
| import './site_permissions.js'; |
| import './site_permissions_by_site.js'; |
| import './toolbar.js'; |
| // <if expr="chromeos_ash"> |
| import './kiosk_dialog.js'; |
| |
| // </if> |
| |
| import {CrViewManagerElement} from 'chrome://resources/cr_elements/cr_view_manager/cr_view_manager.js'; |
| import {assert, assertNotReached} from 'chrome://resources/js/assert_ts.js'; |
| import {loadTimeData} from 'chrome://resources/js/load_time_data.js'; |
| import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js'; |
| |
| import {ActivityLogExtensionPlaceholder} from './activity_log/activity_log.js'; |
| import {ExtensionsDetailViewElement} from './detail_view.js'; |
| import {ExtensionsItemListElement} from './item_list.js'; |
| // <if expr="chromeos_ash"> |
| import {KioskBrowserProxyImpl} from './kiosk_browser_proxy.js'; |
| // </if> |
| import {getTemplate} from './manager.html.js'; |
| import {Dialog, navigation, Page, PageState} from './navigation_helper.js'; |
| import {Service} from './service.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<chrome.developerPrivate.LoadError>; |
| } |
| } |
| |
| export interface ExtensionsManagerElement { |
| $: { |
| viewManager: CrViewManagerElement, |
| 'items-list': ExtensionsItemListElement, |
| }; |
| } |
| |
| export class ExtensionsManagerElement extends PolymerElement { |
| 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'), |
| }, |
| |
| 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, |
| }, |
| |
| 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, |
| |
| // <if expr="chromeos_ash"> |
| kioskEnabled_: { |
| type: Boolean, |
| value: false, |
| }, |
| |
| showKioskDialog_: { |
| type: Boolean, |
| value: false, |
| }, |
| // </if> |
| }; |
| } |
| |
| canLoadUnpacked: boolean; |
| delegate: Service; |
| inDevMode: 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 showDrawer_: boolean; |
| private showLoadErrorDialog_: boolean; |
| private showInstallWarningsDialog_: boolean; |
| private installWarnings_: string[]|null; |
| private showOptionsDialog_: boolean; |
| private fromActivityLog_: boolean; |
| |
| // <if expr="chromeos_ash"> |
| private kioskEnabled_: boolean; |
| private showKioskDialog_: boolean; |
| // </if> |
| |
| 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; |
| } |
| |
| 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; |
| }; |
| service.getProfileStateChangedTarget().addListener(onProfileStateChanged); |
| service.getProfileConfiguration().then(onProfileStateChanged); |
| |
| service.getExtensionsInfo().then(extensionsAndApps => { |
| this.initExtensionsAndApps_(extensionsAndApps); |
| this.initPage_(); |
| |
| service.getItemStateChangedTarget().addListener( |
| this.onItemStateChanged_.bind(this)); |
| }); |
| |
| // <if expr="chromeos_ash"> |
| KioskBrowserProxyImpl.getInstance().initializeKioskAppSettings().then( |
| params => { |
| this.kioskEnabled_ = params.kioskEnabled; |
| }); |
| // </if> |
| } |
| |
| 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; |
| } |
| |
| /** |
| * 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()); |
| } |
| |
| 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: |
| // |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; |
| default: |
| assertNotReached(); |
| } |
| } |
| |
| private onFilterChanged_(event: CustomEvent<string>) { |
| if (this.currentPage_!.page !== Page.LIST) { |
| navigation.navigateTo({page: Page.LIST}); |
| } |
| this.filter = event.detail; |
| } |
| |
| private onMenuButtonTap_() { |
| 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; |
| } |
| } |
| |
| /** |
| * @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.ACTIVITY_LOG || |
| this.currentPage_!.page === Page.DETAILS || |
| this.currentPage_!.page === Page.ERRORS) && |
| this.currentPage_!.extensionId === itemId) { |
| // Leave the details page (the 'list' page is a fine choice). |
| navigation.replaceWith({page: Page.LIST}); |
| } |
| } |
| |
| private onLoadError_(e: CustomEvent<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 (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); |
| } |
| |
| 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; |
| } |
| |
| // <if expr="chromeos_ash"> |
| private onKioskTap_() { |
| this.showKioskDialog_ = true; |
| } |
| |
| private onKioskDialogClose_() { |
| this.showKioskDialog_ = false; |
| } |
| // </if> |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'extensions-manager': ExtensionsManagerElement; |
| } |
| } |
| |
| customElements.define(ExtensionsManagerElement.is, ExtensionsManagerElement); |