xref: /aosp_15_r20/external/perfetto/ui/src/frontend/permalink.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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