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