diff --git a/index.html b/index.html index a027932a..9e57cf5c 100644 --- a/index.html +++ b/index.html @@ -20,14 +20,15 @@ diff --git a/scripts/build.mjs b/scripts/build.mjs index 1534fc7a..06405a47 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -45,12 +45,12 @@ import flexbugsFixes from "postcss-flexbugs-fixes"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const projectDir = path.join(__dirname, "../"); -const cssSrcDir = path.join(projectDir, "src/ui/web/css/"); +const cssSrcDir = path.join(projectDir, "src/platform/web/ui/css/"); -const program = new commander.Command(); -program +const parameters = new commander.Command(); +parameters .option("--modern-only", "don't make a legacy build") -program.parse(process.argv); +parameters.parse(process.argv); async function build({modernOnly}) { // get version number @@ -70,10 +70,13 @@ async function build({modernOnly}) { // copy olm assets const olmAssets = await copyFolder(path.join(projectDir, "lib/olm/"), assets.directory); assets.addSubMap(olmAssets); - await assets.write(`hydrogen.js`, await buildJs("src/main.js")); + await assets.write(`hydrogen.js`, await buildJs("src/main.js", ["src/platform/web/Platform.js"])); if (!modernOnly) { - await assets.write(`hydrogen-legacy.js`, await buildJsLegacy("src/main.js", ['src/legacy-polyfill.js', 'src/legacy-extras.js'])); - await assets.write(`worker.js`, await buildJsLegacy("src/worker.js", ['src/worker-polyfill.js'])); + await assets.write(`hydrogen-legacy.js`, await buildJsLegacy("src/main.js", [ + 'src/platform/web/legacy-polyfill.js', + 'src/platform/web/LegacyPlatform.js' + ])); + await assets.write(`worker.js`, await buildJsLegacy("src/platform/web/worker/main.js", ['src/platform/web/worker/polyfill.js'])); } // creates the directories where the theme css bundles are placed in, // and writes to assets, so the build bundles can translate them, so do it first @@ -82,7 +85,7 @@ async function build({modernOnly}) { await buildManifest(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"); + let swSource = await fs.readFile(path.join(projectDir, "src/platform/web/service-worker.template.js"), "utf8"); assets.addToHashForAll("sw.js", swSource); const globalHash = assets.hashForAll(); @@ -148,12 +151,12 @@ async function buildHtml(doc, version, globalHash, modernOnly, assets) { } }); const mainScripts = [ - `` + `` ]; if (!modernOnly) { mainScripts.push( ``, - `` + `` ); } doc("script#main").replaceWith(mainScripts.join("")); @@ -168,16 +171,16 @@ async function buildHtml(doc, version, globalHash, modernOnly, assets) { await assets.writeUnhashed("index.html", doc.html()); } -async function buildJs(inputFile) { +async function buildJs(mainFile, extraFiles = []) { // create js bundle const bundle = await rollup({ - input: inputFile, + input: extraFiles.concat(mainFile), plugins: [removeJsComments({comments: "none"})] }); const {output} = await bundle.generate({ format: 'es', // TODO: can remove this? - name: `hydrogenBundle` + name: `hydrogen` }); const code = output[0].code; return code; @@ -214,7 +217,7 @@ async function buildJsLegacy(mainFile, extraFiles = []) { const bundle = await rollup(rollupConfig); const {output} = await bundle.generate({ format: 'iife', - name: `hydrogenBundle` + name: `hydrogen` }); const code = output[0].code; return code; @@ -460,4 +463,4 @@ class AssetMap { } } -build(program).catch(err => console.error(err)); +build(parameters).catch(err => console.error(err)); diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index 6669b778..63f5c10a 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -23,10 +23,7 @@ import {ViewModel} from "./ViewModel.js"; export class RootViewModel extends ViewModel { constructor(options) { super(options); - const {createSessionContainer, sessionInfoStorage, storageFactory} = options; - this._createSessionContainer = createSessionContainer; - this._sessionInfoStorage = sessionInfoStorage; - this._storageFactory = storageFactory; + this._createSessionContainer = options.createSessionContainer; this._error = null; this._sessionPickerViewModel = null; this._sessionLoadViewModel = null; @@ -90,10 +87,7 @@ export class RootViewModel extends ViewModel { async _showPicker() { this._setSection(() => { - this._sessionPickerViewModel = new SessionPickerViewModel(this.childOptions({ - sessionInfoStorage: this._sessionInfoStorage, - storageFactory: this._storageFactory, - })); + this._sessionPickerViewModel = new SessionPickerViewModel(this.childOptions()); }); try { await this._sessionPickerViewModel.load(); @@ -125,11 +119,7 @@ export class RootViewModel extends ViewModel { _showSession(sessionContainer) { this._setSection(() => { - this._sessionViewModel = new SessionViewModel(this.childOptions({ - sessionContainer, - updateService: this.getOption("updateService"), - estimateStorageUsage: this.getOption("estimateStorageUsage"), - })); + this._sessionViewModel = new SessionViewModel(this.childOptions({sessionContainer})); this._sessionViewModel.start(); }); } diff --git a/src/domain/SessionPickerViewModel.js b/src/domain/SessionPickerViewModel.js index 76be11dd..3bbbcef7 100644 --- a/src/domain/SessionPickerViewModel.js +++ b/src/domain/SessionPickerViewModel.js @@ -130,9 +130,6 @@ class SessionItemViewModel extends ViewModel { export class SessionPickerViewModel extends ViewModel { constructor(options) { super(options); - const {storageFactory, sessionInfoStorage} = options; - this._storageFactory = storageFactory; - this._sessionInfoStorage = sessionInfoStorage; this._sessions = new SortedArray((s1, s2) => s1.id.localeCompare(s2.id)); this._loadViewModel = null; this._error = null; @@ -140,7 +137,7 @@ export class SessionPickerViewModel extends ViewModel { // this loads all the sessions async load() { - const sessions = await this._sessionInfoStorage.getAll(); + const sessions = await this.platform.sessionInfoStorage.getAll(); this._sessions.setManyUnsorted(sessions.map(s => { return new SessionItemViewModel(this.childOptions({sessionInfo: s}), this); })); @@ -152,8 +149,8 @@ export class SessionPickerViewModel extends ViewModel { } async _exportData(id) { - const sessionInfo = await this._sessionInfoStorage.get(id); - const stores = await this._storageFactory.export(id); + const sessionInfo = await this.platform.sessionInfoStorage.get(id); + const stores = await this.platform.storageFactory.export(id); const data = {sessionInfo, stores}; return data; } @@ -164,8 +161,8 @@ export class SessionPickerViewModel extends ViewModel { const {sessionInfo} = data; sessionInfo.comment = `Imported on ${new Date().toLocaleString()} from id ${sessionInfo.id}.`; sessionInfo.id = this._createSessionContainer().createNewSessionId(); - await this._storageFactory.import(sessionInfo.id, data.stores); - await this._sessionInfoStorage.add(sessionInfo); + await this.platform.storageFactory.import(sessionInfo.id, data.stores); + await this.platform.sessionInfoStorage.add(sessionInfo); this._sessions.set(new SessionItemViewModel(sessionInfo, this)); } catch (err) { alert(err.message); @@ -175,13 +172,13 @@ export class SessionPickerViewModel extends ViewModel { async delete(id) { const idx = this._sessions.array.findIndex(s => s.id === id); - await this._sessionInfoStorage.delete(id); - await this._storageFactory.delete(id); + await this.platform.sessionInfoStorage.delete(id); + await this.platform.storageFactory.delete(id); this._sessions.remove(idx); } async clear(id) { - await this._storageFactory.delete(id); + await this.platform.storageFactory.delete(id); } get sessions() { diff --git a/src/domain/ViewModel.js b/src/domain/ViewModel.js index a8b58921..dd2d9819 100644 --- a/src/domain/ViewModel.js +++ b/src/domain/ViewModel.js @@ -30,8 +30,8 @@ export class ViewModel extends EventEmitter { } childOptions(explicitOptions) { - const {navigation, urlCreator, clock} = this._options; - return Object.assign({navigation, urlCreator, clock}, explicitOptions); + const {navigation, urlCreator, platform} = this._options; + return Object.assign({navigation, urlCreator, platform}, explicitOptions); } // makes it easier to pass through dependencies of a sub-view model @@ -100,8 +100,12 @@ export class ViewModel extends EventEmitter { } } + get platform() { + return this._options.platform; + } + get clock() { - return this._options.clock; + return this._options.platform.clock; } /** diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index ed779747..79d8d87c 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -188,9 +188,7 @@ export class SessionViewModel extends ViewModel { } if (settingsOpen) { this._settingsViewModel = this.track(new SettingsViewModel(this.childOptions({ - updateService: this.getOption("updateService"), session: this._sessionContainer.session, - estimateStorageUsage: this.getOption("estimateStorageUsage") }))); this._settingsViewModel.load(); } diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index 5faa23b3..7366641b 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -43,7 +43,7 @@ export class TimelineViewModel extends ViewModel { // once we support sending messages we could do // timeline.entries.concat(timeline.pendingEvents) // for an ObservableList that also contains local echos - this._tiles = new TilesCollection(timeline.entries, tilesCreator({room, ownUserId, clock: this.clock})); + this._tiles = new TilesCollection(timeline.entries, tilesCreator({room, ownUserId, platform: this.platform})); } async load() { diff --git a/src/domain/session/room/timeline/tiles/MessageTile.js b/src/domain/session/room/timeline/tiles/MessageTile.js index 9db96eff..36d08ca7 100644 --- a/src/domain/session/room/timeline/tiles/MessageTile.js +++ b/src/domain/session/room/timeline/tiles/MessageTile.js @@ -21,7 +21,6 @@ export class MessageTile extends SimpleTile { constructor(options) { super(options); this._mediaRepository = options.mediaRepository; - this._clock = options.clock; this._isOwn = this._entry.sender === options.ownUserId; this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null; this._isContinuation = false; @@ -88,8 +87,8 @@ export class MessageTile extends SimpleTile { let isContinuation = false; if (prev && prev instanceof MessageTile && prev.sender === this.sender) { // timestamp is null for pending events - const myTimestamp = this._entry.timestamp || this._clock.now(); - const otherTimestamp = prev._entry.timestamp || this._clock.now(); + const myTimestamp = this._entry.timestamp || this.clock.now(); + const otherTimestamp = prev._entry.timestamp || this.clock.now(); // other message was sent less than 5min ago isContinuation = (myTimestamp - otherTimestamp) < (5 * 60 * 1000); } diff --git a/src/domain/session/room/timeline/tilesCreator.js b/src/domain/session/room/timeline/tilesCreator.js index 5f5593d5..d682d22e 100644 --- a/src/domain/session/room/timeline/tilesCreator.js +++ b/src/domain/session/room/timeline/tilesCreator.js @@ -23,9 +23,9 @@ import {RoomMemberTile} from "./tiles/RoomMemberTile.js"; import {EncryptedEventTile} from "./tiles/EncryptedEventTile.js"; import {EncryptionEnabledTile} from "./tiles/EncryptionEnabledTile.js"; -export function tilesCreator({room, ownUserId, clock}) { +export function tilesCreator({room, ownUserId, platform}) { return function tilesCreator(entry, emitUpdate) { - const options = {entry, emitUpdate, ownUserId, clock, + const options = {entry, emitUpdate, ownUserId, platform, mediaRepository: room.mediaRepository}; if (entry.isGap) { return new GapTile(options, room); diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index fce74294..0a1f223c 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -35,12 +35,11 @@ export class SettingsViewModel extends ViewModel { this._session = session; this._sessionBackupViewModel = this.track(new SessionBackupViewModel(this.childOptions({session}))); this._closeUrl = this.urlCreator.urlUntilSegment("session"); - this._estimateStorageUsage = options.estimateStorageUsage; this._estimate = null; } async load() { - this._estimate = await this._estimateStorageUsage(); + this._estimate = await this.platform.estimateStorageUsage(); this.emitChange(""); } @@ -61,18 +60,19 @@ export class SettingsViewModel extends ViewModel { } get version() { - if (this._updateService) { - return `${this._updateService.version} (${this._updateService.buildHash})`; + const {updateService} = this.platform; + if (updateService) { + return `${updateService.version} (${updateService.buildHash})`; } return this.i18n`development version`; } checkForUpdate() { - this._updateService?.checkForUpdate(); + this.platform.updateService?.checkForUpdate(); } get showUpdateButton() { - return !!this._updateService; + return !!this.platform.updateService; } get sessionBackupViewModel() { diff --git a/src/legacy-extras.js b/src/legacy-extras.js deleted file mode 100644 index e6b1f08e..00000000 --- a/src/legacy-extras.js +++ /dev/null @@ -1,6 +0,0 @@ -import aesjs from "../lib/aes-js/index.js"; -import {hkdf} from "./utils/crypto/hkdf.js"; - -// these are run-time dependencies that are only needed for the legacy bundle. -// they are exported here and passed into main to make them available to the app. -export const legacyExtras = {crypto:{aesjs, hkdf}}; diff --git a/src/main.js b/src/main.js index 966b3eda..0754b2ab 100644 --- a/src/main.js +++ b/src/main.js @@ -16,82 +16,14 @@ limitations under the License. */ // import {RecordRequester, ReplayRequester} from "./matrix/net/request/replay.js"; -import {createFetchRequest} from "./matrix/net/request/fetch.js"; -import {xhrRequest} from "./matrix/net/request/xhr.js"; import {SessionContainer} from "./matrix/SessionContainer.js"; -import {StorageFactory} from "./matrix/storage/idb/StorageFactory.js"; -import {SessionInfoStorage} from "./matrix/sessioninfo/localstorage/SessionInfoStorage.js"; import {RootViewModel} from "./domain/RootViewModel.js"; import {createNavigation, createRouter} from "./domain/navigation/index.js"; -import {RootView} from "./platform/web/ui/RootView.js"; -import {Clock} from "./platform/web/dom/Clock.js"; -import {ServiceWorkerHandler} from "./platform/web/dom/ServiceWorkerHandler.js"; -import {History} from "./platform/web/dom/History.js"; -import {OnlineStatus} from "./platform/web/dom/OnlineStatus.js"; -import {CryptoDriver} from "./platform/web/dom/CryptoDriver.js"; -import {estimateStorageUsage} from "./platform/web/dom/StorageEstimate.js"; -import {WorkerPool} from "./utils/WorkerPool.js"; -import {OlmWorker} from "./matrix/e2ee/OlmWorker.js"; - -function addScript(src) { - return new Promise(function (resolve, reject) { - var s = document.createElement("script"); - s.setAttribute("src", src ); - s.onload=resolve; - s.onerror=reject; - document.body.appendChild(s); - }); -} - -async function loadOlm(olmPaths) { - // make crypto.getRandomValues available without - // a prefix on IE11, needed by olm to work - if (window.msCrypto && !window.crypto) { - window.crypto = window.msCrypto; - } - if (olmPaths) { - if (window.WebAssembly) { - await addScript(olmPaths.wasmBundle); - await window.Olm.init({locateFile: () => olmPaths.wasm}); - } else { - await addScript(olmPaths.legacyBundle); - await window.Olm.init(); - } - return window.Olm; - } - return null; -} - -// make path relative to basePath, -// assuming it and basePath are relative to document -function relPath(path, basePath) { - const idx = basePath.lastIndexOf("/"); - const dir = idx === -1 ? "" : basePath.slice(0, idx); - const dirCount = dir.length ? dir.split("/").length : 0; - return "../".repeat(dirCount) + path; -} - -async function loadOlmWorker(paths) { - const workerPool = new WorkerPool(paths.worker, 4); - await workerPool.init(); - const path = relPath(paths.olm.legacyBundle, paths.worker); - await workerPool.sendAll({type: "load_olm", path}); - const olmWorker = new OlmWorker(workerPool); - return olmWorker; -} - // Don't use a default export here, as we use multiple entries during legacy build, // which does not support default exports, // see https://github.com/rollup/plugins/tree/master/packages/multi-entry -export async function main(container, paths, legacyExtras) { +export async function main(platform) { try { - // TODO: add .legacy to .hydrogen (container) in (legacy)platform.createAndMountRootView; and use .hydrogen:not(.legacy) if needed for modern stuff - const isIE11 = !!window.MSInputMethodContext && !!document.documentMode; - if (isIE11) { - document.body.className += " ie11"; - } else { - document.body.className += " not-ie11"; - } // to replay: // const fetchLog = await (await fetch("/fetchlogs/constrainterror.json")).json(); // const replay = new ReplayRequester(fetchLog, {delay: false}); @@ -101,61 +33,25 @@ export async function main(container, paths, legacyExtras) { // const recorder = new RecordRequester(createFetchRequest(clock.createTimeout)); // const request = recorder.request; // window.getBrawlFetchLog = () => recorder.log(); - const clock = new Clock(); - let request; - if (typeof fetch === "function") { - request = createFetchRequest(clock.createTimeout); - } else { - request = xhrRequest; - } const navigation = createNavigation(); - const sessionInfoStorage = new SessionInfoStorage("hydrogen_sessions_v1"); - 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 - // to run some olm operations in a worker (mainly for IE11) - let workerPromise; - if (!window.WebAssembly) { - workerPromise = loadOlmWorker(paths); - } - const urlRouter = createRouter({navigation, history: new History()}); + platform.setNavigation(navigation); + const urlRouter = createRouter({navigation, history: platform.history}); urlRouter.attach(); + const olmPromise = platform.loadOlm(); + const workerPromise = platform.loadOlmWorker(); const vm = new RootViewModel({ createSessionContainer: () => { - return new SessionContainer({ - random: Math.random, - onlineStatus: new OnlineStatus(), - storageFactory, - sessionInfoStorage, - request, - clock, - cryptoDriver: new CryptoDriver(legacyExtras?.crypto), - olmPromise, - workerPromise, - }); + return new SessionContainer({platform, olmPromise, workerPromise}); }, - sessionInfoStorage, - storageFactory, - clock, + platform, // 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, - estimateStorageUsage }); - window.__hydrogenViewModel = vm; await vm.load(); - // TODO: replace with platform.createAndMountRootView(vm, container); - const view = new RootView(vm); - container.appendChild(view.mount()); + platform.createAndMountRootView(vm); } catch(err) { console.error(`${err.message}:\n${err.stack}`); } diff --git a/src/matrix/Session.js b/src/matrix/Session.js index e0ee215e..15487ec5 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -41,8 +41,8 @@ const PICKLE_KEY = "DEFAULT_KEY"; export class Session { // sessionInfo contains deviceId, userId and homeServer - constructor({clock, storage, hsApi, sessionInfo, olm, olmWorker, cryptoDriver, mediaRepository}) { - this._clock = clock; + constructor({storage, hsApi, sessionInfo, olm, olmWorker, platform, mediaRepository}) { + this._platform = platform; this._storage = storage; this._hsApi = hsApi; this._mediaRepository = mediaRepository; @@ -61,7 +61,6 @@ export class Session { this._megolmDecryption = null; this._getSyncToken = () => this.syncToken; this._olmWorker = olmWorker; - this._cryptoDriver = cryptoDriver; this._sessionBackup = null; this._hasSecretStorageKey = new ObservableValue(null); @@ -106,7 +105,7 @@ export class Session { pickleKey: PICKLE_KEY, olm: this._olm, storage: this._storage, - now: this._clock.now, + now: this._platform.clock.now, ownUserId: this._user.id, senderKeyLock }); @@ -115,7 +114,7 @@ export class Session { pickleKey: PICKLE_KEY, olm: this._olm, storage: this._storage, - now: this._clock.now, + now: this._platform.clock.now, ownUserId: this._user.id, olmUtil: this._olmUtil, senderKeyLock @@ -125,7 +124,7 @@ export class Session { pickleKey: PICKLE_KEY, olm: this._olm, storage: this._storage, - now: this._clock.now, + now: this._platform.clock.now, ownDeviceId: this._sessionInfo.deviceId, }); this._megolmDecryption = new MegOlmDecryption({ @@ -166,7 +165,7 @@ export class Session { this.needsSessionBackup.set(true) } }, - clock: this._clock + clock: this._platform.clock }); } @@ -185,7 +184,7 @@ export class Session { if (this._sessionBackup) { return false; } - const key = await ssssKeyFromCredential(type, credential, this._storage, this._cryptoDriver, this._olm); + const key = await ssssKeyFromCredential(type, credential, this._storage, this._platform.crypto, this._olm); // and create session backup, which needs to read from accountData const readTxn = this._storage.readTxn([ this._storage.storeNames.accountData, @@ -207,7 +206,7 @@ export class Session { } async _createSessionBackup(ssssKey, txn) { - const secretStorage = new SecretStorage({key: ssssKey, cryptoDriver: this._cryptoDriver}); + const secretStorage = new SecretStorage({key: ssssKey, crypto: this._platform.crypto}); this._sessionBackup = await SessionBackup.fromSecretStorage({olm: this._olm, secretStorage, hsApi: this._hsApi, txn}); if (this._sessionBackup) { for (const room of this._rooms.values()) { @@ -363,7 +362,7 @@ export class Session { pendingEvents, user: this._user, createRoomEncryption: this._createRoomEncryption, - clock: this._clock + clock: this._platform.clock }); this._rooms.add(roomId, room); return room; diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index d59d6c49..5db92d44 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -44,13 +44,8 @@ export const LoginFailure = createEnum( ); export class SessionContainer { - constructor({clock, random, onlineStatus, request, storageFactory, sessionInfoStorage, olmPromise, workerPromise, cryptoDriver}) { - this._random = random; - this._clock = clock; - this._onlineStatus = onlineStatus; - this._request = request; - this._storageFactory = storageFactory; - this._sessionInfoStorage = sessionInfoStorage; + constructor({platform, olmPromise, workerPromise}) { + this._platform = platform; this._sessionStartedByReconnector = false; this._status = new ObservableValue(LoadStatus.NotLoading); this._error = null; @@ -63,11 +58,10 @@ export class SessionContainer { this._requestScheduler = null; this._olmPromise = olmPromise; this._workerPromise = workerPromise; - this._cryptoDriver = cryptoDriver; } createNewSessionId() { - return (Math.floor(this._random() * Number.MAX_SAFE_INTEGER)).toString(); + return (Math.floor(this._platform.random() * Number.MAX_SAFE_INTEGER)).toString(); } get sessionId() { @@ -80,7 +74,7 @@ export class SessionContainer { } this._status.set(LoadStatus.Loading); try { - const sessionInfo = await this._sessionInfoStorage.get(sessionId); + const sessionInfo = await this._platform.sessionInfoStorage.get(sessionId); if (!sessionInfo) { throw new Error("Invalid session id: " + sessionId); } @@ -96,9 +90,11 @@ export class SessionContainer { return; } this._status.set(LoadStatus.Login); + const clock = this._platform.clock; let sessionInfo; try { - const hsApi = new HomeServerApi({homeServer, request: this._request, createTimeout: this._clock.createTimeout}); + const request = this._platform.request; + const hsApi = new HomeServerApi({homeServer, request, createTimeout: clock.createTimeout}); const loginData = await hsApi.passwordLogin(username, password, "Hydrogen").response(); const sessionId = this.createNewSessionId(); sessionInfo = { @@ -107,7 +103,7 @@ export class SessionContainer { userId: loginData.user_id, homeServer: homeServer, accessToken: loginData.access_token, - lastUsed: this._clock.now() + lastUsed: clock.now() }; await this._sessionInfoStorage.add(sessionInfo); } catch (err) { @@ -139,22 +135,23 @@ export class SessionContainer { } async _loadSessionInfo(sessionInfo, isNewLogin) { + const clock = this._platform.clock; this._sessionStartedByReconnector = false; this._status.set(LoadStatus.Loading); this._reconnector = new Reconnector({ - onlineStatus: this._onlineStatus, - retryDelay: new ExponentialRetryDelay(this._clock.createTimeout), - createMeasure: this._clock.createMeasure + onlineStatus: this._platform.onlineStatus, + retryDelay: new ExponentialRetryDelay(clock.createTimeout), + createMeasure: clock.createMeasure }); const hsApi = new HomeServerApi({ homeServer: sessionInfo.homeServer, accessToken: sessionInfo.accessToken, - request: this._request, + request: this._platform.request, reconnector: this._reconnector, - createTimeout: this._clock.createTimeout + createTimeout: clock.createTimeout }); this._sessionId = sessionInfo.id; - this._storage = await this._storageFactory.create(sessionInfo.id); + this._storage = await this._platform.storageFactory.create(sessionInfo.id); // no need to pass access token to session const filteredSessionInfo = { deviceId: sessionInfo.deviceId, @@ -166,16 +163,15 @@ export class SessionContainer { if (this._workerPromise) { olmWorker = await this._workerPromise; } - this._requestScheduler = new RequestScheduler({hsApi, clock: this._clock}); + this._requestScheduler = new RequestScheduler({hsApi, clock}); this._requestScheduler.start(); this._session = new Session({ storage: this._storage, sessionInfo: filteredSessionInfo, hsApi: this._requestScheduler.hsApi, olm, - clock: this._clock, olmWorker, - cryptoDriver: this._cryptoDriver, + platform: this._platform, mediaRepository: new MediaRepository(sessionInfo.homeServer) }); await this._session.load(); @@ -293,8 +289,8 @@ export class SessionContainer { // if one fails, don't block the other from trying // also, run in parallel await Promise.all([ - this._storageFactory.delete(this._sessionId), - this._sessionInfoStorage.delete(this._sessionId), + this._platform.storageFactory.delete(this._sessionId), + this._platform.sessionInfoStorage.delete(this._sessionId), ]); this._sessionId = null; } diff --git a/src/matrix/net/common.js b/src/matrix/net/common.js index 6a772463..c7a06351 100644 --- a/src/matrix/net/common.js +++ b/src/matrix/net/common.js @@ -26,25 +26,3 @@ export function encodeQueryParams(queryParams) { }) .join("&"); } - -export function addCacheBuster(urlStr, random = Math.random) { - // XHR doesn't have a good way to disable cache, - // so add a random query param - // see https://davidtranscend.com/blog/prevent-ie11-cache-ajax-requests/ - if (urlStr.includes("?")) { - urlStr = urlStr + "&"; - } else { - urlStr = urlStr + "?"; - } - return urlStr + `_cacheBuster=${Math.ceil(random() * Number.MAX_SAFE_INTEGER)}`; -} - -export function tests() { - return { - "add cache buster": assert => { - const random = () => 0.5; - assert.equal(addCacheBuster("http://foo", random), "http://foo?_cacheBuster=4503599627370496"); - assert.equal(addCacheBuster("http://foo?bar=baz", random), "http://foo?bar=baz&_cacheBuster=4503599627370496"); - } - } -} diff --git a/src/matrix/ssss/SecretStorage.js b/src/matrix/ssss/SecretStorage.js index a94c2e19..144aa267 100644 --- a/src/matrix/ssss/SecretStorage.js +++ b/src/matrix/ssss/SecretStorage.js @@ -17,9 +17,9 @@ limitations under the License. import base64 from "../../../lib/base64-arraybuffer/index.js"; export class SecretStorage { - constructor({key, cryptoDriver}) { + constructor({key, crypto}) { this._key = key; - this._cryptoDriver = cryptoDriver; + this._crypto = crypto; } async readSecret(name, txn) { @@ -44,7 +44,7 @@ export class SecretStorage { const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); // now derive the aes and mac key from the 4s key - const hkdfKey = await this._cryptoDriver.derive.hkdf( + const hkdfKey = await this._crypto.derive.hkdf( this._key.binaryKey, new Uint8Array(8).buffer, //zero salt textEncoder.encode(type), // info @@ -56,7 +56,7 @@ export class SecretStorage { const ciphertextBytes = base64.decode(encryptedData.ciphertext); - const isVerified = await this._cryptoDriver.hmac.verify( + const isVerified = await this._crypto.hmac.verify( hmacKey, base64.decode(encryptedData.mac), ciphertextBytes, "SHA-256"); @@ -64,7 +64,7 @@ export class SecretStorage { throw new Error("Bad MAC"); } - const plaintextBytes = await this._cryptoDriver.aes.decrypt( + const plaintextBytes = await this._crypto.aes.decrypt( aesKey, base64.decode(encryptedData.iv), ciphertextBytes); return textDecoder.decode(plaintextBytes); diff --git a/src/matrix/ssss/index.js b/src/matrix/ssss/index.js index 25c85b8e..222c4b46 100644 --- a/src/matrix/ssss/index.js +++ b/src/matrix/ssss/index.js @@ -47,14 +47,14 @@ export async function readKey(txn) { return new Key(new KeyDescription(keyData.id, keyAccountData), keyData.binaryKey); } -export async function keyFromCredential(type, credential, storage, cryptoDriver, olm) { +export async function keyFromCredential(type, credential, storage, crypto, olm) { const keyDescription = await readDefaultKeyDescription(storage); if (!keyDescription) { throw new Error("Could not find a default secret storage key in account data"); } let key; if (type === "phrase") { - key = await keyFromPassphrase(keyDescription, credential, cryptoDriver); + key = await keyFromPassphrase(keyDescription, credential, crypto); } else if (type === "key") { key = keyFromRecoveryKey(olm, keyDescription, credential); } else { diff --git a/src/matrix/ssss/passphrase.js b/src/matrix/ssss/passphrase.js index 1e3935a4..14f8dc0e 100644 --- a/src/matrix/ssss/passphrase.js +++ b/src/matrix/ssss/passphrase.js @@ -22,10 +22,10 @@ const DEFAULT_BITSIZE = 256; /** * @param {KeyDescription} keyDescription * @param {string} passphrase - * @param {CryptoDriver} cryptoDriver + * @param {Crypto} crypto * @return {Key} */ -export async function keyFromPassphrase(keyDescription, passphrase, cryptoDriver) { +export async function keyFromPassphrase(keyDescription, passphrase, crypto) { const {passphraseParams} = keyDescription; if (!passphraseParams) { throw new Error("not a passphrase key"); @@ -35,7 +35,7 @@ export async function keyFromPassphrase(keyDescription, passphrase, cryptoDriver } // TODO: we should we move this to platform specific code const textEncoder = new TextEncoder(); - const keyBits = await cryptoDriver.derive.pbkdf2( + const keyBits = await crypto.derive.pbkdf2( textEncoder.encode(passphrase), passphraseParams.iterations || DEFAULT_ITERATIONS, // salt is just a random string, not encoded in any way diff --git a/src/platform/web/LegacyPlatform.js b/src/platform/web/LegacyPlatform.js new file mode 100644 index 00000000..4ecea1d3 --- /dev/null +++ b/src/platform/web/LegacyPlatform.js @@ -0,0 +1,7 @@ +import aesjs from "../../../lib/aes-js/index.js"; +import {hkdf} from "../../utils/crypto/hkdf.js"; +import {Platform as ModernPlatform} from "./Platform.js"; + +export function Platform(container, paths) { + return new ModernPlatform(container, paths, {aesjs, hkdf}); +} diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js new file mode 100644 index 00000000..1ecae5be --- /dev/null +++ b/src/platform/web/Platform.js @@ -0,0 +1,130 @@ +/* +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. +*/ + +import {createFetchRequest} from "./dom/request/fetch.js"; +import {xhrRequest} from "./dom/request/xhr.js"; +import {StorageFactory} from "../../matrix/storage/idb/StorageFactory.js"; +import {SessionInfoStorage} from "../../matrix/sessioninfo/localstorage/SessionInfoStorage.js"; +import {OlmWorker} from "../../matrix/e2ee/OlmWorker.js"; +import {RootView} from "./ui/RootView.js"; +import {Clock} from "./dom/Clock.js"; +import {ServiceWorkerHandler} from "./dom/ServiceWorkerHandler.js"; +import {History} from "./dom/History.js"; +import {OnlineStatus} from "./dom/OnlineStatus.js"; +import {Crypto} from "./dom/Crypto.js"; +import {estimateStorageUsage} from "./dom/StorageEstimate.js"; +import {WorkerPool} from "./dom/WorkerPool.js"; + +function addScript(src) { + return new Promise(function (resolve, reject) { + var s = document.createElement("script"); + s.setAttribute("src", src ); + s.onload=resolve; + s.onerror=reject; + document.body.appendChild(s); + }); +} + +async function loadOlm(olmPaths) { + // make crypto.getRandomValues available without + // a prefix on IE11, needed by olm to work + if (window.msCrypto && !window.crypto) { + window.crypto = window.msCrypto; + } + if (olmPaths) { + if (window.WebAssembly) { + await addScript(olmPaths.wasmBundle); + await window.Olm.init({locateFile: () => olmPaths.wasm}); + } else { + await addScript(olmPaths.legacyBundle); + await window.Olm.init(); + } + return window.Olm; + } + return null; +} + +// make path relative to basePath, +// assuming it and basePath are relative to document +function relPath(path, basePath) { + const idx = basePath.lastIndexOf("/"); + const dir = idx === -1 ? "" : basePath.slice(0, idx); + const dirCount = dir.length ? dir.split("/").length : 0; + return "../".repeat(dirCount) + path; +} + +async function loadOlmWorker(paths) { + const workerPool = new WorkerPool(paths.worker, 4); + await workerPool.init(); + const path = relPath(paths.olm.legacyBundle, paths.worker); + await workerPool.sendAll({type: "load_olm", path}); + const olmWorker = new OlmWorker(workerPool); + return olmWorker; +} + + +export class Platform { + constructor(container, paths, cryptoExtras = null) { + this._paths = paths; + this._container = container; + this.clock = new Clock(); + this.history = new History(); + this.onlineStatus = new OnlineStatus(); + this._serviceWorkerHandler = null; + if (paths.serviceWorker && "serviceWorker" in navigator) { + this._serviceWorkerHandler = new ServiceWorkerHandler(); + this._serviceWorkerHandler.registerAndStart(paths.serviceWorker); + } + this.crypto = new Crypto(cryptoExtras); + this.storageFactory = new StorageFactory(this._serviceWorkerHandler); + this.sessionInfoStorage = new SessionInfoStorage("hydrogen_sessions_v1"); + this.estimateStorageUsage = estimateStorageUsage; + this.random = Math.random; + if (typeof fetch === "function") { + this.request = createFetchRequest(this.clock.createTimeout); + } else { + this.request = xhrRequest; + } + } + + get updateService() { + return this._serviceWorkerHandler; + } + + loadOlm() { + return loadOlm(this._paths.olm); + } + + async loadOlmWorker() { + if (!window.WebAssembly) { + return await loadOlmWorker(this._paths); + } + } + + createAndMountRootView(vm) { + const isIE11 = !!window.MSInputMethodContext && !!document.documentMode; + if (isIE11) { + this._container.className += " legacy"; + } + window.__hydrogenViewModel = vm; + const view = new RootView(vm); + this._container.appendChild(view.mount()); + } + + setNavigation(navigation) { + this._serviceWorkerHandler?.setNavigation(navigation); + } +} diff --git a/src/platform/web/dom/CryptoDriver.js b/src/platform/web/dom/Crypto.js similarity index 91% rename from src/platform/web/dom/CryptoDriver.js rename to src/platform/web/dom/Crypto.js index 66bb35c0..e28d104b 100644 --- a/src/platform/web/dom/CryptoDriver.js +++ b/src/platform/web/dom/Crypto.js @@ -26,7 +26,7 @@ function subtleCryptoResult(promiseOrOp, method) { } } -class CryptoHMACDriver { +class HMACCrypto { constructor(subtleCrypto) { this._subtleCrypto = subtleCrypto; } @@ -80,10 +80,10 @@ class CryptoHMACDriver { } } -class CryptoDeriveDriver { - constructor(subtleCrypto, cryptoDriver, cryptoExtras) { +class DeriveCrypto { + constructor(subtleCrypto, crypto, cryptoExtras) { this._subtleCrypto = subtleCrypto; - this._cryptoDriver = cryptoDriver; + this._crypto = crypto; this._cryptoExtras = cryptoExtras; } /** @@ -130,7 +130,7 @@ class CryptoDeriveDriver { */ async hkdf(key, salt, info, hash, length) { if (!this._subtleCrypto.deriveBits) { - return this._cryptoExtras.hkdf(this._cryptoDriver, key, salt, info, hash, length); + return this._cryptoExtras.hkdf(this._crypto, key, salt, info, hash, length); } const hkdfkey = await subtleCryptoResult(this._subtleCrypto.importKey( 'raw', @@ -152,7 +152,7 @@ class CryptoDeriveDriver { } } -class CryptoAESDriver { +class AESCrypto { constructor(subtleCrypto) { this._subtleCrypto = subtleCrypto; } @@ -196,7 +196,7 @@ class CryptoAESDriver { } -class CryptoLegacyAESDriver { +class AESLegacyCrypto { constructor(aesjs) { this._aesjs = aesjs; } @@ -221,20 +221,20 @@ function hashName(name) { return name; } -export class CryptoDriver { +export class Crypto { constructor(cryptoExtras) { const crypto = window.crypto || window.msCrypto; const subtleCrypto = crypto.subtle || crypto.webkitSubtle; this._subtleCrypto = subtleCrypto; // not exactly guaranteeing AES-CTR support // but in practice IE11 doesn't have this - if (!subtleCrypto.deriveBits && cryptoExtras.aesjs) { - this.aes = new CryptoLegacyAESDriver(cryptoExtras.aesjs); + if (!subtleCrypto.deriveBits && cryptoExtras?.aesjs) { + this.aes = new AESLegacyCrypto(cryptoExtras.aesjs); } else { - this.aes = new CryptoAESDriver(subtleCrypto); + this.aes = new AESCrypto(subtleCrypto); } - this.hmac = new CryptoHMACDriver(subtleCrypto); - this.derive = new CryptoDeriveDriver(subtleCrypto, this, cryptoExtras); + this.hmac = new HMACCrypto(subtleCrypto); + this.derive = new DeriveCrypto(subtleCrypto, this, cryptoExtras); } /** diff --git a/src/platform/web/dom/ServiceWorkerHandler.js b/src/platform/web/dom/ServiceWorkerHandler.js index c3c6d4b0..b05505ea 100644 --- a/src/platform/web/dom/ServiceWorkerHandler.js +++ b/src/platform/web/dom/ServiceWorkerHandler.js @@ -19,15 +19,19 @@ limitations under the License. // - UpdateService (see checkForUpdate method, and should also emit events rather than showing confirm dialog here) // - ConcurrentAccessBlocker (see preventConcurrentSessionAccess method) export class ServiceWorkerHandler { - constructor({navigation}) { + constructor() { this._waitingForReply = new Map(); this._messageIdCounter = 0; + this._navigation = null; this._registration = null; - this._navigation = navigation; this._registrationPromise = null; this._currentController = null; } + setNavigation(navigation) { + this._navigation = navigation; + } + registerAndStart(path) { this._registrationPromise = (async () => { navigator.serviceWorker.addEventListener("message", this); @@ -61,7 +65,7 @@ export class ServiceWorkerHandler { } _closeSessionIfNeeded(sessionId) { - const currentSession = this._navigation.path.get("session"); + const currentSession = this._navigation?.path.get("session"); if (sessionId && currentSession?.value === sessionId) { return new Promise(resolve => { const unsubscribe = this._navigation.pathObservable.subscribe(path => { diff --git a/src/utils/WorkerPool.js b/src/platform/web/dom/WorkerPool.js similarity index 99% rename from src/utils/WorkerPool.js rename to src/platform/web/dom/WorkerPool.js index 2f0e89b8..aeb6ca89 100644 --- a/src/utils/WorkerPool.js +++ b/src/platform/web/dom/WorkerPool.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {AbortError} from "./error.js"; +import {AbortError} from "../../../utils/error.js"; class WorkerState { constructor(worker) { diff --git a/src/platform/web/dom/request/common.js b/src/platform/web/dom/request/common.js new file mode 100644 index 00000000..d97456aa --- /dev/null +++ b/src/platform/web/dom/request/common.js @@ -0,0 +1,38 @@ +/* +Copyright 2020 Bruno Windels +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. +*/ + +export function addCacheBuster(urlStr, random = Math.random) { + // XHR doesn't have a good way to disable cache, + // so add a random query param + // see https://davidtranscend.com/blog/prevent-ie11-cache-ajax-requests/ + if (urlStr.includes("?")) { + urlStr = urlStr + "&"; + } else { + urlStr = urlStr + "?"; + } + return urlStr + `_cacheBuster=${Math.ceil(random() * Number.MAX_SAFE_INTEGER)}`; +} + +export function tests() { + return { + "add cache buster": assert => { + const random = () => 0.5; + assert.equal(addCacheBuster("http://foo", random), "http://foo?_cacheBuster=4503599627370496"); + assert.equal(addCacheBuster("http://foo?bar=baz", random), "http://foo?bar=baz&_cacheBuster=4503599627370496"); + } + } +} diff --git a/src/matrix/net/request/fetch.js b/src/platform/web/dom/request/fetch.js similarity index 97% rename from src/matrix/net/request/fetch.js rename to src/platform/web/dom/request/fetch.js index eaa891ed..33d43ad0 100644 --- a/src/matrix/net/request/fetch.js +++ b/src/platform/web/dom/request/fetch.js @@ -18,9 +18,9 @@ limitations under the License. import { AbortError, ConnectionError -} from "../../error.js"; -import {abortOnTimeout} from "../timeout.js"; -import {addCacheBuster} from "../common.js"; +} from "../../../../matrix/error.js"; +import {abortOnTimeout} from "./timeout.js"; +import {addCacheBuster} from "./common.js"; class RequestResult { constructor(promise, controller) { diff --git a/src/matrix/net/timeout.js b/src/platform/web/dom/request/timeout.js similarity index 97% rename from src/matrix/net/timeout.js rename to src/platform/web/dom/request/timeout.js index abb7fd9e..73227cfb 100644 --- a/src/matrix/net/timeout.js +++ b/src/platform/web/dom/request/timeout.js @@ -18,7 +18,7 @@ limitations under the License. import { AbortError, ConnectionError -} from "../error.js"; +} from "../../../../matrix/error.js"; export function abortOnTimeout(createTimeout, timeoutAmount, requestResult, responsePromise) { diff --git a/src/matrix/net/request/xhr.js b/src/platform/web/dom/request/xhr.js similarity index 96% rename from src/matrix/net/request/xhr.js rename to src/platform/web/dom/request/xhr.js index ad4a33fc..38a189fd 100644 --- a/src/matrix/net/request/xhr.js +++ b/src/platform/web/dom/request/xhr.js @@ -17,8 +17,8 @@ limitations under the License. import { AbortError, ConnectionError -} from "../../error.js"; -import {addCacheBuster} from "../common.js"; +} from "../../../../matrix/error.js"; +import {addCacheBuster} from "./common.js"; class RequestResult { constructor(promise, xhr) { diff --git a/src/legacy-polyfill.js b/src/platform/web/legacy-polyfill.js similarity index 91% rename from src/legacy-polyfill.js rename to src/platform/web/legacy-polyfill.js index c3c8263b..ea2461f7 100644 --- a/src/legacy-polyfill.js +++ b/src/platform/web/legacy-polyfill.js @@ -15,8 +15,8 @@ limitations under the License. */ // polyfills needed for IE11 -import Promise from "../lib/es6-promise/index.js"; -import {checkNeedsSyncPromise} from "./matrix/storage/idb/utils.js"; +import Promise from "../../../lib/es6-promise/index.js"; +import {checkNeedsSyncPromise} from "../../matrix/storage/idb/utils.js"; if (typeof window.Promise === "undefined") { window.Promise = Promise; diff --git a/src/service-worker.template.js b/src/platform/web/service-worker.template.js similarity index 100% rename from src/service-worker.template.js rename to src/platform/web/service-worker.template.js diff --git a/src/platform/web/ui/css/spinner.css b/src/platform/web/ui/css/spinner.css index 1548b9b6..b649cf0f 100644 --- a/src/platform/web/ui/css/spinner.css +++ b/src/platform/web/ui/css/spinner.css @@ -32,7 +32,7 @@ limitations under the License. } } -.not-ie11 .spinner circle { +.hydrogen:not(.legacy) .spinner circle { transform-origin: 50% 50%; animation-name: spinner; animation-duration: 2s; @@ -45,35 +45,35 @@ limitations under the License. stroke-linecap: butt; } -.ie11 .spinner { +.hydrogen.legacy .spinner { display: inline-block; position: relative; } -.ie11 .spinner div { +.hydrogen.legacy .spinner div { box-sizing: border-box; display: block; position: absolute; padding: 2px; border: 2px solid currentcolor; border-radius: 50%; - animation: ie-spinner 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; + animation: legacy-spinner 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; border-color: currentcolor transparent transparent transparent; width: var(--size); height: var(--size); } -.ie11 .spinner div:nth-child(1) { +.hydrogen.legacy .spinner div:nth-child(1) { animation-delay: -0.45s; } -.ie11 .spinner div:nth-child(2) { +.hydrogen.legacy .spinner div:nth-child(2) { animation-delay: -0.3s; } -.ie11 .spinner div:nth-child(3) { +.hydrogen.legacy .spinner div:nth-child(3) { animation-delay: -0.15s; } -@keyframes ie-spinner { +@keyframes legacy-spinner { 0% { transform: rotate(0deg); } diff --git a/src/worker.js b/src/platform/web/worker/main.js similarity index 100% rename from src/worker.js rename to src/platform/web/worker/main.js diff --git a/src/worker-polyfill.js b/src/platform/web/worker/polyfill.js similarity index 97% rename from src/worker-polyfill.js rename to src/platform/web/worker/polyfill.js index 28541188..d7f4b0d2 100644 --- a/src/worker-polyfill.js +++ b/src/platform/web/worker/polyfill.js @@ -19,7 +19,7 @@ limitations under the License. // just enough to run olm, have promises and async/await // load this first just in case anything else depends on it -import Promise from "../lib/es6-promise/index.js"; +import Promise from "../../../../lib/es6-promise/index.js"; // not calling checkNeedsSyncPromise from here as we don't do any idb in the worker, // mainly because IE doesn't handle multiple concurrent connections well self.Promise = Promise;