Compare commits
65 Commits
v37.2.0
...
v37.6.0-rc.0
| Author | SHA1 | Date | |
|---|---|---|---|
| cccaaf6e56 | |||
| 4dbca983b4 | |||
| 54e8f3c9d0 | |||
| 1fcc375dd5 | |||
| d24c5d8b2b | |||
| bb9280ad6b | |||
| 00bd7f0f02 | |||
| a29b8736f3 | |||
| 1c8a1cd5a1 | |||
| fea619d34c | |||
| f322f32a07 | |||
| bc3abd4394 | |||
| 6721aca293 | |||
| 80b51c0e30 | |||
| 91aa1dc092 | |||
| 78972e81d1 | |||
| 663136a95a | |||
| d67b19fa88 | |||
| 6ec200adcf | |||
| 19b1b901f5 | |||
| 1f52fa0c43 | |||
| b08c083c46 | |||
| 69f1bea89b | |||
| a52c64b747 | |||
| de9eb0e7f2 | |||
| a97d7f1133 | |||
| bc399abf6e | |||
| 1085a2469a | |||
| 52a9c52b12 | |||
| ac49137c0e | |||
| a179227bf1 | |||
| 64e27f5d3c | |||
| 634651859b | |||
| 480c8e86a4 | |||
| 1ba4412260 | |||
| e3a3a52f2a | |||
| 3f03c1da89 | |||
| eb793aaa08 | |||
| f7bab544a7 | |||
| b3fd92ad16 | |||
| ba71235539 | |||
| d6ede767c9 | |||
| 43f388d89a | |||
| 1d46b182c6 | |||
| 352f6986cd | |||
| 0ca72ec466 | |||
| 375f086f44 | |||
| 5976083e32 | |||
| d53d2dff45 | |||
| 3657eb61d8 | |||
| 1def88eb35 | |||
| 5a65c8436d | |||
| 2090319bdd | |||
| f4de8837fd | |||
| 1e92c13a75 | |||
| 8061fa924d | |||
| 0760c5f64c | |||
| e2bdb2f057 | |||
| fd47a189e0 | |||
| c233334f27 | |||
| 029ab5f109 | |||
| 366f686827 | |||
| 26387287af | |||
| ba0bbe1cba | |||
| 3c37d7b0fb |
@@ -22,7 +22,7 @@ runs:
|
||||
|
||||
- name: Upload tarball signature
|
||||
if: ${{ inputs.upload-url }}
|
||||
uses: shogo82148/actions-upload-release-asset@8482bd769644976d847e96fb4b9354228885e7b4 # v1
|
||||
uses: shogo82148/actions-upload-release-asset@d22998fda4c1407f60d1ab48cd6fe67f360f34de # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ env.VERSION }}.tar.gz.asc
|
||||
|
||||
@@ -29,13 +29,13 @@ runs:
|
||||
|
||||
- name: Upload asset signatures
|
||||
if: inputs.gpg-fingerprint
|
||||
uses: shogo82148/actions-upload-release-asset@8482bd769644976d847e96fb4b9354228885e7b4 # v1
|
||||
uses: shogo82148/actions-upload-release-asset@d22998fda4c1407f60d1ab48cd6fe67f360f34de # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ inputs.asset-path }}.asc
|
||||
|
||||
- name: Upload assets
|
||||
uses: shogo82148/actions-upload-release-asset@8482bd769644976d847e96fb4b9354228885e7b4 # v1
|
||||
uses: shogo82148/actions-upload-release-asset@d22998fda4c1407f60d1ab48cd6fe67f360f34de # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ inputs.asset-path }}
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
- name: Load GPG key
|
||||
id: gpg
|
||||
if: inputs.gpg-fingerprint
|
||||
uses: crazy-max/ghaction-import-gpg@cb9bde2e2525e640591a934b1fd28eef1dcaf5e5 # v6
|
||||
uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6
|
||||
with:
|
||||
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
passphrase: ${{ secrets.GPG_PASSPHRASE }}
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
steps:
|
||||
# We create the status here and then update it to success/failure in the `report` stage
|
||||
# This provides an easy link to this workflow_run from the PR before Sonarcloud is done.
|
||||
- uses: guibranco/github-status-action-v2@5ef6e175c333bc629f3718b083c8a2ff6e0bbfbc
|
||||
- uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: pending
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
revision: ${{ github.event.workflow_run.head_sha }}
|
||||
token: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
- uses: guibranco/github-status-action-v2@5ef6e175c333bc629f3718b083c8a2ff6e0bbfbc
|
||||
- uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63
|
||||
if: always()
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
steps:
|
||||
- name: Skip SonarCloud on merge queues
|
||||
if: env.ENABLE_COVERAGE == 'false'
|
||||
uses: guibranco/github-status-action-v2@5ef6e175c333bc629f3718b083c8a2ff6e0bbfbc
|
||||
uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: success
|
||||
|
||||
@@ -1,3 +1,45 @@
|
||||
Changes in [37.5.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.5.0) (2025-05-06)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Stabilise MSC3765 ([#4767](https://github.com/matrix-org/matrix-js-sdk/pull/4767)). Contributed by @Johennes.
|
||||
* Inherit `methodFactory` extensions from the parent to the child loggers. ([#4809](https://github.com/matrix-org/matrix-js-sdk/pull/4809)). Contributed by @toger5.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* [Backport staging] Fix token refresh behaviour for non-expired tokens ([#4827](https://github.com/matrix-org/matrix-js-sdk/pull/4827)). Contributed by @RiotRobot.
|
||||
* Refactor how token refreshing works to be more resilient ([#4819](https://github.com/matrix-org/matrix-js-sdk/pull/4819)). Contributed by @t3chguy.
|
||||
|
||||
|
||||
Changes in [37.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.4.0) (2025-04-22)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* MatrixRTC: Add combined `toDeviceAndRoomKeyTransport` ([#4792](https://github.com/matrix-org/matrix-js-sdk/pull/4792)). Contributed by @toger5.
|
||||
* Make logging consistent for matrixRTC ([#4788](https://github.com/matrix-org/matrix-js-sdk/pull/4788)). Contributed by @toger5.
|
||||
* MatrixRTC: ToDevice distribution for media stream keys ([#4785](https://github.com/matrix-org/matrix-js-sdk/pull/4785)). Contributed by @BillCarsonFr.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Fix token refresh racing with other requests and not using new token ([#4798](https://github.com/matrix-org/matrix-js-sdk/pull/4798)). Contributed by @t3chguy.
|
||||
* Fix fallback to MemoryCryptoStore when LocalStorage unavailable ([#4797](https://github.com/matrix-org/matrix-js-sdk/pull/4797)). Contributed by @t3chguy.
|
||||
* Remove duplicate `deleteSecretStorage` in `RustCrypto.resetEncryption` ([#4789](https://github.com/matrix-org/matrix-js-sdk/pull/4789)). Contributed by @florianduros.
|
||||
* Fix `RustCrypto.resetEncryption` failure ([#4772](https://github.com/matrix-org/matrix-js-sdk/pull/4772)). Contributed by @florianduros.
|
||||
|
||||
|
||||
Changes in [37.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.3.0) (2025-04-08)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* MatrixRTC MembershipManger: remove redundant sendDelayedEventAction and expose status ([#4747](https://github.com/matrix-org/matrix-js-sdk/pull/4747)). Contributed by @toger5.
|
||||
* Abstract logout-causing error type from tokenRefreshFunction calls ([#4765](https://github.com/matrix-org/matrix-js-sdk/pull/4765)). Contributed by @t3chguy.
|
||||
* Improve PushProcessor::getPushRuleGlobRegex ([#4764](https://github.com/matrix-org/matrix-js-sdk/pull/4764)). Contributed by @t3chguy.
|
||||
* Export push processor \& method for converting matrix glob to regexp ([#4763](https://github.com/matrix-org/matrix-js-sdk/pull/4763)). Contributed by @t3chguy.
|
||||
* Add authenticated media parameter to getMediaConfig ([#4762](https://github.com/matrix-org/matrix-js-sdk/pull/4762)). Contributed by @m004.
|
||||
* Rust crypto: set a timeout on outgoing HTTP requests ([#4761](https://github.com/matrix-org/matrix-js-sdk/pull/4761)). Contributed by @richvdh.
|
||||
* Switch sliding sync support to simplified sliding sync ([#4400](https://github.com/matrix-org/matrix-js-sdk/pull/4400)). Contributed by @dbkr.
|
||||
|
||||
|
||||
Changes in [37.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.2.0) (2025-03-25)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
@@ -9,6 +9,7 @@ export default {
|
||||
"src/crypto-api/index.ts",
|
||||
"src/testing.ts",
|
||||
"src/matrix.ts",
|
||||
"src/utils.ts", // not really an entrypoint but we have deprecated `defer` there
|
||||
"scripts/**",
|
||||
"spec/**",
|
||||
// XXX: these look entirely unused
|
||||
|
||||
+9
-9
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "37.2.0",
|
||||
"version": "37.6.0-rc.0",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"prepack": "yarn build",
|
||||
@@ -56,7 +56,7 @@
|
||||
"bs58": "^6.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"loglevel": "^1.7.1",
|
||||
"loglevel": "^1.9.2",
|
||||
"matrix-events-sdk": "0.0.1",
|
||||
"matrix-widget-api": "^1.10.0",
|
||||
"oidc-client-ts": "^3.0.1",
|
||||
@@ -97,11 +97,11 @@
|
||||
"eslint": "8.57.1",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^10.0.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.1",
|
||||
"eslint-import-resolver-typescript": "^4.0.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jest": "^28.0.0",
|
||||
"eslint-plugin-jsdoc": "^50.0.0",
|
||||
"eslint-plugin-matrix-org": "^2.1.0",
|
||||
"eslint-plugin-matrix-org": "2.1.0",
|
||||
"eslint-plugin-n": "^14.0.0",
|
||||
"eslint-plugin-tsdoc": "^0.4.0",
|
||||
"eslint-plugin-unicorn": "^56.0.0",
|
||||
@@ -117,13 +117,13 @@
|
||||
"lint-staged": "^15.0.2",
|
||||
"matrix-mock-request": "^2.5.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"prettier": "3.5.1",
|
||||
"prettier": "3.5.3",
|
||||
"rimraf": "^6.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typedoc": "^0.27.0",
|
||||
"typedoc-plugin-coverage": "^3.0.0",
|
||||
"typedoc": "^0.28.1",
|
||||
"typedoc-plugin-coverage": "^4.0.0",
|
||||
"typedoc-plugin-mdn-links": "^5.0.0",
|
||||
"typedoc-plugin-missing-exports": "^3.0.0",
|
||||
"typedoc-plugin-missing-exports": "^4.0.0",
|
||||
"typescript": "^5.4.2"
|
||||
},
|
||||
"@casualbot/jest-sonar-reporter": {
|
||||
|
||||
@@ -59,7 +59,7 @@ import {
|
||||
} from "../../../src/matrix";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { type ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import { defer, escapeRegExp } from "../../../src/utils";
|
||||
import { escapeRegExp } from "../../../src/utils";
|
||||
import { downloadDeviceToJsDevice } from "../../../src/rust-crypto/device-converter";
|
||||
import { flushPromises } from "../../test-utils/flushPromises";
|
||||
import {
|
||||
@@ -1283,13 +1283,13 @@ describe("crypto", () => {
|
||||
const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount);
|
||||
|
||||
// ... and finally, send the room key. We block the response until `sendRoomMessageDefer` completes.
|
||||
const sendRoomMessageDefer = defer<FetchMock.MockResponse>();
|
||||
const sendRoomMessageResolvers = Promise.withResolvers<FetchMock.MockResponse>();
|
||||
const reqProm = new Promise<IContent>((resolve) => {
|
||||
fetchMock.putOnce(
|
||||
new RegExp("/send/m.room.encrypted/"),
|
||||
async (url: string, opts: RequestInit): Promise<FetchMock.MockResponse> => {
|
||||
resolve(JSON.parse(opts.body as string));
|
||||
return await sendRoomMessageDefer.promise;
|
||||
return await sendRoomMessageResolvers.promise;
|
||||
},
|
||||
{
|
||||
// append to the list of intercepts on this path (since we have some tests that call
|
||||
@@ -1318,7 +1318,7 @@ describe("crypto", () => {
|
||||
|
||||
// release the send request
|
||||
const resp = { event_id: "$event_id" };
|
||||
sendRoomMessageDefer.resolve(resp);
|
||||
sendRoomMessageResolvers.resolve(resp);
|
||||
expect(await sendProm).toEqual(resp);
|
||||
|
||||
// still pending at this point
|
||||
|
||||
@@ -36,7 +36,6 @@ import { advanceTimersUntil, awaitDecryption, syncPromise } from "../../test-uti
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import { type KeyBackupInfo, type KeyBackupSession } from "../../../src/crypto-api/keybackup";
|
||||
import { flushPromises } from "../../test-utils/flushPromises";
|
||||
import { defer, type IDeferred } from "../../../src/utils";
|
||||
import { decodeRecoveryKey, DecryptionFailureCode, CryptoEvent, type CryptoApi } from "../../../src/crypto-api";
|
||||
import { type KeyBackup } from "../../../src/rust-crypto/backup.ts";
|
||||
|
||||
@@ -861,7 +860,7 @@ describe("megolm-keys backup", () => {
|
||||
expect(await aliceCrypto.getKeyBackupInfo()).toStrictEqual(testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
// Delete the backup and we are expecting the key backup to be disabled
|
||||
const keyBackupStatus = defer<boolean>();
|
||||
const keyBackupStatus = Promise.withResolvers<boolean>();
|
||||
aliceClient.once(CryptoEvent.KeyBackupStatus, (enabled) => keyBackupStatus.resolve(enabled));
|
||||
await aliceCrypto.deleteKeyBackupVersion(testData.SIGNED_BACKUP_DATA.version!);
|
||||
expect(await keyBackupStatus.promise).toBe(false);
|
||||
@@ -1158,7 +1157,7 @@ describe("megolm-keys backup", () => {
|
||||
// A check backup should happen at some point
|
||||
await aliceCrypto.checkKeyBackupAndEnable();
|
||||
|
||||
const awaitHasQueriedNewBackup: IDeferred<void> = defer<void>();
|
||||
const awaitHasQueriedNewBackup: PromiseWithResolvers<void> = Promise.withResolvers<void>();
|
||||
|
||||
fetchMock.get(
|
||||
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
type Verifier,
|
||||
VerifierEvent,
|
||||
} from "../../../src/crypto-api/verification";
|
||||
import { defer, escapeRegExp } from "../../../src/utils";
|
||||
import { escapeRegExp } from "../../../src/utils";
|
||||
import { awaitDecryption, emitPromise, getSyncResponse, syncPromise } from "../../test-utils/test-utils";
|
||||
import { SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import {
|
||||
@@ -1540,10 +1540,10 @@ function expectSendToDeviceMessage(msgtype: string): Promise<{ messages: any }>
|
||||
* @returns a map of secret name to promise that will resolve (with the id of the secret request) when the secret is requested.
|
||||
*/
|
||||
function mockSecretRequestAndGetPromises(): Map<string, Promise<string>> {
|
||||
const mskRequestDefer = defer<string>();
|
||||
const sskRequestDefer = defer<string>();
|
||||
const uskRequestDefer = defer<string>();
|
||||
const backupKeyRequestDefer = defer<string>();
|
||||
const mskRequestResolvers = Promise.withResolvers<string>();
|
||||
const sskRequestResolvers = Promise.withResolvers<string>();
|
||||
const uskRequestResolvers = Promise.withResolvers<string>();
|
||||
const backupKeyRequestResolvers = Promise.withResolvers<string>();
|
||||
|
||||
fetchMock.put(
|
||||
new RegExp(`/_matrix/client/(r0|v3)/sendToDevice/m.secret.request`),
|
||||
@@ -1555,13 +1555,13 @@ function mockSecretRequestAndGetPromises(): Map<string, Promise<string>> {
|
||||
const name = content.name;
|
||||
const requestId = content.request_id;
|
||||
if (name == "m.cross_signing.user_signing") {
|
||||
uskRequestDefer.resolve(requestId);
|
||||
uskRequestResolvers.resolve(requestId);
|
||||
} else if (name == "m.cross_signing.master") {
|
||||
mskRequestDefer.resolve(requestId);
|
||||
mskRequestResolvers.resolve(requestId);
|
||||
} else if (name == "m.cross_signing.self_signing") {
|
||||
sskRequestDefer.resolve(requestId);
|
||||
sskRequestResolvers.resolve(requestId);
|
||||
} else if (name == "m.megolm_backup.v1") {
|
||||
backupKeyRequestDefer.resolve(requestId);
|
||||
backupKeyRequestResolvers.resolve(requestId);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
@@ -1570,10 +1570,10 @@ function mockSecretRequestAndGetPromises(): Map<string, Promise<string>> {
|
||||
);
|
||||
|
||||
const promiseMap = new Map<string, Promise<string>>();
|
||||
promiseMap.set("m.cross_signing.master", mskRequestDefer.promise);
|
||||
promiseMap.set("m.cross_signing.self_signing", sskRequestDefer.promise);
|
||||
promiseMap.set("m.cross_signing.user_signing", uskRequestDefer.promise);
|
||||
promiseMap.set("m.megolm_backup.v1", backupKeyRequestDefer.promise);
|
||||
promiseMap.set("m.cross_signing.master", mskRequestResolvers.promise);
|
||||
promiseMap.set("m.cross_signing.self_signing", sskRequestResolvers.promise);
|
||||
promiseMap.set("m.cross_signing.user_signing", uskRequestResolvers.promise);
|
||||
promiseMap.set("m.megolm_backup.v1", backupKeyRequestResolvers.promise);
|
||||
return promiseMap;
|
||||
}
|
||||
|
||||
@@ -1604,7 +1604,7 @@ function sha256(commitmentStr: string): string {
|
||||
return encodeUnpaddedBase64(createHash("sha256").update(commitmentStr, "utf8").digest());
|
||||
}
|
||||
|
||||
function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string {
|
||||
function encodeUnpaddedBase64(uint8Array: ArrayLike<number>): string {
|
||||
return Buffer.from(uint8Array).toString("base64").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
@@ -1638,7 +1638,7 @@ function buildReadyMessage(
|
||||
}
|
||||
|
||||
/** build an m.key.verification.start to-device message suitable for the m.reciprocate.v1 flow, originating from the dummy device */
|
||||
function buildReciprocateStartMessage(transactionId: string, sharedSecret: ArrayBuffer) {
|
||||
function buildReciprocateStartMessage(transactionId: string, sharedSecret: ArrayLike<number>) {
|
||||
return {
|
||||
type: "m.key.verification.start",
|
||||
content: {
|
||||
|
||||
@@ -157,6 +157,30 @@ describe("MatrixClient", function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe("mediaConfig", function () {
|
||||
it("should get media config on unauthenticated media call", async () => {
|
||||
httpBackend.when("GET", "/_matrix/media/v3/config").respond(200, '{"m.upload.size": 50000000}', true);
|
||||
|
||||
const prom = client.getMediaConfig();
|
||||
|
||||
httpBackend.flushAllExpected();
|
||||
|
||||
expect((await prom)["m.upload.size"]).toEqual(50000000);
|
||||
});
|
||||
|
||||
it("should get media config on authenticated media call", async () => {
|
||||
httpBackend
|
||||
.when("GET", "/_matrix/client/v1/media/config")
|
||||
.respond(200, '{"m.upload.size": 50000000}', true);
|
||||
|
||||
const prom = client.getMediaConfig(true);
|
||||
|
||||
httpBackend.flushAllExpected();
|
||||
|
||||
expect((await prom)["m.upload.size"]).toEqual(50000000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("joinRoom", function () {
|
||||
it("should no-op given the ID of a room you've already joined", async () => {
|
||||
const roomId = "!foo:bar";
|
||||
@@ -1772,6 +1796,27 @@ describe("MatrixClient", function () {
|
||||
expect(client.getUserIdLocalpart()).toBe("alice");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setRoomMutePushRule", () => {
|
||||
it("should set room push rule to muted", async () => {
|
||||
const roomId = "!roomId:server";
|
||||
const client = new MatrixClient({
|
||||
baseUrl: "http://localhost",
|
||||
fetchFn: httpBackend.fetchFn as typeof globalThis.fetch,
|
||||
});
|
||||
client.pushRules = {
|
||||
global: {
|
||||
room: [{ rule_id: roomId, actions: [], default: false, enabled: false }],
|
||||
},
|
||||
};
|
||||
|
||||
const path = `/pushrules/global/room/${encodeURIComponent(roomId)}`;
|
||||
httpBackend.when("DELETE", path).respond(200, {});
|
||||
httpBackend.when("PUT", path).respond(200, {});
|
||||
client.setRoomMutePushRule("global", roomId, true);
|
||||
await httpBackend.flush("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function withThreadId(event: MatrixEvent, newThreadId: string): MatrixEvent {
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
PayloadType,
|
||||
RendezvousError,
|
||||
} from "../../../src/rendezvous";
|
||||
import { defer } from "../../../src/utils";
|
||||
import {
|
||||
ClientPrefix,
|
||||
DEVICE_CODE_SCOPE,
|
||||
@@ -112,8 +111,8 @@ describe("MSC4108SignInWithQR", () => {
|
||||
let opponentLogin: MSC4108SignInWithQR;
|
||||
|
||||
beforeEach(async () => {
|
||||
let ourData = defer<string>();
|
||||
let opponentData = defer<string>();
|
||||
let ourData = Promise.withResolvers<string>();
|
||||
let opponentData = Promise.withResolvers<string>();
|
||||
|
||||
const ourMockSession = {
|
||||
send: jest.fn(async (newData) => {
|
||||
@@ -122,7 +121,7 @@ describe("MSC4108SignInWithQR", () => {
|
||||
receive: jest.fn(() => {
|
||||
const prom = opponentData.promise;
|
||||
prom.then(() => {
|
||||
opponentData = defer();
|
||||
opponentData = Promise.withResolvers();
|
||||
});
|
||||
return prom;
|
||||
}),
|
||||
@@ -141,7 +140,7 @@ describe("MSC4108SignInWithQR", () => {
|
||||
receive: jest.fn(() => {
|
||||
const prom = ourData.promise;
|
||||
prom.then(() => {
|
||||
ourData = defer();
|
||||
ourData = Promise.withResolvers();
|
||||
});
|
||||
return prom;
|
||||
}),
|
||||
@@ -334,11 +333,11 @@ describe("MSC4108SignInWithQR", () => {
|
||||
// @ts-ignore
|
||||
await opponentLogin.receive();
|
||||
|
||||
const deferred = defer<IMyDevice>();
|
||||
mocked(client.getDevice).mockReturnValue(deferred.promise);
|
||||
const deviceResolvers = Promise.withResolvers<IMyDevice>();
|
||||
mocked(client.getDevice).mockReturnValue(deviceResolvers.promise);
|
||||
|
||||
ourLogin.cancel(MSC4108FailureReason.UserCancelled).catch(() => {});
|
||||
deferred.resolve({} as IMyDevice);
|
||||
deviceResolvers.resolve({} as IMyDevice);
|
||||
|
||||
const secrets = {
|
||||
cross_signing: { master_key: "mk", user_signing_key: "usk", self_signing_key: "ssk" },
|
||||
|
||||
@@ -48,7 +48,6 @@ import { type SyncApiOptions, SyncState } from "../../src/sync";
|
||||
import { type IStoredClientOpts } from "../../src";
|
||||
import { logger } from "../../src/logger";
|
||||
import { emitPromise } from "../test-utils/test-utils";
|
||||
import { defer } from "../../src/utils";
|
||||
import { KnownMembership } from "../../src/@types/membership";
|
||||
import { type SyncCryptoCallbacks } from "../../src/common-crypto/CryptoBackend";
|
||||
|
||||
@@ -369,7 +368,7 @@ describe("SlidingSyncSdk", () => {
|
||||
});
|
||||
|
||||
it("can be created with live events", async () => {
|
||||
const seenLiveEventDeferred = defer<boolean>();
|
||||
const seenLiveEventDeferred = Promise.withResolvers<boolean>();
|
||||
const listener = (
|
||||
ev: MatrixEvent,
|
||||
room?: Room,
|
||||
@@ -649,11 +648,13 @@ describe("SlidingSyncSdk", () => {
|
||||
ext = findExtension("e2ee");
|
||||
});
|
||||
|
||||
it("gets enabled on the initial request only", () => {
|
||||
expect(ext.onRequest(true)).toEqual({
|
||||
it("gets enabled all the time", async () => {
|
||||
expect(await ext.onRequest(true)).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
expect(await ext.onRequest(false)).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
expect(ext.onRequest(false)).toEqual(undefined);
|
||||
});
|
||||
|
||||
it("can update device lists", () => {
|
||||
@@ -695,11 +696,13 @@ describe("SlidingSyncSdk", () => {
|
||||
ext = findExtension("account_data");
|
||||
});
|
||||
|
||||
it("gets enabled on the initial request only", () => {
|
||||
expect(ext.onRequest(true)).toEqual({
|
||||
it("gets enabled all the time", async () => {
|
||||
expect(await ext.onRequest(true)).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
expect(await ext.onRequest(false)).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
expect(ext.onRequest(false)).toEqual(undefined);
|
||||
});
|
||||
|
||||
it("processes global account data", async () => {
|
||||
@@ -823,8 +826,12 @@ describe("SlidingSyncSdk", () => {
|
||||
ext = findExtension("to_device");
|
||||
});
|
||||
|
||||
it("gets enabled with a limit on the initial request only", () => {
|
||||
const reqJson: any = ext.onRequest(true);
|
||||
it("gets enabled all the time", async () => {
|
||||
let reqJson: any = await ext.onRequest(true);
|
||||
expect(reqJson.enabled).toEqual(true);
|
||||
expect(reqJson.limit).toBeGreaterThan(0);
|
||||
expect(reqJson.since).toBeUndefined();
|
||||
reqJson = await ext.onRequest(false);
|
||||
expect(reqJson.enabled).toEqual(true);
|
||||
expect(reqJson.limit).toBeGreaterThan(0);
|
||||
expect(reqJson.since).toBeUndefined();
|
||||
@@ -835,7 +842,7 @@ describe("SlidingSyncSdk", () => {
|
||||
next_batch: "12345",
|
||||
events: [],
|
||||
});
|
||||
expect(ext.onRequest(false)).toEqual({
|
||||
expect(await ext.onRequest(false)).toMatchObject({
|
||||
since: "12345",
|
||||
});
|
||||
});
|
||||
@@ -919,11 +926,13 @@ describe("SlidingSyncSdk", () => {
|
||||
ext = findExtension("typing");
|
||||
});
|
||||
|
||||
it("gets enabled on the initial request only", () => {
|
||||
expect(ext.onRequest(true)).toEqual({
|
||||
it("gets enabled all the time", async () => {
|
||||
expect(await ext.onRequest(true)).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
expect(await ext.onRequest(false)).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
expect(ext.onRequest(false)).toEqual(undefined);
|
||||
});
|
||||
|
||||
it("processes typing notifications", async () => {
|
||||
@@ -1042,11 +1051,13 @@ describe("SlidingSyncSdk", () => {
|
||||
ext = findExtension("receipts");
|
||||
});
|
||||
|
||||
it("gets enabled on the initial request only", () => {
|
||||
expect(ext.onRequest(true)).toEqual({
|
||||
it("gets enabled all the time", async () => {
|
||||
expect(await ext.onRequest(true)).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
expect(await ext.onRequest(false)).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
expect(ext.onRequest(false)).toEqual(undefined);
|
||||
});
|
||||
|
||||
it("processes receipts", async () => {
|
||||
|
||||
+30
-747
@@ -41,7 +41,7 @@ describe("SlidingSync", () => {
|
||||
const selfUserId = "@alice:localhost";
|
||||
const selfAccessToken = "aseukfgwef";
|
||||
const proxyBaseUrl = "http://localhost:8008";
|
||||
const syncUrl = proxyBaseUrl + "/_matrix/client/unstable/org.matrix.msc3575/sync";
|
||||
const syncUrl = proxyBaseUrl + "/_matrix/client/unstable/org.matrix.simplified_msc3575/sync";
|
||||
|
||||
// assign client/httpBackend globals
|
||||
const setupClient = () => {
|
||||
@@ -103,8 +103,8 @@ describe("SlidingSync", () => {
|
||||
};
|
||||
const ext: Extension<any, any> = {
|
||||
name: () => "custom_extension",
|
||||
onRequest: (initial) => {
|
||||
return { initial: initial };
|
||||
onRequest: async (_) => {
|
||||
return { initial: true };
|
||||
},
|
||||
onResponse: async (res) => {
|
||||
return;
|
||||
@@ -143,18 +143,16 @@ describe("SlidingSync", () => {
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
// expect nothing but ranges and non-initial extensions to be sent
|
||||
// expect all params to be sent TODO: check MSC4186
|
||||
httpBackend!
|
||||
.when("POST", syncUrl)
|
||||
.check(function (req) {
|
||||
const body = req.data;
|
||||
logger.debug("got ", body);
|
||||
expect(body.room_subscriptions).toBeFalsy();
|
||||
expect(body.lists["a"]).toEqual({
|
||||
ranges: [[0, 10]],
|
||||
});
|
||||
expect(body.lists["a"]).toEqual(listInfo);
|
||||
expect(body.extensions).toBeTruthy();
|
||||
expect(body.extensions["custom_extension"]).toEqual({ initial: false });
|
||||
expect(body.extensions["custom_extension"]).toEqual({ initial: true });
|
||||
expect(req.queryParams!["pos"]).toEqual("11");
|
||||
})
|
||||
.respond(200, function () {
|
||||
@@ -332,6 +330,7 @@ describe("SlidingSync", () => {
|
||||
await p;
|
||||
});
|
||||
|
||||
// TODO: this does not exist in MSC4186
|
||||
it("should be able to unsubscribe from a room", async () => {
|
||||
httpBackend!
|
||||
.when("POST", syncUrl)
|
||||
@@ -389,18 +388,19 @@ describe("SlidingSync", () => {
|
||||
[3, 5],
|
||||
];
|
||||
|
||||
// request first 3 rooms
|
||||
const listReq = {
|
||||
ranges: [[0, 2]],
|
||||
sort: ["by_name"],
|
||||
timeline_limit: 1,
|
||||
required_state: [["m.room.topic", ""]],
|
||||
filters: {
|
||||
is_dm: true,
|
||||
},
|
||||
};
|
||||
|
||||
let slidingSync: SlidingSync;
|
||||
it("should be possible to subscribe to a list", async () => {
|
||||
// request first 3 rooms
|
||||
const listReq = {
|
||||
ranges: [[0, 2]],
|
||||
sort: ["by_name"],
|
||||
timeline_limit: 1,
|
||||
required_state: [["m.room.topic", ""]],
|
||||
filters: {
|
||||
is_dm: true,
|
||||
},
|
||||
};
|
||||
slidingSync = new SlidingSync(proxyBaseUrl, new Map([["a", listReq]]), {}, client!, 1);
|
||||
httpBackend!
|
||||
.when("POST", syncUrl)
|
||||
@@ -452,11 +452,6 @@ describe("SlidingSync", () => {
|
||||
expect(slidingSync.getListData("b")).toBeNull();
|
||||
const syncData = slidingSync.getListData("a")!;
|
||||
expect(syncData.joinedCount).toEqual(500); // from previous test
|
||||
expect(syncData.roomIndexToRoomId).toEqual({
|
||||
0: roomA,
|
||||
1: roomB,
|
||||
2: roomC,
|
||||
});
|
||||
});
|
||||
|
||||
it("should be possible to adjust list ranges", async () => {
|
||||
@@ -467,10 +462,9 @@ describe("SlidingSync", () => {
|
||||
const body = req.data;
|
||||
logger.log("next ranges", body.lists["a"].ranges);
|
||||
expect(body.lists).toBeTruthy();
|
||||
expect(body.lists["a"]).toEqual({
|
||||
// only the ranges should be sent as the rest are unchanged and sticky
|
||||
ranges: newRanges,
|
||||
});
|
||||
// list range should be changed
|
||||
listReq.ranges = newRanges;
|
||||
expect(body.lists["a"]).toEqual(listReq); // resend all values TODO: check MSC4186
|
||||
})
|
||||
.respond(200, {
|
||||
pos: "b",
|
||||
@@ -495,7 +489,9 @@ describe("SlidingSync", () => {
|
||||
await httpBackend!.flushAllExpected();
|
||||
await responseProcessed;
|
||||
// setListRanges for an invalid list key returns an error
|
||||
await expect(slidingSync.setListRanges("idontexist", newRanges)).rejects.toBeTruthy();
|
||||
expect(() => {
|
||||
slidingSync.setListRanges("idontexist", newRanges);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("should be possible to add an extra list", async () => {
|
||||
@@ -513,10 +509,7 @@ describe("SlidingSync", () => {
|
||||
const body = req.data;
|
||||
logger.log("extra list", body);
|
||||
expect(body.lists).toBeTruthy();
|
||||
expect(body.lists["a"]).toEqual({
|
||||
// only the ranges should be sent as the rest are unchanged and sticky
|
||||
ranges: newRanges,
|
||||
});
|
||||
expect(body.lists["a"]).toEqual(listReq); // resend all values TODO: check MSC4186
|
||||
expect(body.lists["b"]).toEqual(extraListReq);
|
||||
})
|
||||
.respond(200, {
|
||||
@@ -537,16 +530,6 @@ describe("SlidingSync", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
listenUntil(slidingSync, "SlidingSync.List", (listKey, joinedCount, roomIndexToRoomId) => {
|
||||
expect(listKey).toEqual("b");
|
||||
expect(joinedCount).toEqual(50);
|
||||
expect(roomIndexToRoomId).toEqual({
|
||||
0: roomA,
|
||||
1: roomB,
|
||||
2: roomC,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
|
||||
return state === SlidingSyncState.Complete;
|
||||
});
|
||||
@@ -554,706 +537,6 @@ describe("SlidingSync", () => {
|
||||
await httpBackend!.flushAllExpected();
|
||||
await responseProcessed;
|
||||
});
|
||||
|
||||
it("should be possible to get list DELETE/INSERTs", async () => {
|
||||
// move C (2) to A (0)
|
||||
httpBackend!.when("POST", syncUrl).respond(200, {
|
||||
pos: "e",
|
||||
lists: {
|
||||
a: {
|
||||
count: 500,
|
||||
ops: [
|
||||
{
|
||||
op: "DELETE",
|
||||
index: 2,
|
||||
},
|
||||
{
|
||||
op: "INSERT",
|
||||
index: 0,
|
||||
room_id: roomC,
|
||||
},
|
||||
],
|
||||
},
|
||||
b: {
|
||||
count: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
let listPromise = listenUntil(
|
||||
slidingSync,
|
||||
"SlidingSync.List",
|
||||
(listKey, joinedCount, roomIndexToRoomId) => {
|
||||
expect(listKey).toEqual("a");
|
||||
expect(joinedCount).toEqual(500);
|
||||
expect(roomIndexToRoomId).toEqual({
|
||||
0: roomC,
|
||||
1: roomA,
|
||||
2: roomB,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
);
|
||||
let responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
|
||||
return state === SlidingSyncState.Complete;
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
await responseProcessed;
|
||||
await listPromise;
|
||||
|
||||
// move C (0) back to A (2)
|
||||
httpBackend!.when("POST", syncUrl).respond(200, {
|
||||
pos: "f",
|
||||
lists: {
|
||||
a: {
|
||||
count: 500,
|
||||
ops: [
|
||||
{
|
||||
op: "DELETE",
|
||||
index: 0,
|
||||
},
|
||||
{
|
||||
op: "INSERT",
|
||||
index: 2,
|
||||
room_id: roomC,
|
||||
},
|
||||
],
|
||||
},
|
||||
b: {
|
||||
count: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
listPromise = listenUntil(slidingSync, "SlidingSync.List", (listKey, joinedCount, roomIndexToRoomId) => {
|
||||
expect(listKey).toEqual("a");
|
||||
expect(joinedCount).toEqual(500);
|
||||
expect(roomIndexToRoomId).toEqual({
|
||||
0: roomA,
|
||||
1: roomB,
|
||||
2: roomC,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
|
||||
return state === SlidingSyncState.Complete;
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
await responseProcessed;
|
||||
await listPromise;
|
||||
});
|
||||
|
||||
it("should ignore invalid list indexes", async () => {
|
||||
httpBackend!.when("POST", syncUrl).respond(200, {
|
||||
pos: "e",
|
||||
lists: {
|
||||
a: {
|
||||
count: 500,
|
||||
ops: [
|
||||
{
|
||||
op: "DELETE",
|
||||
index: 2324324,
|
||||
},
|
||||
],
|
||||
},
|
||||
b: {
|
||||
count: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
const listPromise = listenUntil(
|
||||
slidingSync,
|
||||
"SlidingSync.List",
|
||||
(listKey, joinedCount, roomIndexToRoomId) => {
|
||||
expect(listKey).toEqual("a");
|
||||
expect(joinedCount).toEqual(500);
|
||||
expect(roomIndexToRoomId).toEqual({
|
||||
0: roomA,
|
||||
1: roomB,
|
||||
2: roomC,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
|
||||
return state === SlidingSyncState.Complete;
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
await responseProcessed;
|
||||
await listPromise;
|
||||
});
|
||||
|
||||
it("should be possible to update a list", async () => {
|
||||
httpBackend!.when("POST", syncUrl).respond(200, {
|
||||
pos: "g",
|
||||
lists: {
|
||||
a: {
|
||||
count: 42,
|
||||
ops: [
|
||||
{
|
||||
op: "INVALIDATE",
|
||||
range: [0, 2],
|
||||
},
|
||||
{
|
||||
op: "SYNC",
|
||||
range: [0, 1],
|
||||
room_ids: [roomB, roomC],
|
||||
},
|
||||
],
|
||||
},
|
||||
b: {
|
||||
count: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
// update the list with a new filter
|
||||
slidingSync.setList("a", {
|
||||
filters: {
|
||||
is_encrypted: true,
|
||||
},
|
||||
ranges: [[0, 100]],
|
||||
});
|
||||
const listPromise = listenUntil(
|
||||
slidingSync,
|
||||
"SlidingSync.List",
|
||||
(listKey, joinedCount, roomIndexToRoomId) => {
|
||||
expect(listKey).toEqual("a");
|
||||
expect(joinedCount).toEqual(42);
|
||||
expect(roomIndexToRoomId).toEqual({
|
||||
0: roomB,
|
||||
1: roomC,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
|
||||
return state === SlidingSyncState.Complete;
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
await responseProcessed;
|
||||
await listPromise;
|
||||
});
|
||||
|
||||
// this refers to a set of operations where the end result is no change.
|
||||
it("should handle net zero operations correctly", async () => {
|
||||
const indexToRoomId = {
|
||||
0: roomB,
|
||||
1: roomC,
|
||||
};
|
||||
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual(indexToRoomId);
|
||||
httpBackend!.when("POST", syncUrl).respond(200, {
|
||||
pos: "f",
|
||||
// currently the list is [B,C] so we will insert D then immediately delete it
|
||||
lists: {
|
||||
a: {
|
||||
count: 500,
|
||||
ops: [
|
||||
{
|
||||
op: "DELETE",
|
||||
index: 2,
|
||||
},
|
||||
{
|
||||
op: "INSERT",
|
||||
index: 0,
|
||||
room_id: roomA,
|
||||
},
|
||||
{
|
||||
op: "DELETE",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
b: {
|
||||
count: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
const listPromise = listenUntil(
|
||||
slidingSync,
|
||||
"SlidingSync.List",
|
||||
(listKey, joinedCount, roomIndexToRoomId) => {
|
||||
expect(listKey).toEqual("a");
|
||||
expect(joinedCount).toEqual(500);
|
||||
expect(roomIndexToRoomId).toEqual(indexToRoomId);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
|
||||
return state === SlidingSyncState.Complete;
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
await responseProcessed;
|
||||
await listPromise;
|
||||
});
|
||||
|
||||
it("should handle deletions correctly", async () => {
|
||||
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({
|
||||
0: roomB,
|
||||
1: roomC,
|
||||
});
|
||||
httpBackend!.when("POST", syncUrl).respond(200, {
|
||||
pos: "g",
|
||||
lists: {
|
||||
a: {
|
||||
count: 499,
|
||||
ops: [
|
||||
{
|
||||
op: "DELETE",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
b: {
|
||||
count: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
const listPromise = listenUntil(
|
||||
slidingSync,
|
||||
"SlidingSync.List",
|
||||
(listKey, joinedCount, roomIndexToRoomId) => {
|
||||
expect(listKey).toEqual("a");
|
||||
expect(joinedCount).toEqual(499);
|
||||
expect(roomIndexToRoomId).toEqual({
|
||||
0: roomC,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
|
||||
return state === SlidingSyncState.Complete;
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
await responseProcessed;
|
||||
await listPromise;
|
||||
});
|
||||
|
||||
it("should handle insertions correctly", async () => {
|
||||
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({
|
||||
0: roomC,
|
||||
});
|
||||
httpBackend!.when("POST", syncUrl).respond(200, {
|
||||
pos: "h",
|
||||
lists: {
|
||||
a: {
|
||||
count: 500,
|
||||
ops: [
|
||||
{
|
||||
op: "INSERT",
|
||||
index: 1,
|
||||
room_id: roomA,
|
||||
},
|
||||
],
|
||||
},
|
||||
b: {
|
||||
count: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
let listPromise = listenUntil(
|
||||
slidingSync,
|
||||
"SlidingSync.List",
|
||||
(listKey, joinedCount, roomIndexToRoomId) => {
|
||||
expect(listKey).toEqual("a");
|
||||
expect(joinedCount).toEqual(500);
|
||||
expect(roomIndexToRoomId).toEqual({
|
||||
0: roomC,
|
||||
1: roomA,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
);
|
||||
let responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
|
||||
return state === SlidingSyncState.Complete;
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
await responseProcessed;
|
||||
await listPromise;
|
||||
|
||||
httpBackend!.when("POST", syncUrl).respond(200, {
|
||||
pos: "h",
|
||||
lists: {
|
||||
a: {
|
||||
count: 501,
|
||||
ops: [
|
||||
{
|
||||
op: "INSERT",
|
||||
index: 1,
|
||||
room_id: roomB,
|
||||
},
|
||||
],
|
||||
},
|
||||
b: {
|
||||
count: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
listPromise = listenUntil(slidingSync, "SlidingSync.List", (listKey, joinedCount, roomIndexToRoomId) => {
|
||||
expect(listKey).toEqual("a");
|
||||
expect(joinedCount).toEqual(501);
|
||||
expect(roomIndexToRoomId).toEqual({
|
||||
0: roomC,
|
||||
1: roomB,
|
||||
2: roomA,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
|
||||
return state === SlidingSyncState.Complete;
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
await responseProcessed;
|
||||
await listPromise;
|
||||
slidingSync.stop();
|
||||
});
|
||||
|
||||
// Regression test to make sure things like DELETE 0 INSERT 0 work correctly and we don't
|
||||
// end up losing room IDs.
|
||||
it("should handle insertions with a spurious DELETE correctly", async () => {
|
||||
slidingSync = new SlidingSync(
|
||||
proxyBaseUrl,
|
||||
new Map([
|
||||
[
|
||||
"a",
|
||||
{
|
||||
ranges: [[0, 20]],
|
||||
},
|
||||
],
|
||||
]),
|
||||
{},
|
||||
client!,
|
||||
1,
|
||||
);
|
||||
// initially start with nothing
|
||||
httpBackend!.when("POST", syncUrl).respond(200, {
|
||||
pos: "a",
|
||||
lists: {
|
||||
a: {
|
||||
count: 0,
|
||||
ops: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
slidingSync.start();
|
||||
await httpBackend!.flushAllExpected();
|
||||
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({});
|
||||
|
||||
// insert a room
|
||||
httpBackend!.when("POST", syncUrl).respond(200, {
|
||||
pos: "b",
|
||||
lists: {
|
||||
a: {
|
||||
count: 1,
|
||||
ops: [
|
||||
{
|
||||
op: "DELETE",
|
||||
index: 0,
|
||||
},
|
||||
{
|
||||
op: "INSERT",
|
||||
index: 0,
|
||||
room_id: roomA,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({
|
||||
0: roomA,
|
||||
});
|
||||
|
||||
// insert another room
|
||||
httpBackend!.when("POST", syncUrl).respond(200, {
|
||||
pos: "c",
|
||||
lists: {
|
||||
a: {
|
||||
count: 1,
|
||||
ops: [
|
||||
{
|
||||
op: "DELETE",
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
op: "INSERT",
|
||||
index: 0,
|
||||
room_id: roomB,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({
|
||||
0: roomB,
|
||||
1: roomA,
|
||||
});
|
||||
|
||||
// insert a final room
|
||||
httpBackend!.when("POST", syncUrl).respond(200, {
|
||||
pos: "c",
|
||||
lists: {
|
||||
a: {
|
||||
count: 1,
|
||||
ops: [
|
||||
{
|
||||
op: "DELETE",
|
||||
index: 2,
|
||||
},
|
||||
{
|
||||
op: "INSERT",
|
||||
index: 0,
|
||||
room_id: roomC,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({
|
||||
0: roomC,
|
||||
1: roomB,
|
||||
2: roomA,
|
||||
});
|
||||
slidingSync.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("transaction IDs", () => {
|
||||
beforeAll(setupClient);
|
||||
afterAll(teardownClient);
|
||||
const roomId = "!foo:bar";
|
||||
|
||||
let slidingSync: SlidingSync;
|
||||
|
||||
// really this applies to them all but it's easier to just test one
|
||||
it("should resolve modifyRoomSubscriptions after SlidingSync.start() is called", async () => {
|
||||
const roomSubInfo = {
|
||||
timeline_limit: 1,
|
||||
required_state: [["m.room.name", ""]],
|
||||
};
|
||||
// add the subscription
|
||||
slidingSync = new SlidingSync(proxyBaseUrl, new Map(), roomSubInfo, client!, 1);
|
||||
// modification before SlidingSync.start()
|
||||
const subscribePromise = slidingSync.modifyRoomSubscriptions(new Set([roomId]));
|
||||
let txnId: string | undefined;
|
||||
httpBackend!
|
||||
.when("POST", syncUrl)
|
||||
.check(function (req) {
|
||||
const body = req.data;
|
||||
logger.debug("got ", body);
|
||||
expect(body.room_subscriptions).toBeTruthy();
|
||||
expect(body.room_subscriptions[roomId]).toEqual(roomSubInfo);
|
||||
expect(body.txn_id).toBeTruthy();
|
||||
txnId = body.txn_id;
|
||||
})
|
||||
.respond(200, function () {
|
||||
return {
|
||||
pos: "aaa",
|
||||
txn_id: txnId,
|
||||
lists: {},
|
||||
extensions: {},
|
||||
rooms: {
|
||||
[roomId]: {
|
||||
name: "foo bar",
|
||||
required_state: [],
|
||||
timeline: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
slidingSync.start();
|
||||
await httpBackend!.flushAllExpected();
|
||||
await subscribePromise;
|
||||
});
|
||||
it("should resolve setList during a connection", async () => {
|
||||
const newList = {
|
||||
ranges: [[0, 20]],
|
||||
};
|
||||
const promise = slidingSync.setList("a", newList);
|
||||
let txnId: string | undefined;
|
||||
httpBackend!
|
||||
.when("POST", syncUrl)
|
||||
.check(function (req) {
|
||||
const body = req.data;
|
||||
logger.debug("got ", body);
|
||||
expect(body.room_subscriptions).toBeFalsy();
|
||||
expect(body.lists["a"]).toEqual(newList);
|
||||
expect(body.txn_id).toBeTruthy();
|
||||
txnId = body.txn_id;
|
||||
})
|
||||
.respond(200, function () {
|
||||
return {
|
||||
pos: "bbb",
|
||||
txn_id: txnId,
|
||||
lists: { a: { count: 5 } },
|
||||
extensions: {},
|
||||
};
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
await promise;
|
||||
expect(txnId).toBeDefined();
|
||||
});
|
||||
it("should resolve setListRanges during a connection", async () => {
|
||||
const promise = slidingSync.setListRanges("a", [[20, 40]]);
|
||||
let txnId: string | undefined;
|
||||
httpBackend!
|
||||
.when("POST", syncUrl)
|
||||
.check(function (req) {
|
||||
const body = req.data;
|
||||
logger.debug("got ", body);
|
||||
expect(body.room_subscriptions).toBeFalsy();
|
||||
expect(body.lists["a"]).toEqual({
|
||||
ranges: [[20, 40]],
|
||||
});
|
||||
expect(body.txn_id).toBeTruthy();
|
||||
txnId = body.txn_id;
|
||||
})
|
||||
.respond(200, function () {
|
||||
return {
|
||||
pos: "ccc",
|
||||
txn_id: txnId,
|
||||
lists: { a: { count: 5 } },
|
||||
extensions: {},
|
||||
};
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
await promise;
|
||||
expect(txnId).toBeDefined();
|
||||
});
|
||||
it("should resolve modifyRoomSubscriptionInfo during a connection", async () => {
|
||||
const promise = slidingSync.modifyRoomSubscriptionInfo({
|
||||
timeline_limit: 99,
|
||||
});
|
||||
let txnId: string | undefined;
|
||||
httpBackend!
|
||||
.when("POST", syncUrl)
|
||||
.check(function (req) {
|
||||
const body = req.data;
|
||||
logger.debug("got ", body);
|
||||
expect(body.room_subscriptions).toBeTruthy();
|
||||
expect(body.room_subscriptions[roomId]).toEqual({
|
||||
timeline_limit: 99,
|
||||
});
|
||||
expect(body.txn_id).toBeTruthy();
|
||||
txnId = body.txn_id;
|
||||
})
|
||||
.respond(200, function () {
|
||||
return {
|
||||
pos: "ddd",
|
||||
txn_id: txnId,
|
||||
extensions: {},
|
||||
};
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
await promise;
|
||||
expect(txnId).toBeDefined();
|
||||
});
|
||||
it("should reject earlier pending promises if a later transaction is acknowledged", async () => {
|
||||
// i.e if we have [A,B,C] and see txn_id=C then A,B should be rejected.
|
||||
const gotTxnIds: any[] = [];
|
||||
const pushTxn = function (req: MockHttpBackend["requests"][0]) {
|
||||
gotTxnIds.push(req.data.txn_id);
|
||||
};
|
||||
const failPromise = slidingSync.setListRanges("a", [[20, 40]]);
|
||||
httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "e" }); // missing txn_id
|
||||
await httpBackend!.flushAllExpected();
|
||||
const failPromise2 = slidingSync.setListRanges("a", [[60, 70]]);
|
||||
httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "f" }); // missing txn_id
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
const okPromise = slidingSync.setListRanges("a", [[0, 20]]);
|
||||
let txnId: string | undefined;
|
||||
httpBackend!
|
||||
.when("POST", syncUrl)
|
||||
.check((req) => {
|
||||
txnId = req.data.txn_id;
|
||||
})
|
||||
.respond(200, () => {
|
||||
// include the txn_id, earlier requests should now be reject()ed.
|
||||
return {
|
||||
pos: "g",
|
||||
txn_id: txnId,
|
||||
};
|
||||
});
|
||||
await Promise.all([
|
||||
expect(failPromise).rejects.toEqual(gotTxnIds[0]),
|
||||
expect(failPromise2).rejects.toEqual(gotTxnIds[1]),
|
||||
httpBackend!.flushAllExpected(),
|
||||
okPromise,
|
||||
]);
|
||||
|
||||
expect(txnId).toBeDefined();
|
||||
});
|
||||
it("should not reject later pending promises if an earlier transaction is acknowledged", async () => {
|
||||
// i.e if we have [A,B,C] and see txn_id=B then C should not be rejected but A should.
|
||||
const gotTxnIds: any[] = [];
|
||||
const pushTxn = function (req: MockHttpBackend["requests"][0]) {
|
||||
gotTxnIds.push(req.data?.txn_id);
|
||||
};
|
||||
const A = slidingSync.setListRanges("a", [[20, 40]]);
|
||||
httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "A" });
|
||||
await httpBackend!.flushAllExpected();
|
||||
const B = slidingSync.setListRanges("a", [[60, 70]]);
|
||||
httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "B" }); // missing txn_id
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
// attach rejection handlers now else if we do it later Jest treats that as an unhandled rejection
|
||||
// which is a fail.
|
||||
|
||||
const C = slidingSync.setListRanges("a", [[0, 20]]);
|
||||
let pendingC = true;
|
||||
C.finally(() => {
|
||||
pendingC = false;
|
||||
});
|
||||
httpBackend!
|
||||
.when("POST", syncUrl)
|
||||
.check(pushTxn)
|
||||
.respond(200, () => {
|
||||
// include the txn_id for B, so C's promise is outstanding
|
||||
return {
|
||||
pos: "C",
|
||||
txn_id: gotTxnIds[1],
|
||||
};
|
||||
});
|
||||
await Promise.all([
|
||||
expect(A).rejects.toEqual(gotTxnIds[0]),
|
||||
httpBackend!.flushAllExpected(),
|
||||
// A is rejected, see above
|
||||
expect(B).resolves.toEqual(gotTxnIds[1]), // B is resolved
|
||||
]);
|
||||
expect(pendingC).toBe(true); // C is pending still
|
||||
});
|
||||
it("should do nothing for unknown txn_ids", async () => {
|
||||
const promise = slidingSync.setListRanges("a", [[20, 40]]);
|
||||
let pending = true;
|
||||
promise.finally(() => {
|
||||
pending = false;
|
||||
});
|
||||
let txnId: string | undefined;
|
||||
httpBackend!
|
||||
.when("POST", syncUrl)
|
||||
.check(function (req) {
|
||||
const body = req.data;
|
||||
logger.debug("got ", body);
|
||||
expect(body.room_subscriptions).toBeFalsy();
|
||||
expect(body.lists["a"]).toEqual({
|
||||
ranges: [[20, 40]],
|
||||
});
|
||||
expect(body.txn_id).toBeTruthy();
|
||||
txnId = body.txn_id;
|
||||
})
|
||||
.respond(200, function () {
|
||||
return {
|
||||
pos: "ccc",
|
||||
txn_id: "bogus transaction id",
|
||||
lists: { a: { count: 5 } },
|
||||
extensions: {},
|
||||
};
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
expect(txnId).toBeDefined();
|
||||
expect(pending).toBe(true);
|
||||
slidingSync.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom room subscriptions", () => {
|
||||
@@ -1543,7 +826,7 @@ describe("SlidingSync", () => {
|
||||
|
||||
const extPre: Extension<any, any> = {
|
||||
name: () => preExtName,
|
||||
onRequest: (initial) => {
|
||||
onRequest: async (initial) => {
|
||||
return onPreExtensionRequest(initial);
|
||||
},
|
||||
onResponse: (res) => {
|
||||
@@ -1553,7 +836,7 @@ describe("SlidingSync", () => {
|
||||
};
|
||||
const extPost: Extension<any, any> = {
|
||||
name: () => postExtName,
|
||||
onRequest: (initial) => {
|
||||
onRequest: async (initial) => {
|
||||
return onPostExtensionRequest(initial);
|
||||
},
|
||||
onResponse: (res) => {
|
||||
@@ -1568,7 +851,7 @@ describe("SlidingSync", () => {
|
||||
|
||||
const callbackOrder: string[] = [];
|
||||
let extensionOnResponseCalled = false;
|
||||
onPreExtensionRequest = () => {
|
||||
onPreExtensionRequest = async () => {
|
||||
return extReq;
|
||||
};
|
||||
onPreExtensionResponse = async (resp) => {
|
||||
@@ -1608,7 +891,7 @@ describe("SlidingSync", () => {
|
||||
});
|
||||
|
||||
it("should be able to send nothing in an extension request/response", async () => {
|
||||
onPreExtensionRequest = () => {
|
||||
onPreExtensionRequest = async () => {
|
||||
return undefined;
|
||||
};
|
||||
let responseCalled = false;
|
||||
@@ -1643,7 +926,7 @@ describe("SlidingSync", () => {
|
||||
|
||||
it("is possible to register extensions after start() has been called", async () => {
|
||||
slidingSync.registerExtension(extPost);
|
||||
onPostExtensionRequest = () => {
|
||||
onPostExtensionRequest = async () => {
|
||||
return extReq;
|
||||
};
|
||||
let responseCalled = false;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { getMockClientWithEventEmitter } from "../test-utils/client";
|
||||
import { StubStore } from "../../src/store/stub";
|
||||
import { type IndexedToDeviceBatch } from "../../src/models/ToDeviceMessage";
|
||||
import { SyncState } from "../../src/sync";
|
||||
import { defer } from "../../src/utils";
|
||||
|
||||
describe("onResumedSync", () => {
|
||||
let batch: IndexedToDeviceBatch | null;
|
||||
@@ -60,7 +59,7 @@ describe("onResumedSync", () => {
|
||||
});
|
||||
|
||||
it("resends queue after connectivity restored", async () => {
|
||||
const deferred = defer();
|
||||
const successResolvers = Promise.withResolvers<void>();
|
||||
|
||||
onSendToDeviceFailure = () => {
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
|
||||
@@ -73,15 +72,15 @@ describe("onResumedSync", () => {
|
||||
onSendToDeviceSuccess = () => {
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(3);
|
||||
expect(store.removeToDeviceBatch).toHaveBeenCalled();
|
||||
deferred.resolve();
|
||||
successResolvers.resolve();
|
||||
};
|
||||
|
||||
queue.start();
|
||||
return deferred.promise;
|
||||
return successResolvers.promise;
|
||||
});
|
||||
|
||||
it("does not resend queue if client sync still catching up", async () => {
|
||||
const deferred = defer();
|
||||
const successResolvers = Promise.withResolvers<void>();
|
||||
|
||||
onSendToDeviceFailure = () => {
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
|
||||
@@ -89,15 +88,15 @@ describe("onResumedSync", () => {
|
||||
|
||||
resumeSync(SyncState.Catchup, SyncState.Catchup);
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
|
||||
deferred.resolve();
|
||||
successResolvers.resolve();
|
||||
};
|
||||
|
||||
queue.start();
|
||||
return deferred.promise;
|
||||
return successResolvers.promise;
|
||||
});
|
||||
|
||||
it("does not resend queue if connectivity restored after queue stopped", async () => {
|
||||
const deferred = defer();
|
||||
const successResolvers = Promise.withResolvers<void>();
|
||||
|
||||
onSendToDeviceFailure = () => {
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
|
||||
@@ -107,10 +106,10 @@ describe("onResumedSync", () => {
|
||||
|
||||
resumeSync(SyncState.Syncing, SyncState.Catchup);
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
|
||||
deferred.resolve();
|
||||
successResolvers.resolve();
|
||||
};
|
||||
|
||||
queue.start();
|
||||
return deferred.promise;
|
||||
return successResolvers.promise;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,14 +196,14 @@ describe("Topic content helpers", () => {
|
||||
expect(makeTopicContent("pizza", "<b>pizza</b>")).toEqual({
|
||||
topic: "pizza",
|
||||
[M_TOPIC.name]: [
|
||||
{
|
||||
body: "pizza",
|
||||
mimetype: "text/plain",
|
||||
},
|
||||
{
|
||||
body: "<b>pizza</b>",
|
||||
mimetype: "text/html",
|
||||
},
|
||||
{
|
||||
body: "pizza",
|
||||
mimetype: "text/plain",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -722,13 +722,14 @@ describe("RoomWidgetClient", () => {
|
||||
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, expectedRequestData);
|
||||
});
|
||||
|
||||
it("sends encrypted (encryptAndSendToDevices)", async () => {
|
||||
it("sends encrypted (encryptAndSendToDevice)", async () => {
|
||||
await makeClient({ sendToDevice: ["org.example.foo"] });
|
||||
expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo");
|
||||
|
||||
const payload = { type: "org.example.foo", hello: "world" };
|
||||
const payload = { hello: "world" };
|
||||
const embeddedClient = client as RoomWidgetClient;
|
||||
await embeddedClient.encryptAndSendToDevices(
|
||||
await embeddedClient.encryptAndSendToDevice(
|
||||
"org.example.foo",
|
||||
[
|
||||
{ userId: "@alice:example.org", deviceId: "aliceWeb" },
|
||||
{ userId: "@bob:example.org", deviceId: "bobDesktop" },
|
||||
|
||||
@@ -29,13 +29,18 @@ import {
|
||||
Method,
|
||||
} from "../../../src";
|
||||
import { emitPromise } from "../../test-utils/test-utils";
|
||||
import { defer, type QueryDict } from "../../../src/utils";
|
||||
import { type QueryDict, sleep } from "../../../src/utils";
|
||||
import { type Logger } from "../../../src/logger";
|
||||
|
||||
describe("FetchHttpApi", () => {
|
||||
const baseUrl = "http://baseUrl";
|
||||
const idBaseUrl = "http://idBaseUrl";
|
||||
const prefix = ClientPrefix.V3;
|
||||
const tokenInactiveError = new MatrixError({ errcode: "M_UNKNOWN_TOKEN", error: "Token is not active" }, 401);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("should support aborting multiple times", () => {
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
|
||||
@@ -351,7 +356,9 @@ describe("FetchHttpApi", () => {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
});
|
||||
const result = await api.authedRequest(Method.Post, "/account/password");
|
||||
const result = await api.authedRequest(Method.Post, "/account/password", undefined, undefined, {
|
||||
headers: {},
|
||||
});
|
||||
expect(result).toEqual(okayResponse);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
|
||||
|
||||
@@ -361,12 +368,22 @@ describe("FetchHttpApi", () => {
|
||||
expect(emitter.emit).not.toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
|
||||
});
|
||||
|
||||
it("should only try to refresh the token once", async () => {
|
||||
it("should not try to refresh the token if it has plenty of time left before expiry", async () => {
|
||||
// We can't specify an expiry for the initial token, so this should:
|
||||
// * Try once, fail
|
||||
// * Attempt a refresh, get a token that's not expired
|
||||
// * Try again, still fail
|
||||
// * Not refresh the token because it's not expired
|
||||
// ...which is TWO attempts and ONE refresh (which doesn't really
|
||||
// count because it's only to get a token with an expiry)
|
||||
const newAccessToken = "new-access-token";
|
||||
const newRefreshToken = "new-refresh-token";
|
||||
const tokenRefreshFunction = jest.fn().mockResolvedValue({
|
||||
const tokenRefreshFunction = jest.fn().mockReturnValue({
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
// This needs to be sufficiently high that it's over the threshold for
|
||||
// 'plenty of time' (which is a minute in practice).
|
||||
expiry: new Date(Date.now() + 5 * 60 * 1000),
|
||||
});
|
||||
|
||||
// fetch doesn't like our new or old tokens
|
||||
@@ -386,7 +403,7 @@ describe("FetchHttpApi", () => {
|
||||
unknownTokenErr,
|
||||
);
|
||||
|
||||
// tried to refresh the token once
|
||||
// tried to refresh the token once (to get the one with an expiry)
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -397,6 +414,54 @@ describe("FetchHttpApi", () => {
|
||||
// logged out after refreshed access token is rejected
|
||||
expect(emitter.emit).toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
|
||||
});
|
||||
|
||||
it("should try to refresh the token if it will expire soon", async () => {
|
||||
const newAccessToken = "new-access-token";
|
||||
const newRefreshToken = "new-refresh-token";
|
||||
|
||||
// first refresh is to get a token with an expiry at all, because we
|
||||
// can't specify an expiry on the token we inject
|
||||
const tokenRefreshFunction = jest.fn().mockResolvedValueOnce({
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
expiry: new Date(Date.now() + 1000),
|
||||
});
|
||||
|
||||
// next refresh is to return a token that will expire 'soon'
|
||||
tokenRefreshFunction.mockResolvedValueOnce({
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
expiry: new Date(Date.now() + 1000),
|
||||
});
|
||||
|
||||
// ...and finally we return a token that has adequate time left
|
||||
// so that it will cease retrying and fail the request.
|
||||
tokenRefreshFunction.mockResolvedValueOnce({
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
expiry: new Date(Date.now() + 5 * 60 * 1000),
|
||||
});
|
||||
|
||||
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
|
||||
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
jest.spyOn(emitter, "emit");
|
||||
const api = new FetchHttpApi(emitter, {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
tokenRefreshFunction,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
});
|
||||
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
|
||||
unknownTokenErr,
|
||||
);
|
||||
|
||||
// We should have seen the 3 token refreshes, as above.
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -460,8 +525,8 @@ describe("FetchHttpApi", () => {
|
||||
|
||||
it("should not log query parameters", async () => {
|
||||
jest.useFakeTimers();
|
||||
const deferred = defer<Response>();
|
||||
const fetchFn = jest.fn().mockReturnValue(deferred.promise);
|
||||
const responseResolvers = Promise.withResolvers<Response>();
|
||||
const fetchFn = jest.fn().mockReturnValue(responseResolvers.promise);
|
||||
const mockLogger = {
|
||||
debug: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
@@ -473,7 +538,7 @@ describe("FetchHttpApi", () => {
|
||||
});
|
||||
const prom = api.requestOtherUrl(Method.Get, "https://server:8448/some/path?query=param#fragment");
|
||||
jest.advanceTimersByTime(1234);
|
||||
deferred.resolve({ ok: true, status: 200, text: () => Promise.resolve("RESPONSE") } as Response);
|
||||
responseResolvers.resolve({ ok: true, status: 200, text: () => Promise.resolve("RESPONSE") } as Response);
|
||||
await prom;
|
||||
expect(mockLogger.debug).not.toHaveBeenCalledWith("fragment");
|
||||
expect(mockLogger.debug).not.toHaveBeenCalledWith("query");
|
||||
@@ -492,9 +557,7 @@ describe("FetchHttpApi", () => {
|
||||
});
|
||||
|
||||
it("should not make multiple concurrent refresh token requests", async () => {
|
||||
const tokenInactiveError = new MatrixError({ errcode: "M_UNKNOWN_TOKEN", error: "Token is not active" }, 401);
|
||||
|
||||
const deferredTokenRefresh = defer<{ accessToken: string; refreshToken: string }>();
|
||||
const deferredTokenRefresh = Promise.withResolvers<{ accessToken: string; refreshToken: string }>();
|
||||
const fetchFn = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: tokenInactiveError.httpStatus,
|
||||
@@ -523,7 +586,7 @@ describe("FetchHttpApi", () => {
|
||||
const prom1 = api.authedRequest(Method.Get, "/path1");
|
||||
const prom2 = api.authedRequest(Method.Get, "/path2");
|
||||
|
||||
await jest.advanceTimersByTimeAsync(10); // wait for requests to fire
|
||||
await sleep(0); // wait for requests to fire
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
fetchFn.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -547,4 +610,66 @@ describe("FetchHttpApi", () => {
|
||||
expect(api.opts.accessToken).toBe("NEW_ACCESS_TOKEN");
|
||||
expect(api.opts.refreshToken).toBe("NEW_REFRESH_TOKEN");
|
||||
});
|
||||
|
||||
it("should use newly refreshed token if request starts mid-refresh", async () => {
|
||||
const deferredTokenRefresh = Promise.withResolvers<{ accessToken: string; refreshToken: string }>();
|
||||
const fetchFn = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: tokenInactiveError.httpStatus,
|
||||
async text() {
|
||||
return JSON.stringify(tokenInactiveError.data);
|
||||
},
|
||||
async json() {
|
||||
return tokenInactiveError.data;
|
||||
},
|
||||
headers: {
|
||||
get: jest.fn().mockReturnValue("application/json"),
|
||||
},
|
||||
});
|
||||
const tokenRefreshFunction = jest.fn().mockReturnValue(deferredTokenRefresh.promise);
|
||||
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
doNotAttemptTokenRefresh: false,
|
||||
tokenRefreshFunction,
|
||||
accessToken: "ACCESS_TOKEN",
|
||||
refreshToken: "REFRESH_TOKEN",
|
||||
});
|
||||
|
||||
const prom1 = api.authedRequest(Method.Get, "/path1");
|
||||
await sleep(0); // wait for request to fire
|
||||
|
||||
const prom2 = api.authedRequest(Method.Get, "/path2");
|
||||
await sleep(0); // wait for request to fire
|
||||
|
||||
deferredTokenRefresh.resolve({ accessToken: "NEW_ACCESS_TOKEN", refreshToken: "NEW_REFRESH_TOKEN" });
|
||||
fetchFn.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
async text() {
|
||||
return "{}";
|
||||
},
|
||||
async json() {
|
||||
return {};
|
||||
},
|
||||
headers: {
|
||||
get: jest.fn().mockReturnValue("application/json"),
|
||||
},
|
||||
});
|
||||
|
||||
await prom1;
|
||||
await prom2;
|
||||
expect(fetchFn).toHaveBeenCalledTimes(3); // 2 original calls + 1 retry
|
||||
expect(fetchFn.mock.calls[0][1]).toEqual(
|
||||
expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer ACCESS_TOKEN" }) }),
|
||||
);
|
||||
expect(fetchFn.mock.calls[2][1]).toEqual(
|
||||
expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer NEW_ACCESS_TOKEN" }) }),
|
||||
);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledTimes(1);
|
||||
expect(api.opts.accessToken).toBe("NEW_ACCESS_TOKEN");
|
||||
expect(api.opts.refreshToken).toBe("NEW_REFRESH_TOKEN");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
PolicyRecommendation,
|
||||
PolicyScope,
|
||||
} from "../../src/models/invites-ignorer";
|
||||
import { defer, type QueryDict } from "../../src/utils";
|
||||
import { type QueryDict } from "../../src/utils";
|
||||
import { type SyncState } from "../../src/sync";
|
||||
import * as featureUtils from "../../src/feature";
|
||||
import { StubStore } from "../../src/store/stub";
|
||||
@@ -1997,8 +1997,8 @@ describe("MatrixClient", function () {
|
||||
});
|
||||
|
||||
it("should cancel an event which is encrypting", async () => {
|
||||
const encryptEventDefer = defer();
|
||||
mockCrypto.encryptEvent.mockReturnValue(encryptEventDefer.promise);
|
||||
const encryptEventResolvers = Promise.withResolvers<void>();
|
||||
mockCrypto.encryptEvent.mockReturnValue(encryptEventResolvers.promise);
|
||||
|
||||
const statusPromise = testUtils.emitPromise(event, "Event.status");
|
||||
// @ts-ignore protected method access
|
||||
@@ -2009,7 +2009,7 @@ describe("MatrixClient", function () {
|
||||
assertCancelled();
|
||||
|
||||
// now let the encryption complete, and check that the message is not sent.
|
||||
encryptEventDefer.resolve();
|
||||
encryptEventResolvers.resolve();
|
||||
await encryptAndSendPromise;
|
||||
assertCancelled();
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ import { DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "../../../sr
|
||||
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
|
||||
import { type EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
|
||||
import { secureRandomString } from "../../../src/randomstring";
|
||||
import { makeMockRoom, makeMockRoomState, membershipTemplate } from "./mocks";
|
||||
import { makeMockEvent, makeMockRoom, makeMockRoomState, membershipTemplate, makeKey } from "./mocks";
|
||||
|
||||
const mockFocus = { type: "mock" };
|
||||
|
||||
@@ -34,6 +34,8 @@ describe("MatrixRTCSession", () => {
|
||||
client = new MatrixClient({ baseUrl: "base_url" });
|
||||
client.getUserId = jest.fn().mockReturnValue("@alice:example.org");
|
||||
client.getDeviceId = jest.fn().mockReturnValue("AAAAAAA");
|
||||
client.sendEvent = jest.fn().mockResolvedValue({ event_id: "success" });
|
||||
client.decryptEventIfNeeded = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -478,19 +480,23 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
describe("key management", () => {
|
||||
// TODO make this test suit only test the encryption manager. And mock the transport directly not the session.
|
||||
describe("sending", () => {
|
||||
let mockRoom: Room;
|
||||
let sendStateEventMock: jest.Mock;
|
||||
let sendDelayedStateMock: jest.Mock;
|
||||
let sendEventMock: jest.Mock;
|
||||
let sendToDeviceMock: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
sendStateEventMock = jest.fn();
|
||||
sendDelayedStateMock = jest.fn();
|
||||
sendEventMock = jest.fn();
|
||||
sendToDeviceMock = jest.fn();
|
||||
client.sendStateEvent = sendStateEventMock;
|
||||
client._unstable_sendDelayedStateEvent = sendDelayedStateMock;
|
||||
client.sendEvent = sendEventMock;
|
||||
client.encryptAndSendToDevice = sendToDeviceMock;
|
||||
|
||||
mockRoom = makeMockRoom([]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
@@ -531,12 +537,7 @@ describe("MatrixRTCSession", () => {
|
||||
{
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
keys: [makeKey(0, expect.stringMatching(".*"))],
|
||||
sent_ts: Date.now(),
|
||||
},
|
||||
);
|
||||
@@ -584,7 +585,7 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
jest.advanceTimersByTime(10000);
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
await eventSentPromise;
|
||||
|
||||
@@ -739,12 +740,7 @@ describe("MatrixRTCSession", () => {
|
||||
{
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
keys: [makeKey(0, expect.stringMatching(".*"))],
|
||||
sent_ts: Date.now(),
|
||||
},
|
||||
);
|
||||
@@ -793,12 +789,7 @@ describe("MatrixRTCSession", () => {
|
||||
{
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
keys: [makeKey(0, expect.stringMatching(".*"))],
|
||||
sent_ts: Date.now(),
|
||||
},
|
||||
);
|
||||
@@ -831,12 +822,7 @@ describe("MatrixRTCSession", () => {
|
||||
{
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
keys: [makeKey(0, expect.stringMatching(".*"))],
|
||||
sent_ts: Date.now(),
|
||||
},
|
||||
);
|
||||
@@ -849,6 +835,7 @@ describe("MatrixRTCSession", () => {
|
||||
it("rotates key if a member leaves", async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const KEY_DELAY = 3000;
|
||||
const member2 = Object.assign({}, membershipTemplate, {
|
||||
device_id: "BBBBBBB",
|
||||
});
|
||||
@@ -869,7 +856,8 @@ describe("MatrixRTCSession", () => {
|
||||
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
|
||||
});
|
||||
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true, makeKeyDelay: KEY_DELAY });
|
||||
const sendKeySpy = jest.spyOn((sess as unknown as any).encryptionManager.transport, "sendKey");
|
||||
const firstKeysPayload = await keysSentPromise1;
|
||||
expect(firstKeysPayload.keys).toHaveLength(1);
|
||||
expect(firstKeysPayload.keys[0].index).toEqual(0);
|
||||
@@ -886,14 +874,24 @@ describe("MatrixRTCSession", () => {
|
||||
.mockReturnValue(makeMockRoomState([membershipTemplate], mockRoom.roomId));
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
|
||||
jest.advanceTimersByTime(10000);
|
||||
jest.advanceTimersByTime(KEY_DELAY);
|
||||
expect(sendKeySpy).toHaveBeenCalledTimes(1);
|
||||
// check that we send the key with index 1 even though the send gets delayed when leaving.
|
||||
// this makes sure we do not use an index that is one too old.
|
||||
expect(sendKeySpy).toHaveBeenLastCalledWith(expect.any(String), 1, sess.memberships);
|
||||
// fake a condition in which we send another encryption key event.
|
||||
// this could happen do to someone joining the call.
|
||||
(sess as unknown as any).encryptionManager.sendEncryptionKeysEvent();
|
||||
expect(sendKeySpy).toHaveBeenLastCalledWith(expect.any(String), 1, sess.memberships);
|
||||
jest.advanceTimersByTime(7000);
|
||||
|
||||
const secondKeysPayload = await keysSentPromise2;
|
||||
|
||||
expect(secondKeysPayload.keys).toHaveLength(1);
|
||||
expect(secondKeysPayload.keys[0].index).toEqual(1);
|
||||
expect(onMyEncryptionKeyChanged).toHaveBeenCalledTimes(2);
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(2);
|
||||
// initial, on leave and the fake one we do with: `(sess as unknown as any).encryptionManager.sendEncryptionKeysEvent();`
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(3);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
@@ -982,64 +980,75 @@ describe("MatrixRTCSession", () => {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("send key as to device", async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const keySentPromise = new Promise((resolve) => {
|
||||
sendToDeviceMock.mockImplementation(resolve);
|
||||
});
|
||||
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, {
|
||||
manageMediaKeys: true,
|
||||
useNewMembershipManager: true,
|
||||
useExperimentalToDeviceTransport: true,
|
||||
});
|
||||
|
||||
await keySentPromise;
|
||||
|
||||
expect(sendToDeviceMock).toHaveBeenCalled();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("receiving", () => {
|
||||
it("collects keys from encryption events", () => {
|
||||
it("collects keys from encryption events", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
keys: [makeKey(0, "dGhpcyBpcyB0aGUga2V5")],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
} as unknown as MatrixEvent);
|
||||
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
const encryptionKeyChangedListener = jest.fn();
|
||||
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
|
||||
sess!.reemitEncryptionKeys();
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("this is the key"),
|
||||
0,
|
||||
"@bob:example.org:bobsphone",
|
||||
);
|
||||
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1);
|
||||
});
|
||||
|
||||
it("collects keys at non-zero indices", () => {
|
||||
it("collects keys at non-zero indices", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 4,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
keys: [makeKey(4, "dGhpcyBpcyB0aGUga2V5")],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
} as unknown as MatrixEvent);
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
const encryptionKeyChangedListener = jest.fn();
|
||||
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
|
||||
sess!.reemitEncryptionKeys();
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("this is the key"),
|
||||
4,
|
||||
@@ -1049,61 +1058,48 @@ describe("MatrixRTCSession", () => {
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1);
|
||||
});
|
||||
|
||||
it("collects keys by merging", () => {
|
||||
it("collects keys by merging", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
keys: [makeKey(0, "dGhpcyBpcyB0aGUga2V5")],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
} as unknown as MatrixEvent);
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
const encryptionKeyChangedListener = jest.fn();
|
||||
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
|
||||
sess!.reemitEncryptionKeys();
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("this is the key"),
|
||||
0,
|
||||
"@bob:example.org:bobsphone",
|
||||
);
|
||||
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1);
|
||||
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 4,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
} as unknown as MatrixEvent);
|
||||
|
||||
encryptionKeyChangedListener.mockClear();
|
||||
sess!.reemitEncryptionKeys();
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("this is the key"),
|
||||
0,
|
||||
"@bob:example.org:bobsphone",
|
||||
);
|
||||
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1);
|
||||
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [makeKey(4, "dGhpcyBpcyB0aGUga2V5")],
|
||||
}),
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
encryptionKeyChangedListener.mockClear();
|
||||
sess!.reemitEncryptionKeys();
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(3);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("this is the key"),
|
||||
0,
|
||||
"@bob:example.org:bobsphone",
|
||||
);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("this is the key"),
|
||||
4,
|
||||
@@ -1113,93 +1109,102 @@ describe("MatrixRTCSession", () => {
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(2);
|
||||
});
|
||||
|
||||
it("ignores older keys at same index", () => {
|
||||
it("ignores older keys at same index", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: encodeBase64(Buffer.from("newer key", "utf-8")),
|
||||
},
|
||||
],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(2000),
|
||||
} as unknown as MatrixEvent);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent(
|
||||
"io.element.call.encryption_keys",
|
||||
"@bob:example.org",
|
||||
"1234roomId",
|
||||
{
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [makeKey(0, encodeBase64(Buffer.from("newer key", "utf-8")))],
|
||||
},
|
||||
2000,
|
||||
),
|
||||
);
|
||||
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: encodeBase64(Buffer.from("older key", "utf-8")),
|
||||
},
|
||||
],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(1000), // earlier timestamp than the newer key
|
||||
} as unknown as MatrixEvent);
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent(
|
||||
"io.element.call.encryption_keys",
|
||||
"@bob:example.org",
|
||||
"1234roomId",
|
||||
{
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [makeKey(0, encodeBase64(Buffer.from("newer key", "utf-8")))],
|
||||
},
|
||||
2000,
|
||||
),
|
||||
);
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent(
|
||||
"io.element.call.encryption_keys",
|
||||
"@bob:example.org",
|
||||
"1234roomId",
|
||||
{
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [makeKey(0, encodeBase64(Buffer.from("older key", "utf-8")))],
|
||||
},
|
||||
1000,
|
||||
),
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
const encryptionKeyChangedListener = jest.fn();
|
||||
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
|
||||
sess!.reemitEncryptionKeys();
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("newer key"),
|
||||
0,
|
||||
"@bob:example.org:bobsphone",
|
||||
);
|
||||
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(2);
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(3);
|
||||
});
|
||||
|
||||
it("key timestamps are treated as monotonic", () => {
|
||||
it("key timestamps are treated as monotonic", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: encodeBase64(Buffer.from("first key", "utf-8")),
|
||||
},
|
||||
],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
} as unknown as MatrixEvent);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent(
|
||||
"io.element.call.encryption_keys",
|
||||
"@bob:example.org",
|
||||
"1234roomId",
|
||||
{
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [makeKey(0, encodeBase64(Buffer.from("older key", "utf-8")))],
|
||||
},
|
||||
1000,
|
||||
),
|
||||
);
|
||||
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: encodeBase64(Buffer.from("second key", "utf-8")),
|
||||
},
|
||||
],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(1000), // same timestamp as the first key
|
||||
} as unknown as MatrixEvent);
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent(
|
||||
"io.element.call.encryption_keys",
|
||||
"@bob:example.org",
|
||||
"1234roomId",
|
||||
{
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [makeKey(0, encodeBase64(Buffer.from("second key", "utf-8")))],
|
||||
},
|
||||
1000,
|
||||
),
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
const encryptionKeyChangedListener = jest.fn();
|
||||
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
|
||||
sess!.reemitEncryptionKeys();
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("second key"),
|
||||
0,
|
||||
@@ -1210,31 +1215,25 @@ describe("MatrixRTCSession", () => {
|
||||
it("ignores keys event for the local participant", () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", client.getUserId()!, "1234roomId", {
|
||||
device_id: client.getDeviceId(),
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 4,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
keys: [makeKey(4, "dGhpcyBpcyB0aGUga2V5")],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue(client.getUserId()),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
} as unknown as MatrixEvent);
|
||||
);
|
||||
|
||||
const encryptionKeyChangedListener = jest.fn();
|
||||
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
|
||||
sess!.reemitEncryptionKeys();
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(0);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(0);
|
||||
});
|
||||
|
||||
it("tracks total age statistics for collected keys", () => {
|
||||
it("tracks total age statistics for collected keys", async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
@@ -1242,59 +1241,49 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
// defaults to getTs()
|
||||
jest.setSystemTime(1000);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(0),
|
||||
} as unknown as MatrixEvent);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent(
|
||||
"io.element.call.encryption_keys",
|
||||
"@bob:example.org",
|
||||
"1234roomId",
|
||||
{
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "dGhpcyBpcyB0aGUga2V5")],
|
||||
},
|
||||
0,
|
||||
),
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
expect(sess!.statistics.totals.roomEventEncryptionKeysReceivedTotalAge).toEqual(1000);
|
||||
|
||||
jest.setSystemTime(2000);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
keys: [makeKey(0, "dGhpcyBpcyB0aGUga2V5")],
|
||||
sent_ts: 0,
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
} as unknown as MatrixEvent);
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
expect(sess!.statistics.totals.roomEventEncryptionKeysReceivedTotalAge).toEqual(3000);
|
||||
|
||||
jest.setSystemTime(3000);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
keys: [makeKey(0, "dGhpcyBpcyB0aGUga2V5")],
|
||||
sent_ts: 1000,
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
} as unknown as MatrixEvent);
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
expect(sess!.statistics.totals.roomEventEncryptionKeysReceivedTotalAge).toEqual(5000);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
|
||||
@@ -16,15 +16,7 @@ limitations under the License.
|
||||
|
||||
import { type Mock } from "jest-mock";
|
||||
|
||||
import {
|
||||
ClientEvent,
|
||||
EventTimeline,
|
||||
EventType,
|
||||
type IRoomTimelineData,
|
||||
MatrixClient,
|
||||
type MatrixEvent,
|
||||
RoomEvent,
|
||||
} from "../../../src";
|
||||
import { ClientEvent, EventTimeline, MatrixClient } from "../../../src";
|
||||
import { RoomStateEvent } from "../../../src/models/room-state";
|
||||
import { MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
|
||||
import { makeMockRoom, makeMockRoomState, membershipTemplate } from "./mocks";
|
||||
@@ -77,117 +69,4 @@ describe("MatrixRTCSessionManager", () => {
|
||||
|
||||
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
|
||||
it("Calls onCallEncryption on encryption keys event", async () => {
|
||||
const room1 = makeMockRoom([membershipTemplate]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
const onCallEncryptionMock = jest.fn();
|
||||
client.matrixRTC.getRoomSession(room1).onCallEncryption = onCallEncryptionMock;
|
||||
client.decryptEventIfNeeded = () => Promise.resolve();
|
||||
const timelineEvent = {
|
||||
getType: jest.fn().mockReturnValue(EventType.CallEncryptionKeysPrefix),
|
||||
getContent: jest.fn().mockReturnValue({}),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getRoomId: jest.fn().mockReturnValue("!room:id"),
|
||||
isDecryptionFailure: jest.fn().mockReturnValue(false),
|
||||
sender: {
|
||||
userId: "@mock:user.example",
|
||||
},
|
||||
} as unknown as MatrixEvent;
|
||||
client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
await new Promise(process.nextTick);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("event decryption", () => {
|
||||
it("Retries decryption and processes success", async () => {
|
||||
try {
|
||||
jest.useFakeTimers();
|
||||
const room1 = makeMockRoom([membershipTemplate]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
const onCallEncryptionMock = jest.fn();
|
||||
client.matrixRTC.getRoomSession(room1).onCallEncryption = onCallEncryptionMock;
|
||||
let isDecryptionFailure = true;
|
||||
client.decryptEventIfNeeded = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(Promise.resolve())
|
||||
.mockImplementation(() => {
|
||||
isDecryptionFailure = false;
|
||||
return Promise.resolve();
|
||||
});
|
||||
const timelineEvent = {
|
||||
getType: jest.fn().mockReturnValue(EventType.CallEncryptionKeysPrefix),
|
||||
getContent: jest.fn().mockReturnValue({}),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getRoomId: jest.fn().mockReturnValue("!room:id"),
|
||||
isDecryptionFailure: jest.fn().mockImplementation(() => isDecryptionFailure),
|
||||
getId: jest.fn().mockReturnValue("event_id"),
|
||||
sender: {
|
||||
userId: "@mock:user.example",
|
||||
},
|
||||
} as unknown as MatrixEvent;
|
||||
client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// should retry after one second:
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("Retries decryption and processes failure", async () => {
|
||||
try {
|
||||
jest.useFakeTimers();
|
||||
const room1 = makeMockRoom([membershipTemplate]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
const onCallEncryptionMock = jest.fn();
|
||||
client.matrixRTC.getRoomSession(room1).onCallEncryption = onCallEncryptionMock;
|
||||
client.decryptEventIfNeeded = jest.fn().mockReturnValue(Promise.resolve());
|
||||
const timelineEvent = {
|
||||
getType: jest.fn().mockReturnValue(EventType.CallEncryptionKeysPrefix),
|
||||
getContent: jest.fn().mockReturnValue({}),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getRoomId: jest.fn().mockReturnValue("!room:id"),
|
||||
isDecryptionFailure: jest.fn().mockReturnValue(true), // always fail
|
||||
getId: jest.fn().mockReturnValue("event_id"),
|
||||
sender: {
|
||||
userId: "@mock:user.example",
|
||||
},
|
||||
} as unknown as MatrixEvent;
|
||||
client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// should retry after one second:
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// doesn't retry again:
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,11 +20,17 @@ limitations under the License.
|
||||
import { type MockedFunction, type Mock } from "jest-mock";
|
||||
|
||||
import { EventType, HTTPError, MatrixError, UnsupportedDelayedEventsEndpointError, type Room } from "../../../src";
|
||||
import { type Focus, type LivekitFocusActive, type SessionMembershipData } from "../../../src/matrixrtc";
|
||||
import {
|
||||
MembershipManagerEvent,
|
||||
Status,
|
||||
type Focus,
|
||||
type LivekitFocusActive,
|
||||
type SessionMembershipData,
|
||||
} from "../../../src/matrixrtc";
|
||||
import { LegacyMembershipManager } from "../../../src/matrixrtc/LegacyMembershipManager";
|
||||
import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks";
|
||||
import { MembershipManager } from "../../../src/matrixrtc/NewMembershipManager";
|
||||
import { defer } from "../../../src/utils";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
|
||||
function waitForMockCall(method: MockedFunction<any>, returnVal?: Promise<any>) {
|
||||
return new Promise<void>((resolve) => {
|
||||
@@ -34,9 +40,17 @@ function waitForMockCall(method: MockedFunction<any>, returnVal?: Promise<any>)
|
||||
});
|
||||
});
|
||||
}
|
||||
function waitForMockCallOnce(method: MockedFunction<any>, returnVal?: Promise<any>) {
|
||||
return new Promise<void>((resolve) => {
|
||||
method.mockImplementationOnce(() => {
|
||||
resolve();
|
||||
return returnVal ?? Promise.resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createAsyncHandle(method: MockedFunction<any>) {
|
||||
const { reject, resolve, promise } = defer();
|
||||
const { reject, resolve, promise } = Promise.withResolvers<void>();
|
||||
method.mockImplementation(() => promise);
|
||||
return { reject, resolve };
|
||||
}
|
||||
@@ -78,16 +92,16 @@ describe.each([
|
||||
// There is no need to clean up mocks since we will recreate the client.
|
||||
});
|
||||
|
||||
describe("isJoined()", () => {
|
||||
describe("isActivated()", () => {
|
||||
it("defaults to false", () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
expect(manager.isJoined()).toEqual(false);
|
||||
expect(manager.isActivated()).toEqual(false);
|
||||
});
|
||||
|
||||
it("returns true after join()", () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
manager.join([]);
|
||||
expect(manager.isJoined()).toEqual(true);
|
||||
expect(manager.isActivated()).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,6 +139,23 @@ describe.each([
|
||||
{},
|
||||
"_@alice:example.org_AAAAAAA",
|
||||
);
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reschedules delayed leave event if sending state cancels it", async () => {
|
||||
const memberManager = new TestMembershipManager(undefined, room, client, () => undefined);
|
||||
const waitForSendState = waitForMockCall(client.sendStateEvent);
|
||||
const waitForUpdateDelaye = waitForMockCallOnce(
|
||||
client._unstable_updateDelayedEvent,
|
||||
Promise.reject(new MatrixError({ errcode: "M_NOT_FOUND" })),
|
||||
);
|
||||
memberManager.join([focus], focusActive);
|
||||
await waitForSendState;
|
||||
await waitForUpdateDelaye;
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
// Once for the initial event and once because of the errcode: "M_NOT_FOUND"
|
||||
// Different to "sends a membership event and schedules delayed leave when joining a call" where its only called once (1)
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe("does not prefix the state key with _ for rooms that support user-owned state events", () => {
|
||||
@@ -505,7 +536,39 @@ describe.each([
|
||||
await testExpires(10_000, 1_000);
|
||||
});
|
||||
});
|
||||
describe("status updates", () => {
|
||||
it("starts 'Disconnected' !FailsForLegacy", () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
expect(manager.status).toBe(Status.Disconnected);
|
||||
});
|
||||
it("emits 'Connection' and 'Connected' after join !FailsForLegacy", async () => {
|
||||
const handleDelayedEvent = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
const handleStateEvent = createAsyncHandle(client.sendStateEvent);
|
||||
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
expect(manager.status).toBe(Status.Disconnected);
|
||||
const connectEmit = jest.fn();
|
||||
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
|
||||
manager.join([focus], focusActive);
|
||||
expect(manager.status).toBe(Status.Connecting);
|
||||
handleDelayedEvent.resolve();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(connectEmit).toHaveBeenCalledWith(Status.Disconnected, Status.Connecting);
|
||||
handleStateEvent.resolve();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected);
|
||||
});
|
||||
it("emits 'Disconnecting' and 'Disconnected' after leave !FailsForLegacy", async () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const connectEmit = jest.fn();
|
||||
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
await manager.leave();
|
||||
expect(connectEmit).toHaveBeenCalledWith(Status.Connected, Status.Disconnecting);
|
||||
expect(connectEmit).toHaveBeenCalledWith(Status.Disconnecting, Status.Disconnected);
|
||||
});
|
||||
});
|
||||
describe("server error handling", () => {
|
||||
// Types of server error: 429 rate limit with no retry-after header, 429 with retry-after, 50x server error (maybe retry every second), connection/socket timeout
|
||||
describe("retries sending delayed leave event", () => {
|
||||
@@ -700,3 +763,19 @@ describe.each([
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("Should prefix log with MembershipManager used", () => {
|
||||
const client = makeMockClient("@alice:example.org", "AAAAAAA");
|
||||
const room = makeMockRoom(membershipTemplate);
|
||||
|
||||
const membershipManager = new MembershipManager(undefined, room, client, () => undefined, logger);
|
||||
|
||||
const spy = jest.spyOn(console, "error");
|
||||
// Double join
|
||||
membershipManager.join([]);
|
||||
membershipManager.join([]);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
const logline: string = spy.mock.calls[0][0];
|
||||
expect(logline.startsWith("[NewMembershipManager]")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
Copyright 2025 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 { type Mocked } from "jest-mock";
|
||||
|
||||
import { makeKey, makeMockEvent, makeMockRoom, membershipTemplate, mockCallMembership } from "./mocks";
|
||||
import { EventType, type IRoomTimelineData, type Room, RoomEvent, type MatrixClient } from "../../../src";
|
||||
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
|
||||
import {
|
||||
getMockClientWithEventEmitter,
|
||||
mockClientMethodsEvents,
|
||||
mockClientMethodsUser,
|
||||
} from "../../test-utils/client.ts";
|
||||
import { type Statistics } from "../../../src/matrixrtc";
|
||||
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport.ts";
|
||||
import { type Logger } from "../../../src/logger.ts";
|
||||
import { RoomAndToDeviceEvents, RoomAndToDeviceTransport } from "../../../src/matrixrtc/RoomAndToDeviceKeyTransport.ts";
|
||||
import { RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport.ts";
|
||||
|
||||
describe("RoomAndToDeviceTransport", () => {
|
||||
const roomId = "!room:id";
|
||||
|
||||
let mockClient: Mocked<MatrixClient>;
|
||||
let statistics: Statistics;
|
||||
let mockLogger: Mocked<Logger>;
|
||||
let transport: RoomAndToDeviceTransport;
|
||||
let mockRoom: Room;
|
||||
let sendEventMock: jest.Mock;
|
||||
let roomKeyTransport: RoomKeyTransport;
|
||||
let toDeviceKeyTransport: ToDeviceKeyTransport;
|
||||
let toDeviceSendKeySpy: jest.SpyInstance;
|
||||
let roomSendKeySpy: jest.SpyInstance;
|
||||
beforeEach(() => {
|
||||
sendEventMock = jest.fn();
|
||||
mockClient = getMockClientWithEventEmitter({
|
||||
encryptAndSendToDevice: jest.fn(),
|
||||
getDeviceId: jest.fn().mockReturnValue("MYDEVICE"),
|
||||
...mockClientMethodsEvents(),
|
||||
...mockClientMethodsUser("@alice:example.org"),
|
||||
sendEvent: sendEventMock,
|
||||
});
|
||||
mockRoom = makeMockRoom([]);
|
||||
mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
getChild: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
mockLogger.getChild.mockReturnValue(mockLogger);
|
||||
statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
roomKeyTransport = new RoomKeyTransport(mockRoom, mockClient, statistics);
|
||||
toDeviceKeyTransport = new ToDeviceKeyTransport(
|
||||
"@alice:example.org",
|
||||
"MYDEVICE",
|
||||
mockRoom.roomId,
|
||||
mockClient,
|
||||
statistics,
|
||||
);
|
||||
transport = new RoomAndToDeviceTransport(toDeviceKeyTransport, roomKeyTransport, mockLogger);
|
||||
toDeviceSendKeySpy = jest.spyOn(toDeviceKeyTransport, "sendKey");
|
||||
roomSendKeySpy = jest.spyOn(roomKeyTransport, "sendKey");
|
||||
});
|
||||
|
||||
it("should enable to device transport when starting", () => {
|
||||
transport.start();
|
||||
expect(transport.enabled.room).toBeFalsy();
|
||||
expect(transport.enabled.toDevice).toBeTruthy();
|
||||
});
|
||||
it("only sends to device keys when sending a key", async () => {
|
||||
transport.start();
|
||||
await transport.sendKey("1235", 0, [mockCallMembership(membershipTemplate, roomId, "@alice:example.org")]);
|
||||
expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(roomSendKeySpy).toHaveBeenCalledTimes(0);
|
||||
expect(transport.enabled.room).toBeFalsy();
|
||||
expect(transport.enabled.toDevice).toBeTruthy();
|
||||
});
|
||||
|
||||
it("enables room transport and disables to device transport when receiving a room key", async () => {
|
||||
transport.start();
|
||||
const onNewKeyFromTransport = jest.fn();
|
||||
const onTransportEnabled = jest.fn();
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, onNewKeyFromTransport);
|
||||
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
|
||||
mockRoom.emit(
|
||||
RoomEvent.Timeline,
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", roomId, {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
}),
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
{} as IRoomTimelineData,
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(transport.enabled.room).toBeTruthy();
|
||||
expect(transport.enabled.toDevice).toBeFalsy();
|
||||
|
||||
await transport.sendKey("1235", 0, [mockCallMembership(membershipTemplate, roomId, "@alice:example.org")]);
|
||||
expect(sendEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(roomSendKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(0);
|
||||
expect(onTransportEnabled).toHaveBeenCalledWith({ toDevice: false, room: true });
|
||||
});
|
||||
it("does log that it did nothing when disabled", () => {
|
||||
transport.start();
|
||||
const onNewKeyFromTransport = jest.fn();
|
||||
const onTransportEnabled = jest.fn();
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, onNewKeyFromTransport);
|
||||
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
|
||||
|
||||
transport.setEnabled({ toDevice: false, room: false });
|
||||
const dateNow = Date.now();
|
||||
roomKeyTransport.emit(KeyTransportEvents.ReceivedKeys, "user", "device", "roomKey", 0, dateNow);
|
||||
toDeviceKeyTransport.emit(KeyTransportEvents.ReceivedKeys, "user", "device", "toDeviceKey", 0, Date.now());
|
||||
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith("To Device transport is disabled, ignoring received keys");
|
||||
// for room key transport we will never get a disabled message because its will always just turn on
|
||||
expect(onTransportEnabled).toHaveBeenNthCalledWith(1, { toDevice: false, room: false });
|
||||
expect(onTransportEnabled).toHaveBeenNthCalledWith(2, { toDevice: false, room: true });
|
||||
expect(onNewKeyFromTransport).toHaveBeenCalledTimes(1);
|
||||
expect(onNewKeyFromTransport).toHaveBeenCalledWith("user", "device", "roomKey", 0, dateNow);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
Copyright 2025 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 { makeMockEvent, makeMockRoom, membershipTemplate, makeKey } from "./mocks";
|
||||
import { RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport";
|
||||
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport";
|
||||
import { EventType, MatrixClient, RoomEvent } from "../../../src";
|
||||
import { type IRoomTimelineData, MatrixEvent, type Room } from "../../../src";
|
||||
import type { Mocked } from "jest-mock";
|
||||
import type { Logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("RoomKeyTransport", () => {
|
||||
let client: MatrixClient;
|
||||
let room: Room & {
|
||||
emitTimelineEvent: (event: MatrixEvent) => void;
|
||||
};
|
||||
let transport: RoomKeyTransport;
|
||||
let mockLogger: Mocked<Logger>;
|
||||
|
||||
const onCallEncryptionMock = jest.fn();
|
||||
beforeEach(() => {
|
||||
onCallEncryptionMock.mockReset();
|
||||
mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
info: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
|
||||
const statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
room = makeMockRoom([membershipTemplate]);
|
||||
client = new MatrixClient({ baseUrl: "base_url" });
|
||||
client.matrixRTC.start();
|
||||
transport = new RoomKeyTransport(room, client, statistics, {
|
||||
getChild: jest.fn().mockReturnValue(mockLogger),
|
||||
} as unknown as Mocked<Logger>);
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, (...p) => {
|
||||
onCallEncryptionMock(...p);
|
||||
});
|
||||
transport.start();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.stopClient();
|
||||
client.matrixRTC.stop();
|
||||
transport.stop();
|
||||
});
|
||||
|
||||
it("Calls onCallEncryption on encryption keys event", async () => {
|
||||
client.decryptEventIfNeeded = () => Promise.resolve();
|
||||
const timelineEvent = makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
});
|
||||
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
await new Promise(process.nextTick);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("event decryption", () => {
|
||||
it("Retries decryption and processes success", async () => {
|
||||
jest.useFakeTimers();
|
||||
let isDecryptionFailure = true;
|
||||
client.decryptEventIfNeeded = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(Promise.resolve())
|
||||
.mockImplementation(() => {
|
||||
isDecryptionFailure = false;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
const timelineEvent = Object.assign(
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
}),
|
||||
{ isDecryptionFailure: jest.fn().mockImplementation(() => isDecryptionFailure) },
|
||||
);
|
||||
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// should retry after one second:
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(1);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("Retries decryption and processes failure", async () => {
|
||||
try {
|
||||
jest.useFakeTimers();
|
||||
const onCallEncryptionMock = jest.fn();
|
||||
client.decryptEventIfNeeded = jest.fn().mockReturnValue(Promise.resolve());
|
||||
|
||||
const timelineEvent = Object.assign(
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
}),
|
||||
{ isDecryptionFailure: jest.fn().mockReturnValue(true) },
|
||||
);
|
||||
|
||||
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// should retry after one second:
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// doesn't retry again:
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("malformed events", () => {
|
||||
const MALFORMED_EVENT = [
|
||||
// empty content
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
sender: "@alice:example.com",
|
||||
content: {},
|
||||
}),
|
||||
// no sender
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
content: {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
}),
|
||||
// Call_id not empty string
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
sender: "@alice:example.com",
|
||||
content: {
|
||||
call_id: "FOO",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
}),
|
||||
// Various Malformed keys
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
sender: "@alice:example.com",
|
||||
content: {
|
||||
call_id: "",
|
||||
keys: "FOO",
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
}),
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
sender: "@alice:example.com",
|
||||
content: {
|
||||
call_id: "",
|
||||
keys: [{ index: 0 }],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
}),
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
sender: "@alice:example.com",
|
||||
content: {
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
key: "BASE64KEY",
|
||||
index: "mcall",
|
||||
},
|
||||
],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
test.each(MALFORMED_EVENT)("should warn on malformed event %j", (event) => {
|
||||
transport.onEncryptionEvent(event);
|
||||
expect(mockLogger.warn).toHaveBeenCalled();
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
/*
|
||||
Copyright 2025 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 { type Mocked } from "jest-mock";
|
||||
|
||||
import { makeMockEvent, membershipTemplate, mockCallMembership } from "./mocks";
|
||||
import { ClientEvent, EventType, type MatrixClient } from "../../../src";
|
||||
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
|
||||
import { getMockClientWithEventEmitter } from "../../test-utils/client.ts";
|
||||
import { type Statistics } from "../../../src/matrixrtc";
|
||||
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport.ts";
|
||||
import { type Logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("ToDeviceKeyTransport", () => {
|
||||
const roomId = "!room:id";
|
||||
|
||||
let mockClient: Mocked<MatrixClient>;
|
||||
let statistics: Statistics;
|
||||
let mockLogger: Mocked<Logger>;
|
||||
let transport: ToDeviceKeyTransport;
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = getMockClientWithEventEmitter({
|
||||
encryptAndSendToDevice: jest.fn(),
|
||||
});
|
||||
mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
|
||||
transport = new ToDeviceKeyTransport("@alice:example.org", "MYDEVICE", roomId, mockClient, statistics, {
|
||||
getChild: jest.fn().mockReturnValue(mockLogger),
|
||||
} as unknown as Mocked<Logger>);
|
||||
});
|
||||
|
||||
it("should send my keys on via to device", async () => {
|
||||
transport.start();
|
||||
|
||||
const keyBase64Encoded = "ABCDEDF";
|
||||
const keyIndex = 2;
|
||||
await transport.sendKey(keyBase64Encoded, keyIndex, [
|
||||
mockCallMembership(
|
||||
Object.assign({}, membershipTemplate, { device_id: "BOBDEVICE" }),
|
||||
roomId,
|
||||
"@bob:example.org",
|
||||
),
|
||||
mockCallMembership(
|
||||
Object.assign({}, membershipTemplate, { device_id: "CARLDEVICE" }),
|
||||
roomId,
|
||||
"@carl:example.org",
|
||||
),
|
||||
mockCallMembership(
|
||||
Object.assign({}, membershipTemplate, { device_id: "MATDEVICE" }),
|
||||
roomId,
|
||||
"@mat:example.org",
|
||||
),
|
||||
]);
|
||||
|
||||
expect(mockClient.encryptAndSendToDevice).toHaveBeenCalledTimes(1);
|
||||
expect(mockClient.encryptAndSendToDevice).toHaveBeenCalledWith(
|
||||
"io.element.call.encryption_keys",
|
||||
[
|
||||
{ userId: "@bob:example.org", deviceId: "BOBDEVICE" },
|
||||
{ userId: "@carl:example.org", deviceId: "CARLDEVICE" },
|
||||
{ userId: "@mat:example.org", deviceId: "MATDEVICE" },
|
||||
],
|
||||
{
|
||||
keys: {
|
||||
index: keyIndex,
|
||||
key: keyBase64Encoded,
|
||||
},
|
||||
member: {
|
||||
claimed_device_id: "MYDEVICE",
|
||||
},
|
||||
room_id: roomId,
|
||||
session: {
|
||||
application: "m.call",
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(1);
|
||||
});
|
||||
|
||||
it("should emit when a key is received", async () => {
|
||||
const receivedKeyResolvers = Promise.withResolvers<{
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
keyBase64Encoded: string;
|
||||
index: number;
|
||||
}>();
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, (userId, deviceId, keyBase64Encoded, index, timestamp) => {
|
||||
receivedKeyResolvers.resolve({ userId, deviceId, keyBase64Encoded, index });
|
||||
});
|
||||
transport.start();
|
||||
|
||||
const testEncoded = "ABCDEDF";
|
||||
const testKeyIndex = 2;
|
||||
|
||||
mockClient.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", undefined, {
|
||||
keys: {
|
||||
index: testKeyIndex,
|
||||
key: testEncoded,
|
||||
},
|
||||
member: {
|
||||
claimed_device_id: "BOBDEVICE",
|
||||
},
|
||||
room_id: roomId,
|
||||
session: {
|
||||
application: "m.call",
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { userId, deviceId, keyBase64Encoded, index } = await receivedKeyResolvers.promise;
|
||||
expect(userId).toBe("@bob:example.org");
|
||||
expect(deviceId).toBe("BOBDEVICE");
|
||||
expect(keyBase64Encoded).toBe(testEncoded);
|
||||
expect(index).toBe(testKeyIndex);
|
||||
|
||||
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(1);
|
||||
});
|
||||
|
||||
it("should not sent to ourself", async () => {
|
||||
const keyBase64Encoded = "ABCDEDF";
|
||||
const keyIndex = 2;
|
||||
await transport.sendKey(keyBase64Encoded, keyIndex, [
|
||||
mockCallMembership(
|
||||
Object.assign({}, membershipTemplate, { device_id: "MYDEVICE" }),
|
||||
roomId,
|
||||
"@alice:example.org",
|
||||
),
|
||||
]);
|
||||
|
||||
transport.start();
|
||||
|
||||
expect(mockClient.encryptAndSendToDevice).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("should warn when there is a room mismatch", () => {
|
||||
transport.start();
|
||||
|
||||
const testEncoded = "ABCDEDF";
|
||||
const testKeyIndex = 2;
|
||||
|
||||
mockClient.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", undefined, {
|
||||
keys: {
|
||||
index: testKeyIndex,
|
||||
key: testEncoded,
|
||||
},
|
||||
member: {
|
||||
claimed_device_id: "BOBDEVICE",
|
||||
},
|
||||
room_id: "!anotherroom:id",
|
||||
session: {
|
||||
application: "m.call",
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith("Malformed Event: Mismatch roomId");
|
||||
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(0);
|
||||
});
|
||||
|
||||
describe("malformed events", () => {
|
||||
const MALFORMED_EVENT = [
|
||||
{
|
||||
keys: {},
|
||||
member: { claimed_device_id: "MYDEVICE" },
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { index: 0 },
|
||||
member: { claimed_device_id: "MYDEVICE" },
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { key: "ABCDEF" },
|
||||
member: { claimed_device_id: "MYDEVICE" },
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { key: "ABCDEF", index: 2 },
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { key: "ABCDEF", index: 2 },
|
||||
member: {},
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { key: "ABCDEF", index: 2 },
|
||||
member: { claimed_device_id: "MYDEVICE" },
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { key: "ABCDEF", index: 2 },
|
||||
member: { claimed_device_id: "MYDEVICE" },
|
||||
room_id: "!wrong_room",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
];
|
||||
|
||||
test.each(MALFORMED_EVENT)("should warn on malformed event %j", (event) => {
|
||||
transport.start();
|
||||
|
||||
mockClient.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", undefined, event),
|
||||
);
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalled();
|
||||
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,7 @@ It is very specific to the MembershipManager.spec.ts file and introduces the fol
|
||||
import { TestEnvironment } from "jest-environment-jsdom";
|
||||
|
||||
import { logger as rootLogger } from "../../../src/logger";
|
||||
const logger = rootLogger.getChild("MatrixRTCSession");
|
||||
const logger = rootLogger.getChild("[MatrixRTCSession]");
|
||||
|
||||
class MemberManagerTestEnvironment extends TestEnvironment {
|
||||
handleTestEvent(event: any) {
|
||||
|
||||
@@ -14,7 +14,9 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { EventType, type MatrixClient, type MatrixEvent, type Room } from "../../../src";
|
||||
import { EventEmitter } from "stream";
|
||||
|
||||
import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent } from "../../../src";
|
||||
import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
import { secureRandomString } from "../../../src/randomstring";
|
||||
|
||||
@@ -65,19 +67,24 @@ export function makeMockClient(userId: string, deviceId: string): MockClient {
|
||||
};
|
||||
}
|
||||
|
||||
export function makeMockRoom(membershipData: MembershipData): Room {
|
||||
export function makeMockRoom(
|
||||
membershipData: MembershipData,
|
||||
): Room & { emitTimelineEvent: (event: MatrixEvent) => void } {
|
||||
const roomId = secureRandomString(8);
|
||||
// Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()`
|
||||
const roomState = makeMockRoomState(membershipData, roomId);
|
||||
const room = {
|
||||
const room = Object.assign(new EventEmitter(), {
|
||||
roomId: roomId,
|
||||
hasMembershipState: jest.fn().mockReturnValue(true),
|
||||
getLiveTimeline: jest.fn().mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue(roomState),
|
||||
}),
|
||||
getVersion: jest.fn().mockReturnValue("default"),
|
||||
} as unknown as Room;
|
||||
return room;
|
||||
}) as unknown as Room;
|
||||
return Object.assign(room, {
|
||||
emitTimelineEvent: (event: MatrixEvent) =>
|
||||
room.emit(RoomEvent.Timeline, event, room, undefined, false, {} as any),
|
||||
});
|
||||
}
|
||||
|
||||
export function makeMockRoomState(membershipData: MembershipData, roomId: string) {
|
||||
@@ -113,17 +120,36 @@ export function makeMockRoomState(membershipData: MembershipData, roomId: string
|
||||
};
|
||||
}
|
||||
|
||||
export function mockRTCEvent(membershipData: MembershipData, roomId: string, customSender?: string): MatrixEvent {
|
||||
const sender = customSender ?? "@mock:user.example";
|
||||
export function makeMockEvent(
|
||||
type: string,
|
||||
sender: string,
|
||||
roomId: string | undefined,
|
||||
content: any,
|
||||
timestamp?: number,
|
||||
): MatrixEvent {
|
||||
return {
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue(membershipData),
|
||||
getType: jest.fn().mockReturnValue(type),
|
||||
getContent: jest.fn().mockReturnValue(content),
|
||||
getSender: jest.fn().mockReturnValue(sender),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
getTs: jest.fn().mockReturnValue(timestamp ?? Date.now()),
|
||||
getRoomId: jest.fn().mockReturnValue(roomId),
|
||||
getId: jest.fn().mockReturnValue(secureRandomString(8)),
|
||||
isDecryptionFailure: jest.fn().mockReturnValue(false),
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
export function mockRTCEvent(membershipData: MembershipData, roomId: string, customSender?: string): MatrixEvent {
|
||||
const sender = customSender ?? "@mock:user.example";
|
||||
return makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData);
|
||||
}
|
||||
|
||||
export function mockCallMembership(membershipData: MembershipData, roomId: string, sender?: string): CallMembership {
|
||||
return new CallMembership(mockRTCEvent(membershipData, roomId, sender), membershipData);
|
||||
}
|
||||
|
||||
export function makeKey(id: number, key: string): { key: string; index: number } {
|
||||
return {
|
||||
key: key,
|
||||
index: id,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ limitations under the License.
|
||||
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { OidcTokenRefresher } from "../../../src";
|
||||
import { OidcTokenRefresher, TokenRefreshLogoutError } from "../../../src";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { makeDelegatedAuthConfig } from "../../test-utils/oidc";
|
||||
|
||||
@@ -130,10 +130,12 @@ describe("OidcTokenRefresher", () => {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should persist the new tokens", async () => {
|
||||
@@ -144,10 +146,12 @@ describe("OidcTokenRefresher", () => {
|
||||
|
||||
await refresher.doRefreshAccessToken("refresh-token");
|
||||
|
||||
expect(refresher.persistTokens).toHaveBeenCalledWith({
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
});
|
||||
expect(refresher.persistTokens).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should only have one inflight refresh request at once", async () => {
|
||||
@@ -189,10 +193,12 @@ describe("OidcTokenRefresher", () => {
|
||||
|
||||
// only one call to token endpoint
|
||||
expect(fetchMock).toHaveFetchedTimes(1, config.token_endpoint);
|
||||
expect(result1).toEqual({
|
||||
accessToken: "first-new-access-token",
|
||||
refreshToken: "first-new-refresh-token",
|
||||
});
|
||||
expect(result1).toEqual(
|
||||
expect.objectContaining({
|
||||
accessToken: "first-new-access-token",
|
||||
refreshToken: "first-new-refresh-token",
|
||||
}),
|
||||
);
|
||||
// same response
|
||||
expect(result1).toEqual(result2);
|
||||
|
||||
@@ -200,10 +206,12 @@ describe("OidcTokenRefresher", () => {
|
||||
const third = await refresher.doRefreshAccessToken("first-new-refresh-token");
|
||||
|
||||
// called token endpoint, got new tokens
|
||||
expect(third).toEqual({
|
||||
accessToken: "second-new-access-token",
|
||||
refreshToken: "second-new-refresh-token",
|
||||
});
|
||||
expect(third).toEqual(
|
||||
expect.objectContaining({
|
||||
accessToken: "second-new-access-token",
|
||||
refreshToken: "second-new-refresh-token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should log and rethrow when token refresh fails", async () => {
|
||||
@@ -261,10 +269,34 @@ describe("OidcTokenRefresher", () => {
|
||||
const result = await refresher.doRefreshAccessToken("first-new-refresh-token");
|
||||
|
||||
// called token endpoint, got new tokens
|
||||
expect(result).toEqual({
|
||||
accessToken: "second-new-access-token",
|
||||
refreshToken: "second-new-refresh-token",
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
accessToken: "second-new-access-token",
|
||||
refreshToken: "second-new-refresh-token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw TokenRefreshLogoutError when expired", async () => {
|
||||
fetchMock.post(
|
||||
config.token_endpoint,
|
||||
{
|
||||
status: 400,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: {
|
||||
error: "invalid_grant",
|
||||
error_description: "The provided access grant is invalid, expired, or revoked.",
|
||||
},
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await refresher.oidcClientReady;
|
||||
|
||||
await expect(refresher.doRefreshAccessToken("refresh-token")).rejects.toThrow(TokenRefreshLogoutError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
/*
|
||||
Copyright 2025 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 * as utils from "../test-utils/test-utils";
|
||||
import { type IActionsObject, PushProcessor } from "../../src/pushprocessor";
|
||||
import {
|
||||
@@ -1004,3 +1020,25 @@ describe("rewriteDefaultRules", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPushRuleGlobRegex", () => {
|
||||
it("should not confuse flags in cache", () => {
|
||||
const pattern = "Test";
|
||||
const regex1 = PushProcessor.getPushRuleGlobRegex(pattern, false, "i");
|
||||
const regex2 = PushProcessor.getPushRuleGlobRegex(pattern, false, "g");
|
||||
const regex3 = PushProcessor.getPushRuleGlobRegex(pattern, false, "i");
|
||||
|
||||
expect(regex1.flags).toBe("i");
|
||||
expect(regex2.flags).toBe("g");
|
||||
|
||||
expect(regex1).not.toEqual(regex2);
|
||||
expect(regex1).toEqual(regex3);
|
||||
});
|
||||
|
||||
it("should not include word boundary in match", () => {
|
||||
const pattern = "@room";
|
||||
const regex = PushProcessor.getPushRuleGlobRegex(pattern, true);
|
||||
const input = "Foo @room Bar";
|
||||
expect(input.split(regex)).toEqual(["Foo ", "@room", " Bar"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,6 @@ import { EventType, RelationType, UNSTABLE_MSC2716_MARKER } from "../../src/@typ
|
||||
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
|
||||
import { M_BEACON } from "../../src/@types/beacon";
|
||||
import { type MatrixClient } from "../../src/client";
|
||||
import { defer } from "../../src/utils";
|
||||
import { Room } from "../../src/models/room";
|
||||
import { KnownMembership } from "../../src/@types/membership";
|
||||
import { DecryptionFailureCode } from "../../src/crypto-api";
|
||||
@@ -1084,8 +1083,8 @@ describe("RoomState", function () {
|
||||
timestamp: Date.now() + 1,
|
||||
});
|
||||
|
||||
const deferred = defer<void>();
|
||||
mockClient.decryptEventIfNeeded.mockReturnValue(deferred.promise);
|
||||
const decryptEventResolvers = Promise.withResolvers<void>();
|
||||
mockClient.decryptEventIfNeeded.mockReturnValue(decryptEventResolvers.promise);
|
||||
|
||||
state.setStateEvents([beacon1, beacon2]);
|
||||
const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon;
|
||||
@@ -1095,7 +1094,7 @@ describe("RoomState", function () {
|
||||
// update type after '''decryption'''
|
||||
decryptingRelatedEvent.event.type = M_BEACON.name;
|
||||
decryptingRelatedEvent.event.content = locationEvent.event.content;
|
||||
deferred.resolve();
|
||||
decryptEventResolvers.resolve();
|
||||
await prom;
|
||||
|
||||
expect(addLocationsSpy).toHaveBeenCalledWith([decryptingRelatedEvent]);
|
||||
|
||||
@@ -1234,6 +1234,16 @@ describe("Room", function () {
|
||||
expect(room.name).toEqual(nameB);
|
||||
});
|
||||
|
||||
it("supports MSC4186 style heroes", () => {
|
||||
const nameB = "Bertha Bobbington";
|
||||
const nameC = "Clarissa Harissa";
|
||||
addMember(userB, KnownMembership.Join, { name: nameB });
|
||||
addMember(userC, KnownMembership.Join, { name: nameC });
|
||||
room.setMSC4186SummaryData([{ user_id: userB }, { user_id: userC }], undefined, undefined);
|
||||
room.recalculate();
|
||||
expect(room.name).toEqual(`${nameB} and ${nameC}`);
|
||||
});
|
||||
|
||||
it("reverts to empty room in case of self chat", function () {
|
||||
room.setSummary({
|
||||
"m.heroes": [],
|
||||
@@ -4276,4 +4286,9 @@ describe("Room", function () {
|
||||
expect(filteredEvents[0].getContent().body).toEqual("ev2");
|
||||
});
|
||||
});
|
||||
|
||||
it("saves and retrieves the bump stamp", () => {
|
||||
room.setBumpStamp(123456789);
|
||||
expect(room.getBumpStamp()).toEqual(123456789);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
type UIAuthCallback,
|
||||
} from "../../../src";
|
||||
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { defer } from "../../../src/utils";
|
||||
|
||||
describe("OutgoingRequestProcessor", () => {
|
||||
/** the OutgoingRequestProcessor implementation under test */
|
||||
@@ -285,13 +284,13 @@ describe("OutgoingRequestProcessor", () => {
|
||||
new RustSdkCryptoJs.DeviceId("TEST_DEVICE"),
|
||||
);
|
||||
|
||||
const authRequestResultDefer = defer<string>();
|
||||
const authRequestResultResolvers = Promise.withResolvers<string>();
|
||||
|
||||
const authRequestCalledPromise = new Promise<void>((resolve) => {
|
||||
const mockHttpApi = {
|
||||
authedRequest: async () => {
|
||||
resolve();
|
||||
return await authRequestResultDefer.promise;
|
||||
return await authRequestResultResolvers.promise;
|
||||
},
|
||||
} as unknown as Mocked<MatrixHttpApi<IHttpOpts & { onlyData: true }>>;
|
||||
processor = new OutgoingRequestProcessor(olmMachine, mockHttpApi);
|
||||
@@ -308,7 +307,7 @@ describe("OutgoingRequestProcessor", () => {
|
||||
olmMachine.close();
|
||||
|
||||
// the HTTP request completes...
|
||||
authRequestResultDefer.resolve("{}");
|
||||
authRequestResultResolvers.resolve("{}");
|
||||
|
||||
// ... and `makeOutgoingRequest` resolves satisfactorily
|
||||
await result;
|
||||
|
||||
@@ -20,7 +20,6 @@ import { type OutgoingRequest } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { type OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { OutgoingRequestsManager } from "../../../src/rust-crypto/OutgoingRequestsManager";
|
||||
import { defer, type IDeferred } from "../../../src/utils";
|
||||
import { logger } from "../../../src/logger";
|
||||
|
||||
describe("OutgoingRequestsManager", () => {
|
||||
@@ -70,11 +69,11 @@ describe("OutgoingRequestsManager", () => {
|
||||
const request2 = new RustSdkCryptoJs.KeysUploadRequest("foo2", "{}");
|
||||
const request3 = new RustSdkCryptoJs.KeysBackupRequest("foo3", "{}", "1");
|
||||
|
||||
const firstOutgoingRequestDefer = defer<OutgoingRequest[]>();
|
||||
const firstOutgoingRequestResolvers = Promise.withResolvers<OutgoingRequest[]>();
|
||||
|
||||
olmMachine.outgoingRequests
|
||||
.mockImplementationOnce(async () => {
|
||||
return firstOutgoingRequestDefer.promise;
|
||||
return firstOutgoingRequestResolvers.promise;
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
return [request3];
|
||||
@@ -87,7 +86,7 @@ describe("OutgoingRequestsManager", () => {
|
||||
const thirdRequest = manager.doProcessOutgoingRequests();
|
||||
|
||||
// let the first request complete
|
||||
firstOutgoingRequestDefer.resolve([request1, request2]);
|
||||
firstOutgoingRequestResolvers.resolve([request1, request2]);
|
||||
|
||||
await firstRequest;
|
||||
await secondRequest;
|
||||
@@ -112,10 +111,10 @@ describe("OutgoingRequestsManager", () => {
|
||||
const outgoingRequestCalledPromises: Promise<void>[] = [];
|
||||
|
||||
// deferreds which will provide the results of OlmMachine.outgoingRequests
|
||||
const outgoingRequestResultDeferreds: IDeferred<OutgoingRequest[]>[] = [];
|
||||
const outgoingRequestResultDeferreds: PromiseWithResolvers<OutgoingRequest[]>[] = [];
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const resultDeferred = defer<OutgoingRequest[]>();
|
||||
const resultDeferred = Promise.withResolvers<OutgoingRequest[]>();
|
||||
const calledPromise = new Promise<void>((resolve) => {
|
||||
olmMachine.outgoingRequests.mockImplementationOnce(() => {
|
||||
resolve();
|
||||
@@ -187,10 +186,10 @@ describe("OutgoingRequestsManager", () => {
|
||||
it("When the manager is stopped after outgoingRequests() call, do not make sever requests", async () => {
|
||||
const request1 = new RustSdkCryptoJs.KeysQueryRequest("foo", "{}");
|
||||
|
||||
const firstOutgoingRequestDefer = defer<OutgoingRequest[]>();
|
||||
const firstOutgoingRequestResolvers = Promise.withResolvers<OutgoingRequest[]>();
|
||||
|
||||
olmMachine.outgoingRequests.mockImplementationOnce(async (): Promise<OutgoingRequest[]> => {
|
||||
return firstOutgoingRequestDefer.promise;
|
||||
return firstOutgoingRequestResolvers.promise;
|
||||
});
|
||||
|
||||
const firstRequest = manager.doProcessOutgoingRequests();
|
||||
@@ -199,7 +198,7 @@ describe("OutgoingRequestsManager", () => {
|
||||
manager.stop();
|
||||
|
||||
// let the first request complete
|
||||
firstOutgoingRequestDefer.resolve([request1]);
|
||||
firstOutgoingRequestResolvers.resolve([request1]);
|
||||
|
||||
await firstRequest;
|
||||
|
||||
@@ -210,7 +209,7 @@ describe("OutgoingRequestsManager", () => {
|
||||
const request1 = new RustSdkCryptoJs.KeysQueryRequest("11", "{}");
|
||||
const request2 = new RustSdkCryptoJs.KeysUploadRequest("12", "{}");
|
||||
|
||||
const firstRequestDefer = defer<void>();
|
||||
const firstRequestResolvers = Promise.withResolvers<void>();
|
||||
|
||||
olmMachine.outgoingRequests.mockImplementationOnce(async (): Promise<OutgoingRequest[]> => {
|
||||
return [request1, request2];
|
||||
@@ -219,7 +218,7 @@ describe("OutgoingRequestsManager", () => {
|
||||
processor.makeOutgoingRequest
|
||||
.mockImplementationOnce(async () => {
|
||||
manager.stop();
|
||||
return firstRequestDefer.promise;
|
||||
return firstRequestResolvers.promise;
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
return;
|
||||
@@ -227,7 +226,7 @@ describe("OutgoingRequestsManager", () => {
|
||||
|
||||
const firstRequest = manager.doProcessOutgoingRequests();
|
||||
|
||||
firstRequestDefer.resolve();
|
||||
firstRequestResolvers.resolve();
|
||||
|
||||
await firstRequest;
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { PerSessionKeyBackupDownloader } from "../../../src/rust-crypto/PerSessionKeyBackupDownloader";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { defer, type IDeferred } from "../../../src/utils";
|
||||
import {
|
||||
type RustBackupCryptoEventMap,
|
||||
type RustBackupCryptoEvents,
|
||||
@@ -57,15 +56,15 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
let mockOlmMachine: Mocked<OlmMachine>;
|
||||
let mockBackupDecryptor: Mocked<BackupDecryptor>;
|
||||
|
||||
let expectedSession: { [roomId: string]: { [sessionId: string]: IDeferred<void> } };
|
||||
let expectedSession: { [roomId: string]: { [sessionId: string]: PromiseWithResolvers<void> } };
|
||||
|
||||
function expectSessionImported(roomId: string, sessionId: string) {
|
||||
const deferred = defer<void>();
|
||||
const sessionImportedResolvers = Promise.withResolvers<void>();
|
||||
if (!expectedSession[roomId]) {
|
||||
expectedSession[roomId] = {};
|
||||
}
|
||||
expectedSession[roomId][sessionId] = deferred;
|
||||
return deferred.promise;
|
||||
expectedSession[roomId][sessionId] = sessionImportedResolvers;
|
||||
return sessionImportedResolvers.promise;
|
||||
}
|
||||
|
||||
function mockClearSession(sessionId: string): Mocked<IMegolmSessionData> {
|
||||
@@ -115,9 +114,9 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
mockRustBackupManager.importBackedUpRoomKeys.mockImplementation(async (keys) => {
|
||||
const roomId = keys[0].room_id;
|
||||
const sessionId = keys[0].session_id;
|
||||
const deferred = expectedSession[roomId] && expectedSession[roomId][sessionId];
|
||||
if (deferred) {
|
||||
deferred.resolve();
|
||||
const sessionImportedResolvers = expectedSession[roomId] && expectedSession[roomId][sessionId];
|
||||
if (sessionImportedResolvers) {
|
||||
sessionImportedResolvers.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -143,7 +142,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
});
|
||||
|
||||
it("Should download and import a missing key from backup", async () => {
|
||||
const awaitKeyImported = defer<void>();
|
||||
const awaitKeyImported = Promise.withResolvers<void>();
|
||||
const roomId = "!roomId";
|
||||
const sessionId = "sessionId";
|
||||
const expectAPICall = new Promise<void>((resolve) => {
|
||||
@@ -168,14 +167,14 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
});
|
||||
|
||||
it("Should not hammer the backup if the key is requested repeatedly", async () => {
|
||||
const blockOnServerRequest = defer<void>();
|
||||
const blockOnServerRequest = Promise.withResolvers<void>();
|
||||
|
||||
fetchMock.get(`express:/_matrix/client/v3/room_keys/keys/!roomId/:session_id`, async (url, request) => {
|
||||
await blockOnServerRequest.promise;
|
||||
return [mockCipherKey];
|
||||
});
|
||||
|
||||
const awaitKey2Imported = defer<void>();
|
||||
const awaitKey2Imported = Promise.withResolvers<void>();
|
||||
|
||||
mockRustBackupManager.importBackedUpRoomKeys.mockImplementation(async (keys) => {
|
||||
if (keys[0].session_id === "sessionId2") {
|
||||
@@ -267,8 +266,8 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
|
||||
it("Should stop properly", async () => {
|
||||
// Simulate a call to stop while request is in flight
|
||||
const blockOnServerRequest = defer<void>();
|
||||
const requestRoomKeyCalled = defer<void>();
|
||||
const blockOnServerRequest = Promise.withResolvers<void>();
|
||||
const requestRoomKeyCalled = Promise.withResolvers<void>();
|
||||
|
||||
// Mock the request to block
|
||||
fetchMock.get(`express:/_matrix/client/v3/room_keys/keys/:roomId/:sessionId`, async (url, request) => {
|
||||
@@ -490,7 +489,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
|
||||
// @ts-ignore access to private function
|
||||
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
|
||||
const rateDeferred = defer<void>();
|
||||
const rateDeferred = Promise.withResolvers<void>();
|
||||
|
||||
keyQuerySpy.mockImplementation(
|
||||
// @ts-ignore
|
||||
@@ -544,7 +543,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
|
||||
// @ts-ignore
|
||||
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
|
||||
const errorDeferred = defer<void>();
|
||||
const errorDeferred = Promise.withResolvers<void>();
|
||||
|
||||
keyQuerySpy.mockImplementation(
|
||||
// @ts-ignore
|
||||
@@ -588,7 +587,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
});
|
||||
|
||||
it("On Unknown error on import skip the key and continue", async () => {
|
||||
const keyImported = defer<void>();
|
||||
const keyImported = Promise.withResolvers<void>();
|
||||
mockRustBackupManager.importBackedUpRoomKeys
|
||||
.mockImplementationOnce(async () => {
|
||||
throw new Error("Didn't work");
|
||||
|
||||
@@ -29,7 +29,6 @@ import { type Mocked } from "jest-mock";
|
||||
import { HistoryVisibility, type MatrixEvent, type Room, type RoomMember } from "../../../src";
|
||||
import { RoomEncryptor, toRustHistoryVisibility } from "../../../src/rust-crypto/RoomEncryptor";
|
||||
import { type KeyClaimManager } from "../../../src/rust-crypto/KeyClaimManager";
|
||||
import { defer } from "../../../src/utils";
|
||||
import { type OutgoingRequestsManager } from "../../../src/rust-crypto/OutgoingRequestsManager";
|
||||
import { KnownMembership } from "../../../src/@types/membership";
|
||||
import {
|
||||
@@ -120,8 +119,8 @@ describe("RoomEncryptor", () => {
|
||||
const defaultDevicesIsolationMode = new AllDevicesIsolationMode(false);
|
||||
|
||||
it("should ensure that there is only one shareRoomKey at a time", async () => {
|
||||
const deferredShare = defer<void>();
|
||||
const insideOlmShareRoom = defer<void>();
|
||||
const deferredShare = Promise.withResolvers<void>();
|
||||
const insideOlmShareRoom = Promise.withResolvers<void>();
|
||||
|
||||
mockOlmMachine.shareRoomKey.mockImplementationOnce(async () => {
|
||||
insideOlmShareRoom.resolve();
|
||||
@@ -149,8 +148,8 @@ describe("RoomEncryptor", () => {
|
||||
|
||||
// Regression test for https://github.com/element-hq/element-web/issues/26684
|
||||
it("Should maintain order of encryption requests", async () => {
|
||||
const firstTargetMembers = defer<void>();
|
||||
const secondTargetMembers = defer<void>();
|
||||
const firstTargetMembers = Promise.withResolvers<void>();
|
||||
const secondTargetMembers = Promise.withResolvers<void>();
|
||||
|
||||
mockOlmMachine.shareRoomKey.mockResolvedValue([]);
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`RustCrypto importing and exporting room keys should import and export keys 1`] = `
|
||||
{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"forwarding_curve25519_key_chain": [],
|
||||
"org.matrix.msc3061.shared_history": false,
|
||||
"room_id": "!room:id",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "QdgHgdpDgihgovpPzUiThXur1fbErTFh7paFvNKSgN0",
|
||||
},
|
||||
"sender_key": "WimPd2udAU/1S/+YBpPbmr9L+0H5H+BnAVHSwDxlPGc",
|
||||
"session_id": "FYOoKQSwe4d9jhTZ/LQCZFJINjPEqZ7Or4Z08reP92M",
|
||||
"session_key": "AQAAAABZ0jXQOprFfXe41tIFmAtHxflJp4O2hM/vzQQpOazOCFeWSoW5P3Z9Q+voU3eXehMwyP8/hm/Q8xLP6/PmJdy+71se/17kdFwcDGgLxBWfa4ODM9zlI4EjKbNqmiii5loJ7rBhA/XXaw80m0hfU6zTDX/KrO55J0Pt4vJ0LDa3LBWDqCkEsHuHfY4U2fy0AmRSSDYzxKmezq+GdPK3j/dj",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`RustCrypto importing and exporting room keys should import and export keys as JSON 1`] = `
|
||||
{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"forwarding_curve25519_key_chain": [],
|
||||
"org.matrix.msc3061.shared_history": false,
|
||||
"room_id": "!room:id",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "QdgHgdpDgihgovpPzUiThXur1fbErTFh7paFvNKSgN0",
|
||||
},
|
||||
"sender_key": "WimPd2udAU/1S/+YBpPbmr9L+0H5H+BnAVHSwDxlPGc",
|
||||
"session_id": "FYOoKQSwe4d9jhTZ/LQCZFJINjPEqZ7Or4Z08reP92M",
|
||||
"session_key": "AQAAAABZ0jXQOprFfXe41tIFmAtHxflJp4O2hM/vzQQpOazOCFeWSoW5P3Z9Q+voU3eXehMwyP8/hm/Q8xLP6/PmJdy+71se/17kdFwcDGgLxBWfa4ODM9zlI4EjKbNqmiii5loJ7rBhA/XXaw80m0hfU6zTDX/KrO55J0Pt4vJ0LDa3LBWDqCkEsHuHfY4U2fy0AmRSSDYzxKmezq+GdPK3j/dj",
|
||||
}
|
||||
`;
|
||||
@@ -68,7 +68,6 @@ import {
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
import { defer } from "../../../src/utils";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { OutgoingRequestsManager } from "../../../src/rust-crypto/OutgoingRequestsManager";
|
||||
import { ClientEvent, type ClientEventHandlerMap } from "../../../src/client";
|
||||
@@ -514,11 +513,8 @@ describe("RustCrypto", () => {
|
||||
expect(Array.isArray(keys)).toBeTruthy();
|
||||
expect(keys.length).toBe(someRoomKeys.length);
|
||||
|
||||
const aSession = someRoomKeys[0];
|
||||
|
||||
const exportedKey = keys.find((k) => k.session_id == aSession.session_id);
|
||||
|
||||
expect(aSession).toStrictEqual(exportedKey);
|
||||
const exportedKey = keys.find((k) => k.session_id == someRoomKeys[0].session_id);
|
||||
expect(exportedKey).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should import and export keys as JSON", async () => {
|
||||
@@ -539,11 +535,8 @@ describe("RustCrypto", () => {
|
||||
expect(Array.isArray(keys)).toBeTruthy();
|
||||
expect(keys.length).toBe(someRoomKeys.length);
|
||||
|
||||
const aSession = someRoomKeys[0];
|
||||
|
||||
const exportedKey = keys.find((k) => k.session_id == aSession.session_id);
|
||||
|
||||
expect(aSession).toStrictEqual(exportedKey);
|
||||
const exportedKey = keys.find((k) => k.session_id == someRoomKeys[0].session_id);
|
||||
expect(exportedKey).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -934,8 +927,8 @@ describe("RustCrypto", () => {
|
||||
|
||||
it("should go round the loop again if another sync completes while the first `outgoingRequests` is running", async () => {
|
||||
// the first call to `outgoingMessages` will return a promise which blocks for a while
|
||||
const firstOutgoingRequestsDefer = defer<Array<any>>();
|
||||
mocked(olmMachine.outgoingRequests).mockReturnValueOnce(firstOutgoingRequestsDefer.promise);
|
||||
const firstOutgoingRequestsResolvers = Promise.withResolvers<Array<any>>();
|
||||
mocked(olmMachine.outgoingRequests).mockReturnValueOnce(firstOutgoingRequestsResolvers.promise);
|
||||
|
||||
// the second will return a KeysQueryRequest.
|
||||
const testReq = new KeysQueryRequest("1234", "{}");
|
||||
@@ -952,7 +945,7 @@ describe("RustCrypto", () => {
|
||||
|
||||
// the first call now completes, *with an empty result*, which would normally cause us to exit the loop, but
|
||||
// we should have a second call queued. It should trigger a call to `makeOutgoingRequest`.
|
||||
firstOutgoingRequestsDefer.resolve([]);
|
||||
firstOutgoingRequestsResolvers.resolve([]);
|
||||
await awaitCallToMakeOutgoingRequest();
|
||||
expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -2255,10 +2248,10 @@ describe("RustCrypto", () => {
|
||||
let secretStorage: ServerSideSecretStorage;
|
||||
beforeEach(() => {
|
||||
secretStorage = {
|
||||
setDefaultKeyId: jest.fn(),
|
||||
setDefaultKeyId: jest.fn().mockResolvedValue(undefined),
|
||||
hasKey: jest.fn().mockResolvedValue(false),
|
||||
getKey: jest.fn().mockResolvedValue(null),
|
||||
store: jest.fn(),
|
||||
store: jest.fn().mockResolvedValue(undefined),
|
||||
getDefaultKeyId: jest.fn().mockResolvedValue("defaultKeyId"),
|
||||
} as unknown as ServerSideSecretStorage;
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { type OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { type IDeviceKeys } from "../../../src/@types/crypto";
|
||||
import { EventType, MatrixEvent, MsgType } from "../../../src";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("VerificationRequest", () => {
|
||||
describe("pending", () => {
|
||||
@@ -147,6 +148,7 @@ describe("VerificationRequest", () => {
|
||||
methods,
|
||||
);
|
||||
const aliceVerificationRequest = new RustVerificationRequest(
|
||||
logger,
|
||||
aliceOlmMachine,
|
||||
innerVerificationRequest,
|
||||
aliceRequestLoop as unknown as OutgoingRequestProcessor,
|
||||
@@ -174,6 +176,7 @@ describe("VerificationRequest", () => {
|
||||
"$m.key.verification.request",
|
||||
)!;
|
||||
const bobVerificationRequest = new RustVerificationRequest(
|
||||
logger,
|
||||
bobOlmMachine,
|
||||
bobInnerVerificationRequest,
|
||||
bobRequestLoop as unknown as OutgoingRequestProcessor,
|
||||
@@ -278,6 +281,7 @@ describe("VerificationRequest", () => {
|
||||
methods,
|
||||
);
|
||||
const aliceVerificationRequest = new RustVerificationRequest(
|
||||
logger,
|
||||
aliceOlmMachine,
|
||||
innerVerificationRequest,
|
||||
aliceRequestLoop as unknown as OutgoingRequestProcessor,
|
||||
@@ -305,6 +309,7 @@ describe("VerificationRequest", () => {
|
||||
"$m.key.verification.request",
|
||||
)!;
|
||||
const bobVerificationRequest = new RustVerificationRequest(
|
||||
logger,
|
||||
bobOlmMachine,
|
||||
bobInnerVerificationRequest,
|
||||
bobRequestLoop as unknown as OutgoingRequestProcessor,
|
||||
@@ -392,6 +397,7 @@ describe("VerificationRequest", () => {
|
||||
methods,
|
||||
);
|
||||
const aliceVerificationRequest = new RustVerificationRequest(
|
||||
logger,
|
||||
aliceOlmMachine,
|
||||
innerVerificationRequest,
|
||||
aliceRequestLoop as unknown as OutgoingRequestProcessor,
|
||||
@@ -419,6 +425,7 @@ describe("VerificationRequest", () => {
|
||||
"$m.key.verification.request",
|
||||
)!;
|
||||
const bobVerificationRequest = new RustVerificationRequest(
|
||||
logger,
|
||||
bobOlmMachine,
|
||||
bobInnerVerificationRequest,
|
||||
bobRequestLoop as unknown as OutgoingRequestProcessor,
|
||||
@@ -496,7 +503,7 @@ function makeTestRequest(
|
||||
inner ??= makeMockedInner();
|
||||
olmMachine ??= {} as RustSdkCryptoJs.OlmMachine;
|
||||
outgoingRequestProcessor ??= {} as OutgoingRequestProcessor;
|
||||
return new RustVerificationRequest(olmMachine, inner, outgoingRequestProcessor, []);
|
||||
return new RustVerificationRequest(logger, olmMachine, inner, outgoingRequestProcessor, []);
|
||||
}
|
||||
|
||||
/** Mock up a rust-side VerificationRequest */
|
||||
|
||||
+10
-11
@@ -1,7 +1,6 @@
|
||||
// This file had a function whose name is all caps, which displeases eslint
|
||||
/* eslint new-cap: "off" */
|
||||
|
||||
import { defer, type IDeferred } from "../../src/utils";
|
||||
import { MatrixError } from "../../src/http-api";
|
||||
import { MatrixScheduler } from "../../src/scheduler";
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
@@ -14,7 +13,7 @@ describe("MatrixScheduler", function () {
|
||||
let scheduler: MatrixScheduler<Record<string, boolean>>;
|
||||
let retryFn: ((event: MatrixEvent | null, attempt: number, err: MatrixError) => number) | null;
|
||||
let queueFn: ((event: MatrixEvent) => string | null) | null;
|
||||
let deferred: IDeferred<Record<string, boolean>>;
|
||||
let deferred: PromiseWithResolvers<Record<string, boolean>>;
|
||||
const roomId = "!foo:bar";
|
||||
const eventA = utils.mkMessage({
|
||||
user: "@alice:bar",
|
||||
@@ -44,7 +43,7 @@ describe("MatrixScheduler", function () {
|
||||
);
|
||||
retryFn = null;
|
||||
queueFn = null;
|
||||
deferred = defer();
|
||||
deferred = Promise.withResolvers();
|
||||
});
|
||||
|
||||
it("should process events in a queue in a FIFO manner", async function () {
|
||||
@@ -54,8 +53,8 @@ describe("MatrixScheduler", function () {
|
||||
queueFn = function () {
|
||||
return "one_big_queue";
|
||||
};
|
||||
const deferA = defer<Record<string, boolean>>();
|
||||
const deferB = defer<Record<string, boolean>>();
|
||||
const deferA = Promise.withResolvers<Record<string, boolean>>();
|
||||
const deferB = Promise.withResolvers<Record<string, boolean>>();
|
||||
let yieldedA = false;
|
||||
scheduler.setProcessFunction(function (event) {
|
||||
if (yieldedA) {
|
||||
@@ -79,9 +78,9 @@ describe("MatrixScheduler", function () {
|
||||
|
||||
it("should invoke the retryFn on failure and wait the amount of time specified", async function () {
|
||||
const waitTimeMs = 1500;
|
||||
const retryDefer = defer();
|
||||
const retryResolvers = Promise.withResolvers<void>();
|
||||
retryFn = function () {
|
||||
retryDefer.resolve();
|
||||
retryResolvers.resolve();
|
||||
return waitTimeMs;
|
||||
};
|
||||
queueFn = function () {
|
||||
@@ -109,7 +108,7 @@ describe("MatrixScheduler", function () {
|
||||
await Promise.resolve();
|
||||
expect(procCount).toEqual(1);
|
||||
deferred.reject({});
|
||||
await retryDefer.promise;
|
||||
await retryResolvers.promise;
|
||||
expect(procCount).toEqual(1);
|
||||
jest.advanceTimersByTime(waitTimeMs);
|
||||
await Promise.resolve();
|
||||
@@ -127,8 +126,8 @@ describe("MatrixScheduler", function () {
|
||||
return "yep";
|
||||
};
|
||||
|
||||
const deferA = defer<Record<string, boolean>>();
|
||||
const deferB = defer<Record<string, boolean>>();
|
||||
const deferA = Promise.withResolvers<Record<string, boolean>>();
|
||||
const deferB = Promise.withResolvers<Record<string, boolean>>();
|
||||
let procCount = 0;
|
||||
scheduler.setProcessFunction(function (ev) {
|
||||
procCount += 1;
|
||||
@@ -180,7 +179,7 @@ describe("MatrixScheduler", function () {
|
||||
};
|
||||
|
||||
const expectOrder = [eventA.getId(), eventB.getId(), eventD.getId()];
|
||||
const deferA = defer<Record<string, boolean>>();
|
||||
const deferA = Promise.withResolvers<Record<string, boolean>>();
|
||||
const allExpectedEventsSeenInOrderPromise = new Promise((resolve) => {
|
||||
scheduler.setProcessFunction(function (event) {
|
||||
const id = expectOrder.shift();
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
import { secureRandomString } from "../../src/randomstring";
|
||||
import { type SecretInfo } from "../../src/secret-storage.ts";
|
||||
import { type AccountDataEvents, ClientEvent, MatrixEvent, TypedEventEmitter } from "../../src";
|
||||
import { defer, type IDeferred } from "../../src/utils";
|
||||
|
||||
declare module "../../src/@types/event" {
|
||||
interface SecretStorageAccountDataEvents {
|
||||
@@ -289,10 +288,10 @@ describe("ServerSideSecretStorageImpl", function () {
|
||||
describe("setDefaultKeyId", function () {
|
||||
let secretStorage: ServerSideSecretStorage;
|
||||
let accountDataAdapter: Mocked<AccountDataClient>;
|
||||
let accountDataPromise: IDeferred<void>;
|
||||
let accountDataPromise: PromiseWithResolvers<void>;
|
||||
beforeEach(() => {
|
||||
accountDataAdapter = mockAccountDataClient();
|
||||
accountDataPromise = defer();
|
||||
accountDataPromise = Promise.withResolvers();
|
||||
accountDataAdapter.setAccountData.mockImplementation(() => {
|
||||
accountDataPromise.resolve();
|
||||
return Promise.resolve({});
|
||||
|
||||
@@ -18,7 +18,6 @@ import "fake-indexeddb/auto";
|
||||
|
||||
import { type LocalIndexedDBStoreBackend } from "../../../src/store/indexeddb-local-backend";
|
||||
import { IndexedDBStoreWorker } from "../../../src/store/indexeddb-store-worker";
|
||||
import { defer } from "../../../src/utils";
|
||||
|
||||
function setupWorker(worker: IndexedDBStoreWorker): void {
|
||||
worker.onMessage({ data: { command: "setupWorker", args: [] } } as any);
|
||||
@@ -27,16 +26,16 @@ function setupWorker(worker: IndexedDBStoreWorker): void {
|
||||
|
||||
describe("IndexedDBStore Worker", () => {
|
||||
it("should pass 'closed' event via postMessage", async () => {
|
||||
const deferred = defer<void>();
|
||||
const postMessageSuccessResolvers = Promise.withResolvers<void>();
|
||||
const postMessage = jest.fn().mockImplementation(({ seq, command }) => {
|
||||
if (seq === 1 && command === "cmd_success") {
|
||||
deferred.resolve();
|
||||
postMessageSuccessResolvers.resolve();
|
||||
}
|
||||
});
|
||||
const worker = new IndexedDBStoreWorker(postMessage);
|
||||
setupWorker(worker);
|
||||
|
||||
await deferred.promise;
|
||||
await postMessageSuccessResolvers.promise;
|
||||
|
||||
// @ts-ignore - private field access
|
||||
(worker.backend as LocalIndexedDBStoreBackend).db!.onclose!({} as Event);
|
||||
|
||||
@@ -21,7 +21,6 @@ import { IDBFactory } from "fake-indexeddb";
|
||||
import { IndexedDBStore, type IStateEventWithRoomId, MemoryStore, User, UserEvent } from "../../../src";
|
||||
import { emitPromise } from "../../test-utils/test-utils";
|
||||
import { type LocalIndexedDBStoreBackend } from "../../../src/store/indexeddb-local-backend";
|
||||
import { defer } from "../../../src/utils";
|
||||
|
||||
describe("IndexedDBStore", () => {
|
||||
afterEach(() => {
|
||||
@@ -80,7 +79,7 @@ describe("IndexedDBStore", () => {
|
||||
it("Should load presence events on startup", async () => {
|
||||
// 1. Create idb database
|
||||
const indexedDB = new IDBFactory();
|
||||
const setupDefer = defer<Event>();
|
||||
const setupResolvers = Promise.withResolvers<Event>();
|
||||
const req = indexedDB.open("matrix-js-sdk:db3", 1);
|
||||
let db: IDBDatabase;
|
||||
req.onupgradeneeded = () => {
|
||||
@@ -89,11 +88,11 @@ describe("IndexedDBStore", () => {
|
||||
db.createObjectStore("accountData", { keyPath: ["type"] });
|
||||
db.createObjectStore("sync", { keyPath: ["clobber"] });
|
||||
};
|
||||
req.onsuccess = setupDefer.resolve;
|
||||
await setupDefer.promise;
|
||||
req.onsuccess = setupResolvers.resolve;
|
||||
await setupResolvers.promise;
|
||||
|
||||
// 2. Fill in user presence data
|
||||
const writeDefer = defer<Event>();
|
||||
const writeResolvers = Promise.withResolvers<Event>();
|
||||
const transaction = db!.transaction(["users"], "readwrite");
|
||||
const objectStore = transaction.objectStore("users");
|
||||
const request = objectStore.put({
|
||||
@@ -106,8 +105,8 @@ describe("IndexedDBStore", () => {
|
||||
type: "m.presence",
|
||||
},
|
||||
});
|
||||
request.onsuccess = writeDefer.resolve;
|
||||
await writeDefer.promise;
|
||||
request.onsuccess = writeResolvers.resolve;
|
||||
await writeResolvers.promise;
|
||||
|
||||
// 3. Close database
|
||||
req.result.close();
|
||||
@@ -201,7 +200,7 @@ describe("IndexedDBStore", () => {
|
||||
});
|
||||
|
||||
it("should resolve isNewlyCreated to false if database existed already but needs upgrade", async () => {
|
||||
const deferred = defer<Event>();
|
||||
const requestSuccessResolvers = Promise.withResolvers<Event>();
|
||||
// seed db3 to Version 1 so it forces a migration
|
||||
const req = indexedDB.open("matrix-js-sdk:db3", 1);
|
||||
req.onupgradeneeded = () => {
|
||||
@@ -210,8 +209,8 @@ describe("IndexedDBStore", () => {
|
||||
db.createObjectStore("accountData", { keyPath: ["type"] });
|
||||
db.createObjectStore("sync", { keyPath: ["clobber"] });
|
||||
};
|
||||
req.onsuccess = deferred.resolve;
|
||||
await deferred.promise;
|
||||
req.onsuccess = requestSuccessResolvers.resolve;
|
||||
await requestSuccessResolvers.promise;
|
||||
req.result.close();
|
||||
|
||||
const store = new IndexedDBStore({
|
||||
@@ -232,20 +231,20 @@ describe("IndexedDBStore", () => {
|
||||
});
|
||||
await store.startup();
|
||||
|
||||
const deferred = defer<void>();
|
||||
store.on("closed", deferred.resolve);
|
||||
const storeClosedResolvers = Promise.withResolvers<void>();
|
||||
store.on("closed", storeClosedResolvers.resolve);
|
||||
|
||||
// @ts-ignore - private field access
|
||||
(store.backend as LocalIndexedDBStoreBackend).db!.onclose!({} as Event);
|
||||
await deferred.promise;
|
||||
await storeClosedResolvers.promise;
|
||||
});
|
||||
|
||||
it("should use remote backend if workerFactory passed", async () => {
|
||||
const deferred = defer<void>();
|
||||
const workerPostMessageResolvers = Promise.withResolvers<void>();
|
||||
class MockWorker {
|
||||
postMessage(data: any) {
|
||||
if (data.command === "setupWorker") {
|
||||
deferred.resolve();
|
||||
workerPostMessageResolvers.resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,7 +256,7 @@ describe("IndexedDBStore", () => {
|
||||
workerFactory: () => new MockWorker() as Worker,
|
||||
});
|
||||
store.startup();
|
||||
await deferred.promise;
|
||||
await workerPostMessageResolvers.promise;
|
||||
});
|
||||
|
||||
it("remote worker should pass closed event", async () => {
|
||||
@@ -273,10 +272,10 @@ describe("IndexedDBStore", () => {
|
||||
});
|
||||
store.startup();
|
||||
|
||||
const deferred = defer<void>();
|
||||
store.on("closed", deferred.resolve);
|
||||
const storeClosedResolvers = Promise.withResolvers<void>();
|
||||
store.on("closed", storeClosedResolvers.resolve);
|
||||
(worker as any).onmessage({ data: { command: "closed" } });
|
||||
await deferred.promise;
|
||||
await storeClosedResolvers.promise;
|
||||
});
|
||||
|
||||
it("remote worker should pass command failures", async () => {
|
||||
|
||||
Vendored
+7
-31
@@ -14,9 +14,6 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// this is needed to tell TS about globalThis.Olm
|
||||
import "@matrix-org/olm";
|
||||
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
@@ -34,39 +31,18 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
interface MediaDevices {
|
||||
// This is experimental and types don't know about it yet
|
||||
// https://github.com/microsoft/TypeScript/issues/33232
|
||||
getDisplayMedia(constraints: MediaStreamConstraints | DesktopCapturerConstraints): Promise<MediaStream>;
|
||||
getUserMedia(constraints: MediaStreamConstraints | DesktopCapturerConstraints): Promise<MediaStream>;
|
||||
// Chrome-specific getUserMedia constraints
|
||||
interface MediaTrackConstraints {
|
||||
mandatory?: {
|
||||
chromeMediaSource: string;
|
||||
chromeMediaSourceId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DesktopCapturerConstraints {
|
||||
audio:
|
||||
| boolean
|
||||
| {
|
||||
mandatory: {
|
||||
chromeMediaSource: string;
|
||||
chromeMediaSourceId: string;
|
||||
};
|
||||
};
|
||||
video:
|
||||
| boolean
|
||||
| {
|
||||
mandatory: {
|
||||
chromeMediaSource: string;
|
||||
chromeMediaSourceId: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface DummyInterfaceWeShouldntBeUsingThis {}
|
||||
|
||||
interface Navigator {
|
||||
// We check for the webkit-prefixed getUserMedia to detect if we're
|
||||
// on webkit: we should check if we still need to do this
|
||||
webkitGetUserMedia?: DummyInterfaceWeShouldntBeUsingThis;
|
||||
webkitGetUserMedia?: unknown;
|
||||
}
|
||||
|
||||
export interface Uint8ArrayToBase64Options {
|
||||
|
||||
+3
-6
@@ -13,7 +13,7 @@ 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 { UnstableValue } from "../NamespacedValue.ts";
|
||||
import { NamespacedValue } from "../NamespacedValue.ts";
|
||||
import { type IMessageRendering } from "./extensible_events.ts";
|
||||
|
||||
/**
|
||||
@@ -42,20 +42,17 @@ import { type IMessageRendering } from "./extensible_events.ts";
|
||||
/**
|
||||
* The event type for an m.topic event (in content)
|
||||
*/
|
||||
export const M_TOPIC = new UnstableValue("m.topic", "org.matrix.msc3765.topic");
|
||||
export const M_TOPIC = new NamespacedValue("m.topic");
|
||||
|
||||
/**
|
||||
* The event content for an m.topic event (in content)
|
||||
*/
|
||||
export type MTopicContent = IMessageRendering[];
|
||||
|
||||
type MTopicStable = { [M_TOPIC.altName]: MTopicContent };
|
||||
type MTopicUnstable = { [M_TOPIC.name]: MTopicContent };
|
||||
|
||||
/**
|
||||
* The event definition for an m.topic event (in content)
|
||||
*/
|
||||
export type MTopicEvent = (MTopicStable & MTopicUnstable) | MTopicStable | MTopicUnstable;
|
||||
export type MTopicEvent = { "m.topic": MTopicContent };
|
||||
|
||||
/**
|
||||
* The event content for an m.room.topic event
|
||||
|
||||
@@ -462,7 +462,7 @@ export class AutoDiscovery {
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status !== 200) {
|
||||
return {
|
||||
raw: {},
|
||||
action: AutoDiscoveryAction.FAIL_PROMPT,
|
||||
|
||||
+45
-15
@@ -52,7 +52,7 @@ import {
|
||||
type GroupCallEventHandlerEventHandlerMap,
|
||||
} from "./webrtc/groupCallEventHandler.ts";
|
||||
import * as utils from "./utils.ts";
|
||||
import { deepCompare, defer, noUnsafeEventProps, type QueryDict, replaceParam, safeSet, sleep } from "./utils.ts";
|
||||
import { deepCompare, noUnsafeEventProps, type QueryDict, replaceParam, safeSet, sleep } from "./utils.ts";
|
||||
import { Direction, EventTimeline } from "./models/event-timeline.ts";
|
||||
import { type IActionsObject, PushProcessor } from "./pushprocessor.ts";
|
||||
import { AutoDiscovery, type AutoDiscoveryAction } from "./autodiscovery.ts";
|
||||
@@ -207,7 +207,7 @@ import {
|
||||
import { M_BEACON_INFO, type MBeaconInfoEventContent } from "./@types/beacon.ts";
|
||||
import { NamespacedValue, UnstableValue } from "./NamespacedValue.ts";
|
||||
import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue.ts";
|
||||
import { type ToDeviceBatch } from "./models/ToDeviceMessage.ts";
|
||||
import { type ToDeviceBatch, type ToDevicePayload } from "./models/ToDeviceMessage.ts";
|
||||
import { IgnoredInvites } from "./models/invites-ignorer.ts";
|
||||
import { type UIARequest } from "./@types/uia.ts";
|
||||
import { type LocalNotificationSettings } from "./@types/local_notifications.ts";
|
||||
@@ -1234,7 +1234,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
public canSupport = new Map<Feature, ServerSupport>();
|
||||
|
||||
// The pushprocessor caches useful things, so keep one and re-use it
|
||||
protected pushProcessor = new PushProcessor(this);
|
||||
public readonly pushProcessor = new PushProcessor(this);
|
||||
|
||||
// Promise to a response of the server's /versions response
|
||||
// TODO: This should expire: https://github.com/matrix-org/matrix-js-sdk/issues/1020
|
||||
@@ -2069,11 +2069,18 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
/**
|
||||
* Get the config for the media repository.
|
||||
*
|
||||
* @param useAuthenticatedMedia - If true, the caller supports authenticated
|
||||
* media and wants an authentication-required URL. Note that server support
|
||||
* for authenticated media will *not* be checked - it is the caller's responsibility
|
||||
* to do so before calling this function.
|
||||
*
|
||||
* @returns Promise which resolves with an object containing the config.
|
||||
*/
|
||||
public getMediaConfig(): Promise<IMediaConfig> {
|
||||
return this.http.authedRequest(Method.Get, "/config", undefined, undefined, {
|
||||
prefix: MediaPrefix.V3,
|
||||
public getMediaConfig(useAuthenticatedMedia: boolean = false): Promise<IMediaConfig> {
|
||||
const path = useAuthenticatedMedia ? "/media/config" : "/config";
|
||||
return this.http.authedRequest(Method.Get, path, undefined, undefined, {
|
||||
prefix: useAuthenticatedMedia ? ClientPrefix.V1 : MediaPrefix.V3,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2184,7 +2191,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
if (existingData && deepCompare(existingData.event.content, content)) return {};
|
||||
|
||||
// Create a promise which will resolve when the update is received
|
||||
const updatedDefer = defer<void>();
|
||||
const updatedResolvers = Promise.withResolvers<void>();
|
||||
function accountDataListener(event: MatrixEvent): void {
|
||||
// Note that we cannot safely check that the content matches what we expected, because there is a race:
|
||||
// * We set the new content
|
||||
@@ -2196,13 +2203,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
//
|
||||
// Anyway, what we *shouldn't* do is get stuck in a loop. I think the best we can do is check that the event
|
||||
// type matches.
|
||||
if (event.getType() === eventType) updatedDefer.resolve();
|
||||
if (event.getType() === eventType) updatedResolvers.resolve();
|
||||
}
|
||||
this.addListener(ClientEvent.AccountData, accountDataListener);
|
||||
|
||||
try {
|
||||
const result = await retryNetworkOperation(5, () => this.setAccountDataRaw(eventType, content));
|
||||
await updatedDefer.promise;
|
||||
await updatedResolvers.promise;
|
||||
return result;
|
||||
} finally {
|
||||
this.removeListener(ClientEvent.AccountData, accountDataListener);
|
||||
@@ -5166,24 +5173,24 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
} else if (!hasDontNotifyRule) {
|
||||
// Remove the existing one before setting the mute push rule
|
||||
// This is a workaround to SYN-590 (Push rule update fails)
|
||||
const deferred = utils.defer();
|
||||
const doneResolvers = Promise.withResolvers<void>();
|
||||
this.deletePushRule(scope, PushRuleKind.RoomSpecific, roomPushRule.rule_id)
|
||||
.then(() => {
|
||||
this.addPushRule(scope, PushRuleKind.RoomSpecific, roomId, {
|
||||
actions: [PushRuleActionName.DontNotify],
|
||||
})
|
||||
.then(() => {
|
||||
deferred.resolve();
|
||||
doneResolvers.resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
deferred.reject(err);
|
||||
doneResolvers.reject(err);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
deferred.reject(err);
|
||||
doneResolvers.reject(err);
|
||||
});
|
||||
|
||||
promise = deferred.promise;
|
||||
promise = doneResolvers.promise;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7935,6 +7942,29 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return this.http.authedRequest(Method.Put, path, undefined, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* This will encrypt the payload for all devices in the list and will queue it.
|
||||
* The type of the sent to-device message will be `m.room.encrypted`.
|
||||
* @param eventType - The type of event to send
|
||||
* @param devices - The list of devices to send the event to.
|
||||
* @param payload - The payload to send. This will be encrypted.
|
||||
* @returns Promise which resolves once queued there is no error feedback when sending fails.
|
||||
*/
|
||||
public async encryptAndSendToDevice(
|
||||
eventType: string,
|
||||
devices: { userId: string; deviceId: string }[],
|
||||
payload: ToDevicePayload,
|
||||
): Promise<void> {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("Cannot encrypt to device event, your client does not support encryption.");
|
||||
}
|
||||
const batch = await this.cryptoBackend.encryptToDeviceMessages(eventType, devices, payload);
|
||||
|
||||
// TODO The batch mechanism removes all possibility to get error feedbacks..
|
||||
// We might want instead to do the API call directly and pass the errors back.
|
||||
await this.queueToDevice(batch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends events directly to specific devices using Matrix's to-device
|
||||
* messaging system. The batch will be split up into appropriately sized
|
||||
@@ -8194,7 +8224,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
const clientTimeout = req.clientTimeout;
|
||||
delete req.clientTimeout;
|
||||
return this.http.authedRequest<MSC3575SlidingSyncResponse>(Method.Post, "/sync", qps, req, {
|
||||
prefix: "/_matrix/client/unstable/org.matrix.msc3575",
|
||||
prefix: "/_matrix/client/unstable/org.matrix.simplified_msc3575",
|
||||
baseUrl: proxyBaseUrl,
|
||||
localTimeoutMs: clientTimeout,
|
||||
abortSignal,
|
||||
|
||||
@@ -138,6 +138,15 @@ export interface SyncCryptoCallbacks {
|
||||
* @param syncState - information about the completed sync.
|
||||
*/
|
||||
onSyncCompleted(syncState: OnSyncCompletedData): void;
|
||||
|
||||
/**
|
||||
* Mark all tracked users' device lists as dirty.
|
||||
*
|
||||
* This method will cause additional `/keys/query` requests on the server, so should be used only
|
||||
* when the client has desynced tracking device list deltas from the server.
|
||||
* In MSC4186: Simplified Sliding Sync, this can happen when the server expires the connection.
|
||||
*/
|
||||
markAllTrackedUsersAsDirty(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -189,12 +189,14 @@ export type MakeTopicContent = (topic: string | null | undefined, htmlTopic?: st
|
||||
|
||||
export const makeTopicContent: MakeTopicContent = (topic, htmlTopic) => {
|
||||
const renderings = [];
|
||||
if (isProvided(topic)) {
|
||||
renderings.push({ body: topic, mimetype: "text/plain" });
|
||||
}
|
||||
// Put HTML first because clients will render the first type in
|
||||
// the array that they understand
|
||||
if (isProvided(htmlTopic)) {
|
||||
renderings.push({ body: htmlTopic, mimetype: "text/html" });
|
||||
}
|
||||
if (isProvided(topic)) {
|
||||
renderings.push({ body: topic, mimetype: "text/plain" });
|
||||
}
|
||||
return { topic, [M_TOPIC.name]: renderings };
|
||||
};
|
||||
|
||||
|
||||
@@ -204,9 +204,12 @@ export class IndexedDBCryptoStore implements CryptoStore {
|
||||
);
|
||||
|
||||
try {
|
||||
if (!(globalThis.localStorage instanceof Storage)) {
|
||||
throw new Error("localStorage is not available");
|
||||
}
|
||||
return new LocalStorageCryptoStore(globalThis.localStorage);
|
||||
} catch (e) {
|
||||
logger.warn(`unable to open localStorage: falling back to in-memory store: ${e}`);
|
||||
logger.warn(`Unable to open localStorage: falling back to in-memory store: ${e}`);
|
||||
return new MemoryCryptoStore();
|
||||
}
|
||||
})
|
||||
|
||||
+19
-23
@@ -62,17 +62,6 @@ interface IStateEventRequest {
|
||||
stateKey?: string;
|
||||
}
|
||||
|
||||
export interface OlmDevice {
|
||||
/**
|
||||
* The user ID of the device owner.
|
||||
*/
|
||||
userId: string;
|
||||
/**
|
||||
* The device ID of the device.
|
||||
*/
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
export interface ICapabilities {
|
||||
/**
|
||||
* Event types that this client expects to send.
|
||||
@@ -464,6 +453,25 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* by {@link MatrixClient.encryptAndSendToDevice}.
|
||||
*/
|
||||
public async encryptAndSendToDevice(
|
||||
eventType: string,
|
||||
devices: { userId: string; deviceId: string }[],
|
||||
payload: ToDevicePayload,
|
||||
): Promise<void> {
|
||||
// map: user Id → device Id → payload
|
||||
const contentMap: MapWithDefault<string, Map<string, ToDevicePayload>> = new MapWithDefault(() => new Map());
|
||||
for (const { userId, deviceId } of devices) {
|
||||
contentMap.getOrCreate(userId).set(deviceId, payload);
|
||||
}
|
||||
|
||||
await this.widgetApi
|
||||
.sendToDevice(eventType, true, recursiveMapToObject(contentMap))
|
||||
.catch(timeoutToConnectionError);
|
||||
}
|
||||
|
||||
public async sendToDevice(eventType: string, contentMap: SendToDeviceContentMap): Promise<EmptyObject> {
|
||||
await this.widgetApi
|
||||
.sendToDevice(eventType, false, recursiveMapToObject(contentMap))
|
||||
@@ -495,18 +503,6 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
.catch(timeoutToConnectionError);
|
||||
}
|
||||
|
||||
public async encryptAndSendToDevices(userDeviceInfoArr: OlmDevice[], payload: object): Promise<void> {
|
||||
// map: user Id → device Id → payload
|
||||
const contentMap: MapWithDefault<string, Map<string, object>> = new MapWithDefault(() => new Map());
|
||||
for (const { userId, deviceId } of userDeviceInfoArr) {
|
||||
contentMap.getOrCreate(userId).set(deviceId, payload);
|
||||
}
|
||||
|
||||
await this.widgetApi
|
||||
.sendToDevice((payload as { type: string }).type, true, recursiveMapToObject(contentMap))
|
||||
.catch(timeoutToConnectionError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an event to a specific list of devices via the widget API. Optionally encrypts the event.
|
||||
*
|
||||
|
||||
@@ -212,3 +212,17 @@ export class TokenRefreshError extends Error {
|
||||
return "TokenRefreshError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a TokenRefreshError. This indicates that a request failed due to the token being expired,
|
||||
* and attempting to refresh said token failed in a way indicative of token invalidation.
|
||||
*/
|
||||
export class TokenRefreshLogoutError extends Error {
|
||||
public constructor(cause?: Error) {
|
||||
super(cause?.message ?? "");
|
||||
}
|
||||
|
||||
public get name(): string {
|
||||
return "TokenRefreshLogoutError";
|
||||
}
|
||||
}
|
||||
|
||||
+33
-66
@@ -18,9 +18,7 @@ limitations under the License.
|
||||
* This is an internal module. See {@link MatrixHttpApi} for the public class.
|
||||
*/
|
||||
|
||||
import { ErrorResponse as OidcAuthError } from "oidc-client-ts";
|
||||
|
||||
import { checkObjectHasKeys, encodeParams } from "../utils.ts";
|
||||
import { checkObjectHasKeys, deepCopy, encodeParams } from "../utils.ts";
|
||||
import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { Method } from "./method.ts";
|
||||
import { ConnectionError, MatrixError, TokenRefreshError } from "./errors.ts";
|
||||
@@ -33,7 +31,7 @@ import {
|
||||
} from "./interface.ts";
|
||||
import { anySignal, parseErrorResponse, timeoutSignal } from "./utils.ts";
|
||||
import { type QueryDict } from "../utils.ts";
|
||||
import { singleAsyncExecution } from "../utils/decorators.ts";
|
||||
import { TokenRefresher, TokenRefreshOutcome } from "./refresh.ts";
|
||||
|
||||
interface TypedResponse<T> extends Response {
|
||||
json(): Promise<T>;
|
||||
@@ -45,14 +43,9 @@ export type ResponseType<T, O extends IHttpOpts> = O extends { json: false }
|
||||
? T
|
||||
: TypedResponse<T>;
|
||||
|
||||
const enum TokenRefreshOutcome {
|
||||
Success = "success",
|
||||
Failure = "failure",
|
||||
Logout = "logout",
|
||||
}
|
||||
|
||||
export class FetchHttpApi<O extends IHttpOpts> {
|
||||
private abortController = new AbortController();
|
||||
private readonly tokenRefresher: TokenRefresher;
|
||||
|
||||
public constructor(
|
||||
private eventEmitter: TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>,
|
||||
@@ -61,6 +54,8 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
checkObjectHasKeys(opts, ["baseUrl", "prefix"]);
|
||||
opts.onlyData = !!opts.onlyData;
|
||||
opts.useAuthorizationHeader = opts.useAuthorizationHeader ?? true;
|
||||
|
||||
this.tokenRefresher = new TokenRefresher(opts);
|
||||
}
|
||||
|
||||
public abort(): void {
|
||||
@@ -115,12 +110,6 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
return this.requestOtherUrl(method, fullUri, body, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise used to block authenticated requests during a token refresh to avoid repeated expected errors.
|
||||
* @private
|
||||
*/
|
||||
private tokenRefreshPromise?: Promise<unknown>;
|
||||
|
||||
/**
|
||||
* Perform an authorised request to the homeserver.
|
||||
* @param method - The HTTP method e.g. "GET".
|
||||
@@ -148,37 +137,49 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
* @returns Rejects with an error if a problem occurred.
|
||||
* This includes network problems and Matrix-specific error JSON.
|
||||
*/
|
||||
public async authedRequest<T>(
|
||||
public authedRequest<T>(
|
||||
method: Method,
|
||||
path: string,
|
||||
queryParams?: QueryDict,
|
||||
queryParams: QueryDict = {},
|
||||
body?: Body,
|
||||
paramOpts: IRequestOpts & { doNotAttemptTokenRefresh?: boolean } = {},
|
||||
paramOpts: IRequestOpts = {},
|
||||
): Promise<ResponseType<T, O>> {
|
||||
if (!queryParams) queryParams = {};
|
||||
return this.doAuthedRequest<T>(1, method, path, queryParams, body, paramOpts);
|
||||
}
|
||||
|
||||
// Wrapper around public method authedRequest to allow for tracking retry attempt counts
|
||||
private async doAuthedRequest<T>(
|
||||
attempt: number,
|
||||
method: Method,
|
||||
path: string,
|
||||
queryParams: QueryDict,
|
||||
body?: Body,
|
||||
paramOpts: IRequestOpts = {},
|
||||
): Promise<ResponseType<T, O>> {
|
||||
// avoid mutating paramOpts so they can be used on retry
|
||||
const opts = { ...paramOpts };
|
||||
const opts = deepCopy(paramOpts);
|
||||
// we have to manually copy the abortSignal over as it is not a plain object
|
||||
opts.abortSignal = paramOpts.abortSignal;
|
||||
|
||||
if (this.opts.accessToken) {
|
||||
// Take a snapshot of the current token state before we start the request so we can reference it if we error
|
||||
const requestSnapshot = await this.tokenRefresher.prepareForRequest();
|
||||
if (requestSnapshot.accessToken) {
|
||||
if (this.opts.useAuthorizationHeader) {
|
||||
if (!opts.headers) {
|
||||
opts.headers = {};
|
||||
}
|
||||
if (!opts.headers.Authorization) {
|
||||
opts.headers.Authorization = "Bearer " + this.opts.accessToken;
|
||||
opts.headers.Authorization = `Bearer ${requestSnapshot.accessToken}`;
|
||||
}
|
||||
if (queryParams.access_token) {
|
||||
delete queryParams.access_token;
|
||||
}
|
||||
} else if (!queryParams.access_token) {
|
||||
queryParams.access_token = this.opts.accessToken;
|
||||
queryParams.access_token = requestSnapshot.accessToken;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Await any ongoing token refresh
|
||||
await this.tokenRefreshPromise;
|
||||
const response = await this.request<T>(method, path, queryParams, body, opts);
|
||||
return response;
|
||||
} catch (error) {
|
||||
@@ -186,27 +187,19 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error.errcode === "M_UNKNOWN_TOKEN" && !opts.doNotAttemptTokenRefresh) {
|
||||
const tokenRefreshPromise = this.tryRefreshToken();
|
||||
this.tokenRefreshPromise = Promise.allSettled([tokenRefreshPromise]);
|
||||
const outcome = await tokenRefreshPromise;
|
||||
|
||||
if (error.errcode === "M_UNKNOWN_TOKEN") {
|
||||
const outcome = await this.tokenRefresher.handleUnknownToken(requestSnapshot, attempt);
|
||||
if (outcome === TokenRefreshOutcome.Success) {
|
||||
// if we got a new token retry the request
|
||||
return this.authedRequest(method, path, queryParams, body, {
|
||||
...paramOpts,
|
||||
doNotAttemptTokenRefresh: true,
|
||||
});
|
||||
return this.doAuthedRequest(attempt + 1, method, path, queryParams, body, paramOpts);
|
||||
}
|
||||
if (outcome === TokenRefreshOutcome.Failure) {
|
||||
throw new TokenRefreshError(error);
|
||||
}
|
||||
// Fall through to SessionLoggedOut handler below
|
||||
}
|
||||
|
||||
// otherwise continue with error handling
|
||||
if (error.errcode == "M_UNKNOWN_TOKEN" && !opts?.inhibitLogoutEmit) {
|
||||
this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, error);
|
||||
if (!opts?.inhibitLogoutEmit) {
|
||||
this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, error);
|
||||
}
|
||||
} else if (error.errcode == "M_CONSENT_NOT_GIVEN") {
|
||||
this.eventEmitter.emit(HttpApiEvent.NoConsent, error.message, error.data.consent_uri);
|
||||
}
|
||||
@@ -215,32 +208,6 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to refresh access tokens.
|
||||
* On success, sets new access and refresh tokens in opts.
|
||||
* @returns Promise that resolves to a boolean - true when token was refreshed successfully
|
||||
*/
|
||||
@singleAsyncExecution
|
||||
private async tryRefreshToken(): Promise<TokenRefreshOutcome> {
|
||||
if (!this.opts.refreshToken || !this.opts.tokenRefreshFunction) {
|
||||
return TokenRefreshOutcome.Logout;
|
||||
}
|
||||
|
||||
try {
|
||||
const { accessToken, refreshToken } = await this.opts.tokenRefreshFunction(this.opts.refreshToken);
|
||||
this.opts.accessToken = accessToken;
|
||||
this.opts.refreshToken = refreshToken;
|
||||
// successfully got new tokens
|
||||
return TokenRefreshOutcome.Success;
|
||||
} catch (error) {
|
||||
this.opts.logger?.warn("Failed to refresh token", error);
|
||||
if (error instanceof OidcAuthError || error instanceof MatrixError) {
|
||||
return TokenRefreshOutcome.Logout;
|
||||
}
|
||||
return TokenRefreshOutcome.Failure;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a request to the homeserver without any credentials.
|
||||
* @param method - The HTTP method e.g. "GET".
|
||||
|
||||
+10
-10
@@ -24,7 +24,7 @@ import {
|
||||
type UploadResponse,
|
||||
} from "./interface.ts";
|
||||
import { MediaPrefix } from "./prefix.ts";
|
||||
import { defer, type QueryDict, removeElement } from "../utils.ts";
|
||||
import { type QueryDict, removeElement } from "../utils.ts";
|
||||
import * as callbacks from "../realtime-callbacks.ts";
|
||||
import { Method } from "./method.ts";
|
||||
import { ConnectionError } from "./errors.ts";
|
||||
@@ -65,14 +65,14 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
|
||||
total: 0,
|
||||
abortController,
|
||||
} as Upload;
|
||||
const deferred = defer<UploadResponse>();
|
||||
const uploadResolvers = Promise.withResolvers<UploadResponse>();
|
||||
|
||||
if (globalThis.XMLHttpRequest) {
|
||||
const xhr = new globalThis.XMLHttpRequest();
|
||||
|
||||
const timeoutFn = function (): void {
|
||||
xhr.abort();
|
||||
deferred.reject(new Error("Timeout"));
|
||||
uploadResolvers.reject(new Error("Timeout"));
|
||||
};
|
||||
|
||||
// set an initial timeout of 30s; we'll advance it each time we get a progress notification
|
||||
@@ -91,16 +91,16 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
|
||||
}
|
||||
|
||||
if (xhr.status >= 400) {
|
||||
deferred.reject(parseErrorResponse(xhr, xhr.responseText));
|
||||
uploadResolvers.reject(parseErrorResponse(xhr, xhr.responseText));
|
||||
} else {
|
||||
deferred.resolve(JSON.parse(xhr.responseText));
|
||||
uploadResolvers.resolve(JSON.parse(xhr.responseText));
|
||||
}
|
||||
} catch (err) {
|
||||
if ((<Error>err).name === "AbortError") {
|
||||
deferred.reject(err);
|
||||
uploadResolvers.reject(err);
|
||||
return;
|
||||
}
|
||||
deferred.reject(new ConnectionError("request failed", <Error>err));
|
||||
uploadResolvers.reject(new ConnectionError("request failed", <Error>err));
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -153,16 +153,16 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
|
||||
.then((response) => {
|
||||
return this.opts.onlyData ? <UploadResponse>response : response.json();
|
||||
})
|
||||
.then(deferred.resolve, deferred.reject);
|
||||
.then(uploadResolvers.resolve, uploadResolvers.reject);
|
||||
}
|
||||
|
||||
// remove the upload from the list on completion
|
||||
upload.promise = deferred.promise.finally(() => {
|
||||
upload.promise = uploadResolvers.promise.finally(() => {
|
||||
removeElement(this.uploads, (elem) => elem === upload);
|
||||
});
|
||||
abortController.signal.addEventListener("abort", () => {
|
||||
removeElement(this.uploads, (elem) => elem === upload);
|
||||
deferred.reject(new DOMException("Aborted", "AbortError"));
|
||||
uploadResolvers.reject(new DOMException("Aborted", "AbortError"));
|
||||
});
|
||||
this.uploads.push(upload);
|
||||
return upload.promise;
|
||||
|
||||
@@ -24,9 +24,20 @@ export type Body = Record<string, any> | BodyInit;
|
||||
* Unencrypted access and (optional) refresh token
|
||||
*/
|
||||
export type AccessTokens = {
|
||||
/**
|
||||
* The new access token to use for authenticated requests
|
||||
*/
|
||||
accessToken: string;
|
||||
/**
|
||||
* The new refresh token to use for refreshing tokens, optional
|
||||
*/
|
||||
refreshToken?: string;
|
||||
/**
|
||||
* Approximate date when the access token will expire, optional
|
||||
*/
|
||||
expiry?: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Function that performs token refresh using the given refreshToken.
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
Copyright 2025 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 { MatrixError, TokenRefreshLogoutError } from "./errors.ts";
|
||||
import { type IHttpOpts } from "./interface.ts";
|
||||
import { sleep } from "../utils.ts";
|
||||
|
||||
/**
|
||||
* This is an internal module. See {@link MatrixHttpApi} for the public class.
|
||||
*/
|
||||
|
||||
export const enum TokenRefreshOutcome {
|
||||
Success = "success",
|
||||
Failure = "failure",
|
||||
Logout = "logout",
|
||||
}
|
||||
|
||||
interface Snapshot {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiry?: Date;
|
||||
}
|
||||
|
||||
// If the token expires in less than this time amount of time, we will eagerly refresh it before making the intended request.
|
||||
const REFRESH_IF_TOKEN_EXPIRES_WITHIN_MS = 500;
|
||||
// If we get an unknown token error and the token expires in less than this time amount of time, we will refresh it before making the intended request.
|
||||
// Otherwise, we will error as the token should not have expired yet and we need to avoid retrying indefinitely.
|
||||
const REFRESH_ON_ERROR_IF_TOKEN_EXPIRES_WITHIN_MS = 60 * 1000;
|
||||
|
||||
type Opts = Pick<IHttpOpts, "tokenRefreshFunction" | "logger" | "refreshToken" | "accessToken">;
|
||||
|
||||
/**
|
||||
* This class is responsible for managing the access token and refresh token for authenticated requests.
|
||||
* It will automatically refresh the access token when it is about to expire, and will handle unknown token errors.
|
||||
*/
|
||||
export class TokenRefresher {
|
||||
public constructor(private readonly opts: Opts) {}
|
||||
|
||||
/**
|
||||
* Promise used to block authenticated requests during a token refresh to avoid repeated expected errors.
|
||||
* @private
|
||||
*/
|
||||
private tokenRefreshPromise?: Promise<TokenRefreshOutcome>;
|
||||
|
||||
private latestTokenRefreshExpiry?: Date;
|
||||
|
||||
/**
|
||||
* This function is called before every request to ensure that the access token is valid.
|
||||
* @returns a snapshot containing the access token and other properties which must be passed to the handleUnknownToken
|
||||
* handler if an M_UNKNOWN_TOKEN error is encountered.
|
||||
*/
|
||||
public async prepareForRequest(): Promise<Snapshot> {
|
||||
// Ensure our token is refreshed before we build the headers/params
|
||||
await this.refreshIfNeeded();
|
||||
|
||||
return {
|
||||
accessToken: this.opts.accessToken!,
|
||||
refreshToken: this.opts.refreshToken,
|
||||
expiry: this.latestTokenRefreshExpiry,
|
||||
};
|
||||
}
|
||||
|
||||
private async refreshIfNeeded(): Promise<unknown> {
|
||||
if (this.tokenRefreshPromise) {
|
||||
return this.tokenRefreshPromise;
|
||||
}
|
||||
// If we don't know the token expiry, we can't eagerly refresh
|
||||
if (!this.latestTokenRefreshExpiry) return;
|
||||
|
||||
const expiresIn = this.latestTokenRefreshExpiry.getTime() - Date.now();
|
||||
if (expiresIn <= REFRESH_IF_TOKEN_EXPIRES_WITHIN_MS) {
|
||||
await this._handleUnknownToken();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is called when an M_UNKNOWN_TOKEN error is encountered.
|
||||
* It will attempt to refresh the access token if it is unknown, and will return a TokenRefreshOutcome.
|
||||
* @param snapshot - the snapshot returned by prepareForRequest
|
||||
* @param attempt - the number of attempts made for this request so far
|
||||
* @returns a TokenRefreshOutcome indicating the result of the refresh attempt
|
||||
*/
|
||||
public async handleUnknownToken(snapshot: Snapshot, attempt: number): Promise<TokenRefreshOutcome> {
|
||||
return this._handleUnknownToken(snapshot, attempt);
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
private async _handleUnknownToken(): Promise<TokenRefreshOutcome>;
|
||||
private async _handleUnknownToken(snapshot: Snapshot, attempt: number): Promise<TokenRefreshOutcome>;
|
||||
private async _handleUnknownToken(snapshot?: Snapshot, attempt?: number): Promise<TokenRefreshOutcome> {
|
||||
if (snapshot?.expiry) {
|
||||
// If our token is unknown, but it should not have expired yet, then we should not refresh
|
||||
const expiresIn = snapshot.expiry.getTime() - Date.now();
|
||||
// If it still has plenty of time left on the clock, we assume something else must be wrong and
|
||||
// do not refresh. Otherwise if it's expired, or will soon, we try refreshing.
|
||||
if (expiresIn >= REFRESH_ON_ERROR_IF_TOKEN_EXPIRES_WITHIN_MS) {
|
||||
return TokenRefreshOutcome.Logout;
|
||||
}
|
||||
}
|
||||
|
||||
if (!snapshot || snapshot?.accessToken === this.opts.accessToken) {
|
||||
// If we have a snapshot, but the access token is the same as the current one then a refresh
|
||||
// did not happen behind us but one may be ongoing anyway
|
||||
this.tokenRefreshPromise ??= this.doTokenRefresh(attempt);
|
||||
|
||||
try {
|
||||
return await this.tokenRefreshPromise;
|
||||
} finally {
|
||||
this.tokenRefreshPromise = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// We may end up here if the token was refreshed in the background due to another request
|
||||
return TokenRefreshOutcome.Success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to refresh access tokens.
|
||||
* On success, sets new access and refresh tokens in opts.
|
||||
* @returns Promise that resolves to a boolean - true when token was refreshed successfully
|
||||
*/
|
||||
private async doTokenRefresh(attempt?: number): Promise<TokenRefreshOutcome> {
|
||||
if (!this.opts.refreshToken || !this.opts.tokenRefreshFunction) {
|
||||
this.opts.logger?.error("Unable to refresh token - no refresh token or refresh function");
|
||||
return TokenRefreshOutcome.Logout;
|
||||
}
|
||||
|
||||
if (attempt && attempt > 1) {
|
||||
// Exponential backoff to ensure we don't trash the server, up to 2^5 seconds
|
||||
await sleep(1000 * Math.min(32, 2 ** attempt));
|
||||
}
|
||||
|
||||
try {
|
||||
this.opts.logger?.debug("Attempting to refresh token");
|
||||
const { accessToken, refreshToken, expiry } = await this.opts.tokenRefreshFunction(this.opts.refreshToken);
|
||||
this.opts.accessToken = accessToken;
|
||||
this.opts.refreshToken = refreshToken;
|
||||
this.latestTokenRefreshExpiry = expiry;
|
||||
this.opts.logger?.debug("... token refresh complete, new token expiry:", expiry);
|
||||
|
||||
// successfully got new tokens
|
||||
return TokenRefreshOutcome.Success;
|
||||
} catch (error) {
|
||||
// If we get a TokenError or MatrixError, we should log out, otherwise assume transient
|
||||
if (error instanceof TokenRefreshLogoutError || error instanceof MatrixError) {
|
||||
this.opts.logger?.error("Failed to refresh token", error);
|
||||
return TokenRefreshOutcome.Logout;
|
||||
}
|
||||
|
||||
this.opts.logger?.warn("Failed to refresh token", error);
|
||||
return TokenRefreshOutcome.Failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ limitations under the License.
|
||||
|
||||
import { logger } from "./logger.ts";
|
||||
import { type MatrixClient } from "./client.ts";
|
||||
import { defer, type IDeferred } from "./utils.ts";
|
||||
import { MatrixError } from "./http-api/index.ts";
|
||||
import { type UserIdentifier } from "./@types/auth.ts";
|
||||
|
||||
@@ -262,7 +261,7 @@ export class InteractiveAuth<T> {
|
||||
private data: IAuthData & MatrixError["data"];
|
||||
private emailSid?: string;
|
||||
private requestingEmailToken = false;
|
||||
private attemptAuthDeferred: IDeferred<T> | null = null;
|
||||
private attemptAuthDeferred: PromiseWithResolvers<T> | null = null;
|
||||
private chosenFlow: UIAFlow | null = null;
|
||||
private currentStage: string | null = null;
|
||||
|
||||
@@ -298,7 +297,7 @@ export class InteractiveAuth<T> {
|
||||
public async attemptAuth(): Promise<T> {
|
||||
// This promise will be quite long-lived and will resolve when the
|
||||
// request is authenticated and completes successfully.
|
||||
this.attemptAuthDeferred = defer();
|
||||
this.attemptAuthDeferred = Promise.withResolvers();
|
||||
// pluck the promise out now, as doRequest may clear before we return
|
||||
const promise = this.attemptAuthDeferred.promise;
|
||||
|
||||
|
||||
+18
-3
@@ -34,6 +34,11 @@ export interface Logger extends BaseLogger {
|
||||
/**
|
||||
* Create a child logger.
|
||||
*
|
||||
* This child will use the `methodFactory` of the parent, so any log extensions applied to the parent
|
||||
* at the time of calling `getChild` will be applied to the child as well.
|
||||
* It will NOT apply changes to the parent's `methodFactory` after the child was created.
|
||||
* Those changes need to be applied to the child manually.
|
||||
*
|
||||
* @param namespace - name to add to the current logger to generate the child. Some implementations of `Logger`
|
||||
* use this as a prefix; others use a different mechanism.
|
||||
*/
|
||||
@@ -128,14 +133,24 @@ interface PrefixedLogger extends loglevel.Logger, LoggerWithLogMethod {
|
||||
*
|
||||
* @param prefix Prefix to add to each logged line. If undefined, no prefix will be added.
|
||||
*/
|
||||
function getPrefixedLogger(prefix?: string): LoggerWithLogMethod {
|
||||
function getPrefixedLogger(prefix?: string): PrefixedLogger {
|
||||
const loggerName = DEFAULT_NAMESPACE + (prefix === undefined ? "" : `-${prefix}`);
|
||||
const prefixLogger = loglevel.getLogger(loggerName) as PrefixedLogger;
|
||||
|
||||
if (prefixLogger.getChild === undefined) {
|
||||
// This is a new loglevel Logger which has not been turned into a PrefixedLogger yet.
|
||||
prefixLogger.prefix = prefix;
|
||||
prefixLogger.getChild = (childPrefix): Logger => getPrefixedLogger((prefix ?? "") + childPrefix);
|
||||
prefixLogger.getChild = (childPrefix): Logger => {
|
||||
// create the new child logger
|
||||
const childLogger = getPrefixedLogger((prefix ?? "") + childPrefix);
|
||||
// Assign the methodFactory from the parent logger.
|
||||
// This is useful if we add extensions to the parent logger that modifies
|
||||
// its methodFactory. (An example extension is: storing each log to a rageshake db)
|
||||
childLogger.methodFactory = prefixLogger.methodFactory;
|
||||
// Rebuild the child logger with the new methodFactory.
|
||||
childLogger.rebuild();
|
||||
return childLogger;
|
||||
};
|
||||
prefixLogger.setLevel(loglevel.levels.DEBUG, false);
|
||||
}
|
||||
|
||||
@@ -146,7 +161,7 @@ function getPrefixedLogger(prefix?: string): LoggerWithLogMethod {
|
||||
* Drop-in replacement for `console` using {@link https://www.npmjs.com/package/loglevel|loglevel}.
|
||||
* Can be tailored down to specific use cases if needed.
|
||||
*/
|
||||
export const logger = getPrefixedLogger();
|
||||
export const logger = getPrefixedLogger() as LoggerWithLogMethod;
|
||||
|
||||
/**
|
||||
* A "span" for grouping related log lines together.
|
||||
|
||||
+116
-198
@@ -1,57 +1,49 @@
|
||||
import { type MatrixClient } from "../client.ts";
|
||||
import { logger as rootLogger } from "../logger.ts";
|
||||
import { type MatrixEvent } from "../models/event.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { type Logger, logger as rootLogger } from "../logger.ts";
|
||||
import { type EncryptionConfig } from "./MatrixRTCSession.ts";
|
||||
import { secureRandomBase64Url } from "../randomstring.ts";
|
||||
import { type EncryptionKeysEventContent } from "./types.ts";
|
||||
import { decodeBase64, encodeUnpaddedBase64 } from "../base64.ts";
|
||||
import { type MatrixError, safeGetRetryAfterMs } from "../http-api/errors.ts";
|
||||
import { safeGetRetryAfterMs } from "../http-api/errors.ts";
|
||||
import { type CallMembership } from "./CallMembership.ts";
|
||||
import { EventType } from "../@types/event.ts";
|
||||
const logger = rootLogger.getChild("MatrixRTCSession");
|
||||
|
||||
/**
|
||||
* A type collecting call encryption statistics for a session.
|
||||
*/
|
||||
export type Statistics = {
|
||||
counters: {
|
||||
/**
|
||||
* The number of times we have sent a room event containing encryption keys.
|
||||
*/
|
||||
roomEventEncryptionKeysSent: number;
|
||||
/**
|
||||
* The number of times we have received a room event containing encryption keys.
|
||||
*/
|
||||
roomEventEncryptionKeysReceived: number;
|
||||
};
|
||||
totals: {
|
||||
/**
|
||||
* The total age (in milliseconds) of all room events containing encryption keys that we have received.
|
||||
* We track the total age so that we can later calculate the average age of all keys received.
|
||||
*/
|
||||
roomEventEncryptionKeysReceivedTotalAge: number;
|
||||
};
|
||||
};
|
||||
import { type KeyTransportEventListener, KeyTransportEvents, type IKeyTransport } from "./IKeyTransport.ts";
|
||||
import { isMyMembership, type Statistics } from "./types.ts";
|
||||
import {
|
||||
type EnabledTransports,
|
||||
RoomAndToDeviceEvents,
|
||||
RoomAndToDeviceTransport,
|
||||
} from "./RoomAndToDeviceKeyTransport.ts";
|
||||
|
||||
/**
|
||||
* This interface is for testing and for making it possible to interchange the encryption manager.
|
||||
* @internal
|
||||
*/
|
||||
export interface IEncryptionManager {
|
||||
join(joinConfig: EncryptionConfig | undefined): void;
|
||||
leave(): void;
|
||||
onMembershipsUpdate(oldMemberships: CallMembership[]): void;
|
||||
/**
|
||||
* Process `m.call.encryption_keys` events to track the encryption keys for call participants.
|
||||
* This should be called each time the relevant event is received from a room timeline.
|
||||
* If the event is malformed then it will be logged and ignored.
|
||||
* Joins the encryption manager with the provided configuration.
|
||||
*
|
||||
* @param event the event to process
|
||||
* @param joinConfig - The configuration for joining encryption, or undefined
|
||||
* if no specific configuration is provided.
|
||||
*/
|
||||
join(joinConfig: EncryptionConfig | undefined): void;
|
||||
|
||||
/**
|
||||
* Leaves the encryption manager, cleaning up any associated resources.
|
||||
*/
|
||||
leave(): void;
|
||||
|
||||
/**
|
||||
* Called from the MatrixRTCSession when the memberships in this session updated.
|
||||
*
|
||||
* @param oldMemberships - The previous state of call memberships before the update.
|
||||
*/
|
||||
onMembershipsUpdate(oldMemberships: CallMembership[]): void;
|
||||
|
||||
/**
|
||||
* Retrieves the encryption keys currently managed by the encryption manager.
|
||||
*
|
||||
* @returns A map where the keys are identifiers and the values are arrays of
|
||||
* objects containing encryption keys and their associated timestamps.
|
||||
*/
|
||||
onCallEncryptionEventReceived(event: MatrixEvent): void;
|
||||
getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>>;
|
||||
statistics: Statistics;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,9 +63,11 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
private get updateEncryptionKeyThrottle(): number {
|
||||
return this.joinConfig?.updateEncryptionKeyThrottle ?? 3_000;
|
||||
}
|
||||
|
||||
private get makeKeyDelay(): number {
|
||||
return this.joinConfig?.makeKeyDelay ?? 3_000;
|
||||
}
|
||||
|
||||
private get useKeyDelay(): number {
|
||||
return this.joinConfig?.useKeyDelay ?? 5_000;
|
||||
}
|
||||
@@ -85,38 +79,42 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
// if it looks like a membership has been updated.
|
||||
private lastMembershipFingerprints: Set<string> | undefined;
|
||||
|
||||
private currentEncryptionKeyIndex = -1;
|
||||
|
||||
public statistics: Statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
private latestGeneratedKeyIndex = -1;
|
||||
private joinConfig: EncryptionConfig | undefined;
|
||||
|
||||
private logger: Logger;
|
||||
public constructor(
|
||||
private client: Pick<MatrixClient, "sendEvent" | "getDeviceId" | "getUserId" | "cancelPendingEvent">,
|
||||
private room: Pick<Room, "roomId">,
|
||||
private userId: string,
|
||||
private deviceId: string,
|
||||
private getMemberships: () => CallMembership[],
|
||||
private transport: IKeyTransport,
|
||||
private statistics: Statistics,
|
||||
private onEncryptionKeysChanged: (
|
||||
keyBin: Uint8Array<ArrayBufferLike>,
|
||||
keyBin: Uint8Array,
|
||||
encryptionKeyIndex: number,
|
||||
participantId: string,
|
||||
) => void,
|
||||
) {}
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
this.logger = (parentLogger ?? rootLogger).getChild(`[EncryptionManager]`);
|
||||
}
|
||||
|
||||
public getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>> {
|
||||
return this.encryptionKeys;
|
||||
}
|
||||
|
||||
private joined = false;
|
||||
|
||||
public join(joinConfig: EncryptionConfig): void {
|
||||
this.joinConfig = joinConfig;
|
||||
this.joined = true;
|
||||
this.manageMediaKeys = this.joinConfig?.manageMediaKeys ?? this.manageMediaKeys;
|
||||
|
||||
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
|
||||
// Deprecate RoomKeyTransport: this can get removed.
|
||||
if (this.transport instanceof RoomAndToDeviceTransport) {
|
||||
this.transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, this.onTransportChanged);
|
||||
}
|
||||
this.transport.start();
|
||||
if (this.joinConfig?.manageMediaKeys) {
|
||||
this.makeNewSenderKey();
|
||||
this.requestSendCurrentKey();
|
||||
@@ -124,15 +122,12 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
}
|
||||
|
||||
public leave(): void {
|
||||
const userId = this.client.getUserId();
|
||||
const deviceId = this.client.getDeviceId();
|
||||
|
||||
if (!userId) throw new Error("No userId");
|
||||
if (!deviceId) throw new Error("No deviceId");
|
||||
// clear our encryption keys as we're done with them now (we'll
|
||||
// make new keys if we rejoin). We leave keys for other participants
|
||||
// as they may still be using the same ones.
|
||||
this.encryptionKeys.set(getParticipantId(userId, deviceId), []);
|
||||
this.encryptionKeys.set(getParticipantId(this.userId, this.deviceId), []);
|
||||
this.transport.off(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
|
||||
this.transport.stop();
|
||||
|
||||
if (this.makeNewKeyTimeout !== undefined) {
|
||||
clearTimeout(this.makeNewKeyTimeout);
|
||||
@@ -146,18 +141,17 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
this.manageMediaKeys = false;
|
||||
this.joined = false;
|
||||
}
|
||||
// TODO deduplicate this method. It also is in MatrixRTCSession.
|
||||
private isMyMembership = (m: CallMembership): boolean =>
|
||||
m.sender === this.client.getUserId() && m.deviceId === this.client.getDeviceId();
|
||||
|
||||
public onMembershipsUpdate(oldMemberships: CallMembership[]): void {
|
||||
if (this.manageMediaKeys && this.joined) {
|
||||
const oldMembershipIds = new Set(
|
||||
oldMemberships.filter((m) => !this.isMyMembership(m)).map(getParticipantIdFromMembership),
|
||||
oldMemberships
|
||||
.filter((m) => !isMyMembership(m, this.userId, this.deviceId))
|
||||
.map(getParticipantIdFromMembership),
|
||||
);
|
||||
const newMembershipIds = new Set(
|
||||
this.getMemberships()
|
||||
.filter((m) => !this.isMyMembership(m))
|
||||
.filter((m) => !isMyMembership(m, this.userId, this.deviceId))
|
||||
.map(getParticipantIdFromMembership),
|
||||
);
|
||||
|
||||
@@ -174,11 +168,11 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
if (this.makeNewKeyTimeout) {
|
||||
// existing rotation in progress, so let it complete
|
||||
} else {
|
||||
logger.debug(`Member(s) have left: queueing sender key rotation`);
|
||||
this.logger.debug(`Member(s) have left: queueing sender key rotation`);
|
||||
this.makeNewKeyTimeout = setTimeout(this.onRotateKeyTimeout, this.makeKeyDelay);
|
||||
}
|
||||
} else if (anyJoined) {
|
||||
logger.debug(`New member(s) have joined: re-sending keys`);
|
||||
this.logger.debug(`New member(s) have joined: re-sending keys`);
|
||||
this.requestSendCurrentKey();
|
||||
} else if (oldFingerprints) {
|
||||
// does it look like any of the members have updated their memberships?
|
||||
@@ -190,7 +184,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
Array.from(oldFingerprints).some((x) => !newFingerprints.has(x)) ||
|
||||
Array.from(newFingerprints).some((x) => !oldFingerprints.has(x));
|
||||
if (candidateUpdates) {
|
||||
logger.debug(`Member(s) have updated/reconnected: re-sending keys to everyone`);
|
||||
this.logger.debug(`Member(s) have updated/reconnected: re-sending keys to everyone`);
|
||||
this.requestSendCurrentKey();
|
||||
}
|
||||
}
|
||||
@@ -204,16 +198,17 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
* @returns The index of the new key
|
||||
*/
|
||||
private makeNewSenderKey(delayBeforeUse = false): number {
|
||||
const userId = this.client.getUserId();
|
||||
const deviceId = this.client.getDeviceId();
|
||||
|
||||
if (!userId) throw new Error("No userId");
|
||||
if (!deviceId) throw new Error("No deviceId");
|
||||
|
||||
const encryptionKey = secureRandomBase64Url(16);
|
||||
const encryptionKeyIndex = this.getNewEncryptionKeyIndex();
|
||||
logger.info("Generated new key at index " + encryptionKeyIndex);
|
||||
this.setEncryptionKey(userId, deviceId, encryptionKeyIndex, encryptionKey, Date.now(), delayBeforeUse);
|
||||
this.logger.info("Generated new key at index " + encryptionKeyIndex);
|
||||
this.setEncryptionKey(
|
||||
this.userId,
|
||||
this.deviceId,
|
||||
encryptionKeyIndex,
|
||||
encryptionKey,
|
||||
Date.now(),
|
||||
delayBeforeUse,
|
||||
);
|
||||
return encryptionKeyIndex;
|
||||
}
|
||||
|
||||
@@ -228,7 +223,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
this.lastEncryptionKeyUpdateRequest &&
|
||||
this.lastEncryptionKeyUpdateRequest + this.updateEncryptionKeyThrottle > Date.now()
|
||||
) {
|
||||
logger.info("Last encryption key event sent too recently: postponing");
|
||||
this.logger.info("Last encryption key event sent too recently: postponing");
|
||||
if (this.keysEventUpdateTimeout === undefined) {
|
||||
this.keysEventUpdateTimeout = setTimeout(
|
||||
() => void this.sendEncryptionKeysEvent(),
|
||||
@@ -264,151 +259,67 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
|
||||
if (!this.joined) return;
|
||||
|
||||
logger.info(`Sending encryption keys event. indexToSend=${indexToSend}`);
|
||||
|
||||
const userId = this.client.getUserId();
|
||||
const deviceId = this.client.getDeviceId();
|
||||
|
||||
if (!userId) throw new Error("No userId");
|
||||
if (!deviceId) throw new Error("No deviceId");
|
||||
|
||||
const myKeys = this.getKeysForParticipant(userId, deviceId);
|
||||
const myKeys = this.getKeysForParticipant(this.userId, this.deviceId);
|
||||
|
||||
if (!myKeys) {
|
||||
logger.warn("Tried to send encryption keys event but no keys found!");
|
||||
this.logger.warn("Tried to send encryption keys event but no keys found!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof indexToSend !== "number" && this.currentEncryptionKeyIndex === -1) {
|
||||
logger.warn("Tried to send encryption keys event but no current key index found!");
|
||||
if (typeof indexToSend !== "number" && this.latestGeneratedKeyIndex === -1) {
|
||||
this.logger.warn("Tried to send encryption keys event but no current key index found!");
|
||||
return;
|
||||
}
|
||||
|
||||
const keyIndexToSend = indexToSend ?? this.currentEncryptionKeyIndex;
|
||||
const keyIndexToSend = indexToSend ?? this.latestGeneratedKeyIndex;
|
||||
|
||||
this.logger.info(
|
||||
`Try sending encryption keys event. keyIndexToSend=${keyIndexToSend} (method parameter: ${indexToSend})`,
|
||||
);
|
||||
const keyToSend = myKeys[keyIndexToSend];
|
||||
|
||||
try {
|
||||
const content: EncryptionKeysEventContent = {
|
||||
keys: [
|
||||
{
|
||||
index: keyIndexToSend,
|
||||
key: encodeUnpaddedBase64(keyToSend),
|
||||
},
|
||||
],
|
||||
device_id: deviceId,
|
||||
call_id: "",
|
||||
sent_ts: Date.now(),
|
||||
};
|
||||
|
||||
this.statistics.counters.roomEventEncryptionKeysSent += 1;
|
||||
|
||||
await this.client.sendEvent(this.room.roomId, EventType.CallEncryptionKeysPrefix, content);
|
||||
|
||||
logger.debug(
|
||||
`Embedded-E2EE-LOG updateEncryptionKeyEvent participantId=${userId}:${deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.currentEncryptionKeyIndex} keyIndexToSend=${keyIndexToSend}`,
|
||||
await this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend, this.getMemberships());
|
||||
this.logger.debug(
|
||||
`sendEncryptionKeysEvent participantId=${this.userId}:${this.deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.latestGeneratedKeyIndex} keyIndexToSend=${keyIndexToSend}`,
|
||||
this.encryptionKeys,
|
||||
);
|
||||
} catch (error) {
|
||||
const matrixError = error as MatrixError;
|
||||
if (matrixError.event) {
|
||||
// cancel the pending event: we'll just generate a new one with our latest
|
||||
// keys when we resend
|
||||
this.client.cancelPendingEvent(matrixError.event);
|
||||
}
|
||||
if (this.keysEventUpdateTimeout === undefined) {
|
||||
const resendDelay = safeGetRetryAfterMs(matrixError, 5000);
|
||||
logger.warn(`Failed to send m.call.encryption_key, retrying in ${resendDelay}`, error);
|
||||
const resendDelay = safeGetRetryAfterMs(error, 5000);
|
||||
this.logger.warn(`Failed to send m.call.encryption_key, retrying in ${resendDelay}`, error);
|
||||
this.keysEventUpdateTimeout = setTimeout(() => void this.sendEncryptionKeysEvent(), resendDelay);
|
||||
} else {
|
||||
logger.info("Not scheduling key resend as another re-send is already pending");
|
||||
this.logger.info("Not scheduling key resend as another re-send is already pending");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public onCallEncryptionEventReceived = (event: MatrixEvent): void => {
|
||||
const userId = event.getSender();
|
||||
const content = event.getContent<EncryptionKeysEventContent>();
|
||||
|
||||
const deviceId = content["device_id"];
|
||||
const callId = content["call_id"];
|
||||
|
||||
if (!userId) {
|
||||
logger.warn(`Received m.call.encryption_keys with no userId: callId=${callId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// We currently only handle callId = "" (which is the default for room scoped calls)
|
||||
if (callId !== "") {
|
||||
logger.warn(
|
||||
`Received m.call.encryption_keys with unsupported callId: userId=${userId}, deviceId=${deviceId}, callId=${callId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content.keys)) {
|
||||
logger.warn(`Received m.call.encryption_keys where keys wasn't an array: callId=${callId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) {
|
||||
// We store our own sender key in the same set along with keys from others, so it's
|
||||
// important we don't allow our own keys to be set by one of these events (apart from
|
||||
// the fact that we don't need it anyway because we already know our own keys).
|
||||
logger.info("Ignoring our own keys event");
|
||||
return;
|
||||
}
|
||||
|
||||
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
|
||||
const age = Date.now() - (typeof content.sent_ts === "number" ? content.sent_ts : event.getTs());
|
||||
this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age;
|
||||
|
||||
for (const key of content.keys) {
|
||||
if (!key) {
|
||||
logger.info("Ignoring false-y key in keys event");
|
||||
continue;
|
||||
}
|
||||
|
||||
const encryptionKey = key.key;
|
||||
const encryptionKeyIndex = key.index;
|
||||
|
||||
if (
|
||||
!encryptionKey ||
|
||||
encryptionKeyIndex === undefined ||
|
||||
encryptionKeyIndex === null ||
|
||||
callId === undefined ||
|
||||
callId === null ||
|
||||
typeof deviceId !== "string" ||
|
||||
typeof callId !== "string" ||
|
||||
typeof encryptionKey !== "string" ||
|
||||
typeof encryptionKeyIndex !== "number"
|
||||
) {
|
||||
logger.warn(
|
||||
`Malformed call encryption_key: userId=${userId}, deviceId=${deviceId}, encryptionKeyIndex=${encryptionKeyIndex} callId=${callId}`,
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Embedded-E2EE-LOG onCallEncryption userId=${userId}:${deviceId} encryptionKeyIndex=${encryptionKeyIndex} age=${age}ms`,
|
||||
this.encryptionKeys,
|
||||
);
|
||||
this.setEncryptionKey(userId, deviceId, encryptionKeyIndex, encryptionKey, event.getTs());
|
||||
}
|
||||
}
|
||||
private onTransportChanged: (enabled: EnabledTransports) => void = () => {
|
||||
this.requestSendCurrentKey();
|
||||
};
|
||||
|
||||
public onNewKeyReceived: KeyTransportEventListener = (userId, deviceId, keyBase64Encoded, index, timestamp) => {
|
||||
this.logger.debug(`Received key over key transport ${userId}:${deviceId} at index ${index}`);
|
||||
this.setEncryptionKey(userId, deviceId, index, keyBase64Encoded, timestamp);
|
||||
};
|
||||
|
||||
private storeLastMembershipFingerprints(): void {
|
||||
this.lastMembershipFingerprints = new Set(
|
||||
this.getMemberships()
|
||||
.filter((m) => !this.isMyMembership(m))
|
||||
.filter((m) => !isMyMembership(m, this.userId, this.deviceId))
|
||||
.map((m) => `${getParticipantIdFromMembership(m)}:${m.createdTs()}`),
|
||||
);
|
||||
}
|
||||
|
||||
private getNewEncryptionKeyIndex(): number {
|
||||
if (this.currentEncryptionKeyIndex === -1) {
|
||||
if (this.latestGeneratedKeyIndex === -1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// maximum key index is 255
|
||||
return (this.currentEncryptionKeyIndex + 1) % 256;
|
||||
return (this.latestGeneratedKeyIndex + 1) % 256;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -433,6 +344,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
timestamp: number,
|
||||
delayBeforeUse = false,
|
||||
): void {
|
||||
this.logger.debug(`Setting encryption key for ${userId}:${deviceId} at index ${encryptionKeyIndex}`);
|
||||
const keyBin = decodeBase64(encryptionKeyString);
|
||||
|
||||
const participantId = getParticipantId(userId, deviceId);
|
||||
@@ -445,7 +357,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
|
||||
if (existingKeyAtIndex) {
|
||||
if (existingKeyAtIndex.timestamp > timestamp) {
|
||||
logger.info(
|
||||
this.logger.info(
|
||||
`Ignoring new key at index ${encryptionKeyIndex} for ${participantId} as it is older than existing known key`,
|
||||
);
|
||||
return;
|
||||
@@ -457,6 +369,15 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
}
|
||||
}
|
||||
|
||||
if (userId === this.userId && deviceId === this.deviceId) {
|
||||
// It is important to already update the latestGeneratedKeyIndex here
|
||||
// NOT IN THE `delayBeforeUse` `setTimeout`.
|
||||
// Even though this is where we call onEncryptionKeysChanged and set the key in EC (and livekit).
|
||||
// It needs to happen here because we will send the key before the timeout has passed and sending
|
||||
// the key will use latestGeneratedKeyIndex as the index. if we update it in the `setTimeout` callback
|
||||
// it will use the wrong index (index - 1)!
|
||||
this.latestGeneratedKeyIndex = encryptionKeyIndex;
|
||||
}
|
||||
participantKeys[encryptionKeyIndex] = {
|
||||
key: keyBin,
|
||||
timestamp,
|
||||
@@ -465,17 +386,12 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
if (delayBeforeUse) {
|
||||
const useKeyTimeout = setTimeout(() => {
|
||||
this.setNewKeyTimeouts.delete(useKeyTimeout);
|
||||
logger.info(`Delayed-emitting key changed event for ${participantId} idx ${encryptionKeyIndex}`);
|
||||
if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) {
|
||||
this.currentEncryptionKeyIndex = encryptionKeyIndex;
|
||||
}
|
||||
this.logger.info(`Delayed-emitting key changed event for ${participantId} index ${encryptionKeyIndex}`);
|
||||
|
||||
this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId);
|
||||
}, this.useKeyDelay);
|
||||
this.setNewKeyTimeouts.add(useKeyTimeout);
|
||||
} else {
|
||||
if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) {
|
||||
this.currentEncryptionKeyIndex = encryptionKeyIndex;
|
||||
}
|
||||
this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId);
|
||||
}
|
||||
}
|
||||
@@ -484,7 +400,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
if (!this.manageMediaKeys) return;
|
||||
|
||||
this.makeNewKeyTimeout = undefined;
|
||||
logger.info("Making new sender key for key rotation");
|
||||
this.logger.info("Making new sender key for key rotation");
|
||||
const newKeyIndex = this.makeNewSenderKey(true);
|
||||
// send immediately: if we're about to start sending with a new key, it's
|
||||
// important we get it out to others as soon as we can.
|
||||
@@ -493,8 +409,10 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
}
|
||||
|
||||
const getParticipantId = (userId: string, deviceId: string): string => `${userId}:${deviceId}`;
|
||||
|
||||
function keysEqual(a: Uint8Array | undefined, b: Uint8Array | undefined): boolean {
|
||||
if (a === b) return true;
|
||||
return !!a && !!b && a.length === b.length && a.every((x, i) => x === b[i]);
|
||||
}
|
||||
|
||||
const getParticipantIdFromMembership = (m: CallMembership): string => getParticipantId(m.sender!, m.deviceId);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
Copyright 2025 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 { type CallMembership } from "./CallMembership.ts";
|
||||
|
||||
export enum KeyTransportEvents {
|
||||
ReceivedKeys = "received_keys",
|
||||
}
|
||||
|
||||
export type KeyTransportEventsHandlerMap = {
|
||||
[KeyTransportEvents.ReceivedKeys]: KeyTransportEventListener;
|
||||
};
|
||||
|
||||
export type KeyTransportEventListener = (
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
keyBase64Encoded: string,
|
||||
index: number,
|
||||
timestamp: number,
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Generic interface for the transport used to share room keys.
|
||||
* Keys can be shared using different transports, e.g. to-device messages or room messages.
|
||||
*/
|
||||
export interface IKeyTransport {
|
||||
/**
|
||||
* Sends the current user media key to the given members.
|
||||
* @param keyBase64Encoded
|
||||
* @param index
|
||||
* @param members - The participants that should get they key
|
||||
*/
|
||||
sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void>;
|
||||
|
||||
/** Subscribe to keys from this transport. */
|
||||
on(event: KeyTransportEvents.ReceivedKeys, listener: KeyTransportEventListener): this;
|
||||
/** Unsubscribe from keys from this transport. */
|
||||
off(event: KeyTransportEvents.ReceivedKeys, listener: KeyTransportEventListener): this;
|
||||
|
||||
/** Once start is called the underlying transport will subscribe to its transport system.
|
||||
* Before start is called this transport will not emit any events.
|
||||
*/
|
||||
start(): void;
|
||||
/** Once stop is called the underlying transport will unsubscribe from its transport system.
|
||||
* After stop is called this transport will not emit any events.
|
||||
*/
|
||||
stop(): void;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
Copyright 2025 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 type { CallMembership } from "./CallMembership.ts";
|
||||
import type { Focus } from "./focus.ts";
|
||||
import type { Status } from "./types.ts";
|
||||
|
||||
export enum MembershipManagerEvent {
|
||||
StatusChanged = "StatusChanged",
|
||||
}
|
||||
|
||||
export type MembershipManagerEventHandlerMap = {
|
||||
[MembershipManagerEvent.StatusChanged]: (prefStatus: Status, newStatus: Status) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* This interface defines what a MembershipManager uses and exposes.
|
||||
* This interface is what we use to write tests and allows changing the actual implementation
|
||||
* without breaking tests because of some internal method renaming.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface IMembershipManager {
|
||||
/**
|
||||
* If we are trying to join, or have successfully joined the session.
|
||||
* It does not reflect if the room state is already configured to represent us being joined.
|
||||
* It only means that the Manager should be trying to connect or to disconnect running.
|
||||
* The Manager is still running right after isJoined becomes false to send the disconnect events.
|
||||
* @returns true if we intend to be participating in the MatrixRTC session
|
||||
* @deprecated This name is confusing and replaced by `isActivated()`. (Returns the same as `isActivated()`)
|
||||
*/
|
||||
isJoined(): boolean;
|
||||
/**
|
||||
* If the manager is activated. This means it tries to do its job to join the call, resend state events...
|
||||
* It does not imply that the room state is already configured to represent being joined.
|
||||
* It means that the Manager tries to connect or is connected. ("the manager is still active")
|
||||
* Once `leave()` is called the manager is not activated anymore but still running until `leave()` resolves.
|
||||
* @returns `true` if we intend to be participating in the MatrixRTC session
|
||||
*/
|
||||
isActivated(): boolean;
|
||||
/**
|
||||
* Get the actual connection status of the manager.
|
||||
*/
|
||||
get status(): Status;
|
||||
/**
|
||||
* The current status while the manager is activated
|
||||
*/
|
||||
/**
|
||||
* Start sending all necessary events to make this user participate in the RTC session.
|
||||
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
|
||||
* @param fociActive the active focus to use in the joined RTC membership event.
|
||||
* @throws can throw if it exceeds a configured maximum retry.
|
||||
*/
|
||||
join(fociPreferred: Focus[], fociActive?: Focus, onError?: (error: unknown) => void): void;
|
||||
/**
|
||||
* Send all necessary events to make this user leave the RTC session.
|
||||
* @param timeout the maximum duration in ms until the promise is forced to resolve.
|
||||
* @returns It resolves with true in case the leave was sent successfully.
|
||||
* It resolves with false in case we hit the timeout before sending successfully.
|
||||
*/
|
||||
leave(timeout?: number): Promise<boolean>;
|
||||
/**
|
||||
* Call this if the MatrixRTC session members have changed.
|
||||
*/
|
||||
onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void>;
|
||||
/**
|
||||
* The used active focus in the currently joined session.
|
||||
* @returns the used active focus in the currently joined session or undefined if not joined.
|
||||
*/
|
||||
getActiveFocus(): Focus | undefined;
|
||||
|
||||
// TypedEventEmitter methods:
|
||||
on(event: MembershipManagerEvent.StatusChanged, listener: (oldStatus: Status, newStatus: Status) => void): this;
|
||||
off(event: MembershipManagerEvent.StatusChanged, listener: (oldStatus: Status, newStatus: Status) => void): this;
|
||||
}
|
||||
@@ -27,7 +27,8 @@ import { type Focus } from "./focus.ts";
|
||||
import { isLivekitFocusActive } from "./LivekitFocus.ts";
|
||||
import { type MembershipConfig } from "./MatrixRTCSession.ts";
|
||||
import { type EmptyObject } from "../@types/common.ts";
|
||||
import { type IMembershipManager } from "./NewMembershipManager.ts";
|
||||
import { Status } from "./types.ts";
|
||||
import type { IMembershipManager, MembershipManagerEvent } from "./IMembershipManager.ts";
|
||||
|
||||
/**
|
||||
* This internal class is used by the MatrixRTCSession to manage the local user's own membership of the session.
|
||||
@@ -103,9 +104,35 @@ export class LegacyMembershipManager implements IMembershipManager {
|
||||
private getOldestMembership: () => CallMembership | undefined,
|
||||
) {}
|
||||
|
||||
public off(
|
||||
event: MembershipManagerEvent.StatusChanged,
|
||||
listener: (oldStatus: Status, newStatus: Status) => void,
|
||||
): this {
|
||||
logger.error("off is not implemented on LegacyMembershipManager");
|
||||
return this;
|
||||
}
|
||||
|
||||
public on(
|
||||
event: MembershipManagerEvent.StatusChanged,
|
||||
listener: (oldStatus: Status, newStatus: Status) => void,
|
||||
): this {
|
||||
logger.error("on is not implemented on LegacyMembershipManager");
|
||||
return this;
|
||||
}
|
||||
|
||||
public isJoined(): boolean {
|
||||
return this.relativeExpiry !== undefined;
|
||||
}
|
||||
public isActivated(): boolean {
|
||||
return this.isJoined();
|
||||
}
|
||||
/**
|
||||
* Unimplemented
|
||||
* @returns Status.Unknown
|
||||
*/
|
||||
public get status(): Status {
|
||||
return Status.Unknown;
|
||||
}
|
||||
|
||||
public join(fociPreferred: Focus[], fociActive?: Focus): void {
|
||||
this.ownFocusActive = fociActive;
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger as rootLogger } from "../logger.ts";
|
||||
import { type Logger, logger as rootLogger } from "../logger.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { EventTimeline } from "../models/event-timeline.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
@@ -24,13 +24,20 @@ import { CallMembership } from "./CallMembership.ts";
|
||||
import { RoomStateEvent } from "../models/room-state.ts";
|
||||
import { type Focus } from "./focus.ts";
|
||||
import { KnownMembership } from "../@types/membership.ts";
|
||||
import { type MatrixEvent } from "../models/event.ts";
|
||||
import { MembershipManager, type IMembershipManager } from "./NewMembershipManager.ts";
|
||||
import { EncryptionManager, type IEncryptionManager, type Statistics } from "./EncryptionManager.ts";
|
||||
import { MembershipManager } from "./NewMembershipManager.ts";
|
||||
import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
|
||||
import { LegacyMembershipManager } from "./LegacyMembershipManager.ts";
|
||||
import { logDurationSync } from "../utils.ts";
|
||||
|
||||
const logger = rootLogger.getChild("MatrixRTCSession");
|
||||
import { type Statistics } from "./types.ts";
|
||||
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
|
||||
import type { IMembershipManager } from "./IMembershipManager.ts";
|
||||
import {
|
||||
RoomAndToDeviceEvents,
|
||||
type RoomAndToDeviceEventsHandlerMap,
|
||||
RoomAndToDeviceTransport,
|
||||
} from "./RoomAndToDeviceKeyTransport.ts";
|
||||
import { TypedReEmitter } from "../ReEmitter.ts";
|
||||
import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
|
||||
|
||||
export enum MatrixRTCSessionEvent {
|
||||
// A member joined, left, or updated a property of their membership.
|
||||
@@ -123,6 +130,11 @@ export interface MembershipConfig {
|
||||
* The maximum number of retries that the manager will do for delayed event sending/updating and state event sending when a network error occurs.
|
||||
*/
|
||||
maximumNetworkErrorRetryCount?: number;
|
||||
|
||||
/**
|
||||
* If true, use the new to-device transport for sending encryption keys.
|
||||
*/
|
||||
useExperimentalToDeviceTransport?: boolean;
|
||||
}
|
||||
|
||||
export interface EncryptionConfig {
|
||||
@@ -156,12 +168,15 @@ export type JoinSessionConfig = MembershipConfig & EncryptionConfig;
|
||||
* A MatrixRTCSession manages the membership & properties of a MatrixRTC session.
|
||||
* This class doesn't deal with media at all, just membership & properties of a session.
|
||||
*/
|
||||
export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, MatrixRTCSessionEventHandlerMap> {
|
||||
export class MatrixRTCSession extends TypedEventEmitter<
|
||||
MatrixRTCSessionEvent | RoomAndToDeviceEvents,
|
||||
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap
|
||||
> {
|
||||
private membershipManager?: IMembershipManager;
|
||||
private encryptionManager: IEncryptionManager;
|
||||
private encryptionManager?: IEncryptionManager;
|
||||
// The session Id of the call, this is the call_id of the call Member event.
|
||||
private _callId: string | undefined;
|
||||
|
||||
private logger: Logger;
|
||||
/**
|
||||
* This timeout is responsible to track any expiration. We need to know when we have to start
|
||||
* to ignore other call members. There is no callback for this. This timeout will always be configured to
|
||||
@@ -172,9 +187,15 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
/**
|
||||
* The statistics for this session.
|
||||
*/
|
||||
public get statistics(): Statistics {
|
||||
return this.encryptionManager.statistics;
|
||||
}
|
||||
public statistics: Statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The callId (sessionId) of the call.
|
||||
@@ -192,6 +213,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
public static callMembershipsForRoom(
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
|
||||
): CallMembership[] {
|
||||
const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`);
|
||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
if (!roomState) {
|
||||
logger.warn("Couldn't get state for room " + room.roomId);
|
||||
@@ -295,24 +317,24 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
| "_unstable_updateDelayedEvent"
|
||||
| "sendEvent"
|
||||
| "cancelPendingEvent"
|
||||
| "encryptAndSendToDevice"
|
||||
| "off"
|
||||
| "on"
|
||||
| "decryptEventIfNeeded"
|
||||
>,
|
||||
private roomSubset: Pick<
|
||||
Room,
|
||||
"getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState" | "on" | "off"
|
||||
>,
|
||||
private roomSubset: Pick<Room, "getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState">,
|
||||
public memberships: CallMembership[],
|
||||
) {
|
||||
super();
|
||||
this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`);
|
||||
this._callId = memberships[0]?.callId;
|
||||
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
// TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager
|
||||
roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
||||
this.setExpiryTimer();
|
||||
this.encryptionManager = new EncryptionManager(
|
||||
this.client,
|
||||
this.roomSubset,
|
||||
() => this.memberships,
|
||||
(keyBin: Uint8Array<ArrayBufferLike>, encryptionKeyIndex: number, participantId: string) => {
|
||||
this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyBin, encryptionKeyIndex, participantId);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -335,6 +357,10 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
roomState?.off(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
||||
}
|
||||
private reEmitter = new TypedReEmitter<
|
||||
MatrixRTCSessionEvent | RoomAndToDeviceEvents,
|
||||
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap
|
||||
>(this);
|
||||
|
||||
/**
|
||||
* Announces this user and device as joined to the MatrixRTC session,
|
||||
@@ -352,24 +378,55 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
*/
|
||||
public joinRoomSession(fociPreferred: Focus[], fociActive?: Focus, joinConfig?: JoinSessionConfig): void {
|
||||
if (this.isJoined()) {
|
||||
logger.info(`Already joined to session in room ${this.roomSubset.roomId}: ignoring join call`);
|
||||
this.logger.info(`Already joined to session in room ${this.roomSubset.roomId}: ignoring join call`);
|
||||
return;
|
||||
} else {
|
||||
// Create MembershipManager
|
||||
// Create MembershipManager and pass the RTCSession logger (with room id info)
|
||||
if (joinConfig?.useNewMembershipManager ?? false) {
|
||||
this.membershipManager = new MembershipManager(joinConfig, this.roomSubset, this.client, () =>
|
||||
this.getOldestMembership(),
|
||||
this.membershipManager = new MembershipManager(
|
||||
joinConfig,
|
||||
this.roomSubset,
|
||||
this.client,
|
||||
() => this.getOldestMembership(),
|
||||
this.logger,
|
||||
);
|
||||
} else {
|
||||
this.membershipManager = new LegacyMembershipManager(joinConfig, this.roomSubset, this.client, () =>
|
||||
this.getOldestMembership(),
|
||||
);
|
||||
}
|
||||
// Create Encryption manager
|
||||
let transport;
|
||||
if (joinConfig?.useExperimentalToDeviceTransport) {
|
||||
this.logger.info("Using to-device with room fallback transport for encryption keys");
|
||||
const [uId, dId] = [this.client.getUserId()!, this.client.getDeviceId()!];
|
||||
const [room, client, statistics] = [this.roomSubset, this.client, this.statistics];
|
||||
// Deprecate RoomKeyTransport: only ToDeviceKeyTransport is needed once deprecated
|
||||
const roomKeyTransport = new RoomKeyTransport(room, client, statistics);
|
||||
const toDeviceTransport = new ToDeviceKeyTransport(uId, dId, room.roomId, client, statistics);
|
||||
transport = new RoomAndToDeviceTransport(toDeviceTransport, roomKeyTransport, this.logger);
|
||||
|
||||
// Expose the changes so the ui can display the currently used transport.
|
||||
this.reEmitter.reEmit(transport, [RoomAndToDeviceEvents.EnabledTransportsChanged]);
|
||||
} else {
|
||||
transport = new RoomKeyTransport(this.roomSubset, this.client, this.statistics);
|
||||
}
|
||||
this.encryptionManager = new EncryptionManager(
|
||||
this.client.getUserId()!,
|
||||
this.client.getDeviceId()!,
|
||||
() => this.memberships,
|
||||
transport,
|
||||
this.statistics,
|
||||
(keyBin: Uint8Array, encryptionKeyIndex: number, participantId: string) => {
|
||||
this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyBin, encryptionKeyIndex, participantId);
|
||||
},
|
||||
this.logger,
|
||||
);
|
||||
}
|
||||
|
||||
// Join!
|
||||
this.membershipManager!.join(fociPreferred, fociActive, (e) => {
|
||||
logger.error("MembershipManager encountered an unrecoverable error: ", e);
|
||||
this.logger.error("MembershipManager encountered an unrecoverable error: ", e);
|
||||
this.emit(MatrixRTCSessionEvent.MembershipManagerError, e);
|
||||
this.emit(MatrixRTCSessionEvent.JoinStateChanged, this.isJoined());
|
||||
});
|
||||
@@ -390,13 +447,13 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
*/
|
||||
public async leaveRoomSession(timeout: number | undefined = undefined): Promise<boolean> {
|
||||
if (!this.isJoined()) {
|
||||
logger.info(`Not joined to session in room ${this.roomSubset.roomId}: ignoring leave call`);
|
||||
this.logger.info(`Not joined to session in room ${this.roomSubset.roomId}: ignoring leave call`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info(`Leaving call session in room ${this.roomSubset.roomId}`);
|
||||
this.logger.info(`Leaving call session in room ${this.roomSubset.roomId}`);
|
||||
|
||||
this.encryptionManager.leave();
|
||||
this.encryptionManager!.leave();
|
||||
|
||||
const leavePromise = this.membershipManager!.leave(timeout);
|
||||
this.emit(MatrixRTCSessionEvent.JoinStateChanged, false);
|
||||
@@ -436,7 +493,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
* the keys.
|
||||
*/
|
||||
public reemitEncryptionKeys(): void {
|
||||
this.encryptionManager.getEncryptionKeys().forEach((keys, participantId) => {
|
||||
this.encryptionManager?.getEncryptionKeys().forEach((keys, participantId) => {
|
||||
keys.forEach((key, index) => {
|
||||
this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, key.key, index, participantId);
|
||||
});
|
||||
@@ -451,7 +508,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
*/
|
||||
public getEncryptionKeys(): IterableIterator<[string, Array<Uint8Array>]> {
|
||||
const keys =
|
||||
this.encryptionManager.getEncryptionKeys() ??
|
||||
this.encryptionManager?.getEncryptionKeys() ??
|
||||
new Map<string, Array<{ key: Uint8Array; timestamp: number }>>();
|
||||
// the returned array doesn't contain the timestamps
|
||||
return Array.from(keys.entries())
|
||||
@@ -483,25 +540,6 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process `m.call.encryption_keys` events to track the encryption keys for call participants.
|
||||
* This should be called each time the relevant event is received from a room timeline.
|
||||
* If the event is malformed then it will be logged and ignored.
|
||||
*
|
||||
* @param event the event to process
|
||||
*/
|
||||
public onCallEncryption = (event: MatrixEvent): void => {
|
||||
this.encryptionManager.onCallEncryptionEventReceived(event);
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated use onRoomMemberUpdate or onRTCSessionMemberUpdate instead. this should be called when any membership in the call is updated
|
||||
* the old name might have implied to only need to call this when your own membership changes.
|
||||
*/
|
||||
public onMembershipUpdate = (): void => {
|
||||
this.recalculateSessionMembers();
|
||||
};
|
||||
|
||||
/**
|
||||
* Call this when the Matrix room members have changed.
|
||||
*/
|
||||
@@ -534,8 +572,8 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
oldMemberships.some((m, i) => !CallMembership.equal(m, this.memberships[i]));
|
||||
|
||||
if (changed) {
|
||||
logger.info(`Memberships for call in room ${this.roomSubset.roomId} have changed: emitting`);
|
||||
logDurationSync(logger, "emit MatrixRTCSessionEvent.MembershipsChanged", () => {
|
||||
this.logger.info(`Memberships for call in room ${this.roomSubset.roomId} have changed: emitting`);
|
||||
logDurationSync(this.logger, "emit MatrixRTCSessionEvent.MembershipsChanged", () => {
|
||||
this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships);
|
||||
});
|
||||
|
||||
@@ -543,7 +581,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
}
|
||||
// This also needs to be done if `changed` = false
|
||||
// A member might have updated their fingerprint (created_ts)
|
||||
void this.encryptionManager.onMembershipsUpdate(oldMemberships);
|
||||
void this.encryptionManager?.onMembershipsUpdate(oldMemberships);
|
||||
|
||||
this.setExpiryTimer();
|
||||
};
|
||||
|
||||
@@ -14,17 +14,15 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger as rootLogger } from "../logger.ts";
|
||||
import { logger as rootLogger, type Logger } from "../logger.ts";
|
||||
import { type MatrixClient, ClientEvent } from "../client.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { type Room, RoomEvent } from "../models/room.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { type RoomState, RoomStateEvent } from "../models/room-state.ts";
|
||||
import { type MatrixEvent } from "../models/event.ts";
|
||||
import { MatrixRTCSession } from "./MatrixRTCSession.ts";
|
||||
import { EventType } from "../@types/event.ts";
|
||||
|
||||
const logger = rootLogger.getChild("MatrixRTCSessionManager");
|
||||
|
||||
export enum MatrixRTCSessionManagerEvents {
|
||||
// A member has joined the MatrixRTC session, creating an active session in a room where there wasn't previously
|
||||
SessionStarted = "session_started",
|
||||
@@ -50,8 +48,10 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
// longer the correct session object for the room.
|
||||
private roomSessions = new Map<string, MatrixRTCSession>();
|
||||
|
||||
private logger: Logger;
|
||||
public constructor(private client: MatrixClient) {
|
||||
super();
|
||||
this.logger = rootLogger.getChild("[MatrixRTCSessionManager]");
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
@@ -65,7 +65,6 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
}
|
||||
|
||||
this.client.on(ClientEvent.Room, this.onRoom);
|
||||
this.client.on(RoomEvent.Timeline, this.onTimeline);
|
||||
this.client.on(RoomStateEvent.Events, this.onRoomState);
|
||||
}
|
||||
|
||||
@@ -76,7 +75,6 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
this.roomSessions.clear();
|
||||
|
||||
this.client.off(ClientEvent.Room, this.onRoom);
|
||||
this.client.off(RoomEvent.Timeline, this.onTimeline);
|
||||
this.client.off(RoomStateEvent.Events, this.onRoomState);
|
||||
}
|
||||
|
||||
@@ -100,37 +98,6 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
return this.roomSessions.get(room.roomId)!;
|
||||
}
|
||||
|
||||
private async consumeCallEncryptionEvent(event: MatrixEvent, isRetry = false): Promise<void> {
|
||||
await this.client.decryptEventIfNeeded(event);
|
||||
if (event.isDecryptionFailure()) {
|
||||
if (!isRetry) {
|
||||
logger.warn(
|
||||
`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason} will retry once only`,
|
||||
);
|
||||
// retry after 1 second. After this we give up.
|
||||
setTimeout(() => void this.consumeCallEncryptionEvent(event, true), 1000);
|
||||
} else {
|
||||
logger.warn(`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason}`);
|
||||
}
|
||||
return;
|
||||
} else if (isRetry) {
|
||||
logger.info(`Decryption succeeded for event ${event.getId()} after retry`);
|
||||
}
|
||||
|
||||
if (event.getType() !== EventType.CallEncryptionKeysPrefix) return Promise.resolve();
|
||||
|
||||
const room = this.client.getRoom(event.getRoomId());
|
||||
if (!room) {
|
||||
logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
this.getRoomSession(room).onCallEncryption(event);
|
||||
}
|
||||
private onTimeline = (event: MatrixEvent): void => {
|
||||
void this.consumeCallEncryptionEvent(event);
|
||||
};
|
||||
|
||||
private onRoom = (room: Room): void => {
|
||||
this.refreshRoom(room);
|
||||
};
|
||||
@@ -138,7 +105,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
private onRoomState = (event: MatrixEvent, _state: RoomState): void => {
|
||||
const room = this.client.getRoom(event.getRoomId());
|
||||
if (!room) {
|
||||
logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
|
||||
this.logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -149,17 +116,23 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
|
||||
private refreshRoom(room: Room): void {
|
||||
const isNewSession = !this.roomSessions.has(room.roomId);
|
||||
const sess = this.getRoomSession(room);
|
||||
const session = this.getRoomSession(room);
|
||||
|
||||
const wasActiveAndKnown = sess.memberships.length > 0 && !isNewSession;
|
||||
const wasActiveAndKnown = session.memberships.length > 0 && !isNewSession;
|
||||
// This needs to be here and the event listener cannot be setup in the MatrixRTCSession,
|
||||
// because we need the update to happen between:
|
||||
// wasActiveAndKnown = session.memberships.length > 0 and
|
||||
// nowActive = session.memberships.length
|
||||
// Alternatively we would need to setup some event emission when the RTC session ended.
|
||||
session.onRTCSessionMemberUpdate();
|
||||
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
|
||||
const nowActive = sess.memberships.length > 0;
|
||||
const nowActive = session.memberships.length > 0;
|
||||
|
||||
if (wasActiveAndKnown && !nowActive) {
|
||||
this.logger.trace(`Session ended for ${room.roomId} (${session.memberships.length} members)`);
|
||||
this.emit(MatrixRTCSessionManagerEvents.SessionEnded, room.roomId, this.roomSessions.get(room.roomId)!);
|
||||
} else if (!wasActiveAndKnown && nowActive) {
|
||||
this.logger.trace(`Session started for ${room.roomId} (${session.memberships.length} members)`);
|
||||
this.emit(MatrixRTCSessionManagerEvents.SessionStarted, room.roomId, this.roomSessions.get(room.roomId)!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,115 +19,83 @@ import { UpdateDelayedEventAction } from "../@types/requests.ts";
|
||||
import { type MatrixClient } from "../client.ts";
|
||||
import { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
|
||||
import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts";
|
||||
import { logger as rootLogger } from "../logger.ts";
|
||||
import { type Logger, logger as rootLogger } from "../logger.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { defer, type IDeferred } from "../utils.ts";
|
||||
import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "./CallMembership.ts";
|
||||
import { type Focus } from "./focus.ts";
|
||||
import { isMyMembership, Status } from "./types.ts";
|
||||
import { isLivekitFocusActive } from "./LivekitFocus.ts";
|
||||
import { type MembershipConfig } from "./MatrixRTCSession.ts";
|
||||
import { ActionScheduler, type ActionUpdate } from "./NewMembershipManagerActionScheduler.ts";
|
||||
|
||||
const logger = rootLogger.getChild("MatrixRTCSession");
|
||||
|
||||
/**
|
||||
* This interface defines what a MembershipManager uses and exposes.
|
||||
* This interface is what we use to write tests and allows changing the actual implementation
|
||||
* without breaking tests because of some internal method renaming.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface IMembershipManager {
|
||||
/**
|
||||
* If we are trying to join, or have successfully joined the session.
|
||||
* It does not reflect if the room state is already configured to represent us being joined.
|
||||
* It only means that the Manager should be trying to connect or to disconnect running.
|
||||
* The Manager is still running right after isJoined becomes false to send the disconnect events.
|
||||
* (A more accurate name would be `isActivated`)
|
||||
* @returns true if we intend to be participating in the MatrixRTC session
|
||||
*/
|
||||
isJoined(): boolean;
|
||||
/**
|
||||
* Start sending all necessary events to make this user participate in the RTC session.
|
||||
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
|
||||
* @param fociActive the active focus to use in the joined RTC membership event.
|
||||
* @throws can throw if it exceeds a configured maximum retry.
|
||||
*/
|
||||
join(fociPreferred: Focus[], fociActive?: Focus, onError?: (error: unknown) => void): void;
|
||||
/**
|
||||
* Send all necessary events to make this user leave the RTC session.
|
||||
* @param timeout the maximum duration in ms until the promise is forced to resolve.
|
||||
* @returns It resolves with true in case the leave was sent successfully.
|
||||
* It resolves with false in case we hit the timeout before sending successfully.
|
||||
*/
|
||||
leave(timeout?: number): Promise<boolean>;
|
||||
/**
|
||||
* Call this if the MatrixRTC session members have changed.
|
||||
*/
|
||||
onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void>;
|
||||
/**
|
||||
* The used active focus in the currently joined session.
|
||||
* @returns the used active focus in the currently joined session or undefined if not joined.
|
||||
*/
|
||||
getActiveFocus(): Focus | undefined;
|
||||
}
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import {
|
||||
MembershipManagerEvent,
|
||||
type IMembershipManager,
|
||||
type MembershipManagerEventHandlerMap,
|
||||
} from "./IMembershipManager.ts";
|
||||
|
||||
/* MembershipActionTypes:
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│SendFirstDelayedEvent│
|
||||
└─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
┌────────────│SendJoinEvent│────────────┐
|
||||
│ └─────────────┘ │
|
||||
│ ┌─────┐ ┌──────┐ │ ┌──────┐
|
||||
▼ ▼ │ │ ▼ ▼ ▼ │
|
||||
|
||||
On Join: ───────────────┐ ┌───────────────(1)───────────┐
|
||||
▼ ▼ │
|
||||
┌────────────────┐ │
|
||||
│SendDelayedEvent│ ──────(2)───┐ │
|
||||
└────────────────┘ │ │
|
||||
│(3) │ │
|
||||
▼ │ │
|
||||
┌─────────────┐ │ │
|
||||
┌──────(4)───│SendJoinEvent│────(4)─────┐ │ │
|
||||
│ └─────────────┘ │ │ │
|
||||
│ ┌─────┐ ┌──────┐ │ │ │
|
||||
▼ ▼ │ │ ▼ ▼ ▼ │
|
||||
┌────────────┐ │ │ ┌───────────────────┐ │
|
||||
│UpdateExpiry│ │ │ │RestartDelayedEvent│ │
|
||||
│UpdateExpiry│ (s) (s)|RestartDelayedEvent│ │
|
||||
└────────────┘ │ │ └───────────────────┘ │
|
||||
│ │ │ │ │ │
|
||||
└─────┘ └──────┘ │ │
|
||||
│ │
|
||||
┌────────────────────┐ │ │
|
||||
│SendMainDelayedEvent│◄───────┘ │
|
||||
└───────────────────┬┘ │
|
||||
│ │
|
||||
└─────────────────────┘
|
||||
STOP ALL ABOVE
|
||||
│ │ │ │ │ │
|
||||
└─────┘ └──────┘ └───────┘
|
||||
|
||||
On Leave: ───────── STOP ALL ABOVE
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
│ SendScheduledDelayedLeaveEvent│
|
||||
└───────────────────────────────┘
|
||||
│
|
||||
┌────────────────────────────────┐
|
||||
│ SendScheduledDelayedLeaveEvent │
|
||||
└────────────────────────────────┘
|
||||
│(5)
|
||||
▼
|
||||
┌──────────────┐
|
||||
│SendLeaveEvent│
|
||||
└──────────────┘
|
||||
|
||||
(1) [Not found error] results in resending the delayed event
|
||||
(2) [hasMemberEvent = true] Sending the delayed event if we
|
||||
already have a call member event results jumping to the
|
||||
RestartDelayedEvent loop directly
|
||||
(3) [hasMemberEvent = false] if there is not call member event
|
||||
sending it is the next step
|
||||
(4) Both (UpdateExpiry and RestartDelayedEvent) actions are
|
||||
scheduled when successfully sending the state event
|
||||
(5) Only if delayed event sending failed (fallback)
|
||||
(s) Successful restart/resend
|
||||
*/
|
||||
|
||||
/**
|
||||
* The different types of actions the MembershipManager can take.
|
||||
* @internal
|
||||
*/
|
||||
export enum MembershipActionType {
|
||||
SendFirstDelayedEvent = "SendFirstDelayedEvent",
|
||||
SendDelayedEvent = "SendDelayedEvent",
|
||||
// -> MembershipActionType.SendJoinEvent if successful
|
||||
// -> DelayedLeaveActionType.SendFirstDelayedEvent on error, retry sending the first delayed event.
|
||||
// -> DelayedLeaveActionType.SendDelayedEvent on error, retry sending the first delayed event.
|
||||
// -> DelayedLeaveActionType.RestartDelayedEvent on success start updating the delayed event
|
||||
SendJoinEvent = "SendJoinEvent",
|
||||
// -> MembershipActionType.SendJoinEvent if we run into a rate limit and need to retry
|
||||
// -> MembershipActionType.Update if we successfully send the join event then schedule the expire event update
|
||||
// -> DelayedLeaveActionType.RestartDelayedEvent to recheck the delayed event
|
||||
RestartDelayedEvent = "RestartDelayedEvent",
|
||||
// -> DelayedLeaveActionType.SendMainDelayedEvent on missing delay id but there is a rtc state event
|
||||
// -> DelayedLeaveActionType.SendFirstDelayedEvent on missing delay id and there is no state event
|
||||
// -> DelayedLeaveActionType.SendDelayedEvent on missing delay id and there is no state event
|
||||
// -> DelayedLeaveActionType.RestartDelayedEvent on success we schedule the next restart
|
||||
UpdateExpiry = "UpdateExpiry",
|
||||
// -> MembershipActionType.Update if the timeout has passed so the next update is required.
|
||||
SendMainDelayedEvent = "SendMainDelayedEvent",
|
||||
// -> DelayedLeaveActionType.RestartDelayedEvent on success start updating the delayed event
|
||||
// -> DelayedLeaveActionType.SendMainDelayedEvent on error try again
|
||||
SendScheduledDelayedLeaveEvent = "SendScheduledDelayedLeaveEvent",
|
||||
// -> MembershipActionType.SendLeaveEvent on failiour (not found) we need to send the leave manually and cannot use the scheduled delayed event
|
||||
// -> DelayedLeaveActionType.SendScheduledDelayedLeaveEvent on error we try again.
|
||||
@@ -138,7 +106,7 @@ export enum MembershipActionType {
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export interface ActionSchedulerState {
|
||||
export interface MembershipManagerState {
|
||||
/** The delayId we got when successfully sending the delayed leave event.
|
||||
* Gets set to undefined if the server claims it cannot find the delayed event anymore. */
|
||||
delayId?: string;
|
||||
@@ -158,17 +126,6 @@ export interface ActionSchedulerState {
|
||||
networkErrorRetries: Map<MembershipActionType, number>;
|
||||
}
|
||||
|
||||
enum Status {
|
||||
Disconnected = "Disconnected",
|
||||
Connecting = "Connecting",
|
||||
ConnectingFailed = "ConnectingFailed",
|
||||
Connected = "Connected",
|
||||
Reconnecting = "Reconnecting",
|
||||
Disconnecting = "Disconnecting",
|
||||
Stuck = "Stuck",
|
||||
Unknown = "Unknown",
|
||||
}
|
||||
|
||||
/**
|
||||
* This class is responsible for sending all events relating to the own membership of a matrixRTC call.
|
||||
* It has the following tasks:
|
||||
@@ -182,11 +139,20 @@ enum Status {
|
||||
* - Stop the timer for the delay refresh
|
||||
* - Stop the timer for updating the state event
|
||||
*/
|
||||
export class MembershipManager implements IMembershipManager {
|
||||
export class MembershipManager
|
||||
extends TypedEventEmitter<MembershipManagerEvent, MembershipManagerEventHandlerMap>
|
||||
implements IMembershipManager
|
||||
{
|
||||
private activated = false;
|
||||
public isJoined(): boolean {
|
||||
private logger: Logger;
|
||||
|
||||
public isActivated(): boolean {
|
||||
return this.activated;
|
||||
}
|
||||
// DEPRECATED use isActivated
|
||||
public isJoined(): boolean {
|
||||
return this.isActivated();
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the MembershipManager in a state where it tries to be joined.
|
||||
@@ -198,30 +164,34 @@ export class MembershipManager implements IMembershipManager {
|
||||
*/
|
||||
public join(fociPreferred: Focus[], focusActive?: Focus, onError?: (error: unknown) => void): void {
|
||||
if (this.scheduler.running) {
|
||||
logger.error("MembershipManager is already running. Ignoring join request.");
|
||||
this.logger.error("MembershipManager is already running. Ignoring join request.");
|
||||
return;
|
||||
}
|
||||
this.fociPreferred = fociPreferred;
|
||||
this.focusActive = focusActive;
|
||||
this.leavePromiseDefer = undefined;
|
||||
this.leavePromiseResolvers = undefined;
|
||||
this.activated = true;
|
||||
|
||||
this.oldStatus = this.status;
|
||||
this.state = MembershipManager.defaultState;
|
||||
|
||||
this.scheduler
|
||||
.startWithJoin()
|
||||
.then(() => {
|
||||
if (!this.scheduler.running) {
|
||||
this.leavePromiseDefer?.resolve(true);
|
||||
this.leavePromiseDefer = undefined;
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error("MembershipManager stopped because: ", e);
|
||||
this.logger.error("MembershipManager stopped because: ", e);
|
||||
onError?.(e);
|
||||
})
|
||||
// Should already be set to false when calling `leave` in non error cases.
|
||||
.finally(() => (this.activated = false));
|
||||
.finally(() => {
|
||||
// Should already be set to false when calling `leave` in non error cases.
|
||||
this.activated = false;
|
||||
// Here the scheduler is not running anymore so we the `membershipLoopHandler` is not called to emit.
|
||||
if (this.oldStatus && this.oldStatus !== this.status) {
|
||||
this.emit(MembershipManagerEvent.StatusChanged, this.oldStatus, this.status);
|
||||
}
|
||||
if (!this.scheduler.running) {
|
||||
this.leavePromiseResolvers?.resolve(true);
|
||||
this.leavePromiseResolvers = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,37 +201,36 @@ export class MembershipManager implements IMembershipManager {
|
||||
*/
|
||||
public leave(timeout?: number): Promise<boolean> {
|
||||
if (!this.scheduler.running) {
|
||||
logger.warn("Called MembershipManager.leave() even though the MembershipManager is not running");
|
||||
this.logger.warn("Called MembershipManager.leave() even though the MembershipManager is not running");
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
// We use the promise to track if we already scheduled a leave event
|
||||
// So we do not check scheduler.actions/scheduler.insertions
|
||||
if (!this.leavePromiseDefer) {
|
||||
if (!this.leavePromiseResolvers) {
|
||||
// reset scheduled actions so we will not do any new actions.
|
||||
this.leavePromiseDefer = defer<boolean>();
|
||||
this.leavePromiseResolvers = defer<boolean>();
|
||||
this.activated = false;
|
||||
this.scheduler.initiateLeave();
|
||||
if (timeout) setTimeout(() => this.leavePromiseDefer?.resolve(false), timeout);
|
||||
if (timeout) setTimeout(() => this.leavePromiseResolvers?.resolve(false), timeout);
|
||||
}
|
||||
return this.leavePromiseDefer.promise;
|
||||
return this.leavePromiseResolvers.promise;
|
||||
}
|
||||
private leavePromiseDefer?: IDeferred<boolean>;
|
||||
private leavePromiseResolvers?: IDeferred<boolean>;
|
||||
|
||||
public async onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void> {
|
||||
const isMyMembership = (m: CallMembership): boolean =>
|
||||
m.sender === this.client.getUserId() && m.deviceId === this.client.getDeviceId();
|
||||
|
||||
if (this.isJoined() && !memberships.some(isMyMembership)) {
|
||||
const userId = this.client.getUserId();
|
||||
const deviceId = this.client.getDeviceId();
|
||||
if (userId && deviceId && this.isJoined() && !memberships.some((m) => isMyMembership(m, userId, deviceId))) {
|
||||
// If one of these actions are scheduled or are getting inserted in the next iteration, we should already
|
||||
// take care of our missing membership.
|
||||
const sendingMembershipActions = [
|
||||
MembershipActionType.SendFirstDelayedEvent,
|
||||
MembershipActionType.SendDelayedEvent,
|
||||
MembershipActionType.SendJoinEvent,
|
||||
];
|
||||
logger.warn("Missing own membership: force re-join");
|
||||
this.logger.warn("Missing own membership: force re-join");
|
||||
if (this.scheduler.actions.find((a) => sendingMembershipActions.includes(a.type as MembershipActionType))) {
|
||||
logger.error(
|
||||
this.logger.error(
|
||||
"NewMembershipManger tried adding another `SendFirstDelayedEvent` actions even though we already have one in the Queue\nActionQueueOnMemberUpdate:",
|
||||
this.scheduler.actions,
|
||||
);
|
||||
@@ -283,7 +252,7 @@ export class MembershipManager implements IMembershipManager {
|
||||
return oldestMembership?.getPreferredFoci()[0];
|
||||
}
|
||||
} else {
|
||||
logger.warn("Unknown own ActiveFocus type. This makes it impossible to connect to an SFU.");
|
||||
this.logger.warn("Unknown own ActiveFocus type. This makes it impossible to connect to an SFU.");
|
||||
}
|
||||
} else {
|
||||
// We do not understand the membership format (could be legacy). We default to oldestMembership
|
||||
@@ -312,18 +281,41 @@ export class MembershipManager implements IMembershipManager {
|
||||
| "_unstable_updateDelayedEvent"
|
||||
>,
|
||||
private getOldestMembership: () => CallMembership | undefined,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
super();
|
||||
this.logger = (parentLogger ?? rootLogger).getChild(`[NewMembershipManager]`);
|
||||
const [userId, deviceId] = [this.client.getUserId(), this.client.getDeviceId()];
|
||||
if (userId === null) throw Error("Missing userId in client");
|
||||
if (deviceId === null) throw Error("Missing deviceId in client");
|
||||
this.deviceId = deviceId;
|
||||
this.stateKey = this.makeMembershipStateKey(userId, deviceId);
|
||||
this.state = MembershipManager.defaultState;
|
||||
this.scheduler = new ActionScheduler((type): Promise<ActionUpdate> => {
|
||||
if (this.oldStatus) {
|
||||
// we put this at the beginning of the actions scheduler loop handle callback since it is a loop this
|
||||
// is equivalent to running it at the end of the loop. (just after applying the status/action list changes)
|
||||
// This order is required because this method needs to return the action updates.
|
||||
this.logger.debug(
|
||||
`MembershipManager applied action changes. Status: ${this.oldStatus} -> ${this.status}`,
|
||||
);
|
||||
if (this.oldStatus !== this.status) {
|
||||
this.emit(MembershipManagerEvent.StatusChanged, this.oldStatus, this.status);
|
||||
}
|
||||
}
|
||||
this.oldStatus = this.status;
|
||||
this.logger.debug(`MembershipManager before processing action. status=${this.oldStatus}`);
|
||||
return this.membershipLoopHandler(type);
|
||||
}, this.logger);
|
||||
}
|
||||
|
||||
// scheduler
|
||||
private oldStatus?: Status;
|
||||
private scheduler: ActionScheduler;
|
||||
|
||||
// MembershipManager mutable state.
|
||||
private state: ActionSchedulerState;
|
||||
private static get defaultState(): ActionSchedulerState {
|
||||
private state: MembershipManagerState;
|
||||
private static get defaultState(): MembershipManagerState {
|
||||
return {
|
||||
hasMemberStateEvent: false,
|
||||
delayId: undefined,
|
||||
@@ -376,32 +368,18 @@ export class MembershipManager implements IMembershipManager {
|
||||
return this.joinConfig?.maximumNetworkErrorRetryCount ?? 10;
|
||||
}
|
||||
|
||||
// Scheduler:
|
||||
private oldStatus?: Status;
|
||||
private scheduler = new ActionScheduler((type): Promise<ActionUpdate> => {
|
||||
if (this.oldStatus) {
|
||||
// we put this at the beginning of the actions scheduler loop handle callback since it is a loop this
|
||||
// is equivalent to running it at the end of the loop. (just after applying the status/action list changes)
|
||||
logger.debug(`MembershipManager applied action changes. Status: ${this.oldStatus} -> ${this.status}`);
|
||||
}
|
||||
this.oldStatus = this.status;
|
||||
logger.debug(`MembershipManager before processing action. status=${this.oldStatus}`);
|
||||
return this.membershipLoopHandler(type);
|
||||
});
|
||||
|
||||
// LOOP HANDLER:
|
||||
private async membershipLoopHandler(type: MembershipActionType): Promise<ActionUpdate> {
|
||||
this.oldStatus = this.status;
|
||||
switch (type) {
|
||||
case MembershipActionType.SendFirstDelayedEvent: {
|
||||
case MembershipActionType.SendDelayedEvent: {
|
||||
// Before we start we check if we come from a state where we have a delay id.
|
||||
if (!this.state.delayId) {
|
||||
return this.sendFirstDelayedLeaveEvent(); // Normal case without any previous delayed id.
|
||||
return this.sendOrResendDelayedLeaveEvent(); // Normal case without any previous delayed id.
|
||||
} else {
|
||||
// This can happen if someone else (or another client) removes our own membership event.
|
||||
// It will trigger `onRTCSessionMemberUpdate` queue `MembershipActionType.SendFirstDelayedEvent`.
|
||||
// We might still have our delayed event from the previous participation and dependent on the server this might not
|
||||
// get automatically removed if the state changes. Hence It would remove our membership unexpectedly shortly after the rejoin.
|
||||
// get removed automatically if the state changes. Hence, it would remove our membership unexpectedly shortly after the rejoin.
|
||||
//
|
||||
// In this block we will try to cancel this delayed event before setting up a new one.
|
||||
|
||||
@@ -411,17 +389,10 @@ export class MembershipManager implements IMembershipManager {
|
||||
case MembershipActionType.RestartDelayedEvent: {
|
||||
if (!this.state.delayId) {
|
||||
// Delay id got reset. This action was used to check if the hs canceled the delayed event when the join state got sent.
|
||||
return createInsertActionUpdate(
|
||||
this.state.hasMemberStateEvent
|
||||
? MembershipActionType.SendMainDelayedEvent
|
||||
: MembershipActionType.SendFirstDelayedEvent,
|
||||
);
|
||||
return createInsertActionUpdate(MembershipActionType.SendDelayedEvent);
|
||||
}
|
||||
return this.restartDelayedEvent(this.state.delayId);
|
||||
}
|
||||
case MembershipActionType.SendMainDelayedEvent: {
|
||||
return this.sendMainDelayedEvent();
|
||||
}
|
||||
case MembershipActionType.SendScheduledDelayedLeaveEvent: {
|
||||
// We are already good
|
||||
if (!this.state.hasMemberStateEvent) {
|
||||
@@ -452,7 +423,11 @@ export class MembershipManager implements IMembershipManager {
|
||||
}
|
||||
|
||||
// HANDLERS (used in the membershipLoopHandler)
|
||||
private async sendFirstDelayedLeaveEvent(): Promise<ActionUpdate> {
|
||||
private async sendOrResendDelayedLeaveEvent(): Promise<ActionUpdate> {
|
||||
// We can reach this at the start of a call (where we do not yet have a membership: state.hasMemberStateEvent=false)
|
||||
// or during a call if the state event canceled our delayed event or caused by an unexpected error that removed our delayed event.
|
||||
// (Another client could have canceled it, the homeserver might have removed/lost it due to a restart, ...)
|
||||
// In the `then` and `catch` block we treat both cases differently. "if (this.state.hasMemberStateEvent) {} else {}"
|
||||
return await this.client
|
||||
._unstable_sendDelayedStateEvent(
|
||||
this.room.roomId,
|
||||
@@ -465,27 +440,46 @@ export class MembershipManager implements IMembershipManager {
|
||||
)
|
||||
.then((response) => {
|
||||
// On success we reset retries and set delayId.
|
||||
this.state.rateLimitRetries.set(MembershipActionType.SendFirstDelayedEvent, 0);
|
||||
this.state.networkErrorRetries.set(MembershipActionType.SendFirstDelayedEvent, 0);
|
||||
this.resetRateLimitCounter(MembershipActionType.SendDelayedEvent);
|
||||
this.state.delayId = response.delay_id;
|
||||
return createInsertActionUpdate(MembershipActionType.SendJoinEvent);
|
||||
if (this.state.hasMemberStateEvent) {
|
||||
// This action was scheduled because the previous delayed event was cancelled
|
||||
// due to lack of https://github.com/element-hq/synapse/pull/17810
|
||||
return createInsertActionUpdate(
|
||||
MembershipActionType.RestartDelayedEvent,
|
||||
this.membershipKeepAlivePeriod,
|
||||
);
|
||||
} else {
|
||||
// This action was scheduled because we are in the process of joining
|
||||
return createInsertActionUpdate(MembershipActionType.SendJoinEvent);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
const repeatActionType = MembershipActionType.SendFirstDelayedEvent;
|
||||
const repeatActionType = MembershipActionType.SendDelayedEvent;
|
||||
if (this.manageMaxDelayExceededSituation(e)) {
|
||||
return createInsertActionUpdate(repeatActionType);
|
||||
}
|
||||
const update = this.actionUpdateFromErrors(e, repeatActionType, "sendDelayedStateEvent");
|
||||
if (update) return update;
|
||||
|
||||
// log and fall through
|
||||
if (this.isUnsupportedDelayedEndpoint(e)) {
|
||||
logger.info("Not using delayed event because the endpoint is not supported");
|
||||
if (this.state.hasMemberStateEvent) {
|
||||
// This action was scheduled because the previous delayed event was cancelled
|
||||
// due to lack of https://github.com/element-hq/synapse/pull/17810
|
||||
|
||||
// Don't do any other delayed event work if its not supported.
|
||||
if (this.isUnsupportedDelayedEndpoint(e)) return {};
|
||||
throw Error("Could not send delayed event, even though delayed events are supported. " + e);
|
||||
} else {
|
||||
logger.info("Not using delayed event because: " + e);
|
||||
// This action was scheduled because we are in the process of joining
|
||||
// log and fall through
|
||||
if (this.isUnsupportedDelayedEndpoint(e)) {
|
||||
this.logger.info("Not using delayed event because the endpoint is not supported");
|
||||
} else {
|
||||
this.logger.info("Not using delayed event because: " + e);
|
||||
}
|
||||
// On any other error we fall back to not using delayed events and send the join state event immediately
|
||||
return createInsertActionUpdate(MembershipActionType.SendJoinEvent);
|
||||
}
|
||||
// On any other error we fall back to not using delayed events and send the join state event immediately
|
||||
return createInsertActionUpdate(MembershipActionType.SendJoinEvent);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -495,11 +489,11 @@ export class MembershipManager implements IMembershipManager {
|
||||
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Cancel)
|
||||
.then(() => {
|
||||
this.state.delayId = undefined;
|
||||
this.resetRateLimitCounter(MembershipActionType.SendFirstDelayedEvent);
|
||||
return createReplaceActionUpdate(MembershipActionType.SendFirstDelayedEvent);
|
||||
this.resetRateLimitCounter(MembershipActionType.SendDelayedEvent);
|
||||
return createReplaceActionUpdate(MembershipActionType.SendDelayedEvent);
|
||||
})
|
||||
.catch((e) => {
|
||||
const repeatActionType = MembershipActionType.SendFirstDelayedEvent;
|
||||
const repeatActionType = MembershipActionType.SendDelayedEvent;
|
||||
const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent");
|
||||
if (update) return update;
|
||||
|
||||
@@ -538,7 +532,7 @@ export class MembershipManager implements IMembershipManager {
|
||||
const repeatActionType = MembershipActionType.RestartDelayedEvent;
|
||||
if (this.isNotFoundError(e)) {
|
||||
this.state.delayId = undefined;
|
||||
return createInsertActionUpdate(MembershipActionType.SendMainDelayedEvent);
|
||||
return createInsertActionUpdate(MembershipActionType.SendDelayedEvent);
|
||||
}
|
||||
// If the HS does not support delayed events we wont reschedule.
|
||||
if (this.isUnsupportedDelayedEndpoint(e)) return {};
|
||||
@@ -552,40 +546,6 @@ export class MembershipManager implements IMembershipManager {
|
||||
});
|
||||
}
|
||||
|
||||
private async sendMainDelayedEvent(): Promise<ActionUpdate> {
|
||||
return await this.client
|
||||
._unstable_sendDelayedStateEvent(
|
||||
this.room.roomId,
|
||||
{
|
||||
delay: this.membershipServerSideExpiryTimeout,
|
||||
},
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{}, // leave event
|
||||
this.stateKey,
|
||||
)
|
||||
.then((response) => {
|
||||
this.state.delayId = response.delay_id;
|
||||
this.resetRateLimitCounter(MembershipActionType.SendMainDelayedEvent);
|
||||
return createInsertActionUpdate(
|
||||
MembershipActionType.RestartDelayedEvent,
|
||||
this.membershipKeepAlivePeriod,
|
||||
);
|
||||
})
|
||||
.catch((e) => {
|
||||
const repeatActionType = MembershipActionType.SendMainDelayedEvent;
|
||||
// Don't do any other delayed event work if its not supported.
|
||||
if (this.isUnsupportedDelayedEndpoint(e)) return {};
|
||||
|
||||
if (this.manageMaxDelayExceededSituation(e)) {
|
||||
return createInsertActionUpdate(repeatActionType);
|
||||
}
|
||||
const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent");
|
||||
if (update) return update;
|
||||
|
||||
throw Error("Could not send delayed event, even though delayed events are supported. " + e);
|
||||
});
|
||||
}
|
||||
|
||||
private async sendScheduledDelayedLeaveEventOrFallbackToSendLeaveEvent(delayId: string): Promise<ActionUpdate> {
|
||||
return await this.client
|
||||
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Send)
|
||||
@@ -606,7 +566,7 @@ export class MembershipManager implements IMembershipManager {
|
||||
if (update) return update;
|
||||
|
||||
// On any other error we fall back to SendLeaveEvent (this includes hard errors from rate limiting)
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
"Encountered unexpected error during SendScheduledDelayedLeaveEvent. Falling back to SendLeaveEvent",
|
||||
e,
|
||||
);
|
||||
@@ -740,7 +700,7 @@ export class MembershipManager implements IMembershipManager {
|
||||
if (typeof maxDelayAllowed === "number" && this.membershipServerSideExpiryTimeout > maxDelayAllowed) {
|
||||
this.membershipServerSideExpiryTimeoutOverride = maxDelayAllowed;
|
||||
}
|
||||
logger.warn("Retry sending delayed disconnection event due to server timeout limitations:", error);
|
||||
this.logger.warn("Retry sending delayed disconnection event due to server timeout limitations:", error);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -782,9 +742,9 @@ export class MembershipManager implements IMembershipManager {
|
||||
const defaultMs = 5000;
|
||||
try {
|
||||
resendDelay = error.getRetryAfterMs() ?? defaultMs;
|
||||
logger.info(`Rate limited by server, retrying in ${resendDelay}ms`);
|
||||
this.logger.info(`Rate limited by server, retrying in ${resendDelay}ms`);
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
`Error while retrieving a rate-limit retry delay, retrying after default delay of ${defaultMs}`,
|
||||
e,
|
||||
);
|
||||
@@ -812,7 +772,7 @@ export class MembershipManager implements IMembershipManager {
|
||||
const retryDurationString = this.callMemberEventRetryDelayMinimum / 1000 + "s";
|
||||
const retryCounterString = "(" + retries + "/" + this.maximumNetworkErrorRetryCount + ")";
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
"Network local timeout error while sending event, retrying in " +
|
||||
retryDurationString +
|
||||
" " +
|
||||
@@ -830,12 +790,12 @@ export class MembershipManager implements IMembershipManager {
|
||||
//
|
||||
// A proper fix would be to either find a place to convert the `HttpError` into a `MatrixError` and the `processError`
|
||||
// method to handle it as expected or to adjust `processError` to also process `HttpError`'s.
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
"delayed event update timeout error, retrying in " + retryDurationString + " " + retryCounterString,
|
||||
error,
|
||||
);
|
||||
} else if (error instanceof ConnectionError) {
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
"Network connection error while sending event, retrying in " +
|
||||
retryDurationString +
|
||||
" " +
|
||||
@@ -848,7 +808,7 @@ export class MembershipManager implements IMembershipManager {
|
||||
error.httpStatus >= 500 &&
|
||||
error.httpStatus < 600
|
||||
) {
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
"Server error while sending event, retrying in " + retryDurationString + " " + retryCounterString,
|
||||
error,
|
||||
);
|
||||
@@ -887,9 +847,8 @@ export class MembershipManager implements IMembershipManager {
|
||||
if (actions.length === 1) {
|
||||
const { type } = actions[0];
|
||||
switch (type) {
|
||||
case MembershipActionType.SendFirstDelayedEvent:
|
||||
case MembershipActionType.SendDelayedEvent:
|
||||
case MembershipActionType.SendJoinEvent:
|
||||
case MembershipActionType.SendMainDelayedEvent:
|
||||
return Status.Connecting;
|
||||
case MembershipActionType.UpdateExpiry: // where no delayed events
|
||||
return Status.Connected;
|
||||
@@ -904,7 +863,7 @@ export class MembershipManager implements IMembershipManager {
|
||||
// normal state for connected with delayed events
|
||||
if (
|
||||
(types.includes(MembershipActionType.RestartDelayedEvent) ||
|
||||
types.includes(MembershipActionType.SendMainDelayedEvent)) &&
|
||||
(types.includes(MembershipActionType.SendDelayedEvent) && this.state.hasMemberStateEvent)) &&
|
||||
types.includes(MembershipActionType.UpdateExpiry)
|
||||
) {
|
||||
return Status.Connected;
|
||||
@@ -925,7 +884,7 @@ export class MembershipManager implements IMembershipManager {
|
||||
return Status.Disconnected;
|
||||
}
|
||||
|
||||
logger.error("MembershipManager has an unknown state. Actions: ", actions);
|
||||
this.logger.error("MembershipManager has an unknown state. Actions: ", actions);
|
||||
return Status.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { logger as rootLogger } from "../logger.ts";
|
||||
import { type Logger, logger as rootLogger } from "../logger.ts";
|
||||
import { type EmptyObject } from "../matrix.ts";
|
||||
import { sleep } from "../utils.ts";
|
||||
import { MembershipActionType } from "./NewMembershipManager.ts";
|
||||
|
||||
const logger = rootLogger.getChild("MatrixRTCSession");
|
||||
|
||||
/** @internal */
|
||||
export interface Action {
|
||||
/**
|
||||
@@ -40,6 +38,7 @@ export type ActionUpdate =
|
||||
* @internal
|
||||
*/
|
||||
export class ActionScheduler {
|
||||
private logger: Logger;
|
||||
/**
|
||||
* This is tracking the state of the scheduler loop.
|
||||
* Only used to prevent starting the loop twice.
|
||||
@@ -49,11 +48,14 @@ export class ActionScheduler {
|
||||
public constructor(
|
||||
/** This is the callback called for each scheduled action (`this.addAction()`) */
|
||||
private membershipLoopHandler: (type: MembershipActionType) => Promise<ActionUpdate>,
|
||||
) {}
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
this.logger = (parentLogger ?? rootLogger).getChild(`[NewMembershipActionScheduler]`);
|
||||
}
|
||||
|
||||
// function for the wakeup mechanism (in case we add an action externally and need to leave the current sleep)
|
||||
private wakeup: (update: ActionUpdate) => void = (update: ActionUpdate): void => {
|
||||
logger.error("Cannot call wakeup before calling `startWithJoin()`");
|
||||
this.logger.error("Cannot call wakeup before calling `startWithJoin()`");
|
||||
};
|
||||
private _actions: Action[] = [];
|
||||
public get actions(): Action[] {
|
||||
@@ -69,11 +71,11 @@ export class ActionScheduler {
|
||||
*/
|
||||
public async startWithJoin(): Promise<void> {
|
||||
if (this.running) {
|
||||
logger.error("Cannot call startWithJoin() on NewMembershipActionScheduler while already running");
|
||||
this.logger.error("Cannot call startWithJoin() on NewMembershipActionScheduler while already running");
|
||||
return;
|
||||
}
|
||||
this.running = true;
|
||||
this._actions = [{ ts: Date.now(), type: MembershipActionType.SendFirstDelayedEvent }];
|
||||
this._actions = [{ ts: Date.now(), type: MembershipActionType.SendDelayedEvent }];
|
||||
try {
|
||||
while (this._actions.length > 0) {
|
||||
// Sort so next (smallest ts) action is at the beginning
|
||||
@@ -92,13 +94,13 @@ export class ActionScheduler {
|
||||
|
||||
let handlerResult: ActionUpdate = {};
|
||||
if (!wakeupUpdate) {
|
||||
logger.debug(
|
||||
this.logger.debug(
|
||||
`Current MembershipManager processing: ${nextAction.type}\nQueue:`,
|
||||
this._actions,
|
||||
`\nDate.now: "${Date.now()}`,
|
||||
);
|
||||
try {
|
||||
// `this.wakeup` can also be called and sets the `wakupUpdate` object while we are in the handler.
|
||||
// `this.wakeup` can also be called and sets the `wakeupUpdate` object while we are in the handler.
|
||||
handlerResult = await this.membershipLoopHandler(nextAction.type as MembershipActionType);
|
||||
} catch (e) {
|
||||
throw Error(`The MembershipManager shut down because of the end condition: ${e}`);
|
||||
@@ -121,11 +123,11 @@ export class ActionScheduler {
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
logger.debug("Leave MembershipManager ActionScheduler loop (no more actions)");
|
||||
this.logger.debug("Leave MembershipManager ActionScheduler loop (no more actions)");
|
||||
}
|
||||
|
||||
public initiateJoin(): void {
|
||||
this.wakeup?.({ replace: [{ ts: Date.now(), type: MembershipActionType.SendFirstDelayedEvent }] });
|
||||
this.wakeup?.({ replace: [{ ts: Date.now(), type: MembershipActionType.SendDelayedEvent }] });
|
||||
}
|
||||
public initiateLeave(): void {
|
||||
this.wakeup?.({ replace: [{ ts: Date.now(), type: MembershipActionType.SendScheduledDelayedLeaveEvent }] });
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
Copyright 2025 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 as rootLogger, type Logger } from "../logger.ts";
|
||||
import { KeyTransportEvents, type KeyTransportEventsHandlerMap, type IKeyTransport } from "./IKeyTransport.ts";
|
||||
import { type CallMembership } from "./CallMembership.ts";
|
||||
import type { RoomKeyTransport } from "./RoomKeyTransport.ts";
|
||||
import type { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
|
||||
// Deprecate RoomAndToDeviceTransport: This whole class is only a stop gap until we remove RoomKeyTransport.
|
||||
export interface EnabledTransports {
|
||||
toDevice: boolean;
|
||||
room: boolean;
|
||||
}
|
||||
|
||||
export enum RoomAndToDeviceEvents {
|
||||
EnabledTransportsChanged = "enabled_transports_changed",
|
||||
}
|
||||
export type RoomAndToDeviceEventsHandlerMap = {
|
||||
[RoomAndToDeviceEvents.EnabledTransportsChanged]: (enabledTransports: EnabledTransports) => void;
|
||||
};
|
||||
/**
|
||||
* A custom transport that subscribes to room key events (via `RoomKeyTransport`) and to device key events (via: `ToDeviceKeyTransport`)
|
||||
* The public setEnabled method allows to turn one or the other on or off on the fly.
|
||||
* It will emit `RoomAndToDeviceEvents.EnabledTransportsChanged` if the enabled transport changes to allow comminitcating this to
|
||||
* the user in the ui.
|
||||
*
|
||||
* Since it will always subscribe to both (room and to device) but only emit for the enabled ones, it can detect
|
||||
* if a room key event was received and autoenable it.
|
||||
*/
|
||||
export class RoomAndToDeviceTransport
|
||||
extends TypedEventEmitter<
|
||||
KeyTransportEvents | RoomAndToDeviceEvents,
|
||||
KeyTransportEventsHandlerMap & RoomAndToDeviceEventsHandlerMap
|
||||
>
|
||||
implements IKeyTransport
|
||||
{
|
||||
private readonly logger: Logger;
|
||||
private _enabled: EnabledTransports = { toDevice: true, room: false };
|
||||
public constructor(
|
||||
private toDeviceTransport: ToDeviceKeyTransport,
|
||||
private roomKeyTransport: RoomKeyTransport,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
super();
|
||||
this.logger = (parentLogger ?? rootLogger).getChild(`[RoomAndToDeviceTransport]`);
|
||||
// update parent loggers for the sub transports so filtering for `RoomAndToDeviceTransport` contains their logs too
|
||||
this.toDeviceTransport.setParentLogger(this.logger);
|
||||
this.roomKeyTransport.setParentLogger(this.logger);
|
||||
|
||||
this.roomKeyTransport.on(KeyTransportEvents.ReceivedKeys, (...props) => {
|
||||
// Turn on the room transport if we receive a roomKey from another participant
|
||||
// and disable the toDevice transport.
|
||||
if (!this._enabled.room) {
|
||||
this.logger.debug("Received room key, enabling room key transport, disabling toDevice transport");
|
||||
this.setEnabled({ toDevice: false, room: true });
|
||||
}
|
||||
this.emit(KeyTransportEvents.ReceivedKeys, ...props);
|
||||
});
|
||||
this.toDeviceTransport.on(KeyTransportEvents.ReceivedKeys, (...props) => {
|
||||
if (this._enabled.toDevice) {
|
||||
this.emit(KeyTransportEvents.ReceivedKeys, ...props);
|
||||
} else {
|
||||
this.logger.debug("To Device transport is disabled, ignoring received keys");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Set which transport type should be used to send and receive keys.*/
|
||||
public setEnabled(enabled: { toDevice: boolean; room: boolean }): void {
|
||||
if (this.enabled.toDevice !== enabled.toDevice || this.enabled.room !== enabled.room) {
|
||||
this._enabled = enabled;
|
||||
this.emit(RoomAndToDeviceEvents.EnabledTransportsChanged, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/** The currently enabled transports that are used to send and receive keys.*/
|
||||
public get enabled(): EnabledTransports {
|
||||
return this._enabled;
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
// always start the underlying transport since we need to enable room transport
|
||||
// when someone else sends us a room key. (we need to listen to roomKeyTransport)
|
||||
this.roomKeyTransport.start();
|
||||
this.toDeviceTransport.start();
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
// always stop since it is always running
|
||||
this.roomKeyTransport.stop();
|
||||
this.toDeviceTransport.stop();
|
||||
}
|
||||
|
||||
public async sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void> {
|
||||
this.logger.debug(
|
||||
`Sending key with index ${index} to call members (count=${members.length}) via:` +
|
||||
(this._enabled.room ? "room transport" : "") +
|
||||
(this._enabled.room && this._enabled.toDevice ? "and" : "") +
|
||||
(this._enabled.toDevice ? "to device transport" : ""),
|
||||
);
|
||||
if (this._enabled.room) await this.roomKeyTransport.sendKey(keyBase64Encoded, index, members);
|
||||
if (this._enabled.toDevice) await this.toDeviceTransport.sendKey(keyBase64Encoded, index, members);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
Copyright 2025 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 type { MatrixClient } from "../client.ts";
|
||||
import type { EncryptionKeysEventContent, Statistics } from "./types.ts";
|
||||
import { EventType } from "../@types/event.ts";
|
||||
import { type MatrixError } from "../http-api/errors.ts";
|
||||
import { logger as rootLogger, type Logger } from "../logger.ts";
|
||||
import { KeyTransportEvents, type KeyTransportEventsHandlerMap, type IKeyTransport } from "./IKeyTransport.ts";
|
||||
import { type MatrixEvent } from "../models/event.ts";
|
||||
import { type CallMembership } from "./CallMembership.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { type Room, RoomEvent } from "../models/room.ts";
|
||||
|
||||
export class RoomKeyTransport
|
||||
extends TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>
|
||||
implements IKeyTransport
|
||||
{
|
||||
private logger: Logger = rootLogger;
|
||||
public setParentLogger(parentLogger: Logger): void {
|
||||
this.logger = parentLogger.getChild(`[RoomKeyTransport]`);
|
||||
}
|
||||
public constructor(
|
||||
private room: Pick<Room, "on" | "off" | "roomId">,
|
||||
private client: Pick<
|
||||
MatrixClient,
|
||||
"sendEvent" | "getDeviceId" | "getUserId" | "cancelPendingEvent" | "decryptEventIfNeeded"
|
||||
>,
|
||||
private statistics: Statistics,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
super();
|
||||
this.setParentLogger(parentLogger ?? rootLogger);
|
||||
}
|
||||
public start(): void {
|
||||
this.room.on(RoomEvent.Timeline, (ev) => void this.consumeCallEncryptionEvent(ev));
|
||||
}
|
||||
public stop(): void {
|
||||
this.room.off(RoomEvent.Timeline, (ev) => void this.consumeCallEncryptionEvent(ev));
|
||||
}
|
||||
|
||||
private async consumeCallEncryptionEvent(event: MatrixEvent, isRetry = false): Promise<void> {
|
||||
await this.client.decryptEventIfNeeded(event);
|
||||
|
||||
if (event.isDecryptionFailure()) {
|
||||
if (!isRetry) {
|
||||
this.logger.warn(
|
||||
`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason} will retry once only`,
|
||||
);
|
||||
// retry after 1 second. After this we give up.
|
||||
setTimeout(() => void this.consumeCallEncryptionEvent(event, true), 1000);
|
||||
} else {
|
||||
this.logger.warn(`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason}`);
|
||||
}
|
||||
return;
|
||||
} else if (isRetry) {
|
||||
this.logger.info(`Decryption succeeded for event ${event.getId()} after retry`);
|
||||
}
|
||||
|
||||
if (event.getType() !== EventType.CallEncryptionKeysPrefix) return Promise.resolve();
|
||||
|
||||
if (!this.room) {
|
||||
this.logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
this.onEncryptionEvent(event);
|
||||
}
|
||||
|
||||
/** implements {@link IKeyTransport#sendKey} */
|
||||
public async sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void> {
|
||||
// members not used in room transports as the keys are sent to all room members
|
||||
const content: EncryptionKeysEventContent = {
|
||||
keys: [
|
||||
{
|
||||
index: index,
|
||||
key: keyBase64Encoded,
|
||||
},
|
||||
],
|
||||
device_id: this.client.getDeviceId()!,
|
||||
call_id: "",
|
||||
sent_ts: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
await this.client.sendEvent(this.room.roomId, EventType.CallEncryptionKeysPrefix, content);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to send call encryption keys", error);
|
||||
const matrixError = error as MatrixError;
|
||||
if (matrixError.event) {
|
||||
// cancel the pending event: we'll just generate a new one with our latest
|
||||
// keys when we resend
|
||||
this.client.cancelPendingEvent(matrixError.event);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public onEncryptionEvent(event: MatrixEvent): void {
|
||||
const userId = event.getSender();
|
||||
const content = event.getContent<EncryptionKeysEventContent>();
|
||||
|
||||
const deviceId = content["device_id"];
|
||||
const callId = content["call_id"];
|
||||
|
||||
if (!userId) {
|
||||
this.logger.warn(`Received m.call.encryption_keys with no userId: callId=${callId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// We currently only handle callId = "" (which is the default for room scoped calls)
|
||||
if (callId !== "") {
|
||||
this.logger.warn(
|
||||
`Received m.call.encryption_keys with unsupported callId: userId=${userId}, deviceId=${deviceId}, callId=${callId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content.keys)) {
|
||||
this.logger.warn(`Received m.call.encryption_keys where keys wasn't an array: callId=${callId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) {
|
||||
// We store our own sender key in the same set along with keys from others, so it's
|
||||
// important we don't allow our own keys to be set by one of these events (apart from
|
||||
// the fact that we don't need it anyway because we already know our own keys).
|
||||
this.logger.info("Ignoring our own keys event");
|
||||
return;
|
||||
}
|
||||
|
||||
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
|
||||
const age = Date.now() - (typeof content.sent_ts === "number" ? content.sent_ts : event.getTs());
|
||||
this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age;
|
||||
|
||||
for (const key of content.keys) {
|
||||
if (!key) {
|
||||
this.logger.info("Ignoring false-y key in keys event");
|
||||
continue;
|
||||
}
|
||||
|
||||
const encryptionKey = key.key;
|
||||
const encryptionKeyIndex = key.index;
|
||||
|
||||
if (
|
||||
!encryptionKey ||
|
||||
encryptionKeyIndex === undefined ||
|
||||
encryptionKeyIndex === null ||
|
||||
callId === undefined ||
|
||||
callId === null ||
|
||||
typeof deviceId !== "string" ||
|
||||
typeof callId !== "string" ||
|
||||
typeof encryptionKey !== "string" ||
|
||||
typeof encryptionKeyIndex !== "number"
|
||||
) {
|
||||
this.logger.warn(
|
||||
`Malformed call encryption_key: userId=${userId}, deviceId=${deviceId}, encryptionKeyIndex=${encryptionKeyIndex} callId=${callId}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.debug(
|
||||
`onCallEncryption userId=${userId}:${deviceId} encryptionKeyIndex=${encryptionKeyIndex} age=${age}ms`,
|
||||
);
|
||||
this.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
userId,
|
||||
deviceId,
|
||||
encryptionKey,
|
||||
encryptionKeyIndex,
|
||||
event.getTs(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
Copyright 2025 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 { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { type IKeyTransport, KeyTransportEvents, type KeyTransportEventsHandlerMap } from "./IKeyTransport.ts";
|
||||
import { type Logger, logger as rootLogger } from "../logger.ts";
|
||||
import type { CallMembership } from "./CallMembership.ts";
|
||||
import type { EncryptionKeysToDeviceEventContent, Statistics } from "./types.ts";
|
||||
import { ClientEvent, type MatrixClient } from "../client.ts";
|
||||
import type { MatrixEvent } from "../models/event.ts";
|
||||
import { EventType } from "../@types/event.ts";
|
||||
|
||||
/**
|
||||
* ToDeviceKeyTransport is used to send MatrixRTC keys to other devices using the
|
||||
* to-device CS-API.
|
||||
*/
|
||||
export class ToDeviceKeyTransport
|
||||
extends TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>
|
||||
implements IKeyTransport
|
||||
{
|
||||
private logger: Logger = rootLogger;
|
||||
public setParentLogger(parentLogger: Logger): void {
|
||||
this.logger = parentLogger.getChild(`[ToDeviceKeyTransport]`);
|
||||
}
|
||||
|
||||
public constructor(
|
||||
private userId: string,
|
||||
private deviceId: string,
|
||||
private roomId: string,
|
||||
private client: Pick<MatrixClient, "encryptAndSendToDevice" | "on" | "off">,
|
||||
private statistics: Statistics,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
super();
|
||||
this.setParentLogger(parentLogger ?? rootLogger);
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
}
|
||||
|
||||
public async sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void> {
|
||||
const content: EncryptionKeysToDeviceEventContent = {
|
||||
keys: {
|
||||
index: index,
|
||||
key: keyBase64Encoded,
|
||||
},
|
||||
room_id: this.roomId,
|
||||
member: {
|
||||
claimed_device_id: this.deviceId,
|
||||
},
|
||||
session: {
|
||||
call_id: "",
|
||||
application: "m.call",
|
||||
scope: "m.room",
|
||||
},
|
||||
};
|
||||
|
||||
const targets = members
|
||||
.filter((member) => {
|
||||
// filter malformed call members
|
||||
if (member.sender == undefined || member.deviceId == undefined) {
|
||||
this.logger.warn(`Malformed call member: ${member.sender}|${member.deviceId}`);
|
||||
return false;
|
||||
}
|
||||
// Filter out me
|
||||
return !(member.sender == this.userId && member.deviceId == this.deviceId);
|
||||
})
|
||||
.map((member) => {
|
||||
return {
|
||||
userId: member.sender!,
|
||||
deviceId: member.deviceId!,
|
||||
};
|
||||
});
|
||||
|
||||
if (targets.length > 0) {
|
||||
await this.client.encryptAndSendToDevice(EventType.CallEncryptionKeysPrefix, targets, content);
|
||||
this.statistics.counters.roomEventEncryptionKeysSent += 1;
|
||||
} else {
|
||||
this.logger.warn("No targets found for sending key");
|
||||
}
|
||||
}
|
||||
|
||||
private receiveCallKeyEvent(fromUser: string, content: EncryptionKeysToDeviceEventContent): void {
|
||||
// The event has already been validated at this point.
|
||||
|
||||
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
|
||||
|
||||
// What is this, and why is it needed?
|
||||
// Also to device events do not have an origin server ts
|
||||
const now = Date.now();
|
||||
const age = now - (typeof content.sent_ts === "number" ? content.sent_ts : now);
|
||||
this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age;
|
||||
|
||||
this.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
// TODO this is claimed information
|
||||
fromUser,
|
||||
// TODO: This is claimed information
|
||||
content.member.claimed_device_id!,
|
||||
content.keys.key,
|
||||
content.keys.index,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
private onToDeviceEvent = (event: MatrixEvent): void => {
|
||||
if (event.getType() !== EventType.CallEncryptionKeysPrefix) {
|
||||
// Ignore this is not a call encryption event
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Not possible to check if the event is encrypted or not
|
||||
// see https://github.com/matrix-org/matrix-rust-sdk/issues/4883
|
||||
// if (evnt.getWireType() != EventType.RoomMessageEncrypted) {
|
||||
// // WARN: The call keys were sent in clear. Ignore them
|
||||
// logger.warn(`Call encryption keys sent in clear from: ${event.getSender()}`);
|
||||
// return;
|
||||
// }
|
||||
|
||||
const content = this.getValidEventContent(event);
|
||||
if (!content) return;
|
||||
|
||||
if (!event.getSender()) return;
|
||||
|
||||
this.receiveCallKeyEvent(event.getSender()!, content);
|
||||
};
|
||||
|
||||
private getValidEventContent(event: MatrixEvent): EncryptionKeysToDeviceEventContent | undefined {
|
||||
const content = event.getContent();
|
||||
const roomId = content.room_id;
|
||||
if (!roomId) {
|
||||
// Invalid event
|
||||
this.logger.warn("Malformed Event: invalid call encryption keys event, no roomId");
|
||||
return;
|
||||
}
|
||||
if (roomId !== this.roomId) {
|
||||
this.logger.warn("Malformed Event: Mismatch roomId");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.keys || !content.keys.key || typeof content.keys.index !== "number") {
|
||||
this.logger.warn("Malformed Event: Missing keys field");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.member || !content.member.claimed_device_id) {
|
||||
this.logger.warn("Malformed Event: Missing claimed_device_id");
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO check for session related fields once the to-device encryption uses the new format.
|
||||
return content as EncryptionKeysToDeviceEventContent;
|
||||
}
|
||||
}
|
||||
@@ -20,3 +20,5 @@ export * from "./LivekitFocus.ts";
|
||||
export * from "./MatrixRTCSession.ts";
|
||||
export * from "./MatrixRTCSessionManager.ts";
|
||||
export type * from "./types.ts";
|
||||
export { Status } from "./types.ts";
|
||||
export { MembershipManagerEvent } from "./IMembershipManager.ts";
|
||||
|
||||
+58
-1
@@ -13,7 +13,9 @@ 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 { type IMentions } from "../matrix.ts";
|
||||
import type { IMentions } from "../matrix.ts";
|
||||
import type { CallMembership } from "./CallMembership.ts";
|
||||
|
||||
export interface EncryptionKeyEntry {
|
||||
index: number;
|
||||
key: string;
|
||||
@@ -26,6 +28,24 @@ export interface EncryptionKeysEventContent {
|
||||
sent_ts?: number;
|
||||
}
|
||||
|
||||
export interface EncryptionKeysToDeviceEventContent {
|
||||
keys: { index: number; key: string };
|
||||
member: {
|
||||
// id: ParticipantId,
|
||||
// TODO Remove that it is claimed, need to get the sealed sender from decryption info
|
||||
claimed_device_id: string;
|
||||
// user_id: string
|
||||
};
|
||||
room_id: string;
|
||||
session: {
|
||||
application: string;
|
||||
call_id: string;
|
||||
scope: string;
|
||||
};
|
||||
// Why is this needed?
|
||||
sent_ts?: number;
|
||||
}
|
||||
|
||||
export type CallNotifyType = "ring" | "notify";
|
||||
|
||||
export interface ICallNotifyContent {
|
||||
@@ -34,3 +54,40 @@ export interface ICallNotifyContent {
|
||||
"notify_type": CallNotifyType;
|
||||
"call_id": string;
|
||||
}
|
||||
|
||||
export enum Status {
|
||||
Disconnected = "Disconnected",
|
||||
Connecting = "Connecting",
|
||||
ConnectingFailed = "ConnectingFailed",
|
||||
Connected = "Connected",
|
||||
Reconnecting = "Reconnecting",
|
||||
Disconnecting = "Disconnecting",
|
||||
Stuck = "Stuck",
|
||||
Unknown = "Unknown",
|
||||
}
|
||||
|
||||
/**
|
||||
* A type collecting call encryption statistics for a session.
|
||||
*/
|
||||
export type Statistics = {
|
||||
counters: {
|
||||
/**
|
||||
* The number of times we have sent a room event containing encryption keys.
|
||||
*/
|
||||
roomEventEncryptionKeysSent: number;
|
||||
/**
|
||||
* The number of times we have received a room event containing encryption keys.
|
||||
*/
|
||||
roomEventEncryptionKeysReceived: number;
|
||||
};
|
||||
totals: {
|
||||
/**
|
||||
* The total age (in milliseconds) of all room events containing encryption keys that we have received.
|
||||
* We track the total age so that we can later calculate the average age of all keys received.
|
||||
*/
|
||||
roomEventEncryptionKeysReceivedTotalAge: number;
|
||||
};
|
||||
};
|
||||
|
||||
export const isMyMembership = (m: CallMembership, userId: string, deviceId: string): boolean =>
|
||||
m.sender === userId && m.deviceId === deviceId;
|
||||
|
||||
@@ -14,9 +14,40 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A stripped m.room.member event which contains the key renderable fields from the event,
|
||||
* sent only in simplified sliding sync (not `/v3/sync`).
|
||||
* This is very similar to MSC4186Hero from sliding-sync.ts but an internal format with
|
||||
* camelCase rather than underscores.
|
||||
*/
|
||||
export type Hero = {
|
||||
userId: string;
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
/**
|
||||
* If true, the hero is from an MSC4186 summary, in which case `displayName` and `avatarUrl` will
|
||||
* have been set by the server if available. If false, the `Hero` has been constructed from a `/v3/sync` response,
|
||||
* so these fields will always be undefined.
|
||||
*/
|
||||
fromMSC4186: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* High level summary information for a room, as returned by `/v3/sync`.
|
||||
*/
|
||||
export interface IRoomSummary {
|
||||
/**
|
||||
* The room heroes: a selected set of members that can be used when summarising or
|
||||
* generating a name for a room. List of user IDs.
|
||||
*/
|
||||
"m.heroes": string[];
|
||||
/**
|
||||
* The number of joined members in the room.
|
||||
*/
|
||||
"m.joined_member_count"?: number;
|
||||
/**
|
||||
* The number of invited members in the room.
|
||||
*/
|
||||
"m.invited_member_count"?: number;
|
||||
}
|
||||
|
||||
|
||||
+133
-23
@@ -35,7 +35,7 @@ import {
|
||||
} from "./event.ts";
|
||||
import { EventStatus } from "./event-status.ts";
|
||||
import { RoomMember } from "./room-member.ts";
|
||||
import { type IRoomSummary, RoomSummary } from "./room-summary.ts";
|
||||
import { type IRoomSummary, type Hero, RoomSummary } from "./room-summary.ts";
|
||||
import { logger } from "../logger.ts";
|
||||
import { TypedReEmitter } from "../ReEmitter.ts";
|
||||
import {
|
||||
@@ -77,6 +77,7 @@ import { compareEventOrdering } from "./compare-event-ordering.ts";
|
||||
import * as utils from "../utils.ts";
|
||||
import { KnownMembership, type Membership } from "../@types/membership.ts";
|
||||
import { type Capabilities, type IRoomVersionsCapability, RoomVersionStability } from "../serverCapabilities.ts";
|
||||
import { type MSC4186Hero } from "../sliding-sync.ts";
|
||||
|
||||
// These constants are used as sane defaults when the homeserver doesn't support
|
||||
// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be
|
||||
@@ -335,6 +336,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
public readonly reEmitter: TypedReEmitter<RoomEmittedEvents, RoomEventHandlerMap>;
|
||||
private txnToEvent: Map<string, MatrixEvent> = new Map(); // Pending in-flight requests { string: MatrixEvent }
|
||||
private notificationCounts: NotificationCount = {};
|
||||
private bumpStamp: number | undefined = undefined;
|
||||
private readonly threadNotifications = new Map<string, NotificationCount>();
|
||||
public readonly cachedThreadReadReceipts = new Map<string, CachedReceiptStructure[]>();
|
||||
// Useful to know at what point the current user has started using threads in this room
|
||||
@@ -361,7 +363,16 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
// read by megolm via getter; boolean value - null indicates "use global value"
|
||||
private blacklistUnverifiedDevices?: boolean;
|
||||
private selfMembership?: Membership;
|
||||
private summaryHeroes: string[] | null = null;
|
||||
/**
|
||||
* A `Hero` is a stripped `m.room.member` event which contains the important renderable fields from the event.
|
||||
*
|
||||
* It is used in MSC4186 (Simplified Sliding Sync) as a replacement for the old `summary` field.
|
||||
*
|
||||
* When we are doing old-style (`/v3/sync`) sync, we simulate the SSS behaviour by constructing
|
||||
* a `Hero` object based on the user id we get from the summary. Obviously, in that case,
|
||||
* the `Hero` will lack a `displayName` or `avatarUrl`.
|
||||
*/
|
||||
private heroes: Hero[] | null = null;
|
||||
// flags to stop logspam about missing m.room.create events
|
||||
private getTypeWarning = false;
|
||||
private getVersionWarning = false;
|
||||
@@ -879,7 +890,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
// fall back to summary information
|
||||
const memberCount = this.getInvitedAndJoinedMemberCount();
|
||||
if (memberCount === 2) {
|
||||
return this.summaryHeroes?.[0];
|
||||
return this.heroes?.[0]?.userId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -897,8 +908,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
}
|
||||
// Remember, we're assuming this room is a DM, so returning the first member we find should be fine
|
||||
if (Array.isArray(this.summaryHeroes) && this.summaryHeroes.length) {
|
||||
return this.summaryHeroes[0];
|
||||
if (Array.isArray(this.heroes) && this.heroes.length) {
|
||||
return this.heroes[0].userId;
|
||||
}
|
||||
const members = this.currentState.getMembers();
|
||||
const anyMember = members.find((m) => m.userId !== this.myUserId);
|
||||
@@ -940,12 +951,45 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
if (nonFunctionalMemberCount > 2) return;
|
||||
|
||||
// Prefer the list of heroes, if present. It should only include the single other user in the DM.
|
||||
const nonFunctionalHeroes = this.summaryHeroes?.filter((h) => !functionalMembers.includes(h));
|
||||
const nonFunctionalHeroes = this.heroes?.filter((h) => !functionalMembers.includes(h.userId));
|
||||
const hasHeroes = Array.isArray(nonFunctionalHeroes) && nonFunctionalHeroes.length;
|
||||
if (hasHeroes) {
|
||||
// use first hero that has a display name or avatar url, or whose user ID
|
||||
// can be looked up as a member of the room
|
||||
for (const hero of nonFunctionalHeroes) {
|
||||
// If the hero was from a legacy sync (`/v3/sync`), we will need to look the user ID up in the room
|
||||
// the display name and avatar URL will not be set.
|
||||
if (!hero.fromMSC4186) {
|
||||
// attempt to look up renderable fields from the m.room.member event if it exists
|
||||
const member = this.getMember(hero.userId);
|
||||
if (member) {
|
||||
return member;
|
||||
}
|
||||
} else {
|
||||
// use the Hero supplied values for the room member.
|
||||
// TODO: It's unfortunate that this function, which clearly only cares about the
|
||||
// avatar url, returns the entire RoomMember event. We need to fake an event
|
||||
// to meet this API shape.
|
||||
const heroMember = new RoomMember(this.roomId, hero.userId);
|
||||
// set the display name and avatar url
|
||||
heroMember.setMembershipEvent(
|
||||
new MatrixEvent({
|
||||
// ensure it's unique even if we hit the same millisecond
|
||||
event_id: "$" + this.roomId + hero.userId + new Date().getTime(),
|
||||
type: EventType.RoomMember,
|
||||
state_key: hero.userId,
|
||||
content: {
|
||||
displayname: hero.displayName,
|
||||
avatar_url: hero.avatarUrl,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return heroMember;
|
||||
}
|
||||
}
|
||||
const availableMember = nonFunctionalHeroes
|
||||
.map((userId) => {
|
||||
return this.getMember(userId);
|
||||
.map((hero) => {
|
||||
return this.getMember(hero.userId);
|
||||
})
|
||||
.find((member) => !!member);
|
||||
if (availableMember) {
|
||||
@@ -970,8 +1014,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
// trust and try falling back to a hero, creating a one-off member for it
|
||||
if (hasHeroes) {
|
||||
const availableUser = nonFunctionalHeroes
|
||||
.map((userId) => {
|
||||
return this.client.getUser(userId);
|
||||
.map((hero) => {
|
||||
return this.client.getUser(hero.userId);
|
||||
})
|
||||
.find((user) => !!user);
|
||||
if (availableUser) {
|
||||
@@ -1602,6 +1646,24 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
this.emit(RoomEvent.UnreadNotifications);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the bump stamp for this room. This can be used for sorting rooms when the timeline
|
||||
* entries are unknown. Used in MSC4186: Simplified Sliding Sync.
|
||||
* @param bumpStamp The bump_stamp value from the server
|
||||
*/
|
||||
public setBumpStamp(bumpStamp: number): void {
|
||||
this.bumpStamp = bumpStamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the bump stamp for this room. This can be used for sorting rooms when the timeline
|
||||
* entries are unknown. Used in MSC4186: Simplified Sliding Sync.
|
||||
* @returns The bump stamp for the room, if it exists.
|
||||
*/
|
||||
public getBumpStamp(): number | undefined {
|
||||
return this.bumpStamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set one of the notification counts for this room
|
||||
* @param type - The type of notification count to set.
|
||||
@@ -1616,8 +1678,13 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
return this.setUnreadNotificationCount(type, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a legacy room summary (/v3/sync as opposed to MSC4186) and updates the room with it.
|
||||
*
|
||||
* @param summary - The room summary to update the room with
|
||||
*/
|
||||
public setSummary(summary: IRoomSummary): void {
|
||||
const heroes = summary["m.heroes"];
|
||||
const heroes = summary["m.heroes"]?.map((h) => ({ userId: h, fromMSC4186: false }));
|
||||
const joinedCount = summary["m.joined_member_count"];
|
||||
const invitedCount = summary["m.invited_member_count"];
|
||||
if (Number.isInteger(joinedCount)) {
|
||||
@@ -1627,17 +1694,53 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
this.currentState.setInvitedMemberCount(invitedCount!);
|
||||
}
|
||||
if (Array.isArray(heroes)) {
|
||||
// be cautious about trusting server values,
|
||||
// and make sure heroes doesn't contain our own id
|
||||
// just to be sure
|
||||
this.summaryHeroes = heroes.filter((userId) => {
|
||||
return userId !== this.myUserId;
|
||||
// filter out ourselves just in case
|
||||
this.heroes = heroes.filter((h) => {
|
||||
return h.userId != this.myUserId;
|
||||
});
|
||||
}
|
||||
|
||||
this.emit(RoomEvent.Summary, summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes information from the MSC4186 room summary and updates the room with it.
|
||||
*
|
||||
* @param heroes - The room's hero members
|
||||
* @param joinedCount - The number of joined members
|
||||
* @param invitedCount - The number of invited members
|
||||
*/
|
||||
public setMSC4186SummaryData(
|
||||
heroes: MSC4186Hero[] | undefined,
|
||||
joinedCount: number | undefined,
|
||||
invitedCount: number | undefined,
|
||||
): void {
|
||||
if (heroes) {
|
||||
this.heroes = heroes
|
||||
.filter((h) => h.user_id !== this.myUserId)
|
||||
.map((h) => ({
|
||||
userId: h.user_id,
|
||||
displayName: h.displayname,
|
||||
avatarUrl: h.avatar_url,
|
||||
fromMSC4186: true,
|
||||
}));
|
||||
}
|
||||
if (joinedCount !== undefined && Number.isInteger(joinedCount)) {
|
||||
this.currentState.setJoinedMemberCount(joinedCount);
|
||||
}
|
||||
if (invitedCount !== undefined && Number.isInteger(invitedCount)) {
|
||||
this.currentState.setInvitedMemberCount(invitedCount);
|
||||
}
|
||||
|
||||
// Construct a summary object to emit as the event wants the info in a single object
|
||||
// more like old-style (/v3/sync) summaries.
|
||||
this.emit(RoomEvent.Summary, {
|
||||
"m.heroes": this.heroes ? this.heroes.map((h) => h.userId) : [],
|
||||
"m.joined_member_count": joinedCount,
|
||||
"m.invited_member_count": invitedCount,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to send encrypted messages to devices within this room.
|
||||
* @param value - true to blacklist unverified devices, null
|
||||
@@ -3459,18 +3562,25 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
// get service members (e.g. helper bots) for exclusion
|
||||
const excludedUserIds = this.getFunctionalMembers();
|
||||
|
||||
// get members that are NOT ourselves and are actually in the room.
|
||||
// get members from heroes that are NOT ourselves
|
||||
let otherNames: string[] = [];
|
||||
if (this.summaryHeroes) {
|
||||
// if we have a summary, the member state events should be in the room state
|
||||
this.summaryHeroes.forEach((userId) => {
|
||||
if (this.heroes) {
|
||||
// if we have heroes, use those as the names
|
||||
this.heroes.forEach((hero) => {
|
||||
// filter service members
|
||||
if (excludedUserIds.includes(userId)) {
|
||||
if (excludedUserIds.includes(hero.userId)) {
|
||||
inviteJoinCount--;
|
||||
return;
|
||||
}
|
||||
const member = this.getMember(userId);
|
||||
otherNames.push(member ? member.name : userId);
|
||||
// If the hero has a display name, use that.
|
||||
// Otherwise, look their user ID up in the membership and use
|
||||
// the name from there, or the user ID as a last resort.
|
||||
if (hero.displayName) {
|
||||
otherNames.push(hero.displayName);
|
||||
} else {
|
||||
const member = this.getMember(hero.userId);
|
||||
otherNames.push(member ? member.name : hero.userId);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
let otherMembers = this.currentState.getMembers().filter((m) => {
|
||||
|
||||
@@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type IdTokenClaims, OidcClient, WebStorageStateStore } from "oidc-client-ts";
|
||||
import { type IdTokenClaims, OidcClient, WebStorageStateStore, ErrorResponse } from "oidc-client-ts";
|
||||
|
||||
import { type AccessTokens } from "../http-api/index.ts";
|
||||
import { type AccessTokens, TokenRefreshLogoutError } from "../http-api/index.ts";
|
||||
import { generateScope } from "./authorize.ts";
|
||||
import { discoverAndValidateOIDCIssuerWellKnown } from "./discovery.ts";
|
||||
import { logger } from "../logger.ts";
|
||||
@@ -104,6 +104,12 @@ export class OidcTokenRefresher {
|
||||
try {
|
||||
const tokens = await this.inflightRefreshRequest;
|
||||
return tokens;
|
||||
} catch (e) {
|
||||
// If we encounter an OIDC error then signal that it should cause a logout by upgrading it to a TokenRefreshLogoutError
|
||||
if (e instanceof ErrorResponse) {
|
||||
throw new TokenRefreshLogoutError(e);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
this.inflightRefreshRequest = undefined;
|
||||
}
|
||||
@@ -133,6 +139,7 @@ export class OidcTokenRefresher {
|
||||
profile: this.idTokenClaims,
|
||||
};
|
||||
|
||||
const requestStart = Date.now();
|
||||
const response = await this.oidcClient.useRefreshToken({
|
||||
state: refreshTokenState,
|
||||
timeoutInSeconds: 300,
|
||||
@@ -141,7 +148,9 @@ export class OidcTokenRefresher {
|
||||
const tokens = {
|
||||
accessToken: response.access_token,
|
||||
refreshToken: response.refresh_token,
|
||||
};
|
||||
// We use the request start time to calculate the expiry time as we don't know when the server received our request
|
||||
expiry: response.expires_in ? new Date(requestStart + response.expires_in * 1000) : undefined,
|
||||
} satisfies AccessTokens;
|
||||
|
||||
await this.persistTokens(tokens);
|
||||
|
||||
|
||||
+25
-16
@@ -308,6 +308,28 @@ export class PushProcessor {
|
||||
return newRules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a RegExp object for the given glob pattern with a single capture group around the pattern itself, caching the result.
|
||||
* No cache invalidation is present currently,
|
||||
* as this will be inherently bounded to the size of the user's own push rules.
|
||||
* @param pattern - the glob pattern to convert to a RegExp
|
||||
* @param alignToWordBoundary - whether to align the pattern to word boundaries,
|
||||
* as specified for `content.body` matches, will use lookaround assertions to ensure the match only includes the pattern
|
||||
* @param flags - the flags to pass to the RegExp constructor, defaults to case-insensitive
|
||||
*/
|
||||
public static getPushRuleGlobRegex(pattern: string, alignToWordBoundary = false, flags = "i"): RegExp {
|
||||
const [prefix, suffix] = alignToWordBoundary ? ["(?<=^|\\W)", "(?=\\W|$)"] : ["^", "$"];
|
||||
const cacheKey = `${alignToWordBoundary}-${flags}-${pattern}`;
|
||||
|
||||
if (!PushProcessor.cachedGlobToRegex[cacheKey]) {
|
||||
PushProcessor.cachedGlobToRegex[cacheKey] = new RegExp(
|
||||
prefix + "(" + globToRegexp(pattern) + ")" + suffix,
|
||||
flags,
|
||||
);
|
||||
}
|
||||
return PushProcessor.cachedGlobToRegex[cacheKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-caches the parsed keys for push rules and cleans out any obsolete cache
|
||||
* entries. Should be called after push rules are updated.
|
||||
@@ -567,11 +589,9 @@ export class PushProcessor {
|
||||
return false;
|
||||
}
|
||||
|
||||
const regex =
|
||||
cond.key === "content.body"
|
||||
? this.createCachedRegex("(^|\\W)", cond.pattern, "(\\W|$)")
|
||||
: this.createCachedRegex("^", cond.pattern, "$");
|
||||
|
||||
// Align to word boundary on `content.body` matches, whole string otherwise
|
||||
// https://spec.matrix.org/v1.13/client-server-api/#conditions-1
|
||||
const regex = PushProcessor.getPushRuleGlobRegex(cond.pattern, cond.key === "content.body");
|
||||
return !!val.match(regex);
|
||||
}
|
||||
|
||||
@@ -621,17 +641,6 @@ export class PushProcessor {
|
||||
);
|
||||
}
|
||||
|
||||
private createCachedRegex(prefix: string, glob: string, suffix: string): RegExp {
|
||||
if (PushProcessor.cachedGlobToRegex[glob]) {
|
||||
return PushProcessor.cachedGlobToRegex[glob];
|
||||
}
|
||||
PushProcessor.cachedGlobToRegex[glob] = new RegExp(
|
||||
prefix + globToRegexp(glob) + suffix,
|
||||
"i", // Case insensitive
|
||||
);
|
||||
return PushProcessor.cachedGlobToRegex[glob];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the key into the separate fields to search by splitting on
|
||||
* unescaped ".", and then removing any escape characters.
|
||||
|
||||
@@ -219,6 +219,12 @@ export class OutgoingRequestProcessor {
|
||||
|
||||
// we use the full prefix
|
||||
prefix: "",
|
||||
|
||||
// We set a timeout of 60 seconds to guard against requests getting stuck forever and wedging the
|
||||
// request loop (cf https://github.com/element-hq/element-web/issues/29534).
|
||||
//
|
||||
// (XXX: should we do this in the whole of the js-sdk?)
|
||||
localTimeoutMs: 60000,
|
||||
};
|
||||
|
||||
return await this.http.authedRequest<string>(method, path, queryParams, body, opts);
|
||||
|
||||
@@ -18,7 +18,7 @@ import { type OlmMachine, type OutgoingRequest } from "@matrix-org/matrix-sdk-cr
|
||||
|
||||
import { type OutgoingRequestProcessor } from "./OutgoingRequestProcessor.ts";
|
||||
import { type Logger } from "../logger.ts";
|
||||
import { defer, type IDeferred, logDuration } from "../utils.ts";
|
||||
import { logDuration } from "../utils.ts";
|
||||
|
||||
/**
|
||||
* OutgoingRequestsManager: responsible for processing outgoing requests from the OlmMachine.
|
||||
@@ -39,7 +39,7 @@ export class OutgoingRequestsManager {
|
||||
* will resolve once that next iteration completes. If it is undefined, there have been no new calls
|
||||
* to `doProcessOutgoingRequests` since the current iteration started.
|
||||
*/
|
||||
private nextLoopDeferred?: IDeferred<void>;
|
||||
private nextLoopDeferred?: PromiseWithResolvers<void>;
|
||||
|
||||
public constructor(
|
||||
private readonly logger: Logger,
|
||||
@@ -74,7 +74,7 @@ export class OutgoingRequestsManager {
|
||||
// In order to circumvent the race, we set a flag which tells the loop to go round once again even if the
|
||||
// queue appears to be empty.
|
||||
if (!this.nextLoopDeferred) {
|
||||
this.nextLoopDeferred = defer();
|
||||
this.nextLoopDeferred = Promise.withResolvers();
|
||||
}
|
||||
|
||||
// ... and wait for it to complete.
|
||||
@@ -99,14 +99,14 @@ export class OutgoingRequestsManager {
|
||||
this.outgoingRequestLoopRunning = true;
|
||||
try {
|
||||
while (!this.stopped && this.nextLoopDeferred) {
|
||||
const deferred = this.nextLoopDeferred;
|
||||
const loopTickResolvers = this.nextLoopDeferred;
|
||||
|
||||
// reset `nextLoopDeferred` so that any future calls to `doProcessOutgoingRequests` are queued
|
||||
// for another additional iteration.
|
||||
this.nextLoopDeferred = undefined;
|
||||
|
||||
// make the requests and feed the results back to the `nextLoopDeferred`
|
||||
await this.processOutgoingRequests().then(deferred.resolve, deferred.reject);
|
||||
await this.processOutgoingRequests().then(loopTickResolvers.resolve, loopTickResolvers.reject);
|
||||
}
|
||||
} finally {
|
||||
this.outgoingRequestLoopRunning = false;
|
||||
|
||||
@@ -969,15 +969,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
);
|
||||
return requests
|
||||
.filter((request) => request.roomId === undefined)
|
||||
.map(
|
||||
(request) =>
|
||||
new RustVerificationRequest(
|
||||
this.olmMachine,
|
||||
request,
|
||||
this.outgoingRequestProcessor,
|
||||
this._supportedVerificationMethods,
|
||||
),
|
||||
);
|
||||
.map((request) => this.makeVerificationRequest(request));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1002,12 +994,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
const request = requests.find((request) => request.roomId?.toString() === roomId);
|
||||
|
||||
if (request) {
|
||||
return new RustVerificationRequest(
|
||||
this.olmMachine,
|
||||
request,
|
||||
this.outgoingRequestProcessor,
|
||||
this._supportedVerificationMethods,
|
||||
);
|
||||
return this.makeVerificationRequest(request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1038,12 +1025,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
new RustSdkCryptoJs.EventId(eventId),
|
||||
methods,
|
||||
);
|
||||
return new RustVerificationRequest(
|
||||
this.olmMachine,
|
||||
request,
|
||||
this.outgoingRequestProcessor,
|
||||
this._supportedVerificationMethods,
|
||||
);
|
||||
return this.makeVerificationRequest(request);
|
||||
} finally {
|
||||
userIdentity.free();
|
||||
}
|
||||
@@ -1114,12 +1096,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
this._supportedVerificationMethods.map(verificationMethodIdentifierToMethod),
|
||||
);
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(outgoingRequest);
|
||||
return new RustVerificationRequest(
|
||||
this.olmMachine,
|
||||
request,
|
||||
this.outgoingRequestProcessor,
|
||||
this._supportedVerificationMethods,
|
||||
);
|
||||
return this.makeVerificationRequest(request);
|
||||
} finally {
|
||||
userIdentity.free();
|
||||
}
|
||||
@@ -1152,12 +1129,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
this._supportedVerificationMethods.map(verificationMethodIdentifierToMethod),
|
||||
);
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(outgoingRequest);
|
||||
return new RustVerificationRequest(
|
||||
this.olmMachine,
|
||||
request,
|
||||
this.outgoingRequestProcessor,
|
||||
this._supportedVerificationMethods,
|
||||
);
|
||||
return this.makeVerificationRequest(request);
|
||||
} finally {
|
||||
device.free();
|
||||
}
|
||||
@@ -1465,7 +1437,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
// Disable backup, and delete all the backups from the server
|
||||
await this.backupManager.deleteAllKeyBackupVersions();
|
||||
|
||||
this.deleteSecretStorage();
|
||||
await this.deleteSecretStorage();
|
||||
|
||||
// Reset the cross-signing keys
|
||||
await this.crossSigningIdentity.bootstrapCrossSigning({
|
||||
@@ -1635,7 +1607,6 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
|
||||
/** called by the sync loop after processing each sync.
|
||||
*
|
||||
* TODO: figure out something equivalent for sliding sync.
|
||||
*
|
||||
* @param syncState - information on the completed sync.
|
||||
*/
|
||||
@@ -1647,6 +1618,13 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#markAllTrackedUsersAsDirty}.
|
||||
*/
|
||||
public async markAllTrackedUsersAsDirty(): Promise<void> {
|
||||
await this.olmMachine.markAllTrackedUsersAsDirty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming m.key.verification.request event, received either in-room or in a to-device message.
|
||||
*
|
||||
@@ -1661,15 +1639,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
);
|
||||
|
||||
if (request) {
|
||||
this.emit(
|
||||
CryptoEvent.VerificationRequestReceived,
|
||||
new RustVerificationRequest(
|
||||
this.olmMachine,
|
||||
request,
|
||||
this.outgoingRequestProcessor,
|
||||
this._supportedVerificationMethods,
|
||||
),
|
||||
);
|
||||
this.emit(CryptoEvent.VerificationRequestReceived, this.makeVerificationRequest(request));
|
||||
} else {
|
||||
// There are multiple reasons this can happen; probably the most likely is that the event is an
|
||||
// in-room event which is too old.
|
||||
@@ -1679,6 +1649,17 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
}
|
||||
}
|
||||
|
||||
/** Utility function to wrap a rust `VerificationRequest` with our own {@link VerificationRequest}. */
|
||||
private makeVerificationRequest(request: RustSdkCryptoJs.VerificationRequest): VerificationRequest {
|
||||
return new RustVerificationRequest(
|
||||
this.logger,
|
||||
this.olmMachine,
|
||||
request,
|
||||
this.outgoingRequestProcessor,
|
||||
this._supportedVerificationMethods,
|
||||
);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Other public functions
|
||||
|
||||
@@ -34,8 +34,8 @@ import { type OutgoingRequestProcessor } from "./OutgoingRequestProcessor.ts";
|
||||
import { TypedReEmitter } from "../ReEmitter.ts";
|
||||
import { type MatrixEvent } from "../models/event.ts";
|
||||
import { EventType, MsgType } from "../@types/event.ts";
|
||||
import { defer, type IDeferred } from "../utils.ts";
|
||||
import { VerificationMethod } from "../types.ts";
|
||||
import type { Logger } from "../logger.ts";
|
||||
|
||||
/**
|
||||
* An incoming, or outgoing, request to verify a user or a device via cross-signing.
|
||||
@@ -60,12 +60,14 @@ export class RustVerificationRequest
|
||||
/**
|
||||
* Construct a new RustVerificationRequest to wrap the rust-level `VerificationRequest`.
|
||||
*
|
||||
* @param logger - A logger instance which will be used to log events.
|
||||
* @param olmMachine - The `OlmMachine` from the underlying rust crypto sdk.
|
||||
* @param inner - VerificationRequest from the Rust SDK.
|
||||
* @param outgoingRequestProcessor - `OutgoingRequestProcessor` to use for making outgoing HTTP requests.
|
||||
* @param supportedVerificationMethods - Verification methods to use when `accept()` is called.
|
||||
*/
|
||||
public constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly olmMachine: RustSdkCryptoJs.OlmMachine,
|
||||
private readonly inner: RustSdkCryptoJs.VerificationRequest,
|
||||
private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
|
||||
@@ -309,6 +311,7 @@ export class RustVerificationRequest
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info("Cancelling verification request with params:", params);
|
||||
this._cancelling = true;
|
||||
try {
|
||||
const req: undefined | OutgoingRequest = this.inner.cancel();
|
||||
@@ -474,7 +477,7 @@ abstract class BaseRustVerifer<InnerType extends RustSdkCryptoJs.Qr | RustSdkCry
|
||||
VerifierEventHandlerMap & VerificationRequestEventHandlerMap
|
||||
> {
|
||||
/** A deferred which completes when the verification completes (or rejects when it is cancelled/fails) */
|
||||
protected readonly completionDeferred: IDeferred<void>;
|
||||
protected readonly completionDeferred: PromiseWithResolvers<void>;
|
||||
|
||||
public constructor(
|
||||
protected inner: InnerType,
|
||||
@@ -482,7 +485,7 @@ abstract class BaseRustVerifer<InnerType extends RustSdkCryptoJs.Qr | RustSdkCry
|
||||
) {
|
||||
super();
|
||||
|
||||
this.completionDeferred = defer();
|
||||
this.completionDeferred = Promise.withResolvers();
|
||||
|
||||
// As with RustVerificationRequest, we need to avoid a reference cycle.
|
||||
// See the comments in RustVerificationRequest.
|
||||
|
||||
+8
-8
@@ -21,7 +21,7 @@ limitations under the License.
|
||||
import { logger } from "./logger.ts";
|
||||
import { type MatrixEvent } from "./models/event.ts";
|
||||
import { EventType } from "./@types/event.ts";
|
||||
import { defer, type IDeferred, removeElement } from "./utils.ts";
|
||||
import { removeElement } from "./utils.ts";
|
||||
import { calculateRetryBackoff, type MatrixError } from "./http-api/index.ts";
|
||||
import { type ISendEventResponse } from "./@types/requests.ts";
|
||||
|
||||
@@ -29,7 +29,7 @@ const DEBUG = false; // set true to enable console logging.
|
||||
|
||||
interface IQueueEntry<T> {
|
||||
event: MatrixEvent;
|
||||
defer: IDeferred<T>;
|
||||
resolvers: PromiseWithResolvers<T>;
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export class MatrixScheduler<T = ISendEventResponse> {
|
||||
|
||||
// queueName: [{
|
||||
// event: MatrixEvent, // event to send
|
||||
// defer: Deferred, // defer to resolve/reject at the END of the retries
|
||||
// defer: PromiseWithResolvers, // defer to resolve/reject at the END of the retries
|
||||
// attempts: Number // number of times we've called processFn
|
||||
// }, ...]
|
||||
private readonly queues: Record<string, IQueueEntry<T>[]> = {};
|
||||
@@ -188,15 +188,15 @@ export class MatrixScheduler<T = ISendEventResponse> {
|
||||
if (!this.queues[queueName]) {
|
||||
this.queues[queueName] = [];
|
||||
}
|
||||
const deferred = defer<T>();
|
||||
const eventResolvers = Promise.withResolvers<T>();
|
||||
this.queues[queueName].push({
|
||||
event: event,
|
||||
defer: deferred,
|
||||
resolvers: eventResolvers,
|
||||
attempts: 0,
|
||||
});
|
||||
debuglog("Queue algorithm dumped event %s into queue '%s'", event.getId(), queueName);
|
||||
this.startProcessingQueues();
|
||||
return deferred.promise;
|
||||
return eventResolvers.promise;
|
||||
}
|
||||
|
||||
private startProcessingQueues(): void {
|
||||
@@ -239,7 +239,7 @@ export class MatrixScheduler<T = ISendEventResponse> {
|
||||
// remove this from the queue
|
||||
this.removeNextEvent(queueName);
|
||||
debuglog("Queue '%s' sent event %s", queueName, obj.event.getId());
|
||||
obj.defer.resolve(res);
|
||||
obj.resolvers.resolve(res);
|
||||
// keep processing
|
||||
this.processQueue(queueName);
|
||||
},
|
||||
@@ -279,7 +279,7 @@ export class MatrixScheduler<T = ISendEventResponse> {
|
||||
logger.info("clearing queue '%s'", queueName);
|
||||
let obj: IQueueEntry<T> | undefined;
|
||||
while ((obj = this.removeNextEvent(queueName))) {
|
||||
obj.defer.reject(err);
|
||||
obj.resolvers.reject(err);
|
||||
}
|
||||
this.disableQueue(queueName);
|
||||
}
|
||||
|
||||
+28
-28
@@ -82,9 +82,16 @@ class ExtensionE2EE implements Extension<ExtensionE2EERequest, ExtensionE2EEResp
|
||||
return ExtensionState.PreProcess;
|
||||
}
|
||||
|
||||
public onRequest(isInitial: boolean): ExtensionE2EERequest | undefined {
|
||||
if (!isInitial) {
|
||||
return undefined;
|
||||
public async onRequest(isInitial: boolean): Promise<ExtensionE2EERequest> {
|
||||
if (isInitial) {
|
||||
// In SSS, the `?pos=` contains the stream position for device list updates.
|
||||
// If we do not have a `?pos=` (e.g because we forgot it, or because the server
|
||||
// invalidated our connection) then we MUST invlaidate all device lists because
|
||||
// the server will not tell us the delta. This will then cause UTDs as we will fail
|
||||
// to encrypt for new devices. This is an expensive call, so we should
|
||||
// really really remember `?pos=` wherever possible.
|
||||
logger.log("ExtensionE2EE: invalidating all device lists due to missing 'pos'");
|
||||
await this.crypto.markAllTrackedUsersAsDirty();
|
||||
}
|
||||
return {
|
||||
enabled: true, // this is sticky so only send it on the initial request
|
||||
@@ -134,15 +141,12 @@ class ExtensionToDevice implements Extension<ExtensionToDeviceRequest, Extension
|
||||
return ExtensionState.PreProcess;
|
||||
}
|
||||
|
||||
public onRequest(isInitial: boolean): ExtensionToDeviceRequest {
|
||||
const extReq: ExtensionToDeviceRequest = {
|
||||
public async onRequest(isInitial: boolean): Promise<ExtensionToDeviceRequest> {
|
||||
return {
|
||||
since: this.nextBatch !== null ? this.nextBatch : undefined,
|
||||
limit: 100,
|
||||
enabled: true,
|
||||
};
|
||||
if (isInitial) {
|
||||
extReq["limit"] = 100;
|
||||
extReq["enabled"] = true;
|
||||
}
|
||||
return extReq;
|
||||
}
|
||||
|
||||
public async onResponse(data: ExtensionToDeviceResponse): Promise<void> {
|
||||
@@ -216,10 +220,7 @@ class ExtensionAccountData implements Extension<ExtensionAccountDataRequest, Ext
|
||||
return ExtensionState.PostProcess;
|
||||
}
|
||||
|
||||
public onRequest(isInitial: boolean): ExtensionAccountDataRequest | undefined {
|
||||
if (!isInitial) {
|
||||
return undefined;
|
||||
}
|
||||
public async onRequest(isInitial: boolean): Promise<ExtensionAccountDataRequest> {
|
||||
return {
|
||||
enabled: true,
|
||||
};
|
||||
@@ -286,10 +287,7 @@ class ExtensionTyping implements Extension<ExtensionTypingRequest, ExtensionTypi
|
||||
return ExtensionState.PostProcess;
|
||||
}
|
||||
|
||||
public onRequest(isInitial: boolean): ExtensionTypingRequest | undefined {
|
||||
if (!isInitial) {
|
||||
return undefined; // don't send a JSON object for subsequent requests, we don't need to.
|
||||
}
|
||||
public async onRequest(isInitial: boolean): Promise<ExtensionTypingRequest> {
|
||||
return {
|
||||
enabled: true,
|
||||
};
|
||||
@@ -325,13 +323,10 @@ class ExtensionReceipts implements Extension<ExtensionReceiptsRequest, Extension
|
||||
return ExtensionState.PostProcess;
|
||||
}
|
||||
|
||||
public onRequest(isInitial: boolean): ExtensionReceiptsRequest | undefined {
|
||||
if (isInitial) {
|
||||
return {
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
return undefined; // don't send a JSON object for subsequent requests, we don't need to.
|
||||
public async onRequest(isInitial: boolean): Promise<ExtensionReceiptsRequest> {
|
||||
return {
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
public async onResponse(data: ExtensionReceiptsResponse): Promise<void> {
|
||||
@@ -442,6 +437,7 @@ export class SlidingSyncSdk {
|
||||
}
|
||||
} else {
|
||||
this.failCount = 0;
|
||||
logger.log(`SlidingSyncState.RequestFinished with ${Object.keys(resp?.rooms || []).length} rooms`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -580,7 +576,7 @@ export class SlidingSyncSdk {
|
||||
|
||||
// TODO: handle threaded / beacon events
|
||||
|
||||
if (roomData.initial) {
|
||||
if (roomData.limited || roomData.initial) {
|
||||
// we should not know about any of these timeline entries if this is a genuinely new room.
|
||||
// If we do, then we've effectively done scrollback (e.g requesting timeline_limit: 1 for
|
||||
// this room, then timeline_limit: 50).
|
||||
@@ -637,6 +633,9 @@ export class SlidingSyncSdk {
|
||||
room.setUnreadNotificationCount(NotificationCountType.Highlight, roomData.highlight_count);
|
||||
}
|
||||
}
|
||||
if (roomData.bump_stamp) {
|
||||
room.setBumpStamp(roomData.bump_stamp);
|
||||
}
|
||||
|
||||
if (Number.isInteger(roomData.invited_count)) {
|
||||
room.currentState.setInvitedMemberCount(roomData.invited_count!);
|
||||
@@ -656,11 +655,10 @@ export class SlidingSyncSdk {
|
||||
inviteStateEvents.forEach((e) => {
|
||||
this.client.emit(ClientEvent.Event, e);
|
||||
});
|
||||
room.updateMyMembership(KnownMembership.Invite);
|
||||
return;
|
||||
}
|
||||
|
||||
if (roomData.initial) {
|
||||
if (roomData.limited) {
|
||||
// set the back-pagination token. Do this *before* adding any
|
||||
// events so that clients can start back-paginating.
|
||||
room.getLiveTimeline().setPaginationToken(roomData.prev_batch ?? null, EventTimeline.BACKWARDS);
|
||||
@@ -728,6 +726,8 @@ export class SlidingSyncSdk {
|
||||
// synchronous execution prior to emitting SlidingSyncState.Complete
|
||||
room.updateMyMembership(KnownMembership.Join);
|
||||
|
||||
room.setMSC4186SummaryData(roomData.heroes, roomData.joined_count, roomData.invited_count);
|
||||
|
||||
room.recalculate();
|
||||
if (roomData.initial) {
|
||||
client.store.storeRoom(room);
|
||||
|
||||
+38
-329
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2022-2024 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.
|
||||
@@ -18,7 +18,7 @@ import { logger } from "./logger.ts";
|
||||
import { type MatrixClient } from "./client.ts";
|
||||
import { type IRoomEvent, type IStateEvent } from "./sync-accumulator.ts";
|
||||
import { TypedEventEmitter } from "./models/typed-event-emitter.ts";
|
||||
import { sleep, type IDeferred, defer } from "./utils.ts";
|
||||
import { sleep } from "./utils.ts";
|
||||
import { type HTTPError } from "./http-api/index.ts";
|
||||
|
||||
// /sync requests allow you to set a timeout= but the request may continue
|
||||
@@ -82,10 +82,23 @@ export interface MSC3575SlidingSyncRequest {
|
||||
clientTimeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* New format of hero introduced in MSC4186 with display name and avatar URL
|
||||
* in addition to just user_id (as it is on the wire, with underscores)
|
||||
* as opposed to Hero in room-summary.ts which has fields in camelCase
|
||||
* (and also a flag to note what format the hero came from).
|
||||
*/
|
||||
export interface MSC4186Hero {
|
||||
user_id: string;
|
||||
displayname?: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
export interface MSC3575RoomData {
|
||||
name: string;
|
||||
required_state: IStateEvent[];
|
||||
timeline: (IRoomEvent | IStateEvent)[];
|
||||
heroes?: MSC4186Hero[];
|
||||
notification_count?: number;
|
||||
highlight_count?: number;
|
||||
joined_count?: number;
|
||||
@@ -96,41 +109,13 @@ export interface MSC3575RoomData {
|
||||
is_dm?: boolean;
|
||||
prev_batch?: string;
|
||||
num_live?: number;
|
||||
bump_stamp?: number;
|
||||
}
|
||||
|
||||
interface ListResponse {
|
||||
count: number;
|
||||
ops: Operation[];
|
||||
}
|
||||
|
||||
interface BaseOperation {
|
||||
op: string;
|
||||
}
|
||||
|
||||
interface DeleteOperation extends BaseOperation {
|
||||
op: "DELETE";
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface InsertOperation extends BaseOperation {
|
||||
op: "INSERT";
|
||||
index: number;
|
||||
room_id: string;
|
||||
}
|
||||
|
||||
interface InvalidateOperation extends BaseOperation {
|
||||
op: "INVALIDATE";
|
||||
range: [number, number];
|
||||
}
|
||||
|
||||
interface SyncOperation extends BaseOperation {
|
||||
op: "SYNC";
|
||||
range: [number, number];
|
||||
room_ids: string[];
|
||||
}
|
||||
|
||||
type Operation = DeleteOperation | InsertOperation | InvalidateOperation | SyncOperation;
|
||||
|
||||
/**
|
||||
* A complete Sliding Sync response
|
||||
*/
|
||||
@@ -163,7 +148,6 @@ class SlidingList {
|
||||
private isModified?: boolean;
|
||||
|
||||
// returned data
|
||||
public roomIndexToRoomId: Record<number, string> = {};
|
||||
public joinedCount = 0;
|
||||
|
||||
/**
|
||||
@@ -204,9 +188,6 @@ class SlidingList {
|
||||
// reset values as the join count may be very different (if filters changed) including the rooms
|
||||
// (e.g. sort orders or sliding window ranges changed)
|
||||
|
||||
// the constantly changing sliding window ranges. Not an array for performance reasons
|
||||
// E.g. tracking ranges 0-99, 500-599, we don't want to have a 600 element array
|
||||
this.roomIndexToRoomId = {};
|
||||
// the total number of joined rooms according to the server, always >= len(roomIndexToRoomId)
|
||||
this.joinedCount = 0;
|
||||
}
|
||||
@@ -226,26 +207,6 @@ class SlidingList {
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given index is within the list range. This is required even though the /sync API
|
||||
* provides explicit updates with index positions because of the following situation:
|
||||
* 0 1 2 3 4 5 6 7 8 indexes
|
||||
* a b c d e f COMMANDS: SYNC 0 2 a b c; SYNC 6 8 d e f;
|
||||
* a b c d _ f COMMAND: DELETE 7;
|
||||
* e a b c d f COMMAND: INSERT 0 e;
|
||||
* c=3 is wrong as we are not tracking it, ergo we need to see if `i` is in range else drop it
|
||||
* @param i - The index to check
|
||||
* @returns True if the index is within a sliding window
|
||||
*/
|
||||
public isIndexInRange(i: number): boolean {
|
||||
for (const r of this.list.ranges) {
|
||||
if (r[0] <= i && i <= r[1]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -274,10 +235,10 @@ export interface Extension<Req extends object, Res extends object> {
|
||||
/**
|
||||
* A function which is called when the request JSON is being formed.
|
||||
* Returns the data to insert under this key.
|
||||
* @param isInitial - True when this is part of the initial request (send sticky params)
|
||||
* @param isInitial - True when this is part of the initial request.
|
||||
* @returns The request JSON to send.
|
||||
*/
|
||||
onRequest(isInitial: boolean): Req | undefined;
|
||||
onRequest(isInitial: boolean): Promise<Req>;
|
||||
/**
|
||||
* A function which is called when there is response JSON under this extension.
|
||||
* @param data - The response JSON under the extension name.
|
||||
@@ -295,12 +256,10 @@ export interface Extension<Req extends object, Res extends object> {
|
||||
* of information when processing sync responses.
|
||||
* - RoomData: concerns rooms, useful for SlidingSyncSdk to update its knowledge of rooms.
|
||||
* - Lifecycle: concerns callbacks at various well-defined points in the sync process.
|
||||
* - List: concerns lists, useful for UI layers to re-render room lists.
|
||||
* Specifically, the order of event invocation is:
|
||||
* - Lifecycle (state=RequestFinished)
|
||||
* - RoomData (N times)
|
||||
* - Lifecycle (state=Complete)
|
||||
* - List (at most once per list)
|
||||
*/
|
||||
export enum SlidingSyncEvent {
|
||||
/**
|
||||
@@ -313,16 +272,9 @@ export enum SlidingSyncEvent {
|
||||
* - SlidingSyncState.RequestFinished: Fires after we receive a valid response but before the
|
||||
* response has been processed. Perform any pre-process steps here. If there was a problem syncing,
|
||||
* `err` will be set (e.g network errors).
|
||||
* - SlidingSyncState.Complete: Fires after all SlidingSyncEvent.RoomData have been fired but before
|
||||
* SlidingSyncEvent.List.
|
||||
* - SlidingSyncState.Complete: Fires after the response has been processed.
|
||||
*/
|
||||
Lifecycle = "SlidingSync.Lifecycle",
|
||||
/**
|
||||
* This event fires whenever there has been a change to this list index. It fires exactly once
|
||||
* per list, even if there were multiple operations for the list.
|
||||
* It fires AFTER Lifecycle and RoomData events.
|
||||
*/
|
||||
List = "SlidingSync.List",
|
||||
}
|
||||
|
||||
export type SlidingSyncEventHandlerMap = {
|
||||
@@ -332,7 +284,6 @@ export type SlidingSyncEventHandlerMap = {
|
||||
resp: MSC3575SlidingSyncResponse | null,
|
||||
err?: Error,
|
||||
) => void;
|
||||
[SlidingSyncEvent.List]: (listKey: string, joinedCount: number, roomIndexToRoomId: Record<number, string>) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -347,11 +298,6 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
private terminated = false;
|
||||
// flag set when resend() is called because we cannot rely on detecting AbortError in JS SDK :(
|
||||
private needsResend = false;
|
||||
// the txn_id to send with the next request.
|
||||
private txnId: string | null = null;
|
||||
// a list (in chronological order of when they were sent) of objects containing the txn ID and
|
||||
// a defer to resolve/reject depending on whether they were successfully sent or not.
|
||||
private txnIdDefers: (IDeferred<string> & { txnId: string })[] = [];
|
||||
// map of extension name to req/resp handler
|
||||
private extensions: Record<string, Extension<any, any>> = {};
|
||||
|
||||
@@ -426,14 +372,13 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
* @param key - The list key
|
||||
* @returns The list data which contains the rooms in this list
|
||||
*/
|
||||
public getListData(key: string): { joinedCount: number; roomIndexToRoomId: Record<number, string> } | null {
|
||||
public getListData(key: string): { joinedCount: number } | null {
|
||||
const data = this.lists.get(key);
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
joinedCount: data.joinedCount,
|
||||
roomIndexToRoomId: Object.assign({}, data.roomIndexToRoomId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -461,13 +406,13 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
* (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
|
||||
* immediately after sending, in which case the action will be applied in the subsequent request)
|
||||
*/
|
||||
public setListRanges(key: string, ranges: number[][]): Promise<string> {
|
||||
public setListRanges(key: string, ranges: number[][]): void {
|
||||
const list = this.lists.get(key);
|
||||
if (!list) {
|
||||
return Promise.reject(new Error("no list with key " + key));
|
||||
throw new Error("no list with key " + key);
|
||||
}
|
||||
list.updateListRange(ranges);
|
||||
return this.resend();
|
||||
this.resend();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -479,7 +424,7 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
* (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
|
||||
* immediately after sending, in which case the action will be applied in the subsequent request)
|
||||
*/
|
||||
public setList(key: string, list: MSC3575List): Promise<string> {
|
||||
public setList(key: string, list: MSC3575List): void {
|
||||
const existingList = this.lists.get(key);
|
||||
if (existingList) {
|
||||
existingList.replaceList(list);
|
||||
@@ -488,7 +433,7 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
this.lists.set(key, new SlidingList(list));
|
||||
}
|
||||
this.listModifiedCount += 1;
|
||||
return this.resend();
|
||||
this.resend();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -504,27 +449,21 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
* /sync request to resend new subscriptions. If the /sync stream has not started, this will
|
||||
* prepare the room subscriptions for when start() is called.
|
||||
* @param s - The new desired room subscriptions.
|
||||
* @returns A promise which resolves to the transaction ID when it has been received down sync
|
||||
* (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
|
||||
* immediately after sending, in which case the action will be applied in the subsequent request)
|
||||
*/
|
||||
public modifyRoomSubscriptions(s: Set<string>): Promise<string> {
|
||||
public modifyRoomSubscriptions(s: Set<string>): void {
|
||||
this.desiredRoomSubscriptions = s;
|
||||
return this.resend();
|
||||
this.resend();
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify which events to retrieve for room subscriptions. Invalidates all room subscriptions
|
||||
* such that they will be sent up afresh.
|
||||
* @param rs - The new room subscription fields to fetch.
|
||||
* @returns A promise which resolves to the transaction ID when it has been received down sync
|
||||
* (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
|
||||
* immediately after sending, in which case the action will be applied in the subsequent request)
|
||||
*/
|
||||
public modifyRoomSubscriptionInfo(rs: MSC3575RoomSubscription): Promise<string> {
|
||||
public modifyRoomSubscriptionInfo(rs: MSC3575RoomSubscription): void {
|
||||
this.roomSubscriptionInfo = rs;
|
||||
this.confirmedRoomSubscriptions = new Set<string>();
|
||||
return this.resend();
|
||||
this.resend();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -538,11 +477,11 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
this.extensions[ext.name()] = ext;
|
||||
}
|
||||
|
||||
private getExtensionRequest(isInitial: boolean): Record<string, object | undefined> {
|
||||
private async getExtensionRequest(isInitial: boolean): Promise<Record<string, object | undefined>> {
|
||||
const ext: Record<string, object | undefined> = {};
|
||||
Object.keys(this.extensions).forEach((extName) => {
|
||||
ext[extName] = this.extensions[extName].onRequest(isInitial);
|
||||
});
|
||||
for (const extName in this.extensions) {
|
||||
ext[extName] = await this.extensions[extName].onRequest(isInitial);
|
||||
}
|
||||
return ext;
|
||||
}
|
||||
|
||||
@@ -595,203 +534,13 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
this.emit(SlidingSyncEvent.Lifecycle, state, resp, err);
|
||||
}
|
||||
|
||||
private shiftRight(listKey: string, hi: number, low: number): void {
|
||||
const list = this.lists.get(listKey);
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
// l h
|
||||
// 0,1,2,3,4 <- before
|
||||
// 0,1,2,2,3 <- after, hi is deleted and low is duplicated
|
||||
for (let i = hi; i > low; i--) {
|
||||
if (list.isIndexInRange(i)) {
|
||||
list.roomIndexToRoomId[i] = list.roomIndexToRoomId[i - 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private shiftLeft(listKey: string, hi: number, low: number): void {
|
||||
const list = this.lists.get(listKey);
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
// l h
|
||||
// 0,1,2,3,4 <- before
|
||||
// 0,1,3,4,4 <- after, low is deleted and hi is duplicated
|
||||
for (let i = low; i < hi; i++) {
|
||||
if (list.isIndexInRange(i)) {
|
||||
list.roomIndexToRoomId[i] = list.roomIndexToRoomId[i + 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private removeEntry(listKey: string, index: number): void {
|
||||
const list = this.lists.get(listKey);
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
// work out the max index
|
||||
let max = -1;
|
||||
for (const n in list.roomIndexToRoomId) {
|
||||
if (Number(n) > max) {
|
||||
max = Number(n);
|
||||
}
|
||||
}
|
||||
if (max < 0 || index > max) {
|
||||
return;
|
||||
}
|
||||
// Everything higher than the gap needs to be shifted left.
|
||||
this.shiftLeft(listKey, max, index);
|
||||
delete list.roomIndexToRoomId[max];
|
||||
}
|
||||
|
||||
private addEntry(listKey: string, index: number): void {
|
||||
const list = this.lists.get(listKey);
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
// work out the max index
|
||||
let max = -1;
|
||||
for (const n in list.roomIndexToRoomId) {
|
||||
if (Number(n) > max) {
|
||||
max = Number(n);
|
||||
}
|
||||
}
|
||||
if (max < 0 || index > max) {
|
||||
return;
|
||||
}
|
||||
// Everything higher than the gap needs to be shifted right, +1 so we don't delete the highest element
|
||||
this.shiftRight(listKey, max + 1, index);
|
||||
}
|
||||
|
||||
private processListOps(list: ListResponse, listKey: string): void {
|
||||
let gapIndex = -1;
|
||||
const listData = this.lists.get(listKey);
|
||||
if (!listData) {
|
||||
return;
|
||||
}
|
||||
list.ops.forEach((op: Operation) => {
|
||||
if (!listData) {
|
||||
return;
|
||||
}
|
||||
switch (op.op) {
|
||||
case "DELETE": {
|
||||
logger.debug("DELETE", listKey, op.index, ";");
|
||||
delete listData.roomIndexToRoomId[op.index];
|
||||
if (gapIndex !== -1) {
|
||||
// we already have a DELETE operation to process, so process it.
|
||||
this.removeEntry(listKey, gapIndex);
|
||||
}
|
||||
gapIndex = op.index;
|
||||
break;
|
||||
}
|
||||
case "INSERT": {
|
||||
logger.debug("INSERT", listKey, op.index, op.room_id, ";");
|
||||
if (listData.roomIndexToRoomId[op.index]) {
|
||||
// something is in this space, shift items out of the way
|
||||
if (gapIndex < 0) {
|
||||
// we haven't been told where to shift from, so make way for a new room entry.
|
||||
this.addEntry(listKey, op.index);
|
||||
} else if (gapIndex > op.index) {
|
||||
// the gap is further down the list, shift every element to the right
|
||||
// starting at the gap so we can just shift each element in turn:
|
||||
// [A,B,C,_] gapIndex=3, op.index=0
|
||||
// [A,B,C,C] i=3
|
||||
// [A,B,B,C] i=2
|
||||
// [A,A,B,C] i=1
|
||||
// Terminate. We'll assign into op.index next.
|
||||
this.shiftRight(listKey, gapIndex, op.index);
|
||||
} else if (gapIndex < op.index) {
|
||||
// the gap is further up the list, shift every element to the left
|
||||
// starting at the gap so we can just shift each element in turn
|
||||
this.shiftLeft(listKey, op.index, gapIndex);
|
||||
}
|
||||
}
|
||||
// forget the gap, we don't need it anymore. This is outside the check for
|
||||
// a room being present in this index position because INSERTs always universally
|
||||
// forget the gap, not conditionally based on the presence of a room in the INSERT
|
||||
// position. Without this, DELETE 0; INSERT 0; would do the wrong thing.
|
||||
gapIndex = -1;
|
||||
listData.roomIndexToRoomId[op.index] = op.room_id;
|
||||
break;
|
||||
}
|
||||
case "INVALIDATE": {
|
||||
const startIndex = op.range[0];
|
||||
for (let i = startIndex; i <= op.range[1]; i++) {
|
||||
delete listData.roomIndexToRoomId[i];
|
||||
}
|
||||
logger.debug("INVALIDATE", listKey, op.range[0], op.range[1], ";");
|
||||
break;
|
||||
}
|
||||
case "SYNC": {
|
||||
const startIndex = op.range[0];
|
||||
for (let i = startIndex; i <= op.range[1]; i++) {
|
||||
const roomId = op.room_ids[i - startIndex];
|
||||
if (!roomId) {
|
||||
break; // we are at the end of list
|
||||
}
|
||||
listData.roomIndexToRoomId[i] = roomId;
|
||||
}
|
||||
logger.debug("SYNC", listKey, op.range[0], op.range[1], (op.room_ids || []).join(" "), ";");
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (gapIndex !== -1) {
|
||||
// we already have a DELETE operation to process, so process it
|
||||
// Everything higher than the gap needs to be shifted left.
|
||||
this.removeEntry(listKey, gapIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend a Sliding Sync request. Used when something has changed in the request. Resolves with
|
||||
* the transaction ID of this request on success. Rejects with the transaction ID of this request
|
||||
* on failure.
|
||||
* Resend a Sliding Sync request. Used when something has changed in the request.
|
||||
*/
|
||||
public resend(): Promise<string> {
|
||||
if (this.needsResend && this.txnIdDefers.length > 0) {
|
||||
// we already have a resend queued, so just return the same promise
|
||||
return this.txnIdDefers[this.txnIdDefers.length - 1].promise;
|
||||
}
|
||||
public resend(): void {
|
||||
this.needsResend = true;
|
||||
this.txnId = this.client.makeTxnId();
|
||||
const d = defer<string>();
|
||||
this.txnIdDefers.push({
|
||||
...d,
|
||||
txnId: this.txnId,
|
||||
});
|
||||
this.abortController?.abort();
|
||||
this.abortController = new AbortController();
|
||||
return d.promise;
|
||||
}
|
||||
|
||||
private resolveTransactionDefers(txnId?: string): void {
|
||||
if (!txnId) {
|
||||
return;
|
||||
}
|
||||
// find the matching index
|
||||
let txnIndex = -1;
|
||||
for (let i = 0; i < this.txnIdDefers.length; i++) {
|
||||
if (this.txnIdDefers[i].txnId === txnId) {
|
||||
txnIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (txnIndex === -1) {
|
||||
// this shouldn't happen; we shouldn't be seeing txn_ids for things we don't know about,
|
||||
// whine about it.
|
||||
logger.warn(`resolveTransactionDefers: seen ${txnId} but it isn't a pending txn, ignoring.`);
|
||||
return;
|
||||
}
|
||||
// This list is sorted in time, so if the input txnId ACKs in the middle of this array,
|
||||
// then everything before it that hasn't been ACKed yet never will and we should reject them.
|
||||
for (let i = 0; i < txnIndex; i++) {
|
||||
this.txnIdDefers[i].reject(this.txnIdDefers[i].txnId);
|
||||
}
|
||||
this.txnIdDefers[txnIndex].resolve(txnId);
|
||||
// clear out settled promises, including the one we resolved.
|
||||
this.txnIdDefers = this.txnIdDefers.slice(txnIndex + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -802,7 +551,6 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
this.abortController?.abort();
|
||||
// remove all listeners so things can be GC'd
|
||||
this.removeAllListeners(SlidingSyncEvent.Lifecycle);
|
||||
this.removeAllListeners(SlidingSyncEvent.List);
|
||||
this.removeAllListeners(SlidingSyncEvent.RoomData);
|
||||
}
|
||||
|
||||
@@ -811,20 +559,13 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
*/
|
||||
private resetup(): void {
|
||||
logger.warn("SlidingSync: resetting connection info");
|
||||
// any pending txn ID defers will be forgotten already by the server, so clear them out
|
||||
this.txnIdDefers.forEach((d) => {
|
||||
d.reject(d.txnId);
|
||||
});
|
||||
this.txnIdDefers = [];
|
||||
// resend sticky params and de-confirm all subscriptions
|
||||
this.lists.forEach((l) => {
|
||||
l.setModified(true);
|
||||
});
|
||||
this.confirmedRoomSubscriptions = new Set<string>(); // leave desired ones alone though!
|
||||
// reset the connection as we might be wedged
|
||||
this.needsResend = true;
|
||||
this.abortController?.abort();
|
||||
this.abortController = new AbortController();
|
||||
this.resend();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -836,20 +577,18 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
let currentPos: string | undefined;
|
||||
while (!this.terminated) {
|
||||
this.needsResend = false;
|
||||
let doNotUpdateList = false;
|
||||
let resp: MSC3575SlidingSyncResponse | undefined;
|
||||
try {
|
||||
const listModifiedCount = this.listModifiedCount;
|
||||
const reqLists: Record<string, MSC3575List> = {};
|
||||
this.lists.forEach((l: SlidingList, key: string) => {
|
||||
reqLists[key] = l.getList(false);
|
||||
reqLists[key] = l.getList(true);
|
||||
});
|
||||
const reqBody: MSC3575SlidingSyncRequest = {
|
||||
lists: reqLists,
|
||||
pos: currentPos,
|
||||
timeout: this.timeoutMS,
|
||||
clientTimeout: this.timeoutMS + BUFFER_PERIOD_MS,
|
||||
extensions: this.getExtensionRequest(currentPos === undefined),
|
||||
extensions: await this.getExtensionRequest(currentPos === undefined),
|
||||
};
|
||||
// check if we are (un)subscribing to a room and modify request this one time for it
|
||||
const newSubscriptions = difference(this.desiredRoomSubscriptions, this.confirmedRoomSubscriptions);
|
||||
@@ -868,10 +607,6 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
reqBody.room_subscriptions[roomId] = sub;
|
||||
}
|
||||
}
|
||||
if (this.txnId) {
|
||||
reqBody.txn_id = this.txnId;
|
||||
this.txnId = null;
|
||||
}
|
||||
this.pendingReq = this.client.slidingSync(reqBody, this.proxyBaseUrl, this.abortController.signal);
|
||||
resp = await this.pendingReq;
|
||||
currentPos = resp.pos;
|
||||
@@ -882,13 +617,6 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
for (const roomId of unsubscriptions) {
|
||||
this.confirmedRoomSubscriptions.delete(roomId);
|
||||
}
|
||||
if (listModifiedCount !== this.listModifiedCount) {
|
||||
// the lists have been modified whilst we were waiting for 'await' to return, but the abort()
|
||||
// call did nothing. It is NOT SAFE to modify the list array now. We'll process the response but
|
||||
// not update list pointers.
|
||||
logger.debug("list modified during await call, not updating list");
|
||||
doNotUpdateList = true;
|
||||
}
|
||||
// mark all these lists as having been sent as sticky so we don't keep sending sticky params
|
||||
this.lists.forEach((l) => {
|
||||
l.setModified(false);
|
||||
@@ -931,27 +659,8 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
await this.invokeRoomDataListeners(roomId, resp!.rooms[roomId]);
|
||||
}
|
||||
|
||||
const listKeysWithUpdates: Set<string> = new Set();
|
||||
if (!doNotUpdateList) {
|
||||
for (const [key, list] of Object.entries(resp.lists)) {
|
||||
list.ops = list.ops ?? [];
|
||||
if (list.ops.length > 0) {
|
||||
listKeysWithUpdates.add(key);
|
||||
}
|
||||
this.processListOps(list, key);
|
||||
}
|
||||
}
|
||||
this.invokeLifecycleListeners(SlidingSyncState.Complete, resp);
|
||||
await this.onPostExtensionsResponse(resp.extensions);
|
||||
listKeysWithUpdates.forEach((listKey: string) => {
|
||||
const list = this.lists.get(listKey);
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
this.emit(SlidingSyncEvent.List, listKey, list.joinedCount, Object.assign({}, list.roomIndexToRoomId));
|
||||
});
|
||||
|
||||
this.resolveTransactionDefers(resp.txn_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger } from "../logger.ts";
|
||||
import { defer, type IDeferred } from "../utils.ts";
|
||||
import { type ISavedSync } from "./index.ts";
|
||||
import { type IStoredClientOpts } from "../client.ts";
|
||||
import { type IStateEventWithRoomId, type ISyncResponse } from "../matrix.ts";
|
||||
@@ -26,7 +25,7 @@ export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend {
|
||||
private worker?: Worker;
|
||||
private nextSeq = 0;
|
||||
// The currently in-flight requests to the actual backend
|
||||
private inFlight: Record<number, IDeferred<any>> = {}; // seq: promise
|
||||
private inFlight: Record<number, PromiseWithResolvers<any>> = {}; // seq: promise
|
||||
// Once we start connecting, we keep the promise and re-use it
|
||||
// if we try to connect again
|
||||
private startPromise?: Promise<void>;
|
||||
@@ -164,7 +163,7 @@ export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend {
|
||||
// the promise automatically gets rejected
|
||||
return Promise.resolve().then(() => {
|
||||
const seq = this.nextSeq++;
|
||||
const def = defer<T>();
|
||||
const def = Promise.withResolvers<T>();
|
||||
|
||||
this.inFlight[seq] = def;
|
||||
|
||||
|
||||
+19
-19
@@ -28,7 +28,7 @@ import { type Optional } from "matrix-events-sdk";
|
||||
import type { SyncCryptoCallbacks } from "./common-crypto/CryptoBackend.ts";
|
||||
import { User } from "./models/user.ts";
|
||||
import { NotificationCountType, Room, RoomEvent } from "./models/room.ts";
|
||||
import { deepCopy, defer, type IDeferred, noUnsafeEventProps, promiseMapSeries, unsafeProp } from "./utils.ts";
|
||||
import { deepCopy, noUnsafeEventProps, promiseMapSeries, unsafeProp } from "./utils.ts";
|
||||
import { Filter } from "./filter.ts";
|
||||
import { EventTimeline } from "./models/event-timeline.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
@@ -220,7 +220,7 @@ export class SyncApi {
|
||||
private catchingUp = false;
|
||||
private running = false;
|
||||
private keepAliveTimer?: ReturnType<typeof setTimeout>;
|
||||
private connectionReturnedDefer?: IDeferred<boolean>;
|
||||
private connectionReturnedResolvers?: PromiseWithResolvers<boolean>;
|
||||
private notifEvents: MatrixEvent[] = []; // accumulator of sync events in the current sync response
|
||||
private failedSyncCount = 0; // Number of consecutive failed /sync requests
|
||||
private storeIsInvalid = false; // flag set if the store needs to be cleared before we can start
|
||||
@@ -792,7 +792,7 @@ export class SyncApi {
|
||||
* @returns True if this resulted in a request being retried.
|
||||
*/
|
||||
public retryImmediately(): boolean {
|
||||
if (!this.connectionReturnedDefer) {
|
||||
if (!this.connectionReturnedResolvers) {
|
||||
return false;
|
||||
}
|
||||
this.startKeepAlives(0);
|
||||
@@ -916,9 +916,9 @@ export class SyncApi {
|
||||
|
||||
if (!this.running) {
|
||||
debuglog("Sync no longer running: exiting.");
|
||||
if (this.connectionReturnedDefer) {
|
||||
this.connectionReturnedDefer.reject();
|
||||
this.connectionReturnedDefer = undefined;
|
||||
if (this.connectionReturnedResolvers) {
|
||||
this.connectionReturnedResolvers.reject();
|
||||
this.connectionReturnedResolvers = undefined;
|
||||
}
|
||||
this.updateSyncState(SyncState.Stopped);
|
||||
}
|
||||
@@ -999,9 +999,9 @@ export class SyncApi {
|
||||
private async onSyncError(err: MatrixError): Promise<boolean> {
|
||||
if (!this.running) {
|
||||
debuglog("Sync no longer running: exiting");
|
||||
if (this.connectionReturnedDefer) {
|
||||
this.connectionReturnedDefer.reject();
|
||||
this.connectionReturnedDefer = undefined;
|
||||
if (this.connectionReturnedResolvers) {
|
||||
this.connectionReturnedResolvers.reject();
|
||||
this.connectionReturnedResolvers = undefined;
|
||||
}
|
||||
this.updateSyncState(SyncState.Stopped);
|
||||
return true; // abort
|
||||
@@ -1551,10 +1551,10 @@ export class SyncApi {
|
||||
} else {
|
||||
this.pokeKeepAlive();
|
||||
}
|
||||
if (!this.connectionReturnedDefer) {
|
||||
this.connectionReturnedDefer = defer();
|
||||
if (!this.connectionReturnedResolvers) {
|
||||
this.connectionReturnedResolvers = Promise.withResolvers();
|
||||
}
|
||||
return this.connectionReturnedDefer.promise;
|
||||
return this.connectionReturnedResolvers.promise;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1562,7 +1562,7 @@ export class SyncApi {
|
||||
* reachable.
|
||||
*
|
||||
* On failure, schedules a call back to itself. On success, resolves
|
||||
* this.connectionReturnedDefer.
|
||||
* this.connectionReturnedResolvers.
|
||||
*
|
||||
* @param connDidFail - True if a connectivity failure has been detected. Optional.
|
||||
*/
|
||||
@@ -1571,18 +1571,18 @@ export class SyncApi {
|
||||
// we are in a keepAlive, retrying to connect, but the syncronization
|
||||
// was stopped, so we are stopping the retry.
|
||||
clearTimeout(this.keepAliveTimer);
|
||||
if (this.connectionReturnedDefer) {
|
||||
this.connectionReturnedDefer.reject("SyncApi.stop() was called");
|
||||
this.connectionReturnedDefer = undefined;
|
||||
if (this.connectionReturnedResolvers) {
|
||||
this.connectionReturnedResolvers.reject("SyncApi.stop() was called");
|
||||
this.connectionReturnedResolvers = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const success = (): void => {
|
||||
clearTimeout(this.keepAliveTimer);
|
||||
if (this.connectionReturnedDefer) {
|
||||
this.connectionReturnedDefer.resolve(connDidFail);
|
||||
this.connectionReturnedDefer = undefined;
|
||||
if (this.connectionReturnedResolvers) {
|
||||
this.connectionReturnedResolvers.resolve(connDidFail);
|
||||
this.connectionReturnedResolvers = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+6
-15
@@ -436,23 +436,14 @@ export function isNullOrUndefined(val: any): boolean {
|
||||
return val === null || val === undefined;
|
||||
}
|
||||
|
||||
export interface IDeferred<T> {
|
||||
resolve: (value: T | Promise<T>) => void;
|
||||
reject: (reason?: any) => void;
|
||||
promise: Promise<T>;
|
||||
}
|
||||
export type IDeferred<T> = PromiseWithResolvers<T>;
|
||||
|
||||
// Returns a Deferred
|
||||
/**
|
||||
* Creates a deferred promise. This is a promise that can be resolved or rejected.
|
||||
* @deprecated use {@link Promise.withResolvers} instead.
|
||||
*/
|
||||
export function defer<T = void>(): IDeferred<T> {
|
||||
let resolve!: IDeferred<T>["resolve"];
|
||||
let reject!: IDeferred<T>["reject"];
|
||||
|
||||
const promise = new Promise<T>((_resolve, _reject) => {
|
||||
resolve = _resolve;
|
||||
reject = _reject;
|
||||
});
|
||||
|
||||
return { resolve, reject, promise };
|
||||
return Promise.withResolvers<T>();
|
||||
}
|
||||
|
||||
export async function promiseMapSeries<T>(
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Method decorator to ensure that only one instance of the method is running at a time,
|
||||
* and any concurrent calls will return the same promise as the original call.
|
||||
* After execution is complete a new call will be able to run the method again.
|
||||
*/
|
||||
export function singleAsyncExecution<This, Args extends unknown[], Return>(
|
||||
target: (this: This, ...args: Args) => Promise<Return>,
|
||||
): (this: This, ...args: Args) => Promise<Return> {
|
||||
let promise: Promise<Return> | undefined;
|
||||
|
||||
async function replacementMethod(this: This, ...args: Args): Promise<Return> {
|
||||
if (promise) return promise;
|
||||
try {
|
||||
promise = target.call(this, ...args);
|
||||
await promise;
|
||||
return promise;
|
||||
} finally {
|
||||
promise = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return replacementMethod;
|
||||
}
|
||||
+1
-1
@@ -2510,7 +2510,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Here we were sending the event to the opponent's device as a to-device message with MatrixClient.encryptAndSendToDevices.
|
||||
// TODO: Here we were sending the event to the opponent's device as a to-device message with MatrixClient.encryptAndSendToDevice.
|
||||
// However due to the switch to Rust cryptography we need to migrate to the new encryptToDeviceMessages API.
|
||||
throw new Error("Unimplemented");
|
||||
} else {
|
||||
|
||||
@@ -462,7 +462,7 @@ export class MediaHandler extends TypedEventEmitter<
|
||||
};
|
||||
}
|
||||
|
||||
private getScreenshareContraints(opts: IScreensharingOpts): DesktopCapturerConstraints {
|
||||
private getScreenshareContraints(opts: IScreensharingOpts): MediaStreamConstraints {
|
||||
const { desktopCapturerSourceId, audio } = opts;
|
||||
if (desktopCapturerSourceId) {
|
||||
return {
|
||||
|
||||
+2
-1
@@ -9,7 +9,8 @@
|
||||
"noEmit": true,
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"allowImportingTsExtensions": true
|
||||
"allowImportingTsExtensions": true,
|
||||
"lib": ["es2024"]
|
||||
},
|
||||
"include": ["./src/**/*.ts", "./spec/**/*.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user