blob: 5b2814e1a1d5ea173649663bd438ccca9170b70d [file] [log] [blame]
// Copyright (C) 2018 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import '../tracks/all_frontend';
import {applyPatches, Patch} from 'immer';
import * as m from 'mithril';
import {defer} from '../base/deferred';
import {assertExists, reportError, setErrorHandler} from '../base/logging';
import {forwardRemoteCalls} from '../base/remote';
import {Actions} from '../common/actions';
import {AggregateData} from '../common/aggregation_data';
import {
LogBoundsKey,
LogEntriesKey,
LogExists,
LogExistsKey
} from '../common/logs';
import {MetricResult} from '../common/metric_data';
import {CurrentSearchResults, SearchSummary} from '../common/search_data';
import {AnalyzePage} from './analyze_page';
import {loadAndroidBugToolInfo} from './android_bug_tool';
import {initCssConstants} from './css_constants';
import {maybeShowErrorDialog} from './error_dialog';
import {installFileDropHandler} from './file_drop_handler';
import {
CounterDetails,
CpuProfileDetails,
Flow,
globals,
HeapProfileDetails,
QuantizedLoad,
SliceDetails,
ThreadDesc,
ThreadStateDetails
} from './globals';
import {HomePage} from './home_page';
import {openBufferWithLegacyTraceViewer} from './legacy_trace_viewer';
import {initLiveReloadIfLocalhost} from './live_reload';
import {MetricsPage} from './metrics_page';
import {postMessageHandler} from './post_message_handler';
import {RecordPage, updateAvailableAdbDevices} from './record_page';
import {Router} from './router';
import {CheckHttpRpcConnection} from './rpc_http_dialog';
import {taskTracker} from './task_tracker';
import {TraceInfoPage} from './trace_info_page';
import {ViewerPage} from './viewer_page';
const EXTENSION_ID = 'lfmkphfpdbjijhpomgecfikhfohaoine';
function isLocalhostTraceUrl(url: string): boolean {
return ['127.0.0.1', 'localhost'].includes((new URL(url)).hostname);
}
/**
* The API the main thread exposes to the controller.
*/
class FrontendApi {
constructor(private router: Router) {}
patchState(patches: Patch[]) {
const oldState = globals.state;
globals.state = applyPatches(globals.state, patches);
// If the visible time in the global state has been updated more recently
// than the visible time handled by the frontend @ 60fps, update it. This
// typically happens when restoring the state from a permalink.
globals.frontendLocalState.mergeState(globals.state.frontendLocalState);
// Only redraw if something other than the frontendLocalState changed.
for (const key in globals.state) {
if (key !== 'frontendLocalState' && key !== 'visibleTracks' &&
oldState[key] !== globals.state[key]) {
this.redraw();
return;
}
}
}
// TODO: we can't have a publish method for each batch of data that we don't
// want to keep in the global state. Figure out a more generic and type-safe
// mechanism to achieve this.
publishOverviewData(data: {[key: string]: QuantizedLoad|QuantizedLoad[]}) {
for (const [key, value] of Object.entries(data)) {
if (!globals.overviewStore.has(key)) {
globals.overviewStore.set(key, []);
}
if (value instanceof Array) {
globals.overviewStore.get(key)!.push(...value);
} else {
globals.overviewStore.get(key)!.push(value);
}
}
globals.rafScheduler.scheduleRedraw();
}
publishTrackData(args: {id: string, data: {}}) {
globals.setTrackData(args.id, args.data);
if ([LogExistsKey, LogBoundsKey, LogEntriesKey].includes(args.id)) {
const data = globals.trackDataStore.get(LogExistsKey) as LogExists;
if (data && data.exists) globals.rafScheduler.scheduleFullRedraw();
} else {
globals.rafScheduler.scheduleRedraw();
}
}
publishQueryResult(args: {id: string, data: {}}) {
globals.queryResults.set(args.id, args.data);
this.redraw();
}
publishThreads(data: ThreadDesc[]) {
globals.threads.clear();
data.forEach(thread => {
globals.threads.set(thread.utid, thread);
});
this.redraw();
}
publishSliceDetails(click: SliceDetails) {
globals.sliceDetails = click;
this.redraw();
}
publishThreadStateDetails(click: ThreadStateDetails) {
globals.threadStateDetails = click;
this.redraw();
}
publishConnectedFlows(connectedFlows: Flow[]) {
globals.connectedFlows = connectedFlows;
// Call resetFlowFocus() each time connectedFlows is updated to correctly
// navigate using hotkeys.
this.resetFlowFocus();
this.redraw();
}
// If a chrome slice is selected and we have any flows in connectedFlows
// we will find the flows on the right and left of that slice to set a default
// focus. In all other cases the focusedFlowId(Left|Right) will be set to -1.
resetFlowFocus() {
globals.frontendLocalState.focusedFlowIdLeft = -1;
globals.frontendLocalState.focusedFlowIdRight = -1;
if (globals.state.currentSelection?.kind === 'CHROME_SLICE') {
const sliceId = globals.state.currentSelection.id;
for (const flow of globals.connectedFlows) {
if (flow.begin.sliceId === sliceId) {
globals.frontendLocalState.focusedFlowIdRight = flow.id;
}
if (flow.end.sliceId === sliceId) {
globals.frontendLocalState.focusedFlowIdLeft = flow.id;
}
}
}
}
publishSelectedFlows(selectedFlows: Flow[]) {
globals.selectedFlows = selectedFlows;
this.redraw();
}
publishCounterDetails(click: CounterDetails) {
globals.counterDetails = click;
this.redraw();
}
publishHeapProfileDetails(click: HeapProfileDetails) {
globals.heapProfileDetails = click;
this.redraw();
}
publishCpuProfileDetails(details: CpuProfileDetails) {
globals.cpuProfileDetails = details;
this.redraw();
}
publishFileDownload(args: {file: File, name?: string}) {
const url = URL.createObjectURL(args.file);
const a = document.createElement('a');
a.href = url;
a.download = args.name !== undefined ? args.name : args.file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
publishLoading(numQueuedQueries: number) {
globals.numQueuedQueries = numQueuedQueries;
// TODO(hjd): Clean up loadingAnimation given that this now causes a full
// redraw anyways. Also this should probably just go via the global state.
globals.rafScheduler.scheduleFullRedraw();
}
// For opening JSON/HTML traces with the legacy catapult viewer.
publishLegacyTrace(args: {data: ArrayBuffer, size: number}) {
const arr = new Uint8Array(args.data, 0, args.size);
const str = (new TextDecoder('utf-8')).decode(arr);
openBufferWithLegacyTraceViewer('trace.json', str, 0);
globals.dispatch(Actions.clearConversionInProgress({}));
}
publishBufferUsage(args: {percentage: number}) {
globals.setBufferUsage(args.percentage);
this.redraw();
}
publishSearch(args: SearchSummary) {
globals.searchSummary = args;
this.redraw();
}
publishSearchResult(args: CurrentSearchResults) {
globals.currentSearchResults = args;
this.redraw();
}
publishRecordingLog(args: {logs: string}) {
globals.setRecordingLog(args.logs);
this.redraw();
}
publishTraceErrors(numErrors: number) {
globals.setTraceErrors(numErrors);
this.redraw();
}
publishMetricError(error: string) {
globals.setMetricError(error);
globals.logging.logError(error, false);
this.redraw();
}
publishMetricResult(metricResult: MetricResult) {
globals.setMetricResult(metricResult);
this.redraw();
}
publishAggregateData(args: {data: AggregateData, kind: string}) {
globals.setAggregateData(args.kind, args.data);
this.redraw();
}
private redraw(): void {
if (globals.state.route &&
globals.state.route !== this.router.getRouteFromHash()) {
this.router.setRouteOnHash(globals.state.route);
}
globals.rafScheduler.scheduleFullRedraw();
}
}
function setExtensionAvailability(available: boolean) {
globals.dispatch(Actions.setExtensionAvailable({
available,
}));
}
function setupContentSecurityPolicy() {
// Note: self and sha-xxx must be quoted, urls data: and blob: must not.
const policy = {
'default-src': [
`'self'`,
// Google Tag Manager bootstrap.
`'sha256-LirUKeorCU4uRNtNzr8tlB11uy8rzrdmqHCX38JSwHY='`,
],
'script-src': [
`'self'`,
'https://*.google.com',
'https://*.googleusercontent.com',
'https://www.googletagmanager.com',
'https://www.google-analytics.com',
],
'object-src': ['none'],
'connect-src': [
`'self'`,
'http://127.0.0.1:9001', // For trace_processor_shell --httpd.
'https://www.google-analytics.com',
'https://*.googleapis.com', // For Google Cloud Storage fetches.
'blob:',
'data:',
],
'img-src': [
`'self'`,
'data:',
'blob:',
'https://www.google-analytics.com',
'https://www.googletagmanager.com',
],
'navigate-to': ['https://*.perfetto.dev', 'self'],
};
const meta = document.createElement('meta');
meta.httpEquiv = 'Content-Security-Policy';
let policyStr = '';
for (const [key, list] of Object.entries(policy)) {
policyStr += `${key} ${list.join(' ')}; `;
}
meta.content = policyStr;
document.head.appendChild(meta);
}
function main() {
setupContentSecurityPolicy();
// Load the css. The load is asynchronous and the CSS is not ready by the time
// appenChild returns.
const cssLoadPromise = defer<void>();
const css = document.createElement('link');
css.rel = 'stylesheet';
css.href = globals.root + 'perfetto.css';
css.onload = () => cssLoadPromise.resolve();
css.onerror = (err) => cssLoadPromise.reject(err);
const favicon = document.head.querySelector('#favicon') as HTMLLinkElement;
if (favicon) favicon.href = globals.root + 'assets/favicon.png';
// Load the script to detect if this is a Googler (see comments on globals.ts)
// and initialize GA after that (or after a timeout if something goes wrong).
const script = document.createElement('script');
script.src =
'https://storage.cloud.google.com/perfetto-ui-internal/is_internal_user.js';
script.async = true;
script.onerror = () => globals.logging.initialize();
script.onload = () => globals.logging.initialize();
setTimeout(() => globals.logging.initialize(), 5000);
document.head.append(script, css);
// Add Error handlers for JS error and for uncaught exceptions in promises.
setErrorHandler((err: string) => maybeShowErrorDialog(err));
window.addEventListener('error', e => reportError(e));
window.addEventListener('unhandledrejection', e => reportError(e));
const controller = new Worker(globals.root + 'controller_bundle.js');
const frontendChannel = new MessageChannel();
const controllerChannel = new MessageChannel();
const extensionLocalChannel = new MessageChannel();
const errorReportingChannel = new MessageChannel();
errorReportingChannel.port2.onmessage = (e) =>
maybeShowErrorDialog(`${e.data}`);
controller.postMessage(
{
frontendPort: frontendChannel.port1,
controllerPort: controllerChannel.port1,
extensionPort: extensionLocalChannel.port1,
errorReportingPort: errorReportingChannel.port1,
},
[
frontendChannel.port1,
controllerChannel.port1,
extensionLocalChannel.port1,
errorReportingChannel.port1,
]);
const dispatch =
controllerChannel.port2.postMessage.bind(controllerChannel.port2);
globals.initialize(dispatch, controller);
globals.serviceWorkerController.install();
const router = new Router(
'/',
{
'/': HomePage,
'/viewer': ViewerPage,
'/record': RecordPage,
'/query': AnalyzePage,
'/metrics': MetricsPage,
'/info': TraceInfoPage,
},
dispatch,
globals.logging);
forwardRemoteCalls(frontendChannel.port2, new FrontendApi(router));
// We proxy messages between the extension and the controller because the
// controller's worker can't access chrome.runtime.
const extensionPort = window.chrome && chrome.runtime ?
chrome.runtime.connect(EXTENSION_ID) :
undefined;
setExtensionAvailability(extensionPort !== undefined);
if (extensionPort) {
extensionPort.onDisconnect.addListener(_ => {
setExtensionAvailability(false);
// tslint:disable-next-line: no-unused-expression
void chrome.runtime.lastError; // Needed to not receive an error log.
});
// This forwards the messages from the extension to the controller.
extensionPort.onMessage.addListener(
(message: object, _port: chrome.runtime.Port) => {
extensionLocalChannel.port2.postMessage(message);
});
}
// This forwards the messages from the controller to the extension
extensionLocalChannel.port2.onmessage = ({data}) => {
if (extensionPort) extensionPort.postMessage(data);
};
// Put these variables in the global scope for better debugging.
(window as {} as {m: {}}).m = m;
(window as {} as {globals: {}}).globals = globals;
(window as {} as {Actions: {}}).Actions = Actions;
// Prevent pinch zoom.
document.body.addEventListener('wheel', (e: MouseEvent) => {
if (e.ctrlKey) e.preventDefault();
}, {passive: false});
cssLoadPromise.then(() => onCssLoaded(router));
}
function onCssLoaded(router: Router) {
initCssConstants();
// Clear all the contents of the initial page (e.g. the <pre> error message)
// And replace it with the root <main> element which will be used by mithril.
document.body.innerHTML = '<main></main>';
const main = assertExists(document.body.querySelector('main'));
globals.rafScheduler.domRedraw = () =>
m.render(main, m(router.resolve(globals.state.route)));
router.navigateToCurrentHash();
// /?s=xxxx for permalinks.
const stateHash = Router.param('s');
const urlHash = Router.param('url');
const androidBugTool = Router.param('openFromAndroidBugTool');
if (typeof stateHash === 'string' && stateHash) {
globals.dispatch(Actions.loadPermalink({
hash: stateHash,
}));
} else if (typeof urlHash === 'string' && urlHash) {
if (isLocalhostTraceUrl(urlHash)) {
const fileName = urlHash.split('/').pop() || 'local_trace.pftrace';
const request = fetch(urlHash)
.then(response => response.blob())
.then(blob => {
globals.dispatch(Actions.openTraceFromFile({
file: new File([blob], fileName),
}));
})
.catch(e => alert(`Could not load local trace ${e}`));
taskTracker.trackPromise(request, 'Downloading local trace');
} else {
globals.dispatch(Actions.openTraceFromUrl({
url: urlHash,
}));
}
} else if (androidBugTool) {
// TODO(hjd): Unify updateStatus and TaskTracker
globals.dispatch(Actions.updateStatus({
msg: 'Loading trace from ABT extension',
timestamp: Date.now() / 1000
}));
const loadInfo = loadAndroidBugToolInfo();
taskTracker.trackPromise(loadInfo, 'Loading trace from ABT extension');
loadInfo
.then(info => {
globals.dispatch(Actions.openTraceFromFile({
file: info.file,
}));
})
.catch(e => {
console.error(e);
});
}
// Add support for opening traces from postMessage().
window.addEventListener('message', postMessageHandler, {passive: true});
// Will update the chip on the sidebar footer that notifies that the RPC is
// connected. Has no effect on the controller (which will repeat this check
// before creating a new engine).
CheckHttpRpcConnection();
initLiveReloadIfLocalhost();
updateAvailableAdbDevices();
try {
navigator.usb.addEventListener(
'connect', () => updateAvailableAdbDevices());
navigator.usb.addEventListener(
'disconnect', () => updateAvailableAdbDevices());
} catch (e) {
console.error('WebUSB API not supported');
}
installFileDropHandler();
}
main();