1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-11-26 17:03:12 +03:00

Merge branch 'develop' into constraint-cleanup

This commit is contained in:
Šimon Brandner
2021-03-03 15:30:46 +01:00
19 changed files with 565 additions and 147 deletions

View File

@@ -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) 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) [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.7.0-rc.1...v9.7.0)

View File

@@ -1,6 +1,6 @@
{ {
"name": "matrix-js-sdk", "name": "matrix-js-sdk",
"version": "9.7.0", "version": "9.8.0",
"description": "Matrix Client-Server SDK for Javascript", "description": "Matrix Client-Server SDK for Javascript",
"scripts": { "scripts": {
"prepublishOnly": "yarn build", "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", "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", "gendoc": "jsdoc -c jsdoc.json -P package.json",
"lint": "yarn lint:types && yarn lint:js", "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", "lint:types": "tsc --noEmit",
"test": "jest spec/ --coverage --testEnvironment node", "test": "jest spec/ --coverage --testEnvironment node",
"test:watch": "jest spec/ --coverage --testEnvironment node --watch" "test:watch": "jest spec/ --coverage --testEnvironment node --watch"

View File

@@ -190,5 +190,91 @@ describe("OlmDevice", function() {
// new session and will have called claimOneTimeKeys // new session and will have called claimOneTimeKeys
expect(count).toBe(2); 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);
});
}); });
}); });

View File

@@ -190,4 +190,62 @@ describe('Call', function() {
// Hangup to stop timers // Hangup to stop timers
call.hangup(CallErrorCode.UserHangup, true); 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: '',
});
});
}); });

View File

@@ -36,6 +36,10 @@ export enum EventType {
*/ */
RoomAliases = "m.room.aliases", // deprecated https://matrix.org/docs/spec/client_server/r0.6.1#historical-events 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 // Room timeline events
RoomRedaction = "m.room.redaction", RoomRedaction = "m.room.redaction",
RoomMessage = "m.room.message", RoomMessage = "m.room.message",
@@ -87,3 +91,9 @@ export enum MsgType {
Location = "m.location", Location = "m.location",
Video = "m.video", Video = "m.video",
} }
export const RoomCreateTypeField = "org.matrix.msc1772.type"; // Spaces MSC1772
export enum RoomType {
Space = "org.matrix.msc1772.space", // Spaces MSC1772
}

View File

@@ -2374,3 +2374,26 @@ MatrixBaseApis.prototype.reportEvent = function(roomId, eventId, score, reason)
return this._http.authedRequest(undefined, "POST", path, null, {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",
});
};

View File

@@ -393,6 +393,9 @@ export function MatrixClient(opts) {
this._clientWellKnown = undefined; this._clientWellKnown = undefined;
this._clientWellKnownPromise = undefined; this._clientWellKnownPromise = undefined;
this._turnServers = [];
this._turnServersExpiry = null;
// The SDK doesn't really provide a clean way for events to recalculate the push // 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. // 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 // We do this so that push rules are correctly executed on events in their decrypted
@@ -4942,6 +4945,15 @@ MatrixClient.prototype.getTurnServers = function() {
return this._turnServers || []; 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 * 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 * WebRTC connection if the homeserver doesn't provide any servers. Defaults to
@@ -5413,6 +5425,9 @@ async function(roomId, eventId, relationType, eventType, opts = {}) {
})); }));
events = events.filter(e => e.getType() === eventType); events = events.filter(e => e.getType() === eventType);
} }
if (originalEvent && relationType === "m.replace") {
events = events.filter(e => e.getSender() === originalEvent.getSender());
}
return { return {
originalEvent, originalEvent,
events, events,
@@ -5437,6 +5452,7 @@ function checkTurnServers(client) {
credential: res.password, credential: res.password,
}; };
client._turnServers = [servers]; client._turnServers = [servers];
client._turnServersExpiry = Date.now() + res.ttl;
// re-fetch when we're about to reach the TTL // re-fetch when we're about to reach the TTL
client._checkTurnServersTimeoutID = setTimeout(() => { client._checkTurnServersTimeoutID = setTimeout(() => {
checkTurnServers(client); checkTurnServers(client);

View File

@@ -545,6 +545,7 @@ OlmDevice.prototype.createOutboundSession = async function(
} }
}); });
}, },
logger.withPrefix("[createOutboundSession]"),
); );
return newSessionId; return newSessionId;
}; };
@@ -605,6 +606,7 @@ OlmDevice.prototype.createInboundSession = async function(
} }
}); });
}, },
logger.withPrefix("[createInboundSession]"),
); );
return result; return result;
@@ -619,8 +621,10 @@ OlmDevice.prototype.createInboundSession = async function(
* @return {Promise<string[]>} a list of known session ids for the device * @return {Promise<string[]>} a list of known session ids for the device
*/ */
OlmDevice.prototype.getSessionIdsForDevice = async function(theirDeviceIdentityKey) { OlmDevice.prototype.getSessionIdsForDevice = async function(theirDeviceIdentityKey) {
const log = logger.withPrefix("[getSessionIdsForDevice]");
if (this._sessionsInProgress[theirDeviceIdentityKey]) { 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 { try {
await this._sessionsInProgress[theirDeviceIdentityKey]; await this._sessionsInProgress[theirDeviceIdentityKey];
} catch (e) { } catch (e) {
@@ -638,6 +642,7 @@ OlmDevice.prototype.getSessionIdsForDevice = async function(theirDeviceIdentityK
}, },
); );
}, },
log,
); );
return sessionIds; 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. * @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 * This should only be set to true of the calling function is the function
* that marked the session as being in-progress. * that marked the session as being in-progress.
* @param {Logger} [log] A possibly customised log
* @return {Promise<?string>} session id, or null if no established session * @return {Promise<?string>} session id, or null if no established session
*/ */
OlmDevice.prototype.getSessionIdForDevice = async function( OlmDevice.prototype.getSessionIdForDevice = async function(
theirDeviceIdentityKey, nowait, theirDeviceIdentityKey, nowait, log,
) { ) {
const sessionInfos = await this.getSessionInfoForDevice( const sessionInfos = await this.getSessionInfoForDevice(
theirDeviceIdentityKey, nowait, theirDeviceIdentityKey, nowait, log,
); );
if (sessionInfos.length === 0) { 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. * @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 * This should only be set to true of the calling function is the function
* that marked the session as being in-progress. * that marked the session as being in-progress.
* @param {Logger} [log] A possibly customised log
* @return {Array.<{sessionId: string, hasReceivedMessage: Boolean}>} * @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) { 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 { try {
await this._sessionsInProgress[deviceIdentityKey]; await this._sessionsInProgress[deviceIdentityKey];
} catch (e) { } catch (e) {
@@ -727,6 +738,7 @@ OlmDevice.prototype.getSessionInfoForDevice = async function(deviceIdentityKey,
} }
}); });
}, },
log,
); );
return info; return info;
@@ -761,6 +773,7 @@ OlmDevice.prototype.encryptMessage = async function(
this._saveSession(theirDeviceIdentityKey, sessionInfo, txn); this._saveSession(theirDeviceIdentityKey, sessionInfo, txn);
}); });
}, },
logger.withPrefix("[encryptMessage]"),
); );
return res; return res;
}; };
@@ -794,6 +807,7 @@ OlmDevice.prototype.decryptMessage = async function(
this._saveSession(theirDeviceIdentityKey, sessionInfo, txn); this._saveSession(theirDeviceIdentityKey, sessionInfo, txn);
}); });
}, },
logger.withPrefix("[decryptMessage]"),
); );
return payloadString; return payloadString;
}; };
@@ -825,6 +839,7 @@ OlmDevice.prototype.matchesSession = async function(
matches = sessionInfo.session.matches_inbound(ciphertext); matches = sessionInfo.session.matches_inbound(ciphertext);
}); });
}, },
logger.withPrefix("[matchesSession]"),
); );
return matches; 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) { if (error) {
@@ -1310,6 +1327,7 @@ OlmDevice.prototype.hasInboundSessionKeys = async function(roomId, senderKey, se
}, },
); );
}, },
logger.withPrefix("[hasInboundSessionKeys]"),
); );
return result; return result;
@@ -1369,6 +1387,7 @@ OlmDevice.prototype.getInboundGroupSessionKey = async function(
}, },
); );
}, },
logger.withPrefix("[getInboundGroupSessionKey]"),
); );
return result; return result;

View File

@@ -271,7 +271,7 @@ MegolmEncryption.prototype._ensureOutboundSession = async function(
logger.debug(`Shared keys with existing Olm sessions in ${this._roomId}`); logger.debug(`Shared keys with existing Olm sessions in ${this._roomId}`);
})(), })(),
(async () => { (async () => {
logger.debug(`Sharing keys with new Olm sessions in ${this._roomId}`); logger.debug(`Sharing keys (start phase 1) with new Olm sessions in ${this._roomId}`);
const errorDevices = []; const errorDevices = [];
// meanwhile, establish olm sessions for devices that we don't // meanwhile, establish olm sessions for devices that we don't
@@ -285,6 +285,7 @@ MegolmEncryption.prototype._ensureOutboundSession = async function(
session, key, payload, devicesWithoutSession, errorDevices, session, key, payload, devicesWithoutSession, errorDevices,
singleOlmCreationPhase ? 10000 : 2000, failedServers, singleOlmCreationPhase ? 10000 : 2000, failedServers,
); );
logger.debug(`Shared keys (end phase 1) with new Olm sessions in ${this._roomId}`);
if (!singleOlmCreationPhase && (Date.now() - start < 10000)) { if (!singleOlmCreationPhase && (Date.now() - start < 10000)) {
// perform the second phase of olm session creation if requested, // perform the second phase of olm session creation if requested,
@@ -313,21 +314,24 @@ MegolmEncryption.prototype._ensureOutboundSession = async function(
} }
} }
logger.debug(`Sharing keys (start phase 2) with new Olm sessions in ${this._roomId}`);
await this._shareKeyWithDevices( await this._shareKeyWithDevices(
session, key, payload, retryDevices, failedDevices, 30000, session, key, payload, retryDevices, failedDevices, 30000,
); );
logger.debug(`Shared keys (end phase 2) with new Olm sessions in ${this._roomId}`);
await this._notifyFailedOlmDevices(session, key, failedDevices); await this._notifyFailedOlmDevices(session, key, failedDevices);
})(); })();
} else { } else {
await this._notifyFailedOlmDevices(session, key, errorDevices); await this._notifyFailedOlmDevices(session, key, errorDevices);
} }
logger.debug(`Shared keys with new Olm sessions in ${this._roomId}`); logger.debug(`Shared keys (all phases done) with new Olm sessions in ${this._roomId}`);
})(), })(),
(async () => { (async () => {
logger.debug(`Notifying blocked devices in ${this._roomId}`); logger.debug(`Notifying blocked devices in ${this._roomId}`);
// also, notify blocked devices that they're blocked // also, notify blocked devices that they're blocked
const blockedMap = {}; const blockedMap = {};
let blockedCount = 0;
for (const [userId, userBlockedDevices] of Object.entries(blocked)) { for (const [userId, userBlockedDevices] of Object.entries(blocked)) {
for (const [deviceId, device] of Object.entries(userBlockedDevices)) { for (const [deviceId, device] of Object.entries(userBlockedDevices)) {
if ( if (
@@ -336,12 +340,13 @@ MegolmEncryption.prototype._ensureOutboundSession = async function(
) { ) {
blockedMap[userId] = blockedMap[userId] || {}; blockedMap[userId] = blockedMap[userId] || {};
blockedMap[userId][deviceId] = { device }; blockedMap[userId][deviceId] = { device };
blockedCount++;
} }
} }
} }
await this._notifyBlockedDevices(session, blockedMap); await this._notifyBlockedDevices(session, blockedMap);
logger.debug(`Notified blocked devices in ${this._roomId}`); logger.debug(`Notified ${blockedCount} blocked devices in ${this._roomId}`);
})(), })(),
]); ]);
}; };
@@ -728,13 +733,18 @@ MegolmEncryption.prototype.reshareKeyWithDevice = async function(
MegolmEncryption.prototype._shareKeyWithDevices = async function( MegolmEncryption.prototype._shareKeyWithDevices = async function(
session, key, payload, devicesByUser, errorDevices, otkTimeout, failedServers, session, key, payload, devicesByUser, errorDevices, otkTimeout, failedServers,
) { ) {
logger.debug(`Ensuring Olm sessions for devices in ${this._roomId}`);
const devicemap = await olmlib.ensureOlmSessionsForDevices( const devicemap = await olmlib.ensureOlmSessionsForDevices(
this._olmDevice, this._baseApis, devicesByUser, otkTimeout, failedServers, this._olmDevice, this._baseApis, devicesByUser, otkTimeout, failedServers,
logger.withPrefix(`[${this._roomId}]`),
); );
logger.debug(`Ensured Olm sessions for devices in ${this._roomId}`);
this._getDevicesWithoutSessions(devicemap, devicesByUser, errorDevices); this._getDevicesWithoutSessions(devicemap, devicesByUser, errorDevices);
logger.debug(`Sharing keys with Olm sessions in ${this._roomId}`);
await this._shareKeyWithOlmSessions(session, key, payload, devicemap); await this._shareKeyWithOlmSessions(session, key, payload, devicemap);
logger.debug(`Shared keys with Olm sessions in ${this._roomId}`);
}; };
MegolmEncryption.prototype._shareKeyWithOlmSessions = async function( MegolmEncryption.prototype._shareKeyWithOlmSessions = async function(
@@ -772,6 +782,11 @@ MegolmEncryption.prototype._shareKeyWithOlmSessions = async function(
MegolmEncryption.prototype._notifyFailedOlmDevices = async function( MegolmEncryption.prototype._notifyFailedOlmDevices = async function(
session, key, failedDevices, session, key, failedDevices,
) { ) {
logger.debug(
`Notifying ${failedDevices.length} devices we failed to ` +
`create Olm sessions in ${this._roomId}`,
);
// mark the devices that failed as "handled" because we don't want to try // mark the devices that failed as "handled" because we don't want to try
// to claim a one-time-key for dead devices on every message. // to claim a one-time-key for dead devices on every message.
for (const {userId, deviceInfo} of failedDevices) { for (const {userId, deviceInfo} of failedDevices) {
@@ -786,6 +801,10 @@ MegolmEncryption.prototype._notifyFailedOlmDevices = async function(
await this._olmDevice.filterOutNotifiedErrorDevices( await this._olmDevice.filterOutNotifiedErrorDevices(
failedDevices, failedDevices,
); );
logger.debug(
`Filtered down to ${filteredFailedDevices.length} error devices ` +
`in ${this._roomId}`,
);
const blockedMap = {}; const blockedMap = {};
for (const {userId, deviceInfo} of filteredFailedDevices) { for (const {userId, deviceInfo} of filteredFailedDevices) {
blockedMap[userId] = blockedMap[userId] || {}; blockedMap[userId] = blockedMap[userId] || {};
@@ -803,6 +822,10 @@ MegolmEncryption.prototype._notifyFailedOlmDevices = async function(
// send the notifications // send the notifications
await this._notifyBlockedDevices(session, blockedMap); await this._notifyBlockedDevices(session, blockedMap);
logger.debug(
`Notified ${filteredFailedDevices.length} devices we failed to ` +
`create Olm sessions in ${this._roomId}`,
);
}; };
/** /**

View File

@@ -183,18 +183,24 @@ export async function getExistingOlmSessions(
* @param {Array} [failedServers] An array to fill with remote servers that * @param {Array} [failedServers] An array to fill with remote servers that
* failed to respond to one-time-key requests. * failed to respond to one-time-key requests.
* *
* @param {Logger} [log] A possibly customised log
*
* @return {Promise} resolves once the sessions are complete, to * @return {Promise} resolves once the sessions are complete, to
* an Object mapping from userId to deviceId to * an Object mapping from userId to deviceId to
* {@link module:crypto~OlmSessionResult} * {@link module:crypto~OlmSessionResult}
*/ */
export async function ensureOlmSessionsForDevices( export async function ensureOlmSessionsForDevices(
olmDevice, baseApis, devicesByUser, force, otkTimeout, failedServers, olmDevice, baseApis, devicesByUser, force, otkTimeout, failedServers, log,
) { ) {
if (typeof force === "number") { if (typeof force === "number") {
log = failedServers;
failedServers = otkTimeout; failedServers = otkTimeout;
otkTimeout = force; otkTimeout = force;
force = false; force = false;
} }
if (!log) {
log = logger;
}
const devicesWithoutSession = [ const devicesWithoutSession = [
// [userId, deviceId], ... // [userId, deviceId], ...
@@ -202,6 +208,39 @@ export async function ensureOlmSessionsForDevices(
const result = {}; const result = {};
const resolveSession = {}; 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)) { for (const [userId, devices] of Object.entries(devicesByUser)) {
result[userId] = {}; result[userId] = {};
for (const deviceInfo of devices) { for (const deviceInfo of devices) {
@@ -216,7 +255,7 @@ export async function ensureOlmSessionsForDevices(
// new chain when this side has an active sender chain. // new chain when this side has an active sender chain.
// If you see this message being logged in the wild, we should find // If you see this message being logged in the wild, we should find
// the thing that is trying to send Olm messages to itself and fix it. // the thing that is trying to send Olm messages to itself and fix it.
logger.info("Attempted to start session with ourself! Ignoring"); log.info("Attempted to start session with ourself! Ignoring");
// We must fill in the section in the return value though, as callers // We must fill in the section in the return value though, as callers
// expect it to be there. // expect it to be there.
result[userId][deviceId] = { result[userId][deviceId] = {
@@ -226,41 +265,23 @@ export async function ensureOlmSessionsForDevices(
continue; continue;
} }
if (!olmDevice._sessionsInProgress[key]) { const forWhom = `for ${key} (${userId}:${deviceId})`;
// pre-emptively mark the session as in-progress to avoid race log.debug(`Ensuring Olm session ${forWhom}`);
// 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 sessionId = await olmDevice.getSessionIdForDevice( const sessionId = await olmDevice.getSessionIdForDevice(
key, resolveSession[key], key, resolveSession[key], log,
); );
log.debug(`Got Olm session ${sessionId} ${forWhom}`);
if (sessionId !== null && resolveSession[key]) { if (sessionId !== null && resolveSession[key]) {
// we found a session, but we had marked the session as // we found a session, but we had marked the session as
// in-progress, so unmark it and unblock anything that was // in-progress, so resolve it now, which will unmark it and
// waiting // unblock anything that was waiting
delete olmDevice._sessionsInProgress[key]; resolveSession[key]();
resolveSession[key].resolve();
delete resolveSession[key];
} }
if (sessionId === null || force) { if (sessionId === null || force) {
if (force) { if (force) {
logger.info("Forcing new Olm session for " + userId + ":" + deviceId); log.info(`Forcing new Olm session ${forWhom}`);
} else { } else {
logger.info("Making new Olm session for " + userId + ":" + deviceId); log.info(`Making new Olm session ${forWhom}`);
} }
devicesWithoutSession.push([userId, deviceId]); devicesWithoutSession.push([userId, deviceId]);
} }
@@ -283,20 +304,24 @@ export async function ensureOlmSessionsForDevices(
// timeout on this request, let's first log whether that's the root // timeout on this request, let's first log whether that's the root
// cause we're seeing in practice. // cause we're seeing in practice.
// See also https://github.com/vector-im/element-web/issues/16194 // See also https://github.com/vector-im/element-web/issues/16194
const otkTimeoutLogger = setTimeout(() => { let otkTimeoutLogger;
logger.error(`Homeserver never replied while claiming ${taskDetail}`); // XXX: Perhaps there should be a default timeout?
if (otkTimeout) {
otkTimeoutLogger = setTimeout(() => {
log.error(`Homeserver never replied while claiming ${taskDetail}`);
}, otkTimeout); }, otkTimeout);
}
try { try {
logger.debug(`Claiming ${taskDetail}`); log.debug(`Claiming ${taskDetail}`);
res = await baseApis.claimOneTimeKeys( res = await baseApis.claimOneTimeKeys(
devicesWithoutSession, oneTimeKeyAlgorithm, otkTimeout, devicesWithoutSession, oneTimeKeyAlgorithm, otkTimeout,
); );
logger.debug(`Claimed ${taskDetail}`); log.debug(`Claimed ${taskDetail}`);
} catch (e) { } catch (e) {
for (const resolver of Object.values(resolveSession)) { for (const resolver of Object.values(resolveSession)) {
resolver.resolve(); resolver();
} }
logger.log(`Failed to claim ${taskDetail}`, e, devicesWithoutSession); log.log(`Failed to claim ${taskDetail}`, e, devicesWithoutSession);
throw e; throw e;
} finally { } finally {
clearTimeout(otkTimeoutLogger); clearTimeout(otkTimeoutLogger);
@@ -306,10 +331,10 @@ export async function ensureOlmSessionsForDevices(
failedServers.push(...Object.keys(res.failures)); failedServers.push(...Object.keys(res.failures));
} }
const otk_res = res.one_time_keys || {}; const otkResult = res.one_time_keys || {};
const promises = []; const promises = [];
for (const [userId, devices] of Object.entries(devicesByUser)) { 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++) { for (let j = 0; j < devices.length; j++) {
const deviceInfo = devices[j]; const deviceInfo = devices[j];
const deviceId = deviceInfo.deviceId; const deviceId = deviceInfo.deviceId;
@@ -336,11 +361,12 @@ export async function ensureOlmSessionsForDevices(
} }
if (!oneTimeKey) { if (!oneTimeKey) {
const msg = "No one-time keys (alg=" + oneTimeKeyAlgorithm + log.warn(
") for device " + userId + ":" + deviceId; `No one-time keys (alg=${oneTimeKeyAlgorithm}) ` +
logger.warn(msg); `for device ${userId}:${deviceId}`,
);
if (resolveSession[key]) { if (resolveSession[key]) {
resolveSession[key].resolve(); resolveSession[key]();
} }
continue; continue;
} }
@@ -350,12 +376,12 @@ export async function ensureOlmSessionsForDevices(
olmDevice, oneTimeKey, userId, deviceInfo, olmDevice, oneTimeKey, userId, deviceInfo,
).then((sid) => { ).then((sid) => {
if (resolveSession[key]) { if (resolveSession[key]) {
resolveSession[key].resolve(sid); resolveSession[key](sid);
} }
result[userId][deviceId].sessionId = sid; result[userId][deviceId].sessionId = sid;
}, (e) => { }, (e) => {
if (resolveSession[key]) { if (resolveSession[key]) {
resolveSession[key].resolve(); resolveSession[key]();
} }
throw e; throw e;
}), }),
@@ -364,9 +390,9 @@ export async function ensureOlmSessionsForDevices(
} }
taskDetail = `Olm sessions for ${promises.length} devices`; taskDetail = `Olm sessions for ${promises.length} devices`;
logger.debug(`Starting ${taskDetail}`); log.debug(`Starting ${taskDetail}`);
await Promise.all(promises); await Promise.all(promises);
logger.debug(`Started ${taskDetail}`); log.debug(`Started ${taskDetail}`);
return result; return result;
} }

View File

@@ -34,6 +34,7 @@ export class Backend {
*/ */
constructor(db) { constructor(db) {
this._db = db; this._db = db;
this._nextTxnId = 0;
// make sure we close the db on `onversionchange` - otherwise // make sure we close the db on `onversionchange` - otherwise
// attempts to delete the database will block (and subsequent // 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 txn = this._db.transaction(stores, mode);
const promise = promiseifyTxn(txn); const promise = promiseifyTxn(txn);
const result = func(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 promise.then(() => {
return result; return result;
}); });

View File

@@ -596,6 +596,7 @@ export class IndexedDBCryptoStore {
* @param {function(*)} func Function called with the * @param {function(*)} func Function called with the
* transaction object: an opaque object that should be passed * transaction object: an opaque object that should be passed
* to store functions. * to store functions.
* @param {Logger} [log] A possibly customised log
* @return {Promise} Promise that resolves with the result of the `func` * @return {Promise} Promise that resolves with the result of the `func`
* when the transaction is complete. If the backend is * when the transaction is complete. If the backend is
* async (ie. the indexeddb backend) any of the callback * async (ie. the indexeddb backend) any of the callback
@@ -603,8 +604,8 @@ export class IndexedDBCryptoStore {
* reject with that exception. On synchronous backends, the * reject with that exception. On synchronous backends, the
* exception will propagate to the caller of the getFoo method. * exception will propagate to the caller of the getFoo method.
*/ */
doTxn(mode, stores, func) { doTxn(mode, stores, func, log) {
return this._backend.doTxn(mode, stores, func); return this._backend.doTxn(mode, stores, func, log);
} }
} }

View File

@@ -1,6 +1,6 @@
/* /*
Copyright 2018 André Jaenisch Copyright 2018 André Jaenisch
Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -19,7 +19,7 @@ limitations under the License.
* @module logger * @module logger
*/ */
import log from "loglevel"; import log, { Logger } from "loglevel";
// This is to demonstrate, that you can use any namespace you want. // This is to demonstrate, that you can use any namespace you want.
// Namespaces allow you to turn on/off the logging for specific parts of the // Namespaces allow you to turn on/off the logging for specific parts of the
@@ -36,6 +36,11 @@ const DEFAULT_NAMESPACE = "matrix";
// when logging so we always get the current value of console methods. // when logging so we always get the current value of console methods.
log.methodFactory = function(methodName, logLevel, loggerName) { log.methodFactory = function(methodName, logLevel, loggerName) {
return function(...args) { return function(...args) {
/* eslint-disable babel/no-invalid-this */
if (this.prefix) {
args.unshift(this.prefix);
}
/* eslint-enable babel/no-invalid-this */
const supportedByConsole = methodName === "error" || const supportedByConsole = methodName === "error" ||
methodName === "warn" || methodName === "warn" ||
methodName === "trace" || methodName === "trace" ||
@@ -54,6 +59,30 @@ log.methodFactory = function(methodName, logLevel, loggerName) {
* Drop-in replacement for <code>console</code> using {@link https://www.npmjs.com/package/loglevel|loglevel}. * Drop-in replacement for <code>console</code> using {@link https://www.npmjs.com/package/loglevel|loglevel}.
* Can be tailored down to specific use cases if needed. * 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); logger.setLevel(log.levels.DEBUG);
interface PrefixedLogger extends Logger {
withPrefix?: (prefix: string) => PrefixedLogger;
prefix?: string;
}
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);
}
return prefixLogger;
}

View File

@@ -52,7 +52,7 @@ export * from "./store/session/webstorage";
export * from "./crypto/store/memory-crypto-store"; export * from "./crypto/store/memory-crypto-store";
export * from "./crypto/store/indexeddb-crypto-store"; export * from "./crypto/store/indexeddb-crypto-store";
export * from "./content-repo"; export * from "./content-repo";
export const ContentHelpers = import("./content-helpers"); export * as ContentHelpers from "./content-helpers";
export { export {
createNewMatrixCall, createNewMatrixCall,
setAudioOutput as setMatrixCallAudioOutput, setAudioOutput as setMatrixCallAudioOutput,

View File

@@ -290,6 +290,9 @@ RoomMember.prototype.getMxcAvatarUrl = function() {
return null; return null;
}; };
const MXID_PATTERN = /@.+:.+/;
const LTR_RTL_PATTERN = /[\u200E\u200F\u202A-\u202F]/;
function calculateDisplayName(selfUserId, displayName, roomState) { function calculateDisplayName(selfUserId, displayName, roomState) {
if (!displayName || displayName === selfUserId) { if (!displayName || displayName === selfUserId) {
return selfUserId; return selfUserId;
@@ -308,13 +311,13 @@ function calculateDisplayName(selfUserId, displayName, roomState) {
// Next check if the name contains something that look like a mxid // Next check if the name contains something that look like a mxid
// If it does, it may be someone trying to impersonate someone else // If it does, it may be someone trying to impersonate someone else
// Show full mxid in this case // Show full mxid in this case
let disambiguate = /@.+:.+/.test(displayName); let disambiguate = MXID_PATTERN.test(displayName);
if (!disambiguate) { if (!disambiguate) {
// Also show mxid if the display name contains any LTR/RTL characters as these // 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 // make it very difficult for us to find similar *looking* display names
// E.g "Mark" could be cloned by writing "kraM" but in RTL. // 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) { if (!disambiguate) {

View File

@@ -30,7 +30,7 @@ import {RoomMember} from "./room-member";
import {RoomSummary} from "./room-summary"; import {RoomSummary} from "./room-summary";
import {logger} from '../logger'; import {logger} from '../logger';
import {ReEmitter} from '../ReEmitter'; 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 // 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 // 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(); 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 * This is an internal method. Calculates the name of the room from the current
* room state. * room state.

View File

@@ -257,8 +257,6 @@ export class MatrixCall extends EventEmitter {
private localAVStream: MediaStream; private localAVStream: MediaStream;
private inviteOrAnswerSent: boolean; private inviteOrAnswerSent: boolean;
private waitForLocalAVStream: 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'. // XXX: I don't know why this is called 'config'.
private config: MediaStreamConstraints; private config: MediaStreamConstraints;
private successor: MatrixCall; private successor: MatrixCall;
@@ -290,6 +288,11 @@ export class MatrixCall extends EventEmitter {
private makingOffer: boolean; private makingOffer: boolean;
private ignoreOffer: 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<string, RTCIceCandidate[]>();
constructor(opts: CallOpts) { constructor(opts: CallOpts) {
super(); super();
this.roomId = opts.roomId; this.roomId = opts.roomId;
@@ -297,9 +300,6 @@ export class MatrixCall extends EventEmitter {
this.type = null; this.type = null;
this.forceTURN = opts.forceTURN; this.forceTURN = opts.forceTURN;
this.ourPartyId = this.client.deviceId; 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 // Array of Objects with urls, username, credential keys
this.turnServers = opts.turnServers || []; this.turnServers = opts.turnServers || [];
if (this.turnServers.length === 0 && this.client.isFallbackICEServerAllowed()) { if (this.turnServers.length === 0 && this.client.isFallbackICEServerAllowed()) {
@@ -524,11 +524,16 @@ export class MatrixCall extends EventEmitter {
* @param {MatrixEvent} event The m.call.invite event * @param {MatrixEvent} event The m.call.invite event
*/ */
async initWithInvite(event: MatrixEvent) { async initWithInvite(event: MatrixEvent) {
this.msg = event.getContent(); const invite = event.getContent();
this.direction = CallDirection.Inbound; this.direction = CallDirection.Inbound;
this.peerConn = this.createPeerConnection(); 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 { try {
await this.peerConn.setRemoteDescription(this.msg.offer); await this.peerConn.setRemoteDescription(invite.offer);
} catch (e) { } catch (e) {
logger.debug("Failed to set remote description", e); logger.debug("Failed to set remote description", e);
this.terminate(CallParty.Local, CallErrorCode.SetRemoteDescription, false); this.terminate(CallParty.Local, CallErrorCode.SetRemoteDescription, false);
@@ -547,13 +552,6 @@ export class MatrixCall extends EventEmitter {
this.type = this.remoteStream.getTracks().some(t => t.kind === 'video') ? CallType.Video : CallType.Voice; this.type = this.remoteStream.getTracks().some(t => t.kind === 'video') ? CallType.Video : CallType.Voice;
this.setState(CallState.Ringing); 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()) { if (event.getLocalAge()) {
setTimeout(() => { setTimeout(() => {
@@ -567,7 +565,7 @@ export class MatrixCall extends EventEmitter {
} }
this.emit(CallEvent.Hangup); this.emit(CallEvent.Hangup);
} }
}, this.msg.lifetime - event.getLocalAge()); }, invite.lifetime - event.getLocalAge());
} }
} }
@@ -579,7 +577,6 @@ export class MatrixCall extends EventEmitter {
// perverse as it may seem, sometimes we want to instantiate a call with a // 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 // 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) // come in reverse order and we want to remember that a call has been hung up)
this.msg = event.getContent();
this.setState(CallState.Ended); this.setState(CallState.Ended);
} }
@@ -873,7 +870,7 @@ export class MatrixCall extends EventEmitter {
// Now we wait for the negotiationneeded event // Now we wait for the negotiationneeded event
}; };
private sendAnswer() { private async sendAnswer() {
const answerContent = { const answerContent = {
answer: { answer: {
sdp: this.peerConn.localDescription.sdp, sdp: this.peerConn.localDescription.sdp,
@@ -895,12 +892,12 @@ export class MatrixCall extends EventEmitter {
logger.info(`Discarding ${this.candidateSendQueue.length} candidates that will be sent in answer`); logger.info(`Discarding ${this.candidateSendQueue.length} candidates that will be sent in answer`);
this.candidateSendQueue = []; 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, // If this isn't the first time we've tried to send the answer,
// we may have candidates queued up, so send them now. // we may have candidates queued up, so send them now.
this.inviteOrAnswerSent = true; this.inviteOrAnswerSent = true;
this.sendCandidateQueue(); } catch (error) {
}).catch((error) => {
// We've failed to answer: back to the ringing state // We've failed to answer: back to the ringing state
this.setState(CallState.Ringing); this.setState(CallState.Ringing);
this.client.cancelPendingEvent(error.event); this.client.cancelPendingEvent(error.event);
@@ -913,7 +910,11 @@ export class MatrixCall extends EventEmitter {
} }
this.emit(CallEvent.Error, new CallError(code, message, error)); this.emit(CallEvent.Error, new CallError(code, message, error));
throw 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) => { private gotUserMediaForAnswer = async (stream: MediaStream) => {
@@ -1017,37 +1018,33 @@ export class MatrixCall extends EventEmitter {
return; 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; const cands = ev.getContent().candidates;
if (!cands) { if (!cands) {
logger.info("Ignoring candidates event with no candidates!"); logger.info("Ignoring candidates event with no candidates!");
return; return;
} }
for (const cand of cands) { const fromPartyId = ev.getContent().version === 0 ? null : ev.getContent().party_id || null;
if (
(cand.sdpMid === null || cand.sdpMid === undefined) && if (this.opponentPartyId === undefined) {
(cand.sdpMLineIndex === null || cand.sdpMLineIndex === undefined) // we haven't picked an opponent yet so save the candidates
) { logger.info(`Bufferring ${cands.length} candidates until we pick an opponent`);
logger.debug("Ignoring remote ICE candidate with no sdpMid or sdpMLineIndex"); const bufferedCands = this.remoteCandidateBuffer.get(fromPartyId) || [];
bufferedCands.push(...cands);
this.remoteCandidateBuffer.set(fromPartyId, bufferedCands);
return; return;
} }
logger.debug("Got remote ICE " + cand.sdpMid + " candidate: " + cand.candidate);
try { if (!this.partyIdMatches(ev.getContent())) {
this.peerConn.addIceCandidate(cand); logger.info(
} catch (err) { `Ignoring candidates from party ID ${ev.getContent().party_id}: ` +
if (!this.ignoreOffer) { `we have chosen party ID ${this.opponentPartyId}`,
logger.info("Failed to add remore ICE candidate", err); );
}
} return;
} }
this.addIceCandidates(cands);
} }
/** /**
@@ -1059,7 +1056,7 @@ export class MatrixCall extends EventEmitter {
return; return;
} }
if (this.opponentPartyId !== null) { if (this.opponentPartyId !== undefined) {
logger.info( logger.info(
`Ignoring answer from party ID ${event.getContent().party_id}: ` + `Ignoring answer from party ID ${event.getContent().party_id}: ` +
`we already have an answer/reject from ${this.opponentPartyId}`, `we already have an answer/reject from ${this.opponentPartyId}`,
@@ -1067,12 +1064,7 @@ export class MatrixCall extends EventEmitter {
return; return;
} }
this.opponentVersion = event.getContent().version; this.chooseOpponent(event);
if (this.opponentVersion !== 0) {
this.opponentPartyId = event.getContent().party_id || null;
}
this.opponentCaps = event.getContent().capabilities || {};
this.opponentMember = event.sender;
this.setState(CallState.Connecting); this.setState(CallState.Connecting);
@@ -1247,19 +1239,9 @@ export class MatrixCall extends EventEmitter {
try { try {
await this.sendVoipEvent(eventType, content); 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) { } 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 code = CallErrorCode.SignallingFailed;
let message = "Signalling failed"; let message = "Signalling failed";
@@ -1274,6 +1256,22 @@ export class MatrixCall extends EventEmitter {
this.emit(CallEvent.Error, new CallError(code, message, error)); this.emit(CallEvent.Error, new CallError(code, message, error));
this.terminate(CallParty.Local, code, false); 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);
} }
}; };
@@ -1610,7 +1608,7 @@ export class MatrixCall extends EventEmitter {
} }
} }
private sendCandidateQueue() { private async sendCandidateQueue() {
if (this.candidateSendQueue.length === 0) { if (this.candidateSendQueue.length === 0) {
return; return;
} }
@@ -1622,20 +1620,28 @@ export class MatrixCall extends EventEmitter {
candidates: cands, candidates: cands,
}; };
logger.debug("Attempting to send " + cands.length + " candidates"); logger.debug("Attempting to send " + cands.length + " candidates");
this.sendVoipEvent(EventType.CallCandidates, content).then(() => { try {
this.candidateSendTries = 0; await this.sendVoipEvent(EventType.CallCandidates, content);
this.sendCandidateQueue(); } catch (error) {
}, (error) => { // don't retry this event: we'll send another one later as we might
for (let i = 0; i < cands.length; i++) { // have more candidates by then.
this.candidateSendQueue.push(cands[i]); 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) { if (this.candidateSendTries > 5) {
logger.debug( logger.debug(
"Failed to send candidates on attempt " + this.candidateSendTries + "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; return;
} }
@@ -1645,7 +1651,7 @@ export class MatrixCall extends EventEmitter {
setTimeout(() => { setTimeout(() => {
this.sendCandidateQueue(); this.sendCandidateQueue();
}, delayMs); }, delayMs);
}); }
} }
private async placeCallWithConstraints(constraints: MediaStreamConstraints) { private async placeCallWithConstraints(constraints: MediaStreamConstraints) {
@@ -1689,9 +1695,60 @@ export class MatrixCall extends EventEmitter {
private partyIdMatches(msg): boolean { private partyIdMatches(msg): boolean {
// They must either match or both be absent (in which case opponentPartyId will be null) // 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; 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<MediaStreamTrack>, enabled: boolean) { function setTracksEnabled(tracks: Array<MediaStreamTrack>, enabled: boolean) {

View File

@@ -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(), { call = createNewMatrixCall(this.client, event.getRoomId(), {
forceTURN: this.client._forceTURN, forceTURN: this.client._forceTURN,
}); });

View File

@@ -5069,11 +5069,16 @@ lodash.sortby@^4.7.0:
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= 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" version "4.17.20"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== 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: loglevel@^1.7.1:
version "1.7.1" version "1.7.1"
resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.7.1.tgz#005fde2f5e6e47068f935ff28573e125ef72f197" 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-runtime "^2.0.5"
pug-code-gen@^2.0.2: pug-code-gen@^2.0.2:
version "2.0.2" version "2.0.3"
resolved "https://registry.yarnpkg.com/pug-code-gen/-/pug-code-gen-2.0.2.tgz#ad0967162aea077dcf787838d94ed14acb0217c2" resolved "https://registry.yarnpkg.com/pug-code-gen/-/pug-code-gen-2.0.3.tgz#122eb9ada9b5bf601705fe15aaa0a7d26bc134ab"
integrity sha512-kROFWv/AHx/9CRgoGJeRSm+4mLWchbgpRzTEn8XCiwwOy6Vh0gAClS8Vh5TEJ9DBjaP8wCjS3J6HKsEsYdvaCw== integrity sha512-r9sezXdDuZJfW9J91TN/2LFbiqDhmltTFmGpHTsGdrNGp3p4SxAjjXEfnuK2e4ywYsRIVP0NeLbSAMHUcaX1EA==
dependencies: dependencies:
constantinople "^3.1.2" constantinople "^3.1.2"
doctypes "^1.1.0" doctypes "^1.1.0"