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