Merge pull request #4331 from element-hq/robin/livekit-updates

Support migration to new LiveKit transport types and endpoints
This commit is contained in:
Johannes Marbach
2026-10-13 07:38:02 +02:00
committed by GitHub
63 changed files with 3412 additions and 2111 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ sender_localpart: "_lk_jwt_service"
namespaces: namespaces:
users: users:
- exclusive: false - exclusive: false
regex: "@.*:homeserver-1" # Cover all local users regex: "@.*:synapse\\.othersite\\.m\\.localhost" # Cover all local users
# Required for Synapse to call POST /_matrix/app/v1/ping on this service. # Required for Synapse to call POST /_matrix/app/v1/ping on this service.
url: "http://auth-server-1:16080" url: "http://auth-server-1:16080"
# Enable membership look-ups via /is_joined. # Enable membership look-ups via /is_joined.
+1 -1
View File
@@ -5,7 +5,7 @@ sender_localpart: "_lk_jwt_service"
namespaces: namespaces:
users: users:
- exclusive: false - exclusive: false
regex: "@.*:homeserver" # Cover all local users regex: "@.*:synapse\\.m\\.localhost" # Cover all local users
# Required for Synapse to call POST /_matrix/app/v1/ping on this service. # Required for Synapse to call POST /_matrix/app/v1/ping on this service.
url: "http://auth-server:6080" url: "http://auth-server:6080"
# Enable membership look-ups via /is_joined. # Enable membership look-ups via /is_joined.
+5
View File
@@ -6,6 +6,11 @@ rtc:
port_range_start: 50300 port_range_start: 50300
port_range_end: 50400 port_range_end: 50400
use_external_ip: false use_external_ip: false
# Advertise the loopback address instead of the container IP, which is not
# routable from the host when Docker runs in a VM (colima, Docker Desktop).
# The browser then reaches the SFU through the published ports. Firefox
# ignores loopback candidates, so this only works in Chromium-based browsers.
node_ip: 127.0.0.1
turn: turn:
enabled: false enabled: false
domain: localhost domain: localhost
+5
View File
@@ -6,6 +6,11 @@ rtc:
port_range_start: 50100 port_range_start: 50100
port_range_end: 50200 port_range_end: 50200
use_external_ip: false use_external_ip: false
# Advertise the loopback address instead of the container IP, which is not
# routable from the host when Docker runs in a VM (colima, Docker Desktop).
# The browser then reaches the SFU through the published ports. Firefox
# ignores loopback candidates, so this only works in Chromium-based browsers.
node_ip: 127.0.0.1
turn: turn:
enabled: false enabled: false
domain: localhost domain: localhost
+2 -2
View File
@@ -3,7 +3,7 @@ networks:
services: services:
auth-service: auth-service:
image: ghcr.io/element-hq/lk-jwt-service:0.7.0 image: ghcr.io/element-hq/lk-jwt-service:0.8.0
pull_policy: always pull_policy: always
hostname: auth-server hostname: auth-server
environment: environment:
@@ -33,7 +33,7 @@ services:
- ecbackend - ecbackend
auth-service-1: auth-service-1:
image: ghcr.io/element-hq/lk-jwt-service:0.7.0 image: ghcr.io/element-hq/lk-jwt-service:0.8.0
pull_policy: always pull_policy: always
hostname: auth-server-1 hostname: auth-server-1
environment: environment:
+3 -3
View File
@@ -59,10 +59,10 @@
"developer_mode": { "developer_mode": {
"always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms", "always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms",
"crypto_version": "Crypto version: {{version}}", "crypto_version": "Crypto version: {{version}}",
"custom_livekit_url": { "custom_transport": {
"current_url": "Currently set to: ", "current": "Currently set to: ",
"from_config": "Currently, no overwrite is set. Url from config is used.", "from_config": "Currently, no overwrite is set. Url from config is used.",
"label": "Custom Livekit-url", "label": "Custom transport JSON",
"reset": "Reset overwrite", "reset": "Reset overwrite",
"save": "Save", "save": "Save",
"saving": "Saving..." "saving": "Saving..."
+9 -4
View File
@@ -38,15 +38,20 @@ export default defineConfig({
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */ /* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined, workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /**
// On CI, `list` prints a line per test as it finishes, which keeps the job * Reporter to use. See https://playwright.dev/docs/test-reporters
// log moving; `github` annotates failures on the PR; `html` is uploaded as *
// an artifact and must never try to open a browser there. * On CI, `list` prints a line per test as it finishes, which keeps the job
* log moving; `github` annotates failures on the PR; `html` is uploaded as
* an artifact and must never try to open a browser there.
*/
reporter: process.env.CI reporter: process.env.CI
? [["list"], ["github"], ["html", { open: "never" }]] ? [["list"], ["github"], ["html", { open: "never" }]]
: [["list"], ["html"]], : [["list"], ["html"]],
/* Stop a broken run early on CI instead of waiting out every retry. */ /* Stop a broken run early on CI instead of waiting out every retry. */
maxFailures: process.env.CI ? 5 : undefined, maxFailures: process.env.CI ? 5 : undefined,
/* Under the job's 60 minute limit, so a hung run still ends with a report. */
globalTimeout: process.env.CI ? 50 * 60 * 1000 : undefined,
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: { use: {
/* Base URL to use in actions like `await page.goto('/')`. */ /* Base URL to use in actions like `await page.goto('/')`. */
+2 -2
View File
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { createJTWToken } from "./fixtures/jwt-token"; import { createJWTToken } from "./fixtures/jwt-token";
test("Should show error screen if fails to get JWT token", async ({ page }) => { test("Should show error screen if fails to get JWT token", async ({ page }) => {
await page.goto("/"); await page.goto("/");
@@ -95,7 +95,7 @@ test("Should show error screen if call creation is restricted", async ({
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ body: JSON.stringify({
url: "wss://badurltotricktest/livekit/sfu", url: "wss://badurltotricktest/livekit/sfu",
jwt: createJTWToken("@fake:user", "!fake:room"), jwt: createJWTToken("@fake:user", "!fake:room"),
}), }),
}), }),
); );
+1 -1
View File
@@ -5,7 +5,7 @@ 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.
*/ */
export function createJTWToken(sub: string, room: string): string { export function createJWTToken(sub: string, room: string): string {
return [ return [
{}, // header {}, // header
{ {
+493
View File
@@ -0,0 +1,493 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, type Page, test } from "@playwright/test";
import { SpaHelpers } from "./spa-helpers";
import { type RtcMode } from "./widget/test-helpers.ts";
const sfuUrl = "wss://matrix-rtc.m.localhost/livekit/sfu";
const serviceUrl = "https://matrix-rtc.m.localhost/livekit/jwt";
const otherHomeserver = {
base_url: "https://synapse.othersite.m.localhost",
server_name: "synapse.othersite.m.localhost",
};
const otherSfuUrl = "wss://matrix-rtc.othersite.m.localhost/livekit/sfu";
interface Transport {
type: "livekit";
url?: string;
livekit_service_url?: string;
}
/**
* The transport shapes a homeserver may advertise while migrating to the
* latest MSC4195. `legacy` is what the e2e Synapse advertises on its own.
*/
const shapes = {
url: { type: "livekit", url: sfuUrl },
both: { type: "livekit", url: sfuUrl, livekit_service_url: serviceUrl },
legacy: null,
} satisfies Record<string, Transport | null>;
type Shape = keyof typeof shapes;
/** Which endpoint a page asks for its SFU tokens. */
type Flow = "cs-api" | "service-legacy" | "service-default";
const cases: {
hostShape: Shape;
guestShape: Shape;
/** The MatrixRTC mode both sides use; the default is compatibility mode. */
mode?: RtcMode;
hostFlows: Flow[];
guestFlows: Flow[];
}[] = [
{
hostShape: "url",
guestShape: "url",
hostFlows: ["cs-api"],
guestFlows: ["cs-api"],
},
{
// Publishers keep the legacy participant identity that clients predating
// MSC4195 expect, which only the JWT service assigns.
hostShape: "both",
guestShape: "both",
hostFlows: ["service-legacy"],
guestFlows: ["service-legacy"],
},
{
// The MatrixRTC mode has no bearing on the choice.
hostShape: "both",
guestShape: "both",
mode: "2_0",
hostFlows: ["service-legacy"],
guestFlows: ["service-legacy"],
},
{
hostShape: "url",
guestShape: "legacy",
hostFlows: ["cs-api", "service-default"],
guestFlows: ["service-legacy", "cs-api"],
},
];
for (const { hostShape, guestShape, mode, hostFlows, guestFlows } of cases) {
test(`One to one call with ${hostShape} host and ${guestShape} guest transport${mode === "2_0" ? " in Matrix 2.0 mode" : ""}`, async ({
browser,
page,
browserName,
}) => {
skipOnFirefox(browserName);
// The host starts the call.
await advertiseTransport(page, shapes[hostShape]);
const hostRequests = recordAuthRequests(page);
await page.goto("/");
await SpaHelpers.createCall(page, "Androl", "HelloCall", true, mode);
const inviteLink = await SpaHelpers.getCallInviteLink(page);
// The guest joins.
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
const guestPage = await guestContext.newPage();
await advertiseTransport(guestPage, shapes[guestShape]);
const guestRequests = recordAuthRequests(guestPage);
await SpaHelpers.joinCallFromInviteLink(
guestPage,
inviteLink,
"Pevara",
mode,
);
// Wait for the call to connect and render.
await SpaHelpers.expectVideoTilesCount(page, 2);
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
// The host and guest ran through the expected flows.
expect(flowsOf(hostRequests)).toEqual(new Set(hostFlows));
expect(flowsOf(guestRequests)).toEqual(new Set(guestFlows));
// All requests succeeded and the SFU URL was set on C-S requests.
for (const request of [...hostRequests, ...guestRequests]) {
expect(request.status).toBe(200);
if (request.flow === "cs-api") expect(request.sfuUrl).toBe(sfuUrl);
// Clients predating MSC4195 match a publisher by the legacy identity,
// which only the JWT service's legacy endpoint assigns. The other flows
// hash it.
expect(request.identity).toMatch(
request.flow === "service-legacy" ? legacyIdentity : hashedIdentity,
);
}
});
}
test("Subscriber falls back to the JWT service when the homeserver lacks MSC4195", async ({
browser,
page,
browserName,
}) => {
skipOnFirefox(browserName);
// The host starts the call with both transport shapes.
await advertiseTransport(page, shapes.both);
await page.goto("/");
await SpaHelpers.createCall(page, "Androl", "HelloCall", true);
const inviteLink = await SpaHelpers.getCallInviteLink(page);
// The guest joins but their C-S requests fail.
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
const guestPage = await guestContext.newPage();
await rejectCsApiTokens(guestPage);
const guestRequests = recordAuthRequests(guestPage);
await SpaHelpers.joinCallFromInviteLink(guestPage, inviteLink, "Pevara");
// Wait for the call to connect and render.
await SpaHelpers.expectVideoTilesCount(page, 2);
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
// The guest ran through all flows.
expect(flowsOf(guestRequests)).toEqual(
new Set<Flow>(["cs-api", "service-legacy", "service-default"]),
);
// The C-S token requests failed.
for (const request of guestRequests) {
expect(request.status).toBe(request.flow === "cs-api" ? 404 : 200);
}
// The C-S token requests preceeded the fallback request.
const flows = guestRequests.map((r) => r.flow);
expect(flows.indexOf("service-default")).toBeGreaterThan(
flows.indexOf("cs-api"),
);
});
test("Publisher shows an error when the homeserver lacks MSC4195", async ({
page,
browserName,
}) => {
skipOnFirefox(browserName);
// The host tries to start a call with only the C-S transport shape available but
// their C-S requests fail.
await advertiseTransport(page, shapes.url);
await rejectCsApiTokens(page);
await page.goto("/");
await SpaHelpers.createCall(page, "Androl", "HelloCall", true);
// We land on an error page.
await expect(
page.getByRole("heading", { name: "Something went wrong" }),
).toBeVisible();
await expect(
page.getByText(
"The authorization service for your media server (SFU) is out of date.",
),
).toBeVisible();
});
/**
* Delegation follows the flow that authenticated the publisher: the homeserver
* for a `url`-only transport, the JWT service wherever there is one.
*/
const delegationCases: { shape: Shape; via: Delegation["via"] }[] = [
{ shape: "url", via: "homeserver" },
{ shape: "legacy", via: "service" },
{ shape: "both", via: "service" },
];
for (const { shape, via } of delegationCases) {
test(`Delayed leave event delegated through the ${via} for a ${shape} transport gets sent on disconnect`, async ({
browser,
page,
browserName,
}) => {
skipOnFirefox(browserName);
// The host starts the call.
await advertiseTransport(page, shapes[shape]);
await page.goto("/");
await SpaHelpers.createCall(page, "Androl", "HelloCall", true);
const inviteLink = await SpaHelpers.getCallInviteLink(page);
// The guest joins.
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
const guestPage = await guestContext.newPage();
await advertiseTransport(guestPage, shapes[shape]);
const delayedLeave = recordDelayedLeave(guestPage);
const delegations = recordDelegations(guestPage);
await SpaHelpers.joinCallFromInviteLink(guestPage, inviteLink, "Pevara");
// Wait for the call to connect and render.
await SpaHelpers.expectVideoTilesCount(page, 2);
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
// The guest handed its delayed leave over, after probing for delegation
// support with bodiless requests, which count as supported unless they
// 404. The homeserver is always probed; the JWT service only when there
// is one.
await expect
.poll(() => delegations.at(-1))
.toEqual({
via,
status: 200,
delayId: delayedLeave.delayId,
...(via === "homeserver" && { sfuUrl }),
});
const probes = delegations.slice(0, -1);
expect(probes.some((probe) => probe.via === via)).toBe(true);
for (const probe of probes) {
expect(probe.status).toBeGreaterThanOrEqual(400);
expect(probe.status).not.toBe(404);
}
// The delay is long enough that a leave within the window below cannot
// come from the delayed event expiring on its own.
expect(delayedLeave.delayMs).toBeGreaterThan(60_000);
// The guest drops without leaving.
await guestPage.reload();
// The host eventually sees the guest go once the delayed event fires.
await expect(page.getByTestId("videoTile")).toHaveCount(1, {
timeout: 30_000,
});
});
}
test("Subscribers get tokens for a remote homeserver's SFU over federation", async ({
browser,
page,
browserName,
}) => {
skipOnFirefox(browserName);
// The host lives on the other homeserver and publishes on its SFU.
await useHomeserver(page, otherHomeserver);
await advertiseTransport(page, { type: "livekit", url: otherSfuUrl });
const hostRequests = recordAuthRequests(page);
await page.goto("/");
await SpaHelpers.createCall(page, "Androl", "HelloCall", true);
const inviteLink = await SpaHelpers.getCallInviteLink(page);
// The guest lives on the default homeserver and joins over federation.
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
const guestPage = await guestContext.newPage();
await advertiseTransport(guestPage, shapes.url);
const guestRequests = recordAuthRequests(guestPage);
await SpaHelpers.joinCallFromInviteLink(
guestPage,
`${inviteLink}&viaServers=${otherHomeserver.server_name}`,
"Pevara",
);
// Wait for the call to connect and render.
await SpaHelpers.expectVideoTilesCount(page, 2);
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
// Each side got a token for its own SFU and for the other side's SFU, all
// through its own homeserver.
for (const requests of [hostRequests, guestRequests]) {
expect(flowsOf(requests)).toEqual(new Set<Flow>(["cs-api"]));
expect(new Set(requests.map((r) => r.sfuUrl))).toEqual(
new Set([sfuUrl, otherSfuUrl]),
);
for (const request of requests) expect(request.status).toBe(200);
}
});
/** Skips the calling test if we're running on Firefox. */
function skipOnFirefox(browserName: string): void {
test.skip(
browserName === "firefox",
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
);
}
/** Points this page's Element Call at another homeserver. */
async function useHomeserver(
page: Page,
homeserver: typeof otherHomeserver,
): Promise<void> {
await page.route("**/config.json", async (route) => {
const response = await route.fetch();
const config = (await response.json()) as Record<string, unknown>;
await route.fulfill({
response,
json: {
...config,
default_server_config: { "m.homeserver": homeserver },
},
});
});
}
/**
* Maps recorded requests to a set of flows. Publisher and subscriber connections
* start concurrently so using arrays for comparison isn't convenient.
*/
function flowsOf(requests: AuthRequest[]): Set<Flow> {
return new Set(requests.map((r) => r.flow));
}
/**
* Replaces the transports the homeserver advertises to this page. The real
* response is fetched first so its CORS headers carry over.
*/
async function advertiseTransport(
page: Page,
transport: Transport | null,
): Promise<void> {
if (transport === null) return;
await page.route("**/org.matrix.msc4143/rtc/transports", async (route) => {
if (route.request().method() !== "GET") return route.continue();
const response = await route.fetch();
await route.fulfill({ response, json: { rtc_transports: [transport] } });
});
}
/**
* Makes the homeserver look like one without MSC4195: its `get_token`
* endpoint answers as Synapse does for an unknown route.
*/
async function rejectCsApiTokens(page: Page): Promise<void> {
await page.route("**/rtc/livekit/get_token", async (route) => {
if (route.request().method() !== "POST") return route.continue();
await route.fulfill({
status: 404,
json: { errcode: "M_UNRECOGNIZED", error: "Unrecognized request" },
headers: { "Access-Control-Allow-Origin": "*" },
});
});
}
/** A recorded delayed leave event. */
interface DelayedLeave {
delayMs?: number;
delayId?: string;
}
/** Records the delayed leave event the page schedules on joining. */
function recordDelayedLeave(page: Page): DelayedLeave {
const delayedLeave: DelayedLeave = {};
page.on("response", (response) => {
const delay = new URL(response.request().url()).searchParams.get(
"org.matrix.msc4140.delay",
);
if (delay === null || response.status() !== 200) return;
delayedLeave.delayMs = Number(delay);
response
.json()
.then((body: { delay_id: string }) => {
delayedLeave.delayId = body.delay_id;
})
.catch(() => {});
});
return delayedLeave;
}
/** A recorded delegation request for a delayed leave event. */
interface Delegation {
/** Who was asked to send the leave event on the guest's behalf. */
via: "homeserver" | "service";
status: number;
sfuUrl?: string;
delayId?: string;
}
/**
* Records requests that delegate a delayed leave event, or probe for support:
* the MSC4195 `delegate_delayed_leave` endpoint of the homeserver, and the JWT
* service's `delegate_delayed_leave` probe and `/sfu/get` with delay fields.
*/
function recordDelegations(page: Page): Delegation[] {
const delegations: Delegation[] = [];
page.on("response", (response) => {
const request = response.request();
if (request.method() !== "POST") return;
const url = request.url();
const body = request.postDataJSON() as {
url?: string;
delay_id?: string;
} | null;
if (url.endsWith("/rtc/livekit/delegate_delayed_leave")) {
delegations.push({
via: "homeserver",
status: response.status(),
...(body && { sfuUrl: body.url, delayId: body.delay_id }),
});
} else if (url.endsWith("/livekit/jwt/delegate_delayed_leave")) {
delegations.push({ via: "service", status: response.status() });
} else if (url.endsWith("/livekit/jwt/sfu/get") && body?.delay_id) {
delegations.push({
via: "service",
status: response.status(),
delayId: body.delay_id,
});
}
});
return delegations;
}
/** A recorded token request. */
interface AuthRequest {
flow: Flow;
status: number;
/** The SFU a request to the MSC4195 `get_token` endpoint asked for. */
sfuUrl?: string;
/** The participant identity the issued token carries. */
identity?: string;
}
/** The legacy `<user_id>:<device_id>` participant identity. */
const legacyIdentity = /^@[^:]+:[^:]+:[A-Z]+$/;
/** A hashed participant identity: unpadded base64 of a SHA-256. */
const hashedIdentity = /^[A-Za-z0-9+/]{43}$/;
/** Reads the subject of a JWT without verifying it. */
function jwtSubject(jwt: string): string {
const [, payload] = jwt.split(".");
return (
JSON.parse(Buffer.from(payload, "base64url").toString()) as {
sub: string;
}
).sub;
}
/** Records token requests, including the identity each token carries. */
function recordAuthRequests(page: Page): AuthRequest[] {
const requests: AuthRequest[] = [];
page.on("response", (response) => {
const request = response.request();
if (request.method() !== "POST") return;
const url = request.url();
let entry: AuthRequest;
if (url.endsWith("/rtc/livekit/get_token")) {
entry = {
flow: "cs-api",
status: response.status(),
sfuUrl: (request.postDataJSON() as { url: string }).url,
};
} else if (url.endsWith("/livekit/jwt/sfu/get")) {
entry = { flow: "service-legacy", status: response.status() };
} else if (url.endsWith("/livekit/jwt/get_token")) {
entry = { flow: "service-default", status: response.status() };
} else {
return;
}
requests.push(entry);
if (entry.status !== 200) return;
response
.json()
.then((body: { jwt: string }) => {
entry.identity = jwtSubject(body.jwt);
})
.catch(() => {});
});
return requests;
}
-4
View File
@@ -37,8 +37,6 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
await TestHelpers.acceptRoomInvite(roomName, timo.page); await TestHelpers.acceptRoomInvite(roomName, timo.page);
await florian.page.pause();
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget( await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
florian.page, florian.page,
rtcMode1, rtcMode1,
@@ -74,8 +72,6 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
await TestHelpers.expectVisibleVideoCount(frame, 2); await TestHelpers.expectVisibleVideoCount(frame, 2);
} }
// await florian.page.pause();
}, },
); );
}); });
+108
View File
@@ -0,0 +1,108 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, type Page, test } from "@playwright/test";
import { widgetTest } from "../fixtures/widget-user.ts";
import { HOST1, TestHelpers } from "./test-helpers.ts";
const sfuUrl = "wss://matrix-rtc.m.localhost/livekit/sfu";
const serviceUrl = "https://matrix-rtc.m.localhost/livekit/jwt";
/** Which endpoint a page asks for its SFU tokens. */
type Flow = "cs-api" | "service-legacy" | "service-default";
widgetTest(
"Call between a url + livekit_service_url transport and a legacy one",
async ({ addUser, browserName }) => {
test.skip(
browserName === "firefox",
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
);
test.slow();
const [host, guest] = await Promise.all([
addUser("Androl", HOST1),
addUser("Pevara", HOST1),
]);
// The host's homeserver has started advertising a `url` next to its JWT
// service; the guest's still advertises the JWT service alone.
await advertiseTransport(host.page, {
type: "livekit",
url: sfuUrl,
livekit_service_url: serviceUrl,
});
// Element Web answers the widget from a cache it filled at login, so make
// it fetch the transports again now that they are rerouted
await host.clientHandle.evaluate(async (client) => {
await client.cachedRtcTransports.refresh();
});
const hostFlows = recordFlows(host.page);
const guestFlows = recordFlows(guest.page);
const roomName = "Transport Room";
await TestHelpers.createRoom(roomName, host.page, [guest.mxId]);
await TestHelpers.acceptRoomInvite(roomName, guest.page);
await TestHelpers.startCallInCurrentRoom(host.page, false);
await TestHelpers.joinCallFromLobby(host.page);
await TestHelpers.joinCallInCurrentRoom(guest.page);
for (const user of [host, guest]) {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.getByTestId("videoTile")).toHaveCount(2, {
timeout: 10000,
});
await expect(frame.getByText("Waiting for media...")).not.toBeVisible({
timeout: 10000,
});
await TestHelpers.expectVisibleVideoCount(frame, 2);
}
// Element Web does not expose the MSC4195 endpoints to widgets yet, so
// neither side reached the CS API. The host published through the JWT
// service's legacy endpoint and subscribed to the guest through its
// default one; the guest published the same way and fell back to the
// default endpoint for the host's transport.
expect(hostFlows).toEqual(new Set(["service-legacy", "service-default"]));
expect(guestFlows).toEqual(new Set(["service-legacy", "service-default"]));
},
);
/**
* Replaces the transports the homeserver advertises to this page, including
* to the widget host, which fetches them on the widget's behalf. The real
* response is fetched first so its CORS headers carry over.
*/
async function advertiseTransport(
page: Page,
transport: { type: "livekit"; url?: string; livekit_service_url?: string },
): Promise<void> {
await page.route("**/org.matrix.msc4143/rtc/transports", async (route) => {
if (route.request().method() !== "GET") return route.continue();
const response = await route.fetch();
await route.fulfill({ response, json: { rtc_transports: [transport] } });
});
}
/** Records which token endpoints the page's widget asks. */
function recordFlows(page: Page): Set<Flow> {
const flows = new Set<Flow>();
page.on("response", (response) => {
const request = response.request();
if (request.method() !== "POST") return;
const url = request.url();
if (url.endsWith("/rtc/livekit/get_token")) flows.add("cs-api");
else if (url.endsWith("/livekit/jwt/sfu/get")) flows.add("service-legacy");
else if (url.endsWith("/livekit/jwt/get_token"))
flows.add("service-default");
});
return flows;
}
+10 -10
View File
@@ -192,10 +192,10 @@ importers:
version: 1.9.2 version: 1.9.2
matrix-js-sdk: matrix-js-sdk:
specifier: github:matrix-org/matrix-js-sdk#develop specifier: github:matrix-org/matrix-js-sdk#develop
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/fa4772a4ddd335a5a8bfbf8bb21df49b70f7a999 version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/4df2f2bfb5826c25f05d9535a8556acecaf2e570
matrix-widget-api: matrix-widget-api:
specifier: ^1.18.0 specifier: ^1.18.0
version: 1.19.0 version: 1.20.0
node-stdlib-browser: node-stdlib-browser:
specifier: ^1.3.1 specifier: ^1.3.1
version: 1.3.1 version: 1.3.1
@@ -4673,13 +4673,13 @@ packages:
matrix-events-sdk@0.0.1: matrix-events-sdk@0.0.1:
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==} resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/fa4772a4ddd335a5a8bfbf8bb21df49b70f7a999: matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/4df2f2bfb5826c25f05d9535a8556acecaf2e570:
resolution: {gitHosted: true, integrity: sha512-qF8++74MX36KS1KEw6Lc8HHXgZ6nHD1oUU4yBZUj5vVPvZTIspk1lOGztkFmizE5vmLAHd5tdadL9Xu1Asy4bA==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/fa4772a4ddd335a5a8bfbf8bb21df49b70f7a999} resolution: {gitHosted: true, integrity: sha512-b1o6ywaPY9jakj+3GIgx3samM9AbqohzPQ5nZKbyOGYB16IHQtNMyjEo/PY0fW/s0uwBVApEHNb/MMtN40c/rQ==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/4df2f2bfb5826c25f05d9535a8556acecaf2e570}
version: 42.3.0 version: 43.0.0
engines: {node: '>=22.0.0'} engines: {node: '>=22.0.0'}
matrix-widget-api@1.19.0: matrix-widget-api@1.20.0:
resolution: {integrity: sha512-Qe7L6G1mQJphBC47/Bzp5ljPIK1UARgLiuGgS+/KmEs/eaNHkQ3+ArD0/0kvMV+d6kCGAZ9OKRqsnuPjizI+tw==} resolution: {integrity: sha512-qwFP37rgns7mPesM5/vvZgknzb+kvyRB5BISv5sCuBxsGMceMr+Kogf7OkjRxBXdMCPBFuf6gIuYRe5S+lpzQw==}
md5.js@1.3.5: md5.js@1.3.5:
resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==} resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==}
@@ -10227,7 +10227,7 @@ snapshots:
matrix-events-sdk@0.0.1: {} matrix-events-sdk@0.0.1: {}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/fa4772a4ddd335a5a8bfbf8bb21df49b70f7a999: matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/4df2f2bfb5826c25f05d9535a8556acecaf2e570:
dependencies: dependencies:
'@babel/runtime': 8.0.0 '@babel/runtime': 8.0.0
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.0 '@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
@@ -10236,12 +10236,12 @@ snapshots:
content-type: 3.1.1 content-type: 3.1.1
loglevel: 1.9.2 loglevel: 1.9.2
matrix-events-sdk: 0.0.1 matrix-events-sdk: 0.0.1
matrix-widget-api: 1.19.0 matrix-widget-api: 1.20.0
p-retry: 8.0.1 p-retry: 8.0.1
sdp-transform: 3.0.0 sdp-transform: 3.0.0
unhomoglyph: 1.0.6 unhomoglyph: 1.0.6
matrix-widget-api@1.19.0: matrix-widget-api@1.20.0:
dependencies: dependencies:
'@types/events': 3.0.3 '@types/events': 3.0.3
events: 3.3.0 events: 3.3.0
+1 -1
View File
@@ -231,7 +231,7 @@ export async function createMatrixRTCSdk(
complete: () => { complete: () => {
logger.info("Livekit room items subscription completed"); logger.info("Livekit room items subscription completed");
for (const item of callViewModel.livekitRoomItems$.value) { for (const item of callViewModel.livekitRoomItems$.value) {
logger.info("unregistering room item from room", item.url); logger.info("unregistering room item from room", item.transport);
item.livekitRoom.unregisterTextStreamHandler(TEXT_LK_TOPIC); item.livekitRoom.unregisterTextStreamHandler(TEXT_LK_TOPIC);
} }
}, },
+7 -16
View File
@@ -16,30 +16,21 @@ import classNames from "classnames";
import { Modal } from "./Modal"; import { Modal } from "./Modal";
import styles from "./RTCConnectionStats.module.css"; import styles from "./RTCConnectionStats.module.css";
import mediaViewStyles from "../src/tile/MediaView.module.css"; import mediaViewStyles from "../src/tile/MediaView.module.css";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
interface Props { interface Props {
audio?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats; audio?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
video?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats; video?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
focusUrl?: string; transport?: UnstableLivekitTransport;
rtcBackendIdentity?: string; rtcBackendIdentity?: string;
} }
const extractDomain = (url: string): string => {
try {
const parsedUrl = new URL(url);
return parsedUrl.hostname; // Returns "kdk.cpm"
} catch (error) {
console.error("Invalid URL:", error);
return url;
}
};
// This is only used in developer mode for debugging purposes, so we don't need full localization // This is only used in developer mode for debugging purposes, so we don't need full localization
export const RTCConnectionStats: FC<Props> = ({ export const RTCConnectionStats: FC<Props> = ({
audio, audio,
video, video,
focusUrl, transport,
rtcBackendIdentity, rtcBackendIdentity,
...rest
}) => { }) => {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [modalContents, setModalContents] = useState< const [modalContents, setModalContents] = useState<
@@ -76,10 +67,10 @@ export const RTCConnectionStats: FC<Props> = ({
<Text as="span" size="xs" title="rtcBackendIdentity"> <Text as="span" size="xs" title="rtcBackendIdentity">
rtcBackendIdentity:{rtcBackendIdentity} rtcBackendIdentity:{rtcBackendIdentity}
</Text> </Text>
{focusUrl && ( {transport && (
<div> <div>
<Text as="span" size="xs" title="focusURL"> <Text as="span" size="xs" title="transport">
&nbsp;{extractDomain(focusUrl)} &nbsp;{JSON.stringify(transport)}
</Text> </Text>
</div> </div>
)} )}
+12 -4
View File
@@ -97,11 +97,19 @@ export interface ConfigOptions {
}; };
}; };
// Describes the LiveKit configuration to be used. /**
* Describes the LiveKit configuration to be used.
*/
livekit?: { livekit?: {
// The link to the service that returns a livekit url and token to use it. /**
// This is a fallback link in case the homeserver in use does not advertise * The link to the service that returns a livekit url and token to use it.
// a livekit service url over the transports endpoint. * This is a fallback link in case the homeserver in use does not advertise
* a livekit service url over the transports endpoint.
*
* @deprecated Homeservers should advertise available transports over the
* /_matrix/client/unstable/org.matrix.msc4143/rtc/transports endpoint
* instead.
*/
livekit_service_url: string; livekit_service_url: string;
}; };
+4 -3
View File
@@ -24,6 +24,7 @@ import { testAudioContext } from "../useAudioContext.test";
import * as MediaDevicesContext from "../MediaDevicesContext"; import * as MediaDevicesContext from "../MediaDevicesContext";
import { LivekitRoomAudioRenderer } from "./MatrixAudioRenderer"; import { LivekitRoomAudioRenderer } from "./MatrixAudioRenderer";
import { import {
exampleTransportLocator,
mockLocalParticipant, mockLocalParticipant,
mockMediaDevices, mockMediaDevices,
mockRemoteParticipant, mockRemoteParticipant,
@@ -118,7 +119,7 @@ function renderTestComponent(
<LivekitRoomAudioRenderer <LivekitRoomAudioRenderer
validIdentities={participants.map((p) => p.identity)} validIdentities={participants.map((p) => p.identity)}
livekitRoom={livekitRoom} livekitRoom={livekitRoom}
url={""} transport={exampleTransportLocator}
/> />
</MediaDevicesProvider>, </MediaDevicesProvider>,
); );
@@ -150,7 +151,7 @@ it("should not render or warn for the local participant", () => {
<LivekitRoomAudioRenderer <LivekitRoomAudioRenderer
validIdentities={[]} validIdentities={[]}
livekitRoom={{ remoteParticipants: new Map() } as unknown as Room} livekitRoom={{ remoteParticipants: new Map() } as unknown as Room}
url={""} transport={exampleTransportLocator}
/> />
</MediaDevicesProvider>, </MediaDevicesProvider>,
); );
@@ -177,7 +178,7 @@ it("should warn only once per unexpected participant", () => {
<LivekitRoomAudioRenderer <LivekitRoomAudioRenderer
validIdentities={[]} validIdentities={[]}
livekitRoom={{ remoteParticipants: new Map() } as unknown as Room} livekitRoom={{ remoteParticipants: new Map() } as unknown as Room}
url={""} transport={exampleTransportLocator}
/> />
</MediaDevicesProvider>, </MediaDevicesProvider>,
); );
+4 -6
View File
@@ -19,12 +19,10 @@ import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { useEarpieceAudioConfig } from "../MediaDevicesContext"; import { useEarpieceAudioConfig } from "../MediaDevicesContext";
import { useReactiveState } from "../useReactiveState"; import { useReactiveState } from "../useReactiveState";
import * as controls from "../controls"; import * as controls from "../controls";
import { type TransportLocator } from "./auth";
export interface MatrixAudioRendererProps { export interface MatrixAudioRendererProps {
/** transport: TransportLocator;
* The service URL of the LiveKit room.
*/
url: string;
livekitRoom: LivekitRoom; livekitRoom: LivekitRoom;
/** /**
* The list of participant identities to render audio for. * The list of participant identities to render audio for.
@@ -54,7 +52,7 @@ export interface MatrixAudioRendererProps {
* @public * @public
*/ */
export function LivekitRoomAudioRenderer({ export function LivekitRoomAudioRenderer({
url, transport,
livekitRoom, livekitRoom,
validIdentities, validIdentities,
muted, muted,
@@ -88,7 +86,7 @@ export function LivekitRoomAudioRenderer({
warnedIdentities.current.add(identity); warnedIdentities.current.add(identity);
// Log that there is an invalid identity, that means that someone is publishing audio that is not expected to be in the call. // Log that there is an invalid identity, that means that someone is publishing audio that is not expected to be in the call.
logger.warn( logger.warn(
`Audio track ${identity} from ${url} has no matching matrix call member`, `Audio track ${identity} from ${JSON.stringify(transport)} has no matching matrix call member`,
`current members: ${validIdentities.join()}`, `current members: ${validIdentities.join()}`,
`track will not get rendered`, `track will not get rendered`,
); );
+137
View File
@@ -0,0 +1,137 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { ConnectionError, HTTPError, type MatrixClient } from "matrix-js-sdk";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { extractFullConfigFromToken, type SFUConfig } from "./types.ts";
import { doNetworkOperationWithRetry } from "../../utils/matrix.ts";
/**
* In widget mode the host may refuse an action before any request is made
* which surfaces as a plain error. Asking again cannot change its answer,
* so only errors from the network are worth retrying.
*/
function isNetworkError(e: unknown): boolean {
return e instanceof HTTPError || e instanceof ConnectionError;
}
export type ClientGetTokenParts = Pick<
MatrixClient,
"_unstable_getLivekitToken"
>;
export interface GetSFUConfigParams {
/**
* The Matrix client.
*/
client: ClientGetTokenParts;
/**
* Data identifying the local user's session membership.
*/
membership: CallMembershipIdentityParts;
/**
* The WebSocket URL of the SFU for which we wish to get an access token.
*/
url: string;
/**
* The name of the homeserver to which the SFU belongs.
*/
serverName: string;
/**
* The ID of the Matrix room in which the session takes place.
*/
roomId: string;
/**
* The ID of the MatrixRTC slot in which the session takes place.
*/
slotId: string;
}
/**
* Gets an {@link SFUConfig} appropriate for connecting to a given SFU, using
* the MSC4195 LiveKit client-server API endpoints.
*/
export async function getSFUConfig({
client,
membership,
url,
serverName,
roomId,
slotId,
}: GetSFUConfigParams): Promise<SFUConfig> {
const res = await doNetworkOperationWithRetry(
async () =>
client._unstable_getLivekitToken({
url,
server_name: serverName,
room_id: roomId,
slot_id: slotId,
member_id: membership.memberId,
}),
isNetworkError,
);
return extractFullConfigFromToken({ url, jwt: res.jwt });
}
export type ClientDelegationParts = Pick<
MatrixClient,
"_unstable_delegateDelayedLeave" | "baseUrl"
>;
export interface DelegateDelayedLeaveParams {
/**
* The Matrix client.
*/
client: ClientDelegationParts;
/**
* Data identifying the local user's session membership.
*/
membership: CallMembershipIdentityParts;
/**
* The WebSocket URL of the SFU to which we are publishing and wish to
* delegate the delayed leave event.
*/
url: string;
/**
* The ID of the Matrix room in which the session takes place.
*/
roomId: string;
/**
* The ID of the MatrixRTC slot in which the session takes place.
*/
slotId: string;
/**
* The delay ID of the leave event to be delegated.
*/
delayId: string;
}
/**
* Delegates a delayed leave event to a given SFU, so that the event will
* be sent on our behalf whenever we disconnect from the SFU.
*/
export async function delegateDelayedLeave({
client,
membership,
url,
roomId,
slotId,
delayId,
}: DelegateDelayedLeaveParams): Promise<void> {
await doNetworkOperationWithRetry(
async () =>
client._unstable_delegateDelayedLeave({
url,
room_id: roomId,
slot_id: slotId,
member_id: membership.memberId,
delay_id: delayId,
}),
isNetworkError,
);
}
+306
View File
@@ -0,0 +1,306 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, expect, type Mocked, vi, describe, it } from "vitest";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import {
aliceDeviceId,
aliceId,
aliceUserId,
createJWTToken,
} from "../../utils/test-fixtures";
import type * as csApi from "./csApi";
import * as openID from "./openID";
import { delegateDelayedLeave, getSFUConfig } from ".";
import { mocked } from "storybook/test";
import { ConnectionError, MatrixError } from "matrix-js-sdk";
import { NoMatrix2AuthorizationService } from "../../utils/errors";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
vi.mock("./openID", () => ({ getSFUConfig: vi.fn() }));
vi.useFakeTimers();
const csApiJWTToken = createJWTToken(aliceId, "via CS API");
const openIDJWTToken = createJWTToken(aliceId, "via OpenID");
mocked(openID).getSFUConfig.mockResolvedValue({
url: "mock-openID-url",
jwt: openIDJWTToken,
livekitAlias: "via OpenID",
livekitIdentity: aliceId,
});
const client: Mocked<
csApi.ClientGetTokenParts &
csApi.ClientDelegationParts &
openID.ClientOpenIDParts
> = {
baseUrl: "https://matrix.example.org",
_unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: csApiJWTToken }),
_unstable_delegateDelayedLeave: vi.fn().mockResolvedValue({}),
getOpenIdToken: vi
.fn()
.mockResolvedValue({ access_token: "mock-openID-token" }),
};
const membership: CallMembershipIdentityParts = {
userId: aliceUserId,
deviceId: aliceDeviceId,
memberId: "123",
};
const roomId = "!room:example.org";
const slotId = "m.call#ROOM";
const unsupportedError = new MatrixError({ errcode: "M_UNSUPPORTED" }, 404);
const urlTransport: UnstableLivekitTransport = {
type: "livekit",
url: "wss://livekit.example.org",
};
const serviceUrlTransport: UnstableLivekitTransport = {
type: "livekit",
livekit_service_url: "https://livekit-jwt.example.org",
};
const urlAndServiceUrlTransport: UnstableLivekitTransport = {
...urlTransport,
...serviceUrlTransport,
};
afterEach(() => vi.clearAllMocks());
describe("getSFUConfig", () => {
const defaultParams = {
client,
membership,
serverName: "example.org",
role: "subscriber" as const,
roomId,
slotId,
logger,
};
it("uses CS API with url transport", async () => {
expect(
await getSFUConfig({ ...defaultParams, transport: urlTransport }),
).toEqual(
expect.objectContaining({
jwt: csApiJWTToken,
livekitAlias: "via CS API",
}),
);
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
expect(openID.getSFUConfig).not.toHaveBeenCalled();
});
it("does not fall back to OpenID with url transport", async () => {
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
await expect(
getSFUConfig({ ...defaultParams, transport: urlTransport }),
).rejects.toThrow(expect.any(NoMatrix2AuthorizationService));
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
expect(openID.getSFUConfig).not.toHaveBeenCalled();
});
it("uses OpenID with livekit_service_url transport", async () => {
expect(
await getSFUConfig({ ...defaultParams, transport: serviceUrlTransport }),
).toEqual(
expect.objectContaining({
jwt: openIDJWTToken,
livekitAlias: "via OpenID",
}),
);
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
expect(openID.getSFUConfig).toHaveBeenCalled();
});
it("uses CS API with url + livekit_service_url transport", async () => {
expect(
await getSFUConfig({
...defaultParams,
transport: urlAndServiceUrlTransport,
}),
).toEqual(
expect.objectContaining({
jwt: csApiJWTToken,
livekitAlias: "via CS API",
}),
);
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
expect(openID.getSFUConfig).not.toHaveBeenCalled();
});
it("falls back to OpenID with url + livekit_service_url transport", async () => {
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
expect(
await getSFUConfig({
...defaultParams,
transport: urlAndServiceUrlTransport,
}),
).toEqual(
expect.objectContaining({
jwt: openIDJWTToken,
livekitAlias: "via OpenID",
}),
);
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
expect(openID.getSFUConfig).toHaveBeenCalled();
});
it("falls back to OpenID when the widget host refuses the CS API", async () => {
// In widget mode a host without MSC4533 rejects the request before it
// reaches the homeserver, with a plain error rather than a Matrix error
client._unstable_getLivekitToken.mockRejectedValueOnce(
new Error(
"The rtc_livekit_get_token action is not supported by the client.",
),
);
expect(
await getSFUConfig({
...defaultParams,
transport: urlAndServiceUrlTransport,
}),
).toEqual(expect.objectContaining({ jwt: openIDJWTToken }));
expect(openID.getSFUConfig).toHaveBeenCalled();
});
it("surfaces the widget host's refusal with url transport", async () => {
const error = new Error(
"The rtc_livekit_get_token action is not supported by the client.",
);
client._unstable_getLivekitToken.mockRejectedValueOnce(error);
await expect(
getSFUConfig({ ...defaultParams, transport: urlTransport }),
).rejects.toThrow(error);
expect(openID.getSFUConfig).not.toHaveBeenCalled();
});
it("uses CS API when publishing with url transport", async () => {
expect(
await getSFUConfig({
...defaultParams,
transport: urlTransport,
role: "publisher",
}),
).toEqual(expect.objectContaining({ jwt: csApiJWTToken }));
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
expect(openID.getSFUConfig).not.toHaveBeenCalled();
});
it("does not fall back to OpenID when publishing", async () => {
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
await expect(
getSFUConfig({
...defaultParams,
transport: urlTransport,
role: "publisher",
}),
).rejects.toThrow(expect.any(NoMatrix2AuthorizationService));
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
expect(openID.getSFUConfig).not.toHaveBeenCalled();
});
it("uses OpenID when publishing with url + livekit_service_url transport", async () => {
// Subscribers predating MSC4195 expect a legacy participant identity from
// anyone advertising a state event, which only the JWT service assigns
expect(
await getSFUConfig({
...defaultParams,
transport: urlAndServiceUrlTransport,
role: "publisher",
}),
).toEqual(
expect.objectContaining({
jwt: openIDJWTToken,
livekitAlias: "via OpenID",
}),
);
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
expect(openID.getSFUConfig).toHaveBeenCalledWith(
expect.objectContaining({ role: "publisher" }),
);
});
it("does not fall back to OpenID on connection error", async () => {
const client_ = {
...client,
_unstable_getLivekitToken: vi
.fn()
.mockRejectedValue(new ConnectionError("No connection")),
};
await Promise.all([
expect(
getSFUConfig({
...defaultParams,
client: client_,
transport: urlAndServiceUrlTransport,
}),
).rejects.toThrow(expect.any(ConnectionError)),
vi.runAllTimersAsync(), // Allow request to be retried
]);
expect(client_._unstable_getLivekitToken).toHaveBeenCalled();
expect(openID.getSFUConfig).not.toHaveBeenCalled();
});
});
describe("delegateDelayedLeave", () => {
const defaultParams = {
client,
membership,
roomId,
slotId,
delayId: "delay123",
logger,
};
it("uses CS API with url transport", async () => {
await delegateDelayedLeave({ ...defaultParams, transport: urlTransport });
expect(client._unstable_delegateDelayedLeave).toHaveBeenCalled();
expect(openID.getSFUConfig).not.toHaveBeenCalled();
});
it("does not fall back to OpenID with url transport", async () => {
client._unstable_delegateDelayedLeave.mockRejectedValueOnce(
unsupportedError,
);
await expect(
delegateDelayedLeave({ ...defaultParams, transport: urlTransport }),
).rejects.toThrow();
expect(client._unstable_delegateDelayedLeave).toHaveBeenCalled();
expect(openID.getSFUConfig).not.toHaveBeenCalled();
});
it("uses OpenID with livekit_service_url transport", async () => {
await delegateDelayedLeave({
...defaultParams,
transport: serviceUrlTransport,
});
expect(client._unstable_delegateDelayedLeave).not.toHaveBeenCalled();
expect(openID.getSFUConfig).toHaveBeenCalled();
});
it("uses OpenID with url + livekit_service_url transport", async () => {
// Must match the flow that got the token, so the identities line up
await delegateDelayedLeave({
...defaultParams,
transport: urlAndServiceUrlTransport,
});
expect(client._unstable_delegateDelayedLeave).not.toHaveBeenCalled();
expect(openID.getSFUConfig).toHaveBeenCalledWith(
expect.objectContaining({
role: "publisher",
delayId: "delay123",
delayEndpointBaseUrl: client.baseUrl,
}),
);
});
});
+253
View File
@@ -0,0 +1,253 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { ConnectionError, MatrixError } from "matrix-js-sdk";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import * as openID from "./openID";
import * as csApi from "./csApi";
import { type SFUConfig } from "./types";
import { NoMatrix2AuthorizationService } from "../../utils/errors";
export type { ClientOpenIDParts } from "./openID";
export type { ClientGetTokenParts, ClientDelegationParts } from "./csApi";
export type * from "./types";
/**
* Checks whether an error means "this homeserver does not implement the
* (unstable) MSC4195 endpoints".
*
* MSC4195 documents `M_NOT_FOUND` for this, but a homeserver without support
* answers with whatever it uses for unknown endpoints (Synapse: 404 with
* `M_UNRECOGNIZED`), so we accept any 404 as "unsupported".
* @param e The error to check.
*/
function isEndpointUnsupported(e: unknown): boolean {
return (
e instanceof MatrixError &&
(e.httpStatus === 404 ||
e.errcode === "M_NOT_FOUND" ||
e.errcode === "M_UNRECOGNIZED")
);
}
/**
* Checks whether an error means the MSC4195 CS API cannot be used from here:
* the homeserver does not implement it, or the request never reached the
* homeserver because in widget mode the host does not expose the endpoint
* (MSC4533), which the widget API reports as a plain error rather than a
* Matrix error. A connection error is neither, as the endpoint may well exist.
* @param e The error to check.
*/
function isCsApiUnavailable(e: unknown): boolean {
if (isEndpointUnsupported(e)) return true;
return !(e instanceof MatrixError) && !(e instanceof ConnectionError);
}
export interface GetSFUConfigParams {
/**
* The Matrix client.
*/
client: csApi.ClientGetTokenParts & openID.ClientOpenIDParts;
/**
* Data identifying the local user's session membership.
*/
membership: CallMembershipIdentityParts;
/**
* The transport for which we wish to get an access token.
*/
transport: UnstableLivekitTransport;
/**
* The name of the homeserver to which the {@link transport} belongs.
*/
serverName: string;
/**
* The ID of the Matrix room in which the session takes place.
*/
roomId: string;
/**
* The ID of the MatrixRTC slot in which the session takes place.
*/
slotId: string;
/**
* Whether we want to publish or only subscribe on the {@link transport}.
*/
role: "publisher" | "subscriber";
logger: Logger;
}
/**
* Gets an {@link SFUConfig} appropriate for connecting to a given transport.
*/
export async function getSFUConfig({
client,
membership,
transport,
serverName,
roomId,
slotId,
role,
logger,
}: GetSFUConfigParams): Promise<SFUConfig> {
const viaOpenID = async (serviceUrl: string): Promise<SFUConfig> =>
await openID.getSFUConfig({
client,
membership,
serviceUrl,
roomId,
slotId,
role,
logger,
});
const flow = chooseAuthFlow(transport, role);
if (flow.kind === "openID") return await viaOpenID(flow.serviceUrl);
try {
return await csApi.getSFUConfig({
client,
membership,
url: flow.url,
serverName,
roomId,
slotId,
});
} catch (e) {
// Publishers should never fall back. If a homeserver advertises a `url`
// but doesn't support MSC4195 itself, that's a misconfiguration which
// the admin should know about so they can prepare for the future
// deprecation of `livekit_service_url`.
const mayFallBack =
role === "subscriber" && "livekit_service_url" in transport;
if (mayFallBack && isCsApiUnavailable(e)) {
logger.warn(
`Cannot use the MSC4195 get_token endpoint (${e}), falling back to the OpenID flow with service ${transport.livekit_service_url}`,
);
return await viaOpenID(transport.livekit_service_url);
}
if (isEndpointUnsupported(e)) {
logger.error(
`Homeserver does not support the MSC4195 get_token endpoint, cannot get token for transport ${flow.url}`,
);
throw new NoMatrix2AuthorizationService(e as Error);
}
throw e;
}
}
export interface DelegateDelayedLeaveParams {
/**
* The Matrix client.
*/
client: csApi.ClientDelegationParts & openID.ClientOpenIDParts;
/**
* Data identifying the local user's session membership.
*/
membership: CallMembershipIdentityParts;
/**
* The transport to which we are publishing and wish to delegate the delayed
* leave event.
*/
transport: UnstableLivekitTransport;
/**
* The ID of the Matrix room in which the session takes place.
*/
roomId: string;
/**
* The ID of the MatrixRTC slot in which the session takes place.
*/
slotId: string;
/**
* The delay ID of the leave event to be delegated.
*/
delayId: string;
logger: Logger;
}
/**
* Delegates a delayed leave event to a given transport, so that the event will
* be sent on our behalf whenever we disconnect from the transport's SFU.
*/
export async function delegateDelayedLeave({
client,
membership,
transport,
roomId,
slotId,
delayId,
logger,
}: DelegateDelayedLeaveParams): Promise<void> {
// It's important that we use the same flow (CS API or OpenID) as whatever we
// would've used to get a JWT token for this transport, since the participant
// identity computed by the service needs to match.
const flow = chooseAuthFlow(transport, "publisher");
if (flow.kind === "csApi") {
try {
await csApi.delegateDelayedLeave({
client,
membership,
url: flow.url,
roomId,
slotId,
delayId,
});
} catch (e) {
if (isEndpointUnsupported(e))
logger.error(
"Server misconfigured: transport contains a 'url' field but server does not support the MSC4195 delegate_delayed_leave endpoint",
);
throw e;
}
} else {
// This will technically cause the service to issue a new JWT token, but
// it's safe to discard. We're only interested in triggering delegation.
await openID.getSFUConfig({
client,
membership,
serviceUrl: flow.serviceUrl,
roomId,
slotId,
role: "publisher",
delayEndpointBaseUrl: client.baseUrl,
delayId,
logger,
});
}
}
/**
* The flow with which the local member authenticates against a transport.
*/
export type AuthFlow =
| { kind: "csApi"; url: string }
| { kind: "openID"; serviceUrl: string };
/**
* Chooses between the MSC4195 CS API and the legacy OpenID flow against the
* transport's JWT service.
*
* Only the JWT service's `/sfu/get` endpoint assigns the legacy participant
* identity that clients predating MSC4195 expect from anyone advertising a
* membership state event. So a publisher keeps using the JWT service wherever
* the transport still offers one, even if it also has a `url`. Subscribers may
* use the CS API regardless, as nobody matches their identity.
* @param transport The transport to authenticate against.
* @param role Whether we want to publish or only subscribe on the transport.
*/
export function chooseAuthFlow(
transport: UnstableLivekitTransport,
role: "publisher" | "subscriber",
): AuthFlow {
if ("url" in transport) {
const useCsApi =
!("livekit_service_url" in transport) || role === "subscriber";
if (useCsApi) return { kind: "csApi", url: transport.url };
}
return { kind: "openID", serviceUrl: transport.livekit_service_url };
}
+230
View File
@@ -0,0 +1,230 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
beforeEach,
afterEach,
describe,
expect,
it,
type MockedObject,
vitest,
} from "vitest";
import fetchMock from "fetch-mock";
import { MatrixError } from "matrix-js-sdk";
import {
getSFUConfig,
type GetSFUConfigParams,
type ClientOpenIDParts,
} from "./openID";
import { testJWTToken } from "../../utils/test-fixtures";
import { ownMemberMock } from "../../utils/test";
import { FailToGetOpenIdToken } from "../../utils/errors";
import { logger } from "matrix-js-sdk/lib/logger";
const serviceUrl = "https://sfu.example.org";
const delayParams = {
delayEndpointBaseUrl: "https://matrix.homeserverserver.org",
delayId: "mock_delay_id",
};
describe("getSFUConfig", () => {
let matrixClient: MockedObject<ClientOpenIDParts>;
let params: GetSFUConfigParams;
beforeEach(() => {
fetchMock.catch(404);
matrixClient = { getOpenIdToken: vitest.fn() };
params = {
client: matrixClient,
membership: ownMemberMock,
serviceUrl: "https://sfu.example.org",
roomId: "!example_room_id",
slotId: "m.call#ROOM",
role: "publisher",
logger,
};
});
afterEach(async () => {
void (await fetchMock.flush());
vitest.clearAllMocks();
fetchMock.reset();
});
it("should handle fetching a token", async () => {
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 200,
body: { url: serviceUrl, jwt: testJWTToken },
};
});
const config = await getSFUConfig(params);
expect(config).toEqual({
jwt: testJWTToken,
url: serviceUrl,
livekitIdentity: "@me:example.org:ABCDEF",
livekitAlias: "!example_room_id",
});
});
it("should fail if the SFU errors", async () => {
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 500,
body: {
errcode: "M_LOOKUP_FAILED",
error: "Failed to look up user info from homeserver",
},
};
});
try {
await getSFUConfig(params);
} catch (ex: unknown) {
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
expect((ex as FailToGetOpenIdToken).cause).toBeInstanceOf(MatrixError);
const mxError = (ex as Error).cause as MatrixError;
expect(mxError.message).toEqual(
"MatrixError: [500] Failed to look up user info from homeserver",
);
return;
}
expect.fail("Expected test to throw;");
});
it("should retry without delay params if the JWT service legacy endpoint returns M_BAD_JSON 400", async () => {
let callCount = 0;
fetchMock.post(
"https://sfu.example.org/sfu/get",
(url, opts) => {
callCount++;
const body = JSON.parse(opts.body as string);
// First call: check if it has delay parts and return 400
if (callCount === 1) {
expect(body).toHaveProperty("delay_id", "mock_delay_id");
return {
status: 400,
body: { errcode: "M_BAD_JSON", error: "Unsupported parameters" },
};
}
// Second call: check if delay parts were stripped and return success
expect(body).not.toHaveProperty("delay_id");
expect(body).not.toHaveProperty("delay_timeout");
expect(body).not.toHaveProperty("delay_cs_api_url");
return {
status: 200,
body: { url: serviceUrl, jwt: testJWTToken },
};
},
{ overwriteRoutes: true },
);
// Note: Assuming getSFUConfig eventually calls getLiveKitJWT
const config = await getSFUConfig({ ...params, ...delayParams });
expect(config.jwt).toBe(testJWTToken);
expect(callCount).toBe(2);
});
it("should successfully send delay parameters to the JWT service legacy endpoint", async () => {
fetchMock.post(
"https://sfu.example.org/sfu/get",
(url, opts) => {
const body = JSON.parse(opts.body as string);
// Verify, that the request contains the expected delay parameters
if (
body.delay_id === "mock_delay_id" &&
body.delay_timeout === 3600000 &&
body.delay_cs_api_url === "https://matrix.homeserverserver.org"
) {
return {
status: 200,
body: { url: serviceUrl, jwt: testJWTToken },
};
}
return {
status: 400,
body: { error: "Missing expected delay params" },
};
},
{ overwriteRoutes: true },
);
const config = await getSFUConfig({ ...params, ...delayParams });
// Prüfe das Ergebnis
expect(config).toMatchObject({
jwt: testJWTToken,
url: serviceUrl,
});
});
it("should use the default endpoint when subscribing", async () => {
fetchMock.post("https://sfu.example.org/get_token", (url, opts) => {
const body = JSON.parse(opts.body as string);
expect(body).toMatchObject({
room_id: "!example_room_id",
slot_id: "m.call#ROOM",
member: {
id: ownMemberMock.memberId,
claimed_user_id: ownMemberMock.userId,
claimed_device_id: ownMemberMock.deviceId,
},
});
return { status: 200, body: { url: serviceUrl, jwt: testJWTToken } };
});
const config = await getSFUConfig({ ...params, role: "subscriber" });
expect(config.jwt).toBe(testJWTToken);
expect(fetchMock.called("https://sfu.example.org/sfu/get")).toBe(false);
});
it("should fail when subscribing if the default endpoint is missing", async () => {
// Nothing but the catch-all 404 answers the default endpoint. Subscribers
// must not fall back to the legacy endpoint, which would hand them a
// legacy identity.
await expect(
getSFUConfig({ ...params, role: "subscriber" }),
).rejects.toThrow(FailToGetOpenIdToken);
expect(fetchMock.called("https://sfu.example.org/get_token")).toBe(true);
expect(fetchMock.called("https://sfu.example.org/sfu/get")).toBe(false);
});
it("should retry fetching the openid token", async () => {
let count = 0;
matrixClient.getOpenIdToken.mockImplementation(async () => {
count++;
if (count < 2) {
throw Error("Test failure");
}
return Promise.resolve({
token_type: "Bearer",
access_token: "foobar",
matrix_server_name: "example.org",
expires_in: 30,
});
});
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 200,
body: { url: serviceUrl, jwt: testJWTToken },
};
});
const config = await getSFUConfig({ ...params, ...delayParams });
expect(config).toEqual({
jwt: testJWTToken,
url: serviceUrl,
livekitIdentity: "@me:example.org:ABCDEF",
livekitAlias: "!example_room_id",
});
});
});
+262
View File
@@ -0,0 +1,262 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type IOpenIDToken,
type MatrixClient,
parseErrorResponse,
} 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 { doNetworkOperationWithRetry } from "../../utils/matrix";
import { Config } from "../../config/Config";
import { extractFullConfigFromToken, type SFUConfig } from "./types";
// The bits we need from MatrixClient
export type ClientOpenIDParts = Pick<MatrixClient, "getOpenIdToken">;
export interface GetSFUConfigParams {
client: ClientOpenIDParts;
/**
* Data identifying the local user's session membership.
*/
membership: CallMembershipIdentityParts;
/**
* The base URL of the LiveKit JWT service.
*/
serviceUrl: string;
/**
* The ID of the Matrix room in which the session takes place.
*/
roomId: string;
/**
* The ID of the MatrixRTC slot in which the session takes place.
*/
slotId: string;
/**
* Whether we want to publish or only subscribe on the {@link transport}.
*/
role: "publisher" | "subscriber";
/**
* The base URL of the Matrix homeserver, for delayed leave delegation.
*/
delayEndpointBaseUrl?: string;
/**
* The delay ID of the leave event to be delegated to the JWT service.
*/
delayId?: string;
logger: Logger;
}
/**
* Gets an {@link SFUConfig} appropriate for connecting to the SFU behind a
* given service URL, using the legacy OpenID flow.
* @throws {@link FailToGetOpenIdToken}
*/
export async function getSFUConfig({
client,
membership,
serviceUrl,
roomId,
slotId,
role,
delayEndpointBaseUrl,
delayId,
logger,
}: GetSFUConfigParams): Promise<SFUConfig> {
let openIdToken: IOpenIDToken;
try {
openIdToken = await doNetworkOperationWithRetry(async () =>
client.getOpenIdToken(),
);
} catch (error) {
throw new FailToGetOpenIdToken(
error instanceof Error ? error : new Error("Unknown error"),
);
}
logger.debug("Got openID token", openIdToken);
let sfuConfig: { url: string; jwt: string } | undefined;
let endpoint: "default" | "legacy";
if (role === "publisher") {
// When publishing a legacy transport (one with a `livekit_service_url`),
// subscribers will expect our participant identity to use the legacy
// `@user_id:device_id` format. Only the legacy JWT service endpoint assigns
// identities in this format, so we must use it.
endpoint = "legacy";
} else {
// Use the default endpoint as it affords us slightly better metadata
// protection (pseudonymous participant identities).
endpoint = "default";
}
try {
logger.info(
`Trying to get JWT with ${endpoint} endpoint for focus ${serviceUrl}...`,
);
sfuConfig = await (
endpoint === "legacy" ? getLiveKitJWTLegacy : getLiveKitJWT
)(
membership,
serviceUrl,
roomId,
slotId,
openIdToken,
delayEndpointBaseUrl,
delayId,
);
logger.info(`Got JWT from call's active focus URL.`);
return extractFullConfigFromToken(sfuConfig);
} catch (e) {
logger.error(`Failed fetching jwt with ${endpoint} endpoint:`, e);
throw new FailToGetOpenIdToken(
e instanceof Error ? e : new Error(`Unknown error ${e}`),
);
}
}
/**
* Gets a JWT token appropriate for connecting to the SFU behind a given service
* URL, using the legacy `/sfu/get` endpoint, which assigns us a legacy
* (`<user_id>:<device_id>`) participant identity.
*/
async function getLiveKitJWTLegacy(
membership: CallMembershipIdentityParts,
livekitServiceURL: string,
matrixRoomId: string,
_slotId: string, // Unused - the legacy endpoint is from before slots existed.
openIDToken: IOpenIDToken,
delayEndpointBaseUrl?: string,
delayId?: string,
): Promise<{ url: string; jwt: string }> {
interface IDelayParams {
delay_id?: string;
delay_timeout?: number;
delay_cs_api_url?: string;
}
let bodyDalayParts: IDelayParams = {};
// Also check for empty string
if (delayId && delayEndpointBaseUrl) {
bodyDalayParts = {
delay_id: delayId,
delay_timeout:
Config.get().matrix_rtc_session.delegated_delayed_leave.delay_ms,
delay_cs_api_url: delayEndpointBaseUrl,
};
}
const makeRequest = async (delayParts: IDelayParams): Promise<Response> => {
return await fetch(livekitServiceURL + "/sfu/get", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
// The legacy JWT endpoint uses only the matrix room id to calculate the livekit room alias.
// However, the livekit room alias is provided as part of the JWT payload.
room: matrixRoomId,
openid_token: openIDToken,
device_id: membership.deviceId,
...delayParts,
}),
});
};
const res = await doNetworkOperationWithRetry(async () => {
let response = await makeRequest(bodyDalayParts);
// Old service compatibility check
const oldServiceDoesNotSupportDelayParts =
response.status === 400 && Object.keys(bodyDalayParts).length > 0;
// If http status 400 with M_BAD_JSON and we sent delay parts, retry without them
if (oldServiceDoesNotSupportDelayParts) {
try {
const errorBody = await response.json();
if (errorBody.errcode === "M_BAD_JSON") {
response = await makeRequest({});
}
} catch {
// If we can't parse the error, treat as real error
}
}
return response;
});
if (!res.ok) {
throw parseErrorResponse(res, await res.text());
}
return await res.json();
}
class NotSupportedError extends Error {
public constructor(message: string) {
super(message);
this.name = "NotSupported";
}
}
/**
* Gets a JWT token appropriate for connecting to the SFU behind a given service
* URL, using the default `/get_token` endpoint, which assigns us a hashed
* (pseudonymised) participant identity.
*/
async function getLiveKitJWT(
membership: CallMembershipIdentityParts,
livekitServiceURL: string,
matrixRoomId: string,
slotId: string,
openIDToken: IOpenIDToken,
delayEndpointBaseUrl?: string,
delayId?: string,
): Promise<{ url: string; jwt: string }> {
const { userId, deviceId, memberId } = membership;
const body = {
room_id: matrixRoomId,
slot_id: slotId,
openid_token: openIDToken,
member: {
id: memberId,
claimed_user_id: userId,
claimed_device_id: deviceId,
},
};
let bodyDalayParts = {};
// Also check for empty string
if (delayId && delayEndpointBaseUrl) {
bodyDalayParts = {
delay_id: delayId,
delay_timeout:
Config.get().matrix_rtc_session.delegated_delayed_leave.delay_ms,
delay_cs_api_url: delayEndpointBaseUrl,
};
}
const res = await doNetworkOperationWithRetry(async () => {
return await fetch(livekitServiceURL + "/get_token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ ...body, ...bodyDalayParts }),
});
});
if (!res.ok) {
const msg = "SFU Config fetch failed with status code " + res.status;
if (res.status === 404) {
throw new NotSupportedError(msg);
} else {
throw parseErrorResponse(res, await res.text());
}
}
return await res.json();
}
+90
View File
@@ -0,0 +1,90 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
/**
* Data identifying a transport and the homeserver to which it belongs, so
* consumers know how to reach it.
*/
export interface TransportLocator {
transport: UnstableLivekitTransport;
serverName: string;
}
/**
* Configuration and access tokens provided by the SFU on successful authentication.
*/
export interface SFUConfig {
/**
* The WebSocket URL of the LiveKit SFU. This is what we connect the LiveKit
* room to. Note that this is NOT the JWT service URL
* (`livekit_service_url`): the legacy JWT service returns this URL as part of
* its response, while the CS-Api based flow requires us to know it upfront.
*/
url: string;
jwt: string;
livekitAlias: string;
// NOTE: Currently unused.
livekitIdentity: string;
}
/**
* Decoded details from the JWT.
*/
interface SFUJWTPayload {
/**
* Expiration time for the JWT.
* Note: This value is in seconds since Unix epoch.
*/
exp: number;
/**
* Name of the instance which authored the JWT
*/
iss: string;
/**
* Time at which the JWT can start to be used.
* Note: This value is in seconds since Unix epoch.
*/
nbf: number;
/**
* Subject. The Livekit alias in this context.
*/
sub: string;
/**
* The set of permissions for the user.
*/
video: {
canPublish: boolean;
canSubscribe: boolean;
room: string;
roomJoin: boolean;
};
}
/**
* Complements the SFU websocket url and the JWT with the information encoded in
* the JWT payload itself.
* @param sfuConfig The SFU websocket url and the JWT to connect with.
* @returns The full SFU config, including the LiveKit alias and identity.
*/
export function extractFullConfigFromToken(sfuConfig: {
url: string;
jwt: string;
}): SFUConfig {
const [, payloadStr] = sfuConfig.jwt.split(".");
const payload = JSON.parse(global.atob(payloadStr)) as SFUJWTPayload;
return {
jwt: sfuConfig.jwt,
url: sfuConfig.url,
livekitAlias: payload.video.room,
// 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,
};
}
-329
View File
@@ -1,329 +0,0 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
beforeEach,
afterEach,
describe,
expect,
it,
type MockedObject,
vitest,
} from "vitest";
import fetchMock from "fetch-mock";
import { MatrixError } from "matrix-js-sdk";
import { getSFUConfigWithOpenID, type OpenIDClientParts } from "./openIDSFU";
import { testJWTToken } from "../utils/test-fixtures";
import { ownMemberMock } from "../utils/test";
import { FailToGetOpenIdToken } from "../utils/errors";
const sfuUrl = "https://sfu.example.org";
describe("getSFUConfigWithOpenID", () => {
let matrixClient: MockedObject<OpenIDClientParts>;
beforeEach(() => {
fetchMock.catch(404);
matrixClient = {
getOpenIdToken: vitest.fn(),
getDeviceId: vitest.fn(),
};
});
afterEach(() => {
vitest.clearAllMocks();
fetchMock.reset();
});
it("should handle fetching a token", async () => {
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 200,
body: { url: sfuUrl, jwt: testJWTToken },
};
});
const config = await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
);
expect(config).toEqual({
jwt: testJWTToken,
url: sfuUrl,
livekitIdentity: "@me:example.org:ABCDEF",
livekitAlias: "!example_room_id",
});
void (await fetchMock.flush());
});
it("should fail if the SFU errors", async () => {
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 500,
body: {
errcode: "M_LOOKUP_FAILED",
error: "Failed to look up user info from homeserver",
},
};
});
try {
await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
);
} catch (ex: unknown) {
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
expect((ex as FailToGetOpenIdToken).cause).toBeInstanceOf(MatrixError);
const mxError = (ex as Error).cause as MatrixError;
expect(mxError.message).toEqual(
"MatrixError: [500] Failed to look up user info from homeserver",
);
void (await fetchMock.flush());
return;
}
expect.fail("Expected test to throw;");
});
it("should retry without delay params if the JWT service legacy endpoint returns M_BAD_JSON 400", async () => {
let callCount = 0;
fetchMock.post(
"https://sfu.example.org/sfu/get",
(url, opts) => {
callCount++;
const body = JSON.parse(opts.body as string);
// First call: check if it has delay parts and return 400
if (callCount === 1) {
expect(body).toHaveProperty("delay_id", "mock_delay_id");
return {
status: 400,
body: { errcode: "M_BAD_JSON", error: "Unsupported parameters" },
};
}
// Second call: check if delay parts were stripped and return success
expect(body).not.toHaveProperty("delay_id");
expect(body).not.toHaveProperty("delay_timeout");
expect(body).not.toHaveProperty("delay_cs_api_url");
return {
status: 200,
body: { url: sfuUrl, jwt: testJWTToken },
};
},
{ overwriteRoutes: true },
);
// Note: Assuming getSFUConfigWithOpenID eventually calls getLiveKitJWT
const config = await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
{
delayEndpointBaseUrl: "https://matrix.homeserverserver.org",
delayId: "mock_delay_id",
},
);
expect(config.jwt).toBe(testJWTToken);
expect(callCount).toBe(2);
void (await fetchMock.flush());
});
it("should successfully send delay parameters to the JWT service legacy endpoint", async () => {
fetchMock.post(
"https://sfu.example.org/sfu/get",
(url, opts) => {
const body = JSON.parse(opts.body as string);
// Verify, that the request contains the expected delay parameters
if (
body.delay_id === "mock_delay_id" &&
body.delay_timeout === 3600000 &&
body.delay_cs_api_url === "https://homeserverserver.org/cs_api"
) {
return {
status: 200,
body: { url: sfuUrl, jwt: testJWTToken },
};
}
return {
status: 400,
body: { error: "Missing expected delay params" },
};
},
{ overwriteRoutes: true },
);
const config = await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
{
delayEndpointBaseUrl: "https://homeserverserver.org/cs_api",
delayId: "mock_delay_id",
},
);
// Prüfe das Ergebnis
expect(config).toMatchObject({
jwt: testJWTToken,
url: sfuUrl,
});
void (await fetchMock.flush());
});
it("should try legacy and then new endpoint with delay delegation", async () => {
fetchMock.post("https://sfu.example.org/get_token", () => {
return {
status: 500,
body: {
errcode: "M_LOOKUP_FAILED",
error: "Failed to look up user info from homeserver",
},
};
});
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 500,
body: {
errcode: "M_LOOKUP_FAILED",
error: "Failed to look up user info from homeserver",
},
};
});
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).toBeInstanceOf(FailToGetOpenIdToken);
expect((ex as FailToGetOpenIdToken).cause).toBeInstanceOf(MatrixError);
const mxError = (ex as Error).cause as MatrixError;
expect(mxError.message).toEqual(
"MatrixError: [500] Failed to look up user info from homeserver",
);
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":3600000,"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","delay_id":"mock_delay_id","delay_timeout":3600000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
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).toBeInstanceOf(FailToGetOpenIdToken);
expect((ex as FailToGetOpenIdToken).cause).toEqual(
new Error("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":3600000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
});
it("should retry fetching the openid token", async () => {
let count = 0;
matrixClient.getOpenIdToken.mockImplementation(async () => {
count++;
if (count < 2) {
throw Error("Test failure");
}
return Promise.resolve({
token_type: "Bearer",
access_token: "foobar",
matrix_server_name: "example.org",
expires_in: 30,
});
});
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 200,
body: { url: sfuUrl, jwt: testJWTToken },
};
});
const config = await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
);
expect(config).toEqual({
jwt: testJWTToken,
url: sfuUrl,
livekitIdentity: "@me:example.org:ABCDEF",
livekitAlias: "!example_room_id",
});
void (await fetchMock.flush());
});
});
-315
View File
@@ -1,315 +0,0 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type IOpenIDToken,
type MatrixClient,
parseErrorResponse,
} 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,
NoMatrix2AuthorizationService,
} from "../utils/errors";
import { doNetworkOperationWithRetry } from "../utils/matrix";
import { Config } from "../config/Config";
import { MatrixRTCMode } from "../config/ConfigOptions";
/**
* Configuration and access tokens provided by the SFU on successful authentication.
*/
export interface SFUConfig {
url: string;
jwt: string;
livekitAlias: string;
// NOTE: Currently unused.
livekitIdentity: string;
}
/**
* Decoded details from the JWT.
*/
interface SFUJWTPayload {
/**
* Expiration time for the JWT.
* Note: This value is in seconds since Unix epoch.
*/
exp: number;
/**
* Name of the instance which authored the JWT
*/
iss: string;
/**
* Time at which the JWT can start to be used.
* Note: This value is in seconds since Unix epoch.
*/
nbf: number;
/**
* Subject. The Livekit alias in this context.
*/
sub: string;
/**
* The set of permissions for the user.
*/
video: {
canPublish: boolean;
canSubscribe: boolean;
room: string;
roomJoin: boolean;
};
}
// The bits we need from MatrixClient
export type OpenIDClientParts = Pick<
MatrixClient,
"getOpenIdToken" | "getDeviceId"
>;
/**
* Gets a bearer token from the homeserver and then use it to authenticate
* 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.
* @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 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 acquire the jwt token.
* @param opts.matrixRTCMode Determines which version of the JWT endpoint to use, which affects whether the
* RTC backend identity is based on string concatenation (legacy) or a hash (Matrix 2.0).
* 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.
* @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
* @throws FailToGetOpenIdToken
*/
export async function getSFUConfigWithOpenID(
client: OpenIDClientParts,
membership: CallMembershipIdentityParts,
serviceUrl: string,
roomId: string,
opts?: {
matrixRTCMode?: MatrixRTCMode;
delayEndpointBaseUrl?: string;
delayId?: string;
},
logger?: Logger,
): Promise<SFUConfig> {
let openIdToken: IOpenIDToken;
try {
openIdToken = await doNetworkOperationWithRetry(async () =>
client.getOpenIdToken(),
);
} catch (error) {
throw new FailToGetOpenIdToken(
error instanceof Error ? error : new Error("Unknown error"),
);
}
logger?.debug("Got openID token", openIdToken);
let sfuConfig: { url: string; jwt: string } | undefined;
const tryBothJwtEndpoints = opts?.matrixRTCMode === undefined; // This is for SFUs where we do not publish.
const forceMatrix2Jwt = opts?.matrixRTCMode === MatrixRTCMode.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 {
logger?.info(
`Trying to get JWT via default endpoint for focus ${serviceUrl}...`,
);
const sfuConfig = await getLiveKitJWT(
membership,
serviceUrl,
roomId,
openIdToken,
opts?.delayEndpointBaseUrl,
opts?.delayId,
);
return extractFullConfigFromToken(sfuConfig);
} catch (e) {
logger?.debug(`Failed fetching jwt with matrix 2.0 endpoint:`, e);
// Make this throw a hard error in case we force the matrix2.0 endpoint.
if (forceMatrix2Jwt) {
throw new NoMatrix2AuthorizationService(e as Error);
}
}
}
// DEPRECATED
// here we either have a sfuConfig or we already exited because of `if (forceMatrix2) throw ...`
// The only case we can get into this condition is, if `forceMatrix2` is `false`
try {
logger?.info(
`Trying to get JWT with legacy endpoint for focus ${serviceUrl}...`,
);
sfuConfig = await getLiveKitJWTLegacy(
membership.deviceId,
serviceUrl,
roomId,
openIdToken,
opts?.delayEndpointBaseUrl,
opts?.delayId,
);
logger?.info(`Got JWT from call's active focus URL.`);
return extractFullConfigFromToken(sfuConfig);
} catch (ex) {
throw new FailToGetOpenIdToken(
ex instanceof Error ? ex : new Error(`Unknown error ${ex}`),
);
}
}
function extractFullConfigFromToken(sfuConfig: {
url: string;
jwt: string;
}): SFUConfig {
const [, payloadStr] = sfuConfig.jwt.split(".");
const payload = JSON.parse(global.atob(payloadStr)) as SFUJWTPayload;
return {
jwt: sfuConfig.jwt,
url: sfuConfig.url,
livekitAlias: payload.video.room,
// 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,
};
}
async function getLiveKitJWTLegacy(
deviceId: string,
livekitServiceURL: string,
matrixRoomId: string,
openIDToken: IOpenIDToken,
delayEndpointBaseUrl?: string,
delayId?: string,
): Promise<{ url: string; jwt: string }> {
interface IDelayParams {
delay_id?: string;
delay_timeout?: number;
delay_cs_api_url?: string;
}
let bodyDalayParts: IDelayParams = {};
// Also check for empty string
if (delayId && delayEndpointBaseUrl) {
bodyDalayParts = {
delay_id: delayId,
delay_timeout:
Config.get().matrix_rtc_session.delegated_delayed_leave.delay_ms,
delay_cs_api_url: delayEndpointBaseUrl,
};
}
const makeRequest = async (delayParts: IDelayParams): Promise<Response> => {
return await fetch(livekitServiceURL + "/sfu/get", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
// The legacy JWT endpoint uses only the matrix room id to calculate the livekit room alias.
// However, the livekit room alias is provided as part of the JWT payload.
room: matrixRoomId,
openid_token: openIDToken,
device_id: deviceId,
...delayParts,
}),
});
};
const res = await doNetworkOperationWithRetry(async () => {
let response = await makeRequest(bodyDalayParts);
// Old service compatibility check
const oldServiceDoesNotSupportDelayParts =
response.status === 400 && Object.keys(bodyDalayParts).length > 0;
// If http status 400 with M_BAD_JSON and we sent delay parts, retry without them
if (oldServiceDoesNotSupportDelayParts) {
try {
const errorBody = await response.json();
if (errorBody.errcode === "M_BAD_JSON") {
response = await makeRequest({});
}
} catch {
// If we can't parse the error, treat as real error
}
}
return response;
});
if (!res.ok) {
throw parseErrorResponse(res, await res.text());
}
return await res.json();
}
class NotSupportedError extends Error {
public constructor(message: string) {
super(message);
this.name = "NotSupported";
}
}
export async function getLiveKitJWT(
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) {
bodyDalayParts = {
delay_id: delayId,
delay_timeout:
Config.get().matrix_rtc_session.delegated_delayed_leave.delay_ms,
delay_cs_api_url: delayEndpointBaseUrl,
};
}
const res = await doNetworkOperationWithRetry(async () => {
return await fetch(livekitServiceURL + "/get_token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ ...body, ...bodyDalayParts }),
});
});
if (!res.ok) {
const msg = "SFU Config fetch failed with status code " + res.status;
if (res.status === 404) {
throw new NotSupportedError(msg);
} else {
throw parseErrorResponse(res, await res.text());
}
}
return await res.json();
}
+2 -2
View File
@@ -17,7 +17,7 @@ import {
import { act } from "react"; import { act } from "react";
import { type RoomMember } from "matrix-js-sdk"; import { type RoomMember } from "matrix-js-sdk";
import { import {
type LivekitTransport, type UnstableLivekitTransport,
type CallMembership, type CallMembership,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
@@ -46,7 +46,7 @@ vitest.mock("../useAudioContext");
vitest.mock("../soundUtils"); vitest.mock("../soundUtils");
vitest.mock("../rtcSessionHelpers", async (importOriginal) => ({ vitest.mock("../rtcSessionHelpers", async (importOriginal) => ({
...(await importOriginal()), ...(await importOriginal()),
makeTransport: (): [LivekitTransport] => [exampleTransport], makeTransport: (): [UnstableLivekitTransport] => [exampleTransport],
})); }));
afterEach(() => { afterEach(() => {
+16 -12
View File
@@ -665,10 +665,10 @@ export const InCallView: FC<InCallViewProps> = ({
onPointerOut={onPointerOut} onPointerOut={onPointerOut}
> >
{header} {header}
{audioParticipants.map(({ livekitRoom, url, participants }) => ( {audioParticipants.map(({ livekitRoom, transport, participants }) => (
<LivekitRoomAudioRenderer <LivekitRoomAudioRenderer
key={url} key={JSON.stringify(transport)}
url={url} transport={transport}
livekitRoom={livekitRoom} livekitRoom={livekitRoom}
validIdentities={participants} validIdentities={participants}
muted={muteAllAudio} muted={muteAllAudio}
@@ -694,15 +694,19 @@ export const InCallView: FC<InCallViewProps> = ({
tab={settingsTab} tab={settingsTab}
onTabChange={setSettingsTab} onTabChange={setSettingsTab}
developerSettingsVm={developerSettingsVm} developerSettingsVm={developerSettingsVm}
livekitRooms={allConnections livekitRooms={allConnections.map(
.getConnections() ({ livekitRoom: room, livekitAlias, transport, role }) => ({
.map((connectionItem) => ({ room,
room: connectionItem.livekitRoom, livekitAlias,
livekitAlias: connectionItem.livekitAlias, role,
// TODO compute is local or tag it in the livekit room items already url:
isLocal: undefined, // It should be relatively clear to the user which kind of URL
url: connectionItem.transport.livekit_service_url, // this is, based on the scheme (ws:// vs. http://)
}))} "url" in transport
? transport.url
: transport.livekit_service_url,
}),
)}
/> />
</> </>
)} )}
+63 -40
View File
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import { afterEach, describe, expect, it, type Mock, vi } from "vitest"; import { afterEach, describe, expect, it, type Mock, vi } from "vitest";
import { render, waitFor, screen } from "@testing-library/react"; import { render, waitFor, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent, { type UserEvent } from "@testing-library/user-event";
import { TooltipProvider } from "@vector-im/compound-web"; import { TooltipProvider } from "@vector-im/compound-web";
import type { MatrixClient } from "matrix-js-sdk"; import type { MatrixClient } from "matrix-js-sdk";
@@ -18,14 +18,14 @@ import {
} from "./DeveloperSettingsTab"; } from "./DeveloperSettingsTab";
import { outOfCallDeveloperSettingsTabViewModel } from "./DeveloperSettingsTabViewModel"; import { outOfCallDeveloperSettingsTabViewModel } from "./DeveloperSettingsTabViewModel";
import { createStaticViewModel } from "../state/ViewModel"; import { createStaticViewModel } from "../state/ViewModel";
import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
import { import {
customLivekitUrl as customLivekitUrlSetting, customTransport as customTransportSetting,
enableExtendedLivekitLogs as enableExtendedLivekitLogsSetting, enableExtendedLivekitLogs as enableExtendedLivekitLogsSetting,
matrixRTCMode as matrixRTCModeSetting, matrixRTCMode as matrixRTCModeSetting,
} from "./settings"; } from "./settings";
import { MatrixRTCMode } from "../config/ConfigOptions"; import { MatrixRTCMode } from "../config/ConfigOptions";
import { mockConfig } from "../utils/test"; import { exampleTransport, mockConfig } from "../utils/test";
import { getSFUConfig } from "../livekit/auth";
// Mock url params hook to avoid environment-dependent snapshot churn. // Mock url params hook to avoid environment-dependent snapshot churn.
vi.mock("../UrlParams", () => ({ vi.mock("../UrlParams", () => ({
@@ -36,8 +36,8 @@ vi.mock("../UrlParams", () => ({
})); }));
// IMPORTANT: mock the same specifier used by DeveloperSettingsTab // IMPORTANT: mock the same specifier used by DeveloperSettingsTab
vi.mock("../livekit/openIDSFU", () => ({ vi.mock("../livekit/auth", () => ({
getSFUConfigWithOpenID: vi.fn().mockResolvedValue({ getSFUConfig: vi.fn().mockResolvedValue({
url: "mock-url", url: "mock-url",
jwt: "mock-jwt", jwt: "mock-jwt",
}), }),
@@ -77,6 +77,21 @@ function createMockMatrixClient(): MatrixClient {
} as unknown as MatrixClient; } as unknown as MatrixClient;
} }
/**
* Types the JSON representation of an object into the given element.
*/
async function typeJson(
user: UserEvent,
element: Element,
object: object,
): Promise<void> {
// `user.type` requires us to escape brackets by doubling them
await user.type(
element,
JSON.stringify(object).replace(/([{[\]])/g, (c) => c + c),
);
}
describe("DeveloperSettingsTab", () => { describe("DeveloperSettingsTab", () => {
it("renders and matches snapshot", async () => { it("renders and matches snapshot", async () => {
const client = createMockMatrixClient(); const client = createMockMatrixClient();
@@ -124,17 +139,18 @@ describe("DeveloperSettingsTab", () => {
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
describe("custom livekit url", () => { describe("custom transport", () => {
afterEach(() => { afterEach(() => {
customLivekitUrlSetting.setValue(null); customTransportSetting.setValue(null);
}); });
const client = { const client = {
doesServerSupportUnstableFeature: vi.fn().mockResolvedValue(true), doesServerSupportUnstableFeature: vi.fn().mockResolvedValue(true),
getCrypto: () => ({ getVersion: (): string => "x" }), getCrypto: () => ({ getVersion: (): string => "x" }),
getUserId: () => "@u:hs", getUserId: () => "@u:hs",
getDeviceId: () => "DEVICE", getDeviceId: () => "DEVICE",
getDomain: () => "hs",
} as unknown as MatrixClient; } as unknown as MatrixClient;
it("will not update custom livekit url without roomId", async () => { it("will not update custom transport without roomId", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render( render(
@@ -147,17 +163,17 @@ describe("DeveloperSettingsTab", () => {
</TooltipProvider>, </TooltipProvider>,
); );
const input = screen.getByLabelText("Custom Livekit-url"); const input = screen.getByLabelText("Custom transport JSON");
await user.clear(input); await user.clear(input);
await user.type(input, "wss://example.livekit.invalid"); await typeJson(user, input, exampleTransport);
const saveButton = screen.getByRole("button", { name: "Save" }); const saveButton = screen.getByRole("button", { name: "Save" });
await user.click(saveButton); await user.click(saveButton);
expect(getSFUConfigWithOpenID).not.toHaveBeenCalled(); expect(getSFUConfig).not.toHaveBeenCalled();
expect(customLivekitUrlSetting.getValue()).toBe(null); expect(customTransportSetting.getValue()).toBe(null);
}); });
it("will not update custom livekit url without text in input", async () => { it("will not update custom transport without text in input", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render( render(
@@ -171,16 +187,16 @@ describe("DeveloperSettingsTab", () => {
</TooltipProvider>, </TooltipProvider>,
); );
const input = screen.getByLabelText("Custom Livekit-url"); const input = screen.getByLabelText("Custom transport JSON");
await user.clear(input); await user.clear(input);
const saveButton = screen.getByRole("button", { name: "Save" }); const saveButton = screen.getByRole("button", { name: "Save" });
await user.click(saveButton); await user.click(saveButton);
expect(getSFUConfigWithOpenID).not.toHaveBeenCalled(); expect(getSFUConfig).not.toHaveBeenCalled();
expect(customLivekitUrlSetting.getValue()).toBe(null); expect(customTransportSetting.getValue()).toBe(null);
}); });
it("will not update custom livekit url when pressing cancel", async () => { it("will not update custom transport when pressing cancel", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render( render(
@@ -194,7 +210,7 @@ describe("DeveloperSettingsTab", () => {
</TooltipProvider>, </TooltipProvider>,
); );
const input = screen.getByLabelText("Custom Livekit-url"); const input = screen.getByLabelText("Custom transport JSON");
await user.clear(input); await user.clear(input);
await user.type(input, "wss://example.livekit.invalid"); await user.type(input, "wss://example.livekit.invalid");
@@ -202,11 +218,11 @@ describe("DeveloperSettingsTab", () => {
name: "Reset overwrite", name: "Reset overwrite",
}); });
await user.click(cancelButton); await user.click(cancelButton);
expect(getSFUConfigWithOpenID).not.toHaveBeenCalled(); expect(getSFUConfig).not.toHaveBeenCalled();
expect(customLivekitUrlSetting.getValue()).toBe(null); expect(customTransportSetting.getValue()).toBe(null);
}); });
it("will update custom livekit url", async () => { it("will update custom transport", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render( render(
@@ -220,24 +236,28 @@ describe("DeveloperSettingsTab", () => {
</TooltipProvider>, </TooltipProvider>,
); );
const input = screen.getByLabelText("Custom Livekit-url"); const input = screen.getByLabelText("Custom transport JSON");
await user.clear(input); await user.clear(input);
await user.type(input, "wss://example.livekit.valid"); await typeJson(user, input, {
type: "livekit",
url: "wss://example.livekit.valid",
});
const saveButton = screen.getByRole("button", { name: "Save" }); const saveButton = screen.getByRole("button", { name: "Save" });
await user.click(saveButton); await user.click(saveButton);
expect(getSFUConfigWithOpenID).toHaveBeenCalledWith( expect(getSFUConfig).toHaveBeenCalledWith(
expect.anything(), expect.objectContaining({
expect.anything(), transport: { type: "livekit", url: "wss://example.livekit.valid" },
"wss://example.livekit.valid", roomId: "#testRoom",
"#testRoom", }),
); );
expect(customLivekitUrlSetting.getValue()).toBe( expect(customTransportSetting.getValue()).toEqual({
"wss://example.livekit.valid", type: "livekit",
); url: "wss://example.livekit.valid",
});
}); });
it("will show error on invalid url", async () => { it("will show error on unreachable transport", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render( render(
@@ -251,24 +271,27 @@ describe("DeveloperSettingsTab", () => {
</TooltipProvider>, </TooltipProvider>,
); );
const input = screen.getByLabelText("Custom Livekit-url"); const input = screen.getByLabelText("Custom transport JSON");
await user.clear(input); await user.clear(input);
await user.type(input, "wss://example.livekit.valid"); await typeJson(user, input, {
type: "livekit",
url: "wss://example.livekit.valid",
});
const saveButton = screen.getByRole("button", { name: "Save" }); const saveButton = screen.getByRole("button", { name: "Save" });
(getSFUConfigWithOpenID as Mock).mockImplementation(() => { (getSFUConfig as Mock).mockImplementation(() => {
throw new Error("Invalid URL"); throw new Error("Failed to get SFU config");
}); });
await user.click(saveButton); await user.click(saveButton);
expect( expect(
screen.getByText("invalid URL (did not update)"), screen.getByText("invalid transport (did not update)"),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(customLivekitUrlSetting.getValue()).toBe(null); expect(customTransportSetting.getValue()).toBe(null);
}); });
}); });
// Add this test inside the describe("DeveloperSettingsTab", () => { block, // Add this test inside the describe("DeveloperSettingsTab", () => { block,
// after the custom livekit url tests: // after the custom transport tests:
describe("enable extended livekit logs", () => { describe("enable extended livekit logs", () => {
afterEach(() => { afterEach(() => {
+52 -34
View File
@@ -45,7 +45,7 @@ import {
muteAllAudio as muteAllAudioSetting, muteAllAudio as muteAllAudioSetting,
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting, alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
matrixRTCMode as matrixRTCModeSetting, matrixRTCMode as matrixRTCModeSetting,
customLivekitUrl as customLivekitUrlSetting, customTransport as customTransportSetting,
advancedScreenShare as advancedScreenShareSetting, advancedScreenShare as advancedScreenShareSetting,
screenShareResolution as screenShareResolutionSetting, screenShareResolution as screenShareResolutionSetting,
screenShareFramerate as screenShareFramerateSetting, screenShareFramerate as screenShareFramerateSetting,
@@ -67,9 +67,10 @@ import styles from "./DeveloperSettingsTab.module.css";
import settingsStyles from "./SettingsModal.module.css"; import settingsStyles from "./SettingsModal.module.css";
import { Slider } from "../Slider"; import { Slider } from "../Slider";
import { useUrlParams } from "../UrlParams"; import { useUrlParams } from "../UrlParams";
import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
import { useBehavior } from "../useBehavior"; import { useBehavior } from "../useBehavior";
import { type ViewModel } from "../state/ViewModel.ts"; import { type ViewModel } from "../state/ViewModel.ts";
import { getSFUConfig } from "../livekit/auth/index.ts";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
/** /**
* The state of MatrixRTC's media key rotation. * The state of MatrixRTC's media key rotation.
@@ -167,16 +168,18 @@ export const DeveloperSettingsTab: FC<Props> = ({
enableExtendedLivekitLogsSetting, enableExtendedLivekitLogsSetting,
); );
const [customLivekitUrlUpdateError, setCustomLivekitUrlUpdateError] = const [customTransportUpdateError, setCustomLivekitUrlUpdateError] = useState<
useState<string | null>(null); string | null
const [customLivekitUrl, setCustomLivekitUrl] = useSetting( >(null);
customLivekitUrlSetting, const [customTransport, setCustomTransport] = useSetting(
customTransportSetting,
);
const [customTransportTextBuffer, setCustomLivekitUrlTextBuffer] = useState(
() => transportText(customTransport),
); );
const [customLivekitUrlTextBuffer, setCustomLivekitUrlTextBuffer] =
useState(customLivekitUrl);
useEffect(() => { useEffect(() => {
setCustomLivekitUrlTextBuffer(customLivekitUrl); setCustomLivekitUrlTextBuffer(transportText(customTransport));
}, [customLivekitUrl]); }, [customTransport]);
const [muteAllAudio, setMuteAllAudio] = useSetting(muteAllAudioSetting); const [muteAllAudio, setMuteAllAudio] = useSetting(muteAllAudioSetting);
@@ -491,23 +494,23 @@ export const DeveloperSettingsTab: FC<Props> = ({
<EditInPlace <EditInPlace
onSubmit={(e) => e.preventDefault()} onSubmit={(e) => e.preventDefault()}
helpLabel={ helpLabel={
customLivekitUrl === null customTransport === null
? t("developer_mode.custom_livekit_url.from_config") ? t("developer_mode.custom_transport.from_config")
: t("developer_mode.custom_livekit_url.current_url") + : t("developer_mode.custom_transport.current") +
customLivekitUrl JSON.stringify(customTransport)
} }
label={t("developer_mode.custom_livekit_url.label")} label={t("developer_mode.custom_transport.label")}
saveButtonLabel={t("developer_mode.custom_livekit_url.save")} saveButtonLabel={t("developer_mode.custom_transport.save")}
savingLabel={t("developer_mode.custom_livekit_url.saving")} savingLabel={t("developer_mode.custom_transport.saving")}
cancelButtonLabel={t("developer_mode.custom_livekit_url.reset")} cancelButtonLabel={t("developer_mode.custom_transport.reset")}
onSave={useCallback( onSave={useCallback(
async (e: React.FormEvent<HTMLFormElement>): Promise<void> => { async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
if ( if (
roomId === undefined || roomId === undefined ||
customLivekitUrlTextBuffer === "" || customTransportTextBuffer === "" ||
customLivekitUrlTextBuffer === null customTransportTextBuffer === null
) { ) {
setCustomLivekitUrl(null); setCustomTransport(null);
return; return;
} }
@@ -518,21 +521,29 @@ export const DeveloperSettingsTab: FC<Props> = ({
if (userId === null || deviceId === null) { if (userId === null || deviceId === null) {
throw new Error("Invalid user or device ID"); throw new Error("Invalid user or device ID");
} }
await getSFUConfigWithOpenID( const transport = JSON.parse(customTransportTextBuffer);
// Validate the transport by attempting to get a token for it
await getSFUConfig({
client, client,
{ userId, deviceId, memberId: "" }, membership: { userId, deviceId, memberId: "" },
customLivekitUrlTextBuffer, transport,
serverName: client.getDomain()!,
roomId, roomId,
); slotId: "org.example.fake-slot#room",
role: "publisher",
logger,
});
setCustomLivekitUrlUpdateError(null); setCustomLivekitUrlUpdateError(null);
setCustomLivekitUrl(customLivekitUrlTextBuffer); setCustomTransport(transport);
} catch { } catch {
setCustomLivekitUrlUpdateError("invalid URL (did not update)"); setCustomLivekitUrlUpdateError(
"invalid transport (did not update)",
);
} }
}, },
[customLivekitUrlTextBuffer, setCustomLivekitUrl, client, roomId], [customTransportTextBuffer, setCustomTransport, client, roomId],
)} )}
value={customLivekitUrlTextBuffer ?? ""} value={customTransportTextBuffer ?? ""}
onChange={useCallback( onChange={useCallback(
(event: ChangeEvent<HTMLInputElement>): void => { (event: ChangeEvent<HTMLInputElement>): void => {
setCustomLivekitUrlTextBuffer(event.target.value); setCustomLivekitUrlTextBuffer(event.target.value);
@@ -541,14 +552,14 @@ export const DeveloperSettingsTab: FC<Props> = ({
)} )}
onCancel={useCallback( onCancel={useCallback(
(e: React.FormEvent<HTMLFormElement>) => { (e: React.FormEvent<HTMLFormElement>) => {
setCustomLivekitUrl(null); setCustomTransport(null);
}, },
[setCustomLivekitUrl], [setCustomTransport],
)} )}
serverInvalid={customLivekitUrlUpdateError !== null} serverInvalid={customTransportUpdateError !== null}
> >
{customLivekitUrlUpdateError !== null && ( {customTransportUpdateError !== null && (
<ErrorMessage>{customLivekitUrlUpdateError}</ErrorMessage> <ErrorMessage>{customTransportUpdateError}</ErrorMessage>
)} )}
</EditInPlace> </EditInPlace>
<Separator /> <Separator />
@@ -686,3 +697,10 @@ export const DeveloperSettingsTab: FC<Props> = ({
</> </>
); );
}; };
/**
* The text shown in the custom transport field for a given setting value.
*/
function transportText(transport: UnstableLivekitTransport | null): string {
return transport === null ? "" : JSON.stringify(transport);
}
+1 -1
View File
@@ -59,7 +59,7 @@ interface Props {
livekitRooms?: { livekitRooms?: {
room: LivekitRoom; room: LivekitRoom;
url: string; url: string;
isLocal?: boolean; role: "publisher" | "subscriber";
}[]; }[];
/** Only available while in a call. Used by the developer tab. */ /** Only available while in a call. Used by the developer tab. */
developerSettingsVm?: ViewModel<DeveloperSettingsSnapshot>; developerSettingsVm?: ViewModel<DeveloperSettingsSnapshot>;
@@ -239,7 +239,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_label_1o4d9_60" class="_label_1o4d9_60"
for="radix-_r_7_" for="radix-_r_7_"
> >
Custom Livekit-url Custom transport JSON
</label> </label>
<div <div
class="_controls_17lij_8" class="_controls_17lij_8"
+3 -2
View File
@@ -12,6 +12,7 @@ import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { type Behavior } from "../state/Behavior"; import { type Behavior } from "../state/Behavior";
import { useBehavior } from "../useBehavior"; import { useBehavior } from "../useBehavior";
import { MatrixRTCMode } from "../config/ConfigOptions"; import { MatrixRTCMode } from "../config/ConfigOptions";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
export class Setting<T> { export class Setting<T> {
public constructor( public constructor(
@@ -153,8 +154,8 @@ export const matrixRTCMode = new Setting<MatrixRTCMode>(
MatrixRTCMode.Compatibility, MatrixRTCMode.Compatibility,
); );
export const customLivekitUrl = new Setting<string | null>( export const customTransport = new Setting<UnstableLivekitTransport | null>(
"custom-livekit-url", "custom-transport",
null, null,
); );
@@ -31,7 +31,7 @@ import {
type CallMembership, type CallMembership,
type IRTCNotificationContent, type IRTCNotificationContent,
MatrixRTCSessionEvent, MatrixRTCSessionEvent,
type LivekitTransport, type UnstableLivekitTransport,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { deepCompare } from "matrix-js-sdk/lib/utils"; import { deepCompare } from "matrix-js-sdk/lib/utils";
@@ -97,7 +97,7 @@ vi.mock(
"../state/CallViewModel/localMember/localTransport", "../state/CallViewModel/localMember/localTransport",
async (importOriginal) => ({ async (importOriginal) => ({
...(await importOriginal()), ...(await importOriginal()),
makeTransport: async (): Promise<LivekitTransport> => makeTransport: async (): Promise<UnstableLivekitTransport> =>
Promise.resolve(exampleTransport), Promise.resolve(exampleTransport),
}), }),
); );
+75 -75
View File
@@ -12,6 +12,7 @@ import {
ExternalE2EEKeyProvider, ExternalE2EEKeyProvider,
type Room as LivekitRoom, type Room as LivekitRoom,
type RoomOptions, type RoomOptions,
type RemoteParticipant,
} from "livekit-client"; } from "livekit-client";
import { type Room as MatrixRoom } from "matrix-js-sdk"; import { type Room as MatrixRoom } from "matrix-js-sdk";
import { import {
@@ -38,11 +39,14 @@ import {
timer, timer,
takeUntil, takeUntil,
from, from,
concatWith,
catchError,
switchAll,
} from "rxjs"; } from "rxjs";
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { import {
MembershipManagerEvent, MembershipManagerEvent,
type LivekitTransport, type UnstableLivekitTransport,
type MatrixRTCSession, type MatrixRTCSession,
type RTCCallIntent, type RTCCallIntent,
type RTCNotificationType, type RTCNotificationType,
@@ -55,6 +59,7 @@ import {
createToggle$, createToggle$,
filterBehavior, filterBehavior,
generateItems, generateItems,
mapScoped,
pauseWhen, pauseWhen,
} from "../../utils/observable"; } from "../../utils/observable";
import { import {
@@ -104,17 +109,14 @@ import {
type OneOnOneMedia, type OneOnOneMedia,
} from "../layoutMedia.ts"; } from "../layoutMedia.ts";
import { ElementCallError, UnknownCallError } from "../../utils/errors.ts"; import { ElementCallError, UnknownCallError } from "../../utils/errors.ts";
import { type Epoch, type ObservableScope } from "../ObservableScope.ts"; import { Epoch, type ObservableScope } from "../ObservableScope.ts";
import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts"; import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts";
import { import {
createLocalMembership$, createLocalMembership$,
enterRTCSession, enterRTCSession,
TransportState, TransportState,
} from "./localMember/LocalMember.ts"; } from "./localMember/LocalMember.ts";
import { import { getLocalTransport } from "./localMember/LocalTransport.ts";
getLocalTransport,
type LocalTransport,
} from "./localMember/LocalTransport.ts";
import { import {
createKeyRotationSuppressed$, createKeyRotationSuppressed$,
createMemberships$, createMemberships$,
@@ -125,9 +127,9 @@ import {
ECConnectionFactory, ECConnectionFactory,
} from "./remoteMembers/ConnectionFactory.ts"; } from "./remoteMembers/ConnectionFactory.ts";
import { import {
type ConnectionManagerData, ConnectionMap,
createConnectionManager$, createConnectionMap$,
} from "./remoteMembers/ConnectionManager.ts"; } from "./remoteMembers/ConnectionMap.ts";
import { import {
createRemoteMatrixLivekitMembers$, createRemoteMatrixLivekitMembers$,
type LocalMatrixLivekitMember, type LocalMatrixLivekitMember,
@@ -163,6 +165,7 @@ import {
type RingingMediaViewModel, type RingingMediaViewModel,
} from "../media/RingingMediaViewModel.ts"; } from "../media/RingingMediaViewModel.ts";
import { type GridTileViewModel } from "../TileViewModel.ts"; import { type GridTileViewModel } from "../TileViewModel.ts";
import { type TransportLocator } from "../../livekit/auth";
//TODO //TODO
// Larger rename // Larger rename
@@ -215,7 +218,7 @@ export interface CallViewModelOptions {
*/ */
windowSize$: Behavior<{ width: number; height: number }>; windowSize$: Behavior<{ width: number; height: number }>;
/** Optional value overriding the local transport, for testing purposes. */ /** Optional value overriding the local transport, for testing purposes. */
localTransport?: LocalTransport; localTransport?: TransportLocator;
/** Optional value overriding the connection factory, for testing purposes. */ /** Optional value overriding the connection factory, for testing purposes. */
connectionFactory?: ConnectionFactory; connectionFactory?: ConnectionFactory;
/** The version & compatibility mode of MatrixRTC that we should use. */ /** The version & compatibility mode of MatrixRTC that we should use. */
@@ -276,7 +279,7 @@ interface LayoutScanState {
export type LivekitRoomItem = { export type LivekitRoomItem = {
livekitRoom: LivekitRoom; livekitRoom: LivekitRoom;
participants: string[]; participants: string[];
url: string; transport: TransportLocator;
}; };
/** /**
@@ -373,7 +376,7 @@ export interface CallViewModel {
* key is generated when someone joins or leaves. * key is generated when someone joins or leaves.
*/ */
keyRotationSuppressed$: Behavior<boolean>; keyRotationSuppressed$: Behavior<boolean>;
allConnections$: Behavior<ConnectionManagerData>; allConnections$: Behavior<Connection[]>;
/** 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[]>;
/** use the layout instead, this is just for the sdk export. */ /** use the layout instead, this is just for the sdk export. */
@@ -564,20 +567,14 @@ export function createCallViewModel$(
const localTransport$ = options.localTransport const localTransport$ = options.localTransport
? constant(options.localTransport) ? constant(options.localTransport)
: from( : from(getLocalTransport(client));
getLocalTransport({
ownMembershipIdentity,
client,
roomId: matrixRoom.roomId,
matrixRTCMode,
}),
);
const connectionFactory = const connectionFactory =
options.connectionFactory ?? options.connectionFactory ??
new ECConnectionFactory( new ECConnectionFactory(
client, client,
matrixRoom.roomId, matrixRoom.roomId,
matrixRTCSession.slotId,
mediaDevices, mediaDevices,
trackProcessorState$, trackProcessorState$,
livekitKeyProvider, livekitKeyProvider,
@@ -585,14 +582,24 @@ export function createCallViewModel$(
options.livekitRoomFactory, options.livekitRoomFactory,
); );
const connectionManager = createConnectionManager$({ const connectionMap$ = scope.behavior<Epoch<ConnectionMap>>(
scope: scope, localTransport$.pipe(
connectionFactory: connectionFactory, catchError(() => NEVER), // Swallow errors
localTransport$, concatWith(NEVER), // So the Observable doesn't complete prematurely
remoteTransports$: membershipsAndTransports.transports$, mapScoped("connectionMap$", (localTransport, scope) =>
logger: logger, createConnectionMap$({
ownMembershipIdentity, scope,
}); connectionFactory,
localTransport,
remoteTransports$: membershipsAndTransports.transports$,
logger,
ownMembershipIdentity,
}),
),
switchAll(),
),
new Epoch(new ConnectionMap()),
);
const remoteMatrixLivekitMembers$: Behavior< const remoteMatrixLivekitMembers$: Behavior<
Epoch<RemoteMatrixLivekitMember[]> Epoch<RemoteMatrixLivekitMember[]>
@@ -600,7 +607,7 @@ export function createCallViewModel$(
scope: scope, scope: scope,
membershipsWithTransport$: membershipsWithTransport$:
membershipsAndTransports.membershipsWithTransport$, membershipsAndTransports.membershipsWithTransport$,
connectionManager: connectionManager, connectionMap$,
localUser: { userId, deviceId }, localUser: { userId, deviceId },
}); });
@@ -613,7 +620,7 @@ export function createCallViewModel$(
), ),
muteStates, muteStates,
joinMatrixRTC: ( joinMatrixRTC: (
transport: LivekitTransport, transport: UnstableLivekitTransport,
delayedLeaveTimings: ResolvedDelayedLeaveTimings, delayedLeaveTimings: ResolvedDelayedLeaveTimings,
) => { ) => {
return enterRTCSession( return enterRTCSession(
@@ -635,13 +642,11 @@ export function createCallViewModel$(
mediaDevices, mediaDevices,
muteStates, muteStates,
trackProcessorState$, trackProcessorState$,
logger.getChild( logger.getChild(`[Publisher ${JSON.stringify(connection.transport)}]`),
"[Publisher " + connection.transport.livekit_service_url + "]",
),
controlledAudioDevices, controlledAudioDevices,
); );
}, },
connectionManager, connectionMap$,
client, client,
matrixRTCSession, matrixRTCSession,
localTransport$, localTransport$,
@@ -660,7 +665,6 @@ export function createCallViewModel$(
).pipe(map(([delayId]) => delayId ?? null)), ).pipe(map(([delayId]) => delayId ?? null)),
matrixRTCSession.delayId ?? null, matrixRTCSession.delayId ?? null,
), ),
matrixRTCMode,
logger: logger.getChild(`[${Date.now()}]`), logger: logger.getChild(`[${Date.now()}]`),
}); });
@@ -728,51 +732,47 @@ export function createCallViewModel$(
}); });
const allConnections$ = scope.behavior( const allConnections$ = scope.behavior(
connectionManager.connectionManagerData$.pipe(map((d) => d.value)), connectionMap$.pipe(map((d) => d.value.getConnections())),
); );
const livekitRoomItems$ = scope.behavior(
remoteMatrixLivekitMembers$.pipe(
switchMap((members) => {
const a$ = combineLatest(
members.value.map((member) =>
combineLatest([member.connection$, member.participant.value$]).pipe(
map(([connection, participant]) => {
// do not render audio for local participant
if (!connection || !participant || participant.isLocal)
return null;
const livekitRoom = connection.livekitRoom;
const url = connection.transport.livekit_service_url;
return { const livekitRoomItems$ = scope.behavior<LivekitRoomItem[]>(
url, remoteMatrixLivekitMembers$.pipe(
livekitRoom, // Flatten the members to a snapshot of their current connections and
participant: participant.identity, // participant objects
}; switchMap(({ value: members }) => {
}), if (members.length === 0) return of([]);
return combineLatest(
members.map((m) =>
combineLatest(
[m.connection$, m.participant.value$],
(connection, participant) => ({ connection, participant }),
), ),
), ),
); );
return a$;
}), }),
// Keep only those with a defined connection and participant
map((members) => map((members) =>
members.reduce<LivekitRoomItem[]>((acc, curr) => { members.filter(
if (!curr) return acc; (
m,
const existing = acc.find((item) => item.url === curr.url); ): m is { connection: Connection; participant: RemoteParticipant } =>
if (existing) { m.connection !== null && m.participant !== null,
existing.participants.push(curr.participant); ),
} else { ),
acc.push({ // Group members by connection
livekitRoom: curr.livekitRoom, map((members) =>
participants: [curr.participant], [...Map.groupBy(members, (m) => m.connection)].map(
url: curr.url, ([connection, members]) => ({
}); transport: {
} transport: connection.transport,
return acc; serverName: connection.serverName,
}, []), },
livekitRoom: connection.livekitRoom,
participants: members.map((m) => m.participant.identity),
}),
),
), ),
), ),
[],
); );
const handsRaised$ = scope.behavior( const handsRaised$ = scope.behavior(
@@ -829,8 +829,8 @@ export function createCallViewModel$(
livekitRoom$: scope.behavior( livekitRoom$: scope.behavior(
connection$.pipe(map((c) => c?.livekitRoom)), connection$.pipe(map((c) => c?.livekitRoom)),
), ),
focusUrl$: scope.behavior( transport$: scope.behavior(
connection$.pipe(map((c) => c?.transport.livekit_service_url)), connection$.pipe(map((c) => c?.transport)),
), ),
mediaDevices, mediaDevices,
pretendToBeDisconnected$: localMembership.reconnecting$, pretendToBeDisconnected$: localMembership.reconnecting$,
@@ -1662,9 +1662,9 @@ export function createCallViewModel$(
returnObject.publishError = value.media; returnObject.publishError = value.media;
else if ( else if (
typeof value.media === "object" && typeof value.media === "object" &&
value.media.connection instanceof ElementCallError value.media.connection.state === "error"
) )
returnObject.connectionError = value.media.connection; returnObject.connectionError = value.media.connection.error;
return returnObject; return returnObject;
}), }),
), ),
@@ -30,8 +30,7 @@ import {
type CallViewModelOptions, type CallViewModelOptions,
} from "./CallViewModel"; } from "./CallViewModel";
import { import {
exampleSfuConfig, exampleTransportLocator,
exampleTransport,
mockConfig, mockConfig,
MockConnection, MockConnection,
mockLivekitRoom, mockLivekitRoom,
@@ -123,6 +122,8 @@ export function withCallViewModel(mode: MatrixRTCMode) {
}; };
const room = mockMatrixRoom({ const room = mockMatrixRoom({
client: new (class extends EventEmitter { client: new (class extends EventEmitter {
public readonly baseUrl = "https://matrix.example.com";
public getUserId(): string | undefined { public getUserId(): string | undefined {
return localRtcMember.userId; return localRtcMember.userId;
} }
@@ -209,26 +210,26 @@ export function withCallViewModel(mode: MatrixRTCMode) {
livekitRoomFactory, livekitRoomFactory,
connectionState$, connectionState$,
windowSize$, windowSize$,
localTransport: { localTransport: exampleTransportLocator,
transport: exampleTransport,
sfuConfig: exampleSfuConfig,
},
connectionFactory: { connectionFactory: {
createConnection( createConnection(
scope, scope,
role,
transport, transport,
serverName,
ownMembershipIdentity, ownMembershipIdentity,
logger, logger,
sfuConfig,
) { ) {
return new MockConnection( return new MockConnection(
{ {
scope, scope,
role,
transport, transport,
serverName,
ownMembershipIdentity, ownMembershipIdentity,
existingSFUConfig: sfuConfig,
client: room.client, client: room.client,
roomId: room.roomId, roomId: room.roomId,
slotId: "m.call#ROOM",
livekitRoomFactory, livekitRoomFactory,
}, },
logger, logger,
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
import { import {
Status as RTCMemberStatus, Status as RTCMemberStatus,
type LivekitTransport, type UnstableLivekitTransport,
type MatrixRTCSession, type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { import {
@@ -21,9 +21,13 @@ import {
beforeEach, beforeEach,
afterEach, afterEach,
} from "vitest"; } from "vitest";
import { BehaviorSubject, map, of, Subject } from "rxjs"; import { BehaviorSubject, of, Subject } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { type LocalParticipant, type LocalTrack } from "livekit-client"; import {
ConnectionState as LivekitConnectionState,
type LocalParticipant,
type LocalTrack,
} from "livekit-client";
import fetchMock from "fetch-mock"; import fetchMock from "fetch-mock";
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics"; import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics";
@@ -53,13 +57,15 @@ import {
import { MatrixRTCTransportMissingError } from "../../../utils/errors"; import { MatrixRTCTransportMissingError } from "../../../utils/errors";
import { Epoch, ObservableScope } from "../../ObservableScope"; import { Epoch, ObservableScope } from "../../ObservableScope";
import { constant } from "../../Behavior"; import { constant } from "../../Behavior";
import { ConnectionManagerData } from "../remoteMembers/ConnectionManager"; import { ConnectionMap } from "../remoteMembers/ConnectionMap";
import { ConnectionState, type Connection } from "../remoteMembers/Connection"; import {
type ConnectionState,
type Connection,
} from "../remoteMembers/Connection";
import { type Publisher } from "./Publisher"; import { type Publisher } from "./Publisher";
import { initializeWidget } from "../../../widget"; import { initializeWidget } from "../../../widget";
import { nullHostBridge } from "../../../HostBridge"; import { nullHostBridge } from "../../../HostBridge";
import { type LocalTransport } from "./LocalTransport"; import * as livekitAuth from "../../../livekit/auth";
import * as openIDSFU from "../../../livekit/openIDSFU";
initializeWidget(); initializeWidget();
@@ -110,6 +116,7 @@ const delegatedTimings: ResolvedDelayedLeaveTimings = {
}; };
const mockedClient = { const mockedClient = {
baseUrl: "https://matrix.example.org",
getDomain: vi.fn().mockReturnValue("example.org"), getDomain: vi.fn().mockReturnValue("example.org"),
getDeviceId: vi.fn().mockReturnValue("AAAA"), getDeviceId: vi.fn().mockReturnValue("AAAA"),
getOpenIdToken: vi.fn().mockResolvedValue({ getOpenIdToken: vi.fn().mockResolvedValue({
@@ -118,10 +125,11 @@ const mockedClient = {
matrix_server_name: "localhost", matrix_server_name: "localhost",
expires_in: 10000, expires_in: 10000,
}), }),
_unstable_delegateDelayedLeave: vi.fn().mockResolvedValue({}),
}; };
describe("enterRTCSession", () => { describe("enterRTCSession", () => {
const transport: LivekitTransport = { const transport: UnstableLivekitTransport = {
livekit_service_url: "http://my-livekit-service-url.com", livekit_service_url: "http://my-livekit-service-url.com",
type: "livekit", type: "livekit",
}; };
@@ -214,7 +222,10 @@ describe("LocalMembership", () => {
processor: undefined, processor: undefined,
}), }),
logger: logger, logger: logger,
createPublisherFactory: vi.fn(), createPublisherFactory: vi.fn().mockReturnValue({
destroy: vi.fn(),
createAndSetupTracks: vi.fn().mockResolvedValue(undefined),
}),
joinMatrixRTC: async (): Promise<void> => {}, joinMatrixRTC: async (): Promise<void> => {},
homeserverConnected: { homeserverConnected: {
combined$: constant<[boolean, HomeserverDisconnectReason | null]>([ combined$: constant<[boolean, HomeserverDisconnectReason | null]>([
@@ -248,29 +259,16 @@ describe("LocalMembership", () => {
afterEach(async () => { afterEach(async () => {
void (await fetchMock.flush()); void (await fetchMock.flush());
fetchMock.reset(); fetchMock.reset();
vi.clearAllMocks();
}); });
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>(
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
null,
);
// we do not need any connection data since we want to fail before reaching that.
const mockConnectionManager = {
transports$: scope.behavior(
localTransport$.pipe(map((t) => new Epoch([t]))),
),
connectionManagerData$: constant(
new Epoch(new ConnectionManagerData()),
),
};
const localMembership = createLocalMembership$({ const localMembership = createLocalMembership$({
scope, scope,
...defaultCreateLocalMemberValues, ...defaultCreateLocalMemberValues,
connectionManager: mockConnectionManager, // we do not need any connection data since we want to fail before reaching that.
connectionMap$: constant(new Epoch(new ConnectionMap())),
localTransport$: hot( localTransport$: hot(
"1ms #", "1ms #",
{}, {},
@@ -286,24 +284,19 @@ describe("LocalMembership", () => {
}); });
it("logs if callIntent cannot be updated", async () => { it("logs if callIntent cannot be updated", async () => {
const scope = new ObservableScope();
const mockConnectionManager = {
transports$: constant(new Epoch([])),
connectionManagerData$: constant(new Epoch(new ConnectionManagerData())),
};
async function reject(): Promise<void> { async function reject(): Promise<void> {
return Promise.reject(new Error("Not connected yet")); return Promise.reject(new Error("Not connected yet"));
} }
const localMembership = createLocalMembership$({ const localMembership = createLocalMembership$({
scope, scope: testScope(),
...defaultCreateLocalMemberValues, ...defaultCreateLocalMemberValues,
matrixRTCSession: { matrixRTCSession: {
slotId: "m.call#ROOM",
updateCallIntent: vi.fn().mockImplementation(reject), updateCallIntent: vi.fn().mockImplementation(reject),
leave: vi.fn(), leave: vi.fn(),
}, },
connectionManager: mockConnectionManager, connectionMap$: constant(new Epoch(new ConnectionMap())),
localTransport$: constant(mockTransport), localTransport$: constant(mockTransportLocator),
}); });
const expextedLog = const expextedLog =
"'not connected yet' while updating the call intent (this is expected on startup)"; "'not connected yet' while updating the call intent (this is expected on startup)";
@@ -312,22 +305,18 @@ describe("LocalMembership", () => {
await flushPromises(); await flushPromises();
defaultCreateLocalMemberValues.muteStates.video.setEnabled$.value?.(true); defaultCreateLocalMemberValues.muteStates.video.setEnabled$.value?.(true);
expect(internalLogger).toHaveBeenCalledWith(expextedLog); expect(internalLogger).toHaveBeenCalledWith(expextedLog);
scope.end();
}); });
const mockTransportConfig = { const mockTransport: UnstableLivekitTransport = {
livekit_service_url: "a", type: "livekit",
} as LivekitTransport; url: "https://sfu.example.org",
livekit_service_url: "https://jwt.example.org",
};
const mockTransport = { const mockTransportLocator: livekitAuth.TransportLocator = {
transport: mockTransportConfig, transport: mockTransport,
sfuConfig: { serverName: "example.org",
jwt: "foo", };
livekitAlias: "bar",
livekitIdentity: "baz",
url: "bro",
},
} as LocalTransport;
const connectionTransportAConnected = { const connectionTransportAConnected = {
livekitRoom: mockLivekitRoom({ livekitRoom: mockLivekitRoom({
@@ -336,31 +325,52 @@ describe("LocalMembership", () => {
trackPublications: [], trackPublications: [],
} as unknown as LocalParticipant, } as unknown as LocalParticipant,
}), }),
state$: constant(ConnectionState.LivekitConnected), state$: constant({
transport: mockTransportConfig, state: "authenticated",
livekitState: LivekitConnectionState.Connected,
}),
transport: mockTransport,
serverName: "example.org",
} as Connection; } as Connection;
const connectionTransportAConnecting = { const connectionTransportAConnecting = {
...connectionTransportAConnected, ...connectionTransportAConnected,
state$: constant(ConnectionState.LivekitConnecting), state$: constant({
state: "authenticated",
livekitState: LivekitConnectionState.Connecting,
}),
livekitRoom: mockLivekitRoom({}), livekitRoom: mockLivekitRoom({}),
} as unknown as Connection; } as unknown as Connection;
const authCallSpy = vi const delegationSpy = vi
.spyOn(openIDSFU, "getSFUConfigWithOpenID") .spyOn(livekitAuth, "delegateDelayedLeave")
.mockImplementation(() => mockedClient.getOpenIdToken()); .mockResolvedValue();
afterEach(() => authCallSpy.mockClear()); afterEach(() => delegationSpy.mockClear());
const urlOnlyTransport: UnstableLivekitTransport = {
type: "livekit",
url: "https://sfu.example.org",
};
// With a `url` only we publish through the CS API, so the homeserver
// delegates. With a `livekit_service_url` we publish through the JWT
// service, so that delegates.
it.each([ it.each([
["no", null, timings], ["no", mockTransport, null, timings],
[ [
"homeserver", "homeserver",
urlOnlyTransport,
"https://matrix.example.org/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave", "https://matrix.example.org/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave",
delegatedTimings, delegatedTimings,
], ],
["transport", "/a/delegate_delayed_leave", delegatedTimings], [
"transport",
mockTransport,
"https://jwt.example.org/delegate_delayed_leave",
delegatedTimings,
],
])( ])(
"joins session with %s delegation support", "joins session with %s delegation support",
async (_serviceName, delegationUrl, delayedLeaveTimings) => { async (_serviceName, transport, delegationUrl, delayedLeaveTimings) => {
const scope = testScope(); const scope = testScope();
const joinMatrixRTC = vi.fn(); const joinMatrixRTC = vi.fn();
const delayId$ = new BehaviorSubject<string | null>(null); const delayId$ = new BehaviorSubject<string | null>(null);
@@ -368,16 +378,17 @@ describe("LocalMembership", () => {
if (delegationUrl !== null) if (delegationUrl !== null)
fetchMock.post(delegationUrl, () => ({ status: 401, body: {} })); fetchMock.post(delegationUrl, () => ({ status: 401, body: {} }));
const connectionMap = new ConnectionMap();
connectionMap.add(
{ ...connectionTransportAConnected, transport } as Connection,
[],
);
const localMembership = createLocalMembership$({ const localMembership = createLocalMembership$({
scope, scope,
...defaultCreateLocalMemberValues, ...defaultCreateLocalMemberValues,
connectionManager: { connectionMap$: constant(new Epoch(connectionMap)),
connectionManagerData$: constant(
new Epoch(new ConnectionManagerData()),
),
},
joinMatrixRTC, joinMatrixRTC,
localTransport$: constant(mockTransport), localTransport$: constant({ transport, serverName: "example.org" }),
delayId$, delayId$,
}); });
@@ -386,31 +397,27 @@ describe("LocalMembership", () => {
await flushPromises(); await flushPromises();
// Joins with timings appropriate for the level of delegation support // Joins with timings appropriate for the level of delegation support
expect(joinMatrixRTC).toHaveBeenCalledWith( expect(joinMatrixRTC).toHaveBeenCalledWith(
mockTransportConfig, transport,
delayedLeaveTimings, delayedLeaveTimings,
); );
expect(authCallSpy).not.toHaveBeenCalled(); expect(delegationSpy).not.toHaveBeenCalled();
delayId$.next("leave1"); delayId$.next("leave1");
await flushPromises(); await flushPromises();
if (delegationUrl === null) { if (delegationUrl === null) {
expect(authCallSpy).not.toHaveBeenCalled(); expect(delegationSpy).not.toHaveBeenCalled();
} else { } else {
// Delegation is supported in this test case, so go on to check that // Delegation is supported in this test case, so go on to check that
// LocalMember actually performs delegation // LocalMember actually performs delegation
const expectDelegation = (delayId: string) => const expectDelegation = (delayId: string) =>
expect(authCallSpy).toHaveBeenLastCalledWith( expect(delegationSpy).toHaveBeenLastCalledWith({
mockedClient, client: mockedClient,
ownMemberMock, membership: ownMemberMock,
"a", transport,
"!test-room-id:example.org", roomId: "!test-room-id:example.org",
{ delayId,
matrixRTCMode: MATRIX_RTC_MODE, logger: expect.anything(),
delayEndpointBaseUrl: "https://matrix.example.org", });
delayId,
},
expect.anything(),
);
expectDelegation("leave1"); expectDelegation("leave1");
delayId$.next("leave2"); // Can change delegated leaves delayId$.next("leave2"); // Can change delegated leaves
@@ -449,16 +456,14 @@ describe("LocalMembership", () => {
typeof vi.fn typeof vi.fn
>; >;
const connectionManagerData = new ConnectionManagerData(); const connectionMap = new ConnectionMap();
connectionManagerData.add(connectionTransportAConnected, []); connectionMap.add(connectionTransportAConnected, []);
// connectionManagerData.add(connectionTransportB, []); // connectionMap.add(connectionTransportB, []);
const localMembership = createLocalMembership$({ const localMembership = createLocalMembership$({
scope, scope,
...defaultCreateLocalMemberValues, ...defaultCreateLocalMemberValues,
connectionManager: { connectionMap$: constant(new Epoch(connectionMap)),
connectionManagerData$: constant(new Epoch(connectionManagerData)), localTransport$: constant(mockTransportLocator),
},
localTransport$: constant(mockTransport),
}); });
await flushPromises(); await flushPromises();
expect(publisherFactory).toHaveBeenCalledOnce(); expect(publisherFactory).toHaveBeenCalledOnce();
@@ -479,12 +484,10 @@ describe("LocalMembership", () => {
// //
it("tracks livekit state correctly", async () => { it("tracks livekit state correctly", async () => {
const scope = new ObservableScope(); const scope = new ObservableScope();
const connectionManagerData = new ConnectionManagerData(); const connectionMap = new ConnectionMap();
const localTransport$ = new Subject<LocalTransport>(); const localTransport$ = new Subject<livekitAuth.TransportLocator>();
const connectionManagerData$ = new BehaviorSubject( const connectionMap$ = new BehaviorSubject(new Epoch(connectionMap));
new Epoch(connectionManagerData),
);
const publishers: Publisher[] = []; const publishers: Publisher[] = [];
const publishing$ = new BehaviorSubject<boolean>(false); const publishing$ = new BehaviorSubject<boolean>(false);
@@ -518,9 +521,7 @@ describe("LocalMembership", () => {
const localMembership = createLocalMembership$({ const localMembership = createLocalMembership$({
scope, scope,
...defaultCreateLocalMemberValues, ...defaultCreateLocalMemberValues,
connectionManager: { connectionMap$: connectionMap$,
connectionManagerData$,
},
localTransport$, localTransport$,
}); });
@@ -528,38 +529,50 @@ describe("LocalMembership", () => {
expect(localMembership.localMemberState$.value).toStrictEqual( expect(localMembership.localMemberState$.value).toStrictEqual(
TransportState.Waiting, TransportState.Waiting,
); );
localTransport$.next(mockTransport); localTransport$.next(mockTransportLocator);
await flushPromises(); await flushPromises();
expect(localMembership.localMemberState$.value).toStrictEqual({ expect(localMembership.localMemberState$.value).toStrictEqual({
matrix: RTCMemberStatus.Connected, matrix: RTCMemberStatus.Connected,
media: { connection: null, tracks: TrackState.WaitingForUser }, media: {
connection: { state: "initializing" },
tracks: TrackState.WaitingForUser,
},
}); });
const connectionManagerData2 = new ConnectionManagerData(); const connectionMap2 = new ConnectionMap();
connectionManagerData2.add( connectionMap2.add(
// clone because we will mutate this later. // clone because we will mutate this later.
{ ...connectionTransportAConnecting } as unknown as Connection, { ...connectionTransportAConnecting } as unknown as Connection,
[], [],
); );
connectionManagerData$.next(new Epoch(connectionManagerData2)); connectionMap$.next(new Epoch(connectionMap2));
await flushPromises(); await flushPromises();
expect(localMembership.localMemberState$.value).toStrictEqual({ expect(localMembership.localMemberState$.value).toStrictEqual({
matrix: RTCMemberStatus.Connected, matrix: RTCMemberStatus.Connected,
media: { media: {
connection: ConnectionState.LivekitConnecting, connection: {
state: "authenticated",
livekitState: LivekitConnectionState.Connecting,
},
tracks: TrackState.WaitingForUser, tracks: TrackState.WaitingForUser,
}, },
}); });
( (
connectionManagerData2.getConnectionForTransport(mockTransportConfig)! connectionMap2.getConnectionForTransport(mockTransportLocator)!
.state$ as BehaviorSubject<ConnectionState> .state$ as BehaviorSubject<ConnectionState>
).next(ConnectionState.LivekitConnected); ).next({
state: "authenticated",
livekitState: LivekitConnectionState.Connected,
});
expect(localMembership.localMemberState$.value).toStrictEqual({ expect(localMembership.localMemberState$.value).toStrictEqual({
matrix: RTCMemberStatus.Connected, matrix: RTCMemberStatus.Connected,
media: { media: {
connection: ConnectionState.LivekitConnected, connection: {
state: "authenticated",
livekitState: LivekitConnectionState.Connected,
},
tracks: TrackState.WaitingForUser, tracks: TrackState.WaitingForUser,
}, },
}); });
@@ -643,8 +656,8 @@ describe("LocalMembership", () => {
[boolean, HomeserverDisconnectReason | null] [boolean, HomeserverDisconnectReason | null]
>([false, "membership"]); >([false, "membership"]);
const connectionManagerData = new ConnectionManagerData(); const connectionMap = new ConnectionMap();
connectionManagerData.add(connectionTransportAConnected, []); connectionMap.add(connectionTransportAConnected, []);
createLocalMembership$({ createLocalMembership$({
scope, scope,
@@ -653,10 +666,8 @@ describe("LocalMembership", () => {
combined$: hsReason$, combined$: hsReason$,
rtsSession$: constant(RTCMemberStatus.Connected), rtsSession$: constant(RTCMemberStatus.Connected),
}, },
connectionManager: { connectionMap$: constant(new Epoch(connectionMap)),
connectionManagerData$: constant(new Epoch(connectionManagerData)), localTransport$: constant(mockTransportLocator),
},
localTransport$: constant(mockTransport),
}); });
await flushPromises(); await flushPromises();
@@ -680,8 +691,8 @@ describe("LocalMembership", () => {
[boolean, HomeserverDisconnectReason | null] [boolean, HomeserverDisconnectReason | null]
>([true, null]); >([true, null]);
const connectionManagerData = new ConnectionManagerData(); const connectionMap = new ConnectionMap();
connectionManagerData.add(connectionTransportAConnected, []); connectionMap.add(connectionTransportAConnected, []);
createLocalMembership$({ createLocalMembership$({
scope, scope,
@@ -690,10 +701,8 @@ describe("LocalMembership", () => {
combined$: hsReason$, combined$: hsReason$,
rtsSession$: constant(RTCMemberStatus.Connected), rtsSession$: constant(RTCMemberStatus.Connected),
}, },
connectionManager: { connectionMap$: constant(new Epoch(connectionMap)),
connectionManagerData$: constant(new Epoch(connectionManagerData)), localTransport$: constant(mockTransportLocator),
},
localTransport$: constant(mockTransport),
}); });
await flushPromises(); await flushPromises();
@@ -717,16 +726,17 @@ describe("LocalMembership", () => {
"track", "track",
); );
const connectionState$ = new BehaviorSubject<ConnectionState>( const connectionState$ = new BehaviorSubject<ConnectionState>({
ConnectionState.LivekitConnected, state: "authenticated",
); livekitState: LivekitConnectionState.Connected,
});
const mutableConnection = { const mutableConnection = {
...connectionTransportAConnected, ...connectionTransportAConnected,
state$: connectionState$, state$: connectionState$,
} as unknown as Connection; } as unknown as Connection;
const connectionManagerData = new ConnectionManagerData(); const connectionMap = new ConnectionMap();
connectionManagerData.add(mutableConnection, []); connectionMap.add(mutableConnection, []);
createLocalMembership$({ createLocalMembership$({
scope, scope,
@@ -738,16 +748,20 @@ describe("LocalMembership", () => {
]), ]),
rtsSession$: constant(RTCMemberStatus.Connected), rtsSession$: constant(RTCMemberStatus.Connected),
}, },
connectionManager: { connectionMap$: constant(new Epoch(connectionMap)),
connectionManagerData$: constant(new Epoch(connectionManagerData)), localTransport$: constant(mockTransportLocator),
},
localTransport$: constant(mockTransport),
}); });
await flushPromises(); await flushPromises();
connectionState$.next(ConnectionState.LivekitDisconnected); connectionState$.next({
connectionState$.next(ConnectionState.LivekitConnected); state: "authenticated",
livekitState: LivekitConnectionState.Disconnected,
});
connectionState$.next({
state: "authenticated",
livekitState: LivekitConnectionState.Connected,
});
expect(trackSpy).toHaveBeenCalledWith( expect(trackSpy).toHaveBeenCalledWith(
defaultCreateLocalMemberValues.roomId, defaultCreateLocalMemberValues.roomId,
@@ -769,8 +783,8 @@ describe("LocalMembership", () => {
[boolean, HomeserverDisconnectReason | null] [boolean, HomeserverDisconnectReason | null]
>([true, null]); >([true, null]);
const connectionManagerData = new ConnectionManagerData(); const connectionMap = new ConnectionMap();
connectionManagerData.add(connectionTransportAConnected, []); connectionMap.add(connectionTransportAConnected, []);
createLocalMembership$({ createLocalMembership$({
scope, scope,
@@ -779,10 +793,8 @@ describe("LocalMembership", () => {
combined$: hsReason$, combined$: hsReason$,
rtsSession$: constant(RTCMemberStatus.Connected), rtsSession$: constant(RTCMemberStatus.Connected),
}, },
connectionManager: { connectionMap$: constant(new Epoch(connectionMap)),
connectionManagerData$: constant(new Epoch(connectionManagerData)), localTransport$: constant(mockTransportLocator),
},
localTransport$: constant(mockTransport),
}); });
await flushPromises(); await flushPromises();
@@ -840,15 +852,13 @@ describe("LocalMembership", () => {
localMembership: ReturnType<typeof createLocalMembership$>; localMembership: ReturnType<typeof createLocalMembership$>;
} => { } => {
const scope = new ObservableScope(); const scope = new ObservableScope();
const connectionManagerData = new ConnectionManagerData(); const connectionMap = new ConnectionMap();
if (connection) connectionManagerData.add(connection, []); if (connection) connectionMap.add(connection, []);
const localMembership = createLocalMembership$({ const localMembership = createLocalMembership$({
scope, scope,
...defaultCreateLocalMemberValues, ...defaultCreateLocalMemberValues,
connectionManager: { connectionMap$: constant(new Epoch(connectionMap)),
connectionManagerData$: constant(new Epoch(connectionManagerData)), localTransport$: constant(mockTransportLocator),
},
localTransport$: constant(mockTransport),
}); });
return { scope, localMembership }; return { scope, localMembership };
}; };
@@ -857,8 +867,12 @@ describe("LocalMembership", () => {
const error = new Error("NotReadableError"); const error = new Error("NotReadableError");
const setScreenShareEnabled = vi.fn().mockRejectedValue(error); const setScreenShareEnabled = vi.fn().mockRejectedValue(error);
const connection = { const connection = {
state$: constant(ConnectionState.LivekitConnected), state$: constant({
transport: mockTransportConfig, state: "authenticated",
livekitState: LivekitConnectionState.Connected,
}),
transport: mockTransport,
serverName: "example.org",
livekitRoom: mockLivekitRoom({ livekitRoom: mockLivekitRoom({
localParticipant: mockLocalParticipant({ localParticipant: mockLocalParticipant({
isScreenShareEnabled: false, isScreenShareEnabled: false,
@@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details.
*/ */
import { import {
ConnectionState as LivekitConnectionState,
type Participant, type Participant,
ParticipantEvent, ParticipantEvent,
type LocalParticipant, type LocalParticipant,
@@ -15,10 +16,9 @@ import {
MediaDeviceFailure, MediaDeviceFailure,
} from "livekit-client"; } from "livekit-client";
import { observeParticipantEvents } from "@livekit/components-core"; import { observeParticipantEvents } from "@livekit/components-core";
import { type MatrixClient } from "matrix-js-sdk";
import { import {
Status as RTCSessionStatus, Status as RTCSessionStatus,
type LivekitTransport, type UnstableLivekitTransport,
type MatrixRTCSession, type MatrixRTCSession,
type RTCCallIntent, type RTCCallIntent,
type RTCNotificationType, type RTCNotificationType,
@@ -47,8 +47,8 @@ import { deepCompare } from "matrix-js-sdk/lib/utils";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; 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 ConnectionMap } from "../remoteMembers/ConnectionMap.ts";
import { type ObservableScope } from "../../ObservableScope.ts"; import { type Epoch, type ObservableScope } from "../../ObservableScope.ts";
import { type Publisher } from "./Publisher.ts"; import { type Publisher } from "./Publisher.ts";
import { type MuteStates } from "../../MuteStates.ts"; import { type MuteStates } from "../../MuteStates.ts";
import { import {
@@ -74,13 +74,17 @@ import {
} from "../../../config/ConfigOptions.ts"; } from "../../../config/ConfigOptions.ts";
import { Config } from "../../../config/Config.ts"; import { Config } from "../../../config/Config.ts";
import { import {
ConnectionState, type ConnectionState,
type Connection, type Connection,
type FailedToStartError,
} from "../remoteMembers/Connection.ts"; } from "../remoteMembers/Connection.ts";
import { type HomeserverConnected } from "./HomeserverConnected.ts"; import { type HomeserverConnected } from "./HomeserverConnected.ts";
import { type LocalTransport } from "./LocalTransport.ts"; import {
import { getSFUConfigWithOpenID } from "../../../livekit/openIDSFU.ts"; type ClientDelegationParts,
delegateDelayedLeave,
type ClientOpenIDParts,
chooseAuthFlow,
type TransportLocator,
} from "../../../livekit/auth";
export enum TransportState { export enum TransportState {
/** Not even a transport is available to the LocalMembership */ /** Not even a transport is available to the LocalMembership */
@@ -113,7 +117,7 @@ export enum TrackState {
export type LocalMemberMediaState = export type LocalMemberMediaState =
| { | {
tracks: TrackState; tracks: TrackState;
connection: ConnectionState | FailedToStartError; connection: ConnectionState;
} }
| PublishState | PublishState
| ElementCallError; | ElementCallError;
@@ -140,25 +144,27 @@ interface Props {
// that the inputs for those createSomething$() functions should NOT contain any js-sdk objectes // that the inputs for those createSomething$() functions should NOT contain any js-sdk objectes
scope: ObservableScope; scope: ObservableScope;
muteStates: MuteStates; muteStates: MuteStates;
connectionManager: IConnectionManager; connectionMap$: Behavior<Epoch<ConnectionMap>>;
createPublisherFactory: (connection: Connection) => Publisher; createPublisherFactory: (connection: Connection) => Publisher;
joinMatrixRTC: ( joinMatrixRTC: (
transport: LivekitTransport, transport: UnstableLivekitTransport,
delayedLeaveTimings: ResolvedDelayedLeaveTimings, delayedLeaveTimings: ResolvedDelayedLeaveTimings,
) => void; ) => void;
homeserverConnected: HomeserverConnected; homeserverConnected: HomeserverConnected;
roomId: string; roomId: string;
ownMembershipIdentity: CallMembershipIdentityParts; ownMembershipIdentity: CallMembershipIdentityParts;
localTransport$: Observable<LocalTransport>; localTransport$: Observable<TransportLocator>;
client: Pick<MatrixClient, "getDeviceId" | "getOpenIdToken">; client: ClientDelegationParts & ClientOpenIDParts;
matrixRTCSession: Pick<MatrixRTCSession, "updateCallIntent" | "leave">; matrixRTCSession: Pick<
MatrixRTCSession,
"slotId" | "updateCallIntent" | "leave"
>;
/** Whether to hide the screen-sharing button. */ /** Whether to hide the screen-sharing button. */
hideScreensharing: boolean; hideScreensharing: boolean;
/** The application hosting Element Call, to be kept informed of join/leave. */ /** The application hosting Element Call, to be kept informed of join/leave. */
hostBridge: HostBridge; hostBridge: HostBridge;
baseUrl: string; baseUrl: string;
delayId$: Behavior<string | null>; delayId$: Behavior<string | null>;
matrixRTCMode: MatrixRTCMode;
logger: Logger; logger: Logger;
} }
@@ -169,7 +175,7 @@ interface Props {
* - * -
* @param props The properties required to create the local membership. * @param props The properties required to create the local membership.
* @param props.scope The observable scope to use. * @param props.scope The observable scope to use.
* @param props.connectionManager The connection manager to get connections from. * @param props.connectionMap$ The connection map to get connections from.
* @param props.createPublisherFactory Factory to create a publisher once we have a connection. * @param props.createPublisherFactory Factory to create a publisher once we have a connection.
* @param props.joinMatrixRTC Callback to join the matrix RTC session once we have a transport. * @param props.joinMatrixRTC Callback to join the matrix RTC session once we have a transport.
* @param props.homeserverConnected The homeserver connected state. * @param props.homeserverConnected The homeserver connected state.
@@ -191,7 +197,7 @@ interface Props {
*/ */
export const createLocalMembership$ = ({ export const createLocalMembership$ = ({
scope, scope,
connectionManager, connectionMap$,
localTransport$: localTransportWithErrors$, localTransport$: localTransportWithErrors$,
homeserverConnected, homeserverConnected,
createPublisherFactory, createPublisherFactory,
@@ -206,7 +212,6 @@ export const createLocalMembership$ = ({
hostBridge, hostBridge,
ownMembershipIdentity, ownMembershipIdentity,
delayId$, delayId$,
matrixRTCMode,
}: Props): { }: Props): {
/** /**
* This request to start audio and video tracks. * This request to start audio and video tracks.
@@ -308,16 +313,17 @@ export const createLocalMembership$ = ({
const joinParams$ = scope.behavior( const joinParams$ = scope.behavior(
localTransport$.pipe( localTransport$.pipe(
switchMap(async ({ transport }) => { switchMap(async ({ transport }) => {
const transportSupportsDelegation = checkDelegationSupport( // Delegation goes through whichever service authenticates us as a
transport.livekit_service_url + "/delegate_delayed_leave", // publisher, so only that service's support counts
`transport ${transport.livekit_service_url}`, const flow = chooseAuthFlow(transport, "publisher");
); const delegationSupported =
return { flow.kind === "openID"
transport, ? await checkDelegationSupport(
delegationSupported: flow.serviceUrl + "/delegate_delayed_leave",
(await homeserverSupportsDelegation) || `transport ${flow.serviceUrl}`,
(await transportSupportsDelegation), )
}; : await homeserverSupportsDelegation;
return { transport, delegationSupported };
}), }),
), ),
null, null,
@@ -325,16 +331,14 @@ export const createLocalMembership$ = ({
// Drop Epoch data here since we will not combine this anymore // Drop Epoch data here since we will not combine this anymore
const localConnection$ = scope.behavior( const localConnection$ = scope.behavior(
combineLatest([ combineLatest(
connectionManager.connectionManagerData$, [connectionMap$, localTransport$],
localTransport$, ({ value: connectionMap }, transport) =>
]).pipe( connectionMap.getConnectionForTransport(transport),
map(([{ value: connectionData }, { transport }]) => ).pipe(
connectionData.getConnectionForTransport(transport),
),
tap((connection) => { tap((connection) => {
logger.info( logger.info(
`Local connection updated: ${connection?.transport?.livekit_service_url}`, `Local connection updated: ${JSON.stringify(connection?.transport)}`,
); );
}), }),
), ),
@@ -406,7 +410,7 @@ export const createLocalMembership$ = ({
scope.reconcile(localConnection$, async (connection) => { scope.reconcile(localConnection$, async (connection) => {
logger.info( logger.info(
"reconcile based on new localConnection:", "reconcile based on new localConnection:",
connection?.transport.livekit_service_url, JSON.stringify(connection?.transport),
); );
if (connection !== null) { if (connection !== null) {
const publisher = createPublisherFactory(connection); const publisher = createPublisherFactory(connection);
@@ -475,7 +479,13 @@ export const createLocalMembership$ = ({
}; };
const localConnectionState$ = localConnection$.pipe( const localConnectionState$ = localConnection$.pipe(
switchMap((connection) => (connection ? connection.state$ : of(null))), switchMap((connection) =>
connection ? connection.state$ : of({ state: "initializing" as const }),
),
);
const authenticated$ = scope.behavior(
localConnectionState$.pipe(map(({ state }) => state === "authenticated")),
); );
const mediaState$: Behavior<LocalMemberMediaState> = scope.behavior( const mediaState$: Behavior<LocalMemberMediaState> = scope.behavior(
@@ -493,7 +503,9 @@ export const createLocalMembership$ = ({
: TrackState.WaitingForUser; : TrackState.WaitingForUser;
if ( if (
localConnectionState !== ConnectionState.LivekitConnected || localConnectionState.state !== "authenticated" ||
localConnectionState.livekitState !==
LivekitConnectionState.Connected ||
trackState !== TrackState.Ready trackState !== TrackState.Ready
) )
return { return {
@@ -561,7 +573,11 @@ export const createLocalMembership$ = ({
combineLatest([ combineLatest([
homeserverConnected.combined$, homeserverConnected.combined$,
localConnectionState$.pipe( localConnectionState$.pipe(
map((state) => state === ConnectionState.LivekitConnected), map(
(state) =>
state.state === "authenticated" &&
state.livekitState === LivekitConnectionState.Connected,
),
), ),
]).pipe( ]).pipe(
map(([[hsConnected, hsReason], livekitConnected]) => { map(([[hsConnected, hsReason], livekitConnected]) => {
@@ -656,34 +672,38 @@ export const createLocalMembership$ = ({
// Join and leave the session as needed // Join and leave the session as needed
scope.reconcile( scope.reconcile(
scope.behavior(combineLatest([joinParams$, joinAndPublishRequested$])), scope.behavior(
async ([joinParams, shouldConnect]) => { combineLatest([joinParams$, joinAndPublishRequested$, authenticated$]),
if (!joinParams) return; ),
// if shouldConnect=false we will do the disconnect as the cleanup from the previous reconcile iteration. async ([joinParams, shouldConnect, authenticated]) => {
if (!shouldConnect) return;
const sessionConfig = Config.get().matrix_rtc_session; const sessionConfig = Config.get().matrix_rtc_session;
try { // Only join once authenticated, to ensure the LiveKit room exists first.
joinMatrixRTC( // Otherwise, subscribers authenticating with the OpenID flow may fail to
joinParams.transport, // join (they lack permission to create the room) and never retry.
joinParams.delegationSupported if (joinParams && shouldConnect && authenticated) {
? sessionConfig.delegated_delayed_leave
: sessionConfig.delayed_leave,
);
} catch (error) {
logger.error("Error entering RTC session", error);
if (error instanceof Error)
setMatrixError(new MembershipManagerError(error));
}
return Promise.resolve(async (): Promise<void> => {
try { try {
// TODO Update matrixRTCSession to allow udpating the transport without leaving the session! joinMatrixRTC(
await matrixRTCSession.leave(1000); joinParams.transport,
} catch (e) { joinParams.delegationSupported
logger.error("Error leaving RTC session", e); ? sessionConfig.delegated_delayed_leave
: sessionConfig.delayed_leave,
);
} catch (error) {
logger.error("Error entering RTC session", error);
if (error instanceof Error)
setMatrixError(new MembershipManagerError(error));
} }
});
// Clean-up callback to leave the session
return Promise.resolve(async (): Promise<void> => {
try {
await matrixRTCSession.leave(1000);
} catch (e) {
logger.error("Error leaving RTC session", e);
}
});
}
}, },
); );
@@ -702,23 +722,22 @@ export const createLocalMembership$ = ({
async ([joinParams, delayId]) => { async ([joinParams, delayId]) => {
if (joinParams?.delegationSupported && delayId !== null) { if (joinParams?.delegationSupported && delayId !== null) {
try { try {
// This will technically cause the service to issue a new JWT token, logger.info(
// but it's safe to discard. We're only interested in triggering `Delegating delayed leave to ${JSON.stringify(joinParams.transport)}…`,
// delegation.
await getSFUConfigWithOpenID(
client,
ownMembershipIdentity,
joinParams.transport.livekit_service_url,
roomId,
{ matrixRTCMode, delayEndpointBaseUrl: baseUrl, delayId },
logger,
); );
await delegateDelayedLeave({
client,
membership: ownMembershipIdentity,
transport: joinParams.transport,
slotId: matrixRTCSession.slotId,
roomId,
delayId,
logger,
});
logger.info("Delayed leave successfully delegated");
} catch (e) { } catch (e) {
// TODO: Surface this to the user as a service interruption? // TODO: Surface this to the user as a service interruption?
logger.error( logger.error(`Failed to delegate leave`, e);
`Failed to delegate leave to ${joinParams.transport.livekit_service_url}`,
e,
);
} }
} }
}, },
@@ -951,7 +970,7 @@ interface EnterRTCSessionOptions {
* *
* @param rtcSession - The MatrixRTCSession to join. * @param rtcSession - The MatrixRTCSession to join.
* @param ownMembershipIdentity - Options for entering the RTC session. * @param ownMembershipIdentity - Options for entering the RTC session.
* @param transport - The LivekitTransport to use for this session. * @param transport - The UnstableLivekitTransport to use for this session.
* @param options - `encryptMedia`: Whether to encrypt media. `matrixRTCMode`: The * @param options - `encryptMedia`: Whether to encrypt media. `matrixRTCMode`: The
* Matrix RTC mode to use. `delayedLeaveTimings`: The preferred timings for * Matrix RTC mode to use. `delayedLeaveTimings`: The preferred timings for
* delayed leave events. `sendNotificationType`: Whether and what kind of * delayed leave events. `sendNotificationType`: Whether and what kind of
@@ -962,7 +981,7 @@ interface EnterRTCSessionOptions {
export function enterRTCSession( export function enterRTCSession(
rtcSession: MatrixRTCSession, rtcSession: MatrixRTCSession,
ownMembershipIdentity: CallMembershipIdentityParts, ownMembershipIdentity: CallMembershipIdentityParts,
transport: LivekitTransport, transport: UnstableLivekitTransport,
{ {
encryptMedia, encryptMedia,
matrixRTCMode, matrixRTCMode,
@@ -16,130 +16,54 @@ import {
} from "vitest"; } from "vitest";
import fetchMock from "fetch-mock"; import fetchMock from "fetch-mock";
import { mockConfig, ownMemberMock } from "../../../utils/test"; import { mockConfig } from "../../../utils/test";
import { getLocalTransport } from "./LocalTransport"; import { getLocalTransport } from "./LocalTransport";
import { import { MatrixRTCTransportMissingError } from "../../../utils/errors";
MatrixRTCTransportMissingError, import { customTransport } from "../../../settings/settings";
FailToGetOpenIdToken, import { type MatrixClient } from "matrix-js-sdk";
} from "../../../utils/errors";
import * as openIDSFU from "../../../livekit/openIDSFU";
import { customLivekitUrl } from "../../../settings/settings";
import { testJWTToken } from "../../../utils/test-fixtures";
import { MatrixRTCMode } from "../../../config/ConfigOptions";
describe("LocalTransport", () => { describe("LocalTransport", () => {
const openIdResponse: openIDSFU.SFUConfig = {
url: "https://lk.example.org",
jwt: testJWTToken,
livekitAlias: "Akph4alDMhen",
livekitIdentity: "@lk_user:ABCDEF",
};
beforeEach(() => vi.clearAllMocks()); beforeEach(() => vi.clearAllMocks());
it("throws if config is missing", async () => { it("throws if config is missing", async () => {
await expect( await expect(
getLocalTransport({ getLocalTransport({
roomId: "!room:example.org", _unstable_getRTCTransports: async () => Promise.resolve([]),
client: { getDomain: () => "example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getDomain: () => "example.org",
// These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
ownMembershipIdentity: ownMemberMock,
matrixRTCMode: MatrixRTCMode.Compatibility,
}), }),
).rejects.toThrow(new MatrixRTCTransportMissingError("example.org")); ).rejects.toThrow(new MatrixRTCTransportMissingError("example.org"));
}); });
it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => {
// Provide a valid config so makeTransportInternal resolves a transport
mockConfig({
livekit: { livekit_service_url: "https://lk.example.org" },
});
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockRejectedValue(
new FailToGetOpenIdToken(new Error("no openid")),
);
await expect(
getLocalTransport({
roomId: "!example_room_id",
client: {
getDomain: () => "example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
ownMembershipIdentity: ownMemberMock,
matrixRTCMode: MatrixRTCMode.Compatibility,
}),
).rejects.toThrow(new FailToGetOpenIdToken(new Error("no openid")));
});
it("returns preferred transport", async () => { it("returns preferred transport", async () => {
// Use config so transport discovery succeeds, but delay OpenID JWT fetch // Use config so transport discovery succeeds
mockConfig({ mockConfig({
livekit: { livekit_service_url: "https://lk.example.org" }, livekit: { livekit_service_url: "https://lk.example.org" },
}); });
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue({
url: "https://lk.example.org",
jwt: "jwt",
livekitAlias: "Akph4alDMhen",
livekitIdentity: ownMemberMock.userId + ":" + ownMemberMock.deviceId,
});
expect( expect(
await getLocalTransport({ await getLocalTransport({
roomId: "!room:example.org", _unstable_getRTCTransports: async () => Promise.resolve([]),
client: { getDomain: () => "example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getDomain: () => "example.org",
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
ownMembershipIdentity: ownMemberMock,
matrixRTCMode: MatrixRTCMode.Compatibility,
}), }),
).toStrictEqual({ ).toStrictEqual({
transport: { transport: {
livekit_service_url: "https://lk.example.org", livekit_service_url: "https://lk.example.org",
type: "livekit", type: "livekit",
}, },
sfuConfig: { serverName: "example.org",
jwt: "jwt",
livekitAlias: "Akph4alDMhen",
livekitIdentity: "@alice:example.org:DEVICE",
url: "https://lk.example.org",
},
}); });
}); });
type LocalTransportProps = Parameters<typeof getLocalTransport>[0];
describe("transport configuration mechanisms", () => { describe("transport configuration mechanisms", () => {
let localTransportOpts: LocalTransportProps & { let client: MockedObject<
client: MockedObject<LocalTransportProps["client"]>; Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports">
}; >;
beforeEach(() => { beforeEach(() => {
mockConfig({}); mockConfig({});
customLivekitUrl.setValue(customLivekitUrl.defaultValue); customTransport.setValue(customTransport.defaultValue);
localTransportOpts = { client = {
ownMembershipIdentity: ownMemberMock, getDomain: vi.fn().mockReturnValue("example.org"),
roomId: "!example_room_id", _unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
matrixRTCMode: MatrixRTCMode.Compatibility,
client: {
getDomain: vi.fn().mockReturnValue("example.org"),
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
}; };
}); });
@@ -151,93 +75,50 @@ describe("LocalTransport", () => {
mockConfig({ mockConfig({
livekit: { livekit_service_url: "https://lk.example.org" }, livekit: { livekit_service_url: "https://lk.example.org" },
}); });
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue(
openIdResponse,
);
expect(await getLocalTransport(localTransportOpts)).toStrictEqual({ expect(await getLocalTransport(client)).toStrictEqual({
transport: { transport: {
livekit_service_url: "https://lk.example.org", livekit_service_url: "https://lk.example.org",
type: "livekit", type: "livekit",
}, },
sfuConfig: { serverName: "example.org",
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
livekitAlias: "Akph4alDMhen",
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 () => {
customLivekitUrl.setValue("https://lk.example.org"); customTransport.setValue({
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue( type: "livekit",
openIdResponse, livekit_service_url: "https://lk.example.org",
); });
expect(await getLocalTransport(localTransportOpts)).toStrictEqual({ expect(await getLocalTransport(client)).toStrictEqual({
transport: { transport: {
livekit_service_url: "https://lk.example.org", livekit_service_url: "https://lk.example.org",
type: "livekit", type: "livekit",
}, },
sfuConfig: { serverName: "example.org",
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
livekitAlias: "Akph4alDMhen",
livekitIdentity: "@lk_user:ABCDEF",
url: "https://lk.example.org",
},
}); });
}); });
it("supports getting transport via backend", async () => { it("supports getting transport via backend", async () => {
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([ client._unstable_getRTCTransports.mockResolvedValue([
{ type: "livekit", livekit_service_url: "https://lk.example.org" }, { type: "livekit", livekit_service_url: "https://lk.example.org" },
]); ]);
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue(
openIdResponse,
);
expect(await getLocalTransport(localTransportOpts)).toStrictEqual({ expect(await getLocalTransport(client)).toStrictEqual({
transport: { transport: {
livekit_service_url: "https://lk.example.org", livekit_service_url: "https://lk.example.org",
type: "livekit", type: "livekit",
}, },
sfuConfig: { serverName: "example.org",
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
livekitAlias: "Akph4alDMhen",
livekitIdentity: "@lk_user:ABCDEF",
url: "https://lk.example.org",
},
}); });
}); });
it("fails fast if the openID request fails for backend config", async () => {
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
]);
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockRejectedValue(
new FailToGetOpenIdToken(new Error("Test driven error")),
);
await expect(getLocalTransport(localTransportOpts)).rejects.toThrow(
expect.any(FailToGetOpenIdToken),
);
});
it("throws if no options are available", async () => { it("throws if no options are available", async () => {
await expect( await expect(
getLocalTransport({ getLocalTransport({
ownMembershipIdentity: ownMemberMock, getDomain: () => "example.org",
roomId: "!example_room_id", _unstable_getRTCTransports: async () => Promise.resolve([]),
matrixRTCMode: MatrixRTCMode.Compatibility,
client: {
getDomain: () => "example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
// These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
}), }),
).rejects.toThrow(new MatrixRTCTransportMissingError("example.org")); ).rejects.toThrow(new MatrixRTCTransportMissingError("example.org"));
}); });
@@ -5,142 +5,37 @@ SPDX-License-IdFentifier: 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 { type MatrixClient } from "matrix-js-sdk"; import { type MatrixClient } from "matrix-js-sdk";
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { Config } from "../../../config/Config.ts"; import { Config } from "../../../config/Config.ts";
import { import { MatrixRTCTransportMissingError } from "../../../utils/errors.ts";
FailToGetOpenIdToken, import { customTransport as customTransportSetting } from "../../../settings/settings.ts";
MatrixRTCTransportMissingError,
NoMatrix2AuthorizationService,
} from "../../../utils/errors.ts";
import {
getSFUConfigWithOpenID,
type SFUConfig,
type OpenIDClientParts,
} from "../../../livekit/openIDSFU.ts";
import { customLivekitUrl } from "../../../settings/settings.ts";
import { RtcTransportAutoDiscovery } from "./RtcTransportAutoDiscovery.ts"; import { RtcTransportAutoDiscovery } from "./RtcTransportAutoDiscovery.ts";
import { type MatrixRTCMode } from "../../../config/ConfigOptions.ts"; import { type TransportLocator } from "../../../livekit/auth/types.ts";
interface Props {
ownMembershipIdentity: CallMembershipIdentityParts;
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports"> &
OpenIDClientParts;
// Used by the jwt service to create the livekit room and compute the livekit alias.
roomId: string;
matrixRTCMode: MatrixRTCMode;
}
export interface LocalTransport {
transport: LivekitTransport;
sfuConfig: SFUConfig;
}
export function isLocalTransport(
obj: LivekitTransport | LocalTransport,
): obj is LocalTransport {
return "transport" in obj && "sfuConfig" in obj;
}
/** /**
* Connects to the JWT service and determines the transport that the local member should use. * Determines the transport to advertise in our MatrixRTC membership and publish
* * media on.
* @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 transport to advertise in our MatrixRTC membership and publish media on.
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
*/ */
export async function getLocalTransport({ export async function getLocalTransport(
ownMembershipIdentity, client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports">,
client, ): Promise<TransportLocator> {
roomId, const serverName = client.getDomain();
matrixRTCMode, if (serverName === null) throw new Error("No server name");
}: Props): Promise<LocalTransport> {
const logger = rootLogger.getChild("[LocalTransport]");
const discovery = new RtcTransportAutoDiscovery({ const discovery = new RtcTransportAutoDiscovery({
client: client,
resolvedConfig: Config.get(),
logger: logger,
});
const customUrl = customLivekitUrl.value$.value;
// Respect the user's custom URL, if set
const transport: LivekitTransport | null = customUrl
? { type: "livekit", livekit_service_url: customUrl }
: await discovery.discoverPreferredTransport();
if (transport === null)
throw new MatrixRTCTransportMissingError(client.getDomain() ?? "");
try {
return await doOpenIdAndJWTFromUrl(
transport,
matrixRTCMode,
ownMembershipIdentity,
roomId,
client,
logger,
);
} catch (e) {
logger.error(
`Failed to authenticate to transport ${transport.livekit_service_url}`,
e,
);
throw mapAuthErrorToUserFriendlyError(e);
}
}
/**
* Utility to ensure the user can authenticate with the SFU.
* We will call `getSFUConfigWithOpenID` once per transport here as it's our
* only mechanism of validation. This means we will also ask the
* homeserver for a OpenID token a few times. Since OpenID tokens are single
* use we don't want to risk any issues by re-using a token.
*
* @param transport The transport to authenticate with.
* @param matrixRTCMode Whether to force the JWT endpoint to be used.
* @param membership The identity of the local member.
* @param roomId The room ID to use for the JWT.
* @param client The client to use for the OpenID token.
*
* @throws FailToGetOpenIdToken, NoMatrix2AuthorizationService
*/
async function doOpenIdAndJWTFromUrl(
transport: LivekitTransport,
matrixRTCMode: MatrixRTCMode,
membership: CallMembershipIdentityParts,
roomId: string,
client: Pick<MatrixClient, "_unstable_getRTCTransports"> & OpenIDClientParts,
logger?: Logger,
): Promise<LocalTransport> {
const sfuConfig = await getSFUConfigWithOpenID(
client, client,
membership, resolvedConfig: Config.get(),
transport.livekit_service_url, logger: rootLogger.getChild("[LocalTransport]"),
roomId, });
{ matrixRTCMode },
logger,
);
return {
transport,
sfuConfig,
};
}
function mapAuthErrorToUserFriendlyError(e: unknown): Error { // Respect the user's custom transport, if set
if ( const transport =
e instanceof FailToGetOpenIdToken || customTransportSetting.value$.value ??
e instanceof NoMatrix2AuthorizationService (await discovery.discoverPreferredTransport());
) {
// rethrow as is if (transport === null) throw new MatrixRTCTransportMissingError(serverName);
return e;
} return { transport, serverName };
// Catch others and rethrow as FailToGetOpenIdToken that has user friendly message.
return new FailToGetOpenIdToken(
e instanceof Error ? e : new Error(String(e)),
);
} }
@@ -16,7 +16,7 @@ import {
import { MatrixError } from "matrix-js-sdk"; import { MatrixError } from "matrix-js-sdk";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { import {
type LivekitTransport, type UnstableLivekitTransport,
type Transport, type Transport,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
@@ -28,12 +28,12 @@ import {
type DiscoveryClient = RtcTransportAutoDiscoveryProps["client"]; type DiscoveryClient = RtcTransportAutoDiscoveryProps["client"];
const backendTransport: LivekitTransport = { const backendTransport: UnstableLivekitTransport = {
type: "livekit", type: "livekit",
livekit_service_url: "https://backend.example.org", livekit_service_url: "https://backend.example.org",
}; };
const configTransport: LivekitTransport = { const configTransport: UnstableLivekitTransport = {
type: "livekit", type: "livekit",
livekit_service_url: "https://config.example.org", livekit_service_url: "https://config.example.org",
}; };
@@ -5,8 +5,8 @@ SPDX-License-IdFentifier: 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 { import {
isLivekitTransport, isUnstableLivekitTransport,
type LivekitTransport, type UnstableLivekitTransport,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixClient } from "matrix-js-sdk";
import { type Logger } from "matrix-js-sdk/lib/logger"; import { type Logger } from "matrix-js-sdk/lib/logger";
@@ -40,12 +40,12 @@ export class RtcTransportAutoDiscovery {
this.logger = logger.getChild("[RtcTransportAutoDiscovery]"); this.logger = logger.getChild("[RtcTransportAutoDiscovery]");
} }
public async discoverPreferredTransport(): Promise<LivekitTransport | null> { public async discoverPreferredTransport(): Promise<UnstableLivekitTransport | null> {
// 1) backend transports // 1) backend transports
const backendTransport = await this.tryBackendTransports(); const backendTransport = await this.tryBackendTransports();
if (backendTransport) { if (backendTransport) {
this.logger.info( this.logger.info(
`Found backend transport: ${backendTransport.livekit_service_url}`, `Found backend transport: ${JSON.stringify(backendTransport)}`,
); );
return backendTransport; return backendTransport;
} }
@@ -54,7 +54,7 @@ export class RtcTransportAutoDiscovery {
const configTransport = this.tryConfigTransport(); const configTransport = this.tryConfigTransport();
if (configTransport) { if (configTransport) {
this.logger.info( this.logger.info(
`Found app config transport: ${configTransport.livekit_service_url}`, `Found app config transport: ${JSON.stringify(configTransport)}`,
); );
return configTransport; return configTransport;
} }
@@ -67,7 +67,7 @@ export class RtcTransportAutoDiscovery {
* This will not throw errors, but instead just log them and return null if the expected config is not found or malformed. * This will not throw errors, but instead just log them and return null if the expected config is not found or malformed.
* @private * @private
*/ */
private async tryBackendTransports(): Promise<LivekitTransport | null> { private async tryBackendTransports(): Promise<UnstableLivekitTransport | null> {
const client = this.client; const client = this.client;
// MSC4143: Attempt to fetch transports from backend. // MSC4143: Attempt to fetch transports from backend.
this.logger.info("First try to use getRTCTransports end point ..."); this.logger.info("First try to use getRTCTransports end point ...");
@@ -75,7 +75,7 @@ export class RtcTransportAutoDiscovery {
const transportList = await doNetworkOperationWithRetry(async () => const transportList = await doNetworkOperationWithRetry(async () =>
client._unstable_getRTCTransports(), client._unstable_getRTCTransports(),
); );
const first = transportList.find(isLivekitTransport); const first = transportList.find(isUnstableLivekitTransport);
if (first) { if (first) {
return first; return first;
} else { } else {
@@ -90,7 +90,7 @@ export class RtcTransportAutoDiscovery {
return null; return null;
} }
private tryConfigTransport(): LivekitTransport | null { private tryConfigTransport(): UnstableLivekitTransport | null {
const url = this.resolvedConfig.livekit?.livekit_service_url; const url = this.resolvedConfig.livekit?.livekit_service_url;
if (url) { if (url) {
return { return {
@@ -28,27 +28,30 @@ import {
} from "livekit-client"; } from "livekit-client";
import fetchMock from "fetch-mock"; import fetchMock from "fetch-mock";
import EventEmitter from "events"; import EventEmitter from "events";
import { type IOpenIDToken } from "matrix-js-sdk"; import { MatrixError } from "matrix-js-sdk";
import { logger, type Logger } from "matrix-js-sdk/lib/logger"; import { logger, type Logger } from "matrix-js-sdk/lib/logger";
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; import {
type LivekitGetTokenResponse,
type UnstableLivekitTransport,
} from "matrix-js-sdk/lib/matrixrtc";
import { import {
Connection, Connection,
ConnectionState, type ConnectionState,
type ConnectionOpts, type ConnectionOpts,
} from "./Connection.ts"; } from "./Connection.ts";
import { ObservableScope } from "../../ObservableScope.ts"; import { ObservableScope } from "../../ObservableScope.ts";
import { type OpenIDClientParts } from "../../../livekit/openIDSFU.ts"; import { FailToGetOpenIdToken } from "../../../utils/errors.ts";
import {
ElementCallError,
FailToGetOpenIdToken,
} from "../../../utils/errors.ts";
import { testJWTToken } from "../../../utils/test-fixtures.ts"; import { testJWTToken } from "../../../utils/test-fixtures.ts";
import { mockRemoteParticipant, ownMemberMock } from "../../../utils/test.ts"; import { mockRemoteParticipant, ownMemberMock } from "../../../utils/test.ts";
import {
type ClientGetTokenParts,
type ClientOpenIDParts,
} from "../../../livekit/auth";
let testScope: ObservableScope; let testScope: ObservableScope;
let client: MockedObject<OpenIDClientParts>; let client: MockedObject<ClientGetTokenParts & ClientOpenIDParts>;
let fakeLivekitRoom: MockedObject<LivekitRoom>; let fakeLivekitRoom: MockedObject<LivekitRoom>;
@@ -56,23 +59,24 @@ let localParticipantEventEmiter: EventEmitter;
let fakeLocalParticipant: MockedObject<LocalParticipant>; let fakeLocalParticipant: MockedObject<LocalParticipant>;
const ROOM_ID = "!roomID:example.org"; const ROOM_ID = "!roomID:example.org";
const SLOT_ID = "m.call#ROOM";
const livekitFocus: LivekitTransport = { const transport: UnstableLivekitTransport = {
livekit_service_url: "https://matrix-rtc.example.org/livekit/jwt",
type: "livekit", type: "livekit",
url: "wss://matrix-rtc.m.localhost/livekit/sfu",
}; };
function setupTest(): void { function setupTest(): void {
testScope = new ObservableScope(); testScope = new ObservableScope();
client = vi.mocked<OpenIDClientParts>({ client = vi.mocked<ClientGetTokenParts & ClientOpenIDParts>({
getOpenIdToken: vi.fn().mockResolvedValue({ getOpenIdToken: vi.fn().mockResolvedValue({
access_token: "rYsmGUEwNjKgJYyeNUkZseJN", access_token: "rYsmGUEwNjKgJYyeNUkZseJN",
token_type: "Bearer", token_type: "Bearer",
matrix_server_name: "example.org", matrix_server_name: "example.org",
expires_in: 3600, expires_in: 3600,
}), }),
getDeviceId: vi.fn().mockReturnValue("ABCDEF"), _unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: testJWTToken }),
} as unknown as OpenIDClientParts); });
localParticipantEventEmiter = new EventEmitter(); localParticipantEventEmiter = new EventEmitter();
@@ -116,24 +120,17 @@ function setupTest(): void {
function setupRemoteConnection(): Connection { function setupRemoteConnection(): Connection {
const opts: ConnectionOpts = { const opts: ConnectionOpts = {
client: client, client,
roomId: ROOM_ID, roomId: ROOM_ID,
transport: livekitFocus, slotId: SLOT_ID,
role: "subscriber",
transport,
serverName: "m.localhost",
scope: testScope, scope: testScope,
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
livekitRoomFactory: () => fakeLivekitRoom, livekitRoomFactory: () => fakeLivekitRoom,
}; };
fetchMock.post(`${livekitFocus.livekit_service_url}/sfu/get`, () => {
return {
status: 200,
body: {
url: "wss://matrix-rtc.m.localhost/livekit/sfu",
jwt: testJWTToken,
},
};
});
fakeLivekitRoom.connect.mockImplementation(async (): Promise<void> => { fakeLivekitRoom.connect.mockImplementation(async (): Promise<void> => {
const changeEv = RoomEvent.ConnectionStateChanged; const changeEv = RoomEvent.ConnectionStateChanged;
@@ -172,26 +169,32 @@ describe("Start connection states", () => {
setupTest(); setupTest();
const opts: ConnectionOpts = { const opts: ConnectionOpts = {
client: client, client,
roomId: ROOM_ID, roomId: ROOM_ID,
transport: livekitFocus, slotId: SLOT_ID,
role: "subscriber",
transport,
serverName: "m.localhost",
scope: testScope, scope: testScope,
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
livekitRoomFactory: () => fakeLivekitRoom, livekitRoomFactory: () => fakeLivekitRoom,
}; };
const connection = new Connection(opts, logger); const connection = new Connection(opts, logger);
expect(connection.state$.getValue()).toEqual("Initialized"); expect(connection.state$.getValue()).toEqual({ state: "initialized" });
}); });
it("fail to getOpenId token then error state", async () => { it("fail to get SFU token then error state", async () => {
setupTest(); setupTest();
vi.useFakeTimers(); vi.useFakeTimers();
const opts: ConnectionOpts = { const opts: ConnectionOpts = {
client: client, client,
roomId: ROOM_ID, roomId: ROOM_ID,
transport: livekitFocus, slotId: SLOT_ID,
role: "subscriber",
transport,
serverName: "m.localhost",
scope: testScope, scope: testScope,
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
livekitRoomFactory: () => fakeLivekitRoom, livekitRoomFactory: () => fakeLivekitRoom,
@@ -199,16 +202,16 @@ describe("Start connection states", () => {
const connection = new Connection(opts, logger); const connection = new Connection(opts, logger);
const capturedStates: (ConnectionState | Error)[] = []; const capturedStates: ConnectionState[] = [];
const s = connection.state$.subscribe((value) => { const s = connection.state$.subscribe((value) => {
capturedStates.push(value); capturedStates.push(value);
}); });
onTestFinished(() => s.unsubscribe()); onTestFinished(() => s.unsubscribe());
const deferred = Promise.withResolvers<IOpenIDToken>(); const deferred = Promise.withResolvers<LivekitGetTokenResponse>();
client.getOpenIdToken.mockImplementation( client._unstable_getLivekitToken.mockImplementation(
async (): Promise<IOpenIDToken> => { async (): Promise<LivekitGetTokenResponse> => {
return await deferred.promise; return await deferred.promise;
}, },
); );
@@ -219,18 +222,20 @@ describe("Start connection states", () => {
let capturedState = capturedStates.pop(); let capturedState = capturedStates.pop();
expect(capturedState).toBeDefined(); expect(capturedState).toBeDefined();
expect(capturedState!).toEqual("FetchingConfig"); expect(capturedState).toEqual({ state: "authenticating" });
deferred.reject(new FailToGetOpenIdToken(new Error("Failed to get token"))); deferred.reject(new FailToGetOpenIdToken(new Error("Failed to get token")));
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
capturedState = capturedStates.pop(); capturedState = capturedStates.pop();
if (capturedState instanceof Error) { if (capturedState?.state === "error") {
expect(capturedState.message).toEqual("Something went wrong"); expect(capturedState.error.message).toEqual("Something went wrong");
expect(connection.transport).toEqual(livekitFocus); expect(connection.transport).toEqual(transport);
} else { } else {
expect.fail("Expected FailedToStart state but got " + capturedState); expect.fail(
"Expected FailedToStart state but got " + JSON.stringify(capturedState),
);
} }
}); });
@@ -239,9 +244,12 @@ describe("Start connection states", () => {
vi.useFakeTimers(); vi.useFakeTimers();
const opts: ConnectionOpts = { const opts: ConnectionOpts = {
client: client, client,
roomId: ROOM_ID, roomId: ROOM_ID,
transport: livekitFocus, slotId: SLOT_ID,
role: "subscriber",
transport,
serverName: "m.localhost",
scope: testScope, scope: testScope,
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
livekitRoomFactory: () => fakeLivekitRoom, livekitRoomFactory: () => fakeLivekitRoom,
@@ -249,23 +257,22 @@ describe("Start connection states", () => {
const connection = new Connection(opts, logger); const connection = new Connection(opts, logger);
const capturedStates: (ConnectionState | Error)[] = []; const capturedStates: ConnectionState[] = [];
const s = connection.state$.subscribe((value) => { const s = connection.state$.subscribe((value) => {
capturedStates.push(value); capturedStates.push(value);
}); });
onTestFinished(() => s.unsubscribe()); onTestFinished(() => s.unsubscribe());
const deferredSFU = Promise.withResolvers<void>(); const deferredSFU = Promise.withResolvers<void>();
// mock the /sfu/get call client._unstable_getLivekitToken.mockImplementation(async () => {
fetchMock.post(`${livekitFocus.livekit_service_url}/sfu/get`, async () => {
await deferredSFU.promise; await deferredSFU.promise;
return { throw new MatrixError(
status: 500, {
body: {
errcode: "M_LOOKUP_FAILED", errcode: "M_LOOKUP_FAILED",
error: "Failed to look up user info from homeserver", error: "Failed to look up user info from homeserver",
}, },
}; 500,
);
}); });
connection.start().catch(() => { connection.start().catch(() => {
@@ -274,7 +281,7 @@ describe("Start connection states", () => {
let capturedState = capturedStates.pop(); let capturedState = capturedStates.pop();
expect(capturedState).toBeDefined(); expect(capturedState).toBeDefined();
expect(capturedState).toEqual(ConnectionState.FetchingConfig); expect(capturedState).toEqual({ state: "authenticating" });
deferredSFU.resolve(); deferredSFU.resolve();
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
@@ -282,15 +289,17 @@ describe("Start connection states", () => {
capturedState = capturedStates.pop(); capturedState = capturedStates.pop();
if ( if (
capturedState instanceof ElementCallError && capturedState?.state === "error" &&
capturedState.cause instanceof Error capturedState.error.cause instanceof Error
) { ) {
expect(capturedState.cause.message).toContain( expect(capturedState.error.cause.message).toContain(
"Failed to look up user info from homeserver", "Failed to look up user info from homeserver",
); );
expect(connection.transport).toEqual(livekitFocus); expect(connection.transport).toEqual(transport);
} else { } else {
expect.fail("Expected FailedToStart state but got " + capturedState); expect.fail(
"Expected FailedToStart state but got " + JSON.stringify(capturedState),
);
} }
}); });
@@ -299,9 +308,12 @@ describe("Start connection states", () => {
vi.useFakeTimers(); vi.useFakeTimers();
const opts: ConnectionOpts = { const opts: ConnectionOpts = {
client: client, client,
roomId: ROOM_ID, roomId: ROOM_ID,
transport: livekitFocus, slotId: SLOT_ID,
role: "subscriber",
transport,
serverName: "m.localhost",
scope: testScope, scope: testScope,
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
livekitRoomFactory: () => fakeLivekitRoom, livekitRoomFactory: () => fakeLivekitRoom,
@@ -309,24 +321,13 @@ describe("Start connection states", () => {
const connection = new Connection(opts, logger); const connection = new Connection(opts, logger);
const capturedStates: (ConnectionState | Error)[] = []; const capturedStates: ConnectionState[] = [];
const s = connection.state$.subscribe((value) => { const s = connection.state$.subscribe((value) => {
capturedStates.push(value); capturedStates.push(value);
}); });
onTestFinished(() => s.unsubscribe()); onTestFinished(() => s.unsubscribe());
const deferredSFU = Promise.withResolvers<void>(); const deferredSFU = Promise.withResolvers<void>();
// mock the /sfu/get call
fetchMock.post(`${livekitFocus.livekit_service_url}/sfu/get`, () => {
return {
status: 200,
body: {
url: "wss://matrix-rtc.m.localhost/livekit/sfu",
jwt: testJWTToken,
},
};
});
fakeLivekitRoom.connect.mockImplementation(async () => { fakeLivekitRoom.connect.mockImplementation(async () => {
await deferredSFU.promise; await deferredSFU.promise;
throw new Error("Failed to connect to livekit"); throw new Error("Failed to connect to livekit");
@@ -339,7 +340,7 @@ describe("Start connection states", () => {
let capturedState = capturedStates.pop(); let capturedState = capturedStates.pop();
expect(capturedState).toBeDefined(); expect(capturedState).toBeDefined();
expect(capturedState).toEqual(ConnectionState.FetchingConfig); expect(capturedState).toEqual({ state: "authenticating" });
deferredSFU.resolve(); deferredSFU.resolve();
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
@@ -347,13 +348,13 @@ describe("Start connection states", () => {
capturedState = capturedStates.pop(); capturedState = capturedStates.pop();
if ( if (
capturedState instanceof ElementCallError && capturedState?.state === "error" &&
capturedState.cause instanceof Error capturedState.error.cause instanceof Error
) { ) {
expect(capturedState.cause.message).toContain( expect(capturedState.error.cause.message).toContain(
"Failed to connect to livekit", "Failed to connect to livekit",
); );
expect(connection.transport).toEqual(livekitFocus); expect(connection.transport).toEqual(transport);
} else { } else {
expect.fail( expect.fail(
"Expected FailedToStart state but got " + JSON.stringify(capturedState), "Expected FailedToStart state but got " + JSON.stringify(capturedState),
@@ -367,7 +368,7 @@ describe("Start connection states", () => {
const connection = setupRemoteConnection(); const connection = setupRemoteConnection();
const capturedStates: (ConnectionState | Error)[] = []; const capturedStates: ConnectionState[] = [];
const s = connection.state$.subscribe((value) => { const s = connection.state$.subscribe((value) => {
capturedStates.push(value); capturedStates.push(value);
}); });
@@ -377,22 +378,31 @@ describe("Start connection states", () => {
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
const initialState = capturedStates.shift(); const initialState = capturedStates.shift();
expect(initialState).toEqual(ConnectionState.Initialized); expect(initialState).toEqual({ state: "initialized" });
const fetchingState = capturedStates.shift(); const fetchingState = capturedStates.shift();
expect(fetchingState).toEqual(ConnectionState.FetchingConfig); expect(fetchingState).toEqual({ state: "authenticating" });
const disconnectedState = capturedStates.shift(); const disconnectedState = capturedStates.shift();
expect(disconnectedState).toEqual(ConnectionState.LivekitDisconnected); expect(disconnectedState).toEqual({
state: "authenticated",
livekitState: LivekitConnectionState.Disconnected,
});
const connectingState = capturedStates.shift(); const connectingState = capturedStates.shift();
expect(connectingState).toEqual(ConnectionState.LivekitConnecting); expect(connectingState).toEqual({
state: "authenticated",
livekitState: LivekitConnectionState.Connecting,
});
const connectedState = capturedStates.shift(); const connectedState = capturedStates.shift();
expect(connectedState).toEqual(ConnectionState.LivekitConnected); expect(connectedState).toEqual({
state: "authenticated",
livekitState: LivekitConnectionState.Connected,
});
}); });
it("stopping while connecting does not report an error", async () => { it("stopping while connecting does not report an error", async () => {
setupTest(); setupTest();
const connection = setupRemoteConnection(); const connection = setupRemoteConnection();
const capturedStates: (ConnectionState | Error)[] = []; const capturedStates: ConnectionState[] = [];
const s = connection.state$.subscribe((value) => { const s = connection.state$.subscribe((value) => {
capturedStates.push(value); capturedStates.push(value);
}); });
@@ -409,10 +419,10 @@ describe("Start connection states", () => {
pendingConnect.reject(new Error("Client initiated disconnect")); pendingConnect.reject(new Error("Client initiated disconnect"));
await stopping; await stopping;
// start() resolves rather than rejecting (it is not awaited by the // start() resolves rather than rejecting (it is not awaited by the caller,
// ConnectionManager, so a rejection would be unhandled). // so a rejection would be unhandled).
await expect(started).resolves.toBeUndefined(); await expect(started).resolves.toBeUndefined();
expect(capturedStates.at(-1)).toEqual(ConnectionState.Stopped); expect(capturedStates.at(-1)).toEqual({ state: "stopped" });
expect(capturedStates.some((st) => st instanceof Error)).toBe(false); expect(capturedStates.some((st) => st instanceof Error)).toBe(false);
}); });
@@ -550,7 +560,10 @@ describe("remote track logging", () => {
{ {
client, client,
roomId: ROOM_ID, roomId: ROOM_ID,
transport: livekitFocus, slotId: SLOT_ID,
role: "subscriber",
transport,
serverName: "m.localhost",
scope: testScope, scope: testScope,
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
livekitRoomFactory: () => fakeLivekitRoom, livekitRoomFactory: () => fakeLivekitRoom,
@@ -13,6 +13,7 @@ import {
import { import {
ConnectionError, ConnectionError,
ConnectionErrorReason, ConnectionErrorReason,
type ConnectionState as LivekitConnectionState,
type Participant, type Participant,
type RemoteParticipant, type RemoteParticipant,
type RemoteTrackPublication, type RemoteTrackPublication,
@@ -22,16 +23,11 @@ import {
type Track, type Track,
type TrackPublication, type TrackPublication,
} from "livekit-client"; } from "livekit-client";
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, map } from "rxjs"; import { BehaviorSubject } 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 { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import {
getSFUConfigWithOpenID,
type OpenIDClientParts,
type SFUConfig,
} from "../../../livekit/openIDSFU.ts";
import { type Behavior } from "../../Behavior.ts"; import { type Behavior } from "../../Behavior.ts";
import { type ObservableScope } from "../../ObservableScope.ts"; import { type ObservableScope } from "../../ObservableScope.ts";
import { import {
@@ -42,51 +38,43 @@ import {
SFURoomCreationRestrictedError, SFURoomCreationRestrictedError,
UnknownCallError, UnknownCallError,
} from "../../../utils/errors.ts"; } from "../../../utils/errors.ts";
import {
type ClientGetTokenParts,
getSFUConfig,
type ClientOpenIDParts,
type SFUConfig,
} from "../../../livekit/auth";
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;
/** The identity parts to use on this connection */ /** The identity parts to use on this connection */
ownMembershipIdentity: CallMembershipIdentityParts; ownMembershipIdentity: CallMembershipIdentityParts;
/** Whether we want to publish or only subscribe on this connection. */
role: "publisher" | "subscriber";
/** The media transport to connect to. */ /** The media transport to connect to. */
transport: LivekitTransport; transport: UnstableLivekitTransport;
/** The name of the homeserver to which the {@link transport} belongs. */
serverName: string;
/** The Matrix client to use for OpenID and SFU config requests. */ /** The Matrix client to use for OpenID and SFU config requests. */
client: OpenIDClientParts; client: ClientGetTokenParts & ClientOpenIDParts;
/** The room ID this connection is associated with. */ /** The ID of the Matrix room in which the session takes place. */
roomId: string; roomId: string;
/** The ID of the MatrixRTC slot in which the session takes place. */
slotId: string;
/** The observable scope to use for this connection. */ /** The observable scope to use for this connection. */
scope: ObservableScope; scope: ObservableScope;
/** Optional factory to create the LiveKit room, mainly for testing purposes. */ /** Optional factory to create the LiveKit room, mainly for testing purposes. */
livekitRoomFactory: () => LivekitRoom; livekitRoomFactory: () => LivekitRoom;
} }
export class FailedToStartError extends Error {
public constructor(message: string) {
super(message);
this.name = "FailedToStartError";
}
}
export enum ConnectionState { export type ConnectionState =
/** The start state of a connection. It has been created but nothing has loaded yet. */ // The start state of a connection. It has been created but nothing has loaded yet.
Initialized = "Initialized", | { state: "initialized" }
/** `start` has been called on the connection. It aquires the jwt info to conenct to the LK Room */ // `start` has been called on the connection. It acquires the JWT token to connect to the LK room.
FetchingConfig = "FetchingConfig", | { state: "authenticating" }
Stopped = "Stopped", | { state: "authenticated"; livekitState: LivekitConnectionState }
/** The same as ConnectionState.Disconnected from `livekit-client` */ | { state: "error"; error: ElementCallError }
LivekitDisconnected = "disconnected", | { state: "stopped" };
/** The same as ConnectionState.Connecting from `livekit-client` */
LivekitConnecting = "connecting",
/** The same as ConnectionState.Connected from `livekit-client` */
LivekitConnected = "connected",
/** The same as ConnectionState.Reconnecting from `livekit-client` */
LivekitReconnecting = "reconnecting",
/** The same as ConnectionState.SignalReconnecting from `livekit-client` */
LivekitSignalReconnecting = "signalReconnecting",
}
/** /**
* A connection to a Matrix RTC LiveKit backend. * A connection to a Matrix RTC LiveKit backend.
@@ -95,19 +83,29 @@ export enum ConnectionState {
*/ */
export class Connection { export class Connection {
// Private Behavior // Private Behavior
private readonly _state$ = new BehaviorSubject< private readonly _state$ = new BehaviorSubject<ConnectionState>({
ConnectionState | ElementCallError state: "initialized",
>(ConnectionState.Initialized); });
/** /**
* The current state of the connection to the media transport. * The current state of the connection to the media transport.
*/ */
public readonly state$: Behavior<ConnectionState | Error> = this._state$; public readonly state$: Behavior<ConnectionState> = this._state$;
/**
* Whether we want to publish or only subscribe on this connection.
*/
public readonly role: "publisher" | "subscriber";
/** /**
* The media transport to connect to. * The media transport to connect to.
*/ */
public readonly transport: LivekitTransport; public readonly transport: UnstableLivekitTransport;
/**
* The name of the homeserver to which the {@link transport} belongs.
*/
public readonly serverName: string;
public readonly livekitRoom: LivekitRoom; public readonly livekitRoom: LivekitRoom;
@@ -137,11 +135,11 @@ export class Connection {
protected stopped = false; protected stopped = false;
// TODO: can we just keep the ConnectionOpts object instead of spreading? // TODO: can we just keep the ConnectionOpts object instead of spreading?
private readonly client: OpenIDClientParts; private readonly client: ClientGetTokenParts & ClientOpenIDParts;
private readonly roomId: string; private readonly roomId: string;
private readonly slotId: string;
private readonly logger: Logger; private readonly logger: Logger;
private readonly ownMembershipIdentity: CallMembershipIdentityParts; 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.
* *
@@ -151,19 +149,19 @@ export class Connection {
*/ */
public constructor(opts: ConnectionOpts, logger: Logger) { public constructor(opts: ConnectionOpts, logger: Logger) {
this.ownMembershipIdentity = opts.ownMembershipIdentity; this.ownMembershipIdentity = opts.ownMembershipIdentity;
this.existingSFUConfig = opts.existingSFUConfig;
this.roomId = opts.roomId; this.roomId = opts.roomId;
this.slotId = opts.slotId;
this.logger = logger.getChild( this.logger = logger.getChild(
"[Connection " + opts.transport.livekit_service_url + "]", `[Connection ${JSON.stringify(opts.transport)}]`,
); );
this.logger.info( this.logger.debug(`constructor called`);
`constructor: ${opts.transport.livekit_service_url} roomId: ${this.roomId} withSfuConfig?: ${opts.existingSFUConfig ? JSON.stringify(opts.existingSFUConfig) : "undefined"}`, const { role, transport, serverName, client, scope } = opts;
);
const { transport, client, scope } = opts;
this.scope = scope; this.scope = scope;
this.livekitRoom = opts.livekitRoomFactory(); this.livekitRoom = opts.livekitRoomFactory();
this.role = role;
this.transport = transport; this.transport = transport;
this.serverName = serverName;
this.client = client; this.client = client;
this.remoteParticipants$ = scope.behavior( this.remoteParticipants$ = scope.behavior(
@@ -304,16 +302,10 @@ export class Connection {
this.logger.debug("Starting Connection"); this.logger.debug("Starting Connection");
this.stopped = false; this.stopped = false;
try { try {
this._state$.next(ConnectionState.FetchingConfig); this._state$.next({ state: "authenticating" });
// We should already have this information after creating the localTransport. const { url, jwt, livekitAlias } = await this.getSFUConfig();
// only call getSFUConfigWithOpenID for connections where we do not have a token yet. (existingJwtTokenData === undefined)
const { url, jwt, livekitAlias } =
this.existingSFUConfig ??
(await this.getSFUConfigForRemoteConnection());
this.logger.debug( this.logger.debug(
"Starting Connection to: ", "Starting Connection - jwt: ",
this.transport.livekit_service_url,
"jwt: ",
jwt, jwt,
"wss: ", "wss: ",
url, url,
@@ -324,16 +316,12 @@ export class Connection {
// 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;
// Setup observer once we are done with getSFUConfigWithOpenID // Setup observer once we are done with getSFUConfig
connectionStateObserver(this.livekitRoom) connectionStateObserver(this.livekitRoom)
.pipe( .pipe(this.scope.bind())
this.scope.bind(), .subscribe((livekitState) =>
map((s) => s as unknown as ConnectionState), this._state$.next({ state: "authenticated", livekitState }),
) );
.subscribe((lkState) => {
// It is save to cast lkState to ConnectionState as they are fully overlapping.
this._state$.next(lkState);
});
try { try {
this.logger.info(`livekitRoom.connect ${url}`); this.logger.info(`livekitRoom.connect ${url}`);
@@ -377,36 +365,37 @@ export class Connection {
// stop() was called while we were connecting, which makes the pending // stop() was called while we were connecting, which makes the pending
// connect reject. That is the abort we asked for, not a failure, so // connect reject. That is the abort we asked for, not a failure, so
// don't record an error state on a stopped connection or rethrow it // don't record an error state on a stopped connection or rethrow it
// (start() is not awaited by the ConnectionManager, so a throw here // (start() is not necessarily awaited by the caller, so a throw here
// becomes an unhandled promise rejection). // becomes an unhandled promise rejection).
this.logger.debug(`Connect aborted because the connection was stopped`); this.logger.debug(`Connect aborted because the connection was stopped`);
return; return;
} }
this.logger.debug(`Failed to connect to LiveKit room: ${error}`); this.logger.debug(`Failed to connect to LiveKit room: ${error}`);
this._state$.next( this._state$.next({
error instanceof ElementCallError state: "error",
? error error:
: error instanceof Error error instanceof ElementCallError
? new UnknownCallError(error) ? error
: new UnknownCallError(new Error(`${error}`)), : error instanceof Error
); ? new UnknownCallError(error)
: new UnknownCallError(new Error(`${error}`)),
});
// Its okay to ignore the throw. The error is part of the state. // Its okay to ignore the throw. The error is part of the state.
throw error; throw error;
} }
} }
protected async getSFUConfigForRemoteConnection(): Promise<SFUConfig> { protected async getSFUConfig(): Promise<SFUConfig> {
// This will only be called for sfu's where we do not publish ourselves. return await getSFUConfig({
// For the local connection we will use the existingJwtTokenData client: this.client,
return await getSFUConfigWithOpenID( membership: this.ownMembershipIdentity,
this.client, role: this.role,
this.ownMembershipIdentity, transport: this.transport,
this.transport.livekit_service_url, serverName: this.serverName,
this.roomId, roomId: this.roomId,
// dont pass any custom opts for the subscribe only connections slotId: this.slotId,
{}, logger: this.logger,
this.logger, });
);
} }
/** /**
@@ -416,17 +405,13 @@ export class Connection {
* If the connection is already stopped, this is a no-op. * If the connection is already stopped, this is a no-op.
*/ */
public async stop(): Promise<void> { public async stop(): Promise<void> {
this.logger.debug( this.logger.debug("stop: disconnecing from lk room");
`stop: disconnecing from lk room ${this.transport.livekit_service_url}`,
);
if (this.stopped) return; if (this.stopped) return;
// Mark as stopped before disconnecting so that a connect() aborted by the // Mark as stopped before disconnecting so that a connect() aborted by the
// disconnect sees the flag and does not report the abort as an error. // disconnect sees the flag and does not report the abort as an error.
this.stopped = true; this.stopped = true;
await this.livekitRoom.disconnect(); await this.livekitRoom.disconnect();
this._state$.next(ConnectionState.Stopped); this._state$.next({ state: "stopped" });
this.logger.debug( this.logger.debug("stop: DONE disconnecing from lk room");
`stop: DONE disconnecing from lk room ${this.transport.livekit_service_url}`,
);
} }
} }
@@ -16,14 +16,10 @@ import { logger, 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 CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
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,
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";
@@ -39,15 +35,20 @@ import {
noiseSuppressionSetting, noiseSuppressionSetting,
autoGainControlSetting, autoGainControlSetting,
} from "../../../settings/settings.ts"; } from "../../../settings/settings.ts";
import {
type ClientGetTokenParts,
type ClientOpenIDParts,
} from "../../../livekit/auth";
// 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(
scope: ObservableScope, scope: ObservableScope,
transport: LivekitTransport, role: "publisher" | "subscriber",
transport: UnstableLivekitTransport,
serverName: string,
ownMembershipIdentity: CallMembershipIdentityParts, ownMembershipIdentity: CallMembershipIdentityParts,
logger: Logger, logger: Logger,
sfuConfig?: SFUConfig,
): Connection; ): Connection;
} }
@@ -58,7 +59,8 @@ export class ECConnectionFactory implements ConnectionFactory {
* Creates a ConnectionFactory for LiveKit connections. * Creates a ConnectionFactory for LiveKit connections.
* *
* @param client - The OpenID client parts for authentication, needed to get openID and JWT tokens. * @param client - The OpenID client parts for authentication, needed to get openID and JWT tokens.
* @param roomId - The current room ID. * @param roomId - The ID of the Matrix room in which the session takes place.
* @param slotId - The ID of the MatrixRTC slot in which the session takes place.
* @param devices - Used for video/audio out/in capture options. * @param devices - Used for video/audio out/in capture options.
* @param processorState$ - Effects like background blur (only for publishing connection?) * @param processorState$ - Effects like background blur (only for publishing connection?)
* @param livekitKeyProvider - Optional key provider for end-to-end encryption. * @param livekitKeyProvider - Optional key provider for end-to-end encryption.
@@ -66,8 +68,9 @@ export class ECConnectionFactory implements ConnectionFactory {
* @param livekitRoomFactory - Optional factory function (for testing) to create LivekitRoom instances. If not provided, a default factory is used. * @param livekitRoomFactory - Optional factory function (for testing) to create LivekitRoom instances. If not provided, a default factory is used.
*/ */
public constructor( public constructor(
private client: OpenIDClientParts, private client: ClientGetTokenParts & ClientOpenIDParts,
private readonly roomId: string, private readonly roomId: string,
private readonly slotId: string,
private devices: MediaDevices, private devices: MediaDevices,
private processorState$: Behavior<ProcessorState>, private processorState$: Behavior<ProcessorState>,
livekitKeyProvider: BaseKeyProvider | undefined, livekitKeyProvider: BaseKeyProvider | undefined,
@@ -96,6 +99,7 @@ export class ECConnectionFactory implements ConnectionFactory {
* *
* @param scope The observable scope (used for clean-up) * @param scope The observable scope (used for clean-up)
* @param transport The transport to use for this connection. * @param transport The transport to use for this connection.
* @param serverName The name of the homeserver to which the {@link transport} belongs.
* @param ownMembershipIdentity required to connect (using the jwt service) with the SFU. * @param ownMembershipIdentity required to connect (using the jwt service) with the SFU.
* @param logger The logger instance to use for this connection. * @param logger The logger instance to use for this connection.
* @param sfuConfig optional config in case we already have a token for this connection. * @param sfuConfig optional config in case we already have a token for this connection.
@@ -103,16 +107,19 @@ export class ECConnectionFactory implements ConnectionFactory {
*/ */
public createConnection( public createConnection(
scope: ObservableScope, scope: ObservableScope,
transport: LivekitTransport, role: "publisher" | "subscriber",
transport: UnstableLivekitTransport,
serverName: string,
ownMembershipIdentity: CallMembershipIdentityParts, ownMembershipIdentity: CallMembershipIdentityParts,
logger: Logger, logger: Logger,
sfuConfig?: SFUConfig,
): Connection { ): Connection {
return new Connection( return new Connection(
{ {
existingSFUConfig: sfuConfig,
roomId: this.roomId, roomId: this.roomId,
slotId: this.slotId,
role,
transport, transport,
serverName,
client: this.client, client: this.client,
scope: scope, scope: scope,
livekitRoomFactory: this.livekitRoomFactory, livekitRoomFactory: this.livekitRoomFactory,
@@ -1,282 +0,0 @@
/*
Copyright 2025 Element Creations Ltd.
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import {
combineLatest,
map,
type Observable,
of,
switchMap,
startWith,
catchError,
NEVER,
} from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type RemoteParticipant } from "livekit-client";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Behavior } from "../../Behavior.ts";
import { type Connection } from "./Connection.ts";
import { Epoch, type ObservableScope } from "../../ObservableScope.ts";
import { generateItemsWithEpoch } from "../../../utils/observable.ts";
import { areLivekitTransportsEqual } from "./MatrixLivekitMembers.ts";
import { type ConnectionFactory } from "./ConnectionFactory.ts";
import {
isLocalTransport,
type LocalTransport,
} from "../localMember/LocalTransport.ts";
import { type SFUConfig } from "../../../livekit/openIDSFU.ts";
export class ConnectionManagerData {
private readonly store: Map<
string,
{ connection: Connection; participants: RemoteParticipant[] }
> = new Map();
public constructor(private readonly logger?: Logger) {}
public add(connection: Connection, participants: RemoteParticipant[]): void {
const key = this.getKey(connection.transport);
const existing = this.store.get(key);
if (!existing) {
this.store.set(key, { connection, participants });
} else {
// Transports are deduplicated by URL upstream, so this should never
// happen; if it does, members may be matched against the wrong room.
this.logger?.warn(
`Merging participants from a second connection to ${key}: existing [${existing.participants.map((p) => p.identity).join(", ")}], adding [${participants.map((p) => p.identity).join(", ")}]`,
);
existing.participants.push(...participants);
}
}
private getKey(transport: LivekitTransport): string {
// This is enough as a key because the ConnectionManager is already scoped by room.
// We also do not need to consider the slotId at this point since each `MatrixRTCSession` is already scoped by `slotDescription: {id, application}`.
return transport.livekit_service_url;
}
public getConnections(): Connection[] {
return Array.from(this.store.values()).map(({ connection }) => connection);
}
public getConnectionForTransport(
transport: LivekitTransport,
): Connection | null {
return this.store.get(this.getKey(transport))?.connection ?? null;
}
public getParticipantsForTransport(
transport: LivekitTransport,
): RemoteParticipant[] {
const key = this.getKey(transport);
const existing = this.store.get(key);
if (existing) {
return existing.participants;
}
return [];
}
}
interface Props {
scope: ObservableScope;
connectionFactory: ConnectionFactory;
localTransport$: Observable<LocalTransport>;
remoteTransports$: Behavior<Epoch<LivekitTransport[]>>;
logger: Logger;
ownMembershipIdentity: CallMembershipIdentityParts;
}
// TODO - write test for scopes (do we really need to bind scope)
export interface IConnectionManager {
connectionManagerData$: Behavior<Epoch<ConnectionManagerData>>;
}
/**
* Crete a `ConnectionManager`
* @param props - Configuration object
* @param props.scope - The observable scope used by this object
* @param props.connectionFactory - Used to create new connections
* @param props.localTransport$ - The transport to publish local media on. (deduplicated with remoteTransports$)
* @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.
*
* Using `registerTransports` independent external modules can control what connections
* are created by the ConnectionManager.
*
* The connection manager will remove all duplicate transports in each subscibed list.
*
* See `unregisterAllTransports` and `unregisterTransport` for details on how to unsubscribe.
*/
export function createConnectionManager$({
scope,
connectionFactory,
localTransport$,
remoteTransports$,
logger: parentLogger,
ownMembershipIdentity,
}: Props): IConnectionManager {
const logger = parentLogger.getChild("[ConnectionManager]");
// TODO logger: only construct one logger from the client and make it compatible via a EC specific sing
const localTransportAsArray$ = localTransport$.pipe(
// LocalMember already surfaces local transport errors properly in the UI,
// here we can just swallow them
catchError(() => NEVER),
map((transport) => [transport]),
startWith([]),
);
/**
* All transports currently managed by the ConnectionManager.
*
* This list does not include duplicate transports.
*
* It is build based on the list of subscribed transports (`transportsSubscriptions$`).
* externally this is modified via `registerTransports()`.
*/
const localAndRemoteTransports$: Behavior<
Epoch<(LivekitTransport | LocalTransport)[]>
> = scope.behavior(
combineLatest([localTransportAsArray$, remoteTransports$]).pipe(
// Combine local and remote transports into one transport array
// and set the forceOldJwtEndpoint property on the local transport
map(([localTransportAsArray, remoteTransports]) => {
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,
);
}),
),
);
/**
* Connections for each transport in use by one or more session members.
*/
const connections$ = scope.behavior(
localAndRemoteTransports$.pipe(
generateItemsWithEpoch(
"ConnectionManager connections$",
function* (transports) {
for (const transport of transports) {
if (isLocalTransport(transport)) {
// This is the local transport; only the `LocalTransport`
// interface has a `sfuConfig` field.
yield {
keys: [
transport.transport.livekit_service_url,
transport.sfuConfig,
],
data: undefined,
};
} else {
yield {
keys: [
transport.livekit_service_url,
undefined as SFUConfig | undefined,
],
data: undefined,
};
}
}
},
(scope, _data$, serviceUrl, sfuConfig) => {
const connection = connectionFactory.createConnection(
scope,
{
type: "livekit",
livekit_service_url: serviceUrl,
},
ownMembershipIdentity,
logger,
// TODO: This whole optional SFUConfig parameter is not particularly elegant.
// I would like it if connections always fetched the SFUConfig by themselves.
sfuConfig,
);
// Start the connection immediately
// Use connection state to track connection progress
void connection.start();
// TODO subscribe to connection state to retry or log issues?
return connection;
},
),
),
);
const connectionManagerData$ = scope.behavior(
connections$.pipe(
switchMap((connections) => {
const epoch = connections.epoch;
// Map the connections to list of {connection, participants}[]
const listOfConnectionsWithRemoteParticipants = connections.value.map(
(connection) => {
return connection.remoteParticipants$.pipe(
map((participants) => ({
connection,
participants,
})),
);
},
);
// probably not required
if (listOfConnectionsWithRemoteParticipants.length === 0) {
return of(new Epoch(new ConnectionManagerData(), epoch));
}
// combineLatest the several streams into a single stream with the ConnectionManagerData
return combineLatest(listOfConnectionsWithRemoteParticipants).pipe(
map(
(lists) =>
new Epoch(
lists.reduce((data, { connection, participants }) => {
data.add(connection, participants);
return data;
}, new ConnectionManagerData(logger)),
epoch,
),
),
);
}),
),
new Epoch(new ConnectionManagerData(), -1),
);
return { connectionManagerData$ };
}
function removeDuplicateTransports<T extends LivekitTransport>(
transports: T[],
): T[] {
return transports.reduce((acc, transport) => {
if (!acc.some((t) => areLivekitTransportsEqual(t, transport)))
acc.push(transport);
return acc;
}, [] as T[]);
}
@@ -6,34 +6,49 @@ Please see LICENSE in the repository root for full details.
*/ */
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { BehaviorSubject, NEVER } from "rxjs"; import { BehaviorSubject } from "rxjs";
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { type RemoteParticipant } from "livekit-client"; import { type RemoteParticipant } from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { Epoch, mapEpoch, ObservableScope } from "../../ObservableScope.ts"; import { Epoch, mapEpoch, ObservableScope } from "../../ObservableScope.ts";
import { import {
createConnectionManager$, createConnectionMap$,
ConnectionManagerData, ConnectionMap,
} from "./ConnectionManager.ts"; keyFor,
} from "./ConnectionMap.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 { ownMemberMock, withTestScheduler } from "../../../utils/test.ts"; import { ownMemberMock, withTestScheduler } from "../../../utils/test.ts";
import { areLivekitTransportsEqual } from "./MatrixLivekitMembers.ts";
import { type Behavior } from "../../Behavior.ts"; import { type Behavior } from "../../Behavior.ts";
import { type TransportLocator } from "../../../livekit/auth";
// Some test constants // Some test constants
const TRANSPORT_1: LivekitTransport = { const TRANSPORT_1: TransportLocator = {
type: "livekit", transport: {
livekit_service_url: "https://lk.example.org", type: "livekit",
livekit_service_url: "https://lk.example.org",
},
serverName: "example.org",
}; };
const TRANSPORT_2: LivekitTransport = { const TRANSPORT_2: TransportLocator = {
type: "livekit", transport: {
livekit_service_url: "https://lk.sample.com", type: "livekit",
livekit_service_url: "https://lk.sample.com",
},
serverName: "sample.com",
}; };
function findConnection(
transport: TransportLocator,
connections: Connection[],
): Connection | undefined {
const key = keyFor(transport);
return connections.find((c) => keyFor(c) === key);
}
let fakeConnectionFactory: ConnectionFactory; let fakeConnectionFactory: ConnectionFactory;
let testScope: ObservableScope; let testScope: ObservableScope;
@@ -47,9 +62,15 @@ beforeEach(() => {
vi.mocked(fakeConnectionFactory).createConnection = vi vi.mocked(fakeConnectionFactory).createConnection = vi
.fn() .fn()
.mockImplementation( .mockImplementation(
(scope: ObservableScope, transport: LivekitTransport) => { (
scope: ObservableScope,
_role: unknown,
transport: UnstableLivekitTransport,
serverName: string,
) => {
const mockConnection = { const mockConnection = {
transport, transport,
serverName,
remoteParticipants$: new BehaviorSubject([]), remoteParticipants$: new BehaviorSubject([]),
} as unknown as Connection; } as unknown as Connection;
vi.mocked(mockConnection).start = vi.fn(); vi.mocked(mockConnection).start = vi.fn();
@@ -71,10 +92,10 @@ afterEach(() => {
describe("connections$ stream", () => { describe("connections$ stream", () => {
test("Should create and start new connections for each transports", () => { test("Should create and start new connections for each transports", () => {
withTestScheduler(({ behavior, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const { connectionManagerData$ } = createConnectionManager$({ const connectionMap$ = createConnectionMap$({
scope: testScope, scope: testScope,
connectionFactory: fakeConnectionFactory, connectionFactory: fakeConnectionFactory,
localTransport$: NEVER, localTransport: TRANSPORT_1,
remoteTransports$: behavior("a", { remoteTransports$: behavior("a", {
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0), a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
}), }),
@@ -83,7 +104,7 @@ describe("connections$ stream", () => {
}); });
expectObservable( expectObservable(
connectionManagerData$.pipe(mapEpoch((d) => d.getConnections())), connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
).toBe("a", { ).toBe("a", {
a: expect.toSatisfy((e: Epoch<Connection[]>) => { a: expect.toSatisfy((e: Epoch<Connection[]>) => {
const connections = e.value; const connections = e.value;
@@ -93,15 +114,11 @@ describe("connections$ stream", () => {
vi.mocked(fakeConnectionFactory).createConnection, vi.mocked(fakeConnectionFactory).createConnection,
).toHaveBeenCalledTimes(2); ).toHaveBeenCalledTimes(2);
const conn1 = connections.find((c) => const conn1 = findConnection(TRANSPORT_1, connections);
areLivekitTransportsEqual(c.transport, TRANSPORT_1),
);
expect(conn1).toBeDefined(); expect(conn1).toBeDefined();
expect(conn1!.start).toHaveBeenCalled(); expect(conn1!.start).toHaveBeenCalled();
const conn2 = connections.find((c) => const conn2 = findConnection(TRANSPORT_2, connections);
areLivekitTransportsEqual(c.transport, TRANSPORT_2),
);
expect(conn2).toBeDefined(); expect(conn2).toBeDefined();
expect(conn2!.start).toHaveBeenCalled(); expect(conn2!.start).toHaveBeenCalled();
return true; return true;
@@ -110,12 +127,88 @@ describe("connections$ stream", () => {
}); });
}); });
test("Should start connection only once", () => { test("Shares one connection between homeservers advertising the same JWT service", () => {
withTestScheduler(({ behavior, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const { connectionManagerData$ } = createConnectionManager$({ const sameServiceElsewhere: TransportLocator = {
transport: TRANSPORT_1.transport,
serverName: "elsewhere.org",
};
const connectionMap$ = createConnectionMap$({
scope: testScope, scope: testScope,
connectionFactory: fakeConnectionFactory, connectionFactory: fakeConnectionFactory,
localTransport$: NEVER, localTransport: TRANSPORT_1,
remoteTransports$: behavior("a", {
a: new Epoch([sameServiceElsewhere], 0),
}),
logger: logger,
ownMembershipIdentity: ownMemberMock,
});
expectObservable(
connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
).toBe("a", {
a: expect.toSatisfy((e: Epoch<Connection[]>) => {
expect(e.value.length).toBe(1);
// The JWT service is reached directly, so the server name plays no
// part and the local locator, which came first, is the one used
expect(
vi.mocked(fakeConnectionFactory).createConnection,
).toHaveBeenCalledExactlyOnceWith(
expect.anything(),
"publisher",
TRANSPORT_1.transport,
"example.org",
expect.anything(),
expect.anything(),
);
return true;
}),
});
});
});
test("Keeps one connection per homeserver for transports with a url", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const urlTransport: TransportLocator = {
transport: { type: "livekit", url: "wss://sfu.example.org" },
serverName: "example.org",
};
const sameUrlElsewhere: TransportLocator = {
transport: urlTransport.transport,
serverName: "elsewhere.org",
};
const connectionMap$ = createConnectionMap$({
scope: testScope,
connectionFactory: fakeConnectionFactory,
localTransport: urlTransport,
remoteTransports$: behavior("a", {
a: new Epoch([sameUrlElsewhere], 0),
}),
logger: logger,
ownMembershipIdentity: ownMemberMock,
});
expectObservable(
connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
).toBe("a", {
a: expect.toSatisfy((e: Epoch<Connection[]>) => {
// Our homeserver asks the named server for the token, so each
// server name needs its own connection
expect(e.value.length).toBe(2);
expect(findConnection(urlTransport, e.value)).toBeDefined();
expect(findConnection(sameUrlElsewhere, e.value)).toBeDefined();
return true;
}),
});
});
});
test("Should start connection only once", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const connectionMap$ = createConnectionMap$({
scope: testScope,
connectionFactory: fakeConnectionFactory,
localTransport: TRANSPORT_1,
remoteTransports$: behavior("abcdef", { 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),
@@ -129,7 +222,7 @@ describe("connections$ stream", () => {
}); });
expectObservable( expectObservable(
connectionManagerData$.pipe(mapEpoch((d) => d.getConnections())), connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
).toBe("xxxxxa", { ).toBe("xxxxxa", {
x: expect.anything(), x: expect.anything(),
a: expect.toSatisfy((e: Epoch<Connection[]>) => { a: expect.toSatisfy((e: Epoch<Connection[]>) => {
@@ -140,14 +233,10 @@ describe("connections$ stream", () => {
vi.mocked(fakeConnectionFactory).createConnection, vi.mocked(fakeConnectionFactory).createConnection,
).toHaveBeenCalledTimes(2); ).toHaveBeenCalledTimes(2);
const conn2 = connections.find((c) => const conn2 = findConnection(TRANSPORT_2, connections);
areLivekitTransportsEqual(c.transport, TRANSPORT_2),
);
expect(conn2).toBeDefined(); expect(conn2).toBeDefined();
const conn1 = connections.find((c) => const conn1 = findConnection(TRANSPORT_1, connections);
areLivekitTransportsEqual(c.transport, TRANSPORT_1),
);
expect(conn1).toBeDefined(); expect(conn1).toBeDefined();
expect(conn1!.start).toHaveBeenCalledOnce(); expect(conn1!.start).toHaveBeenCalledOnce();
@@ -159,10 +248,10 @@ describe("connections$ stream", () => {
test("Should cleanup connections when not needed anymore", () => { test("Should cleanup connections when not needed anymore", () => {
withTestScheduler(({ behavior, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const { connectionManagerData$ } = createConnectionManager$({ const connectionMap$ = createConnectionMap$({
scope: testScope, scope: testScope,
connectionFactory: fakeConnectionFactory, connectionFactory: fakeConnectionFactory,
localTransport$: NEVER, localTransport: TRANSPORT_1,
remoteTransports$: behavior("abc", { 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),
@@ -173,7 +262,7 @@ describe("connections$ stream", () => {
}); });
expectObservable( expectObservable(
connectionManagerData$.pipe(mapEpoch((d) => d.getConnections())), connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
).toBe("xab", { ).toBe("xab", {
x: expect.anything(), x: expect.anything(),
a: expect.toSatisfy((e: Epoch<Connection[]>) => { a: expect.toSatisfy((e: Epoch<Connection[]>) => {
@@ -186,8 +275,9 @@ describe("connections$ stream", () => {
expect(connections.length).toBe(1); expect(connections.length).toBe(1);
// The second connection should have been stopped has it is no longer needed. // The second connection should have been stopped has it is no longer needed.
const connection2 = allCreatedConnections.find((c) => const connection2 = findConnection(
areLivekitTransportsEqual(c.transport, TRANSPORT_2), TRANSPORT_2,
allCreatedConnections,
); );
expect(connection2).toBeDefined(); expect(connection2).toBeDefined();
expect(connection2!.stop).toHaveBeenCalled(); expect(connection2!.stop).toHaveBeenCalled();
@@ -203,40 +293,36 @@ describe("connections$ stream", () => {
}); });
}); });
describe("ConnectionManagerData", () => { describe("ConnectionMap", () => {
test("warns when a second connection to the same URL is merged", () => { test("warns when a second connection to the same URL is merged", () => {
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {}); const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
const data = new ConnectionManagerData(logger); const data = new ConnectionMap(logger);
const connection = { transport: TRANSPORT_1 } as unknown as Connection; const connection = TRANSPORT_1 as unknown as Connection;
const p = (identity: string): RemoteParticipant => const p = (identity: string): RemoteParticipant =>
({ identity }) as unknown as RemoteParticipant; ({ identity }) as unknown as RemoteParticipant;
data.add(connection, [p("a")]); data.add(connection, [p("a")]);
data.add({ ...connection } as Connection, [p("b")]); data.add({ ...connection } as Connection, [p("b")]);
expect(warn).toHaveBeenCalledWith( expect(warn).toHaveBeenCalledWith(
expect.stringContaining( expect.stringContaining(
"second connection to https://lk.example.org: existing [a], adding [b]", 'second connection to [null,"https://lk.example.org",null]: existing [a], adding [b]',
), ),
); );
expect(data.getParticipantsForTransport(TRANSPORT_1)).toHaveLength(2); expect(data.getParticipantsForTransport(TRANSPORT_1)).toHaveLength(2);
}); });
}); });
describe("connectionManagerData$ stream", () => { describe("connectionMap$", () => {
// Used in test to control fake connections' remoteParticipants$ streams // Used in test to control fake connections' remoteParticipants$ streams
let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>; let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>;
function keyForTransport(transport: LivekitTransport): string {
return `${transport.livekit_service_url}`;
}
beforeEach(() => { beforeEach(() => {
fakeRemoteParticipantsStreams = new Map(); fakeRemoteParticipantsStreams = new Map();
function getRemoteParticipantsFor( function getRemoteParticipantsFor(
transport: LivekitTransport, transport: TransportLocator,
): Behavior<RemoteParticipant[]> { ): Behavior<RemoteParticipant[]> {
return ( return (
fakeRemoteParticipantsStreams.get(keyForTransport(transport)) ?? fakeRemoteParticipantsStreams.get(keyFor(transport)) ??
new BehaviorSubject([]) new BehaviorSubject([])
); );
} }
@@ -245,13 +331,22 @@ describe("connectionManagerData$ stream", () => {
vi.mocked(fakeConnectionFactory).createConnection = vi vi.mocked(fakeConnectionFactory).createConnection = vi
.fn() .fn()
.mockImplementation( .mockImplementation(
(scope: ObservableScope, transport: LivekitTransport) => { (
scope: ObservableScope,
_role: unknown,
transport: UnstableLivekitTransport,
serverName: string,
) => {
const fakeRemoteParticipants$ = new BehaviorSubject< const fakeRemoteParticipants$ = new BehaviorSubject<
RemoteParticipant[] RemoteParticipant[]
>([]); >([]);
const mockConnection = { const mockConnection = {
transport, transport,
remoteParticipants$: getRemoteParticipantsFor(transport), serverName,
remoteParticipants$: getRemoteParticipantsFor({
transport,
serverName,
}),
} as unknown as Connection; } as unknown as Connection;
vi.mocked(mockConnection).start = vi.fn(); vi.mocked(mockConnection).start = vi.fn();
vi.mocked(mockConnection).stop = vi.fn(); vi.mocked(mockConnection).stop = vi.fn();
@@ -261,7 +356,7 @@ describe("connectionManagerData$ stream", () => {
}); });
fakeRemoteParticipantsStreams.set( fakeRemoteParticipantsStreams.set(
keyForTransport(transport), keyFor({ transport, serverName }),
fakeRemoteParticipants$, fakeRemoteParticipants$,
); );
return mockConnection; return mockConnection;
@@ -274,7 +369,7 @@ describe("connectionManagerData$ stream", () => {
// Setup the fake participants streams behavior // Setup the fake participants streams behavior
// ============================== // ==============================
fakeRemoteParticipantsStreams.set( fakeRemoteParticipantsStreams.set(
keyForTransport(TRANSPORT_1), keyFor(TRANSPORT_1),
behavior("oa-b", { behavior("oa-b", {
o: [], o: [],
a: [{ identity: "user1A" } as RemoteParticipant], a: [{ identity: "user1A" } as RemoteParticipant],
@@ -286,7 +381,7 @@ describe("connectionManagerData$ stream", () => {
); );
fakeRemoteParticipantsStreams.set( fakeRemoteParticipantsStreams.set(
keyForTransport(TRANSPORT_2), keyFor(TRANSPORT_2),
behavior("o-a", { behavior("o-a", {
o: [], o: [],
a: [{ identity: "user2A" } as RemoteParticipant], a: [{ identity: "user2A" } as RemoteParticipant],
@@ -294,10 +389,10 @@ describe("connectionManagerData$ stream", () => {
); );
// ============================== // ==============================
const { connectionManagerData$ } = createConnectionManager$({ const connectionMap$ = createConnectionMap$({
scope: testScope, scope: testScope,
connectionFactory: fakeConnectionFactory, connectionFactory: fakeConnectionFactory,
localTransport$: NEVER, localTransport: TRANSPORT_1,
remoteTransports$: behavior("a", { remoteTransports$: behavior("a", {
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0), a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
}), }),
@@ -305,16 +400,16 @@ describe("connectionManagerData$ stream", () => {
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
}); });
expectObservable(connectionManagerData$).toBe("abcd", { expectObservable(connectionMap$).toBe("abcd", {
a: expect.toSatisfy((e) => { a: expect.toSatisfy((e) => {
const data: ConnectionManagerData = e.value; const data: ConnectionMap = e.value;
expect(data.getConnections().length).toBe(2); expect(data.getConnections().length).toBe(2);
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(0); expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(0);
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(0); expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(0);
return true; return true;
}), }),
b: expect.toSatisfy((e) => { b: expect.toSatisfy((e) => {
const data: ConnectionManagerData = e.value; const data: ConnectionMap = e.value;
expect(data.getConnections().length).toBe(2); expect(data.getConnections().length).toBe(2);
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(1); expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(1);
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(0); expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(0);
@@ -324,7 +419,7 @@ describe("connectionManagerData$ stream", () => {
return true; return true;
}), }),
c: expect.toSatisfy((e) => { c: expect.toSatisfy((e) => {
const data: ConnectionManagerData = e.value; const data: ConnectionMap = e.value;
expect(data.getConnections().length).toBe(2); expect(data.getConnections().length).toBe(2);
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(1); expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(1);
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(1); expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(1);
@@ -337,7 +432,7 @@ describe("connectionManagerData$ stream", () => {
return true; return true;
}), }),
d: expect.toSatisfy((e) => { d: expect.toSatisfy((e) => {
const data: ConnectionManagerData = e.value; const data: ConnectionMap = e.value;
expect(data.getConnections().length).toBe(2); expect(data.getConnections().length).toBe(2);
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(2); expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(2);
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(1); expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(1);
@@ -0,0 +1,219 @@
/*
Copyright 2025-2026 Element Creations Ltd.
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { combineLatest, map, of, switchMap, tap } from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type RemoteParticipant } from "livekit-client";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Behavior } from "../../Behavior.ts";
import { type Connection } from "./Connection.ts";
import {
Epoch,
mapEpoch,
type ObservableScope,
} from "../../ObservableScope.ts";
import { generateItemsWithEpoch } from "../../../utils/observable.ts";
import { type ConnectionFactory } from "./ConnectionFactory.ts";
import { type TransportLocator } from "../../../livekit/auth/types.ts";
// Branded string type, so that only values returned by `keyFor` can be used
// as keys
export type TransportKey = string & { __transportKey: true };
/**
* Converts a {@link TransportLocator} to a key suitable for identifying it in a
* {@link Map} or {@link Set}. Locators with the same key share a connection.
*/
export function keyFor({
transport,
serverName,
}: TransportLocator): TransportKey {
// The server name only takes part in authentication for a transport with a
// `url`, where our homeserver asks that server for the token. A JWT service
// is reached directly, so members of several homeservers advertising the
// same one must share a connection rather than each get their own.
const hasUrl = "url" in transport;
// Serialize the fields in a consistent order
return JSON.stringify([
hasUrl ? transport.url : null,
"livekit_service_url" in transport ? transport.livekit_service_url : null,
hasUrl ? serverName : null,
]) as TransportKey;
}
function removeDuplicateTransports(
transports: TransportLocator[],
): TransportLocator[] {
// The first locator wins, so the local one stays first in the list
const map = new Map<TransportKey, TransportLocator>();
for (const transport of transports) {
const key = keyFor(transport);
if (!map.has(key)) map.set(key, transport);
}
return [...map.values()];
}
export class ConnectionMap {
private readonly map = new Map<
TransportKey,
{ connection: Connection; participants: RemoteParticipant[] }
>();
public constructor(private readonly logger?: Logger) {}
public add(connection: Connection, participants: RemoteParticipant[]): void {
const key = keyFor(connection);
const existing = this.map.get(key);
if (!existing) {
this.map.set(key, { connection, participants });
} else {
// Transports are deduplicated by key upstream, so this should never
// happen; if it does, members may be matched against the wrong room.
this.logger?.warn(
`Merging participants from a second connection to ${key}: existing [${existing.participants.map((p) => p.identity).join(", ")}], adding [${participants.map((p) => p.identity).join(", ")}]`,
);
existing.participants.push(...participants);
}
}
public getConnections(): Connection[] {
return Array.from(this.map.values()).map(({ connection }) => connection);
}
public getConnectionForTransport(
transport: TransportLocator,
): Connection | null {
return this.map.get(keyFor(transport))?.connection ?? null;
}
public getParticipantsForTransport(
transport: TransportLocator,
): RemoteParticipant[] {
const existing = this.map.get(keyFor(transport));
if (existing) {
return existing.participants;
}
return [];
}
}
interface Props {
scope: ObservableScope;
connectionFactory: ConnectionFactory;
localTransport: TransportLocator;
remoteTransports$: Behavior<Epoch<TransportLocator[]>>;
logger: Logger;
ownMembershipIdentity: CallMembershipIdentityParts;
}
/**
* Create a dynamic map of connections and their associated backend participants.
* @param props - Configuration object
* @param props.scope - The observable scope used by this object
* @param props.connectionFactory - Used to create new connections
* @param props.localTransport - The transport to publish local media on. (deduplicated with remoteTransports$)
* @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.
*/
export function createConnectionMap$({
scope,
connectionFactory,
localTransport,
remoteTransports$,
logger: parentLogger,
ownMembershipIdentity,
}: Props): Behavior<Epoch<ConnectionMap>> {
const logger = parentLogger.getChild("[ConnectionMap]");
// TODO logger: only construct one logger from the client and make it compatible via a EC specific sing
/**
* All transports currently represented in the connection map. This list does
* not include duplicate transports.
*/
const localAndRemoteTransports$: Behavior<Epoch<TransportLocator[]>> =
scope.behavior(
remoteTransports$.pipe(
mapEpoch((remoteTransports) =>
removeDuplicateTransports([localTransport, ...remoteTransports]),
),
tap((transports) =>
logger.debug("localAndRemoteTransports$ = ", transports),
),
),
);
const localTransportKey = keyFor(localTransport);
/**
* Connections for each transport in use by one or more session members.
*/
const connections$ = scope.behavior(
localAndRemoteTransports$.pipe(
generateItemsWithEpoch(
"ConnectionMap connections$",
function* (transports) {
for (const transport of transports) {
const role =
keyFor(transport) === localTransportKey
? ("publisher" as const)
: ("subscriber" as const);
yield {
// The locator is an object, so a stable key stands in for it
keys: [role, keyFor(transport)],
data: transport,
};
}
},
(scope, locator$, role, _key) => {
const { transport, serverName } = locator$.value;
const connection = connectionFactory.createConnection(
scope,
role,
transport,
serverName,
ownMembershipIdentity,
logger,
);
// Start the connection immediately
// Use connection state to track connection progress
void connection.start();
// TODO subscribe to connection state to retry or log issues?
return connection;
},
),
),
);
return scope.behavior(
connections$.pipe(
switchMap(({ value: connections, epoch }) => {
if (connections.length === 0)
return of(new Epoch(new ConnectionMap(), epoch));
return combineLatest(
// Map the connections to list of Observable<{connection, participants}>
connections.map((connection) =>
connection.remoteParticipants$.pipe(
map((participants) => ({ connection, participants })),
),
),
// Collect into a single ConnectionMap
(...list) => {
const map = new ConnectionMap(logger);
for (const item of list)
map.add(item.connection, item.participants);
return new Epoch(map, epoch);
},
);
}),
),
);
}
@@ -14,7 +14,10 @@ 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 ClientGetTokenParts,
type ClientOpenIDParts,
} from "../../../livekit/auth";
import { import {
exampleTransport, exampleTransport,
mockMediaDevices, mockMediaDevices,
@@ -32,6 +35,7 @@ import {
cameraBitrate, cameraBitrate,
cameraCodec, cameraCodec,
} from "../../../settings/settings.ts"; } from "../../../settings/settings.ts";
import { testJWTToken } from "../../../utils/test-fixtures.ts";
// At the top of your test file, after imports // At the top of your test file, after imports
vi.mock("livekit-client", async (importOriginal) => { vi.mock("livekit-client", async (importOriginal) => {
@@ -51,13 +55,13 @@ vi.mock("livekit-client", async (importOriginal) => {
}); });
let testScope: ObservableScope; let testScope: ObservableScope;
let mockClient: OpenIDClientParts; let mockClient: ClientGetTokenParts & ClientOpenIDParts;
beforeEach(() => { beforeEach(() => {
testScope = new ObservableScope(); testScope = new ObservableScope();
mockClient = { mockClient = {
getOpenIdToken: vi.fn().mockReturnValue(""), getOpenIdToken: vi.fn().mockReturnValue(""),
getDeviceId: vi.fn().mockReturnValue("DEV000"), _unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: testJWTToken }),
}; };
}); });
@@ -79,6 +83,7 @@ describe("ECConnectionFactory - Audio inputs options", () => {
const ecConnectionFactory = new ECConnectionFactory( const ecConnectionFactory = new ECConnectionFactory(
mockClient, mockClient,
"!roomid:example.org", "!roomid:example.org",
"m.call#ROOM",
mockMediaDevices({}), mockMediaDevices({}),
new BehaviorSubject<ProcessorState>({ new BehaviorSubject<ProcessorState>({
supported: true, supported: true,
@@ -89,7 +94,9 @@ describe("ECConnectionFactory - Audio inputs options", () => {
); );
ecConnectionFactory.createConnection( ecConnectionFactory.createConnection(
testScope, testScope,
"subscriber",
exampleTransport, exampleTransport,
"example.org",
ownMemberMock, ownMemberMock,
logger, logger,
); );
@@ -121,6 +128,7 @@ describe("ECConnectionFactory - ControlledAudioDevice", () => {
const ecConnectionFactory = new ECConnectionFactory( const ecConnectionFactory = new ECConnectionFactory(
mockClient, mockClient,
"!roomid:example.org", "!roomid:example.org",
"m.call#ROOM",
mockMediaDevices({ mockMediaDevices({
audioOutput: { audioOutput: {
available$: constant(new Map<never, never>()), available$: constant(new Map<never, never>()),
@@ -137,7 +145,9 @@ describe("ECConnectionFactory - ControlledAudioDevice", () => {
); );
ecConnectionFactory.createConnection( ecConnectionFactory.createConnection(
testScope, testScope,
"subscriber",
exampleTransport, exampleTransport,
"example.org",
ownMemberMock, ownMemberMock,
logger, logger,
); );
@@ -162,6 +172,7 @@ describe("ECConnectionFactory - Camera quality settings", () => {
const ecConnectionFactory = new ECConnectionFactory( const ecConnectionFactory = new ECConnectionFactory(
mockClient, mockClient,
"!roomid:example.org", "!roomid:example.org",
"m.call#ROOM",
mockMediaDevices({}), mockMediaDevices({}),
new BehaviorSubject<ProcessorState>({ new BehaviorSubject<ProcessorState>({
supported: true, supported: true,
@@ -172,7 +183,9 @@ describe("ECConnectionFactory - Camera quality settings", () => {
); );
ecConnectionFactory.createConnection( ecConnectionFactory.createConnection(
testScope, testScope,
"subscriber",
exampleTransport, exampleTransport,
"example.org",
ownMemberMock, ownMemberMock,
logger, logger,
); );
@@ -199,6 +212,7 @@ describe("ECConnectionFactory - Camera quality settings", () => {
const ecConnectionFactory = new ECConnectionFactory( const ecConnectionFactory = new ECConnectionFactory(
mockClient, mockClient,
"!roomid:example.org", "!roomid:example.org",
"m.call#ROOM",
mockMediaDevices({}), mockMediaDevices({}),
new BehaviorSubject<ProcessorState>({ new BehaviorSubject<ProcessorState>({
supported: true, supported: true,
@@ -209,7 +223,9 @@ describe("ECConnectionFactory - Camera quality settings", () => {
); );
ecConnectionFactory.createConnection( ecConnectionFactory.createConnection(
testScope, testScope,
"subscriber",
exampleTransport, exampleTransport,
"example.org",
ownMemberMock, ownMemberMock,
logger, logger,
); );
@@ -237,6 +253,7 @@ describe("ECConnectionFactory - Camera quality settings", () => {
const ecConnectionFactory = new ECConnectionFactory( const ecConnectionFactory = new ECConnectionFactory(
mockClient, mockClient,
"!roomid:example.org", "!roomid:example.org",
"m.call#ROOM",
mockMediaDevices({}), mockMediaDevices({}),
new BehaviorSubject<ProcessorState>({ new BehaviorSubject<ProcessorState>({
supported: true, supported: true,
@@ -247,7 +264,9 @@ describe("ECConnectionFactory - Camera quality settings", () => {
); );
ecConnectionFactory.createConnection( ecConnectionFactory.createConnection(
testScope, testScope,
"subscriber",
exampleTransport, exampleTransport,
"example.org",
ownMemberMock, ownMemberMock,
logger, logger,
); );
@@ -9,22 +9,16 @@ import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { import {
type CallMembership, type CallMembership,
type LivekitTransport, type UnstableLivekitTransport,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, combineLatest, map, type Observable } from "rxjs"; import { BehaviorSubject, combineLatest, map, type Observable } from "rxjs";
import { type IConnectionManager } from "./ConnectionManager.ts";
import { import {
type RemoteMatrixLivekitMember, type RemoteMatrixLivekitMember,
createRemoteMatrixLivekitMembers$, createRemoteMatrixLivekitMembers$,
} from "./MatrixLivekitMembers.ts"; } from "./MatrixLivekitMembers.ts";
import { import { Epoch, ObservableScope, trackEpoch } from "../../ObservableScope.ts";
Epoch, import { ConnectionMap } from "./ConnectionMap.ts";
mapEpoch,
ObservableScope,
trackEpoch,
} from "../../ObservableScope.ts";
import { ConnectionManagerData } from "./ConnectionManager.ts";
import { import {
flushPromises, flushPromises,
mockRtcMembership, mockRtcMembership,
@@ -33,18 +27,20 @@ import {
import { type Connection } from "./Connection.ts"; import { type Connection } from "./Connection.ts";
import { constant } from "../../Behavior.ts"; import { constant } from "../../Behavior.ts";
import { localRtcMember } from "../../../utils/test-fixtures.ts"; import { localRtcMember } from "../../../utils/test-fixtures.ts";
import { type TransportLocator } from "../../../livekit/auth";
import { membershipsAndTransports$ } from "../../SessionBehaviors.ts";
let testScope: ObservableScope; let testScope: ObservableScope;
const fallbackMemberId = (userId: string, deviceId: string): string => const fallbackMemberId = (userId: string, deviceId: string): string =>
`${userId}:${deviceId}`; `${userId}:${deviceId}`;
const transportA: LivekitTransport = { const transportA: UnstableLivekitTransport = {
type: "livekit", type: "livekit",
livekit_service_url: "https://lk.example.org", livekit_service_url: "https://lk.example.org",
}; };
const transportB: LivekitTransport = { const transportB: UnstableLivekitTransport = {
type: "livekit", type: "livekit",
livekit_service_url: "https://lk.sample.com", livekit_service_url: "https://lk.sample.com",
}; };
@@ -78,23 +74,18 @@ function epochMeWith$<T, U>(
test("should signal participant not yet connected to livekit", async () => { test("should signal participant not yet connected to livekit", async () => {
const info = vi.spyOn(logger, "info"); const info = vi.spyOn(logger, "info");
const mockedMemberships$ = new BehaviorSubject([bobMembership]); const mockedMemberships$ = new BehaviorSubject([bobMembership]);
const mockConnectionManagerData$ = new BehaviorSubject( const mockConnectionMap$ = new BehaviorSubject(new ConnectionMap());
new ConnectionManagerData(),
);
const { memberships$, membershipsWithTransport$ } = const { memberships$, membershipsWithTransport$ } =
createEpochedMemberships$(mockedMemberships$); createEpochedMemberships$(mockedMemberships$);
const connectionManagerData$ = epochMeWith$( const connectionMap$ = testScope.behavior(
memberships$, epochMeWith$(memberships$, mockConnectionMap$),
mockConnectionManagerData$,
); );
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({ const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
scope: testScope, scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: { connectionMap$,
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
localUser: localRtcMember, localUser: localRtcMember,
}); });
@@ -117,21 +108,15 @@ test("should signal participant not yet connected to livekit", async () => {
function createEpochedMemberships$(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: TransportLocator | undefined }[]
>
>; >;
} { } {
const memberships$ = m$.pipe(trackEpoch()); const memberships$ = testScope.behavior(m$.pipe(trackEpoch()));
const membershipsWithTransport$ = memberships$.pipe( const { membershipsWithTransport$ } = membershipsAndTransports$(
mapEpoch((members) => { testScope,
return members.map((m) => { memberships$,
const tr = m.getTransport();
return {
membership: m,
transport:
tr?.type === "livekit" ? (tr as LivekitTransport) : undefined,
};
});
}),
); );
return { return {
memberships$, memberships$,
@@ -151,23 +136,21 @@ test("should signal participant on a connection that is publishing", async () =>
const connection = { const connection = {
transport: bobMembership.getTransport(), transport: bobMembership.getTransport(),
serverName: "example.org",
} as unknown as Connection; } as unknown as Connection;
const dataWithPublisher = new ConnectionManagerData(); const dataWithPublisher = new ConnectionMap();
dataWithPublisher.add(connection, [ dataWithPublisher.add(connection, [
mockRemoteParticipant({ identity: bobParticipantId }), mockRemoteParticipant({ identity: bobParticipantId }),
]); ]);
const connectionManagerData$ = epochMeWith$( const connectionMap$ = testScope.behavior(
memberships$, epochMeWith$(memberships$, constant(dataWithPublisher)),
constant(dataWithPublisher),
); );
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({ const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
scope: testScope, scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: { connectionMap$,
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
localUser: localRtcMember, localUser: localRtcMember,
}); });
@@ -187,6 +170,90 @@ test("should signal participant on a connection that is publishing", async () =>
); );
}); });
// A member advertising both a `url` and a `livekit_service_url` may have
// authenticated through either flow, so it has two candidate identities.
const bothTransport: UnstableLivekitTransport = {
type: "livekit",
url: "wss://sfu.example.org",
livekit_service_url: "https://lk.example.org",
};
const bobLegacyId = fallbackMemberId("@bob:example.org", "DEV000");
const bobHashedId = "2qkXfWz1S7jQmU0bJ6yN8aVtR3pLcDgHe9oZxKwMiA4";
const bobWithBothIdentities = mockRtcMembership("@bob:example.org", "DEV000", {
fociPreferred: [bothTransport],
backendIdentities: [bobHashedId, bobLegacyId],
});
test.each([
["legacy", bobLegacyId],
["hashed", bobHashedId],
])(
"matches a member with two candidate identities publishing under the %s one",
async (_name, identity) => {
const { memberships$, membershipsWithTransport$ } =
createEpochedMemberships$(constant([bobWithBothIdentities]));
const connection = {
transport: bothTransport,
serverName: "example.org",
} as unknown as Connection;
const dataWithPublisher = new ConnectionMap();
dataWithPublisher.add(connection, [mockRemoteParticipant({ identity })]);
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionMap$: testScope.behavior(
epochMeWith$(memberships$, constant(dataWithPublisher)),
),
localUser: localRtcMember,
});
await flushPromises();
expect(remoteMatrixLivekitMembers$.value.value).toSatisfy(
(data: RemoteMatrixLivekitMember[]) => {
expect(data.length).toEqual(1);
expect(data[0].participant.value$.value?.identity).toEqual(identity);
expect(data[0].connection$.value).toBe(connection);
return true;
},
);
},
);
test("does not match a member with two candidate identities to anyone else", async () => {
const { memberships$, membershipsWithTransport$ } = createEpochedMemberships$(
constant([bobWithBothIdentities]),
);
const connection = {
transport: bothTransport,
serverName: "example.org",
} as unknown as Connection;
const dataWithStranger = new ConnectionMap();
dataWithStranger.add(connection, [
mockRemoteParticipant({ identity: "@stranger:example.org:DEV999" }),
]);
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionMap$: testScope.behavior(
epochMeWith$(memberships$, constant(dataWithStranger)),
),
localUser: localRtcMember,
});
await flushPromises();
expect(remoteMatrixLivekitMembers$.value.value).toSatisfy(
(data: RemoteMatrixLivekitMember[]) => {
expect(data.length).toEqual(1);
expect(data[0].participant.value$.value).toBe(null);
return true;
},
);
});
test("should signal participant on a connection that is not publishing", async () => { test("should signal participant on a connection that is not publishing", async () => {
const { memberships$, membershipsWithTransport$ } = createEpochedMemberships$( const { memberships$, membershipsWithTransport$ } = createEpochedMemberships$(
constant([bobMembership]), constant([bobMembership]),
@@ -194,21 +261,19 @@ test("should signal participant on a connection that is not publishing", async (
const connection = { const connection = {
transport: bobMembership.getTransport(), transport: bobMembership.getTransport(),
serverName: "example.org",
} as unknown as Connection; } as unknown as Connection;
const dataWithPublisher = new ConnectionManagerData(); const dataWithPublisher = new ConnectionMap();
dataWithPublisher.add(connection, []); dataWithPublisher.add(connection, []);
const connectionManagerData$ = epochMeWith$( const connectionMap$ = testScope.behavior(
memberships$, epochMeWith$(memberships$, constant(dataWithPublisher)),
constant(dataWithPublisher),
); );
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({ const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
scope: testScope, scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: { connectionMap$,
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
localUser: localRtcMember, localUser: localRtcMember,
}); });
await flushPromises(); await flushPromises();
@@ -228,16 +293,18 @@ describe("Publication edge case", () => {
const { memberships$, membershipsWithTransport$ } = const { memberships$, membershipsWithTransport$ } =
createEpochedMemberships$(constant([bobMembership, carlMembership])); createEpochedMemberships$(constant([bobMembership, carlMembership]));
const connectionWithPublisher = new ConnectionManagerData(); const connectionWithPublisher = new ConnectionMap();
const bobParticipantId = fallbackMemberId( const bobParticipantId = fallbackMemberId(
bobMembership.userId, bobMembership.userId,
bobMembership.deviceId, bobMembership.deviceId,
); );
const connectionA = { const connectionA = {
transport: transportA, transport: transportA,
serverName: "example.org",
} as unknown as Connection; } as unknown as Connection;
const connectionB = { const connectionB = {
transport: transportB, transport: transportB,
serverName: "sample.com",
} as unknown as Connection; } as unknown as Connection;
connectionWithPublisher.add(connectionA, [ connectionWithPublisher.add(connectionA, [
@@ -247,17 +314,14 @@ describe("Publication edge case", () => {
mockRemoteParticipant({ identity: bobParticipantId }), mockRemoteParticipant({ identity: bobParticipantId }),
]); ]);
const connectionManagerData$ = epochMeWith$( const connectionMap$ = testScope.behavior(
memberships$, epochMeWith$(memberships$, constant(connectionWithPublisher)),
constant(connectionWithPublisher),
); );
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({ const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
scope: testScope, scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: { connectionMap$,
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
localUser: localRtcMember, localUser: localRtcMember,
}); });
await flushPromises(); await flushPromises();
@@ -287,14 +351,20 @@ test("bob is publishing in the wrong connection", async () => {
const { memberships$, membershipsWithTransport$ } = const { memberships$, membershipsWithTransport$ } =
createEpochedMemberships$(mockedMemberships$); createEpochedMemberships$(mockedMemberships$);
const connectionWithPublisher = new ConnectionManagerData(); const connectionWithPublisher = new ConnectionMap();
const bobParticipantId = fallbackMemberId( const bobParticipantId = fallbackMemberId(
bobMembership.userId, bobMembership.userId,
bobMembership.deviceId, bobMembership.deviceId,
); );
const connectionA = { transport: transportA } as unknown as Connection; const connectionA = {
const connectionB = { transport: transportB } as unknown as Connection; transport: transportA,
serverName: "example.org",
} as unknown as Connection;
const connectionB = {
transport: transportB,
serverName: "sample.com",
} as unknown as Connection;
// Bob is not publishing on A // Bob is not publishing on A
connectionWithPublisher.add(connectionA, []); connectionWithPublisher.add(connectionA, []);
@@ -306,17 +376,14 @@ test("bob is publishing in the wrong connection", async () => {
const connectionsWithPublisher$ = new BehaviorSubject( const connectionsWithPublisher$ = new BehaviorSubject(
connectionWithPublisher, connectionWithPublisher,
); );
const connectionManagerData$ = epochMeWith$( const connectionMap$ = testScope.behavior(
memberships$, epochMeWith$(memberships$, connectionsWithPublisher$),
connectionsWithPublisher$,
); );
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({ const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
scope: testScope, scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: { connectionMap$,
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
localUser: localRtcMember, localUser: localRtcMember,
}); });
@@ -6,18 +6,16 @@ Please see LICENSE in the repository root for full details.
*/ */
import { type LocalParticipant, type RemoteParticipant } from "livekit-client"; import { type LocalParticipant, type RemoteParticipant } from "livekit-client";
import { import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
type CallMembership,
type LivekitTransport,
} from "matrix-js-sdk/lib/matrixrtc";
import { combineLatest, filter, map } from "rxjs"; import { combineLatest, filter, map } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../../Behavior"; import { type Behavior } from "../../Behavior";
import { type IConnectionManager } from "./ConnectionManager"; import { type ConnectionMap } from "./ConnectionMap";
import { Epoch, type ObservableScope } from "../../ObservableScope"; import { Epoch, type ObservableScope } from "../../ObservableScope";
import { type Connection } from "./Connection"; import { type Connection } from "./Connection";
import { generateItemsWithEpoch } from "../../../utils/observable"; import { generateItemsWithEpoch } from "../../../utils/observable";
import { type TransportLocator } from "../../../livekit/auth";
interface LocalTaggedParticipant { interface LocalTaggedParticipant {
type: "local"; type: "local";
@@ -60,9 +58,11 @@ export interface RemoteMatrixLivekitMember extends MatrixLivekitMember {
interface Props { interface Props {
scope: ObservableScope; scope: ObservableScope;
membershipsWithTransport$: Behavior< membershipsWithTransport$: Behavior<
Epoch<{ membership: CallMembership; transport?: LivekitTransport }[]> Epoch<
{ membership: CallMembership; transport: TransportLocator | undefined }[]
>
>; >;
connectionManager: IConnectionManager; connectionMap$: Behavior<Epoch<ConnectionMap>>;
localUser: { deviceId: string; userId: string }; localUser: { deviceId: string; userId: string };
} }
@@ -72,24 +72,21 @@ interface Props {
* It has a small public interface: * It has a small public interface:
* - in (via constructor): * - in (via constructor):
* - an observable of CallMembership[] to track the call members (The matrix side) * - an observable of CallMembership[] to track the call members (The matrix side)
* - a `ConnectionManager` for the lk rooms (The livekit side) * - a `ConnectionMap` for the lk rooms (The livekit side)
* - out (via public Observable): * - out (via public Observable):
* - `remoteMatrixLivekitMember` an observable of MatrixLivekitMember[] to track the remote members and associated livekit data. * - `remoteMatrixLivekitMember` an observable of MatrixLivekitMember[] to track the remote members and associated livekit data.
*/ */
export function createRemoteMatrixLivekitMembers$({ export function createRemoteMatrixLivekitMembers$({
scope, scope,
membershipsWithTransport$, membershipsWithTransport$,
connectionManager, connectionMap$,
localUser, localUser,
}: Props): Behavior<Epoch<RemoteMatrixLivekitMember[]>> { }: Props): Behavior<Epoch<RemoteMatrixLivekitMember[]>> {
/** /**
* Behavior of all the remote call members and their associated livekit data (if available). * Behavior of all the remote call members and their associated livekit data (if available).
*/ */
return scope.behavior( return scope.behavior(
combineLatest([ combineLatest([membershipsWithTransport$, connectionMap$]).pipe(
membershipsWithTransport$,
connectionManager.connectionManagerData$,
]).pipe(
filter((values) => filter((values) =>
values.every((value) => value.epoch === values[0].epoch), values.every((value) => value.epoch === values[0].epoch),
), ),
@@ -111,8 +108,8 @@ export function createRemoteMatrixLivekitMembers$({
const participants = transport const participants = transport
? managerData.getParticipantsForTransport(transport) ? managerData.getParticipantsForTransport(transport)
: []; : [];
const matches = participants.filter( const matches = participants.filter((p) =>
(p) => p.identity == membership.rtcBackendIdentity, membership.backendIdentities.includes(p.identity),
); );
const participant = matches[0] ?? null; const participant = matches[0] ?? null;
const connection = transport const connection = transport
@@ -143,9 +140,9 @@ export function createRemoteMatrixLivekitMembers$({
// Log whether the member could be matched to a LiveKit participant, // Log whether the member could be matched to a LiveKit participant,
// since a tile shows "waiting for media" for as long as it cannot. // since a tile shows "waiting for media" for as long as it cannot.
participant$.pipe(scope.bind()).subscribe((p) => { participant$.pipe(scope.bind()).subscribe((p) => {
const url = data$.value.connection?.transport.livekit_service_url; const transport = data$.value.connection?.transport;
logger.info( logger.info(
`[RemoteMatrixLivekitMembers] ${rtcBackendIdentity}: LiveKit participant ${p ? `matched (${p.sid})` : "missing"} on ${url ?? "no connection"}`, `[RemoteMatrixLivekitMembers] ${rtcBackendIdentity}: LiveKit participant ${p ? `matched (${p.sid})` : "missing"} on ${transport ? JSON.stringify(transport) : "no connection"}`,
); );
}); });
// will only get called once per backend identity. // will only get called once per backend identity.
@@ -163,14 +160,3 @@ export function createRemoteMatrixLivekitMembers$({
} }
// 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
export function areLivekitTransportsEqual<T extends LivekitTransport>(
t1: T | null,
t2: T | null,
): boolean {
if (t1 && t2) {
return t1.livekit_service_url === t2.livekit_service_url;
}
return !t1 && !t2;
}
@@ -6,11 +6,10 @@ Please see LICENSE in the repository root for full details.
*/ */
import { test, vi, expect, beforeEach, afterEach } from "vitest"; import { test, vi, expect, beforeEach, afterEach } from "vitest";
import { BehaviorSubject, NEVER } from "rxjs"; import { BehaviorSubject } from "rxjs";
import { type Room as LivekitRoom } from "livekit-client"; import { type Room as LivekitRoom } from "livekit-client";
import EventEmitter from "events"; import EventEmitter from "events";
import fetchMock from "fetch-mock"; import fetchMock from "fetch-mock";
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { import {
@@ -19,8 +18,12 @@ import {
trackEpoch, trackEpoch,
} from "../../ObservableScope.ts"; } from "../../ObservableScope.ts";
import { ECConnectionFactory } from "./ConnectionFactory.ts"; import { ECConnectionFactory } from "./ConnectionFactory.ts";
import { type OpenIDClientParts } from "../../../livekit/openIDSFU.ts";
import { import {
type ClientGetTokenParts,
type ClientOpenIDParts,
} from "../../../livekit/auth";
import {
exampleTransportLocator,
mockMediaDevices, mockMediaDevices,
mockRtcMembership, mockRtcMembership,
ownMemberMock, ownMemberMock,
@@ -28,19 +31,19 @@ import {
} from "../../../utils/test.ts"; } from "../../../utils/test.ts";
import { type ProcessorState } from "../../../livekit/TrackProcessorContext.tsx"; import { type ProcessorState } from "../../../livekit/TrackProcessorContext.tsx";
import { import {
areLivekitTransportsEqual,
createRemoteMatrixLivekitMembers$, createRemoteMatrixLivekitMembers$,
type RemoteMatrixLivekitMember, type RemoteMatrixLivekitMember,
} from "./MatrixLivekitMembers.ts"; } from "./MatrixLivekitMembers.ts";
import { createConnectionManager$ } from "./ConnectionManager.ts"; import { createConnectionMap$ } from "./ConnectionMap.ts";
import { membershipsAndTransports$ } from "../../SessionBehaviors.ts"; import { membershipsAndTransports$ } from "../../SessionBehaviors.ts";
import { localRtcMember, testJWTToken } from "../../../utils/test-fixtures.ts"; import { localRtcMember, testJWTToken } from "../../../utils/test-fixtures.ts";
import { deepCompare } from "matrix-js-sdk/lib/utils";
// Test the integration of ConnectionManager and MatrixLivekitMerger // Test the integration of ConnectionMap and MatrixLivekitMembers
let testScope: ObservableScope; let testScope: ObservableScope;
let ecConnectionFactory: ECConnectionFactory; let ecConnectionFactory: ECConnectionFactory;
let mockClient: OpenIDClientParts; let mockClient: ClientGetTokenParts & ClientOpenIDParts;
let lkRoomFactory: () => LivekitRoom; let lkRoomFactory: () => LivekitRoom;
const createdMockLivekitRooms: Map<string, LivekitRoom> = new Map(); const createdMockLivekitRooms: Map<string, LivekitRoom> = new Map();
@@ -49,7 +52,7 @@ beforeEach(() => {
testScope = new ObservableScope(); testScope = new ObservableScope();
mockClient = { mockClient = {
getOpenIdToken: vi.fn().mockReturnValue(""), getOpenIdToken: vi.fn().mockReturnValue(""),
getDeviceId: vi.fn().mockReturnValue("DEV000"), _unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: testJWTToken }),
}; };
lkRoomFactory = vi.fn().mockImplementation(() => { lkRoomFactory = vi.fn().mockImplementation(() => {
@@ -71,6 +74,7 @@ beforeEach(() => {
ecConnectionFactory = new ECConnectionFactory( ecConnectionFactory = new ECConnectionFactory(
mockClient, mockClient,
"!roomid:example.org", "!roomid:example.org",
"m.call#ROOM",
mockMediaDevices({}), mockMediaDevices({}),
new BehaviorSubject<ProcessorState>({ new BehaviorSubject<ProcessorState>({
supported: true, supported: true,
@@ -120,10 +124,10 @@ test("bob, carl, then bob joining no tracks yet", () => {
memberships$, memberships$,
); );
const connectionManager = createConnectionManager$({ const connectionMap$ = createConnectionMap$({
scope: testScope, scope: testScope,
connectionFactory: ecConnectionFactory, connectionFactory: ecConnectionFactory,
localTransport$: NEVER, localTransport: exampleTransportLocator,
remoteTransports$: membershipsAndTransports.transports$, remoteTransports$: membershipsAndTransports.transports$,
logger: logger, logger: logger,
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
@@ -133,7 +137,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
scope: testScope, scope: testScope,
membershipsWithTransport$: membershipsWithTransport$:
membershipsAndTransports.membershipsWithTransport$, membershipsAndTransports.membershipsWithTransport$,
connectionManager, connectionMap$,
localUser: localRtcMember, localUser: localRtcMember,
}); });
@@ -147,10 +151,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
}); });
expectObservable(item.connection$).toBe("a", { expectObservable(item.connection$).toBe("a", {
a: expect.toSatisfy((co) => a: expect.toSatisfy((co) =>
areLivekitTransportsEqual( deepCompare(co.transport, bobMembership.transports[0]),
co.transport,
bobMembership.transports[0]! as LivekitTransport,
),
), ),
}); });
expectObservable(item.participant.value$).toBe("a", { expectObservable(item.participant.value$).toBe("a", {
@@ -182,15 +183,9 @@ test("bob, carl, then bob joining no tracks yet", () => {
a: null, a: null,
}); });
expectObservable(item.connection$).toBe("a", { expectObservable(item.connection$).toBe("a", {
a: expect.toSatisfy((connection) => { a: expect.toSatisfy((connection) =>
expect( deepCompare(connection.transport, carlMembership.transports[0]),
areLivekitTransportsEqual( ),
connection.transport,
carlMembership.transports[0]! as LivekitTransport,
),
).toBe(true);
return true;
}),
}); });
} }
return true; return true;
@@ -212,15 +207,9 @@ test("bob, carl, then bob joining no tracks yet", () => {
a: daveMembership, a: daveMembership,
}); });
expectObservable(item.connection$).toBe("a", { expectObservable(item.connection$).toBe("a", {
a: expect.toSatisfy((connection) => { a: expect.toSatisfy((connection) =>
expect( deepCompare(connection.transport, daveMembership.transports[0]),
areLivekitTransportsEqual( ),
connection.transport,
daveMembership.transports[0]! as LivekitTransport,
),
).toBe(true);
return true;
}),
}); });
expectObservable(item.participant.value$).toBe("a", { expectObservable(item.participant.value$).toBe("a", {
a: null, a: null,
+63 -4
View File
@@ -1,16 +1,25 @@
/* /*
Copyright 2025 Element Creations Ltd. Copyright 2025, 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial 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 { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { MatrixRTCSessionEvent } from "matrix-js-sdk/lib/matrixrtc"; import {
MatrixRTCSessionEvent,
type Transport,
type UnstableLivekitTransport,
} from "matrix-js-sdk/lib/matrixrtc";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { createKeyRotationSuppressed$ } from "./SessionBehaviors"; import {
import { testScope } from "../utils/test"; createKeyRotationSuppressed$,
membershipsAndTransports$,
} from "./SessionBehaviors";
import { Epoch } from "./ObservableScope";
import { constant } from "./Behavior";
import { mockRtcMembership, testScope } from "../utils/test";
describe("SessionBehaviors", () => { describe("SessionBehaviors", () => {
describe("createKeyRotationSuppressed$", () => { describe("createKeyRotationSuppressed$", () => {
@@ -38,4 +47,54 @@ describe("SessionBehaviors", () => {
expect(keyRotationSuppressed$.value).toBe(false); expect(keyRotationSuppressed$.value).toBe(false);
}); });
}); });
describe("membershipsAndTransports$", () => {
const serviceTransport: UnstableLivekitTransport = {
type: "livekit",
livekit_service_url: "https://lk.example.org",
};
const urlTransport: UnstableLivekitTransport = {
type: "livekit",
url: "wss://sfu.sample.com",
};
it("locates each member's transport on the member's homeserver", () => {
const bob = mockRtcMembership("@bob:example.org", "DEV000", {
fociPreferred: [serviceTransport],
});
const carl = mockRtcMembership("@carl:sample.com", "DEV111", {
fociPreferred: [urlTransport],
});
const { membershipsWithTransport$, transports$ } =
membershipsAndTransports$(
testScope(),
constant(new Epoch([bob, carl])),
);
expect(transports$.value.value).toEqual([
{ transport: serviceTransport, serverName: "example.org" },
{ transport: urlTransport, serverName: "sample.com" },
]);
expect(
membershipsWithTransport$.value.value.map(
(m) => m.transport?.serverName,
),
).toEqual(["example.org", "sample.com"]);
});
it("leaves out members without a LiveKit transport", () => {
const dave = mockRtcMembership("@dave:example.org", "DEV222", {
fociPreferred: [{ type: "something-else" } as Transport],
});
const { membershipsWithTransport$, transports$ } =
membershipsAndTransports$(testScope(), constant(new Epoch([dave])));
expect(transports$.value.value).toEqual([]);
expect(membershipsWithTransport$.value.value).toEqual([
{ membership: dave, transport: undefined },
]);
});
});
}); });
+14 -10
View File
@@ -7,10 +7,9 @@ Please see LICENSE in the repository root for full details.
import { import {
type CallMembership, type CallMembership,
type LivekitTransport,
type MatrixRTCSession, type MatrixRTCSession,
MatrixRTCSessionEvent, MatrixRTCSessionEvent,
isLivekitTransport, isUnstableLivekitTransport,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { fromEvent } from "rxjs"; import { fromEvent } from "rxjs";
@@ -21,6 +20,7 @@ import {
type ObservableScope, type ObservableScope,
} from "./ObservableScope"; } from "./ObservableScope";
import { type Behavior } from "./Behavior"; import { type Behavior } from "./Behavior";
import { type TransportLocator } from "../livekit/auth";
/** /**
* Tracks the transports used by ourselves, plus all other MatrixRTC session * Tracks the transports used by ourselves, plus all other MatrixRTC session
@@ -31,16 +31,15 @@ export const membershipsAndTransports$ = (
memberships$: Behavior<Epoch<CallMembership[]>>, memberships$: Behavior<Epoch<CallMembership[]>>,
): { ): {
membershipsWithTransport$: Behavior< membershipsWithTransport$: Behavior<
Epoch<{ membership: CallMembership; transport?: LivekitTransport }[]> Epoch<
{ membership: CallMembership; transport: TransportLocator | undefined }[]
>
>; >;
transports$: Behavior<Epoch<LivekitTransport[]>>; transports$: Behavior<Epoch<TransportLocator[]>>;
} => { } => {
const membershipsWithTransport$: Behavior< const membershipsWithTransport$: Behavior<
Epoch< Epoch<
{ { membership: CallMembership; transport: TransportLocator | undefined }[]
membership: CallMembership;
transport: LivekitTransport | undefined;
}[]
> >
> = scope.behavior( > = scope.behavior(
memberships$.pipe( memberships$.pipe(
@@ -49,14 +48,19 @@ export const membershipsAndTransports$ = (
const transport = membership.getTransport(); const transport = membership.getTransport();
return { return {
membership, membership,
transport: isLivekitTransport(transport) ? transport : undefined, transport: isUnstableLivekitTransport(transport)
? {
transport,
serverName: membership.userId.replace(/^.*?:/, ""),
}
: undefined,
}; };
}); });
}), }),
), ),
); );
const transports$: Behavior<Epoch<LivekitTransport[]>> = scope.behavior( const transports$: Behavior<Epoch<TransportLocator[]>> = scope.behavior(
membershipsWithTransport$.pipe( membershipsWithTransport$.pipe(
mapEpoch((mts) => mts.flatMap(({ transport: t }) => (t ? [t] : []))), mapEpoch((mts) => mts.flatMap(({ transport: t }) => (t ? [t] : []))),
), ),
+7 -6
View File
@@ -40,6 +40,7 @@ import { E2eeType } from "../../e2ee/e2eeType";
import { observeInboundRtpStreamStats$ } from "./observeRtpStreamStats"; import { observeInboundRtpStreamStats$ } from "./observeRtpStreamStats";
import { type UserMediaViewModel } from "./UserMediaViewModel"; import { type UserMediaViewModel } from "./UserMediaViewModel";
import { type ScreenShareViewModel } from "./ScreenShareViewModel"; import { type ScreenShareViewModel } from "./ScreenShareViewModel";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
// TODO: Encryption status is kinda broken and thus unused right now. Remove? // TODO: Encryption status is kinda broken and thus unused right now. Remove?
export enum EncryptionStatus { export enum EncryptionStatus {
@@ -59,10 +60,10 @@ export interface BaseMemberMediaViewModel extends BaseMediaViewModel {
*/ */
video$: Behavior<TrackReference | undefined>; video$: Behavior<TrackReference | undefined>;
/** /**
* The URL of the LiveKit focus on which this member should be publishing. * The LiveKit transport on which this member should be publishing. Exposed
* Exposed for debugging. * for debugging.
*/ */
focusUrl$: Behavior<string | undefined>; transport$: Behavior<UnstableLivekitTransport | undefined>;
/** /**
* Whether there should be a warning that this media is unencrypted. * Whether there should be a warning that this media is unencrypted.
*/ */
@@ -75,7 +76,7 @@ export interface MemberMediaInputs extends BaseMediaViewModel {
livekitRoom$: Behavior<LivekitRoom | undefined>; livekitRoom$: Behavior<LivekitRoom | undefined>;
audioSource: AudioSource; audioSource: AudioSource;
videoSource: VideoSource; videoSource: VideoSource;
focusUrl$: Behavior<string | undefined>; transport$: Behavior<UnstableLivekitTransport | undefined>;
encryptionSystem: EncryptionSystem; encryptionSystem: EncryptionSystem;
} }
@@ -86,7 +87,7 @@ export function createMemberMedia(
livekitRoom$, livekitRoom$,
audioSource, audioSource,
videoSource, videoSource,
focusUrl$, transport$,
encryptionSystem, encryptionSystem,
...inputs ...inputs
}: MemberMediaInputs, }: MemberMediaInputs,
@@ -109,7 +110,7 @@ export function createMemberMedia(
return { return {
...createBaseMedia(inputs), ...createBaseMedia(inputs),
video$, video$,
focusUrl$, transport$,
unencryptedWarning$: scope.behavior( unencryptedWarning$: scope.behavior(
combineLatest( combineLatest(
[audio$, video$], [audio$, video$],
+8 -7
View File
@@ -51,6 +51,7 @@ import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaVie
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel"; import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel"; import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
import { RingingStatus } from "./RingingStatus"; import { RingingStatus } from "./RingingStatus";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
interface TileProps { interface TileProps {
ref?: Ref<HTMLDivElement>; ref?: Ref<HTMLDivElement>;
@@ -103,7 +104,7 @@ interface UserMediaTileProps extends TileProps {
playbackMuted: boolean; playbackMuted: boolean;
waitingForMedia?: boolean; waitingForMedia?: boolean;
primaryButton?: ReactNode; primaryButton?: ReactNode;
focusUrl: string | undefined; transport: UnstableLivekitTransport | undefined;
} }
/** /**
@@ -120,7 +121,7 @@ const UserMediaTileInner: FC<UserMediaTileProps & { menu: ReactNode }> = ({
primaryButton, primaryButton,
menu, menu,
className, className,
focusUrl, transport,
displayName, displayName,
mxcAvatarUrl, mxcAvatarUrl,
focusable, focusable,
@@ -214,7 +215,7 @@ const UserMediaTileInner: FC<UserMediaTileProps & { menu: ReactNode }> = ({
currentReaction={reaction ?? undefined} currentReaction={reaction ?? undefined}
raisedHandOnClick={raisedHandOnClick} raisedHandOnClick={raisedHandOnClick}
waitingForMedia={waitingForMedia} waitingForMedia={waitingForMedia}
focusUrl={focusUrl} transport={transport}
setVideoAspectRatio={vm.setVideoAspectRatio} setVideoAspectRatio={vm.setVideoAspectRatio}
audioStreamStats={audioStreamStats} audioStreamStats={audioStreamStats}
videoStreamStats={videoStreamStats} videoStreamStats={videoStreamStats}
@@ -279,7 +280,7 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
const mirror = useBehavior(vm.mirror$); const mirror = useBehavior(vm.mirror$);
const alwaysShow = useBehavior(vm.alwaysShow$); const alwaysShow = useBehavior(vm.alwaysShow$);
const switchCamera = useBehavior(vm.switchCamera$); const switchCamera = useBehavior(vm.switchCamera$);
const focusUrl = useBehavior(vm.focusUrl$); const transport = useBehavior(vm.transport$);
const latestAlwaysShow = useLatest(alwaysShow); const latestAlwaysShow = useLatest(alwaysShow);
const onSelectAlwaysShow = useCallback( const onSelectAlwaysShow = useCallback(
@@ -334,7 +335,7 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
menuStart={menuStart} menuStart={menuStart}
menuEnd={menuEnd} menuEnd={menuEnd}
focusable={focusable} focusable={focusable}
focusUrl={focusUrl} transport={transport}
{...props} {...props}
/> />
); );
@@ -356,7 +357,7 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
const waitingForMedia = useBehavior(vm.waitingForMedia$); const waitingForMedia = useBehavior(vm.waitingForMedia$);
const playbackMuted = useBehavior(vm.playbackMuted$); const playbackMuted = useBehavior(vm.playbackMuted$);
const playbackVolume = useBehavior(vm.playbackVolume$); const playbackVolume = useBehavior(vm.playbackVolume$);
const focusUrl = useBehavior(vm.focusUrl$); const transport = useBehavior(vm.transport$);
const onSelectMute = useCallback( const onSelectMute = useCallback(
(e: Event) => { (e: Event) => {
@@ -398,7 +399,7 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
</MenuItem> </MenuItem>
</> </>
} }
focusUrl={focusUrl} transport={transport}
{...props} {...props}
/> />
); );
+5 -4
View File
@@ -33,6 +33,7 @@ import { ReactionIndicator } from "../reactions/ReactionIndicator";
import { RTCConnectionStats } from "../RTCConnectionStats"; import { RTCConnectionStats } from "../RTCConnectionStats";
import videoPlaceholder from "../graphics/video-placeholder.gif"; import videoPlaceholder from "../graphics/video-placeholder.gif";
import { autoVideoFit } from "../utils/videoFit"; import { autoVideoFit } from "../utils/videoFit";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
interface Props extends ComponentProps<typeof animated.div> { interface Props extends ComponentProps<typeof animated.div> {
className?: string; className?: string;
@@ -67,9 +68,9 @@ interface Props extends ComponentProps<typeof animated.div> {
videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats; videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
rtcBackendIdentity?: string; rtcBackendIdentity?: string;
/** /**
* The focus url, mainly for debugging purposes. * The LiveKit transport, mainly for debugging purposes.
*/ */
focusUrl?: string; transport?: UnstableLivekitTransport;
/** /**
* Called whenever the aspect ratio of the video content becomes known or * Called whenever the aspect ratio of the video content becomes known or
* otherwise changes. * otherwise changes.
@@ -106,7 +107,7 @@ export const MediaView: FC<Props> = ({
audioStreamStats, audioStreamStats,
videoStreamStats, videoStreamStats,
rtcBackendIdentity, rtcBackendIdentity,
focusUrl, transport,
setVideoAspectRatio: setTheirVideoAspectRatio, setVideoAspectRatio: setTheirVideoAspectRatio,
...props ...props
}) => { }) => {
@@ -227,7 +228,7 @@ export const MediaView: FC<Props> = ({
<RTCConnectionStats <RTCConnectionStats
audio={audioStreamStats} audio={audioStreamStats}
video={videoStreamStats} video={videoStreamStats}
focusUrl={focusUrl} transport={transport}
rtcBackendIdentity={rtcBackendIdentity} rtcBackendIdentity={rtcBackendIdentity}
/> />
</> </>
+4 -3
View File
@@ -54,6 +54,7 @@ import { platform } from "../Platform";
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel"; import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
import { RingingStatus } from "./RingingStatus"; import { RingingStatus } from "./RingingStatus";
import { useRootElement } from "../RootElementContext"; import { useRootElement } from "../RootElementContext";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
interface SpotlightItemBaseProps { interface SpotlightItemBaseProps {
ref?: Ref<HTMLDivElement>; ref?: Ref<HTMLDivElement>;
@@ -74,7 +75,7 @@ interface SpotlightItemBaseProps {
interface SpotlightMemberMediaItemBaseProps extends SpotlightItemBaseProps { interface SpotlightMemberMediaItemBaseProps extends SpotlightItemBaseProps {
video: TrackReferenceOrPlaceholder | undefined; video: TrackReferenceOrPlaceholder | undefined;
unencryptedWarning: boolean; unencryptedWarning: boolean;
focusUrl: string | undefined; transport: UnstableLivekitTransport | undefined;
} }
interface SpotlightUserMediaItemBaseProps extends SpotlightMemberMediaItemBaseProps { interface SpotlightUserMediaItemBaseProps extends SpotlightMemberMediaItemBaseProps {
@@ -177,13 +178,13 @@ const SpotlightMemberMediaItem: FC<SpotlightMemberMediaItemProps> = ({
}) => { }) => {
const video = useBehavior(vm.video$); const video = useBehavior(vm.video$);
const unencryptedWarning = useBehavior(vm.unencryptedWarning$); const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
const focusUrl = useBehavior(vm.focusUrl$); const transport = useBehavior(vm.transport$);
const baseProps: SpotlightMemberMediaItemBaseProps & const baseProps: SpotlightMemberMediaItemBaseProps &
RefAttributes<HTMLDivElement> = { RefAttributes<HTMLDivElement> = {
video: video ?? undefined, video: video ?? undefined,
unencryptedWarning, unencryptedWarning,
focusUrl, transport,
...props, ...props,
}; };
+7 -1
View File
@@ -338,9 +338,13 @@ export function getRelativeRoomUrl(
* Perform a network operation with retries on ConnectionError. * Perform a network operation with retries on ConnectionError.
* If the error is not retryable, or the max number of retries is reached, the error is rethrown. * If the error is not retryable, or the max number of retries is reached, the error is rethrown.
* Supports handling of matrix quotas. * Supports handling of matrix quotas.
* @param operation The operation to perform.
* @param shouldRetry Rules out errors that retrying cannot fix, on top of the
* standard backoff rule. Retries everything by default.
*/ */
export async function doNetworkOperationWithRetry<T>( export async function doNetworkOperationWithRetry<T>(
operation: () => Promise<T>, operation: () => Promise<T>,
shouldRetry: (e: unknown) => boolean = () => true,
): Promise<T> { ): Promise<T> {
let currentRetryCount = 0; let currentRetryCount = 0;
@@ -350,7 +354,9 @@ export async function doNetworkOperationWithRetry<T>(
return await operation(); return await operation();
} catch (e) { } catch (e) {
currentRetryCount++; currentRetryCount++;
const backoff = calculateRetryBackoff(e, currentRetryCount, true); const backoff = shouldRetry(e)
? calculateRetryBackoff(e, currentRetryCount, true)
: -1;
if (backoff < 0) { if (backoff < 0) {
// Max number of retries reached, or error is not retryable. rethrow the error // Max number of retries reached, or error is not retryable. rethrow the error
throw e; throw e;
+22
View File
@@ -218,6 +218,28 @@ export function filterBehavior<T, S extends T>(
); );
} }
/**
* Applies a given projection to each value emitted by the source Observable.
* The `project` function additionally receives an {@link ObservableScope}.
*/
export function mapScoped<A, B>(
name: string,
project: (a: A, scope: ObservableScope) => B,
): OperatorFunction<A, B> {
return (a$) =>
a$.pipe(
generateItemsInternal(
name,
function* (a) {
yield { keys: [a], data: undefined };
},
(scope, _data$, a) => project(a, scope),
(items) => items,
),
map(([item]) => item),
);
}
function generateItemsInternal< function generateItemsInternal<
Input, Input,
Keys extends [unknown, ...unknown[]], Keys extends [unknown, ...unknown[]],
+19 -12
View File
@@ -64,16 +64,23 @@ export const daveRTL = mockMatrixRoomMember(daveRTLRtcMember, {
rawDisplayName: "\u202eevaD", rawDisplayName: "\u202eevaD",
}); });
export const testJWTToken = [ export function createJWTToken(sub: string, room: string): string {
{}, // header return [
{ {}, // header
// payload {
sub: "@me:example.org:ABCDEF", // payload
video: { sub,
room: "!example_room_id", video: {
room,
},
}, },
}, {}, // signature
{}, // signature ]
] .map((d) => global.btoa(JSON.stringify(d)))
.map((d) => global.btoa(JSON.stringify(d))) .join(".");
.join("."); }
export const testJWTToken = createJWTToken(
"@me:example.org:ABCDEF",
"!example_room_id",
);
+1 -1
View File
@@ -69,7 +69,7 @@ export function getBasicRTCSession(
getUserId: () => localRtcMember.userId, getUserId: () => localRtcMember.userId,
getDeviceId: () => localRtcMember.deviceId, getDeviceId: () => localRtcMember.deviceId,
getSyncState: () => SyncState.Syncing, getSyncState: () => SyncState.Syncing,
getDomain: () => null, getDomain: () => "example.com",
getAccessToken: () => "fake-token", getAccessToken: () => "fake-token",
sendEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }), sendEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }),
redactEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }), redactEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }),
+15 -14
View File
@@ -26,7 +26,7 @@ import {
import { import {
CallMembership, CallMembership,
type LivekitFocusSelection, type LivekitFocusSelection,
type LivekitTransport, type UnstableLivekitTransport,
type MatrixRTCSession, type MatrixRTCSession,
MatrixRTCSessionEvent, MatrixRTCSessionEvent,
type MatrixRTCSessionEventHandlerMap, type MatrixRTCSessionEventHandlerMap,
@@ -78,7 +78,7 @@ import {
type RemoteScreenShareViewModel, type RemoteScreenShareViewModel,
} from "../state/media/RemoteScreenShareViewModel"; } from "../state/media/RemoteScreenShareViewModel";
import { Connection } from "../state/CallViewModel/remoteMembers/Connection"; import { Connection } from "../state/CallViewModel/remoteMembers/Connection";
import { type SFUConfig } from "../livekit/openIDSFU"; import { type TransportLocator } from "../livekit/auth";
export function withFakeTimers(continuation: () => void): void { export function withFakeTimers(continuation: () => void): void {
vi.useFakeTimers(); vi.useFakeTimers();
@@ -209,16 +209,14 @@ export function mockEmitter<T>(): EmitterMock<T> {
}; };
} }
export const exampleTransport: LivekitTransport = { export const exampleTransport: UnstableLivekitTransport = {
type: "livekit", type: "livekit",
livekit_service_url: "https://lk.example.org", livekit_service_url: "https://lk.example.org",
}; };
export const exampleSfuConfig: SFUConfig = { export const exampleTransportLocator: TransportLocator = {
jwt: "foo", transport: exampleTransport,
livekitAlias: "bar", serverName: "example.org",
livekitIdentity: "baz",
url: "bro",
}; };
export function mockRtcMembership( export function mockRtcMembership(
@@ -226,6 +224,8 @@ export function mockRtcMembership(
deviceId: string, deviceId: string,
customOverwrites?: { customOverwrites?: {
rtcBackendIdentity?: string; rtcBackendIdentity?: string;
/** Every identity the member may have in LiveKit, when more than one. */
backendIdentities?: string[];
callId?: string; callId?: string;
fociPreferred?: Transport[]; fociPreferred?: Transport[];
focusActive?: LivekitFocusSelection; focusActive?: LivekitFocusSelection;
@@ -261,11 +261,12 @@ export function mockRtcMembership(
content: data, content: data,
}); });
const membershipData = CallMembership.membershipDataFromMatrixEvent(event); const backendIdentity = rtcBackendIdentity ?? `${userId}:${deviceId}`;
const cms = new CallMembership( const cms = new CallMembership(
event, event,
membershipData, CallMembership.membershipDataFromMatrixEvent(event),
rtcBackendIdentity ?? `${userId}:${deviceId}`, customOverwrites?.backendIdentities ?? [backendIdentity],
backendIdentity,
); );
vi.mocked(cms).getTransport = vi.fn().mockReturnValue(fociPreferred[0]); vi.mocked(cms).getTransport = vi.fn().mockReturnValue(fociPreferred[0]);
@@ -357,7 +358,7 @@ export function mockLocalMedia(
participant$: constant(localParticipant), participant$: constant(localParticipant),
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
livekitRoom$: constant(mockLivekitRoom({ localParticipant })), livekitRoom$: constant(mockLivekitRoom({ localParticipant })),
focusUrl$: constant("https://rtc-example.org"), transport$: constant(exampleTransport),
mediaDevices, mediaDevices,
displayName$: constant(member.rawDisplayName ?? "nodisplayname"), displayName$: constant(member.rawDisplayName ?? "nodisplayname"),
mxcAvatarUrl$: constant(member.getMxcAvatarUrl()), mxcAvatarUrl$: constant(member.getMxcAvatarUrl()),
@@ -400,7 +401,7 @@ export function mockRemoteMedia(
participant$: constant(participant), participant$: constant(participant),
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
livekitRoom$: constant(livekitRoom), livekitRoom$: constant(livekitRoom),
focusUrl$: constant("https://rtc-example.org"), transport$: constant(exampleTransport),
pretendToBeDisconnected$: constant(false), pretendToBeDisconnected$: constant(false),
displayName$: constant(member.rawDisplayName ?? "nodisplayname"), displayName$: constant(member.rawDisplayName ?? "nodisplayname"),
mxcAvatarUrl$: constant(member.getMxcAvatarUrl()), mxcAvatarUrl$: constant(member.getMxcAvatarUrl()),
@@ -427,7 +428,7 @@ export function mockRemoteScreenShare(
participant$: constant(participant), participant$: constant(participant),
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
livekitRoom$: constant(livekitRoom), livekitRoom$: constant(livekitRoom),
focusUrl$: constant("https://rtc-example.org"), transport$: constant(exampleTransport),
pretendToBeDisconnected$: constant(false), pretendToBeDisconnected$: constant(false),
displayName$: constant(member.rawDisplayName ?? "nodisplayname"), displayName$: constant(member.rawDisplayName ?? "nodisplayname"),
mxcAvatarUrl$: constant(member.getMxcAvatarUrl()), mxcAvatarUrl$: constant(member.getMxcAvatarUrl()),