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:
commit
753bee2509
17 changed files with 370 additions and 128 deletions
|
@ -29,11 +29,5 @@
|
|||
}
|
||||
});
|
||||
</script>
|
||||
<script id="service-worker" type="disabled">
|
||||
if('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('sw.js')
|
||||
.then(function() { console.log("Service Worker registered"); });
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -70,7 +70,7 @@ export class LoginViewModel extends ViewModel {
|
|||
}
|
||||
|
||||
get cancelUrl() {
|
||||
return this.urlRouter.urlForSegment("session");
|
||||
return this.urlCreator.urlForSegment("session");
|
||||
}
|
||||
|
||||
dispose() {
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
23
src/main.js
23
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());
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
158
src/ui/web/dom/ServiceWorkerHandler.js
Normal file
158
src/ui/web/dom/ServiceWorkerHandler.js
Normal 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});
|
||||
}
|
||||
}
|
Reference in a new issue