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 MicroModal from 'micromodal';
19import * as m from 'mithril';
20
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 {maybeShowErrorDialog} from './error_dialog';
37import {
38  CounterDetails,
39  CpuProfileDetails,
40  Flow,
41  globals,
42  HeapProfileDetails,
43  QuantizedLoad,
44  SliceDetails,
45  ThreadDesc,
46  ThreadStateDetails
47} from './globals';
48import {HomePage} from './home_page';
49import {openBufferWithLegacyTraceViewer} from './legacy_trace_viewer';
50import {MetricsPage} from './metrics_page';
51import {postMessageHandler} from './post_message_handler';
52import {RecordPage, updateAvailableAdbDevices} from './record_page';
53import {Router} from './router';
54import {CheckHttpRpcConnection} from './rpc_http_dialog';
55import {taskTracker} from './task_tracker';
56import {TraceInfoPage} from './trace_info_page';
57import {ViewerPage} from './viewer_page';
58
59const EXTENSION_ID = 'lfmkphfpdbjijhpomgecfikhfohaoine';
60
61/**
62 * The API the main thread exposes to the controller.
63 */
64class FrontendApi {
65  constructor(private router: Router) {}
66
67  patchState(patches: Patch[]) {
68    const oldState = globals.state;
69    globals.state = applyPatches(globals.state, patches);
70
71    // If the visible time in the global state has been updated more recently
72    // than the visible time handled by the frontend @ 60fps, update it. This
73    // typically happens when restoring the state from a permalink.
74    globals.frontendLocalState.mergeState(globals.state.frontendLocalState);
75
76    // Only redraw if something other than the frontendLocalState changed.
77    for (const key in globals.state) {
78      if (key !== 'frontendLocalState' && key !== 'visibleTracks' &&
79          oldState[key] !== globals.state[key]) {
80        this.redraw();
81        return;
82      }
83    }
84  }
85
86  // TODO: we can't have a publish method for each batch of data that we don't
87  // want to keep in the global state. Figure out a more generic and type-safe
88  // mechanism to achieve this.
89
90  publishOverviewData(data: {[key: string]: QuantizedLoad|QuantizedLoad[]}) {
91    for (const [key, value] of Object.entries(data)) {
92      if (!globals.overviewStore.has(key)) {
93        globals.overviewStore.set(key, []);
94      }
95      if (value instanceof Array) {
96        globals.overviewStore.get(key)!.push(...value);
97      } else {
98        globals.overviewStore.get(key)!.push(value);
99      }
100    }
101    globals.rafScheduler.scheduleRedraw();
102  }
103
104  publishTrackData(args: {id: string, data: {}}) {
105    globals.setTrackData(args.id, args.data);
106    if ([LogExistsKey, LogBoundsKey, LogEntriesKey].includes(args.id)) {
107      const data = globals.trackDataStore.get(LogExistsKey) as LogExists;
108      if (data && data.exists) globals.rafScheduler.scheduleFullRedraw();
109    } else {
110      globals.rafScheduler.scheduleRedraw();
111    }
112  }
113
114  publishQueryResult(args: {id: string, data: {}}) {
115    globals.queryResults.set(args.id, args.data);
116    this.redraw();
117  }
118
119  publishThreads(data: ThreadDesc[]) {
120    globals.threads.clear();
121    data.forEach(thread => {
122      globals.threads.set(thread.utid, thread);
123    });
124    this.redraw();
125  }
126
127  publishSliceDetails(click: SliceDetails) {
128    globals.sliceDetails = click;
129    this.redraw();
130  }
131
132  publishThreadStateDetails(click: ThreadStateDetails) {
133    globals.threadStateDetails = click;
134    this.redraw();
135  }
136
137  publishConnectedFlows(connectedFlows: Flow[]) {
138    globals.connectedFlows = connectedFlows;
139    // Call resetFlowFocus() each time connectedFlows is updated to correctly
140    // navigate using hotkeys.
141    this.resetFlowFocus();
142    this.redraw();
143  }
144
145  // If a chrome slice is selected and we have any flows in connectedFlows
146  // we will find the flows on the right and left of that slice to set a default
147  // focus. In all other cases the focusedFlowId(Left|Right) will be set to -1.
148  resetFlowFocus() {
149    globals.frontendLocalState.focusedFlowIdLeft = -1;
150    globals.frontendLocalState.focusedFlowIdRight = -1;
151    if (globals.state.currentSelection?.kind === 'CHROME_SLICE') {
152      const sliceId = globals.state.currentSelection.id;
153      for (const flow of globals.connectedFlows) {
154        if (flow.begin.sliceId === sliceId) {
155          globals.frontendLocalState.focusedFlowIdRight = flow.id;
156        }
157        if (flow.end.sliceId === sliceId) {
158          globals.frontendLocalState.focusedFlowIdLeft = flow.id;
159        }
160      }
161    }
162  }
163
164  publishSelectedFlows(selectedFlows: Flow[]) {
165    globals.selectedFlows = selectedFlows;
166    this.redraw();
167  }
168
169  publishCounterDetails(click: CounterDetails) {
170    globals.counterDetails = click;
171    this.redraw();
172  }
173
174  publishHeapProfileDetails(click: HeapProfileDetails) {
175    globals.heapProfileDetails = click;
176    this.redraw();
177  }
178
179  publishCpuProfileDetails(details: CpuProfileDetails) {
180    globals.cpuProfileDetails = details;
181    this.redraw();
182  }
183
184  publishFileDownload(args: {file: File, name?: string}) {
185    const url = URL.createObjectURL(args.file);
186    const a = document.createElement('a');
187    a.href = url;
188    a.download = args.name !== undefined ? args.name : args.file.name;
189    document.body.appendChild(a);
190    a.click();
191    document.body.removeChild(a);
192    URL.revokeObjectURL(url);
193  }
194
195  publishLoading(numQueuedQueries: number) {
196    globals.numQueuedQueries = numQueuedQueries;
197    // TODO(hjd): Clean up loadingAnimation given that this now causes a full
198    // redraw anyways. Also this should probably just go via the global state.
199    globals.rafScheduler.scheduleFullRedraw();
200  }
201
202  // For opening JSON/HTML traces with the legacy catapult viewer.
203  publishLegacyTrace(args: {data: ArrayBuffer, size: number}) {
204    const arr = new Uint8Array(args.data, 0, args.size);
205    const str = (new TextDecoder('utf-8')).decode(arr);
206    openBufferWithLegacyTraceViewer('trace.json', str, 0);
207  }
208
209  publishBufferUsage(args: {percentage: number}) {
210    globals.setBufferUsage(args.percentage);
211    this.redraw();
212  }
213
214  publishSearch(args: SearchSummary) {
215    globals.searchSummary = args;
216    this.redraw();
217  }
218
219  publishSearchResult(args: CurrentSearchResults) {
220    globals.currentSearchResults = args;
221    this.redraw();
222  }
223
224  publishRecordingLog(args: {logs: string}) {
225    globals.setRecordingLog(args.logs);
226    this.redraw();
227  }
228
229  publishTraceErrors(numErrors: number) {
230    globals.setTraceErrors(numErrors);
231    this.redraw();
232  }
233
234  publishMetricError(error: string) {
235    globals.setMetricError(error);
236    globals.logging.logError(error, false);
237    this.redraw();
238  }
239
240  publishMetricResult(metricResult: MetricResult) {
241    globals.setMetricResult(metricResult);
242    this.redraw();
243  }
244
245  publishAggregateData(args: {data: AggregateData, kind: string}) {
246    globals.setAggregateData(args.kind, args.data);
247    this.redraw();
248  }
249
250  private redraw(): void {
251    if (globals.state.route &&
252        globals.state.route !== this.router.getRouteFromHash()) {
253      this.router.setRouteOnHash(globals.state.route);
254    }
255
256    globals.rafScheduler.scheduleFullRedraw();
257  }
258}
259
260function setExtensionAvailability(available: boolean) {
261  globals.dispatch(Actions.setExtensionAvailable({
262    available,
263  }));
264}
265
266function main() {
267  // Add Error handlers for JS error and for uncaught exceptions in promises.
268  setErrorHandler((err: string) => maybeShowErrorDialog(err));
269  window.addEventListener('error', e => reportError(e));
270  window.addEventListener('unhandledrejection', e => reportError(e));
271
272  const controller = new Worker('controller_bundle.js');
273  const frontendChannel = new MessageChannel();
274  const controllerChannel = new MessageChannel();
275  const extensionLocalChannel = new MessageChannel();
276  const errorReportingChannel = new MessageChannel();
277
278  errorReportingChannel.port2.onmessage = (e) =>
279      maybeShowErrorDialog(`${e.data}`);
280
281  controller.postMessage(
282      {
283        frontendPort: frontendChannel.port1,
284        controllerPort: controllerChannel.port1,
285        extensionPort: extensionLocalChannel.port1,
286        errorReportingPort: errorReportingChannel.port1,
287      },
288      [
289        frontendChannel.port1,
290        controllerChannel.port1,
291        extensionLocalChannel.port1,
292        errorReportingChannel.port1,
293      ]);
294
295  const dispatch =
296      controllerChannel.port2.postMessage.bind(controllerChannel.port2);
297  globals.initialize(dispatch, controller);
298  globals.serviceWorkerController.install();
299
300  const router = new Router(
301      '/',
302      {
303        '/': HomePage,
304        '/viewer': ViewerPage,
305        '/record': RecordPage,
306        '/query': AnalyzePage,
307        '/metrics': MetricsPage,
308        '/info': TraceInfoPage,
309      },
310      dispatch,
311      globals.logging);
312  forwardRemoteCalls(frontendChannel.port2, new FrontendApi(router));
313
314  // We proxy messages between the extension and the controller because the
315  // controller's worker can't access chrome.runtime.
316  const extensionPort = window.chrome && chrome.runtime ?
317      chrome.runtime.connect(EXTENSION_ID) :
318      undefined;
319
320  setExtensionAvailability(extensionPort !== undefined);
321
322  if (extensionPort) {
323    extensionPort.onDisconnect.addListener(_ => {
324      setExtensionAvailability(false);
325      // tslint:disable-next-line: no-unused-expression
326      void chrome.runtime.lastError;  // Needed to not receive an error log.
327    });
328    // This forwards the messages from the extension to the controller.
329    extensionPort.onMessage.addListener(
330        (message: object, _port: chrome.runtime.Port) => {
331          extensionLocalChannel.port2.postMessage(message);
332        });
333  }
334
335  updateAvailableAdbDevices();
336  try {
337    navigator.usb.addEventListener(
338        'connect', () => updateAvailableAdbDevices());
339    navigator.usb.addEventListener(
340        'disconnect', () => updateAvailableAdbDevices());
341  } catch (e) {
342    console.error('WebUSB API not supported');
343  }
344  // This forwards the messages from the controller to the extension
345  extensionLocalChannel.port2.onmessage = ({data}) => {
346    if (extensionPort) extensionPort.postMessage(data);
347  };
348  const main = assertExists(document.body.querySelector('main'));
349
350  globals.rafScheduler.domRedraw = () =>
351      m.render(main, m(router.resolve(globals.state.route)));
352
353  // Add support for opening traces from postMessage().
354  window.addEventListener('message', postMessageHandler, {passive: true});
355
356  // Put these variables in the global scope for better debugging.
357  (window as {} as {m: {}}).m = m;
358  (window as {} as {globals: {}}).globals = globals;
359  (window as {} as {Actions: {}}).Actions = Actions;
360
361  // /?s=xxxx for permalinks.
362  const stateHash = Router.param('s');
363  const urlHash = Router.param('url');
364  const androidBugTool = Router.param('openFromAndroidBugTool');
365  if (typeof stateHash === 'string' && stateHash) {
366    globals.dispatch(Actions.loadPermalink({
367      hash: stateHash,
368    }));
369  } else if (typeof urlHash === 'string' && urlHash) {
370    globals.dispatch(Actions.openTraceFromUrl({
371      url: urlHash,
372    }));
373  } else if (androidBugTool) {
374    // TODO(hjd): Unify updateStatus and TaskTracker
375    globals.dispatch(Actions.updateStatus({
376      msg: 'Loading trace from ABT extension',
377      timestamp: Date.now() / 1000
378    }));
379    const loadInfo = loadAndroidBugToolInfo();
380    taskTracker.trackPromise(loadInfo, 'Loading trace from ABT extension');
381    loadInfo
382        .then(info => {
383          globals.dispatch(Actions.openTraceFromFile({
384            file: info.file,
385          }));
386        })
387        .catch(e => {
388          console.error(e);
389        });
390  }
391
392  // Prevent pinch zoom.
393  document.body.addEventListener('wheel', (e: MouseEvent) => {
394    if (e.ctrlKey) e.preventDefault();
395  }, {passive: false});
396
397  router.navigateToCurrentHash();
398
399  MicroModal.init();
400
401  // Will update the chip on the sidebar footer that notifies that the RPC is
402  // connected. Has no effect on the controller (which will repeat this check
403  // before creating a new engine).
404  CheckHttpRpcConnection();
405}
406
407main();
408