|  | // Copyright (C) 2019 The Android Open Source Project | 
|  | // | 
|  | // Licensed under the Apache License, Version 2.0 (the "License"); | 
|  | // you may not use this file except in compliance with the License. | 
|  | // You may obtain a copy of the License at | 
|  | // | 
|  | //      http://www.apache.org/licenses/LICENSE-2.0 | 
|  | // | 
|  | // Unless required by applicable law or agreed to in writing, software | 
|  | // distributed under the License is distributed on an "AS IS" BASIS, | 
|  | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | 
|  | // See the License for the specific language governing permissions and | 
|  | // limitations under the License. | 
|  |  | 
|  | import {Protocol} from 'devtools-protocol'; | 
|  | import {ProtocolProxyApi} from 'devtools-protocol/types/protocol-proxy-api'; | 
|  | import {Client} from 'noice-json-rpc'; | 
|  |  | 
|  | import {base64Encode} from '../base/string_utils'; | 
|  | import { | 
|  | ConsumerPortResponse, | 
|  | GetTraceStatsResponse, | 
|  | ReadBuffersResponse, | 
|  | } from '../controller/consumer_port_types'; | 
|  | import {RpcConsumerPort} from '../controller/record_controller_interfaces'; | 
|  | import { | 
|  | browserSupportsPerfettoConfig, | 
|  | extractTraceConfig, | 
|  | hasSystemDataSourceConfig, | 
|  | } from '../core/trace_config_utils'; | 
|  | import {ITraceStats, TraceConfig} from '../protos'; | 
|  |  | 
|  | import {DevToolsSocket} from './devtools_socket'; | 
|  |  | 
|  | const CHUNK_SIZE: number = 1024 * 1024 * 16;  // 16Mb | 
|  |  | 
|  | export class ChromeTracingController extends RpcConsumerPort { | 
|  | private streamHandle: string|undefined = undefined; | 
|  | private uiPort: chrome.runtime.Port; | 
|  | private api: ProtocolProxyApi.ProtocolApi; | 
|  | private devtoolsSocket: DevToolsSocket; | 
|  | private lastBufferUsageEvent: Protocol.Tracing.BufferUsageEvent|undefined; | 
|  | private tracingSessionOngoing = false; | 
|  | private tracingSessionId = 0; | 
|  |  | 
|  | constructor(port: chrome.runtime.Port) { | 
|  | super({ | 
|  | onConsumerPortResponse: (message: ConsumerPortResponse) => | 
|  | this.uiPort.postMessage(message), | 
|  |  | 
|  | onError: (error: string) => | 
|  | this.uiPort.postMessage({type: 'ChromeExtensionError', error}), | 
|  |  | 
|  | onStatus: (status) => | 
|  | this.uiPort.postMessage({type: 'ChromeExtensionStatus', status}), | 
|  | }); | 
|  | this.uiPort = port; | 
|  | this.devtoolsSocket = new DevToolsSocket(); | 
|  | this.devtoolsSocket.on('close', () => this.resetState()); | 
|  | const rpcClient = new Client(this.devtoolsSocket); | 
|  | this.api = rpcClient.api(); | 
|  | this.api.Tracing.on('tracingComplete', this.onTracingComplete.bind(this)); | 
|  | this.api.Tracing.on('bufferUsage', this.onBufferUsage.bind(this)); | 
|  | this.uiPort.onDisconnect.addListener(() => { | 
|  | this.devtoolsSocket.detach(); | 
|  | }); | 
|  | } | 
|  |  | 
|  | handleCommand(methodName: string, requestData: Uint8Array) { | 
|  | switch (methodName) { | 
|  | case 'EnableTracing': | 
|  | this.enableTracing(requestData); | 
|  | break; | 
|  | case 'FreeBuffers': | 
|  | this.freeBuffers(); | 
|  | break; | 
|  | case 'ReadBuffers': | 
|  | this.readBuffers(); | 
|  | break; | 
|  | case 'DisableTracing': | 
|  | this.disableTracing(); | 
|  | break; | 
|  | case 'GetTraceStats': | 
|  | this.getTraceStats(); | 
|  | break; | 
|  | case 'GetCategories': | 
|  | this.getCategories(); | 
|  | break; | 
|  | default: | 
|  | this.sendErrorMessage('Action not recognized'); | 
|  | console.log('Received not recognized message: ', methodName); | 
|  | break; | 
|  | } | 
|  | } | 
|  |  | 
|  | enableTracing(enableTracingRequest: Uint8Array) { | 
|  | this.resetState(); | 
|  | const traceConfigProto = extractTraceConfig(enableTracingRequest); | 
|  | if (!traceConfigProto) { | 
|  | this.sendErrorMessage('Invalid trace config'); | 
|  | return; | 
|  | } | 
|  |  | 
|  | this.handleStartTracing(traceConfigProto); | 
|  | } | 
|  |  | 
|  | toCamelCase(key: string, separator: string): string { | 
|  | return key.split(separator) | 
|  | .map((part, index) => { | 
|  | return (index === 0) ? part : part[0].toUpperCase() + part.slice(1); | 
|  | }) | 
|  | .join(''); | 
|  | } | 
|  |  | 
|  | // eslint-disable-next-line @typescript-eslint/no-explicit-any | 
|  | convertDictKeys(obj: any): any { | 
|  | if (Array.isArray(obj)) { | 
|  | return obj.map((v) => this.convertDictKeys(v)); | 
|  | } | 
|  | if (typeof obj === 'object' && obj !== null) { | 
|  | // eslint-disable-next-line @typescript-eslint/no-explicit-any | 
|  | const converted: any = {}; | 
|  | for (const key of Object.keys(obj)) { | 
|  | converted[this.toCamelCase(key, '_')] = this.convertDictKeys(obj[key]); | 
|  | } | 
|  | return converted; | 
|  | } | 
|  | return obj; | 
|  | } | 
|  |  | 
|  | convertToDevToolsConfig(config: unknown): Protocol.Tracing.TraceConfig { | 
|  | // DevTools uses a different naming style for config properties: Dictionary | 
|  | // keys are named "camelCase" style, rather than "underscore_case" style as | 
|  | // in the TraceConfig. | 
|  | const convertedConfig = this.convertDictKeys(config); | 
|  | // recordMode is specified as an enum with camelCase values. | 
|  | if (convertedConfig.recordMode) { | 
|  | convertedConfig.recordMode = | 
|  | this.toCamelCase(convertedConfig.recordMode as string, '-'); | 
|  | } | 
|  | return convertedConfig as Protocol.Tracing.TraceConfig; | 
|  | } | 
|  |  | 
|  | // TODO(nicomazz): write unit test for this | 
|  | extractChromeConfig(perfettoConfig: TraceConfig): | 
|  | Protocol.Tracing.TraceConfig { | 
|  | for (const ds of perfettoConfig.dataSources) { | 
|  | if (ds.config && ds.config.name === 'org.chromium.trace_event' && | 
|  | ds.config.chromeConfig && ds.config.chromeConfig.traceConfig) { | 
|  | const chromeConfigJsonString = ds.config.chromeConfig.traceConfig; | 
|  | const config = JSON.parse(chromeConfigJsonString); | 
|  | return this.convertToDevToolsConfig(config); | 
|  | } | 
|  | } | 
|  | return {}; | 
|  | } | 
|  |  | 
|  | freeBuffers() { | 
|  | this.devtoolsSocket.detach(); | 
|  | this.sendMessage({type: 'FreeBuffersResponse'}); | 
|  | } | 
|  |  | 
|  | async readBuffers(offset = 0) { | 
|  | if (!this.devtoolsSocket.isAttached() || this.streamHandle === undefined) { | 
|  | this.sendErrorMessage('No tracing session to read from'); | 
|  | return; | 
|  | } | 
|  |  | 
|  | const res = await this.api.IO.read( | 
|  | {handle: this.streamHandle, offset, size: CHUNK_SIZE}); | 
|  | if (res === undefined) return; | 
|  |  | 
|  | const chunk = res.base64Encoded ? atob(res.data) : res.data; | 
|  | // The 'as {} as UInt8Array' is done because we can't send ArrayBuffers | 
|  | // trough a chrome.runtime.Port. The conversion from string to ArrayBuffer | 
|  | // takes place on the other side of the port. | 
|  | const response: ReadBuffersResponse = { | 
|  | type: 'ReadBuffersResponse', | 
|  | slices: [{data: chunk as {} as Uint8Array, lastSliceForPacket: res.eof}], | 
|  | }; | 
|  | this.sendMessage(response); | 
|  | if (res.eof) return; | 
|  | this.readBuffers(offset + chunk.length); | 
|  | } | 
|  |  | 
|  | async disableTracing() { | 
|  | await this.endTracing(this.tracingSessionId); | 
|  | this.sendMessage({type: 'DisableTracingResponse'}); | 
|  | } | 
|  |  | 
|  | async endTracing(tracingSessionId: number) { | 
|  | if (tracingSessionId !== this.tracingSessionId) { | 
|  | return; | 
|  | } | 
|  | if (this.tracingSessionOngoing) { | 
|  | await this.api.Tracing.end(); | 
|  | } | 
|  | this.tracingSessionOngoing = false; | 
|  | } | 
|  |  | 
|  | getTraceStats() { | 
|  | // If the statistics are not available yet, it is 0. | 
|  | const percentFull = this.lastBufferUsageEvent?.percentFull ?? 0; | 
|  | const stats: ITraceStats = { | 
|  | bufferStats: | 
|  | [{bufferSize: 1000, bytesWritten: Math.round(percentFull * 1000)}], | 
|  | }; | 
|  | const response: GetTraceStatsResponse = { | 
|  | type: 'GetTraceStatsResponse', | 
|  | traceStats: stats, | 
|  | }; | 
|  | this.sendMessage(response); | 
|  | } | 
|  |  | 
|  | getCategories() { | 
|  | const fetchCategories = async () => { | 
|  | const categories = (await this.api.Tracing.getCategories()).categories; | 
|  | this.uiPort.postMessage({type: 'GetCategoriesResponse', categories}); | 
|  | }; | 
|  | // If a target is already attached, we simply fetch the categories. | 
|  | if (this.devtoolsSocket.isAttached()) { | 
|  | fetchCategories(); | 
|  | return; | 
|  | } | 
|  | // Otherwise, we attach temporarily. | 
|  | this.devtoolsSocket.attachToBrowser(async (error?: string) => { | 
|  | if (error) { | 
|  | this.sendErrorMessage( | 
|  | `Could not attach to DevTools browser target ` + | 
|  | `(req. Chrome >= M81): ${error}`); | 
|  | return; | 
|  | } | 
|  | fetchCategories(); | 
|  | this.devtoolsSocket.detach(); | 
|  | }); | 
|  | } | 
|  |  | 
|  | resetState() { | 
|  | this.devtoolsSocket.detach(); | 
|  | this.streamHandle = undefined; | 
|  | } | 
|  |  | 
|  | onTracingComplete(params: Protocol.Tracing.TracingCompleteEvent) { | 
|  | this.streamHandle = params.stream; | 
|  | this.sendMessage({type: 'EnableTracingResponse'}); | 
|  | } | 
|  |  | 
|  | onBufferUsage(params: Protocol.Tracing.BufferUsageEvent) { | 
|  | this.lastBufferUsageEvent = params; | 
|  | } | 
|  |  | 
|  | handleStartTracing(traceConfigProto: Uint8Array) { | 
|  | this.devtoolsSocket.attachToBrowser(async (error?: string) => { | 
|  | if (error) { | 
|  | this.sendErrorMessage( | 
|  | `Could not attach to DevTools browser target ` + | 
|  | `(req. Chrome >= M81): ${error}`); | 
|  | return; | 
|  | } | 
|  |  | 
|  | const requestParams: Protocol.Tracing.StartRequest = { | 
|  | streamFormat: 'proto', | 
|  | transferMode: 'ReturnAsStream', | 
|  | streamCompression: 'gzip', | 
|  | bufferUsageReportingInterval: 200, | 
|  | }; | 
|  |  | 
|  | const traceConfig = TraceConfig.decode(traceConfigProto); | 
|  | if (browserSupportsPerfettoConfig()) { | 
|  | const configEncoded = base64Encode(traceConfigProto); | 
|  | await this.api.Tracing.start( | 
|  | {perfettoConfig: configEncoded, ...requestParams}); | 
|  | this.tracingSessionOngoing = true; | 
|  | const tracingSessionId = ++this.tracingSessionId; | 
|  | setTimeout( | 
|  | () => this.endTracing(tracingSessionId), traceConfig.durationMs); | 
|  | } else { | 
|  | console.log( | 
|  | 'Used Chrome version is too old to support ' + | 
|  | 'perfettoConfig parameter. Using chrome config only instead.'); | 
|  |  | 
|  | if (hasSystemDataSourceConfig(traceConfig)) { | 
|  | this.sendErrorMessage( | 
|  | 'System tracing is not supported by this Chrome version. Choose' + | 
|  | ' the \'Chrome\' target instead to record a Chrome-only trace.'); | 
|  | return; | 
|  | } | 
|  |  | 
|  | const chromeConfig = this.extractChromeConfig(traceConfig); | 
|  | await this.api.Tracing.start( | 
|  | {traceConfig: chromeConfig, ...requestParams}); | 
|  | } | 
|  | }); | 
|  | } | 
|  | } |