chrome-devtools-frontend
Version:
Chrome DevTools UI
600 lines (526 loc) • 22.8 kB
text/typescript
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/* eslint-disable rulesdir/no-imperative-dom-api */
import '../../ui/legacy/legacy.js';
import type * as Common from '../../core/common/common.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Protocol from '../../generated/protocol.js';
import * as Bindings from '../../models/bindings/bindings.js';
import * as Buttons from '../../ui/components/buttons/buttons.js';
import * as DataGrid from '../../ui/legacy/components/data_grid/data_grid.js';
// eslint-disable-next-line rulesdir/es-modules-import
import emptyWidgetStyles from '../../ui/legacy/emptyWidget.css.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import {type BackgroundServiceModel, Events} from './BackgroundServiceModel.js';
import backgroundServiceViewStyles from './backgroundServiceView.css.js';
const UIStrings = {
/**
*@description Text in Background Service View of the Application panel
*/
backgroundFetch: 'Background fetch',
/**
*@description Text in Background Service View of the Application panel
*/
backgroundSync: 'Background sync',
/**
*@description Text in Background Service View of the Application panel
*/
pushMessaging: 'Push messaging',
/**
*@description Text in Background Service View of the Application panel
*/
notifications: 'Notifications',
/**
*@description Text in Background Service View of the Application panel
*/
paymentHandler: 'Payment handler',
/**
*@description Text in the Periodic Background Service View of the Application panel
*/
periodicBackgroundSync: 'Periodic background sync',
/**
*@description Text to clear content
*/
clear: 'Clear',
/**
*@description Tooltip text that appears when hovering over the largeicon download button in the Background Service View of the Application panel
*/
saveEvents: 'Save events',
/**
*@description Text in Background Service View of the Application panel
*/
showEventsFromOtherDomains: 'Show events from other domains',
/**
*@description Text of a checkbox to show events for other storage keys
*/
showEventsForOtherStorageKeys: 'Show events from other storage partitions',
/**
*@description Title of an action under the Background Services category that can be invoked through the Command Menu
*/
stopRecordingEvents: 'Stop recording events',
/**
*@description Title of an action under the Background Services category that can be invoked through the Command Menu
*/
startRecordingEvents: 'Start recording events',
/**
*@description Text for timestamps of items
*/
timestamp: 'Timestamp',
/**
*@description Text that refers to some events
*/
event: 'Event',
/**
*@description Text for the origin of something
*/
origin: 'Origin',
/**
*@description Text for the storage key of something
*/
storageKey: 'Storage Key',
/**
*@description Text in Background Service View of the Application panel. The Scope is a URL associated with the Service Worker, which limits which pages/sites the Service Worker operates on.
*/
swScope: 'Service Worker Scope',
/**
*@description Text in Background Service View of the Application panel
*/
instanceId: 'Instance ID',
/**
*@description Text in Application Panel Sidebar of the Application panel
*/
backgroundServices: 'Background services',
/**
*@description Text in Background Service View of the Application panel.
* An event here refers to a background service event that is an entry in a table.
*/
noEventSelected: 'No event selected',
/**
*@description Text in Background Service View of the Application panel
*/
selectAnEventToViewMetadata: 'Select an event to view its metadata',
/**
*@description Text in Background Service View of the Application panel
*@example {Background Fetch} PH1
*/
recordingSActivity: 'Recording {PH1} activity…',
/**
*@description Text in Background Service View of the Application panel
*/
noRecording: 'No recording yet',
/**
*@description Inform users that DevTools are recording/waiting for events in the Periodic Background Sync tool of the Application panel
*@example {Background Fetch} PH1
*/
devtoolsWillRecordAllSActivity: 'DevTools will record all {PH1} activity for up to 3 days, even when closed.',
/**
*@description Text in Background Service View of the Application panel to instruct the user on how to start a recording for
* background services.
*
*@example {Start recording events} PH1
*@example {Ctrl + E} PH2
*/
startRecordingToDebug: 'Start to debug background services by using the "{PH1}" button or by pressing {PH2}.',
/**
*@description Text to show an item is empty
*/
empty: 'empty',
/**
*@description Text in Background Service View of the Application panel
*/
noMetadataForThisEvent: 'No metadata for this event',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/application/BackgroundServiceView.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export class BackgroundServiceView extends UI.Widget.VBox {
private readonly serviceName: Protocol.BackgroundService.ServiceName;
private readonly model: BackgroundServiceModel;
private readonly serviceWorkerManager: SDK.ServiceWorkerManager.ServiceWorkerManager|null;
private readonly securityOriginManager: SDK.SecurityOriginManager.SecurityOriginManager;
private readonly storageKeyManager: SDK.StorageKeyManager.StorageKeyManager;
private recordAction: UI.ActionRegistration.Action;
private recordButton!: UI.Toolbar.ToolbarToggle;
private originCheckbox!: UI.Toolbar.ToolbarCheckbox;
private storageKeyCheckbox!: UI.Toolbar.ToolbarCheckbox;
private saveButton!: UI.Toolbar.ToolbarButton;
private readonly toolbar: UI.Toolbar.Toolbar;
private readonly splitWidget: UI.SplitWidget.SplitWidget;
private readonly dataGrid: DataGrid.DataGrid.DataGridImpl<EventData>;
private readonly previewPanel: UI.Widget.VBox;
private selectedEventNode: EventDataNode|null;
private preview: UI.Widget.Widget|null;
static getUIString(serviceName: string): string {
switch (serviceName) {
case Protocol.BackgroundService.ServiceName.BackgroundFetch:
return i18nString(UIStrings.backgroundFetch);
case Protocol.BackgroundService.ServiceName.BackgroundSync:
return i18nString(UIStrings.backgroundSync);
case Protocol.BackgroundService.ServiceName.PushMessaging:
return i18nString(UIStrings.pushMessaging);
case Protocol.BackgroundService.ServiceName.Notifications:
return i18nString(UIStrings.notifications);
case Protocol.BackgroundService.ServiceName.PaymentHandler:
return i18nString(UIStrings.paymentHandler);
case Protocol.BackgroundService.ServiceName.PeriodicBackgroundSync:
return i18nString(UIStrings.periodicBackgroundSync);
default:
return '';
}
}
constructor(serviceName: Protocol.BackgroundService.ServiceName, model: BackgroundServiceModel) {
super(true);
this.registerRequiredCSS(emptyWidgetStyles, backgroundServiceViewStyles);
this.serviceName = serviceName;
const kebabName = Platform.StringUtilities.toKebabCase(serviceName);
this.element.setAttribute('jslog', `${VisualLogging.pane().context(kebabName)}`);
this.model = model;
this.model.addEventListener(Events.RecordingStateChanged, this.onRecordingStateChanged, this);
this.model.addEventListener(Events.BackgroundServiceEventReceived, this.onEventReceived, this);
this.model.enable(this.serviceName);
this.serviceWorkerManager = this.model.target().model(SDK.ServiceWorkerManager.ServiceWorkerManager);
this.securityOriginManager = this.model.target().model(SDK.SecurityOriginManager.SecurityOriginManager) as
SDK.SecurityOriginManager.SecurityOriginManager;
if (!this.securityOriginManager) {
throw new Error('SecurityOriginManager instance is missing');
}
this.securityOriginManager.addEventListener(
SDK.SecurityOriginManager.Events.MainSecurityOriginChanged, () => this.onOriginChanged());
this.storageKeyManager =
this.model.target().model(SDK.StorageKeyManager.StorageKeyManager) as SDK.StorageKeyManager.StorageKeyManager;
if (!this.storageKeyManager) {
throw new Error('StorageKeyManager instance is missing');
}
this.storageKeyManager.addEventListener(
SDK.StorageKeyManager.Events.MAIN_STORAGE_KEY_CHANGED, () => this.onStorageKeyChanged());
this.recordAction = UI.ActionRegistry.ActionRegistry.instance().getAction('background-service.toggle-recording');
this.toolbar = this.contentElement.createChild('devtools-toolbar', 'background-service-toolbar');
this.toolbar.setAttribute('jslog', `${VisualLogging.toolbar()}`);
void this.setupToolbar();
/**
* This will contain the DataGrid for displaying events, and a panel at the bottom for showing
* extra metadata related to the selected event.
*/
this.splitWidget = new UI.SplitWidget.SplitWidget(/* isVertical= */ false, /* secondIsSidebar= */ true);
this.splitWidget.show(this.contentElement);
this.dataGrid = this.createDataGrid();
this.previewPanel = new UI.Widget.VBox();
this.previewPanel.element.setAttribute('jslog', `${VisualLogging.pane('preview').track({resize: true})}`);
this.selectedEventNode = null;
this.preview = null;
this.splitWidget.setMainWidget(this.dataGrid.asWidget());
this.splitWidget.setSidebarWidget(this.previewPanel);
this.splitWidget.hideMain();
this.showPreview(null);
}
getDataGrid(): DataGrid.DataGrid.DataGridImpl<EventData> {
return this.dataGrid;
}
/**
* Creates the toolbar UI element.
*/
private async setupToolbar(): Promise<void> {
this.toolbar.wrappable = true;
this.recordButton = (UI.Toolbar.Toolbar.createActionButton(this.recordAction) as UI.Toolbar.ToolbarToggle);
this.recordButton.toggleOnClick(false);
this.toolbar.appendToolbarItem(this.recordButton);
const clearButton =
new UI.Toolbar.ToolbarButton(i18nString(UIStrings.clear), 'clear', undefined, 'background-service.clear');
clearButton.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, () => this.clearEvents());
this.toolbar.appendToolbarItem(clearButton);
this.toolbar.appendSeparator();
this.saveButton = new UI.Toolbar.ToolbarButton(
i18nString(UIStrings.saveEvents), 'download', undefined, 'background-service.save-events');
this.saveButton.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, _event => {
void this.saveToFile();
});
this.saveButton.setEnabled(false);
this.toolbar.appendToolbarItem(this.saveButton);
this.toolbar.appendSeparator();
this.originCheckbox = new UI.Toolbar.ToolbarCheckbox(
i18nString(UIStrings.showEventsFromOtherDomains), i18nString(UIStrings.showEventsFromOtherDomains),
() => this.refreshView(), 'show-events-from-other-domains');
this.toolbar.appendToolbarItem(this.originCheckbox);
this.storageKeyCheckbox = new UI.Toolbar.ToolbarCheckbox(
i18nString(UIStrings.showEventsForOtherStorageKeys), i18nString(UIStrings.showEventsForOtherStorageKeys),
() => this.refreshView(), 'show-events-from-other-partitions');
this.toolbar.appendToolbarItem(this.storageKeyCheckbox);
}
/**
* Displays all available events in the grid.
*/
private refreshView(): void {
this.clearView();
const events = this.model.getEvents(this.serviceName).filter(event => this.acceptEvent(event));
for (const event of events) {
this.addEvent(event);
}
}
/**
* Clears the grid and panel.
*/
private clearView(): void {
this.selectedEventNode = null;
this.dataGrid.rootNode().removeChildren();
this.splitWidget.hideMain();
this.saveButton.setEnabled(false);
this.showPreview(null);
}
/**
* Called when the `Toggle Record` button is clicked.
*/
toggleRecording(): void {
this.model.setRecording(!this.recordButton.isToggled(), this.serviceName);
}
/**
* Called when the `Clear` button is clicked.
*/
private clearEvents(): void {
this.model.clearEvents(this.serviceName);
this.clearView();
}
private onRecordingStateChanged({data: state}: Common.EventTarget.EventTargetEvent<RecordingState>): void {
if (state.serviceName !== this.serviceName) {
return;
}
if (state.isRecording === this.recordButton.isToggled()) {
return;
}
this.recordButton.setToggled(state.isRecording);
this.updateRecordButtonTooltip();
this.showPreview(this.selectedEventNode);
}
private updateRecordButtonTooltip(): void {
const buttonTooltip = this.recordButton.isToggled() ? i18nString(UIStrings.stopRecordingEvents) :
i18nString(UIStrings.startRecordingEvents);
this.recordButton.setTitle(buttonTooltip, 'background-service.toggle-recording');
}
private onEventReceived({
data: serviceEvent,
}: Common.EventTarget.EventTargetEvent<Protocol.BackgroundService.BackgroundServiceEvent>): void {
if (!this.acceptEvent(serviceEvent)) {
return;
}
this.addEvent(serviceEvent);
}
private onOriginChanged(): void {
// No need to refresh the view if we are already showing all events.
if (this.originCheckbox.checked()) {
return;
}
this.refreshView();
}
private onStorageKeyChanged(): void {
if (this.storageKeyCheckbox.checked()) {
return;
}
this.refreshView();
}
private addEvent(serviceEvent: Protocol.BackgroundService.BackgroundServiceEvent): void {
const data = this.createEventData(serviceEvent);
const dataNode = new EventDataNode(data, serviceEvent.eventMetadata);
this.dataGrid.rootNode().appendChild(dataNode);
if (this.splitWidget.showMode() !== UI.SplitWidget.ShowMode.BOTH) {
this.splitWidget.showBoth();
}
if (this.dataGrid.rootNode().children.length === 1) {
this.saveButton.setEnabled(true);
this.showPreview(this.selectedEventNode);
}
}
private createDataGrid(): DataGrid.DataGrid.DataGridImpl<EventData> {
const columns = ([
{id: 'id', title: '#', weight: 1},
{id: 'timestamp', title: i18nString(UIStrings.timestamp), weight: 7},
{id: 'event-name', title: i18nString(UIStrings.event), weight: 8},
{id: 'origin', title: i18nString(UIStrings.origin), weight: 8},
{id: 'storage-key', title: i18nString(UIStrings.storageKey), weight: 8},
{id: 'sw-scope', title: i18nString(UIStrings.swScope), weight: 4},
{id: 'instance-id', title: i18nString(UIStrings.instanceId), weight: 8},
] as DataGrid.DataGrid.ColumnDescriptor[]);
const dataGrid = new DataGrid.DataGrid.DataGridImpl({
displayName: i18nString(UIStrings.backgroundServices),
columns,
refreshCallback: undefined,
deleteCallback: undefined,
});
dataGrid.setStriped(true);
dataGrid.addEventListener(
DataGrid.DataGrid.Events.SELECTED_NODE, event => this.showPreview((event.data as EventDataNode)));
return dataGrid;
}
/**
* Creates the data object to pass to the DataGrid Node.
*/
private createEventData(serviceEvent: Protocol.BackgroundService.BackgroundServiceEvent): EventData {
let swScope = '';
// Try to get the scope of the Service Worker registration to be more user-friendly.
const registration = this.serviceWorkerManager ?
this.serviceWorkerManager.registrations().get(serviceEvent.serviceWorkerRegistrationId) :
undefined;
if (registration) {
swScope = registration.scopeURL.substr(registration.securityOrigin.length);
}
return {
id: this.dataGrid.rootNode().children.length + 1,
timestamp: UI.UIUtils.formatTimestamp(serviceEvent.timestamp * 1000, /* full= */ true),
origin: serviceEvent.origin,
'storage-key': serviceEvent.storageKey,
'sw-scope': swScope,
'event-name': serviceEvent.eventName,
'instance-id': serviceEvent.instanceId,
};
}
/**
* Filtration function to know whether event should be shown or not.
*/
private acceptEvent(event: Protocol.BackgroundService.BackgroundServiceEvent): boolean {
if (event.service !== this.serviceName) {
return false;
}
if (this.originCheckbox.checked() || this.storageKeyCheckbox.checked()) {
return true;
}
// Trim the trailing '/'.
const origin = event.origin.substr(0, event.origin.length - 1);
const storageKey = event.storageKey;
return this.securityOriginManager.securityOrigins().includes(origin) ||
this.storageKeyManager.storageKeys().includes(storageKey);
}
private createLearnMoreLink(): Platform.DevToolsPath.UrlString {
let url = 'https://developer.chrome.com/docs/devtools/javascript/background-services/';
switch (this.serviceName) {
case Protocol.BackgroundService.ServiceName.BackgroundFetch:
url += '#fetch';
break;
case Protocol.BackgroundService.ServiceName.BackgroundSync:
url += '#sync';
break;
case Protocol.BackgroundService.ServiceName.PushMessaging:
url += '#push';
break;
case Protocol.BackgroundService.ServiceName.Notifications:
url += '#notifications';
break;
default:
break;
}
return url as Platform.DevToolsPath.UrlString;
}
private showPreview(dataNode: EventDataNode|null): void {
if (this.selectedEventNode && this.selectedEventNode === dataNode) {
return;
}
this.selectedEventNode = dataNode;
if (this.preview) {
this.preview.detach();
}
if (this.selectedEventNode) {
this.preview = this.selectedEventNode.createPreview();
this.preview.show(this.previewPanel.contentElement);
return;
}
const emptyWidget = new UI.EmptyWidget.EmptyWidget('', '');
if (this.dataGrid.rootNode().children.length) {
emptyWidget.header = i18nString(UIStrings.noEventSelected);
emptyWidget.text = i18nString(UIStrings.selectAnEventToViewMetadata);
} else if (this.recordButton.isToggled()) {
// Inform users that we are recording/waiting for events.
const featureName = BackgroundServiceView.getUIString(this.serviceName).toLowerCase();
emptyWidget.header = i18nString(UIStrings.recordingSActivity, {PH1: featureName});
emptyWidget.text = i18nString(UIStrings.devtoolsWillRecordAllSActivity, {PH1: featureName});
} else {
const recordShortcuts =
UI.ShortcutRegistry.ShortcutRegistry.instance().shortcutsForAction('background-service.toggle-recording')[0];
emptyWidget.header = i18nString(UIStrings.noRecording);
emptyWidget.text = i18nString(
UIStrings.startRecordingToDebug,
{PH1: i18nString(UIStrings.startRecordingEvents), PH2: recordShortcuts.title()});
emptyWidget.link = this.createLearnMoreLink();
const button = UI.UIUtils.createTextButton(
i18nString(UIStrings.startRecordingEvents), () => this.toggleRecording(),
{jslogContext: 'start-recording', variant: Buttons.Button.Variant.TONAL});
emptyWidget.contentElement.appendChild(button);
}
this.preview = emptyWidget;
this.preview.show(this.previewPanel.contentElement);
}
/**
* Saves all currently displayed events in a file (JSON format).
*/
private async saveToFile(): Promise<void> {
const fileName = `${this.serviceName}-${Platform.DateUtilities.toISO8601Compact(new Date())}.json` as
Platform.DevToolsPath.RawPathString;
const stream = new Bindings.FileUtils.FileOutputStream();
const accepted = await stream.open(fileName);
if (!accepted) {
return;
}
const events = this.model.getEvents(this.serviceName).filter(event => this.acceptEvent(event));
await stream.write(JSON.stringify(events, undefined, 2));
void stream.close();
}
}
export class EventDataNode extends DataGrid.DataGrid.DataGridNode<EventData> {
private readonly eventMetadata: Protocol.BackgroundService.EventMetadata[];
constructor(data: EventData, eventMetadata: Protocol.BackgroundService.EventMetadata[]) {
super(data);
this.eventMetadata = eventMetadata.sort((m1, m2) => Platform.StringUtilities.compare(m1.key, m2.key));
}
createPreview(): UI.Widget.VBox {
const preview = new UI.Widget.VBox();
preview.element.classList.add('background-service-metadata');
preview.element.setAttribute('jslog', `${VisualLogging.section('metadata')}`);
for (const entry of this.eventMetadata) {
const div = document.createElement('div');
div.classList.add('background-service-metadata-entry');
div.createChild('div', 'background-service-metadata-name').textContent = entry.key + ': ';
if (entry.value) {
div.createChild('div', 'background-service-metadata-value source-code').textContent = entry.value;
} else {
div.createChild('div', 'background-service-metadata-value background-service-empty-value').textContent =
i18nString(UIStrings.empty);
}
preview.element.appendChild(div);
}
if (!preview.element.children.length) {
const div = document.createElement('div');
div.classList.add('background-service-metadata-entry');
div.createChild('div', 'background-service-metadata-name background-service-empty-value').textContent =
i18nString(UIStrings.noMetadataForThisEvent);
preview.element.appendChild(div);
}
return preview;
}
}
export class ActionDelegate implements UI.ActionRegistration.ActionDelegate {
handleAction(context: UI.Context.Context, actionId: string): boolean {
const view = context.flavor(BackgroundServiceView);
switch (actionId) {
case 'background-service.toggle-recording': {
if (!view) {
throw new Error('BackgroundServiceView instance is missing');
}
view.toggleRecording();
return true;
}
}
return false;
}
}
export interface RecordingState {
isRecording: boolean;
serviceName: Protocol.BackgroundService.ServiceName;
}
export interface EventData {
id: number;
timestamp: string;
origin: string;
'storage-key': string;
'sw-scope': string;
'event-name': string;
'instance-id': string;
}