Merge "TP: Ignore FrameTimeline events with zero timestamp"
diff --git a/CHANGELOG b/CHANGELOG
index cc3e886..36fca95 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -5,6 +5,10 @@
     *
   UI:
     *
+  SDK:
+    * Use process start time hashed with the process id as a unique process
+      identifier, allowing multiple independent users of the SDK in the same
+      process to interleave their events on shared tracks.
 
 
 v12.1 - 2021-02-01:
diff --git a/gn/standalone/write_ui_dist_file_map.py b/gn/standalone/write_ui_dist_file_map.py
deleted file mode 100644
index 20ba344..0000000
--- a/gn/standalone/write_ui_dist_file_map.py
+++ /dev/null
@@ -1,89 +0,0 @@
-#!/usr/bin/env python
-# Copyright (C) 2020 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.
-""" Writes a TypeScript dict that contains SHA256s of the passed files.
-
-The output looks like this:
-{
-  hex_digest: '6c761701c5840483833ffb0bd70ae155b2b3c70e8f667c7bd1f6abc98095930',
-  files: {
-    'frontend_bundle.js': 'sha256-2IVKK/3mEMlDdXNADyK03L1cANKbBpU+xue+vnLOcyo=',
-    'index.html': 'sha256-ZRS1+Xh/dFZeWZi/dz8QMWg/8PYQHNdazsNX2oX8s70=',
-    ...
-  }
-}
-"""
-
-from __future__ import print_function
-
-import argparse
-import base64
-import hashlib
-import os
-import sys
-
-from base64 import b64encode
-
-
-def hash_file(file_path):
-  hasher = hashlib.sha256()
-  with open(file_path, 'rb') as f:
-    for chunk in iter(lambda: f.read(32768), b''):
-      hasher.update(chunk)
-    return file_path, hasher.digest()
-
-
-def hash_list_hex(args):
-  hasher = hashlib.sha256()
-  for arg in args:
-    hasher.update(arg)
-  return hasher.hexdigest()
-
-
-def main():
-  parser = argparse.ArgumentParser()
-  parser.add_argument('--out', help='Path of the output file')
-  parser.add_argument(
-      '--strip', help='Strips the leading path in the generated file list')
-  parser.add_argument('file_list', nargs=argparse.REMAINDER)
-  args = parser.parse_args()
-
-  # Compute the hash of each file.
-  digests = dict(map(hash_file, args.file_list))
-
-  contents = '// __generated_by %s\n' % __file__
-  contents += 'export const UI_DIST_MAP = {\n'
-  contents += '  files: {\n'
-  strip = args.strip + ('' if args.strip[-1] == os.path.sep else os.path.sep)
-  for fname, digest in digests.items():
-    if not fname.startswith(strip):
-      raise Exception('%s must start with %s (--strip arg)' % (fname, strip))
-    fname = fname[len(strip):]
-    # We use b64 instead of hexdigest() because it's handy for handling fetch()
-    # subresource integrity.
-    contents += '    \'%s\': \'sha256-%s\',\n' % (
-        fname, b64encode(digest).decode("ascii"))
-  contents += '  },\n'
-
-  # Compute the hash of the all resources' hashes.
-  contents += '  hex_digest: \'%s\',\n' % hash_list_hex(digests.values())
-  contents += '};\n'
-
-  with open(args.out + '.tmp', 'w') as fout:
-    fout.write(contents)
-  os.rename(args.out + '.tmp', args.out)
-
-
-if __name__ == '__main__':
-  sys.exit(main())
diff --git a/infra/ci/config.py b/infra/ci/config.py
index a512fff..35a141a 100755
--- a/infra/ci/config.py
+++ b/infra/ci/config.py
@@ -110,10 +110,6 @@
         'PERFETTO_TEST_GN_ARGS': '',
         'PERFETTO_TEST_SCRIPT': 'test/ci/bazel_tests.sh',
     },
-    'ui-clang-x86_64-debug': {
-        'PERFETTO_TEST_GN_ARGS': 'is_debug=true',
-        'PERFETTO_TEST_SCRIPT': 'test/ci/ui_tests.sh',
-    },
     'ui-clang-x86_64-release': {
         'PERFETTO_TEST_GN_ARGS': 'is_debug=false',
         'PERFETTO_TEST_SCRIPT': 'test/ci/ui_tests.sh',
diff --git a/infra/ci/frontend/static/script.js b/infra/ci/frontend/static/script.js
index 9f56e3d..f5e83c3 100644
--- a/infra/ci/frontend/static/script.js
+++ b/infra/ci/frontend/static/script.js
@@ -26,7 +26,6 @@
   { id: 'linux-clang-x86-asan_lsan', label: 'x86 {a,l}san' },
   { id: 'linux-clang-x86_64-libfuzzer', label: 'fuzzer' },
   { id: 'linux-clang-x86_64-bazel', label: 'bazel' },
-  { id: 'ui-clang-x86_64-debug', label: 'dbg' },
   { id: 'ui-clang-x86_64-release', label: 'rel' },
   { id: 'android-clang-arm-release', label: 'rel' },
   { id: 'android-clang-arm-asan', label: 'asan' },
@@ -194,16 +193,16 @@
                 m('td[rowspan=4]', 'Status'),
                 m('td[rowspan=4]', 'Owner'),
                 m('td[rowspan=4]', 'Updated'),
-                m('td[colspan=12]', 'Bots'),
+                m('td[colspan=11]', 'Bots'),
               ),
               m('tr',
-                m('td[colspan=10]', 'linux'),
+                m('td[colspan=9]', 'linux'),
                 m('td[colspan=2]', 'android'),
               ),
               m('tr',
                 m('td', 'gcc7'),
                 m('td[colspan=7]', 'clang'),
-                m('td[colspan=2]', 'ui'),
+                m('td[colspan=1]', 'ui'),
                 m('td[colspan=2]', 'clang-arm'),
               ),
               m('tr#cls_header',
diff --git a/infra/perfetto.dev/cloudbuild.yaml b/infra/perfetto.dev/cloudbuild.yaml
index 1acfb31..f8e0889 100644
--- a/infra/perfetto.dev/cloudbuild.yaml
+++ b/infra/perfetto.dev/cloudbuild.yaml
@@ -4,4 +4,5 @@
 - name: gcr.io/perfetto-ui/perfetto-ui-builder
   args:
   - 'infra/perfetto.dev/cloud_build_entrypoint.sh'
-  timeout: 1200s
+# Timeout: 15 min (last measured time in Feb 2021: 2 min)
+timeout: 900s
diff --git a/infra/ui.perfetto.dev/README.md b/infra/ui.perfetto.dev/README.md
new file mode 100644
index 0000000..6006a7f
--- /dev/null
+++ b/infra/ui.perfetto.dev/README.md
@@ -0,0 +1,38 @@
+# ui.perfetto.dev Cloud scripts
+
+See [go/perfetto-ui-autopush](http://go/perfetto-ui-autopush) for docs on how
+this works end-to-end.
+
+## /appengine : GAE <> GCS proxy
+
+The Google AppEngine instance that responds to ui.perfetto.dev.
+It simply passes through the requests to the bucket gs://ui.perfetto.dev .
+This should NOT be re-deployed when uploading a new version of the ui,
+as the actual UI artifacts live in GCS.
+
+We are using AppEngine for historical reasons, at some point this should
+be migrated to a Type 7 Google Cloud Load Balancer, which supports
+direct backing by a GCS bucket. The only blocker for that is figuring out
+a seamless migration strategy for the SSL certificate.
+
+## /builder : Docker container for Google Cloud Build
+
+Contains the Dockerfile to generate the container image which is used by
+Google Cloud Build when auto-triggering new ui builds.
+Cloud Build invokes the equivalent of:
+
+```bash
+docker run gcr.io/perfetto-ui/perfetto-ui-builder \
+    ui/release/builder_entrypoint.sh
+```
+
+NOTE: the `builder_entrypoint.sh` script is not bundled in the docker container
+and is taken from the HEAD if the checked out repo.
+
+To update the container:
+
+```bash
+cd infra/ui.perfetto.dev/builder
+docker build -t gcr.io/perfetto-ui/perfetto-ui-builder .
+docker push gcr.io/perfetto-ui/perfetto-ui-builder .
+```
diff --git a/infra/ui.perfetto.dev/appengine/.gcloudignore b/infra/ui.perfetto.dev/appengine/.gcloudignore
new file mode 100644
index 0000000..cc91f41
--- /dev/null
+++ b/infra/ui.perfetto.dev/appengine/.gcloudignore
@@ -0,0 +1,6 @@
+.gcloudignore
+.git
+.gitignore
+__pycache__/
+/setup.cfg
+deploy
diff --git a/infra/ui.perfetto.dev/appengine/app.yaml b/infra/ui.perfetto.dev/appengine/app.yaml
new file mode 100644
index 0000000..1f4e65c
--- /dev/null
+++ b/infra/ui.perfetto.dev/appengine/app.yaml
@@ -0,0 +1,5 @@
+runtime: python38
+instance_class: B1
+basic_scaling:
+  max_instances: 4
+  idle_timeout: 10m
diff --git a/infra/ui.perfetto.dev/appengine/deploy b/infra/ui.perfetto.dev/appengine/deploy
new file mode 100755
index 0000000..37a13b7
--- /dev/null
+++ b/infra/ui.perfetto.dev/appengine/deploy
@@ -0,0 +1,20 @@
+#!/bin/bash
+# Copyright (C) 2021 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.
+
+set -exu
+
+cd -P ${BASH_SOURCE[0]%/*}
+gcloud app deploy app.yaml --project perfetto-ui \
+    -v testing --no-promote --stop-previous-version
diff --git a/infra/ui.perfetto.dev/appengine/index.yaml b/infra/ui.perfetto.dev/appengine/index.yaml
new file mode 100644
index 0000000..6536212
--- /dev/null
+++ b/infra/ui.perfetto.dev/appengine/index.yaml
@@ -0,0 +1,2 @@
+indexes:
+# AUTOGENERATED
diff --git a/infra/ui.perfetto.dev/appengine/main.py b/infra/ui.perfetto.dev/appengine/main.py
new file mode 100644
index 0000000..0fac701
--- /dev/null
+++ b/infra/ui.perfetto.dev/appengine/main.py
@@ -0,0 +1,65 @@
+# Copyright (C) 2021 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 flask
+import requests
+
+BUCKET_NAME = 'ui.perfetto.dev'
+
+REQ_HEADERS = [
+    'Accept',
+    'Accept-Encoding',
+    'Cache-Control',
+]
+
+RESP_HEADERS = [
+    'Content-Type',
+    'Content-Encoding',
+    'Content-Length',
+    'Cache-Control',
+    'Date',
+    'Expires',
+]
+
+app = flask.Flask(__name__)
+
+
+# Redirect v1.2.3 to v.1.2.3/
+@app.route('/v<int:x>.<int:y>.<int:z>')
+def version_redirect(x, y, z):
+  return flask.redirect('/v%d.%d.%d/' % (x, y, z), code=302)
+
+
+# Serve the requests from the GCS bucket.
+@app.route('/', methods=['GET'])
+@app.route('/<path:path>', methods=['GET'])
+def main(path=''):
+  path = '/' + path
+  path += 'index.html' if path.endswith('/') else ''
+  req_headers = {}
+  for key in set(flask.request.headers.keys()).intersection(REQ_HEADERS):
+    req_headers[key] = flask.request.headers.get(key)
+  url = 'https://commondatastorage.googleapis.com/' + BUCKET_NAME + path
+  req = requests.get(url, headers=req_headers)
+  if (req.status_code != 200):
+    return flask.abort(req.status_code)
+  resp = flask.Response(req.content)
+  for key in set(req.headers.keys()).intersection(RESP_HEADERS):
+    resp.headers[key] = req.headers.get(key)
+  return resp
+
+
+if __name__ == '__main__':
+  # This is used when running locally only.
+  app.run(host='127.0.0.1', port=10000, debug=True)
diff --git a/infra/ui.perfetto.dev/appengine/requirements.txt b/infra/ui.perfetto.dev/appengine/requirements.txt
new file mode 100644
index 0000000..1faca3d
--- /dev/null
+++ b/infra/ui.perfetto.dev/appengine/requirements.txt
@@ -0,0 +1,2 @@
+Flask==1.1.2
+requests
diff --git a/infra/ui.perfetto.dev/builder/Dockerfile b/infra/ui.perfetto.dev/builder/Dockerfile
new file mode 100644
index 0000000..07944c1
--- /dev/null
+++ b/infra/ui.perfetto.dev/builder/Dockerfile
@@ -0,0 +1,40 @@
+# Copyright (C) 2021 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.
+
+# The image that builds the Perfetto UI and deploys to GCS.
+# See go/perfetto-ui-autopush for docs on how this works end-to-end.
+
+FROM debian:buster-slim
+
+ENV PATH=/builder/google-cloud-sdk/bin/:$PATH
+RUN set -ex; \
+    export DEBIAN_FRONTEND=noninteractive; \
+    apt-get update; \
+    apt-get -y install python3 python3-distutils python3-pip git curl tar tini \
+            pkg-config zip libc-dev libgcc-8-dev; \
+    update-alternatives --install /usr/bin/python python /usr/bin/python3.7 1; \
+    pip3 install --quiet protobuf crcmod; \
+    mkdir -p /builder && \
+    curl -s -o - https://dl.google.com/dl/cloudsdk/release/google-cloud-sdk.tar.gz | tar -zx -C /builder; \
+    /builder/google-cloud-sdk/install.sh \
+        --usage-reporting=false \
+        --bash-completion=false \
+        --disable-installation-options \
+        --override-components gcloud gsutil; \
+    git config --system credential.helper gcloud.sh; \
+    useradd -d /home/perfetto perfetto; \
+    apt-get -y autoremove; \
+    rm -rf /var/lib/apt/lists/* /usr/share/man/* /usr/share/doc/*;
+
+ENTRYPOINT [ "tini", "-g", "--" ]
diff --git a/infra/ui.perfetto.dev/cloudbuild.yaml b/infra/ui.perfetto.dev/cloudbuild.yaml
new file mode 100644
index 0000000..6cfca1b
--- /dev/null
+++ b/infra/ui.perfetto.dev/cloudbuild.yaml
@@ -0,0 +1,9 @@
+# See go/perfetto-ui-autopush for docs on how this works end-to-end.
+steps:
+- name: gcr.io/$PROJECT_ID/perfetto-ui-builder
+  args:
+  - 'ui/release/builder_entrypoint.sh'
+# Timeout = 30 min (last measured time in Feb 2021: 12 min)
+timeout: 1800s
+options:
+  machineType: E2_HIGHCPU_32
diff --git a/src/tracing/track.cc b/src/tracing/track.cc
index 56525f7..56b6820 100644
--- a/src/tracing/track.cc
+++ b/src/tracing/track.cc
@@ -16,6 +16,11 @@
 
 #include "perfetto/tracing/track.h"
 
+#include "perfetto/ext/base/file_utils.h"
+#include "perfetto/ext/base/hash.h"
+#include "perfetto/ext/base/scoped_file.h"
+#include "perfetto/ext/base/string_splitter.h"
+#include "perfetto/ext/base/string_utils.h"
 #include "perfetto/ext/base/uuid.h"
 #include "perfetto/tracing/internal/track_event_data_source.h"
 #include "protos/perfetto/trace/track_event/process_descriptor.gen.h"
@@ -69,6 +74,35 @@
 }
 
 namespace internal {
+namespace {
+
+uint64_t GetProcessStartTime() {
+#if !PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
+  std::string stat;
+  if (!base::ReadFile("/proc/self/stat", &stat))
+    return 0u;
+  // The stat file is a single line split into space-separated fields as "pid
+  // (comm) state ppid ...". However because the command name can contain any
+  // characters (including parentheses and spaces), we need to skip past it
+  // before parsing the rest of the fields. To do that, we look for the last
+  // instance of ") " (parentheses followed by space) and parse forward from
+  // that point.
+  size_t comm_end = stat.rfind(") ");
+  if (comm_end == std::string::npos)
+    return 0u;
+  stat = stat.substr(comm_end + strlen(") "));
+  base::StringSplitter splitter(stat, ' ');
+  for (size_t skip = 0; skip < 20; skip++) {
+    if (!splitter.Next())
+      return 0u;
+  }
+  return base::CStringToUInt64(splitter.cur_token()).value_or(0u);
+#else
+  return 0;
+#endif  // !PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
+}
+
+}  // namespace
 
 // static
 TrackRegistry* TrackRegistry::instance_;
@@ -83,7 +117,21 @@
   if (instance_)
     return;
   instance_ = new TrackRegistry();
-  Track::process_uuid = static_cast<uint64_t>(base::Uuidv4().lsb());
+
+  // Use the process start time + pid as the unique identifier for this process.
+  // This ensures that if there are two independent copies of the Perfetto SDK
+  // in the same process (e.g., one in the app and another in a system
+  // framework), events emitted by each will be consistently interleaved on
+  // common thread and process tracks.
+  if (uint64_t start_time = GetProcessStartTime()) {
+    base::Hash hash;
+    hash.Update(start_time);
+    hash.Update(base::GetProcessId());
+    Track::process_uuid = hash.digest();
+  } else {
+    // Fall back to a randomly generated identifier.
+    Track::process_uuid = static_cast<uint64_t>(base::Uuidv4().lsb());
+  }
 }
 
 void TrackRegistry::UpdateTrack(Track track,
diff --git a/tools/write_version_header.py b/tools/write_version_header.py
index 88dcb30..028ad2a 100755
--- a/tools/write_version_header.py
+++ b/tools/write_version_header.py
@@ -101,6 +101,7 @@
       help='Skips running git rev-parse, emits only the version from CHANGELOG')
   parser.add_argument('--cpp_out', help='Path of the generated .h file.')
   parser.add_argument('--ts_out', help='Path of the generated .ts file.')
+  parser.add_argument('--stdout', help='Write to stdout', action='store_true')
   parser.add_argument('--changelog', help='Path to CHANGELOG.')
   args = parser.parse_args()
 
@@ -142,6 +143,9 @@
     content = '\n'.join(lines)
     write_if_unchanged(args.ts_out, content)
 
+  if args.stdout:
+    print(version)
+
 
 if __name__ == '__main__':
   sys.exit(main())
diff --git a/ui/build.js b/ui/build.js
index 3363d53..3760b71 100644
--- a/ui/build.js
+++ b/ui/build.js
@@ -66,6 +66,7 @@
 
 const argparse = require('argparse');
 const child_process = require('child_process');
+var crypto = require('crypto');
 const fs = require('fs');
 const http = require('http');
 const path = require('path');
@@ -85,14 +86,17 @@
 
   // The fields below will be changed by main() after cmdline parsing.
   // Directory structure:
-  // out/xxx/      -> outDir     : Root build dir, for both ninja/wasm and UI.
-  //   ui/         -> outUiDir   : UI dir. All outputs from this script.
-  //    tsc/       -> outTscDir  : Transpiled .ts -> .js.
-  //      gen/     -> outGenDir  : Auto-generated .ts/.js (e.g. protos).
-  //    dist/      -> outDistDir : Final artifacts (JS bundles, assets).
-  //    chrome_extension/        : Chrome extension.
+  // out/xxx/    -> outDir         : Root build dir, for both ninja/wasm and UI.
+  //   ui/       -> outUiDir       : UI dir. All outputs from this script.
+  //    tsc/     -> outTscDir      : Transpiled .ts -> .js.
+  //      gen/   -> outGenDir      : Auto-generated .ts/.js (e.g. protos).
+  //    dist/    -> outDistRootDir : Only index.html and service_worker.js
+  //      v1.2/  -> outDistDir     : JS bundles and assets
+  //    chrome_extension/          : Chrome extension.
   outDir: pjoin(ROOT_DIR, 'out/ui'),
+  version: '',  // v1.2.3, derived from the CHANGELOG + git.
   outUiDir: '',
+  outDistRootDir: '',
   outTscDir: '',
   outGenDir: '',
   outDistRootDir: '',
@@ -101,14 +105,14 @@
 };
 
 const RULES = [
-  {r: /ui\/src\/assets\/(index.html)/, f: copyIntoDistRoot},
+  {r: /ui\/src\/assets\/index.html/, f: copyIndexHtml},
   {r: /ui\/src\/assets\/((.*)[.]png)/, f: copyAssets},
   {r: /buildtools\/typefaces\/(.+[.]woff2)/, f: copyAssets},
   {r: /buildtools\/catapult_trace_viewer\/(.+(js|html))/, f: copyAssets},
   {r: /ui\/src\/assets\/.+[.]scss/, f: compileScss},
   {r: /ui\/src\/assets\/.+[.]scss/, f: compileScss},
   {r: /ui\/src\/chrome_extension\/.*/, f: copyExtensionAssets},
-  {r: /.*\/dist\/(?!service_worker).*/, f: genServiceWorkerDistHashes},
+  {r: /.*\/dist\/.+\/(?!manifest\.json).*/, f: genServiceWorkerManifestJson},
   {r: /.*\/dist\/.*/, f: notifyLiveServer},
 ];
 
@@ -136,9 +140,9 @@
   cfg.outUiDir = ensureDir(pjoin(cfg.outDir, 'ui'), clean);
   cfg.outExtDir = ensureDir(pjoin(cfg.outUiDir, 'chrome_extension'));
   cfg.outDistRootDir = ensureDir(pjoin(cfg.outUiDir, 'dist'));
-  // TODO(primiano): for now distDir == distRootDir. In next CLs distDir will
-  // become dist/v1.2.3/.
-  cfg.outDistDir = cfg.outDistRootDir;
+  const proc = exec('python3', [VERSION_SCRIPT, '--stdout'], {stdout: 'pipe'});
+  cfg.version = proc.stdout.toString().trim();
+  cfg.outDistDir = ensureDir(pjoin(cfg.outDistRootDir, cfg.version));
   cfg.outTscDir = ensureDir(pjoin(cfg.outUiDir, 'tsc'));
   cfg.outGenDir = ensureDir(pjoin(cfg.outUiDir, 'tsc/gen'));
   cfg.watch = !!args.watch;
@@ -162,7 +166,7 @@
   console.log('Entering', cfg.outDir);
   process.chdir(cfg.outDir);
 
-  updateSymilnks();  // Links //ui/out -> //out/xxx/ui/
+  updateSymlinks();  // Links //ui/out -> //out/xxx/ui/
 
   // Enqueue empty task. This is needed only for --no-build --serve. The HTTP
   // server is started when the task queue reaches quiescence, but it takes at
@@ -178,12 +182,9 @@
     compileProtos();
     genVersion();
     transpileTsProject('ui');
-    bundleJs('rollup.config.js');
-
-    // ServiceWorker.
-    genServiceWorkerDistHashes();
     transpileTsProject('ui/src/service_worker');
-    bundleJs('rollup-serviceworker.config.js');
+    bundleJs('rollup.config.js');
+    genServiceWorkerManifestJson();
 
     // Watches the /dist. When changed:
     // - Notifies the HTTP live reload clients.
@@ -214,8 +215,23 @@
   }
 }
 
-function copyIntoDistRoot(src, dst) {
-  addTask(cp, [src, pjoin(cfg.outDistRootDir, dst)]);
+function copyIndexHtml(src) {
+  const index_html = () => {
+    let html = fs.readFileSync(src).toString();
+    // First copy the index.html as-is into the dist/v1.2.3/ directory. This is
+    // only used for archival purporses, so one can open
+    // ui.perfetto.dev/v1.2.3/ to skip the auto-update and channel logic.
+    fs.writeFileSync(pjoin(cfg.outDistDir, 'index.html'), html);
+
+    // Then copy it into the dist/ root by patching the version code.
+    // TODO(primiano): in next CLs, this script should take a
+    // --release_map=xxx.json argument, to populate this with multiple channels.
+    const versionMap = JSON.stringify({'stable': cfg.version});
+    const bodyRegex = /data-perfetto_version='[^']*'/;
+    html = html.replace(bodyRegex, `data-perfetto_version='${versionMap}'`);
+    fs.writeFileSync(pjoin(cfg.outDistRootDir, 'index.html'), html);
+  };
+  addTask(index_html);
 }
 
 function copyAssets(src, dst) {
@@ -267,8 +283,15 @@
   addTask(exec, [cmd, args]);
 }
 
-function updateSymilnks() {
+function updateSymlinks() {
   mklink(cfg.outUiDir, pjoin(ROOT_DIR, 'ui/out'));
+
+  // Creates a out/dist_version -> out/dist/v1.2.3 symlink, so rollup config
+  // can point to that without having to know the current version number.
+  mklink(
+      path.relative(cfg.outUiDir, cfg.outDistDir),
+      pjoin(cfg.outUiDir, 'dist_version'));
+
   mklink(
       pjoin(ROOT_DIR, 'ui/node_modules'), pjoin(cfg.outTscDir, 'node_modules'))
 }
@@ -304,12 +327,7 @@
 // This transpiles all the sources (frontend, controller, engine, extension) in
 // one go. The only project that has a dedicated invocation is service_worker.
 function transpileTsProject(project) {
-  const args = [
-    '--project',
-    pjoin(ROOT_DIR, project),
-    '--outDir',
-    cfg.outTscDir,
-  ];
+  const args = ['--project', pjoin(ROOT_DIR, project)];
   if (cfg.watch) {
     args.push('--watch', '--preserveWatchOutput');
     addTask(execNode, ['tsc', args, {async: true}]);
@@ -333,24 +351,23 @@
   }
 }
 
-// Generates a map of {"dist_file_name" -> "sha256-01234"} used by the SW.
-function genServiceWorkerDistHashes() {
-  function write_ui_dist_file_map() {
-    const distFiles = [];
-    const skipRegex = /(service_worker\.js)|(\.map$)/;
-    walk(cfg.outDistDir, f => distFiles.push(f), skipRegex);
-    const dst = pjoin(cfg.outGenDir, 'dist_file_map.ts');
-    const cmd = 'python3';
-    const args = [
-      pjoin(ROOT_DIR, 'gn/standalone/write_ui_dist_file_map.py'),
-      '--out',
-      dst,
-      '--strip',
-      cfg.outDistDir,
-    ].concat(distFiles);
-    exec(cmd, args);
+function genServiceWorkerManifestJson() {
+  function make_manifest() {
+    const manifest = {resources: {}};
+    // When building the subresource manifest skip source maps, the manifest
+    // itself and the copy of the index.html which is copied under /v1.2.3/.
+    // The root /index.html will be fetched by service_worker.js separately.
+    const skipRegex = /(\.map|manifest\.json|index.html)$/;
+    walk(cfg.outDistDir, absPath => {
+      const contents = fs.readFileSync(absPath);
+      const relPath = path.relative(cfg.outDistDir, absPath);
+      const b64 = crypto.createHash('sha256').update(contents).digest('base64');
+      manifest.resources[relPath] = 'sha256-' + b64;
+    }, skipRegex);
+    const manifestJson = JSON.stringify(manifest, null, 2);
+    fs.writeFileSync(pjoin(cfg.outDistDir, 'manifest.json'), manifestJson);
   }
-  addTask(write_ui_dist_file_map, []);
+  addTask(make_manifest, []);
 }
 
 function startServer() {
@@ -403,7 +420,8 @@
             'Cache-Control': 'no-cache',
           };
           res.writeHead(200, head);
-          res.end(data);
+          res.write(data);
+          res.end();
         });
       })
       .listen(port);
diff --git a/ui/config/rollup.config.js b/ui/config/rollup.config.js
index 0055444..380aa91 100644
--- a/ui/config/rollup.config.js
+++ b/ui/config/rollup.config.js
@@ -60,9 +60,32 @@
   };
 }
 
+function defServiceWorkerBundle() {
+  return {
+    input: `${OUT_SYMLINK}/tsc/service_worker/service_worker.js`,
+    output: {
+      name: 'service_worker',
+      format: 'iife',
+      esModule: false,
+      file: `${OUT_SYMLINK}/dist/service_worker.js`,
+      sourcemap: true,
+    },
+    plugins: [
+      nodeResolve({
+        mainFields: ['browser'],
+        browser: true,
+        preferBuiltins: false,
+      }),
+      commonjs(),
+      sourcemaps(),
+    ],
+  };
+}
+
 export default [
-  defBundle('frontend', 'dist'),
-  defBundle('controller', 'dist'),
-  defBundle('engine', 'dist'),
+  defBundle('frontend', 'dist_version'),
+  defBundle('controller', 'dist_version'),
+  defBundle('engine', 'dist_version'),
   defBundle('chrome_extension', 'chrome_extension'),
+  defServiceWorkerBundle(),
 ]
diff --git a/ui/deploy b/ui/deploy
deleted file mode 100755
index 6eb0740..0000000
--- a/ui/deploy
+++ /dev/null
@@ -1,124 +0,0 @@
-#!/bin/bash
-# Copyright (C) 2018 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.
-
-set -e
-
-function print_usage {
-  echo "Usage: $0 [--no-clean] [--prod] [--staging]"
-  echo "    --no-clean  Don't remove $OUT_DIR"
-  echo "    --prod      Deploy prod version"
-  echo "    --staging   Deploy staging version"
-  echo "    --debug     Do a debug build"
-  echo "    -h|--help   Display this message"
-}
-
-function echo_and_do {
-  echo $@
-  $@
-}
-
-PROJECT_ROOT="$(cd -P ${BASH_SOURCE[0]%/*}/..; pwd)"
-OUT_DIR="$PROJECT_ROOT/out/ui-deploy.tmp"
-UI_DIST_DIR="$OUT_DIR/ui-dist"
-
-CLEAN_OUT_DIR=true
-DEPLOY_PROD=false
-DEPLOY_STAGING=false
-DEBUG_ARG=""
-
-while [[ $# -gt 0 ]]; do
-  key="$1"
-  case $key in
-      --no-clean)
-      CLEAN_OUT_DIR=false
-      shift
-      ;;
-      --prod)
-      DEPLOY_PROD=true
-      shift
-      ;;
-      --staging)
-      DEPLOY_STAGING=true
-      shift
-      ;;
-      --debug)
-      DEBUG_ARG="--debug"
-      shift
-      ;;
-      -h|--help)
-      print_usage
-      exit 0
-      shift
-      ;;
-      *)
-      print_usage
-      exit 1
-      shift
-      ;;
-  esac
-done
-
-if [ "$CLEAN_OUT_DIR" = true ]; then
-  echo_and_do rm -rf "$OUT_DIR"
-fi
-echo_and_do mkdir -p "$UI_DIST_DIR"
-
-echo_and_do "$PROJECT_ROOT/ui/build" --out "$OUT_DIR" $DEBUG_ARG
-
-echo "Writing $UI_DIST_DIR/app.yaml"
-cat<<EOF > "$UI_DIST_DIR/app.yaml"
-runtime: python27
-api_version: 1
-threadsafe: yes
-instance_class: B1
-default_expiration: "1m"
-manual_scaling:
-  instances: 1
-handlers:
-- url: /
-  static_files: static/index.html
-  upload: static/index.html
-  secure: always
-  redirect_http_response_code: 301
-- url: /(.*[.]wasm)
-  static_files: static/\1
-  upload: static/(.*[.]wasm)
-  mime_type: application/wasm
-- url: /assets/(.*)
-  static_files: static/assets/\1
-  upload: static/assets/(.*)
-- url: /(.*)
-  static_files: static/\1
-  upload: static/(.*)
-EOF
-
-echo_and_do rm -f $UI_DIST_DIR/static; ln -fs ../ui/dist $UI_DIST_DIR/static
-
-(
-  echo_and_do cd "$UI_DIST_DIR";
-  if [ "$DEPLOY_PROD" = true ]; then
-    echo_and_do gcloud app deploy app.yaml --project perfetto-ui -v prod
-  elif [ "$DEPLOY_STAGING" = true ]; then
-    echo_and_do gcloud app deploy app.yaml --project perfetto-ui \
-        -v staging --no-promote --stop-previous-version
-  else
-    echo_and_do gcloud app deploy app.yaml --project perfetto-ui \
-        -v $USER --no-promote --stop-previous-version
-  fi
-)
-
-if [ "$CLEAN_OUT_DIR" = true ]; then
-  echo_and_do rm -rf "$OUT_DIR"
-fi
diff --git a/ui/release/OWNERS b/ui/release/OWNERS
new file mode 100644
index 0000000..fe8fd04
--- /dev/null
+++ b/ui/release/OWNERS
@@ -0,0 +1,7 @@
+set noparent
+eseckler@google.com
+hjd@google.com
+oysteine@google.com
+nyquist@google.com
+primiano@google.com
+skyostil@google.com
diff --git a/ui/release/build_all_channels.py b/ui/release/build_all_channels.py
new file mode 100755
index 0000000..4a61e5e
--- /dev/null
+++ b/ui/release/build_all_channels.py
@@ -0,0 +1,147 @@
+#!/usr/bin/env python3
+# Copyright (C) 2021 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.
+""" Builds all the revisions in channels.json and deploys them if --upload.
+
+See go/perfetto-ui-autopush for docs on how this works end-to-end.
+"""
+
+import argparse
+import json
+import os
+import re
+import shutil
+import subprocess
+import sys
+
+from os.path import dirname
+pjoin = os.path.join
+
+BUCKET_NAME = 'ui.perfetto.dev'
+CUR_DIR = dirname(os.path.abspath(__file__))
+ROOT_DIR = dirname(dirname(CUR_DIR))
+
+
+def check_call_and_log(args):
+  print(' '.join(args))
+  subprocess.check_call(args)
+
+
+def check_output(args):
+  return subprocess.check_output(args).decode().strip()
+
+
+def version_exists(version):
+  url = 'https://commondatastorage.googleapis.com/%s/%s/manifest.json' % (
+      BUCKET_NAME, version)
+  return 0 == subprocess.call(['curl', '-fLs', '-o', '/dev/null', url])
+
+
+def build_git_revision(channel, git_ref, tmp_dir):
+  workdir = pjoin(tmp_dir, channel)
+  check_call_and_log(['rm', '-rf', workdir])
+  check_call_and_log(['git', 'clone', '--quiet', '--shared', ROOT_DIR, workdir])
+  old_cwd = os.getcwd()
+  os.chdir(workdir)
+  try:
+    check_call_and_log(['git', 'reset', '--hard', git_ref])
+    check_call_and_log(['git', 'clean', '-dfx'])
+    git_sha = check_output(['git', 'rev-parse', 'HEAD'])
+    print('===================================================================')
+    print('Building UI for channel %s @ %s (%s)' % (channel, git_ref, git_sha))
+    print('===================================================================')
+    version = check_output(['tools/write_version_header.py', '--stdout'])
+    check_call_and_log(['tools/install-build-deps', '--ui'])
+    check_call_and_log(['ui/build'])
+    return version, pjoin(workdir, 'ui/out/dist')
+  finally:
+    os.chdir(old_cwd)
+
+
+def build_all_channels(channels, tmp_dir, merged_dist_dir):
+  channel_map = {}
+  for chan in channels:
+    channel = chan['name']
+    git_ref = chan['rev']
+    # version here is something like "v1.2.3".
+    version, dist_dir = build_git_revision(channel, git_ref, tmp_dir)
+    channel_map[channel] = version
+    check_call_and_log(['cp', '-an', pjoin(dist_dir, version), merged_dist_dir])
+    if channel != 'stable':
+      continue
+    # Copy also the /index.html and /service_worker.*, but only for the stable
+    # channel. The /index.html and SW must be shared between all channels,
+    # because they are all reachable through ui.perfetto.dev/. Both the index
+    # and the SQ are supposed to be version-independent (go/perfetto-channels).
+    # If an accidental incompatibility bug sneaks in, we should much rather
+    # crash canary (or any other channel) rather than stable. Hence why we copy
+    # the index+sw from the stable channel.
+    for fname in os.listdir(dist_dir):
+      fpath = pjoin(dist_dir, fname)
+      if os.path.isfile(fpath):
+        check_call_and_log(['cp', '-an', fpath, merged_dist_dir])
+  return channel_map
+
+
+def main():
+  parser = argparse.ArgumentParser()
+  parser.add_argument('--upload', action='store_true')
+  parser.add_argument('--tmp', default='/tmp/perfetto_ui')
+  args = parser.parse_args()
+
+  # Read the releases.json, which maps channel names to git refs, e.g.:
+  # {name:'stable', rev:'a0b1c2...0}, {name:'canary', rev:'HEAD'}
+  channels = []
+  with open(pjoin(CUR_DIR, 'channels.json')) as f:
+    channels = json.load(f)['channels']
+
+  merged_dist_dir = pjoin(args.tmp, 'dist')
+  check_call_and_log(['rm', '-rf', merged_dist_dir])
+  shutil.os.makedirs(merged_dist_dir)
+  channel_map = build_all_channels(channels, args.tmp, merged_dist_dir)
+
+  print('Updating index in ' + merged_dist_dir)
+  with open(pjoin(merged_dist_dir, 'index.html'), 'r+') as f:
+    index_html = f.read()
+    f.seek(0, 0)
+    f.truncate()
+    index_html = re.sub(r"data-perfetto_version='[^']*'",
+                        "data-perfetto_version='%s'" % json.dumps(channel_map),
+                        index_html)
+    f.write(index_html)
+
+  if not args.upload:
+    return
+
+  print('===================================================================')
+  print('Uploading to gs://%s' % BUCKET_NAME)
+  print('===================================================================')
+  cp_cmd = [
+      'gsutil', '-m', '-h', 'Cache-Control:public, max-age=3600', 'cp', '-j',
+      'html,js,css,wasm'
+  ]
+  for name in os.listdir(merged_dist_dir):
+    path = pjoin(merged_dist_dir, name)
+    if os.path.isdir(path):
+      if version_exists(name):
+        print('Skipping upload of %s because it already exists on GCS' % name)
+        continue
+      check_call_and_log(cp_cmd + ['-r', path, 'gs://%s/' % BUCKET_NAME])
+    else:
+      # /index.html or /service_worker.js{,.map}
+      check_call_and_log(cp_cmd + [path, 'gs://%s/%s' % (BUCKET_NAME, name)])
+
+
+if __name__ == '__main__':
+  sys.exit(main())
diff --git a/ui/release/builder_entrypoint.sh b/ui/release/builder_entrypoint.sh
new file mode 100755
index 0000000..7a940b8
--- /dev/null
+++ b/ui/release/builder_entrypoint.sh
@@ -0,0 +1,40 @@
+#!/bin/bash
+# Copyright (C) 2021 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.
+
+# See go/perfetto-ui-autopush for docs on how this works end-to-end.
+
+set -exu
+
+CUR_DUR=$(dirname ${BASH_SOURCE[0]})
+
+env
+pwd
+mount
+
+# This script will be run in /workspace after the Cloud Build environment has
+# pulled the GitHub repo in shallow mode. We want to build off the official
+# AOSP repo, not the GitHub mirror though, hence why the clone below.
+# GitHub is used only as a trigger. This is because Google Cloud Build doesn't
+# support yet triggering from Gerrit.
+
+cd /workspace/
+ls -A1 | xargs rm -rf
+UPSTREAM="https://android.googlesource.com/platform/external/perfetto.git"
+git clone $UPSTREAM upstream
+
+cd upstream/
+git rev-parse HEAD
+mkdir /workspace/tmp
+python3 -u "$CUR_DUR/build_all_channels.py" --upload --tmp=/workspace/tmp
diff --git a/ui/release/channels.json b/ui/release/channels.json
new file mode 100644
index 0000000..40a7f17
--- /dev/null
+++ b/ui/release/channels.json
@@ -0,0 +1,12 @@
+{
+  "channels": [
+    {
+      "name": "stable",
+      "rev": "HEAD"
+    },
+    {
+      "name": "canary",
+      "rev": "HEAD"
+    }
+  ]
+}
diff --git a/ui/src/assets/common.scss b/ui/src/assets/common.scss
index f6f5fa5..2c2fd4e 100644
--- a/ui/src/assets/common.scss
+++ b/ui/src/assets/common.scss
@@ -212,34 +212,6 @@
   }
 }
 
-.home-page {
-    text-align: center;
-    padding-top: 20vh;
-    align-items: center;
-}
-
-.home-page .logo {
-    width: var(--track-shell-width);
-}
-
-.home-page-title {
-    font-size: 60px;
-    margin: 25px;
-    text-align: center;
-    font-family: 'Raleway', sans-serif;
-    font-weight: 100;
-    color: #333;
-}
-
-.privacy {
-  text-decoration: none;
-  font-family: 'Raleway', sans-serif;
-  color: #333;
-  position: absolute;
-  bottom: 60px;
-  font-size: 15px;
-}
-
 .query-table-container {
   width: 100%;
   overflow-x: auto;
diff --git a/ui/src/assets/home_page.scss b/ui/src/assets/home_page.scss
new file mode 100644
index 0000000..8eed245
--- /dev/null
+++ b/ui/src/assets/home_page.scss
@@ -0,0 +1,125 @@
+// Copyright (C) 2021 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.
+
+.home-page {
+  text-align: center;
+  align-items: center;
+  display: grid;
+  grid-template-columns: 1fr;
+  grid-template-rows: 2em 1fr 60px;
+  grid-template-areas: "." "center" "footer";
+
+  .home-page-center {
+    grid-area: center;
+
+    .logo {
+      width: var(--track-shell-width);
+    }
+
+    .home-page-title {
+      font-size: 60px;
+      margin: 25px;
+      text-align: center;
+      font-family: 'Raleway', sans-serif;
+      font-weight: 100;
+      color: #333;
+    }
+
+    .channel-select {
+      font-family: 'Roboto', sans-serif;
+      font-size: 1.2rem;
+      font-weight: 200;
+      margin-top: 3em;
+      --chan-width: 100px;
+      --chan-num: 2;
+
+      input[type=radio] {
+        width: 0;
+        height: 0;
+        margin: 0;
+        padding: 0;
+        -moz-appearance: none;
+        -webkit-appearance: none;
+        &:nth-of-type(1):checked ~ .highlight { margin-left: 0; }
+        &:nth-of-type(2):checked ~ .highlight {
+          margin-left: 100px;
+          background-color: hsl(54, 100%, 40%);
+        }
+        &:nth-of-type(3):checked ~ .highlight {
+          margin-left: 200px;
+          background-color: hsl(24, 80%, 42%);
+        }
+      }
+
+      fieldset {
+        margin: 30px auto 10px auto;
+        padding: 0;
+        position: relative;
+        background-color: hsl(218, 8%, 30%);
+        border-radius: 3px;
+        box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.4);
+        border: 0;
+        width: calc(var(--chan-width) * var(--chan-num));
+        height: 40px;
+        line-height: 40px;
+        z-index: 0;
+      }
+
+      label {
+        display: inline-block;
+        cursor: pointer;
+        position: relative;
+        width: var(--chan-width);
+        height: 100%;
+        color: white;
+        z-index: 2;
+        text-transform: uppercase;
+        font-size: 16px;
+        font-weight: 200;
+        letter-spacing: 0.3px;
+      }
+
+      .highlight {
+        width: var(--chan-width);
+        height: 100%;
+        position: absolute;
+        background: hsla(122, 45%, 45%, 0.99);
+        background-image: linear-gradient(rgba(255, 255, 255, 0.2), transparent);
+        top: 0;
+        left: 0;
+        z-index: 1;
+        border-radius: inherit;
+        @include transition();
+      }
+
+      .home-page-reload {
+        font-size: 12px;
+        opacity: 0;
+        color: #da4534;
+        font-weight: 400;
+        @include transition;
+        &.show { opacity: 1; }
+      }
+    }  // .channel-select
+  }  // .home-page-center
+
+  .privacy {
+    grid-area: footer;
+    text-decoration: none;
+    font-family: 'Roboto', sans-serif;
+    font-weight: 200;
+    color: #333;
+    font-size: 15px;
+  }
+}  // .home-page
diff --git a/ui/src/assets/index.html b/ui/src/assets/index.html
index 3237423..7ad3b21 100644
--- a/ui/src/assets/index.html
+++ b/ui/src/assets/index.html
@@ -1,20 +1,118 @@
 <!doctype html>
 <html lang="en-us">
 <head>
+  <meta charset="utf-8">
   <title>Perfetto UI</title>
-  <!-- See b/149573396 for CSP rationale. -->
-  <!-- TODO(b/121211019): remove script-src-elem rule once fixed. -->
-  <meta http-equiv="Content-Security-Policy" content="default-src 'self' 'sha256-LirUKeorCU4uRNtNzr8tlB11uy8rzrdmqHCX38JSwHY='; script-src 'self' https://*.google.com https://*.googleusercontent.com https://www.googletagmanager.com https://www.google-analytics.com; object-src 'none'; connect-src 'self' http://127.0.0.1:9001 https://www.google-analytics.com https://*.googleapis.com blob: data:; img-src 'self' data: blob: https://www.google-analytics.com https://www.googletagmanager.com; navigate-to https://*.perfetto.dev;">
-
   <meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" name="viewport" />
-  <link href="perfetto.css" rel="stylesheet">
-  <link rel="icon" type="image/png" href="assets/favicon.png">
+  <link rel="shortcut icon" id="favicon" type="image/png" href="data:image/png;base64,iVBORw0KGgo=">
 </head>
-<body>
-  <main>
-    <div class="full-page-loading-screen"></div>
-  </main>
-  <div id="main-modal" aria-hidden="true" class="modal micromodal-slide"></div>
+<body data-perfetto_version='{"filled_by_build_js":"."}'>
+  <!--
+    Don't add any content here. The whole <body> is replaced by
+    frontend/index.ts when bootstrapping. This is only used for very early
+    error reporting.
+  -->
+  <style>
+  #app_load_failure {opacity:0;transition:opacity 1s ease;position:absolute;background:#080082;top:0;left:0;width:100%;height:100%;bottom:0;right:0;margin:0;opacity:0;user-select:text}
+  #app_load_failure > pre {color:#fff;position:absolute;margin:auto;white-space:pre-wrap;top:50%;transform:translate(0,-50%);max-width:90vw;width:880px;left:0;right:0;font-size:16px;line-height:30px;font-weight:700}
+  #app_load_failure > pre span {background:#fff;color:#080082;padding:2px}
+  #app_load_failure a {color:#fff}
+  #app_load { position: absolute; top: 0; left: 0; right:0; bottom: 0; background-color: #2c3e50;}
+  #app_load_spinner { margin: 30vh auto; width: 150px; height: 150px; border: 3px solid rgba(255,255,255,.3); border-radius: 50%; border-top-color: #fff; animation: app_load_spin 1s ease-in-out infinite; }
+  @keyframes app_load_spin { to { transform: rotate(360deg); } }
+  </style>
+  <div id="app_load"><div id="app_load_spinner"></div></div>
+  <div id="app_load_failure">
+<pre>
+<span>Perfetto UI - An unrecoverable problem occurred</span>
+
+If you are seeing this message, something went wrong while loading the UI.
+Please file a bug (details below) and try these remediation steps:
+
+* Force-reload the page with Ctrl+Shift+R (Mac: Meta+Shift+R) or
+  Shift + click on the refresh button.
+
+* <a href="javascript:clearAllCaches();">Click here</a> to clear all the site storage and caches and reload the page.
+
+* Clear the site data and caches from devtools, following <a target="_blank" href="https://developers.google.com/web/tools/chrome-devtools/storage/cache#deletecache">these instructions</a>.
+
+* If neither of these work, you can use an old fallback instance hosted at
+  <a target="_blank" href="https://staging-dot-perfetto-ui.appspot.com">staging-dot-perfetto-ui.appspot.com</a>
+
+In any case, **FILE A BUG** attaching logs and screenshots from devtools.
+  Googlers:      <a href="http://go/perfetto-ui-bug" target="_blank">go/perfetto-ui-bug</a>
+  Non-googlers:  <a href="https://github.com/google/perfetto/issues/new" target="_blank">github.com/google/perfetto/issues/new</a>
+
+<div id=app_load_failure_err></div>
+Technical Information:
+<div id=app_load_failure_dbg></div>
+</pre>
+  </div>
+  <script type="text/javascript">
+    'use strict';
+    (function () {
+      const TIMEOUT_MS = 10000;
+      let errTimerId = undefined;
+
+      function errHandler(err) {
+        // Note: we deliberately don't clearTimeout(), which means that this
+        // handler is called also in the happy case when the UI loads. In that
+        // case, though, the onCssLoaded() in frontend/index.ts will empty the
+        // <body>, so |div| below will be null and this function becomes a
+        // no-op.
+        const div = document.getElementById('app_load_failure');
+        if (!div) return;
+        div.style.opacity ='1';
+        const errDom = document.getElementById('app_load_failure_err');
+        if (!errDom) return;
+        console.error(err);
+        errDom.innerText += `${err}\n`;
+        const storageJson = JSON.stringify(window.localStorage);
+        const dbg = document.getElementById('app_load_failure_dbg');
+        if (!dbg) return;
+        dbg.innerText = `LocalStorage: ${storageJson}\n`;
+        if (errTimerId !== undefined) clearTimeout(errTimerId);
+      }
+
+      // For the 'Click here to clear all caches'.
+      window.clearAllCaches = () => {
+        if (window.localStorage) window.localStorage.clear();
+        if (window.sessionStorage) window.sessionStorage.clear();
+        const promises = [];
+        if (window.caches) {
+          window.caches.keys().then(
+            keys => keys.forEach(k => promises.push(window.caches.delete(k))));
+        }
+        if (navigator.serviceWorker) {
+          navigator.serviceWorker.getRegistrations().then(regs => {
+            regs.forEach(reg => promises.push(reg.unregister()));
+          });
+        }
+        Promise.all(promises).then(() => window.location.reload());
+      }
+
+      // If the frontend doesn't come up, make the error page above visible.
+      errTimerId = setTimeout(() => errHandler('Timed out'), TIMEOUT_MS);
+      window.onerror = errHandler;
+      window.onunhandledrejection = errHandler;
+
+      const versionStr = document.body.dataset['perfetto_version'] || '{}';
+      const versionMap = JSON.parse(versionStr);
+      const channel = localStorage.getItem('perfettoUiChannel') || 'stable';
+
+      // The '.' below is a fallback for the case of opening a pinned version
+      // (e.g., ui.perfetto.dev/v1.2.3./). In that case, the index.html has no
+      // valid version map; we want to load the frontend from the same
+      // sub-directory directory, hence ./frontend_bundle.js.
+      const version = versionMap[channel] || versionMap['stable'] || '.';
+
+      const script = document.createElement('script');
+      script.async = true;
+      script.src = version + '/frontend_bundle.js';
+      script.onerror = () => errHandler(`Failed to load ${script.src}`);
+
+      document.head.append(script);
+    })();
+  </script>
 </body>
 </html>
-<script src="frontend_bundle.js"></script>
diff --git a/ui/src/assets/perfetto.scss b/ui/src/assets/perfetto.scss
index 05de317..d3cc23a 100644
--- a/ui/src/assets/perfetto.scss
+++ b/ui/src/assets/perfetto.scss
@@ -14,6 +14,7 @@
 
 @import 'typefaces';
 @import 'common';
+@import 'home_page';
 @import 'analyze_page';
 @import 'metrics_page';
 @import 'sidebar';
diff --git a/ui/src/assets/sidebar.scss b/ui/src/assets/sidebar.scss
index b368214..cb0e1c0 100644
--- a/ui/src/assets/sidebar.scss
+++ b/ui/src/assets/sidebar.scss
@@ -43,6 +43,17 @@
           height: 40px;
           margin-top: 4px;
         }
+        &.canary::before {
+          content: 'CANARY';
+          display: block;
+          color: gold;
+          position: absolute;
+          font-size: 10px;
+          line-height: 10px;
+          font-family: 'Roboto', sans-serif;
+          left: 155px;
+          top: 7px;
+        }
     }
     .sidebar-button {
       position: absolute;
diff --git a/ui/src/assets/typefaces.scss b/ui/src/assets/typefaces.scss
index b5999b3..5e4ba16 100644
--- a/ui/src/assets/typefaces.scss
+++ b/ui/src/assets/typefaces.scss
@@ -52,6 +52,7 @@
 }
 
 .material-icons {
+  font-display: block;
   font-family: 'Material Icons';
   font-weight: normal;
   font-style: normal;
diff --git a/ui/src/frontend/analytics.ts b/ui/src/frontend/analytics.ts
index 5abf7ed..d7913d7 100644
--- a/ui/src/frontend/analytics.ts
+++ b/ui/src/frontend/analytics.ts
@@ -13,6 +13,7 @@
 // limitations under the License.
 
 import {globals} from '../frontend/globals';
+import * as version from '../gen/perfetto_version';
 
 type TraceCategories = 'Trace Actions'|'Record Trace'|'User Actions';
 const ANALYTICS_ID = 'UA-137828855-1';
@@ -92,6 +93,8 @@
       referrer: document.referrer.split('?')[0],
       send_page_view: false,
       dimension1: globals.isInternalUser ? '1' : '0',
+      dimension2: version.VERSION,
+      dimension3: globals.channel,
     });
     this.updatePath(route);
   }
diff --git a/ui/src/frontend/css_constants.ts b/ui/src/frontend/css_constants.ts
index a443306..3b95eab 100644
--- a/ui/src/frontend/css_constants.ts
+++ b/ui/src/frontend/css_constants.ts
@@ -12,34 +12,29 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-function readCssVarSlow(prop: string, defaultValue: string): string {
-  // This code can be used in unittests where we can't read CSS variables.
-  if (typeof window === 'undefined') {
-    return defaultValue;
-  } else {
-    const body = window.document.body;
-    return window.getComputedStyle(body).getPropertyValue(prop);
-  }
+// This code can be used in unittests where we can't read CSS variables.
+// Also we cannot have global constructors beacause when the javascript is
+// loaded, the CSS might not be ready yet.
+export let TRACK_SHELL_WIDTH = 100;
+export let TRACK_BORDER_COLOR = '#ffc0cb';
+export let TOPBAR_HEIGHT = 48;
+
+export function initCssConstants() {
+  TRACK_SHELL_WIDTH = getCssNum('--track-shell-width') || TRACK_SHELL_WIDTH;
+  TRACK_BORDER_COLOR = getCssStr('--track-border-color') || TRACK_BORDER_COLOR;
+  TOPBAR_HEIGHT = getCssNum('--topbar-height') || TOPBAR_HEIGHT;
 }
 
-function getTrackShellWidth(): number {
-  const width = readCssVarSlow('--track-shell-width', '100px');
-  const match = width.match(/^\W*(\d+)px$/);
-  if (!match) throw Error(`Could not parse shell width as number (${width})`);
+function getCssStr(prop: string): string|undefined {
+  if (typeof window === 'undefined') return undefined;
+  const body = window.document.body;
+  return window.getComputedStyle(body).getPropertyValue(prop);
+}
+
+function getCssNum(prop: string): number|undefined {
+  const str = getCssStr(prop);
+  if (str === undefined) return undefined;
+  const match = str.match(/^\W*(\d+)px$/);
+  if (!match) throw Error(`Could not parse CSS property "${str}" as a number`);
   return Number(match[1]);
 }
-
-function getTrackBorderColor(): string {
-  return readCssVarSlow('--track-border-color', '#ffc0cb');
-}
-
-function getTopbarHeight(): number {
-  const width = readCssVarSlow('--topbar-height', '48px');
-  const match = width.match(/^\W*(\d+)px$/);
-  if (!match) throw Error(`Could not parse topbar height as number (${width})`);
-  return Number(match[1]);
-}
-
-export const TRACK_SHELL_WIDTH = getTrackShellWidth();
-export const TRACK_BORDER_COLOR = getTrackBorderColor();
-export const TOPBAR_HEIGHT = getTopbarHeight();
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index 38bd7a9..dff9c1b 100644
--- a/ui/src/frontend/globals.ts
+++ b/ui/src/frontend/globals.ts
@@ -125,10 +125,20 @@
 }
 type ThreadMap = Map<number, ThreadDesc>;
 
+function getRoot() {
+  // Works out the root directory where the content should be served from
+  // e.g. `http://origin/v1.2.3/`.
+  let root = (document.currentScript as HTMLScriptElement).src;
+  root = root.substr(0, root.lastIndexOf('/') + 1);
+  return root;
+}
+
 /**
  * Global accessors for state/dispatch in the frontend.
  */
 class Globals {
+  readonly root = getRoot();
+
   private _dispatch?: Dispatch = undefined;
   private _controllerWorker?: Worker = undefined;
   private _state?: State = undefined;
@@ -137,6 +147,7 @@
   private _serviceWorkerController?: ServiceWorkerController = undefined;
   private _logging?: Analytics = undefined;
   private _isInternalUser: boolean|undefined = undefined;
+  private _channel: string|undefined = undefined;
 
   // TODO(hjd): Unify trackDataStore, queryResults, overviewStore, threads.
   private _trackDataStore?: TrackDataStore = undefined;
@@ -444,6 +455,13 @@
     this._isInternalUser = value;
   }
 
+  get channel() {
+    if (this._channel === undefined) {
+      this._channel = localStorage.getItem('perfettoUiChannel') || 'stable';
+    }
+    return this._channel;
+  }
+
   // Used when switching to the legacy TraceViewer UI.
   // Most resources are cleaned up by replacing the current |window| object,
   // however pending RAFs and workers seem to outlive the |window| and need to
diff --git a/ui/src/frontend/home_page.ts b/ui/src/frontend/home_page.ts
index 5d37cbb..91a3186 100644
--- a/ui/src/frontend/home_page.ts
+++ b/ui/src/frontend/home_page.ts
@@ -13,17 +13,52 @@
 // limitations under the License.
 
 import * as m from 'mithril';
+import {globals} from './globals';
 
 import {createPage} from './pages';
 
+let channelChanged = false;
+
 export const HomePage = createPage({
   view() {
     return m(
         '.page.home-page',
-        m('.home-page-title', 'Perfetto'),
-        m('img.logo[src=assets/logo-3d.png]'),
+        m(
+            '.home-page-center',
+            m('.home-page-title', 'Perfetto'),
+            m(`img.logo[src=${globals.root}assets/logo-3d.png]`),
+            m(
+                'div.channel-select',
+                m('div',
+                  'Feeling adventurous? Try our bleeding edge Canary version'),
+                m(
+                    'fieldset',
+                    mkChan('stable'),
+                    mkChan('canary'),
+                    m('.highlight'),
+                    ),
+                m(`.home-page-reload${channelChanged ? '.show' : ''}`,
+                  'You need to reload the page for the changes to have effect'),
+                ),
+            ),
         m('a.privacy',
           {href: 'https://policies.google.com/privacy', target: '_blank'},
           'Privacy policy'));
   }
 });
+
+function mkChan(chan: string) {
+  const checked =
+      !channelChanged && globals.channel === chan ? '[checked=true]' : '';
+  return [
+    m(`input[type=radio][name=chan][id=chan_${chan}]${checked}`,
+      {onchange: () => changeChannel(chan)}),
+    m(`label[for=chan_${chan}]`, chan),
+  ];
+}
+
+function changeChannel(chan: string) {
+  localStorage.setItem('perfettoUiChannel', chan);
+  channelChanged = true;
+  globals.rafScheduler.scheduleFullRedraw();
+}
diff --git a/ui/src/frontend/index.ts b/ui/src/frontend/index.ts
index a2ba0ac..1b6584e 100644
--- a/ui/src/frontend/index.ts
+++ b/ui/src/frontend/index.ts
@@ -15,9 +15,9 @@
 import '../tracks/all_frontend';
 
 import {applyPatches, Patch} from 'immer';
-import * as MicroModal from 'micromodal';
 import * as m from 'mithril';
 
+import {defer} from '../base/deferred';
 import {assertExists, reportError, setErrorHandler} from '../base/logging';
 import {forwardRemoteCalls} from '../base/remote';
 import {Actions} from '../common/actions';
@@ -33,6 +33,7 @@
 
 import {AnalyzePage} from './analyze_page';
 import {loadAndroidBugToolInfo} from './android_bug_tool';
+import {initCssConstants} from './css_constants';
 import {maybeShowErrorDialog} from './error_dialog';
 import {
   CounterDetails,
@@ -264,13 +265,81 @@
   }));
 }
 
+function setupContentSecurityPolicy() {
+  // Note: self and sha-xxx must be quoted, urls data: and blob: must not.
+  const policy = {
+    'default-src': [
+      `'self'`,
+      // Google Tag Manager bootstrap.
+      `'sha256-LirUKeorCU4uRNtNzr8tlB11uy8rzrdmqHCX38JSwHY='`,
+    ],
+    'script-src': [
+      `'self'`,
+      'https://*.google.com',
+      'https://*.googleusercontent.com',
+      'https://www.googletagmanager.com',
+      'https://www.google-analytics.com',
+    ],
+    'object-src': ['none'],
+    'connect-src': [
+      `'self'`,
+      'http://127.0.0.1:9001',  // For trace_processor_shell --http.
+      'https://www.google-analytics.com',
+      'https://*.googleapis.com',  // For Google Cloud Storage fetches.
+      'blob:',
+      'data:',
+    ],
+    'img-src': [
+      `'self'`,
+      'data:',
+      'blob:',
+      'https://www.google-analytics.com',
+      'https://www.googletagmanager.com',
+    ],
+    'navigate-to': ['https://*.perfetto.dev']
+  };
+  const meta = document.createElement('meta');
+  meta.httpEquiv = 'Content-Security-Policy';
+  let policyStr = '';
+  for (const [key, list] of Object.entries(policy)) {
+    policyStr += `${key} ${list.join(' ')}; `;
+  }
+  meta.content = policyStr;
+  document.head.appendChild(meta);
+}
+
 function main() {
+  setupContentSecurityPolicy();
+
+  // Load the css. The load is asynchronous and the CSS is not ready by the time
+  // appenChild returns.
+  const cssLoadPromise = defer<void>();
+  const css = document.createElement('link');
+  css.rel = 'stylesheet';
+  css.href = globals.root + 'perfetto.css';
+  css.onload = () => cssLoadPromise.resolve();
+  css.onerror = (err) => cssLoadPromise.reject(err);
+  const favicon = document.head.querySelector('#favicon') as HTMLLinkElement;
+  if (favicon) favicon.href = globals.root + 'assets/favicon.png';
+
+  // Load the script to detect if this is a Googler (see comments on globals.ts)
+  // and initialize GA after that (or after a timeout if something goes wrong).
+  const script = document.createElement('script');
+  script.src =
+      'https://storage.cloud.google.com/perfetto-ui-internal/is_internal_user.js';
+  script.async = true;
+  script.onerror = () => globals.logging.initialize();
+  script.onload = () => globals.logging.initialize();
+  setTimeout(() => globals.logging.initialize(), 5000);
+
+  document.head.append(script, css);
+
   // Add Error handlers for JS error and for uncaught exceptions in promises.
   setErrorHandler((err: string) => maybeShowErrorDialog(err));
   window.addEventListener('error', e => reportError(e));
   window.addEventListener('unhandledrejection', e => reportError(e));
 
-  const controller = new Worker('controller_bundle.js');
+  const controller = new Worker(globals.root + 'controller_bundle.js');
   const frontendChannel = new MessageChannel();
   const controllerChannel = new MessageChannel();
   const extensionLocalChannel = new MessageChannel();
@@ -333,32 +402,35 @@
         });
   }
 
-  updateAvailableAdbDevices();
-  try {
-    navigator.usb.addEventListener(
-        'connect', () => updateAvailableAdbDevices());
-    navigator.usb.addEventListener(
-        'disconnect', () => updateAvailableAdbDevices());
-  } catch (e) {
-    console.error('WebUSB API not supported');
-  }
   // This forwards the messages from the controller to the extension
   extensionLocalChannel.port2.onmessage = ({data}) => {
     if (extensionPort) extensionPort.postMessage(data);
   };
-  const main = assertExists(document.body.querySelector('main'));
-
-  globals.rafScheduler.domRedraw = () =>
-      m.render(main, m(router.resolve(globals.state.route)));
-
-  // Add support for opening traces from postMessage().
-  window.addEventListener('message', postMessageHandler, {passive: true});
 
   // Put these variables in the global scope for better debugging.
   (window as {} as {m: {}}).m = m;
   (window as {} as {globals: {}}).globals = globals;
   (window as {} as {Actions: {}}).Actions = Actions;
 
+  // Prevent pinch zoom.
+  document.body.addEventListener('wheel', (e: MouseEvent) => {
+    if (e.ctrlKey) e.preventDefault();
+  }, {passive: false});
+
+  cssLoadPromise.then(() => onCssLoaded(router));
+}
+
+function onCssLoaded(router: Router) {
+  initCssConstants();
+  // Clear all the contents of the initial page (e.g. the <pre> error message)
+  // And replace it with the root <main> element which will be used by mithril.
+  document.body.innerHTML = '<main></main>';
+  const main = assertExists(document.body.querySelector('main'));
+  globals.rafScheduler.domRedraw = () =>
+      m.render(main, m(router.resolve(globals.state.route)));
+
+  router.navigateToCurrentHash();
+
   // /?s=xxxx for permalinks.
   const stateHash = Router.param('s');
   const urlHash = Router.param('url');
@@ -390,31 +462,24 @@
         });
   }
 
-  // Prevent pinch zoom.
-  document.body.addEventListener('wheel', (e: MouseEvent) => {
-    if (e.ctrlKey) e.preventDefault();
-  }, {passive: false});
-
-  router.navigateToCurrentHash();
-
-  MicroModal.init();
-
-  // Load the script to detect if this is a Googler (see comments on globals.ts)
-  // and initialize GA after that (or after a timeout if something goes wrong).
-  const script = document.createElement('script');
-  script.src =
-      'https://storage.cloud.google.com/perfetto-ui-internal/is_internal_user.js';
-  script.async = true;
-  script.onerror = () => globals.logging.initialize();
-  script.onload = () => globals.logging.initialize();
-  setTimeout(() => globals.logging.initialize(), 5000);
-  document.head.appendChild(script);
+  // Add support for opening traces from postMessage().
+  window.addEventListener('message', postMessageHandler, {passive: true});
 
   // Will update the chip on the sidebar footer that notifies that the RPC is
   // connected. Has no effect on the controller (which will repeat this check
   // before creating a new engine).
   CheckHttpRpcConnection();
   initLiveReloadIfLocalhost();
+
+  updateAvailableAdbDevices();
+  try {
+    navigator.usb.addEventListener(
+        'connect', () => updateAvailableAdbDevices());
+    navigator.usb.addEventListener(
+        'disconnect', () => updateAvailableAdbDevices());
+  } catch (e) {
+    console.error('WebUSB API not supported');
+  }
 }
 
 main();
diff --git a/ui/src/frontend/legacy_trace_viewer.ts b/ui/src/frontend/legacy_trace_viewer.ts
index a200ea9..122a618 100644
--- a/ui/src/frontend/legacy_trace_viewer.ts
+++ b/ui/src/frontend/legacy_trace_viewer.ts
@@ -15,6 +15,7 @@
 import * as m from 'mithril';
 import {inflate} from 'pako';
 import {assertTrue} from '../base/logging';
+import {globals} from './globals';
 import {showModal} from './modal';
 
 const CTRACE_HEADER = 'TRACE:\n';
@@ -126,9 +127,7 @@
 
   // The location.pathname mangling is to make this code work also when hosted
   // in a non-root sub-directory, for the case of CI artifacts.
-  const urlParts = location.pathname.split('/');
-  urlParts[urlParts.length - 1] = 'assets/catapult_trace_viewer.html';
-  const catapultUrl = urlParts.join('/');
+  const catapultUrl = globals.root + 'assets/catapult_trace_viewer.html';
   const newWin = window.open(catapultUrl) as Window;
   if (newWin) {
     // Popup succeedeed.
diff --git a/ui/src/frontend/modal.ts b/ui/src/frontend/modal.ts
index 0778e4c..69482e3 100644
--- a/ui/src/frontend/modal.ts
+++ b/ui/src/frontend/modal.ts
@@ -32,8 +32,23 @@
   action: () => void;
 }
 
+// We need to create a div outside of the mithril's render root (<main>), that's
+// why the manual DOM manipulation.
+function getOrCreateDOM() {
+  let div = document.getElementById('main-modal') as HTMLElement;
+  if (div) return div;
+  div = document.createElement('div');
+  div.id = 'main-modal';
+  div.classList.add('modal');
+  div.classList.add('micromodal-slide');
+  (div as {} as {ariaHidden: boolean}).ariaHidden = true;
+  document.body.appendChild(div);
+  MicroModal.init();
+  return div;
+}
+
 export async function showModal(attrs: ModalDefinition): Promise<void> {
-  const modal = document.querySelector('#main-modal') as HTMLElement;
+  const modal = getOrCreateDOM();
   m.render(
       modal,
       m('.modal-overlay[data-micromodal-close]',
diff --git a/ui/src/frontend/record_page.ts b/ui/src/frontend/record_page.ts
index 3f425f8..4de254a 100644
--- a/ui/src/frontend/record_page.ts
+++ b/ui/src/frontend/record_page.ts
@@ -137,7 +137,7 @@
     return m(
         `label${cfg.mode === mode ? '.selected' : ''}`,
         m(`input[type=radio][name=rec_mode]`, checkboxArgs),
-        m(`img[src=assets/${img}]`),
+        m(`img[src=${globals.root}assets/${img}]`),
         m('span', title));
   };
 
diff --git a/ui/src/frontend/record_widgets.ts b/ui/src/frontend/record_widgets.ts
index 2e6db1f..fd919a8 100644
--- a/ui/src/frontend/record_widgets.ts
+++ b/ui/src/frontend/record_widgets.ts
@@ -70,7 +70,7 @@
     return m(
         `.probe${enabled ? '.enabled' : ''}`,
         attrs.img && m('img', {
-          src: `assets/${attrs.img}`,
+          src: `${globals.root}assets/${attrs.img}`,
           onclick: () => onToggle(!enabled),
         }),
         m('label',
diff --git a/ui/src/frontend/service_worker_controller.ts b/ui/src/frontend/service_worker_controller.ts
index 55bcfdc..bc11ee3 100644
--- a/ui/src/frontend/service_worker_controller.ts
+++ b/ui/src/frontend/service_worker_controller.ts
@@ -74,12 +74,24 @@
   async install() {
     if (!('serviceWorker' in navigator)) return;  // Not supported.
 
+    if (location.pathname !== '/') {
+      // Disable the service worker when the UI is loaded from a non-root URL
+      // (e.g. from the CI artifacts GCS bucket). Supporting the case of a
+      // nested index.html is too cumbersome and has no benefits.
+      return;
+    }
+
     if (await caches.has(BYPASS_ID)) {
       this._bypassed = true;
       console.log('Skipping service worker registration, disabled by the user');
       return;
     }
-    navigator.serviceWorker.register('service_worker.js').then(registration => {
+    // In production cases versionDir == VERSION. We use this here for ease of
+    // testing (so we can have /v1.0.0a/ /v1.0.0b/ even if they have the same
+    // version code).
+    const versionDir = globals.root.split('/').slice(-2)[0];
+    const swUri = `/service_worker.js?v=${versionDir}`;
+    navigator.serviceWorker.register(swUri).then(registration => {
       this._initialWorker = registration.active;
 
       // At this point there are two options:
@@ -90,8 +102,7 @@
       this.monitorWorker(registration.installing);
       this.monitorWorker(registration.active);
 
-      // Setup the event that shows the "A new release is available"
-      // notification.
+      // Setup the event that shows the "Updated to v1.2.3" notification.
       registration.addEventListener('updatefound', () => {
         this.monitorWorker(registration.installing);
       });
diff --git a/ui/src/frontend/sidebar.ts b/ui/src/frontend/sidebar.ts
index a2d0a8d..37394ed 100644
--- a/ui/src/frontend/sidebar.ts
+++ b/ui/src/frontend/sidebar.ts
@@ -688,6 +688,7 @@
               {
                 href: `https://github.com/google/perfetto/tree/${
                     version.SCM_REVISION}/ui`,
+                title: `Channel: ${globals.channel}`,
                 target: '_blank',
               },
               `${version.VERSION}`),
@@ -813,8 +814,8 @@
           ontransitionend: () => this._redrawWhileAnimating.stop(),
         },
         m(
-            'header',
-            m('img[src=assets/brand.png].brand'),
+            `header.${globals.channel}`,
+            m(`img[src=${globals.root}assets/brand.png].brand`),
             m('button.sidebar-button',
               {
                 onclick: () => {
diff --git a/ui/src/frontend/topbar.ts b/ui/src/frontend/topbar.ts
index dfeb056..0af8781 100644
--- a/ui/src/frontend/topbar.ts
+++ b/ui/src/frontend/topbar.ts
@@ -16,6 +16,7 @@
 
 import {Actions} from '../common/actions';
 import {EngineConfig} from '../common/state';
+import * as version from '../gen/perfetto_version';
 
 import {globals} from './globals';
 import {executeSearch} from './search_handler';
@@ -198,17 +199,10 @@
     }
     return m(
         '.new-version-toast',
-        'A new version of the UI is available!',
+        `Updated to ${version.VERSION} and ready for offline use!`,
         m('button.notification-btn.preferred',
           {
             onclick: () => {
-              location.reload();
-            }
-          },
-          'Reload'),
-        m('button.notification-btn',
-          {
-            onclick: () => {
               globals.frontendLocalState.newVersionAvailable = false;
               globals.rafScheduler.scheduleFullRedraw();
             }
diff --git a/ui/src/service_worker/service_worker.ts b/ui/src/service_worker/service_worker.ts
index 668f840..fbe5551 100644
--- a/ui/src/service_worker/service_worker.ts
+++ b/ui/src/service_worker/service_worker.ts
@@ -18,38 +18,110 @@
 
 // When a new version of the UI is released (e.g. v1 -> v2), the following
 // happens on the next visit:
-// 1. The v1 (old) service worker is activated (at this point we don't know yet
-//    that v2 is released).
-// 2. /index.html is requested. The SW intercepts the request and serves
-//    v1 from cache.
-// 3. The browser checks if a new version of service_worker.js is available. It
-//    does that by comparing the bytes of the current and new version.
-// 5. service_worker.js v2 will not be byte identical with v1, even if v2 was a
-//    css-only change. This is due to the hashes in UI_DIST_MAP below. For this
-//    reason v2 is installed in the background (it takes several seconds).
-// 6. The 'install' handler is triggered, the new resources are fetched and
-//    populated in the cache.
-// 7. The 'activate' handler is triggered. The old caches are deleted at this
+// 1. The v1 (old) service worker is activated. At this point we don't know yet
+//    that v2 is released.
+// 2. /index.html is requested. The SW intercepts the request and serves it from
+//    the network.
+// 3a If the request fails (offline / server unreachable) or times out, the old
+//    v1 is served.
+// 3b If the request succeeds, the browser receives the index.html for v2. That
+//    will try to fetch resources from /v2/frontend_bundle.ts.
+// 4. When the SW sees the /v2/ request, will have a cache miss and will issue
+//    a network fetch(), returning the fresh /v2/ content.
+// 4. The v2 site will call serviceWorker.register('service_worker.js?v=v2').
+//    This (i.e. the different querystring) will cause a re-installation of the
+//    service worker (even if the service_worker.js script itself is unchanged).
+// 5. In the "install" step, the service_worker.js script will fetch the newer
+//    version (v2).
+//    Note: the v2 will be fetched twice, once upon the first request that
+//    causes causes a cache-miss, the second time while re-installing the SW.
+//    The  latter though will hit a HTTP 304 (Not Changed) and will be served
+//    from the browser cache after the revalidation request.
+// 6. The 'activate' handler is triggered. The old v1 cache is deleted at this
 //    point.
-// 8. frontend/index.ts (in setupServiceWorker()) is notified about the activate
-//    and shows a notification prompting to reload the UI.
-//
-// If the user just closes the tab or hits refresh, v2 will be served anyways
-// on the next load.
-
-// UI_DIST_FILES is map of {file_name -> sha1}.
-// It is really important that this map is bundled directly in the
-// service_worker.js bundle file, as it's used to cause the browser to
-// re-install the service worker and re-fetch resources when anything changes.
-// This is why the map contains the SHA1s even if we don't directly use them in
-// the code (because it makes the final .js file content-dependent).
-
-import {UI_DIST_MAP} from '../gen/dist_file_map';
 
 declare var self: ServiceWorkerGlobalScope;
+export {};
 
-const CACHE_NAME = 'dist-' + UI_DIST_MAP.hex_digest.substr(0, 16);
-const LOG_TAG = `ServiceWorker[${UI_DIST_MAP.hex_digest.substr(0, 16)}]: `;
+const LOG_TAG = `ServiceWorker: `;
+const CACHE_NAME = 'ui-perfetto-dev';
+
+// 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.
+const INDEX_TIMEOUT_MS = 3000;
+
+// Use more relaxed timeouts when caching the subresources for the new version
+// in the background.
+const INSTALL_TIMEOUT_MS = 30000;
+
+// 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
+//    will get the newer version regardless, unless we hit INDEX_TIMEOUT_MS).
+// The latter happens because:
+// - / (index.html) is always served from the network (% timeout) and it pulls
+//   /v1.2.3/frontend_bundle.js.
+// - /v1.2.3/frontend_bundle.js will register /service_worker.js?v=v1.2.3 .
+// The service_worker.js script itself never changes, but the browser
+// re-installs it because the version in the V? query-string argument changes.
+// The reinstallation will cache the new files from the v.1.2.3/manifest.json.
+self.addEventListener('install', event => {
+  const doInstall = async () => {
+    if (await caches.has('BYPASS_SERVICE_WORKER')) {
+      // Throw will prevent the installation.
+      throw new Error(LOG_TAG + 'skipping installation, bypass enabled');
+    }
+
+    // Delete old cache entries from the pre-feb-2021 service worker.
+    for (const key of await caches.keys()) {
+      if (key.startsWith('dist-')) {
+        await caches.delete(key);
+      }
+    }
+
+    // The UI should register this as service_worker.js?v=v1.2.3. Extract the
+    // version number and pre-fetch all the contents for the version.
+    const match = /\bv=([\w.]*)/.exec(location.search);
+    if (!match) {
+      throw new Error(
+          'Failed to install. Was epecting a query string like ' +
+          `?v=v1.2.3 query string, got "${location.search}" instead`);
+    }
+    await installAppVersionIntoCache(match[1]);
+
+    // skipWaiting() still waits for the install to be complete. Without this
+    // call, the new version would be activated only when all tabs are closed.
+    // Instead, we ask to activate it immediately. This is safe because the
+    // subresources are versioned (e.g. /v1.2.3/frontend_bundle.js). Even if
+    // there is an old UI tab opened while we activate() a newer version, the
+    // activate() would just cause cache-misses, hence fetch from the network,
+    // for the old tab.
+    self.skipWaiting();
+  };
+  event.waitUntil(doInstall());
+});
+
+self.addEventListener('activate', (event) => {
+  console.info(LOG_TAG + 'activated');
+  const doActivate = async () => {
+    // This makes a difference only for the very first load, when no service
+    // worker is present. In all the other cases the skipWaiting() will hot-swap
+    // the active service worker anyways.
+    await self.clients.claim();
+  };
+  event.waitUntil(doActivate());
+});
+
+self.addEventListener('fetch', event => {
+  // The early return here will cause the browser to fall back on standard
+  // network-based fetch.
+  if (!shouldHandleHttpRequest(event.request)) {
+    console.debug(LOG_TAG + `serving ${event.request.url} from network`);
+    return;
+  }
+
+  event.respondWith(handleHttpRequest(event.request));
+});
 
 
 function shouldHandleHttpRequest(req: Request): boolean {
@@ -80,88 +152,90 @@
   // resources, which is undesirable.
   // * Only Ctrl+R. Ctrl+Shift+R will always bypass service-worker for all the
   // requests (index.html and the rest) made in that tab.
-  try {
-    const cacheOps = {cacheName: CACHE_NAME} as CacheQueryOptions;
-    const cachedRes = await caches.match(req, cacheOps);
-    if (cachedRes) {
-      console.debug(LOG_TAG + `serving ${req.url} from cache`);
-      return cachedRes;
+
+  const cacheOps = {cacheName: CACHE_NAME} as CacheQueryOptions;
+  const url = new URL(req.url);
+  if (url.pathname === '/') {
+    try {
+      console.debug(LOG_TAG + `Fetching live ${req.url}`);
+      // The await bleow is needed to fall through in case of an exception.
+      return await fetchWithTimeout(req, INDEX_TIMEOUT_MS);
+    } catch (err) {
+      console.warn(LOG_TAG + `Failed to fetch ${req.url}, using cache.`, err);
+      // Fall through the code below.
     }
-    console.warn(LOG_TAG + `cache miss on ${req.url}`);
-  } catch (exc) {
-    console.error(LOG_TAG + `Cache request failed for ${req.url}`, exc);
+  } else if (url.pathname === '/offline') {
+    // Escape hatch to force serving the offline version without attemping the
+    // network fetch.
+    const cachedRes = await caches.match(new Request('/'), cacheOps);
+    if (cachedRes) return cachedRes;
+  }
+
+  const cachedRes = await caches.match(req, cacheOps);
+  if (cachedRes) {
+    console.debug(LOG_TAG + `serving ${req.url} from cache`);
+    return cachedRes;
   }
 
   // In any other case, just propagate the fetch on the network, which is the
   // safe behavior.
-  console.debug(LOG_TAG + `falling back on network fetch() for ${req.url}`);
+  console.warn(LOG_TAG + `cache miss on ${req.url}, using live network`);
   return fetch(req);
 }
 
-// The install() event is fired:
-// - The very first time the site is visited, after frontend/index.ts has
-//   executed the serviceWorker.register() method.
-// - *After* the site is loaded, if the service_worker.js code
-//   has changed (because of the hashes in UI_DIST_MAP, service_worker.js will
-//   change if anything in the UI has changed).
-self.addEventListener('install', event => {
-  const doInstall = async () => {
-    if (await caches.has('BYPASS_SERVICE_WORKER')) {
-      // Throw will prevent the installation.
-      throw new Error(LOG_TAG + 'skipping installation, bypass enabled');
+async function installAppVersionIntoCache(version: string) {
+  const manifestUrl = `${version}/manifest.json`;
+  try {
+    console.log(LOG_TAG + `Starting installation of ${manifestUrl}`);
+    await caches.delete(CACHE_NAME);
+    const resp = await fetchWithTimeout(manifestUrl, INSTALL_TIMEOUT_MS);
+    const manifest = await resp.json();
+    const manifestResources = manifest['resources'];
+    if (!manifestResources || !(manifestResources instanceof Object)) {
+      throw new Error(`Invalid manifest ${manifestUrl} : ${manifest}`);
     }
-    console.log(LOG_TAG + 'installation started');
+
     const cache = await caches.open(CACHE_NAME);
     const urlsToCache: RequestInfo[] = [];
-    for (const [file, integrity] of Object.entries(UI_DIST_MAP.files)) {
-      const reqOpts:
-          RequestInit = {cache: 'reload', mode: 'same-origin', integrity};
-      urlsToCache.push(new Request(file, reqOpts));
-      if (file === 'index.html' && location.host !== 'storage.googleapis.com') {
-        // Disable cachinig of '/' for cases where the UI is hosted on GCS.
-        // GCS doesn't support auto indexes. GCS returns a 404 page on / that
-        // fails the integrity check.
-        urlsToCache.push(new Request('/', reqOpts));
-      }
+
+    // We use cache:reload to make sure that the index is always current and we
+    // don't end up in some cycle where we keep re-caching the index coming from
+    // the service worker itself.
+    urlsToCache.push(new Request('/', {cache: 'reload', mode: 'same-origin'}));
+
+    for (const [resource, integrity] of Object.entries(manifestResources)) {
+      // We use cache: no-cache rather then reload here because the versioned
+      // sub-resources are expected to be immutable and should never be
+      // ambiguous. A revalidation request is enough.
+      const reqOpts: RequestInit = {
+        cache: 'no-cache',
+        mode: 'same-origin',
+        integrity: `${integrity}`
+      };
+      urlsToCache.push(new Request(`${version}/${resource}`, reqOpts));
     }
     await cache.addAll(urlsToCache);
-    console.log(LOG_TAG + 'installation completed');
-
-    // skipWaiting() still waits for the install to be complete. Without this
-    // call, the new version would be activated only when all tabs are closed.
-    // Instead, we ask to activate it immediately. This is safe because each
-    // service worker version uses a different cache named after the SHA256 of
-    // the contents. When the old version is activated, the activate() method
-    // below will evict the cache for the old versions. If there is an old still
-    // opened, any further request from that tab will be a cache-miss and go
-    // through the network (which is inconsitent, but not the end of the world).
-    self.skipWaiting();
-  };
-  event.waitUntil(doInstall());
-});
-
-self.addEventListener('activate', (event) => {
-  console.warn(LOG_TAG + 'activated');
-  const doActivate = async () => {
-    // Clear old caches.
-    for (const key of await caches.keys()) {
-      if (key !== CACHE_NAME) await caches.delete(key);
-    }
-    // This makes a difference only for the very first load, when no service
-    // worker is present. In all the other cases the skipWaiting() will hot-swap
-    // the active service worker anyways.
-    await self.clients.claim();
-  };
-  event.waitUntil(doActivate());
-});
-
-self.addEventListener('fetch', event => {
-  // The early return here will cause the browser to fall back on standard
-  // network-based fetch.
-  if (!shouldHandleHttpRequest(event.request)) {
-    console.debug(LOG_TAG + `serving ${event.request.url} from network`);
-    return;
+    console.log(LOG_TAG + 'installation completed for ' + version);
+  } catch (err) {
+    await caches.delete(CACHE_NAME);
+    console.error(LOG_TAG + `Installation failed for ${manifestUrl}`, err);
+    throw err;
   }
+}
 
-  event.respondWith(handleHttpRequest(event.request));
-});
+function fetchWithTimeout(req: Request|string, timeoutMs: number) {
+  const url = (req as {url?: string}).url || `${req}`;
+  return new Promise<Response>((resolve, reject) => {
+    const timerId = setTimeout(() => {
+      reject(`Timed out while fetching ${url}`);
+    }, timeoutMs);
+    fetch(req).then(resp => {
+      clearTimeout(timerId);
+      if (resp.ok) {
+        resolve(resp);
+      } else {
+        reject(`Fetch failed for ${url}: ${resp.status} ${resp.statusText}`);
+      }
+    }, reject);
+  });
+}
diff --git a/ui/src/service_worker/tsconfig.json b/ui/src/service_worker/tsconfig.json
index 7b58182..35ff5b6 100644
--- a/ui/src/service_worker/tsconfig.json
+++ b/ui/src/service_worker/tsconfig.json
@@ -5,6 +5,7 @@
     "../gen/"
   ],
   "compilerOptions": {
+    "outDir": "../../out/tsc/service_worker",
     "lib": [
       "webworker",
       "es2018",
diff --git a/ui/tsconfig.base.json b/ui/tsconfig.base.json
index f8cdfc4..075b1b3 100644
--- a/ui/tsconfig.base.json
+++ b/ui/tsconfig.base.json
@@ -8,7 +8,6 @@
     "allowJs": true,
     "declaration": false,                  // Generates corresponding '.d.ts' file.
     "sourceMap": true,                     // Generates corresponding '.map' file.
-    "outDir": "./out/tsc",                 // Redirect output structure to the directory.
     "removeComments": false,               // Do not emit comments to output.
     "importHelpers": true,                 // Import emit helpers from 'tslib'.
     "downlevelIteration": true,            // Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'.
diff --git a/ui/tsconfig.json b/ui/tsconfig.json
index ada3c9b..5867aea 100644
--- a/ui/tsconfig.json
+++ b/ui/tsconfig.json
@@ -8,6 +8,7 @@
     "./out"
   ],
   "compilerOptions": {
+    "outDir": "./out/tsc",
     "lib": [
       "dom",                               // Need to be explicitly mentioned now since we're overriding default included libs.
       "es2018",                            // Need this to use Object.values.