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>
<script id="service-worker" type="disabled">
if('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js')
.then(function() { console.log("Service Worker registered"); });
}
</script>
</body> </body>
</html> </html>

View file

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

View file

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

View file

@ -27,21 +27,21 @@ export class RootViewModel extends ViewModel {
this._createSessionContainer = createSessionContainer; this._createSessionContainer = createSessionContainer;
this._sessionInfoStorage = sessionInfoStorage; this._sessionInfoStorage = sessionInfoStorage;
this._storageFactory = storageFactory; this._storageFactory = storageFactory;
this._error = null; this._error = null;
this._sessionPickerViewModel = null; this._sessionPickerViewModel = null;
this._sessionLoadViewModel = null; this._sessionLoadViewModel = null;
this._loginViewModel = null; this._loginViewModel = null;
this._sessionViewModel = 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("login").subscribe(() => this._applyNavigation()));
this.track(this.navigation.observe("session").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 isLogin = this.navigation.observe("login").get();
const sessionId = this.navigation.observe("session").get(); const sessionId = this.navigation.observe("session").get();
if (isLogin) { if (isLogin) {
@ -54,34 +54,40 @@ export class RootViewModel extends ViewModel {
} }
} else if (sessionId) { } else if (sessionId) {
if (!this._sessionViewModel || this._sessionViewModel.id !== 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 { } else {
try { try {
let url = restoreHashIfAtDefault; if (restoreUrlIfAtDefault) {
if (!url) { this.urlCreator.pushUrl(restoreUrlIfAtDefault);
// redirect depending on what sessions are already present } else {
const sessionInfos = await this._sessionInfoStorage.getAll(); 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) { } catch (err) {
this._setSection(() => this._error = 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() { async _showPicker() {
this._setSection(() => { this._setSection(() => {
this._sessionPickerViewModel = new SessionPickerViewModel(this.childOptions({ this._sessionPickerViewModel = new SessionPickerViewModel(this.childOptions({
@ -102,10 +108,16 @@ export class RootViewModel extends ViewModel {
defaultHomeServer: "https://matrix.org", defaultHomeServer: "https://matrix.org",
createSessionContainer: this._createSessionContainer, createSessionContainer: this._createSessionContainer,
ready: sessionContainer => { ready: sessionContainer => {
const url = this.urlRouter.urlForSegment("session", sessionContainer.sessionId); // we don't want to load the session container again,
this.urlRouter.applyUrl(url); // but we also want the change of screen to go through the navigation
this.urlRouter.history.replaceUrl(url); // so we store the session container in a temporary variable that will be
this._showSession(sessionContainer); // 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() { get openUrl() {
return this.urlRouter.urlForSegment("session", this.id); return this.urlCreator.urlForSegment("session", this.id);
} }
get label() { get label() {
@ -189,6 +189,6 @@ export class SessionPickerViewModel extends ViewModel {
} }
get cancelUrl() { 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) { childOptions(explicitOptions) {
const {navigation, urlRouter, clock} = this._options; const {navigation, urlCreator, clock} = this._options;
return Object.assign({navigation, urlRouter, clock}, explicitOptions); return Object.assign({navigation, urlCreator, clock}, explicitOptions);
} }
track(disposable) { track(disposable) {
@ -99,8 +99,12 @@ export class ViewModel extends EventEmitter {
return this._options.clock; 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() { get navigation() {

View file

@ -14,19 +14,28 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {BaseObservableValue} from "../../observable/ObservableValue.js"; import {BaseObservableValue, ObservableValue} from "../../observable/ObservableValue.js";
export class Navigation { export class Navigation {
constructor(allowsChild) { constructor(allowsChild) {
this._allowsChild = allowsChild; this._allowsChild = allowsChild;
this._path = new Path([], allowsChild); this._path = new Path([], allowsChild);
this._observables = new Map(); this._observables = new Map();
this._pathObservable = new ObservableValue(this._path);
}
get pathObservable() {
return this._pathObservable;
} }
get path() { get path() {
return this._path; return this._path;
} }
push(type, value = undefined) {
return this.applyPath(this.path.with(new Segment(type, value)));
}
applyPath(path) { applyPath(path) {
// Path is not exported, so you can only create a Path through Navigation, // Path is not exported, so you can only create a Path through Navigation,
// so we assume it respects the allowsChild rules // so we assume it respects the allowsChild rules
@ -45,6 +54,10 @@ export class Navigation {
const observable = this._observables.get(segment.type); const observable = this._observables.get(segment.type);
observable?.emitIfChanged(); 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) { observe(type) {

View file

@ -14,40 +14,50 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {Segment} from "./Navigation.js";
export class URLRouter { export class URLRouter {
constructor({history, navigation, parseUrlPath, stringifyPath}) { constructor({history, navigation, parseUrlPath, stringifyPath}) {
this._subscription = null;
this._history = history; this._history = history;
this._navigation = navigation; this._navigation = navigation;
this._parseUrlPath = parseUrlPath; this._parseUrlPath = parseUrlPath;
this._stringifyPath = stringifyPath; this._stringifyPath = stringifyPath;
this._subscription = null;
this._pathSubscription = null;
} }
attach() { attach() {
this._subscription = this._history.subscribe(url => { this._subscription = this._history.subscribe(url => {
const redirectedUrl = this.applyUrl(url); const redirectedUrl = this._applyUrl(url);
if (redirectedUrl !== 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() { dispose() {
this._subscription = this._subscription(); this._subscription = this._subscription();
this._pathSubscription = this._pathSubscription();
} }
applyUrl(url) { _applyUrl(url) {
const urlPath = this._history.urlAsPath(url) const urlPath = this._history.urlAsPath(url)
const navPath = this._navigation.pathFrom(this._parseUrlPath(urlPath, this._navigation.path)); const navPath = this._navigation.pathFrom(this._parseUrlPath(urlPath, this._navigation.path));
this._navigation.applyPath(navPath); this._navigation.applyPath(navPath);
return this._history.pathAsUrl(this._stringifyPath(navPath)); return this._history.pathAsUrl(this._stringifyPath(navPath));
} }
get history() { pushUrl(url) {
return this._history; this._history.pushUrl(url);
}
getLastUrl() {
return this._history.getLastUrl();
} }
urlForSegments(segments) { urlForSegments(segments) {
@ -70,7 +80,7 @@ export class URLRouter {
} }
urlForPath(path) { urlForPath(path) {
return this.history.pathAsUrl(this._stringifyPath(path)); return this._history.pathAsUrl(this._stringifyPath(path));
} }
openRoomActionUrl(roomId) { openRoomActionUrl(roomId) {
@ -78,26 +88,4 @@ export class URLRouter {
const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${roomId}`; const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${roomId}`;
return this._history.pathAsUrl(urlPath); 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) { if (index === this._selectedIndex) {
return; return;
} }
let path = this.navigation.path;
const vm = this._viewModels[index]; const vm = this._viewModels[index];
if (vm) { if (vm) {
path = path.with(this.navigation.segment("room", vm.id)); this.navigation.push("room", vm.id);
} else { } 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 */ /** 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._roomList = this._roomListFilterMap.sortValues((a, b) => a.compare(b));
this._currentTileVM = null; this._currentTileVM = null;
this._setupNavigation(); this._setupNavigation();
this._closeUrl = this.urlRouter.urlForSegment("session"); this._closeUrl = this.urlCreator.urlForSegment("session");
} }
get closeUrl() { get closeUrl() {
@ -76,14 +76,25 @@ export class LeftPanelViewModel extends ViewModel {
} }
toggleGrid() { toggleGrid() {
let url;
if (this.gridEnabled) { 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 { } 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() { get roomList() {

View file

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

View file

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

View file

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

View file

@ -15,6 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
const VERSION = "%%VERSION%%";
const GLOBAL_HASH = "%%GLOBAL_HASH%%"; const GLOBAL_HASH = "%%GLOBAL_HASH%%";
const UNHASHED_PRECACHED_ASSETS = "%%UNHASHED_PRECACHED_ASSETS%%"; const UNHASHED_PRECACHED_ASSETS = "%%UNHASHED_PRECACHED_ASSETS%%";
const HASHED_PRECACHED_ASSETS = "%%HASHED_PRECACHED_ASSETS%%"; const HASHED_PRECACHED_ASSETS = "%%HASHED_PRECACHED_ASSETS%%";
@ -60,7 +61,12 @@ async function purgeOldCaches() {
} }
self.addEventListener('activate', (event) => { 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) => { self.addEventListener('fetch', (event) => {
@ -119,3 +125,51 @@ async function readCache(request) {
return response; 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() { constructor() {
super(); super();
this._boundOnHashChange = null; this._boundOnHashChange = null;
this._expectSetEcho = false;
} }
_onHashChange() { _onHashChange() {
if (this._expectSetEcho) {
this._expectSetEcho = false;
return;
}
this.emit(this.get()); this.emit(this.get());
this._storeHash(this.get()); this._storeHash(this.get());
} }
@ -37,28 +32,19 @@ export class History extends BaseObservableValue {
} }
/** does not emit */ /** does not emit */
replaceUrl(url) { replaceUrlSilently(url) {
window.history.replaceState(null, null, url); window.history.replaceState(null, null, url);
this._storeHash(url); this._storeHash(url);
} }
/** does not emit */ /** does not emit */
pushUrl(url) { pushUrlSilently(url) {
window.history.pushState(null, null, url); window.history.pushState(null, null, url);
this._storeHash(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 pushUrl(url) {
// // trigger onhashchange document.location.hash = url;
// 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;
} }
urlAsPath(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});
}
}