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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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" } } },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: () => {
|
||||||
|
|||||||
@@ -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$,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ?? "");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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[]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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$(
|
||||||
|
|||||||
@@ -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$({
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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$,
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
yarn.lock
17
yarn.lock
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user