// Copyright 2022 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/js/jstemplate_compiled.js';
import './database.js';
import {assert} from 'chrome://resources/js/assert.js';
import {mojoString16ToString} from 'chrome://resources/js/mojo_type_util.js';
import {getRequiredElement} from 'chrome://resources/js/util.js';
import type {String16} from 'chrome://resources/mojo/mojo/public/mojom/base/string16.mojom-webui.js';
import type {Time} from 'chrome://resources/mojo/mojo/public/mojom/base/time.mojom-webui.js';
import type {Origin} from 'chrome://resources/mojo/url/mojom/origin.mojom-webui.js';
import type {BucketId} from './bucket_id.mojom-webui.js';
import type {IndexedDbDatabase} from './database.js';
import type {IdbInternalsHandlerInterface, IdbPartitionMetadata} from './indexed_db_internals.mojom-webui.js';
import {IdbInternalsHandler} from './indexed_db_internals.mojom-webui.js';
import type {IdbBucketMetadata} from './indexed_db_internals_types.mojom-webui.js';
import type {SchemefulSite} from './schemeful_site.mojom-webui.js';
// TODO: This comes from components/flags_ui/resources/flags.ts. It should be
// extracted into a tools/typescript/definitions/jstemplate.d.ts file, and
// include that as part of build_webui()'s ts_definitions, instead of copying it
// here.
declare global {
class JsEvalContext {
constructor(data: any);
}
function jstProcess(context: JsEvalContext, template: HTMLElement): void;
function jstGetTemplate(templateName: string): HTMLElement;
}
// Methods to convert mojo values to strings or to objects with readable
// toString values. Accessible to jstemplate html code.
const stringifyMojo = {
time(mojoTime: Time): Date {
// The JS Date() is based off of the number of milliseconds since
// the UNIX epoch (1970-01-01 00::00:00 UTC), while |internalValue|
// of the base::Time (represented in mojom.Time) represents the
// number of microseconds since the Windows FILETIME epoch
// (1601-01-01 00:00:00 UTC). This computes the final JS time by
// computing the epoch delta and the conversion from microseconds to
// milliseconds.
const windowsEpoch = Date.UTC(1601, 0, 1, 0, 0, 0, 0);
const unixEpoch = Date.UTC(1970, 0, 1, 0, 0, 0, 0);
// |epochDeltaInMs| equals to
// base::Time::kTimeTToMicrosecondsOffset.
const epochDeltaInMs = unixEpoch - windowsEpoch;
const timeInMs = Number(mojoTime.internalValue) / 1000;
return new Date(timeInMs - epochDeltaInMs);
},
string16(mojoString16: String16): string {
return mojoString16ToString(mojoString16);
},
scope(mojoScope: String16[]): string {
return `[${mojoScope.map(s => stringifyMojo.string16(s)).join(', ')}]`;
},
origin(mojoOrigin: Origin): string {
const {scheme, host, port} = mojoOrigin;
const portSuf = (port === 0 ? '' : `:${port}`);
return `${scheme}://${host}${portSuf}`;
},
schemefulSite(mojoSite: SchemefulSite): string {
return stringifyMojo.origin(mojoSite.siteAsOrigin);
},
};
interface MojomResponse<T> {
error: string|null;
[key: string]: T|string|null;
}
function promisifyMojoResult<T>(
remotePromise: Promise<MojomResponse<T>>,
valueProp: keyof MojomResponse<T>): Promise<T> {
return new Promise((resolve, reject) => {
remotePromise.then((response: MojomResponse<T>) => {
if (response.error !== null) {
reject(response.error);
} else {
resolve(response[valueProp] as T);
}
});
});
}
class IdbInternalsRemote {
private handler: IdbInternalsHandlerInterface =
IdbInternalsHandler.getRemote();
getAllBucketsAcrossAllStorageKeys(): Promise<IdbPartitionMetadata[]> {
return promisifyMojoResult(
this.handler.getAllBucketsAcrossAllStorageKeys(), 'partitions');
}
stopMetadataRecording(bucketId: BucketId): Promise<IdbBucketMetadata[]> {
return promisifyMojoResult(
this.handler.stopMetadataRecording(bucketId), 'metadata');
}
}
const internalsRemote = new IdbInternalsRemote();
function initialize() {
internalsRemote.getAllBucketsAcrossAllStorageKeys()
.then(onStorageKeysReady)
.catch(errorMsg => console.error(errorMsg));
}
class BucketElement extends HTMLElement {
// this field is filled by the jstemplate annotations in the HTML code
idbBucketId: BucketId;
progressNode: HTMLElement;
connectionCountNode: HTMLElement;
seriesCurrentSnapshotIndex: number|null;
seriesData: IdbBucketMetadata[]|null;
constructor() {
super();
this.getNode(`.control.download`).addEventListener('click', () => {
// Show loading
this.progressNode.style.display = 'inline';
IdbInternalsHandler.getRemote()
.downloadBucketData(this.idbBucketId)
.then(this.onLoadComplete.bind(this))
.catch(errorMsg => console.error(errorMsg));
});
this.getNode(`.control.force-close`).addEventListener('click', () => {
// Show loading
this.progressNode.style.display = 'inline';
IdbInternalsHandler.getRemote()
.forceClose(this.idbBucketId)
.then(this.onLoadComplete.bind(this))
.catch(errorMsg => console.error(errorMsg));
});
this.getNode(`.control.start-record`).addEventListener('click', () => {
this.getNode(`.control.stop-record`)!.hidden = false;
this.getNode(`.control.start-record`)!.hidden = true;
IdbInternalsHandler.getRemote()
.startMetadataRecording(this.idbBucketId)
.then(this.onLoadComplete.bind(this))
.catch(errorMsg => console.error(errorMsg));
if (!this.getNode('.snapshots').hidden) {
this.getNode('.snapshots').hidden = true;
this.setRecordingSnapshot(null);
}
});
this.getNode(`.control.stop-record`).addEventListener('click', () => {
// Show loading
this.progressNode.style.display = 'inline';
this.getNode(`.control.start-record`)!.hidden = false;
this.getNode(`.control.stop-record`)!.hidden = true;
new IdbInternalsRemote()
.stopMetadataRecording(this.idbBucketId)
.then(this.onMetadataRecordingReady.bind(this))
.catch(errorMsg => console.error(errorMsg));
});
this.getNode('.snapshots input.slider')
.addEventListener('input', (event: Event) => {
const input = event.target as HTMLInputElement;
this.setRecordingSnapshot(parseInt(input.value));
});
this.getNode('.snapshots .prev').addEventListener('click', () => {
this.setRecordingSnapshot((this.seriesCurrentSnapshotIndex || 0) - 1);
});
this.getNode('.snapshots .next').addEventListener('click', () => {
this.setRecordingSnapshot((this.seriesCurrentSnapshotIndex || 0) + 1);
});
this.progressNode = this.getNode('.download-status');
this.connectionCountNode = this.getNode('.connection-count');
}
private setRecordingSnapshot(idx: number | null) {
this.getNode('.database-view').textContent = '';
if (!this.seriesData || idx === null ||
(idx < 0 || idx > this.seriesData.length - 1)) {
return;
}
const slider =
this.getNode<HTMLInputElement>('.snapshots input.slider');
this.seriesCurrentSnapshotIndex = idx;
const snapshot = this.seriesData[this.seriesCurrentSnapshotIndex];
if (snapshot === undefined) {
return;
}
slider.value = idx.toString();
slider.max = (this.seriesData.length - 1).toString();
this.getNode('.snapshots .current-snapshot')!.textContent =
slider.value;
this.getNode('.snapshots .total-snapshots')!.textContent = slider.max;
this.getNode('.snapshots .snapshot-delta')!.textContent =
`+${snapshot.deltaRecordingStartMs}ms`;
for (const db of snapshot.databases || []) {
const dbView = document.createElement('indexeddb-database');
const dbElement = this.getNode('.database-view').appendChild(dbView) as
IndexedDbDatabase;
dbElement.clients = snapshot.clients;
dbElement.data = db;
}
}
private getNode<T extends HTMLElement>(selector: string) {
const controlNode = this.querySelector<T>(`${selector}`);
assert(controlNode);
return controlNode;
}
private onLoadComplete() {
this.progressNode.style.display = 'none';
this.connectionCountNode.innerText = '0';
}
private onMetadataRecordingReady(metadata: IdbBucketMetadata[]) {
this.seriesData = metadata;
this.onLoadComplete();
this.getNode('.snapshots').hidden = false;
this.getNode('.snapshots .controls').hidden = metadata.length === 0;
if (metadata.length === 0) {
this.setRecordingSnapshot(null);
this.getNode('.snapshots .message').innerText =
'No snapshots were captured.';
return;
}
this.getNode('.snapshots .message').innerText = '';
this.setRecordingSnapshot(0);
}
}
function onStorageKeysReady(partitions: IdbPartitionMetadata[]) {
const template = jstGetTemplate('indexeddb-list-template');
getRequiredElement('indexeddb-list').appendChild(template);
const currentOriginFilter = () => window.location.hash.replace('#', '');
const processTemplate = () => jstProcess(
new JsEvalContext({
partitions,
stringifyMojo,
originFilter: currentOriginFilter(),
}),
template);
processTemplate();
// Re process the template when the origin filter is updated.
const originFilterInput =
document
.querySelector<HTMLInputElement>('#origin-filter')!;
originFilterInput.value = currentOriginFilter();
originFilterInput.addEventListener('input', (event: Event) => {
const input = event.target as HTMLInputElement;
window.location.hash = input.value;
processTemplate();
});
}
customElements.define('indexeddb-bucket', BucketElement);
document.addEventListener('DOMContentLoaded', initialize);