chrome-devtools-frontend
Version:
Chrome DevTools UI
648 lines (597 loc) • 24.3 kB
text/typescript
// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import '../../ui/legacy/legacy.js';
import '../../ui/legacy/components/data_grid/data_grid.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 Workspace from '../../models/workspace/workspace.js';
import * as Buttons from '../../ui/components/buttons/buttons.js';
import * as Input from '../../ui/components/input/input.js';
import * as UI from '../../ui/legacy/legacy.js';
import {html, render} from '../../ui/lit/lit.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 interface ViewInput {
serviceName: Protocol.BackgroundService.ServiceName;
isRecording: boolean;
selectedEvent: EventData|null;
events: EventData[];
onClear: () => void;
onSave: () => void;
toggleRecording: () => void;
onSelectEvent: (event: EventData) => void;
onOriginCheckboxChanged: (event: Event) => void;
onStorageKeyCheckboxChanged: (event: Event) => void;
isOriginCheckboxChecked: boolean;
isStorageKeyCheckboxChecked: boolean;
createLearnMoreLink: () => Platform.DevToolsPath.UrlString;
}
type View = (input: ViewInput, output: undefined, target: HTMLElement) => void;
export const DEFAULT_VIEW = (input: ViewInput, output: undefined, target: HTMLElement): void => {
const recordShortcuts =
UI.ShortcutRegistry.ShortcutRegistry.instance().shortcutsForAction('background-service.toggle-recording')[0];
const startRecordingText = i18nString(UIStrings.startRecordingToDebug, {
PH1: i18nString(UIStrings.startRecordingEvents),
PH2: recordShortcuts ? recordShortcuts.title() : '',
});
const featureName = BackgroundServiceView.getUIString(input.serviceName).toLowerCase();
// Toolbar state
const buttonTooltip =
input.isRecording ? i18nString(UIStrings.stopRecordingEvents) : i18nString(UIStrings.startRecordingEvents);
const dataGridTemplate = html`
<table>
<tr>
<th id="id" weight="1">${'#' as Common.UIString.LocalizedString}</th>
<th id="timestamp" weight="7">${i18nString(UIStrings.timestamp)}</th>
<th id="event-name" weight="8">${i18nString(UIStrings.event)}</th>
<th id="origin" weight="8">${i18nString(UIStrings.origin)}</th>
<th id="storage-key" weight="8">${i18nString(UIStrings.storageKey)}</th>
<th id="sw-scope" weight="4">${i18nString(UIStrings.swScope)}</th>
<th id="instance-id" weight="8">${i18nString(UIStrings.instanceId)}</th>
</tr>
${input.events.map(event => html`
<tr =${() => input.onSelectEvent(event)}
class=${event === input.selectedEvent ? 'selected' : ''}
?selected=${event === input.selectedEvent}>
<td>${event.id}</td>
<td>${event.timestamp}</td>
<td>${event['event-name']}</td>
<td>${event.origin}</td>
<td>${event['storage-key']}</td>
<td>${event['sw-scope']}</td>
<td>${event['instance-id']}</td>
</tr>
`)}
</table>
`;
// clang-format off
render(html`
<style>${backgroundServiceViewStyles}</style>
<style>${Input.checkboxStyles}</style>
<div class="background-service-view">
<devtools-toolbar class="background-service-toolbar" jslog=${VisualLogging.toolbar()}>
<devtools-button title=${buttonTooltip}
class="toolbar-button"
.iconName=${'record-start'}
.toggledIconName=${'record-stop'}
.toggleType=${Buttons.Button.ToggleType.PRIMARY}
.toggled=${input.isRecording}
=${input.toggleRecording}
.variant=${Buttons.Button.Variant.TOOLBAR}
.jslogContext=${'background-service.toggle-recording'}></devtools-button>
<devtools-button title=${i18nString(UIStrings.clear)} =${input.onClear}
class="toolbar-button"
.iconName=${'clear'} .variant=${Buttons.Button.Variant.TOOLBAR}
.jslogContext=${'background-service.clear'}></devtools-button>
<div class="toolbar-divider"></div>
<devtools-button title=${i18nString(UIStrings.saveEvents)}
class="toolbar-button"
=${input.onSave}
.disabled=${input.events.length === 0}
.iconName=${'download'} .variant=${Buttons.Button.Variant.TOOLBAR}
.jslogContext=${'background-service.save-events'}></devtools-button>
<div class="toolbar-divider"></div>
<label title=${i18nString(UIStrings.showEventsFromOtherDomains)} class="checkbox-label"
jslog=${VisualLogging.toggle('show-events-from-other-domains').track({click: true})}
>
<input type="checkbox" .checked=${input.isOriginCheckboxChecked} =${input.onOriginCheckboxChanged}>
${i18nString(UIStrings.showEventsFromOtherDomains)}
</label>
<label title=${i18nString(UIStrings.showEventsForOtherStorageKeys)} class="checkbox-label"
jslog=${VisualLogging.toggle('show-events-from-other-partitions').track({click: true})}
>
<input type="checkbox" .checked=${input.isStorageKeyCheckboxChecked}
=${input.onStorageKeyCheckboxChanged}
>
${i18nString(UIStrings.showEventsForOtherStorageKeys)}
</label>
</devtools-toolbar>
${input.events.length === 0 ? (input.isRecording ? html`
${UI.Widget.widget(UI.EmptyWidget.EmptyWidget, {
header: i18nString(UIStrings.recordingSActivity, {PH1: featureName}),
text: i18nString(UIStrings.devtoolsWillRecordAllSActivity, {PH1: featureName}),
})}
` : html`
<devtools-widget ${UI.Widget.widget(UI.EmptyWidget.EmptyWidget, {
header: i18nString(UIStrings.noRecording),
text: startRecordingText,
link: input.createLearnMoreLink(),
})}>
<devtools-button class="start-recording-button" .variant=${Buttons.Button.Variant.TONAL} jslogContext=${'start-recording'} =${input.toggleRecording}>
${i18nString(UIStrings.startRecordingEvents)}
</devtools-button>
</devtools-widget>
`) : html`
<devtools-split-view sidebar-position="second" direction="row">
<div slot="main" class="data-grid-container">
<devtools-data-grid class="data-grid" striped
name=${i18nString(UIStrings.backgroundServices)}
style="outline: none;"
>
${dataGridTemplate}
</devtools-data-grid>
</div>
<div slot="sidebar" class="preview-panel empty-state-container"
jslog=${VisualLogging.pane('preview').track({resize: true})}
>
${input.selectedEvent ? html`
<div class="background-service-metadata" jslog=${VisualLogging.section('metadata')}>
${input.selectedEvent.eventMetadata.length > 0 ? input.selectedEvent.eventMetadata.map(entry => html`
<div class="background-service-metadata-entry">
<div class="background-service-metadata-name">${entry.key}: </div>${
entry.value ?
html`<div class="background-service-metadata-value source-code">${entry.value}</div>` :
html`<div class="background-service-metadata-value background-service-empty-value">${i18nString(UIStrings.empty)}</div>`}
</div>
`) : html`
<div class="background-service-metadata-entry">
<div class="background-service-metadata-name background-service-empty-value">${i18nString(UIStrings.noMetadataForThisEvent)}</div>
</div>
`}
</div>
` : html`
${UI.Widget.widget(UI.EmptyWidget.EmptyWidget, {
header: i18nString(UIStrings.noEventSelected),
text: i18nString(UIStrings.selectAnEventToViewMetadata),
})}
`}
</div>
</devtools-split-view>
`}
</div>
`, target, {
container: {
attributes: {
jslog: `${VisualLogging.pane().context(Platform.StringUtilities.toKebabCase(input.serviceName))}`,
},
},
});
// clang-format on
};
export class BackgroundServiceView extends UI.Widget.Widget {
#serviceName?: Protocol.BackgroundService.ServiceName;
#model?: BackgroundServiceModel;
#serviceWorkerManager?: SDK.ServiceWorkerManager.ServiceWorkerManager;
#securityOriginManager?: SDK.SecurityOriginManager.SecurityOriginManager;
#storageKeyManager?: SDK.StorageKeyManager.StorageKeyManager;
#isRecording = false;
#selectedEvent: EventData|null = null;
#events: EventData[] = [];
#isOriginCheckboxChecked = false;
#isStorageKeyCheckboxChecked = false;
readonly #view: View;
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(element?: HTMLElement, view: View = DEFAULT_VIEW) {
super(element);
this.#view = view;
}
get serviceName(): Protocol.BackgroundService.ServiceName|undefined {
return this.#serviceName;
}
set serviceName(serviceName: Protocol.BackgroundService.ServiceName) {
if (this.#serviceName === serviceName) {
return;
}
this.#serviceName = serviceName;
if (this.#model) {
this.#model.enable(this.#serviceName);
}
this.requestUpdate();
}
get model(): BackgroundServiceModel|undefined {
return this.#model;
}
set model(model: BackgroundServiceModel) {
if (this.#model === model) {
return;
}
if (this.#model) {
this.#model.removeEventListener(Events.RecordingStateChanged, this.onRecordingStateChanged, this);
this.#model.removeEventListener(Events.BackgroundServiceEventReceived, this.onEventReceived, this);
}
this.#model = model;
this.#model.addEventListener(Events.RecordingStateChanged, this.onRecordingStateChanged, this);
this.#model.addEventListener(Events.BackgroundServiceEventReceived, this.onEventReceived, this);
if (this.#serviceName) {
this.#model.enable(this.#serviceName);
}
this.#serviceWorkerManager = this.#model.target().model(SDK.ServiceWorkerManager.ServiceWorkerManager) ?? undefined;
this.#securityOriginManager =
this.#model.target().model(SDK.SecurityOriginManager.SecurityOriginManager) ?? undefined;
if (this.#securityOriginManager) {
this.#securityOriginManager.addEventListener(SDK.SecurityOriginManager.Events.MainSecurityOriginChanged,
() => this.onOriginChanged());
}
this.#storageKeyManager = this.#model.target().model(SDK.StorageKeyManager.StorageKeyManager) ?? undefined;
if (this.#storageKeyManager) {
this.#storageKeyManager.addEventListener(SDK.StorageKeyManager.Events.MAIN_STORAGE_KEY_CHANGED,
() => this.onStorageKeyChanged());
}
this.requestUpdate();
}
override wasShown(): void {
super.wasShown();
this.requestUpdate();
}
/**
* Displays all available events in the grid.
*/
private refreshView(): void {
this.clearView();
if (!this.#model || !this.#serviceName) {
return;
}
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.#selectedEvent = null;
this.#events = [];
this.requestUpdate();
}
/**
* Called when the `Toggle Record` button is clicked.
*/
toggleRecording(): void {
if (!this.#model || !this.#serviceName) {
return;
}
const isRecording = !this.#isRecording;
this.#model.setRecording(isRecording, this.#serviceName);
const featureName = BackgroundServiceView.getUIString(this.#serviceName).toLowerCase();
if (isRecording) {
UI.ARIAUtils.LiveAnnouncer.alert(
i18nString(UIStrings.recordingSActivity, {PH1: featureName}) + ' ' +
i18nString(UIStrings.devtoolsWillRecordAllSActivity, {PH1: featureName}));
}
}
/**
* Called when the `Clear` button is clicked.
*/
private clearEvents(): void {
if (this.#model && this.#serviceName) {
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.#isRecording) {
return;
}
this.#isRecording = state.isRecording;
this.requestUpdate();
}
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.#isOriginCheckboxChecked) {
return;
}
this.refreshView();
}
private onStorageKeyChanged(): void {
if (this.#isStorageKeyCheckboxChecked) {
return;
}
this.refreshView();
}
private addEvent(serviceEvent: Protocol.BackgroundService.BackgroundServiceEvent): void {
const data = this.createEventData(serviceEvent);
this.#events.push(data);
this.requestUpdate();
}
/**
* 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.#events.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,
eventMetadata: serviceEvent.eventMetadata.sort((m1, m2) => Platform.StringUtilities.compare(m1.key, m2.key)),
};
}
/**
* 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.#isOriginCheckboxChecked || this.#isStorageKeyCheckboxChecked) {
return true;
}
// Trim the trailing '/'.
const origin = event.origin.substr(0, event.origin.length - 1);
const storageKey = event.storageKey;
return Boolean(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;
}
override performUpdate(): void {
if (!this.#serviceName || !this.#model) {
return;
}
const viewInput = {
serviceName: this.#serviceName,
isRecording: this.#isRecording,
selectedEvent: this.#selectedEvent,
events: this.#events,
onClear: () => this.clearEvents(),
onSave: () => void this.saveToFile(),
onSelectEvent: (event: EventData) => {
this.#selectedEvent = event;
this.requestUpdate();
},
onOriginCheckboxChanged: (event: Event) => {
const checkbox = event.target as HTMLInputElement;
this.#isOriginCheckboxChecked = checkbox.checked;
this.refreshView();
},
onStorageKeyCheckboxChanged: (event: Event) => {
const checkbox = event.target as HTMLInputElement;
this.#isStorageKeyCheckboxChecked = checkbox.checked;
this.refreshView();
},
isOriginCheckboxChecked: this.#isOriginCheckboxChecked,
isStorageKeyCheckboxChecked: this.#isStorageKeyCheckboxChecked,
toggleRecording: () => this.toggleRecording(),
createLearnMoreLink: () => this.createLearnMoreLink(),
};
this.#view(viewInput, undefined, this.contentElement);
}
/**
* Saves all currently displayed events in a file (JSON format).
*/
private async saveToFile(): Promise<void> {
if (!this.#serviceName || !this.#model) {
return;
}
const fileName = `${this.#serviceName}-${Platform.DateUtilities.toISO8601Compact(new Date())}.json` as
Platform.DevToolsPath.RawPathString;
const stream = new Bindings.FileUtils.FileOutputStream(Workspace.FileManager.FileManager.instance());
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 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;
eventMetadata: Protocol.BackgroundService.EventMetadata[];
}