forked from google/perfetto
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathpost_message_handler.ts
More file actions
370 lines (333 loc) · 12 KB
/
post_message_handler.ts
File metadata and controls
370 lines (333 loc) · 12 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
// 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 {showModal} from '../widgets/modal';
import {initCssConstants} from './css_constants';
import {toggleHelp} from './help_modal';
import {AppImpl} from '../core/app_impl';
import {SerializedAppState} from '../core/state_serialization_schema';
import {parseAppState} from '../core/state_serialization';
import {BUCKET_NAME} from '../base/gcs_uploader';
import {embedderContext} from '../core/embedder';
const TRUSTED_ORIGINS_KEY = 'trustedOrigins';
interface PostedTrace {
buffer: ArrayBuffer;
title: string;
fileName?: string;
url?: string;
// The hash of the app state to load from GCS after the trace is loaded
appStateHash?: string;
// if |localOnly| is true then the trace should not be shared or downloaded.
localOnly?: boolean;
keepApiOpen?: boolean;
// Allows to pass extra arguments to plugins. This can be read by plugins
// onTraceLoad() and can be used to trigger plugin-specific-behaviours (e.g.
// allow dashboards like APC to pass extra data to materialize onto tracks).
// The format is the following:
// pluginArgs: {
// 'dev.perfetto.PluginFoo': { 'key1': 'value1', 'key2': 1234 }
// 'dev.perfetto.PluginBar': { 'key3': '...', 'key4': ... }
// }
pluginArgs?: {[pluginId: string]: {[key: string]: unknown}};
}
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.endsWith('.proxy.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(AppImpl.instance.trace ?? AppImpl.instance);
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 if (embedderContext?.postMessageHandler?.(messageEvent) === true) {
return;
} 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 = async () => {
// Maybe load the app state from the URL.
let appState: SerializedAppState | undefined;
if (postedTrace.appStateHash) {
const url = `https://storage.googleapis.com/${BUCKET_NAME}/${postedTrace.appStateHash}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to fetch app state from ${url}: ` +
`${response.status} ${response.statusText}`,
);
}
const json = (await response.json()).appState;
const parsedState = parseAppState(json);
if (parsedState.ok) {
appState = parsedState.value;
}
}
AppImpl.instance.openTraceFromBuffer(postedTrace, appState);
};
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,
// For external traces, we need to disable other features such as
// downloading and sharing a trace, unless the caller allows it.
localOnly: postedTrace.localOnly ?? true,
appStateHash: postedTrace.appStateHash,
pluginArgs: postedTrace.pluginArgs,
};
if (postedTrace.url !== undefined) {
result.url = sanitizeString(postedTrace.url);
}
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 app = AppImpl.instance;
const trace = app.trace;
if (trace && !app.isTraceLoading(trace.traceInfo.source)) {
const start = Time.fromSeconds(postedScrollToRange.timeStart);
const end = Time.fromSeconds(postedScrollToRange.timeEnd);
trace.scrollTo({
time: {start, end, viewPercentage: postedScrollToRange.viewPercentage},
});
} else {
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);
}
}
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
);
}