You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-07-31 15:24:23 +03:00
Merge changes from develop (#2563)
* Prepare changelog for v19.2.0-rc.1 * v19.2.0-rc.1 * Sliding sync: add missing filters from latest MSC * Gracefully handle missing room_ids * Prepare changelog for v19.2.0 * v19.2.0 * Resetting package fields for development * Use EventType enum values instead of hardcoded strings (#2557) * Retry to-device messages (#2549) * Retry to-device messages This adds a queueToDevice API alongside sendToDevice which is a much higher-level API that adds the messages to a queue, stored in persistent storage, and retries them periodically. Also converts sending of megolm keys to use the new API. Other uses of sendToDevice are nopt converted in this PR, but could be later. Requires https://github.com/matrix-org/matrix-mock-request/pull/17 * Bump matrix-mock-request * Add more waits to make indexeddb tests pass * Switch some test expectations to queueToDevice * Stop straight away if the client has been stopped Hopefully will fix tests being flakey and logging after tests have finished. * Add return types & fix constant usage * Fix return type Co-authored-by: Germain <germains@element.io> * Fix return type Co-authored-by: Germain <germains@element.io> * Fix return type Co-authored-by: Germain <germains@element.io> * Stop the client in all test cases Co-authored-by: Germain <germains@element.io> * Add support for sending user-defined encrypted to-device messages (#2528) * Add support for sending user-defined encrypted to-device messages This is a port of the same change from the robertlong/group-call branch. * Fix tests * Expose the method in MatrixClient * Fix a code smell * Fix types * Test the MatrixClient method * Fix some types in Crypto test suite * Test the Crypto method * Fix tests * Upgrade matrix-mock-request * Move useRealTimers to afterEach * Remove stream-replacement (#2551) * Reintroduce setNewStream method, fix test, update yarn.lock Co-authored-by: RiotRobot <releases@riot.im> Co-authored-by: Kegan Dougal <kegan@matrix.org> Co-authored-by: Germain <germains@element.io> Co-authored-by: Robin <robin@robin.town> Co-authored-by: Šimon Brandner <simon.bra.ag@gmail.com>
This commit is contained in:
15
CHANGELOG.md
15
CHANGELOG.md
@ -1,3 +1,18 @@
|
||||
Changes in [19.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.2.0) (2022-08-02)
|
||||
==================================================================================================
|
||||
|
||||
## 🦖 Deprecations
|
||||
* Remove unstable support for `m.room_key.withheld` ([\#2512](https://github.com/matrix-org/matrix-js-sdk/pull/2512)). Fixes #2233.
|
||||
|
||||
## ✨ Features
|
||||
* Sliding sync: add missing filters from latest MSC ([\#2555](https://github.com/matrix-org/matrix-js-sdk/pull/2555)).
|
||||
* Use stable prefixes for MSC3827 ([\#2537](https://github.com/matrix-org/matrix-js-sdk/pull/2537)).
|
||||
* Add support for MSC3575: Sliding Sync ([\#2242](https://github.com/matrix-org/matrix-js-sdk/pull/2242)).
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Correct the units in TURN servers expiry documentation ([\#2520](https://github.com/matrix-org/matrix-js-sdk/pull/2520)).
|
||||
* Re-insert room IDs when decrypting bundled redaction events returned by `/sync` ([\#2531](https://github.com/matrix-org/matrix-js-sdk/pull/2531)). Contributed by @duxovni.
|
||||
|
||||
Changes in [19.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.1.0) (2022-07-26)
|
||||
==================================================================================================
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "19.1.0",
|
||||
"version": "19.2.0",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=12.9.0"
|
||||
@ -104,7 +104,7 @@
|
||||
"jest-localstorage-mock": "^2.4.6",
|
||||
"jest-sonar-reporter": "^2.0.0",
|
||||
"jsdoc": "^3.6.6",
|
||||
"matrix-mock-request": "^2.1.0",
|
||||
"matrix-mock-request": "^2.1.2",
|
||||
"rimraf": "^3.0.2",
|
||||
"terser": "^5.5.1",
|
||||
"tsify": "^5.0.2",
|
||||
|
@ -2,6 +2,7 @@ import '../olm-loader';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
import { MatrixClient } from "../../src/client";
|
||||
import { Crypto } from "../../src/crypto";
|
||||
import { MemoryCryptoStore } from "../../src/crypto/store/memory-crypto-store";
|
||||
import { MockStorageApi } from "../MockStorageApi";
|
||||
@ -64,6 +65,10 @@ describe("Crypto", function() {
|
||||
return Olm.init();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("Crypto exposes the correct olm library version", function() {
|
||||
expect(Crypto.getOlmVersion()[0]).toEqual(3);
|
||||
});
|
||||
@ -225,8 +230,8 @@ describe("Crypto", function() {
|
||||
});
|
||||
|
||||
describe('Key requests', function() {
|
||||
let aliceClient;
|
||||
let bobClient;
|
||||
let aliceClient: MatrixClient;
|
||||
let bobClient: MatrixClient;
|
||||
|
||||
beforeEach(async function() {
|
||||
aliceClient = (new TestClient(
|
||||
@ -313,7 +318,7 @@ describe("Crypto", function() {
|
||||
expect(events[0].getContent().msgtype).toBe("m.bad.encrypted");
|
||||
expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted");
|
||||
|
||||
const cryptoStore = bobClient.cryptoStore;
|
||||
const cryptoStore = bobClient.crypto.cryptoStore;
|
||||
const eventContent = events[0].getWireContent();
|
||||
const senderKey = eventContent.sender_key;
|
||||
const sessionId = eventContent.session_id;
|
||||
@ -383,9 +388,9 @@ describe("Crypto", function() {
|
||||
|
||||
const ksEvent = await keyshareEventForEvent(aliceClient, event, 1);
|
||||
ksEvent.getContent().sender_key = undefined; // test
|
||||
bobClient.crypto.addInboundGroupSession = jest.fn();
|
||||
bobClient.crypto.olmDevice.addInboundGroupSession = jest.fn();
|
||||
await bobDecryptor.onRoomKeyEvent(ksEvent);
|
||||
expect(bobClient.crypto.addInboundGroupSession).not.toHaveBeenCalled();
|
||||
expect(bobClient.crypto.olmDevice.addInboundGroupSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates a new keyshare request if we request a keyshare", async function() {
|
||||
@ -401,7 +406,7 @@ describe("Crypto", function() {
|
||||
},
|
||||
});
|
||||
await aliceClient.cancelAndResendEventRoomKeyRequest(event);
|
||||
const cryptoStore = aliceClient.cryptoStore;
|
||||
const cryptoStore = aliceClient.crypto.cryptoStore;
|
||||
const roomKeyRequestBody = {
|
||||
algorithm: olmlib.MEGOLM_ALGORITHM,
|
||||
room_id: "!someroom",
|
||||
@ -425,7 +430,8 @@ describe("Crypto", function() {
|
||||
},
|
||||
});
|
||||
// replace Alice's sendToDevice function with a mock
|
||||
aliceClient.sendToDevice = jest.fn().mockResolvedValue(undefined);
|
||||
const aliceSendToDevice = jest.fn().mockResolvedValue(undefined);
|
||||
aliceClient.sendToDevice = aliceSendToDevice;
|
||||
aliceClient.startClient();
|
||||
|
||||
// make a room key request, and record the transaction ID for the
|
||||
@ -434,11 +440,12 @@ describe("Crypto", function() {
|
||||
// key requests get queued until the sync has finished, but we don't
|
||||
// let the client set up enough for that to happen, so gut-wrench a bit
|
||||
// to force it to send now.
|
||||
// @ts-ignore
|
||||
aliceClient.crypto.outgoingRoomKeyRequestManager.sendQueuedRequests();
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
expect(aliceClient.sendToDevice).toBeCalledTimes(1);
|
||||
const txnId = aliceClient.sendToDevice.mock.calls[0][2];
|
||||
expect(aliceSendToDevice).toBeCalledTimes(1);
|
||||
const txnId = aliceSendToDevice.mock.calls[0][2];
|
||||
|
||||
// give the room key request manager time to update the state
|
||||
// of the request
|
||||
@ -451,8 +458,8 @@ describe("Crypto", function() {
|
||||
// cancelAndResend will call sendToDevice twice:
|
||||
// the first call to sendToDevice will be the cancellation
|
||||
// the second call to sendToDevice will be the key request
|
||||
expect(aliceClient.sendToDevice).toBeCalledTimes(3);
|
||||
expect(aliceClient.sendToDevice.mock.calls[2][2]).not.toBe(txnId);
|
||||
expect(aliceSendToDevice).toBeCalledTimes(3);
|
||||
expect(aliceSendToDevice.mock.calls[2][2]).not.toBe(txnId);
|
||||
});
|
||||
});
|
||||
|
||||
@ -480,4 +487,105 @@ describe("Crypto", function() {
|
||||
client.stopClient();
|
||||
});
|
||||
});
|
||||
|
||||
describe("encryptAndSendToDevices", () => {
|
||||
let client: TestClient;
|
||||
let ensureOlmSessionsForDevices: jest.SpiedFunction<typeof olmlib.ensureOlmSessionsForDevices>;
|
||||
let encryptMessageForDevice: jest.SpiedFunction<typeof olmlib.encryptMessageForDevice>;
|
||||
const payload = { hello: "world" };
|
||||
let encryptedPayload: object;
|
||||
|
||||
beforeEach(async () => {
|
||||
ensureOlmSessionsForDevices = jest.spyOn(olmlib, "ensureOlmSessionsForDevices");
|
||||
ensureOlmSessionsForDevices.mockResolvedValue({});
|
||||
encryptMessageForDevice = jest.spyOn(olmlib, "encryptMessageForDevice");
|
||||
encryptMessageForDevice.mockImplementation(async (...[result,,,,,, payload]) => {
|
||||
result.plaintext = JSON.stringify(payload);
|
||||
});
|
||||
|
||||
client = new TestClient("@alice:example.org", "aliceweb");
|
||||
await client.client.initCrypto();
|
||||
|
||||
encryptedPayload = {
|
||||
algorithm: "m.olm.v1.curve25519-aes-sha2",
|
||||
sender_key: client.client.crypto.olmDevice.deviceCurve25519Key,
|
||||
ciphertext: { plaintext: JSON.stringify(payload) },
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
ensureOlmSessionsForDevices.mockRestore();
|
||||
encryptMessageForDevice.mockRestore();
|
||||
await client.stop();
|
||||
});
|
||||
|
||||
it("encrypts and sends to devices", async () => {
|
||||
client.httpBackend
|
||||
.when("PUT", "/sendToDevice/m.room.encrypted", {
|
||||
messages: {
|
||||
"@bob:example.org": {
|
||||
bobweb: encryptedPayload,
|
||||
bobmobile: encryptedPayload,
|
||||
},
|
||||
"@carol:example.org": {
|
||||
caroldesktop: encryptedPayload,
|
||||
},
|
||||
},
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
await Promise.all([
|
||||
client.client.encryptAndSendToDevices(
|
||||
[
|
||||
{ userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobweb") },
|
||||
{ userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobmobile") },
|
||||
{ userId: "@carol:example.org", deviceInfo: new DeviceInfo("caroldesktop") },
|
||||
],
|
||||
payload,
|
||||
),
|
||||
client.httpBackend.flushAllExpected(),
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends nothing to devices that couldn't be encrypted to", async () => {
|
||||
encryptMessageForDevice.mockImplementation(async (...[result,,,, userId, device, payload]) => {
|
||||
// Refuse to encrypt to Carol's desktop device
|
||||
if (userId === "@carol:example.org" && device.deviceId === "caroldesktop") return;
|
||||
result.plaintext = JSON.stringify(payload);
|
||||
});
|
||||
|
||||
client.httpBackend
|
||||
.when("PUT", "/sendToDevice/m.room.encrypted", {
|
||||
// Carol is nowhere to be seen
|
||||
messages: { "@bob:example.org": { bobweb: encryptedPayload } },
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
await Promise.all([
|
||||
client.client.encryptAndSendToDevices(
|
||||
[
|
||||
{ userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobweb") },
|
||||
{ userId: "@carol:example.org", deviceInfo: new DeviceInfo("caroldesktop") },
|
||||
],
|
||||
payload,
|
||||
),
|
||||
client.httpBackend.flushAllExpected(),
|
||||
]);
|
||||
});
|
||||
|
||||
it("no-ops if no devices can be encrypted to", async () => {
|
||||
// Refuse to encrypt to anybody
|
||||
encryptMessageForDevice.mockResolvedValue(undefined);
|
||||
|
||||
// Get the room keys version request out of the way
|
||||
client.httpBackend.when("GET", "/room_keys/version").respond(404, {});
|
||||
await client.httpBackend.flush("/room_keys/version", 1);
|
||||
|
||||
await client.client.encryptAndSendToDevices(
|
||||
[{ userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobweb") }],
|
||||
payload,
|
||||
);
|
||||
client.httpBackend.verifyNoOutstandingRequests();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -59,6 +59,7 @@ describe("MegolmDecryption", function() {
|
||||
mockBaseApis = {
|
||||
claimOneTimeKeys: jest.fn(),
|
||||
sendToDevice: jest.fn(),
|
||||
queueToDevice: jest.fn(),
|
||||
} as unknown as MockedObject<MatrixClient>;
|
||||
|
||||
const cryptoStore = new MemoryCryptoStore();
|
||||
@ -179,6 +180,7 @@ describe("MegolmDecryption", function() {
|
||||
});
|
||||
|
||||
mockBaseApis.sendToDevice.mockReset();
|
||||
mockBaseApis.queueToDevice.mockReset();
|
||||
|
||||
// do the share
|
||||
megolmDecryption.shareKeysWithDevice(keyRequest);
|
||||
@ -324,6 +326,7 @@ describe("MegolmDecryption", function() {
|
||||
},
|
||||
});
|
||||
mockBaseApis.sendToDevice.mockResolvedValue(undefined);
|
||||
mockBaseApis.queueToDevice.mockResolvedValue(undefined);
|
||||
|
||||
aliceDeviceInfo = {
|
||||
deviceId: 'aliceDevice',
|
||||
@ -413,7 +416,7 @@ describe("MegolmDecryption", function() {
|
||||
expect(mockCrypto.downloadKeys).toHaveBeenCalledWith(
|
||||
['@alice:home.server'], false,
|
||||
);
|
||||
expect(mockBaseApis.sendToDevice).toHaveBeenCalled();
|
||||
expect(mockBaseApis.queueToDevice).toHaveBeenCalled();
|
||||
expect(mockBaseApis.claimOneTimeKeys).toHaveBeenCalledWith(
|
||||
[['@alice:home.server', 'aliceDevice']], 'signed_curve25519', 2000,
|
||||
);
|
||||
@ -456,7 +459,7 @@ describe("MegolmDecryption", function() {
|
||||
'YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWI',
|
||||
);
|
||||
|
||||
mockBaseApis.sendToDevice.mockClear();
|
||||
mockBaseApis.queueToDevice.mockClear();
|
||||
await megolmEncryption.reshareKeyWithDevice(
|
||||
olmDevice.deviceCurve25519Key,
|
||||
ct1.session_id,
|
||||
@ -464,7 +467,7 @@ describe("MegolmDecryption", function() {
|
||||
aliceDeviceInfo,
|
||||
);
|
||||
|
||||
expect(mockBaseApis.sendToDevice).not.toHaveBeenCalled();
|
||||
expect(mockBaseApis.queueToDevice).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -27,6 +27,7 @@ import {
|
||||
UNSTABLE_MSC3089_TREE_SUBTYPE,
|
||||
} from "../../src/@types/event";
|
||||
import { MEGOLM_ALGORITHM } from "../../src/crypto/olmlib";
|
||||
import { Crypto } from "../../src/crypto";
|
||||
import { EventStatus, MatrixEvent } from "../../src/models/event";
|
||||
import { Preset } from "../../src/@types/partials";
|
||||
import { ReceiptType } from "../../src/@types/read_receipts";
|
||||
@ -1297,4 +1298,19 @@ describe("MatrixClient", function() {
|
||||
expect(result!.aliases).toEqual(response.aliases);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encryptAndSendToDevices", () => {
|
||||
it("throws an error if crypto is unavailable", () => {
|
||||
client.crypto = undefined;
|
||||
expect(() => client.encryptAndSendToDevices([], {})).toThrow();
|
||||
});
|
||||
|
||||
it("is an alias for the crypto method", async () => {
|
||||
client.crypto = testUtils.mock(Crypto, "Crypto");
|
||||
const deviceInfos = [];
|
||||
const payload = {};
|
||||
await client.encryptAndSendToDevices(deviceInfos, payload);
|
||||
expect(client.crypto.encryptAndSendToDevices).toHaveBeenLastCalledWith(deviceInfos, payload);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
338
spec/unit/queueToDevice.spec.ts
Normal file
338
spec/unit/queueToDevice.spec.ts
Normal file
@ -0,0 +1,338 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import MockHttpBackend from 'matrix-mock-request';
|
||||
import { indexedDB as fakeIndexedDB } from 'fake-indexeddb';
|
||||
|
||||
import { IHttpOpts, IndexedDBStore, MatrixEvent, MemoryStore, Room } from "../../src";
|
||||
import { MatrixClient } from "../../src/client";
|
||||
import { ToDeviceBatch } from '../../src/models/ToDeviceMessage';
|
||||
import { logger } from '../../src/logger';
|
||||
import { IStore } from '../../src/store';
|
||||
|
||||
const FAKE_USER = "@alice:example.org";
|
||||
const FAKE_DEVICE_ID = "AAAAAAAA";
|
||||
const FAKE_PAYLOAD = {
|
||||
"foo": 42,
|
||||
};
|
||||
const EXPECTED_BODY = {
|
||||
messages: {
|
||||
[FAKE_USER]: {
|
||||
[FAKE_DEVICE_ID]: FAKE_PAYLOAD,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const FAKE_MSG = {
|
||||
userId: FAKE_USER,
|
||||
deviceId: FAKE_DEVICE_ID,
|
||||
payload: FAKE_PAYLOAD,
|
||||
};
|
||||
|
||||
enum StoreType {
|
||||
Memory = 'Memory',
|
||||
IndexedDB = 'IndexedDB',
|
||||
}
|
||||
|
||||
// Jest now uses @sinonjs/fake-timers which exposes tickAsync() and a number of
|
||||
// other async methods which break the event loop, letting scheduled promise
|
||||
// callbacks run. Unfortunately, Jest doesn't expose these, so we have to do
|
||||
// it manually (this is what sinon does under the hood). We do both in a loop
|
||||
// until the thing we expect happens: hopefully this is the least flakey way
|
||||
// and avoids assuming anything about the app's behaviour.
|
||||
const realSetTimeout = setTimeout;
|
||||
function flushPromises() {
|
||||
return new Promise(r => {
|
||||
realSetTimeout(r, 1);
|
||||
});
|
||||
}
|
||||
|
||||
async function flushAndRunTimersUntil(cond: () => boolean) {
|
||||
while (!cond()) {
|
||||
await flushPromises();
|
||||
if (cond()) break;
|
||||
jest.advanceTimersToNextTimer();
|
||||
}
|
||||
}
|
||||
|
||||
describe.each([
|
||||
[StoreType.Memory], [StoreType.IndexedDB],
|
||||
])("queueToDevice (%s store)", function(storeType) {
|
||||
let httpBackend: MockHttpBackend;
|
||||
let client: MatrixClient;
|
||||
|
||||
beforeEach(async function() {
|
||||
httpBackend = new MockHttpBackend();
|
||||
|
||||
let store: IStore;
|
||||
if (storeType === StoreType.IndexedDB) {
|
||||
const idbStore = new IndexedDBStore({ indexedDB: fakeIndexedDB });
|
||||
await idbStore.startup();
|
||||
store = idbStore;
|
||||
} else {
|
||||
store = new MemoryStore();
|
||||
}
|
||||
|
||||
client = new MatrixClient({
|
||||
baseUrl: "https://my.home.server",
|
||||
accessToken: "my.access.token",
|
||||
request: httpBackend.requestFn as IHttpOpts["request"],
|
||||
store,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
jest.useRealTimers();
|
||||
client.stopClient();
|
||||
});
|
||||
|
||||
it("sends a to-device message", async function() {
|
||||
httpBackend.when(
|
||||
"PUT", "/sendToDevice/org.example.foo/",
|
||||
).check((request) => {
|
||||
expect(request.data).toEqual(EXPECTED_BODY);
|
||||
}).respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [
|
||||
FAKE_MSG,
|
||||
],
|
||||
});
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
});
|
||||
|
||||
it("retries on error", async function() {
|
||||
jest.useFakeTimers();
|
||||
|
||||
httpBackend.when(
|
||||
"PUT", "/sendToDevice/org.example.foo/",
|
||||
).respond(500);
|
||||
|
||||
httpBackend.when(
|
||||
"PUT", "/sendToDevice/org.example.foo/",
|
||||
).check((request) => {
|
||||
expect(request.data).toEqual(EXPECTED_BODY);
|
||||
}).respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [
|
||||
FAKE_MSG,
|
||||
],
|
||||
});
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
expect(httpBackend.flushSync(null, 1)).toEqual(1);
|
||||
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
|
||||
expect(httpBackend.flushSync(null, 1)).toEqual(1);
|
||||
});
|
||||
|
||||
it("stops retrying on 4xx errors", async function() {
|
||||
jest.useFakeTimers();
|
||||
|
||||
httpBackend.when(
|
||||
"PUT", "/sendToDevice/org.example.foo/",
|
||||
).respond(400);
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [
|
||||
FAKE_MSG,
|
||||
],
|
||||
});
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
expect(httpBackend.flushSync(null, 1)).toEqual(1);
|
||||
|
||||
// Asserting that another request is never made is obviously
|
||||
// a bit tricky - we just flush the queue what should hopefully
|
||||
// be plenty of times and assert that nothing comes through.
|
||||
let tries = 0;
|
||||
await flushAndRunTimersUntil(() => ++tries === 10);
|
||||
|
||||
expect(httpBackend.requests.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("honours ratelimiting", async function() {
|
||||
jest.useFakeTimers();
|
||||
|
||||
// pick something obscure enough it's unlikley to clash with a
|
||||
// retry delay the algorithm uses anyway
|
||||
const retryDelay = 279 * 1000;
|
||||
|
||||
httpBackend.when(
|
||||
"PUT", "/sendToDevice/org.example.foo/",
|
||||
).respond(429, {
|
||||
errcode: "M_LIMIT_EXCEEDED",
|
||||
retry_after_ms: retryDelay,
|
||||
});
|
||||
|
||||
httpBackend.when(
|
||||
"PUT", "/sendToDevice/org.example.foo/",
|
||||
).respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [
|
||||
FAKE_MSG,
|
||||
],
|
||||
});
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
expect(httpBackend.flushSync(null, 1)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
logger.info("Advancing clock to just before expected retry time...");
|
||||
|
||||
jest.advanceTimersByTime(retryDelay - 1000);
|
||||
await flushPromises();
|
||||
|
||||
expect(httpBackend.requests.length).toEqual(0);
|
||||
|
||||
logger.info("Advancing clock past expected retry time...");
|
||||
|
||||
jest.advanceTimersByTime(2000);
|
||||
await flushPromises();
|
||||
|
||||
expect(httpBackend.flushSync(null, 1)).toEqual(1);
|
||||
});
|
||||
|
||||
it("retries on retryImmediately()", async function() {
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
versions: ["r0.0.1"],
|
||||
});
|
||||
|
||||
await Promise.all([client.startClient(), httpBackend.flush(null, 1, 20)]);
|
||||
|
||||
httpBackend.when(
|
||||
"PUT", "/sendToDevice/org.example.foo/",
|
||||
).respond(500);
|
||||
|
||||
httpBackend.when(
|
||||
"PUT", "/sendToDevice/org.example.foo/",
|
||||
).respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [
|
||||
FAKE_MSG,
|
||||
],
|
||||
});
|
||||
expect(await httpBackend.flush(null, 1, 1)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
client.retryImmediately();
|
||||
|
||||
expect(await httpBackend.flush(null, 1, 20)).toEqual(1);
|
||||
});
|
||||
|
||||
it("retries on when client is started", async function() {
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
versions: ["r0.0.1"],
|
||||
});
|
||||
|
||||
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
|
||||
|
||||
httpBackend.when(
|
||||
"PUT", "/sendToDevice/org.example.foo/",
|
||||
).respond(500);
|
||||
|
||||
httpBackend.when(
|
||||
"PUT", "/sendToDevice/org.example.foo/",
|
||||
).respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [
|
||||
FAKE_MSG,
|
||||
],
|
||||
});
|
||||
expect(await httpBackend.flush(null, 1, 1)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
client.stopClient();
|
||||
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
|
||||
|
||||
expect(await httpBackend.flush(null, 1, 20)).toEqual(1);
|
||||
});
|
||||
|
||||
it("retries when a message is retried", async function() {
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
versions: ["r0.0.1"],
|
||||
});
|
||||
|
||||
await Promise.all([client.startClient(), httpBackend.flush(null, 1, 20)]);
|
||||
|
||||
httpBackend.when(
|
||||
"PUT", "/sendToDevice/org.example.foo/",
|
||||
).respond(500);
|
||||
|
||||
httpBackend.when(
|
||||
"PUT", "/sendToDevice/org.example.foo/",
|
||||
).respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [
|
||||
FAKE_MSG,
|
||||
],
|
||||
});
|
||||
|
||||
expect(await httpBackend.flush(null, 1, 1)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
const dummyEvent = new MatrixEvent({
|
||||
event_id: "!fake:example.org",
|
||||
});
|
||||
const mockRoom = {
|
||||
updatePendingEvent: jest.fn(),
|
||||
} as unknown as Room;
|
||||
client.resendEvent(dummyEvent, mockRoom);
|
||||
|
||||
expect(await httpBackend.flush(null, 1, 20)).toEqual(1);
|
||||
});
|
||||
|
||||
it("splits many messages into multiple HTTP requests", async function() {
|
||||
const batch: ToDeviceBatch = {
|
||||
eventType: "org.example.foo",
|
||||
batch: [],
|
||||
};
|
||||
|
||||
for (let i = 0; i <= 20; ++i) {
|
||||
batch.batch.push({
|
||||
userId: `@user${i}:example.org`,
|
||||
deviceId: FAKE_DEVICE_ID,
|
||||
payload: FAKE_PAYLOAD,
|
||||
});
|
||||
}
|
||||
|
||||
httpBackend.when(
|
||||
"PUT", "/sendToDevice/org.example.foo/",
|
||||
).check((request) => {
|
||||
expect(Object.keys(request.data.messages).length).toEqual(20);
|
||||
}).respond(200, {});
|
||||
|
||||
httpBackend.when(
|
||||
"PUT", "/sendToDevice/org.example.foo/",
|
||||
).check((request) => {
|
||||
expect(Object.keys(request.data.messages).length).toEqual(1);
|
||||
}).respond(200, {});
|
||||
|
||||
await client.queueToDevice(batch);
|
||||
await httpBackend.flushAllExpected();
|
||||
});
|
||||
});
|
@ -415,71 +415,6 @@ describe('Call', function() {
|
||||
}).track.id).toBe("video_track");
|
||||
});
|
||||
|
||||
describe("should handle stream replacement", () => {
|
||||
it("with both purpose and id", async () => {
|
||||
await startVoiceCall(client, call);
|
||||
|
||||
call.updateRemoteSDPStreamMetadata({
|
||||
"remote_stream1": {
|
||||
purpose: SDPStreamMetadataPurpose.Usermedia,
|
||||
},
|
||||
});
|
||||
call.pushRemoteFeed(new MockMediaStream("remote_stream1", []));
|
||||
const feed = call.getFeeds().find((feed) => feed.stream.id === "remote_stream1");
|
||||
|
||||
call.updateRemoteSDPStreamMetadata({
|
||||
"remote_stream2": {
|
||||
purpose: SDPStreamMetadataPurpose.Usermedia,
|
||||
},
|
||||
});
|
||||
call.pushRemoteFeed(new MockMediaStream("remote_stream2", []));
|
||||
|
||||
expect(feed?.stream?.id).toBe("remote_stream2");
|
||||
});
|
||||
|
||||
it("with just purpose", async () => {
|
||||
await startVoiceCall(client, call);
|
||||
|
||||
call.updateRemoteSDPStreamMetadata({
|
||||
"remote_stream1": {
|
||||
purpose: SDPStreamMetadataPurpose.Usermedia,
|
||||
},
|
||||
});
|
||||
call.pushRemoteFeed(new MockMediaStream("remote_stream1", []));
|
||||
const feed = call.getFeeds().find((feed) => feed.stream.id === "remote_stream1");
|
||||
|
||||
call.updateRemoteSDPStreamMetadata({
|
||||
"remote_stream2": {
|
||||
purpose: SDPStreamMetadataPurpose.Usermedia,
|
||||
},
|
||||
});
|
||||
call.pushRemoteFeed(new MockMediaStream("remote_stream2", []));
|
||||
|
||||
expect(feed?.stream?.id).toBe("remote_stream2");
|
||||
});
|
||||
|
||||
it("should not replace purpose is different", async () => {
|
||||
await startVoiceCall(client, call);
|
||||
|
||||
call.updateRemoteSDPStreamMetadata({
|
||||
"remote_stream1": {
|
||||
purpose: SDPStreamMetadataPurpose.Usermedia,
|
||||
},
|
||||
});
|
||||
call.pushRemoteFeed(new MockMediaStream("remote_stream1", []));
|
||||
const feed = call.getFeeds().find((feed) => feed.stream.id === "remote_stream1");
|
||||
|
||||
call.updateRemoteSDPStreamMetadata({
|
||||
"remote_stream2": {
|
||||
purpose: SDPStreamMetadataPurpose.Screenshare,
|
||||
},
|
||||
});
|
||||
call.pushRemoteFeed(new MockMediaStream("remote_stream2", []));
|
||||
|
||||
expect(feed?.stream?.id).toBe("remote_stream1");
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle SDPStreamMetadata changes", async () => {
|
||||
await startVoiceCall(client, call);
|
||||
|
||||
@ -794,4 +729,64 @@ describe('Call', function() {
|
||||
expect(supportsMatrixCall()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ignoring streams with ids for which we already have a feed", () => {
|
||||
const STREAM_ID = "stream_id";
|
||||
const FEEDS_CHANGED_CALLBACK = jest.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
await startVoiceCall(client, call);
|
||||
call.on(CallEvent.FeedsChanged, FEEDS_CHANGED_CALLBACK);
|
||||
jest.spyOn(call, "pushLocalFeed");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
FEEDS_CHANGED_CALLBACK.mockReset();
|
||||
});
|
||||
|
||||
it("should ignore stream passed to pushRemoteFeed()", async () => {
|
||||
await call.onAnswerReceived({
|
||||
getContent: () => {
|
||||
return {
|
||||
version: 1,
|
||||
call_id: call.callId,
|
||||
party_id: 'party_id',
|
||||
answer: {
|
||||
sdp: DUMMY_SDP,
|
||||
},
|
||||
[SDPStreamMetadataKey]: {
|
||||
[STREAM_ID]: {
|
||||
purpose: SDPStreamMetadataPurpose.Usermedia,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
getSender: () => "@test:foo",
|
||||
});
|
||||
|
||||
call.pushRemoteFeed(new MockMediaStream(STREAM_ID));
|
||||
call.pushRemoteFeed(new MockMediaStream(STREAM_ID));
|
||||
|
||||
expect(call.getRemoteFeeds().length).toBe(1);
|
||||
expect(FEEDS_CHANGED_CALLBACK).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should ignore stream passed to pushRemoteFeedWithoutMetadata()", async () => {
|
||||
call.pushRemoteFeedWithoutMetadata(new MockMediaStream(STREAM_ID));
|
||||
call.pushRemoteFeedWithoutMetadata(new MockMediaStream(STREAM_ID));
|
||||
|
||||
expect(call.getRemoteFeeds().length).toBe(1);
|
||||
expect(FEEDS_CHANGED_CALLBACK).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should ignore stream passed to pushNewLocalFeed()", async () => {
|
||||
call.pushNewLocalFeed(new MockMediaStream(STREAM_ID), SDPStreamMetadataPurpose.Screenshare);
|
||||
call.pushNewLocalFeed(new MockMediaStream(STREAM_ID), SDPStreamMetadataPurpose.Screenshare);
|
||||
|
||||
// We already have one local feed from placeVoiceCall()
|
||||
expect(call.getLocalFeeds().length).toBe(2);
|
||||
expect(FEEDS_CHANGED_CALLBACK).toHaveBeenCalledTimes(1);
|
||||
expect(call.pushLocalFeed).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -15,13 +15,11 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { SDPStreamMetadataPurpose } from "../../../src/webrtc/callEventTypes";
|
||||
import { CallFeed, CallFeedEvent } from "../../../src/webrtc/callFeed";
|
||||
import { MockMediaStream, MockMediaStreamTrack } from "../../test-utils/webrtc";
|
||||
import { CallFeed } from "../../../src/webrtc/callFeed";
|
||||
import { TestClient } from "../../TestClient";
|
||||
import { MockMediaStream, MockMediaStreamTrack } from "../../test-utils/webrtc";
|
||||
|
||||
describe("CallFeed", () => {
|
||||
const roomId = "room_id";
|
||||
|
||||
let client;
|
||||
|
||||
beforeEach(() => {
|
||||
@ -32,30 +30,60 @@ describe("CallFeed", () => {
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it("should handle stream replacement", () => {
|
||||
const feedNewStreamCallback = jest.fn();
|
||||
const feed = new CallFeed({
|
||||
describe("muting", () => {
|
||||
let feed: CallFeed;
|
||||
|
||||
beforeEach(() => {
|
||||
feed = new CallFeed({
|
||||
client,
|
||||
roomId,
|
||||
roomId: "room1",
|
||||
userId: "user1",
|
||||
// @ts-ignore Mock
|
||||
stream: new MockMediaStream("stream1"),
|
||||
id: "id",
|
||||
purpose: SDPStreamMetadataPurpose.Usermedia,
|
||||
audioMuted: false,
|
||||
videoMuted: false,
|
||||
});
|
||||
feed.on(CallFeedEvent.NewStream, feedNewStreamCallback);
|
||||
});
|
||||
|
||||
const replacementStream = new MockMediaStream("stream2");
|
||||
describe("muting by default", () => {
|
||||
it("should mute audio by default", () => {
|
||||
expect(feed.isAudioMuted()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should mute video by default", () => {
|
||||
expect(feed.isVideoMuted()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("muting after adding a track", () => {
|
||||
it("should un-mute audio", () => {
|
||||
// @ts-ignore Mock
|
||||
feed.setNewStream(replacementStream);
|
||||
expect(feedNewStreamCallback).toHaveBeenCalledWith(replacementStream);
|
||||
expect(feed.stream).toBe(replacementStream);
|
||||
feed.stream.addTrack(new MockMediaStreamTrack("track", "audio", true));
|
||||
expect(feed.isAudioMuted()).toBeFalsy();
|
||||
});
|
||||
|
||||
feedNewStreamCallback.mockReset();
|
||||
it("should un-mute video", () => {
|
||||
// @ts-ignore Mock
|
||||
feed.stream.addTrack(new MockMediaStreamTrack("track", "video", true));
|
||||
expect(feed.isVideoMuted()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
replacementStream.addTrack(new MockMediaStreamTrack("track_id", "audio"));
|
||||
expect(feedNewStreamCallback).toHaveBeenCalledWith(replacementStream);
|
||||
describe("muting after calling setAudioVideoMuted()", () => {
|
||||
it("should mute audio by default ", () => {
|
||||
// @ts-ignore Mock
|
||||
feed.stream.addTrack(new MockMediaStreamTrack("track", "audio", true));
|
||||
feed.setAudioVideoMuted(true, false);
|
||||
expect(feed.isAudioMuted()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should mute video by default", () => {
|
||||
// @ts-ignore Mock
|
||||
feed.stream.addTrack(new MockMediaStreamTrack("track", "video", true));
|
||||
feed.setAudioVideoMuted(false, true);
|
||||
expect(feed.isVideoMuted()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
125
src/ToDeviceMessageQueue.ts
Normal file
125
src/ToDeviceMessageQueue.ts
Normal file
@ -0,0 +1,125 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger } from "./logger";
|
||||
import { MatrixClient } from "./matrix";
|
||||
import { IndexedToDeviceBatch, ToDeviceBatch, ToDeviceBatchWithTxnId, ToDevicePayload } from "./models/ToDeviceMessage";
|
||||
import { MatrixScheduler } from "./scheduler";
|
||||
|
||||
const MAX_BATCH_SIZE = 20;
|
||||
|
||||
/**
|
||||
* Maintains a queue of outgoing to-device messages, sending them
|
||||
* as soon as the homeserver is reachable.
|
||||
*/
|
||||
export class ToDeviceMessageQueue {
|
||||
private sending = false;
|
||||
private running = true;
|
||||
private retryTimeout: number = null;
|
||||
private retryAttempts = 0;
|
||||
|
||||
constructor(private client: MatrixClient) {
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
this.running = true;
|
||||
this.sendQueue();
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.running = false;
|
||||
if (this.retryTimeout !== null) clearTimeout(this.retryTimeout);
|
||||
this.retryTimeout = null;
|
||||
}
|
||||
|
||||
public async queueBatch(batch: ToDeviceBatch): Promise<void> {
|
||||
const batches: ToDeviceBatchWithTxnId[] = [];
|
||||
for (let i = 0; i < batch.batch.length; i += MAX_BATCH_SIZE) {
|
||||
batches.push({
|
||||
eventType: batch.eventType,
|
||||
batch: batch.batch.slice(i, i + MAX_BATCH_SIZE),
|
||||
txnId: this.client.makeTxnId(),
|
||||
});
|
||||
}
|
||||
|
||||
await this.client.store.saveToDeviceBatches(batches);
|
||||
this.sendQueue();
|
||||
}
|
||||
|
||||
public sendQueue = async (): Promise<void> => {
|
||||
if (this.retryTimeout !== null) clearTimeout(this.retryTimeout);
|
||||
this.retryTimeout = null;
|
||||
|
||||
if (this.sending || !this.running) return;
|
||||
|
||||
logger.debug("Attempting to send queued to-device messages");
|
||||
|
||||
this.sending = true;
|
||||
let headBatch;
|
||||
try {
|
||||
while (this.running) {
|
||||
headBatch = await this.client.store.getOldestToDeviceBatch();
|
||||
if (headBatch === null) break;
|
||||
await this.sendBatch(headBatch);
|
||||
await this.client.store.removeToDeviceBatch(headBatch.id);
|
||||
this.retryAttempts = 0;
|
||||
}
|
||||
|
||||
// Make sure we're still running after the async tasks: if not, stop.
|
||||
if (!this.running) return;
|
||||
|
||||
logger.debug("All queued to-device messages sent");
|
||||
} catch (e) {
|
||||
++this.retryAttempts;
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
// eslint-disable-next-line new-cap
|
||||
const retryDelay = MatrixScheduler.RETRY_BACKOFF_RATELIMIT(null, this.retryAttempts, e);
|
||||
if (retryDelay === -1) {
|
||||
// the scheduler function doesn't differentiate between fatal errors and just getting
|
||||
// bored and giving up for now
|
||||
if (Math.floor(e.httpStatus / 100) === 4) {
|
||||
logger.error("Fatal error when sending to-device message - dropping to-device batch!", e);
|
||||
await this.client.store.removeToDeviceBatch(headBatch.id);
|
||||
} else {
|
||||
logger.info("Automatic retry limit reached for to-device messages.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Failed to send batch of to-device messages. Will retry in ${retryDelay}ms`, e);
|
||||
this.retryTimeout = setTimeout(this.sendQueue, retryDelay);
|
||||
} finally {
|
||||
this.sending = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempts to send a batch of to-device messages.
|
||||
*/
|
||||
private async sendBatch(batch: IndexedToDeviceBatch): Promise<void> {
|
||||
const contentMap: Record<string, Record<string, ToDevicePayload>> = {};
|
||||
for (const item of batch.batch) {
|
||||
if (!contentMap[item.userId]) {
|
||||
contentMap[item.userId] = {};
|
||||
}
|
||||
contentMap[item.userId][item.deviceId] = item.payload;
|
||||
}
|
||||
|
||||
logger.info(`Sending batch of ${batch.batch.length} to-device messages with ID ${batch.id}`);
|
||||
|
||||
await this.client.sendToDevice(batch.eventType, contentMap, batch.txnId);
|
||||
}
|
||||
}
|
@ -41,9 +41,11 @@ import { sleep } from './utils';
|
||||
import { Direction, EventTimeline } from "./models/event-timeline";
|
||||
import { IActionsObject, PushProcessor } from "./pushprocessor";
|
||||
import { AutoDiscovery, AutoDiscoveryAction } from "./autodiscovery";
|
||||
import { IEncryptAndSendToDevicesResult } from "./crypto";
|
||||
import * as olmlib from "./crypto/olmlib";
|
||||
import { decodeBase64, encodeBase64 } from "./crypto/olmlib";
|
||||
import { IExportedDevice as IOlmDevice } from "./crypto/OlmDevice";
|
||||
import { IExportedDevice as IExportedOlmDevice } from "./crypto/OlmDevice";
|
||||
import { IOlmDevice } from "./crypto/algorithms/megolm";
|
||||
import { TypedReEmitter } from './ReEmitter';
|
||||
import { IRoomEncryption, RoomList } from './crypto/RoomList';
|
||||
import { logger } from './logger';
|
||||
@ -202,6 +204,8 @@ import { MSC3575SlidingSyncRequest, MSC3575SlidingSyncResponse, SlidingSync } fr
|
||||
import { SlidingSyncSdk } from "./sliding-sync-sdk";
|
||||
import { Thread, THREAD_RELATION_TYPE } from "./models/thread";
|
||||
import { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon";
|
||||
import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue";
|
||||
import { ToDeviceBatch } from "./models/ToDeviceMessage";
|
||||
|
||||
export type Store = IStore;
|
||||
|
||||
@ -214,7 +218,7 @@ const CAPABILITIES_CACHE_MS = 21600000; // 6 hours - an arbitrary value
|
||||
const TURN_CHECK_INTERVAL = 10 * 60 * 1000; // poll for turn credentials every 10 minutes
|
||||
|
||||
interface IExportedDevice {
|
||||
olmDevice: IOlmDevice;
|
||||
olmDevice: IExportedOlmDevice;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
}
|
||||
@ -955,13 +959,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
protected turnServers: ITurnServer[] = [];
|
||||
protected turnServersExpiry = 0;
|
||||
protected checkTurnServersIntervalID: ReturnType<typeof setInterval>;
|
||||
protected exportedOlmDeviceToImport: IOlmDevice;
|
||||
protected exportedOlmDeviceToImport: IExportedOlmDevice;
|
||||
protected txnCtr = 0;
|
||||
protected mediaHandler = new MediaHandler(this);
|
||||
protected sessionId: string;
|
||||
protected pendingEventEncryption = new Map<string, Promise<void>>();
|
||||
|
||||
private useE2eForGroupCall = true;
|
||||
private toDeviceMessageQueue: ToDeviceMessageQueue;
|
||||
|
||||
constructor(opts: IMatrixClientCreateOpts) {
|
||||
super();
|
||||
@ -1061,6 +1066,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// we don't want to start sending unencrypted events to them.
|
||||
this.roomList = new RoomList(this.cryptoStore);
|
||||
|
||||
this.toDeviceMessageQueue = new ToDeviceMessageQueue(this);
|
||||
|
||||
// The SDK doesn't really provide a clean way for events to recalculate the push
|
||||
// actions for themselves, so we have to kinda help them out when they are encrypted.
|
||||
// We do this so that push rules are correctly executed on events in their decrypted
|
||||
@ -1224,6 +1231,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}, 1000 * this.clientOpts.clientWellKnownPollPeriod);
|
||||
this.fetchClientWellKnown();
|
||||
}
|
||||
|
||||
this.toDeviceMessageQueue.start();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1251,6 +1260,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
if (this.clientWellKnownIntervalID !== undefined) {
|
||||
global.clearInterval(this.clientWellKnownIntervalID);
|
||||
}
|
||||
|
||||
this.toDeviceMessageQueue.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1652,9 +1663,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
/**
|
||||
* Retry a backed off syncing request immediately. This should only be used when
|
||||
* the user <b>explicitly</b> attempts to retry their lost connection.
|
||||
* Will also retry any outbound to-device messages currently in the queue to be sent
|
||||
* (retries of regular outgoing events are handled separately, per-event).
|
||||
* @return {boolean} True if this resulted in a request being retried.
|
||||
*/
|
||||
public retryImmediately(): boolean {
|
||||
// don't await for this promise: we just want to kick it off
|
||||
this.toDeviceMessageQueue.sendQueue();
|
||||
return this.syncApi.retryImmediately();
|
||||
}
|
||||
|
||||
@ -2635,6 +2650,30 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return this.roomList.isRoomEncrypted(roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts and sends a given object via Olm to-device messages to a given
|
||||
* set of devices.
|
||||
*
|
||||
* @param {object[]} userDeviceInfoArr
|
||||
* mapping from userId to deviceInfo
|
||||
*
|
||||
* @param {object} payload fields to include in the encrypted payload
|
||||
* *
|
||||
* @return {Promise<{contentMap, deviceInfoByDeviceId}>} Promise which
|
||||
* resolves once the message has been encrypted and sent to the given
|
||||
* userDeviceMap, and returns the { contentMap, deviceInfoByDeviceId }
|
||||
* of the successfully sent messages.
|
||||
*/
|
||||
public encryptAndSendToDevices(
|
||||
userDeviceInfoArr: IOlmDevice<DeviceInfo>[],
|
||||
payload: object,
|
||||
): Promise<IEncryptAndSendToDevicesResult> {
|
||||
if (!this.crypto) {
|
||||
throw new Error("End-to-End encryption disabled");
|
||||
}
|
||||
return this.crypto.encryptAndSendToDevices(userDeviceInfoArr, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the current outbound group session to be discarded such
|
||||
* that another one will be created next time an event is sent.
|
||||
@ -3591,7 +3630,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend an event.
|
||||
* Resend an event. Will also retry any to-device messages waiting to be sent.
|
||||
* @param {MatrixEvent} event The event to resend.
|
||||
* @param {Room} room Optional. The room the event is in. Will update the
|
||||
* timeline entry if provided.
|
||||
@ -3599,6 +3638,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @return {module:http-api.MatrixError} Rejects: with an error response.
|
||||
*/
|
||||
public resendEvent(event: MatrixEvent, room: Room): Promise<ISendEventResponse> {
|
||||
// also kick the to-device queue to retry
|
||||
this.toDeviceMessageQueue.sendQueue();
|
||||
|
||||
this.updatePendingEventStatus(room, event, EventStatus.SENDING);
|
||||
return this.encryptAndSendEvent(room, event);
|
||||
}
|
||||
@ -8786,7 +8828,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an event to a specific list of devices
|
||||
* Send an event to a specific list of devices.
|
||||
* This is a low-level API that simply wraps the HTTP API
|
||||
* call to send to-device messages. We recommend using
|
||||
* queueToDevice() which is a higher level API.
|
||||
*
|
||||
* @param {string} eventType type of event to send
|
||||
* @param {Object.<string, Object<string, Object>>} contentMap
|
||||
@ -8818,6 +8863,17 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return this.http.authedRequest(undefined, Method.Put, path, undefined, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends events directly to specific devices using Matrix's to-device
|
||||
* messaging system. The batch will be split up into appropriately sized
|
||||
* batches for sending and stored in the store so they can be retried
|
||||
* later if they fail to send. Retries will happen automatically.
|
||||
* @param batch The to-device messages to send
|
||||
*/
|
||||
public queueToDevice(batch: ToDeviceBatch): Promise<void> {
|
||||
return this.toDeviceMessageQueue.queueBatch(batch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the third party protocols that can be reached using
|
||||
* this HS
|
||||
|
@ -606,18 +606,16 @@ class MegolmEncryption extends EncryptionAlgorithm {
|
||||
return this.crypto.encryptAndSendToDevices(
|
||||
userDeviceMap,
|
||||
payload,
|
||||
).then(({ contentMap, deviceInfoByUserIdAndDeviceId }) => {
|
||||
).then(({ toDeviceBatch, deviceInfoByUserIdAndDeviceId }) => {
|
||||
// store that we successfully uploaded the keys of the current slice
|
||||
for (const userId of Object.keys(contentMap)) {
|
||||
for (const deviceId of Object.keys(contentMap[userId])) {
|
||||
for (const msg of toDeviceBatch.batch) {
|
||||
session.markSharedWithDevice(
|
||||
userId,
|
||||
deviceId,
|
||||
deviceInfoByUserIdAndDeviceId.get(userId).get(deviceId).getIdentityKey(),
|
||||
msg.userId,
|
||||
msg.deviceId,
|
||||
deviceInfoByUserIdAndDeviceId.get(msg.userId).get(msg.deviceId).getIdentityKey(),
|
||||
chainIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
}).catch((error) => {
|
||||
logger.error("failed to encryptAndSendToDevices", error);
|
||||
throw error;
|
||||
|
@ -23,6 +23,7 @@ limitations under the License.
|
||||
|
||||
import anotherjson from "another-json";
|
||||
|
||||
import { EventType } from "../@types/event";
|
||||
import { TypedReEmitter } from '../ReEmitter';
|
||||
import { logger } from '../logger';
|
||||
import { IExportedDevice, OlmDevice } from "./OlmDevice";
|
||||
@ -69,6 +70,7 @@ import { IStore } from "../store";
|
||||
import { Room, RoomEvent } from "../models/room";
|
||||
import { RoomMember, RoomMemberEvent } from "../models/room-member";
|
||||
import { EventStatus, IClearEvent, IEvent, MatrixEvent, MatrixEventEvent } from "../models/event";
|
||||
import { ToDeviceBatch } from "../models/ToDeviceMessage";
|
||||
import {
|
||||
ClientEvent,
|
||||
ICrossSigningKey,
|
||||
@ -210,8 +212,8 @@ export interface IEncryptedContent {
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
interface IEncryptAndSendToDevicesResult {
|
||||
contentMap: Record<string, Record<string, IEncryptedContent>>;
|
||||
export interface IEncryptAndSendToDevicesResult {
|
||||
toDeviceBatch: ToDeviceBatch;
|
||||
deviceInfoByUserIdAndDeviceId: Map<string, Map<string, DeviceInfo>>;
|
||||
}
|
||||
|
||||
@ -3115,29 +3117,27 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts and sends a given object via Olm to-device message to a given
|
||||
* set of devices. Factored out from encryptAndSendKeysToDevices in
|
||||
* megolm.ts.
|
||||
*
|
||||
* @param {object[]} userDeviceInfoArr
|
||||
* mapping from userId to deviceInfo
|
||||
*
|
||||
* Encrypts and sends a given object via Olm to-device messages to a given
|
||||
* set of devices.
|
||||
* @param {object[]} userDeviceInfoArr the devices to send to
|
||||
* @param {object} payload fields to include in the encrypted payload
|
||||
* *
|
||||
* @return {Promise<{contentMap, deviceInfoByDeviceId}>} Promise which
|
||||
* resolves once the message has been encrypted and sent to the given
|
||||
* userDeviceMap, and returns the { contentMap, deviceInfoByDeviceId }
|
||||
* of the successfully sent messages.
|
||||
*/
|
||||
public encryptAndSendToDevices(
|
||||
public async encryptAndSendToDevices(
|
||||
userDeviceInfoArr: IOlmDevice<DeviceInfo>[],
|
||||
payload: object,
|
||||
): Promise<IEncryptAndSendToDevicesResult> {
|
||||
const contentMap: Record<string, Record<string, IEncryptedContent>> = {};
|
||||
const toDeviceBatch: ToDeviceBatch = {
|
||||
eventType: EventType.RoomMessageEncrypted,
|
||||
batch: [],
|
||||
};
|
||||
const deviceInfoByUserIdAndDeviceId = new Map<string, Map<string, DeviceInfo>>();
|
||||
|
||||
const promises: Promise<unknown>[] = [];
|
||||
for (const { userId, deviceInfo } of userDeviceInfoArr) {
|
||||
try {
|
||||
await Promise.all(userDeviceInfoArr.map(async ({ userId, deviceInfo }) => {
|
||||
const deviceId = deviceInfo.deviceId;
|
||||
const encryptedContent: IEncryptedContent = {
|
||||
algorithm: olmlib.OLM_ALGORITHM,
|
||||
@ -3156,18 +3156,18 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
// We hold by reference, this updates deviceInfoByUserIdAndDeviceId[userId]
|
||||
userIdDeviceInfo.set(deviceId, deviceInfo);
|
||||
|
||||
if (!contentMap[userId]) {
|
||||
contentMap[userId] = {};
|
||||
}
|
||||
contentMap[userId][deviceId] = encryptedContent;
|
||||
toDeviceBatch.batch.push({
|
||||
userId,
|
||||
deviceId,
|
||||
payload: encryptedContent,
|
||||
});
|
||||
|
||||
promises.push(
|
||||
olmlib.ensureOlmSessionsForDevices(
|
||||
await olmlib.ensureOlmSessionsForDevices(
|
||||
this.olmDevice,
|
||||
this.baseApis,
|
||||
{ [userId]: [deviceInfo] },
|
||||
).then(() =>
|
||||
olmlib.encryptMessageForDevice(
|
||||
);
|
||||
await olmlib.encryptMessageForDevice(
|
||||
encryptedContent.ciphertext,
|
||||
this.userId,
|
||||
this.deviceId,
|
||||
@ -3175,46 +3175,33 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
userId,
|
||||
deviceInfo,
|
||||
payload,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}));
|
||||
|
||||
return Promise.all(promises).then(() => {
|
||||
// prune out any devices that encryptMessageForDevice could not encrypt for,
|
||||
// in which case it will have just not added anything to the ciphertext object.
|
||||
// There's no point sending messages to devices if we couldn't encrypt to them,
|
||||
// since that's effectively a blank message.
|
||||
for (const userId of Object.keys(contentMap)) {
|
||||
for (const deviceId of Object.keys(contentMap[userId])) {
|
||||
if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) {
|
||||
logger.log(`No ciphertext for device ${userId}:${deviceId}: pruning`);
|
||||
delete contentMap[userId][deviceId];
|
||||
toDeviceBatch.batch = toDeviceBatch.batch.filter(msg => {
|
||||
if (Object.keys(msg.payload.ciphertext).length > 0) {
|
||||
return true;
|
||||
} else {
|
||||
logger.log(`No ciphertext for device ${msg.userId}:${msg.deviceId}: pruning`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// No devices left for that user? Strip that too.
|
||||
if (Object.keys(contentMap[userId]).length === 0) {
|
||||
logger.log(`Pruned all devices for user ${userId}`);
|
||||
delete contentMap[userId];
|
||||
}
|
||||
}
|
||||
|
||||
// Is there anything left?
|
||||
if (Object.keys(contentMap).length === 0) {
|
||||
logger.log("No users left to send to: aborting");
|
||||
return;
|
||||
}
|
||||
|
||||
return this.baseApis.sendToDevice("m.room.encrypted", contentMap).then(
|
||||
(response) => ({ contentMap, deviceInfoByUserIdAndDeviceId }),
|
||||
).catch(error => {
|
||||
logger.error("sendToDevice failed", error);
|
||||
throw error;
|
||||
});
|
||||
}).catch(error => {
|
||||
logger.error("encryptAndSendToDevices promises failed", error);
|
||||
throw error;
|
||||
});
|
||||
|
||||
try {
|
||||
await this.baseApis.queueToDevice(toDeviceBatch);
|
||||
return { toDeviceBatch, deviceInfoByUserIdAndDeviceId };
|
||||
} catch (e) {
|
||||
logger.error("sendToDevice failed", e);
|
||||
throw e;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("encryptAndSendToDevices promises failed", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private onMembership = (event: MatrixEvent, member: RoomMember, oldMembership?: string) => {
|
||||
|
38
src/models/ToDeviceMessage.ts
Normal file
38
src/models/ToDeviceMessage.ts
Normal file
@ -0,0 +1,38 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
export type ToDevicePayload = Record<string, any>;
|
||||
|
||||
export interface ToDeviceMessage {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
payload: ToDevicePayload;
|
||||
}
|
||||
|
||||
export interface ToDeviceBatch {
|
||||
eventType: string;
|
||||
batch: ToDeviceMessage[];
|
||||
}
|
||||
|
||||
// Only used internally
|
||||
export interface ToDeviceBatchWithTxnId extends ToDeviceBatch {
|
||||
txnId: string;
|
||||
}
|
||||
|
||||
// Only used internally
|
||||
export interface IndexedToDeviceBatch extends ToDeviceBatchWithTxnId {
|
||||
id: number;
|
||||
}
|
@ -72,7 +72,7 @@ function synthesizeReceipt(userId: string, event: MatrixEvent, receiptType: Rece
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "m.receipt",
|
||||
type: EventType.Receipt,
|
||||
room_id: event.getRoomId(),
|
||||
});
|
||||
}
|
||||
@ -2423,9 +2423,9 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
|
||||
*/
|
||||
public addEphemeralEvents(events: MatrixEvent[]): void {
|
||||
for (const event of events) {
|
||||
if (event.getType() === 'm.typing') {
|
||||
if (event.getType() === EventType.Typing) {
|
||||
this.currentState.setTypingEvent(event);
|
||||
} else if (event.getType() === 'm.receipt') {
|
||||
} else if (event.getType() === EventType.Receipt) {
|
||||
this.addReceipt(event);
|
||||
} // else ignore - life is too short for us to care about these events
|
||||
}
|
||||
|
@ -57,7 +57,7 @@ export class MatrixScheduler<T = ISendEventResponse> {
|
||||
* failure was due to a rate limited request, the time specified in the error is
|
||||
* waited before being retried.
|
||||
* @param {MatrixEvent} event
|
||||
* @param {Number} attempts
|
||||
* @param {Number} attempts Number of attempts that have been made, including the one that just failed (ie. starting at 1)
|
||||
* @param {MatrixError} err
|
||||
* @return {Number}
|
||||
* @see module:scheduler~retryAlgorithm
|
||||
|
@ -44,6 +44,9 @@ export interface MSC3575Filter {
|
||||
is_invite?: boolean;
|
||||
is_tombstoned?: boolean;
|
||||
room_name_like?: string;
|
||||
room_types?: string[];
|
||||
not_room_types?: string[];
|
||||
spaces?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -602,7 +605,7 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
listIndex,
|
||||
op.range[0],
|
||||
op.range[1],
|
||||
op.room_ids.join(" "),
|
||||
(op.room_ids || []).join(" "),
|
||||
";",
|
||||
);
|
||||
break;
|
||||
|
@ -23,6 +23,7 @@ import { RoomSummary } from "../models/room-summary";
|
||||
import { IMinimalEvent, IRooms, ISyncResponse } from "../sync-accumulator";
|
||||
import { IStartClientOpts } from "../client";
|
||||
import { IStateEventWithRoomId } from "../@types/search";
|
||||
import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage";
|
||||
|
||||
export interface ISavedSync {
|
||||
nextBatch: string;
|
||||
@ -31,8 +32,7 @@ export interface ISavedSync {
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a stub store. This does no-ops on most store methods.
|
||||
* @constructor
|
||||
* A store for most of the data js-sdk needs to store, apart from crypto data
|
||||
*/
|
||||
export interface IStore {
|
||||
readonly accountData: Record<string, MatrixEvent>; // type : content
|
||||
@ -57,21 +57,21 @@ export interface IStore {
|
||||
setSyncToken(token: string): void;
|
||||
|
||||
/**
|
||||
* No-op.
|
||||
* @param {Room} room
|
||||
* Store the given room.
|
||||
* @param {Room} room The room to be stored. All properties must be stored.
|
||||
*/
|
||||
storeRoom(room: Room): void;
|
||||
|
||||
/**
|
||||
* No-op.
|
||||
* @param {string} roomId
|
||||
* @return {null}
|
||||
* Retrieve a room by its' room ID.
|
||||
* @param {string} roomId The room ID.
|
||||
* @return {Room} The room or null.
|
||||
*/
|
||||
getRoom(roomId: string): Room | null;
|
||||
|
||||
/**
|
||||
* No-op.
|
||||
* @return {Array} An empty array.
|
||||
* Retrieve all known rooms.
|
||||
* @return {Room[]} A list of rooms, which may be empty.
|
||||
*/
|
||||
getRooms(): Room[];
|
||||
|
||||
@ -82,35 +82,36 @@ export interface IStore {
|
||||
removeRoom(roomId: string): void;
|
||||
|
||||
/**
|
||||
* No-op.
|
||||
* @return {Array} An empty array.
|
||||
* Retrieve a summary of all the rooms.
|
||||
* @return {RoomSummary[]} A summary of each room.
|
||||
*/
|
||||
getRoomSummaries(): RoomSummary[];
|
||||
|
||||
/**
|
||||
* No-op.
|
||||
* @param {User} user
|
||||
* Store a User.
|
||||
* @param {User} user The user to store.
|
||||
*/
|
||||
storeUser(user: User): void;
|
||||
|
||||
/**
|
||||
* No-op.
|
||||
* @param {string} userId
|
||||
* @return {null}
|
||||
* Retrieve a User by its' user ID.
|
||||
* @param {string} userId The user ID.
|
||||
* @return {User} The user or null.
|
||||
*/
|
||||
getUser(userId: string): User | null;
|
||||
|
||||
/**
|
||||
* No-op.
|
||||
* @return {User[]}
|
||||
* Retrieve all known users.
|
||||
* @return {User[]} A list of users, which may be empty.
|
||||
*/
|
||||
getUsers(): User[];
|
||||
|
||||
/**
|
||||
* No-op.
|
||||
* @param {Room} room
|
||||
* @param {number} limit
|
||||
* @return {Array}
|
||||
* Retrieve scrollback for this room.
|
||||
* @param {Room} room The matrix room
|
||||
* @param {number} limit The max number of old events to retrieve.
|
||||
* @return {Array<Object>} An array of objects which will be at most 'limit'
|
||||
* length and at least 0. The objects are the raw event JSON.
|
||||
*/
|
||||
scrollback(room: Room, limit: number): MatrixEvent[];
|
||||
|
||||
@ -209,8 +210,23 @@ export interface IStore {
|
||||
*/
|
||||
deleteAllData(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Returns the out-of-band membership events for this room that
|
||||
* were previously loaded.
|
||||
* @param {string} roomId
|
||||
* @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members
|
||||
* @returns {null} in case the members for this room haven't been stored yet
|
||||
*/
|
||||
getOutOfBandMembers(roomId: string): Promise<IStateEventWithRoomId[] | null>;
|
||||
|
||||
/**
|
||||
* Stores the out-of-band membership events for this room. Note that
|
||||
* it still makes sense to store an empty array as the OOB status for the room is
|
||||
* marked as fetched, and getOutOfBandMembers will return an empty array instead of null
|
||||
* @param {string} roomId
|
||||
* @param {event[]} membershipEvents the membership events to store
|
||||
* @returns {Promise} when all members have been stored
|
||||
*/
|
||||
setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void>;
|
||||
|
||||
clearOutOfBandMembers(roomId: string): Promise<void>;
|
||||
@ -222,4 +238,19 @@ export interface IStore {
|
||||
getPendingEvents(roomId: string): Promise<Partial<IEvent>[]>;
|
||||
|
||||
setPendingEvents(roomId: string, events: Partial<IEvent>[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Stores batches of outgoing to-device messages
|
||||
*/
|
||||
saveToDeviceBatches(batch: ToDeviceBatchWithTxnId[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Fetches the oldest batch of to-device messages in the queue
|
||||
*/
|
||||
getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch>;
|
||||
|
||||
/**
|
||||
* Removes a specific batch of to-device messages from the queue
|
||||
*/
|
||||
removeToDeviceBatch(id: number): Promise<void>;
|
||||
}
|
||||
|
@ -16,6 +16,7 @@ limitations under the License.
|
||||
|
||||
import { ISavedSync } from "./index";
|
||||
import { IEvent, IStartClientOpts, IStateEventWithRoomId, ISyncResponse } from "..";
|
||||
import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage";
|
||||
|
||||
export interface IIndexedDBBackend {
|
||||
connect(): Promise<void>;
|
||||
@ -31,6 +32,9 @@ export interface IIndexedDBBackend {
|
||||
getUserPresenceEvents(): Promise<UserTuple[]>;
|
||||
getClientOptions(): Promise<IStartClientOpts>;
|
||||
storeClientOptions(options: IStartClientOpts): Promise<void>;
|
||||
saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise<void>;
|
||||
getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch>;
|
||||
removeToDeviceBatch(id: number): Promise<void>;
|
||||
}
|
||||
|
||||
export type UserTuple = [userId: string, presenceEvent: Partial<IEvent>];
|
||||
|
@ -21,8 +21,9 @@ import { logger } from '../logger';
|
||||
import { IStartClientOpts, IStateEventWithRoomId } from "..";
|
||||
import { ISavedSync } from "./index";
|
||||
import { IIndexedDBBackend, UserTuple } from "./indexeddb-backend";
|
||||
import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage";
|
||||
|
||||
const VERSION = 3;
|
||||
const VERSION = 4;
|
||||
|
||||
function createDatabase(db: IDBDatabase): void {
|
||||
// Make user store, clobber based on user ID. (userId property of User objects)
|
||||
@ -49,6 +50,10 @@ function upgradeSchemaV3(db: IDBDatabase): void {
|
||||
{ keyPath: ["clobber"] });
|
||||
}
|
||||
|
||||
function upgradeSchemaV4(db: IDBDatabase): void {
|
||||
db.createObjectStore("to_device_queue", { autoIncrement: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to collect results from a Cursor and promiseify it.
|
||||
* @param {ObjectStore|Index} store The store to perform openCursor on.
|
||||
@ -112,7 +117,7 @@ function reqAsPromise(req: IDBRequest): Promise<IDBRequest> {
|
||||
});
|
||||
}
|
||||
|
||||
function reqAsCursorPromise(req: IDBRequest<IDBCursor | null>): Promise<IDBCursor> {
|
||||
function reqAsCursorPromise<T>(req: IDBRequest<T>): Promise<T> {
|
||||
return reqAsEventPromise(req).then((event) => req.result);
|
||||
}
|
||||
|
||||
@ -177,6 +182,9 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
|
||||
if (oldVersion < 3) {
|
||||
upgradeSchemaV3(db);
|
||||
}
|
||||
if (oldVersion < 4) {
|
||||
upgradeSchemaV4(db);
|
||||
}
|
||||
// Expand as needed.
|
||||
};
|
||||
|
||||
@ -561,4 +569,36 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
|
||||
}); // put == UPSERT
|
||||
await txnAsPromise(txn);
|
||||
}
|
||||
|
||||
public async saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise<void> {
|
||||
const txn = this.db.transaction(["to_device_queue"], "readwrite");
|
||||
const store = txn.objectStore("to_device_queue");
|
||||
for (const batch of batches) {
|
||||
store.add(batch);
|
||||
}
|
||||
await txnAsPromise(txn);
|
||||
}
|
||||
|
||||
public async getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch | null> {
|
||||
const txn = this.db.transaction(["to_device_queue"], "readonly");
|
||||
const store = txn.objectStore("to_device_queue");
|
||||
const cursor = await reqAsCursorPromise(store.openCursor());
|
||||
if (!cursor) return null;
|
||||
|
||||
const resultBatch = cursor.value as ToDeviceBatchWithTxnId;
|
||||
|
||||
return {
|
||||
id: cursor.key as number,
|
||||
txnId: resultBatch.txnId,
|
||||
eventType: resultBatch.eventType,
|
||||
batch: resultBatch.batch,
|
||||
};
|
||||
}
|
||||
|
||||
public async removeToDeviceBatch(id: number): Promise<void> {
|
||||
const txn = this.db.transaction(["to_device_queue"], "readwrite");
|
||||
const store = txn.objectStore("to_device_queue");
|
||||
store.delete(id);
|
||||
await txnAsPromise(txn);
|
||||
}
|
||||
}
|
||||
|
@ -20,6 +20,7 @@ import { ISavedSync } from "./index";
|
||||
import { IStartClientOpts } from "../client";
|
||||
import { IStateEventWithRoomId, ISyncResponse } from "..";
|
||||
import { IIndexedDBBackend, UserTuple } from "./indexeddb-backend";
|
||||
import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage";
|
||||
|
||||
export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend {
|
||||
private worker: Worker;
|
||||
@ -133,6 +134,18 @@ export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend {
|
||||
return this.doCmd('getUserPresenceEvents');
|
||||
}
|
||||
|
||||
public async saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise<void> {
|
||||
return this.doCmd('saveToDeviceBatches', [batches]);
|
||||
}
|
||||
|
||||
public async getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch> {
|
||||
return this.doCmd('getOldestToDeviceBatch');
|
||||
}
|
||||
|
||||
public async removeToDeviceBatch(id: number): Promise<void> {
|
||||
return this.doCmd('removeToDeviceBatch', [id]);
|
||||
}
|
||||
|
||||
private ensureStarted(): Promise<void> {
|
||||
if (this.startPromise === null) {
|
||||
this.worker = this.workerFactory();
|
||||
|
@ -103,6 +103,15 @@ export class IndexedDBStoreWorker {
|
||||
case 'storeClientOptions':
|
||||
prom = this.backend.storeClientOptions(msg.args[0]);
|
||||
break;
|
||||
case 'saveToDeviceBatches':
|
||||
prom = this.backend.saveToDeviceBatches(msg.args[0]);
|
||||
break;
|
||||
case 'getOldestToDeviceBatch':
|
||||
prom = this.backend.getOldestToDeviceBatch();
|
||||
break;
|
||||
case 'removeToDeviceBatch':
|
||||
prom = this.backend.removeToDeviceBatch(msg.args[0]);
|
||||
break;
|
||||
}
|
||||
|
||||
if (prom === undefined) {
|
||||
|
@ -27,6 +27,7 @@ import { IIndexedDBBackend } from "./indexeddb-backend";
|
||||
import { ISyncResponse } from "../sync-accumulator";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
import { IStateEventWithRoomId } from "../@types/search";
|
||||
import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage";
|
||||
|
||||
/**
|
||||
* This is an internal module. See {@link IndexedDBStore} for the public class.
|
||||
@ -351,6 +352,18 @@ export class IndexedDBStore extends MemoryStore {
|
||||
this.localStorage.removeItem(pendingEventsKey(roomId));
|
||||
}
|
||||
}
|
||||
|
||||
public saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise<void> {
|
||||
return this.backend.saveToDeviceBatches(batches);
|
||||
}
|
||||
|
||||
public getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch> {
|
||||
return this.backend.getOldestToDeviceBatch();
|
||||
}
|
||||
|
||||
public removeToDeviceBatch(id: number): Promise<void> {
|
||||
return this.backend.removeToDeviceBatch(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -30,6 +30,7 @@ import { ISavedSync, IStore } from "./index";
|
||||
import { RoomSummary } from "../models/room-summary";
|
||||
import { ISyncResponse } from "../sync-accumulator";
|
||||
import { IStateEventWithRoomId } from "../@types/search";
|
||||
import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage";
|
||||
|
||||
function isValidFilterId(filterId: string): boolean {
|
||||
const isValidStr = typeof filterId === "string" &&
|
||||
@ -64,6 +65,8 @@ export class MemoryStore implements IStore {
|
||||
private oobMembers: Record<string, IStateEventWithRoomId[]> = {}; // roomId: [member events]
|
||||
private pendingEvents: { [roomId: string]: Partial<IEvent>[] } = {};
|
||||
private clientOptions = {};
|
||||
private pendingToDeviceBatches: IndexedToDeviceBatch[] = [];
|
||||
private nextToDeviceBatchId = 0;
|
||||
|
||||
constructor(opts: IOpts = {}) {
|
||||
this.localStorage = opts.localStorage;
|
||||
@ -429,4 +432,26 @@ export class MemoryStore implements IStore {
|
||||
public async setPendingEvents(roomId: string, events: Partial<IEvent>[]): Promise<void> {
|
||||
this.pendingEvents[roomId] = events;
|
||||
}
|
||||
|
||||
public saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise<void> {
|
||||
for (const batch of batches) {
|
||||
this.pendingToDeviceBatches.push({
|
||||
id: this.nextToDeviceBatchId++,
|
||||
eventType: batch.eventType,
|
||||
txnId: batch.txnId,
|
||||
batch: batch.batch,
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public async getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch | null> {
|
||||
if (this.pendingToDeviceBatches.length === 0) return null;
|
||||
return this.pendingToDeviceBatches[0];
|
||||
}
|
||||
|
||||
public removeToDeviceBatch(id: number): Promise<void> {
|
||||
this.pendingToDeviceBatches = this.pendingToDeviceBatches.filter(batch => batch.id !== id);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
@ -28,6 +28,7 @@ import { ISavedSync, IStore } from "./index";
|
||||
import { RoomSummary } from "../models/room-summary";
|
||||
import { ISyncResponse } from "../sync-accumulator";
|
||||
import { IStateEventWithRoomId } from "../@types/search";
|
||||
import { IndexedToDeviceBatch, ToDeviceBatch } from "../models/ToDeviceMessage";
|
||||
|
||||
/**
|
||||
* Construct a stub store. This does no-ops on most store methods.
|
||||
@ -270,4 +271,16 @@ export class StubStore implements IStore {
|
||||
public setPendingEvents(roomId: string, events: Partial<IEvent>[]): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public async saveToDeviceBatches(batch: ToDeviceBatch[]): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch | null> {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
public async removeToDeviceBatch(id: number): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
@ -614,12 +614,11 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to find a feed with the same purpose as the new stream,
|
||||
// if we find it replace the old stream with the new one
|
||||
const existingFeed = this.getRemoteFeeds().find((feed) => feed.purpose === purpose);
|
||||
if (existingFeed) {
|
||||
existingFeed.setNewStream(stream);
|
||||
} else {
|
||||
if (this.getFeedByStreamId(stream.id)) {
|
||||
logger.warn(`Ignoring stream with id ${stream.id} because we already have a feed for it`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.feeds.push(new CallFeed({
|
||||
client: this.client,
|
||||
roomId: this.roomId,
|
||||
@ -630,10 +629,11 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
videoMuted,
|
||||
}));
|
||||
this.emit(CallEvent.FeedsChanged, this.feeds);
|
||||
}
|
||||
|
||||
logger.info(`Call ${this.callId} Pushed remote stream (id="${
|
||||
stream.id}", active="${stream.active}", purpose=${purpose})`);
|
||||
logger.info(
|
||||
`Call ${this.callId} pushed remote stream (id="${stream.id}", ` +
|
||||
`active="${stream.active}", purpose=${purpose})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -655,12 +655,11 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to find a feed with the same stream id as the new stream,
|
||||
// if we find it replace the old stream with the new one
|
||||
const feed = this.getFeedByStreamId(stream.id);
|
||||
if (feed) {
|
||||
feed.setNewStream(stream);
|
||||
} else {
|
||||
if (this.getFeedByStreamId(stream.id)) {
|
||||
logger.warn(`Ignoring stream with id ${stream.id} because we already have a feed for it`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.feeds.push(new CallFeed({
|
||||
client: this.client,
|
||||
roomId: this.roomId,
|
||||
@ -671,9 +670,8 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
purpose,
|
||||
}));
|
||||
this.emit(CallEvent.FeedsChanged, this.feeds);
|
||||
}
|
||||
|
||||
logger.info(`Call ${this.callId} Pushed remote stream (id="${stream.id}", active="${stream.active}")`);
|
||||
logger.info(`Call ${this.callId} pushed remote stream (id="${stream.id}", active="${stream.active}")`);
|
||||
}
|
||||
|
||||
private pushNewLocalFeed(stream: MediaStream, purpose: SDPStreamMetadataPurpose, addToPeerConnection = true): void {
|
||||
@ -685,11 +683,11 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
setTracksEnabled(stream.getAudioTracks(), true);
|
||||
setTracksEnabled(stream.getVideoTracks(), true);
|
||||
|
||||
// We try to replace an existing feed if there already is one with the same purpose
|
||||
const existingFeed = this.getLocalFeeds().find((feed) => feed.purpose === purpose);
|
||||
if (existingFeed) {
|
||||
existingFeed.setNewStream(stream);
|
||||
} else {
|
||||
if (this.getFeedByStreamId(stream.id)) {
|
||||
logger.warn(`Ignoring stream with id ${stream.id} because we already have a feed for it`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.pushLocalFeed(
|
||||
new CallFeed({
|
||||
client: this.client,
|
||||
@ -702,8 +700,6 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
}),
|
||||
addToPeerConnection,
|
||||
);
|
||||
this.emit(CallEvent.FeedsChanged, this.feeds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -181,7 +181,9 @@ export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap>
|
||||
|
||||
/**
|
||||
* Replaces the current MediaStream with a new one.
|
||||
* This method should be only used by MatrixCall.
|
||||
* The stream will be different and new stream as remore parties are
|
||||
* concerned, but this can be used for convenience locally to set up
|
||||
* volume listeners automatically on the new stream etc.
|
||||
* @param newStream new stream with which to replace the current one
|
||||
*/
|
||||
public setNewStream(newStream: MediaStream): void {
|
||||
|
31
yarn.lock
31
yarn.lock
@ -1307,7 +1307,6 @@
|
||||
|
||||
"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.12.tgz":
|
||||
version "3.2.12"
|
||||
uid "0bce3c86f9d36a4984d3c3e07df1c3fb4c679bd9"
|
||||
resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.12.tgz#0bce3c86f9d36a4984d3c3e07df1c3fb4c679bd9"
|
||||
|
||||
"@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3":
|
||||
@ -1438,9 +1437,9 @@
|
||||
"@octokit/openapi-types" "^12.10.0"
|
||||
|
||||
"@sinclair/typebox@^0.24.1":
|
||||
version "0.24.20"
|
||||
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.20.tgz#11a657875de6008622d53f56e063a6347c51a6dd"
|
||||
integrity sha512-kVaO5aEFZb33nPMTZBxiPEkY+slxiPtqC7QX8f9B3eGOMBvEfuMfxp9DSTTCsRJPumPKjrge4yagyssO4q6qzQ==
|
||||
version "0.24.26"
|
||||
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.26.tgz#84f9e8c1d93154e734a7947609a1dc7c7a81cc22"
|
||||
integrity sha512-1ZVIyyS1NXDRVT8GjWD5jULjhDyM3IsIHef2VGUMdnWOlX2tkPjyEX/7K0TGSH2S8EaPhp1ylFdjSjUGQ+gecg==
|
||||
|
||||
"@sinonjs/commons@^1.7.0":
|
||||
version "1.8.3"
|
||||
@ -1581,9 +1580,9 @@
|
||||
integrity sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==
|
||||
|
||||
"@types/node@*":
|
||||
version "18.6.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.6.1.tgz#828e4785ccca13f44e2fb6852ae0ef11e3e20ba5"
|
||||
integrity sha512-z+2vB6yDt1fNwKOeGbckpmirO+VBDuQqecXkgeIqDlaOtmKn6hPR/viQ8cxCfqLU4fTlvM3+YjM367TukWdxpg==
|
||||
version "18.6.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.6.3.tgz#4e4a95b6fe44014563ceb514b2598b3e623d1c98"
|
||||
integrity sha512-6qKpDtoaYLM+5+AFChLhHermMQxc3TOEFIDzrZLPRGHPrLEwqFkkT5Kx3ju05g6X7uDPazz3jHbKPX0KzCjntg==
|
||||
|
||||
"@types/node@16":
|
||||
version "16.11.45"
|
||||
@ -1610,6 +1609,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d"
|
||||
integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==
|
||||
|
||||
"@types/sdp-transform@^2.4.5":
|
||||
version "2.4.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/sdp-transform/-/sdp-transform-2.4.5.tgz#3167961e0a1a5265545e278627aa37c606003f53"
|
||||
integrity sha512-GVO0gnmbyO3Oxm2HdPsYUNcyihZE3GyCY8ysMYHuQGfLhGZq89Nm4lSzULWTzZoyHtg+VO/IdrnxZHPnPSGnAg==
|
||||
|
||||
"@types/stack-utils@^2.0.0":
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c"
|
||||
@ -4802,10 +4806,10 @@ matrix-events-sdk@^0.0.1-beta.7:
|
||||
resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1-beta.7.tgz#5ffe45eba1f67cc8d7c2377736c728b322524934"
|
||||
integrity sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA==
|
||||
|
||||
matrix-mock-request@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/matrix-mock-request/-/matrix-mock-request-2.1.0.tgz#86f5b0ef846865d0767d3a8e64f5bcd6ca94c178"
|
||||
integrity sha512-Cjpl3yP6h0yu5GKG89m1XZXZlm69Kg/qHV41N/t6SrQsgcfM3Bfavqx9YrtG0UnuXGy4bBSZIe1QiWVeFPZw1A==
|
||||
matrix-mock-request@^2.1.2:
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/matrix-mock-request/-/matrix-mock-request-2.1.2.tgz#11e38ed1233dced88a6f2bfba1684d5c5b3aa2c2"
|
||||
integrity sha512-/OXCIzDGSLPJ3fs+uzDrtaOHI/Sqp4iEuniRn31U8S06mPXbvAnXknHqJ4c6A/KVwJj/nPFbGXpK4wPM038I6A==
|
||||
dependencies:
|
||||
expect "^28.1.0"
|
||||
|
||||
@ -5816,6 +5820,11 @@ safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
|
||||
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
|
||||
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
|
||||
|
||||
sdp-transform@^2.14.1:
|
||||
version "2.14.1"
|
||||
resolved "https://registry.yarnpkg.com/sdp-transform/-/sdp-transform-2.14.1.tgz#2bb443583d478dee217df4caa284c46b870d5827"
|
||||
integrity sha512-RjZyX3nVwJyCuTo5tGPx+PZWkDMCg7oOLpSlhjDdZfwUoNqG1mM8nyj31IGHyaPWXhjbP7cdK3qZ2bmkJ1GzRw==
|
||||
|
||||
semver@7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e"
|
||||
|
Reference in New Issue
Block a user