diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cd70e709..b88da3c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,30 @@ +Changes in [9.8.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.8.0) (2021-03-01) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.8.0-rc.1...v9.8.0) + + * No changes since rc.1 + +Changes in [9.8.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.8.0-rc.1) (2021-02-24) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.7.0...v9.8.0-rc.1) + + * Optimise prefixed logger + [\#1615](https://github.com/matrix-org/matrix-js-sdk/pull/1615) + * Add debug logs to encryption prep, take 3 + [\#1614](https://github.com/matrix-org/matrix-js-sdk/pull/1614) + * Add functions for upper & lowercase random strings + [\#1612](https://github.com/matrix-org/matrix-js-sdk/pull/1612) + * Room helpers for invite permissions and join rules + [\#1609](https://github.com/matrix-org/matrix-js-sdk/pull/1609) + * Fixed wording in "Adding video track with id" log + [\#1606](https://github.com/matrix-org/matrix-js-sdk/pull/1606) + * Add more debug logs to encryption prep + [\#1605](https://github.com/matrix-org/matrix-js-sdk/pull/1605) + * Add option to set ice candidate pool size + [\#1604](https://github.com/matrix-org/matrix-js-sdk/pull/1604) + * Cancel call if no source was selected + [\#1601](https://github.com/matrix-org/matrix-js-sdk/pull/1601) + Changes in [9.7.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.7.0) (2021-02-16) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.7.0-rc.1...v9.7.0) diff --git a/package.json b/package.json index a963428b7..f9fafb132 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "9.7.0", + "version": "9.8.0", "description": "Matrix Client-Server SDK for Javascript", "scripts": { "prepublishOnly": "yarn build", @@ -15,7 +15,7 @@ "build:minify-browser": "terser dist/browser-matrix.js --compress --mangle --source-map --output dist/browser-matrix.min.js", "gendoc": "jsdoc -c jsdoc.json -P package.json", "lint": "yarn lint:types && yarn lint:js", - "lint:js": "eslint --max-warnings 73 src spec", + "lint:js": "eslint --max-warnings 72 src spec", "lint:types": "tsc --noEmit", "test": "jest spec/ --coverage --testEnvironment node", "test:watch": "jest spec/ --coverage --testEnvironment node --watch" diff --git a/spec/unit/crypto/algorithms/olm.spec.js b/spec/unit/crypto/algorithms/olm.spec.js index 0efe74e77..c548355b5 100644 --- a/spec/unit/crypto/algorithms/olm.spec.js +++ b/spec/unit/crypto/algorithms/olm.spec.js @@ -190,5 +190,91 @@ describe("OlmDevice", function() { // new session and will have called claimOneTimeKeys expect(count).toBe(2); }); + + it("avoids deadlocks when two tasks are ensuring the same devices", async function() { + // This test checks whether `ensureOlmSessionsForDevices` properly + // handles multiple tasks in flight ensuring some set of devices in + // common without deadlocks. + + let claimRequestCount = 0; + const baseApis = { + claimOneTimeKeys: () => { + // simulate a very slow server (.5 seconds to respond) + claimRequestCount++; + return new Promise((resolve, reject) => { + setTimeout(reject, 500); + }); + }, + }; + + const deviceBobA = DeviceInfo.fromStorage({ + keys: { + "curve25519:BOB-A": "akey", + }, + }, "BOB-A"); + const deviceBobB = DeviceInfo.fromStorage({ + keys: { + "curve25519:BOB-B": "bkey", + }, + }, "BOB-B"); + + // There's no required ordering of devices per user, so here we + // create two different orderings so that each task reserves a + // device the other task needs before continuing. + const devicesByUserAB = { + "@bob:example.com": [ + deviceBobA, + deviceBobB, + ], + }; + const devicesByUserBA = { + "@bob:example.com": [ + deviceBobB, + deviceBobA, + ], + }; + + function alwaysSucceed(promise) { + // swallow any exception thrown by a promise, so that + // Promise.all doesn't abort + return promise.catch(() => {}); + } + + const task1 = alwaysSucceed(olmlib.ensureOlmSessionsForDevices( + aliceOlmDevice, baseApis, devicesByUserAB, + )); + + // After a single tick through the first task, it should have + // claimed ownership of all devices to avoid deadlocking others. + expect(Object.keys(aliceOlmDevice._sessionsInProgress).length).toBe(2); + + const task2 = alwaysSucceed(olmlib.ensureOlmSessionsForDevices( + aliceOlmDevice, baseApis, devicesByUserBA, + )); + + // The second task should not have changed the ownership count, as + // it's waiting on the first task. + expect(Object.keys(aliceOlmDevice._sessionsInProgress).length).toBe(2); + + // Track the tasks, but don't await them yet. + const promises = Promise.all([ + task1, + task2, + ]); + + await new Promise((resolve) => { + setTimeout(resolve, 200); + }); + + // After .2s, the first task should have made an initial claim request. + expect(claimRequestCount).toBe(1); + + await promises; + + // After waiting for both tasks to complete, the first task should + // have failed, so the second task should have tried to create a + // new session and will have called claimOneTimeKeys + expect(claimRequestCount).toBe(2); + }); }); }); diff --git a/spec/unit/webrtc/call.spec.ts b/spec/unit/webrtc/call.spec.ts index 06da02502..7336a7371 100644 --- a/spec/unit/webrtc/call.spec.ts +++ b/spec/unit/webrtc/call.spec.ts @@ -190,4 +190,62 @@ describe('Call', function() { // Hangup to stop timers call.hangup(CallErrorCode.UserHangup, true); }); + + it('should add candidates received before answer if party ID is correct', async function() { + await call.placeVoiceCall(); + call.peerConn.addIceCandidate = jest.fn(); + + call.onRemoteIceCandidatesReceived({ + getContent: () => { + return { + version: 1, + call_id: call.callId, + party_id: 'the_correct_party_id', + candidates: [ + { + candidate: 'the_correct_candidate', + sdpMid: '', + }, + ], + }; + }, + }); + + call.onRemoteIceCandidatesReceived({ + getContent: () => { + return { + version: 1, + call_id: call.callId, + party_id: 'some_other_party_id', + candidates: [ + { + candidate: 'the_wrong_candidate', + sdpMid: '', + }, + ], + }; + }, + }); + + expect(call.peerConn.addIceCandidate.mock.calls.length).toBe(0); + + await call.onAnswerReceived({ + getContent: () => { + return { + version: 1, + call_id: call.callId, + party_id: 'the_correct_party_id', + answer: { + sdp: DUMMY_SDP, + }, + }; + }, + }); + + expect(call.peerConn.addIceCandidate.mock.calls.length).toBe(1); + expect(call.peerConn.addIceCandidate).toHaveBeenCalledWith({ + candidate: 'the_correct_candidate', + sdpMid: '', + }); + }); }); diff --git a/src/@types/event.ts b/src/@types/event.ts index d54920a62..adffcd560 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -36,6 +36,10 @@ export enum EventType { */ RoomAliases = "m.room.aliases", // deprecated https://matrix.org/docs/spec/client_server/r0.6.1#historical-events + // Spaces MSC1772 + SpaceChild = "org.matrix.msc1772.space.child", + SpaceParent = "org.matrix.msc1772.space.parent", + // Room timeline events RoomRedaction = "m.room.redaction", RoomMessage = "m.room.message", @@ -87,3 +91,9 @@ export enum MsgType { Location = "m.location", Video = "m.video", } + +export const RoomCreateTypeField = "org.matrix.msc1772.type"; // Spaces MSC1772 + +export enum RoomType { + Space = "org.matrix.msc1772.space", // Spaces MSC1772 +} diff --git a/src/base-apis.js b/src/base-apis.js index 9e5b078c7..187a2cc71 100644 --- a/src/base-apis.js +++ b/src/base-apis.js @@ -2389,3 +2389,26 @@ MatrixBaseApis.prototype.reportEvent = function(roomId, eventId, score, reason) return this._http.authedRequest(undefined, "POST", path, null, {score, reason}); }; +/** + * Fetches or paginates a summary of a space as defined by MSC2946 + * @param {string} roomId The ID of the space-room to use as the root of the summary. + * @param {number?} maxRoomsPerSpace The maximum number of rooms to return per subspace. + * @param {boolean?} autoJoinOnly Whether to only return rooms with auto_join=true. + * @param {number?} limit The maximum number of rooms to return in total. + * @param {string?} batch The opaque token to paginate a previous summary request. + * @returns {Promise} the response, with next_batch, rooms, events fields. + */ +MatrixBaseApis.prototype.getSpaceSummary = function(roomId, maxRoomsPerSpace, autoJoinOnly, limit, batch) { + const path = utils.encodeUri("/rooms/$roomId/spaces", { + $roomId: roomId, + }); + + return this._http.authedRequest(undefined, "POST", path, null, { + max_rooms_per_space: maxRoomsPerSpace, + auto_join_only: autoJoinOnly, + limit, + batch, + }, { + prefix: "/_matrix/client/unstable/org.matrix.msc2946", + }); +}; diff --git a/src/client.js b/src/client.js index 03aba0c15..fe2081fcb 100644 --- a/src/client.js +++ b/src/client.js @@ -393,6 +393,9 @@ export function MatrixClient(opts) { this._clientWellKnown = undefined; this._clientWellKnownPromise = undefined; + this._turnServers = []; + this._turnServersExpiry = null; + // The SDK doesn't really provide a clean way for events to recalculate the push // actions for themselves, so we have to kinda help them out when they are encrypted. // We do this so that push rules are correctly executed on events in their decrypted @@ -4963,6 +4966,15 @@ MatrixClient.prototype.getTurnServers = function() { return this._turnServers || []; }; +/** + * Get the unix timestamp (in seconds) at which the current + * TURN credentials (from getTurnServers) expire + * @return {number} The expiry timestamp, in seconds, or null if no credentials + */ +MatrixClient.prototype.getTurnServersExpiry = function() { + return this._turnServersExpiry; +}; + /** * Set whether to allow a fallback ICE server should be used for negotiating a * WebRTC connection if the homeserver doesn't provide any servers. Defaults to @@ -5434,6 +5446,9 @@ async function(roomId, eventId, relationType, eventType, opts = {}) { })); events = events.filter(e => e.getType() === eventType); } + if (originalEvent && relationType === "m.replace") { + events = events.filter(e => e.getSender() === originalEvent.getSender()); + } return { originalEvent, events, @@ -5458,6 +5473,7 @@ function checkTurnServers(client) { credential: res.password, }; client._turnServers = [servers]; + client._turnServersExpiry = Date.now() + res.ttl; // re-fetch when we're about to reach the TTL client._checkTurnServersTimeoutID = setTimeout(() => { checkTurnServers(client); diff --git a/src/crypto/OlmDevice.js b/src/crypto/OlmDevice.js index 24565507d..0989d19f3 100644 --- a/src/crypto/OlmDevice.js +++ b/src/crypto/OlmDevice.js @@ -545,6 +545,7 @@ OlmDevice.prototype.createOutboundSession = async function( } }); }, + logger.withPrefix("[createOutboundSession]"), ); return newSessionId; }; @@ -605,6 +606,7 @@ OlmDevice.prototype.createInboundSession = async function( } }); }, + logger.withPrefix("[createInboundSession]"), ); return result; @@ -619,8 +621,10 @@ OlmDevice.prototype.createInboundSession = async function( * @return {Promise} a list of known session ids for the device */ OlmDevice.prototype.getSessionIdsForDevice = async function(theirDeviceIdentityKey) { + const log = logger.withPrefix("[getSessionIdsForDevice]"); + if (this._sessionsInProgress[theirDeviceIdentityKey]) { - logger.log("waiting for olm session to be created"); + log.debug(`Waiting for Olm session for ${theirDeviceIdentityKey} to be created`); try { await this._sessionsInProgress[theirDeviceIdentityKey]; } catch (e) { @@ -638,6 +642,7 @@ OlmDevice.prototype.getSessionIdsForDevice = async function(theirDeviceIdentityK }, ); }, + log, ); return sessionIds; @@ -651,13 +656,14 @@ OlmDevice.prototype.getSessionIdsForDevice = async function(theirDeviceIdentityK * @param {boolean} nowait Don't wait for an in-progress session to complete. * This should only be set to true of the calling function is the function * that marked the session as being in-progress. + * @param {Logger} [log] A possibly customised log * @return {Promise} session id, or null if no established session */ OlmDevice.prototype.getSessionIdForDevice = async function( - theirDeviceIdentityKey, nowait, + theirDeviceIdentityKey, nowait, log, ) { const sessionInfos = await this.getSessionInfoForDevice( - theirDeviceIdentityKey, nowait, + theirDeviceIdentityKey, nowait, log, ); if (sessionInfos.length === 0) { @@ -697,11 +703,16 @@ OlmDevice.prototype.getSessionIdForDevice = async function( * @param {boolean} nowait Don't wait for an in-progress session to complete. * This should only be set to true of the calling function is the function * that marked the session as being in-progress. + * @param {Logger} [log] A possibly customised log * @return {Array.<{sessionId: string, hasReceivedMessage: Boolean}>} */ -OlmDevice.prototype.getSessionInfoForDevice = async function(deviceIdentityKey, nowait) { +OlmDevice.prototype.getSessionInfoForDevice = async function( + deviceIdentityKey, nowait, log = logger, +) { + log = log.withPrefix("[getSessionInfoForDevice]"); + if (this._sessionsInProgress[deviceIdentityKey] && !nowait) { - logger.log("waiting for olm session to be created"); + log.debug(`Waiting for Olm session for ${deviceIdentityKey} to be created`); try { await this._sessionsInProgress[deviceIdentityKey]; } catch (e) { @@ -727,6 +738,7 @@ OlmDevice.prototype.getSessionInfoForDevice = async function(deviceIdentityKey, } }); }, + log, ); return info; @@ -761,6 +773,7 @@ OlmDevice.prototype.encryptMessage = async function( this._saveSession(theirDeviceIdentityKey, sessionInfo, txn); }); }, + logger.withPrefix("[encryptMessage]"), ); return res; }; @@ -794,6 +807,7 @@ OlmDevice.prototype.decryptMessage = async function( this._saveSession(theirDeviceIdentityKey, sessionInfo, txn); }); }, + logger.withPrefix("[decryptMessage]"), ); return payloadString; }; @@ -825,6 +839,7 @@ OlmDevice.prototype.matchesSession = async function( matches = sessionInfo.session.matches_inbound(ciphertext); }); }, + logger.withPrefix("[matchesSession]"), ); return matches; }; @@ -1095,6 +1110,7 @@ OlmDevice.prototype.addInboundGroupSession = async function( }, ); }, + logger.withPrefix("[addInboundGroupSession]"), ); }; @@ -1265,6 +1281,7 @@ OlmDevice.prototype.decryptGroupMessage = async function( }, ); }, + logger.withPrefix("[decryptGroupMessage]"), ); if (error) { @@ -1310,6 +1327,7 @@ OlmDevice.prototype.hasInboundSessionKeys = async function(roomId, senderKey, se }, ); }, + logger.withPrefix("[hasInboundSessionKeys]"), ); return result; @@ -1369,6 +1387,7 @@ OlmDevice.prototype.getInboundGroupSessionKey = async function( }, ); }, + logger.withPrefix("[getInboundGroupSessionKey]"), ); return result; diff --git a/src/crypto/algorithms/megolm.js b/src/crypto/algorithms/megolm.js index 66694bedf..7ea048d2b 100644 --- a/src/crypto/algorithms/megolm.js +++ b/src/crypto/algorithms/megolm.js @@ -22,7 +22,7 @@ limitations under the License. * @module crypto/algorithms/megolm */ -import {getPrefixedLogger, logger} from '../../logger'; +import {logger} from '../../logger'; import * as utils from "../../utils"; import {polyfillSuper} from "../../utils"; import * as olmlib from "../olmlib"; @@ -736,7 +736,7 @@ MegolmEncryption.prototype._shareKeyWithDevices = async function( logger.debug(`Ensuring Olm sessions for devices in ${this._roomId}`); const devicemap = await olmlib.ensureOlmSessionsForDevices( this._olmDevice, this._baseApis, devicesByUser, otkTimeout, failedServers, - getPrefixedLogger(`[${this._roomId}]`), + logger.withPrefix(`[${this._roomId}]`), ); logger.debug(`Ensured Olm sessions for devices in ${this._roomId}`); diff --git a/src/crypto/olmlib.js b/src/crypto/olmlib.js index f21bd5db4..eb0d8a4a9 100644 --- a/src/crypto/olmlib.js +++ b/src/crypto/olmlib.js @@ -183,7 +183,7 @@ export async function getExistingOlmSessions( * @param {Array} [failedServers] An array to fill with remote servers that * failed to respond to one-time-key requests. * - * @param {Object} [log] A possibly customised log + * @param {Logger} [log] A possibly customised log * * @return {Promise} resolves once the sessions are complete, to * an Object mapping from userId to deviceId to @@ -208,6 +208,39 @@ export async function ensureOlmSessionsForDevices( const result = {}; const resolveSession = {}; + // Mark all sessions this task intends to update as in progress. It is + // important to do this for all devices this task cares about in a single + // synchronous operation, as otherwise it is possible to have deadlocks + // where multiple tasks wait indefinitely on another task to update some set + // of common devices. + for (const [userId, devices] of Object.entries(devicesByUser)) { + for (const deviceInfo of devices) { + const deviceId = deviceInfo.deviceId; + const key = deviceInfo.getIdentityKey(); + + if (key === olmDevice.deviceCurve25519Key) { + // We don't start sessions with ourself, so there's no need to + // mark it in progress. + continue; + } + + const forWhom = `for ${key} (${userId}:${deviceId})`; + if (!olmDevice._sessionsInProgress[key]) { + // pre-emptively mark the session as in-progress to avoid race + // conditions. If we find that we already have a session, then + // we'll resolve + log.debug(`Marking Olm session in progress ${forWhom}`); + olmDevice._sessionsInProgress[key] = new Promise(resolve => { + resolveSession[key] = (...args) => { + log.debug(`Resolved Olm session in progress ${forWhom}`); + delete olmDevice._sessionsInProgress[key]; + resolve(...args); + }; + }); + } + } + } + for (const [userId, devices] of Object.entries(devicesByUser)) { result[userId] = {}; for (const deviceInfo of devices) { @@ -232,41 +265,23 @@ export async function ensureOlmSessionsForDevices( continue; } - if (!olmDevice._sessionsInProgress[key]) { - // pre-emptively mark the session as in-progress to avoid race - // conditions. If we find that we already have a session, then - // we'll resolve - olmDevice._sessionsInProgress[key] = new Promise( - (resolve, reject) => { - resolveSession[key] = { - resolve: (...args) => { - delete olmDevice._sessionsInProgress[key]; - resolve(...args); - }, - reject: (...args) => { - delete olmDevice._sessionsInProgress[key]; - reject(...args); - }, - }; - }, - ); - } + const forWhom = `for ${key} (${userId}:${deviceId})`; + log.debug(`Ensuring Olm session ${forWhom}`); const sessionId = await olmDevice.getSessionIdForDevice( - key, resolveSession[key], + key, resolveSession[key], log, ); + log.debug(`Got Olm session ${sessionId} ${forWhom}`); if (sessionId !== null && resolveSession[key]) { // we found a session, but we had marked the session as - // in-progress, so unmark it and unblock anything that was - // waiting - delete olmDevice._sessionsInProgress[key]; - resolveSession[key].resolve(); - delete resolveSession[key]; + // in-progress, so resolve it now, which will unmark it and + // unblock anything that was waiting + resolveSession[key](); } if (sessionId === null || force) { if (force) { - log.info(`Forcing new Olm session for ${userId}:${deviceId}`); + log.info(`Forcing new Olm session ${forWhom}`); } else { - log.info(`Making new Olm session for ${userId}:${deviceId}`); + log.info(`Making new Olm session ${forWhom}`); } devicesWithoutSession.push([userId, deviceId]); } @@ -289,9 +304,13 @@ export async function ensureOlmSessionsForDevices( // timeout on this request, let's first log whether that's the root // cause we're seeing in practice. // See also https://github.com/vector-im/element-web/issues/16194 - const otkTimeoutLogger = setTimeout(() => { - log.error(`Homeserver never replied while claiming ${taskDetail}`); - }, otkTimeout); + let otkTimeoutLogger; + // XXX: Perhaps there should be a default timeout? + if (otkTimeout) { + otkTimeoutLogger = setTimeout(() => { + log.error(`Homeserver never replied while claiming ${taskDetail}`); + }, otkTimeout); + } try { log.debug(`Claiming ${taskDetail}`); res = await baseApis.claimOneTimeKeys( @@ -300,7 +319,7 @@ export async function ensureOlmSessionsForDevices( log.debug(`Claimed ${taskDetail}`); } catch (e) { for (const resolver of Object.values(resolveSession)) { - resolver.resolve(); + resolver(); } log.log(`Failed to claim ${taskDetail}`, e, devicesWithoutSession); throw e; @@ -312,10 +331,10 @@ export async function ensureOlmSessionsForDevices( failedServers.push(...Object.keys(res.failures)); } - const otk_res = res.one_time_keys || {}; + const otkResult = res.one_time_keys || {}; const promises = []; for (const [userId, devices] of Object.entries(devicesByUser)) { - const userRes = otk_res[userId] || {}; + const userRes = otkResult[userId] || {}; for (let j = 0; j < devices.length; j++) { const deviceInfo = devices[j]; const deviceId = deviceInfo.deviceId; @@ -347,7 +366,7 @@ export async function ensureOlmSessionsForDevices( `for device ${userId}:${deviceId}`, ); if (resolveSession[key]) { - resolveSession[key].resolve(); + resolveSession[key](); } continue; } @@ -357,12 +376,12 @@ export async function ensureOlmSessionsForDevices( olmDevice, oneTimeKey, userId, deviceInfo, ).then((sid) => { if (resolveSession[key]) { - resolveSession[key].resolve(sid); + resolveSession[key](sid); } result[userId][deviceId].sessionId = sid; }, (e) => { if (resolveSession[key]) { - resolveSession[key].resolve(); + resolveSession[key](); } throw e; }), diff --git a/src/crypto/store/indexeddb-crypto-store-backend.js b/src/crypto/store/indexeddb-crypto-store-backend.js index 505e5fe51..6ecffe7be 100644 --- a/src/crypto/store/indexeddb-crypto-store-backend.js +++ b/src/crypto/store/indexeddb-crypto-store-backend.js @@ -34,6 +34,7 @@ export class Backend { */ constructor(db) { this._db = db; + this._nextTxnId = 0; // make sure we close the db on `onversionchange` - otherwise // attempts to delete the database will block (and subsequent @@ -757,10 +758,21 @@ export class Backend { })); } - doTxn(mode, stores, func) { + doTxn(mode, stores, func, log = logger) { + const txnId = this._nextTxnId++; + const startTime = Date.now(); + const description = `${mode} crypto store transaction ${txnId} in ${stores}`; + log.debug(`Starting ${description}`); const txn = this._db.transaction(stores, mode); const promise = promiseifyTxn(txn); const result = func(txn); + promise.then(() => { + const elapsedTime = Date.now() - startTime; + log.debug(`Finished ${description}, took ${elapsedTime} ms`); + }, () => { + const elapsedTime = Date.now() - startTime; + log.error(`Failed ${description}, took ${elapsedTime} ms`); + }); return promise.then(() => { return result; }); diff --git a/src/crypto/store/indexeddb-crypto-store.js b/src/crypto/store/indexeddb-crypto-store.js index 3dc49858c..50f3c2678 100644 --- a/src/crypto/store/indexeddb-crypto-store.js +++ b/src/crypto/store/indexeddb-crypto-store.js @@ -596,6 +596,7 @@ export class IndexedDBCryptoStore { * @param {function(*)} func Function called with the * transaction object: an opaque object that should be passed * to store functions. + * @param {Logger} [log] A possibly customised log * @return {Promise} Promise that resolves with the result of the `func` * when the transaction is complete. If the backend is * async (ie. the indexeddb backend) any of the callback @@ -603,8 +604,8 @@ export class IndexedDBCryptoStore { * reject with that exception. On synchronous backends, the * exception will propagate to the caller of the getFoo method. */ - doTxn(mode, stores, func) { - return this._backend.doTxn(mode, stores, func); + doTxn(mode, stores, func, log) { + return this._backend.doTxn(mode, stores, func, log); } } diff --git a/src/logger.ts b/src/logger.ts index 8357c70c0..e5fced726 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -59,17 +59,28 @@ log.methodFactory = function(methodName, logLevel, loggerName) { * Drop-in replacement for console using {@link https://www.npmjs.com/package/loglevel|loglevel}. * Can be tailored down to specific use cases if needed. */ -export const logger = log.getLogger(DEFAULT_NAMESPACE); +export const logger: PrefixedLogger = log.getLogger(DEFAULT_NAMESPACE); logger.setLevel(log.levels.DEBUG); interface PrefixedLogger extends Logger { - prefix?: any; + withPrefix?: (prefix: string) => PrefixedLogger; + prefix?: string; } -export function getPrefixedLogger(prefix): PrefixedLogger { +function extendLogger(logger: PrefixedLogger) { + logger.withPrefix = function(prefix: string): PrefixedLogger { + const existingPrefix = this.prefix || ""; + return getPrefixedLogger(existingPrefix + prefix); + }; +} + +extendLogger(logger); + +function getPrefixedLogger(prefix): PrefixedLogger { const prefixLogger: PrefixedLogger = log.getLogger(`${DEFAULT_NAMESPACE}-${prefix}`); if (prefixLogger.prefix !== prefix) { // Only do this setup work the first time through, as loggers are saved by name. + extendLogger(prefixLogger); prefixLogger.prefix = prefix; prefixLogger.setLevel(log.levels.DEBUG); } diff --git a/src/matrix.ts b/src/matrix.ts index cffda610c..f1014c5de 100644 --- a/src/matrix.ts +++ b/src/matrix.ts @@ -52,7 +52,7 @@ export * from "./store/session/webstorage"; export * from "./crypto/store/memory-crypto-store"; export * from "./crypto/store/indexeddb-crypto-store"; export * from "./content-repo"; -export const ContentHelpers = import("./content-helpers"); +export * as ContentHelpers from "./content-helpers"; export { createNewMatrixCall, setAudioOutput as setMatrixCallAudioOutput, diff --git a/src/models/room-member.js b/src/models/room-member.js index 0ba6edd60..444c45609 100644 --- a/src/models/room-member.js +++ b/src/models/room-member.js @@ -290,6 +290,9 @@ RoomMember.prototype.getMxcAvatarUrl = function() { return null; }; +const MXID_PATTERN = /@.+:.+/; +const LTR_RTL_PATTERN = /[\u200E\u200F\u202A-\u202F]/; + function calculateDisplayName(selfUserId, displayName, roomState) { if (!displayName || displayName === selfUserId) { return selfUserId; @@ -308,13 +311,13 @@ function calculateDisplayName(selfUserId, displayName, roomState) { // Next check if the name contains something that look like a mxid // If it does, it may be someone trying to impersonate someone else // Show full mxid in this case - let disambiguate = /@.+:.+/.test(displayName); + let disambiguate = MXID_PATTERN.test(displayName); if (!disambiguate) { // Also show mxid if the display name contains any LTR/RTL characters as these // make it very difficult for us to find similar *looking* display names // E.g "Mark" could be cloned by writing "kraM" but in RTL. - disambiguate = /[\u200E\u200F\u202A-\u202F]/.test(displayName); + disambiguate = LTR_RTL_PATTERN.test(displayName); } if (!disambiguate) { diff --git a/src/models/room.js b/src/models/room.js index 047d7c5f4..9c321e1bb 100644 --- a/src/models/room.js +++ b/src/models/room.js @@ -30,7 +30,7 @@ import {RoomMember} from "./room-member"; import {RoomSummary} from "./room-summary"; import {logger} from '../logger'; import {ReEmitter} from '../ReEmitter'; -import {EventType} from "../@types/event"; +import {EventType, RoomCreateTypeField, RoomType} from "../@types/event"; // These constants are used as sane defaults when the homeserver doesn't support // the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be @@ -1856,6 +1856,27 @@ Room.prototype.getJoinRule = function() { return this.currentState.getJoinRule(); }; +/** + * Returns the type of the room from the `m.room.create` event content or undefined if none is set + * @returns {?string} the type of the room. Currently only RoomType.Space is known. + */ +Room.prototype.getType = function() { + const createEvent = this.currentState.getStateEvents("m.room.create", ""); + if (!createEvent) { + logger.warn("Room " + this.roomId + " does not have an m.room.create event"); + return undefined; + } + return createEvent.getContent()[RoomCreateTypeField]; +}; + +/** + * Returns whether the room is a space-room as defined by MSC1772. + * @returns {boolean} true if the room's type is RoomType.Space + */ +Room.prototype.isSpaceRoom = function() { + return this.getType() === RoomType.Space; +}; + /** * This is an internal method. Calculates the name of the room from the current * room state. diff --git a/src/store/indexeddb.js b/src/store/indexeddb.js index 9cde4467d..50049bf9b 100644 --- a/src/store/indexeddb.js +++ b/src/store/indexeddb.js @@ -51,8 +51,8 @@ const WRITE_DELAY_MS = 1000 * 60 * 5; // once every 5 minutes * sync from the server is not required. This does not reduce memory usage as all * the data is eagerly fetched when startup() is called. *
- * let opts = { localStorage: window.localStorage };
- * let store = new IndexedDBStore();
+ * let opts = { indexedDB: window.indexedDB, localStorage: window.localStorage };
+ * let store = new IndexedDBStore(opts);
  * await store.startup(); // load from indexed db
  * let client = sdk.createClient({
  *     store: store,
diff --git a/src/webrtc/call.ts b/src/webrtc/call.ts
index bcca5268d..725060d67 100644
--- a/src/webrtc/call.ts
+++ b/src/webrtc/call.ts
@@ -174,6 +174,11 @@ export enum CallErrorCode {
     SignallingFailed = 'signalling_timeout',
 }
 
+enum ConstraintsType {
+    Audio = "audio",
+    Video = "video",
+}
+
 /**
  * The version field that we set in m.call.* events
  */
@@ -251,8 +256,6 @@ export class MatrixCall extends EventEmitter {
     private localAVStream: MediaStream;
     private inviteOrAnswerSent: boolean;
     private waitForLocalAVStream: boolean;
-    // XXX: This is either the invite or answer from remote...
-    private msg: any;
     // XXX: I don't know why this is called 'config'.
     private config: MediaStreamConstraints;
     private successor: MatrixCall;
@@ -284,6 +287,11 @@ export class MatrixCall extends EventEmitter {
     private makingOffer: boolean;
     private ignoreOffer: boolean;
 
+    // If candidates arrive before we've picked an opponent (which, in particular,
+    // will happen if the opponent sends candidates eagerly before the user answers
+    // the call) we buffer them up here so we can then add the ones from the party we pick
+    private remoteCandidateBuffer = new Map();
+
     constructor(opts: CallOpts) {
         super();
         this.roomId = opts.roomId;
@@ -291,9 +299,6 @@ export class MatrixCall extends EventEmitter {
         this.type = null;
         this.forceTURN = opts.forceTURN;
         this.ourPartyId = this.client.deviceId;
-        // We compare this to null to checks the presence of a party ID:
-        // make sure it's null, not undefined
-        this.opponentPartyId = null;
         // Array of Objects with urls, username, credential keys
         this.turnServers = opts.turnServers || [];
         if (this.turnServers.length === 0 && this.client.isFallbackICEServerAllowed()) {
@@ -331,7 +336,8 @@ export class MatrixCall extends EventEmitter {
     placeVoiceCall() {
         logger.debug("placeVoiceCall");
         this.checkForErrorListener();
-        this.placeCallWithConstraints(getUserMediaVideoContraints(CallType.Voice));
+        const constraints = getUserMediaContraints(ConstraintsType.Audio);
+        this.placeCallWithConstraints(constraints);
         this.type = CallType.Voice;
     }
 
@@ -348,7 +354,8 @@ export class MatrixCall extends EventEmitter {
         this.checkForErrorListener();
         this.localVideoElement = localVideoElement;
         this.remoteVideoElement = remoteVideoElement;
-        this.placeCallWithConstraints(getUserMediaVideoContraints(CallType.Video));
+        const constraints = getUserMediaContraints(ConstraintsType.Video);
+        this.placeCallWithConstraints(constraints);
         this.type = CallType.Video;
     }
 
@@ -372,54 +379,30 @@ export class MatrixCall extends EventEmitter {
         this.localVideoElement = localVideoElement;
         this.remoteVideoElement = remoteVideoElement;
 
-        if (window.electron?.getDesktopCapturerSources) {
-            // We have access to getDesktopCapturerSources()
-            logger.debug("Electron getDesktopCapturerSources() is available...");
-            try {
-                const selectedSource = await selectDesktopCapturerSource();
-                // If no source was selected cancel call
-                if (!selectedSource) return;
-                const getUserMediaOptions: MediaStreamConstraints | DesktopCapturerConstraints = {
-                    audio: false,
-                    video: {
-                        mandatory: {
-                            chromeMediaSource: "desktop",
-                            chromeMediaSourceId: selectedSource.id,
-                        },
-                    },
-                }
-                this.screenSharingStream = await window.navigator.mediaDevices.getUserMedia(getUserMediaOptions);
+        try {
+            const screenshareConstraints = await getScreenshareContraints(selectDesktopCapturerSource);
+            if (!screenshareConstraints) return;
+            if (window.electron?.getDesktopCapturerSources) {
+                // We are using Electron
+                logger.debug("Getting screen stream using getUserMedia()...");
+                this.screenSharingStream = await navigator.mediaDevices.getUserMedia(screenshareConstraints);
+            } else {
+                // We are not using Electron
+                logger.debug("Getting screen stream using getDisplayMedia()...");
+                this.screenSharingStream = await navigator.mediaDevices.getDisplayMedia(screenshareConstraints);
+            }
 
-                logger.debug("Got screen stream, requesting audio stream...");
-                const audioConstraints = getUserMediaVideoContraints(CallType.Voice);
-                this.placeCallWithConstraints(audioConstraints);
-            } catch (err) {
-                this.emit(CallEvent.Error,
-                    new CallError(
-                        CallErrorCode.NoUserMedia,
-                        "Failed to get screen-sharing stream: ", err,
-                    ),
-                );
-            }
-        } else {
-            /* We do not have access to the Electron desktop capturer,
-             * therefore we can assume we are on the web */
-            logger.debug("Electron desktopCapturer is not available...");
-            try {
-                this.screenSharingStream = await navigator.mediaDevices.getDisplayMedia({'audio': false});
-                logger.debug("Got screen stream, requesting audio stream...");
-                const audioConstraints = getUserMediaVideoContraints(CallType.Voice);
-                this.placeCallWithConstraints(audioConstraints);
-            } catch (err) {
-                this.emit(CallEvent.Error,
-                    new CallError(
-                        CallErrorCode.NoUserMedia,
-                        "Failed to get screen-sharing stream: ", err,
-                    ),
-                );
-            }
+            logger.debug("Got screen stream, requesting audio stream...");
+            const audioConstraints = getUserMediaContraints(ConstraintsType.Audio);
+            this.placeCallWithConstraints(audioConstraints);
+        } catch (err) {
+            this.emit(CallEvent.Error,
+                new CallError(
+                    CallErrorCode.NoUserMedia,
+                    "Failed to get screen-sharing stream: ", err,
+                ),
+            );
         }
-
         this.type = CallType.Video;
     }
 
@@ -541,11 +524,16 @@ export class MatrixCall extends EventEmitter {
      * @param {MatrixEvent} event The m.call.invite event
      */
     async initWithInvite(event: MatrixEvent) {
-        this.msg = event.getContent();
+        const invite = event.getContent();
         this.direction = CallDirection.Inbound;
+
         this.peerConn = this.createPeerConnection();
+        // we must set the party ID before await-ing on anything: the call event
+        // handler will start giving us more call events (eg. candidates) so if
+        // we haven't set the party ID, we'll ignore them.
+        this.chooseOpponent(event);
         try {
-            await this.peerConn.setRemoteDescription(this.msg.offer);
+            await this.peerConn.setRemoteDescription(invite.offer);
         } catch (e) {
             logger.debug("Failed to set remote description", e);
             this.terminate(CallParty.Local, CallErrorCode.SetRemoteDescription, false);
@@ -564,13 +552,6 @@ export class MatrixCall extends EventEmitter {
         this.type = this.remoteStream.getTracks().some(t => t.kind === 'video') ? CallType.Video : CallType.Voice;
 
         this.setState(CallState.Ringing);
-        this.opponentVersion = this.msg.version;
-        if (this.opponentVersion !== 0) {
-            // ignore party ID in v0 calls: party ID isn't a thing until v1
-            this.opponentPartyId = this.msg.party_id || null;
-        }
-        this.opponentCaps = this.msg.capabilities || {};
-        this.opponentMember = event.sender;
 
         if (event.getLocalAge()) {
             setTimeout(() => {
@@ -584,7 +565,7 @@ export class MatrixCall extends EventEmitter {
                     }
                     this.emit(CallEvent.Hangup);
                 }
-            }, this.msg.lifetime - event.getLocalAge());
+            }, invite.lifetime - event.getLocalAge());
         }
     }
 
@@ -596,7 +577,6 @@ export class MatrixCall extends EventEmitter {
         // perverse as it may seem, sometimes we want to instantiate a call with a
         // hangup message (because when getting the state of the room on load, events
         // come in reverse order and we want to remember that a call has been hung up)
-        this.msg = event.getContent();
         this.setState(CallState.Ended);
     }
 
@@ -611,7 +591,11 @@ export class MatrixCall extends EventEmitter {
         logger.debug(`Answering call ${this.callId} of type ${this.type}`);
 
         if (!this.localAVStream && !this.waitForLocalAVStream) {
-            const constraints = getUserMediaVideoContraints(this.type);
+            const constraints = getUserMediaContraints(
+                this.type == CallType.Video ?
+                    ConstraintsType.Video:
+                    ConstraintsType.Audio,
+            );
             logger.log("Getting user media with constraints", constraints);
             this.setState(CallState.WaitLocalMedia);
             this.waitForLocalAVStream = true;
@@ -886,7 +870,7 @@ export class MatrixCall extends EventEmitter {
         // Now we wait for the negotiationneeded event
     };
 
-    private sendAnswer() {
+    private async sendAnswer() {
         const answerContent = {
             answer: {
                 sdp: this.peerConn.localDescription.sdp,
@@ -908,12 +892,12 @@ export class MatrixCall extends EventEmitter {
         logger.info(`Discarding ${this.candidateSendQueue.length} candidates that will be sent in answer`);
         this.candidateSendQueue = [];
 
-        this.sendVoipEvent(EventType.CallAnswer, answerContent).then(() => {
+        try {
+            await this.sendVoipEvent(EventType.CallAnswer, answerContent);
             // If this isn't the first time we've tried to send the answer,
             // we may have candidates queued up, so send them now.
             this.inviteOrAnswerSent = true;
-            this.sendCandidateQueue();
-        }).catch((error) => {
+        } catch (error) {
             // We've failed to answer: back to the ringing state
             this.setState(CallState.Ringing);
             this.client.cancelPendingEvent(error.event);
@@ -926,7 +910,11 @@ export class MatrixCall extends EventEmitter {
             }
             this.emit(CallEvent.Error, new CallError(code, message, error));
             throw error;
-        });
+        }
+
+        // error handler re-throws so this won't happen on error, but
+        // we don't want the same error handling on the candidate queue
+        this.sendCandidateQueue();
     }
 
     private gotUserMediaForAnswer = async (stream: MediaStream) => {
@@ -1030,37 +1018,33 @@ export class MatrixCall extends EventEmitter {
             return;
         }
 
-        if (!this.partyIdMatches(ev.getContent())) {
-            logger.info(
-                `Ignoring candidates from party ID ${ev.getContent().party_id}: ` +
-                `we have chosen party ID ${this.opponentPartyId}`,
-            );
-            return;
-        }
-
         const cands = ev.getContent().candidates;
         if (!cands) {
             logger.info("Ignoring candidates event with no candidates!");
             return;
         }
 
-        for (const cand of cands) {
-            if (
-                (cand.sdpMid === null || cand.sdpMid === undefined) &&
-                (cand.sdpMLineIndex === null || cand.sdpMLineIndex === undefined)
-            ) {
-                logger.debug("Ignoring remote ICE candidate with no sdpMid or sdpMLineIndex");
-                return;
-            }
-            logger.debug("Got remote ICE " + cand.sdpMid + " candidate: " + cand.candidate);
-            try {
-                this.peerConn.addIceCandidate(cand);
-            } catch (err) {
-                if (!this.ignoreOffer) {
-                    logger.info("Failed to add remore ICE candidate", err);
-                }
-            }
+        const fromPartyId = ev.getContent().version === 0 ? null : ev.getContent().party_id || null;
+
+        if (this.opponentPartyId === undefined) {
+            // we haven't picked an opponent yet so save the candidates
+            logger.info(`Bufferring ${cands.length} candidates until we pick an opponent`);
+            const bufferedCands = this.remoteCandidateBuffer.get(fromPartyId) || [];
+            bufferedCands.push(...cands);
+            this.remoteCandidateBuffer.set(fromPartyId, bufferedCands);
+            return;
         }
+
+        if (!this.partyIdMatches(ev.getContent())) {
+            logger.info(
+                `Ignoring candidates from party ID ${ev.getContent().party_id}: ` +
+                `we have chosen party ID ${this.opponentPartyId}`,
+            );
+
+            return;
+        }
+
+        this.addIceCandidates(cands);
     }
 
     /**
@@ -1072,7 +1056,7 @@ export class MatrixCall extends EventEmitter {
             return;
         }
 
-        if (this.opponentPartyId !== null) {
+        if (this.opponentPartyId !== undefined) {
             logger.info(
                 `Ignoring answer from party ID ${event.getContent().party_id}: ` +
                 `we already have an answer/reject from ${this.opponentPartyId}`,
@@ -1080,12 +1064,7 @@ export class MatrixCall extends EventEmitter {
             return;
         }
 
-        this.opponentVersion = event.getContent().version;
-        if (this.opponentVersion !== 0) {
-            this.opponentPartyId = event.getContent().party_id || null;
-        }
-        this.opponentCaps = event.getContent().capabilities || {};
-        this.opponentMember = event.sender;
+        this.chooseOpponent(event);
 
         this.setState(CallState.Connecting);
 
@@ -1260,19 +1239,9 @@ export class MatrixCall extends EventEmitter {
 
         try {
             await this.sendVoipEvent(eventType, content);
-            this.sendCandidateQueue();
-            if (this.state === CallState.CreateOffer) {
-                this.inviteOrAnswerSent = true;
-                this.setState(CallState.InviteSent);
-                this.inviteTimeout = setTimeout(() => {
-                    this.inviteTimeout = null;
-                    if (this.state === CallState.InviteSent) {
-                        this.hangup(CallErrorCode.InviteTimeout, false);
-                    }
-                }, CALL_TIMEOUT_MS);
-            }
         } catch (error) {
-            this.client.cancelPendingEvent(error.event);
+            logger.error("Failed to send invite", error);
+            if (error.event) this.client.cancelPendingEvent(error.event);
 
             let code = CallErrorCode.SignallingFailed;
             let message = "Signalling failed";
@@ -1287,6 +1256,22 @@ export class MatrixCall extends EventEmitter {
 
             this.emit(CallEvent.Error, new CallError(code, message, error));
             this.terminate(CallParty.Local, code, false);
+
+            // no need to carry on & send the candidate queue, but we also
+            // don't want to rethrow the error
+            return;
+        }
+
+        this.sendCandidateQueue();
+        if (this.state === CallState.CreateOffer) {
+            this.inviteOrAnswerSent = true;
+            this.setState(CallState.InviteSent);
+            this.inviteTimeout = setTimeout(() => {
+                this.inviteTimeout = null;
+                if (this.state === CallState.InviteSent) {
+                    this.hangup(CallErrorCode.InviteTimeout, false);
+                }
+            }, CALL_TIMEOUT_MS);
         }
     };
 
@@ -1623,7 +1608,7 @@ export class MatrixCall extends EventEmitter {
         }
     }
 
-    private sendCandidateQueue() {
+    private async sendCandidateQueue() {
         if (this.candidateSendQueue.length === 0) {
             return;
         }
@@ -1635,20 +1620,28 @@ export class MatrixCall extends EventEmitter {
             candidates: cands,
         };
         logger.debug("Attempting to send " + cands.length + " candidates");
-        this.sendVoipEvent(EventType.CallCandidates, content).then(() => {
-            this.candidateSendTries = 0;
-            this.sendCandidateQueue();
-        }, (error) => {
-            for (let i = 0; i < cands.length; i++) {
-                this.candidateSendQueue.push(cands[i]);
-            }
+        try {
+            await this.sendVoipEvent(EventType.CallCandidates, content);
+        } catch (error) {
+            // don't retry this event: we'll send another one later as we might
+            // have more candidates by then.
+            if (error.event) this.client.cancelPendingEvent(error.event);
+
+            // put all the candidates we failed to send back in the queue
+            this.candidateSendQueue.push(...cands);
 
             if (this.candidateSendTries > 5) {
                 logger.debug(
                     "Failed to send candidates on attempt " + this.candidateSendTries +
-                    ". Giving up for now.", error,
+                    ". Giving up on this call.", error,
                 );
-                this.candidateSendTries = 0;
+
+                const code = CallErrorCode.SignallingFailed;
+                const message = "Signalling failed";
+
+                this.emit(CallEvent.Error, new CallError(code, message, error));
+                this.hangup(code, false);
+
                 return;
             }
 
@@ -1658,7 +1651,7 @@ export class MatrixCall extends EventEmitter {
             setTimeout(() => {
                 this.sendCandidateQueue();
             }, delayMs);
-        });
+        }
     }
 
     private async placeCallWithConstraints(constraints: MediaStreamConstraints) {
@@ -1702,9 +1695,60 @@ export class MatrixCall extends EventEmitter {
 
     private partyIdMatches(msg): boolean {
         // They must either match or both be absent (in which case opponentPartyId will be null)
-        const msgPartyId = msg.party_id || null;
+        // Also we ignore party IDs on the invite/offer if the version is 0, so we must do the same
+        // here and use null if the version is 0 (woe betide any opponent sending messages in the
+        // same call with different versions)
+        const msgPartyId = msg.version === 0 ? null : msg.party_id || null;
         return msgPartyId === this.opponentPartyId;
     }
+
+    // Commits to an opponent for the call
+    // ev: An invite or answer event
+    private chooseOpponent(ev: MatrixEvent) {
+        // I choo-choo-choose you
+        const msg = ev.getContent();
+
+        this.opponentVersion = msg.version;
+        if (this.opponentVersion === 0) {
+            // set to null to indicate that we've chosen an opponent, but because
+            // they're v0 they have no party ID (even if they sent one, we're ignoring it)
+            this.opponentPartyId = null;
+        } else {
+            // set to their party ID, or if they're naughty and didn't send one despite
+            // not being v0, set it to null to indicate we picked an opponent with no
+            // party ID
+            this.opponentPartyId = msg.party_id || null;
+        }
+        this.opponentCaps = msg.capabilities || {};
+        this.opponentMember = ev.sender;
+
+        const bufferedCands = this.remoteCandidateBuffer.get(this.opponentPartyId);
+        if (bufferedCands) {
+            logger.info(`Adding ${bufferedCands.length} buffered candidates for opponent ${this.opponentPartyId}`);
+            this.addIceCandidates(bufferedCands);
+        }
+        this.remoteCandidateBuffer = null;
+    }
+
+    private addIceCandidates(cands: RTCIceCandidate[]) {
+        for (const cand of cands) {
+            if (
+                (cand.sdpMid === null || cand.sdpMid === undefined) &&
+                (cand.sdpMLineIndex === null || cand.sdpMLineIndex === undefined)
+            ) {
+                logger.debug("Ignoring remote ICE candidate with no sdpMid or sdpMLineIndex");
+                return;
+            }
+            logger.debug("Got remote ICE " + cand.sdpMid + " candidate: " + cand.candidate);
+            try {
+                this.peerConn.addIceCandidate(cand);
+            } catch (err) {
+                if (!this.ignoreOffer) {
+                    logger.info("Failed to add remore ICE candidate", err);
+                }
+            }
+        }
+    }
 }
 
 function setTracksEnabled(tracks: Array, enabled: boolean) {
@@ -1713,17 +1757,19 @@ function setTracksEnabled(tracks: Array, enabled: boolean) {
     }
 }
 
-function getUserMediaVideoContraints(callType: CallType) {
+function getUserMediaContraints(type: ConstraintsType) {
     const isWebkit = !!navigator.webkitGetUserMedia;
 
-    switch (callType) {
-        case CallType.Voice:
+    switch (type) {
+        case ConstraintsType.Audio: {
             return {
                 audio: {
                     deviceId: audioInput ? {ideal: audioInput} : undefined,
-                }, video: false,
+                },
+                video: false,
             };
-        case CallType.Video:
+        }
+        case ConstraintsType.Video: {
             return {
                 audio: {
                     deviceId: audioInput ? {ideal: audioInput} : undefined,
@@ -1738,6 +1784,33 @@ function getUserMediaVideoContraints(callType: CallType) {
                     height: isWebkit ? { exact: 360 } : { ideal: 360 },
                 },
             };
+        }
+    }
+}
+
+async function getScreenshareContraints(selectDesktopCapturerSource?: () => Promise) {
+    if (window.electron?.getDesktopCapturerSources && selectDesktopCapturerSource) {
+        // We have access to getDesktopCapturerSources()
+        logger.debug("Electron getDesktopCapturerSources() is available...");
+        const selectedSource = await selectDesktopCapturerSource();
+        if (!selectedSource) return null;
+        return {
+            audio: false,
+            video: {
+                mandatory: {
+                    chromeMediaSource: "desktop",
+                    chromeMediaSourceId: selectedSource.id,
+                },
+            },
+        };
+    } else {
+        // We do not have access to the Electron desktop capturer,
+        // therefore we can assume we are on the web
+        logger.debug("Electron desktopCapturer is not available...");
+        return {
+            audio: false,
+            video: true,
+        };
     }
 }
 
diff --git a/src/webrtc/callEventHandler.ts b/src/webrtc/callEventHandler.ts
index ba4452ea8..304eacfdd 100644
--- a/src/webrtc/callEventHandler.ts
+++ b/src/webrtc/callEventHandler.ts
@@ -138,6 +138,8 @@ export class CallEventHandler {
                 );
             }
 
+            const timeUntilTurnCresExpire = this.client.getTurnServersExpiry() - Date.now();
+            logger.info("Current turn creds expire in " + timeUntilTurnCresExpire + " seconds");
             call = createNewMatrixCall(this.client, event.getRoomId(), {
                 forceTURN: this.client._forceTURN,
             });
diff --git a/yarn.lock b/yarn.lock
index a47f6eb4e..d3a09a364 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5069,11 +5069,16 @@ lodash.sortby@^4.7.0:
   resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
   integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=
 
-lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.4:
+lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20:
   version "4.17.20"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
   integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==
 
+lodash@^4.17.4:
+  version "4.17.21"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
+  integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
+
 loglevel@^1.7.1:
   version "1.7.1"
   resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.7.1.tgz#005fde2f5e6e47068f935ff28573e125ef72f197"
@@ -5917,9 +5922,9 @@ pug-attrs@^2.0.4:
     pug-runtime "^2.0.5"
 
 pug-code-gen@^2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/pug-code-gen/-/pug-code-gen-2.0.2.tgz#ad0967162aea077dcf787838d94ed14acb0217c2"
-  integrity sha512-kROFWv/AHx/9CRgoGJeRSm+4mLWchbgpRzTEn8XCiwwOy6Vh0gAClS8Vh5TEJ9DBjaP8wCjS3J6HKsEsYdvaCw==
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/pug-code-gen/-/pug-code-gen-2.0.3.tgz#122eb9ada9b5bf601705fe15aaa0a7d26bc134ab"
+  integrity sha512-r9sezXdDuZJfW9J91TN/2LFbiqDhmltTFmGpHTsGdrNGp3p4SxAjjXEfnuK2e4ywYsRIVP0NeLbSAMHUcaX1EA==
   dependencies:
     constantinople "^3.1.2"
     doctypes "^1.1.0"