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 {raf} from '../core/raf_scheduler'; 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 (_) { 36 // TODO(288483453): Reinstate: 37 // return ignoreCacheUnactionableErrors(e, false); 38 return false; 39 } 40 } 41 42 static async setBypass(bypass: boolean): Promise<void> { 43 try { 44 if (bypass) { 45 await caches.open(BYPASS_ID); 46 } else { 47 await caches.delete(BYPASS_ID); 48 } 49 } catch (_) { 50 // TODO(288483453): Reinstate: 51 // ignoreCacheUnactionableErrors(e, undefined); 52 } 53 } 54} 55 56export class ServiceWorkerController { 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 if (!('serviceWorker' in navigator)) return; // Not supported. 98 99 if (location.pathname !== '/') { 100 // Disable the service worker when the UI is loaded from a non-root URL 101 // (e.g. from the CI artifacts GCS bucket). Supporting the case of a 102 // nested index.html is too cumbersome and has no benefits. 103 return; 104 } 105 106 // If this is localhost disable the service worker by default, unless the 107 // user manually re-enabled it (in which case bypassDisabled = '1'). 108 const hostname = location.hostname; 109 const isLocalhost = ['127.0.0.1', '::1', 'localhost'].includes(hostname); 110 const bypassDisabled = 111 // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions 112 window.localStorage && 113 window.localStorage.getItem('bypassDisabled') === '1'; 114 if (isLocalhost && !bypassDisabled) { 115 await this.setBypass(true); // Will cause the check below to bail out. 116 } 117 118 if (await BypassCache.isBypassed()) { 119 this._bypassed = true; 120 console.log('Skipping service worker registration, disabled by the user'); 121 return; 122 } 123 // In production cases versionDir == VERSION. We use this here for ease of 124 // testing (so we can have /v1.0.0a/ /v1.0.0b/ even if they have the same 125 // version code). 126 const versionDir = globals.root.split('/').slice(-2)[0]; 127 const swUri = `/service_worker.js?v=${versionDir}`; 128 navigator.serviceWorker.register(swUri).then((registration) => { 129 // At this point there are two options: 130 // 1. This is the first time we visit the site (or cache was cleared) and 131 // no SW is installed yet. In this case |installing| will be set. 132 // 2. A SW is already installed (though it might be obsolete). In this 133 // case |active| will be set. 134 this.monitorWorker(registration.installing); 135 this.monitorWorker(registration.active); 136 137 // Setup the event that shows the "Updated to v1.2.3" notification. 138 registration.addEventListener('updatefound', () => { 139 this.monitorWorker(registration.installing); 140 }); 141 }); 142 } 143 144 get bypassed() { 145 return this._bypassed; 146 } 147 get installing() { 148 return this._installing; 149 } 150} 151