• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 {reportError} from '../base/logging';
22import {ignoreCacheUnactionableErrors} from '../common/errors';
23
24import {globals} from './globals';
25
26// We use a dedicated |caches| object to share a global boolean beween the main
27// thread and the SW. SW cannot use local-storage or anything else other than
28// IndexedDB (which would be overkill).
29const BYPASS_ID = 'BYPASS_SERVICE_WORKER';
30
31class BypassCache {
32  static async isBypassed(): Promise<boolean> {
33    try {
34      return await caches.has(BYPASS_ID);
35    } catch (e) {
36      return ignoreCacheUnactionableErrors(e, false);
37    }
38  }
39
40  static async setBypass(bypass: boolean): Promise<void> {
41    try {
42      if (bypass) {
43        await caches.open(BYPASS_ID);
44      } else {
45        await caches.delete(BYPASS_ID);
46      }
47    } catch (e) {
48      ignoreCacheUnactionableErrors(e, undefined);
49    }
50  }
51}
52
53export class ServiceWorkerController {
54  private _initialWorker: ServiceWorker|null = null;
55  private _bypassed = false;
56  private _installing = false;
57
58  // Caller should reload().
59  async setBypass(bypass: boolean) {
60    if (!('serviceWorker' in navigator)) return;  // Not supported.
61    this._bypassed = bypass;
62    if (bypass) {
63      await BypassCache.setBypass(true);  // Create the entry.
64      for (const reg of await navigator.serviceWorker.getRegistrations()) {
65        await reg.unregister();
66      }
67    } else {
68      await BypassCache.setBypass(false);
69      if (window.localStorage) {
70        window.localStorage.setItem('bypassDisabled', '1');
71      }
72      this.install();
73    }
74    globals.rafScheduler.scheduleFullRedraw();
75  }
76
77  onStateChange(sw: ServiceWorker) {
78    globals.rafScheduler.scheduleFullRedraw();
79    if (sw.state === 'installing') {
80      this._installing = true;
81    } else if (sw.state === 'activated') {
82      this._installing = false;
83      // Don't show the notification if the site was served straight
84      // from the network (e.g., on the very first visit or after
85      // Ctrl+Shift+R). In these cases, we are already at the last
86      // version.
87      if (sw !== this._initialWorker && this._initialWorker) {
88        globals.frontendLocalState.newVersionAvailable = true;
89      }
90    }
91  }
92
93  monitorWorker(sw: ServiceWorker|null) {
94    if (!sw) return;
95    sw.addEventListener('error', (e) => reportError(e));
96    sw.addEventListener('statechange', () => this.onStateChange(sw));
97    this.onStateChange(sw);  // Trigger updates for the current state.
98  }
99
100  async install() {
101    if (!('serviceWorker' in navigator)) return;  // Not supported.
102
103    if (location.pathname !== '/') {
104      // Disable the service worker when the UI is loaded from a non-root URL
105      // (e.g. from the CI artifacts GCS bucket). Supporting the case of a
106      // nested index.html is too cumbersome and has no benefits.
107      return;
108    }
109
110    // If this is localhost disable the service worker by default, unless the
111    // user manually re-enabled it (in which case bypassDisabled = '1').
112    const hostname = location.hostname;
113    const isLocalhost = ['127.0.0.1', '::1', 'localhost'].includes(hostname);
114    const bypassDisabled = 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 versionDir = globals.root.split('/').slice(-2)[0];
129    const swUri = `/service_worker.js?v=${versionDir}`;
130    navigator.serviceWorker.register(swUri).then((registration) => {
131      this._initialWorker = registration.active;
132
133      // At this point there are two options:
134      // 1. This is the first time we visit the site (or cache was cleared) and
135      //    no SW is installed yet. In this case |installing| will be set.
136      // 2. A SW is already installed (though it might be obsolete). In this
137      //    case |active| will be set.
138      this.monitorWorker(registration.installing);
139      this.monitorWorker(registration.active);
140
141      // Setup the event that shows the "Updated to v1.2.3" notification.
142      registration.addEventListener('updatefound', () => {
143        this.monitorWorker(registration.installing);
144      });
145    });
146  }
147
148  get bypassed() {
149    return this._bypassed;
150  }
151  get installing() {
152    return this._installing;
153  }
154}
155