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