UI: move build artifacts into /v1.2.3/

This CL moves all the build artifacts into a
subdirectory named after the version number.
This is done for two reasons:
1. Cache-busting, to avoid pulling subresources
   at different versions.
2. Prepare for multiple channels (see
   go/perfetto-ui-channels).

This CL also changes the service worker logic
as follows (See go/perfetto-offline for full
details):
- Network-first fetch of the / index, to
  guarantee that the index is always current.
- The subresource map with hashes is not bundled
  in the service_worker.js script but is fetched
  separately from /v.1.2.3/manifest.json.

Bug: 179864115
Change-Id: Iece22b770f7e2bd958cdcfe30df046832f070641
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/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/src/assets/index.html b/ui/src/assets/index.html
index b1e57fa..7ad3b21 100644
--- a/ui/src/assets/index.html
+++ b/ui/src/assets/index.html
@@ -4,9 +4,9 @@
   <meta charset="utf-8">
   <title>Perfetto UI</title>
   <meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" name="viewport" />
-  <link rel="shortcut icon" id="favicon" type="image/png" href="assets/favicon.png">
+  <link rel="shortcut icon" id="favicon" type="image/png" href="data:image/png;base64,iVBORw0KGgo=">
 </head>
-<body>
+<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
@@ -55,12 +55,17 @@
       let errTimerId = undefined;
 
       function errHandler(err) {
-        console.error(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');
@@ -91,9 +96,19 @@
       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 = 'frontend_bundle.js';
+      script.src = version + '/frontend_bundle.js';
       script.onerror = () => errHandler(`Failed to load ${script.src}`);
 
       document.head.append(script);
diff --git a/ui/src/frontend/analytics.ts b/ui/src/frontend/analytics.ts
index 5abf7ed..c652d6a 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,7 @@
       referrer: document.referrer.split('?')[0],
       send_page_view: false,
       dimension1: globals.isInternalUser ? '1' : '0',
+      dimension2: version.VERSION,
     });
     this.updatePath(route);
   }
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index 38bd7a9..2d8d66e 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;
diff --git a/ui/src/frontend/home_page.ts b/ui/src/frontend/home_page.ts
index 5d37cbb..6662e8f 100644
--- a/ui/src/frontend/home_page.ts
+++ b/ui/src/frontend/home_page.ts
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 import * as m from 'mithril';
-
+import {globals} from './globals';
 import {createPage} from './pages';
 
 export const HomePage = createPage({
@@ -21,7 +21,7 @@
     return m(
         '.page.home-page',
         m('.home-page-title', 'Perfetto'),
-        m('img.logo[src=assets/logo-3d.png]'),
+        m(`img.logo[src=${globals.root}assets/logo-3d.png]`),
         m('a.privacy',
           {href: 'https://policies.google.com/privacy', target: '_blank'},
           'Privacy policy'));
diff --git a/ui/src/frontend/index.ts b/ui/src/frontend/index.ts
index 7645a8f..1b6584e 100644
--- a/ui/src/frontend/index.ts
+++ b/ui/src/frontend/index.ts
@@ -316,9 +316,11 @@
   const cssLoadPromise = defer<void>();
   const css = document.createElement('link');
   css.rel = 'stylesheet';
-  css.href = 'perfetto.css';
+  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).
@@ -337,7 +339,7 @@
   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();
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/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..51c48b5 100644
--- a/ui/src/frontend/sidebar.ts
+++ b/ui/src/frontend/sidebar.ts
@@ -813,8 +813,8 @@
           ontransitionend: () => this._redrawWhileAnimating.stop(),
         },
         m(
-            'header',
-            m('img[src=assets/brand.png].brand'),
+            `header`,
+            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.