Merge pull request #161 from vector-im/bwindels/sw-updates

Prompt to install update and prevent concurrent session access through service worker
This commit is contained in:
Bruno Windels 2020-10-16 13:49:42 +00:00 committed by GitHub
commit 753bee2509
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 370 additions and 128 deletions

View File

@ -29,11 +29,5 @@
}
});
</script>
<script id="service-worker" type="disabled">
if('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js')
.then(function() { console.log("Service Worker registered"); });
}
</script>
</body>
</html>

View File

@ -80,11 +80,14 @@ async function build({modernOnly}) {
await copyThemeAssets(themes, assets);
await buildCssBundles(buildCssLegacy, themes, assets);
await buildManifest(assets);
// all assets have been added, create a hash from all assets name to cache unhashed files like index.html by
const globalHashAssets = Array.from(assets).map(([, resolved]) => resolved);
globalHashAssets.sort();
const globalHash = contentHash(globalHashAssets.join(","));
await buildServiceWorker(globalHash, assets);
// all assets have been added, create a hash from all assets name to cache unhashed files like index.html
assets.addToHashForAll("index.html", devHtml);
let swSource = await fs.readFile(path.join(projectDir, "src/service-worker.template.js"), "utf8");
assets.addToHashForAll("sw.js", swSource);
const globalHash = assets.hashForAll();
await buildServiceWorker(swSource, version, globalHash, assets);
await buildHtml(doc, version, globalHash, modernOnly, assets);
console.log(`built hydrogen ${version} (${globalHash}) successfully with ${assets.size} files`);
}
@ -137,6 +140,7 @@ async function buildHtml(doc, version, globalHash, modernOnly, assets) {
});
const pathsJSON = JSON.stringify({
worker: assets.has("worker.js") ? assets.resolve(`worker.js`) : null,
serviceWorker: "sw.js",
olm: {
wasm: assets.resolve("olm.wasm"),
legacyBundle: assets.resolve("olm_legacy.js"),
@ -153,7 +157,6 @@ async function buildHtml(doc, version, globalHash, modernOnly, assets) {
);
}
doc("script#main").replaceWith(mainScripts.join(""));
doc("script#service-worker").attr("type", "text/javascript");
const versionScript = doc("script#version");
versionScript.attr("type", "text/javascript");
@ -217,10 +220,10 @@ async function buildJsLegacy(mainFile, extraFiles = []) {
return code;
}
const SERVICEWORKER_NONCACHED_ASSETS = [
const NON_PRECACHED_JS = [
"hydrogen-legacy.js",
"olm_legacy.js",
"sw.js",
"worker.js"
];
function isPreCached(asset) {
@ -229,7 +232,7 @@ function isPreCached(asset) {
asset.endsWith(".css") ||
asset.endsWith(".wasm") ||
// most environments don't need the worker
asset.endsWith(".js") && asset !== "worker.js";
asset.endsWith(".js") && !NON_PRECACHED_JS.includes(asset);
}
async function buildManifest(assets) {
@ -243,15 +246,13 @@ async function buildManifest(assets) {
await assets.write("manifest.json", JSON.stringify(webManifest));
}
async function buildServiceWorker(globalHash, assets) {
async function buildServiceWorker(swSource, version, globalHash, assets) {
const unhashedPreCachedAssets = ["index.html"];
const hashedPreCachedAssets = [];
const hashedCachedOnRequestAssets = [];
for (const [unresolved, resolved] of assets) {
if (SERVICEWORKER_NONCACHED_ASSETS.includes(unresolved)) {
continue;
} else if (unresolved === resolved) {
if (unresolved === resolved) {
unhashedPreCachedAssets.push(resolved);
} else if (isPreCached(unresolved)) {
hashedPreCachedAssets.push(resolved);
@ -260,7 +261,7 @@ async function buildServiceWorker(globalHash, assets) {
}
}
// write service worker
let swSource = await fs.readFile(path.join(projectDir, "src/service-worker.template.js"), "utf8");
swSource = swSource.replace(`"%%VERSION%%"`, `"${version}"`);
swSource = swSource.replace(`"%%GLOBAL_HASH%%"`, `"${globalHash}"`);
swSource = swSource.replace(`"%%UNHASHED_PRECACHED_ASSETS%%"`, JSON.stringify(unhashedPreCachedAssets));
swSource = swSource.replace(`"%%HASHED_PRECACHED_ASSETS%%"`, JSON.stringify(hashedPreCachedAssets));
@ -355,6 +356,8 @@ class AssetMap {
// remove last / if any, so substr in create works well
this._targetDir = path.resolve(targetDir);
this._assets = new Map();
// hashes for unhashed resources so changes in these resources also contribute to the hashForAll
this._unhashedHashes = [];
}
_toRelPath(resourcePath) {
@ -444,6 +447,17 @@ class AssetMap {
has(relPath) {
return this._assets.has(relPath);
}
hashForAll() {
const globalHashAssets = Array.from(this).map(([, resolved]) => resolved);
globalHashAssets.push(...this._unhashedHashes);
globalHashAssets.sort();
return contentHash(globalHashAssets.join(","));
}
addToHashForAll(resourcePath, content) {
this._unhashedHashes.push(`${resourcePath}-${contentHash(Buffer.from(content))}`);
}
}
build(program).catch(err => console.error(err));

View File

@ -70,7 +70,7 @@ export class LoginViewModel extends ViewModel {
}
get cancelUrl() {
return this.urlRouter.urlForSegment("session");
return this.urlCreator.urlForSegment("session");
}
dispose() {

View File

@ -27,21 +27,21 @@ export class RootViewModel extends ViewModel {
this._createSessionContainer = createSessionContainer;
this._sessionInfoStorage = sessionInfoStorage;
this._storageFactory = storageFactory;
this._error = null;
this._sessionPickerViewModel = null;
this._sessionLoadViewModel = null;
this._loginViewModel = null;
this._sessionViewModel = null;
this._pendingSessionContainer = null;
}
async load(lastUrlHash) {
async load() {
this.track(this.navigation.observe("login").subscribe(() => this._applyNavigation()));
this.track(this.navigation.observe("session").subscribe(() => this._applyNavigation()));
this._applyNavigation(lastUrlHash);
this._applyNavigation(this.urlCreator.getLastUrl());
}
async _applyNavigation(restoreHashIfAtDefault) {
async _applyNavigation(restoreUrlIfAtDefault) {
const isLogin = this.navigation.observe("login").get();
const sessionId = this.navigation.observe("session").get();
if (isLogin) {
@ -54,34 +54,40 @@ export class RootViewModel extends ViewModel {
}
} else if (sessionId) {
if (!this._sessionViewModel || this._sessionViewModel.id !== sessionId) {
this._showSessionLoader(sessionId);
// see _showLogin for where _pendingSessionContainer comes from
if (this._pendingSessionContainer && this._pendingSessionContainer.sessionId === sessionId) {
const sessionContainer = this._pendingSessionContainer;
this._pendingSessionContainer = null;
this._showSession(sessionContainer);
} else {
// this should never happen, but we want to be sure not to leak it
if (this._pendingSessionContainer) {
this._pendingSessionContainer.dispose();
this._pendingSessionContainer = null;
}
this._showSessionLoader(sessionId);
}
}
} else {
try {
let url = restoreHashIfAtDefault;
if (!url) {
// redirect depending on what sessions are already present
if (restoreUrlIfAtDefault) {
this.urlCreator.pushUrl(restoreUrlIfAtDefault);
} else {
const sessionInfos = await this._sessionInfoStorage.getAll();
url = this._urlForSessionInfos(sessionInfos);
if (sessionInfos.length === 0) {
this.navigation.push("login");
} else if (sessionInfos.length === 1) {
this.navigation.push("session", sessionInfos[0].id);
} else {
this.navigation.push("session");
}
}
this.urlRouter.history.replaceUrl(url);
this.urlRouter.applyUrl(url);
} catch (err) {
this._setSection(() => this._error = err);
}
}
}
_urlForSessionInfos(sessionInfos) {
if (sessionInfos.length === 0) {
return this.urlRouter.urlForSegment("login");
} else if (sessionInfos.length === 1) {
return this.urlRouter.urlForSegment("session", sessionInfos[0].id);
} else {
return this.urlRouter.urlForSegment("session");
}
}
async _showPicker() {
this._setSection(() => {
this._sessionPickerViewModel = new SessionPickerViewModel(this.childOptions({
@ -102,10 +108,16 @@ export class RootViewModel extends ViewModel {
defaultHomeServer: "https://matrix.org",
createSessionContainer: this._createSessionContainer,
ready: sessionContainer => {
const url = this.urlRouter.urlForSegment("session", sessionContainer.sessionId);
this.urlRouter.applyUrl(url);
this.urlRouter.history.replaceUrl(url);
this._showSession(sessionContainer);
// we don't want to load the session container again,
// but we also want the change of screen to go through the navigation
// so we store the session container in a temporary variable that will be
// consumed by _applyNavigation, triggered by the navigation change
//
// Also, we should not call _setSection before the navigation is in the correct state,
// as url creation (e.g. in RoomTileViewModel)
// won't be using the correct navigation base path.
this._pendingSessionContainer = sessionContainer;
this.navigation.push("session", sessionContainer.sessionId);
},
}));
});

View File

@ -76,7 +76,7 @@ class SessionItemViewModel extends ViewModel {
}
get openUrl() {
return this.urlRouter.urlForSegment("session", this.id);
return this.urlCreator.urlForSegment("session", this.id);
}
get label() {
@ -189,6 +189,6 @@ export class SessionPickerViewModel extends ViewModel {
}
get cancelUrl() {
return this.urlRouter.urlForSegment("login");
return this.urlCreator.urlForSegment("login");
}
}

View File

@ -30,8 +30,8 @@ export class ViewModel extends EventEmitter {
}
childOptions(explicitOptions) {
const {navigation, urlRouter, clock} = this._options;
return Object.assign({navigation, urlRouter, clock}, explicitOptions);
const {navigation, urlCreator, clock} = this._options;
return Object.assign({navigation, urlCreator, clock}, explicitOptions);
}
track(disposable) {
@ -99,8 +99,12 @@ export class ViewModel extends EventEmitter {
return this._options.clock;
}
get urlRouter() {
return this._options.urlRouter;
/**
* The url router, only meant to be used to create urls with from view models.
* @return {URLRouter}
*/
get urlCreator() {
return this._options.urlCreator;
}
get navigation() {

View File

@ -14,19 +14,28 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {BaseObservableValue} from "../../observable/ObservableValue.js";
import {BaseObservableValue, ObservableValue} from "../../observable/ObservableValue.js";
export class Navigation {
constructor(allowsChild) {
this._allowsChild = allowsChild;
this._path = new Path([], allowsChild);
this._observables = new Map();
this._pathObservable = new ObservableValue(this._path);
}
get pathObservable() {
return this._pathObservable;
}
get path() {
return this._path;
}
push(type, value = undefined) {
return this.applyPath(this.path.with(new Segment(type, value)));
}
applyPath(path) {
// Path is not exported, so you can only create a Path through Navigation,
// so we assume it respects the allowsChild rules
@ -45,6 +54,10 @@ export class Navigation {
const observable = this._observables.get(segment.type);
observable?.emitIfChanged();
}
// to observe the whole path having changed
// Since paths are immutable,
// we can just use set here which will compare the references
this._pathObservable.set(this._path);
}
observe(type) {

View File

@ -14,40 +14,50 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {Segment} from "./Navigation.js";
export class URLRouter {
constructor({history, navigation, parseUrlPath, stringifyPath}) {
this._subscription = null;
this._history = history;
this._navigation = navigation;
this._parseUrlPath = parseUrlPath;
this._stringifyPath = stringifyPath;
this._subscription = null;
this._pathSubscription = null;
}
attach() {
this._subscription = this._history.subscribe(url => {
const redirectedUrl = this.applyUrl(url);
const redirectedUrl = this._applyUrl(url);
if (redirectedUrl !== url) {
this._history.replaceUrl(redirectedUrl);
this._history.replaceUrlSilently(redirectedUrl);
}
});
this._applyUrl(this._history.get());
this._pathSubscription = this._navigation.pathObservable.subscribe(path => {
const url = this.urlForPath(path);
if (url !== this._history.get()) {
this._history.pushUrlSilently(url);
}
});
this.applyUrl(this._history.get());
}
dispose() {
this._subscription = this._subscription();
this._pathSubscription = this._pathSubscription();
}
applyUrl(url) {
_applyUrl(url) {
const urlPath = this._history.urlAsPath(url)
const navPath = this._navigation.pathFrom(this._parseUrlPath(urlPath, this._navigation.path));
this._navigation.applyPath(navPath);
return this._history.pathAsUrl(this._stringifyPath(navPath));
}
get history() {
return this._history;
pushUrl(url) {
this._history.pushUrl(url);
}
getLastUrl() {
return this._history.getLastUrl();
}
urlForSegments(segments) {
@ -70,7 +80,7 @@ export class URLRouter {
}
urlForPath(path) {
return this.history.pathAsUrl(this._stringifyPath(path));
return this._history.pathAsUrl(this._stringifyPath(path));
}
openRoomActionUrl(roomId) {
@ -78,26 +88,4 @@ export class URLRouter {
const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${roomId}`;
return this._history.pathAsUrl(urlPath);
}
disableGridUrl() {
let path = this._navigation.path.until("session");
const room = this._navigation.path.get("room");
if (room) {
path = path.with(room);
}
return this.urlForPath(path);
}
enableGridUrl() {
let path = this._navigation.path.until("session");
const room = this._navigation.path.get("room");
if (room) {
path = path.with(this._navigation.segment("rooms", [room.value]));
path = path.with(room);
} else {
path = path.with(this._navigation.segment("rooms", []));
path = path.with(this._navigation.segment("empty-grid-tile", 0));
}
return this.urlForPath(path);
}
}

View File

@ -83,16 +83,12 @@ export class RoomGridViewModel extends ViewModel {
if (index === this._selectedIndex) {
return;
}
let path = this.navigation.path;
const vm = this._viewModels[index];
if (vm) {
path = path.with(this.navigation.segment("room", vm.id));
this.navigation.push("room", vm.id);
} else {
path = path.with(this.navigation.segment("empty-grid-tile", index));
this.navigation.push("empty-grid-tile", index);
}
let url = this.urlRouter.urlForPath(path);
url = this.urlRouter.applyUrl(url);
this.urlRouter.history.pushUrl(url);
}
/** called from SessionViewModel */

View File

@ -44,7 +44,7 @@ export class LeftPanelViewModel extends ViewModel {
this._roomList = this._roomListFilterMap.sortValues((a, b) => a.compare(b));
this._currentTileVM = null;
this._setupNavigation();
this._closeUrl = this.urlRouter.urlForSegment("session");
this._closeUrl = this.urlCreator.urlForSegment("session");
}
get closeUrl() {
@ -76,14 +76,25 @@ export class LeftPanelViewModel extends ViewModel {
}
toggleGrid() {
let url;
if (this.gridEnabled) {
url = this.urlRouter.disableGridUrl();
let path = this.navigation.path.until("session");
const room = this.navigation.path.get("room");
if (room) {
path = path.with(room);
}
this.navigation.applyPath(path);
} else {
url = this.urlRouter.enableGridUrl();
let path = this.navigation.path.until("session");
const room = this.navigation.path.get("room");
if (room) {
path = path.with(this.navigation.segment("rooms", [room.value]));
path = path.with(room);
} else {
path = path.with(this.navigation.segment("rooms", []));
path = path.with(this.navigation.segment("empty-grid-tile", 0));
}
this.navigation.applyPath(path);
}
url = this.urlRouter.applyUrl(url);
this.urlRouter.history.pushUrl(url);
}
get roomList() {

View File

@ -30,7 +30,7 @@ export class RoomTileViewModel extends ViewModel {
this._isOpen = false;
this._wasUnreadWhenOpening = false;
this._hidden = false;
this._url = this.urlRouter.openRoomActionUrl(this._room.id);
this._url = this.urlCreator.openRoomActionUrl(this._room.id);
if (options.isOpen) {
this.open();
}

View File

@ -32,7 +32,7 @@ export class RoomViewModel extends ViewModel {
this._sendError = null;
this._composerVM = new ComposerViewModel(this);
this._clearUnreadTimout = null;
this._closeUrl = this.urlRouter.urlUntilSegment("session");
this._closeUrl = this.urlCreator.urlUntilSegment("session");
}
get closeUrl() {

View File

@ -25,6 +25,7 @@ import {RootViewModel} from "./domain/RootViewModel.js";
import {createNavigation, createRouter} from "./domain/navigation/index.js";
import {RootView} from "./ui/web/RootView.js";
import {Clock} from "./ui/web/dom/Clock.js";
import {ServiceWorkerHandler} from "./ui/web/dom/ServiceWorkerHandler.js";
import {History} from "./ui/web/dom/History.js";
import {OnlineStatus} from "./ui/web/dom/OnlineStatus.js";
import {CryptoDriver} from "./ui/web/dom/CryptoDriver.js";
@ -106,8 +107,14 @@ export async function main(container, paths, legacyExtras) {
} else {
request = xhrRequest;
}
const navigation = createNavigation();
const sessionInfoStorage = new SessionInfoStorage("hydrogen_sessions_v1");
const storageFactory = new StorageFactory();
let serviceWorkerHandler;
if (paths.serviceWorker && "serviceWorker" in navigator) {
serviceWorkerHandler = new ServiceWorkerHandler({navigation});
serviceWorkerHandler.registerAndStart(paths.serviceWorker);
}
const storageFactory = new StorageFactory(serviceWorkerHandler);
const olmPromise = loadOlm(paths.olm);
// if wasm is not supported, we'll want
@ -116,10 +123,7 @@ export async function main(container, paths, legacyExtras) {
if (!window.WebAssembly) {
workerPromise = loadOlmWorker(paths);
}
const navigation = createNavigation();
const history = new History();
const urlRouter = createRouter({navigation, history});
const urlRouter = createRouter({navigation, history: new History()});
urlRouter.attach();
const vm = new RootViewModel({
@ -139,11 +143,14 @@ export async function main(container, paths, legacyExtras) {
sessionInfoStorage,
storageFactory,
clock,
urlRouter,
navigation
// the only public interface of the router is to create urls,
// so we call it that in the view models
urlCreator: urlRouter,
navigation,
updateService: serviceWorkerHandler
});
window.__brawlViewModel = vm;
await vm.load(history.getLastUrl());
await vm.load();
// TODO: replace with platform.createAndMountRootView(vm, container);
const view = new RootView(vm);
container.appendChild(view.mount());

View File

@ -23,7 +23,12 @@ const sessionName = sessionId => `hydrogen_session_${sessionId}`;
const openDatabaseWithSessionId = sessionId => openDatabase(sessionName(sessionId), createStores, schema.length);
export class StorageFactory {
constructor(serviceWorkerHandler) {
this._serviceWorkerHandler = serviceWorkerHandler;
}
async create(sessionId) {
await this._serviceWorkerHandler?.preventConcurrentSessionAccess(sessionId);
const db = await openDatabaseWithSessionId(sessionId);
return new Storage(db);
}

View File

@ -15,6 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
const VERSION = "%%VERSION%%";
const GLOBAL_HASH = "%%GLOBAL_HASH%%";
const UNHASHED_PRECACHED_ASSETS = "%%UNHASHED_PRECACHED_ASSETS%%";
const HASHED_PRECACHED_ASSETS = "%%HASHED_PRECACHED_ASSETS%%";
@ -60,7 +61,12 @@ async function purgeOldCaches() {
}
self.addEventListener('activate', (event) => {
event.waitUntil(purgeOldCaches());
event.waitUntil(Promise.all([
purgeOldCaches(),
// on a first page load/sw install,
// start using the service worker on all pages straight away
self.clients.claim()
]));
});
self.addEventListener('fetch', (event) => {
@ -119,3 +125,51 @@ async function readCache(request) {
return response;
}
self.addEventListener('message', (event) => {
const reply = payload => event.source.postMessage({replyTo: event.data.id, payload});
const {replyTo} = event.data;
if (replyTo) {
const resolve = pendingReplies.get(replyTo);
if (resolve) {
pendingReplies.delete(replyTo);
resolve(event.data.payload);
}
} else {
switch (event.data?.type) {
case "version":
reply({version: VERSION, buildHash: GLOBAL_HASH});
break;
case "skipWaiting":
self.skipWaiting();
break;
case "closeSession":
event.waitUntil(
closeSession(event.data.payload.sessionId, event.source.id)
.then(() => reply())
);
break;
}
}
});
async function closeSession(sessionId, requestingClientId) {
const clients = await self.clients.matchAll();
await Promise.all(clients.map(async client => {
if (client.id !== requestingClientId) {
await sendAndWaitForReply(client, "closeSession", {sessionId});
}
}));
}
const pendingReplies = new Map();
let messageIdCounter = 0;
function sendAndWaitForReply(client, type, payload) {
messageIdCounter += 1;
const id = messageIdCounter;
const promise = new Promise(resolve => {
pendingReplies.set(id, resolve);
});
client.postMessage({type, id, payload});
return promise;
}

View File

@ -20,14 +20,9 @@ export class History extends BaseObservableValue {
constructor() {
super();
this._boundOnHashChange = null;
this._expectSetEcho = false;
}
_onHashChange() {
if (this._expectSetEcho) {
this._expectSetEcho = false;
return;
}
this.emit(this.get());
this._storeHash(this.get());
}
@ -37,28 +32,19 @@ export class History extends BaseObservableValue {
}
/** does not emit */
replaceUrl(url) {
replaceUrlSilently(url) {
window.history.replaceState(null, null, url);
this._storeHash(url);
}
/** does not emit */
pushUrl(url) {
pushUrlSilently(url) {
window.history.pushState(null, null, url);
this._storeHash(url);
// const hash = this.urlAsPath(url);
// // important to check before we expect an echo
// // as setting the hash to it's current value doesn't
// // trigger onhashchange
// if (hash === document.location.hash) {
// return;
// }
// // this operation is silent,
// // so avoid emitting on echo hashchange event
// if (this._boundOnHashChange) {
// this._expectSetEcho = true;
// }
// document.location.hash = hash;
}
pushUrl(url) {
document.location.hash = url;
}
urlAsPath(url) {

View File

@ -0,0 +1,158 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// 3 (imaginary) interfaces are implemented here:
// - OfflineAvailability (done by registering the sw)
// - UpdateService (see checkForUpdate method, and should also emit events rather than showing confirm dialog here)
// - ConcurrentAccessBlocker (see preventConcurrentSessionAccess method)
export class ServiceWorkerHandler {
constructor({navigation}) {
this._waitingForReply = new Map();
this._messageIdCounter = 0;
this._registration = null;
this._navigation = navigation;
this._registrationPromise = null;
this._currentController = null;
}
registerAndStart(path) {
this._registrationPromise = (async () => {
navigator.serviceWorker.addEventListener("message", this);
navigator.serviceWorker.addEventListener("controllerchange", this);
this._registration = await navigator.serviceWorker.register(path);
await navigator.serviceWorker.ready;
this._currentController = navigator.serviceWorker.controller;
this._registrationPromise = null;
console.log("Service Worker registered");
this._registration.addEventListener("updatefound", this);
this._tryActivateUpdate();
})();
}
_onMessage(event) {
const {data} = event;
const replyTo = data.replyTo;
if (replyTo) {
const resolve = this._waitingForReply.get(replyTo);
if (resolve) {
this._waitingForReply.delete(replyTo);
resolve(data.payload);
}
}
if (data.type === "closeSession") {
const {sessionId} = data.payload;
this._closeSessionIfNeeded(sessionId).finally(() => {
event.source.postMessage({replyTo: data.id});
});
}
}
_closeSessionIfNeeded(sessionId) {
const currentSession = this._navigation.path.get("session");
if (sessionId && currentSession?.value === sessionId) {
return new Promise(resolve => {
const unsubscribe = this._navigation.pathObservable.subscribe(path => {
const session = path.get("session");
if (!session || session.value !== sessionId) {
unsubscribe();
resolve();
}
});
this._navigation.push("session");
});
} else {
return Promise.resolve();
}
}
async _tryActivateUpdate() {
// we don't do confirm when the tab is hidden because it will block the event loop and prevent
// events from the service worker to be processed (like controllerchange when the visible tab applies the update).
if (!document.hidden && this._registration.waiting && this._registration.active) {
this._registration.waiting.removeEventListener("statechange", this);
const version = await this._sendAndWaitForReply("version", null, this._registration.waiting);
if (confirm(`Version ${version.version} (${version.buildHash}) is ready to install. Apply now?`)) {
this._registration.waiting.postMessage({type: "skipWaiting"}); // will trigger controllerchange event
}
}
}
handleEvent(event) {
switch (event.type) {
case "message":
this._onMessage(event);
break;
case "updatefound":
this._registration.installing.addEventListener("statechange", this);
this._tryActivateUpdate();
break;
case "statechange":
this._tryActivateUpdate();
break;
case "controllerchange":
if (!this._currentController) {
// Clients.claim() in the SW can trigger a controllerchange event
// if we had no SW before. This is fine,
// and now our requests will be served from the SW.
this._currentController = navigator.serviceWorker.controller;
} else {
// active service worker changed,
// refresh, so we can get all assets
// (and not some if we would not refresh)
// up to date from it
document.location.reload();
}
break;
}
}
async _send(type, payload, worker = undefined) {
if (this._registrationPromise) {
await this._registrationPromise;
}
if (!worker) {
worker = this._registration.active;
}
worker.postMessage({type, payload});
}
async _sendAndWaitForReply(type, payload, worker = undefined) {
if (this._registrationPromise) {
await this._registrationPromise;
}
if (!worker) {
worker = this._registration.active;
}
this._messageIdCounter += 1;
const id = this._messageIdCounter;
const promise = new Promise(resolve => {
this._waitingForReply.set(id, resolve);
});
worker.postMessage({type, id, payload});
return await promise;
}
async checkForUpdate() {
if (this._registrationPromise) {
await this._registrationPromise;
}
this._registration.update();
}
async preventConcurrentSessionAccess(sessionId) {
return this._sendAndWaitForReply("closeSession", {sessionId});
}
}