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