• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 {TrackNode} from '../../public/workspace';
16import {Trace} from '../../public/trace';
17import {PerfettoPlugin} from '../../public/plugin';
18import {Track} from '../../public/track';
19import {z} from 'zod';
20import {assertIsInstance} from '../../base/logging';
21
22const PLUGIN_ID = 'dev.perfetto.RestorePinnedTrack';
23const SAVED_TRACKS_KEY = `${PLUGIN_ID}#savedPerfettoTracks`;
24
25const RESTORE_COMMAND_ID = `${PLUGIN_ID}#restore`;
26
27/**
28 * Fuzzy save and restore of pinned tracks.
29 *
30 * Tries to persist pinned tracks. Uses full string matching between track name
31 * and group name. When no match is found for a saved track, it tries again
32 * without numbers.
33 */
34export default class implements PerfettoPlugin {
35  static readonly id = PLUGIN_ID;
36  private ctx!: Trace;
37
38  static onActivate() {
39    const input = document.createElement('input');
40    input.classList.add('pinned_tracks_import_selector');
41    input.setAttribute('type', 'file');
42    input.style.display = 'none';
43    input.addEventListener('change', async (e) => {
44      if (!(e.target instanceof HTMLInputElement)) {
45        throw new Error('Not an input element');
46      }
47      if (!e.target.files) {
48        return;
49      }
50      const file = e.target.files[0];
51      const textPromise = file.text();
52
53      // Reset the value so onchange will be fired with the same file.
54      e.target.value = '';
55
56      const rawFile = JSON.parse(await textPromise);
57      const parsed = SAVED_NAMED_PINNED_TRACKS_SCHEMA.safeParse(rawFile);
58      if (!parsed.success) {
59        alert('Unable to import saved tracks.');
60        return;
61      }
62      addOrReplaceNamedPinnedTracks(parsed.data);
63    });
64    document.body.appendChild(input);
65  }
66
67  async onTraceLoad(ctx: Trace): Promise<void> {
68    this.ctx = ctx;
69
70    ctx.commands.registerCommand({
71      id: `${PLUGIN_ID}#save`,
72      name: 'Save: Pinned tracks',
73      callback: () => {
74        setSavedState({
75          ...getSavedState(),
76          tracks: this.getCurrentPinnedTracks(),
77        });
78      },
79    });
80    ctx.commands.registerCommand({
81      id: RESTORE_COMMAND_ID,
82      name: 'Restore: Pinned tracks',
83      callback: () => {
84        const tracks = getSavedState()?.tracks;
85        if (!tracks) {
86          alert('No saved tracks. Use the Save command first');
87          return;
88        }
89        this.restoreTracks(tracks);
90      },
91    });
92
93    ctx.commands.registerCommand({
94      id: `${PLUGIN_ID}#saveByName`,
95      name: 'Save by name: Pinned tracks',
96      callback: async () => {
97        const name = await this.ctx.omnibox.prompt(
98          'Give a name to the pinned set of tracks',
99        );
100        if (name) {
101          const tracks = this.getCurrentPinnedTracks();
102          addOrReplaceNamedPinnedTracks({name, tracks});
103        }
104      },
105    });
106    ctx.commands.registerCommand({
107      id: `${PLUGIN_ID}#restoreByName`,
108      name: 'Restore by name: Pinned tracks',
109      callback: async () => {
110        const tracksByName = getSavedState()?.tracksByName ?? [];
111        if (tracksByName.length === 0) {
112          alert('No saved tracks. Use the Save by name command first');
113          return;
114        }
115        const res = await this.ctx.omnibox.prompt(
116          'Select name of set of pinned tracks to restore',
117          {
118            values: tracksByName,
119            getName: (x) => x.name,
120          },
121        );
122        if (res) {
123          this.restoreTracks(res.tracks);
124        }
125      },
126    });
127
128    ctx.commands.registerCommand({
129      id: `${PLUGIN_ID}#exportByName`,
130      name: 'Export by name: Pinned tracks',
131      callback: async () => {
132        const tracksByName = getSavedState()?.tracksByName ?? [];
133        if (tracksByName.length === 0) {
134          alert('No saved tracks. Use the Save by name command first');
135          return;
136        }
137        const tracks = await this.ctx.omnibox.prompt(
138          'Select name of set of pinned tracks to export',
139          {
140            values: tracksByName,
141            getName: (x) => x.name,
142          },
143        );
144        if (tracks) {
145          const a = document.createElement('a');
146          a.href =
147            'data:application/json;charset=utf-8,' + JSON.stringify(tracks);
148          a.download = 'perfetto-pinned-tracks-export.json';
149          a.target = '_blank';
150          document.body.appendChild(a);
151          a.click();
152          document.body.removeChild(a);
153        }
154      },
155    });
156    ctx.commands.registerCommand({
157      id: `${PLUGIN_ID}#importByName`,
158      name: 'Import by name: Pinned tracks',
159      callback: async () => {
160        const files = document.querySelector('.pinned_tracks_import_selector');
161        assertIsInstance<HTMLInputElement>(files, HTMLInputElement).click();
162      },
163    });
164  }
165
166  private restoreTracks(tracks: ReadonlyArray<SavedPinnedTrack>) {
167    const localTracks = this.ctx.workspace.flatTracks.map((track) => ({
168      savedTrack: this.toSavedTrack(track),
169      track: track,
170    }));
171    const unrestoredTracks = tracks
172      .map((trackToRestore) => {
173        const foundTrack = this.findMatchingTrack(localTracks, trackToRestore);
174        if (foundTrack) {
175          foundTrack.pin();
176          return {restored: true, track: trackToRestore};
177        } else {
178          console.warn(
179            '[RestorePinnedTracks] No track found that matches',
180            trackToRestore,
181          );
182          return {restored: false, track: trackToRestore};
183        }
184      })
185      .filter(({restored}) => !restored)
186      .map(({track}) => track.trackName);
187
188    if (unrestoredTracks.length > 0) {
189      alert(
190        `[RestorePinnedTracks]\nUnable to restore the following tracks:\n${unrestoredTracks.join('\n')}`,
191      );
192    }
193  }
194
195  private getCurrentPinnedTracks() {
196    const res = [];
197    for (const track of this.ctx.workspace.pinnedTracks) {
198      res.push(this.toSavedTrack(track));
199    }
200    return res;
201  }
202
203  private findMatchingTrack(
204    localTracks: Array<LocalTrack>,
205    savedTrack: SavedPinnedTrack,
206  ): TrackNode | undefined {
207    let mostSimilarTrack: LocalTrack | undefined = undefined;
208    let mostSimilarTrackDifferenceScore: number = 0;
209
210    for (let i = 0; i < localTracks.length; i++) {
211      const localTrack = localTracks[i];
212      const differenceScore = this.calculateSimilarityScore(
213        localTrack.savedTrack,
214        savedTrack,
215      );
216
217      // Return immediately if we found the exact match
218      if (differenceScore === Number.MAX_SAFE_INTEGER) {
219        return localTrack.track;
220      }
221
222      // Ignore too different objects
223      if (differenceScore === 0) {
224        continue;
225      }
226
227      if (differenceScore > mostSimilarTrackDifferenceScore) {
228        mostSimilarTrackDifferenceScore = differenceScore;
229        mostSimilarTrack = localTrack;
230      }
231    }
232
233    return mostSimilarTrack?.track || undefined;
234  }
235
236  /**
237   * Returns the similarity score where 0 means the objects are completely
238   * different, and the higher the number, the smaller the difference is.
239   * Returns Number.MAX_SAFE_INTEGER if the objects are completely equal.
240   * We attempt a fuzzy match based on the similarity score.
241   * For example, one of the ways we do this is we remove the numbers
242   * from the title to potentially pin a "similar" track from a different trace.
243   * Removing numbers allows flexibility; for instance, with multiple 'sysui'
244   * processes (e.g. track group name: "com.android.systemui 123") without
245   * this approach, any could be mistakenly pinned. The goal is to restore
246   * specific tracks within the same trace, ensuring that a previously pinned
247   * track is pinned again.
248   * If the specific process with that PID is unavailable, pinning any
249   * other process matching the package name is attempted.
250   * @param track1 first saved track to compare
251   * @param track2 second saved track to compare
252   * @private
253   */
254  private calculateSimilarityScore(
255    track1: SavedPinnedTrack,
256    track2: SavedPinnedTrack,
257  ): number {
258    // Return immediately when objects are equal
259    if (
260      track1.trackName === track2.trackName &&
261      track1.groupName === track2.groupName &&
262      track1.pluginId === track2.pluginId &&
263      track1.kind === track2.kind &&
264      track1.isMainThread === track2.isMainThread
265    ) {
266      return Number.MAX_SAFE_INTEGER;
267    }
268
269    let similarityScore = 0;
270    if (track1.trackName === track2.trackName) {
271      similarityScore += 100;
272    } else if (
273      this.removeNumbers(track1.trackName) ===
274      this.removeNumbers(track2.trackName)
275    ) {
276      similarityScore += 50;
277    }
278
279    if (track1.groupName === track2.groupName) {
280      similarityScore += 90;
281    } else if (
282      this.removeNumbers(track1.groupName) ===
283      this.removeNumbers(track2.groupName)
284    ) {
285      similarityScore += 45;
286    }
287
288    // Do not consider other parameters if there is no match in name/group
289    if (similarityScore === 0) return similarityScore;
290
291    if (track1.pluginId === track2.pluginId) {
292      similarityScore += 30;
293    }
294
295    if (track1.kind === track2.kind) {
296      similarityScore += 20;
297    }
298
299    if (track1.isMainThread === track2.isMainThread) {
300      similarityScore += 10;
301    }
302
303    return similarityScore;
304  }
305
306  private removeNumbers(inputString?: string): string | undefined {
307    return inputString?.replace(/\d+/g, '');
308  }
309
310  private toSavedTrack(trackNode: TrackNode): SavedPinnedTrack {
311    let track: Track | undefined = undefined;
312    if (trackNode.uri != undefined) {
313      track = this.ctx.tracks.getTrack(trackNode.uri);
314    }
315
316    return {
317      groupName: groupName(trackNode),
318      trackName: trackNode.title,
319      pluginId: track?.pluginId,
320      kind: track?.tags?.kind,
321      isMainThread: track?.chips?.includes('main thread') || false,
322    };
323  }
324}
325
326function getSavedState(): SavedState | undefined {
327  const savedStateString = window.localStorage.getItem(SAVED_TRACKS_KEY);
328  if (!savedStateString) {
329    return undefined;
330  }
331  const savedState = SAVED_STATE_SCHEMA.safeParse(JSON.parse(savedStateString));
332  if (!savedState.success) {
333    return undefined;
334  }
335  return savedState.data;
336}
337
338function setSavedState(state: SavedState) {
339  window.localStorage.setItem(SAVED_TRACKS_KEY, JSON.stringify(state));
340}
341
342function addOrReplaceNamedPinnedTracks({name, tracks}: SavedNamedPinnedTracks) {
343  const savedState = getSavedState();
344  const rawTracksByName = savedState?.tracksByName ?? [];
345  const tracksByNameMap = new Map(
346    rawTracksByName.map((x) => [x.name, x.tracks]),
347  );
348  tracksByNameMap.set(name, tracks);
349  setSavedState({
350    ...savedState,
351    tracksByName: Array.from(tracksByNameMap.entries()).map(([k, v]) => ({
352      name: k,
353      tracks: v,
354    })),
355  });
356}
357
358// Return the displayname of the containing group
359// If the track is a child of a workspace, return undefined...
360function groupName(track: TrackNode): string | undefined {
361  return track.parent?.title;
362}
363
364const SAVED_PINNED_TRACK_SCHEMA = z
365  .object({
366    // Optional: group name for the track. Usually matches with process name.
367    groupName: z.string().optional(),
368    // Track name to restore.
369    trackName: z.string(),
370    // Plugin used to create this track
371    pluginId: z.string().optional(),
372    // Kind of the track
373    kind: z.string().optional(),
374    // If it's a thread track, it should be true in case it's a main thread track
375    isMainThread: z.boolean(),
376  })
377  .readonly();
378
379type SavedPinnedTrack = z.infer<typeof SAVED_PINNED_TRACK_SCHEMA>;
380
381const SAVED_NAMED_PINNED_TRACKS_SCHEMA = z
382  .object({
383    name: z.string(),
384    tracks: z.array(SAVED_PINNED_TRACK_SCHEMA).readonly(),
385  })
386  .readonly();
387
388type SavedNamedPinnedTracks = z.infer<typeof SAVED_NAMED_PINNED_TRACKS_SCHEMA>;
389
390const SAVED_STATE_SCHEMA = z
391  .object({
392    tracks: z.array(SAVED_PINNED_TRACK_SCHEMA).optional().readonly(),
393    tracksByName: z
394      .array(SAVED_NAMED_PINNED_TRACKS_SCHEMA)
395      .optional()
396      .readonly(),
397  })
398  .readonly();
399
400type SavedState = z.infer<typeof SAVED_STATE_SCHEMA>;
401
402interface LocalTrack {
403  readonly savedTrack: SavedPinnedTrack;
404  readonly track: TrackNode;
405}
406