diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index 336411c89d..d47ed4e9e0 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -664,6 +664,7 @@ MenuButtons--publish--renderCheckbox-label-preference = Include preference value MenuButtons--publish--renderCheckbox-label-private-browsing = Include the data from private browsing windows MenuButtons--publish--renderCheckbox-label-private-browsing-warning-image = .title = This profile contains private browsing data +MenuButtons--publish--renderCheckbox-label-js-sources = Include JavaScript source code MenuButtons--publish--reupload-performance-profile = Re-upload Performance Profile MenuButtons--publish--share-performance-profile = Share Performance Profile MenuButtons--publish--info-description = Upload your profile and make it accessible to anyone with the link. diff --git a/src/actions/receive-profile.ts b/src/actions/receive-profile.ts index 0bd60fb0ec..9dfaa3f6a6 100644 --- a/src/actions/receive-profile.ts +++ b/src/actions/receive-profile.ts @@ -95,6 +95,7 @@ import type { BrowserConnectionStatus, } from '../app-logic/browser-connection'; import type { LibSymbolicationRequest } from '../profile-logic/symbol-store'; +import { getJSSourcesViaWebChannel } from '../app-logic/web-channel'; /** * This file collects all the actions that are used for receiving the profile in the @@ -212,6 +213,15 @@ export function finalizeProfileView( ); } + // Download all JS sources from the browser and store them in the profile + let jsSourcesPromise = null; + if (browserConnection) { + jsSourcesPromise = downloadJSSourcesFromBrowser( + profile, + browserConnection + ); + } + // Note we kick off symbolication only for the profiles we know for sure // that they weren't symbolicated. // We can skip the symbolication in tests if needed. @@ -233,7 +243,11 @@ export function finalizeProfileView( } } - await Promise.all([faviconsPromise, symbolicationPromise]); + await Promise.all([ + faviconsPromise, + jsSourcesPromise, + symbolicationPromise, + ]); }; } @@ -741,6 +755,84 @@ export async function retrievePageFaviconsFromBrowser( }); } +/** + * Download all JavaScript sources from the browser and store them in the profile's + * string table. This is called during profile loading to pre-fetch all JS sources. + */ +export async function downloadJSSourcesFromBrowser( + profile: Profile, + _browserConnection: BrowserConnection +): Promise { + const { shared } = profile; + const { sources } = shared; + + // Collect all unique source UUIDs that don't already have source code + const uuidsToFetch: string[] = []; + const sourceIndexByUuid: Map = new Map(); + + for (let i = 0; i < sources.length; i++) { + const uuid = sources.uuid[i]; + const sourceCode = sources.sourceCode[i]; + + // Only fetch if we have a UUID and don't already have the source code + if (uuid !== null && sourceCode === null) { + uuidsToFetch.push(uuid); + sourceIndexByUuid.set(uuid, i); + } + } + + if (uuidsToFetch.length === 0) { + // No sources to fetch + console.log('No JS sources to download from browser'); + return; + } + + console.log( + `Starting to download ${uuidsToFetch.length} JS source(s) from browser...` + ); + + try { + // Fetch all JS sources at once using the WebChannel + const results = await getJSSourcesViaWebChannel(uuidsToFetch); + + // Store the fetched sources in the string table + let successCount = 0; + let errorCount = 0; + + for (let i = 0; i < results.length; i++) { + const result = results[i]; + const uuid = uuidsToFetch[i]; + const sourceIndex = sourceIndexByUuid.get(uuid); + + if (sourceIndex === undefined) { + continue; + } + + if ('error' in result) { + // Log the error but continue with other sources + console.warn( + `Failed to fetch JS source for UUID ${uuid}: ${result.error}` + ); + errorCount++; + continue; + } + + // Add the source code to the string table and update the sources table + const sourceCodeIndex = shared.stringArray.length; + shared.stringArray.push(result.sourceText); + sources.sourceCode[sourceIndex] = sourceCodeIndex; + successCount++; + } + + console.log( + `Finished downloading JS sources: ${successCount} succeeded, ${errorCount} failed` + ); + } catch (error) { + // Log the error but don't fail the entire profile load + console.error('Failed to download JS sources from browser:', error); + } +} + // From a BrowserConnectionStatus, this unwraps the included browserConnection // when possible. export function unwrapBrowserConnection( diff --git a/src/components/app/MenuButtons/Publish.tsx b/src/components/app/MenuButtons/Publish.tsx index b464465a33..94f79b3698 100644 --- a/src/components/app/MenuButtons/Publish.tsx +++ b/src/components/app/MenuButtons/Publish.tsx @@ -210,6 +210,10 @@ class PublishPanelImpl extends React.PureComponent { ) : null} + {this._renderCheckbox( + 'includeJSSources', + 'MenuButtons--publish--renderCheckbox-label-js-sources' + )} {sanitizedProfileEncodingState.phase === 'ERROR' ? (
diff --git a/src/components/app/SourceCodeFetcher.tsx b/src/components/app/SourceCodeFetcher.tsx index 0800b3f81d..89884a5815 100644 --- a/src/components/app/SourceCodeFetcher.tsx +++ b/src/components/app/SourceCodeFetcher.tsx @@ -109,7 +109,9 @@ class SourceCodeFetcherImpl extends React.PureComponent { symbolServerUrl, addressProof, this._archiveCache, - delegate + delegate, + profile, + sourceViewSourceIndex ); switch (fetchSourceResult.type) { diff --git a/src/profile-logic/data-structures.ts b/src/profile-logic/data-structures.ts index dfb9994e8e..9a95f52d2a 100644 --- a/src/profile-logic/data-structures.ts +++ b/src/profile-logic/data-structures.ts @@ -379,6 +379,7 @@ export function getEmptySourceTable(): SourceTable { // be caught by the type system. uuid: [], filename: [], + sourceCode: [], length: 0, }; } diff --git a/src/profile-logic/global-data-collector.ts b/src/profile-logic/global-data-collector.ts index 8f9899529c..737a10499b 100644 --- a/src/profile-logic/global-data-collector.ts +++ b/src/profile-logic/global-data-collector.ts @@ -26,7 +26,7 @@ export class GlobalDataCollector { _libKeyToLibIndex: Map = new Map(); _stringArray: string[] = []; _stringTable: StringTable = StringTable.withBackingArray(this._stringArray); - _sources: SourceTable = { length: 0, uuid: [], filename: [] }; + _sources: SourceTable = { length: 0, uuid: [], filename: [], sourceCode: [] }; _uuidToSourceIndex: Map = new Map(); _filenameToSourceIndex: Map = new Map(); @@ -72,6 +72,7 @@ export class GlobalDataCollector { const filenameIndex = this._stringTable.indexForString(filename); this._sources.uuid[index] = uuid; this._sources.filename[index] = filenameIndex; + this._sources.sourceCode[index] = null; // Initially no source code this._sources.length++; if (uuid !== null) { diff --git a/src/profile-logic/merge-compare.ts b/src/profile-logic/merge-compare.ts index be1bdaabdd..baef67cc72 100644 --- a/src/profile-logic/merge-compare.ts +++ b/src/profile-logic/merge-compare.ts @@ -499,7 +499,12 @@ function mergeSources( sources: SourceTable; translationMaps: TranslationMapForSources[]; } { - const newSources: SourceTable = { length: 0, uuid: [], filename: [] }; + const newSources: SourceTable = { + length: 0, + uuid: [], + filename: [], + sourceCode: [], + }; const mapOfInsertedSources: Map = new Map(); const translationMaps = sourcesPerProfile.map((sources, profileIndex) => { diff --git a/src/profile-logic/profile-compacting.ts b/src/profile-logic/profile-compacting.ts index 49bd963c15..6b382d5bbd 100644 --- a/src/profile-logic/profile-compacting.ts +++ b/src/profile-logic/profile-compacting.ts @@ -398,6 +398,7 @@ function _createCompactedSourceTable( let nextIndex = 0; const newUuid = []; const newFilename = []; + const newSourceCode = []; for (let i = 0; i < sourceTable.length; i++) { if (referencedSources[i] === 0) { @@ -418,6 +419,21 @@ function _createCompactedSourceTable( } newFilename[newIndex] = newFilenameIndexPlusOne - 1; + // Translate the source code string index + const oldSourceCodeIndex = sourceTable.sourceCode[i]; + if (oldSourceCodeIndex !== null) { + const newSourceCodeIndexPlusOne = + oldStringToNewStringPlusOne[oldSourceCodeIndex]; + if (newSourceCodeIndexPlusOne === 0) { + throw new Error( + `String index ${oldSourceCodeIndex} was not found in the translation map` + ); + } + newSourceCode[newIndex] = newSourceCodeIndexPlusOne - 1; + } else { + newSourceCode[newIndex] = null; + } + oldSourceToNewSourcePlusOne[i] = newIndex + 1; } @@ -425,6 +441,7 @@ function _createCompactedSourceTable( length: nextIndex, uuid: newUuid, filename: newFilename, + sourceCode: newSourceCode, }; return { newSources, oldSourceToNewSourcePlusOne }; diff --git a/src/profile-logic/sanitize.ts b/src/profile-logic/sanitize.ts index e9eefdd8f7..f76c570293 100644 --- a/src/profile-logic/sanitize.ts +++ b/src/profile-logic/sanitize.ts @@ -117,6 +117,18 @@ export function sanitizePII( } const stringTable = StringTable.withBackingArray(stringArray); + // Handle JS source removal if requested + let sources = profile.shared.sources; + if (PIIToBeRemoved.shouldRemoveJSSources) { + // Create a new sources table with sourceCode set to null for all entries + sources = { + length: sources.length, + uuid: sources.uuid.slice(), + filename: sources.filename.slice(), + sourceCode: sources.sourceCode.map(() => null), + }; + } + let removingCounters = false; const newProfile: Profile = { ...profile, @@ -129,7 +141,7 @@ export function sanitizePII( pages: pages, shared: { stringArray, - sources: profile.shared.sources, + sources, }, threads: profile.threads.reduce((acc, thread, threadIndex) => { const newThread: RawThread | null = sanitizeThreadPII( diff --git a/src/reducers/publish.ts b/src/reducers/publish.ts index d3c515ce9e..490ec0c4db 100644 --- a/src/reducers/publish.ts +++ b/src/reducers/publish.ts @@ -25,6 +25,7 @@ function _getSanitizingSharingOptions(): CheckedSharingOptions { includeExtension: false, includePreferenceValues: false, includePrivateBrowsingData: false, + includeJSSources: false, }; } @@ -39,6 +40,7 @@ function _getMostlyNonSanitizingSharingOptions(): CheckedSharingOptions { includePreferenceValues: true, // We always want to sanitize the private browsing data by default includePrivateBrowsingData: false, + includeJSSources: true, }; } diff --git a/src/selectors/publish.ts b/src/selectors/publish.ts index 04397c601d..2c2ffa6fa8 100644 --- a/src/selectors/publish.ts +++ b/src/selectors/publish.ts @@ -177,6 +177,7 @@ export const getRemoveProfileInformation: Selector; filename: Array; + // Index into the string table for the source code content. + // Null if the source code hasn't been downloaded yet. + sourceCode: Array; }; export type RawProfileSharedData = { diff --git a/src/utils/fetch-source.ts b/src/utils/fetch-source.ts index 4ad15da0db..41f50d104d 100644 --- a/src/utils/fetch-source.ts +++ b/src/utils/fetch-source.ts @@ -15,6 +15,8 @@ import type { ExternalCommunicationDelegate } from './query-api'; import type { SourceCodeLoadingError, AddressProof, + Profile, + IndexIntoSourceTable, } from 'firefox-profiler/types'; export type FetchSourceResult = @@ -36,6 +38,8 @@ export type FetchSourceResult = * @param archiveCache - A map which allows reusing the bytes of the archive file. * Stores promises to the bytes of uncompressed tar files. * @param delegate - An object which handles web requests and browser connection queries. + * @param profile - The profile, used to check if source code is already downloaded. + * @param sourceIndex - The index into the sources table, used to check if source code is already downloaded. */ export async function fetchSource( file: string, @@ -43,10 +47,25 @@ export async function fetchSource( symbolServerUrl: string, addressProof: AddressProof | null, archiveCache: Map>, - delegate: ExternalCommunicationDelegate + delegate: ExternalCommunicationDelegate, + profile: Profile | null = null, + sourceIndex: IndexIntoSourceTable | null = null ): Promise { const errors: SourceCodeLoadingError[] = []; + // First, check if we already have the source code in the profile + if ( + profile !== null && + sourceIndex !== null && + sourceIndex < profile.shared.sources.length + ) { + const sourceCodeIndex = profile.shared.sources.sourceCode[sourceIndex]; + if (sourceCodeIndex !== null) { + const sourceCode = profile.shared.stringArray[sourceCodeIndex]; + return { type: 'SUCCESS', source: sourceCode }; + } + } + if (addressProof !== null) { // Prepare a request to /source/v1. The API format for this endpoint is documented // at https://github.com/mstange/profiler-get-symbols/blob/master/API.md#sourcev1