• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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