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