1// Copyright (C) 2018 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15// Keep this import first. 16import '../base/static_initializers'; 17import '../gen/all_plugins'; 18import '../gen/all_core_plugins'; 19 20import {Draft} from 'immer'; 21import m from 'mithril'; 22 23import {defer} from '../base/deferred'; 24import {addErrorHandler, reportError} from '../base/logging'; 25import {Store} from '../base/store'; 26import {Actions, DeferredAction, StateActions} from '../common/actions'; 27import {flattenArgs, traceEvent} from '../common/metatracing'; 28import {pluginManager} from '../common/plugins'; 29import {State} from '../common/state'; 30import {initController, runControllers} from '../controller'; 31import {isGetCategoriesResponse} from '../controller/chrome_proxy_record_controller'; 32import {RECORDING_V2_FLAG, featureFlags} from '../core/feature_flags'; 33import {initLiveReloadIfLocalhost} from '../core/live_reload'; 34import {raf} from '../core/raf_scheduler'; 35import {initWasm} from '../trace_processor/wasm_engine_proxy'; 36import {setScheduleFullRedraw} from '../widgets/raf'; 37 38import {App} from './app'; 39import {initCssConstants} from './css_constants'; 40import {registerDebugGlobals} from './debug'; 41import {maybeShowErrorDialog} from './error_dialog'; 42import {installFileDropHandler} from './file_drop_handler'; 43import {FlagsPage} from './flags_page'; 44import {globals} from './globals'; 45import {HomePage} from './home_page'; 46import {InsightsPage} from './insights_page'; 47import {MetricsPage} from './metrics_page'; 48import {PluginsPage} from './plugins_page'; 49import {postMessageHandler} from './post_message_handler'; 50import {QueryPage} from './query_page'; 51import {RecordPage, updateAvailableAdbDevices} from './record_page'; 52import {RecordPageV2} from './record_page_v2'; 53import {Route, Router} from './router'; 54import {CheckHttpRpcConnection} from './rpc_http_dialog'; 55import {TraceInfoPage} from './trace_info_page'; 56import {maybeOpenTraceFromRoute} from './trace_url_handler'; 57import {ViewerPage} from './viewer_page'; 58import {VizPage} from './viz_page'; 59import {WidgetsPage} from './widgets_page'; 60import {HttpRpcEngine} from '../trace_processor/http_rpc_engine'; 61import {showModal} from '../widgets/modal'; 62 63const EXTENSION_ID = 'lfmkphfpdbjijhpomgecfikhfohaoine'; 64 65const CSP_WS_PERMISSIVE_PORT = featureFlags.register({ 66 id: 'cspAllowAnyWebsocketPort', 67 name: 'Relax Content Security Policy for 127.0.0.1:*', 68 description: 69 'Allows simultaneous usage of several trace_processor_shell ' + 70 '-D --http-port 1234 by opening ' + 71 'https://ui.perfetto.dev/#!/?rpc_port=1234', 72 defaultValue: false, 73}); 74 75class FrontendApi { 76 constructor() { 77 globals.store.subscribe(this.handleStoreUpdate); 78 } 79 80 private handleStoreUpdate = (store: Store<State>, oldState: State) => { 81 const newState = store.state; 82 83 // If the visible time in the global state has been updated more 84 // recently than the visible time handled by the frontend @ 60fps, 85 // update it. This typically happens when restoring the state from a 86 // permalink. 87 globals.timeline.mergeState(newState.frontendLocalState); 88 89 // Only redraw if something other than the frontendLocalState changed. 90 let key: keyof State; 91 for (key in store.state) { 92 if (key !== 'frontendLocalState' && oldState[key] !== newState[key]) { 93 raf.scheduleFullRedraw(); 94 break; 95 } 96 } 97 98 // Run in microtask to avoid avoid reentry 99 setTimeout(runControllers, 0); 100 }; 101 102 dispatchMultiple(actions: DeferredAction[]) { 103 const edits = actions.map((action) => { 104 return traceEvent( 105 `action.${action.type}`, 106 () => { 107 return (draft: Draft<State>) => { 108 // eslint-disable-next-line @typescript-eslint/no-explicit-any 109 (StateActions as any)[action.type](draft, action.args); 110 }; 111 }, 112 { 113 args: flattenArgs(action.args), 114 }, 115 ); 116 }); 117 globals.store.edit(edits); 118 } 119} 120 121function setExtensionAvailability(available: boolean) { 122 globals.dispatch( 123 Actions.setExtensionAvailable({ 124 available, 125 }), 126 ); 127} 128 129function routeChange(route: Route) { 130 raf.scheduleFullRedraw(); 131 maybeOpenTraceFromRoute(route); 132 if (route.fragment) { 133 // This needs to happen after the next redraw call. It's not enough 134 // to use setTimeout(..., 0); since that may occur before the 135 // redraw scheduled above. 136 raf.addPendingCallback(() => { 137 const e = document.getElementById(route.fragment); 138 if (e) { 139 e.scrollIntoView(); 140 } 141 }); 142 } 143} 144 145function setupContentSecurityPolicy() { 146 // Note: self and sha-xxx must be quoted, urls data: and blob: must not. 147 148 let rpcPolicy = [ 149 'http://127.0.0.1:9001', // For trace_processor_shell --httpd. 150 'ws://127.0.0.1:9001', // Ditto, for the websocket RPC. 151 ]; 152 if (CSP_WS_PERMISSIVE_PORT.get()) { 153 const route = Router.parseUrl(window.location.href); 154 if (/^\d+$/.exec(route.args.rpc_port ?? '')) { 155 rpcPolicy = [ 156 `http://127.0.0.1:${route.args.rpc_port}`, 157 `ws://127.0.0.1:${route.args.rpc_port}`, 158 ]; 159 } 160 } 161 const policy = { 162 'default-src': [ 163 `'self'`, 164 // Google Tag Manager bootstrap. 165 `'sha256-LirUKeorCU4uRNtNzr8tlB11uy8rzrdmqHCX38JSwHY='`, 166 ], 167 'script-src': [ 168 `'self'`, 169 // TODO(b/201596551): this is required for Wasm after crrev.com/c/3179051 170 // and should be replaced with 'wasm-unsafe-eval'. 171 `'unsafe-eval'`, 172 'https://*.google.com', 173 'https://*.googleusercontent.com', 174 'https://www.googletagmanager.com', 175 'https://*.google-analytics.com', 176 ], 177 'object-src': ['none'], 178 'connect-src': [ 179 `'self'`, 180 'ws://127.0.0.1:8037', // For the adb websocket server. 181 'https://*.google-analytics.com', 182 'https://*.googleapis.com', // For Google Cloud Storage fetches. 183 'blob:', 184 'data:', 185 ].concat(rpcPolicy), 186 'img-src': [ 187 `'self'`, 188 'data:', 189 'blob:', 190 'https://*.google-analytics.com', 191 'https://www.googletagmanager.com', 192 'https://*.googleapis.com', 193 ], 194 'style-src': [`'self'`, `'unsafe-inline'`], 195 'navigate-to': ['https://*.perfetto.dev', 'self'], 196 }; 197 const meta = document.createElement('meta'); 198 meta.httpEquiv = 'Content-Security-Policy'; 199 let policyStr = ''; 200 for (const [key, list] of Object.entries(policy)) { 201 policyStr += `${key} ${list.join(' ')}; `; 202 } 203 meta.content = policyStr; 204 document.head.appendChild(meta); 205} 206 207function main() { 208 // Wire up raf for widgets. 209 setScheduleFullRedraw(() => raf.scheduleFullRedraw()); 210 211 setupContentSecurityPolicy(); 212 213 // Load the css. The load is asynchronous and the CSS is not ready by the time 214 // appendChild returns. 215 const cssLoadPromise = defer<void>(); 216 const css = document.createElement('link'); 217 css.rel = 'stylesheet'; 218 css.href = globals.root + 'perfetto.css'; 219 css.onload = () => cssLoadPromise.resolve(); 220 css.onerror = (err) => cssLoadPromise.reject(err); 221 const favicon = document.head.querySelector('#favicon'); 222 if (favicon instanceof HTMLLinkElement) { 223 favicon.href = globals.root + 'assets/favicon.png'; 224 } 225 226 // Load the script to detect if this is a Googler (see comments on globals.ts) 227 // and initialize GA after that (or after a timeout if something goes wrong). 228 const script = document.createElement('script'); 229 script.src = 230 'https://storage.cloud.google.com/perfetto-ui-internal/is_internal_user.js'; 231 script.async = true; 232 script.onerror = () => globals.logging.initialize(); 233 script.onload = () => globals.logging.initialize(); 234 setTimeout(() => globals.logging.initialize(), 5000); 235 236 document.head.append(script, css); 237 238 // Route errors to both the UI bugreport dialog and Analytics (if enabled). 239 addErrorHandler(maybeShowErrorDialog); 240 addErrorHandler((e) => globals.logging.logError(e)); 241 242 // Add Error handlers for JS error and for uncaught exceptions in promises. 243 window.addEventListener('error', (e) => reportError(e)); 244 window.addEventListener('unhandledrejection', (e) => reportError(e)); 245 246 const extensionLocalChannel = new MessageChannel(); 247 248 initWasm(globals.root); 249 initController(extensionLocalChannel.port1); 250 251 const dispatch = (action: DeferredAction) => { 252 frontendApi.dispatchMultiple([action]); 253 }; 254 255 const router = new Router({ 256 '/': HomePage, 257 '/viewer': ViewerPage, 258 '/record': RECORDING_V2_FLAG.get() ? RecordPageV2 : RecordPage, 259 '/query': QueryPage, 260 '/insights': InsightsPage, 261 '/flags': FlagsPage, 262 '/metrics': MetricsPage, 263 '/info': TraceInfoPage, 264 '/widgets': WidgetsPage, 265 '/viz': VizPage, 266 '/plugins': PluginsPage, 267 }); 268 router.onRouteChanged = routeChange; 269 270 // These need to be set before globals.initialize. 271 const route = Router.parseUrl(window.location.href); 272 globals.embeddedMode = route.args.mode === 'embedded'; 273 globals.hideSidebar = route.args.hideSidebar === true; 274 275 globals.initialize(dispatch, router); 276 277 globals.serviceWorkerController.install(); 278 279 const frontendApi = new FrontendApi(); 280 globals.publishRedraw = () => raf.scheduleFullRedraw(); 281 282 // We proxy messages between the extension and the controller because the 283 // controller's worker can't access chrome.runtime. 284 const extensionPort = 285 // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions 286 window.chrome && chrome.runtime 287 ? chrome.runtime.connect(EXTENSION_ID) 288 : undefined; 289 290 setExtensionAvailability(extensionPort !== undefined); 291 292 if (extensionPort) { 293 extensionPort.onDisconnect.addListener((_) => { 294 setExtensionAvailability(false); 295 void chrome.runtime.lastError; // Needed to not receive an error log. 296 }); 297 // This forwards the messages from the extension to the controller. 298 extensionPort.onMessage.addListener( 299 (message: object, _port: chrome.runtime.Port) => { 300 if (isGetCategoriesResponse(message)) { 301 globals.dispatch(Actions.setChromeCategories(message)); 302 return; 303 } 304 extensionLocalChannel.port2.postMessage(message); 305 }, 306 ); 307 } 308 309 // This forwards the messages from the controller to the extension 310 extensionLocalChannel.port2.onmessage = ({data}) => { 311 if (extensionPort) extensionPort.postMessage(data); 312 }; 313 314 // Put debug variables in the global scope for better debugging. 315 registerDebugGlobals(); 316 317 // Prevent pinch zoom. 318 document.body.addEventListener( 319 'wheel', 320 (e: MouseEvent) => { 321 if (e.ctrlKey) e.preventDefault(); 322 }, 323 {passive: false}, 324 ); 325 326 cssLoadPromise.then(() => onCssLoaded()); 327 328 if (globals.testing) { 329 document.body.classList.add('testing'); 330 } 331 332 pluginManager.initialize(); 333} 334 335function onCssLoaded() { 336 initCssConstants(); 337 // Clear all the contents of the initial page (e.g. the <pre> error message) 338 // And replace it with the root <main> element which will be used by mithril. 339 document.body.innerHTML = ''; 340 341 raf.domRedraw = () => { 342 m.render(document.body, m(App, globals.router.resolve())); 343 }; 344 345 initLiveReloadIfLocalhost(globals.embeddedMode); 346 347 if (!RECORDING_V2_FLAG.get()) { 348 updateAvailableAdbDevices(); 349 try { 350 navigator.usb.addEventListener('connect', () => 351 updateAvailableAdbDevices(), 352 ); 353 navigator.usb.addEventListener('disconnect', () => 354 updateAvailableAdbDevices(), 355 ); 356 } catch (e) { 357 console.error('WebUSB API not supported'); 358 } 359 } 360 361 // Will update the chip on the sidebar footer that notifies that the RPC is 362 // connected. Has no effect on the controller (which will repeat this check 363 // before creating a new engine). 364 // Don't auto-open any trace URLs until we get a response here because we may 365 // accidentially clober the state of an open trace processor instance 366 // otherwise. 367 maybeChangeRpcPortFromFragment(); 368 CheckHttpRpcConnection().then(() => { 369 const route = Router.parseUrl(window.location.href); 370 globals.dispatch( 371 Actions.maybeSetPendingDeeplink({ 372 ts: route.args.ts, 373 tid: route.args.tid, 374 dur: route.args.dur, 375 pid: route.args.pid, 376 query: route.args.query, 377 visStart: route.args.visStart, 378 visEnd: route.args.visEnd, 379 }), 380 ); 381 382 if (!globals.embeddedMode) { 383 installFileDropHandler(); 384 } 385 386 // Don't allow postMessage or opening trace from route when the user says 387 // that they want to reuse the already loaded trace in trace processor. 388 const engine = globals.getCurrentEngine(); 389 if (engine && engine.source.type === 'HTTP_RPC') { 390 return; 391 } 392 393 // Add support for opening traces from postMessage(). 394 window.addEventListener('message', postMessageHandler, {passive: true}); 395 396 // Handles the initial ?local_cache_key=123 or ?s=permalink or ?url=... 397 // cases. 398 routeChange(route); 399 }); 400} 401 402// If the URL is /#!?rpc_port=1234, change the default RPC port. 403// For security reasons, this requires toggling a flag. Detect this and tell the 404// user what to do in this case. 405function maybeChangeRpcPortFromFragment() { 406 const route = Router.parseUrl(window.location.href); 407 if (route.args.rpc_port !== undefined) { 408 if (!CSP_WS_PERMISSIVE_PORT.get()) { 409 showModal({ 410 title: 'Using a different port requires a flag change', 411 content: m( 412 'div', 413 m( 414 'span', 415 'For security reasons before connecting to a non-standard ' + 416 'TraceProcessor port you need to manually enable the flag to ' + 417 'relax the Content Security Policy and restart the UI.', 418 ), 419 ), 420 buttons: [ 421 { 422 text: 'Take me to the flags page', 423 primary: true, 424 action: () => Router.navigate('#!/flags/cspAllowAnyWebsocketPort'), 425 }, 426 ], 427 }); 428 } else { 429 HttpRpcEngine.rpcPort = route.args.rpc_port; 430 } 431 } 432} 433 434main(); 435