xref: /aosp_15_r20/external/perfetto/ui/src/frontend/service_worker_controller.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
1// Copyright (C) 2020 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15// Handles registration, unregistration and lifecycle of the service worker.
16// This class contains only the controlling logic, all the code in here runs in
17// the main thread, not in the service worker thread.
18// The actual service worker code is in src/service_worker.
19// Design doc: http://go/perfetto-offline.
20
21import {getServingRoot} from '../base/http_utils';
22import {reportError} from '../base/logging';
23import {raf} from '../core/raf_scheduler';
24
25// We use a dedicated |caches| object to share a global boolean beween the main
26// thread and the SW. SW cannot use local-storage or anything else other than
27// IndexedDB (which would be overkill).
28const BYPASS_ID = 'BYPASS_SERVICE_WORKER';
29
30class BypassCache {
31  static async isBypassed(): Promise<boolean> {
32    try {
33      return await caches.has(BYPASS_ID);
34    } catch (_) {
35      // TODO(288483453): Reinstate:
36      // return ignoreCacheUnactionableErrors(e, false);
37      return false;
38    }
39  }
40
41  static async setBypass(bypass: boolean): Promise<void> {
42    try {
43      if (bypass) {
44        await caches.open(BYPASS_ID);
45      } else {
46        await caches.delete(BYPASS_ID);
47      }
48    } catch (_) {
49      // TODO(288483453): Reinstate:
50      // ignoreCacheUnactionableErrors(e, undefined);
51    }
52  }
53}
54
55export class ServiceWorkerController {
56  private readonly servingRoot = getServingRoot();
57  private _bypassed = false;
58  private _installing = false;
59
60  // Caller should reload().
61  async setBypass(bypass: boolean) {
62    if (!('serviceWorker' in navigator)) return; // Not supported.
63    this._bypassed = bypass;
64    if (bypass) {
65      await BypassCache.setBypass(true); // Create the entry.
66      for (const reg of await navigator.serviceWorker.getRegistrations()) {
67        await reg.unregister();
68      }
69    } else {
70      await BypassCache.setBypass(false);
71      // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
72      if (window.localStorage) {
73        window.localStorage.setItem('bypassDisabled', '1');
74      }
75      this.install();
76    }
77    raf.scheduleFullRedraw();
78  }
79
80  onStateChange(sw: ServiceWorker) {
81    raf.scheduleFullRedraw();
82    if (sw.state === 'installing') {
83      this._installing = true;
84    } else if (sw.state === 'activated') {
85      this._installing = false;
86    }
87  }
88
89  monitorWorker(sw: ServiceWorker | null) {
90    if (!sw) return;
91    sw.addEventListener('error', (e) => reportError(e));
92    sw.addEventListener('statechange', () => this.onStateChange(sw));
93    this.onStateChange(sw); // Trigger updates for the current state.
94  }
95
96  async install() {
97    const versionDir = this.servingRoot.split('/').slice(-2)[0];
98
99    if (!('serviceWorker' in navigator)) return; // Not supported.
100
101    if (location.pathname !== '/') {
102      // Disable the service worker when the UI is loaded from a non-root URL
103      // (e.g. from the CI artifacts GCS bucket). Supporting the case of a
104      // nested index.html is too cumbersome and has no benefits.
105      return;
106    }
107
108    // If this is localhost disable the service worker by default, unless the
109    // user manually re-enabled it (in which case bypassDisabled = '1').
110    const hostname = location.hostname;
111    const isLocalhost = ['127.0.0.1', '::1', 'localhost'].includes(hostname);
112    const bypassDisabled =
113      // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
114      window.localStorage &&
115      window.localStorage.getItem('bypassDisabled') === '1';
116    if (isLocalhost && !bypassDisabled) {
117      await this.setBypass(true); // Will cause the check below to bail out.
118    }
119
120    if (await BypassCache.isBypassed()) {
121      this._bypassed = true;
122      console.log('Skipping service worker registration, disabled by the user');
123      return;
124    }
125    // In production cases versionDir == VERSION. We use this here for ease of
126    // testing (so we can have /v1.0.0a/ /v1.0.0b/ even if they have the same
127    // version code).
128    const swUri = `/service_worker.js?v=${versionDir}`;
129    navigator.serviceWorker.register(swUri).then((registration) => {
130      // At this point there are two options:
131      // 1. This is the first time we visit the site (or cache was cleared) and
132      //    no SW is installed yet. In this case |installing| will be set.
133      // 2. A SW is already installed (though it might be obsolete). In this
134      //    case |active| will be set.
135      this.monitorWorker(registration.installing);
136      this.monitorWorker(registration.active);
137
138      // Setup the event that shows the "Updated to v1.2.3" notification.
139      registration.addEventListener('updatefound', () => {
140        this.monitorWorker(registration.installing);
141      });
142    });
143  }
144
145  get bypassed() {
146    return this._bypassed;
147  }
148  get installing() {
149    return this._installing;
150  }
151}
152