Merge "ui: add offline support via ServiceWorker"
diff --git a/gn/standalone/write_ui_dist_file_map.py b/gn/standalone/write_ui_dist_file_map.py
new file mode 100644
index 0000000..8ea5aff
--- /dev/null
+++ b/gn/standalone/write_ui_dist_file_map.py
@@ -0,0 +1,90 @@
+#!/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 multiprocessing
+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 (in parallel).
+  pool = multiprocessing.Pool(multiprocessing.cpu_count() * 2)
+  digests = dict(pool.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.iteritems():
+    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))
+  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/ui/BUILD.gn b/ui/BUILD.gn
index b27e380..d7d67a8 100644
--- a/ui/BUILD.gn
+++ b/ui/BUILD.gn
@@ -28,23 +28,44 @@
 # +----------------------------------------------------------------------------+
 # | The outer "ui" target to just ninja -C out/xxx ui                          |
 # +----------------------------------------------------------------------------+
+
 group("ui") {
   deps = [
-    ":assets_dist",
-    ":catapult_dist",
     ":chrome_extension_assets_dist",
     ":chrome_extension_bundle_dist",
-    ":controller_bundle_dist",
-    ":engine_bundle_dist",
-    ":frontend_bundle_dist",
-    ":index_dist",
-    ":scss",
+    ":dist",
+    ":gen_dist_file_map",
+    ":service_worker_bundle_dist",
     ":test_scripts",
-    ":typefaces_dist",
-    ":wasm_dist",
+
+    # IMPORTANT: Only add deps here if they are NOT part of the production UI
+    # (e.g., tests, extensions, ...). Any UI dep should go in the
+    # |ui_dist_targets| list below. The only exception is the service worker
+    # target, that depends on that list.
   ]
 }
 
+# The list of targets that produces dist/ files for the UI. This list is used
+# also by the gen_dist_file_map to generate the map of hashes of all UI files,
+# which is turn used by the service worker code for the offline caching.
+ui_dist_targets = [
+  ":assets_dist",
+  ":catapult_dist",
+  ":controller_bundle_dist",
+  ":engine_bundle_dist",
+  ":frontend_bundle_dist",
+  ":index_dist",
+  ":scss",
+  ":typefaces_dist",
+  ":wasm_dist",
+]
+
+# Buils the ui, but not service worker, tests and extensions.
+group("dist") {
+  deps = ui_dist_targets
+}
+
+# A minimal page to profile the WASM engine without the all UI.
 group("query") {
   deps = [
     ":query_bundle_dist",
@@ -182,6 +203,14 @@
   output = "$target_out_dir/engine_bundle.js"
 }
 
+bundle("service_worker_bundle") {
+  deps = [
+    ":transpile_service_worker_ts",
+  ]
+  input = "$target_out_dir/service_worker/service_worker.js"
+  output = "$target_out_dir/service_worker.js"
+}
+
 bundle("query_bundle") {
   deps = [
     ":transpile_all_ts",
@@ -270,6 +299,7 @@
                 "--filter=*.ts",
                 "--exclude=node_modules",
                 "--exclude=dist",
+                "--exclude=service_worker",
                 "--deps=obj/ui/frontend/index.js",
                 "--output=" + rebase_path(depfile),
               ],
@@ -284,6 +314,28 @@
   ]
 }
 
+node_bin("transpile_service_worker_ts") {
+  deps = [
+    ":dist_symlink",
+    ":gen_dist_file_map",
+  ]
+  inputs = [
+    "tsconfig.json",
+    "src/service_worker/service_worker.ts",
+  ]
+  outputs = [
+    "$target_out_dir/service_worker/service_worker.js",
+  ]
+
+  node_cmd = "tsc"
+  args = [
+    "--project",
+    rebase_path("src/service_worker", root_build_dir),
+    "--outDir",
+    rebase_path(target_out_dir, root_build_dir),
+  ]
+}
+
 # +----------------------------------------------------------------------------+
 # | Build css.                                                                 |
 # +----------------------------------------------------------------------------+
@@ -424,6 +476,14 @@
   output = "$ui_dir/engine_bundle.js"
 }
 
+sorcery("service_worker_bundle_dist") {
+  deps = [
+    ":service_worker_bundle",
+  ]
+  input = "$target_out_dir/service_worker.js"
+  output = "$ui_dir/service_worker.js"
+}
+
 sorcery("query_bundle_dist") {
   deps = [
     ":query_bundle",
@@ -585,3 +645,32 @@
     "$root_build_dir/ui_tests",
   ]
 }
+
+# This target generates an map containing all the UI subresources and their
+# hashes. This is used by the service worker code for offline caching.
+# This taarget needs to be kept at the end of the BUILD.gn file, because of the
+# get_target_outputs() call (fails otherwise due to GN's evaluation order).
+action("gen_dist_file_map") {
+  out_file_path = "$ui_gen_dir/dist_file_map.ts"
+
+  dist_files = []
+  foreach(target, ui_dist_targets) {
+    foreach(dist_file, get_target_outputs(target)) {
+      dist_files += [ rebase_path(dist_file, root_build_dir) ]
+    }
+  }
+  deps = [
+    ":dist",
+  ]
+  script = "../gn/standalone/write_ui_dist_file_map.py"
+  inputs = []
+  outputs = [
+    out_file_path,
+  ]
+  args = [
+           "--out",
+           rebase_path(out_file_path, root_build_dir),
+           "--strip",
+           rebase_path(ui_dir, root_build_dir),
+         ] + dist_files
+}
diff --git a/ui/index.html b/ui/index.html
index 0e1833a..fae544d 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -8,9 +8,6 @@
   <meta http-equiv="origin-trial" content="AtzsILqIzNPGftktQTEYxI9GpnqFBuse5uB5n4JQO3Wa1ky4TCKmnXZli0A9g9p7Es7Il9pqarELntnfm0HriwkAAABreyJvcmlnaW4iOiJodHRwczovL3VpLnBlcmZldHRvLmRldjo0NDMiLCJmZWF0dXJlIjoiV2ViQ29tcG9uZW50c1YwIiwiZXhwaXJ5IjoxNjA4MjI2NDQzLCJpc1N1YmRvbWFpbiI6dHJ1ZX0=">
   <link href="perfetto.css" rel="stylesheet">
   <link rel="icon" type="image/png" href="assets/logo.png">
-  <link rel="preload" href="controller_bundle.js" as="script">
-  <link rel="preload" href="engine_bundle.js" as="script">
-  <link rel="preload" href="trace_processor.wasm" as="fetch">
 </head>
 <body>
   <main>
diff --git a/ui/src/assets/sidebar.scss b/ui/src/assets/sidebar.scss
index 7cb1ce2..7868f27 100644
--- a/ui/src/assets/sidebar.scss
+++ b/ui/src/assets/sidebar.scss
@@ -184,7 +184,7 @@
         }
       }
 
-      > .num-queued-queries {
+      > .dbg-info-square {
         width: 24px;
         height: 22px;
         line-height: 22px;
@@ -194,11 +194,15 @@
         border-radius: 5px;
         font-size: 12px;
         text-align: center;
-        &.rpc {
+        &.green {
           background: #7aca75;
           color: #12161b;
         }
-        &.failed {
+        &.amber {
+          background: #FFC107;
+          color: #333;
+        }
+        &.red {
           background: #d32f2f;
           color: #fff;
         }
diff --git a/ui/src/assets/topbar.scss b/ui/src/assets/topbar.scss
index 49c06df..f5ce2f5 100644
--- a/ui/src/assets/topbar.scss
+++ b/ui/src/assets/topbar.scss
@@ -201,4 +201,24 @@
             right: -90%;
         }
     }
+
+    .notification-btn {
+        @include transition(0.25s);
+        font-size: 16px;
+        padding: 8px 10px;
+        margin: 0 10px;
+        border-radius: 2px;
+        background: hsl(210, 10%, 73%);
+        &:hover {
+            background: hsl(210, 10%, 83%);
+        }
+
+        &.preferred {
+            background: hsl(210, 98%, 53%);
+            color: #fff;
+            &:hover {
+                background: hsl(210, 98%, 63%);
+            }
+        }
+    }
 }
diff --git a/ui/src/frontend/frontend_local_state.ts b/ui/src/frontend/frontend_local_state.ts
index b38fe94..0f4a605 100644
--- a/ui/src/frontend/frontend_local_state.ts
+++ b/ui/src/frontend/frontend_local_state.ts
@@ -107,6 +107,7 @@
   currentTab?: Tab;
   scrollToTrackId?: string|number;
   httpRpcState: HttpRpcState = {connected: false};
+  newVersionAvailable = false;
   private scrollBarWidth?: number;
 
   private _omniboxState: OmniboxState = {
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index 0fb518d..7c384ba 100644
--- a/ui/src/frontend/globals.ts
+++ b/ui/src/frontend/globals.ts
@@ -20,6 +20,7 @@
 
 import {FrontendLocalState} from './frontend_local_state';
 import {RafScheduler} from './raf_scheduler';
+import {ServiceWorkerController} from './service_worker_controller';
 
 type Dispatch = (action: DeferredAction) => void;
 type TrackDataStore = Map<string, {}>;
@@ -85,6 +86,7 @@
   private _state?: State = undefined;
   private _frontendLocalState?: FrontendLocalState = undefined;
   private _rafScheduler?: RafScheduler = undefined;
+  private _serviceWorkerController?: ServiceWorkerController = undefined;
 
   // TODO(hjd): Unify trackDataStore, queryResults, overviewStore, threads.
   private _trackDataStore?: TrackDataStore = undefined;
@@ -126,6 +128,7 @@
     this._state = createEmptyState();
     this._frontendLocalState = new FrontendLocalState();
     this._rafScheduler = new RafScheduler();
+    this._serviceWorkerController = new ServiceWorkerController();
 
     // TODO(hjd): Unify trackDataStore, queryResults, overviewStore, threads.
     this._trackDataStore = new Map<string, {}>();
@@ -157,6 +160,10 @@
     return assertExists(this._rafScheduler);
   }
 
+  get serviceWorkerController() {
+    return assertExists(this._serviceWorkerController);
+  }
+
   // TODO(hjd): Unify trackDataStore, queryResults, overviewStore, threads.
   get overviewStore(): OverviewStore {
     return assertExists(this._overviewStore);
@@ -262,6 +269,7 @@
     this._state = undefined;
     this._frontendLocalState = undefined;
     this._rafScheduler = undefined;
+    this._serviceWorkerController = undefined;
 
     // TODO(hjd): Unify trackDataStore, queryResults, overviewStore, threads.
     this._trackDataStore = undefined;
diff --git a/ui/src/frontend/index.ts b/ui/src/frontend/index.ts
index c9f75bc..e1d5c64 100644
--- a/ui/src/frontend/index.ts
+++ b/ui/src/frontend/index.ts
@@ -78,7 +78,7 @@
   // want to keep in the global state. Figure out a more generic and type-safe
   // mechanism to achieve this.
 
-  publishOverviewData(data: {[key: string]: QuantizedLoad | QuantizedLoad[]}) {
+  publishOverviewData(data: {[key: string]: QuantizedLoad|QuantizedLoad[]}) {
     for (const [key, value] of Object.entries(data)) {
       if (!globals.overviewStore.has(key)) {
         globals.overviewStore.set(key, []);
@@ -251,6 +251,7 @@
       dispatch);
   forwardRemoteCalls(frontendChannel.port2, new FrontendApi(router));
   globals.initialize(dispatch, controller);
+  globals.serviceWorkerController.install();
 
   // We proxy messages between the extension and the controller because the
   // controller's worker can't access chrome.runtime.
diff --git a/ui/src/frontend/service_worker_controller.ts b/ui/src/frontend/service_worker_controller.ts
new file mode 100644
index 0000000..54c5fe4
--- /dev/null
+++ b/ui/src/frontend/service_worker_controller.ts
@@ -0,0 +1,119 @@
+// 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.
+
+// Handles registration, unregistration and lifecycle of the service worker.
+// This class contains only the controlling logic, all the code in here runs in
+// the main thread, not in the service worker thread.
+// The actual service worker code is in src/service_worker.
+// Design doc: http://go/perfetto-offline.
+
+import {reportError} from '../base/logging';
+import {globals} from './globals';
+
+// We use a dedicated |caches| object to share a global boolean beween the main
+// thread and the SW. SW cannot use local-storage or anything else other than
+// IndexedDB (which would be overkill).
+const BYPASS_ID = 'BYPASS_SERVICE_WORKER';
+
+export class ServiceWorkerController {
+  private _initialWorker: ServiceWorker|null = null;
+  private _bypassed = false;
+  private _installing = false;
+
+  // Caller should reload().
+  async setBypass(bypass: boolean) {
+    this._bypassed = bypass;
+    if (bypass) {
+      await caches.open(BYPASS_ID);  // Create the entry.
+      for (const reg of await navigator.serviceWorker.getRegistrations()) {
+        await reg.unregister();
+      }
+    } else {
+      await caches.delete(BYPASS_ID);
+      this.install();
+    }
+    globals.rafScheduler.scheduleFullRedraw();
+  }
+
+  onStateChange(sw: ServiceWorker) {
+    globals.rafScheduler.scheduleFullRedraw();
+    if (sw.state === 'installing') {
+      this._installing = true;
+    } else if (sw.state === 'activated') {
+      this._installing = false;
+      // Don't show the notification if the site was served straight
+      // from the network (e.g., on the very first visit or after
+      // Ctrl+Shift+R). In these cases, we are already at the last
+      // version.
+      if (sw !== this._initialWorker && this._initialWorker) {
+        globals.frontendLocalState.newVersionAvailable = true;
+      }
+    } else if (
+        sw.state === 'redundant' && sw !== this._initialWorker &&
+        !this._bypassed) {
+      // Note that upon updates, the initial SW will hit the 'redundant'
+      // state by design once the new one is activated. That's why the
+      // != _initialWorker above.
+
+      // In the other cases, the 'redundant' state signals a failure in the
+      // SW installation. This can happen, for instance, if the subresource
+      // integrity check fails. In that case there doesn't seem to be any easy
+      // way to get the failure output from the service worker.
+      reportError(
+          'Service Worker installation failed.\n' +
+          'Please attach the JavaScript console output to the bug.');
+    }
+  }
+
+  monitorWorker(sw: ServiceWorker|null) {
+    if (!sw) return;
+    sw.addEventListener('error', (e) => reportError(e));
+    sw.addEventListener('statechange', () => this.onStateChange(sw));
+    this.onStateChange(sw);  // Trigger updates for the current state.
+  }
+
+  async install() {
+    if (!('serviceWorker' in navigator)) return;  // Not supported.
+
+    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 => {
+      this._initialWorker = registration.active;
+
+      // At this point there are two options:
+      // 1. This is the first time we visit the site (or cache was cleared) and
+      //    no SW is installed yet. In this case |installing| will be set.
+      // 2. A SW is already installed (though it might be obsolete). In this
+      //    case |active| will be set.
+      this.monitorWorker(registration.installing);
+      this.monitorWorker(registration.active);
+
+      // Setup the event that shows the "A new release is available"
+      // notification.
+      registration.addEventListener('updatefound', () => {
+        this.monitorWorker(registration.installing);
+      });
+    });
+  }
+
+  get bypassed() {
+     return this._bypassed;
+  }
+  get installing() {
+    return this._installing;
+  }
+}
\ No newline at end of file
diff --git a/ui/src/frontend/sidebar.ts b/ui/src/frontend/sidebar.ts
index 4e7c3aa..f4305ad 100644
--- a/ui/src/frontend/sidebar.ts
+++ b/ui/src/frontend/sidebar.ts
@@ -464,7 +464,7 @@
 }
 
 
-const SidebarFooter: m.Component = {
+const EngineRPCWidget: m.Component = {
   view() {
     let cssClass = '';
     let title = 'Number of pending SQL queries';
@@ -478,7 +478,7 @@
     for (const engine of engines) {
       mode = engine.mode;
       if (engine.failed !== undefined) {
-        cssClass += '.failed';
+        cssClass += '.red';
         title = 'Query engine crashed\n' + engine.failed;
         failed = true;
       }
@@ -499,7 +499,7 @@
     }
 
     if (mode === 'HTTP_RPC') {
-      cssClass += '.rpc';
+      cssClass += '.green';
       label = 'RPC';
       title += '\n(Query engine: native accelerator over HTTP+RPC)';
     } else {
@@ -508,6 +508,85 @@
     }
 
     return m(
+        `.dbg-info-square${cssClass}`,
+        {title},
+        m('div', label),
+        m('div', `${failed ? 'FAIL' : globals.numQueuedQueries}`));
+  }
+};
+
+const ServiceWorkerWidget: m.Component = {
+  view() {
+    let cssClass = '';
+    let title = 'Service Worker: ';
+    let label = 'N/A';
+    const ctl = globals.serviceWorkerController;
+    if (ctl.bypassed) {
+      label = 'OFF';
+      cssClass = '.red';
+      title += 'Bypassed, using live network. Double-click to re-enable';
+    } else if (ctl.installing) {
+      label = 'UPD';
+      cssClass = '.amber';
+      title += 'Installing / updating ...';
+    } else if (!navigator.serviceWorker.controller) {
+      label = 'N/A';
+      title += 'Not available, using network';
+    } else {
+      label = 'ON';
+      cssClass = '.green';
+      title += 'Serving from cache. Ready for offline use';
+    }
+
+    const toggle = async () => {
+      if (globals.serviceWorkerController.bypassed) {
+        globals.serviceWorkerController.setBypass(false);
+        return;
+      }
+      showModal({
+        title: 'Disable service worker?',
+        content: m(
+            'div',
+            m('p', `If you continue the service worker will be disabled until
+                      manually re-enabled.`),
+            m('p', `All future requests will be served from the network and the
+                    UI won't be available offline.`),
+            m('p', `You should do this only if you are debugging the UI
+                    or if you are experiencing caching-related problems.`),
+            m('p', `Disabling will cause a refresh of the UI, the current state
+                    will be lost.`),
+            ),
+        buttons: [
+          {
+            text: 'Disable and reload',
+            primary: true,
+            id: 'sw-bypass-enable',
+            action: () => {
+              globals.serviceWorkerController.setBypass(true).then(
+                  () => location.reload());
+            }
+          },
+          {
+            text: 'Cancel',
+            primary: false,
+            id: 'sw-bypass-cancel',
+            action: () => {}
+          }
+        ]
+      });
+    };
+
+    return m(
+        `.dbg-info-square${cssClass}`,
+        {title, ondblclick: toggle},
+        m('div', 'SW'),
+        m('div', label));
+  }
+};
+
+const SidebarFooter: m.Component = {
+  view() {
+    return m(
         '.sidebar-footer',
         m('button',
           {
@@ -516,10 +595,8 @@
           m('i.material-icons',
             {title: 'Toggle Perf Debug Mode'},
             'assessment')),
-        m(`.num-queued-queries${cssClass}`,
-          {title},
-          m('div', label),
-          m('div', `${failed ? 'FAIL' : globals.numQueuedQueries}`)),
+        m(EngineRPCWidget),
+        m(ServiceWorkerWidget),
     );
   }
 };
diff --git a/ui/src/frontend/topbar.ts b/ui/src/frontend/topbar.ts
index 2163ace..4fd3c66 100644
--- a/ui/src/frontend/topbar.ts
+++ b/ui/src/frontend/topbar.ts
@@ -229,8 +229,39 @@
   }
 }
 
+
+class NewVersionNotification implements m.ClassComponent {
+  view() {
+    if (!globals.frontendLocalState.newVersionAvailable) return;
+    return m(
+        '.new-version-toast',
+        'A new version of the UI is available!',
+        m('button.notification-btn.preferred',
+          {
+            onclick: () => {
+              location.reload();
+            }
+          },
+          'Reload'),
+        m('button.notification-btn',
+          {
+            onclick: () => {
+              globals.frontendLocalState.newVersionAvailable = false;
+              globals.rafScheduler.scheduleFullRedraw();
+            }
+          },
+          'Dismiss'),
+    );
+  }
+}
+
 export class Topbar implements m.ClassComponent {
   view() {
-    return m('.topbar', m(Omnibox), m(Progress));
+    return m(
+        '.topbar',
+        globals.frontendLocalState.newVersionAvailable ?
+            m(NewVersionNotification) :
+            m(Omnibox),
+        m(Progress));
   }
 }
diff --git a/ui/src/service_worker/service_worker.ts b/ui/src/service_worker/service_worker.ts
new file mode 100644
index 0000000..4226a8a
--- /dev/null
+++ b/ui/src/service_worker/service_worker.ts
@@ -0,0 +1,154 @@
+// 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.
+
+// This script handles the caching of the UI resources, allowing it to work
+// offline (as long as the UI site has been visited at least once).
+// Design doc: http://go/perfetto-offline.
+
+// 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
+//    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;
+
+const CACHE_NAME = 'dist-' + UI_DIST_MAP.hex_digest.substr(0, 16);
+const LOG_TAG = `ServiceWorker[${UI_DIST_MAP.hex_digest.substr(0, 16)}]: `;
+
+async function handleHttpRequest(req: Request): Promise<Response> {
+  let fetchReason = 'N/A';
+  const url = new URL(req.url);
+
+  // We serve from the cache even if req.cache == 'no-cache'. It's a bit
+  // contra-intuitive but it's the most consistent option. If the user hits the
+  // reload button*, the browser requests the "/" index with a 'no-cache' fetch.
+  // However all the other resources (css, js, ...) are requested with a
+  // 'default' fetch (this is just how Chrome works, it's not us). If we bypass
+  // the service worker cache when we get a 'no-cache' request, we can end up in
+  // an inconsistent state where the index.html is more recent than the other
+  // 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.
+  const cacheable = req.method === 'GET' && url.origin === self.location.origin;
+  if (cacheable) {
+    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;
+      }
+      console.warn(LOG_TAG + `cache miss on ${req.url}`);
+      fetchReason = 'cache miss';
+    } catch (exc) {
+      console.error(LOG_TAG + `Fetch failed for ${req.url}`, exc);
+      fetchReason = 'fetch failed';
+    }
+  } else {
+    fetchReason = `not cacheable (${req.method}, ${req.cache}, ${url.origin})`;
+  }
+
+  // In any other case, just propagate the fetch on the network, which is the
+  // safe behavior.
+  console.debug(LOG_TAG + `serving ${req.url} from network: ${fetchReason}`);
+  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');
+    }
+    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', integrity};
+      urlsToCache.push(new Request(file, reqOpts));
+      if (file === 'index.html') {
+        const indexPage = location.href.split('service_worker.js')[0];
+        // Disable cachinig of '/' for cases where the UI is hosted in a
+        // subdirectory, because the ci-artifacts GCS bucket doesn't support
+        // auto indexes (it has a fallback 404 page that fails the check).
+        if (indexPage === '/') {
+          urlsToCache.push(new Request(indexPage, 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 => {
+  event.respondWith(handleHttpRequest(event.request));
+});
diff --git a/ui/src/service_worker/tsconfig.json b/ui/src/service_worker/tsconfig.json
new file mode 100644
index 0000000..7b58182
--- /dev/null
+++ b/ui/src/service_worker/tsconfig.json
@@ -0,0 +1,14 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "include": [ "." ],
+  "exclude": [
+    "../gen/"
+  ],
+  "compilerOptions": {
+    "lib": [
+      "webworker",
+      "es2018",
+    ],
+    "types" : []
+  }
+}
diff --git a/ui/tsconfig.base.json b/ui/tsconfig.base.json
new file mode 100644
index 0000000..e240301
--- /dev/null
+++ b/ui/tsconfig.base.json
@@ -0,0 +1,27 @@
+{
+  "compilerOptions": {
+    "baseUrl": ".",
+    "target": "es6",
+    "module": "commonjs",
+    "moduleResolution": "node",
+    // Lints and checks.
+    "allowJs": true,
+    "declaration": false,                  // Generates corresponding '.d.ts' file.
+    "sourceMap": true,                     // Generates corresponding '.map' file.
+    "outDir": "./dist",                    // 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'.
+    "strict": true,                        // Enable all strict type-checking options.
+    "noImplicitAny": true,                 // Raise error on expressions and declarations with an implied 'any' type.
+    "strictNullChecks": true,              // Enable strict null checks.
+    "strictFunctionTypes": true,           // Enable strict checking of function types.
+    "strictPropertyInitialization": true,  // Enable strict checking of property initialization in classes.
+    "noImplicitThis": true,                // Raise error on 'this' expressions with an implied 'any' type.
+    "alwaysStrict": true,                  // Parse in strict mode and emit "use strict" for each source file.
+    "noUnusedLocals": true,                // Report errors on unused locals.
+    "noUnusedParameters": true,            // Report errors on unused parameters.
+    "noImplicitReturns": true,             // Report error when not all code paths in function return a value.
+    "noFallthroughCasesInSwitch": true,    // Report errors for fallthrough cases in switch statement.
+  }
+}
diff --git a/ui/tsconfig.json b/ui/tsconfig.json
index e0564a8..8f2c266 100644
--- a/ui/tsconfig.json
+++ b/ui/tsconfig.json
@@ -1,7 +1,9 @@
 {
+  "extends": "./tsconfig.base.json",
   "include": [ "src/" ],
   "exclude": [
     "./node_modules/",
+    "./src/service_worker/",
     "./src/gen/"
   ],
   "compilerOptions": {
@@ -9,31 +11,8 @@
       "dom",                               // Need to be explicitly mentioned now since we're overriding default included libs.
       "es2018",                            // Need this to use Object.values.
     ],
-    "baseUrl": ".",
     "paths": {
       "*" : ["*", "./node_modules/@tsundoku/micromodal_types/*"]
     },
-    "target": "es6",
-    "module": "commonjs",
-    "moduleResolution": "node",
-    // Lints and checks.
-    "allowJs": true,
-    "declaration": false,                  // Generates corresponding '.d.ts' file.
-    "sourceMap": true,                     // Generates corresponding '.map' file.
-    "outDir": "./dist",                    // 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'.
-    "strict": true,                        // Enable all strict type-checking options.
-    "noImplicitAny": true,                 // Raise error on expressions and declarations with an implied 'any' type.
-    "strictNullChecks": true,              // Enable strict null checks.
-    "strictFunctionTypes": true,           // Enable strict checking of function types.
-    "strictPropertyInitialization": true,  // Enable strict checking of property initialization in classes.
-    "noImplicitThis": true,                // Raise error on 'this' expressions with an implied 'any' type.
-    "alwaysStrict": true,                  // Parse in strict mode and emit "use strict" for each source file.
-    "noUnusedLocals": true,                // Report errors on unused locals.
-    "noUnusedParameters": true,            // Report errors on unused parameters.
-    "noImplicitReturns": true,             // Report error when not all code paths in function return a value.
-    "noFallthroughCasesInSwitch": true,    // Report errors for fallthrough cases in switch statement.
   }
 }