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 15import '../tracks/all_frontend'; 16 17import {applyPatches, Patch} from 'immer'; 18import * as m from 'mithril'; 19 20import {defer} from '../base/deferred'; 21import {assertExists, reportError, setErrorHandler} from '../base/logging'; 22import {forwardRemoteCalls} from '../base/remote'; 23import {Actions} from '../common/actions'; 24import {AggregateData} from '../common/aggregation_data'; 25import { 26 LogBoundsKey, 27 LogEntriesKey, 28 LogExists, 29 LogExistsKey 30} from '../common/logs'; 31import {MetricResult} from '../common/metric_data'; 32import {CurrentSearchResults, SearchSummary} from '../common/search_data'; 33 34import {AnalyzePage} from './analyze_page'; 35import {loadAndroidBugToolInfo} from './android_bug_tool'; 36import {initCssConstants} from './css_constants'; 37import {maybeShowErrorDialog} from './error_dialog'; 38import {installFileDropHandler} from './file_drop_handler'; 39import { 40 CounterDetails, 41 CpuProfileDetails, 42 Flow, 43 globals, 44 HeapProfileDetails, 45 QuantizedLoad, 46 SliceDetails, 47 ThreadDesc, 48 ThreadStateDetails 49} from './globals'; 50import {HomePage} from './home_page'; 51import {openBufferWithLegacyTraceViewer} from './legacy_trace_viewer'; 52import {initLiveReloadIfLocalhost} from './live_reload'; 53import {MetricsPage} from './metrics_page'; 54import {postMessageHandler} from './post_message_handler'; 55import {RecordPage, updateAvailableAdbDevices} from './record_page'; 56import {Router} from './router'; 57import {CheckHttpRpcConnection} from './rpc_http_dialog'; 58import {taskTracker} from './task_tracker'; 59import {TraceInfoPage} from './trace_info_page'; 60import {ViewerPage} from './viewer_page'; 61 62const EXTENSION_ID = 'lfmkphfpdbjijhpomgecfikhfohaoine'; 63 64function isLocalhostTraceUrl(url: string): boolean { 65 return ['127.0.0.1', 'localhost'].includes((new URL(url)).hostname); 66} 67 68/** 69 * The API the main thread exposes to the controller. 70 */ 71class FrontendApi { 72 constructor(private router: Router) {} 73 74 patchState(patches: Patch[]) { 75 const oldState = globals.state; 76 globals.state = applyPatches(globals.state, patches); 77 78 // If the visible time in the global state has been updated more recently 79 // than the visible time handled by the frontend @ 60fps, update it. This 80 // typically happens when restoring the state from a permalink. 81 globals.frontendLocalState.mergeState(globals.state.frontendLocalState); 82 83 // Only redraw if something other than the frontendLocalState changed. 84 for (const key in globals.state) { 85 if (key !== 'frontendLocalState' && key !== 'visibleTracks' && 86 oldState[key] !== globals.state[key]) { 87 this.redraw(); 88 return; 89 } 90 } 91 } 92 93 // TODO: we can't have a publish method for each batch of data that we don't 94 // want to keep in the global state. Figure out a more generic and type-safe 95 // mechanism to achieve this. 96 97 publishOverviewData(data: {[key: string]: QuantizedLoad|QuantizedLoad[]}) { 98 for (const [key, value] of Object.entries(data)) { 99 if (!globals.overviewStore.has(key)) { 100 globals.overviewStore.set(key, []); 101 } 102 if (value instanceof Array) { 103 globals.overviewStore.get(key)!.push(...value); 104 } else { 105 globals.overviewStore.get(key)!.push(value); 106 } 107 } 108 globals.rafScheduler.scheduleRedraw(); 109 } 110 111 publishTrackData(args: {id: string, data: {}}) { 112 globals.setTrackData(args.id, args.data); 113 if ([LogExistsKey, LogBoundsKey, LogEntriesKey].includes(args.id)) { 114 const data = globals.trackDataStore.get(LogExistsKey) as LogExists; 115 if (data && data.exists) globals.rafScheduler.scheduleFullRedraw(); 116 } else { 117 globals.rafScheduler.scheduleRedraw(); 118 } 119 } 120 121 publishQueryResult(args: {id: string, data: {}}) { 122 globals.queryResults.set(args.id, args.data); 123 this.redraw(); 124 } 125 126 publishThreads(data: ThreadDesc[]) { 127 globals.threads.clear(); 128 data.forEach(thread => { 129 globals.threads.set(thread.utid, thread); 130 }); 131 this.redraw(); 132 } 133 134 publishSliceDetails(click: SliceDetails) { 135 globals.sliceDetails = click; 136 this.redraw(); 137 } 138 139 publishThreadStateDetails(click: ThreadStateDetails) { 140 globals.threadStateDetails = click; 141 this.redraw(); 142 } 143 144 publishConnectedFlows(connectedFlows: Flow[]) { 145 globals.connectedFlows = connectedFlows; 146 // Call resetFlowFocus() each time connectedFlows is updated to correctly 147 // navigate using hotkeys. 148 this.resetFlowFocus(); 149 this.redraw(); 150 } 151 152 // If a chrome slice is selected and we have any flows in connectedFlows 153 // we will find the flows on the right and left of that slice to set a default 154 // focus. In all other cases the focusedFlowId(Left|Right) will be set to -1. 155 resetFlowFocus() { 156 globals.frontendLocalState.focusedFlowIdLeft = -1; 157 globals.frontendLocalState.focusedFlowIdRight = -1; 158 if (globals.state.currentSelection?.kind === 'CHROME_SLICE') { 159 const sliceId = globals.state.currentSelection.id; 160 for (const flow of globals.connectedFlows) { 161 if (flow.begin.sliceId === sliceId) { 162 globals.frontendLocalState.focusedFlowIdRight = flow.id; 163 } 164 if (flow.end.sliceId === sliceId) { 165 globals.frontendLocalState.focusedFlowIdLeft = flow.id; 166 } 167 } 168 } 169 } 170 171 publishSelectedFlows(selectedFlows: Flow[]) { 172 globals.selectedFlows = selectedFlows; 173 this.redraw(); 174 } 175 176 publishCounterDetails(click: CounterDetails) { 177 globals.counterDetails = click; 178 this.redraw(); 179 } 180 181 publishHeapProfileDetails(click: HeapProfileDetails) { 182 globals.heapProfileDetails = click; 183 this.redraw(); 184 } 185 186 publishCpuProfileDetails(details: CpuProfileDetails) { 187 globals.cpuProfileDetails = details; 188 this.redraw(); 189 } 190 191 publishFileDownload(args: {file: File, name?: string}) { 192 const url = URL.createObjectURL(args.file); 193 const a = document.createElement('a'); 194 a.href = url; 195 a.download = args.name !== undefined ? args.name : args.file.name; 196 document.body.appendChild(a); 197 a.click(); 198 document.body.removeChild(a); 199 URL.revokeObjectURL(url); 200 } 201 202 publishLoading(numQueuedQueries: number) { 203 globals.numQueuedQueries = numQueuedQueries; 204 // TODO(hjd): Clean up loadingAnimation given that this now causes a full 205 // redraw anyways. Also this should probably just go via the global state. 206 globals.rafScheduler.scheduleFullRedraw(); 207 } 208 209 // For opening JSON/HTML traces with the legacy catapult viewer. 210 publishLegacyTrace(args: {data: ArrayBuffer, size: number}) { 211 const arr = new Uint8Array(args.data, 0, args.size); 212 const str = (new TextDecoder('utf-8')).decode(arr); 213 openBufferWithLegacyTraceViewer('trace.json', str, 0); 214 globals.dispatch(Actions.clearConversionInProgress({})); 215 } 216 217 publishBufferUsage(args: {percentage: number}) { 218 globals.setBufferUsage(args.percentage); 219 this.redraw(); 220 } 221 222 publishSearch(args: SearchSummary) { 223 globals.searchSummary = args; 224 this.redraw(); 225 } 226 227 publishSearchResult(args: CurrentSearchResults) { 228 globals.currentSearchResults = args; 229 this.redraw(); 230 } 231 232 publishRecordingLog(args: {logs: string}) { 233 globals.setRecordingLog(args.logs); 234 this.redraw(); 235 } 236 237 publishTraceErrors(numErrors: number) { 238 globals.setTraceErrors(numErrors); 239 this.redraw(); 240 } 241 242 publishMetricError(error: string) { 243 globals.setMetricError(error); 244 globals.logging.logError(error, false); 245 this.redraw(); 246 } 247 248 publishMetricResult(metricResult: MetricResult) { 249 globals.setMetricResult(metricResult); 250 this.redraw(); 251 } 252 253 publishAggregateData(args: {data: AggregateData, kind: string}) { 254 globals.setAggregateData(args.kind, args.data); 255 this.redraw(); 256 } 257 258 private redraw(): void { 259 if (globals.state.route && 260 globals.state.route !== this.router.getRouteFromHash()) { 261 this.router.setRouteOnHash(globals.state.route); 262 } 263 264 globals.rafScheduler.scheduleFullRedraw(); 265 } 266} 267 268function setExtensionAvailability(available: boolean) { 269 globals.dispatch(Actions.setExtensionAvailable({ 270 available, 271 })); 272} 273 274function setupContentSecurityPolicy() { 275 // Note: self and sha-xxx must be quoted, urls data: and blob: must not. 276 const policy = { 277 'default-src': [ 278 `'self'`, 279 // Google Tag Manager bootstrap. 280 `'sha256-LirUKeorCU4uRNtNzr8tlB11uy8rzrdmqHCX38JSwHY='`, 281 ], 282 'script-src': [ 283 `'self'`, 284 'https://*.google.com', 285 'https://*.googleusercontent.com', 286 'https://www.googletagmanager.com', 287 'https://www.google-analytics.com', 288 ], 289 'object-src': ['none'], 290 'connect-src': [ 291 `'self'`, 292 'http://127.0.0.1:9001', // For trace_processor_shell --httpd. 293 'https://www.google-analytics.com', 294 'https://*.googleapis.com', // For Google Cloud Storage fetches. 295 'blob:', 296 'data:', 297 ], 298 'img-src': [ 299 `'self'`, 300 'data:', 301 'blob:', 302 'https://www.google-analytics.com', 303 'https://www.googletagmanager.com', 304 ], 305 'navigate-to': ['https://*.perfetto.dev', 'self'], 306 }; 307 const meta = document.createElement('meta'); 308 meta.httpEquiv = 'Content-Security-Policy'; 309 let policyStr = ''; 310 for (const [key, list] of Object.entries(policy)) { 311 policyStr += `${key} ${list.join(' ')}; `; 312 } 313 meta.content = policyStr; 314 document.head.appendChild(meta); 315} 316 317function main() { 318 setupContentSecurityPolicy(); 319 320 // Load the css. The load is asynchronous and the CSS is not ready by the time 321 // appenChild returns. 322 const cssLoadPromise = defer<void>(); 323 const css = document.createElement('link'); 324 css.rel = 'stylesheet'; 325 css.href = globals.root + 'perfetto.css'; 326 css.onload = () => cssLoadPromise.resolve(); 327 css.onerror = (err) => cssLoadPromise.reject(err); 328 const favicon = document.head.querySelector('#favicon') as HTMLLinkElement; 329 if (favicon) favicon.href = globals.root + 'assets/favicon.png'; 330 331 // Load the script to detect if this is a Googler (see comments on globals.ts) 332 // and initialize GA after that (or after a timeout if something goes wrong). 333 const script = document.createElement('script'); 334 script.src = 335 'https://storage.cloud.google.com/perfetto-ui-internal/is_internal_user.js'; 336 script.async = true; 337 script.onerror = () => globals.logging.initialize(); 338 script.onload = () => globals.logging.initialize(); 339 setTimeout(() => globals.logging.initialize(), 5000); 340 341 document.head.append(script, css); 342 343 // Add Error handlers for JS error and for uncaught exceptions in promises. 344 setErrorHandler((err: string) => maybeShowErrorDialog(err)); 345 window.addEventListener('error', e => reportError(e)); 346 window.addEventListener('unhandledrejection', e => reportError(e)); 347 348 const controller = new Worker(globals.root + 'controller_bundle.js'); 349 const frontendChannel = new MessageChannel(); 350 const controllerChannel = new MessageChannel(); 351 const extensionLocalChannel = new MessageChannel(); 352 const errorReportingChannel = new MessageChannel(); 353 354 errorReportingChannel.port2.onmessage = (e) => 355 maybeShowErrorDialog(`${e.data}`); 356 357 controller.postMessage( 358 { 359 frontendPort: frontendChannel.port1, 360 controllerPort: controllerChannel.port1, 361 extensionPort: extensionLocalChannel.port1, 362 errorReportingPort: errorReportingChannel.port1, 363 }, 364 [ 365 frontendChannel.port1, 366 controllerChannel.port1, 367 extensionLocalChannel.port1, 368 errorReportingChannel.port1, 369 ]); 370 371 const dispatch = 372 controllerChannel.port2.postMessage.bind(controllerChannel.port2); 373 globals.initialize(dispatch, controller); 374 globals.serviceWorkerController.install(); 375 376 const router = new Router( 377 '/', 378 { 379 '/': HomePage, 380 '/viewer': ViewerPage, 381 '/record': RecordPage, 382 '/query': AnalyzePage, 383 '/metrics': MetricsPage, 384 '/info': TraceInfoPage, 385 }, 386 dispatch, 387 globals.logging); 388 forwardRemoteCalls(frontendChannel.port2, new FrontendApi(router)); 389 390 // We proxy messages between the extension and the controller because the 391 // controller's worker can't access chrome.runtime. 392 const extensionPort = window.chrome && chrome.runtime ? 393 chrome.runtime.connect(EXTENSION_ID) : 394 undefined; 395 396 setExtensionAvailability(extensionPort !== undefined); 397 398 if (extensionPort) { 399 extensionPort.onDisconnect.addListener(_ => { 400 setExtensionAvailability(false); 401 // tslint:disable-next-line: no-unused-expression 402 void chrome.runtime.lastError; // Needed to not receive an error log. 403 }); 404 // This forwards the messages from the extension to the controller. 405 extensionPort.onMessage.addListener( 406 (message: object, _port: chrome.runtime.Port) => { 407 extensionLocalChannel.port2.postMessage(message); 408 }); 409 } 410 411 // This forwards the messages from the controller to the extension 412 extensionLocalChannel.port2.onmessage = ({data}) => { 413 if (extensionPort) extensionPort.postMessage(data); 414 }; 415 416 // Put these variables in the global scope for better debugging. 417 (window as {} as {m: {}}).m = m; 418 (window as {} as {globals: {}}).globals = globals; 419 (window as {} as {Actions: {}}).Actions = Actions; 420 421 // Prevent pinch zoom. 422 document.body.addEventListener('wheel', (e: MouseEvent) => { 423 if (e.ctrlKey) e.preventDefault(); 424 }, {passive: false}); 425 426 cssLoadPromise.then(() => onCssLoaded(router)); 427} 428 429function onCssLoaded(router: Router) { 430 initCssConstants(); 431 // Clear all the contents of the initial page (e.g. the <pre> error message) 432 // And replace it with the root <main> element which will be used by mithril. 433 document.body.innerHTML = '<main></main>'; 434 const main = assertExists(document.body.querySelector('main')); 435 globals.rafScheduler.domRedraw = () => 436 m.render(main, m(router.resolve(globals.state.route))); 437 438 router.navigateToCurrentHash(); 439 440 // /?s=xxxx for permalinks. 441 const stateHash = Router.param('s'); 442 const urlHash = Router.param('url'); 443 const androidBugTool = Router.param('openFromAndroidBugTool'); 444 if (typeof stateHash === 'string' && stateHash) { 445 globals.dispatch(Actions.loadPermalink({ 446 hash: stateHash, 447 })); 448 } else if (typeof urlHash === 'string' && urlHash) { 449 if (isLocalhostTraceUrl(urlHash)) { 450 const fileName = urlHash.split('/').pop() || 'local_trace.pftrace'; 451 const request = fetch(urlHash) 452 .then(response => response.blob()) 453 .then(blob => { 454 globals.dispatch(Actions.openTraceFromFile({ 455 file: new File([blob], fileName), 456 })); 457 }) 458 .catch(e => alert(`Could not load local trace ${e}`)); 459 taskTracker.trackPromise(request, 'Downloading local trace'); 460 } else { 461 globals.dispatch(Actions.openTraceFromUrl({ 462 url: urlHash, 463 })); 464 } 465 } else if (androidBugTool) { 466 // TODO(hjd): Unify updateStatus and TaskTracker 467 globals.dispatch(Actions.updateStatus({ 468 msg: 'Loading trace from ABT extension', 469 timestamp: Date.now() / 1000 470 })); 471 const loadInfo = loadAndroidBugToolInfo(); 472 taskTracker.trackPromise(loadInfo, 'Loading trace from ABT extension'); 473 loadInfo 474 .then(info => { 475 globals.dispatch(Actions.openTraceFromFile({ 476 file: info.file, 477 })); 478 }) 479 .catch(e => { 480 console.error(e); 481 }); 482 } 483 484 // Add support for opening traces from postMessage(). 485 window.addEventListener('message', postMessageHandler, {passive: true}); 486 487 // Will update the chip on the sidebar footer that notifies that the RPC is 488 // connected. Has no effect on the controller (which will repeat this check 489 // before creating a new engine). 490 CheckHttpRpcConnection(); 491 initLiveReloadIfLocalhost(); 492 493 updateAvailableAdbDevices(); 494 try { 495 navigator.usb.addEventListener( 496 'connect', () => updateAvailableAdbDevices()); 497 navigator.usb.addEventListener( 498 'disconnect', () => updateAvailableAdbDevices()); 499 } catch (e) { 500 console.error('WebUSB API not supported'); 501 } 502 installFileDropHandler(); 503} 504 505main(); 506