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>
|
||||||
<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>
|
||||||
|
|
|
@ -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));
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
|
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 */
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
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 {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());
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
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