• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright (C) 2020 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 {isString} from '../base/object_utils';
16import {RecordConfig} from '../controller/record_config_types';
17
18export const BUCKET_NAME = 'perfetto-ui-data';
19import {v4 as uuidv4} from 'uuid';
20import {State} from './state';
21import {defer} from '../base/deferred';
22import {Time} from '../base/time';
23
24export class TraceGcsUploader {
25  state: 'UPLOADING' | 'UPLOADED' | 'ERROR' = 'UPLOADING';
26  error = '';
27  totalSize = 0;
28  uploadedSize = 0;
29  uploadedUrl = '';
30  onProgress: () => void;
31  private req: XMLHttpRequest;
32  private reqUrl: string;
33  private donePromise = defer<void>();
34  private startTime = performance.now();
35
36  constructor(trace: File | ArrayBuffer, onProgress?: () => void) {
37    // TODO(hjd): This should probably also be a hash but that requires
38    // trace processor support.
39    const name = uuidv4();
40    this.uploadedUrl = `https://storage.googleapis.com/${BUCKET_NAME}/${name}`;
41    this.reqUrl =
42      'https://www.googleapis.com/upload/storage/v1/b/' +
43      `${BUCKET_NAME}/o?uploadType=media` +
44      `&name=${name}&predefinedAcl=publicRead`;
45    this.onProgress = onProgress || (() => {});
46    this.req = new XMLHttpRequest();
47    this.req.onabort = (e: ProgressEvent) => this.onRpcEvent(e);
48    this.req.onerror = (e: ProgressEvent) => this.onRpcEvent(e);
49    this.req.upload.onprogress = (e: ProgressEvent) => this.onRpcEvent(e);
50    this.req.onloadend = (e: ProgressEvent) => this.onRpcEvent(e);
51    this.req.open('POST', this.reqUrl);
52    this.req.setRequestHeader('Content-Type', 'application/octet-stream');
53    this.req.send(trace);
54  }
55
56  waitForCompletion(): Promise<void> {
57    return this.donePromise;
58  }
59
60  abort() {
61    if (this.state === 'UPLOADING') {
62      this.req.abort();
63    }
64  }
65
66  getEtaString() {
67    let str = `${Math.ceil((100 * this.uploadedSize) / this.totalSize)}%`;
68    str += ` (${(this.uploadedSize / 1e6).toFixed(2)} MB)`;
69    const elapsed = (performance.now() - this.startTime) / 1000;
70    const rate = this.uploadedSize / elapsed;
71    const etaSecs = Math.round((this.totalSize - this.uploadedSize) / rate);
72    str += ' - ETA: ' + Time.toTimecode(Time.fromSeconds(etaSecs)).dhhmmss;
73    return str;
74  }
75
76  private onRpcEvent(e: ProgressEvent) {
77    let done = false;
78    switch (e.type) {
79      case 'progress':
80        this.uploadedSize = e.loaded;
81        this.totalSize = e.total;
82        break;
83      case 'abort':
84        this.state = 'ERROR';
85        this.error = 'Upload aborted';
86        break;
87      case 'error':
88        this.state = 'ERROR';
89        this.error = `${this.req.status} - ${this.req.statusText}`;
90        break;
91      case 'loadend':
92        done = true;
93        if (this.req.status === 200) {
94          this.state = 'UPLOADED';
95        } else if (this.state === 'UPLOADING') {
96          this.state = 'ERROR';
97          this.error = `${this.req.status} - ${this.req.statusText}`;
98        }
99        break;
100      default:
101        return;
102    }
103    this.onProgress();
104    if (done) {
105      this.donePromise.resolve();
106    }
107  }
108}
109
110// Bigint's are not serializable using JSON.stringify, so we use a special
111// object when serialising
112export type SerializedBigint = {
113  __kind: 'bigint';
114  value: string;
115};
116
117// Check if a value looks like a serialized bigint
118export function isSerializedBigint(value: unknown): value is SerializedBigint {
119  if (value === null) {
120    return false;
121  }
122  if (typeof value !== 'object') {
123    return false;
124  }
125  if ('__kind' in value && 'value' in value) {
126    return value.__kind === 'bigint' && isString(value.value);
127  }
128  return false;
129}
130
131export function serializeStateObject(object: unknown): string {
132  const json = JSON.stringify(object, (key, value) => {
133    if (typeof value === 'bigint') {
134      return {
135        __kind: 'bigint',
136        value: value.toString(),
137      };
138    }
139    return key === 'nonSerializableState' ? undefined : value;
140  });
141  return json;
142}
143
144export function deserializeStateObject<T>(json: string): T {
145  const object = JSON.parse(json, (_key, value) => {
146    if (isSerializedBigint(value)) {
147      return BigInt(value.value);
148    }
149    return value;
150  });
151  return object as T;
152}
153
154export async function saveState(
155  stateOrConfig: State | RecordConfig,
156): Promise<string> {
157  const text = serializeStateObject(stateOrConfig);
158  const hash = await toSha256(text);
159  const url =
160    'https://www.googleapis.com/upload/storage/v1/b/' +
161    `${BUCKET_NAME}/o?uploadType=media` +
162    `&name=${hash}&predefinedAcl=publicRead`;
163  const response = await fetch(url, {
164    method: 'post',
165    headers: {
166      'Content-Type': 'application/json; charset=utf-8',
167    },
168    body: text,
169  });
170  await response.json();
171  return hash;
172}
173
174// This has a bug:
175// x.toString(16) doesn't zero pad so if the digest is:
176// [23, 7, 42, ...]
177// You get:
178// ['17', '7', '2a', ...] = 1772a...
179// Rather than:
180// ['17', '07', '2a', ...] = 17072a...
181// As you ought to (and as the hexdigest is computed by e.g. Python).
182// Unfortunately there are a lot of old permalinks out there so we
183// still need this broken implementation to check their hashes.
184export async function buggyToSha256(str: string): Promise<string> {
185  const buffer = new TextEncoder().encode(str);
186  const digest = await crypto.subtle.digest('SHA-256', buffer);
187  return Array.from(new Uint8Array(digest))
188    .map((x) => x.toString(16))
189    .join('');
190}
191
192export async function toSha256(str: string): Promise<string> {
193  const buffer = new TextEncoder().encode(str);
194  const digest = await crypto.subtle.digest('SHA-256', buffer);
195  return Array.from(new Uint8Array(digest))
196    .map((x) => x.toString(16).padStart(2, '0'))
197    .join('');
198}
199