blob: a5b5b0bb33b25987b0f6f65b543deee52b2b9d48 [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.
// Keep this import first.
import '../base/disposable_polyfill';
import '../base/static_initializers';
import NON_CORE_PLUGINS from '../gen/all_plugins';
import CORE_PLUGINS from '../gen/all_core_plugins';
import {Draft} from 'immer';
import m from 'mithril';
import {defer} from '../base/deferred';
import {addErrorHandler, reportError} from '../base/logging';
import {Store} from '../base/store';
import {Actions, DeferredAction, StateActions} from '../common/actions';
import {traceEvent} from '../core/metatracing';
import {State} from '../common/state';
import {initController, runControllers} from '../controller';
import {isGetCategoriesResponse} from '../controller/chrome_proxy_record_controller';
import {RECORDING_V2_FLAG, featureFlags} from '../core/feature_flags';
import {initLiveReload} from '../core/live_reload';
import {raf} from '../core/raf_scheduler';
import {initWasm} from '../trace_processor/wasm_engine_proxy';
import {setScheduleFullRedraw} from '../widgets/raf';
import {UiMain} from './ui_main';
import {initCssConstants} from './css_constants';
import {registerDebugGlobals} from './debug';
import {maybeShowErrorDialog} from './error_dialog';
import {ExplorePage} from './explore_page';
import {installFileDropHandler} from './file_drop_handler';
import {FlagsPage} from './flags_page';
import {globals} from './globals';
import {HomePage} from './home_page';
import {InsightsPage} from './insights_page';
import {MetricsPage} from './metrics_page';
import {PluginsPage} from './plugins_page';
import {postMessageHandler} from './post_message_handler';
import {QueryPage} from './query_page';
import {RecordPage, updateAvailableAdbDevices} from './record_page';
import {RecordPageV2} from './record_page_v2';
import {Route, Router} from '../core/router';
import {CheckHttpRpcConnection} from './rpc_http_dialog';
import {TraceInfoPage} from './trace_info_page';
import {maybeOpenTraceFromRoute} from './trace_url_handler';
import {ViewerPage} from './viewer_page';
import {VizPage} from './viz_page';
import {WidgetsPage} from './widgets_page';
import {HttpRpcEngine} from '../trace_processor/http_rpc_engine';
import {showModal} from '../widgets/modal';
import {IdleDetector} from './idle_detector';
import {IdleDetectorWindow} from './idle_detector_interface';
import {pageWithTrace} from './pages';
import {AppImpl} from '../core/app_impl';
import {addSqlTableTab} from './sql_table_tab';
import {getServingRoot} from '../base/http_utils';
import {configureExtensions} from '../public/lib/extensions';
import {
addDebugCounterTrack,
addDebugSliceTrack,
} from '../public/lib/debug_tracks/debug_tracks';
import {addVisualizedArgTracks} from './visualized_args_tracks';
import {addQueryResultsTab} from '../public/lib/query_table/query_result_tab';
const EXTENSION_ID = 'lfmkphfpdbjijhpomgecfikhfohaoine';
const CSP_WS_PERMISSIVE_PORT = featureFlags.register({
id: 'cspAllowAnyWebsocketPort',
name: 'Relax Content Security Policy for 127.0.0.1:*',
description:
'Allows simultaneous usage of several trace_processor_shell ' +
'-D --http-port 1234 by opening ' +
'https://ui.perfetto.dev/#!/?rpc_port=1234',
defaultValue: false,
});
function setExtensionAvailability(available: boolean) {
globals.dispatch(
Actions.setExtensionAvailable({
available,
}),
);
}
function routeChange(route: Route) {
raf.scheduleFullRedraw();
maybeOpenTraceFromRoute(route);
if (route.fragment) {
// This needs to happen after the next redraw call. It's not enough
// to use setTimeout(..., 0); since that may occur before the
// redraw scheduled above.
raf.addPendingCallback(() => {
const e = document.getElementById(route.fragment);
if (e) {
e.scrollIntoView();
}
});
}
}
function setupContentSecurityPolicy() {
// Note: self and sha-xxx must be quoted, urls data: and blob: must not.
let rpcPolicy = [
'http://127.0.0.1:9001', // For trace_processor_shell --httpd.
'ws://127.0.0.1:9001', // Ditto, for the websocket RPC.
];
if (CSP_WS_PERMISSIVE_PORT.get()) {
const route = Router.parseUrl(window.location.href);
if (/^\d+$/.exec(route.args.rpc_port ?? '')) {
rpcPolicy = [
`http://127.0.0.1:${route.args.rpc_port}`,
`ws://127.0.0.1:${route.args.rpc_port}`,
];
}
}
const policy = {
'default-src': [
`'self'`,
// Google Tag Manager bootstrap.
`'sha256-LirUKeorCU4uRNtNzr8tlB11uy8rzrdmqHCX38JSwHY='`,
],
'script-src': [
`'self'`,
// TODO(b/201596551): this is required for Wasm after crrev.com/c/3179051
// and should be replaced with 'wasm-unsafe-eval'.
`'unsafe-eval'`,
'https://*.google.com',
'https://*.googleusercontent.com',
'https://www.googletagmanager.com',
'https://*.google-analytics.com',
],
'object-src': ['none'],
'connect-src': [
`'self'`,
'ws://127.0.0.1:8037', // For the adb websocket server.
'https://*.google-analytics.com',
'https://*.googleapis.com', // For Google Cloud Storage fetches.
'blob:',
'data:',
].concat(rpcPolicy),
'img-src': [
`'self'`,
'data:',
'blob:',
'https://*.google-analytics.com',
'https://www.googletagmanager.com',
'https://*.googleapis.com',
],
'style-src': [`'self'`, `'unsafe-inline'`],
'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 setupExtentionPort(extensionLocalChannel: MessageChannel) {
// We proxy messages between the extension and the controller because the
// controller's worker can't access chrome.runtime.
const extensionPort =
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
window.chrome && chrome.runtime
? chrome.runtime.connect(EXTENSION_ID)
: undefined;
setExtensionAvailability(extensionPort !== undefined);
if (extensionPort) {
// Send messages to keep-alive the extension port.
const interval = setInterval(() => {
extensionPort.postMessage({
method: 'ExtensionVersion',
});
}, 25000);
extensionPort.onDisconnect.addListener((_) => {
setExtensionAvailability(false);
clearInterval(interval);
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) => {
if (isGetCategoriesResponse(message)) {
globals.dispatch(Actions.setChromeCategories(message));
return;
}
extensionLocalChannel.port2.postMessage(message);
},
);
}
// This forwards the messages from the controller to the extension
extensionLocalChannel.port2.onmessage = ({data}) => {
if (extensionPort) extensionPort.postMessage(data);
};
}
function main() {
// Setup content security policy before anything else.
setupContentSecurityPolicy();
AppImpl.initialize({
rootUrl: getServingRoot(),
initialRouteArgs: Router.parseUrl(window.location.href).args,
clearState: () => globals.dispatch(Actions.clearState({})),
});
// Wire up raf for widgets.
setScheduleFullRedraw(() => raf.scheduleFullRedraw());
// Load the css. The load is asynchronous and the CSS is not ready by the time
// appendChild 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');
if (favicon instanceof HTMLLinkElement) {
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).
function initAnalyticsOnScriptLoad() {
AppImpl.instance.analytics.initialize(globals.isInternalUser);
}
const script = document.createElement('script');
script.src =
'https://storage.cloud.google.com/perfetto-ui-internal/is_internal_user.js';
script.async = true;
script.onerror = () => initAnalyticsOnScriptLoad();
script.onload = () => initAnalyticsOnScriptLoad();
setTimeout(() => initAnalyticsOnScriptLoad(), 5000);
document.head.append(script, css);
// Route errors to both the UI bugreport dialog and Analytics (if enabled).
addErrorHandler(maybeShowErrorDialog);
addErrorHandler((e) => AppImpl.instance.analytics.logError(e));
// Add Error handlers for JS error and for uncaught exceptions in promises.
window.addEventListener('error', (e) => reportError(e));
window.addEventListener('unhandledrejection', (e) => reportError(e));
const extensionLocalChannel = new MessageChannel();
initWasm(globals.root);
initController(extensionLocalChannel.port1);
// These need to be set before globals.initialize.
globals.initialize(stateActionDispatcher);
globals.serviceWorkerController.install();
globals.store.subscribe(scheduleRafAndRunControllersOnStateChange);
globals.publishRedraw = () => raf.scheduleFullRedraw();
setupExtentionPort(extensionLocalChannel);
// Put debug variables in the global scope for better debugging.
registerDebugGlobals();
// Prevent pinch zoom.
document.body.addEventListener(
'wheel',
(e: MouseEvent) => {
if (e.ctrlKey) e.preventDefault();
},
{passive: false},
);
cssLoadPromise.then(() => onCssLoaded());
if (AppImpl.instance.testingMode) {
document.body.classList.add('testing');
}
(window as {} as IdleDetectorWindow).waitForPerfettoIdle = (ms?: number) => {
return new IdleDetector().waitForPerfettoIdle(ms);
};
}
function onCssLoaded() {
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 = '';
const router = new Router({
'/': HomePage,
'/explore': pageWithTrace(ExplorePage),
'/flags': FlagsPage,
'/info': pageWithTrace(TraceInfoPage),
'/insights': pageWithTrace(InsightsPage),
'/metrics': pageWithTrace(MetricsPage),
'/plugins': PluginsPage,
'/query': pageWithTrace(QueryPage),
'/record': RECORDING_V2_FLAG.get() ? RecordPageV2 : RecordPage,
'/viewer': pageWithTrace(ViewerPage),
'/viz': pageWithTrace(VizPage),
'/widgets': WidgetsPage,
});
router.onRouteChanged = routeChange;
raf.domRedraw = () => {
m.render(document.body, m(UiMain, router.resolve()));
};
if (
(location.origin.startsWith('http://localhost:') ||
location.origin.startsWith('http://127.0.0.1:')) &&
!AppImpl.instance.embeddedMode &&
!AppImpl.instance.testingMode
) {
initLiveReload();
}
if (!RECORDING_V2_FLAG.get()) {
updateAvailableAdbDevices();
try {
navigator.usb.addEventListener('connect', () =>
updateAvailableAdbDevices(),
);
navigator.usb.addEventListener('disconnect', () =>
updateAvailableAdbDevices(),
);
} catch (e) {
console.error('WebUSB API not supported');
}
}
// 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).
// Don't auto-open any trace URLs until we get a response here because we may
// accidentially clober the state of an open trace processor instance
// otherwise.
maybeChangeRpcPortFromFragment();
CheckHttpRpcConnection().then(() => {
const route = Router.parseUrl(window.location.href);
if (!AppImpl.instance.embeddedMode) {
installFileDropHandler();
}
// Don't allow postMessage or opening trace from route when the user says
// that they want to reuse the already loaded trace in trace processor.
const traceSource = AppImpl.instance.trace?.traceInfo.source;
if (traceSource && traceSource.type === 'HTTP_RPC') {
return;
}
// Add support for opening traces from postMessage().
window.addEventListener('message', postMessageHandler, {passive: true});
// Handles the initial ?local_cache_key=123 or ?s=permalink or ?url=...
// cases.
routeChange(route);
});
// Force one initial render to get everything in place
m.render(document.body, m(UiMain, router.resolve()));
// Initialize plugins, now that we are ready to go.
const pluginManager = AppImpl.instance.plugins;
CORE_PLUGINS.forEach((p) => pluginManager.registerPlugin(p));
NON_CORE_PLUGINS.forEach((p) => pluginManager.registerPlugin(p));
const route = Router.parseUrl(window.location.href);
const overrides = (route.args.enablePlugins ?? '').split(',');
pluginManager.activatePlugins(overrides);
}
// If the URL is /#!?rpc_port=1234, change the default RPC port.
// For security reasons, this requires toggling a flag. Detect this and tell the
// user what to do in this case.
function maybeChangeRpcPortFromFragment() {
const route = Router.parseUrl(window.location.href);
if (route.args.rpc_port !== undefined) {
if (!CSP_WS_PERMISSIVE_PORT.get()) {
showModal({
title: 'Using a different port requires a flag change',
content: m(
'div',
m(
'span',
'For security reasons before connecting to a non-standard ' +
'TraceProcessor port you need to manually enable the flag to ' +
'relax the Content Security Policy and restart the UI.',
),
),
buttons: [
{
text: 'Take me to the flags page',
primary: true,
action: () => Router.navigate('#!/flags/cspAllowAnyWebsocketPort'),
},
],
});
} else {
HttpRpcEngine.rpcPort = route.args.rpc_port;
}
}
}
function stateActionDispatcher(actions: DeferredAction[]) {
const edits = actions.map((action) => {
return traceEvent(`action.${action.type}`, () => {
return (draft: Draft<State>) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(StateActions as any)[action.type](draft, action.args);
};
});
});
globals.store.edit(edits);
}
function scheduleRafAndRunControllersOnStateChange(
store: Store<State>,
oldState: State,
) {
// Only redraw if something actually changed
if (oldState !== store.state) {
raf.scheduleFullRedraw();
}
// Run in a separate task to avoid avoid reentry.
setTimeout(runControllers, 0);
}
// TODO(primiano): this injection is to break a cirular dependency. See
// comment in sql_table_tab_interface.ts. Remove once we add an extension
// point for context menus.
configureExtensions({
addDebugCounterTrack,
addDebugSliceTrack,
addVisualizedArgTracks,
addSqlTableTab,
addQueryResultsTab,
});
main();