mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
Merge pull request #4331 from element-hq/robin/livekit-updates
Support migration to new LiveKit transport types and endpoints
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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..."
|
||||||
|
|||||||
@@ -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('/')`. */
|
||||||
|
|||||||
@@ -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"),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Generated
+10
-10
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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">
|
||||||
{extractDomain(focusUrl)}
|
{JSON.stringify(transport)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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,
|
||||||
|
}),
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
mapScoped("connectionMap$", (localTransport, scope) =>
|
||||||
|
createConnectionMap$({
|
||||||
|
scope,
|
||||||
|
connectionFactory,
|
||||||
|
localTransport,
|
||||||
remoteTransports$: membershipsAndTransports.transports$,
|
remoteTransports$: membershipsAndTransports.transports$,
|
||||||
logger: logger,
|
logger,
|
||||||
ownMembershipIdentity,
|
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(
|
|
||||||
|
const livekitRoomItems$ = scope.behavior<LivekitRoomItem[]>(
|
||||||
remoteMatrixLivekitMembers$.pipe(
|
remoteMatrixLivekitMembers$.pipe(
|
||||||
switchMap((members) => {
|
// Flatten the members to a snapshot of their current connections and
|
||||||
const a$ = combineLatest(
|
// participant objects
|
||||||
members.value.map((member) =>
|
switchMap(({ value: members }) => {
|
||||||
combineLatest([member.connection$, member.participant.value$]).pipe(
|
if (members.length === 0) return of([]);
|
||||||
map(([connection, participant]) => {
|
return combineLatest(
|
||||||
// do not render audio for local participant
|
members.map((m) =>
|
||||||
if (!connection || !participant || participant.isLocal)
|
combineLatest(
|
||||||
return null;
|
[m.connection$, m.participant.value$],
|
||||||
const livekitRoom = connection.livekitRoom;
|
(connection, participant) => ({ connection, participant }),
|
||||||
const url = connection.transport.livekit_service_url;
|
|
||||||
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
livekitRoom,
|
|
||||||
participant: participant.identity,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
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",
|
||||||
{
|
|
||||||
matrixRTCMode: MATRIX_RTC_MODE,
|
|
||||||
delayEndpointBaseUrl: "https://matrix.example.org",
|
|
||||||
delayId,
|
delayId,
|
||||||
},
|
logger: expect.anything(),
|
||||||
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,13 +672,16 @@ 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;
|
||||||
|
|
||||||
|
// Only join once authenticated, to ensure the LiveKit room exists first.
|
||||||
|
// Otherwise, subscribers authenticating with the OpenID flow may fail to
|
||||||
|
// join (they lack permission to create the room) and never retry.
|
||||||
|
if (joinParams && shouldConnect && authenticated) {
|
||||||
try {
|
try {
|
||||||
joinMatrixRTC(
|
joinMatrixRTC(
|
||||||
joinParams.transport,
|
joinParams.transport,
|
||||||
@@ -676,14 +695,15 @@ export const createLocalMembership$ = ({
|
|||||||
setMatrixError(new MembershipManagerError(error));
|
setMatrixError(new MembershipManagerError(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean-up callback to leave the session
|
||||||
return Promise.resolve(async (): Promise<void> => {
|
return Promise.resolve(async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
// TODO Update matrixRTCSession to allow udpating the transport without leaving the session!
|
|
||||||
await matrixRTCSession.leave(1000);
|
await matrixRTCSession.leave(1000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error("Error leaving RTC session", 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",
|
|
||||||
client: {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||||
getDomain: () => "example.org",
|
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",
|
|
||||||
client: {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||||
getDomain: () => "example.org",
|
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,
|
|
||||||
roomId: "!example_room_id",
|
|
||||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
|
||||||
client: {
|
|
||||||
getDomain: vi.fn().mockReturnValue("example.org"),
|
getDomain: vi.fn().mockReturnValue("example.org"),
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
|
_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,
|
|
||||||
roomId: "!example_room_id",
|
|
||||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
|
||||||
client: {
|
|
||||||
getDomain: () => "example.org",
|
getDomain: () => "example.org",
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||||
// These won't be called in this error path but satisfy the type
|
|
||||||
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,
|
client,
|
||||||
resolvedConfig: Config.get(),
|
resolvedConfig: Config.get(),
|
||||||
logger: logger,
|
logger: rootLogger.getChild("[LocalTransport]"),
|
||||||
});
|
});
|
||||||
const customUrl = customLivekitUrl.value$.value;
|
|
||||||
|
|
||||||
// Respect the user's custom URL, if set
|
// Respect the user's custom transport, if set
|
||||||
const transport: LivekitTransport | null = customUrl
|
const transport =
|
||||||
? { type: "livekit", livekit_service_url: customUrl }
|
customTransportSetting.value$.value ??
|
||||||
: await discovery.discoverPreferredTransport();
|
(await discovery.discoverPreferredTransport());
|
||||||
|
|
||||||
if (transport === null)
|
if (transport === null) throw new MatrixRTCTransportMissingError(serverName);
|
||||||
throw new MatrixRTCTransportMissingError(client.getDomain() ?? "");
|
|
||||||
|
|
||||||
try {
|
return { transport, serverName };
|
||||||
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,
|
|
||||||
membership,
|
|
||||||
transport.livekit_service_url,
|
|
||||||
roomId,
|
|
||||||
{ matrixRTCMode },
|
|
||||||
logger,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
transport,
|
|
||||||
sfuConfig,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapAuthErrorToUserFriendlyError(e: unknown): Error {
|
|
||||||
if (
|
|
||||||
e instanceof FailToGetOpenIdToken ||
|
|
||||||
e instanceof NoMatrix2AuthorizationService
|
|
||||||
) {
|
|
||||||
// rethrow as is
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
// 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({
|
||||||
|
state: "error",
|
||||||
|
error:
|
||||||
error instanceof ElementCallError
|
error instanceof ElementCallError
|
||||||
? error
|
? error
|
||||||
: error instanceof Error
|
: error instanceof Error
|
||||||
? new UnknownCallError(error)
|
? new UnknownCallError(error)
|
||||||
: new UnknownCallError(new Error(`${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[]);
|
|
||||||
}
|
|
||||||
+151
-56
@@ -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 = {
|
||||||
|
transport: {
|
||||||
type: "livekit",
|
type: "livekit",
|
||||||
livekit_service_url: "https://lk.example.org",
|
livekit_service_url: "https://lk.example.org",
|
||||||
|
},
|
||||||
|
serverName: "example.org",
|
||||||
};
|
};
|
||||||
|
|
||||||
const TRANSPORT_2: LivekitTransport = {
|
const TRANSPORT_2: TransportLocator = {
|
||||||
|
transport: {
|
||||||
type: "livekit",
|
type: "livekit",
|
||||||
livekit_service_url: "https://lk.sample.com",
|
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,
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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] : []))),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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$],
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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[]],
|
||||||
|
|||||||
@@ -64,16 +64,23 @@ export const daveRTL = mockMatrixRoomMember(daveRTLRtcMember, {
|
|||||||
rawDisplayName: "\u202eevaD",
|
rawDisplayName: "\u202eevaD",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const testJWTToken = [
|
export function createJWTToken(sub: string, room: string): string {
|
||||||
|
return [
|
||||||
{}, // header
|
{}, // header
|
||||||
{
|
{
|
||||||
// payload
|
// payload
|
||||||
sub: "@me:example.org:ABCDEF",
|
sub,
|
||||||
video: {
|
video: {
|
||||||
room: "!example_room_id",
|
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",
|
||||||
|
);
|
||||||
|
|||||||
@@ -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
@@ -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()),
|
||||||
|
|||||||
Reference in New Issue
Block a user