ui: add ability to open traces via POST /_open_trace/xxxx

This change makes it possible to open traces by simply doing
a HTTP POST operation on https://ui.perfetto.dev/_open_trace/
via a <form>. This allows:
1. Easier integration from other dashboards, even in presence
   of cross-origin isolation (which applies to fetch, but not
   <form>) without requiring popups.
2. Allows in future to solve the teardown problem and open a
   2nd trace in the UI by replacing the current window with a
   fresh UI state (a follow up CL is required for this).

The main downside is that this works only after the serviceworker
has been registered. In other words, this works only after the
user has visited ui.perfetto.dev once.
I think this is easily solvable by adding an error page to
handle /_open_trace on ui.perfetto.dev that explains what's
happening.

Bug: 337296884
Change-Id: I543b76481c400cfb4e9947252ce89964655e247e
diff --git a/ui/build.js b/ui/build.js
index 698842c..56745df 100644
--- a/ui/build.js
+++ b/ui/build.js
@@ -110,11 +110,13 @@
   outDistDir: '',
   outExtDir: '',
   outBigtraceDistDir: '',
+  outOpenPerfettoTraceDistDir: '',
 };
 
 const RULES = [
   {r: /ui\/src\/assets\/index.html/, f: copyIndexHtml},
   {r: /ui\/src\/assets\/bigtrace.html/, f: copyBigtraceHtml},
+  {r: /ui\/src\/open_perfetto_trace\/index.html/, f: copyOpenPerfettoTraceHtml},
   {r: /ui\/src\/assets\/((.*)[.]png)/, f: copyAssets},
   {r: /buildtools\/typefaces\/(.+[.]woff2)/, f: copyAssets},
   {r: /buildtools\/catapult_trace_viewer\/(.+(js|html))/, f: copyAssets},
@@ -149,6 +151,7 @@
   parser.add_argument('--run-integrationtests', '-T', {action: 'store_true'});
   parser.add_argument('--debug', '-d', {action: 'store_true'});
   parser.add_argument('--bigtrace', {action: 'store_true'});
+  parser.add_argument('--open-perfetto-trace', {action: 'store_true'});
   parser.add_argument('--interactive', '-i', {action: 'store_true'});
   parser.add_argument('--rebaseline', '-r', {action: 'store_true'});
   parser.add_argument('--no-depscheck', {action: 'store_true'});
@@ -175,11 +178,16 @@
   cfg.verbose = !!args.verbose;
   cfg.debug = !!args.debug;
   cfg.bigtrace = !!args.bigtrace;
+  cfg.openPerfettoTrace = !!args.open_perfetto_trace;
   cfg.startHttpServer = args.serve;
   cfg.noOverrideGnArgs = !!args.no_override_gn_args;
   if (args.bigtrace) {
     cfg.outBigtraceDistDir = ensureDir(pjoin(cfg.outDistDir, 'bigtrace'));
   }
+  if (cfg.openPerfettoTrace) {
+    cfg.outOpenPerfettoTraceDistDir = ensureDir(pjoin(cfg.outDistRootDir,
+                                                      'open_perfetto_trace'));
+  }
   if (args.serve_host) {
     cfg.httpServerListenHost = args.serve_host;
   }
@@ -247,17 +255,25 @@
     generateImports('ui/src/plugins', 'all_plugins.ts');
     compileProtos();
     genVersion();
-    transpileTsProject('ui');
-    transpileTsProject('ui/src/service_worker');
-    if (cfg.bigtrace) {
-      transpileTsProject('ui/src/bigtrace');
+
+    const tsProjects = [
+      'ui',
+      'ui/src/service_worker'
+    ];
+    if (cfg.bigtrace) tsProjects.push('ui/src/bigtrace');
+    if (cfg.openPerfettoTrace) {
+      scanDir('ui/src/open_perfetto_trace');
+      tsProjects.push('ui/src/open_perfetto_trace');
+    }
+
+
+    for (const prj of tsProjects) {
+      transpileTsProject(prj);
     }
 
     if (cfg.watch) {
-      transpileTsProject('ui', {watch: cfg.watch});
-      transpileTsProject('ui/src/service_worker', {watch: cfg.watch});
-      if (cfg.bigtrace) {
-        transpileTsProject('ui/src/bigtrace', {watch: cfg.watch});
+      for (const prj of tsProjects) {
+        transpileTsProject(prj, {watch: cfg.watch});
       }
     }
 
@@ -350,6 +366,12 @@
   }
 }
 
+function copyOpenPerfettoTraceHtml(src) {
+  if (cfg.openPerfettoTrace) {
+    addTask(cp, [src, pjoin(cfg.outOpenPerfettoTraceDistDir, 'index.html')]);
+  }
+}
+
 function copyAssets(src, dst) {
   addTask(cp, [src, pjoin(cfg.outDistDir, 'assets', dst)]);
   if (cfg.bigtrace) {
@@ -519,6 +541,9 @@
   if (cfg.bigtrace) {
     args.push('--environment', 'ENABLE_BIGTRACE:true');
   }
+  if (cfg.openPerfettoTrace) {
+    args.push('--environment', 'ENABLE_OPEN_PERFETTO_TRACE:true');
+  }
   args.push(...(cfg.verbose ? [] : ['--silent']));
   if (cfg.watch) {
     // --waitForBundleInput is sadly quite busted so it is required ts
diff --git a/ui/config/rollup.config.js b/ui/config/rollup.config.js
index f87a99e..144f5d9 100644
--- a/ui/config/rollup.config.js
+++ b/ui/config/rollup.config.js
@@ -97,10 +97,15 @@
     [defBundle('tsc/bigtrace', 'bigtrace', 'dist_version/bigtrace')] :
     [];
 
+const maybeOpenPerfettoTrace = process.env['ENABLE_OPEN_PERFETTO_TRACE'] ?
+    [defBundle('tsc', 'open_perfetto_trace', 'dist/open_perfetto_trace')] :
+    [];
+
+
 export default [
   defBundle('tsc', 'frontend', 'dist_version'),
   defBundle('tsc', 'engine', 'dist_version'),
   defBundle('tsc', 'traceconv', 'dist_version'),
   defBundle('tsc', 'chrome_extension', 'chrome_extension'),
   defServiceWorkerBundle(),
-].concat(maybeBigtrace);
+].concat(maybeBigtrace).concat(maybeOpenPerfettoTrace);
diff --git a/ui/src/bigtrace/index.ts b/ui/src/bigtrace/index.ts
index d54bd87..739594e 100644
--- a/ui/src/bigtrace/index.ts
+++ b/ui/src/bigtrace/index.ts
@@ -18,7 +18,7 @@
 import m from 'mithril';
 
 import {defer} from '../base/deferred';
-import {reportError, setErrorHandler} from '../base/logging';
+import {reportError, addErrorHandler, ErrorDetails} from '../base/logging';
 import {initLiveReloadIfLocalhost} from '../core/live_reload';
 import {raf} from '../core/raf_scheduler';
 import {setScheduleFullRedraw} from '../widgets/raf';
@@ -92,7 +92,7 @@
   document.head.append(css);
 
   // Add Error handlers for JS error and for uncaught exceptions in promises.
-  setErrorHandler((err: string) => console.log(err));
+  addErrorHandler((err: ErrorDetails) => console.log(err.message, err.stack));
   window.addEventListener('error', (e) => reportError(e));
   window.addEventListener('unhandledrejection', (e) => reportError(e));
 
diff --git a/ui/src/open_perfetto_trace/index.html b/ui/src/open_perfetto_trace/index.html
new file mode 100644
index 0000000..1fb52e3
--- /dev/null
+++ b/ui/src/open_perfetto_trace/index.html
@@ -0,0 +1,59 @@
+<!doctype html>
+<html lang='en-us'>
+<head>
+  <script src="open_perfetto_trace_bundle.js"></script>
+  <style type="text/css">
+  html { font-family: Roboto, sans-serif; }
+  main {display: flex; flex-direction: column; max-width: 800px;}
+  main > * { margin: 5px; }
+  </style>
+</head>
+<body>
+
+
+<main>
+  <select id='trace_source' size='5'>
+    <option>https://storage.googleapis.com/perfetto-misc/example_android_trace_15s</option>
+    <option selected>https://storage.googleapis.com/perfetto-misc/chrome_example_wikipedia.perfetto_trace.gz</option>
+  </select>
+  <label>Or select a local file: <input type="file" id="file"></label>
+  <input type='button' value='Fetch and open selected trace' id='fetch'>
+  <label><input type='checkbox' id='show_progress' checked="checked">Show progress dialog</label>
+  <label><input type='checkbox' id='new_tab'>Open in new tab</label>
+  <label><input type='checkbox' id='hide_sidebar'>Hide sidebar in Perfetto UI</label>
+
+</main>
+
+<script type='text/javascript'>
+
+function getCheckbox(id) {
+  return document.getElementById(id).checked;
+}
+
+document.getElementById('fetch').addEventListener('click', () => {
+  const opts = {};
+
+  if (location.host.startsWith('127.0.0.1') ||
+      location.host.startsWith('localhost')) {
+        opts.uiUrl = `${location.protocol}//${location.host}`;
+  }
+
+  opts.statusDialog = getCheckbox('show_progress');
+  opts.newTab = getCheckbox('new_tab');
+  opts.hideSidebar = getCheckbox('hide_sidebar');
+
+  const fileInput = document.getElementById('file');
+  let traceSource;
+  if (fileInput.files.length > 0) {
+    traceSource = fileInput.files[0]
+  } else {
+    traceSource = document.getElementById('trace_source').value;
+  }
+
+  open_perfetto_trace(traceSource, opts);
+});
+
+</script>
+</body>
+</html>
+
diff --git a/ui/src/open_perfetto_trace/index.ts b/ui/src/open_perfetto_trace/index.ts
new file mode 100644
index 0000000..5462783
--- /dev/null
+++ b/ui/src/open_perfetto_trace/index.ts
@@ -0,0 +1,191 @@
+// Copyright (C) 2024 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.
+
+// open-perfetto-trace is a standalone JS/TS library that can be used in other
+// projects to facilitate the deep linking into perfetto. It allows opening
+// trace files or URL with ui.perfetto.dev, handling all the handshake with it.
+
+const PERFETTO_UI_URL = 'https://ui.perfetto.dev';
+
+interface OpenTraceOptions {
+  // If true (default) shows a popup dialog with a progress bar that informs
+  // about the status of the fetch. This is only relevant when the trace source
+  // is a url.
+  statusDialog?: boolean;
+
+  // Opens the trace in a new tab.
+  newTab?: boolean;
+
+  // Override the referrer. Useful for scripts such as
+  // record_android_trace to record where the trace is coming from.
+  referrer?: string;
+
+  // For the 'mode' of the UI. For example when the mode is 'embedded'
+  // some features are disabled.
+  mode: 'embedded' | undefined;
+
+  // Hides the sidebar in the opened perfetto UI.
+  hideSidebar?: boolean;
+
+  ts?: string;
+
+  dur?: string;
+  tid?: string;
+  pid?: string;
+  query?: string;
+  visStart?: string;
+  visEnd?: string;
+
+  // Used to override ui.perfetto.dev with a custom hosted URL.
+  // Useful for testing.
+  uiUrl?: string;
+}
+
+// Opens a trace in the Perfetto UI.
+// `source` can be either:
+// - A blob (e.g. a File).
+// - A URL.
+export default function openPerfettoTrace(
+  source: Blob | string,
+  opts?: OpenTraceOptions,
+) {
+  if (source instanceof Blob) {
+    return openTraceBlob(source, opts);
+  } else if (typeof source === 'string') {
+    return fetchAndOpenTrace(source, opts);
+  }
+}
+
+function openTraceBlob(blob: Blob, opts?: OpenTraceOptions) {
+  const form = document.createElement('form');
+  form.method = 'POST';
+  form.style.visibility = 'hidden';
+  form.enctype = 'multipart/form-data';
+  const uiUrl = opts?.uiUrl ?? PERFETTO_UI_URL;
+  form.action = `${uiUrl}/_open_trace/${Date.now()}`;
+  if (opts?.newTab === true) {
+    form.target = '_blank';
+  }
+  const fileInput = document.createElement('input');
+  fileInput.name = 'trace';
+  fileInput.type = 'file';
+  const dataTransfer = new DataTransfer();
+  dataTransfer.items.add(new File([blob], 'trace.file'));
+  fileInput.files = dataTransfer.files;
+  form.appendChild(fileInput);
+  for (const [key, value] of Object.entries(opts ?? {})) {
+    const varInput = document.createElement('input');
+    varInput.type = 'hidden';
+    varInput.name = key;
+    varInput.value = value;
+    form.appendChild(varInput);
+  }
+  document.body.appendChild(form);
+  form.submit();
+}
+
+function fetchAndOpenTrace(url: string, opts?: OpenTraceOptions) {
+  updateProgressDiv({status: 'Fetching trace'}, opts);
+  const xhr = new XMLHttpRequest();
+  xhr.addEventListener('progress', (event) => {
+    if (event.lengthComputable) {
+      updateProgressDiv(
+        {
+          status: `Fetching trace (${Math.round(event.loaded / 1000)} KB)`,
+          progress: event.loaded / event.total,
+        },
+        opts,
+      );
+    }
+  });
+  xhr.addEventListener('loadend', () => {
+    if (xhr.readyState === 4 && xhr.status === 200) {
+      const blob = xhr.response as Blob;
+      updateProgressDiv({status: 'Opening trace'}, opts);
+      openTraceBlob(blob, opts);
+      updateProgressDiv({close: true}, opts);
+    }
+  });
+  xhr.addEventListener('error', () => {
+    updateProgressDiv({status: 'Failed to fetch trace'}, opts);
+  });
+  xhr.responseType = 'blob';
+  xhr.overrideMimeType('application/octet-stream');
+  xhr.open('GET', url);
+  xhr.send();
+}
+
+interface ProgressDivOpts {
+  status?: string;
+  progress?: number;
+  close?: boolean;
+}
+
+function updateProgressDiv(progress: ProgressDivOpts, opts?: OpenTraceOptions) {
+  if (opts?.statusDialog === false) return;
+
+  const kDivId = 'open_perfetto_trace';
+  let div = document.getElementById(kDivId);
+  if (!div) {
+    div = document.createElement('div');
+    div.id = kDivId;
+    div.style.all = 'initial';
+    div.style.position = 'fixed';
+    div.style.bottom = '10px';
+    div.style.left = '0';
+    div.style.right = '0';
+    div.style.width = 'fit-content';
+    div.style.height = '20px';
+    div.style.padding = '10px';
+    div.style.zIndex = '99';
+    div.style.margin = 'auto';
+    div.style.backgroundColor = '#fff';
+    div.style.color = '#333';
+    div.style.fontFamily = 'monospace';
+    div.style.fontSize = '12px';
+    div.style.border = '1px solid #eee';
+    div.style.boxShadow = '0 0 20px #aaa';
+    div.style.display = 'flex';
+    div.style.flexDirection = 'column';
+
+    const title = document.createElement('div');
+    title.className = 'perfetto-open-title';
+    title.innerText = 'Opening perfetto trace';
+    title.style.fontWeight = '12px';
+    title.style.textAlign = 'center';
+    div.appendChild(title);
+
+    const progressbar = document.createElement('progress');
+    progressbar.className = 'perfetto-open-progress';
+    progressbar.style.width = '200px';
+    progressbar.value = 0;
+    div.appendChild(progressbar);
+
+    document.body.appendChild(div);
+  }
+  const title = div.querySelector('.perfetto-open-title') as HTMLElement;
+  if (progress.status !== undefined) {
+    title.innerText = progress.status;
+  }
+  const bar = div.querySelector('.perfetto-open-progress') as HTMLInputElement;
+  if (progress.progress === undefined) {
+    bar.style.visibility = 'hidden';
+  } else {
+    bar.style.visibility = 'visible';
+    bar.value = `${progress.progress}`;
+  }
+  if (progress.close === true) {
+    div.remove();
+  }
+}
diff --git a/ui/src/open_perfetto_trace/tsconfig.json b/ui/src/open_perfetto_trace/tsconfig.json
new file mode 100644
index 0000000..66bf290
--- /dev/null
+++ b/ui/src/open_perfetto_trace/tsconfig.json
@@ -0,0 +1,16 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "include": [ "." ],
+  "exclude": [
+    "../gen/"
+  ],
+  "compilerOptions": {
+    "outDir": "../../out/tsc/open_perfetto_trace",
+    "lib": [
+      "dom",                               // Need to be explicitly mentioned now since we're overriding default included libs.
+      "es2021",                            // Need this to use Promise.allSettled, replaceAll, etc
+    ],
+    "esModuleInterop": true,
+    "allowSyntheticDefaultImports": true,
+  }
+}
diff --git a/ui/src/service_worker/service_worker.ts b/ui/src/service_worker/service_worker.ts
index dc467f0..699db43 100644
--- a/ui/src/service_worker/service_worker.ts
+++ b/ui/src/service_worker/service_worker.ts
@@ -45,6 +45,7 @@
 
 const LOG_TAG = `ServiceWorker: `;
 const CACHE_NAME = 'ui-perfetto-dev';
+const OPEN_TRACE_PREFIX = '/_open_trace'
 
 // If the fetch() for the / doesn't respond within 3s, return a cached version.
 // This is to avoid that a user waits too much if on a flaky network.
@@ -54,6 +55,9 @@
 // in the background.
 const INSTALL_TIMEOUT_MS = 30000;
 
+// Files passed to POST /_open_trace/NNNN.
+let postedFiles = new Map<string, File>();
+
 // The install() event is fired:
 // 1. On the first visit, when there is no SW installed.
 // 2. Every time the user opens the site and the version has been updated (they
@@ -128,6 +132,7 @@
 });
 
 self.addEventListener('fetch', (event) => {
+
   // The early return here will cause the browser to fall back on standard
   // network-based fetch.
   if (!shouldHandleHttpRequest(event.request)) {
@@ -149,6 +154,8 @@
 
   const url = new URL(req.url);
   if (url.pathname === '/live_reload') return false;
+  if (url.pathname.startsWith(OPEN_TRACE_PREFIX)) return true;
+
   return req.method === 'GET' && url.origin === self.location.origin;
 }
 
@@ -184,6 +191,8 @@
     // network fetch.
     const cachedRes = await caches.match(new Request('/'), cacheOps);
     if (cachedRes) return cachedRes;
+  } else if (url.pathname.startsWith(OPEN_TRACE_PREFIX)) {
+    return await handleOpenTraceRequest(req);
   }
 
   const cachedRes = await caches.match(req, cacheOps);
@@ -198,6 +207,60 @@
   return fetch(req);
 }
 
+// Handles GET and POST requests to /_open_trace/NNNN, where NNNN is typically a
+// random token generated by the client.
+// This works as follows:
+// - The client does a POST request to /_open_trace/NNNN passing the trace blob
+//   as multipart-data, alongside other options like hideSidebar & co that we
+//   support in the usual querystring (see router.ts)
+// - The SW takes the file and puts it in the global variable `postedFiles`.
+// - The SW responds to the POST request with a redirect to
+//   ui.perfetto.dev/#!/?url=https://ui.perfetto.dev/_open_trace/NNNN&other_args
+// - When the new ui.perfetto.dev is reloaded, it will naturally try to fetch
+//   the trace from /_open_trace/NNNN, this time via a GET request.
+// - The SW intercepts the GET request and returns the file previosly stored in
+//   `postedFiles`.
+// We use postedFiles here to handle the case of progammatically POST-ing to >1
+// instances of ui.perfetto.dev simultaneously, to avoid races.
+// Note that we should not use a global variable for `postedFiles` but we should
+// use the CacheAPI because, technically speaking, the SW could be disposed
+// and respawned in between the POST and the GET request. In practice, however,
+// SWs are disposed only after 30s seconds of idleness. The POST->GET requests
+// happen back-to-back..
+async function handleOpenTraceRequest(req: Request): Promise<Response> {
+  const url = new URL(req.url);
+  console.assert(url.pathname.startsWith(OPEN_TRACE_PREFIX));
+  const fileKey = url.pathname.substring(OPEN_TRACE_PREFIX.length);
+  if (req.method === 'POST') {
+    const formData = await req.formData();
+    const qsParams = new URLSearchParams();
+    // Iterate over the POST fields and copy them over the querystring in
+    // the hash, with the exception of the trace file. The trace file is
+    // kept in the serviceworker and passed as a url= argument.
+    formData.forEach((value, key) => {
+      if (key === 'trace') {
+        if (value instanceof File) {
+          postedFiles.set(fileKey, value);
+          qsParams.set('url', req.url);
+        }
+        return;
+      }
+      qsParams.set(key, `${value}`);
+    });  // formData.forEach()
+    return Response.redirect(`${url.protocol}//${url.host}/#!/?${qsParams}`);
+  }
+
+  // else... method == 'GET'
+  const file = postedFiles.get(fileKey);
+  if (file !== undefined) {
+    postedFiles.delete(fileKey);
+    return new Response(file);
+  }
+
+  // The file /_open_trace/NNNN does not exist.
+  return Response.error();
+}
+
 async function installAppVersionIntoCache(version: string) {
   const manifestUrl = `${version}/manifest.json`;
   try {