| // Copyright (C) 2020 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 {isString} from '../base/object_utils'; |
| import {RecordConfig} from '../controller/record_config_types'; |
| |
| export const BUCKET_NAME = 'perfetto-ui-data'; |
| import {v4 as uuidv4} from 'uuid'; |
| import {State} from './state'; |
| import {defer} from '../base/deferred'; |
| import {Time} from '../base/time'; |
| |
| export class TraceGcsUploader { |
| state: 'UPLOADING' | 'UPLOADED' | 'ERROR' = 'UPLOADING'; |
| error = ''; |
| totalSize = 0; |
| uploadedSize = 0; |
| uploadedUrl = ''; |
| onProgress: () => void; |
| private req: XMLHttpRequest; |
| private reqUrl: string; |
| private donePromise = defer<void>(); |
| private startTime = performance.now(); |
| |
| constructor(trace: File | ArrayBuffer, onProgress?: () => void) { |
| // TODO(hjd): This should probably also be a hash but that requires |
| // trace processor support. |
| const name = uuidv4(); |
| this.uploadedUrl = `https://storage.googleapis.com/${BUCKET_NAME}/${name}`; |
| this.reqUrl = |
| 'https://www.googleapis.com/upload/storage/v1/b/' + |
| `${BUCKET_NAME}/o?uploadType=media` + |
| `&name=${name}&predefinedAcl=publicRead`; |
| this.onProgress = onProgress || (() => {}); |
| this.req = new XMLHttpRequest(); |
| this.req.onabort = (e: ProgressEvent) => this.onRpcEvent(e); |
| this.req.onerror = (e: ProgressEvent) => this.onRpcEvent(e); |
| this.req.upload.onprogress = (e: ProgressEvent) => this.onRpcEvent(e); |
| this.req.onloadend = (e: ProgressEvent) => this.onRpcEvent(e); |
| this.req.open('POST', this.reqUrl); |
| this.req.setRequestHeader('Content-Type', 'application/octet-stream'); |
| this.req.send(trace); |
| } |
| |
| waitForCompletion(): Promise<void> { |
| return this.donePromise; |
| } |
| |
| abort() { |
| if (this.state === 'UPLOADING') { |
| this.req.abort(); |
| } |
| } |
| |
| getEtaString() { |
| let str = `${Math.ceil((100 * this.uploadedSize) / this.totalSize)}%`; |
| str += ` (${(this.uploadedSize / 1e6).toFixed(2)} MB)`; |
| const elapsed = (performance.now() - this.startTime) / 1000; |
| const rate = this.uploadedSize / elapsed; |
| const etaSecs = Math.round((this.totalSize - this.uploadedSize) / rate); |
| str += ' - ETA: ' + Time.toTimecode(Time.fromSeconds(etaSecs)).dhhmmss; |
| return str; |
| } |
| |
| private onRpcEvent(e: ProgressEvent) { |
| let done = false; |
| switch (e.type) { |
| case 'progress': |
| this.uploadedSize = e.loaded; |
| this.totalSize = e.total; |
| break; |
| case 'abort': |
| this.state = 'ERROR'; |
| this.error = 'Upload aborted'; |
| break; |
| case 'error': |
| this.state = 'ERROR'; |
| this.error = `${this.req.status} - ${this.req.statusText}`; |
| break; |
| case 'loadend': |
| done = true; |
| if (this.req.status === 200) { |
| this.state = 'UPLOADED'; |
| } else if (this.state === 'UPLOADING') { |
| this.state = 'ERROR'; |
| this.error = `${this.req.status} - ${this.req.statusText}`; |
| } |
| break; |
| default: |
| return; |
| } |
| this.onProgress(); |
| if (done) { |
| this.donePromise.resolve(); |
| } |
| } |
| } |
| |
| // Bigint's are not serializable using JSON.stringify, so we use a special |
| // object when serialising |
| export type SerializedBigint = { |
| __kind: 'bigint'; |
| value: string; |
| }; |
| |
| // Check if a value looks like a serialized bigint |
| export function isSerializedBigint(value: unknown): value is SerializedBigint { |
| if (value === null) { |
| return false; |
| } |
| if (typeof value !== 'object') { |
| return false; |
| } |
| if ('__kind' in value && 'value' in value) { |
| return value.__kind === 'bigint' && isString(value.value); |
| } |
| return false; |
| } |
| |
| export function serializeStateObject(object: unknown): string { |
| const json = JSON.stringify(object, (key, value) => { |
| if (typeof value === 'bigint') { |
| return { |
| __kind: 'bigint', |
| value: value.toString(), |
| }; |
| } |
| return key === 'nonSerializableState' ? undefined : value; |
| }); |
| return json; |
| } |
| |
| export function deserializeStateObject<T>(json: string): T { |
| const object = JSON.parse(json, (_key, value) => { |
| if (isSerializedBigint(value)) { |
| return BigInt(value.value); |
| } |
| return value; |
| }); |
| return object as T; |
| } |
| |
| export async function saveState( |
| stateOrConfig: State | RecordConfig, |
| ): Promise<string> { |
| const text = serializeStateObject(stateOrConfig); |
| const hash = await toSha256(text); |
| const url = |
| 'https://www.googleapis.com/upload/storage/v1/b/' + |
| `${BUCKET_NAME}/o?uploadType=media` + |
| `&name=${hash}&predefinedAcl=publicRead`; |
| const response = await fetch(url, { |
| method: 'post', |
| headers: { |
| 'Content-Type': 'application/json; charset=utf-8', |
| }, |
| body: text, |
| }); |
| await response.json(); |
| return hash; |
| } |
| |
| // This has a bug: |
| // x.toString(16) doesn't zero pad so if the digest is: |
| // [23, 7, 42, ...] |
| // You get: |
| // ['17', '7', '2a', ...] = 1772a... |
| // Rather than: |
| // ['17', '07', '2a', ...] = 17072a... |
| // As you ought to (and as the hexdigest is computed by e.g. Python). |
| // Unfortunately there are a lot of old permalinks out there so we |
| // still need this broken implementation to check their hashes. |
| export async function buggyToSha256(str: string): Promise<string> { |
| const buffer = new TextEncoder().encode(str); |
| const digest = await crypto.subtle.digest('SHA-256', buffer); |
| return Array.from(new Uint8Array(digest)) |
| .map((x) => x.toString(16)) |
| .join(''); |
| } |
| |
| export async function toSha256(str: string): Promise<string> { |
| const buffer = new TextEncoder().encode(str); |
| const digest = await crypto.subtle.digest('SHA-256', buffer); |
| return Array.from(new Uint8Array(digest)) |
| .map((x) => x.toString(16).padStart(2, '0')) |
| .join(''); |
| } |