1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-11-25 05:23:13 +03:00

Merge branch 'develop' into room-history-key-sharing2

This commit is contained in:
Hubert Chathi
2021-03-25 12:24:42 -04:00
24 changed files with 631 additions and 235 deletions

View File

@@ -1,3 +1,58 @@
Changes in [9.9.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.9.0) (2021-03-15)
================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.9.0-rc.1...v9.9.0)
* No changes since rc.1
Changes in [9.9.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.9.0-rc.1) (2021-03-10)
==========================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.8.0...v9.9.0-rc.1)
* Remove detailed Olm session logging
[\#1638](https://github.com/matrix-org/matrix-js-sdk/pull/1638)
* Add space summary suggested only param
[\#1637](https://github.com/matrix-org/matrix-js-sdk/pull/1637)
* Check TURN servers periodically, and at start of calls
[\#1634](https://github.com/matrix-org/matrix-js-sdk/pull/1634)
* Support sending invite reasons
[\#1624](https://github.com/matrix-org/matrix-js-sdk/pull/1624)
* Bump elliptic from 6.5.3 to 6.5.4
[\#1636](https://github.com/matrix-org/matrix-js-sdk/pull/1636)
* Add a function to get a room's MXC URI
[\#1635](https://github.com/matrix-org/matrix-js-sdk/pull/1635)
* Stop streams if the call has ended
[\#1633](https://github.com/matrix-org/matrix-js-sdk/pull/1633)
* Remove export keyword from global.d.ts
[\#1631](https://github.com/matrix-org/matrix-js-sdk/pull/1631)
* Fix IndexedDB store creation example
[\#1445](https://github.com/matrix-org/matrix-js-sdk/pull/1445)
* An attempt to cleanup how constraints are handled in calls
[\#1613](https://github.com/matrix-org/matrix-js-sdk/pull/1613)
* Extract display name patterns to constants
[\#1628](https://github.com/matrix-org/matrix-js-sdk/pull/1628)
* Bump pug-code-gen from 2.0.2 to 2.0.3
[\#1630](https://github.com/matrix-org/matrix-js-sdk/pull/1630)
* Avoid deadlocks when ensuring Olm sessions for devices
[\#1627](https://github.com/matrix-org/matrix-js-sdk/pull/1627)
* Filter out edits from other senders in history
[\#1626](https://github.com/matrix-org/matrix-js-sdk/pull/1626)
* Fix ContentHelpers export
[\#1618](https://github.com/matrix-org/matrix-js-sdk/pull/1618)
* Add logging to in progress Olm sessions
[\#1621](https://github.com/matrix-org/matrix-js-sdk/pull/1621)
* Don't ignore ICE candidates received before offer/answer
[\#1623](https://github.com/matrix-org/matrix-js-sdk/pull/1623)
* Better handling of send failures on VoIP events
[\#1622](https://github.com/matrix-org/matrix-js-sdk/pull/1622)
* Log when turn creds expire
[\#1620](https://github.com/matrix-org/matrix-js-sdk/pull/1620)
* Initial Spaces [MSC1772] support
[\#1563](https://github.com/matrix-org/matrix-js-sdk/pull/1563)
* Add logging to crypto store transactions
[\#1617](https://github.com/matrix-org/matrix-js-sdk/pull/1617)
* Room helper for getting type and checking if it is a space room
[\#1610](https://github.com/matrix-org/matrix-js-sdk/pull/1610)
Changes in [9.8.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.8.0) (2021-03-01) 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) [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.8.0-rc.1...v9.8.0)

View File

@@ -1,6 +1,6 @@
{ {
"name": "matrix-js-sdk", "name": "matrix-js-sdk",
"version": "9.8.0", "version": "9.9.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

@@ -51,6 +51,36 @@ const signedDeviceList = {
}, },
}; };
const signedDeviceList2 = {
"failures": {},
"device_keys": {
"@test2:sw1v.org": {
"QJVRHWAKGH": {
"signatures": {
"@test2:sw1v.org": {
"ed25519:QJVRHWAKGH":
"w1xxdLe1iIqzEFHLRVYQeuiM6t2N2ZRiI8s5nDKxf054BP8" +
"1CPEX/AQXh5BhkKAVMlKnwg4T9zU1/wBALeajk3",
},
},
"user_id": "@test2:sw1v.org",
"keys": {
"ed25519:QJVRHWAKGH":
"Ig0/C6T+bBII1l2By2Wnnvtjp1nm/iXBlLU5/QESFXL",
"curve25519:QJVRHWAKGH":
"YR3eQnUvTQzGlWih4rsmJkKxpDxzgkgIgsBd1DEZIbm",
},
"algorithms": [
"m.olm.v1.curve25519-aes-sha2",
"m.megolm.v1.aes-sha2",
],
"device_id": "QJVRHWAKGH",
"unsigned": {},
},
},
},
};
describe('DeviceList', function() { describe('DeviceList', function() {
let downloadSpy; let downloadSpy;
let cryptoStore; let cryptoStore;
@@ -69,7 +99,7 @@ describe('DeviceList', function() {
} }
}); });
function createTestDeviceList() { function createTestDeviceList(keyDownloadChunkSize = 250) {
const baseApis = { const baseApis = {
downloadKeysForUsers: downloadSpy, downloadKeysForUsers: downloadSpy,
getUserId: () => '@test1:sw1v.org', getUserId: () => '@test1:sw1v.org',
@@ -78,7 +108,7 @@ describe('DeviceList', function() {
const mockOlm = { const mockOlm = {
verifySignature: function(key, message, signature) {}, verifySignature: function(key, message, signature) {},
}; };
const dl = new DeviceList(baseApis, cryptoStore, mockOlm); const dl = new DeviceList(baseApis, cryptoStore, mockOlm, keyDownloadChunkSize);
deviceLists.push(dl); deviceLists.push(dl);
return dl; return dl;
} }
@@ -150,4 +180,30 @@ describe('DeviceList', function() {
expect(Object.keys(storedKeys)).toEqual(['HGKAWHRVJQ']); expect(Object.keys(storedKeys)).toEqual(['HGKAWHRVJQ']);
}); });
}); });
it("should download device keys in batches", function() {
const dl = createTestDeviceList(1);
dl.startTrackingDeviceList('@test1:sw1v.org');
dl.startTrackingDeviceList('@test2:sw1v.org');
const queryDefer1 = utils.defer();
downloadSpy.mockReturnValueOnce(queryDefer1.promise);
const queryDefer2 = utils.defer();
downloadSpy.mockReturnValueOnce(queryDefer2.promise);
const prom1 = dl.refreshOutdatedDeviceLists();
expect(downloadSpy).toBeCalledTimes(2);
expect(downloadSpy).toHaveBeenNthCalledWith(1, ['@test1:sw1v.org'], {});
expect(downloadSpy).toHaveBeenNthCalledWith(2, ['@test2:sw1v.org'], {});
queryDefer1.resolve(utils.deepCopy(signedDeviceList));
queryDefer2.resolve(utils.deepCopy(signedDeviceList2));
return prom1.then(() => {
const storedKeys1 = dl.getRawStoredDevicesForUser('@test1:sw1v.org');
expect(Object.keys(storedKeys1)).toEqual(['HGKAWHRVJQ']);
const storedKeys2 = dl.getRawStoredDevicesForUser('@test2:sw1v.org');
expect(Object.keys(storedKeys2)).toEqual(['QJVRHWAKGH']);
});
});
}); });

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

@@ -193,7 +193,9 @@ describe("Cross Signing", function() {
const keyChangePromise = new Promise((resolve, reject) => { const keyChangePromise = new Promise((resolve, reject) => {
alice.once("crossSigning.keysChanged", async (e) => { alice.once("crossSigning.keysChanged", async (e) => {
resolve(e); resolve(e);
await alice.checkOwnCrossSigningTrust(); await alice.checkOwnCrossSigningTrust({
allowPrivateKeyRequests: true,
});
}); });
}); });

View File

@@ -282,4 +282,30 @@ describe("utils", function() {
expect(target.nonenumerableProp).toBe(undefined); expect(target.nonenumerableProp).toBe(undefined);
}); });
}); });
describe("chunkPromises", function() {
it("should execute promises in chunks", async function() {
let promiseCount = 0;
function fn1() {
return new Promise(async function(resolve, reject) {
await utils.sleep(1);
expect(promiseCount).toEqual(0);
++promiseCount;
resolve();
});
}
function fn2() {
return new Promise(function(resolve, reject) {
expect(promiseCount).toEqual(1);
++promiseCount;
resolve();
});
}
await utils.chunkPromises([fn1, fn2], 1);
expect(promiseCount).toEqual(2);
});
});
}); });

View File

@@ -79,6 +79,7 @@ class MockRTCPeerConnection {
return Promise.resolve(); return Promise.resolve();
} }
close() {} close() {}
getStats() { return []; }
} }
describe('Call', function() { describe('Call', function() {
@@ -122,6 +123,7 @@ describe('Call', function() {
// We just stub out sendEvent: we're not interested in testing the client's // We just stub out sendEvent: we're not interested in testing the client's
// event sending code here // event sending code here
client.client.sendEvent = () => {}; client.client.sendEvent = () => {};
client.httpBackend.when("GET", "/voip/turnServer").respond(200, {});
call = new MatrixCall({ call = new MatrixCall({
client: client.client, client: client.client,
roomId: '!foo:bar', roomId: '!foo:bar',
@@ -138,7 +140,9 @@ describe('Call', function() {
}); });
it('should ignore candidate events from non-matching party ID', async function() { it('should ignore candidate events from non-matching party ID', async function() {
await call.placeVoiceCall(); const callPromise = call.placeVoiceCall();
await client.httpBackend.flush();
await callPromise;
await call.onAnswerReceived({ await call.onAnswerReceived({
getContent: () => { getContent: () => {
return { return {
@@ -192,7 +196,9 @@ describe('Call', function() {
}); });
it('should add candidates received before answer if party ID is correct', async function() { it('should add candidates received before answer if party ID is correct', async function() {
await call.placeVoiceCall(); const callPromise = call.placeVoiceCall();
await client.httpBackend.flush();
await callPromise;
call.peerConn.addIceCandidate = jest.fn(); call.peerConn.addIceCandidate = jest.fn();
call.onRemoteIceCandidatesReceived({ call.onRemoteIceCandidatesReceived({

View File

@@ -41,7 +41,7 @@ declare global {
getUserMedia(constraints: MediaStreamConstraints | DesktopCapturerConstraints): Promise<MediaStream>; getUserMedia(constraints: MediaStreamConstraints | DesktopCapturerConstraints): Promise<MediaStream>;
} }
export interface DesktopCapturerConstraints { interface DesktopCapturerConstraints {
audio: boolean | { audio: boolean | {
mandatory: { mandatory: {
chromeMediaSource: string; chromeMediaSource: string;
@@ -56,7 +56,7 @@ declare global {
}; };
} }
export interface DesktopCapturerSource { interface DesktopCapturerSource {
id: string; id: string;
name: string; name: string;
thumbnailURL: string; thumbnailURL: string;

View File

@@ -246,10 +246,25 @@ MatrixBaseApis.prototype.register = function(
/** /**
* Register a guest account. * Register a guest account.
* This method returns the auth info needed to create a new authenticated client,
* Remember to call `setGuest(true)` on the (guest-)authenticated client, e.g:
* ```javascript
* const tmpClient = await sdk.createClient(MATRIX_INSTANCE);
* const { user_id, device_id, access_token } = tmpClient.registerGuest();
* const client = createClient({
* baseUrl: MATRIX_INSTANCE,
* accessToken: access_token,
* userId: user_id,
* deviceId: device_id,
* })
* client.setGuest(true);
* ```
*
* @param {Object=} opts Registration options * @param {Object=} opts Registration options
* @param {Object} opts.body JSON HTTP body to provide. * @param {Object} opts.body JSON HTTP body to provide.
* @param {module:client.callback} callback Optional. * @param {module:client.callback} callback Optional.
* @return {Promise} Resolves: TODO * @return {Promise} Resolves: JSON object that contains:
* { user_id, device_id, access_token, home_server }
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
MatrixBaseApis.prototype.registerGuest = function(opts, callback) { MatrixBaseApis.prototype.registerGuest = function(opts, callback) {
@@ -1518,6 +1533,21 @@ MatrixBaseApis.prototype.getDevices = function() {
); );
}; };
/**
* Gets specific device details for the logged-in user
* @param {string} device_id device to query
* @return {Promise} Resolves: result object
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
MatrixBaseApis.prototype.getDevice = function(device_id) {
const path = utils.encodeUri("/devices/$device_id", {
$device_id: device_id,
});
return this._http.authedRequest(
undefined, 'GET', path, undefined, undefined,
);
};
/** /**
* Update the given device * Update the given device
* *
@@ -2378,18 +2408,27 @@ MatrixBaseApis.prototype.reportEvent = function(roomId, eventId, score, reason)
* Fetches or paginates a summary of a space as defined by MSC2946 * 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 {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 {number?} maxRoomsPerSpace The maximum number of rooms to return per subspace.
* @param {boolean?} suggestedOnly Whether to only return rooms with suggested=true.
* @param {boolean?} autoJoinOnly Whether to only return rooms with auto_join=true. * @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 {number?} limit The maximum number of rooms to return in total.
* @param {string?} batch The opaque token to paginate a previous summary request. * @param {string?} batch The opaque token to paginate a previous summary request.
* @returns {Promise} the response, with next_batch, rooms, events fields. * @returns {Promise} the response, with next_batch, rooms, events fields.
*/ */
MatrixBaseApis.prototype.getSpaceSummary = function(roomId, maxRoomsPerSpace, autoJoinOnly, limit, batch) { MatrixBaseApis.prototype.getSpaceSummary = function(
roomId,
maxRoomsPerSpace,
suggestedOnly,
autoJoinOnly,
limit,
batch,
) {
const path = utils.encodeUri("/rooms/$roomId/spaces", { const path = utils.encodeUri("/rooms/$roomId/spaces", {
$roomId: roomId, $roomId: roomId,
}); });
return this._http.authedRequest(undefined, "POST", path, null, { return this._http.authedRequest(undefined, "POST", path, null, {
max_rooms_per_space: maxRoomsPerSpace, max_rooms_per_space: maxRoomsPerSpace,
suggested_only: suggestedOnly,
auto_join_only: autoJoinOnly, auto_join_only: autoJoinOnly,
limit, limit,
batch, batch,

View File

@@ -61,6 +61,7 @@ import {DEHYDRATION_ALGORITHM} from "./crypto/dehydration";
const SCROLLBACK_DELAY_MS = 3000; const SCROLLBACK_DELAY_MS = 3000;
export const CRYPTO_ENABLED = isCryptoAvailable(); export const CRYPTO_ENABLED = isCryptoAvailable();
const CAPABILITIES_CACHE_MS = 21600000; // 6 hours - an arbitrary value const CAPABILITIES_CACHE_MS = 21600000; // 6 hours - an arbitrary value
const TURN_CHECK_INTERVAL = 10 * 60 * 1000; // poll for turn credentials every 10 minutes
function keysFromRecoverySession(sessions, decryptionKey, roomId) { function keysFromRecoverySession(sessions, decryptionKey, roomId) {
const keys = []; const keys = [];
@@ -394,7 +395,8 @@ export function MatrixClient(opts) {
this._clientWellKnownPromise = undefined; this._clientWellKnownPromise = undefined;
this._turnServers = []; this._turnServers = [];
this._turnServersExpiry = null; this._turnServersExpiry = 0;
this._checkTurnServersIntervalID = 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.
@@ -498,19 +500,8 @@ MatrixClient.prototype.rehydrateDevice = async function() {
return; return;
} }
let getDeviceResult; const getDeviceResult = this.getDehydratedDevice();
try { if (!getDeviceResult) {
getDeviceResult = await this._http.authedRequest(
undefined,
"GET",
"/dehydrated_device",
undefined, undefined,
{
prefix: "/_matrix/client/unstable/org.matrix.msc2697.v2",
},
);
} catch (e) {
logger.info("could not get dehydrated device", e.toString());
return; return;
} }
@@ -572,6 +563,27 @@ MatrixClient.prototype.rehydrateDevice = async function() {
} }
}; };
/**
* Get the current dehydrated device, if any
* @return {Promise} A promise of an object containing the dehydrated device
*/
MatrixClient.prototype.getDehydratedDevice = async function() {
try {
return await this._http.authedRequest(
undefined,
"GET",
"/dehydrated_device",
undefined, undefined,
{
prefix: "/_matrix/client/unstable/org.matrix.msc2697.v2",
},
);
} catch (e) {
logger.info("could not get dehydrated device", e.toString());
return;
}
};
/** /**
* Set the dehydration key. This will also periodically dehydrate devices to * Set the dehydration key. This will also periodically dehydrate devices to
* the server. * the server.
@@ -3483,11 +3495,12 @@ MatrixClient.prototype.getRoomUpgradeHistory = function(roomId, verifyLinks=fals
* @param {string} roomId * @param {string} roomId
* @param {string} userId * @param {string} userId
* @param {module:client.callback} callback Optional. * @param {module:client.callback} callback Optional.
* @param {string} reason Optional.
* @return {Promise} Resolves: TODO * @return {Promise} Resolves: TODO
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
MatrixClient.prototype.invite = function(roomId, userId, callback) { MatrixClient.prototype.invite = function(roomId, userId, callback, reason) {
return _membershipChange(this, roomId, userId, "invite", undefined, return _membershipChange(this, roomId, userId, "invite", reason,
callback); callback);
}; };
@@ -4987,6 +5000,48 @@ MatrixClient.prototype.getTurnServersExpiry = function() {
return this._turnServersExpiry; return this._turnServersExpiry;
}; };
MatrixClient.prototype._checkTurnServers = async function() {
if (!this._supportsVoip) {
return;
}
let credentialsGood = false;
const remainingTime = this._turnServersExpiry - Date.now();
if (remainingTime > TURN_CHECK_INTERVAL) {
logger.debug("TURN creds are valid for another " + remainingTime + " ms: not fetching new ones.");
credentialsGood = true;
} else {
logger.debug("Fetching new TURN credentials");
try {
const res = await this.turnServer();
if (res.uris) {
logger.log("Got TURN URIs: " + res.uris + " refresh in " + res.ttl + " secs");
// map the response to a format that can be fed to RTCPeerConnection
const servers = {
urls: res.uris,
username: res.username,
credential: res.password,
};
this._turnServers = [servers];
// The TTL is in seconds but we work in ms
this._turnServersExpiry = Date.now() + (res.ttl * 1000);
credentialsGood = true;
}
} catch (err) {
logger.error("Failed to get TURN URIs", err);
// If we get a 403, there's no point in looping forever.
if (err.httpStatus === 403) {
logger.info("TURN access unavailable for this account: stopping credentials checks");
if (this._checkTurnServersIntervalID !== null) global.clearInterval(this._checkTurnServersIntervalID);
this._checkTurnServersIntervalID = null;
}
}
// otherwise, if we failed for whatever reason, try again the next time we're called.
}
return credentialsGood;
};
/** /**
* 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
@@ -5139,7 +5194,12 @@ MatrixClient.prototype.startClient = async function(opts) {
} }
// periodically poll for turn servers if we support voip // periodically poll for turn servers if we support voip
checkTurnServers(this); if (this._supportsVoip) {
this._checkTurnServersIntervalID = setInterval(() => {
this._checkTurnServers();
}, TURN_CHECK_INTERVAL);
this._checkTurnServers();
}
if (this._syncApi) { if (this._syncApi) {
// This shouldn't happen since we thought the client was not running // This shouldn't happen since we thought the client was not running
@@ -5251,7 +5311,7 @@ MatrixClient.prototype.stopClient = function() {
this._callEventHandler = null; this._callEventHandler = null;
} }
global.clearTimeout(this._checkTurnServersTimeoutID); global.clearInterval(this._checkTurnServersIntervalID);
if (this._clientWellKnownIntervalID !== undefined) { if (this._clientWellKnownIntervalID !== undefined) {
global.clearInterval(this._clientWellKnownIntervalID); global.clearInterval(this._clientWellKnownIntervalID);
} }
@@ -5458,6 +5518,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,
@@ -5465,42 +5528,6 @@ async function(roomId, eventId, relationType, eventType, opts = {}) {
}; };
}; };
function checkTurnServers(client) {
if (!client._supportsVoip) {
return;
}
client.turnServer().then(function(res) {
if (res.uris) {
logger.log("Got TURN URIs: " + res.uris + " refresh in " +
res.ttl + " secs");
// map the response to a format that can be fed to
// RTCPeerConnection
const servers = {
urls: res.uris,
username: res.username,
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);
}, (res.ttl || (60 * 60)) * 1000 * 0.9);
}
}, function(err) {
logger.error("Failed to get TURN URIs");
// If we get a 403, there's no point in looping forever.
if (err.httpStatus === 403) {
logger.info("TURN access unavailable for this account");
return;
}
client._checkTurnServersTimeoutID = setTimeout(function() {
checkTurnServers(client);
}, 60000);
});
}
function _reject(callback, reject, err) { function _reject(callback, reject, err) {
if (callback) { if (callback) {
callback(err); callback(err);

View File

@@ -28,7 +28,7 @@ import {DeviceInfo} from './deviceinfo';
import {CrossSigningInfo} from './CrossSigning'; import {CrossSigningInfo} from './CrossSigning';
import * as olmlib from './olmlib'; import * as olmlib from './olmlib';
import {IndexedDBCryptoStore} from './store/indexeddb-crypto-store'; import {IndexedDBCryptoStore} from './store/indexeddb-crypto-store';
import {defer, sleep} from '../utils'; import {chunkPromises, defer, sleep} from '../utils';
/* State transition diagram for DeviceList._deviceTrackingStatus /* State transition diagram for DeviceList._deviceTrackingStatus
@@ -62,7 +62,7 @@ const TRACKING_STATUS_UP_TO_DATE = 3;
* @alias module:crypto/DeviceList * @alias module:crypto/DeviceList
*/ */
export class DeviceList extends EventEmitter { export class DeviceList extends EventEmitter {
constructor(baseApis, cryptoStore, olmDevice) { constructor(baseApis, cryptoStore, olmDevice, keyDownloadChunkSize = 250) {
super(); super();
this._cryptoStore = cryptoStore; this._cryptoStore = cryptoStore;
@@ -98,6 +98,9 @@ export class DeviceList extends EventEmitter {
// userId -> promise // userId -> promise
this._keyDownloadsInProgressByUser = {}; this._keyDownloadsInProgressByUser = {};
// Maximum number of user IDs per request to prevent server overload (#1619)
this._keyDownloadChunkSize = keyDownloadChunkSize;
// Set whenever changes are made other than setting the sync token // Set whenever changes are made other than setting the sync token
this._dirty = false; this._dirty = false;
@@ -780,13 +783,17 @@ class DeviceListUpdateSerialiser {
opts.token = this._syncToken; opts.token = this._syncToken;
} }
this._baseApis.downloadKeysForUsers( const factories = [];
downloadUsers, opts, for (let i = 0; i < downloadUsers.length; i += this._deviceList._keyDownloadChunkSize) {
).then(async (res) => { const userSlice = downloadUsers.slice(i, i + this._deviceList._keyDownloadChunkSize);
const dk = res.device_keys || {}; factories.push(() => this._baseApis.downloadKeysForUsers(userSlice, opts));
const masterKeys = res.master_keys || {}; }
const ssks = res.self_signing_keys || {};
const usks = res.user_signing_keys || {}; chunkPromises(factories, 3).then(async (responses) => {
const dk = Object.assign({}, ...(responses.map(res => res.device_keys || {})));
const masterKeys = Object.assign({}, ...(responses.map(res => res.master_keys || {})));
const ssks = Object.assign({}, ...(responses.map(res => res.self_signing_keys || {})));
const usks = Object.assign({}, ...(responses.map(res => res.user_signing_keys || {})));
// yield to other things that want to execute in between users, to // yield to other things that want to execute in between users, to
// avoid wedging the CPU // avoid wedging the CPU

View File

@@ -568,7 +568,9 @@ Crypto.prototype.bootstrapCrossSigning = async function({
"Cross-signing private keys not found locally, but they are available " + "Cross-signing private keys not found locally, but they are available " +
"in secret storage, reading storage and caching locally", "in secret storage, reading storage and caching locally",
); );
await this.checkOwnCrossSigningTrust(); await this.checkOwnCrossSigningTrust({
allowPrivateKeyRequests: true,
});
} }
// Assuming no app-supplied callback, default to storing new private keys in // Assuming no app-supplied callback, default to storing new private keys in
@@ -1300,13 +1302,19 @@ Crypto.prototype._onDeviceListUserCrossSigningUpdated = async function(userId) {
* Check the copy of our cross-signing key that we have in the device list and * Check the copy of our cross-signing key that we have in the device list and
* see if we can get the private key. If so, mark it as trusted. * see if we can get the private key. If so, mark it as trusted.
*/ */
Crypto.prototype.checkOwnCrossSigningTrust = async function() { Crypto.prototype.checkOwnCrossSigningTrust = async function({
allowPrivateKeyRequests = false,
} = {}) {
const userId = this._userId; const userId = this._userId;
// Before proceeding, ensure our cross-signing public keys have been // Before proceeding, ensure our cross-signing public keys have been
// downloaded via the device list. // downloaded via the device list.
await this.downloadKeys([this._userId]); await this.downloadKeys([this._userId]);
// Also check which private keys are locally cached.
const crossSigningPrivateKeys =
await this._crossSigningInfo.getCrossSigningKeysFromCache();
// If we see an update to our own master key, check it against the master // If we see an update to our own master key, check it against the master
// key we have and, if it matches, mark it as verified // key we have and, if it matches, mark it as verified
@@ -1324,6 +1332,11 @@ Crypto.prototype.checkOwnCrossSigningTrust = async function() {
const masterChanged = this._crossSigningInfo.getId() !== seenPubkey; const masterChanged = this._crossSigningInfo.getId() !== seenPubkey;
if (masterChanged) { if (masterChanged) {
logger.info("Got new master public key", seenPubkey); logger.info("Got new master public key", seenPubkey);
}
if (
allowPrivateKeyRequests &&
(masterChanged || !crossSigningPrivateKeys.has("master"))
) {
logger.info("Attempting to retrieve cross-signing master private key"); logger.info("Attempting to retrieve cross-signing master private key");
let signing = null; let signing = null;
try { try {
@@ -1352,6 +1365,11 @@ Crypto.prototype.checkOwnCrossSigningTrust = async function() {
if (selfSigningChanged) { if (selfSigningChanged) {
logger.info("Got new self-signing key", newCrossSigning.getId("self_signing")); logger.info("Got new self-signing key", newCrossSigning.getId("self_signing"));
}
if (
allowPrivateKeyRequests &&
(selfSigningChanged || !crossSigningPrivateKeys.has("self_signing"))
) {
logger.info("Attempting to retrieve cross-signing self-signing private key"); logger.info("Attempting to retrieve cross-signing self-signing private key");
let signing = null; let signing = null;
try { try {
@@ -1374,6 +1392,11 @@ Crypto.prototype.checkOwnCrossSigningTrust = async function() {
} }
if (userSigningChanged) { if (userSigningChanged) {
logger.info("Got new user-signing key", newCrossSigning.getId("user_signing")); logger.info("Got new user-signing key", newCrossSigning.getId("user_signing"));
}
if (
allowPrivateKeyRequests &&
(userSigningChanged || !crossSigningPrivateKeys.has("user_signing"))
) {
logger.info("Attempting to retrieve cross-signing user-signing private key"); logger.info("Attempting to retrieve cross-signing user-signing private key");
let signing = null; let signing = null;
try { try {

View File

@@ -208,6 +208,35 @@ 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 [, devices] of Object.entries(devicesByUser)) {
for (const deviceInfo of devices) {
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;
}
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 => {
resolveSession[key] = (...args) => {
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) {
@@ -233,40 +262,14 @@ export async function ensureOlmSessionsForDevices(
} }
const forWhom = `for ${key} (${userId}:${deviceId})`; const forWhom = `for ${key} (${userId}:${deviceId})`;
log.debug(`Ensuring Olm session ${forWhom}`);
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, reject) => {
resolveSession[key] = {
resolve: (...args) => {
log.debug(`Resolved Olm session in progress ${forWhom}`);
delete olmDevice._sessionsInProgress[key];
resolve(...args);
},
reject: (...args) => {
log.debug(`Rejected Olm session in progress ${forWhom}`);
delete olmDevice._sessionsInProgress[key];
reject(...args);
},
};
},
);
}
const sessionId = await olmDevice.getSessionIdForDevice( const sessionId = await olmDevice.getSessionIdForDevice(
key, resolveSession[key], log, 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) {
@@ -290,14 +293,6 @@ export async function ensureOlmSessionsForDevices(
const oneTimeKeyAlgorithm = "signed_curve25519"; const oneTimeKeyAlgorithm = "signed_curve25519";
let res; let res;
let taskDetail = `one-time keys for ${devicesWithoutSession.length} devices`; let taskDetail = `one-time keys for ${devicesWithoutSession.length} devices`;
// If your homeserver takes a nap here and never replies, this process
// would hang indefinitely. While that's easily fixed by setting a
// 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);
try { try {
log.debug(`Claiming ${taskDetail}`); log.debug(`Claiming ${taskDetail}`);
res = await baseApis.claimOneTimeKeys( res = await baseApis.claimOneTimeKeys(
@@ -306,22 +301,20 @@ export async function ensureOlmSessionsForDevices(
log.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();
} }
log.log(`Failed to claim ${taskDetail}`, e, devicesWithoutSession); log.log(`Failed to claim ${taskDetail}`, e, devicesWithoutSession);
throw e; throw e;
} finally {
clearTimeout(otkTimeoutLogger);
} }
if (failedServers && "failures" in res) { if (failedServers && "failures" in res) {
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;
@@ -353,7 +346,7 @@ export async function ensureOlmSessionsForDevices(
`for device ${userId}:${deviceId}`, `for device ${userId}:${deviceId}`,
); );
if (resolveSession[key]) { if (resolveSession[key]) {
resolveSession[key].resolve(); resolveSession[key]();
} }
continue; continue;
} }
@@ -363,12 +356,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;
}), }),

View File

@@ -20,6 +20,7 @@ import {logger} from '../../logger';
import * as utils from "../../utils"; import * as utils from "../../utils";
export const VERSION = 10; export const VERSION = 10;
const PROFILE_TRANSACTIONS = false;
/** /**
* Implementation of a CryptoStore which is backed by an existing * Implementation of a CryptoStore which is backed by an existing
@@ -791,13 +792,18 @@ export class Backend {
} }
doTxn(mode, stores, func, log = logger) { doTxn(mode, stores, func, log = logger) {
let startTime;
let description;
if (PROFILE_TRANSACTIONS) {
const txnId = this._nextTxnId++; const txnId = this._nextTxnId++;
const startTime = Date.now(); startTime = Date.now();
const description = `${mode} crypto store transaction ${txnId} in ${stores}`; description = `${mode} crypto store transaction ${txnId} in ${stores}`;
log.debug(`Starting ${description}`); 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);
if (PROFILE_TRANSACTIONS) {
promise.then(() => { promise.then(() => {
const elapsedTime = Date.now() - startTime; const elapsedTime = Date.now() - startTime;
log.debug(`Finished ${description}, took ${elapsedTime} ms`); log.debug(`Finished ${description}, took ${elapsedTime} ms`);
@@ -805,6 +811,7 @@ export class Backend {
const elapsedTime = Date.now() - startTime; const elapsedTime = Date.now() - startTime;
log.error(`Failed ${description}, took ${elapsedTime} ms`); log.error(`Failed ${description}, took ${elapsedTime} ms`);
}); });
}
return promise.then(() => { return promise.then(() => {
return result; return result;
}); });

View File

@@ -179,7 +179,9 @@ export class ToDeviceChannel {
const isAcceptingEvent = type === START_TYPE || type === READY_TYPE; const isAcceptingEvent = type === START_TYPE || type === READY_TYPE;
// the request has picked a ready or start event, tell the other devices about it // the request has picked a ready or start event, tell the other devices about it
if (isAcceptingEvent && !wasStarted && isStarted && this._deviceId) { if (isAcceptingEvent && !wasStarted && isStarted && this._deviceId) {
const nonChosenDevices = this._devices.filter(d => d !== this._deviceId); const nonChosenDevices = this._devices.filter(
d => d !== this._deviceId && d !== this._client.getDeviceId(),
);
if (nonChosenDevices.length) { if (nonChosenDevices.length) {
const message = this.completeContent({ const message = this.completeContent({
code: "m.accepted", code: "m.accepted",

View File

@@ -271,10 +271,10 @@ MatrixHttpApi.prototype = {
xhr.timeout_timer = callbacks.setTimeout(timeout_fn, 30000); xhr.timeout_timer = callbacks.setTimeout(timeout_fn, 30000);
xhr.onreadystatechange = function() { xhr.onreadystatechange = function() {
let resp;
switch (xhr.readyState) { switch (xhr.readyState) {
case global.XMLHttpRequest.DONE: case global.XMLHttpRequest.DONE:
callbacks.clearTimeout(xhr.timeout_timer); callbacks.clearTimeout(xhr.timeout_timer);
var resp;
try { try {
if (xhr.status === 0) { if (xhr.status === 0) {
throw new AbortError(); throw new AbortError();

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

@@ -818,7 +818,7 @@ Room.prototype.getBlacklistUnverifiedDevices = function() {
*/ */
Room.prototype.getAvatarUrl = function(baseUrl, width, height, resizeMethod, Room.prototype.getAvatarUrl = function(baseUrl, width, height, resizeMethod,
allowDefault) { allowDefault) {
const roomAvatarEvent = this.currentState.getStateEvents("m.room.avatar", ""); const roomAvatarEvent = this.currentState.getStateEvents(EventType.RoomAvatar, "");
if (allowDefault === undefined) { if (allowDefault === undefined) {
allowDefault = true; allowDefault = true;
} }
@@ -836,6 +836,15 @@ Room.prototype.getAvatarUrl = function(baseUrl, width, height, resizeMethod,
return null; return null;
}; };
/**
* Get the mxc avatar url for the room, if one was set.
* @return {string} the mxc avatar url or falsy
*/
Room.prototype.getMxcAvatarUrl = function() {
const roomAvatarEvent = this.currentState.getStateEvents(EventType.RoomAvatar, "");
return roomAvatarEvent ? roomAvatarEvent.getContent().url : null;
};
/** /**
* Get the aliases this room has according to the room's state * Get the aliases this room has according to the room's state
* The aliases returned by this function may not necessarily * The aliases returned by this function may not necessarily

View File

@@ -435,7 +435,7 @@ LocalIndexedDBStoreBackend.prototype = {
* @return {Promise} Resolves if the data was persisted. * @return {Promise} Resolves if the data was persisted.
*/ */
_persistSyncData: function(nextBatch, roomsData, groupsData) { _persistSyncData: function(nextBatch, roomsData, groupsData) {
logger.log("Persisting sync data up to ", nextBatch); logger.log("Persisting sync data up to", nextBatch);
return utils.promiseTry(() => { return utils.promiseTry(() => {
const txn = this.db.transaction(["sync"], "readwrite"); const txn = this.db.transaction(["sync"], "readwrite");
const store = txn.objectStore("sync"); const store = txn.objectStore("sync");

View File

@@ -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 * sync from the server is not required. This does not reduce memory usage as all
* the data is eagerly fetched when <code>startup()</code> is called. * the data is eagerly fetched when <code>startup()</code> is called.
* <pre> * <pre>
* let opts = { localStorage: window.localStorage }; * let opts = { indexedDB: window.indexedDB, localStorage: window.localStorage };
* let store = new IndexedDBStore(); * let store = new IndexedDBStore(opts);
* await store.startup(); // load from indexed db * await store.startup(); // load from indexed db
* let client = sdk.createClient({ * let client = sdk.createClient({
* store: store, * store: store,

View File

@@ -745,6 +745,15 @@ export function promiseTry<T>(fn: () => T): Promise<T> {
return new Promise((resolve) => resolve(fn())); return new Promise((resolve) => resolve(fn()));
} }
// Creates and awaits all promises, running no more than `chunkSize` at the same time
export async function chunkPromises<T>(fns: (() => Promise<T>)[], chunkSize: number): Promise<T[]> {
const results: T[] = [];
for (let i = 0; i < fns.length; i += chunkSize) {
results.push(...(await Promise.all(fns.slice(i, i + chunkSize).map(fn => fn()))));
}
return results;
}
// We need to be able to access the Node.js crypto library from within the // We need to be able to access the Node.js crypto library from within the
// Matrix SDK without needing to `require("crypto")`, which will fail in // Matrix SDK without needing to `require("crypto")`, which will fail in
// browsers. So `index.ts` will call `setCrypto` to store it, and when we need // browsers. So `index.ts` will call `setCrypto` to store it, and when we need

View File

@@ -174,6 +174,11 @@ export enum CallErrorCode {
SignallingFailed = 'signalling_timeout', SignallingFailed = 'signalling_timeout',
} }
enum ConstraintsType {
Audio = "audio",
Video = "video",
}
/** /**
* The version field that we set in m.call.* events * The version field that we set in m.call.* events
*/ */
@@ -328,10 +333,11 @@ export class MatrixCall extends EventEmitter {
* Place a voice call to this room. * Place a voice call to this room.
* @throws If you have not specified a listener for 'error' events. * @throws If you have not specified a listener for 'error' events.
*/ */
placeVoiceCall() { async placeVoiceCall() {
logger.debug("placeVoiceCall"); logger.debug("placeVoiceCall");
this.checkForErrorListener(); this.checkForErrorListener();
this.placeCallWithConstraints(getUserMediaVideoContraints(CallType.Voice)); const constraints = getUserMediaContraints(ConstraintsType.Audio);
await this.placeCallWithConstraints(constraints);
this.type = CallType.Voice; this.type = CallType.Voice;
} }
@@ -343,12 +349,13 @@ export class MatrixCall extends EventEmitter {
* to render the local camera preview. * to render the local camera preview.
* @throws If you have not specified a listener for 'error' events. * @throws If you have not specified a listener for 'error' events.
*/ */
placeVideoCall(remoteVideoElement: HTMLVideoElement, localVideoElement: HTMLVideoElement) { async placeVideoCall(remoteVideoElement: HTMLVideoElement, localVideoElement: HTMLVideoElement) {
logger.debug("placeVideoCall"); logger.debug("placeVideoCall");
this.checkForErrorListener(); this.checkForErrorListener();
this.localVideoElement = localVideoElement; this.localVideoElement = localVideoElement;
this.remoteVideoElement = remoteVideoElement; this.remoteVideoElement = remoteVideoElement;
this.placeCallWithConstraints(getUserMediaVideoContraints(CallType.Video)); const constraints = getUserMediaContraints(ConstraintsType.Video);
await this.placeCallWithConstraints(constraints);
this.type = CallType.Video; this.type = CallType.Video;
} }
@@ -365,50 +372,28 @@ export class MatrixCall extends EventEmitter {
async placeScreenSharingCall( async placeScreenSharingCall(
remoteVideoElement: HTMLVideoElement, remoteVideoElement: HTMLVideoElement,
localVideoElement: HTMLVideoElement, localVideoElement: HTMLVideoElement,
selectDesktopCapturerSource: () => Promise<DesktopCapturerSource>, selectDesktopCapturerSource?: () => Promise<DesktopCapturerSource>,
) { ) {
logger.debug("placeScreenSharingCall"); logger.debug("placeScreenSharingCall");
this.checkForErrorListener(); this.checkForErrorListener();
this.localVideoElement = localVideoElement; this.localVideoElement = localVideoElement;
this.remoteVideoElement = remoteVideoElement; this.remoteVideoElement = remoteVideoElement;
try {
const screenshareConstraints = await getScreenshareContraints(selectDesktopCapturerSource);
if (!screenshareConstraints) return;
if (window.electron?.getDesktopCapturerSources) { if (window.electron?.getDesktopCapturerSources) {
// We have access to getDesktopCapturerSources() // We are using Electron
logger.debug("Electron getDesktopCapturerSources() is available..."); logger.debug("Getting screen stream using getUserMedia()...");
try { this.screenSharingStream = await navigator.mediaDevices.getUserMedia(screenshareConstraints);
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);
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 { } else {
/* We do not have access to the Electron desktop capturer, // We are not using Electron
* therefore we can assume we are on the web */ logger.debug("Getting screen stream using getDisplayMedia()...");
logger.debug("Electron desktopCapturer is not available..."); this.screenSharingStream = await navigator.mediaDevices.getDisplayMedia(screenshareConstraints);
try { }
this.screenSharingStream = await navigator.mediaDevices.getDisplayMedia({'audio': false});
logger.debug("Got screen stream, requesting audio stream..."); logger.debug("Got screen stream, requesting audio stream...");
const audioConstraints = getUserMediaVideoContraints(CallType.Voice); const audioConstraints = getUserMediaContraints(ConstraintsType.Audio);
this.placeCallWithConstraints(audioConstraints); this.placeCallWithConstraints(audioConstraints);
} catch (err) { } catch (err) {
this.emit(CallEvent.Error, this.emit(CallEvent.Error,
@@ -418,8 +403,6 @@ export class MatrixCall extends EventEmitter {
), ),
); );
} }
}
this.type = CallType.Video; this.type = CallType.Video;
} }
@@ -544,6 +527,13 @@ export class MatrixCall extends EventEmitter {
const invite = event.getContent(); const invite = event.getContent();
this.direction = CallDirection.Inbound; this.direction = CallDirection.Inbound;
// make sure we have valid turn creds. Unless something's gone wrong, it should
// poll and keep the credentials valid so this should be instant.
const haveTurnCreds = await this.client._checkTurnServers();
if (!haveTurnCreds) {
logger.warn("Failed to get TURN credentials! Proceeding with call anyway...");
}
this.peerConn = this.createPeerConnection(); this.peerConn = this.createPeerConnection();
// we must set the party ID before await-ing on anything: the call event // 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 // handler will start giving us more call events (eg. candidates) so if
@@ -551,6 +541,7 @@ export class MatrixCall extends EventEmitter {
this.chooseOpponent(event); this.chooseOpponent(event);
try { try {
await this.peerConn.setRemoteDescription(invite.offer); await this.peerConn.setRemoteDescription(invite.offer);
await this.addBufferedIceCandidates();
} 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);
@@ -608,7 +599,11 @@ export class MatrixCall extends EventEmitter {
logger.debug(`Answering call ${this.callId} of type ${this.type}`); logger.debug(`Answering call ${this.callId} of type ${this.type}`);
if (!this.localAVStream && !this.waitForLocalAVStream) { 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); logger.log("Getting user media with constraints", constraints);
this.setState(CallState.WaitLocalMedia); this.setState(CallState.WaitLocalMedia);
this.waitForLocalAVStream = true; this.waitForLocalAVStream = true;
@@ -665,6 +660,8 @@ export class MatrixCall extends EventEmitter {
logger.debug("Ending call " + this.callId); logger.debug("Ending call " + this.callId);
this.terminate(CallParty.Local, reason, !suppressEvent); this.terminate(CallParty.Local, reason, !suppressEvent);
// We don't want to send hangup here if we didn't even get to sending an invite
if (this.state === CallState.WaitLocalMedia) return;
const content = {}; const content = {};
// Continue to send no reason for user hangups temporarily, until // Continue to send no reason for user hangups temporarily, until
// clients understand the user_hangup reason (voip v1) // clients understand the user_hangup reason (voip v1)
@@ -838,8 +835,11 @@ export class MatrixCall extends EventEmitter {
return; return;
} }
if (this.callHasEnded()) { if (this.callHasEnded()) {
this.stopAllMedia();
return; return;
} }
this.localAVStream = stream;
logger.info("Got local AV stream with id " + this.localAVStream.id);
this.setState(CallState.CreateOffer); this.setState(CallState.CreateOffer);
@@ -865,11 +865,8 @@ export class MatrixCall extends EventEmitter {
} }
} }
this.localAVStream = stream;
logger.info("Got local AV stream with id " + this.localAVStream.id);
// why do we enable audio (and only audio) tracks here? -- matthew // why do we enable audio (and only audio) tracks here? -- matthew
setTracksEnabled(stream.getAudioTracks(), true); setTracksEnabled(stream.getAudioTracks(), true);
this.peerConn = this.createPeerConnection();
for (const audioTrack of stream.getAudioTracks()) { for (const audioTrack of stream.getAudioTracks()) {
logger.info("Adding audio track with id " + audioTrack.id); logger.info("Adding audio track with id " + audioTrack.id);
@@ -991,7 +988,7 @@ export class MatrixCall extends EventEmitter {
private gotLocalIceCandidate = (event: RTCPeerConnectionIceEvent) => { private gotLocalIceCandidate = (event: RTCPeerConnectionIceEvent) => {
if (event.candidate) { if (event.candidate) {
logger.debug( logger.debug(
"Got local ICE " + event.candidate.sdpMid + " candidate: " + "Call " + this.callId + " got local ICE " + event.candidate.sdpMid + " candidate: " +
event.candidate.candidate, event.candidate.candidate,
); );
@@ -1025,7 +1022,7 @@ export class MatrixCall extends EventEmitter {
} }
}; };
onRemoteIceCandidatesReceived(ev: MatrixEvent) { async onRemoteIceCandidatesReceived(ev: MatrixEvent) {
if (this.callHasEnded()) { if (this.callHasEnded()) {
//debuglog("Ignoring remote ICE candidate because call has ended"); //debuglog("Ignoring remote ICE candidate because call has ended");
return; return;
@@ -1057,7 +1054,7 @@ export class MatrixCall extends EventEmitter {
return; return;
} }
this.addIceCandidates(cands); await this.addIceCandidates(cands);
} }
/** /**
@@ -1065,7 +1062,10 @@ export class MatrixCall extends EventEmitter {
* @param {Object} msg * @param {Object} msg
*/ */
async onAnswerReceived(event: MatrixEvent) { async onAnswerReceived(event: MatrixEvent) {
logger.debug(`Got answer for call ID ${this.callId} from party ID ${event.getContent().party_id}`);
if (this.callHasEnded()) { if (this.callHasEnded()) {
logger.debug(`Ignoring answer because call ID ${this.callId} has ended`);
return; return;
} }
@@ -1078,6 +1078,7 @@ export class MatrixCall extends EventEmitter {
} }
this.chooseOpponent(event); this.chooseOpponent(event);
await this.addBufferedIceCandidates();
this.setState(CallState.Connecting); this.setState(CallState.Connecting);
@@ -1674,11 +1675,18 @@ export class MatrixCall extends EventEmitter {
this.setState(CallState.WaitLocalMedia); this.setState(CallState.WaitLocalMedia);
this.direction = CallDirection.Outbound; this.direction = CallDirection.Outbound;
this.config = constraints; this.config = constraints;
// It would be really nice if we could start gathering candidates at this point
// so the ICE agent could be gathering while we open our media devices: we already // make sure we have valid turn creds. Unless something's gone wrong, it should
// know the type of the call and therefore what tracks we want to send. // poll and keep the credentials valid so this should be instant.
// Perhaps we could do this by making fake tracks now and then using replaceTrack() const haveTurnCreds = await this.client._checkTurnServers();
// once we have the actual tracks? (Can we make fake tracks?) if (!haveTurnCreds) {
logger.warn("Failed to get TURN credentials! Proceeding with call anyway...");
}
// create the peer connection now so it can be gathering candidates while we get user
// media (assuming a candidate pool size is configured)
this.peerConn = this.createPeerConnection();
try { try {
const mediaStream = await navigator.mediaDevices.getUserMedia(constraints); const mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
this.gotUserMediaForInvite(mediaStream); this.gotUserMediaForInvite(mediaStream);
@@ -1721,6 +1729,8 @@ export class MatrixCall extends EventEmitter {
// I choo-choo-choose you // I choo-choo-choose you
const msg = ev.getContent(); const msg = ev.getContent();
logger.debug(`Choosing party ID ${msg.party_id} for call ID ${this.callId}`);
this.opponentVersion = msg.version; this.opponentVersion = msg.version;
if (this.opponentVersion === 0) { if (this.opponentVersion === 0) {
// set to null to indicate that we've chosen an opponent, but because // set to null to indicate that we've chosen an opponent, but because
@@ -1734,30 +1744,32 @@ export class MatrixCall extends EventEmitter {
} }
this.opponentCaps = msg.capabilities || {}; this.opponentCaps = msg.capabilities || {};
this.opponentMember = ev.sender; this.opponentMember = ev.sender;
}
private async addBufferedIceCandidates() {
const bufferedCands = this.remoteCandidateBuffer.get(this.opponentPartyId); const bufferedCands = this.remoteCandidateBuffer.get(this.opponentPartyId);
if (bufferedCands) { if (bufferedCands) {
logger.info(`Adding ${bufferedCands.length} buffered candidates for opponent ${this.opponentPartyId}`); logger.info(`Adding ${bufferedCands.length} buffered candidates for opponent ${this.opponentPartyId}`);
this.addIceCandidates(bufferedCands); await this.addIceCandidates(bufferedCands);
} }
this.remoteCandidateBuffer = null; this.remoteCandidateBuffer = null;
} }
private addIceCandidates(cands: RTCIceCandidate[]) { private async addIceCandidates(cands: RTCIceCandidate[]) {
for (const cand of cands) { for (const cand of cands) {
if ( if (
(cand.sdpMid === null || cand.sdpMid === undefined) && (cand.sdpMid === null || cand.sdpMid === undefined) &&
(cand.sdpMLineIndex === null || cand.sdpMLineIndex === undefined) (cand.sdpMLineIndex === null || cand.sdpMLineIndex === undefined)
) { ) {
logger.debug("Ignoring remote ICE candidate with no sdpMid or sdpMLineIndex"); logger.debug("Ignoring remote ICE candidate with no sdpMid or sdpMLineIndex");
return; continue;
} }
logger.debug("Got remote ICE " + cand.sdpMid + " candidate: " + cand.candidate); logger.debug("Call " + this.callId + " got remote ICE " + cand.sdpMid + " candidate: " + cand.candidate);
try { try {
this.peerConn.addIceCandidate(cand); await this.peerConn.addIceCandidate(cand);
} catch (err) { } catch (err) {
if (!this.ignoreOffer) { if (!this.ignoreOffer) {
logger.info("Failed to add remore ICE candidate", err); logger.info("Failed to add remote ICE candidate", err);
} }
} }
} }
@@ -1770,17 +1782,19 @@ function setTracksEnabled(tracks: Array<MediaStreamTrack>, enabled: boolean) {
} }
} }
function getUserMediaVideoContraints(callType: CallType) { function getUserMediaContraints(type: ConstraintsType) {
const isWebkit = !!navigator.webkitGetUserMedia; const isWebkit = !!navigator.webkitGetUserMedia;
switch (callType) { switch (type) {
case CallType.Voice: case ConstraintsType.Audio: {
return { return {
audio: { audio: {
deviceId: audioInput ? {ideal: audioInput} : undefined, deviceId: audioInput ? {ideal: audioInput} : undefined,
}, video: false, },
video: false,
}; };
case CallType.Video: }
case ConstraintsType.Video: {
return { return {
audio: { audio: {
deviceId: audioInput ? {ideal: audioInput} : undefined, deviceId: audioInput ? {ideal: audioInput} : undefined,
@@ -1796,6 +1810,33 @@ function getUserMediaVideoContraints(callType: CallType) {
}, },
}; };
} }
}
}
async function getScreenshareContraints(selectDesktopCapturerSource?: () => Promise<DesktopCapturerSource>) {
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,
};
}
} }
let audioOutput: string; let audioOutput: string;

View File

@@ -139,7 +139,7 @@ export class CallEventHandler {
} }
const timeUntilTurnCresExpire = this.client.getTurnServersExpiry() - Date.now(); const timeUntilTurnCresExpire = this.client.getTurnServersExpiry() - Date.now();
logger.info("Current turn creds expire in " + timeUntilTurnCresExpire + " seconds"); logger.info("Current turn creds expire in " + timeUntilTurnCresExpire + " ms");
call = createNewMatrixCall(this.client, event.getRoomId(), { call = createNewMatrixCall(this.client, event.getRoomId(), {
forceTURN: this.client._forceTURN, forceTURN: this.client._forceTURN,
}); });

View File

@@ -1876,10 +1876,10 @@ bluebird@^3.5.0, bluebird@^3.7.2:
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.4.0: bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9:
version "4.11.9" version "4.12.0"
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88"
integrity sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw== integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==
bn.js@^5.0.0, bn.js@^5.1.1: bn.js@^5.0.0, bn.js@^5.1.1:
version "5.1.3" version "5.1.3"
@@ -1922,7 +1922,7 @@ braces@^3.0.1, braces@~3.0.2:
dependencies: dependencies:
fill-range "^7.0.1" fill-range "^7.0.1"
brorand@^1.0.1: brorand@^1.0.1, brorand@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f"
integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8= integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=
@@ -2796,17 +2796,17 @@ electron-to-chromium@^1.3.634:
integrity sha512-cev+jOrz/Zm1i+Yh334Hed6lQVOkkemk2wRozfMF4MtTR7pxf3r3L5Rbd7uX1zMcEqVJ7alJBnJL7+JffkC6FQ== integrity sha512-cev+jOrz/Zm1i+Yh334Hed6lQVOkkemk2wRozfMF4MtTR7pxf3r3L5Rbd7uX1zMcEqVJ7alJBnJL7+JffkC6FQ==
elliptic@^6.5.3: elliptic@^6.5.3:
version "6.5.3" version "6.5.4"
resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.3.tgz#cb59eb2efdaf73a0bd78ccd7015a62ad6e0f93d6" resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb"
integrity sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw== integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==
dependencies: dependencies:
bn.js "^4.4.0" bn.js "^4.11.9"
brorand "^1.0.1" brorand "^1.1.0"
hash.js "^1.0.0" hash.js "^1.0.0"
hmac-drbg "^1.0.0" hmac-drbg "^1.0.1"
inherits "^2.0.1" inherits "^2.0.4"
minimalistic-assert "^1.0.0" minimalistic-assert "^1.0.1"
minimalistic-crypto-utils "^1.0.0" minimalistic-crypto-utils "^1.0.1"
emittery@^0.7.1: emittery@^0.7.1:
version "0.7.2" version "0.7.2"
@@ -3820,7 +3820,7 @@ he@^1.1.0:
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
hmac-drbg@^1.0.0: hmac-drbg@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
integrity sha1-0nRXAQJabHdabFRXk+1QL8DGSaE= integrity sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=
@@ -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"
@@ -5255,7 +5260,7 @@ minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1:
resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==
minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1: minimalistic-crypto-utils@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a"
integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo= integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=
@@ -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"