mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
Drop the auth modules main's PR 4331 added under src
The SDK replaced `src/livekit` and the remote-member modules before that PR landed on `main`, so its `src/livekit/auth/*` and `remoteMembers/ConnectionMap.ts` have no consumer here: the same code lives under `sdk/src/media-backend/livekit`. The imports of them that `main` added to the view model, the developer settings tab and the test utils go with them.
This commit is contained in:
@@ -1,137 +0,0 @@
|
|||||||
/*
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
/*
|
|
||||||
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,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
/*
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,230 +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 {
|
|
||||||
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",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,262 +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 } 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();
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
/*
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -72,8 +72,6 @@ import {
|
|||||||
} from "@element-hq/matrixrtc-sdk";
|
} from "@element-hq/matrixrtc-sdk";
|
||||||
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.
|
||||||
|
|||||||
@@ -126,7 +126,6 @@ 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";
|
|
||||||
|
|
||||||
/** How a call captures and encodes the tracks it publishes. */
|
/** How a call captures and encodes the tracks it publishes. */
|
||||||
export interface CaptureSettings {
|
export interface CaptureSettings {
|
||||||
|
|||||||
@@ -128,8 +128,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,219 +0,0 @@
|
|||||||
/*
|
|
||||||
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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -34,7 +34,6 @@ import {
|
|||||||
import { type BaseMediaViewModel, createBaseMedia } from "./MediaViewModel";
|
import { type BaseMediaViewModel, createBaseMedia } from "./MediaViewModel";
|
||||||
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";
|
|
||||||
|
|
||||||
/** How often a view model reads a track's RTP statistics while it needs them. */
|
/** How often a view model reads a track's RTP statistics while it needs them. */
|
||||||
export const statsIntervalMs = 1000;
|
export const statsIntervalMs = 1000;
|
||||||
|
|||||||
Reference in New Issue
Block a user