diff --git a/index.html b/index.html index 2e87892f..f2d7a261 100644 --- a/index.html +++ b/index.html @@ -29,11 +29,5 @@ } }); - diff --git a/scripts/build.mjs b/scripts/build.mjs index 27e8eafb..1534fc7a 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -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)); diff --git a/src/domain/LoginViewModel.js b/src/domain/LoginViewModel.js index 11ba3d73..a23ccb6f 100644 --- a/src/domain/LoginViewModel.js +++ b/src/domain/LoginViewModel.js @@ -70,7 +70,7 @@ export class LoginViewModel extends ViewModel { } get cancelUrl() { - return this.urlRouter.urlForSegment("session"); + return this.urlCreator.urlForSegment("session"); } dispose() { diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index 24c810c7..3316bacc 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -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); }, })); }); diff --git a/src/domain/SessionPickerViewModel.js b/src/domain/SessionPickerViewModel.js index ca9430b3..76be11dd 100644 --- a/src/domain/SessionPickerViewModel.js +++ b/src/domain/SessionPickerViewModel.js @@ -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"); } } diff --git a/src/domain/ViewModel.js b/src/domain/ViewModel.js index cccdb847..8c847247 100644 --- a/src/domain/ViewModel.js +++ b/src/domain/ViewModel.js @@ -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() { diff --git a/src/domain/navigation/Navigation.js b/src/domain/navigation/Navigation.js index f7222ec2..fa1c7142 100644 --- a/src/domain/navigation/Navigation.js +++ b/src/domain/navigation/Navigation.js @@ -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) { diff --git a/src/domain/navigation/URLRouter.js b/src/domain/navigation/URLRouter.js index ac3f968c..082be2b9 100644 --- a/src/domain/navigation/URLRouter.js +++ b/src/domain/navigation/URLRouter.js @@ -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); - } } diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index 04e810fb..b9b62153 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -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 */ diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index b5b652ab..9d57d6bd 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -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() { diff --git a/src/domain/session/leftpanel/RoomTileViewModel.js b/src/domain/session/leftpanel/RoomTileViewModel.js index 09cb6372..6cfea617 100644 --- a/src/domain/session/leftpanel/RoomTileViewModel.js +++ b/src/domain/session/leftpanel/RoomTileViewModel.js @@ -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(); } diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 8c470370..d401c843 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -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() { diff --git a/src/main.js b/src/main.js index d482c078..7bf7d5af 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,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()); diff --git a/src/matrix/storage/idb/StorageFactory.js b/src/matrix/storage/idb/StorageFactory.js index 0226f395..9234ba7a 100644 --- a/src/matrix/storage/idb/StorageFactory.js +++ b/src/matrix/storage/idb/StorageFactory.js @@ -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); } diff --git a/src/service-worker.template.js b/src/service-worker.template.js index bbb8c892..bd6cfc17 100644 --- a/src/service-worker.template.js +++ b/src/service-worker.template.js @@ -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; +} diff --git a/src/ui/web/dom/History.js b/src/ui/web/dom/History.js index 8bc433cd..61f04444 100644 --- a/src/ui/web/dom/History.js +++ b/src/ui/web/dom/History.js @@ -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) { diff --git a/src/ui/web/dom/ServiceWorkerHandler.js b/src/ui/web/dom/ServiceWorkerHandler.js new file mode 100644 index 00000000..432a6ce8 --- /dev/null +++ b/src/ui/web/dom/ServiceWorkerHandler.js @@ -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}); + } +}