From e290822a32984fc5f4bf704e365705161e06b1b9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 15 Oct 2020 11:49:09 +0200 Subject: [PATCH 01/19] test code for sw state changes --- index.html | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index 2e87892f..ede773c2 100644 --- a/index.html +++ b/index.html @@ -29,10 +29,24 @@ } }); - From 0f71564d658a19288b2a7793cacb8427a091e4bb Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 15 Oct 2020 15:58:29 +0200 Subject: [PATCH 02/19] also make changes in index.html and sw.js trigger an update --- scripts/build.mjs | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/scripts/build.mjs b/scripts/build.mjs index 27e8eafb..b302fe32 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, globalHash, assets); await buildHtml(doc, version, globalHash, modernOnly, assets); console.log(`built hydrogen ${version} (${globalHash}) successfully with ${assets.size} files`); } @@ -243,7 +246,7 @@ async function buildManifest(assets) { await assets.write("manifest.json", JSON.stringify(webManifest)); } -async function buildServiceWorker(globalHash, assets) { +async function buildServiceWorker(swSource, globalHash, assets) { const unhashedPreCachedAssets = ["index.html"]; const hashedPreCachedAssets = []; const hashedCachedOnRequestAssets = []; @@ -260,7 +263,6 @@ 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(`"%%GLOBAL_HASH%%"`, `"${globalHash}"`); swSource = swSource.replace(`"%%UNHASHED_PRECACHED_ASSETS%%"`, JSON.stringify(unhashedPreCachedAssets)); swSource = swSource.replace(`"%%HASHED_PRECACHED_ASSETS%%"`, JSON.stringify(hashedPreCachedAssets)); @@ -355,6 +357,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 +448,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)); From 6b15e7e3ba01a308ea34c3bd2fb9e5eb3c552283 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 15 Oct 2020 15:58:59 +0200 Subject: [PATCH 03/19] remove extra newline --- src/service-worker.template.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/service-worker.template.js b/src/service-worker.template.js index bbb8c892..6ae3988f 100644 --- a/src/service-worker.template.js +++ b/src/service-worker.template.js @@ -118,4 +118,3 @@ async function readCache(request) { } return response; } - From 3354d601d171f15073226d223f4dae26b7cf0c5b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 15 Oct 2020 15:59:15 +0200 Subject: [PATCH 04/19] deregister sw when update is ready to go to force activating on iOS --- index.html | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/index.html b/index.html index ede773c2..3173a8d6 100644 --- a/index.html +++ b/index.html @@ -31,19 +31,19 @@ - - diff --git a/scripts/build.mjs b/scripts/build.mjs index f7bb7857..81399039 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -87,7 +87,7 @@ async function build({modernOnly}) { const globalHash = assets.hashForAll(); - await buildServiceWorker(swSource, globalHash, assets); + await buildServiceWorker(swSource, version, globalHash, assets); await buildHtml(doc, version, globalHash, modernOnly, assets); console.log(`built hydrogen ${version} (${globalHash}) successfully with ${assets.size} files`); } @@ -246,7 +246,7 @@ async function buildManifest(assets) { await assets.write("manifest.json", JSON.stringify(webManifest)); } -async function buildServiceWorker(swSource, globalHash, assets) { +async function buildServiceWorker(swSource, version, globalHash, assets) { const unhashedPreCachedAssets = ["index.html"]; const hashedPreCachedAssets = []; const hashedCachedOnRequestAssets = []; @@ -261,6 +261,7 @@ async function buildServiceWorker(swSource, globalHash, assets) { } } // write service worker + 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)); diff --git a/src/main.js b/src/main.js index d482c078..91c31321 100644 --- a/src/main.js +++ b/src/main.js @@ -148,6 +148,6 @@ export async function main(container, paths, legacyExtras) { const view = new RootView(vm); container.appendChild(view.mount()); } catch(err) { - console.error(`${err.message}:\n${err.stack}`); + console.error(`${err.message}:\n${err.stack}sdfdf`); } } diff --git a/src/service-worker.template.js b/src/service-worker.template.js index 6ae3988f..af8eae88 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%%"; @@ -67,6 +68,18 @@ self.addEventListener('fetch', (event) => { event.respondWith(handleRequest(event.request)); }); +self.addEventListener('message', (event) => { + const reply = content => event.source.postMessage({replyTo: event.data?.id, content}); + switch (event.data?.type) { + case "version": + reply({version: VERSION, buildHash: GLOBAL_HASH}); + break; + case "skipWaiting": + self.skipWaiting(); + break; + } +}); + async function handleRequest(request) { const baseURL = self.registration.scope; if (request.url === baseURL) { From ddf7d0176002b28c145b55d197fe5d10b5636cf6 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 15 Oct 2020 21:35:10 +0200 Subject: [PATCH 08/19] WIP working --- index.html | 4 ++-- src/main.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/index.html b/index.html index 513a7b03..d90dec83 100644 --- a/index.html +++ b/index.html @@ -37,11 +37,11 @@ const promise = new Promise(r => resolve = r); const onMessage = function(event) { if (event.data.replyTo === body.id) { - worker.removeEventListener("message", onMessage); + navigator.serviceWorker.removeEventListener("message", onMessage); resolve(event.data.content); } } - worker.addEventListener("message", onMessage); + navigator.serviceWorker.addEventListener("message", onMessage); worker.postMessage(body); return promise; } diff --git a/src/main.js b/src/main.js index 91c31321..6e9571f4 100644 --- a/src/main.js +++ b/src/main.js @@ -148,6 +148,6 @@ export async function main(container, paths, legacyExtras) { const view = new RootView(vm); container.appendChild(view.mount()); } catch(err) { - console.error(`${err.message}:\n${err.stack}sdfdf`); + console.error(`${err.message}:\n${err.stack}dfdfdfdf`); } } From 788bce7904bfc54f42fbf9b16e5373e9549c8d1b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 16 Oct 2020 12:46:14 +0200 Subject: [PATCH 09/19] reduce navigation boilerplate this makes the url router adjust the url when the navigation path is changed, instead of doing urlRouter.applyUrl() and urlRouter.history.pushUrl(). This history field and applyUrl method on URLRouter are now private, as the URLRouter should only be used to generate urls you want to put in an , anything else should use navigator.push() --- src/domain/RootViewModel.js | 36 +++++-------- src/domain/navigation/Navigation.js | 15 +++++- src/domain/navigation/URLRouter.js | 52 +++++++------------ src/domain/session/RoomGridViewModel.js | 8 +-- .../session/leftpanel/LeftPanelViewModel.js | 21 ++++++-- src/main.js | 5 +- src/ui/web/dom/History.js | 26 +++------- 7 files changed, 74 insertions(+), 89 deletions(-) diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index 24c810c7..b8ef2015 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -35,13 +35,13 @@ export class RootViewModel extends ViewModel { this._sessionViewModel = 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.urlRouter.getLastUrl()); } - async _applyNavigation(restoreHashIfAtDefault) { + async _applyNavigation(restoreUrlIfAtDefault) { const isLogin = this.navigation.observe("login").get(); const sessionId = this.navigation.observe("session").get(); if (isLogin) { @@ -58,30 +58,24 @@ export class RootViewModel extends ViewModel { } } else { try { - let url = restoreHashIfAtDefault; - if (!url) { - // redirect depending on what sessions are already present + if (restoreUrlIfAtDefault) { + this.urlRouter.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 +96,8 @@ 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); + this.navigation.push("session", sessionContainer.sessionId); }, })); }); 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..cca0ae06 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -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/main.js b/src/main.js index 6e9571f4..d98448dd 100644 --- a/src/main.js +++ b/src/main.js @@ -118,8 +118,7 @@ export async function main(container, paths, legacyExtras) { } 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({ @@ -143,7 +142,7 @@ export async function main(container, paths, legacyExtras) { navigation }); 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/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) { From 101c7015f2ffe49c5ff1efa8113d6cfd02308b55 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 16 Oct 2020 12:49:42 +0200 Subject: [PATCH 10/19] move service worker code in bundle, and support closing sessions --- index.html | 41 ------- scripts/build.mjs | 2 +- src/main.js | 14 ++- src/ui/web/dom/ServiceWorkerHandler.js | 150 +++++++++++++++++++++++++ 4 files changed, 161 insertions(+), 46 deletions(-) create mode 100644 src/ui/web/dom/ServiceWorkerHandler.js 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}); + } +} From fc61729cb3cb2db72153f3efafa6c6edc3b68506 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 16 Oct 2020 12:50:19 +0200 Subject: [PATCH 11/19] support closing session in other clients in service worker --- src/service-worker.template.js | 61 +++++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/src/service-worker.template.js b/src/service-worker.template.js index af8eae88..2a246737 100644 --- a/src/service-worker.template.js +++ b/src/service-worker.template.js @@ -68,18 +68,6 @@ self.addEventListener('fetch', (event) => { event.respondWith(handleRequest(event.request)); }); -self.addEventListener('message', (event) => { - const reply = content => event.source.postMessage({replyTo: event.data?.id, content}); - switch (event.data?.type) { - case "version": - reply({version: VERSION, buildHash: GLOBAL_HASH}); - break; - case "skipWaiting": - self.skipWaiting(); - break; - } -}); - async function handleRequest(request) { const baseURL = self.registration.scope; if (request.url === baseURL) { @@ -131,3 +119,52 @@ 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; +} From 0d622164df50f148fde7b2140c1b44e5b473afba Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 16 Oct 2020 12:50:37 +0200 Subject: [PATCH 12/19] close session db in other tabs when opening --- src/matrix/storage/idb/StorageFactory.js | 5 +++++ 1 file changed, 5 insertions(+) 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); } From 5a308552275e3477def6c555b95f501cd2ca8beb Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 16 Oct 2020 13:02:21 +0200 Subject: [PATCH 13/19] rename urlRouter option in view models to urlCreator --- src/domain/LoginViewModel.js | 2 +- src/domain/RootViewModel.js | 4 ++-- src/domain/SessionPickerViewModel.js | 4 ++-- src/domain/ViewModel.js | 12 ++++++++---- src/domain/session/leftpanel/LeftPanelViewModel.js | 2 +- src/domain/session/leftpanel/RoomTileViewModel.js | 2 +- src/domain/session/room/RoomViewModel.js | 2 +- src/main.js | 4 +++- 8 files changed, 19 insertions(+), 13 deletions(-) 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 b8ef2015..ae3fbc8e 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -38,7 +38,7 @@ export class RootViewModel extends ViewModel { async load() { this.track(this.navigation.observe("login").subscribe(() => this._applyNavigation())); this.track(this.navigation.observe("session").subscribe(() => this._applyNavigation())); - this._applyNavigation(this.urlRouter.getLastUrl()); + this._applyNavigation(this.urlCreator.getLastUrl()); } async _applyNavigation(restoreUrlIfAtDefault) { @@ -59,7 +59,7 @@ export class RootViewModel extends ViewModel { } else { try { if (restoreUrlIfAtDefault) { - this.urlRouter.pushUrl(restoreUrlIfAtDefault); + this.urlCreator.pushUrl(restoreUrlIfAtDefault); } else { const sessionInfos = await this._sessionInfoStorage.getAll(); if (sessionInfos.length === 0) { 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/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index cca0ae06..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() { 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 792c333b..0b47c95d 100644 --- a/src/main.js +++ b/src/main.js @@ -143,7 +143,9 @@ export async function main(container, paths, legacyExtras) { sessionInfoStorage, storageFactory, clock, - urlRouter, + // 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 }); From c125335ef0df2d28c2026df26df448410d234ce9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 16 Oct 2020 13:03:16 +0200 Subject: [PATCH 14/19] undo debug squibels --- src/main.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.js b/src/main.js index 0b47c95d..7bf7d5af 100644 --- a/src/main.js +++ b/src/main.js @@ -155,6 +155,6 @@ export async function main(container, paths, legacyExtras) { const view = new RootView(vm); container.appendChild(view.mount()); } catch(err) { - console.error(`${err.message}:\n${err.stack}dfdfdfdf`); + console.error(`${err.message}:\n${err.stack}`); } } From 743cdd0bea56a86e9da40b38684a3c66f0ad9520 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 16 Oct 2020 13:26:19 +0200 Subject: [PATCH 15/19] safari is tooo fast and skips it when it shouldn't also, I've never had the registration fail, seems very unlikely --- src/ui/web/dom/ServiceWorkerHandler.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/ui/web/dom/ServiceWorkerHandler.js b/src/ui/web/dom/ServiceWorkerHandler.js index e76f202c..0c50c0f8 100644 --- a/src/ui/web/dom/ServiceWorkerHandler.js +++ b/src/ui/web/dom/ServiceWorkerHandler.js @@ -141,10 +141,6 @@ export class ServiceWorkerHandler { } 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}); } } From 0ca926d42716770e8c8cb992ef2a8637d4b67b04 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 16 Oct 2020 14:52:34 +0200 Subject: [PATCH 16/19] claim clients immediately from service worker --- src/service-worker.template.js | 7 ++++++- src/ui/web/dom/ServiceWorkerHandler.js | 24 ++++++++++++++++++------ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/service-worker.template.js b/src/service-worker.template.js index 2a246737..bd6cfc17 100644 --- a/src/service-worker.template.js +++ b/src/service-worker.template.js @@ -61,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) => { diff --git a/src/ui/web/dom/ServiceWorkerHandler.js b/src/ui/web/dom/ServiceWorkerHandler.js index 0c50c0f8..432a6ce8 100644 --- a/src/ui/web/dom/ServiceWorkerHandler.js +++ b/src/ui/web/dom/ServiceWorkerHandler.js @@ -25,6 +25,7 @@ export class ServiceWorkerHandler { this._registration = null; this._navigation = navigation; this._registrationPromise = null; + this._currentController = null; } registerAndStart(path) { @@ -32,6 +33,8 @@ export class ServiceWorkerHandler { 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); @@ -76,7 +79,9 @@ export class ServiceWorkerHandler { } async _tryActivateUpdate() { - if (this._registration.waiting && this._registration.active) { + // 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?`)) { @@ -98,11 +103,18 @@ export class ServiceWorkerHandler { 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(); + 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; } } From 74e6d018f4747ff76af0c597c060d7e17ddf57e7 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 16 Oct 2020 15:46:06 +0200 Subject: [PATCH 17/19] ensure url creation does not race on the current path of the navigation --- src/domain/RootViewModel.js | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index ae3fbc8e..a0d3fa12 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -27,12 +27,12 @@ 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() { @@ -54,7 +54,18 @@ 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._showSessionLoader(sessionId); + } } } else { try { @@ -96,7 +107,15 @@ export class RootViewModel extends ViewModel { defaultHomeServer: "https://matrix.org", createSessionContainer: this._createSessionContainer, ready: sessionContainer => { - 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); }, })); From e599d8b6fab4db7df6ff8e7d2b014fc616dfbb03 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 16 Oct 2020 15:47:13 +0200 Subject: [PATCH 18/19] also clear --- src/domain/RootViewModel.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index a0d3fa12..2a1e0c82 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -63,6 +63,7 @@ export class RootViewModel extends ViewModel { // 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); } From 90435a8fb70c73d563d1341a3f993a65043a2436 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 16 Oct 2020 15:48:49 +0200 Subject: [PATCH 19/19] semicolons, add them --- src/domain/RootViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index 2a1e0c82..3316bacc 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -63,7 +63,7 @@ export class RootViewModel extends ViewModel { // this should never happen, but we want to be sure not to leak it if (this._pendingSessionContainer) { this._pendingSessionContainer.dispose(); - this._pendingSessionContainer = null + this._pendingSessionContainer = null; } this._showSessionLoader(sessionId); }