Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Retrieve the page favicons from the browser and display it on the tab selector #5166

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 23 additions & 10 deletions src/actions/icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@

// @flow
import type { Action, ThunkAction } from 'firefox-profiler/types';
import sha1 from 'firefox-profiler/utils/sha1';

export function iconHasLoaded(icon: string): Action {
export function iconHasLoaded(iconWithClassName: [string, string]): Action {
return {
type: 'ICON_HAS_LOADED',
icon,
iconWithClassName,
};
}

Expand All @@ -21,24 +22,27 @@ export function iconIsInError(icon: string): Action {

const icons: Set<string> = new Set();

type IconRequestResult = 'loaded' | 'error' | 'cached';
type IconRequestResult =
| {| type: 'error' | 'cached' |}
| {| type: 'loaded', iconWithClassName: [string, string] |};
canova marked this conversation as resolved.
Show resolved Hide resolved

function _getIcon(icon: string): Promise<IconRequestResult> {
async function _getIcon(icon: string): Promise<IconRequestResult> {
if (icons.has(icon)) {
return Promise.resolve('cached');
return Promise.resolve({ type: 'cached' });
}

icons.add(icon);
const className = await _classNameFromUrl(icon);

const result = new Promise((resolve) => {
const image = new Image();
image.src = icon;
image.referrerPolicy = 'no-referrer';
image.onload = () => {
resolve('loaded');
resolve({ type: 'loaded', iconWithClassName: [icon, className] });
};
image.onerror = () => {
resolve('error');
resolve({ type: 'error' });
};
});

Expand All @@ -48,9 +52,9 @@ function _getIcon(icon: string): Promise<IconRequestResult> {
export function iconStartLoading(icon: string): ThunkAction<Promise<void>> {
return (dispatch) => {
return _getIcon(icon).then((result) => {
switch (result) {
switch (result.type) {
case 'loaded':
dispatch(iconHasLoaded(icon));
dispatch(iconHasLoaded(result.iconWithClassName));
break;
case 'error':
dispatch(iconIsInError(icon));
Expand All @@ -59,8 +63,17 @@ export function iconStartLoading(icon: string): ThunkAction<Promise<void>> {
// nothing to do
break;
default:
throw new Error(`Unknown icon load result ${result}`);
throw new Error(`Unknown icon load result ${result.type}`);
}
});
};
}

/**
* Transforms a URL into a valid CSS class name.
*/
async function _classNameFromUrl(url): Promise<string> {
return url.startsWith('data:image/')
? 'dataUrl' + (await sha1(url))
: url.replace(/[/:.+>< ~()#,]/g, '_');
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(optional)
The goal of this function was to provide always the same class name for the same URL, so that we wouldn't try to insert a lot of identical CSS classes. The other goal was an idempotent operation, so that calling it with the same parameter in 2 different places would generate the same result.

But now that you have this Map, we have a way to ensure this unicity without this trick.

So I wonder if we could do a simple counter with a common prefix instead of a possibly expensive sha1 (expensive depending on the size of the input).

return `favicon-${++faviconCounter}`

What do you think?

The problem with a static counter is for testing, you need to provide a function to reset it for the tests only. Also it might need to change more than just this because of how the class is used currently (but I'm not sure).

(not optional) IMO you can do the sha1 operation for both data urls and and normal urls. Also as a comment you could check for data: only (but you don't need this check anyway).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also I believe you moved how the classname was generated here just becase of the fact you made it async. But I don't mind, I feel like it's better to generate the classnames once rather than regenerating them always. And it makes things easier with the counter.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm yeah, you are right about the favicon class name. We don't really need to sha1 the whole url and just can have a counter. I changed it to that one, thanks!

35 changes: 35 additions & 0 deletions src/actions/receive-profile.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import type {
InnerWindowID,
Pid,
OriginsTimelineRoot,
PageList,
} from 'firefox-profiler/types';

import type {
Expand Down Expand Up @@ -279,6 +280,10 @@ export function finalizeProfileView(
await doSymbolicateProfile(dispatch, profile, symbolStore);
}
}

if (browserConnection && pages && pages.length > 0) {
await retrievePageFaviconsFromBrowser(dispatch, pages, browserConnection);
}
};
}

Expand Down Expand Up @@ -1017,6 +1022,36 @@ export async function doSymbolicateProfile(
dispatch(doneSymbolicating());
}

export async function retrievePageFaviconsFromBrowser(
dispatch: Dispatch,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: instead of passing dispatch as a parameter, I'd make this a thunk action, so that dispatch is injected by the thunk action middleware.

Then in the caller, you'd do await dispatch(retrievePageFaviconsFromBrowser(...))

I think it's better because it makes it clearer in the caller that this will ultimately dispatch an action.
Currently it feels like await retrievePageFaviconsFromBrowser ought to return something.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to do similarly to what we already do for doSymbolicateProfile above:

export async function doSymbolicateProfile(
dispatch: Dispatch,
profile: Profile,
symbolStore: SymbolStore
) {
dispatch(startSymbolicating());
const completionPromises = [];
await symbolicateProfile(
profile,
symbolStore,
(
threadIndex: ThreadIndex,
symbolicationStepInfo: SymbolicationStepInfo
) => {
completionPromises.push(
new Promise((resolve) => {
_symbolicationStepQueueSingleton.enqueueSingleSymbolicationStep(
dispatch,
threadIndex,
symbolicationStepInfo,
resolve
);
})
);
}
);
await Promise.all(completionPromises);
dispatch(doneSymbolicating());
}

Both of them are called right after each other. So I would prefer to keep their implementation similar to keep them uniform in the function where we call them:

await doSymbolicateProfile(dispatch, profile, symbolStore);
}
}
if (browserConnection && pages && pages.length > 0) {
await retrievePageFaviconsFromBrowser(dispatch, pages, browserConnection);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mmm I see (and I was the one who implemented doSymbolicateProfile initially so I can't blame anyone else :-) )

The difference I see though, is that doSymbolicateProfile starts the symbolication proces which can take long, while retrievePageFaviconsFromBrowser is a more direct process. But I admit the difference is thin.

I guess seeing that we pass dispatch is good enough to know that actions may be dispatched.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes me think that we might want to run doSymbolicateProfile and retrievePageFaviconsFromBrowser in parallel. Or at least do retrievePageFaviconsFromBrowser first, because it's likely much faster than the symbolication, and it may happen that users share their profile before the symbolication ends.

pages: PageList,
browserConnection: BrowserConnection
) {
const newPages = [...pages];

await browserConnection
.getPageFavicons(newPages.map((p) => p.url))
.then((favicons) => {
canova marked this conversation as resolved.
Show resolved Hide resolved
if (newPages.length !== favicons.length) {
// It appears that an error occurred since the pages and favicons arrays
// have different lengths. Return early without doing anything.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if that's not normal, it would be good to output a warning or an error to the console so that it's not silently swallowed.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could also happen in the case where the backend doesn't support the new webchannel request yet and we fallback to an empty array in the webchannel logic. I would prefer to not pollute the console for now. But we might want to add for later.

return;
}

for (let index = 0; index < favicons.length; index++) {
newPages[index] = {
...newPages[index],
favicon: favicons[index],
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(optional)

Suggested change
for (let index = 0; index < favicons.length; index++) {
newPages[index] = {
...newPages[index],
favicon: favicons[index],
};
for (let index = 0; index < favicons.length; index++) {
if (favicons[index]){
newPages[index] = {
...newPages[index],
favicon: favicons[index],
};
}

so that we don't add unneeded content to the profile.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you follow my suggestion from https://phabricator.services.mozilla.com/D225197, that is the WebChannel should return the binary data + the mimetype, then here is the location where you'll generate base64 data using the FIleReader.

I was also wondering it we should store the data url, or a pair (base64 + mimetype), in the profile JSON. But I think the data url makes sense as it contains both these information.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah it think it's easier/simpler to keep a single data url string that contains the both information.

}
});

dispatch({
type: 'UPDATE_PAGES',
newPages,
});
}

// From a BrowserConnectionStatus, this unwraps the included browserConnection
// when possible.
export function unwrapBrowserConnection(
Expand Down
16 changes: 16 additions & 0 deletions src/app-logic/browser-connection.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getSymbolTableViaWebChannel,
queryWebChannelVersionViaWebChannel,
querySymbolicationApiViaWebChannel,
getPageFaviconsViaWebChannel,
} from './web-channel';
import type { Milliseconds } from 'firefox-profiler/types';

Expand Down Expand Up @@ -68,6 +69,8 @@ export interface BrowserConnection {
debugName: string,
breakpadId: string
): Promise<SymbolTableAsTuple>;

getPageFavicons(pageUrls: Array<string>): Promise<Array<string | null>>;
}

/**
Expand All @@ -81,12 +84,14 @@ class BrowserConnectionImpl implements BrowserConnection {
_webChannelSupportsGetProfileAndSymbolication: boolean;
_webChannelSupportsGetExternalPowerTracks: boolean;
_webChannelSupportsGetExternalMarkers: boolean;
_webChannelSupportsGetPageFavicons: boolean;
_geckoProfiler: $GeckoProfiler | void;

constructor(webChannelVersion: number) {
this._webChannelSupportsGetProfileAndSymbolication = webChannelVersion >= 1;
this._webChannelSupportsGetExternalPowerTracks = webChannelVersion >= 2;
this._webChannelSupportsGetExternalMarkers = webChannelVersion >= 3;
this._webChannelSupportsGetPageFavicons = webChannelVersion >= 4;
}

// Only called when we must obtain the profile from the browser, i.e. if we
Expand Down Expand Up @@ -181,6 +186,17 @@ class BrowserConnectionImpl implements BrowserConnection {
'Cannot obtain a symbol table: have neither WebChannel nor a GeckoProfiler object'
);
}

async getPageFavicons(
pageUrls: Array<string>
): Promise<Array<string | null>> {
// This is added in Firefox 133.
if (this._webChannelSupportsGetPageFavicons) {
return getPageFaviconsViaWebChannel(pageUrls);
}

return [];
}
}

// Should work with:
Expand Down
23 changes: 21 additions & 2 deletions src/app-logic/web-channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ export type Request =
| GetExternalMarkersRequest
| GetExternalPowerTracksRequest
| GetSymbolTableRequest
| QuerySymbolicationApiRequest;
| QuerySymbolicationApiRequest
| GetPageFaviconsRequest;

type StatusQueryRequest = {| type: 'STATUS_QUERY' |};
type EnableMenuButtonRequest = {| type: 'ENABLE_MENU_BUTTON' |};
Expand All @@ -52,6 +53,10 @@ type QuerySymbolicationApiRequest = {|
path: string,
requestJson: string,
|};
type GetPageFaviconsRequest = {|
type: 'GET_PAGE_FAVICONS',
pageUrls: Array<string>,
|};

export type MessageFromBrowser<R: ResponseFromBrowser> =
| OutOfBandErrorMessageFromBrowser
Expand Down Expand Up @@ -82,7 +87,8 @@ export type ResponseFromBrowser =
| GetExternalMarkersResponse
| GetExternalPowerTracksResponse
| GetSymbolTableResponse
| QuerySymbolicationApiResponse;
| QuerySymbolicationApiResponse
| GetPageFaviconsResponse;

type StatusQueryResponse = {|
menuButtonIsEnabled: boolean,
Expand Down Expand Up @@ -114,6 +120,7 @@ type GetExternalMarkersResponse = ExternalMarkersData;
type GetExternalPowerTracksResponse = MixedObject[];
type GetSymbolTableResponse = SymbolTableAsTuple;
type QuerySymbolicationApiResponse = string;
type GetPageFaviconsResponse = Array<string | null>;

// Manually declare all pairs of request + response for Flow.
/* eslint-disable no-redeclare */
Expand All @@ -138,6 +145,9 @@ declare function _sendMessageWithResponse(
declare function _sendMessageWithResponse(
QuerySymbolicationApiRequest
): Promise<QuerySymbolicationApiResponse>;
declare function _sendMessageWithResponse(
GetPageFaviconsRequest
): Promise<GetPageFaviconsResponse>;
/* eslint-enable no-redeclare */

/**
Expand Down Expand Up @@ -226,6 +236,15 @@ export async function querySymbolicationApiViaWebChannel(
});
}

export async function getPageFaviconsViaWebChannel(
pageUrls: Array<string>
): Promise<GetPageFaviconsResponse> {
return _sendMessageWithResponse({
type: 'GET_PAGE_FAVICONS',
pageUrls,
});
}

/**
* -----------------------------------------------------------------------------
*
Expand Down
3 changes: 1 addition & 2 deletions src/components/app/ProfileFilterNavigator.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,7 @@ class ProfileFilterNavigatorBarImpl extends React.PureComponent<Props> {
// profile or when the page information is empty. This could happen for
// older profiles and profiles from external importers that don't have
// this information.
// eslint-disable-next-line no-constant-condition
if (false && pageDataByTabID && pageDataByTabID.size > 0) {
if (pageDataByTabID && pageDataByTabID.size > 0) {
const pageData =
tabFilter !== null ? pageDataByTabID.get(tabFilter) : null;

Expand Down
6 changes: 3 additions & 3 deletions src/components/app/ProfileViewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { BackgroundImageStyleDef } from 'firefox-profiler/components/shared/Styl
import classNames from 'classnames';
import { DebugWarning } from 'firefox-profiler/components/app/DebugWarning';

import type { CssPixels, IconWithClassName } from 'firefox-profiler/types';
import type { CssPixels, IconsWithClassNames } from 'firefox-profiler/types';
import type { ConnectedProps } from 'firefox-profiler/utils/connect';

import './ProfileViewer.css';
Expand All @@ -50,7 +50,7 @@ type StateProps = {|
+isUploading: boolean,
+isHidingStaleProfile: boolean,
+hasSanitizedProfile: boolean,
+icons: IconWithClassName[],
+icons: IconsWithClassNames,
+isBottomBoxOpen: boolean,
|};

Expand Down Expand Up @@ -83,7 +83,7 @@ class ProfileViewerImpl extends PureComponent<Props> {
profileViewerWrapperBackground: hasSanitizedProfile,
})}
>
{icons.map(({ className, icon }) => (
{[...icons].map(([icon, className]) => (
<BackgroundImageStyleDef
className={className}
url={icon}
Expand Down
4 changes: 4 additions & 0 deletions src/components/shared/TabSelectorMenu.css
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@
/* Move the checkmark to inline-start instead of right, as it's logically better. */
inset-inline: 8px 0;
}

.tabSelectorMenuItem .nodeIcon {
margin-inline-end: 10px;
}
6 changes: 6 additions & 0 deletions src/components/shared/TabSelectorMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import explicitConnect from 'firefox-profiler/utils/connect';
import { changeTabFilter } from 'firefox-profiler/actions/receive-profile';
import { getTabFilter } from '../../selectors/url-state';
import { getProfileFilterSortedPageData } from 'firefox-profiler/selectors/profile';
import { Icon } from 'firefox-profiler/components/shared/Icon';

import type { TabID, SortedTabPageData } from 'firefox-profiler/types';
import type { ConnectedProps } from 'firefox-profiler/utils/connect';
Expand Down Expand Up @@ -41,6 +42,10 @@ class TabSelectorMenuImpl extends React.PureComponent<Props> {
return null;
}

const hasSomeIcons = sortedPageData.some(
({ pageData }) => !!pageData.favicon
);

return (
<>
<MenuItem
Expand Down Expand Up @@ -71,6 +76,7 @@ class TabSelectorMenuImpl extends React.PureComponent<Props> {
'aria-checked': tabFilter === tabID ? 'false' : 'true',
}}
>
{hasSomeIcons ? <Icon iconUrl={pageData.favicon} /> : null}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still think we should have some nice placeholder when there's no icon, instead of emptiness. But we can look at this later.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I would like to have a placeholder as well. I can work on it as a followup.

Do you have any ideas for possible icons that we can put it here?
Maybe "Globe" for the websites: https://icons.design.firefox.com/viewer/#globe
and "Extensions" icon for the webextensions: https://icons.design.firefox.com/viewer/#extensions
What do you think?

{pageData.hostname}
</MenuItem>
))}
Expand Down
14 changes: 4 additions & 10 deletions src/profile-logic/profile-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -2963,7 +2963,8 @@ export function extractProfileFilterPageData(
}

// The last page is the one we care about.
const pageUrl = topMostPages[topMostPages.length - 1].url;
const currentPage = topMostPages[topMostPages.length - 1];
const pageUrl = currentPage.url;
if (pageUrl.startsWith('about:')) {
// If we only have an `about:*` page, we should return early with a friendly
// origin and hostname. Otherwise the try block will always fail.
Expand All @@ -2986,7 +2987,7 @@ export function extractProfileFilterPageData(
const pageData: ProfileFilterPageData = {
origin: '',
hostname: '',
favicon: null,
favicon: currentPage.favicon ?? null,
};

try {
Expand All @@ -3005,15 +3006,8 @@ export function extractProfileFilterPageData(
) ?? '')
: page.hostname;

// FIXME(Bug 1620546): This is not ideal and we should get the favicon
// either during profile capture or profile pre-process.
pageData.origin = page.origin;
const favicon = new URL('/favicon.ico', page.origin);
if (favicon.protocol === 'http:') {
// Upgrade http requests.
favicon.protocol = 'https:';
}
pageData.favicon = favicon.href;
pageData.favicon = currentPage.favicon ?? null;
canova marked this conversation as resolved.
Show resolved Hide resolved
} catch (e) {
console.warn(
'Error while extracing the hostname and favicon from the page url',
Expand Down
2 changes: 2 additions & 0 deletions src/profile-logic/sanitize.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ export function sanitizePII(
pages = pages.map((page, pageIndex) => ({
...page,
url: removeURLs(page.url, `<Page #${pageIndex}>`),
// Remove the favicon data as it could reveal the url.
favicon: null,
}));
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/reducers/icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
// @flow
import type { Reducer } from 'firefox-profiler/types';

const favicons: Reducer<Set<string>> = (state = new Set(), action) => {
const favicons: Reducer<Map<string, string>> = (state = new Map(), action) => {
canova marked this conversation as resolved.
Show resolved Hide resolved
switch (action.type) {
case 'ICON_HAS_LOADED':
return new Set([...state, action.icon]);
return new Map([...state.entries(), action.iconWithClassName]);
canova marked this conversation as resolved.
Show resolved Hide resolved
case 'ICON_IN_ERROR': // nothing to do
default:
return state;
Expand Down
10 changes: 10 additions & 0 deletions src/reducers/profile-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ const profile: Reducer<Profile | null> = (state = null, action) => {
},
};
}
case 'UPDATE_PAGES': {
if (state === null) {
throw new Error(
`Assumed that a profile would be loaded by the time for the pages update`
canova marked this conversation as resolved.
Show resolved Hide resolved
);
}

const { newPages } = action;
return { ...state, pages: newPages };
}
default:
return state;
}
Expand Down
Loading