blob: f04bea1240c837ab6417a52a7dde4445e1807267 [file] [log] [blame]
// Copyright (C) 2019 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import {Time} from '../base/time';
import {PostedTrace} from '../core/trace_source';
import {showModal} from '../widgets/modal';
import {initCssConstants} from './css_constants';
import {toggleHelp} from './help_modal';
import {scrollTo} from '../public/scroll_helper';
import {AppImpl} from '../core/app_impl';
const TRUSTED_ORIGINS_KEY = 'trustedOrigins';
interface PostedTraceWrapped {
perfetto: PostedTrace;
}
interface PostedScrollToRangeWrapped {
perfetto: PostedScrollToRange;
}
interface PostedScrollToRange {
timeStart: number;
timeEnd: number;
viewPercentage?: number;
}
// Returns whether incoming traces should be opened automatically or should
// instead require a user interaction.
export function isTrustedOrigin(origin: string): boolean {
const TRUSTED_ORIGINS = [
'https://chrometto.googleplex.com',
'https://uma.googleplex.com',
'https://android-build.googleplex.com',
];
if (origin === window.origin) return true;
if (origin === 'null') return false;
if (TRUSTED_ORIGINS.includes(origin)) return true;
if (isUserTrustedOrigin(origin)) return true;
const hostname = new URL(origin).hostname;
if (hostname.endsWith('.corp.google.com')) return true;
if (hostname.endsWith('.c.googlers.com')) return true;
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '[::1]'
) {
return true;
}
return false;
}
// Returns whether the user saved this as an always-trusted origin.
function isUserTrustedOrigin(hostname: string): boolean {
const trustedOrigins = window.localStorage.getItem(TRUSTED_ORIGINS_KEY);
if (trustedOrigins === null) return false;
try {
return JSON.parse(trustedOrigins).includes(hostname);
} catch {
return false;
}
}
// Saves the given hostname as a trusted origin.
// This is used for user convenience: if it fails for any reason, it's not a
// big deal.
function saveUserTrustedOrigin(hostname: string) {
const s = window.localStorage.getItem(TRUSTED_ORIGINS_KEY);
let origins: string[];
try {
origins = JSON.parse(s ?? '[]');
if (origins.includes(hostname)) return;
origins.push(hostname);
window.localStorage.setItem(TRUSTED_ORIGINS_KEY, JSON.stringify(origins));
} catch (e) {
console.warn('unable to save trusted origins to localStorage', e);
}
}
// Returns whether we should ignore a given message based on the value of
// the 'perfettoIgnore' field in the event data.
function shouldGracefullyIgnoreMessage(messageEvent: MessageEvent) {
return messageEvent.data.perfettoIgnore === true;
}
// The message handler supports loading traces from an ArrayBuffer.
// There is no other requirement than sending the ArrayBuffer as the |data|
// property. However, since this will happen across different origins, it is not
// possible for the source website to inspect whether the message handler is
// ready, so the message handler always replies to a 'PING' message with 'PONG',
// which indicates it is ready to receive a trace.
export function postMessageHandler(messageEvent: MessageEvent) {
if (shouldGracefullyIgnoreMessage(messageEvent)) {
// This message should not be handled in this handler,
// because it will be handled elsewhere.
return;
}
if (messageEvent.origin === 'https://tagassistant.google.com') {
// The GA debugger, does a window.open() and sends messages to the GA
// script. Ignore them.
return;
}
if (document.readyState !== 'complete') {
console.error('Ignoring message - document not ready yet.');
return;
}
const fromOpener = messageEvent.source === window.opener;
const fromIframeHost = messageEvent.source === window.parent;
// This adds support for the folowing flow:
// * A (page that whats to open a trace in perfetto) opens B
// * B (does something to get the traceBuffer)
// * A is navigated to Perfetto UI
// * B sends the traceBuffer to A
// * closes itself
const fromOpenee = (messageEvent.source as WindowProxy).opener === window;
if (
messageEvent.source === null ||
!(fromOpener || fromIframeHost || fromOpenee)
) {
// This can happen if an extension tries to postMessage.
return;
}
if (!('data' in messageEvent)) {
throw new Error('Incoming message has no data property');
}
if (messageEvent.data === 'PING') {
// Cross-origin messaging means we can't read |messageEvent.source|, but
// it still needs to be of the correct type to be able to invoke the
// correct version of postMessage(...).
const windowSource = messageEvent.source as Window;
// Use '*' for the reply because in cases of cross-domain isolation, we
// see the messageEvent.origin as 'null'. PONG doen't disclose any
// interesting information, so there is no harm sending that to the wrong
// origin in the worst case.
windowSource.postMessage('PONG', '*');
return;
}
if (messageEvent.data === 'SHOW-HELP') {
toggleHelp();
return;
}
if (messageEvent.data === 'RELOAD-CSS-CONSTANTS') {
initCssConstants();
return;
}
let postedScrollToRange: PostedScrollToRange;
if (isPostedScrollToRange(messageEvent.data)) {
postedScrollToRange = messageEvent.data.perfetto;
scrollToTimeRange(postedScrollToRange);
return;
}
let postedTrace: PostedTrace;
let keepApiOpen = false;
if (isPostedTraceWrapped(messageEvent.data)) {
postedTrace = sanitizePostedTrace(messageEvent.data.perfetto);
if (postedTrace.keepApiOpen) {
keepApiOpen = true;
}
} else if (messageEvent.data instanceof ArrayBuffer) {
postedTrace = {title: 'External trace', buffer: messageEvent.data};
} else {
console.warn(
'Unknown postMessage() event received. If you are trying to open a ' +
'trace via postMessage(), this is a bug in your code. If not, this ' +
'could be due to some Chrome extension.',
);
console.log('origin:', messageEvent.origin, 'data:', messageEvent.data);
return;
}
if (postedTrace.buffer.byteLength === 0) {
throw new Error('Incoming message trace buffer is empty');
}
if (!keepApiOpen) {
/* Removing this event listener to avoid callers posting the trace multiple
* times. If the callers add an event listener which upon receiving 'PONG'
* posts the trace to ui.perfetto.dev, the callers can receive multiple
* 'PONG' messages and accidentally post the trace multiple times. This was
* part of the cause of b/182502595.
*/
window.removeEventListener('message', postMessageHandler);
}
const openTrace = () => {
// For external traces, we need to disable other features such as
// downloading and sharing a trace.
postedTrace.localOnly = true;
AppImpl.instance.openTraceFromBuffer(postedTrace);
};
const trustAndOpenTrace = () => {
saveUserTrustedOrigin(messageEvent.origin);
openTrace();
};
// If the origin is trusted open the trace directly.
if (isTrustedOrigin(messageEvent.origin)) {
openTrace();
return;
}
// If not ask the user if they expect this and trust the origin.
let originTxt = messageEvent.origin;
let originUnknown = false;
if (originTxt === 'null') {
originTxt = 'An unknown origin';
originUnknown = true;
}
showModal({
title: 'Open trace?',
content: m(
'div',
m('div', `${originTxt} is trying to open a trace file.`),
m('div', 'Do you trust the origin and want to proceed?'),
),
buttons: [
{text: 'No', primary: true},
{text: 'Yes', primary: false, action: openTrace},
].concat(
originUnknown
? []
: {text: 'Always trust', primary: false, action: trustAndOpenTrace},
),
});
}
function sanitizePostedTrace(postedTrace: PostedTrace): PostedTrace {
const result: PostedTrace = {
title: sanitizeString(postedTrace.title),
buffer: postedTrace.buffer,
keepApiOpen: postedTrace.keepApiOpen,
};
if (postedTrace.url !== undefined) {
result.url = sanitizeString(postedTrace.url);
}
result.pluginArgs = postedTrace.pluginArgs;
return result;
}
function sanitizeString(str: string): string {
return str.replace(/[^A-Za-z0-9.\-_#:/?=&;%+$ ]/g, ' ');
}
const _maxScrollToRangeAttempts = 20;
async function scrollToTimeRange(
postedScrollToRange: PostedScrollToRange,
maxAttempts?: number,
) {
const ready = AppImpl.instance.trace && !AppImpl.instance.isLoadingTrace;
if (!ready) {
if (maxAttempts === undefined) {
maxAttempts = 0;
}
if (maxAttempts > _maxScrollToRangeAttempts) {
console.warn('Could not scroll to time range. Trace viewer not ready.');
return;
}
setTimeout(scrollToTimeRange, 200, postedScrollToRange, maxAttempts + 1);
} else {
const start = Time.fromSeconds(postedScrollToRange.timeStart);
const end = Time.fromSeconds(postedScrollToRange.timeEnd);
scrollTo({
time: {start, end, viewPercentage: postedScrollToRange.viewPercentage},
});
}
}
function isPostedScrollToRange(
obj: unknown,
): obj is PostedScrollToRangeWrapped {
const wrapped = obj as PostedScrollToRangeWrapped;
if (wrapped.perfetto === undefined) {
return false;
}
return (
wrapped.perfetto.timeStart !== undefined ||
wrapped.perfetto.timeEnd !== undefined
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isPostedTraceWrapped(obj: any): obj is PostedTraceWrapped {
const wrapped = obj as PostedTraceWrapped;
if (wrapped.perfetto === undefined) {
return false;
}
return (
wrapped.perfetto.buffer !== undefined &&
wrapped.perfetto.title !== undefined
);
}