| // Copyright (C) 2024 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 m from 'mithril'; | |
| import { | |
| JsonSerialize, | |
| parseAppState, | |
| serializeAppState, | |
| } from '../core/state_serialization'; | |
| import { | |
| BUCKET_NAME, | |
| MIME_BINARY, | |
| MIME_JSON, | |
| GcsUploader, | |
| isValidGcsFileName, | |
| } from '../base/gcs_uploader'; | |
| import { | |
| SERIALIZED_STATE_VERSION, | |
| type SerializedAppState, | |
| } from '../core/state_serialization_schema'; | |
| import {z} from 'zod'; | |
| import {showModal} from '../widgets/modal'; | |
| import {AppImpl} from '../core/app_impl'; | |
| import type {TraceImpl} from '../core/trace_impl'; | |
| // Permalink serialization has two layers: | |
| // 1. Serialization of the app state (state_serialization.ts): | |
| // This is a JSON object that represents the visual app state (pinned tracks, | |
| // visible viewport bounds, etc) BUT not the trace source. | |
| // 2. An outer layer that contains the app state AND a link to the trace file. | |
| // (This file) | |
| // | |
| // In a nutshell: | |
| // AppState: {viewport: {...}, pinnedTracks: {...}, notes: {...}} | |
| // Permalink: {appState: {see above}, traceUrl: 'https://gcs/trace/file'} | |
| // | |
| // This file deals with the outer layer, state_serialization.ts with the inner. | |
| const PERMALINK_SCHEMA = z.object({ | |
| traceUrl: z.string().optional(), | |
| // We don't want to enforce validation at this level but want to delegate it | |
| // to parseAppState(), for two reasons: | |
| // 1. parseAppState() does further semantic checks (e.g. version checking). | |
| // 2. We want to still load the traceUrl even if the app state is invalid. | |
| appState: z.any().optional(), | |
| }); | |
| type PermalinkState = z.infer<typeof PERMALINK_SCHEMA>; | |
| /** | |
| * Make the trace blob available for sharing. If we have the trace blob locally | |
| * it will be uploaded to GCS, but if it was originally loaded from a URL, we | |
| * just return that URL. | |
| * | |
| * @param trace The trace to upload. | |
| * @returns The trace URL if the upload was successful, or undefined if the | |
| * trace was already uploaded (i.e. the source type is 'URL'). | |
| */ | |
| export async function uploadTraceBlob( | |
| trace: TraceImpl, | |
| ): Promise<string | undefined> { | |
| // Check if we need to upload the trace file, before serializing the app | |
| // state. | |
| let alreadyUploadedUrl = ''; | |
| const traceSource = trace.traceInfo.source; | |
| let dataToUpload: File | ArrayBuffer | undefined = undefined; | |
| let traceName = trace.traceInfo.traceTitle || 'trace'; | |
| if (traceSource.type === 'FILE') { | |
| dataToUpload = traceSource.file; | |
| traceName = dataToUpload.name; | |
| } else if (traceSource.type === 'ARRAY_BUFFER') { | |
| dataToUpload = traceSource.buffer; | |
| } else if (traceSource.type === 'URL') { | |
| alreadyUploadedUrl = traceSource.url; | |
| } else { | |
| throw new Error(`Cannot share trace ${JSON.stringify(traceSource)}`); | |
| } | |
| // Upload the trace file, unless it's already uploaded (type == 'URL'). | |
| // Internally TraceGcsUploader will skip the upload if an object with the | |
| // same hash exists already. | |
| if (alreadyUploadedUrl) { | |
| return alreadyUploadedUrl; | |
| } else if (dataToUpload !== undefined) { | |
| updateStatus(`Uploading ${traceName}`); | |
| const uploader = new GcsUploader(dataToUpload, { | |
| mimeType: MIME_BINARY, | |
| onProgress: () => reportUpdateProgress(uploader), | |
| }); | |
| await uploader.waitForCompletion(); | |
| return uploader.uploadedUrl; | |
| } | |
| return undefined; | |
| } | |
| /** | |
| * Serializes the UI state for a given trace object and uploads it to GCS, | |
| * with an optional trace URL. | |
| * | |
| * @param trace The trace object to serialize and upload. | |
| * @param traceUrl The URL of the trace file, if available. If undefined, only | |
| * the app state will be uploaded. | |
| * @returns The hash of the uploaded file, which can be used to create a | |
| * permalink. | |
| */ | |
| export async function createPermalink( | |
| trace: TraceImpl, | |
| traceUrl: string | undefined, | |
| ): Promise<string> { | |
| AppImpl.instance.analytics.logEvent('Trace Actions', 'Create permalink'); | |
| const permalinkData: PermalinkState = { | |
| traceUrl, | |
| appState: serializeAppState(trace), | |
| }; | |
| // Serialize the permalink with the app state (or recording state) and upload. | |
| updateStatus(`Creating permalink...`); | |
| const permalinkJson = JsonSerialize(permalinkData); | |
| const uploader = new GcsUploader(permalinkJson, { | |
| mimeType: MIME_JSON, | |
| onProgress: () => reportUpdateProgress(uploader), | |
| }); | |
| await uploader.waitForCompletion(); | |
| return uploader.uploadedFileName; | |
| } | |
| /** | |
| * Loads a permalink from Google Cloud Storage. | |
| * This is invoked when passing !#?s=fileName to URL. | |
| * @param gcsFileName the file name of the cloud storage object. This is | |
| * expected to be a JSON file that respects the schema defined by | |
| * PERMALINK_SCHEMA. | |
| */ | |
| export async function loadPermalink(gcsFileName: string): Promise<void> { | |
| if (!isValidGcsFileName(gcsFileName)) { | |
| throw new Error( | |
| `Invalid permalink id: ${gcsFileName}. Expected a 40-char hex hash.`, | |
| ); | |
| } | |
| // Otherwise, this is a request to load the permalink. | |
| const url = `https://storage.googleapis.com/${BUCKET_NAME}/${gcsFileName}`; | |
| const response = await fetch(url); | |
| if (!response.ok) { | |
| throw new Error(`Could not fetch permalink.\n URL: ${url}`); | |
| } | |
| const text = await response.text(); | |
| const permalinkJson = JSON.parse(text); | |
| let permalink: PermalinkState; | |
| let error = ''; | |
| // Try to recover permalinks generated by older versions of the UI before | |
| // r.android.com/3119920 . | |
| const convertedLegacyPermalink = tryLoadLegacyPermalink(permalinkJson); | |
| if (convertedLegacyPermalink !== undefined) { | |
| permalink = convertedLegacyPermalink; | |
| } else { | |
| const res = PERMALINK_SCHEMA.safeParse(permalinkJson); | |
| if (res.success) { | |
| permalink = res.data; | |
| } else { | |
| error = res.error.toString(); | |
| permalink = {}; | |
| } | |
| } | |
| let serializedAppState: SerializedAppState | undefined = undefined; | |
| if (permalink.appState !== undefined) { | |
| // This is the most common case where the permalink contains the app state | |
| // (and optionally a traceUrl, below). | |
| const parseRes = parseAppState(permalink.appState); | |
| if (parseRes.ok) { | |
| serializedAppState = parseRes.value; | |
| } else { | |
| error = parseRes.error; | |
| } | |
| } | |
| if (permalink.traceUrl) { | |
| AppImpl.instance.openTraceFromUrl(permalink.traceUrl, serializedAppState); | |
| } | |
| if (error) { | |
| showModal({ | |
| title: 'Failed to restore the serialized app state', | |
| content: m( | |
| 'div', | |
| m( | |
| 'p', | |
| 'Something went wrong when restoring the app state.' + | |
| 'This is due to some backwards-incompatible change ' + | |
| 'when the permalink is generated and then opened using ' + | |
| 'two different UI versions.', | |
| ), | |
| m( | |
| 'p', | |
| "I'm going to try to open the trace file anyways, but " + | |
| 'the zoom level, pinned tracks and other UI ' + | |
| "state wont't be recovered", | |
| ), | |
| m('p', 'Error details:'), | |
| m('.pf-modal-logs', error), | |
| ), | |
| buttons: [ | |
| { | |
| text: 'Open only the trace file', | |
| primary: true, | |
| }, | |
| ], | |
| }); | |
| } | |
| } | |
| // Tries to recover a previous permalink, before the split in two layers, | |
| // where the permalink JSON contains the app state, which contains inside it | |
| // the trace URL. | |
| // If we suceed, convert it to a new-style JSON object preserving some minimal | |
| // information (really just vieport and pinned tracks). | |
| function tryLoadLegacyPermalink(data: unknown): PermalinkState | undefined { | |
| const legacyData = data as { | |
| version?: number; | |
| engine?: {source?: {url?: string}}; | |
| pinnedTracks?: string[]; | |
| frontendLocalState?: { | |
| visibleState?: {start?: {value?: string}; end?: {value?: string}}; | |
| }; | |
| }; | |
| if (legacyData.version === undefined) return undefined; | |
| const vizState = legacyData.frontendLocalState?.visibleState; | |
| return { | |
| traceUrl: legacyData.engine?.source?.url, | |
| appState: { | |
| version: SERIALIZED_STATE_VERSION, | |
| pinnedTracks: legacyData.pinnedTracks ?? [], | |
| viewport: vizState | |
| ? {start: vizState.start?.value, end: vizState.end?.value} | |
| : undefined, | |
| } as SerializedAppState, | |
| } as PermalinkState; | |
| } | |
| function reportUpdateProgress(uploader: GcsUploader) { | |
| switch (uploader.state) { | |
| case 'UPLOADING': | |
| const statusTxt = `Uploading ${uploader.getEtaString()}`; | |
| updateStatus(statusTxt); | |
| break; | |
| case 'ERROR': | |
| updateStatus(`Upload failed ${uploader.error}`); | |
| break; | |
| default: | |
| break; | |
| } // switch (state) | |
| } | |
| function updateStatus(msg: string): void { | |
| AppImpl.instance.omnibox.showStatusMessage(msg); | |
| } |