diff --git a/scripts/build-plugins/service-worker.js b/scripts/build-plugins/service-worker.js index 856195453a..8dad6064f8 100644 --- a/scripts/build-plugins/service-worker.js +++ b/scripts/build-plugins/service-worker.js @@ -72,6 +72,21 @@ const NON_PRECACHED_JS = [ function isPreCached(asset) { const {name, fileName} = asset; + + // For sync-worker.js and sync-worker.js.map, `name` isn't set, so we must rely on `fileName` only. + if (!name && fileName.includes("sync-worker")) { + // Don't precache sourcemap. + if (fileName.endsWith(".js.map")) { + return false; + } + + // sync-worker.js is only used when the SameSessionInMultipleTabs feature is enabled, so we don't precache it. + // TODO: Once the SameSessionInMultipleTabs feature is removed, we should probably precache sync-worker.js by returning true below. + if (fileName.endsWith(".js")) { + return false; + } + } + return name.endsWith(".svg") || name.endsWith(".png") || name.endsWith(".css") || diff --git a/scripts/sdk/build.sh b/scripts/sdk/build.sh index 5e1632d3ee..62b9a32859 100755 --- a/scripts/sdk/build.sh +++ b/scripts/sdk/build.sh @@ -10,6 +10,9 @@ shopt -s extglob rm -rf target/* yarn run vite build -c vite.sdk-assets-config.js yarn run vite build -c vite.sdk-lib-config.js +# Remove sync-worker.js from SDK build. +# TODO: Once SameSessionInMultipleTabs feature flag is globally enabled, remove the following line. +rm -rf target/lib-build/assets yarn tsc -p tsconfig-declaration.json ./scripts/sdk/create-manifest.js ./target/package.json mkdir target/paths diff --git a/src/domain/session/settings/FeaturesViewModel.ts b/src/domain/session/settings/FeaturesViewModel.ts index b0ee359869..707138a0f3 100644 --- a/src/domain/session/settings/FeaturesViewModel.ts +++ b/src/domain/session/settings/FeaturesViewModel.ts @@ -28,12 +28,17 @@ export class FeaturesViewModel extends ViewModel { new FeatureViewModel(this.childOptions({ name: this.i18n`Audio/video calls`, description: this.i18n`Allows starting and participating in A/V calls compatible with Element Call (MSC3401). Look for the start call option in the room menu ((...) in the right corner) to start a call.`, - feature: FeatureFlag.Calls + feature: FeatureFlag.Calls, })), new FeatureViewModel(this.childOptions({ name: this.i18n`Cross-Signing`, description: this.i18n`Allows verifying the identity of people you chat with. This feature is still evolving constantly, expect things to break.`, - feature: FeatureFlag.CrossSigning + feature: FeatureFlag.CrossSigning, + })), + new FeatureViewModel(this.childOptions({ + name: this.i18n`Open the same session in multiple tabs`, + description: this.i18n`Allows having the same session open in multiple browser tabs or windows. This feature is currently not functional and is intended only for usage by Hydrogen developers. Do not enable.`, + feature: FeatureFlag.SameSessionInMultipleTabs, })), ]; } diff --git a/src/features.ts b/src/features.ts index 6fa5dd432a..84c9eecc46 100644 --- a/src/features.ts +++ b/src/features.ts @@ -18,7 +18,8 @@ import type {SettingsStorage} from "./platform/web/dom/SettingsStorage"; export enum FeatureFlag { Calls = 1 << 0, - CrossSigning = 1 << 1 + CrossSigning = 1 << 1, + SameSessionInMultipleTabs = 1 << 2, } export class FeatureSet { @@ -44,6 +45,10 @@ export class FeatureSet { return this.isFeatureEnabled(FeatureFlag.CrossSigning); } + get sameSessionInMultipleTabs(): boolean { + return this.isFeatureEnabled(FeatureFlag.SameSessionInMultipleTabs); + } + static async load(settingsStorage: SettingsStorage): Promise { const flags = await settingsStorage.getInt("enabled_features") || 0; return new FeatureSet(flags); diff --git a/src/matrix/Client.js b/src/matrix/Client.js index fabb489b67..a8ced4abc2 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -291,14 +291,18 @@ export class Client { await log.wrap("createIdentity", log => this._session.createIdentity(log)); } - this._sync = new Sync({hsApi: this._requestScheduler.hsApi, storage: this._storage, session: this._session, logger: this._platform.logger}); + this._sync = this._platform.syncFactory.make({ + scheduler: this._requestScheduler, + storage: this._storage, + session: this._session, + }); // notify sync and session when back online this._reconnectSubscription = this._reconnector.connectionStatus.subscribe(state => { if (state === ConnectionStatus.Online) { this._platform.logger.runDetached("reconnect", async log => { // needs to happen before sync and session or it would abort all requests this._requestScheduler.start(); - this._sync.start(); + await this._sync.start(); this._sessionStartedByReconnector = true; const d = dehydratedDevice; dehydratedDevice = undefined; @@ -329,7 +333,7 @@ export class Client { } async _waitForFirstSync() { - this._sync.start(); + await this._sync.start(); this._status.set(LoadStatus.FirstSync); // only transition into Ready once the first sync has succeeded this._waitForFirstSyncHandle = this._sync.status.waitFor(s => { diff --git a/src/matrix/ISync.ts b/src/matrix/ISync.ts new file mode 100644 index 0000000000..f035a6a904 --- /dev/null +++ b/src/matrix/ISync.ts @@ -0,0 +1,25 @@ +/* +Copyright 2023 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. +*/ + +import {ObservableValue} from "../observable/value"; +import {SyncStatus} from "./Sync"; + +export interface ISync { + get status(): ObservableValue; + get error(): Error | null; + start(): Promise; + stop(): void; +} diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index d335336d29..f244476b71 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -51,6 +51,7 @@ function timelineIsEmpty(roomResponse) { * await room.afterSyncCompleted(changes); * ``` */ +// TODO: When this file gets converted to Typescript, Sync must implement ISync. export class Sync { constructor({hsApi, session, storage, logger}) { this._hsApi = hsApi; @@ -71,7 +72,7 @@ export class Sync { return this._error; } - start() { + async start() { // not already syncing? if (this._status.get() !== SyncStatus.Stopped) { return; @@ -83,7 +84,7 @@ export class Sync { } else { this._status.set(SyncStatus.InitialSync); } - this._syncLoop(syncToken); + void this._syncLoop(syncToken); } async _syncLoop(syncToken) { diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index be8c997078..a5a7c5fac0 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -43,6 +43,8 @@ import {MediaDevicesWrapper} from "./dom/MediaDevices"; import {DOMWebRTC} from "./dom/WebRTC"; import {ThemeLoader} from "./theming/ThemeLoader"; import {TimeFormatter} from "./dom/TimeFormatter"; +import {FeatureSet} from "../../features"; +import {SyncFactory} from "./sync/SyncFactory"; function addScript(src) { return new Promise(function (resolve, reject) { @@ -201,6 +203,8 @@ export class Platform { log.log({ l: "Active theme", name: themeName, variant: themeVariant }); await this._themeLoader.setTheme(themeName, themeVariant, log); } + this.features = await FeatureSet.load(this.settingsStorage); + this.syncFactory = new SyncFactory({logger: this.logger, features: this.features}); }); } catch (err) { this._container.innerText = err.message; diff --git a/src/platform/web/main.js b/src/platform/web/main.js index d9c6fe8a94..d48b442dd7 100644 --- a/src/platform/web/main.js +++ b/src/platform/web/main.js @@ -35,7 +35,6 @@ export async function main(platform) { // const request = recorder.request; // window.getBrawlFetchLog = () => recorder.log(); await platform.init(); - const features = await FeatureSet.load(platform.settingsStorage); const navigation = createNavigation(); platform.setNavigation(navigation); const urlRouter = createRouter({navigation, history: platform.history}); @@ -46,7 +45,7 @@ export async function main(platform) { // so we call it that in the view models urlRouter: urlRouter, navigation, - features + features: platform.features, }); await vm.load(); platform.createAndMountRootView(vm); diff --git a/src/platform/web/sync/SyncFactory.ts b/src/platform/web/sync/SyncFactory.ts new file mode 100644 index 0000000000..037662643e --- /dev/null +++ b/src/platform/web/sync/SyncFactory.ts @@ -0,0 +1,66 @@ +/* +Copyright 2023 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. +*/ + +import {ISync} from "../../../matrix/ISync"; +import {Sync} from "../../../matrix/Sync"; +import {RequestScheduler} from "../../../matrix/net/RequestScheduler"; +import {Session} from "../../../matrix/Session"; +import {Logger} from "../../../logging/Logger"; +import {Storage} from "../../../matrix/storage/idb/Storage"; +import {FeatureSet} from "../../../features"; +import {SyncProxy} from "./SyncProxy"; + +type Options = { + logger: Logger; + features: FeatureSet; +} + +type MakeOptions = { + scheduler: RequestScheduler; + storage: Storage; + session: Session; +} + +export class SyncFactory { + private readonly _logger: Logger; + private readonly _features: FeatureSet; + + constructor(options: Options) { + const {logger, features} = options; + this._logger = logger; + this._features = features; + } + + make(options: MakeOptions): ISync { + const {scheduler, storage, session} = options; + let runSyncInWorker = this._features.sameSessionInMultipleTabs; + + if (typeof SharedWorker === "undefined") { + runSyncInWorker = false; + } + + if (runSyncInWorker) { + return new SyncProxy({session}); + } + + return new Sync({ + logger: this._logger, + hsApi: scheduler.hsApi, + storage, + session, + }); + } +} diff --git a/src/platform/web/sync/SyncProxy.ts b/src/platform/web/sync/SyncProxy.ts new file mode 100644 index 0000000000..7e59edcc61 --- /dev/null +++ b/src/platform/web/sync/SyncProxy.ts @@ -0,0 +1,58 @@ +/* +Copyright 2023 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. +*/ + +import {ISync} from "../../../matrix/ISync"; +import {ObservableValue} from "../../../observable/value"; +import {SyncStatus} from "../../../matrix/Sync"; +import {Session} from "../../../matrix/Session"; + +type Options = { + session: Session; +} + +export class SyncProxy implements ISync { + private _session: Session; + private readonly _status: ObservableValue = new ObservableValue(SyncStatus.Stopped); + private _error: Error | null = null; + private _worker?: SharedWorker; + + constructor(options: Options) { + const {session} = options; + this._session = session; + } + + get status(): ObservableValue { + return this._status; + } + + get error(): Error | null { + return this._error; + } + + async start(): Promise { + this._worker = new SharedWorker(new URL("./sync-worker", import.meta.url), { + type: "module", + }); + this._worker.port.onmessage = (event: MessageEvent) => { + // TODO + console.log(event); + }; + } + + stop(): void { + // TODO + } +} diff --git a/src/platform/web/sync/sync-worker.ts b/src/platform/web/sync/sync-worker.ts new file mode 100644 index 0000000000..559c77d742 --- /dev/null +++ b/src/platform/web/sync/sync-worker.ts @@ -0,0 +1,29 @@ +/* +Copyright 2023 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. +*/ + +// TODO: Figure out how to get WebWorkers Typescript lib working. For now we just disable checks on the whole file. +// @ts-nocheck + +// The empty export makes this a module. It can be removed once there's at least one import. +export {} + +declare let self: SharedWorkerGlobalScope; + +self.onconnect = (event: MessageEvent) => { + const port = event.ports[0]; + port.postMessage("hello from sync worker"); + console.log("hello from sync worker"); +}