1// Copyright (C) 2024 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 {produce} from 'immer'; 16import {assertExists} from '../base/logging'; 17import {runValidator} from '../base/validators'; 18import {Actions} from '../common/actions'; 19import {ConversionJobStatus} from '../common/conversion_jobs'; 20import { 21 createEmptyNonSerializableState, 22 createEmptyState, 23} from '../common/empty_state'; 24import {EngineConfig, ObjectById, STATE_VERSION, State} from '../common/state'; 25import { 26 BUCKET_NAME, 27 TraceGcsUploader, 28 buggyToSha256, 29 deserializeStateObject, 30 saveState, 31 toSha256, 32} from '../common/upload_utils'; 33import { 34 RecordConfig, 35 recordConfigValidator, 36} from '../controller/record_config_types'; 37import {globals} from './globals'; 38import { 39 publishConversionJobStatusUpdate, 40 publishPermalinkHash, 41} from './publish'; 42import {Router} from './router'; 43import {showModal} from '../widgets/modal'; 44 45export interface PermalinkOptions { 46 isRecordingConfig?: boolean; 47} 48 49export async function createPermalink( 50 options: PermalinkOptions = {}, 51): Promise<void> { 52 const {isRecordingConfig = false} = options; 53 const jobName = 'create_permalink'; 54 publishConversionJobStatusUpdate({ 55 jobName, 56 jobStatus: ConversionJobStatus.InProgress, 57 }); 58 59 try { 60 const hash = await createPermalinkInternal(isRecordingConfig); 61 publishPermalinkHash(hash); 62 } finally { 63 publishConversionJobStatusUpdate({ 64 jobName, 65 jobStatus: ConversionJobStatus.NotRunning, 66 }); 67 } 68} 69 70async function createPermalinkInternal( 71 isRecordingConfig: boolean, 72): Promise<string> { 73 let uploadState: State | RecordConfig = globals.state; 74 75 if (isRecordingConfig) { 76 uploadState = globals.state.recordConfig; 77 } else { 78 const engine = assertExists(globals.getCurrentEngine()); 79 let dataToUpload: File | ArrayBuffer | undefined = undefined; 80 let traceName = `trace ${engine.id}`; 81 if (engine.source.type === 'FILE') { 82 dataToUpload = engine.source.file; 83 traceName = dataToUpload.name; 84 } else if (engine.source.type === 'ARRAY_BUFFER') { 85 dataToUpload = engine.source.buffer; 86 } else if (engine.source.type !== 'URL') { 87 throw new Error(`Cannot share trace ${JSON.stringify(engine.source)}`); 88 } 89 90 if (dataToUpload !== undefined) { 91 updateStatus(`Uploading ${traceName}`); 92 const uploader = new TraceGcsUploader(dataToUpload, () => { 93 switch (uploader.state) { 94 case 'UPLOADING': 95 const statusTxt = `Uploading ${uploader.getEtaString()}`; 96 updateStatus(statusTxt); 97 break; 98 case 'UPLOADED': 99 // Convert state to use URLs and remove permalink. 100 const url = uploader.uploadedUrl; 101 uploadState = produce(globals.state, (draft) => { 102 assertExists(draft.engine).source = {type: 'URL', url}; 103 }); 104 break; 105 case 'ERROR': 106 updateStatus(`Upload failed ${uploader.error}`); 107 break; 108 } // switch (state) 109 }); // onProgress 110 await uploader.waitForCompletion(); 111 } 112 } 113 114 // Upload state. 115 updateStatus(`Creating permalink...`); 116 const hash = await saveState(uploadState); 117 updateStatus(`Permalink ready`); 118 return hash; 119} 120 121function updateStatus(msg: string): void { 122 // TODO(hjd): Unify loading updates. 123 globals.dispatch( 124 Actions.updateStatus({ 125 msg, 126 timestamp: Date.now() / 1000, 127 }), 128 ); 129} 130 131export async function loadPermalink(hash: string): Promise<void> { 132 // Otherwise, this is a request to load the permalink. 133 const stateOrConfig = await loadState(hash); 134 135 if (isRecordConfig(stateOrConfig)) { 136 // This permalink state only contains a RecordConfig. Show the 137 // recording page with the config, but keep other state as-is. 138 const validConfig = runValidator( 139 recordConfigValidator, 140 stateOrConfig as unknown, 141 ).result; 142 globals.dispatch(Actions.setRecordConfig({config: validConfig})); 143 Router.navigate('#!/record'); 144 return; 145 } 146 globals.dispatch(Actions.setState({newState: stateOrConfig})); 147} 148 149async function loadState(id: string): Promise<State | RecordConfig> { 150 const url = `https://storage.googleapis.com/${BUCKET_NAME}/${id}`; 151 const response = await fetch(url); 152 if (!response.ok) { 153 throw new Error( 154 `Could not fetch permalink.\n` + 155 `Are you sure the id (${id}) is correct?\n` + 156 `URL: ${url}`, 157 ); 158 } 159 const text = await response.text(); 160 const stateHash = await toSha256(text); 161 const state = deserializeStateObject<State>(text); 162 if (stateHash !== id) { 163 // Old permalinks incorrectly dropped some digits from the 164 // hexdigest of the SHA256. We don't want to invalidate those 165 // links so we also compute the old string and try that here 166 // also. 167 const buggyStateHash = await buggyToSha256(text); 168 if (buggyStateHash !== id) { 169 throw new Error(`State hash does not match ${id} vs. ${stateHash}`); 170 } 171 } 172 if (!isRecordConfig(state)) { 173 return upgradeState(state); 174 } 175 return state; 176} 177 178function isRecordConfig( 179 stateOrConfig: State | RecordConfig, 180): stateOrConfig is RecordConfig { 181 const mode = (stateOrConfig as {mode?: string}).mode; 182 return ( 183 mode !== undefined && 184 ['STOP_WHEN_FULL', 'RING_BUFFER', 'LONG_TRACE'].includes(mode) 185 ); 186} 187 188function upgradeState(state: State): State { 189 if (state.engine !== undefined && state.engine.source.type !== 'URL') { 190 // All permalink traces should be modified to have a source.type=URL 191 // pointing to the uploaded trace. Due to a bug in some older version 192 // of the UI (b/327049372), an upload failure can end up with a state that 193 // has type=FILE but a null file object. If this happens, invalidate the 194 // trace and show a message. 195 showModal({ 196 title: 'Cannot load trace permalink', 197 content: m( 198 'div', 199 'The permalink stored on the server is corrupted ' + 200 'and cannot be loaded.', 201 ), 202 }); 203 return createEmptyState(); 204 } 205 206 if (state.version !== STATE_VERSION) { 207 const newState = createEmptyState(); 208 // Old permalinks from state versions prior to version 24 209 // have multiple engines of which only one is identified as the 210 // current engine via currentEngineId. Handle this case: 211 if (isMultiEngineState(state)) { 212 const engineId = state.currentEngineId; 213 if (engineId !== undefined) { 214 newState.engine = state.engines[engineId]; 215 } 216 } else { 217 newState.engine = state.engine; 218 } 219 220 if (newState.engine !== undefined) { 221 newState.engine.ready = false; 222 } 223 const message = 224 `Unable to parse old state version. Discarding state ` + 225 `and loading trace.`; 226 console.warn(message); 227 updateStatus(message); 228 return newState; 229 } else { 230 // Loaded state is presumed to be compatible with the State type 231 // definition in the app. However, a non-serializable part has to be 232 // recreated. 233 state.nonSerializableState = createEmptyNonSerializableState(); 234 } 235 return state; 236} 237 238interface MultiEngineState { 239 currentEngineId?: string; 240 engines: ObjectById<EngineConfig>; 241} 242 243function isMultiEngineState( 244 state: State | MultiEngineState, 245): state is MultiEngineState { 246 if ((state as MultiEngineState).engines !== undefined) { 247 return true; 248 } 249 return false; 250} 251