diff --git a/index.html b/index.html index d90dec83..f2d7a261 100644 --- a/index.html +++ b/index.html @@ -29,46 +29,5 @@ } }); - diff --git a/scripts/build.mjs b/scripts/build.mjs index 81399039..1534fc7a 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -140,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"), @@ -156,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"); diff --git a/src/main.js b/src/main.js index d98448dd..792c333b 100644 --- a/src/main.js +++ b/src/main.js @@ -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,8 +123,6 @@ export async function main(container, paths, legacyExtras) { if (!window.WebAssembly) { workerPromise = loadOlmWorker(paths); } - - const navigation = createNavigation(); const urlRouter = createRouter({navigation, history: new History()}); urlRouter.attach(); @@ -139,7 +144,8 @@ export async function main(container, paths, legacyExtras) { storageFactory, clock, urlRouter, - navigation + navigation, + updateService: serviceWorkerHandler }); window.__brawlViewModel = vm; await vm.load(); diff --git a/src/ui/web/dom/ServiceWorkerHandler.js b/src/ui/web/dom/ServiceWorkerHandler.js new file mode 100644 index 00000000..e76f202c --- /dev/null +++ b/src/ui/web/dom/ServiceWorkerHandler.js @@ -0,0 +1,150 @@ +/* +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; + } + + registerAndStart(path) { + this._registrationPromise = (async () => { + navigator.serviceWorker.addEventListener("message", this); + navigator.serviceWorker.addEventListener("controllerchange", this); + this._registration = await navigator.serviceWorker.register(path); + 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() { + if (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": + // 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) { + // don't block if we didn't manage to install service worker + if (!this._registration) { + return Promise.resolve(); + } + return this._sendAndWaitForReply("closeSession", {sessionId}); + } +}