Merge pull request #3638 from element-hq/toger5/delayed-event-delegation

Pseudonomous identity and use the new jwt service endpoint (with delayed event delegation)
This commit is contained in:
Timo
2026-01-09 19:58:30 +01:00
committed by GitHub
45 changed files with 1447 additions and 683 deletions

View File

@@ -38,6 +38,8 @@ experimental_features:
# MSC4222 needed for syncv2 state_after. This allow clients to # MSC4222 needed for syncv2 state_after. This allow clients to
# correctly track the state of the room. # correctly track the state of the room.
msc4222_enabled: true msc4222_enabled: true
# sticky events for MatrixRTC user state
msc4354_enabled: true
# The maximum allowed duration by which sent events can be delayed, as # The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no # per MSC4140. Must be a positive value if set. Defaults to no

View File

@@ -38,7 +38,7 @@ experimental_features:
# MSC4222 needed for syncv2 state_after. This allow clients to # MSC4222 needed for syncv2 state_after. This allow clients to
# correctly track the state of the room. # correctly track the state of the room.
msc4222_enabled: true msc4222_enabled: true
# sticky events for matrixRTC user state # sticky events for MatrixRTC user state
msc4354_enabled: true msc4354_enabled: true
# The maximum allowed duration by which sent events can be delayed, as # The maximum allowed duration by which sent events can be delayed, as

View File

@@ -38,6 +38,8 @@ experimental_features:
# MSC4222 needed for syncv2 state_after. This allow clients to # MSC4222 needed for syncv2 state_after. This allow clients to
# correctly track the state of the room. # correctly track the state of the room.
msc4222_enabled: true msc4222_enabled: true
# sticky events for MatrixRTC user state
msc4354_enabled: true
# The maximum allowed duration by which sent events can be delayed, as # The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no # per MSC4140. Must be a positive value if set. Defaults to no

View File

@@ -38,6 +38,8 @@ experimental_features:
# MSC4222 needed for syncv2 state_after. This allow clients to # MSC4222 needed for syncv2 state_after. This allow clients to
# correctly track the state of the room. # correctly track the state of the room.
msc4222_enabled: true msc4222_enabled: true
# sticky events for MatrixRTC user state
msc4354_enabled: true
# The maximum allowed duration by which sent events can be delayed, as # The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no # per MSC4140. Must be a positive value if set. Defaults to no

View File

@@ -3,7 +3,7 @@ networks:
services: services:
auth-service: auth-service:
image: ghcr.io/element-hq/lk-jwt-service:latest-ci image: ghcr.io/element-hq/lk-jwt-service:pr_139
pull_policy: always pull_policy: always
hostname: auth-server hostname: auth-server
environment: environment:
@@ -25,7 +25,7 @@ services:
- ecbackend - ecbackend
auth-service-1: auth-service-1:
image: ghcr.io/element-hq/lk-jwt-service:latest-ci image: ghcr.io/element-hq/lk-jwt-service:pr_139
pull_policy: always pull_policy: always
hostname: auth-server-1 hostname: auth-server-1
environment: environment:
@@ -88,7 +88,7 @@ services:
synapse: synapse:
hostname: homeserver hostname: homeserver
image: docker.io/matrixdotorg/synapse:latest image: ghcr.io/element-hq/synapse:pr-18968-dcb7678281bc02d4551043a6338fe5b7e6aa47ce
pull_policy: always pull_policy: always
environment: environment:
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml - SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
@@ -106,7 +106,7 @@ services:
synapse-1: synapse-1:
hostname: homeserver-1 hostname: homeserver-1
image: docker.io/matrixdotorg/synapse:latest image: ghcr.io/element-hq/synapse:pr-18968-dcb7678281bc02d4551043a6338fe5b7e6aa47ce
pull_policy: always pull_policy: always
environment: environment:
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml - SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml

View File

@@ -116,6 +116,7 @@
"matrix_rtc_transport_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).", "matrix_rtc_transport_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).",
"membership_manager": "Membership Manager Error", "membership_manager": "Membership Manager Error",
"membership_manager_description": "The Membership Manager had to shut down. This is caused by many consequtive failed network requests.", "membership_manager_description": "The Membership Manager had to shut down. This is caused by many consequtive failed network requests.",
"no_matrix_2_authorization_service": "Your authorization service for you media server (SFU) is not on the newest version",
"open_elsewhere": "Opened in another tab", "open_elsewhere": "Opened in another tab",
"open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.", "open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.",
"room_creation_restricted": "Failed to create call", "room_creation_restricted": "Failed to create call",

View File

@@ -125,6 +125,7 @@
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint-language-service": "^5.0.5", "typescript-eslint-language-service": "^5.0.5",
"unique-names-generator": "^4.6.0", "unique-names-generator": "^4.6.0",
"uuid": "^13.0.0",
"vaul": "^1.0.0", "vaul": "^1.0.0",
"vite": "^7.0.0", "vite": "^7.0.0",
"vite-plugin-generate-file": "^0.3.0", "vite-plugin-generate-file": "^0.3.0",

View File

@@ -101,12 +101,7 @@ export async function createMatrixRTCSdk(
const mediaDevices = new MediaDevices(scope); const mediaDevices = new MediaDevices(scope);
const muteStates = new MuteStates(scope, mediaDevices, constant(true)); const muteStates = new MuteStates(scope, mediaDevices, constant(true));
const slot = { application, id }; const slot = { application, id };
const rtcSession = new MatrixRTCSession( const rtcSession = new MatrixRTCSession(client, room, slot);
client,
room,
MatrixRTCSession.sessionMembershipsForSlot(room, slot),
slot,
);
const callViewModel = createCallViewModel$( const callViewModel = createCallViewModel$(
scope, scope,
rtcSession, rtcSession,

View File

@@ -20,6 +20,7 @@ interface Props {
audio?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats; audio?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
video?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats; video?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
focusUrl?: string; focusUrl?: string;
rtcBackendIdentity?: string;
} }
const extractDomain = (url: string): string => { const extractDomain = (url: string): string => {
@@ -37,6 +38,7 @@ export const RTCConnectionStats: FC<Props> = ({
audio, audio,
video, video,
focusUrl, focusUrl,
rtcBackendIdentity,
...rest ...rest
}) => { }) => {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
@@ -71,6 +73,9 @@ export const RTCConnectionStats: FC<Props> = ({
</pre> </pre>
</div> </div>
</Modal> </Modal>
<Text as="span" size="xs" title="rtcBackendIdentity">
rtcBackendIdentity:{rtcBackendIdentity}
</Text>
{focusUrl && ( {focusUrl && (
<div> <div>
<Text as="span" size="xs" title="focusURL"> <Text as="span" size="xs" title="focusURL">

View File

@@ -6,11 +6,13 @@ Please see LICENSE in the repository root for full details.
*/ */
import { BaseKeyProvider } from "livekit-client"; import { BaseKeyProvider } from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger";
import { import {
type MatrixRTCSession, type MatrixRTCSession,
MatrixRTCSessionEvent, MatrixRTCSessionEvent,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
const logger = rootLogger.getChild("[MatrixKeyProvider]");
export class MatrixKeyProvider extends BaseKeyProvider { export class MatrixKeyProvider extends BaseKeyProvider {
private rtcSession?: MatrixRTCSession; private rtcSession?: MatrixRTCSession;
@@ -42,7 +44,8 @@ export class MatrixKeyProvider extends BaseKeyProvider {
private onEncryptionKeyChanged = ( private onEncryptionKeyChanged = (
encryptionKey: Uint8Array<ArrayBuffer>, encryptionKey: Uint8Array<ArrayBuffer>,
encryptionKeyIndex: number, encryptionKeyIndex: number,
participantId: string, membershipParts: CallMembershipIdentityParts,
rtcBackendIdentity: string,
): void => { ): void => {
crypto.subtle crypto.subtle
.importKey("raw", encryptionKey, "HKDF", false, [ .importKey("raw", encryptionKey, "HKDF", false, [
@@ -53,17 +56,17 @@ export class MatrixKeyProvider extends BaseKeyProvider {
(keyMaterial) => { (keyMaterial) => {
this.onSetEncryptionKey( this.onSetEncryptionKey(
keyMaterial, keyMaterial,
participantId, rtcBackendIdentity,
encryptionKeyIndex, encryptionKeyIndex,
); );
logger.debug( logger.debug(
`Sent new key to livekit room=${this.rtcSession?.room.roomId} participantId=${participantId} encryptionKeyIndex=${encryptionKeyIndex}`, `Sent new key to livekit room=${this.rtcSession?.room.roomId} participantId=${rtcBackendIdentity} (before hash: ${membershipParts.userId}:${membershipParts.deviceId}) encryptionKeyIndex=${encryptionKeyIndex}`,
); );
}, },
(e) => { (e) => {
logger.error( logger.error(
`Failed to create key material from buffer for livekit room=${this.rtcSession?.room.roomId} participantId=${participantId} encryptionKeyIndex=${encryptionKeyIndex}`, `Failed to create key material from buffer for livekit room=${this.rtcSession?.room.roomId} participantId before hash=${membershipParts.userId}:${membershipParts.deviceId} encryptionKeyIndex=${encryptionKeyIndex}`,
e, e,
); );
}, },

View File

@@ -15,7 +15,6 @@ import {
type AudioTrackProps, type AudioTrackProps,
} from "@livekit/components-react"; } from "@livekit/components-react";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { type ParticipantId } from "matrix-js-sdk/lib/matrixrtc";
import { useEarpieceAudioConfig } from "../MediaDevicesContext"; import { useEarpieceAudioConfig } from "../MediaDevicesContext";
import { useReactiveState } from "../useReactiveState"; import { useReactiveState } from "../useReactiveState";
@@ -32,7 +31,7 @@ export interface MatrixAudioRendererProps {
* This list needs to be composed based on the matrixRTC members so that we do not play audio from users * This list needs to be composed based on the matrixRTC members so that we do not play audio from users
* that are not expected to be in the rtc session (local user is excluded). * that are not expected to be in the rtc session (local user is excluded).
*/ */
validIdentities: ParticipantId[]; validIdentities: string[];
/** /**
* If set to `true`, mutes all audio tracks rendered by the component. * If set to `true`, mutes all audio tracks rendered by the component.
* @remarks * @remarks

View File

@@ -18,6 +18,7 @@ import fetchMock from "fetch-mock";
import { getSFUConfigWithOpenID, type OpenIDClientParts } from "./openIDSFU"; import { getSFUConfigWithOpenID, type OpenIDClientParts } from "./openIDSFU";
import { testJWTToken } from "../utils/test-fixtures"; import { testJWTToken } from "../utils/test-fixtures";
import { ownMemberMock } from "../utils/test";
const sfuUrl = "https://sfu.example.org"; const sfuUrl = "https://sfu.example.org";
@@ -33,6 +34,7 @@ describe("getSFUConfigWithOpenID", () => {
vitest.clearAllMocks(); vitest.clearAllMocks();
fetchMock.reset(); fetchMock.reset();
}); });
it("should handle fetching a token", async () => { it("should handle fetching a token", async () => {
fetchMock.post("https://sfu.example.org/sfu/get", () => { fetchMock.post("https://sfu.example.org/sfu/get", () => {
return { return {
@@ -42,6 +44,7 @@ describe("getSFUConfigWithOpenID", () => {
}); });
const config = await getSFUConfigWithOpenID( const config = await getSFUConfigWithOpenID(
matrixClient, matrixClient,
ownMemberMock,
"https://sfu.example.org", "https://sfu.example.org",
"!example_room_id", "!example_room_id",
); );
@@ -53,6 +56,7 @@ describe("getSFUConfigWithOpenID", () => {
}); });
void (await fetchMock.flush()); void (await fetchMock.flush());
}); });
it("should fail if the SFU errors", async () => { it("should fail if the SFU errors", async () => {
fetchMock.post("https://sfu.example.org/sfu/get", () => { fetchMock.post("https://sfu.example.org/sfu/get", () => {
return { return {
@@ -63,11 +67,12 @@ describe("getSFUConfigWithOpenID", () => {
try { try {
await getSFUConfigWithOpenID( await getSFUConfigWithOpenID(
matrixClient, matrixClient,
ownMemberMock,
"https://sfu.example.org", "https://sfu.example.org",
"!example_room_id", "!example_room_id",
); );
} catch (ex) { } catch (ex) {
expect(((ex as Error).cause as Error).message).toEqual( expect((ex as Error).message).toEqual(
"SFU Config fetch failed with status code 500", "SFU Config fetch failed with status code 500",
); );
void (await fetchMock.flush()); void (await fetchMock.flush());
@@ -76,6 +81,104 @@ describe("getSFUConfigWithOpenID", () => {
expect.fail("Expected test to throw;"); expect.fail("Expected test to throw;");
}); });
it("should try legacy and then new endpoint with delay delegation", async () => {
fetchMock.post("https://sfu.example.org/get_token", () => {
return {
status: 500,
body: { error: "Test failure" },
};
});
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 500,
body: { error: "Test failure" },
};
});
try {
await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
{
delayEndpointBaseUrl: "https://matrix.homeserverserver.org",
delayId: "mock_delay_id",
},
);
} catch (ex) {
expect((ex as Error).message).toEqual(
"SFU Config fetch failed with status code 500",
);
void (await fetchMock.flush());
}
const calls = fetchMock.calls();
expect(calls.length).toBe(2);
expect(calls[0][0]).toStrictEqual("https://sfu.example.org/get_token");
expect(calls[0][1]).toStrictEqual({
// check if it uses correct delayID!
body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":1000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
expect(calls[1][0]).toStrictEqual("https://sfu.example.org/sfu/get");
expect(calls[1][1]).toStrictEqual({
body: '{"room":"!example_room_id","device_id":"DEVICE"}',
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
});
it("dont try legacy if endpoint with delay delegation is sucessful", async () => {
fetchMock.post("https://sfu.example.org/get_token", () => {
return {
status: 200,
body: { url: sfuUrl, jwt: testJWTToken },
};
});
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 500,
body: { error: "Test failure" },
};
});
try {
await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
{
delayEndpointBaseUrl: "https://matrix.homeserverserver.org",
delayId: "mock_delay_id",
},
);
} catch (ex) {
expect((ex as Error).message).toEqual(
"SFU Config fetch failed with status code 500",
);
void (await fetchMock.flush());
}
const calls = fetchMock.calls();
expect(calls.length).toBe(1);
expect(calls[0][0]).toStrictEqual("https://sfu.example.org/get_token");
expect(calls[0][1]).toStrictEqual({
// check if it uses correct delayID!
body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":1000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
});
it("should retry fetching the openid token", async () => { it("should retry fetching the openid token", async () => {
let count = 0; let count = 0;
matrixClient.getOpenIdToken.mockImplementation(async () => { matrixClient.getOpenIdToken.mockImplementation(async () => {
@@ -98,6 +201,7 @@ describe("getSFUConfigWithOpenID", () => {
}); });
const config = await getSFUConfigWithOpenID( const config = await getSFUConfigWithOpenID(
matrixClient, matrixClient,
ownMemberMock,
"https://sfu.example.org", "https://sfu.example.org",
"!example_room_id", "!example_room_id",
); );

View File

@@ -5,11 +5,21 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type IOpenIDToken, type MatrixClient } from "matrix-js-sdk"; import {
import { logger } from "matrix-js-sdk/lib/logger"; retryNetworkOperation,
type IOpenIDToken,
type MatrixClient,
} from "matrix-js-sdk";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { FailToGetOpenIdToken } from "../utils/errors"; import {
FailToGetOpenIdToken,
NoMatrix2AuthorizationService,
} from "../utils/errors";
import { doNetworkOperationWithRetry } from "../utils/matrix"; import { doNetworkOperationWithRetry } from "../utils/matrix";
import { Config } from "../config/Config";
import { JwtEndpointVersion } from "../state/CallViewModel/localMember/LocalTransport";
/** /**
* Configuration and access tokens provided by the SFU on successful authentication. * Configuration and access tokens provided by the SFU on successful authentication.
@@ -18,6 +28,7 @@ export interface SFUConfig {
url: string; url: string;
jwt: string; jwt: string;
livekitAlias: string; livekitAlias: string;
// NOTE: Currently unused.
livekitIdentity: string; livekitIdentity: string;
} }
@@ -64,15 +75,32 @@ export type OpenIDClientParts = Pick<
* to the matrix RTC backend in order to get acces to the SFU. * to the matrix RTC backend in order to get acces to the SFU.
* It has built-in retry for calls to the homeserver with a backoff policy. * It has built-in retry for calls to the homeserver with a backoff policy.
* @param client The Matrix client * @param client The Matrix client
* @param membership Our own membership identity parts used to send to jwt service.
* @param serviceUrl The URL of the livekit SFU service * @param serviceUrl The URL of the livekit SFU service
* @param matrixRoomId The Matrix room ID for which to get the SFU config * @param roomId The room id used in the jwt request. This is NOT the livekit_alias. The jwt service will provide the alias. It maps matrix room ids <-> Livekit aliases.
* @param opts Additional options to modify which endpoint with which data will be used to aquire the jwt token.
* @param opts.forceJwtEndpoint This will use the old jwt endpoint which will create the rtc backend identity based on string concatination
* instead of a hash.
* This function by default uses whatever is possible with the current jwt service installed next to the SFU.
* For remote connections this does not matter, since we will not publish there we can rely on the newest option.
* For our own connection we can only use the hashed version if we also send the new matrix2.0 sticky events.
* @param opts.delayEndpointBaseUrl The URL of the matrix homeserver.
* @param opts.delayId The delay id used for the jwt service to manage.
* @param logger optional logger.
* @returns Object containing the token information * @returns Object containing the token information
* @throws FailToGetOpenIdToken * @throws FailToGetOpenIdToken
*/ */
export async function getSFUConfigWithOpenID( export async function getSFUConfigWithOpenID(
client: OpenIDClientParts, client: OpenIDClientParts,
membership: CallMembershipIdentityParts,
serviceUrl: string, serviceUrl: string,
matrixRoomId: string, roomId: string,
opts?: {
forceJwtEndpoint?: JwtEndpointVersion;
delayEndpointBaseUrl?: string;
delayId?: string;
},
logger?: Logger,
): Promise<SFUConfig> { ): Promise<SFUConfig> {
let openIdToken: IOpenIDToken; let openIdToken: IOpenIDToken;
try { try {
@@ -84,16 +112,67 @@ export async function getSFUConfigWithOpenID(
error instanceof Error ? error : new Error("Unknown error"), error instanceof Error ? error : new Error("Unknown error"),
); );
} }
logger.debug("Got openID token", openIdToken); logger?.debug("Got openID token", openIdToken);
logger.info(`Trying to get JWT for focus ${serviceUrl}...`); logger?.info(`Trying to get JWT for focus ${serviceUrl}...`);
const sfuConfig = await getLiveKitJWT(
client, let sfuConfig: { url: string; jwt: string } | undefined;
serviceUrl,
matrixRoomId, const tryBothJwtEndpoints = opts?.forceJwtEndpoint === undefined; // This is for SFUs where we do not publish.
openIdToken,
); const forceMatrix2Jwt =
logger.info(`Got JWT from call's active focus URL.`); opts?.forceJwtEndpoint === JwtEndpointVersion.Matrix_2_0;
// We want to start using the new endpoint (with optional delay delegation)
// if we can use both or if we are forced to use the new one.
if (tryBothJwtEndpoints || forceMatrix2Jwt) {
try {
sfuConfig = await getLiveKitJWTWithDelayDelegation(
membership,
serviceUrl,
roomId,
openIdToken,
opts?.delayEndpointBaseUrl,
opts?.delayId,
);
logger?.info(`Got JWT from call's active focus URL.`);
} catch (e) {
if (e instanceof NotSupportedError) {
logger?.warn(
`Failed fetching jwt with matrix 2.0 endpoint (retry with legacy) Not supported`,
e,
);
sfuConfig = undefined;
} else {
logger?.warn(
`Failed fetching jwt with matrix 2.0 endpoint other issues ->`,
`(not going to try with legacy endpoint: forceOldJwtEndpoint is set to false, we did not get a not supported error from the sfu)`,
e,
);
// Make this throw a hard error in case we force the matrix2.0 endpoint.
if (forceMatrix2Jwt)
throw new NoMatrix2AuthorizationService(e as Error);
// NEVER get bejond this point if we forceMatrix2 and it failed!
}
}
}
// DEPRECATED
// here we either have a sfuConfig or we alredy exited because of `if (forceMatrix2) throw ...`
// The only case we can get into this condition is, if `forceMatrix2` is `false`
if (sfuConfig === undefined) {
sfuConfig = await getLiveKitJWT(
membership.deviceId,
serviceUrl,
roomId,
openIdToken,
);
logger?.info(`Got JWT from call's active focus URL.`);
}
if (!sfuConfig) {
throw new Error("No `sfuConfig` after trying with old and new endpoints");
}
// Pull the details from the JWT // Pull the details from the JWT
const [, payloadStr] = sfuConfig.jwt.split("."); const [, payloadStr] = sfuConfig.jwt.split(".");
@@ -104,33 +183,108 @@ export async function getSFUConfigWithOpenID(
url: sfuConfig.url, url: sfuConfig.url,
livekitAlias: payload.video.room, livekitAlias: payload.video.room,
// NOTE: Currently unused. // NOTE: Currently unused.
// Probably also not helpful since we now compute the backendIdentity on joining the call so we can use it for the encryption manager.
// The only reason for us to know it locally is to connect the right users with the lk world. (and to set our own keys)
livekitIdentity: payload.sub, livekitIdentity: payload.sub,
}; };
} }
const RETRIES = 4;
async function getLiveKitJWT( async function getLiveKitJWT(
client: OpenIDClientParts, deviceId: string,
livekitServiceURL: string, livekitServiceURL: string,
roomName: string, matrixRoomId: string,
openIDToken: IOpenIDToken, openIDToken: IOpenIDToken,
): Promise<{ url: string; jwt: string }> { ): Promise<{ url: string; jwt: string }> {
try { let res: Response | undefined;
const res = await fetch(livekitServiceURL + "/sfu/get", { await retryNetworkOperation(RETRIES, async () => {
res = await fetch(livekitServiceURL + "/sfu/get", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
room: roomName, // This is the actual livekit room alias. For the legacy jwt endpoint simply the room id was used.
room: matrixRoomId,
openid_token: openIDToken, openid_token: openIDToken,
device_id: client.getDeviceId(), device_id: deviceId,
}), }),
}); });
if (!res.ok) { });
throw new Error("SFU Config fetch failed with status code " + res.status); if (!res) {
} throw new Error(
return await res.json(); `Network error while connecting to jwt service after ${RETRIES} retries`,
} catch (e) { );
throw new Error("SFU Config fetch failed with exception", { cause: e }); }
if (!res.ok) {
throw new Error("SFU Config fetch failed with status code " + res.status);
}
return await res.json();
}
class NotSupportedError extends Error {
public constructor(message: string) {
super(message);
this.name = "NotSupported";
} }
} }
export async function getLiveKitJWTWithDelayDelegation(
membership: CallMembershipIdentityParts,
livekitServiceURL: string,
matrixRoomId: string,
openIDToken: IOpenIDToken,
delayEndpointBaseUrl?: string,
delayId?: string,
): Promise<{ url: string; jwt: string }> {
const { userId, deviceId, memberId } = membership;
const body = {
room_id: matrixRoomId,
slot_id: "m.call#ROOM",
openid_token: openIDToken,
member: {
id: memberId,
claimed_user_id: userId,
claimed_device_id: deviceId,
},
};
let bodyDalayParts = {};
// Also check for empty string
if (delayId && delayEndpointBaseUrl) {
const delayTimeoutMs =
Config.get().matrix_rtc_session?.delayed_leave_event_delay_ms ?? 1000;
bodyDalayParts = {
delay_id: delayId,
delay_timeout: delayTimeoutMs,
delay_cs_api_url: delayEndpointBaseUrl,
};
}
let res: Response | undefined;
await retryNetworkOperation(RETRIES, async () => {
res = await fetch(livekitServiceURL + "/get_token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ ...body, ...bodyDalayParts }),
});
});
if (!res) {
throw new Error(
`Network error while connecting to jwt service after ${RETRIES} retries`,
);
}
if (!res.ok) {
const msg = "SFU Config fetch failed with status code " + res.status;
if (res.status === 404) {
throw new NotSupportedError(msg);
} else {
throw new Error(msg);
}
}
return await res.json();
}

View File

@@ -798,6 +798,8 @@ export const InCallView: FC<InCallViewProps> = ({
</div> </div>
); );
const allConnections = useBehavior(vm.allConnections$);
return ( return (
<div <div
className={styles.inRoom} className={styles.inRoom}
@@ -836,8 +838,14 @@ export const InCallView: FC<InCallViewProps> = ({
onDismiss={closeSettings} onDismiss={closeSettings}
tab={settingsTab} tab={settingsTab}
onTabChange={setSettingsTab} onTabChange={setSettingsTab}
// TODO expose correct data to setttings modal livekitRooms={allConnections
livekitRooms={[]} .getConnections()
.map((connectionItem) => ({
room: connectionItem.livekitRoom,
// TODO compute is local or tag it in the livekit room items already
isLocal: undefined,
url: connectionItem.transport.livekit_service_url,
}))}
/> />
</> </>
)} )}

View File

@@ -8,3 +8,14 @@ Please see LICENSE in the repository root for full details.
pre { pre {
font-size: var(--font-size-micro); font-size: var(--font-size-micro);
} }
.livekit_room_box {
border: 3px solid var(--cpd-color-bg-subtle-secondary);
border-radius: var(--cpd-space-8x);
padding: var(--cpd-space-4x);
margin-bottom: var(--cpd-space-4x);
margin-top: var(--cpd-space-4x);
li {
font-size: var(--font-size-micro);
}
}

View File

@@ -7,9 +7,9 @@ Please see LICENSE in the repository root for full details.
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { render, waitFor } from "@testing-library/react"; import { render, waitFor } from "@testing-library/react";
import { type Room as LivekitRoom } from "livekit-client";
import type { MatrixClient } from "matrix-js-sdk"; import type { MatrixClient } from "matrix-js-sdk";
import type { Room as LivekitRoom } from "livekit-client";
import { DeveloperSettingsTab } from "./DeveloperSettingsTab"; import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
// Mock url params hook to avoid environment-dependent snapshot churn. // Mock url params hook to avoid environment-dependent snapshot churn.
@@ -30,6 +30,8 @@ function createMockLivekitRoom(
serverInfo, serverInfo,
metadata, metadata,
engine: { client: { ws: { url: wsUrl } } }, engine: { client: { ws: { url: wsUrl } } },
localParticipant: { identity: "localParticipantIdentity" },
remoteParticipants: new Map(),
} as unknown as LivekitRoom; } as unknown as LivekitRoom;
return { return {
@@ -69,6 +71,8 @@ describe("DeveloperSettingsTab", () => {
isLocal: false, isLocal: false,
url: "wss://remote-sfu.example.org", url: "wss://remote-sfu.example.org",
room: { room: {
localParticipant: { identity: "localParticipantIdentity" },
remoteParticipants: new Map(),
serverInfo: { region: "remote", version: "4.5.6" }, serverInfo: { region: "remote", version: "4.5.6" },
metadata: "remote-metadata", metadata: "remote-metadata",
engine: { client: { ws: { url: "wss://remote-sfu.example.org" } } }, engine: { client: { ws: { url: "wss://remote-sfu.example.org" } } },

View File

@@ -29,6 +29,7 @@ import {
Label, Label,
RadioControl, RadioControl,
} from "@vector-im/compound-web"; } from "@vector-im/compound-web";
import { type Room as LivekitRoom } from "livekit-client";
import { FieldRow, InputField } from "../input/Input"; import { FieldRow, InputField } from "../input/Input";
import { import {
@@ -42,7 +43,6 @@ import {
customLivekitUrl as customLivekitUrlSetting, customLivekitUrl as customLivekitUrlSetting,
MatrixRTCMode, MatrixRTCMode,
} from "./settings"; } from "./settings";
import type { Room as LivekitRoom } from "livekit-client";
import styles from "./DeveloperSettingsTab.module.css"; import styles from "./DeveloperSettingsTab.module.css";
import { useUrlParams } from "../UrlParams"; import { useUrlParams } from "../UrlParams";
@@ -275,8 +275,8 @@ export const DeveloperSettingsTab: FC<Props> = ({
name={matrixRTCModeRadioGroup} name={matrixRTCModeRadioGroup}
control={ control={
<RadioControl <RadioControl
checked={matrixRTCMode === MatrixRTCMode.Compatibil} checked={matrixRTCMode === MatrixRTCMode.Compatibility}
value={MatrixRTCMode.Compatibil} value={MatrixRTCMode.Compatibility}
onChange={onMatrixRTCModeChange} onChange={onMatrixRTCModeChange}
/> />
} }
@@ -304,12 +304,12 @@ export const DeveloperSettingsTab: FC<Props> = ({
</InlineField> </InlineField>
</Form> </Form>
{livekitRooms?.map((livekitRoom) => ( {livekitRooms?.map((livekitRoom) => (
<> <div className={styles.livekit_room_box}>
<h3> <h4>
{t("developer_mode.livekit_sfu", { {t("developer_mode.livekit_sfu", {
url: livekitRoom.url || "unknown", url: livekitRoom.url || "unknown",
})} })}
</h3> </h4>
{livekitRoom.isLocal && <p>ws-url: {localSfuUrl?.href}</p>} {livekitRoom.isLocal && <p>ws-url: {localSfuUrl?.href}</p>}
<p> <p>
{t("developer_mode.livekit_server_info")}( {t("developer_mode.livekit_server_info")}(
@@ -321,7 +321,19 @@ export const DeveloperSettingsTab: FC<Props> = ({
: "undefined"} : "undefined"}
{livekitRoom.room.metadata} {livekitRoom.room.metadata}
</pre> </pre>
</> <p>Local Participant</p>
<pre className={styles.pre}>
{livekitRoom.room.localParticipant.identity}
</pre>
<p>Remote Participants</p>
<ul>
{Array.from(livekitRoom.room.remoteParticipants.keys()).map(
(id) => (
<li key={id}>{id}</li>
),
)}
</ul>
</div>
))} ))}
<p>{t("developer_mode.environment_variables")}</p> <p>{t("developer_mode.environment_variables")}</p>
<pre>{JSON.stringify(env, null, 2)}</pre> <pre>{JSON.stringify(env, null, 2)}</pre>

View File

@@ -284,7 +284,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
name="_r_0_" name="_r_0_"
title="" title=""
type="radio" type="radio"
value="compatibil" value="compatibility"
/> />
<div <div
class="_ui_1qhtc_19" class="_ui_1qhtc_19"
@@ -349,46 +349,78 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</div> </div>
</div> </div>
</form> </form>
<h3> <div
LiveKit SFU: wss://local-sfu.example.org class="livekit_room_box"
</h3>
<p>
ws-url:
wss://local-sfu.example.org/
</p>
<p>
LiveKit Server Info
(
local
)
</p>
<pre
class="pre"
> >
{ <h4>
LiveKit SFU: wss://local-sfu.example.org
</h4>
<p>
ws-url:
wss://local-sfu.example.org/
</p>
<p>
LiveKit Server Info
(
local
)
</p>
<pre
class="pre"
>
{
"region": "local", "region": "local",
"version": "1.2.3" "version": "1.2.3"
} }
local-metadata local-metadata
</pre> </pre>
<h3> <p>
LiveKit SFU: wss://remote-sfu.example.org Local Participant
</h3> </p>
<p> <pre
LiveKit Server Info class="pre"
( >
remote localParticipantIdentity
) </pre>
</p> <p>
<pre Remote Participants
class="pre" </p>
<ul />
</div>
<div
class="livekit_room_box"
> >
{ <h4>
LiveKit SFU: wss://remote-sfu.example.org
</h4>
<p>
LiveKit Server Info
(
remote
)
</p>
<pre
class="pre"
>
{
"region": "remote", "region": "remote",
"version": "4.5.6" "version": "4.5.6"
} }
remote-metadata remote-metadata
</pre> </pre>
<p>
Local Participant
</p>
<pre
class="pre"
>
localParticipantIdentity
</pre>
<p>
Remote Participants
</p>
<ul />
</div>
<p> <p>
Environment variables Environment variables
</p> </p>

View File

@@ -126,7 +126,13 @@ export const alwaysShowIphoneEarpiece = new Setting<boolean>(
export enum MatrixRTCMode { export enum MatrixRTCMode {
Legacy = "legacy", Legacy = "legacy",
Compatibil = "compatibil", Compatibility = "compatibility",
/** This implies using
* - sticky events
* - hashed RTC backend identity
* - the new endpoint for the jwt token on the local membership (remote memberships will always try the new jwt endpoint first -> then the legacy one)
* - use the hashed identity for the local membership
*/
Matrix_2_0 = "matrix_2_0", Matrix_2_0 = "matrix_2_0",
} }

View File

@@ -78,11 +78,14 @@ vi.mock("../e2ee/matrixKeyProvider");
const getUrlParams = vi.hoisted(() => vi.fn(() => ({}))); const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
vi.mock("../UrlParams", () => ({ getUrlParams })); vi.mock("../UrlParams", () => ({ getUrlParams }));
vi.mock("../rtcSessionHelpers", async (importOriginal) => ({ vi.mock(
...(await importOriginal()), "../state/CallViewModel/localMember/localTransport",
makeTransport: async (): Promise<LivekitTransport> => async (importOriginal) => ({
Promise.resolve(exampleTransport), ...(await importOriginal()),
})); makeTransport: async (): Promise<LivekitTransport> =>
Promise.resolve(exampleTransport),
}),
);
const yesNo = { const yesNo = {
y: true, y: true,
@@ -232,7 +235,7 @@ const mockLegacyRingEvent = {} as { event_id: string } & ICallNotifyContent;
describe.each([ describe.each([
[MatrixRTCMode.Legacy], [MatrixRTCMode.Legacy],
[MatrixRTCMode.Compatibil], [MatrixRTCMode.Compatibility],
[MatrixRTCMode.Matrix_2_0], [MatrixRTCMode.Matrix_2_0],
])("CallViewModel (%s mode)", (mode) => { ])("CallViewModel (%s mode)", (mode) => {
const withCallViewModel = withCallViewModelInMode(mode); const withCallViewModel = withCallViewModelInMode(mode);
@@ -1255,11 +1258,6 @@ describe.each([
y: () => { y: () => {
rtcSession.membershipStatus = Status.Connected; rtcSession.membershipStatus = Status.Connected;
}, },
n: () => {
// NOTE: This was removed in https://github.com/matrix-org/matrix-js-sdk/pull/5103 accidentally.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
rtcSession.membershipStatus = "Reconnecting" as any;
},
}); });
schedule(probablyLeftMarbles, { schedule(probablyLeftMarbles, {
y: () => { y: () => {

View File

@@ -41,10 +41,13 @@ import {
} from "rxjs"; } from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { import {
MembershipManagerEvent,
type LivekitTransport, type LivekitTransport,
type MatrixRTCSession, type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { type IWidgetApiRequest } from "matrix-widget-api"; import { type IWidgetApiRequest } from "matrix-widget-api";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { v4 as uuidv4 } from "uuid";
import { import {
LocalUserMediaViewModel, LocalUserMediaViewModel,
@@ -98,7 +101,7 @@ import {
type SpotlightLandscapeLayoutMedia, type SpotlightLandscapeLayoutMedia,
type SpotlightPortraitLayoutMedia, type SpotlightPortraitLayoutMedia,
} from "../layout-types.ts"; } from "../layout-types.ts";
import { ElementCallError } from "../../utils/errors.ts"; import { ElementCallError, UnknownCallError } from "../../utils/errors.ts";
import { type ObservableScope } from "../ObservableScope.ts"; import { type ObservableScope } from "../ObservableScope.ts";
import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts"; import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts";
import { import {
@@ -106,13 +109,19 @@ import {
enterRTCSession, enterRTCSession,
TransportState, TransportState,
} from "./localMember/LocalMember.ts"; } from "./localMember/LocalMember.ts";
import { createLocalTransport$ } from "./localMember/LocalTransport.ts"; import {
createLocalTransport$,
JwtEndpointVersion,
} from "./localMember/LocalTransport.ts";
import { import {
createMemberships$, createMemberships$,
membershipsAndTransports$, membershipsAndTransports$,
} from "../SessionBehaviors.ts"; } from "../SessionBehaviors.ts";
import { ECConnectionFactory } from "./remoteMembers/ConnectionFactory.ts"; import { ECConnectionFactory } from "./remoteMembers/ConnectionFactory.ts";
import { createConnectionManager$ } from "./remoteMembers/ConnectionManager.ts"; import {
type ConnectionManagerData,
createConnectionManager$,
} from "./remoteMembers/ConnectionManager.ts";
import { import {
createMatrixLivekitMembers$, createMatrixLivekitMembers$,
type TaggedParticipant, type TaggedParticipant,
@@ -261,6 +270,7 @@ export interface CallViewModel {
* multiple devices. * multiple devices.
*/ */
participantCount$: Behavior<number>; participantCount$: Behavior<number>;
allConnections$: Behavior<ConnectionManagerData>;
/** Participants sorted by livekit room so they can be used in the audio rendering */ /** Participants sorted by livekit room so they can be used in the audio rendering */
livekitRoomItems$: Behavior<LivekitRoomItem[]>; livekitRoomItems$: Behavior<LivekitRoomItem[]>;
userMedia$: Behavior<UserMedia[]>; userMedia$: Behavior<UserMedia[]>;
@@ -381,8 +391,11 @@ export function createCallViewModel$(
trackProcessorState$: Behavior<ProcessorState>, trackProcessorState$: Behavior<ProcessorState>,
): CallViewModel { ): CallViewModel {
const client = matrixRoom.client; const client = matrixRoom.client;
const userId = client.getUserId()!; const userId = client.getUserId();
const deviceId = client.getDeviceId()!; const deviceId = client.getDeviceId();
if (!(userId && deviceId))
throw new UnknownCallError(new Error("userId and deviceId are required"));
const livekitKeyProvider = getE2eeKeyProvider( const livekitKeyProvider = getE2eeKeyProvider(
options.encryptionSystem, options.encryptionSystem,
matrixRTCSession, matrixRTCSession,
@@ -415,11 +428,37 @@ export function createCallViewModel$(
memberships$, memberships$,
); );
const ownMembershipIdentity: CallMembershipIdentityParts = {
userId,
deviceId,
// This will only be consumed by the sticky membership manager. So it has no impact on legacy calls.
memberId: uuidv4(),
};
const localTransport$ = createLocalTransport$({ const localTransport$ = createLocalTransport$({
scope: scope, scope: scope,
memberships$: memberships$, memberships$: memberships$,
ownMembershipIdentity,
client, client,
delayId$: scope.behavior(
(
fromEvent(
matrixRTCSession,
MembershipManagerEvent.DelayIdChanged,
) as Observable<string | undefined>
).pipe(map((v) => v ?? null)),
matrixRTCSession.delayId ?? null,
),
roomId: matrixRoom.roomId, roomId: matrixRoom.roomId,
forceJwtEndpoint$: scope.behavior(
matrixRTCMode$.pipe(
map((v) =>
v === MatrixRTCMode.Matrix_2_0
? JwtEndpointVersion.Matrix_2_0
: JwtEndpointVersion.Legacy,
),
),
),
useOldestMember$: scope.behavior( useOldestMember$: scope.behavior(
matrixRTCMode$.pipe(map((v) => v === MatrixRTCMode.Legacy)), matrixRTCMode$.pipe(map((v) => v === MatrixRTCMode.Legacy)),
), ),
@@ -439,30 +478,20 @@ export function createCallViewModel$(
const connectionManager = createConnectionManager$({ const connectionManager = createConnectionManager$({
scope: scope, scope: scope,
connectionFactory: connectionFactory, connectionFactory: connectionFactory,
inputTransports$: scope.behavior( localTransport$: scope.behavior(
combineLatest( localTransport$.pipe(
[ catchError((e: unknown) => {
localTransport$.pipe( logger.info(
catchError((e: unknown) => { "could not pass local transport to createConnectionManager$. localTransport$ threw an error",
logger.info( e,
"dont pass local transport to createConnectionManager$. localTransport$ threw an error", );
e, return of(null);
); }),
return of(null);
}),
),
membershipsAndTransports.transports$,
],
(localTransport, transports) => {
const localTransportAsArray = localTransport ? [localTransport] : [];
return transports.mapInner((transports) => [
...localTransportAsArray,
...transports,
]);
},
), ),
), ),
logger, remoteTransports$: membershipsAndTransports.transports$,
logger: logger,
ownMembershipIdentity,
}); });
const matrixLivekitMembers$ = createMatrixLivekitMembers$({ const matrixLivekitMembers$ = createMatrixLivekitMembers$({
@@ -493,6 +522,7 @@ export function createCallViewModel$(
joinMatrixRTC: (transport: LivekitTransport) => { joinMatrixRTC: (transport: LivekitTransport) => {
return enterRTCSession( return enterRTCSession(
matrixRTCSession, matrixRTCSession,
ownMembershipIdentity,
transport, transport,
connectOptions$.value, connectOptions$.value,
); );
@@ -604,15 +634,14 @@ export function createCallViewModel$(
), ),
); );
const allConnections$ = scope.behavior(
connectionManager.connectionManagerData$.pipe(map((d) => d.value)),
);
const livekitRoomItems$ = scope.behavior( const livekitRoomItems$ = scope.behavior(
matrixLivekitMembers$.pipe( matrixLivekitMembers$.pipe(
tap((val) => { switchMap((members) => {
logger.debug("matrixLivekitMembers$ updated", val.value);
}),
switchMap((membersWithEpoch) => {
const members = membersWithEpoch.value;
const a$ = combineLatest( const a$ = combineLatest(
members.map((member) => members.value.map((member) =>
combineLatest([member.connection$, member.participant.value$]).pipe( combineLatest([member.connection$, member.participant.value$]).pipe(
map(([connection, participant]) => { map(([connection, participant]) => {
// do not render audio for local participant // do not render audio for local participant
@@ -685,29 +714,29 @@ export function createCallViewModel$(
generateItems( generateItems(
function* ([ function* ([
localMatrixLivekitMember, localMatrixLivekitMember,
{ value: matrixLivekitMembers }, matrixLivekitMembers,
duplicateTiles, duplicateTiles,
]) { ]) {
let localParticipantId: string | undefined = undefined; let localUserMediaId: string | undefined = undefined;
// add local member if available // add local member if available
if (localMatrixLivekitMember) { if (localMatrixLivekitMember) {
const { userId, participant, connection$, membership$ } = const { userId, participant, connection$, membership$ } =
localMatrixLivekitMember; localMatrixLivekitMember;
localParticipantId = `${userId}:${membership$.value.deviceId}`; // should be membership$.value.membershipID which is not optional
// const participantId = membership$.value.membershipID; localUserMediaId = `${userId}:${membership$.value.deviceId}`;
if (localParticipantId) { const rtcBackendIdentity = membership$.value.rtcBackendIdentity;
for (let dup = 0; dup < 1 + duplicateTiles; dup++) { for (let dup = 0; dup < 1 + duplicateTiles; dup++) {
yield { yield {
keys: [ keys: [
dup, dup,
localParticipantId, localUserMediaId,
userId, userId,
participant satisfies TaggedParticipant as TaggedParticipant, // Widen the type safely participant satisfies TaggedParticipant as TaggedParticipant, // Widen the type safely
connection$, connection$,
], rtcBackendIdentity,
data: undefined, ],
}; data: undefined,
} };
} }
} }
// add remote members that are available // add remote members that are available
@@ -716,13 +745,22 @@ export function createCallViewModel$(
participant, participant,
connection$, connection$,
membership$, membership$,
} of matrixLivekitMembers) { } of matrixLivekitMembers.value) {
const participantId = `${userId}:${membership$.value.deviceId}`; const userMediaId = `${userId}:${membership$.value.deviceId}`;
if (participantId === localParticipantId) continue; const rtcBackendIdentity = membership$.value.rtcBackendIdentity;
// const participantId = membership$.value?.identity; // skip local user as we added them manually before
if (userMediaId === localUserMediaId) continue;
for (let dup = 0; dup < 1 + duplicateTiles; dup++) { for (let dup = 0; dup < 1 + duplicateTiles; dup++) {
yield { yield {
keys: [dup, participantId, userId, participant, connection$], keys: [
dup,
userMediaId,
userId,
participant,
connection$,
rtcBackendIdentity,
],
data: undefined, data: undefined,
}; };
} }
@@ -732,10 +770,11 @@ export function createCallViewModel$(
scope, scope,
_data$, _data$,
dup, dup,
participantId, userMediaId,
userId, userId,
participant, participant,
connection$, connection$,
rtcBackendIdentity,
) => { ) => {
const livekitRoom$ = scope.behavior( const livekitRoom$ = scope.behavior(
connection$.pipe(map((c) => c?.livekitRoom)), connection$.pipe(map((c) => c?.livekitRoom)),
@@ -751,8 +790,9 @@ export function createCallViewModel$(
return new UserMedia( return new UserMedia(
scope, scope,
`${participantId}:${dup}`, `${userMediaId}:${dup}`,
userId, userId,
rtcBackendIdentity,
participant, participant,
options.encryptionSystem, options.encryptionSystem,
livekitRoom$, livekitRoom$,
@@ -761,8 +801,8 @@ export function createCallViewModel$(
localMembership.reconnecting$, localMembership.reconnecting$,
displayName$, displayName$,
matrixMemberMetadataStore.createAvatarUrlBehavior$(userId), matrixMemberMetadataStore.createAvatarUrlBehavior$(userId),
handsRaised$.pipe(map((v) => v[participantId]?.time ?? null)), handsRaised$.pipe(map((v) => v[userMediaId]?.time ?? null)),
reactions$.pipe(map((v) => v[participantId] ?? undefined)), reactions$.pipe(map((v) => v[userMediaId] ?? undefined)),
); );
}, },
), ),
@@ -1503,6 +1543,7 @@ export function createCallViewModel$(
), ),
null, null,
), ),
allConnections$,
participantCount$: participantCount$, participantCount$: participantCount$,
handsRaised$: handsRaised$, handsRaised$: handsRaised$,
reactions$: reactions$, reactions$: reactions$,

View File

@@ -24,6 +24,7 @@ import {
mockLivekitRoom, mockLivekitRoom,
mockMuteStates, mockMuteStates,
withTestScheduler, withTestScheduler,
ownMemberMock,
} from "../../../utils/test"; } from "../../../utils/test";
import { import {
TransportState, TransportState,
@@ -38,6 +39,7 @@ import { constant } from "../../Behavior";
import { ConnectionManagerData } from "../remoteMembers/ConnectionManager"; import { ConnectionManagerData } from "../remoteMembers/ConnectionManager";
import { ConnectionState, type Connection } from "../remoteMembers/Connection"; import { ConnectionState, type Connection } from "../remoteMembers/Connection";
import { type Publisher } from "./Publisher"; import { type Publisher } from "./Publisher";
import { type LocalTransportWithSFUConfig } from "./LocalTransport";
const MATRIX_RTC_MODE = MatrixRTCMode.Legacy; const MATRIX_RTC_MODE = MatrixRTCMode.Legacy;
const getUrlParams = vi.hoisted(() => vi.fn(() => ({}))); const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
@@ -103,11 +105,12 @@ describe("LocalMembership", () => {
getOldestMembership: vi.fn().mockReturnValue({ getOldestMembership: vi.fn().mockReturnValue({
getPreferredFoci: vi.fn().mockReturnValue([focusFromOlderMembership]), getPreferredFoci: vi.fn().mockReturnValue([focusFromOlderMembership]),
}), }),
joinRoomSession: vi.fn(), joinRTCSession: vi.fn(),
}) as unknown as MatrixRTCSession; }) as unknown as MatrixRTCSession;
enterRTCSession( enterRTCSession(
mockedSession, mockedSession,
ownMemberMock,
{ {
livekit_alias: "roomId", livekit_alias: "roomId",
livekit_service_url: "http://my-well-known-service-url.com", livekit_service_url: "http://my-well-known-service-url.com",
@@ -119,7 +122,12 @@ describe("LocalMembership", () => {
}, },
); );
expect(mockedSession.joinRoomSession).toHaveBeenLastCalledWith( expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith(
{
deviceId: "DEVICE",
memberId: "@alice:example.org:DEVICE",
userId: "@alice:example.org",
},
[ [
{ {
livekit_alias: "roomId", livekit_alias: "roomId",
@@ -161,11 +169,12 @@ describe("LocalMembership", () => {
}, },
memberships: [], memberships: [],
getFocusInUse: vi.fn(), getFocusInUse: vi.fn(),
joinRoomSession: vi.fn(), joinRTCSession: vi.fn(),
}) as unknown as MatrixRTCSession; }) as unknown as MatrixRTCSession;
enterRTCSession( enterRTCSession(
mockedSession, mockedSession,
ownMemberMock,
{ {
livekit_alias: "roomId", livekit_alias: "roomId",
livekit_service_url: "http://my-well-known-service-url.com", livekit_service_url: "http://my-well-known-service-url.com",
@@ -204,10 +213,11 @@ describe("LocalMembership", () => {
it("throws error on missing RTC config error", () => { it("throws error on missing RTC config error", () => {
withTestScheduler(({ scope, hot, expectObservable }) => { withTestScheduler(({ scope, hot, expectObservable }) => {
const localTransport$ = scope.behavior<null | LivekitTransport>( const localTransport$ =
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")), scope.behavior<null | LocalTransportWithSFUConfig>(
null, hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
); null,
);
// we do not need any connection data since we want to fail before reaching that. // we do not need any connection data since we want to fail before reaching that.
const mockConnectionManager = { const mockConnectionManager = {
@@ -235,11 +245,23 @@ describe("LocalMembership", () => {
}); });
const aTransport = { const aTransport = {
livekit_service_url: "a", transport: {
} as LivekitTransport; livekit_service_url: "a",
} as LivekitTransport,
sfuConfig: {
url: "sfu-url",
jwt: "sfu-token",
},
} as LocalTransportWithSFUConfig;
const bTransport = { const bTransport = {
livekit_service_url: "b", transport: {
} as LivekitTransport; livekit_service_url: "b",
} as LivekitTransport,
sfuConfig: {
url: "sfu-url",
jwt: "sfu-token",
},
} as LocalTransportWithSFUConfig;
const connectionTransportAConnected = { const connectionTransportAConnected = {
livekitRoom: mockLivekitRoom({ livekitRoom: mockLivekitRoom({
@@ -249,7 +271,7 @@ describe("LocalMembership", () => {
} as unknown as LocalParticipant, } as unknown as LocalParticipant,
}), }),
state$: constant(ConnectionState.LivekitConnected), state$: constant(ConnectionState.LivekitConnected),
transport: aTransport, transport: aTransport.transport,
} as unknown as Connection; } as unknown as Connection;
const connectionTransportAConnecting = { const connectionTransportAConnecting = {
...connectionTransportAConnected, ...connectionTransportAConnected,
@@ -258,11 +280,11 @@ describe("LocalMembership", () => {
} as unknown as Connection; } as unknown as Connection;
const connectionTransportBConnected = { const connectionTransportBConnected = {
state$: constant(ConnectionState.LivekitConnected), state$: constant(ConnectionState.LivekitConnected),
transport: bTransport, transport: bTransport.transport,
livekitRoom: mockLivekitRoom({}), livekitRoom: mockLivekitRoom({}),
} as unknown as Connection; } as unknown as Connection;
it("recreates publisher if new connection is used and ENDS always unpublish and end tracks", async () => { it("recreates publisher if new connection is used, always unpublish and end tracks", async () => {
const scope = new ObservableScope(); const scope = new ObservableScope();
const localTransport$ = new BehaviorSubject(aTransport); const localTransport$ = new BehaviorSubject(aTransport);
@@ -310,8 +332,12 @@ describe("LocalMembership", () => {
expect(publishers[1].stopTracks).not.toHaveBeenCalled(); expect(publishers[1].stopTracks).not.toHaveBeenCalled();
expect(publishers[0].stopPublishing).toHaveBeenCalled(); expect(publishers[0].stopPublishing).toHaveBeenCalled();
expect(publishers[1].stopPublishing).not.toHaveBeenCalled(); expect(publishers[1].stopPublishing).not.toHaveBeenCalled();
expect(publisherFactory.mock.calls[0][0].transport).toBe(aTransport); expect(publisherFactory.mock.calls[0][0].transport).toBe(
expect(publisherFactory.mock.calls[1][0].transport).toBe(bTransport); aTransport.transport,
);
expect(publisherFactory.mock.calls[1][0].transport).toBe(
bTransport.transport,
);
scope.end(); scope.end();
await flushPromises(); await flushPromises();
// stop all tracks after ending scopes // stop all tracks after ending scopes
@@ -383,7 +409,8 @@ describe("LocalMembership", () => {
const scope = new ObservableScope(); const scope = new ObservableScope();
const connectionManagerData = new ConnectionManagerData(); const connectionManagerData = new ConnectionManagerData();
const localTransport$ = new BehaviorSubject<null | LivekitTransport>(null); const localTransport$ =
new BehaviorSubject<null | LocalTransportWithSFUConfig>(null);
const connectionManagerData$ = new BehaviorSubject( const connectionManagerData$ = new BehaviorSubject(
new Epoch(connectionManagerData), new Epoch(connectionManagerData),
); );
@@ -460,7 +487,7 @@ describe("LocalMembership", () => {
}); });
( (
connectionManagerData2.getConnectionForTransport(aTransport)! connectionManagerData2.getConnectionForTransport(aTransport.transport)!
.state$ as BehaviorSubject<ConnectionState> .state$ as BehaviorSubject<ConnectionState>
).next(ConnectionState.LivekitConnected); ).next(ConnectionState.LivekitConnected);
expect(localMembership.localMemberState$.value).toStrictEqual({ expect(localMembership.localMemberState$.value).toStrictEqual({

View File

@@ -36,6 +36,7 @@ import {
} from "rxjs"; } from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger"; import { type Logger } from "matrix-js-sdk/lib/logger";
import { deepCompare } from "matrix-js-sdk/lib/utils"; import { deepCompare } from "matrix-js-sdk/lib/utils";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Behavior } from "../../Behavior.ts"; import { type Behavior } from "../../Behavior.ts";
import { type IConnectionManager } from "../remoteMembers/ConnectionManager.ts"; import { type IConnectionManager } from "../remoteMembers/ConnectionManager.ts";
@@ -60,6 +61,7 @@ import {
} from "../remoteMembers/Connection.ts"; } from "../remoteMembers/Connection.ts";
import { type HomeserverConnected } from "./HomeserverConnected.ts"; import { type HomeserverConnected } from "./HomeserverConnected.ts";
import { and$ } from "../../../utils/observable.ts"; import { and$ } from "../../../utils/observable.ts";
import { type LocalTransportWithSFUConfig } from "./LocalTransport.ts";
export enum TransportState { export enum TransportState {
/** Not even a transport is available to the LocalMembership */ /** Not even a transport is available to the LocalMembership */
@@ -125,7 +127,7 @@ interface Props {
createPublisherFactory: (connection: Connection) => Publisher; createPublisherFactory: (connection: Connection) => Publisher;
joinMatrixRTC: (transport: LivekitTransport) => void; joinMatrixRTC: (transport: LivekitTransport) => void;
homeserverConnected: HomeserverConnected; homeserverConnected: HomeserverConnected;
localTransport$: Behavior<LivekitTransport | null>; localTransport$: Behavior<LocalTransportWithSFUConfig | null>;
matrixRTCSession: Pick< matrixRTCSession: Pick<
MatrixRTCSession, MatrixRTCSession,
"updateCallIntent" | "leaveRoomSession" "updateCallIntent" | "leaveRoomSession"
@@ -233,7 +235,9 @@ export const createLocalMembership$ = ({
return null; return null;
} }
return connectionData.getConnectionForTransport(localTransport); return connectionData.getConnectionForTransport(
localTransport.transport,
);
}), }),
tap((connection) => { tap((connection) => {
logger.info( logger.info(
@@ -532,7 +536,7 @@ export const createLocalMembership$ = ({
if (!shouldConnect) return; if (!shouldConnect) return;
try { try {
joinMatrixRTC(transport); joinMatrixRTC(transport.transport);
} catch (error) { } catch (error) {
logger.error("Error entering RTC session", error); logger.error("Error entering RTC session", error);
if (error instanceof Error) if (error instanceof Error)
@@ -551,7 +555,12 @@ export const createLocalMembership$ = ({
); );
const participant$ = scope.behavior( const participant$ = scope.behavior(
localConnection$.pipe(map((c) => c?.livekitRoom?.localParticipant ?? null)), localConnection$.pipe(
map((c) => c?.livekitRoom?.localParticipant ?? null),
tap((p) => {
logger.debug("participant$ updated:", p?.identity);
}),
),
); );
// Pause upstream of all local media tracks when we're disconnected from // Pause upstream of all local media tracks when we're disconnected from
@@ -686,18 +695,19 @@ interface EnterRTCSessionOptions {
* - Handles retries (fails only after several attempts) * - Handles retries (fails only after several attempts)
* *
* @param rtcSession - The MatrixRTCSession to join. * @param rtcSession - The MatrixRTCSession to join.
* @param ownMembershipIdentity - Options for entering the RTC session.
* @param transport - The LivekitTransport to use for this session. * @param transport - The LivekitTransport to use for this session.
* @param options - Options for entering the RTC session. * @param options - `encryptMedia`: Whether to encrypt media `matrixRTCMode`: The Matrix RTC mode to use.
* @param options.encryptMedia - Whether to encrypt media.
* @param options.matrixRTCMode - The Matrix RTC mode to use.
* @throws If the widget could not send ElementWidgetActions.JoinCall action. * @throws If the widget could not send ElementWidgetActions.JoinCall action.
*/ */
// Exported for unit testing // Exported for unit testing
export function enterRTCSession( export function enterRTCSession(
rtcSession: MatrixRTCSession, rtcSession: MatrixRTCSession,
ownMembershipIdentity: CallMembershipIdentityParts,
transport: LivekitTransport, transport: LivekitTransport,
{ encryptMedia, matrixRTCMode }: EnterRTCSessionOptions, options: EnterRTCSessionOptions,
): void { ): void {
const { encryptMedia, matrixRTCMode } = options;
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date()); PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId); PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
@@ -709,10 +719,13 @@ export function enterRTCSession(
const useDeviceSessionMemberEvents = const useDeviceSessionMemberEvents =
features?.feature_use_device_session_member_events; features?.feature_use_device_session_member_events;
const { sendNotificationType: notificationType, callIntent } = getUrlParams(); const { sendNotificationType: notificationType, callIntent } = getUrlParams();
const multiSFU = matrixRTCMode !== MatrixRTCMode.Legacy; const multiSFU =
matrixRTCMode === MatrixRTCMode.Compatibility ||
matrixRTCMode === MatrixRTCMode.Matrix_2_0;
// Multi-sfu does not need a preferred foci list. just the focus that is actually used. // Multi-sfu does not need a preferred foci list. just the focus that is actually used.
// TODO where/how do we track errors originating from the ongoing rtcSession? // TODO where/how do we track errors originating from the ongoing rtcSession?
rtcSession.joinRoomSession( rtcSession.joinRTCSession(
ownMembershipIdentity,
multiSFU ? [] : [transport], multiSFU ? [] : [transport],
multiSFU ? transport : undefined, multiSFU ? transport : undefined,
{ {

View File

@@ -18,8 +18,8 @@ import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, lastValueFrom } from "rxjs"; import { BehaviorSubject, lastValueFrom } from "rxjs";
import fetchMock from "fetch-mock"; import fetchMock from "fetch-mock";
import { mockConfig, flushPromises } from "../../../utils/test"; import { mockConfig, flushPromises, ownMemberMock } from "../../../utils/test";
import { createLocalTransport$ } from "./LocalTransport"; import { createLocalTransport$, JwtEndpointVersion } from "./LocalTransport";
import { constant } from "../../Behavior"; import { constant } from "../../Behavior";
import { Epoch, ObservableScope } from "../../ObservableScope"; import { Epoch, ObservableScope } from "../../ObservableScope";
import { import {
@@ -39,11 +39,35 @@ describe("LocalTransport", () => {
}; };
let scope: ObservableScope; let scope: ObservableScope;
beforeEach(() => { beforeEach(() => (scope = new ObservableScope()));
scope = new ObservableScope();
});
afterEach(() => scope.end()); afterEach(() => scope.end());
it("throws if config is missing", async () => {
const localTransport$ = createLocalTransport$({
scope,
roomId: "!room:example.org",
useOldestMember$: constant(false),
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getDomain: () => "",
baseUrl: "example.org",
// These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
ownMembershipIdentity: ownMemberMock,
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
delayId$: constant("delay_id_mock"),
});
await flushPromises();
expect(() => localTransport$.value).toThrow(
new MatrixRTCTransportMissingError(""),
);
});
it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => { it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => {
// Provide a valid config so makeTransportInternal resolves a transport // Provide a valid config so makeTransportInternal resolves a transport
const scope = new ObservableScope(); const scope = new ObservableScope();
@@ -65,6 +89,7 @@ describe("LocalTransport", () => {
useOldestMember$: constant(false), useOldestMember$: constant(false),
memberships$: constant(new Epoch<CallMembership[]>([])), memberships$: constant(new Epoch<CallMembership[]>([])),
client: { client: {
baseUrl: "https://lk.example.org",
// Use empty domain to skip .well-known and use config directly // Use empty domain to skip .well-known and use config directly
getDomain: () => "", getDomain: () => "",
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
@@ -72,6 +97,9 @@ describe("LocalTransport", () => {
getOpenIdToken: vi.fn(), getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(), getDeviceId: vi.fn(),
}, },
ownMembershipIdentity: ownMemberMock,
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
delayId$: constant("delay_id_mock"),
}); });
localTransport$.subscribe( localTransport$.subscribe(
(o) => observations.push(o), (o) => observations.push(o),
@@ -86,6 +114,60 @@ describe("LocalTransport", () => {
expect(() => localTransport$.value).toThrow(expectedError); expect(() => localTransport$.value).toThrow(expectedError);
}); });
it("emits preferred transport after OpenID resolves", async () => {
// Use config so transport discovery succeeds, but delay OpenID JWT fetch
mockConfig({
livekit: { livekit_service_url: "https://lk.example.org" },
});
const openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
openIdResolver.promise,
);
const localTransport$ = createLocalTransport$({
scope,
roomId: "!room:example.org",
useOldestMember$: constant(false),
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getDomain: () => "",
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
baseUrl: "https://lk.example.org",
},
ownMembershipIdentity: ownMemberMock,
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
delayId$: constant("delay_id_mock"),
});
openIdResolver.resolve?.({
url: "https://lk.example.org",
jwt: "jwt",
livekitAlias: "!room:example.org",
livekitIdentity: ownMemberMock.userId + ":" + ownMemberMock.deviceId,
});
expect(localTransport$.value).toBe(null);
await flushPromises();
// final
expect(localTransport$.value).toStrictEqual({
transport: {
livekit_alias: "!room:example.org",
livekit_service_url: "https://lk.example.org",
type: "livekit",
},
sfuConfig: {
jwt: "jwt",
livekitAlias: "!room:example.org",
livekitIdentity: "@alice:example.org:DEVICE",
url: "https://lk.example.org",
},
});
});
it("updates local transport when oldest member changes", async () => { it("updates local transport when oldest member changes", async () => {
// Use config so transport discovery succeeds, but delay OpenID JWT fetch // Use config so transport discovery succeeds, but delay OpenID JWT fetch
mockConfig({ mockConfig({
@@ -109,7 +191,11 @@ describe("LocalTransport", () => {
_unstable_getRTCTransports: async () => Promise.resolve([]), _unstable_getRTCTransports: async () => Promise.resolve([]),
getOpenIdToken: vi.fn(), getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(), getDeviceId: vi.fn(),
baseUrl: "https://lk.example.org",
}, },
ownMembershipIdentity: ownMemberMock,
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
delayId$: constant("delay_id_mock"),
}); });
openIdResolver.resolve?.(openIdResponse); openIdResolver.resolve?.(openIdResponse);
@@ -117,9 +203,17 @@ describe("LocalTransport", () => {
await flushPromises(); await flushPromises();
// final // final
expect(localTransport$.value).toStrictEqual({ expect(localTransport$.value).toStrictEqual({
livekit_alias: "!example_room_id", transport: {
livekit_service_url: "https://lk.example.org", livekit_alias: "!example_room_id",
type: "livekit", livekit_service_url: "https://lk.example.org",
type: "livekit",
},
sfuConfig: {
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
livekitAlias: "!example_room_id",
livekitIdentity: "@lk_user:ABCDEF",
url: "https://lk.example.org",
},
}); });
}); });
@@ -134,11 +228,15 @@ describe("LocalTransport", () => {
mockConfig({}); mockConfig({});
customLivekitUrl.setValue(customLivekitUrl.defaultValue); customLivekitUrl.setValue(customLivekitUrl.defaultValue);
localTransportOpts = { localTransportOpts = {
ownMembershipIdentity: ownMemberMock,
scope, scope,
roomId: "!example_room_id", roomId: "!example_room_id",
useOldestMember$: constant(false), useOldestMember$: constant(false),
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
delayId$: constant(null),
memberships$: constant(new Epoch<CallMembership[]>([])), memberships$: constant(new Epoch<CallMembership[]>([])),
client: { client: {
baseUrl: "https://example.org",
getDomain: vi.fn().mockReturnValue(""), getDomain: vi.fn().mockReturnValue(""),
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]), _unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
@@ -165,9 +263,17 @@ describe("LocalTransport", () => {
expect(localTransport$.value).toBe(null); expect(localTransport$.value).toBe(null);
await flushPromises(); await flushPromises();
expect(localTransport$.value).toStrictEqual({ expect(localTransport$.value).toStrictEqual({
livekit_alias: "!example_room_id", transport: {
livekit_service_url: "https://lk.example.org", livekit_alias: "!example_room_id",
type: "livekit", livekit_service_url: "https://lk.example.org",
type: "livekit",
},
sfuConfig: {
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
livekitAlias: "!example_room_id",
livekitIdentity: "@lk_user:ABCDEF",
url: "https://lk.example.org",
},
}); });
}); });
it("supports getting transport via user settings", async () => { it("supports getting transport via user settings", async () => {
@@ -177,9 +283,17 @@ describe("LocalTransport", () => {
expect(localTransport$.value).toBe(null); expect(localTransport$.value).toBe(null);
await flushPromises(); await flushPromises();
expect(localTransport$.value).toStrictEqual({ expect(localTransport$.value).toStrictEqual({
livekit_alias: "!example_room_id", transport: {
livekit_service_url: "https://lk.example.org", livekit_alias: "!example_room_id",
type: "livekit", livekit_service_url: "https://lk.example.org",
type: "livekit",
},
sfuConfig: {
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
livekitAlias: "!example_room_id",
livekitIdentity: "@lk_user:ABCDEF",
url: "https://lk.example.org",
},
}); });
}); });
it("supports getting transport via backend", async () => { it("supports getting transport via backend", async () => {
@@ -191,9 +305,17 @@ describe("LocalTransport", () => {
expect(localTransport$.value).toBe(null); expect(localTransport$.value).toBe(null);
await flushPromises(); await flushPromises();
expect(localTransport$.value).toStrictEqual({ expect(localTransport$.value).toStrictEqual({
livekit_alias: "!example_room_id", transport: {
livekit_service_url: "https://lk.example.org", livekit_alias: "!example_room_id",
type: "livekit", livekit_service_url: "https://lk.example.org",
type: "livekit",
},
sfuConfig: {
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
livekitAlias: "!example_room_id",
livekitIdentity: "@lk_user:ABCDEF",
url: "https://lk.example.org",
},
}); });
}); });
it("fails fast if the openID request fails for backend config", async () => { it("fails fast if the openID request fails for backend config", async () => {
@@ -222,9 +344,17 @@ describe("LocalTransport", () => {
expect(localTransport$.value).toBe(null); expect(localTransport$.value).toBe(null);
await flushPromises(); await flushPromises();
expect(localTransport$.value).toStrictEqual({ expect(localTransport$.value).toStrictEqual({
livekit_alias: "!example_room_id", transport: {
livekit_service_url: "https://lk.example.org", livekit_alias: "!example_room_id",
type: "livekit", livekit_service_url: "https://lk.example.org",
type: "livekit",
},
sfuConfig: {
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
livekitAlias: "!example_room_id",
livekitIdentity: "@lk_user:ABCDEF",
url: "https://lk.example.org",
},
}); });
expect(fetchMock.done()).toEqual(true); expect(fetchMock.done()).toEqual(true);
}); });
@@ -248,11 +378,15 @@ describe("LocalTransport", () => {
it("throws if no options are available", async () => { it("throws if no options are available", async () => {
const localTransport$ = createLocalTransport$({ const localTransport$ = createLocalTransport$({
scope, scope,
ownMembershipIdentity: ownMemberMock,
roomId: "!example_room_id", roomId: "!example_room_id",
useOldestMember$: constant(false), useOldestMember$: constant(false),
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
delayId$: constant(null),
memberships$: constant(new Epoch<CallMembership[]>([])), memberships$: constant(new Epoch<CallMembership[]>([])),
client: { client: {
getDomain: () => "", getDomain: () => "",
baseUrl: "https://example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]), _unstable_getRTCTransports: async () => Promise.resolve([]),
// These won't be called in this error path but satisfy the type // These won't be called in this error path but satisfy the type

View File

@@ -23,6 +23,7 @@ import {
} from "rxjs"; } from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery"; import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Behavior } from "../../Behavior.ts"; import { type Behavior } from "../../Behavior.ts";
import { type Epoch, type ObservableScope } from "../../ObservableScope.ts"; import { type Epoch, type ObservableScope } from "../../ObservableScope.ts";
@@ -30,9 +31,11 @@ import { Config } from "../../../config/Config.ts";
import { import {
FailToGetOpenIdToken, FailToGetOpenIdToken,
MatrixRTCTransportMissingError, MatrixRTCTransportMissingError,
NoMatrix2AuthorizationService,
} from "../../../utils/errors.ts"; } from "../../../utils/errors.ts";
import { import {
getSFUConfigWithOpenID, getSFUConfigWithOpenID,
type SFUConfig,
type OpenIDClientParts, type OpenIDClientParts,
} from "../../../livekit/openIDSFU.ts"; } from "../../../livekit/openIDSFU.ts";
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts"; import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts";
@@ -47,11 +50,32 @@ const logger = rootLogger.getChild("[LocalTransport]");
*/ */
interface Props { interface Props {
scope: ObservableScope; scope: ObservableScope;
ownMembershipIdentity: CallMembershipIdentityParts;
memberships$: Behavior<Epoch<CallMembership[]>>; memberships$: Behavior<Epoch<CallMembership[]>>;
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports"> & client: Pick<
MatrixClient,
"getDomain" | "baseUrl" | "_unstable_getRTCTransports"
> &
OpenIDClientParts; OpenIDClientParts;
roomId: string; roomId: string;
useOldestMember$: Behavior<boolean>; useOldestMember$: Behavior<boolean>;
forceJwtEndpoint$: Behavior<JwtEndpointVersion>;
delayId$: Behavior<string | null>;
}
export enum JwtEndpointVersion {
Legacy = "legacy",
Matrix_2_0 = "matrix_2_0",
}
export interface LocalTransportWithSFUConfig {
transport: LivekitTransport;
sfuConfig: SFUConfig;
}
export function isLocalTransportWithSFUConfig(
obj: LivekitTransport | LocalTransportWithSFUConfig,
): obj is LocalTransportWithSFUConfig {
return "transport" in obj && "sfuConfig" in obj;
} }
/** /**
@@ -61,26 +85,53 @@ interface Props {
* @prop useOldestMember Whether to use the same transport as the oldest member. * @prop useOldestMember Whether to use the same transport as the oldest member.
* This will only update once the first oldest member appears. Will not recompute if the oldest member leaves. * This will only update once the first oldest member appears. Will not recompute if the oldest member leaves.
* *
* @prop useOldJwtEndpoint$ Whether to set forceOldJwtEndpoint on the returned transport and to use the old JWT endpoint.
* This is used when the connection manager needs to know if it has to use the legacy endpoint which implies a string concatenated rtcBackendIdentity.
* (which is expected for non sticky event based rtc member events)
* @returns The local transport. It will be created using the correct sfu endpoint based on the useOldJwtEndpoint$ value.
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken * @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
*/ */
export const createLocalTransport$ = ({ export const createLocalTransport$ = ({
scope, scope,
memberships$, memberships$,
ownMembershipIdentity,
client, client,
roomId, roomId,
useOldestMember$, useOldestMember$,
}: Props): Behavior<LivekitTransport | null> => { forceJwtEndpoint$,
delayId$,
}: Props): Behavior<LocalTransportWithSFUConfig | null> => {
/** /**
* The transport over which we should be actively publishing our media. * The transport over which we should be actively publishing our media.
* undefined when not joined. * undefined when not joined.
*/ */
const oldestMemberTransport$ = scope.behavior( const oldestMemberTransport$ = scope.behavior(
memberships$.pipe( combineLatest([memberships$]).pipe(
map( map(([memberships]) => {
(memberships) => const oldestMember = memberships.value[0];
memberships.value[0]?.getTransport(memberships.value[0]) ?? null, const transport = oldestMember?.getTransport(memberships.value[0]);
), if (!transport) return null;
return transport;
}),
first((t) => t != null && isLivekitTransport(t)), first((t) => t != null && isLivekitTransport(t)),
switchMap((transport) => {
// Get the open jwt token to connect to the sfu
const computeLocalTransportWithSFUConfig =
async (): Promise<LocalTransportWithSFUConfig> => {
return {
transport,
sfuConfig: await getSFUConfigWithOpenID(
client,
ownMembershipIdentity,
transport.livekit_service_url,
roomId,
{ forceJwtEndpoint: JwtEndpointVersion.Legacy },
logger,
),
};
};
return from(computeLocalTransportWithSFUConfig());
}),
), ),
null, null,
); );
@@ -91,9 +142,30 @@ export const createLocalTransport$ = ({
* *
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken * @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
*/ */
const preferredTransport$: Behavior<LivekitTransport | null> = scope.behavior( const preferredTransport$ = scope.behavior(
customLivekitUrl.value$.pipe( // preferredTransport$ (used for multi sfu) needs to know if we are using the old or new
switchMap((customUrl) => from(makeTransport(client, roomId, customUrl))), // jwt endpoint (`get_token` vs `sfu/get`) based on that the jwt endpoint will compute the rtcBackendIdentity
// differently. (sha(`${userId}|${deviceId}|${memberId}`) vs `${userId}|${deviceId}|${memberId}`)
// When using sticky events (we need to use the new endpoint).
combineLatest([customLivekitUrl.value$, delayId$, forceJwtEndpoint$]).pipe(
switchMap(([customUrl, delayId, forceEndpoint]) => {
logger.info(
"Creating preferred transport based on: ",
customUrl,
delayId,
forceEndpoint,
);
return from(
makeTransport(
client,
ownMembershipIdentity,
roomId,
customUrl,
forceEndpoint,
delayId ?? undefined,
),
);
}),
), ),
null, null,
); );
@@ -112,7 +184,9 @@ export const createLocalTransport$ = ({
? (oldestMemberTransport ?? preferredTransport) ? (oldestMemberTransport ?? preferredTransport)
: preferredTransport, : preferredTransport,
), ),
distinctUntilChanged(areLivekitTransportsEqual), distinctUntilChanged((t1, t2) =>
areLivekitTransportsEqual(t1?.transport ?? null, t2?.transport ?? null),
),
), ),
); );
}; };
@@ -124,25 +198,63 @@ const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
* validating auth against the service to ensure it's correct. * validating auth against the service to ensure it's correct.
* Prefers in order: * Prefers in order:
* *
* 1. The `urlFromDevSettings` value. If this cannot be validated, the function will throw. * 1. The `urlFromDevSettings` value. If this cannot be validated, the function will throw.
* 2. The transports returned via the homeserver. * 2. The transports returned via the homeserver.
* 3. The transports returned via .well-known. * 3. The transports returned via .well-known.
* 4. The transport configured in Element Call's config. * 4. The transport configured in Element Call's config.
* *
* @param client The authenticated Matrix client for the current user * @param client The authenticated Matrix client for the current user
* @param membership The membership identity of the user.
* @param roomId The ID of the room to be connected to. * @param roomId The ID of the room to be connected to.
* @param urlFromDevSettings Override URL provided by the user's local config. * @param urlFromDevSettings Override URL provided by the user's local config.
* @param forceJwtEndpoint Whether to force a specific JWT endpoint
* - `Legacy` / `Matrix_2_0`
* - `get_token` / `sfu/get`
* - not hashing / hashing the backendIdentity
* @param delayId the delay id passed to the jwt service.
*
* @returns A fully validated transport config. * @returns A fully validated transport config.
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken * @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
*/ */
async function makeTransport( async function makeTransport(
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports"> & client: Pick<
MatrixClient,
"getDomain" | "baseUrl" | "_unstable_getRTCTransports"
> &
OpenIDClientParts, OpenIDClientParts,
membership: CallMembershipIdentityParts,
roomId: string, roomId: string,
urlFromDevSettings: string | null, urlFromDevSettings: string | null,
): Promise<LivekitTransport> { forceJwtEndpoint: JwtEndpointVersion,
delayId?: string,
): Promise<LocalTransportWithSFUConfig> {
logger.trace("Searching for a preferred transport"); logger.trace("Searching for a preferred transport");
async function doOpenIdAndJWTFromUrl(
url: string,
): Promise<LocalTransportWithSFUConfig> {
const sfuConfig = await getSFUConfigWithOpenID(
client,
membership,
url,
roomId,
{
forceJwtEndpoint: forceJwtEndpoint,
delayEndpointBaseUrl: client.baseUrl,
delayId,
},
logger,
);
return {
transport: {
type: "livekit",
livekit_service_url: url,
livekit_alias: sfuConfig.livekitAlias,
},
sfuConfig,
};
}
// We will call `getSFUConfigWithOpenID` once per transport here as it's our // We will call `getSFUConfigWithOpenID` once per transport here as it's our
// only mechanism of valiation. This means we will also ask the // only mechanism of valiation. This means we will also ask the
// homeserver for a OpenID token a few times. Since OpenID tokens are single // homeserver for a OpenID token a few times. Since OpenID tokens are single
@@ -153,39 +265,29 @@ async function makeTransport(
// DEVTOOL: Highest priority: Load from devtool setting // DEVTOOL: Highest priority: Load from devtool setting
if (urlFromDevSettings !== null) { if (urlFromDevSettings !== null) {
logger.info("Using LiveKit transport from dev tools: ", urlFromDevSettings);
// Validate that the SFU is up. Otherwise, we want to fail on this // Validate that the SFU is up. Otherwise, we want to fail on this
// as we don't permit other SFUs. // as we don't permit other SFUs.
const config = await getSFUConfigWithOpenID( // This will call the jwt/sfu/get endpoint to pre create the livekit room.
client, logger.info("Using LiveKit transport from dev tools: ", urlFromDevSettings);
urlFromDevSettings, return await doOpenIdAndJWTFromUrl(urlFromDevSettings);
roomId,
);
return {
type: "livekit",
livekit_service_url: urlFromDevSettings,
livekit_alias: config.livekitAlias,
};
} }
async function getFirstUsableTransport( async function getFirstUsableTransport(
transports: Transport[], transports: Transport[],
): Promise<LivekitTransport | null> { ): Promise<LocalTransportWithSFUConfig | null> {
for (const potentialTransport of transports) { for (const potentialTransport of transports) {
if (isLivekitTransportConfig(potentialTransport)) { if (isLivekitTransportConfig(potentialTransport)) {
try { try {
const { livekitAlias } = await getSFUConfigWithOpenID( // This will call the jwt/sfu/get endpoint to pre create the livekit room.
client, return await doOpenIdAndJWTFromUrl(
potentialTransport.livekit_service_url, potentialTransport.livekit_service_url,
roomId,
); );
return {
...potentialTransport,
livekit_alias: livekitAlias,
};
} catch (ex) { } catch (ex) {
// Explictly throw these
if (ex instanceof FailToGetOpenIdToken) { if (ex instanceof FailToGetOpenIdToken) {
// Explictly throw these throw ex;
}
if (ex instanceof NoMatrix2AuthorizationService) {
throw ex; throw ex;
} }
logger.debug( logger.debug(
@@ -245,18 +347,9 @@ async function makeTransport(
const urlFromConf = Config.get().livekit?.livekit_service_url; const urlFromConf = Config.get().livekit?.livekit_service_url;
if (urlFromConf) { if (urlFromConf) {
try { try {
const { livekitAlias } = await getSFUConfigWithOpenID( // This will call the jwt/sfu/get endpoint to pre create the livekit room.
client, logger.info("Using config SFU", urlFromConf);
urlFromConf, return await doOpenIdAndJWTFromUrl(urlFromConf);
roomId,
);
const selectedTransport: LivekitTransport = {
type: "livekit",
livekit_service_url: urlFromConf,
livekit_alias: livekitAlias,
};
logger.info("Using config SFU", selectedTransport);
return selectedTransport;
} catch (ex) { } catch (ex) {
if (ex instanceof FailToGetOpenIdToken) { if (ex instanceof FailToGetOpenIdToken) {
throw ex; throw ex;
@@ -265,5 +358,6 @@ async function makeTransport(
} }
} }
// If we do not have returned a transport by now we throw an error
throw new MatrixRTCTransportMissingError(domain ?? ""); throw new MatrixRTCTransportMissingError(domain ?? "");
} }

View File

@@ -26,8 +26,8 @@ import fetchMock from "fetch-mock";
import EventEmitter from "events"; import EventEmitter from "events";
import { type IOpenIDToken } from "matrix-js-sdk"; import { type IOpenIDToken } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc/LivekitTransport";
import type { LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { import {
Connection, Connection,
ConnectionState, ConnectionState,
@@ -40,7 +40,7 @@ import {
FailToGetOpenIdToken, FailToGetOpenIdToken,
} from "../../../utils/errors.ts"; } from "../../../utils/errors.ts";
import { testJWTToken } from "../../../utils/test-fixtures.ts"; import { testJWTToken } from "../../../utils/test-fixtures.ts";
import { mockRemoteParticipant } from "../../../utils/test.ts"; import { mockRemoteParticipant, ownMemberMock } from "../../../utils/test.ts";
let testScope: ObservableScope; let testScope: ObservableScope;
@@ -114,6 +114,7 @@ function setupRemoteConnection(): Connection {
client: client, client: client,
transport: livekitFocus, transport: livekitFocus,
scope: testScope, scope: testScope,
ownMembershipIdentity: ownMemberMock,
livekitRoomFactory: () => fakeLivekitRoom, livekitRoomFactory: () => fakeLivekitRoom,
}; };
@@ -155,6 +156,7 @@ describe("Start connection states", () => {
client: client, client: client,
transport: livekitFocus, transport: livekitFocus,
scope: testScope, scope: testScope,
ownMembershipIdentity: ownMemberMock,
livekitRoomFactory: () => fakeLivekitRoom, livekitRoomFactory: () => fakeLivekitRoom,
}; };
const connection = new Connection(opts, logger); const connection = new Connection(opts, logger);
@@ -170,6 +172,7 @@ describe("Start connection states", () => {
client: client, client: client,
transport: livekitFocus, transport: livekitFocus,
scope: testScope, scope: testScope,
ownMembershipIdentity: ownMemberMock,
livekitRoomFactory: () => fakeLivekitRoom, livekitRoomFactory: () => fakeLivekitRoom,
}; };
@@ -220,6 +223,7 @@ describe("Start connection states", () => {
client: client, client: client,
transport: livekitFocus, transport: livekitFocus,
scope: testScope, scope: testScope,
ownMembershipIdentity: ownMemberMock,
livekitRoomFactory: () => fakeLivekitRoom, livekitRoomFactory: () => fakeLivekitRoom,
}; };
@@ -259,7 +263,7 @@ describe("Start connection states", () => {
capturedState.cause instanceof Error capturedState.cause instanceof Error
) { ) {
expect(capturedState.cause.message).toContain( expect(capturedState.cause.message).toContain(
"SFU Config fetch failed with exception", "SFU Config fetch failed with status code 500",
); );
expect(connection.transport.livekit_alias).toEqual( expect(connection.transport.livekit_alias).toEqual(
livekitFocus.livekit_alias, livekitFocus.livekit_alias,
@@ -277,6 +281,7 @@ describe("Start connection states", () => {
client: client, client: client,
transport: livekitFocus, transport: livekitFocus,
scope: testScope, scope: testScope,
ownMembershipIdentity: ownMemberMock,
livekitRoomFactory: () => fakeLivekitRoom, livekitRoomFactory: () => fakeLivekitRoom,
}; };

View File

@@ -18,6 +18,7 @@ import {
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, map } from "rxjs"; import { BehaviorSubject, map } from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger"; import { type Logger } from "matrix-js-sdk/lib/logger";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { import {
getSFUConfigWithOpenID, getSFUConfigWithOpenID,
@@ -32,8 +33,21 @@ import {
SFURoomCreationRestrictedError, SFURoomCreationRestrictedError,
UnknownCallError, UnknownCallError,
} from "../../../utils/errors.ts"; } from "../../../utils/errors.ts";
import { type JwtEndpointVersion } from "../localMember/LocalTransport.ts";
export interface ConnectionOpts { export interface ConnectionOpts {
/**
* For the local transport we already do know the jwt token and url. We can reuse it.
* On top the local transport will send additional data to the jwt server to use delayed event delegation.
*/
existingSFUConfig?: SFUConfig;
/**
* For local connections that use the oldest member pattern. here we have not prefetched the sfuConfig
* and hence we need to let the connection do the jwt token fetching.
*/
forceJwtEndpoint?: JwtEndpointVersion;
/** The identity parts to use on this connection */
ownMembershipIdentity: CallMembershipIdentityParts;
/** The media transport to connect to. */ /** The media transport to connect to. */
transport: LivekitTransport; transport: LivekitTransport;
/** The Matrix client to use for OpenID and SFU config requests. */ /** The Matrix client to use for OpenID and SFU config requests. */
@@ -129,8 +143,10 @@ export class Connection {
try { try {
this._state$.next(ConnectionState.FetchingConfig); this._state$.next(ConnectionState.FetchingConfig);
// We should already have this information after creating the localTransport. // We should already have this information after creating the localTransport.
// It would probably be better to forward this here. // only call getSFUConfigWithOpenID for connections where we do not have a token yet. (existingJwtTokenData === undefined)
const { url, jwt } = await this.getSFUConfigWithOpenID(); const { url, jwt } =
this.existingSFUConfig ??
(await this.getSFUConfigForRemoteConnection());
// If we were stopped while fetching the config, don't proceed to connect // If we were stopped while fetching the config, don't proceed to connect
if (this.stopped) return; if (this.stopped) return;
@@ -186,11 +202,17 @@ export class Connection {
} }
} }
protected async getSFUConfigWithOpenID(): Promise<SFUConfig> { protected async getSFUConfigForRemoteConnection(): Promise<SFUConfig> {
// This will only be called for sfu's where we do not publish ourselves.
// For the local connection we will use the existingJwtTokenData
return await getSFUConfigWithOpenID( return await getSFUConfigWithOpenID(
this.client, this.client,
this.ownMembershipIdentity,
this.transport.livekit_service_url, this.transport.livekit_service_url,
this.transport.livekit_alias, this.transport.livekit_alias,
// dont pass any custom opts for the subscribe only connections
{},
this.logger,
); );
} }
@@ -212,7 +234,8 @@ export class Connection {
private readonly client: OpenIDClientParts; private readonly client: OpenIDClientParts;
private readonly logger: Logger; private readonly logger: Logger;
private readonly ownMembershipIdentity: CallMembershipIdentityParts;
private readonly existingSFUConfig?: SFUConfig;
/** /**
* Creates a new connection to a matrix RTC LiveKit backend. * Creates a new connection to a matrix RTC LiveKit backend.
* *
@@ -221,6 +244,8 @@ export class Connection {
* @param logger - The logger to use. * @param logger - The logger to use.
*/ */
public constructor(opts: ConnectionOpts, logger: Logger) { public constructor(opts: ConnectionOpts, logger: Logger) {
this.ownMembershipIdentity = opts.ownMembershipIdentity;
this.existingSFUConfig = opts.existingSFUConfig;
this.logger = logger.getChild("[Connection]"); this.logger = logger.getChild("[Connection]");
this.logger.info( this.logger.info(
`Creating new connection to ${opts.transport.livekit_service_url} ${opts.transport.livekit_alias}`, `Creating new connection to ${opts.transport.livekit_service_url} ${opts.transport.livekit_alias}`,

View File

@@ -5,7 +5,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { import {
Room as LivekitRoom, Room as LivekitRoom,
type RoomOptions, type RoomOptions,
@@ -16,10 +15,15 @@ import {
import { type Logger } from "matrix-js-sdk/lib/logger"; import { type Logger } from "matrix-js-sdk/lib/logger";
// imported as inline to support worker when loaded from a cdn (cross domain) // imported as inline to support worker when loaded from a cdn (cross domain)
import E2EEWorker from "livekit-client/e2ee-worker?worker&inline"; import E2EEWorker from "livekit-client/e2ee-worker?worker&inline";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc/LivekitTransport";
import { type ObservableScope } from "../../ObservableScope.ts"; import { type ObservableScope } from "../../ObservableScope.ts";
import { Connection } from "./Connection.ts"; import { Connection } from "./Connection.ts";
import type { OpenIDClientParts } from "../../../livekit/openIDSFU.ts"; import type {
OpenIDClientParts,
SFUConfig,
} from "../../../livekit/openIDSFU.ts";
import type { MediaDevices } from "../../MediaDevices.ts"; import type { MediaDevices } from "../../MediaDevices.ts";
import type { Behavior } from "../../Behavior.ts"; import type { Behavior } from "../../Behavior.ts";
import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx"; import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx";
@@ -28,9 +32,11 @@ import { defaultLiveKitOptions } from "../../../livekit/options.ts";
// TODO evaluate if this should be done like the Publisher Factory // TODO evaluate if this should be done like the Publisher Factory
export interface ConnectionFactory { export interface ConnectionFactory {
createConnection( createConnection(
transport: LivekitTransport,
scope: ObservableScope, scope: ObservableScope,
transport: LivekitTransport,
ownMembershipIdentity: CallMembershipIdentityParts,
logger: Logger, logger: Logger,
sfuConfig?: SFUConfig,
): Connection; ): Connection;
} }
@@ -78,17 +84,30 @@ export class ECConnectionFactory implements ConnectionFactory {
this.livekitRoomFactory = livekitRoomFactory ?? defaultFactory; this.livekitRoomFactory = livekitRoomFactory ?? defaultFactory;
} }
/**
*
* @param scope The observable scope (used for clean-up)
* @param transport The transport to use for this connection.
* @param ownMembershipIdentity required to connect (using the jwt service) with the SFU.
* @param logger The logger instance to use for this connection.
* @param sfuConfig optional config in case we already have a token for this connection.
* @returns
*/
public createConnection( public createConnection(
transport: LivekitTransport,
scope: ObservableScope, scope: ObservableScope,
transport: LivekitTransport,
ownMembershipIdentity: CallMembershipIdentityParts,
logger: Logger, logger: Logger,
sfuConfig?: SFUConfig,
): Connection { ): Connection {
return new Connection( return new Connection(
{ {
existingSFUConfig: sfuConfig,
transport, transport,
client: this.client, client: this.client,
scope: scope, scope: scope,
livekitRoomFactory: this.livekitRoomFactory, livekitRoomFactory: this.livekitRoomFactory,
ownMembershipIdentity,
}, },
logger, logger,
); );

View File

@@ -18,9 +18,9 @@ import {
} from "./ConnectionManager.ts"; } from "./ConnectionManager.ts";
import { type ConnectionFactory } from "./ConnectionFactory.ts"; import { type ConnectionFactory } from "./ConnectionFactory.ts";
import { type Connection } from "./Connection.ts"; import { type Connection } from "./Connection.ts";
import { withTestScheduler } from "../../../utils/test.ts"; import { ownMemberMock, withTestScheduler } from "../../../utils/test.ts";
import { areLivekitTransportsEqual } from "./MatrixLivekitMembers.ts"; import { areLivekitTransportsEqual } from "./MatrixLivekitMembers.ts";
import { type Behavior } from "../../Behavior.ts"; import { constant, type Behavior } from "../../Behavior.ts";
// Some test constants // Some test constants
@@ -49,7 +49,7 @@ beforeEach(() => {
vi.mocked(fakeConnectionFactory).createConnection = vi vi.mocked(fakeConnectionFactory).createConnection = vi
.fn() .fn()
.mockImplementation( .mockImplementation(
(transport: LivekitTransport, scope: ObservableScope) => { (scope: ObservableScope, transport: LivekitTransport) => {
const mockConnection = { const mockConnection = {
transport, transport,
remoteParticipants$: new BehaviorSubject([]), remoteParticipants$: new BehaviorSubject([]),
@@ -76,10 +76,12 @@ describe("connections$ stream", () => {
const { connectionManagerData$ } = createConnectionManager$({ const { connectionManagerData$ } = createConnectionManager$({
scope: testScope, scope: testScope,
connectionFactory: fakeConnectionFactory, connectionFactory: fakeConnectionFactory,
inputTransports$: behavior("a", { localTransport$: constant(null),
remoteTransports$: behavior("a", {
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0), a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
}), }),
logger: logger, logger: logger,
ownMembershipIdentity: ownMemberMock,
}); });
expectObservable( expectObservable(
@@ -115,7 +117,8 @@ describe("connections$ stream", () => {
const { connectionManagerData$ } = createConnectionManager$({ const { connectionManagerData$ } = createConnectionManager$({
scope: testScope, scope: testScope,
connectionFactory: fakeConnectionFactory, connectionFactory: fakeConnectionFactory,
inputTransports$: behavior("abcdef", { localTransport$: constant(null),
remoteTransports$: behavior("abcdef", {
a: new Epoch([TRANSPORT_1], 0), a: new Epoch([TRANSPORT_1], 0),
b: new Epoch([TRANSPORT_1], 1), b: new Epoch([TRANSPORT_1], 1),
c: new Epoch([TRANSPORT_1], 2), c: new Epoch([TRANSPORT_1], 2),
@@ -124,6 +127,7 @@ describe("connections$ stream", () => {
f: new Epoch([TRANSPORT_1, TRANSPORT_2], 5), f: new Epoch([TRANSPORT_1, TRANSPORT_2], 5),
}), }),
logger: logger, logger: logger,
ownMembershipIdentity: ownMemberMock,
}); });
expectObservable( expectObservable(
@@ -160,12 +164,14 @@ describe("connections$ stream", () => {
const { connectionManagerData$ } = createConnectionManager$({ const { connectionManagerData$ } = createConnectionManager$({
scope: testScope, scope: testScope,
connectionFactory: fakeConnectionFactory, connectionFactory: fakeConnectionFactory,
inputTransports$: behavior("abc", { localTransport$: constant(null),
remoteTransports$: behavior("abc", {
a: new Epoch([TRANSPORT_1], 0), a: new Epoch([TRANSPORT_1], 0),
b: new Epoch([TRANSPORT_1, TRANSPORT_2], 1), b: new Epoch([TRANSPORT_1, TRANSPORT_2], 1),
c: new Epoch([TRANSPORT_1], 2), c: new Epoch([TRANSPORT_1], 2),
}), }),
logger: logger, logger: logger,
ownMembershipIdentity: ownMemberMock,
}); });
expectObservable( expectObservable(
@@ -223,7 +229,7 @@ describe("connectionManagerData$ stream", () => {
vi.mocked(fakeConnectionFactory).createConnection = vi vi.mocked(fakeConnectionFactory).createConnection = vi
.fn() .fn()
.mockImplementation( .mockImplementation(
(transport: LivekitTransport, scope: ObservableScope) => { (scope: ObservableScope, transport: LivekitTransport) => {
const fakeRemoteParticipants$ = new BehaviorSubject< const fakeRemoteParticipants$ = new BehaviorSubject<
RemoteParticipant[] RemoteParticipant[]
>([]); >([]);
@@ -275,10 +281,12 @@ describe("connectionManagerData$ stream", () => {
const { connectionManagerData$ } = createConnectionManager$({ const { connectionManagerData$ } = createConnectionManager$({
scope: testScope, scope: testScope,
connectionFactory: fakeConnectionFactory, connectionFactory: fakeConnectionFactory,
inputTransports$: behavior("a", { localTransport$: constant(null),
remoteTransports$: behavior("a", {
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0), a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
}), }),
logger, logger,
ownMembershipIdentity: ownMemberMock,
}); });
expectObservable(connectionManagerData$).toBe("abcd", { expectObservable(connectionManagerData$).toBe("abcd", {

View File

@@ -7,9 +7,10 @@ Please see LICENSE in the repository root for full details.
*/ */
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { combineLatest, map, of, switchMap, tap } from "rxjs"; import { combineLatest, map, of, switchMap } from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger"; import { type Logger } from "matrix-js-sdk/lib/logger";
import { type RemoteParticipant } from "livekit-client"; import { type RemoteParticipant } from "livekit-client";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Behavior } from "../../Behavior.ts"; import { type Behavior } from "../../Behavior.ts";
import { type Connection } from "./Connection.ts"; import { type Connection } from "./Connection.ts";
@@ -17,6 +18,11 @@ import { Epoch, type ObservableScope } from "../../ObservableScope.ts";
import { generateItemsWithEpoch } from "../../../utils/observable.ts"; import { generateItemsWithEpoch } from "../../../utils/observable.ts";
import { areLivekitTransportsEqual } from "./MatrixLivekitMembers.ts"; import { areLivekitTransportsEqual } from "./MatrixLivekitMembers.ts";
import { type ConnectionFactory } from "./ConnectionFactory.ts"; import { type ConnectionFactory } from "./ConnectionFactory.ts";
import {
isLocalTransportWithSFUConfig,
type LocalTransportWithSFUConfig,
} from "../localMember/LocalTransport.ts";
import { type SFUConfig } from "../../../livekit/openIDSFU.ts";
export class ConnectionManagerData { export class ConnectionManagerData {
private readonly store: Map< private readonly store: Map<
@@ -65,8 +71,11 @@ export class ConnectionManagerData {
interface Props { interface Props {
scope: ObservableScope; scope: ObservableScope;
connectionFactory: ConnectionFactory; connectionFactory: ConnectionFactory;
inputTransports$: Behavior<Epoch<LivekitTransport[]>>; localTransport$: Behavior<LocalTransportWithSFUConfig | null>;
remoteTransports$: Behavior<Epoch<LivekitTransport[]>>;
logger: Logger; logger: Logger;
ownMembershipIdentity: CallMembershipIdentityParts;
} }
// TODO - write test for scopes (do we really need to bind scope) // TODO - write test for scopes (do we really need to bind scope)
@@ -79,8 +88,12 @@ export interface IConnectionManager {
* @param props - Configuration object * @param props - Configuration object
* @param props.scope - The observable scope used by this object * @param props.scope - The observable scope used by this object
* @param props.connectionFactory - Used to create new connections * @param props.connectionFactory - Used to create new connections
* @param props.inputTransports$ - A list of Behaviors each containing a LIST of LivekitTransport. * @param props.localTransport$ - The local transport to use. (deduplicated with remoteTransports$)
* @param props.logger - The logger to use * @param props.remoteTransports$ - All other transports. The connection manager will create connections for each transport. (deduplicated with localTransport$)
* @param props.ownMembershipIdentity - The own membership identity to use.
* @param props.logger - The logger to use.
*
* Each of these behaviors can be interpreted as subscribed list of transports. * Each of these behaviors can be interpreted as subscribed list of transports.
* *
* Using `registerTransports` independent external modules can control what connections * Using `registerTransports` independent external modules can control what connections
@@ -93,8 +106,10 @@ export interface IConnectionManager {
export function createConnectionManager$({ export function createConnectionManager$({
scope, scope,
connectionFactory, connectionFactory,
inputTransports$, localTransport$,
remoteTransports$,
logger: parentLogger, logger: parentLogger,
ownMembershipIdentity,
}: Props): IConnectionManager { }: Props): IConnectionManager {
const logger = parentLogger.getChild("[ConnectionManager]"); const logger = parentLogger.getChild("[ConnectionManager]");
// TODO logger: only construct one logger from the client and make it compatible via a EC specific sing // TODO logger: only construct one logger from the client and make it compatible via a EC specific sing
@@ -107,12 +122,33 @@ export function createConnectionManager$({
* It is build based on the list of subscribed transports (`transportsSubscriptions$`). * It is build based on the list of subscribed transports (`transportsSubscriptions$`).
* externally this is modified via `registerTransports()`. * externally this is modified via `registerTransports()`.
*/ */
const transports$ = scope.behavior( const localAndRemoteTransports$: Behavior<
inputTransports$.pipe( Epoch<(LivekitTransport | LocalTransportWithSFUConfig)[]>
map((transports) => transports.mapInner(removeDuplicateTransports)), > = scope.behavior(
tap(({ value: transports }) => { combineLatest([remoteTransports$, localTransport$]).pipe(
logger.trace( // Combine local and remote transports into one transport array
`Managing transports: ${transports.map((t) => t.livekit_service_url).join(", ")}`, // and set the forceOldJwtEndpoint property on the local transport
map(([remoteTransports, localTransport]) => {
let localTransportAsArray: LocalTransportWithSFUConfig[] = [];
if (localTransport) {
localTransportAsArray = [localTransport];
}
const dedupedRemote = removeDuplicateTransports(remoteTransports.value);
const remoteWithoutLocal = dedupedRemote.filter(
(transport) =>
!localTransportAsArray.find((l) =>
areLivekitTransportsEqual(l.transport, transport),
),
);
logger.debug(
"remoteWithoutLocal",
remoteWithoutLocal,
"localTransportAsArray",
localTransportAsArray,
);
return new Epoch(
[...localTransportAsArray, ...remoteWithoutLocal],
remoteTransports.epoch,
); );
}), }),
), ),
@@ -122,25 +158,51 @@ export function createConnectionManager$({
* Connections for each transport in use by one or more session members. * Connections for each transport in use by one or more session members.
*/ */
const connections$ = scope.behavior( const connections$ = scope.behavior(
transports$.pipe( localAndRemoteTransports$.pipe(
generateItemsWithEpoch( generateItemsWithEpoch(
function* (transports) { function* (transports) {
for (const transport of transports) for (const transportWithOrWithoutSfuConfig of transports) {
yield { if (
keys: [transport.livekit_service_url, transport.livekit_alias], isLocalTransportWithSFUConfig(transportWithOrWithoutSfuConfig)
data: undefined, ) {
}; // This is the local transport only the `LocalTransportWithSFUConfig` has a `sfuConfig` field
const { transport, sfuConfig } = transportWithOrWithoutSfuConfig;
yield {
keys: [
transport.livekit_service_url,
transport.livekit_alias,
sfuConfig,
],
data: undefined,
};
} else {
const transport = transportWithOrWithoutSfuConfig;
yield {
keys: [
transport.livekit_service_url,
transport.livekit_alias,
undefined as undefined | SFUConfig,
],
data: undefined,
};
}
}
}, },
(scope, _data$, serviceUrl, alias) => { (scope, _data$, serviceUrl, alias, sfuConfig) => {
logger.debug(`Creating connection to ${serviceUrl} (${alias})`); logger.debug(
`Creating connection to ${serviceUrl} (${alias}, withSfuConfig (local connection?): ${JSON.stringify(sfuConfig) ?? "no config->remote connection"})`,
);
const connection = connectionFactory.createConnection( const connection = connectionFactory.createConnection(
scope,
{ {
type: "livekit", type: "livekit",
livekit_service_url: serviceUrl, livekit_service_url: serviceUrl,
livekit_alias: alias, livekit_alias: alias,
}, },
scope, ownMembershipIdentity,
logger, logger,
sfuConfig,
); );
// Start the connection immediately // Start the connection immediately
// Use connection state to track connection progress // Use connection state to track connection progress
@@ -190,18 +252,18 @@ export function createConnectionManager$({
); );
}), }),
), ),
new Epoch(new ConnectionManagerData()), new Epoch(new ConnectionManagerData(), -1),
); );
return { connectionManagerData$ }; return { connectionManagerData$ };
} }
function removeDuplicateTransports( function removeDuplicateTransports<T extends LivekitTransport>(
transports: LivekitTransport[], transports: T[],
): LivekitTransport[] { ): T[] {
return transports.reduce((acc, transport) => { return transports.reduce((acc, transport) => {
if (!acc.some((t) => areLivekitTransportsEqual(t, transport))) if (!acc.some((t) => areLivekitTransportsEqual(t, transport)))
acc.push(transport); acc.push(transport);
return acc; return acc;
}, [] as LivekitTransport[]); }, [] as T[]);
} }

View File

@@ -15,7 +15,11 @@ import EventEmitter from "events";
import { ObservableScope } from "../../ObservableScope.ts"; import { ObservableScope } from "../../ObservableScope.ts";
import { ECConnectionFactory } from "./ConnectionFactory.ts"; import { ECConnectionFactory } from "./ConnectionFactory.ts";
import type { OpenIDClientParts } from "../../../livekit/openIDSFU.ts"; import type { OpenIDClientParts } from "../../../livekit/openIDSFU.ts";
import { exampleTransport, mockMediaDevices } from "../../../utils/test.ts"; import {
exampleTransport,
mockMediaDevices,
ownMemberMock,
} from "../../../utils/test.ts";
import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx"; import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx";
import { constant } from "../../Behavior"; import { constant } from "../../Behavior";
@@ -72,7 +76,12 @@ describe("ECConnectionFactory - Audio inputs options", () => {
echo, echo,
noise, noise,
); );
ecConnectionFactory.createConnection(exampleTransport, testScope, logger); ecConnectionFactory.createConnection(
testScope,
exampleTransport,
ownMemberMock,
logger,
);
// Check if Room was constructed with expected options // Check if Room was constructed with expected options
expect(RoomConstructor).toHaveBeenCalledWith( expect(RoomConstructor).toHaveBeenCalledWith(
@@ -113,7 +122,12 @@ describe("ECConnectionFactory - ControlledAudioDevice", () => {
false, false,
false, false,
); );
ecConnectionFactory.createConnection(exampleTransport, testScope, logger); ecConnectionFactory.createConnection(
testScope,
exampleTransport,
ownMemberMock,
logger,
);
// Check if Room was constructed with expected options // Check if Room was constructed with expected options
expect(RoomConstructor).toHaveBeenCalledWith( expect(RoomConstructor).toHaveBeenCalledWith(

View File

@@ -10,8 +10,7 @@ import {
type CallMembership, type CallMembership,
type LivekitTransport, type LivekitTransport,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { getParticipantId } from "matrix-js-sdk/lib/matrixrtc/utils"; import { BehaviorSubject, combineLatest, map, type Observable } from "rxjs";
import { combineLatest, map, type Observable } from "rxjs";
import { type IConnectionManager } from "./ConnectionManager.ts"; import { type IConnectionManager } from "./ConnectionManager.ts";
import { import {
@@ -26,14 +25,18 @@ import {
} from "../../ObservableScope.ts"; } from "../../ObservableScope.ts";
import { ConnectionManagerData } from "./ConnectionManager.ts"; import { ConnectionManagerData } from "./ConnectionManager.ts";
import { import {
mockCallMembership, flushPromises,
mockRtcMembership,
mockRemoteParticipant, mockRemoteParticipant,
withTestScheduler,
} from "../../../utils/test.ts"; } from "../../../utils/test.ts";
import { type Connection } from "./Connection.ts"; import { type Connection } from "./Connection.ts";
import { constant } from "../../Behavior.ts";
let testScope: ObservableScope; let testScope: ObservableScope;
const fallbackMemberId = (userId: string, deviceId: string): string =>
`${userId}:${deviceId}`;
const transportA: LivekitTransport = { const transportA: LivekitTransport = {
type: "livekit", type: "livekit",
livekit_service_url: "https://lk.example.org", livekit_service_url: "https://lk.example.org",
@@ -46,16 +49,12 @@ const transportB: LivekitTransport = {
livekit_alias: "!alias:sample.com", livekit_alias: "!alias:sample.com",
}; };
const bobMembership = mockCallMembership( const bobMembership = mockRtcMembership("@bob:example.org", "DEV000", {
"@bob:example.org", fociPreferred: [transportA],
"DEV000", });
transportA, const carlMembership = mockRtcMembership("@carl:sample.com", "DEV111", {
); fociPreferred: [transportB],
const carlMembership = mockCallMembership( });
"@carl:sample.com",
"DEV111",
transportB,
);
beforeEach(() => { beforeEach(() => {
testScope = new ObservableScope(); testScope = new ObservableScope();
@@ -76,52 +75,41 @@ function epochMeWith$<T, U>(
); );
} }
test("should signal participant not yet connected to livekit", () => { test("should signal participant not yet connected to livekit", async () => {
withTestScheduler(({ behavior, expectObservable }) => { const mockedMemberships$ = new BehaviorSubject([bobMembership]);
const { memberships$, membershipsWithTransport$ } = fromMemberships$( const mockConnectionManagerData$ = new BehaviorSubject(
behavior("a", { new ConnectionManagerData(),
a: [bobMembership], );
}), const { memberships$, membershipsWithTransport$ } =
); createEpochedMemberships$(mockedMemberships$);
const connectionManagerData$ = epochMeWith$( const connectionManagerData$ = epochMeWith$(
memberships$, memberships$,
behavior("a", { mockConnectionManagerData$,
a: new ConnectionManagerData(), );
}),
);
const matrixLivekitMembers$ = createMatrixLivekitMembers$({ const matrixLivekitMember$ = createMatrixLivekitMembers$({
scope: testScope, scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: { connectionManager: {
connectionManagerData$: connectionManagerData$, connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager, } as unknown as IConnectionManager,
});
expectObservable(matrixLivekitMembers$.pipe(map((e) => e.value))).toBe(
"a",
{
a: expect.toSatisfy((data: RemoteMatrixLivekitMember[]) => {
expect(data.length).toEqual(1);
expectObservable(data[0].membership$).toBe("a", {
a: bobMembership,
});
expectObservable(data[0].participant.value$).toBe("a", {
a: null,
});
expectObservable(data[0].connection$).toBe("a", {
a: null,
});
return true;
}),
},
);
}); });
await flushPromises();
expect(matrixLivekitMember$.value.value).toSatisfy(
(data: RemoteMatrixLivekitMember[]) => {
expect(data.length).toEqual(1);
expect(data[0].membership$.value).toBe(bobMembership);
expect(data[0].participant.value$.value).toBe(null);
expect(data[0].connection$.value).toBe(null);
return true;
},
);
}); });
// Helper to create epoch'ed memberships$ and membershipsWithTransport$ from memberships observable. // Helper to create epoch'ed memberships$ and membershipsWithTransport$ from memberships observable.
function fromMemberships$(m$: Observable<CallMembership[]>): { function createEpochedMemberships$(m$: Observable<CallMembership[]>): {
memberships$: Observable<Epoch<CallMembership[]>>; memberships$: Observable<Epoch<CallMembership[]>>;
membershipsWithTransport$: Observable< membershipsWithTransport$: Observable<
Epoch<{ membership: CallMembership; transport?: LivekitTransport }[]> Epoch<{ membership: CallMembership; transport?: LivekitTransport }[]>
@@ -146,32 +134,115 @@ function fromMemberships$(m$: Observable<CallMembership[]>): {
}; };
} }
test("should signal participant on a connection that is publishing", () => { test("should signal participant on a connection that is publishing", async () => {
withTestScheduler(({ behavior, expectObservable }) => { const bobParticipantId = fallbackMemberId(
const bobParticipantId = getParticipantId( bobMembership.userId,
bobMembership.deviceId,
);
const { memberships$, membershipsWithTransport$ } = createEpochedMemberships$(
constant([bobMembership]),
);
const connection = {
transport: bobMembership.getTransport(bobMembership),
} as unknown as Connection;
const dataWithPublisher = new ConnectionManagerData();
dataWithPublisher.add(connection, [
mockRemoteParticipant({ identity: bobParticipantId }),
]);
const connectionManagerData$ = epochMeWith$(
memberships$,
constant(dataWithPublisher),
);
const matrixLivekitMember$ = createMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: {
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
});
await flushPromises();
expect(matrixLivekitMember$.value.value).toSatisfy(
(data: RemoteMatrixLivekitMember[]) => {
expect(data.length).toEqual(1);
expect(data[0].membership$.value).toBe(bobMembership);
expect(data[0].participant.value$.value).toSatisfy((participant) => {
expect(participant).toBeDefined();
expect(participant!.identity).toEqual(bobParticipantId);
return true;
});
expect(data[0].connection$.value).toBe(connection);
return true;
},
);
});
test("should signal participant on a connection that is not publishing", async () => {
const { memberships$, membershipsWithTransport$ } = createEpochedMemberships$(
constant([bobMembership]),
);
const connection = {
transport: bobMembership.getTransport(bobMembership),
} as unknown as Connection;
const dataWithPublisher = new ConnectionManagerData();
dataWithPublisher.add(connection, []);
const connectionManagerData$ = epochMeWith$(
memberships$,
constant(dataWithPublisher),
);
const matrixLivekitMember$ = createMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: {
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
});
await flushPromises();
expect(matrixLivekitMember$.value.value).toSatisfy(
(data: RemoteMatrixLivekitMember[]) => {
expect(data.length).toEqual(1);
expect(data[0].membership$.value).toBe(bobMembership);
expect(data[0].participant.value$.value).toBe(null);
expect(data[0].connection$.value).toBe(connection);
return true;
},
);
});
describe("Publication edge case", () => {
test("bob is publishing in several connections", async () => {
const { memberships$, membershipsWithTransport$ } =
createEpochedMemberships$(constant([bobMembership, carlMembership]));
const connectionWithPublisher = new ConnectionManagerData();
const bobParticipantId = fallbackMemberId(
bobMembership.userId, bobMembership.userId,
bobMembership.deviceId, bobMembership.deviceId,
); );
const connectionA = {
const { memberships$, membershipsWithTransport$ } = fromMemberships$( transport: transportA,
behavior("a", {
a: [bobMembership],
}),
);
const connection = {
transport: bobMembership.getTransport(bobMembership),
} as unknown as Connection; } as unknown as Connection;
const dataWithPublisher = new ConnectionManagerData(); const connectionB = {
dataWithPublisher.add(connection, [ transport: transportB,
} as unknown as Connection;
connectionWithPublisher.add(connectionA, [
mockRemoteParticipant({ identity: bobParticipantId }),
]);
connectionWithPublisher.add(connectionB, [
mockRemoteParticipant({ identity: bobParticipantId }), mockRemoteParticipant({ identity: bobParticipantId }),
]); ]);
const connectionManagerData$ = epochMeWith$( const connectionManagerData$ = epochMeWith$(
memberships$, memberships$,
behavior("a", { constant(connectionWithPublisher),
a: dataWithPublisher,
}),
); );
const matrixLivekitMembers$ = createMatrixLivekitMembers$({ const matrixLivekitMembers$ = createMatrixLivekitMembers$({
@@ -181,213 +252,73 @@ test("should signal participant on a connection that is publishing", () => {
connectionManagerData$: connectionManagerData$, connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager, } as unknown as IConnectionManager,
}); });
await flushPromises();
expectObservable(matrixLivekitMembers$.pipe(map((e) => e.value))).toBe( expect(matrixLivekitMembers$.value.value).toSatisfy(
"a", (data: RemoteMatrixLivekitMember[]) => {
{ expect(data.length).toEqual(2);
a: expect.toSatisfy((data: RemoteMatrixLivekitMember[]) => { expect(data[0].membership$.value).toBe(bobMembership);
expect(data.length).toEqual(1); expect(data[0].connection$.value).toBe(connectionA);
expectObservable(data[0].membership$).toBe("a", { expect(data[0].participant.value$.value).toSatisfy((participant) => {
a: bobMembership, expect(participant).toBeDefined();
}); expect(participant!.identity).toEqual(bobParticipantId);
expectObservable(data[0].participant.value$).toBe("a", {
a: expect.toSatisfy((participant) => {
expect(participant).toBeDefined();
expect(participant!.identity).toEqual(bobParticipantId);
return true;
}),
});
expectObservable(data[0].connection$).toBe("a", {
a: connection,
});
return true; return true;
}), });
return true;
}, },
); );
}); });
}); });
test("should signal participant on a connection that is not publishing", () => { test("bob is publishing in the wrong connection", async () => {
withTestScheduler(({ behavior, expectObservable }) => { const mockedMemberships$ = new BehaviorSubject([
const { memberships$, membershipsWithTransport$ } = fromMemberships$( bobMembership,
behavior("a", { carlMembership,
a: [bobMembership], ]);
}),
);
const connection = { const { memberships$, membershipsWithTransport$ } =
transport: bobMembership.getTransport(bobMembership), createEpochedMemberships$(mockedMemberships$);
} as unknown as Connection;
const dataWithPublisher = new ConnectionManagerData();
dataWithPublisher.add(connection, []);
const connectionManagerData$ = epochMeWith$( const connectionWithPublisher = new ConnectionManagerData();
memberships$,
behavior("a", {
a: dataWithPublisher,
}),
);
const matrixLivekitMembers$ = createMatrixLivekitMembers$({ const bobParticipantId = fallbackMemberId(
scope: testScope, bobMembership.userId,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), bobMembership.deviceId,
connectionManager: { );
connectionManagerData$: connectionManagerData$, const connectionA = { transport: transportA } as unknown as Connection;
} as unknown as IConnectionManager, const connectionB = { transport: transportB } as unknown as Connection;
});
expectObservable(matrixLivekitMembers$.pipe(map((e) => e.value))).toBe( // Bob is not publishing on A
"a", connectionWithPublisher.add(connectionA, []);
{ // Bob is publishing on B but his membership says A
a: expect.toSatisfy((data: RemoteMatrixLivekitMember[]) => { connectionWithPublisher.add(connectionB, [
expect(data.length).toEqual(1); mockRemoteParticipant({ identity: bobParticipantId }),
expectObservable(data[0].membership$).toBe("a", { ]);
a: bobMembership,
}); const connectionsWithPublisher$ = new BehaviorSubject(
expectObservable(data[0].participant.value$).toBe("a", { connectionWithPublisher,
a: null, );
}); const connectionManagerData$ = epochMeWith$(
expectObservable(data[0].connection$).toBe("a", { memberships$,
a: connection, connectionsWithPublisher$,
}); );
return true;
}), const matrixLivekitMember$ = createMatrixLivekitMembers$({
}, scope: testScope,
); membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
}); connectionManager: {
}); connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
describe("Publication edge case", () => {
test("bob is publishing in several connections", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const { memberships$, membershipsWithTransport$ } = fromMemberships$(
behavior("a", {
a: [bobMembership, carlMembership],
}),
);
const connectionWithPublisher = new ConnectionManagerData();
const bobParticipantId = getParticipantId(
bobMembership.userId,
bobMembership.deviceId,
);
const connectionA = {
transport: transportA,
} as unknown as Connection;
const connectionB = {
transport: transportB,
} as unknown as Connection;
connectionWithPublisher.add(connectionA, [
mockRemoteParticipant({ identity: bobParticipantId }),
]);
connectionWithPublisher.add(connectionB, [
mockRemoteParticipant({ identity: bobParticipantId }),
]);
const connectionManagerData$ = epochMeWith$(
memberships$,
behavior("a", {
a: connectionWithPublisher,
}),
);
const matrixLivekitMembers$ = createMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$: testScope.behavior(
membershipsWithTransport$,
),
connectionManager: {
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
});
expectObservable(matrixLivekitMembers$.pipe(map((e) => e.value))).toBe(
"a",
{
a: expect.toSatisfy((data: RemoteMatrixLivekitMember[]) => {
expect(data.length).toEqual(2);
expectObservable(data[0].membership$).toBe("a", {
a: bobMembership,
});
expectObservable(data[0].connection$).toBe("a", {
// The real connection should be from transportA as per the membership
a: connectionA,
});
expectObservable(data[0].participant.value$).toBe("a", {
a: expect.toSatisfy((participant) => {
expect(participant).toBeDefined();
expect(participant!.identity).toEqual(bobParticipantId);
return true;
}),
});
return true;
}),
},
);
});
});
test("bob is publishing in the wrong connection", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const { memberships$, membershipsWithTransport$ } = fromMemberships$(
behavior("a", {
a: [bobMembership, carlMembership],
}),
);
const connectionWithPublisher = new ConnectionManagerData();
const bobParticipantId = getParticipantId(
bobMembership.userId,
bobMembership.deviceId,
);
const connectionA = { transport: transportA } as unknown as Connection;
const connectionB = { transport: transportB } as unknown as Connection;
// Bob is not publishing on A
connectionWithPublisher.add(connectionA, []);
// Bob is publishing on B but his membership says A
connectionWithPublisher.add(connectionB, [
mockRemoteParticipant({ identity: bobParticipantId }),
]);
const connectionManagerData$ = epochMeWith$(
memberships$,
behavior("a", {
a: connectionWithPublisher,
}),
);
const matrixLivekitMembers$ = createMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$: testScope.behavior(
membershipsWithTransport$,
),
connectionManager: {
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
});
expectObservable(matrixLivekitMembers$.pipe(map((e) => e.value))).toBe(
"a",
{
a: expect.toSatisfy((data: RemoteMatrixLivekitMember[]) => {
expect(data.length).toEqual(2);
expectObservable(data[0].membership$).toBe("a", {
a: bobMembership,
});
expectObservable(data[0].connection$).toBe("a", {
// The real connection should be from transportA as per the membership
a: connectionA,
});
expectObservable(data[0].participant.value$).toBe("a", {
// No participant as Bob is not publishing on his membership transport
a: null,
});
return true;
}),
},
);
});
}); });
await flushPromises();
expect(matrixLivekitMember$.value.value).toSatisfy(
(data: RemoteMatrixLivekitMember[]) => {
expect(data.length).toEqual(2);
expect(data[0].membership$.value).toBe(bobMembership);
expect(data[0].connection$.value).toBe(connectionA);
expect(data[0].participant.value$.value).toBe(null);
return true;
},
);
}); });

View File

@@ -84,7 +84,6 @@ export function createMatrixLivekitMembers$({
/** /**
* Stream of all the call members and their associated livekit data (if available). * Stream of all the call members and their associated livekit data (if available).
*/ */
return scope.behavior( return scope.behavior(
combineLatest([ combineLatest([
membershipsWithTransport$, membershipsWithTransport$,
@@ -93,47 +92,39 @@ export function createMatrixLivekitMembers$({
filter((values) => filter((values) =>
values.every((value) => value.epoch === values[0].epoch), values.every((value) => value.epoch === values[0].epoch),
), ),
map( map(([ms, data]) => new Epoch([ms.value, data.value] as const, ms.epoch)),
([
{ value: membershipsWithTransports, epoch },
{ value: managerData },
]) =>
new Epoch([membershipsWithTransports, managerData] as const, epoch),
),
generateItemsWithEpoch( generateItemsWithEpoch(
// Generator function. // Generator function.
// creates an array of `{key, data}[]` // creates an array of `{key, data}[]`
// Each change in the keys (new key, missing key) will result in a call to the factory function. // Each change in the keys (new key, missing key) will result in a call to the factory function.
function* ([membershipsWithTransports, managerData]) { function* ([membershipsWithTransport, managerData]) {
for (const { membership, transport } of membershipsWithTransports) { for (const { membership, transport } of membershipsWithTransport) {
// TODO! cannot use membership.membershipID yet, Currently its hardcoded by the jwt service to
const participantId = /*membership.membershipID*/ `${membership.userId}:${membership.deviceId}`;
const participants = transport const participants = transport
? managerData.getParticipantsForTransport(transport) ? managerData.getParticipantsForTransport(transport)
: []; : [];
const participant = const participant =
participants.find((p) => p.identity == participantId) ?? null; participants.find(
(p) => p.identity == membership.rtcBackendIdentity,
) ?? null;
const connection = transport const connection = transport
? managerData.getConnectionForTransport(transport) ? managerData.getConnectionForTransport(transport)
: null; : null;
yield { yield {
keys: [participantId, membership.userId], keys: [membership.userId, membership.deviceId],
data: { membership, participant, connection }, data: { membership, participant, connection },
}; };
} }
}, },
// Each update where the key of the generator array do not change will result in updates to the `data$` observable in the factory. // Each update where the key of the generator array do not change will result in updates to the `data$` observable in the factory.
(scope, data$, participantId, userId) => { (scope, data$, userId, deviceId) => {
logger.debug( logger.debug(
`Generating member for participantId: ${participantId}, userId: ${userId}`, `Generating member for livekitIdentity: ${data$.value.membership.rtcBackendIdentity}, userId:deviceId: ${userId}${deviceId}`,
); );
const { participant$, ...rest } = scope.splitBehavior(data$); const { participant$, ...rest } = scope.splitBehavior(data$);
// will only get called once per `participantId, userId` pair. // will only get called once per `participantId, userId` pair.
// updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent. // updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent.
return { return {
participantId,
userId, userId,
participant: { type: "remote" as const, value$: participant$ }, participant: { type: "remote" as const, value$: participant$ },
...rest, ...rest,
@@ -141,15 +132,16 @@ export function createMatrixLivekitMembers$({
}, },
), ),
), ),
new Epoch([], -1),
); );
} }
// TODO add back in the callviewmodel pauseWhen(this.pretendToBeDisconnected$) // TODO add back in the callviewmodel pauseWhen(this.pretendToBeDisconnected$)
// TODO add this to the JS-SDK // TODO add this to the JS-SDK
export function areLivekitTransportsEqual( export function areLivekitTransportsEqual<T extends LivekitTransport>(
t1: LivekitTransport | null, t1: T | null,
t2: LivekitTransport | null, t2: T | null,
): boolean { ): boolean {
if (t1 && t2) return t1.livekit_service_url === t2.livekit_service_url; if (t1 && t2) return t1.livekit_service_url === t2.livekit_service_url;
// In case we have different lk rooms in the same SFU (depends on the livekit authorization service) // In case we have different lk rooms in the same SFU (depends on the livekit authorization service)

View File

@@ -18,7 +18,7 @@ import { it } from "vitest";
import { ObservableScope } from "../../ObservableScope.ts"; import { ObservableScope } from "../../ObservableScope.ts";
import type { Room as MatrixRoom } from "matrix-js-sdk/lib/models/room"; import type { Room as MatrixRoom } from "matrix-js-sdk/lib/models/room";
import { import {
mockCallMembership, mockRtcMembership,
mockMatrixRoomMember, mockMatrixRoomMember,
withTestScheduler, withTestScheduler,
} from "../../../utils/test.ts"; } from "../../../utils/test.ts";
@@ -111,7 +111,7 @@ describe("MatrixMemberMetadata", () => {
rawDisplayName: "it's a me", rawDisplayName: "it's a me",
}); });
const memberships$ = behavior("a", { const memberships$ = behavior("a", {
a: [mockCallMembership("@local:example.com", "DEVICE1")], a: [mockRtcMembership("@local:example.com", "DEVICE1")],
}); });
const metadataStore = createMatrixMemberMetadata$( const metadataStore = createMatrixMemberMetadata$(
testScope, testScope,
@@ -149,8 +149,8 @@ describe("MatrixMemberMetadata", () => {
withTestScheduler(({ behavior, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const memberships$ = behavior("a", { const memberships$ = behavior("a", {
a: [ a: [
mockCallMembership("@alice:example.com", "DEVICE1"), mockRtcMembership("@alice:example.com", "DEVICE1"),
mockCallMembership("@bob:example.com", "DEVICE1"), mockRtcMembership("@bob:example.com", "DEVICE1"),
], ],
}); });
const metadataStore = createMatrixMemberMetadata$( const metadataStore = createMatrixMemberMetadata$(
@@ -179,7 +179,7 @@ describe("MatrixMemberMetadata", () => {
setUpBasicRoom(); setUpBasicRoom();
const memberships$ = behavior("a", { const memberships$ = behavior("a", {
a: [mockCallMembership("@no-name:foo.bar", "D000")], a: [mockRtcMembership("@no-name:foo.bar", "D000")],
}); });
const metadataStore = createMatrixMemberMetadata$( const metadataStore = createMatrixMemberMetadata$(
testScope, testScope,
@@ -201,11 +201,11 @@ describe("MatrixMemberMetadata", () => {
const memberships$ = behavior("a", { const memberships$ = behavior("a", {
a: [ a: [
mockCallMembership("@bob:example.com", "DEVICE1"), mockRtcMembership("@bob:example.com", "DEVICE1"),
mockCallMembership("@bob:example.com", "DEVICE2"), mockRtcMembership("@bob:example.com", "DEVICE2"),
mockCallMembership("@bob:foo.bar", "BOB000"), mockRtcMembership("@bob:foo.bar", "BOB000"),
mockCallMembership("@carl:example.com", "C000"), mockRtcMembership("@carl:example.com", "C000"),
mockCallMembership("@evil:example.com", "E000"), mockRtcMembership("@evil:example.com", "E000"),
], ],
}); });
@@ -233,10 +233,10 @@ describe("MatrixMemberMetadata", () => {
setUpBasicRoom(); setUpBasicRoom();
const memberships$ = behavior("ab", { const memberships$ = behavior("ab", {
a: [mockCallMembership("@bob:example.com", "DEVICE1")], a: [mockRtcMembership("@bob:example.com", "DEVICE1")],
b: [ b: [
mockCallMembership("@bob:example.com", "DEVICE1"), mockRtcMembership("@bob:example.com", "DEVICE1"),
mockCallMembership("@bob:foo.bar", "BOB000"), mockRtcMembership("@bob:foo.bar", "BOB000"),
], ],
}); });
@@ -262,10 +262,10 @@ describe("MatrixMemberMetadata", () => {
const memberships$ = behavior("ab", { const memberships$ = behavior("ab", {
a: [ a: [
mockCallMembership("@bob:example.com", "DEVICE1"), mockRtcMembership("@bob:example.com", "DEVICE1"),
mockCallMembership("@bob:foo.bar", "BOB000"), mockRtcMembership("@bob:foo.bar", "BOB000"),
], ],
b: [mockCallMembership("@bob:example.com", "DEVICE1")], b: [mockRtcMembership("@bob:example.com", "DEVICE1")],
}); });
const metadataStore = createMatrixMemberMetadata$( const metadataStore = createMatrixMemberMetadata$(
@@ -292,8 +292,8 @@ describe("MatrixMemberMetadata", () => {
const memberships$ = behavior("a", { const memberships$ = behavior("a", {
a: [ a: [
mockCallMembership("@bob:example.com", "B000"), mockRtcMembership("@bob:example.com", "B000"),
mockCallMembership("@carl:example.com", "C000"), mockRtcMembership("@carl:example.com", "C000"),
], ],
}); });
const metadataStore = createMatrixMemberMetadata$( const metadataStore = createMatrixMemberMetadata$(
@@ -331,16 +331,16 @@ describe("MatrixMemberMetadata", () => {
// - room join/leave // - room join/leave
// - disambiguate // - disambiguate
const memberships$ = behavior("ab-d", { const memberships$ = behavior("ab-d", {
a: [mockCallMembership(CARL, "C000")], a: [mockRtcMembership(CARL, "C000")],
b: [ b: [
mockCallMembership(CARL, "C000"), mockRtcMembership(CARL, "C000"),
// bob joins // bob joins
mockCallMembership(BOB, "B000"), mockRtcMembership(BOB, "B000"),
], ],
// c carl gets renamed to BOB // c carl gets renamed to BOB
d: [ d: [
// carl leaves // carl leaves
mockCallMembership(BOB, "B000"), mockRtcMembership(BOB, "B000"),
], ],
}); });
schedule("--a-", { schedule("--a-", {
@@ -379,8 +379,8 @@ describe("MatrixMemberMetadata", () => {
it("should disambiguate users with invisible characters", () => { it("should disambiguate users with invisible characters", () => {
withTestScheduler(({ behavior, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const bobRtcMember = mockCallMembership("@bob:example.org", "BBBB"); const bobRtcMember = mockRtcMembership("@bob:example.org", "BBBB");
const bobZeroWidthSpaceRtcMember = mockCallMembership( const bobZeroWidthSpaceRtcMember = mockRtcMembership(
"@bob2:example.org", "@bob2:example.org",
"BBBB", "BBBB",
); );
@@ -397,9 +397,9 @@ describe("MatrixMemberMetadata", () => {
fakeMemberWith(bobZeroWidthSpace); fakeMemberWith(bobZeroWidthSpace);
fakeMemberWith({ userId: "@carol:example.org" }); fakeMemberWith({ userId: "@carol:example.org" });
const memberships$ = behavior("ab", { const memberships$ = behavior("ab", {
a: [mockCallMembership("@carol:example.org", "1111"), bobRtcMember], a: [mockRtcMembership("@carol:example.org", "1111"), bobRtcMember],
b: [ b: [
mockCallMembership("@carol:example.org", "1111"), mockRtcMembership("@carol:example.org", "1111"),
bobRtcMember, bobRtcMember,
bobZeroWidthSpaceRtcMember, bobZeroWidthSpaceRtcMember,
], ],
@@ -450,8 +450,8 @@ describe("MatrixMemberMetadata", () => {
it("should strip RTL characters from displayname", () => { it("should strip RTL characters from displayname", () => {
withTestScheduler(({ behavior, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const daveRtcMember = mockCallMembership("@dave:example.org", "DDDD"); const daveRtcMember = mockRtcMembership("@dave:example.org", "DDDD");
const daveRTLRtcMember = mockCallMembership( const daveRTLRtcMember = mockRtcMembership(
"@dave2:example.org", "@dave2:example.org",
"DDDD", "DDDD",
); );
@@ -466,9 +466,9 @@ describe("MatrixMemberMetadata", () => {
fakeMemberWith(daveRTL); fakeMemberWith(daveRTL);
fakeMemberWith(dave); fakeMemberWith(dave);
const memberships$ = behavior("ab", { const memberships$ = behavior("ab", {
a: [mockCallMembership("@carol:example.org", "DDDD")], a: [mockRtcMembership("@carol:example.org", "DDDD")],
b: [ b: [
mockCallMembership("@carol:example.org", "DDDD"), mockRtcMembership("@carol:example.org", "DDDD"),
daveRtcMember, daveRtcMember,
daveRTLRtcMember, daveRTLRtcMember,
], ],
@@ -527,8 +527,8 @@ describe("MatrixMemberMetadata", () => {
}); });
const memberships$ = behavior("a", { const memberships$ = behavior("a", {
a: [ a: [
mockCallMembership("@local:example.com", "DEVICE1"), mockRtcMembership("@local:example.com", "DEVICE1"),
mockCallMembership("@alice:example.com", "DEVICE1"), mockRtcMembership("@alice:example.com", "DEVICE1"),
], ],
}); });
const metadataStore = createMatrixMemberMetadata$( const metadataStore = createMatrixMemberMetadata$(
@@ -562,12 +562,12 @@ describe("MatrixMemberMetadata", () => {
fakeMemberWith({ userId: "@carl:example.com" }); fakeMemberWith({ userId: "@carl:example.com" });
fakeMemberWith({ userId: "@bob:example.com" }); fakeMemberWith({ userId: "@bob:example.com" });
const memberships$ = behavior("ab-d", { const memberships$ = behavior("ab-d", {
a: [mockCallMembership("@bob:example.com", "B000")], a: [mockRtcMembership("@bob:example.com", "B000")],
b: [ b: [
mockCallMembership("@bob:example.com", "B000"), mockRtcMembership("@bob:example.com", "B000"),
mockCallMembership("@carl:example.com", "C000"), mockRtcMembership("@carl:example.com", "C000"),
], ],
d: [mockCallMembership("@carl:example.com", "C000")], d: [mockRtcMembership("@carl:example.com", "C000")],
}); });
const metadataStore = createMatrixMemberMetadata$( const metadataStore = createMatrixMemberMetadata$(

View File

@@ -21,8 +21,9 @@ import {
import { ECConnectionFactory } from "./ConnectionFactory.ts"; import { ECConnectionFactory } from "./ConnectionFactory.ts";
import { type OpenIDClientParts } from "../../../livekit/openIDSFU.ts"; import { type OpenIDClientParts } from "../../../livekit/openIDSFU.ts";
import { import {
mockCallMembership,
mockMediaDevices, mockMediaDevices,
mockRtcMembership,
ownMemberMock,
withTestScheduler, withTestScheduler,
} from "../../../utils/test.ts"; } from "../../../utils/test.ts";
import { type ProcessorState } from "../../../livekit/TrackProcessorContext.tsx"; import { type ProcessorState } from "../../../livekit/TrackProcessorContext.tsx";
@@ -33,6 +34,7 @@ import {
} from "./MatrixLivekitMembers.ts"; } from "./MatrixLivekitMembers.ts";
import { createConnectionManager$ } from "./ConnectionManager.ts"; import { createConnectionManager$ } from "./ConnectionManager.ts";
import { membershipsAndTransports$ } from "../../SessionBehaviors.ts"; import { membershipsAndTransports$ } from "../../SessionBehaviors.ts";
import { constant } from "../../Behavior.ts";
import { testJWTToken } from "../../../utils/test-fixtures.ts"; import { testJWTToken } from "../../../utils/test-fixtures.ts";
// Test the integration of ConnectionManager and MatrixLivekitMerger // Test the integration of ConnectionManager and MatrixLivekitMerger
@@ -99,9 +101,9 @@ afterEach(() => {
test("bob, carl, then bob joining no tracks yet", () => { test("bob, carl, then bob joining no tracks yet", () => {
withTestScheduler(({ expectObservable, behavior, scope }) => { withTestScheduler(({ expectObservable, behavior, scope }) => {
const bobMembership = mockCallMembership("@bob:example.com", "BDEV000"); const bobMembership = mockRtcMembership("@bob:example.com", "BDEV000");
const carlMembership = mockCallMembership("@carl:example.com", "CDEV000"); const carlMembership = mockRtcMembership("@carl:example.com", "CDEV000");
const daveMembership = mockCallMembership("@dave:foo.bar", "DDEV000"); const daveMembership = mockRtcMembership("@dave:foo.bar", "DDEV000");
const eMarble = "abc"; const eMarble = "abc";
const vMarble = "abc"; const vMarble = "abc";
@@ -121,8 +123,10 @@ test("bob, carl, then bob joining no tracks yet", () => {
const connectionManager = createConnectionManager$({ const connectionManager = createConnectionManager$({
scope: testScope, scope: testScope,
connectionFactory: ecConnectionFactory, connectionFactory: ecConnectionFactory,
inputTransports$: membershipsAndTransports.transports$, localTransport$: constant(null),
remoteTransports$: membershipsAndTransports.transports$,
logger: logger, logger: logger,
ownMembershipIdentity: ownMemberMock,
}); });
const matrixLivekitMembers$ = createMatrixLivekitMembers$({ const matrixLivekitMembers$ = createMatrixLivekitMembers$({

View File

@@ -37,7 +37,7 @@ vi.mock("../widget", () => ({
it.each([ it.each([
[MatrixRTCMode.Legacy], [MatrixRTCMode.Legacy],
[MatrixRTCMode.Compatibil], [MatrixRTCMode.Compatibility],
[MatrixRTCMode.Matrix_2_0], [MatrixRTCMode.Matrix_2_0],
])( ])(
"expect leave when ElementWidgetActions.HangupCall is called (%s mode)", "expect leave when ElementWidgetActions.HangupCall is called (%s mode)",

View File

@@ -257,6 +257,7 @@ abstract class BaseMediaViewModel {
* The Matrix user to which this media belongs. * The Matrix user to which this media belongs.
*/ */
public readonly userId: string, public readonly userId: string,
public readonly rtcBackendIdentity: string,
// We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through // We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through
// livekit. // livekit.
protected readonly participant$: Observable< protected readonly participant$: Observable<
@@ -406,6 +407,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
scope: ObservableScope, scope: ObservableScope,
id: string, id: string,
userId: string, userId: string,
rtcBackendIdentity: string,
participant$: Observable<LocalParticipant | RemoteParticipant | null>, participant$: Observable<LocalParticipant | RemoteParticipant | null>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom$: Behavior<LivekitRoom | undefined>, livekitRoom$: Behavior<LivekitRoom | undefined>,
@@ -419,6 +421,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
scope, scope,
id, id,
userId, userId,
rtcBackendIdentity,
participant$, participant$,
encryptionSystem, encryptionSystem,
Track.Source.Microphone, Track.Source.Microphone,
@@ -544,6 +547,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
scope: ObservableScope, scope: ObservableScope,
id: string, id: string,
userId: string, userId: string,
rtcBackendIdentity: string,
participant$: Behavior<LocalParticipant | null>, participant$: Behavior<LocalParticipant | null>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom$: Behavior<LivekitRoom | undefined>, livekitRoom$: Behavior<LivekitRoom | undefined>,
@@ -558,6 +562,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
scope, scope,
id, id,
userId, userId,
rtcBackendIdentity,
participant$, participant$,
encryptionSystem, encryptionSystem,
livekitRoom$, livekitRoom$,
@@ -671,6 +676,7 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
scope: ObservableScope, scope: ObservableScope,
id: string, id: string,
userId: string, userId: string,
rtcBackendIdentity: string,
participant$: Observable<RemoteParticipant | null>, participant$: Observable<RemoteParticipant | null>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom$: Behavior<LivekitRoom | undefined>, livekitRoom$: Behavior<LivekitRoom | undefined>,
@@ -685,6 +691,7 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
scope, scope,
id, id,
userId, userId,
rtcBackendIdentity,
participant$, participant$,
encryptionSystem, encryptionSystem,
livekitRoom$, livekitRoom$,
@@ -772,6 +779,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
scope: ObservableScope, scope: ObservableScope,
id: string, id: string,
userId: string, userId: string,
rtcBackendIdentity: string,
participant$: Observable<LocalParticipant | RemoteParticipant>, participant$: Observable<LocalParticipant | RemoteParticipant>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom$: Behavior<LivekitRoom | undefined>, livekitRoom$: Behavior<LivekitRoom | undefined>,
@@ -785,6 +793,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
scope, scope,
id, id,
userId, userId,
rtcBackendIdentity,
participant$, participant$,
encryptionSystem, encryptionSystem,
Track.Source.ScreenShareAudio, Track.Source.ScreenShareAudio,

View File

@@ -28,6 +28,7 @@ export class ScreenShare {
private readonly scope: ObservableScope, private readonly scope: ObservableScope,
id: string, id: string,
userId: string, userId: string,
rtcBackendIdentity: string,
participant: LocalParticipant | RemoteParticipant, participant: LocalParticipant | RemoteParticipant,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom$: Behavior<LivekitRoom | undefined>, livekitRoom$: Behavior<LivekitRoom | undefined>,
@@ -40,6 +41,7 @@ export class ScreenShare {
this.scope, this.scope,
id, id,
userId, userId,
rtcBackendIdentity,
of(participant), of(participant),
encryptionSystem, encryptionSystem,
livekitRoom$, livekitRoom$,

View File

@@ -75,6 +75,7 @@ export class UserMedia {
this.scope, this.scope,
this.id, this.id,
this.userId, this.userId,
this.rtcBackendIdentity,
this.participant.value$, this.participant.value$,
this.encryptionSystem, this.encryptionSystem,
this.livekitRoom$, this.livekitRoom$,
@@ -89,6 +90,7 @@ export class UserMedia {
this.scope, this.scope,
this.id, this.id,
this.userId, this.userId,
this.rtcBackendIdentity,
this.participant.value$, this.participant.value$,
this.encryptionSystem, this.encryptionSystem,
this.livekitRoom$, this.livekitRoom$,
@@ -140,6 +142,7 @@ export class UserMedia {
scope, scope,
`${this.id}:${key}`, `${this.id}:${key}`,
this.userId, this.userId,
this.rtcBackendIdentity,
p, p,
this.encryptionSystem, this.encryptionSystem,
this.livekitRoom$, this.livekitRoom$,
@@ -191,6 +194,7 @@ export class UserMedia {
private readonly scope: ObservableScope, private readonly scope: ObservableScope,
public readonly id: string, public readonly id: string,
private readonly userId: string, private readonly userId: string,
private readonly rtcBackendIdentity: string,
private readonly participant: TaggedParticipant, private readonly participant: TaggedParticipant,
private readonly encryptionSystem: EncryptionSystem, private readonly encryptionSystem: EncryptionSystem,
private readonly livekitRoom$: Behavior<LivekitRoom | undefined>, private readonly livekitRoom$: Behavior<LivekitRoom | undefined>,

View File

@@ -113,6 +113,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
}, },
[vm], [vm],
); );
const rtcBackendIdentity = vm.rtcBackendIdentity;
const handRaised = useBehavior(vm.handRaised$); const handRaised = useBehavior(vm.handRaised$);
const reaction = useBehavior(vm.reaction$); const reaction = useBehavior(vm.reaction$);
@@ -200,6 +201,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
focusUrl={focusUrl} focusUrl={focusUrl}
audioStreamStats={audioStreamStats} audioStreamStats={audioStreamStats}
videoStreamStats={videoStreamStats} videoStreamStats={videoStreamStats}
rtcBackendIdentity={rtcBackendIdentity}
{...props} {...props}
/> />
); );

View File

@@ -18,7 +18,11 @@ import styles from "./MediaView.module.css";
import { Avatar } from "../Avatar"; import { Avatar } from "../Avatar";
import { type EncryptionStatus } from "../state/MediaViewModel"; import { type EncryptionStatus } from "../state/MediaViewModel";
import { RaisedHandIndicator } from "../reactions/RaisedHandIndicator"; import { RaisedHandIndicator } from "../reactions/RaisedHandIndicator";
import { showHandRaisedTimer, useSetting } from "../settings/settings"; import {
showConnectionStats,
showHandRaisedTimer,
useSetting,
} from "../settings/settings";
import { type ReactionOption } from "../reactions"; import { type ReactionOption } from "../reactions";
import { ReactionIndicator } from "../reactions/ReactionIndicator"; import { ReactionIndicator } from "../reactions/ReactionIndicator";
import { RTCConnectionStats } from "../RTCConnectionStats"; import { RTCConnectionStats } from "../RTCConnectionStats";
@@ -46,6 +50,7 @@ interface Props extends ComponentProps<typeof animated.div> {
waitingForMedia?: boolean; waitingForMedia?: boolean;
audioStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats; audioStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats; videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
rtcBackendIdentity?: string;
// The focus url, mainly for debugging purposes // The focus url, mainly for debugging purposes
focusUrl?: string; focusUrl?: string;
} }
@@ -74,11 +79,13 @@ export const MediaView: FC<Props> = ({
waitingForMedia, waitingForMedia,
audioStreamStats, audioStreamStats,
videoStreamStats, videoStreamStats,
rtcBackendIdentity,
focusUrl, focusUrl,
...props ...props
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [handRaiseTimerVisible] = useSetting(showHandRaisedTimer); const [handRaiseTimerVisible] = useSetting(showHandRaisedTimer);
const [showConnectioStats] = useSetting(showConnectionStats);
const avatarSize = Math.round(Math.min(targetWidth, targetHeight) / 2); const avatarSize = Math.round(Math.min(targetWidth, targetHeight) / 2);
@@ -132,14 +139,18 @@ export const MediaView: FC<Props> = ({
{waitingForMedia && ( {waitingForMedia && (
<div className={styles.status}> <div className={styles.status}>
{t("video_tile.waiting_for_media")} {t("video_tile.waiting_for_media")}
{showConnectioStats ? " " + rtcBackendIdentity : ""}
</div> </div>
)} )}
{(audioStreamStats || videoStreamStats) && ( {(audioStreamStats || videoStreamStats) && (
<RTCConnectionStats <>
audio={audioStreamStats} <RTCConnectionStats
video={videoStreamStats} audio={audioStreamStats}
focusUrl={focusUrl} video={videoStreamStats}
/> focusUrl={focusUrl}
rtcBackendIdentity={rtcBackendIdentity}
/>
</>
)} )}
{/* TODO: Bring this back once encryption status is less broken */} {/* TODO: Bring this back once encryption status is less broken */}
{/*encryptionStatus !== EncryptionStatus.Okay && ( {/*encryptionStatus !== EncryptionStatus.Okay && (

View File

@@ -19,6 +19,7 @@ export enum ErrorCode {
INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR", INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR",
E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED", E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED",
OPEN_ID_ERROR = "OPEN_ID_ERROR", OPEN_ID_ERROR = "OPEN_ID_ERROR",
NO_MATRIX_2_AUTHORIZATION_SERVICE = "NO_MATRIX_2_0_AUTHORIZATION_SERVICE",
SFU_ERROR = "SFU_ERROR", SFU_ERROR = "SFU_ERROR",
UNKNOWN_ERROR = "UNKNOWN_ERROR", UNKNOWN_ERROR = "UNKNOWN_ERROR",
} }
@@ -171,6 +172,23 @@ export class FailToGetOpenIdToken extends ElementCallError {
} }
} }
export class NoMatrix2AuthorizationService extends ElementCallError {
/**
* Creates an instance of NoMatrix2_0AuthorizationService.
* @param error - The underlying error that caused the failure.
*/
public constructor(error: Error) {
super(
t("error.generic"),
ErrorCode.NO_MATRIX_2_AUTHORIZATION_SERVICE,
ErrorCategory.CONFIGURATION_ISSUE,
t("error.no_matrix_2_authorization_service"),
// Properly set it as a cause for a better reporting on sentry
error,
);
}
}
/** /**
* Error indicating a failure to start publishing on a LiveKit connection. * Error indicating a failure to start publishing on a LiveKit connection.
*/ */

View File

@@ -17,14 +17,16 @@ export const localRtcMemberDevice2 = mockRtcMembership(
"2222", "2222",
); );
export const local = mockMatrixRoomMember(localRtcMember); export const local = mockMatrixRoomMember(localRtcMember);
// export const localParticipant = mockLocalParticipant({ identity: "" });
export const localId = `${local.userId}:${localRtcMember.deviceId}`; export const localId = `${local.userId}:${localRtcMember.deviceId}`;
export const aliceRtcMember = mockRtcMembership("@alice:example.org", "AAAA"); export const aliceDeviceId = "AAAA";
export const aliceUserId = "@alice:example.org";
export const aliceId = `${aliceUserId}:${aliceDeviceId}`;
export const aliceRtcMember = mockRtcMembership(aliceUserId, aliceDeviceId);
export const alice = mockMatrixRoomMember(aliceRtcMember, { export const alice = mockMatrixRoomMember(aliceRtcMember, {
rawDisplayName: "Alice", rawDisplayName: "Alice",
}); });
export const aliceId = `${alice.userId}:${aliceRtcMember.deviceId}`;
export const aliceParticipant = mockRemoteParticipant({ identity: aliceId }); export const aliceParticipant = mockRemoteParticipant({ identity: aliceId });
export const aliceDoppelgangerRtcMember = mockRtcMembership( export const aliceDoppelgangerRtcMember = mockRtcMembership(
@@ -38,11 +40,13 @@ export const aliceDoppelganger = mockMatrixRoomMember(
}, },
); );
export const bobRtcMember = mockRtcMembership("@bob:example.org", "BBBB"); export const bobDeviceId = "BBBB";
export const bobUserId = "@bob:example.org";
export const bobId = `${bobUserId}:${bobDeviceId}`;
export const bobRtcMember = mockRtcMembership(bobUserId, bobDeviceId);
export const bob = mockMatrixRoomMember(bobRtcMember, { export const bob = mockMatrixRoomMember(bobRtcMember, {
rawDisplayName: "Bob", rawDisplayName: "Bob",
}); });
export const bobId = `${bob.userId}:${bobRtcMember.deviceId}`;
export const bobZeroWidthSpaceRtcMember = mockRtcMembership( export const bobZeroWidthSpaceRtcMember = mockRtcMembership(
"@bob2:example.org", "@bob2:example.org",

View File

@@ -50,6 +50,7 @@ import {
type KeyTransportEvents, type KeyTransportEvents,
type KeyTransportEventsHandlerMap, type KeyTransportEventsHandlerMap,
} from "matrix-js-sdk/lib/matrixrtc/IKeyTransport"; } from "matrix-js-sdk/lib/matrixrtc/IKeyTransport";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { import {
LocalUserMediaViewModel, LocalUserMediaViewModel,
@@ -201,40 +202,30 @@ export const exampleTransport: LivekitTransport = {
livekit_alias: "!alias:example.org", livekit_alias: "!alias:example.org",
}; };
export function mockCallMembership(
userId: string,
deviceId: string,
transport?: Transport,
): CallMembership {
const t = transport ?? transportForUser(userId);
return {
userId: userId,
deviceId: deviceId,
getTransport: vi.fn().mockReturnValue(t),
transports: [t],
} as unknown as CallMembership;
}
function transportForUser(userId: string): Transport {
const domain = userId.split(":")[1];
return {
type: "livekit",
livekit_service_url: `https://lk.${domain}`,
livekit_alias: `!alias:${domain}`,
};
}
export function mockRtcMembership( export function mockRtcMembership(
user: string | RoomMember, user: string | RoomMember,
deviceId: string, deviceId: string,
callId = "", customOverwrites?: {
fociPreferred: Transport[] = [exampleTransport], rtcBackendIdentity?: string;
focusActive: LivekitFocusSelection = { callId?: string;
type: "livekit", fociPreferred?: Transport[];
focus_selection: "oldest_membership", focusActive?: LivekitFocusSelection;
membership?: Partial<SessionMembershipData>;
}, },
membership: Partial<SessionMembershipData> = {},
): CallMembership { ): CallMembership {
// setup defaults based on overwrites and fallback values.
const { rtcBackendIdentity, callId, fociPreferred, focusActive, membership } =
{
fociPreferred: [exampleTransport],
focusActive: {
type: "livekit" as const,
focus_selection: "oldest_membership" as const,
},
callId: "",
membership: {},
...customOverwrites,
};
const data: SessionMembershipData = { const data: SessionMembershipData = {
application: "m.call", application: "m.call",
call_id: callId, call_id: callId,
@@ -243,17 +234,29 @@ export function mockRtcMembership(
focus_active: focusActive, focus_active: focusActive,
...membership, ...membership,
}; };
const userId = typeof user === "string" ? user : user.userId;
const event = new MatrixEvent({ const event = new MatrixEvent({
sender: typeof user === "string" ? user : user.userId, sender: userId,
event_id: `$-ev-${randomUUID()}:example.org`, event_id: `$-ev-${randomUUID()}:example.org`,
content: data, content: data,
}); });
const cms = new CallMembership(event, data); const membershipData = CallMembership.membershipDataFromMatrixEvent(event);
const cms = new CallMembership(
event,
membershipData,
rtcBackendIdentity ?? `${userId}:${deviceId}`,
);
vi.mocked(cms).getTransport = vi.fn().mockReturnValue(fociPreferred[0]); vi.mocked(cms).getTransport = vi.fn().mockReturnValue(fociPreferred[0]);
return cms; return cms;
} }
export const ownMemberMock: CallMembershipIdentityParts = {
userId: "@alice:example.org",
deviceId: "DEVICE",
memberId: "@alice:example.org:DEVICE",
};
// Maybe it'd be good to move this to matrix-js-sdk? Our testing needs are // Maybe it'd be good to move this to matrix-js-sdk? Our testing needs are
// rather simple, but if one util to mock a member is good enough for us, maybe // rather simple, but if one util to mock a member is good enough for us, maybe
// it's useful for matrix-js-sdk consumers in general. // it's useful for matrix-js-sdk consumers in general.
@@ -331,6 +334,7 @@ export function createLocalMedia(
testScope(), testScope(),
"local", "local",
member.userId, member.userId,
rtcMember.rtcBackendIdentity,
constant(localParticipant), constant(localParticipant),
{ {
kind: E2eeType.PER_PARTICIPANT, kind: E2eeType.PER_PARTICIPANT,
@@ -376,6 +380,7 @@ export function createRemoteMedia(
testScope(), testScope(),
"remote", "remote",
member.userId, member.userId,
rtcMember.rtcBackendIdentity,
constant(participant), constant(participant),
{ {
kind: E2eeType.PER_PARTICIPANT, kind: E2eeType.PER_PARTICIPANT,
@@ -478,7 +483,7 @@ export class MockRTCSession extends TypedEventEmitter<
if (value !== prev) this.emit(MembershipManagerEvent.ProbablyLeft, value); if (value !== prev) this.emit(MembershipManagerEvent.ProbablyLeft, value);
} }
public async joinRoomSession(): Promise<void> { public async joinRTCSession(): Promise<void> {
return Promise.resolve(); return Promise.resolve();
} }
} }

View File

@@ -3300,10 +3300,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@matrix-org/matrix-sdk-crypto-wasm@npm:^16.0.0": "@matrix-org/matrix-sdk-crypto-wasm@npm:^17.0.0":
version: 16.0.0 version: 17.0.0
resolution: "@matrix-org/matrix-sdk-crypto-wasm@npm:16.0.0" resolution: "@matrix-org/matrix-sdk-crypto-wasm@npm:17.0.0"
checksum: 10c0/13b4ede3e618da819957abff778afefcf3baf9a2faac04a36bb5a07a44fae2ea05fbfa072eb3408d48b2b7b9aaf27242ce52c594c8ce9bf1fb8b3aade2832be1 checksum: 10c0/fa97e3111099057e0953e7550d6556b6e7553f3badd5b25a6988d2fcc94d22288a27e63cb204771b74ff24388d770c83f2cf5aec583f05c6ecf46509b8020570
languageName: node languageName: node
linkType: hard linkType: hard
@@ -8399,6 +8399,7 @@ __metadata:
typescript: "npm:^5.8.3" typescript: "npm:^5.8.3"
typescript-eslint-language-service: "npm:^5.0.5" typescript-eslint-language-service: "npm:^5.0.5"
unique-names-generator: "npm:^4.6.0" unique-names-generator: "npm:^4.6.0"
uuid: "npm:^13.0.0"
vaul: "npm:^1.0.0" vaul: "npm:^1.0.0"
vite: "npm:^7.0.0" vite: "npm:^7.0.0"
vite-plugin-generate-file: "npm:^0.3.0" vite-plugin-generate-file: "npm:^0.3.0"
@@ -11467,10 +11468,10 @@ __metadata:
"matrix-js-sdk@matrix-org/matrix-js-sdk#develop": "matrix-js-sdk@matrix-org/matrix-js-sdk#develop":
version: 39.4.0 version: 39.4.0
resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=174439c2f0c09cf9926c28435ba4db1345df4aee" resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=4d0d32307eb4f1ce1fb65080fcca704f5bdedc31"
dependencies: dependencies:
"@babel/runtime": "npm:^7.12.5" "@babel/runtime": "npm:^7.12.5"
"@matrix-org/matrix-sdk-crypto-wasm": "npm:^16.0.0" "@matrix-org/matrix-sdk-crypto-wasm": "npm:^17.0.0"
another-json: "npm:^0.2.0" another-json: "npm:^0.2.0"
bs58: "npm:^6.0.0" bs58: "npm:^6.0.0"
content-type: "npm:^1.0.4" content-type: "npm:^1.0.4"
@@ -11483,7 +11484,7 @@ __metadata:
sdp-transform: "npm:^3.0.0" sdp-transform: "npm:^3.0.0"
unhomoglyph: "npm:^1.0.6" unhomoglyph: "npm:^1.0.6"
uuid: "npm:13" uuid: "npm:13"
checksum: 10c0/5178de27bb618aed6f80632a72c5582542ceedb51ef15534493360a624b072e0c276693ad9e37d83f2ddb06716f9eb6d02960e158e029f7a005676873778c745 checksum: 10c0/59c9d81ccf823584dc783502cb5c928562e3490c63f5ce98ee3232a603545d6278e90dc951c1fd0bae2792ba732ec5171e03596fd396bb2150d596cebb7fbac9
languageName: node languageName: node
linkType: hard linkType: hard
@@ -15346,7 +15347,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"uuid@npm:13": "uuid@npm:13, uuid@npm:^13.0.0":
version: 13.0.0 version: 13.0.0
resolution: "uuid@npm:13.0.0" resolution: "uuid@npm:13.0.0"
bin: bin: