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