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 m from 'mithril'; 16import { 17 JsonSerialize, 18 parseAppState, 19 serializeAppState, 20} from '../core/state_serialization'; 21import { 22 BUCKET_NAME, 23 MIME_BINARY, 24 MIME_JSON, 25 GcsUploader, 26} from '../base/gcs_uploader'; 27import { 28 SERIALIZED_STATE_VERSION, 29 SerializedAppState, 30} from '../core/state_serialization_schema'; 31import {z} from 'zod'; 32import {showModal} from '../widgets/modal'; 33import {AppImpl} from '../core/app_impl'; 34import {CopyableLink} from '../widgets/copyable_link'; 35import {TraceImpl} from '../core/trace_impl'; 36 37// Permalink serialization has two layers: 38// 1. Serialization of the app state (state_serialization.ts): 39// This is a JSON object that represents the visual app state (pinned tracks, 40// visible viewport bounds, etc) BUT not the trace source. 41// 2. An outer layer that contains the app state AND a link to the trace file. 42// (This file) 43// 44// In a nutshell: 45// AppState: {viewport: {...}, pinnedTracks: {...}, notes: {...}} 46// Permalink: {appState: {see above}, traceUrl: 'https://gcs/trace/file'} 47// 48// This file deals with the outer layer, state_serialization.ts with the inner. 49 50const PERMALINK_SCHEMA = z.object({ 51 traceUrl: z.string().optional(), 52 53 // We don't want to enforce validation at this level but want to delegate it 54 // to parseAppState(), for two reasons: 55 // 1. parseAppState() does further semantic checks (e.g. version checking). 56 // 2. We want to still load the traceUrl even if the app state is invalid. 57 appState: z.any().optional(), 58}); 59 60type PermalinkState = z.infer<typeof PERMALINK_SCHEMA>; 61 62export async function createPermalink(trace: TraceImpl): Promise<void> { 63 const hash = await createPermalinkInternal(trace); 64 showPermalinkDialog(hash); 65} 66 67// Returns the file name, not the full url (i.e. the name of the GCS object). 68async function createPermalinkInternal(trace: TraceImpl): Promise<string> { 69 const permalinkData: PermalinkState = {}; 70 71 // Check if we need to upload the trace file, before serializing the app 72 // state. 73 let alreadyUploadedUrl = ''; 74 const traceSource = trace.traceInfo.source; 75 let dataToUpload: File | ArrayBuffer | undefined = undefined; 76 let traceName = trace.traceInfo.traceTitle || 'trace'; 77 if (traceSource.type === 'FILE') { 78 dataToUpload = traceSource.file; 79 traceName = dataToUpload.name; 80 } else if (traceSource.type === 'ARRAY_BUFFER') { 81 dataToUpload = traceSource.buffer; 82 } else if (traceSource.type === 'URL') { 83 alreadyUploadedUrl = traceSource.url; 84 } else { 85 throw new Error(`Cannot share trace ${JSON.stringify(traceSource)}`); 86 } 87 88 // Upload the trace file, unless it's already uploaded (type == 'URL'). 89 // Internally TraceGcsUploader will skip the upload if an object with the 90 // same hash exists already. 91 if (alreadyUploadedUrl) { 92 permalinkData.traceUrl = alreadyUploadedUrl; 93 } else if (dataToUpload !== undefined) { 94 updateStatus(`Uploading ${traceName}`); 95 const uploader: GcsUploader = new GcsUploader(dataToUpload, { 96 mimeType: MIME_BINARY, 97 onProgress: () => reportUpdateProgress(uploader), 98 }); 99 await uploader.waitForCompletion(); 100 permalinkData.traceUrl = uploader.uploadedUrl; 101 } 102 103 permalinkData.appState = serializeAppState(trace); 104 105 // Serialize the permalink with the app state (or recording state) and upload. 106 updateStatus(`Creating permalink...`); 107 const permalinkJson = JsonSerialize(permalinkData); 108 const uploader: GcsUploader = new GcsUploader(permalinkJson, { 109 mimeType: MIME_JSON, 110 onProgress: () => reportUpdateProgress(uploader), 111 }); 112 await uploader.waitForCompletion(); 113 114 return uploader.uploadedFileName; 115} 116 117/** 118 * Loads a permalink from Google Cloud Storage. 119 * This is invoked when passing !#?s=fileName to URL. 120 * @param gcsFileName the file name of the cloud storage object. This is 121 * expected to be a JSON file that respects the schema defined by 122 * PERMALINK_SCHEMA. 123 */ 124export async function loadPermalink(gcsFileName: string): Promise<void> { 125 // Otherwise, this is a request to load the permalink. 126 const url = `https://storage.googleapis.com/${BUCKET_NAME}/${gcsFileName}`; 127 const response = await fetch(url); 128 if (!response.ok) { 129 throw new Error(`Could not fetch permalink.\n URL: ${url}`); 130 } 131 const text = await response.text(); 132 const permalinkJson = JSON.parse(text); 133 let permalink: PermalinkState; 134 let error = ''; 135 136 // Try to recover permalinks generated by older versions of the UI before 137 // r.android.com/3119920 . 138 const convertedLegacyPermalink = tryLoadLegacyPermalink(permalinkJson); 139 if (convertedLegacyPermalink !== undefined) { 140 permalink = convertedLegacyPermalink; 141 } else { 142 const res = PERMALINK_SCHEMA.safeParse(permalinkJson); 143 if (res.success) { 144 permalink = res.data; 145 } else { 146 error = res.error.toString(); 147 permalink = {}; 148 } 149 } 150 151 let serializedAppState: SerializedAppState | undefined = undefined; 152 if (permalink.appState !== undefined) { 153 // This is the most common case where the permalink contains the app state 154 // (and optionally a traceUrl, below). 155 const parseRes = parseAppState(permalink.appState); 156 if (parseRes.success) { 157 serializedAppState = parseRes.data; 158 } else { 159 error = parseRes.error; 160 } 161 } 162 if (permalink.traceUrl) { 163 AppImpl.instance.openTraceFromUrl(permalink.traceUrl, serializedAppState); 164 } 165 166 if (error) { 167 showModal({ 168 title: 'Failed to restore the serialized app state', 169 content: m( 170 'div', 171 m( 172 'p', 173 'Something went wrong when restoring the app state.' + 174 'This is due to some backwards-incompatible change ' + 175 'when the permalink is generated and then opened using ' + 176 'two different UI versions.', 177 ), 178 m( 179 'p', 180 "I'm going to try to open the trace file anyways, but " + 181 'the zoom level, pinned tracks and other UI ' + 182 "state wont't be recovered", 183 ), 184 m('p', 'Error details:'), 185 m('.modal-logs', error), 186 ), 187 buttons: [ 188 { 189 text: 'Open only the trace file', 190 primary: true, 191 }, 192 ], 193 }); 194 } 195} 196 197// Tries to recover a previous permalink, before the split in two layers, 198// where the permalink JSON contains the app state, which contains inside it 199// the trace URL. 200// If we suceed, convert it to a new-style JSON object preserving some minimal 201// information (really just vieport and pinned tracks). 202function tryLoadLegacyPermalink(data: unknown): PermalinkState | undefined { 203 const legacyData = data as { 204 version?: number; 205 engine?: {source?: {url?: string}}; 206 pinnedTracks?: string[]; 207 frontendLocalState?: { 208 visibleState?: {start?: {value?: string}; end?: {value?: string}}; 209 }; 210 }; 211 if (legacyData.version === undefined) return undefined; 212 const vizState = legacyData.frontendLocalState?.visibleState; 213 return { 214 traceUrl: legacyData.engine?.source?.url, 215 appState: { 216 version: SERIALIZED_STATE_VERSION, 217 pinnedTracks: legacyData.pinnedTracks ?? [], 218 viewport: vizState 219 ? {start: vizState.start?.value, end: vizState.end?.value} 220 : undefined, 221 } as SerializedAppState, 222 } as PermalinkState; 223} 224 225function reportUpdateProgress(uploader: GcsUploader) { 226 switch (uploader.state) { 227 case 'UPLOADING': 228 const statusTxt = `Uploading ${uploader.getEtaString()}`; 229 updateStatus(statusTxt); 230 break; 231 case 'ERROR': 232 updateStatus(`Upload failed ${uploader.error}`); 233 break; 234 default: 235 break; 236 } // switch (state) 237} 238 239function updateStatus(msg: string): void { 240 AppImpl.instance.omnibox.showStatusMessage(msg); 241} 242 243function showPermalinkDialog(hash: string) { 244 showModal({ 245 title: 'Permalink', 246 content: m(CopyableLink, {url: `${self.location.origin}/#!/?s=${hash}`}), 247 }); 248} 249