mirror of
https://github.com/vector-im/element-call.git
synced 2026-11-04 00:42:28 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e02db20c9f | ||
|
|
9aabe05b34 | ||
|
|
b5165b8f3b | ||
|
|
9714412a43 | ||
|
|
76da59be40 | ||
|
|
ed6a0e1341 | ||
|
|
90ccf62d5c | ||
|
|
dde07f6d54 |
@@ -19,7 +19,7 @@ services:
|
||||
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
||||
element-web:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:b2a5a30afb4f583a7560448b005d8332bee29672f15826e4a995b393058f0291
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:79e47d8d5a93848403cb83042a405d8ed233a7727c499fa53b4b39a80f090be4
|
||||
element-web-1:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:b2a5a30afb4f583a7560448b005d8332bee29672f15826e4a995b393058f0291
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:79e47d8d5a93848403cb83042a405d8ed233a7727c499fa53b4b39a80f090be4
|
||||
|
||||
@@ -71,6 +71,9 @@
|
||||
"device_id": "Device ID: {{id}}",
|
||||
"duplicate_tiles_label": "Number of additional tile copies per participant",
|
||||
"environment_variables": "Environment variables",
|
||||
"full_mesh_peer_connections": "Full mesh peer connections",
|
||||
"full_mesh_publishing": "Publish on full mesh (MSC4575) instead of LiveKit",
|
||||
"full_mesh_transport": "Full mesh: {{device}}",
|
||||
"hostname": "Hostname: {{hostname}}",
|
||||
"livekit_server_info": "LiveKit Server Info",
|
||||
"livekit_sfu": "LiveKit SFU: {{url}}",
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
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";
|
||||
import { HOST1, TestHelpers } from "./test-helpers";
|
||||
|
||||
/**
|
||||
* Records the LiveKit signalling websockets a page opens: the SFU is reached
|
||||
* with an access token in the url, which full mesh never needs.
|
||||
*/
|
||||
function recordSfuSockets(page: Page): string[] {
|
||||
const urls: string[] = [];
|
||||
page.on("websocket", (ws) => {
|
||||
if (ws.url().includes("access_token")) urls.push(ws.url());
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts recording the types of the to-device messages the widget on a page
|
||||
* asks its host to send, and returns a function reading them. On the wire
|
||||
* they are encrypted, so the type is only visible in the widget API.
|
||||
*/
|
||||
async function recordWidgetToDevice(
|
||||
page: Page,
|
||||
): Promise<() => Promise<string[]>> {
|
||||
await page.evaluate(() => {
|
||||
const types: string[] = [];
|
||||
Object.assign(window, { widgetToDeviceTypes: types });
|
||||
window.addEventListener("message", ({ data }) => {
|
||||
const {
|
||||
action,
|
||||
response,
|
||||
data: request,
|
||||
} = (data ?? {}) as {
|
||||
action?: string;
|
||||
response?: unknown;
|
||||
data?: { type?: string };
|
||||
};
|
||||
if (action === "send_to_device" && response === undefined)
|
||||
types.push(request?.type ?? "");
|
||||
});
|
||||
});
|
||||
return async () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window as unknown as { widgetToDeviceTypes: string[] })
|
||||
.widgetToDeviceTypes,
|
||||
);
|
||||
}
|
||||
|
||||
const cases: { name: string; fullMesh: [boolean, boolean] }[] = [
|
||||
// Both publish on full mesh: the media flows without any SFU
|
||||
{ name: "both on full mesh", fullMesh: [true, true] },
|
||||
// One on each: Florian receives Valere over LiveKit without publishing there
|
||||
{ name: "full mesh and LiveKit mixed", fullMesh: [true, false] },
|
||||
];
|
||||
|
||||
cases.forEach(({ name, fullMesh: [florianMesh, valereMesh] }) => {
|
||||
widgetTest(`Full mesh call: ${name}`, 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();
|
||||
|
||||
// Distinct per case: the cases run in parallel and register at once
|
||||
const suffix = `${Number(florianMesh)}${Number(valereMesh)}`;
|
||||
const [florian, valere] = await Promise.all([
|
||||
addUser(`florian${suffix}`, HOST1),
|
||||
addUser(`valere${suffix}`, HOST1),
|
||||
]);
|
||||
// Element Web doesn't auto-approve the full mesh signalling for Element
|
||||
// Call yet, so it asks for it whenever a widget with full mesh opens
|
||||
for (const { page } of [florian, valere])
|
||||
await page.addLocatorHandler(
|
||||
page.getByRole("heading", { name: "Approve widget permissions" }),
|
||||
async () => page.getByRole("button", { name: "Approve" }).click(),
|
||||
);
|
||||
const sockets = {
|
||||
florian: recordSfuSockets(florian.page),
|
||||
valere: recordSfuSockets(valere.page),
|
||||
};
|
||||
|
||||
const roomName = "Full Mesh Room";
|
||||
await TestHelpers.createRoom(roomName, florian.page, [valere.mxId]);
|
||||
await TestHelpers.acceptRoomInvite(roomName, valere.page);
|
||||
|
||||
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
florian.page,
|
||||
"2_0",
|
||||
{ fullMesh: florianMesh },
|
||||
);
|
||||
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
valere.page,
|
||||
"2_0",
|
||||
{ fullMesh: valereMesh },
|
||||
);
|
||||
|
||||
await TestHelpers.startCallInCurrentRoom(florian.page, false);
|
||||
await TestHelpers.joinCallFromLobby(florian.page);
|
||||
await TestHelpers.joinCallInCurrentRoom(valere.page);
|
||||
|
||||
// A widget only asks for the full mesh signalling with full mesh turned
|
||||
// on, so a LiveKit publisher in Element Web can't receive full mesh yet:
|
||||
// the other's media arrives if it is on LiveKit or we have full mesh
|
||||
const receives = {
|
||||
florian: !valereMesh || florianMesh,
|
||||
valere: !florianMesh || valereMesh,
|
||||
};
|
||||
// In parallel: assertions on a page also run its permission handler
|
||||
await Promise.all(
|
||||
(["florian", "valere"] as const).map(async (name) => {
|
||||
const frame = { florian, valere }[name].page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await expect(frame.getByTestId("videoTile")).toHaveCount(2, {
|
||||
timeout: 15000,
|
||||
});
|
||||
const waiting = frame.getByText("Waiting for media...");
|
||||
if (receives[name]) {
|
||||
// Over whichever transport carries it
|
||||
await expect(waiting).not.toBeVisible({ timeout: 20000 });
|
||||
await TestHelpers.expectVisibleVideoCount(frame, 2);
|
||||
} else {
|
||||
await expect(waiting).toBeVisible({ timeout: 20000 });
|
||||
await TestHelpers.expectVisibleVideoCount(frame, 1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (florianMesh && valereMesh) {
|
||||
// Nobody published on LiveKit, so nobody connected to an SFU
|
||||
expect(sockets.florian).toEqual([]);
|
||||
expect(sockets.valere).toEqual([]);
|
||||
} else {
|
||||
// Florian receives Valere's media from the SFU without publishing there
|
||||
expect(sockets.florian.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Florian sees Valere's camera go off: over the track metadata for full
|
||||
// mesh, without renegotiating
|
||||
const toDevice = await recordWidgetToDevice(valere.page);
|
||||
const valereFrame = valere.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await valereFrame.getByRole("switch", { name: "Stop video" }).click();
|
||||
const florianFrame = florian.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await TestHelpers.expectVisibleVideoCount(florianFrame, 1);
|
||||
// On the connection's control channel, not through the homeserver.
|
||||
// Candidates may still trickle there until the channel is up
|
||||
if (florianMesh && valereMesh)
|
||||
expect(
|
||||
(await toDevice()).filter(
|
||||
(type) => type.includes("full_mesh") && !type.endsWith(".candidates"),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -159,7 +159,7 @@ export class TestHelpers {
|
||||
public static async dismissStartupToasts(page: Page): Promise<void> {
|
||||
const expectedToasts = [
|
||||
{ title: "Failed to load service worker", button: "OK" },
|
||||
{ title: "Back up your chats", button: "Dismiss" },
|
||||
{ title: "Back up your chats", button: "Not now" },
|
||||
{ title: "Turn on key storage", button: "Dismiss" },
|
||||
{ title: "Element does not support this browser", button: "Dismiss" },
|
||||
];
|
||||
@@ -178,7 +178,11 @@ export class TestHelpers {
|
||||
);
|
||||
|
||||
if (toastConfig) {
|
||||
await toast.getByRole("button", { name: toastConfig.button }).click();
|
||||
// Bounded so that a renamed button leaves the toast in place and the
|
||||
// next step reports it, instead of hanging until the test timeout.
|
||||
await toast
|
||||
.getByRole("button", { name: toastConfig.button })
|
||||
.click({ timeout: 5000 });
|
||||
} else {
|
||||
// Unknown toast. We don't want to act on unknown toasts
|
||||
break;
|
||||
@@ -288,6 +292,7 @@ export class TestHelpers {
|
||||
public static async openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
page: Page,
|
||||
mode: RtcMode,
|
||||
{ fullMesh = false }: { fullMesh?: boolean } = {},
|
||||
): Promise<void> {
|
||||
await page.getByRole("button", { name: "Video call" }).click({
|
||||
timeout: 5000,
|
||||
@@ -296,7 +301,7 @@ export class TestHelpers {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await TestHelpers.setEmbeddedElementCallRtcMode(page, mode);
|
||||
await TestHelpers.setEmbeddedElementCallRtcMode(page, mode, { fullMesh });
|
||||
await page.getByRole("button", { name: "Close lobby" }).click();
|
||||
}
|
||||
|
||||
@@ -309,6 +314,7 @@ export class TestHelpers {
|
||||
public static async setEmbeddedElementCallRtcMode(
|
||||
page: Page,
|
||||
mode: RtcMode,
|
||||
{ fullMesh = false }: { fullMesh?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const iframe = page.locator('iframe[title="Element Call"]').contentFrame();
|
||||
|
||||
@@ -326,6 +332,10 @@ export class TestHelpers {
|
||||
// compat
|
||||
await iframe.getByText("Compatibility: state events").click();
|
||||
}
|
||||
// Publish on the full mesh transport (MSC4575) instead of LiveKit
|
||||
await iframe
|
||||
.getByText("Publish on full mesh", { exact: false })
|
||||
.setChecked(fullMesh);
|
||||
await iframe.getByTestId("modal_close").click();
|
||||
}
|
||||
|
||||
|
||||
@@ -66,9 +66,14 @@ media backend: whatever carries the media, behind the `MediaBackend` interface i
|
||||
`sdk/src/media-backend/api.ts`. The client is the join of the two: a member exists
|
||||
once its membership does, and gets its media once the backend has something
|
||||
for that membership on its transport; `state$` is the membership state combined
|
||||
with the local media connection. LiveKit is the one backend today; a
|
||||
cascading SFU or full mesh is another folder under `sdk/src/media-backend/`, handed
|
||||
in through `options.backend`, and nothing in the left box changes.
|
||||
with the local media connection. Two backends exist, LiveKit (`livekit/`) and
|
||||
full mesh (`full-mesh/`, MSC4575), and the client always runs both, combined
|
||||
(`combineBackends.ts`). It publishes on the backend of the type of
|
||||
`options.transport`, LiveKit without one, and hands every remote member to the
|
||||
backend of that member's transport, so members on either are received. A
|
||||
backend nobody is on connects nowhere. Another transport is another folder
|
||||
under `sdk/src/media-backend/`, added to the combination, and nothing in the
|
||||
left box changes; `options.backend` replaces the combination altogether.
|
||||
|
||||
### How Element Call uses it
|
||||
|
||||
@@ -149,7 +154,7 @@ the sections below still describe `MatrixRTCClient` until that lands.
|
||||
| publish request | a `PublishRequest`: a source, where to capture it from and how to encode it. Given at the join in `publish`, or later to `publish` on the participation | |
|
||||
| attach, detach | handing a `<video>` or `<audio>` element to a track and taking it back. The SDK owns the stream and the observers on the element | LiveKit `Track.attach` |
|
||||
| transport | an `RTCTransport`: where media is exchanged, a LiveKit service url today. Carries the raw js-sdk `Transport` from the membership and, in `resolved$`, what the backend fetched to connect to it | MSC4143 "focus" |
|
||||
| media backend | a `MediaBackend`: what carries the media, behind `sdk/src/media-backend/api.ts`. One per participation, chosen with `backend` in the join options; LiveKit under `sdk/src/media-backend/livekit/` is the one that exists | "SFU", a LiveKit room, a peer connection |
|
||||
| media backend | a `MediaBackend`: what carries the media, behind `sdk/src/media-backend/api.ts`. One per participation: LiveKit and full mesh combined, publishing on the type of `transport` in the join options, unless `backend` replaces them | "SFU", a LiveKit room, a peer connection |
|
||||
|
||||
Names use `RTC` in capitals, as the js-sdk does: `RTCSlot`, `RTCParticipation`,
|
||||
`RTCMember`, `matrixRTCMode`.
|
||||
|
||||
@@ -61,6 +61,14 @@ export {
|
||||
createLivekitBackend,
|
||||
type LivekitBackendOptions,
|
||||
} from "./src/media-backend/livekit/LivekitBackend";
|
||||
// Join with `transport: { type: FULL_MESH_TRANSPORT_TYPE }` to publish on full mesh
|
||||
export {
|
||||
createFullMeshBackend,
|
||||
FULL_MESH_TRANSPORT_TYPE,
|
||||
type FullMeshBackendOptions,
|
||||
} from "./src/media-backend/full-mesh/FullMeshBackend";
|
||||
// For a widget host to allow the full mesh signalling
|
||||
export { signallingEventTypes as fullMeshSignallingEventTypes } from "./src/media-backend/full-mesh/signalling";
|
||||
// For a host that creates LiveKit tracks itself, such as a preview before joining
|
||||
export { convertToLivekitProcessor } from "./src/media-backend/livekit/videoProcessor";
|
||||
// For a developer panel to check a transport before using it
|
||||
|
||||
@@ -50,6 +50,11 @@ import {
|
||||
type MediaBackendContext,
|
||||
type MediaKey,
|
||||
} from "./media-backend/api";
|
||||
import { combineBackends } from "./media-backend/combineBackends";
|
||||
import {
|
||||
completeFullMeshTransport,
|
||||
createFullMeshBackend,
|
||||
} from "./media-backend/full-mesh/FullMeshBackend";
|
||||
import { createLivekitBackend } from "./media-backend/livekit/LivekitBackend";
|
||||
import { findRemotePublishLivekitTransport } from "./media-backend/livekit/RemotePublishTransport";
|
||||
import { createHomeserverConnected$ } from "./matrixrtc/HomeserverConnected";
|
||||
@@ -121,6 +126,13 @@ export function createRTCParticipation(
|
||||
: `${userId}:${deviceId}`,
|
||||
};
|
||||
|
||||
// The host names full mesh by its type alone; the device is ours
|
||||
if (options.transport)
|
||||
options = {
|
||||
...options,
|
||||
transport: completeFullMeshTransport(options.transport, deviceId),
|
||||
};
|
||||
|
||||
const backend = createBackend(scope, client, options, {
|
||||
roomId: room.roomId,
|
||||
slotId: jsSdkSession.slotId,
|
||||
@@ -310,15 +322,26 @@ export function createRTCParticipation(
|
||||
};
|
||||
}
|
||||
|
||||
/** The host's backend, or LiveKit configured from the participation options. */
|
||||
/**
|
||||
* The host's backend, or every backend the SDK has, combined: we publish on
|
||||
* the type of the transport the host named (LiveKit without one) and receive
|
||||
* from members on any of them.
|
||||
*/
|
||||
function createBackend(
|
||||
scope: ObservableScope,
|
||||
client: MatrixClient,
|
||||
{ backend, mediaQuality }: RTCParticipationOptions,
|
||||
{ backend, mediaQuality, transport }: RTCParticipationOptions,
|
||||
context: MediaBackendContext,
|
||||
): MediaBackend {
|
||||
if (backend) return backend(scope, context);
|
||||
return createLivekitBackend(scope, context, { client, mediaQuality });
|
||||
return combineBackends(
|
||||
scope,
|
||||
[
|
||||
createLivekitBackend(scope, context, { client, mediaQuality }),
|
||||
createFullMeshBackend(scope, context, { client }),
|
||||
],
|
||||
transport?.type ?? "livekit",
|
||||
);
|
||||
}
|
||||
|
||||
/** The slot's per-participant keys: the ones it already holds on subscribe, then each new one. */
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
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 { describe, expect, it, vi } from "vitest";
|
||||
import { BehaviorSubject, NEVER } from "rxjs";
|
||||
|
||||
import { type RTCMember, type TransportMetadata } from "../api";
|
||||
import { type MemberMedia } from "../media-api";
|
||||
import { constant } from "../reactive/Behavior";
|
||||
import { ObservableScope } from "../reactive/ObservableScope";
|
||||
import { type MediaBackend } from "./api";
|
||||
import { combineBackends } from "./combineBackends";
|
||||
|
||||
function fakeBackend(type: string): MediaBackend & { ended: string[] } {
|
||||
const ended: string[] = [];
|
||||
return {
|
||||
ended,
|
||||
transportType: type,
|
||||
prepareLocalTransport: vi.fn(async () =>
|
||||
Promise.resolve({ canDelegateDelayedLeave: false }),
|
||||
),
|
||||
delegateDelayedLeave: vi.fn(),
|
||||
local: {} as MediaBackend["local"],
|
||||
mediaFor: (scope, member): MemberMedia => {
|
||||
scope.onEnd(() => ended.push(member.memberId));
|
||||
return {
|
||||
tracks$: constant([{ id: `${type}:${member.memberId}` }] as never),
|
||||
encryptionError$: NEVER,
|
||||
};
|
||||
},
|
||||
connections$: constant([
|
||||
{ transport: { type }, state: "connected" as never, resolved: undefined },
|
||||
]),
|
||||
setAudioOutputDeviceId: vi.fn(),
|
||||
sendData: vi.fn(),
|
||||
data$: NEVER,
|
||||
};
|
||||
}
|
||||
|
||||
function transport(type: string): TransportMetadata {
|
||||
return { type, id: type, raw: { type }, resolved$: constant(undefined) };
|
||||
}
|
||||
|
||||
describe("combineBackends", () => {
|
||||
it("routes each member to the backend of its transport type", () => {
|
||||
const scope = new ObservableScope();
|
||||
const livekit = fakeBackend("livekit");
|
||||
const other = fakeBackend("other");
|
||||
const combined = combineBackends(scope, [livekit, other], "livekit");
|
||||
expect(combined.transportType).toBe("livekit");
|
||||
expect(combined.connections$.value).toHaveLength(2);
|
||||
|
||||
const transport$ = new BehaviorSubject<TransportMetadata | undefined>(
|
||||
transport("other"),
|
||||
);
|
||||
const memberScope = new ObservableScope();
|
||||
const media = combined.mediaFor(memberScope, {
|
||||
memberId: "m1",
|
||||
transport$,
|
||||
} as unknown as RTCMember);
|
||||
expect(media.tracks$.value).toEqual([{ id: "other:m1" }]);
|
||||
|
||||
transport$.next(transport("livekit"));
|
||||
expect(other.ended).toEqual(["m1"]);
|
||||
expect(media.tracks$.value).toEqual([{ id: "livekit:m1" }]);
|
||||
|
||||
transport$.next(transport("unknown"));
|
||||
expect(media.tracks$.value).toBeNull();
|
||||
// The publishing backend keeps every member for as long as it is one
|
||||
expect(livekit.ended).toEqual([]);
|
||||
memberScope.end();
|
||||
expect(livekit.ended).toEqual(["m1"]);
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("publishes on the backend of the publish type", () => {
|
||||
const scope = new ObservableScope();
|
||||
const livekit = fakeBackend("livekit");
|
||||
const other = fakeBackend("other");
|
||||
const combined = combineBackends(scope, [livekit, other], "other");
|
||||
expect(combined.transportType).toBe("other");
|
||||
expect(() => combineBackends(scope, [livekit, other], "unknown")).toThrow(
|
||||
"No media backend for transport unknown",
|
||||
);
|
||||
scope.end();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
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 { combineLatest, map, merge, NEVER, of, switchMap } from "rxjs";
|
||||
|
||||
import { type RTCMember } from "../api";
|
||||
import { MatrixRTCError } from "../errors";
|
||||
import { type MemberMedia } from "../media-api";
|
||||
import { type ObservableScope } from "../reactive/ObservableScope";
|
||||
import { mapScoped } from "../utils/mapScoped";
|
||||
import { type MediaBackend } from "./api";
|
||||
|
||||
/**
|
||||
* Several backends as one: we publish on the backend of `publishType`, and
|
||||
* receive from every member on a transport any of the backends serves. A
|
||||
* member is handed to the backend of its current transport type. A backend
|
||||
* nobody is on costs nothing: it connects only for the members handed to it.
|
||||
*/
|
||||
export function combineBackends(
|
||||
scope: ObservableScope,
|
||||
all: MediaBackend[],
|
||||
publishType: string,
|
||||
): MediaBackend {
|
||||
const byType = new Map(all.map((b) => [b.transportType, b]));
|
||||
const publish = byType.get(publishType);
|
||||
if (publish === undefined)
|
||||
throw new MatrixRTCError(`No media backend for transport ${publishType}`);
|
||||
|
||||
const mediaFor = (
|
||||
memberScope: ObservableScope,
|
||||
member: RTCMember,
|
||||
): MemberMedia => {
|
||||
// The publishing backend sees every member, whatever its transport, so
|
||||
// that it can tell members from strangers when a backend is asked for our
|
||||
// media by members on other transports. It connects nowhere for them
|
||||
const publishMedia = publish.mediaFor(memberScope, member);
|
||||
const type$ = memberScope.behavior(
|
||||
member.transport$.pipe(map((transport) => transport?.type)),
|
||||
);
|
||||
const media$ = mapScoped(memberScope, type$, (s, type) =>
|
||||
type === publish.transportType
|
||||
? publishMedia
|
||||
: byType.get(type)?.mediaFor(s, member),
|
||||
);
|
||||
return {
|
||||
tracks$: memberScope.behavior(
|
||||
media$.pipe(switchMap((media) => media?.tracks$ ?? of(null))),
|
||||
),
|
||||
encryptionError$: media$.pipe(
|
||||
switchMap((media) => media?.encryptionError$ ?? NEVER),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
transportType: publish.transportType,
|
||||
prepareLocalTransport: async (transport) =>
|
||||
publish.prepareLocalTransport(transport),
|
||||
delegateDelayedLeave: async (delayId) =>
|
||||
publish.delegateDelayedLeave(delayId),
|
||||
local: publish.local,
|
||||
mediaFor,
|
||||
connections$: scope.behavior(
|
||||
combineLatest(all.map((b) => b.connections$)).pipe(
|
||||
map((lists) => lists.flat()),
|
||||
),
|
||||
),
|
||||
setAudioOutputDeviceId: async (deviceId) => {
|
||||
await Promise.all(
|
||||
all.map(async (b) => b.setAudioOutputDeviceId(deviceId)),
|
||||
);
|
||||
},
|
||||
sendData: async (topic, text) => publish.sendData(topic, text),
|
||||
data$: merge(...all.map((b) => b.data$)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The control channel of a full mesh connection (MSC4575): a negotiated data
|
||||
* channel both sides create, which carries every message of the connection
|
||||
* once it is open, apart from `close`.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ConsumptionMetadata,
|
||||
parseConsumptionMetadata,
|
||||
parseTrackMetadata,
|
||||
type TrackMetadata,
|
||||
} from "./metadata";
|
||||
|
||||
export const CONTROL_CHANNEL_LABEL = "org.matrix.msc4575.rtc.full_mesh.control";
|
||||
const CONTROL_CHANNEL_ID = 0;
|
||||
|
||||
export type ControlMessage =
|
||||
| {
|
||||
type: "description";
|
||||
description: { type: "offer" | "answer"; sdp: string };
|
||||
track_metadata?: TrackMetadata;
|
||||
}
|
||||
| { type: "candidates"; candidates: RTCIceCandidateInit[] }
|
||||
| { type: "track_metadata"; track_metadata: TrackMetadata }
|
||||
| { type: "consumption_metadata"; consumption_metadata: ConsumptionMetadata };
|
||||
|
||||
export function createControlChannel(pc: RTCPeerConnection): RTCDataChannel {
|
||||
return pc.createDataChannel(CONTROL_CHANNEL_LABEL, {
|
||||
negotiated: true,
|
||||
id: CONTROL_CHANNEL_ID,
|
||||
});
|
||||
}
|
||||
|
||||
/** A received control message, or null for one to ignore: malformed, or of a type we don't know. */
|
||||
export function parseControlMessage(data: unknown): ControlMessage | null {
|
||||
if (typeof data !== "string") return null;
|
||||
let m: Record<string, unknown>;
|
||||
try {
|
||||
m = JSON.parse(data) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof m !== "object" || m === null) return null;
|
||||
switch (m.type) {
|
||||
case "description": {
|
||||
const d = m.description as { type?: unknown; sdp?: unknown } | undefined;
|
||||
if (
|
||||
(d?.type !== "offer" && d?.type !== "answer") ||
|
||||
typeof d.sdp !== "string"
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
type: "description",
|
||||
description: { type: d.type, sdp: d.sdp },
|
||||
track_metadata: parseTrackMetadata(m.track_metadata) ?? undefined,
|
||||
};
|
||||
}
|
||||
case "candidates":
|
||||
if (!Array.isArray(m.candidates)) return null;
|
||||
return {
|
||||
type: "candidates",
|
||||
candidates: m.candidates as RTCIceCandidateInit[],
|
||||
};
|
||||
case "track_metadata": {
|
||||
const metadata = parseTrackMetadata(m.track_metadata);
|
||||
return metadata && { type: "track_metadata", track_metadata: metadata };
|
||||
}
|
||||
case "consumption_metadata": {
|
||||
const metadata = parseConsumptionMetadata(m.consumption_metadata);
|
||||
return (
|
||||
metadata && {
|
||||
type: "consumption_metadata",
|
||||
consumption_metadata: metadata,
|
||||
}
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
/*
|
||||
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, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "events";
|
||||
import {
|
||||
ClientEvent,
|
||||
EventType,
|
||||
type ITurnServer,
|
||||
MatrixEvent,
|
||||
} from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { BehaviorSubject, NEVER } from "rxjs";
|
||||
|
||||
import { type RTCMember, type TransportMetadata } from "../../api";
|
||||
import { defaultSessionTimings } from "../../config";
|
||||
import { E2eeType } from "../../encryption";
|
||||
import { constant } from "../../reactive/Behavior";
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import {
|
||||
type DataPacket,
|
||||
type MediaBackend,
|
||||
MediaConnectionState,
|
||||
} from "../api";
|
||||
import {
|
||||
completeFullMeshTransport,
|
||||
createFullMeshBackend,
|
||||
type FullMeshBackendOptions,
|
||||
} from "./FullMeshBackend";
|
||||
import { DATA_CHANNEL_LABEL, DATA_PROTOCOL } from "./metadata";
|
||||
import { FULL_MESH_TRANSPORT_TYPE, SignallingEventType } from "./signalling";
|
||||
import {
|
||||
FakePeerConnection,
|
||||
installWebRtcFakes,
|
||||
lastPeerConnection,
|
||||
peer,
|
||||
settle,
|
||||
} from "./test";
|
||||
|
||||
const roomId = "!room:example.org";
|
||||
const slotId = "m.call#ROOM";
|
||||
const own = { userId: "@me:example.org", deviceId: "MYDEVICE", memberId: "me" };
|
||||
|
||||
const fullMesh = (deviceId: string): Transport => ({
|
||||
type: FULL_MESH_TRANSPORT_TYPE,
|
||||
device_id: deviceId,
|
||||
});
|
||||
const livekit: Transport = { type: "livekit", livekit_service_url: "x" };
|
||||
|
||||
function metadata(raw: Transport): TransportMetadata {
|
||||
return {
|
||||
type: raw.type,
|
||||
id: JSON.stringify(raw),
|
||||
raw,
|
||||
resolved$: constant(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe("createFullMeshBackend", () => {
|
||||
let scope: ObservableScope;
|
||||
let client: EventEmitter & FullMeshBackendOptions["client"];
|
||||
let send: ReturnType<typeof vi.fn>;
|
||||
let turnServers: ITurnServer[];
|
||||
let backend: MediaBackend;
|
||||
const seq = new Map<string, number>();
|
||||
|
||||
/** A to-device message from `peer` (or another sender), as the sync would deliver it. */
|
||||
function deliver(
|
||||
type: string,
|
||||
connectionId: string,
|
||||
content: Record<string, unknown> = {},
|
||||
from = peer,
|
||||
): void {
|
||||
const next = seq.get(from.memberId) ?? 0;
|
||||
seq.set(from.memberId, next + 1);
|
||||
const event = new MatrixEvent({
|
||||
type,
|
||||
sender: from.userId,
|
||||
content: {
|
||||
room_id: roomId,
|
||||
slot_id: slotId,
|
||||
member_id: from.memberId,
|
||||
target_member_id: own.memberId,
|
||||
device_id: from.deviceId,
|
||||
connection_id: connectionId,
|
||||
seq: next,
|
||||
...content,
|
||||
},
|
||||
});
|
||||
vi.spyOn(event, "getWireType").mockReturnValue(
|
||||
EventType.RoomMessageEncrypted,
|
||||
);
|
||||
client.emit(ClientEvent.ToDeviceEvent, event);
|
||||
}
|
||||
|
||||
/** What we sent, as [type, recipient device, content]. */
|
||||
function sent(type?: string): [string, string, Record<string, unknown>][] {
|
||||
return send.mock.calls
|
||||
.filter(([t]) => type === undefined || t === type)
|
||||
.map(([t, [target], content]) => [t, target.deviceId, content]);
|
||||
}
|
||||
|
||||
/** A member of the slot, as the session hands it to the backend. */
|
||||
function addMember(
|
||||
transport: Transport,
|
||||
who = peer,
|
||||
): { scope: ObservableScope; tracks: () => unknown } {
|
||||
const memberScope = new ObservableScope();
|
||||
const media = backend.mediaFor(memberScope, {
|
||||
...who,
|
||||
rtcBackendIdentity: `${who.userId}:${who.deviceId}`,
|
||||
transport$: new BehaviorSubject(metadata(transport)),
|
||||
} as unknown as RTCMember);
|
||||
return { scope: memberScope, tracks: () => media.tracks$.value };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
installWebRtcFakes();
|
||||
seq.clear();
|
||||
scope = new ObservableScope();
|
||||
send = vi.fn(async () => Promise.resolve());
|
||||
turnServers = [];
|
||||
client = Object.assign(new EventEmitter(), {
|
||||
encryptAndSendToDevice: send,
|
||||
getTurnServers: (): ITurnServer[] => turnServers,
|
||||
checkTurnServers: vi.fn(async () => {
|
||||
turnServers = [{ urls: ["turn:x"], username: "u", credential: "p" }];
|
||||
return Promise.resolve(true);
|
||||
}),
|
||||
}) as unknown as EventEmitter & FullMeshBackendOptions["client"];
|
||||
backend = createFullMeshBackend(
|
||||
scope,
|
||||
{
|
||||
roomId,
|
||||
slotId,
|
||||
ownMembershipIdentity: own,
|
||||
publish: [],
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
mediaKeys$: NEVER,
|
||||
timings: defaultSessionTimings,
|
||||
logger,
|
||||
},
|
||||
{ client },
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
scope.end();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("only prepares a full mesh transport", async () => {
|
||||
await expect(backend.prepareLocalTransport(livekit)).rejects.toThrow(
|
||||
"Not a full mesh transport: livekit",
|
||||
);
|
||||
await expect(
|
||||
backend.prepareLocalTransport(fullMesh(own.deviceId)),
|
||||
).resolves.toEqual({ canDelegateDelayedLeave: false });
|
||||
expect(backend.local.connectionState$.value).toBe(
|
||||
MediaConnectionState.Connected,
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches TURN servers when the client has none yet", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
expect(client.checkTurnServers).toHaveBeenCalledOnce();
|
||||
addMember(fullMesh(peer.deviceId));
|
||||
await settle();
|
||||
expect(lastPeerConnection().config.iceServers).toEqual([
|
||||
{ urls: ["turn:x"], username: "u", credential: "p" },
|
||||
]);
|
||||
});
|
||||
|
||||
describe("as a publisher", () => {
|
||||
it("turns subscribers away while it doesn't publish", async () => {
|
||||
addMember(livekit);
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await settle();
|
||||
expect(sent()).toEqual([
|
||||
[
|
||||
SignallingEventType.close,
|
||||
peer.deviceId,
|
||||
expect.objectContaining({
|
||||
reason: "not_publishing",
|
||||
connection_id: "c1",
|
||||
}),
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers to a member on any transport", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
addMember(livekit);
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await settle();
|
||||
expect(sent(SignallingEventType.description)).toEqual([
|
||||
[
|
||||
SignallingEventType.description,
|
||||
peer.deviceId,
|
||||
expect.objectContaining({
|
||||
connection_id: "c1",
|
||||
target_member_id: peer.memberId,
|
||||
description: { type: "offer", sdp: "offer-1" },
|
||||
}),
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("waits for the membership of a subscriber that is ahead of it", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(sent()).toEqual([]);
|
||||
addMember(livekit);
|
||||
await settle();
|
||||
expect(sent(SignallingEventType.description)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects a subscriber that never becomes a member", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(sent()).toEqual([
|
||||
[
|
||||
SignallingEventType.close,
|
||||
peer.deviceId,
|
||||
expect.objectContaining({ reason: "not_a_member" }),
|
||||
],
|
||||
]);
|
||||
expect(FakePeerConnection.instances).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a subscriber claiming another device of the member", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
addMember(livekit);
|
||||
deliver(
|
||||
SignallingEventType.subscribe,
|
||||
"c1",
|
||||
{},
|
||||
{
|
||||
...peer,
|
||||
deviceId: "OTHERDEVICE",
|
||||
},
|
||||
);
|
||||
await settle();
|
||||
expect(sent()).toEqual([
|
||||
[
|
||||
SignallingEventType.close,
|
||||
"OTHERDEVICE",
|
||||
expect.objectContaining({ reason: "not_a_member" }),
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("only serves the newest of several waiting subscribe requests", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
deliver(SignallingEventType.subscribe, "c2");
|
||||
addMember(livekit);
|
||||
await settle();
|
||||
expect(
|
||||
sent(SignallingEventType.description).map(([, , c]) => c.connection_id),
|
||||
).toEqual(["c2"]);
|
||||
});
|
||||
|
||||
it("replaces the connection of a member that subscribes again", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
addMember(livekit);
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await settle();
|
||||
const first = lastPeerConnection();
|
||||
deliver(SignallingEventType.subscribe, "c2");
|
||||
await settle();
|
||||
expect(first.closed).toBe(true);
|
||||
expect(lastPeerConnection()).not.toBe(first);
|
||||
});
|
||||
|
||||
it("hands the subscriber's messages to its connection", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
addMember(livekit);
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await settle();
|
||||
deliver(SignallingEventType.description, "c1", {
|
||||
description: { type: "answer", sdp: "answer" },
|
||||
});
|
||||
deliver(SignallingEventType.candidates, "c1", {
|
||||
candidates: [{ candidate: "x" }],
|
||||
});
|
||||
// A late message of an earlier connection goes nowhere
|
||||
deliver(SignallingEventType.candidates, "c0", {
|
||||
candidates: [{ candidate: "stale" }],
|
||||
});
|
||||
await settle();
|
||||
const pc = lastPeerConnection();
|
||||
expect(pc.remoteDescription).toEqual({ type: "answer", sdp: "answer" });
|
||||
expect(pc.addedCandidates).toEqual([{ candidate: "x" }]);
|
||||
});
|
||||
|
||||
it("stops sending to a member that leaves", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
const member = addMember(livekit);
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await settle();
|
||||
member.scope.end();
|
||||
expect(lastPeerConnection().closed).toBe(true);
|
||||
expect(sent(SignallingEventType.close)).toEqual([
|
||||
[
|
||||
SignallingEventType.close,
|
||||
peer.deviceId,
|
||||
expect.objectContaining({ connection_id: "c1" }),
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists its subscribers for the developer tools", async () => {
|
||||
expect(backend.connections$.value).toEqual([]);
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
expect(backend.connections$.value).toEqual([
|
||||
{
|
||||
transport: fullMesh(own.deviceId),
|
||||
state: MediaConnectionState.Connected,
|
||||
resolved: {
|
||||
type: FULL_MESH_TRANSPORT_TYPE,
|
||||
role: "publisher",
|
||||
peerConnections: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
addMember(livekit);
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await settle();
|
||||
expect(backend.connections$.value[0].resolved).toMatchObject({
|
||||
peerConnections: [
|
||||
{
|
||||
memberId: peer.memberId,
|
||||
connectionId: "c1",
|
||||
connectionState: "new",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("as a subscriber", () => {
|
||||
it("subscribes to a member publishing on full mesh", async () => {
|
||||
const member = addMember(fullMesh(peer.deviceId));
|
||||
await settle();
|
||||
expect(sent()).toEqual([
|
||||
[
|
||||
SignallingEventType.subscribe,
|
||||
peer.deviceId,
|
||||
expect.objectContaining({
|
||||
room_id: roomId,
|
||||
slot_id: slotId,
|
||||
member_id: own.memberId,
|
||||
device_id: own.deviceId,
|
||||
target_member_id: peer.memberId,
|
||||
seq: 0,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
expect(member.tracks()).toBeNull();
|
||||
expect(backend.connections$.value).toEqual([
|
||||
expect.objectContaining({
|
||||
transport: fullMesh(peer.deviceId),
|
||||
resolved: expect.objectContaining({ role: "subscriber" }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores a full mesh transport advertising another device", async () => {
|
||||
addMember(fullMesh("OTHERDEVICE"));
|
||||
await settle();
|
||||
expect(sent()).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves members on other transports alone", async () => {
|
||||
const member = addMember(livekit);
|
||||
await settle();
|
||||
expect(sent()).toEqual([]);
|
||||
expect(member.tracks()).toBeNull();
|
||||
});
|
||||
|
||||
it("hands the publisher's messages to the subscription", async () => {
|
||||
addMember(fullMesh(peer.deviceId));
|
||||
await settle();
|
||||
const [[, , subscribe]] = sent();
|
||||
lastPeerConnection().receive("video", "1");
|
||||
deliver(
|
||||
SignallingEventType.description,
|
||||
subscribe.connection_id as string,
|
||||
{
|
||||
description: { type: "offer", sdp: "offer" },
|
||||
track_metadata: { "1": { purpose: "m.usermedia" } },
|
||||
},
|
||||
);
|
||||
await settle();
|
||||
expect(sent(SignallingEventType.description)).toEqual([
|
||||
[
|
||||
SignallingEventType.description,
|
||||
peer.deviceId,
|
||||
expect.objectContaining({
|
||||
description: { type: "answer", sdp: "answer" },
|
||||
}),
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("closes the subscription when the member leaves", async () => {
|
||||
const member = addMember(fullMesh(peer.deviceId));
|
||||
await settle();
|
||||
member.scope.end();
|
||||
expect(lastPeerConnection().closed).toBe(true);
|
||||
expect(backend.connections$.value).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("doesn't take over the delayed leave", async () => {
|
||||
await expect(backend.delegateDelayedLeave("id")).rejects.toThrow();
|
||||
});
|
||||
|
||||
describe("data", () => {
|
||||
it("can't be sent while not publishing", async () => {
|
||||
await expect(backend.sendData("topic", "text")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("is sent to every subscriber on its data channel", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
addMember(livekit);
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await settle();
|
||||
const data = lastPeerConnection().dataChannels.find(
|
||||
(c) => c.label === DATA_CHANNEL_LABEL,
|
||||
)!;
|
||||
data.open();
|
||||
await backend.sendData("greeting", "hello");
|
||||
expect(data.sent.map((m) => JSON.parse(m) as unknown)).toEqual([
|
||||
{ topic: "greeting", text: "hello" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("is received from a publisher, named by its member", async () => {
|
||||
const received: DataPacket[] = [];
|
||||
backend.data$.subscribe((p) => received.push(p));
|
||||
addMember(fullMesh(peer.deviceId));
|
||||
await settle();
|
||||
const [[, , subscribe]] = sent();
|
||||
deliver(
|
||||
SignallingEventType.description,
|
||||
subscribe.connection_id as string,
|
||||
{
|
||||
description: { type: "offer", sdp: "offer" },
|
||||
track_metadata: {},
|
||||
},
|
||||
);
|
||||
await settle();
|
||||
const channel = lastPeerConnection().announceDataChannel(
|
||||
DATA_CHANNEL_LABEL,
|
||||
DATA_PROTOCOL,
|
||||
);
|
||||
channel.receive({ topic: "greeting", text: "hello" });
|
||||
channel.receive({ topic: 1 });
|
||||
expect(received).toEqual([
|
||||
{
|
||||
senderId: `${peer.userId}:${peer.deviceId}`,
|
||||
topic: "greeting",
|
||||
text: "hello",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeFullMeshTransport", () => {
|
||||
it("adds our device to a full mesh transport only", () => {
|
||||
expect(
|
||||
completeFullMeshTransport({ type: FULL_MESH_TRANSPORT_TYPE }, "DEV"),
|
||||
).toEqual({ type: FULL_MESH_TRANSPORT_TYPE, device_id: "DEV" });
|
||||
expect(completeFullMeshTransport(livekit, "DEV")).toBe(livekit);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,424 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The full mesh transport (MSC4575): one WebRTC connection per subscriber
|
||||
* and publisher, signalled over to-device messages, through the TURN server
|
||||
* of each side's homeserver. Works subscribe-only too: without
|
||||
* `prepareLocalTransport` it publishes nothing and still receives from every
|
||||
* member on full mesh.
|
||||
*/
|
||||
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
NEVER,
|
||||
type Observable,
|
||||
of,
|
||||
share,
|
||||
Subject,
|
||||
switchMap,
|
||||
timeout,
|
||||
} from "rxjs";
|
||||
|
||||
import { type RTCMember } from "../../api";
|
||||
import { MatrixRTCError } from "../../errors";
|
||||
import { type MemberMedia } from "../../media-api";
|
||||
import { type Behavior } from "../../reactive/Behavior";
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { mapScoped } from "../../utils/mapScoped";
|
||||
import {
|
||||
type BackendConnection,
|
||||
type DataPacket,
|
||||
type MediaBackend,
|
||||
type MediaBackendContext,
|
||||
MediaConnectionState,
|
||||
} from "../api";
|
||||
import { createFullMeshLocalMedia } from "./LocalMedia";
|
||||
import { type PeerConnectionBase } from "./PeerConnection";
|
||||
import {
|
||||
createPublisherConnection,
|
||||
type PublisherConnection,
|
||||
} from "./PublisherConnection";
|
||||
import {
|
||||
createSignalling,
|
||||
FULL_MESH_TRANSPORT_TYPE,
|
||||
type Peer,
|
||||
type SignallingClient,
|
||||
type SignallingMessage,
|
||||
} from "./signalling";
|
||||
import {
|
||||
createSubscriberConnection,
|
||||
type SubscriberConnection,
|
||||
} from "./SubscriberConnection";
|
||||
|
||||
export { FULL_MESH_TRANSPORT_TYPE } from "./signalling";
|
||||
|
||||
/** How long a subscribe request waits for the subscriber's membership to arrive. */
|
||||
const SUBSCRIBE_GRACE_MS = 10_000;
|
||||
|
||||
export interface FullMeshBackendOptions {
|
||||
client: SignallingClient &
|
||||
Pick<MatrixClient, "getTurnServers" | "checkTurnServers">;
|
||||
}
|
||||
|
||||
/**
|
||||
* The transport object a member publishing on full mesh advertises: a host
|
||||
* passes `{ type: FULL_MESH_TRANSPORT_TYPE }`, and the device is filled in.
|
||||
* Any other transport is returned as it is.
|
||||
*/
|
||||
export function completeFullMeshTransport(
|
||||
transport: Transport,
|
||||
deviceId: string,
|
||||
): Transport {
|
||||
return transport.type === FULL_MESH_TRANSPORT_TYPE
|
||||
? { ...transport, device_id: deviceId }
|
||||
: transport;
|
||||
}
|
||||
|
||||
export function createFullMeshBackend(
|
||||
scope: ObservableScope,
|
||||
{
|
||||
roomId,
|
||||
slotId,
|
||||
ownMembershipIdentity,
|
||||
publish,
|
||||
logger: parentLogger,
|
||||
}: MediaBackendContext,
|
||||
{ client }: FullMeshBackendOptions,
|
||||
): MediaBackend {
|
||||
const logger = parentLogger.getChild("[FullMeshBackend]");
|
||||
const signalling = createSignalling({
|
||||
scope,
|
||||
client,
|
||||
roomId,
|
||||
slotId,
|
||||
own: ownMembershipIdentity,
|
||||
logger,
|
||||
});
|
||||
|
||||
const iceServers = async (): Promise<RTCIceServer[]> => {
|
||||
if (client.getTurnServers().length === 0)
|
||||
await client
|
||||
.checkTurnServers()
|
||||
.catch((e: unknown) => logger.warn("No TURN servers", e));
|
||||
return client.getTurnServers().map(({ urls, username, credential }) => ({
|
||||
urls,
|
||||
username,
|
||||
credential,
|
||||
}));
|
||||
};
|
||||
|
||||
let localTransport: Transport | null = null;
|
||||
const prepared$ = new BehaviorSubject(false);
|
||||
const audioOutputDeviceId$ = new BehaviorSubject<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const data$ = new Subject<DataPacket>();
|
||||
scope.onEnd(() => data$.complete());
|
||||
|
||||
const members = new Map<string, RTCMember>();
|
||||
const memberAdded$ = new Subject<RTCMember>();
|
||||
const subscribers = new Map<
|
||||
string,
|
||||
{ transport: Transport; peer: Peer; connection: SubscriberConnection }
|
||||
>();
|
||||
const publishers = new Map<
|
||||
string,
|
||||
{ connection: PublisherConnection; peer: Peer; scope: ObservableScope }
|
||||
>();
|
||||
scope.onEnd(() => {
|
||||
for (const { scope: s } of Array.from(publishers.values())) s.end();
|
||||
});
|
||||
/** The connection id of the newest subscribe request per member. */
|
||||
const latestSubscribe = new Map<string, string>();
|
||||
|
||||
// For the developer tools: one entry per publisher we subscribe to, and one
|
||||
// for our own transport with every subscriber we send to. `resolved` lists
|
||||
// the peer connections, as an SFU's entry lists what it connected to
|
||||
const connectionsChanged$ = new BehaviorSubject<void>(undefined);
|
||||
const connections$ = scope.behavior<BackendConnection[]>(
|
||||
combineLatest([connectionsChanged$, prepared$]).pipe(
|
||||
switchMap(([, prepared]) => {
|
||||
const entries: Observable<BackendConnection>[] = [];
|
||||
for (const { transport, peer, connection } of subscribers.values()) {
|
||||
const base = connection.current$.value;
|
||||
if (base)
|
||||
entries.push(
|
||||
base.state$.pipe(
|
||||
map((state) => ({
|
||||
transport,
|
||||
state,
|
||||
resolved: {
|
||||
type: FULL_MESH_TRANSPORT_TYPE,
|
||||
role: "subscriber",
|
||||
peerConnections: [describe(peer, base)],
|
||||
},
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (localTransport && prepared) {
|
||||
const transport = localTransport;
|
||||
const outgoing = [...publishers.values()];
|
||||
entries.push(
|
||||
(outgoing.length === 0
|
||||
? of([])
|
||||
: combineLatest(outgoing.map((p) => p.connection.state$))
|
||||
).pipe(
|
||||
map(() => ({
|
||||
transport,
|
||||
// Publishing needs no server, so it is up once prepared
|
||||
state: MediaConnectionState.Connected,
|
||||
resolved: {
|
||||
type: FULL_MESH_TRANSPORT_TYPE,
|
||||
role: "publisher",
|
||||
peerConnections: outgoing.map(({ peer, connection }) =>
|
||||
describe(peer, connection),
|
||||
),
|
||||
},
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
return entries.length === 0 ? of([]) : combineLatest(entries);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const local = createFullMeshLocalMedia({
|
||||
scope,
|
||||
publish,
|
||||
connectionState$: scope.behavior(
|
||||
prepared$.pipe(
|
||||
map((prepared) =>
|
||||
prepared
|
||||
? MediaConnectionState.Connected
|
||||
: MediaConnectionState.Initialized,
|
||||
),
|
||||
),
|
||||
),
|
||||
peerConnections: () =>
|
||||
[...publishers.values()].map(({ connection }) => connection.pc),
|
||||
logger,
|
||||
});
|
||||
|
||||
const acceptSubscribe = async (message: SignallingMessage): Promise<void> => {
|
||||
const peer: Peer = {
|
||||
userId: message.userId,
|
||||
deviceId: message.deviceId,
|
||||
memberId: message.memberId,
|
||||
};
|
||||
// We don't advertise full mesh, so nobody should have asked
|
||||
if (!prepared$.value) {
|
||||
signalling.send(peer, message.connectionId, {
|
||||
type: "close",
|
||||
reason: "not_publishing",
|
||||
});
|
||||
return;
|
||||
}
|
||||
latestSubscribe.set(peer.memberId, message.connectionId);
|
||||
// The request may overtake the subscriber's membership
|
||||
const member =
|
||||
members.get(peer.memberId) ??
|
||||
(await firstValueFrom(
|
||||
memberAdded$.pipe(
|
||||
filter((m) => m.memberId === peer.memberId),
|
||||
timeout(SUBSCRIBE_GRACE_MS),
|
||||
),
|
||||
).catch(() => undefined));
|
||||
if (latestSubscribe.get(peer.memberId) !== message.connectionId) return;
|
||||
if (
|
||||
member === undefined ||
|
||||
member.userId !== peer.userId ||
|
||||
member.deviceId !== peer.deviceId
|
||||
) {
|
||||
logger.warn(`Rejecting a subscribe from ${peer.memberId}`);
|
||||
signalling.send(peer, message.connectionId, {
|
||||
type: "close",
|
||||
reason: "not_a_member",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// At most one connection per member pair: the new one replaces the old
|
||||
publishers.get(peer.memberId)?.scope.end();
|
||||
const pubScope = new ObservableScope();
|
||||
const connection = createPublisherConnection({
|
||||
scope: pubScope,
|
||||
peer,
|
||||
connectionId: message.connectionId,
|
||||
signalling,
|
||||
iceServers: await iceServers(),
|
||||
local,
|
||||
logger,
|
||||
});
|
||||
publishers.set(peer.memberId, { connection, peer, scope: pubScope });
|
||||
pubScope.onEnd(() => {
|
||||
if (publishers.get(peer.memberId)?.connection === connection) {
|
||||
publishers.delete(peer.memberId);
|
||||
connectionsChanged$.next();
|
||||
}
|
||||
});
|
||||
connectionsChanged$.next();
|
||||
};
|
||||
|
||||
signalling.messages$.pipe(scope.bind()).subscribe((message) => {
|
||||
if (message.type === "subscribe") return void acceptSubscribe(message);
|
||||
const subscriber = subscribers.get(message.memberId)?.connection;
|
||||
if (subscriber?.current$.value?.connectionId === message.connectionId)
|
||||
return subscriber.handle(message);
|
||||
const publisher = publishers.get(message.memberId)?.connection;
|
||||
if (publisher?.connectionId === message.connectionId)
|
||||
return publisher.handle(message);
|
||||
logger.debug(`Dropping ${message.type} of a stale connection`);
|
||||
});
|
||||
|
||||
const mediaFor = (
|
||||
memberScope: ObservableScope,
|
||||
member: RTCMember,
|
||||
): MemberMedia => {
|
||||
members.set(member.memberId, member);
|
||||
memberAdded$.next(member);
|
||||
memberScope.onEnd(() => {
|
||||
members.delete(member.memberId);
|
||||
// We stop sending to members that are no longer joined
|
||||
publishers.get(member.memberId)?.scope.end();
|
||||
});
|
||||
|
||||
// In encrypted rooms the advertised device has to be the one the
|
||||
// membership came from
|
||||
const fullMesh$ = memberScope.behavior(
|
||||
member.transport$.pipe(
|
||||
map((transport) =>
|
||||
transport?.type === FULL_MESH_TRANSPORT_TYPE &&
|
||||
transport.raw.device_id === member.deviceId
|
||||
? transport.raw
|
||||
: undefined,
|
||||
),
|
||||
distinctUntilChanged((a, b) => a?.device_id === b?.device_id),
|
||||
),
|
||||
);
|
||||
const subscriber$ = mapScoped(memberScope, fullMesh$, (s, transport) => {
|
||||
const connection = createSubscriberConnection({
|
||||
scope: s,
|
||||
peer: {
|
||||
userId: member.userId,
|
||||
deviceId: member.deviceId,
|
||||
memberId: member.memberId,
|
||||
},
|
||||
signalling,
|
||||
iceServers,
|
||||
audioOutputDeviceId$,
|
||||
logger,
|
||||
});
|
||||
subscribers.set(member.memberId, {
|
||||
transport,
|
||||
peer: {
|
||||
userId: member.userId,
|
||||
deviceId: member.deviceId,
|
||||
memberId: member.memberId,
|
||||
},
|
||||
connection,
|
||||
});
|
||||
connection.current$
|
||||
.pipe(s.bind())
|
||||
.subscribe(() => connectionsChanged$.next());
|
||||
connection.data$.pipe(s.bind()).subscribe((message) => {
|
||||
const packet = parseDataMessage(message);
|
||||
if (packet === null)
|
||||
logger.debug(`Ignoring a data message from ${member.memberId}`);
|
||||
else data$.next({ senderId: member.rtcBackendIdentity, ...packet });
|
||||
});
|
||||
s.onEnd(() => {
|
||||
subscribers.delete(member.memberId);
|
||||
connectionsChanged$.next();
|
||||
});
|
||||
return connection;
|
||||
});
|
||||
|
||||
return {
|
||||
tracks$: memberScope.behavior(
|
||||
subscriber$.pipe(switchMap((s) => s?.tracks$ ?? of(null))),
|
||||
),
|
||||
encryptionError$: NEVER,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
transportType: FULL_MESH_TRANSPORT_TYPE,
|
||||
prepareLocalTransport: async (transport) => {
|
||||
if (transport.type !== FULL_MESH_TRANSPORT_TYPE)
|
||||
throw new MatrixRTCError(
|
||||
`Not a full mesh transport: ${transport.type}`,
|
||||
);
|
||||
localTransport = transport;
|
||||
// Fetched now, so that the first subscriber isn't kept waiting
|
||||
await iceServers();
|
||||
prepared$.next(true);
|
||||
return { canDelegateDelayedLeave: false };
|
||||
},
|
||||
delegateDelayedLeave: async () =>
|
||||
Promise.reject(
|
||||
new MatrixRTCError("Full mesh cannot take over the delayed leave"),
|
||||
),
|
||||
local,
|
||||
mediaFor,
|
||||
connections$: connections$ as Behavior<BackendConnection[]>,
|
||||
setAudioOutputDeviceId: async (deviceId) => {
|
||||
audioOutputDeviceId$.next(deviceId);
|
||||
return Promise.resolve();
|
||||
},
|
||||
sendData: async (topic, text) => {
|
||||
if (!prepared$.value)
|
||||
throw new MatrixRTCError(
|
||||
"Cannot send data: not publishing on full mesh",
|
||||
);
|
||||
const message = JSON.stringify({ topic, text });
|
||||
for (const { connection } of publishers.values())
|
||||
connection.sendData(message);
|
||||
return Promise.resolve();
|
||||
},
|
||||
data$: data$.pipe(share()),
|
||||
};
|
||||
}
|
||||
|
||||
/** A message of the `sendData` data channel, or null for a malformed one. */
|
||||
function parseDataMessage(
|
||||
message: string,
|
||||
): Pick<DataPacket, "topic" | "text"> | null {
|
||||
try {
|
||||
const { topic, text } = JSON.parse(message) as Record<string, unknown>;
|
||||
return typeof topic === "string" && typeof text === "string"
|
||||
? { topic, text }
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** One peer connection as the developer tools show it. */
|
||||
function describe(
|
||||
{ userId, deviceId, memberId }: Peer,
|
||||
{ connectionId, pc }: PeerConnectionBase,
|
||||
): Record<string, string> {
|
||||
return {
|
||||
userId,
|
||||
deviceId,
|
||||
memberId,
|
||||
connectionId,
|
||||
connectionState: pc.connectionState,
|
||||
iceConnectionState: pc.iceConnectionState,
|
||||
signalingState: pc.signalingState,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
/*
|
||||
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 Logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
NEVER,
|
||||
} from "rxjs";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { MatrixRTCError, toMatrixRTCError } from "../../errors";
|
||||
import {
|
||||
type LocalAudioMediaTrack,
|
||||
type LocalVideoMediaTrack,
|
||||
type MediaSource,
|
||||
type PublishRequest,
|
||||
type VideoProcessor,
|
||||
} from "../../media-api";
|
||||
import { type Behavior } from "../../reactive/Behavior";
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { type LocalMediaBackend, type MediaConnectionState } from "../api";
|
||||
import { createFullMeshMediaTrack, rtpStats } from "./MediaTrack";
|
||||
|
||||
type PublishedSource = Exclude<MediaSource, "unknown">;
|
||||
|
||||
/** One local track as the publisher connections send it. */
|
||||
export interface LocalPublication {
|
||||
source: PublishedSource;
|
||||
kind: "audio" | "video";
|
||||
/**
|
||||
* The `MediaStream` it is sent in: one for the user media, one for the
|
||||
* screen share, so that the subscriber plays each in sync.
|
||||
*/
|
||||
stream: MediaStream;
|
||||
/** What goes on the wire; null while the source is off. */
|
||||
track$: Behavior<MediaStreamTrack | null>;
|
||||
mediaTrack: LocalAudioMediaTrack | LocalVideoMediaTrack;
|
||||
}
|
||||
|
||||
export interface FullMeshLocalMedia extends LocalMediaBackend {
|
||||
/** Every local track, once the first `setPublishing(true)` started the capture. */
|
||||
publications$: Behavior<LocalPublication[]>;
|
||||
/** Whether the tracks reach the publisher connections. */
|
||||
publishing$: Behavior<boolean>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
publish: PublishRequest[];
|
||||
connectionState$: Behavior<MediaConnectionState | Error>;
|
||||
/** The publisher connections, for the outbound statistics of a track. */
|
||||
peerConnections: () => RTCPeerConnection[];
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
interface Capture {
|
||||
/** What was captured, before any processor. */
|
||||
raw: MediaStreamTrack;
|
||||
out: MediaStreamTrack;
|
||||
}
|
||||
|
||||
/** The local member's side: capture, the publication list and the controls. */
|
||||
export function createFullMeshLocalMedia({
|
||||
scope,
|
||||
publish: initialRequests,
|
||||
connectionState$,
|
||||
peerConnections,
|
||||
logger: parentLogger,
|
||||
}: Props): FullMeshLocalMedia {
|
||||
const logger = parentLogger.getChild("[LocalMedia]");
|
||||
// Only their ids matter: the tracks are added per connection
|
||||
const userMediaStream = new MediaStream();
|
||||
const screenShareStream = new MediaStream();
|
||||
const desired = new Map<PublishRequest["source"], PublishRequest>(
|
||||
initialRequests.map((r) => [r.source, r]),
|
||||
);
|
||||
const started$ = new BehaviorSubject(false);
|
||||
const publishing$ = new BehaviorSubject(false);
|
||||
const publishError$ = new BehaviorSubject<Error | null>(null);
|
||||
const publications$ = new BehaviorSubject<LocalPublication[]>([]);
|
||||
const teardowns = new Map<PublishedSource, () => void>();
|
||||
|
||||
const statsFor = async (track: MediaStreamTrack | null) => {
|
||||
for (const pc of peerConnections())
|
||||
if (pc.getSenders().some((s) => s.track === track && track !== null))
|
||||
return rtpStats(pc, track, "outbound-rtp");
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Adds a publication whose track is managed by `control`. */
|
||||
const addPublication = (
|
||||
source: PublishedSource,
|
||||
kind: "audio" | "video",
|
||||
stream: MediaStream,
|
||||
initial: MediaStreamTrack,
|
||||
control: {
|
||||
setEnabled: (
|
||||
enabled: boolean,
|
||||
track$: BehaviorSubject<MediaStreamTrack | null>,
|
||||
) => Promise<boolean>;
|
||||
setDevice?: (
|
||||
deviceId: string,
|
||||
track$: BehaviorSubject<MediaStreamTrack | null>,
|
||||
) => Promise<void>;
|
||||
switchFacingMode?: (
|
||||
track$: BehaviorSubject<MediaStreamTrack | null>,
|
||||
) => Promise<string | undefined>;
|
||||
setProcessor?: (
|
||||
p: VideoProcessor | undefined,
|
||||
track$: BehaviorSubject<MediaStreamTrack | null>,
|
||||
) => Promise<void>;
|
||||
stop: () => void;
|
||||
},
|
||||
): LocalPublication => {
|
||||
const pubScope = new ObservableScope();
|
||||
const track$ = new BehaviorSubject<MediaStreamTrack | null>(initial);
|
||||
const enabled$ = new BehaviorSubject(true);
|
||||
const base = createFullMeshMediaTrack(pubScope, {
|
||||
source,
|
||||
kind,
|
||||
id: uuidv4(),
|
||||
track$,
|
||||
muted$: pubScope.behavior(enabled$.pipe(map((e) => !e))),
|
||||
stats: async () => statsFor(track$.value),
|
||||
});
|
||||
const local = {
|
||||
setEnabled: async (enabled: boolean): Promise<boolean> => {
|
||||
const result = await control.setEnabled(enabled, track$);
|
||||
enabled$.next(result);
|
||||
return result;
|
||||
},
|
||||
setDevice: async (deviceId: string): Promise<void> => {
|
||||
if (!control.setDevice)
|
||||
throw new MatrixRTCError(`A ${source} track has no device`);
|
||||
await control.setDevice(deviceId, track$);
|
||||
},
|
||||
};
|
||||
const mediaTrack =
|
||||
base.kind === "audio"
|
||||
? { ...base, ...local }
|
||||
: {
|
||||
...base,
|
||||
...local,
|
||||
facingMode$: pubScope.behavior(
|
||||
track$.pipe(map((t) => facingModeOf(t))),
|
||||
),
|
||||
switchFacingMode: async () => control.switchFacingMode?.(track$),
|
||||
setProcessor: async (p: VideoProcessor | undefined) =>
|
||||
control.setProcessor?.(p, track$),
|
||||
};
|
||||
const publication: LocalPublication = {
|
||||
source,
|
||||
kind,
|
||||
stream,
|
||||
track$,
|
||||
mediaTrack: mediaTrack as LocalAudioMediaTrack | LocalVideoMediaTrack,
|
||||
};
|
||||
publications$.next([...publications$.value, publication]);
|
||||
teardowns.set(source, () => {
|
||||
control.stop();
|
||||
track$.value?.stop();
|
||||
pubScope.end();
|
||||
publications$.next(publications$.value.filter((p) => p !== publication));
|
||||
teardowns.delete(source);
|
||||
});
|
||||
return publication;
|
||||
};
|
||||
|
||||
const captureMicrophone = async (
|
||||
request: Extract<PublishRequest, { source: "microphone" }>,
|
||||
): Promise<void> => {
|
||||
const track = await getTrack({
|
||||
audio: { deviceId: exact(request.deviceId), ...request.capture },
|
||||
});
|
||||
addPublication("microphone", "audio", userMediaStream, track, {
|
||||
// Muting a microphone keeps the device: unmuting is instant
|
||||
setEnabled: async (enabled, track$) => {
|
||||
if (track$.value) track$.value.enabled = enabled;
|
||||
return Promise.resolve(enabled);
|
||||
},
|
||||
setDevice: async (deviceId, track$) => {
|
||||
const next = await getTrack({
|
||||
audio: { deviceId: { exact: deviceId }, ...request.capture },
|
||||
});
|
||||
next.enabled = track$.value?.enabled ?? true;
|
||||
track$.value?.stop();
|
||||
track$.next(next);
|
||||
},
|
||||
stop: () => {},
|
||||
});
|
||||
};
|
||||
|
||||
const captureCamera = async (
|
||||
request: Extract<PublishRequest, { source: "camera" }>,
|
||||
): Promise<void> => {
|
||||
let deviceId = request.deviceId;
|
||||
let facingMode: string | undefined;
|
||||
let processor = request.processor;
|
||||
let capture: Capture | null = null;
|
||||
let processorStarted = false;
|
||||
const constraints = (): MediaTrackConstraints => ({
|
||||
deviceId: exact(deviceId),
|
||||
facingMode,
|
||||
width: request.capture?.resolution?.width,
|
||||
height: request.capture?.resolution?.height,
|
||||
frameRate: request.capture?.resolution?.frameRate,
|
||||
});
|
||||
const open = async (): Promise<Capture> => {
|
||||
const raw = await getTrack({ video: constraints() });
|
||||
if (processor === undefined) return { raw, out: raw };
|
||||
const out = processorStarted
|
||||
? await processor.restart(raw)
|
||||
: await processor.init(raw);
|
||||
processorStarted = true;
|
||||
return { raw, out };
|
||||
};
|
||||
const close = (): void => {
|
||||
capture?.raw.stop();
|
||||
if (capture?.out !== capture?.raw) capture?.out.stop();
|
||||
capture = null;
|
||||
};
|
||||
// Reopens the camera with the current settings, if it is on
|
||||
const reopen = async (
|
||||
track$: BehaviorSubject<MediaStreamTrack | null>,
|
||||
): Promise<void> => {
|
||||
if (capture === null) return;
|
||||
close();
|
||||
capture = await open();
|
||||
track$.next(capture.out);
|
||||
};
|
||||
capture = await open();
|
||||
addPublication("camera", "video", userMediaStream, capture.out, {
|
||||
// Muting a camera releases it, so that its light goes off
|
||||
setEnabled: async (enabled, track$) => {
|
||||
if (enabled === (capture !== null)) return enabled;
|
||||
if (!enabled) {
|
||||
close();
|
||||
track$.next(null);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
capture = await open();
|
||||
track$.next(capture.out);
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.warn("Could not reopen the camera", e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
setDevice: async (id, track$) => {
|
||||
deviceId = id;
|
||||
facingMode = undefined;
|
||||
await reopen(track$);
|
||||
},
|
||||
switchFacingMode: async (track$) => {
|
||||
const current = facingModeOf(track$.value);
|
||||
if (current === undefined) return undefined;
|
||||
deviceId = undefined;
|
||||
facingMode = current === "user" ? "environment" : "user";
|
||||
await reopen(track$);
|
||||
return capture?.raw.getSettings().deviceId;
|
||||
},
|
||||
setProcessor: async (p, track$) => {
|
||||
await processor?.destroy();
|
||||
processor = p;
|
||||
processorStarted = false;
|
||||
if (capture === null) return;
|
||||
const out = p ? await p.init(capture.raw) : capture.raw;
|
||||
processorStarted = p !== undefined;
|
||||
if (capture.out !== capture.raw) capture.out.stop();
|
||||
capture = { raw: capture.raw, out };
|
||||
track$.next(out);
|
||||
},
|
||||
stop: () => {
|
||||
close();
|
||||
void processor?.destroy();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const captureScreen = async (
|
||||
request: Extract<PublishRequest, { source: "screenShare" }>,
|
||||
): Promise<void> => {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: {
|
||||
width: request.capture?.resolution?.width,
|
||||
height: request.capture?.resolution?.height,
|
||||
frameRate: request.capture?.resolution?.frameRate,
|
||||
},
|
||||
audio: request.audio ?? true,
|
||||
});
|
||||
const [video] = stream.getVideoTracks();
|
||||
const [audio] = stream.getAudioTracks();
|
||||
const toggle = async (
|
||||
enabled: boolean,
|
||||
track$: BehaviorSubject<MediaStreamTrack | null>,
|
||||
): Promise<boolean> => {
|
||||
if (track$.value) track$.value.enabled = enabled;
|
||||
return Promise.resolve(enabled);
|
||||
};
|
||||
addPublication("screenShare", "video", screenShareStream, video, {
|
||||
setEnabled: toggle,
|
||||
// The audio goes with its video
|
||||
stop: () => {
|
||||
audio?.stop();
|
||||
teardowns.get("screenShareAudio")?.();
|
||||
},
|
||||
});
|
||||
if (audio)
|
||||
addPublication("screenShareAudio", "audio", screenShareStream, audio, {
|
||||
setEnabled: toggle,
|
||||
stop: () => {},
|
||||
});
|
||||
// Stopped from the browser's own controls
|
||||
video.addEventListener("ended", () => void unpublish("screenShare"));
|
||||
};
|
||||
|
||||
const capture = async (request: PublishRequest): Promise<void> => {
|
||||
teardowns.get(request.source)?.();
|
||||
switch (request.source) {
|
||||
case "microphone":
|
||||
return captureMicrophone(request);
|
||||
case "camera":
|
||||
return captureCamera(request);
|
||||
case "screenShare":
|
||||
return captureScreen(request);
|
||||
}
|
||||
};
|
||||
|
||||
const start = async (): Promise<void> => {
|
||||
for (const request of desired.values())
|
||||
await capture(request).catch((e: unknown) => {
|
||||
logger.warn(`Could not capture ${request.source}`, e);
|
||||
if (publishError$.value === null)
|
||||
publishError$.next(toMatrixRTCError(e));
|
||||
});
|
||||
};
|
||||
|
||||
const setPublishing = (publish: boolean): void => {
|
||||
publishing$.next(publish);
|
||||
if (publish && !started$.value) {
|
||||
started$.next(true);
|
||||
void start();
|
||||
}
|
||||
};
|
||||
|
||||
const publish = async (
|
||||
request: PublishRequest,
|
||||
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack> => {
|
||||
desired.set(request.source, request);
|
||||
try {
|
||||
// Before the start, the start captures everything desired itself
|
||||
if (started$.value) await capture(request);
|
||||
return await firstValueFrom(
|
||||
publications$.pipe(
|
||||
map((ps) => ps.find((p) => p.source === request.source)),
|
||||
filter((p) => p !== undefined),
|
||||
map((p) => p.mediaTrack),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
desired.delete(request.source);
|
||||
throw toMatrixRTCError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const unpublish = async (source: MediaSource): Promise<void> => {
|
||||
const published = source === "screenShareAudio" ? "screenShare" : source;
|
||||
if (published === "unknown") return;
|
||||
desired.delete(published);
|
||||
teardowns.get(published)?.();
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
scope.onEnd(() => {
|
||||
// Each teardown removes itself from the map
|
||||
for (const teardown of Array.from(teardowns.values())) teardown();
|
||||
});
|
||||
|
||||
const tracks$ = scope.behavior(
|
||||
combineLatest([started$, publications$]).pipe(
|
||||
map(([started, ps]) => (started ? ps.map((p) => p.mediaTrack) : null)),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
connectionState$,
|
||||
tracks$,
|
||||
encryptionError$: NEVER,
|
||||
setPublishing,
|
||||
publishError$,
|
||||
publish,
|
||||
unpublish,
|
||||
publications$,
|
||||
publishing$,
|
||||
};
|
||||
}
|
||||
|
||||
function exact(deviceId: string | undefined): ConstrainDOMString | undefined {
|
||||
return deviceId === undefined ? undefined : { exact: deviceId };
|
||||
}
|
||||
|
||||
async function getTrack(
|
||||
constraints: MediaStreamConstraints,
|
||||
): Promise<MediaStreamTrack> {
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
const [track] = stream.getTracks();
|
||||
if (track === undefined) throw new MatrixRTCError("No track was captured");
|
||||
return track;
|
||||
}
|
||||
|
||||
function facingModeOf(
|
||||
track: MediaStreamTrack | null,
|
||||
): "user" | "environment" | undefined {
|
||||
const mode = track?.getSettings().facingMode;
|
||||
return mode === "user" || mode === "environment" ? mode : undefined;
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
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 {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
scan,
|
||||
switchMap,
|
||||
timer,
|
||||
} from "rxjs";
|
||||
|
||||
import { type Behavior, constant } from "../../reactive/Behavior";
|
||||
import { type ObservableScope } from "../../reactive/ObservableScope";
|
||||
import {
|
||||
type AudioMediaTrack,
|
||||
type MediaSource,
|
||||
type MediaStreamStats,
|
||||
type MediaTrack,
|
||||
type VideoMediaTrack,
|
||||
} from "../../media-api";
|
||||
|
||||
export interface TrackProps {
|
||||
source: MediaSource;
|
||||
kind: "audio" | "video";
|
||||
id: string;
|
||||
/** The track behind it; may change, as on a device switch, or be null while off. */
|
||||
track$: Behavior<MediaStreamTrack | null>;
|
||||
muted$: Behavior<boolean>;
|
||||
/** Statistics of the RTP stream carrying it, if there is one. */
|
||||
stats: () => Promise<MediaStreamStats>;
|
||||
/** Where attached elements play; only for tracks that are played. */
|
||||
audioOutputDeviceId$?: Behavior<string | undefined>;
|
||||
/** Whether to follow where attached video elements are shown, for `viewport$`. */
|
||||
observeViewport?: boolean;
|
||||
}
|
||||
|
||||
/** Where a video is shown: the largest of its visible elements, in device pixels if known. */
|
||||
export interface Viewport {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export type FullMeshMediaTrack = (AudioMediaTrack | VideoMediaTrack) & {
|
||||
/** Null while no attached element is visible; always null unless observed. */
|
||||
viewport$: Behavior<Viewport | null>;
|
||||
};
|
||||
|
||||
/** Above this level a track turns active; below `INACTIVE_LEVEL` for `INACTIVE_AFTER_MS` it turns inactive. */
|
||||
const ACTIVE_LEVEL = 0.04;
|
||||
const INACTIVE_LEVEL = 0.02;
|
||||
const LEVEL_INTERVAL_MS = 100;
|
||||
const INACTIVE_AFTER_MS = 500;
|
||||
|
||||
let levelContext: AudioContext | undefined;
|
||||
|
||||
/**
|
||||
* A `MediaStreamTrack` as a `MediaTrack`: the same object for the life of a
|
||||
* publication, with every attached element following the track behind it.
|
||||
*/
|
||||
export function createFullMeshMediaTrack(
|
||||
scope: ObservableScope,
|
||||
props: TrackProps,
|
||||
): FullMeshMediaTrack {
|
||||
const { source, kind, id, track$, muted$, stats, audioOutputDeviceId$ } =
|
||||
props;
|
||||
const attached = new Set<HTMLMediaElement>();
|
||||
const viewport =
|
||||
kind === "video" && props.observeViewport
|
||||
? observeViewport(scope)
|
||||
: undefined;
|
||||
let audioContext: AudioContext | undefined;
|
||||
let audioPlugins: AudioNode[] = [];
|
||||
let volume = 1;
|
||||
let webAudio: { nodes: AudioNode[]; gain: GainNode } | undefined;
|
||||
|
||||
const routeWebAudio = (track: MediaStreamTrack | null): void => {
|
||||
for (const node of webAudio?.nodes ?? []) node.disconnect();
|
||||
webAudio = undefined;
|
||||
if (audioContext === undefined || track === null) return;
|
||||
const input = audioContext.createMediaStreamSource(
|
||||
new MediaStream([track]),
|
||||
);
|
||||
const gain = audioContext.createGain();
|
||||
gain.gain.value = volume;
|
||||
const chain: AudioNode[] = [input, ...audioPlugins, gain];
|
||||
for (let i = 0; i < chain.length - 1; i++) chain[i].connect(chain[i + 1]);
|
||||
gain.connect(audioContext.destination);
|
||||
webAudio = { nodes: chain, gain };
|
||||
};
|
||||
|
||||
const apply = (element: HTMLMediaElement): void => {
|
||||
const track = track$.value;
|
||||
element.srcObject = track ? new MediaStream([track]) : null;
|
||||
if (kind === "audio") {
|
||||
// Played through Web Audio instead, where it is routed there
|
||||
element.muted = audioContext !== undefined;
|
||||
element.volume = volume;
|
||||
const sinkId = audioOutputDeviceId$?.value;
|
||||
if (sinkId !== undefined && "setSinkId" in element)
|
||||
void element.setSinkId(sinkId).catch(() => {});
|
||||
}
|
||||
if (element.paused) void element.play().catch(() => {});
|
||||
};
|
||||
|
||||
track$.pipe(scope.bind()).subscribe((track) => {
|
||||
for (const element of attached) apply(element);
|
||||
if (kind === "audio") routeWebAudio(track);
|
||||
});
|
||||
audioOutputDeviceId$?.pipe(scope.bind()).subscribe(() => {
|
||||
for (const element of attached) apply(element);
|
||||
});
|
||||
scope.onEnd(() => {
|
||||
for (const element of attached) element.srcObject = null;
|
||||
attached.clear();
|
||||
routeWebAudio(null);
|
||||
});
|
||||
|
||||
const base: MediaTrack = {
|
||||
source,
|
||||
kind,
|
||||
id,
|
||||
muted$,
|
||||
// DTLS-SRTP, its fingerprints exchanged over Olm
|
||||
encrypted: true,
|
||||
stats$: (intervalMs): Observable<MediaStreamStats> =>
|
||||
timer(0, intervalMs).pipe(
|
||||
switchMap(async () => stats()),
|
||||
scope.bind(),
|
||||
),
|
||||
attach: (element) => {
|
||||
if (attached.has(element)) return;
|
||||
attached.add(element);
|
||||
apply(element);
|
||||
viewport?.observe(element);
|
||||
},
|
||||
detach: (element) => {
|
||||
if (!attached.delete(element)) return;
|
||||
element.srcObject = null;
|
||||
viewport?.unobserve(element);
|
||||
},
|
||||
};
|
||||
|
||||
if (kind === "video")
|
||||
return {
|
||||
...base,
|
||||
kind: "video",
|
||||
viewport$: viewport?.viewport$ ?? constant(null),
|
||||
};
|
||||
|
||||
const audioLevel$ = scope.behavior(
|
||||
combineLatest([track$, muted$]).pipe(
|
||||
switchMap(([track, muted]) =>
|
||||
track === null || muted ? of(0) : level$(track),
|
||||
),
|
||||
),
|
||||
0,
|
||||
);
|
||||
const isActive$ = scope.behavior(
|
||||
audioLevel$.pipe(
|
||||
scan(
|
||||
({ active, quietMs }, level) => {
|
||||
if (level >= ACTIVE_LEVEL) return { active: true, quietMs: 0 };
|
||||
if (level >= INACTIVE_LEVEL) return { active, quietMs: 0 };
|
||||
const quiet = quietMs + LEVEL_INTERVAL_MS;
|
||||
return {
|
||||
active: active && quiet < INACTIVE_AFTER_MS,
|
||||
quietMs: quiet,
|
||||
};
|
||||
},
|
||||
{ active: false, quietMs: 0 },
|
||||
),
|
||||
map(({ active }) => active),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
return {
|
||||
...base,
|
||||
kind: "audio",
|
||||
viewport$: constant(null),
|
||||
audioLevel$,
|
||||
isActive$,
|
||||
setAudioContext: (ctx, plugins = []) => {
|
||||
audioContext = ctx;
|
||||
audioPlugins = plugins;
|
||||
routeWebAudio(track$.value);
|
||||
for (const element of attached) apply(element);
|
||||
},
|
||||
setVolume: (v) => {
|
||||
volume = v;
|
||||
if (webAudio) webAudio.gain.gain.value = v;
|
||||
for (const element of attached) element.volume = v;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows the size and visibility of elements. Where the platform has no
|
||||
* observers, an element counts as visible at an unknown size.
|
||||
*/
|
||||
function observeViewport(scope: ObservableScope): {
|
||||
viewport$: Behavior<Viewport | null>;
|
||||
observe: (element: Element) => void;
|
||||
unobserve: (element: Element) => void;
|
||||
} {
|
||||
const shown = new Map<
|
||||
Element,
|
||||
{ visible: boolean; width?: number; height?: number }
|
||||
>();
|
||||
const viewport$ = new BehaviorSubject<Viewport | null>(null);
|
||||
const update = (): void => {
|
||||
const visible = [...shown.values()].filter(
|
||||
({ visible, width, height }) => visible && width !== 0 && height !== 0,
|
||||
);
|
||||
// One element of unknown size leaves the size unknown
|
||||
const next: Viewport | null =
|
||||
visible.length === 0
|
||||
? null
|
||||
: visible.some((e) => e.width === undefined || e.height === undefined)
|
||||
? {}
|
||||
: {
|
||||
width: Math.max(...visible.map((e) => e.width!)),
|
||||
height: Math.max(...visible.map((e) => e.height!)),
|
||||
};
|
||||
const current = viewport$.value;
|
||||
if (
|
||||
(current === null) !== (next === null) ||
|
||||
current?.width !== next?.width ||
|
||||
current?.height !== next?.height
|
||||
)
|
||||
viewport$.next(next);
|
||||
};
|
||||
|
||||
const pixelRatio = globalThis.devicePixelRatio ?? 1;
|
||||
const resizes =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? undefined
|
||||
: new ResizeObserver((entries) => {
|
||||
for (const { target, contentRect } of entries) {
|
||||
const entry = shown.get(target);
|
||||
if (entry === undefined) continue;
|
||||
entry.width = Math.ceil(contentRect.width * pixelRatio);
|
||||
entry.height = Math.ceil(contentRect.height * pixelRatio);
|
||||
}
|
||||
update();
|
||||
});
|
||||
const intersections =
|
||||
typeof IntersectionObserver === "undefined"
|
||||
? undefined
|
||||
: new IntersectionObserver((entries) => {
|
||||
for (const { target, isIntersecting } of entries) {
|
||||
const entry = shown.get(target);
|
||||
if (entry !== undefined) entry.visible = isIntersecting;
|
||||
}
|
||||
update();
|
||||
});
|
||||
scope.onEnd(() => {
|
||||
resizes?.disconnect();
|
||||
intersections?.disconnect();
|
||||
});
|
||||
|
||||
return {
|
||||
viewport$: scope.behavior(viewport$),
|
||||
observe: (element): void => {
|
||||
// Visible until the observers say otherwise, so that nothing is paused
|
||||
// before they first report
|
||||
shown.set(element, { visible: true });
|
||||
resizes?.observe(element);
|
||||
intersections?.observe(element);
|
||||
update();
|
||||
},
|
||||
unobserve: (element): void => {
|
||||
shown.delete(element);
|
||||
resizes?.unobserve(element);
|
||||
intersections?.unobserve(element);
|
||||
update();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The RMS level of an audio track, 0 to 1, sampled while subscribed. */
|
||||
function level$(track: MediaStreamTrack): Observable<number> {
|
||||
return new Observable<number>((subscriber) => {
|
||||
levelContext ??= new AudioContext();
|
||||
const ctx = levelContext;
|
||||
const input = ctx.createMediaStreamSource(new MediaStream([track]));
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
input.connect(analyser);
|
||||
const samples = new Float32Array(analyser.fftSize);
|
||||
const sub = timer(0, LEVEL_INTERVAL_MS).subscribe(() => {
|
||||
analyser.getFloatTimeDomainData(samples);
|
||||
let sum = 0;
|
||||
for (const s of samples) sum += s * s;
|
||||
subscriber.next(Math.min(1, Math.sqrt(sum / samples.length) * 4));
|
||||
});
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
input.disconnect();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** The inbound or outbound RTP statistics of one track on a peer connection. */
|
||||
export async function rtpStats(
|
||||
pc: RTCPeerConnection | undefined,
|
||||
track: MediaStreamTrack | null,
|
||||
direction: "inbound-rtp" | "outbound-rtp",
|
||||
): Promise<MediaStreamStats> {
|
||||
if (pc === undefined || track === null) return undefined;
|
||||
const report = await pc.getStats(track);
|
||||
for (const stats of report.values())
|
||||
if ((stats as RTCStats).type === direction)
|
||||
return stats as RTCInboundRtpStreamStats & RTCOutboundRtpStreamStats;
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
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, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { MediaConnectionState } from "../api";
|
||||
import {
|
||||
CANDIDATE_BATCH_MS,
|
||||
createPeerConnectionBase,
|
||||
type PeerConnectionBase,
|
||||
} from "./PeerConnection";
|
||||
import { type ControlMessage, CONTROL_CHANNEL_LABEL } from "./ControlChannel";
|
||||
import {
|
||||
type FakeSignalling,
|
||||
fakeSignalling,
|
||||
installWebRtcFakes,
|
||||
lastPeerConnection,
|
||||
peer,
|
||||
settle,
|
||||
} from "./test";
|
||||
|
||||
describe("createPeerConnectionBase", () => {
|
||||
let scope: ObservableScope;
|
||||
let signalling: FakeSignalling;
|
||||
let base: PeerConnectionBase;
|
||||
let received: ControlMessage[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
installWebRtcFakes();
|
||||
scope = new ObservableScope();
|
||||
signalling = fakeSignalling();
|
||||
received = [];
|
||||
base = createPeerConnectionBase({
|
||||
scope,
|
||||
peer,
|
||||
connectionId: "c1",
|
||||
signalling,
|
||||
iceServers: [{ urls: ["turn:example.org"] }],
|
||||
onControlMessage: (message) => received.push(message),
|
||||
logger,
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
scope.end();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("uses the ICE servers it was given", () => {
|
||||
expect(lastPeerConnection().config.iceServers).toEqual([
|
||||
{ urls: ["turn:example.org"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends the candidates gathered within a batch in one message", () => {
|
||||
const pc = lastPeerConnection();
|
||||
pc.gather({ candidate: "a", sdpMid: "0", sdpMLineIndex: 0 });
|
||||
pc.gather({ candidate: "b", sdpMid: "0", sdpMLineIndex: 0 });
|
||||
pc.gather(null);
|
||||
expect(signalling.send).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(CANDIDATE_BATCH_MS);
|
||||
expect(signalling.sent()).toEqual([
|
||||
{
|
||||
type: "candidates",
|
||||
candidates: [
|
||||
{ candidate: "a", sdpMid: "0", sdpMLineIndex: 0 },
|
||||
{ candidate: "b", sdpMid: "0", sdpMLineIndex: 0 },
|
||||
// The end of the gathering
|
||||
{ candidate: "", sdpMid: null, sdpMLineIndex: null },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(signalling.sentConnectionIds()).toEqual(["c1"]);
|
||||
});
|
||||
|
||||
it("drops pending candidates when it closes", () => {
|
||||
lastPeerConnection().gather({ candidate: "a" });
|
||||
base.close("bye", false);
|
||||
vi.advanceTimersByTime(CANDIDATE_BATCH_MS);
|
||||
expect(signalling.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs queued operations in order, past a failing one", async () => {
|
||||
const order: string[] = [];
|
||||
base.enqueue(async () => {
|
||||
order.push("first");
|
||||
return Promise.reject(new Error("failed"));
|
||||
});
|
||||
base.enqueue(async () => {
|
||||
order.push("second");
|
||||
return Promise.resolve();
|
||||
});
|
||||
await settle();
|
||||
expect(order).toEqual(["first", "second"]);
|
||||
});
|
||||
|
||||
it("applies received candidates, ignoring one that fails", async () => {
|
||||
base.addCandidates([{ candidate: "bad" }, { candidate: "good" }]);
|
||||
await settle();
|
||||
expect(lastPeerConnection().addedCandidates).toEqual([
|
||||
{ candidate: "good" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("tells the peer once when it closes", () => {
|
||||
base.close("done", true);
|
||||
base.close("again", true);
|
||||
expect(lastPeerConnection().closed).toBe(true);
|
||||
expect(signalling.sent()).toEqual([{ type: "close", reason: "done" }]);
|
||||
});
|
||||
|
||||
it("doesn't tell a peer that closed first", () => {
|
||||
base.close("theirs", false);
|
||||
expect(lastPeerConnection().closed).toBe(true);
|
||||
expect(signalling.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes when its scope ends", () => {
|
||||
scope.end();
|
||||
expect(lastPeerConnection().closed).toBe(true);
|
||||
expect(signalling.sent()).toEqual([{ type: "close", reason: "ended" }]);
|
||||
});
|
||||
|
||||
it("maps the connection state", () => {
|
||||
const pc = lastPeerConnection();
|
||||
expect(base.state$.value).toBe(MediaConnectionState.Initialized);
|
||||
pc.setConnectionState("connecting");
|
||||
expect(base.state$.value).toBe(MediaConnectionState.Connecting);
|
||||
pc.setConnectionState("connected");
|
||||
expect(base.state$.value).toBe(MediaConnectionState.Connected);
|
||||
pc.setConnectionState("disconnected");
|
||||
expect(base.state$.value).toBe(MediaConnectionState.Reconnecting);
|
||||
pc.setConnectionState("failed");
|
||||
expect(base.state$.value).toBeInstanceOf(Error);
|
||||
pc.setConnectionState("closed");
|
||||
expect(base.state$.value).toBe(MediaConnectionState.Stopped);
|
||||
});
|
||||
|
||||
describe("the control channel", () => {
|
||||
const offer: ControlMessage = {
|
||||
type: "description",
|
||||
description: { type: "offer", sdp: "o" },
|
||||
};
|
||||
const metadata = (muted: boolean): ControlMessage => ({
|
||||
type: "track_metadata",
|
||||
track_metadata: { "0": { purpose: "m.usermedia", muted } },
|
||||
});
|
||||
|
||||
it("is negotiated, with the reserved label and id", () => {
|
||||
base.openControlChannel();
|
||||
base.openControlChannel();
|
||||
const [channel] = lastPeerConnection().dataChannels;
|
||||
expect(lastPeerConnection().dataChannels).toHaveLength(1);
|
||||
expect(channel.label).toBe(CONTROL_CHANNEL_LABEL);
|
||||
expect(channel.init).toEqual({ negotiated: true, id: 0 });
|
||||
});
|
||||
|
||||
it("carries every message once it is open, apart from close", () => {
|
||||
base.openControlChannel();
|
||||
base.send(offer);
|
||||
expect(signalling.sent()).toEqual([offer]);
|
||||
|
||||
const channel = lastPeerConnection().control();
|
||||
channel.open();
|
||||
base.answerReceived();
|
||||
base.send(offer);
|
||||
lastPeerConnection().gather({ candidate: "a" });
|
||||
vi.advanceTimersByTime(CANDIDATE_BATCH_MS);
|
||||
expect(channel.sentMessages()).toEqual([
|
||||
offer,
|
||||
{ type: "candidates", candidates: [{ candidate: "a" }] },
|
||||
]);
|
||||
|
||||
base.close("done", true);
|
||||
expect(signalling.sent()).toEqual([
|
||||
offer,
|
||||
{ type: "close", reason: "done" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("is only switched to once no to-device offer awaits its answer", () => {
|
||||
base.openControlChannel();
|
||||
base.send(offer);
|
||||
base.send(metadata(true));
|
||||
const channel = lastPeerConnection().control();
|
||||
channel.open();
|
||||
// A late to-device offer could otherwise overwrite this metadata
|
||||
lastPeerConnection().gather({ candidate: "a" });
|
||||
vi.advanceTimersByTime(CANDIDATE_BATCH_MS);
|
||||
expect(channel.sentMessages()).toEqual([]);
|
||||
expect(signalling.sent()).toEqual([
|
||||
offer,
|
||||
{ type: "candidates", candidates: [{ candidate: "a" }] },
|
||||
]);
|
||||
|
||||
base.answerReceived();
|
||||
expect(channel.sentMessages()).toEqual([metadata(true)]);
|
||||
});
|
||||
|
||||
it("holds the latest metadata until it opens", () => {
|
||||
base.openControlChannel();
|
||||
base.send(metadata(true));
|
||||
base.send(metadata(false));
|
||||
expect(signalling.send).not.toHaveBeenCalled();
|
||||
|
||||
const channel = lastPeerConnection().control();
|
||||
channel.open();
|
||||
expect(channel.sentMessages()).toEqual([metadata(false)]);
|
||||
});
|
||||
|
||||
it("hands over the messages it receives, ignoring what it doesn't know", () => {
|
||||
base.openControlChannel();
|
||||
const channel = lastPeerConnection().control();
|
||||
channel.open();
|
||||
channel.receive(metadata(true));
|
||||
channel.receive({ type: "future_message" });
|
||||
channel.receive("not json");
|
||||
channel.receive({ type: "description", description: { type: "x" } });
|
||||
expect(received).toEqual([metadata(true)]);
|
||||
});
|
||||
|
||||
it("fails the connection when it closes on its own", () => {
|
||||
base.openControlChannel();
|
||||
const pc = lastPeerConnection();
|
||||
pc.setConnectionState("connected");
|
||||
pc.control().open();
|
||||
pc.control().close();
|
||||
expect(base.state$.value).toEqual(
|
||||
new Error("The control channel closed"),
|
||||
);
|
||||
});
|
||||
|
||||
it("doesn't fail a connection we close", () => {
|
||||
base.openControlChannel();
|
||||
lastPeerConnection().control().open();
|
||||
base.close("done", true);
|
||||
expect(base.state$.value).not.toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/** What the publisher and the subscriber side of a connection share. */
|
||||
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
fromEvent,
|
||||
map,
|
||||
startWith,
|
||||
} from "rxjs";
|
||||
|
||||
import { type Behavior } from "../../reactive/Behavior";
|
||||
import { type ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { MediaConnectionState } from "../api";
|
||||
import {
|
||||
type ControlMessage,
|
||||
createControlChannel,
|
||||
parseControlMessage,
|
||||
} from "./ControlChannel";
|
||||
import { type Peer, type Signalling } from "./signalling";
|
||||
|
||||
/** How long trickled candidates are collected before they are sent in one message. */
|
||||
export const CANDIDATE_BATCH_MS = 200;
|
||||
|
||||
type MetadataMessage = Extract<
|
||||
ControlMessage,
|
||||
{ type: "track_metadata" | "consumption_metadata" }
|
||||
>;
|
||||
|
||||
export interface PeerConnectionBase {
|
||||
readonly connectionId: string;
|
||||
readonly pc: RTCPeerConnection;
|
||||
readonly state$: Behavior<MediaConnectionState | Error>;
|
||||
/** Runs `op` after every operation queued before it, so that SDP and candidates apply in order. */
|
||||
enqueue(op: () => Promise<void>): void;
|
||||
/**
|
||||
* Creates our side of the control channel. The publisher does so before
|
||||
* its first offer, the subscriber once it applied that offer.
|
||||
*/
|
||||
openControlChannel(): void;
|
||||
/**
|
||||
* Sends a message to the peer: over the control channel once we switched
|
||||
* to it, before that as a to-device message. Metadata has no to-device
|
||||
* message of its own: the latest waits for the switch.
|
||||
*/
|
||||
send(message: ControlMessage): void;
|
||||
/**
|
||||
* Tells that the answer to our latest offer arrived. We switch to the
|
||||
* control channel only once no to-device offer is waiting for its answer,
|
||||
* so that a late offer can't overwrite newer metadata.
|
||||
*/
|
||||
answerReceived(): void;
|
||||
/** Applies a received batch of candidates. */
|
||||
addCandidates(candidates: RTCIceCandidateInit[]): void;
|
||||
/** Closes the peer connection, telling the peer unless it closed first. */
|
||||
close(reason: string | undefined, notifyPeer: boolean): void;
|
||||
}
|
||||
|
||||
export function createPeerConnectionBase({
|
||||
scope,
|
||||
peer,
|
||||
connectionId,
|
||||
signalling,
|
||||
iceServers,
|
||||
onControlMessage,
|
||||
logger,
|
||||
}: {
|
||||
scope: ObservableScope;
|
||||
peer: Peer;
|
||||
connectionId: string;
|
||||
signalling: Signalling;
|
||||
iceServers: RTCIceServer[];
|
||||
/** A message the peer sent over the control channel. */
|
||||
onControlMessage: (message: ControlMessage) => void;
|
||||
logger: Logger;
|
||||
}): PeerConnectionBase {
|
||||
const pc = new RTCPeerConnection({ iceServers });
|
||||
const closed$ = new BehaviorSubject(false);
|
||||
const controlFailed$ = new BehaviorSubject(false);
|
||||
|
||||
let queue = Promise.resolve();
|
||||
const enqueue = (op: () => Promise<void>): void => {
|
||||
queue = queue.then(op).catch((e: unknown) => {
|
||||
logger.warn("Signalling operation failed", e);
|
||||
});
|
||||
};
|
||||
|
||||
let control: RTCDataChannel | null = null;
|
||||
let switched = false;
|
||||
let awaitingAnswer = false;
|
||||
const pendingMetadata = new Map<MetadataMessage["type"], MetadataMessage>();
|
||||
const trySwitch = (): void => {
|
||||
if (switched || awaitingAnswer || control?.readyState !== "open") return;
|
||||
switched = true;
|
||||
logger.debug("Switched to the control channel");
|
||||
for (const message of pendingMetadata.values())
|
||||
control.send(JSON.stringify(message));
|
||||
pendingMetadata.clear();
|
||||
};
|
||||
const openControlChannel = (): void => {
|
||||
if (control !== null) return;
|
||||
const channel = createControlChannel(pc);
|
||||
control = channel;
|
||||
channel.addEventListener("open", trySwitch);
|
||||
channel.addEventListener("message", ({ data }: MessageEvent) => {
|
||||
const message = parseControlMessage(data);
|
||||
if (message === null) logger.debug("Ignoring a control message", data);
|
||||
else onControlMessage(message);
|
||||
});
|
||||
// Ours to close only with the connection: closing otherwise is a failure
|
||||
channel.addEventListener("close", () => {
|
||||
if (!closed$.value) controlFailed$.next(true);
|
||||
});
|
||||
};
|
||||
|
||||
const send = (message: ControlMessage): void => {
|
||||
if (closed$.value) return;
|
||||
if (switched) control!.send(JSON.stringify(message));
|
||||
else if (
|
||||
message.type === "track_metadata" ||
|
||||
message.type === "consumption_metadata"
|
||||
)
|
||||
pendingMetadata.set(message.type, message);
|
||||
else {
|
||||
if (
|
||||
message.type === "description" &&
|
||||
message.description.type === "offer"
|
||||
)
|
||||
awaitingAnswer = true;
|
||||
signalling.send(peer, connectionId, message);
|
||||
}
|
||||
};
|
||||
|
||||
const answerReceived = (): void => {
|
||||
awaitingAnswer = false;
|
||||
trySwitch();
|
||||
};
|
||||
|
||||
// Trickled, in batches
|
||||
let pending: RTCIceCandidateInit[] = [];
|
||||
let batchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const flushCandidates = (): void => {
|
||||
batchTimer = undefined;
|
||||
if (pending.length === 0) return;
|
||||
send({ type: "candidates", candidates: pending });
|
||||
pending = [];
|
||||
};
|
||||
pc.addEventListener("icecandidate", ({ candidate }) => {
|
||||
// A null candidate ends the gathering: an empty one says so on the wire
|
||||
pending.push(
|
||||
candidate
|
||||
? candidate.toJSON()
|
||||
: { candidate: "", sdpMid: null, sdpMLineIndex: null },
|
||||
);
|
||||
batchTimer ??= setTimeout(flushCandidates, CANDIDATE_BATCH_MS);
|
||||
});
|
||||
|
||||
const addCandidates: PeerConnectionBase["addCandidates"] = (candidates) =>
|
||||
enqueue(async () => {
|
||||
for (const candidate of candidates)
|
||||
await pc
|
||||
.addIceCandidate(candidate)
|
||||
// A candidate of an outdated description, or a bad one: not fatal
|
||||
.catch((e: unknown) => logger.debug("Ignored a candidate", e));
|
||||
});
|
||||
|
||||
const close: PeerConnectionBase["close"] = (reason, notifyPeer) => {
|
||||
if (closed$.value) return;
|
||||
clearTimeout(batchTimer);
|
||||
// Always to-device: closing the peer connection would race a channel message
|
||||
if (notifyPeer)
|
||||
signalling.send(peer, connectionId, { type: "close", reason });
|
||||
closed$.next(true);
|
||||
pc.close();
|
||||
};
|
||||
scope.onEnd(() => close("ended", true));
|
||||
|
||||
const state$ = scope.behavior(
|
||||
combineLatest([
|
||||
fromEvent(pc, "connectionstatechange").pipe(startWith(null)),
|
||||
controlFailed$,
|
||||
]).pipe(
|
||||
map(([, controlFailed]) =>
|
||||
controlFailed && pc.connectionState !== "closed"
|
||||
? new Error("The control channel closed")
|
||||
: toState(pc.connectionState),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
connectionId,
|
||||
pc,
|
||||
state$,
|
||||
enqueue,
|
||||
openControlChannel,
|
||||
send,
|
||||
answerReceived,
|
||||
addCandidates,
|
||||
close,
|
||||
};
|
||||
}
|
||||
|
||||
function toState(state: RTCPeerConnectionState): MediaConnectionState | Error {
|
||||
switch (state) {
|
||||
case "new":
|
||||
return MediaConnectionState.Initialized;
|
||||
case "connecting":
|
||||
return MediaConnectionState.Connecting;
|
||||
case "connected":
|
||||
return MediaConnectionState.Connected;
|
||||
case "disconnected":
|
||||
return MediaConnectionState.Reconnecting;
|
||||
case "failed":
|
||||
return new Error("The peer connection failed");
|
||||
case "closed":
|
||||
return MediaConnectionState.Stopped;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
/*
|
||||
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, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { CONTROL_CHANNEL_LABEL } from "./ControlChannel";
|
||||
import { type FullMeshLocalMedia } from "./LocalMedia";
|
||||
import { DATA_CHANNEL_LABEL, DATA_PROTOCOL } from "./metadata";
|
||||
import {
|
||||
createPublisherConnection,
|
||||
type PublisherConnection,
|
||||
} from "./PublisherConnection";
|
||||
import {
|
||||
type FakeSignalling,
|
||||
fakeLocalMedia,
|
||||
fakePublication,
|
||||
fakeSignalling,
|
||||
fakeTrack,
|
||||
fromPeer,
|
||||
type PeerMessage,
|
||||
installWebRtcFakes,
|
||||
lastPeerConnection,
|
||||
peer,
|
||||
settle,
|
||||
} from "./test";
|
||||
|
||||
describe("createPublisherConnection", () => {
|
||||
let scope: ObservableScope;
|
||||
let signalling: FakeSignalling;
|
||||
let local: ReturnType<typeof fakeLocalMedia>;
|
||||
|
||||
function create(): PublisherConnection {
|
||||
return createPublisherConnection({
|
||||
scope,
|
||||
peer,
|
||||
connectionId: "c1",
|
||||
signalling,
|
||||
iceServers: [],
|
||||
local: local as unknown as FullMeshLocalMedia,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
const answer = (): PeerMessage =>
|
||||
fromPeer("c1", {
|
||||
type: "description",
|
||||
description: { type: "answer", sdp: "answer" },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
installWebRtcFakes();
|
||||
scope = new ObservableScope();
|
||||
signalling = fakeSignalling();
|
||||
local = fakeLocalMedia();
|
||||
});
|
||||
afterEach(() => {
|
||||
scope.end();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("offers right away, even without tracks", async () => {
|
||||
create();
|
||||
await settle();
|
||||
expect(signalling.sent("description")).toEqual([
|
||||
{
|
||||
type: "description",
|
||||
description: { type: "offer", sdp: "offer-1" },
|
||||
track_metadata: {},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("describes each track by the mid it gets with the offer", async () => {
|
||||
const mic = fakePublication("audio");
|
||||
const camera = fakePublication("video", { stream: mic.stream });
|
||||
const screen = fakePublication("video", { source: "screenShare" });
|
||||
camera.muted$.next(true);
|
||||
local.publications$.next([mic, camera, screen]);
|
||||
create();
|
||||
await settle();
|
||||
expect(signalling.sent("description")).toEqual([
|
||||
expect.objectContaining({
|
||||
track_metadata: {
|
||||
"0": { purpose: "m.usermedia", muted: false },
|
||||
"1": { purpose: "m.usermedia", muted: true },
|
||||
"2": { purpose: "m.screenshare", muted: false },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates the control channel and the data channel before its first offer", () => {
|
||||
create();
|
||||
const [control, data] = lastPeerConnection().dataChannels;
|
||||
expect(control.label).toBe(CONTROL_CHANNEL_LABEL);
|
||||
expect(data).toMatchObject({
|
||||
label: DATA_CHANNEL_LABEL,
|
||||
protocol: DATA_PROTOCOL,
|
||||
});
|
||||
expect(lastPeerConnection().offers).toBe(0);
|
||||
});
|
||||
|
||||
it("sends each publication on a sendonly transceiver in its stream", async () => {
|
||||
const mic = fakePublication("audio");
|
||||
const camera = fakePublication("video", { stream: mic.stream });
|
||||
local.publications$.next([mic, camera]);
|
||||
create();
|
||||
await settle();
|
||||
|
||||
const [a, v] = lastPeerConnection().transceivers;
|
||||
expect(a).toMatchObject({ kind: "audio", direction: "sendonly" });
|
||||
expect(a.streams).toEqual([mic.stream]);
|
||||
expect(a.sender.track).toBe(mic.track$.value);
|
||||
expect(v).toMatchObject({ kind: "video", direction: "sendonly" });
|
||||
expect(v.sender.track).toBe(camera.track$.value);
|
||||
// One offer for both
|
||||
expect(signalling.sent("description")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("follows a track change without renegotiating", async () => {
|
||||
const camera = fakePublication("video");
|
||||
local.publications$.next([camera]);
|
||||
create();
|
||||
await settle();
|
||||
const next = fakeTrack("video");
|
||||
camera.track$.next(next);
|
||||
expect(lastPeerConnection().transceivers[0].sender.track).toBe(next);
|
||||
expect(signalling.sent("description")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("sends nothing while not publishing", async () => {
|
||||
const camera = fakePublication("video");
|
||||
local.publications$.next([camera]);
|
||||
local.publishing$.next(false);
|
||||
create();
|
||||
await settle();
|
||||
const { sender } = lastPeerConnection().transceivers[0];
|
||||
expect(sender.track).toBeNull();
|
||||
|
||||
local.publishing$.next(true);
|
||||
expect(sender.track).toBe(camera.track$.value);
|
||||
});
|
||||
|
||||
it("holds changes while an offer is unanswered, then offers once for all", async () => {
|
||||
const connection = create();
|
||||
const pc = lastPeerConnection();
|
||||
await settle();
|
||||
expect(pc.offers).toBe(1);
|
||||
|
||||
local.publications$.next([fakePublication("audio")]);
|
||||
local.publications$.next([
|
||||
...local.publications$.value,
|
||||
fakePublication("video"),
|
||||
]);
|
||||
await settle();
|
||||
expect(pc.offers).toBe(1);
|
||||
|
||||
connection.handle(answer());
|
||||
await settle();
|
||||
expect(pc.offers).toBe(2);
|
||||
expect(pc.transceivers).toHaveLength(2);
|
||||
|
||||
// Nothing changed since: the next answer doesn't lead to another offer
|
||||
connection.handle(answer());
|
||||
await settle();
|
||||
expect(pc.offers).toBe(2);
|
||||
});
|
||||
|
||||
it("stops the transceiver of a removed publication and renegotiates", async () => {
|
||||
const camera = fakePublication("video");
|
||||
local.publications$.next([camera]);
|
||||
const connection = create();
|
||||
await settle();
|
||||
connection.handle(answer());
|
||||
await settle();
|
||||
|
||||
local.publications$.next([]);
|
||||
await settle();
|
||||
const pc = lastPeerConnection();
|
||||
expect(pc.transceivers[0].stopped).toBe(true);
|
||||
expect(pc.offers).toBe(2);
|
||||
});
|
||||
|
||||
it("signals mute changes over the control channel without renegotiating", async () => {
|
||||
const mic = fakePublication("audio");
|
||||
local.publications$.next([mic]);
|
||||
const connection = create();
|
||||
await settle();
|
||||
connection.handle(answer());
|
||||
await settle();
|
||||
const control = lastPeerConnection().control();
|
||||
control.open();
|
||||
mic.muted$.next(true);
|
||||
expect(control.sentMessages()).toEqual([
|
||||
{
|
||||
type: "track_metadata",
|
||||
track_metadata: { "0": { purpose: "m.usermedia", muted: true } },
|
||||
},
|
||||
]);
|
||||
expect(lastPeerConnection().offers).toBe(1);
|
||||
});
|
||||
|
||||
it("holds a mute change until its to-device offer is answered", async () => {
|
||||
const mic = fakePublication("audio");
|
||||
local.publications$.next([mic]);
|
||||
const connection = create();
|
||||
await settle();
|
||||
const control = lastPeerConnection().control();
|
||||
control.open();
|
||||
mic.muted$.next(true);
|
||||
expect(control.sentMessages()).toEqual([]);
|
||||
|
||||
connection.handle(answer());
|
||||
await settle();
|
||||
expect(control.sentMessages()).toEqual([
|
||||
{
|
||||
type: "track_metadata",
|
||||
track_metadata: { "0": { purpose: "m.usermedia", muted: true } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("renegotiates over the control channel once it is open", async () => {
|
||||
const connection = create();
|
||||
await settle();
|
||||
connection.handle(answer());
|
||||
await settle();
|
||||
const control = lastPeerConnection().control();
|
||||
control.open();
|
||||
|
||||
local.publications$.next([fakePublication("audio")]);
|
||||
await settle();
|
||||
expect(control.sentMessages()).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "description",
|
||||
description: { type: "offer", sdp: "offer-2" },
|
||||
}),
|
||||
);
|
||||
expect(signalling.sent("description")).toHaveLength(1);
|
||||
|
||||
// The answer comes back the same way
|
||||
control.receive({
|
||||
type: "description",
|
||||
description: { type: "answer", sdp: "answer-2" },
|
||||
});
|
||||
await settle();
|
||||
expect(lastPeerConnection().remoteDescription).toEqual({
|
||||
type: "answer",
|
||||
sdp: "answer-2",
|
||||
});
|
||||
});
|
||||
|
||||
describe("consumption metadata", () => {
|
||||
/** A connection sending one camera track, with its control channel open. */
|
||||
async function sendingCamera(settings: MediaTrackSettings = {}): Promise<{
|
||||
camera: ReturnType<typeof fakePublication>;
|
||||
consume: (metadata: unknown) => Promise<void>;
|
||||
}> {
|
||||
const camera = fakePublication("video");
|
||||
camera.track$.next(fakeTrack("video", settings));
|
||||
local.publications$.next([camera]);
|
||||
const connection = create();
|
||||
await settle();
|
||||
connection.handle(answer());
|
||||
await settle();
|
||||
const control = lastPeerConnection().control();
|
||||
control.open();
|
||||
return {
|
||||
camera,
|
||||
consume: async (metadata): Promise<void> => {
|
||||
control.receive({
|
||||
type: "consumption_metadata",
|
||||
consumption_metadata: metadata,
|
||||
});
|
||||
await settle();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("stops sending a track that isn't in use, without renegotiating", async () => {
|
||||
const { camera, consume } = await sendingCamera();
|
||||
const { sender } = lastPeerConnection().transceivers[0];
|
||||
await consume({ "0": { in_use: false } });
|
||||
expect(sender.track).toBeNull();
|
||||
|
||||
await consume({});
|
||||
expect(sender.track).toBe(camera.track$.value);
|
||||
expect(lastPeerConnection().offers).toBe(1);
|
||||
});
|
||||
|
||||
it("scales the video down to the largest size shown", async () => {
|
||||
const { consume } = await sendingCamera({ width: 1280, height: 720 });
|
||||
const { sender } = lastPeerConnection().transceivers[0];
|
||||
await consume({ "0": { max_width: 320, max_height: 320 } });
|
||||
expect(sender.parameters.encodings).toEqual([
|
||||
{ scaleResolutionDownBy: 4 },
|
||||
]);
|
||||
|
||||
// Larger than the camera: sent as it is
|
||||
await consume({ "0": { max_width: 4000, max_height: 4000 } });
|
||||
expect(sender.parameters.encodings).toEqual([
|
||||
{ scaleResolutionDownBy: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("sends data once the data channel is open", () => {
|
||||
const connection = create();
|
||||
expect(connection.sendData("early")).toBe(false);
|
||||
const data = lastPeerConnection().dataChannels[1];
|
||||
data.open();
|
||||
expect(connection.sendData("hello")).toBe(true);
|
||||
expect(data.sent).toEqual(["hello"]);
|
||||
});
|
||||
|
||||
it("closes, telling the subscriber, when the control channel fails", async () => {
|
||||
create();
|
||||
await settle();
|
||||
const pc = lastPeerConnection();
|
||||
pc.control().open();
|
||||
pc.control().close();
|
||||
expect(pc.closed).toBe(true);
|
||||
expect(signalling.sent("close")).toEqual([
|
||||
{ type: "close", reason: "failed" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies the subscriber's candidates", async () => {
|
||||
const connection = create();
|
||||
await settle();
|
||||
connection.handle(answer());
|
||||
connection.handle(
|
||||
fromPeer("c1", {
|
||||
type: "candidates",
|
||||
candidates: [{ candidate: "x" }],
|
||||
}),
|
||||
);
|
||||
await settle();
|
||||
expect(lastPeerConnection().addedCandidates).toEqual([{ candidate: "x" }]);
|
||||
});
|
||||
|
||||
it("ends without answering when the subscriber closes", async () => {
|
||||
const connection = create();
|
||||
await settle();
|
||||
connection.handle(fromPeer("c1", { type: "close", reason: "bye" }));
|
||||
const pc = lastPeerConnection();
|
||||
expect(pc.closed).toBe(true);
|
||||
expect(signalling.sent("close")).toEqual([]);
|
||||
// Its scope ended: no more offers go out
|
||||
local.publications$.next([fakePublication("audio")]);
|
||||
await settle();
|
||||
expect(pc.offers).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
/*
|
||||
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 Logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
map,
|
||||
merge,
|
||||
type Observable,
|
||||
skip,
|
||||
type Subscription,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { type ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { type FullMeshLocalMedia, type LocalPublication } from "./LocalMedia";
|
||||
import {
|
||||
type ConsumptionMetadata,
|
||||
type ConsumptionMetadataEntry,
|
||||
DATA_CHANNEL_LABEL,
|
||||
DATA_PROTOCOL,
|
||||
type MediaPurpose,
|
||||
type TrackMetadata,
|
||||
} from "./metadata";
|
||||
import {
|
||||
createPeerConnectionBase,
|
||||
type PeerConnectionBase,
|
||||
} from "./PeerConnection";
|
||||
import {
|
||||
type ConnectionMessage,
|
||||
type Peer,
|
||||
type Signalling,
|
||||
} from "./signalling";
|
||||
|
||||
export interface PublisherConnection extends PeerConnectionBase {
|
||||
/** A to-device message of this connection from the subscriber. */
|
||||
handle(message: ConnectionMessage): void;
|
||||
/** Sends one message on the data channel; false while it isn't open. */
|
||||
sendData(message: string): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Our side of a connection a subscriber asked for: we only send, and we are
|
||||
* the only side that makes offers, so there is no glare.
|
||||
*/
|
||||
export function createPublisherConnection({
|
||||
scope,
|
||||
peer,
|
||||
connectionId,
|
||||
signalling,
|
||||
iceServers,
|
||||
local,
|
||||
logger: parentLogger,
|
||||
}: {
|
||||
scope: ObservableScope;
|
||||
peer: Peer;
|
||||
connectionId: string;
|
||||
signalling: Signalling;
|
||||
iceServers: RTCIceServer[];
|
||||
local: FullMeshLocalMedia;
|
||||
logger: Logger;
|
||||
}): PublisherConnection {
|
||||
const logger = parentLogger.getChild(
|
||||
`[Publisher → ${peer.memberId} ${connectionId}]`,
|
||||
);
|
||||
const base = createPeerConnectionBase({
|
||||
scope,
|
||||
peer,
|
||||
connectionId,
|
||||
signalling,
|
||||
iceServers,
|
||||
onControlMessage: (message) => handle(message),
|
||||
logger,
|
||||
});
|
||||
const { pc, enqueue } = base;
|
||||
const consumption$ = new BehaviorSubject<ConsumptionMetadata>({});
|
||||
|
||||
// Both before the first offer, which so has the connection's data section
|
||||
base.openControlChannel();
|
||||
const data = pc.createDataChannel(DATA_CHANNEL_LABEL, {
|
||||
protocol: DATA_PROTOCOL,
|
||||
});
|
||||
|
||||
// One sendonly transceiver per publication, for as long as it exists
|
||||
const transceivers = new Map<
|
||||
LocalPublication,
|
||||
{ transceiver: RTCRtpTransceiver; tracks: Subscription }
|
||||
>();
|
||||
|
||||
/** The metadata of every track with a `mid`, which it gets with the offer that adds it. */
|
||||
const trackMetadata = (): TrackMetadata => {
|
||||
const metadata: TrackMetadata = {};
|
||||
for (const [publication, { transceiver }] of transceivers)
|
||||
if (transceiver.mid !== null)
|
||||
metadata[transceiver.mid] = {
|
||||
purpose: purposeOf(publication),
|
||||
muted: publication.mediaTrack.muted$.value,
|
||||
};
|
||||
return metadata;
|
||||
};
|
||||
|
||||
// A new offer has to wait while one is unanswered; changes made meanwhile
|
||||
// go out together in the next one
|
||||
let offerPending = false;
|
||||
let renegotiate = false;
|
||||
const negotiate = (): void => {
|
||||
if (offerPending) {
|
||||
renegotiate = true;
|
||||
return;
|
||||
}
|
||||
offerPending = true;
|
||||
renegotiate = false;
|
||||
enqueue(async () => {
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
base.send({
|
||||
type: "description",
|
||||
description: { type: "offer", sdp: pc.localDescription!.sdp },
|
||||
track_metadata: trackMetadata(),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
scope.onEnd(() => {
|
||||
for (const { tracks } of transceivers.values()) tracks.unsubscribe();
|
||||
});
|
||||
local.publications$.pipe(scope.bind()).subscribe((publications) => {
|
||||
let changed = false;
|
||||
for (const [publication, { transceiver, tracks }] of transceivers)
|
||||
if (!publications.includes(publication)) {
|
||||
tracks.unsubscribe();
|
||||
if (pc.signalingState !== "closed") transceiver.stop();
|
||||
transceivers.delete(publication);
|
||||
changed = true;
|
||||
}
|
||||
for (const publication of publications)
|
||||
if (!transceivers.has(publication)) {
|
||||
const transceiver = pc.addTransceiver(publication.kind, {
|
||||
direction: "sendonly",
|
||||
streams: [publication.stream],
|
||||
});
|
||||
const tracks = sendPublication(transceiver, publication);
|
||||
transceivers.set(publication, { transceiver, tracks });
|
||||
changed = true;
|
||||
}
|
||||
if (changed) negotiate();
|
||||
});
|
||||
// Even without tracks: the subscriber learns that we are there
|
||||
if (transceivers.size === 0) negotiate();
|
||||
|
||||
/**
|
||||
* Sends the track of a publication as the subscriber wants it. None while
|
||||
* we don't publish or it isn't in use: the local preview stays live, and
|
||||
* turning it back on needs no renegotiation.
|
||||
*/
|
||||
function sendPublication(
|
||||
transceiver: RTCRtpTransceiver,
|
||||
publication: LocalPublication,
|
||||
): Subscription {
|
||||
const { sender } = transceiver;
|
||||
return combineLatest([
|
||||
publication.track$,
|
||||
local.publishing$,
|
||||
consumptionOf(transceiver),
|
||||
]).subscribe(([track, publishing, consumption]) => {
|
||||
const inUse = consumption.in_use ?? true;
|
||||
void sender
|
||||
.replaceTrack(publishing && inUse ? track : null)
|
||||
.then(async () => {
|
||||
if (publication.kind === "video" && track !== null)
|
||||
await constrainVideo(sender, track, consumption);
|
||||
})
|
||||
.catch((e: unknown) => logger.warn("Could not send a track", e));
|
||||
});
|
||||
}
|
||||
|
||||
// The mid is only set with the local description
|
||||
function consumptionOf(
|
||||
transceiver: RTCRtpTransceiver,
|
||||
): Observable<ConsumptionMetadataEntry> {
|
||||
return consumption$.pipe(
|
||||
map((c) => (transceiver.mid === null ? {} : (c[transceiver.mid] ?? {}))),
|
||||
distinctUntilChanged(
|
||||
(a, b) =>
|
||||
a.in_use === b.in_use &&
|
||||
a.max_width === b.max_width &&
|
||||
a.max_height === b.max_height,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Mute changes without renegotiating; an offer carries the state at its time
|
||||
local.publications$
|
||||
.pipe(
|
||||
switchMap((ps) =>
|
||||
merge(...ps.map((p) => p.mediaTrack.muted$.pipe(skip(1)))),
|
||||
),
|
||||
scope.bind(),
|
||||
)
|
||||
.subscribe(() =>
|
||||
base.send({ type: "track_metadata", track_metadata: trackMetadata() }),
|
||||
);
|
||||
|
||||
base.state$
|
||||
.pipe(
|
||||
filter((s) => s instanceof Error),
|
||||
scope.bind(),
|
||||
)
|
||||
.subscribe((e) => {
|
||||
// The subscriber retries; a publisher doesn't
|
||||
logger.info(`Connection failed: ${e.message}`);
|
||||
base.close("failed", true);
|
||||
scope.end();
|
||||
});
|
||||
|
||||
function handle(message: ConnectionMessage): void {
|
||||
switch (message.type) {
|
||||
case "description":
|
||||
if (message.description.type !== "answer") return;
|
||||
enqueue(async () => {
|
||||
await pc.setRemoteDescription(message.description);
|
||||
base.answerReceived();
|
||||
offerPending = false;
|
||||
if (renegotiate) negotiate();
|
||||
});
|
||||
break;
|
||||
case "candidates":
|
||||
base.addCandidates(message.candidates);
|
||||
break;
|
||||
case "consumption_metadata":
|
||||
consumption$.next(message.consumption_metadata);
|
||||
break;
|
||||
case "close":
|
||||
logger.info(`Closed by the subscriber: ${message.reason}`);
|
||||
base.close(message.reason, false);
|
||||
scope.end();
|
||||
break;
|
||||
default:
|
||||
logger.debug(`Ignoring ${message.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
const sendData = (message: string): boolean => {
|
||||
if (data.readyState !== "open") return false;
|
||||
data.send(message);
|
||||
return true;
|
||||
};
|
||||
|
||||
return { ...base, handle, sendData };
|
||||
}
|
||||
|
||||
function purposeOf({ source }: LocalPublication): MediaPurpose {
|
||||
return source === "microphone" || source === "camera"
|
||||
? "m.usermedia"
|
||||
: "m.screenshare";
|
||||
}
|
||||
|
||||
/** Scales the video down to the size the subscriber shows it at. */
|
||||
async function constrainVideo(
|
||||
sender: RTCRtpSender,
|
||||
track: MediaStreamTrack,
|
||||
{ max_width, max_height }: ConsumptionMetadataEntry,
|
||||
): Promise<void> {
|
||||
const parameters = sender.getParameters();
|
||||
const [encoding] = parameters.encodings ?? [];
|
||||
if (encoding === undefined) return;
|
||||
const { width, height } = track.getSettings();
|
||||
encoding.scaleResolutionDownBy = Math.max(
|
||||
1,
|
||||
width && max_width ? width / max_width : 1,
|
||||
height && max_height ? height / max_height : 1,
|
||||
);
|
||||
await sender.setParameters(parameters);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Full mesh media backend
|
||||
|
||||
The MatrixRTC full mesh transport, as specified in
|
||||
[MSC4575: Full mesh transport for MatrixRTC](https://github.com/matrix-org/matrix-spec-proposals/pull/4575).
|
||||
|
||||
Every subscriber opens a WebRTC connection to every publisher it receives from. To-device messages set
|
||||
the connection up; after that, the connection's control channel carries all signalling.
|
||||
|
||||
The MSC is not merged, so all identifiers use its unstable prefix `org.matrix.msc4575`.
|
||||
|
||||
| File | What it implements |
|
||||
| ------------------------- | ------------------------------------------------------------------- |
|
||||
| `FullMeshBackend.ts` | The `MediaBackend`: subscribing to members, accepting subscribers |
|
||||
| `signalling.ts` | The to-device messages and their ordering by `seq` |
|
||||
| `ControlChannel.ts` | The control channel and its messages |
|
||||
| `PeerConnection.ts` | What both sides of a connection share, including the channel switch |
|
||||
| `PublisherConnection.ts` | Sending our tracks and the data channel to one subscriber |
|
||||
| `SubscriberConnection.ts` | Receiving one publisher's tracks, with retries |
|
||||
| `metadata.ts` | Track and consumption metadata, and their validation |
|
||||
| `LocalMedia.ts` | Capturing the local tracks every publisher connection sends |
|
||||
| `MediaTrack.ts` | A WebRTC track as the SDK's `MediaTrack` |
|
||||
@@ -0,0 +1,307 @@
|
||||
/*
|
||||
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, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { constant } from "../../reactive/Behavior";
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import {
|
||||
DATA_CHANNEL_LABEL,
|
||||
DATA_PROTOCOL,
|
||||
type TrackMetadata,
|
||||
} from "./metadata";
|
||||
import {
|
||||
createSubscriberConnection,
|
||||
type SubscriberConnection,
|
||||
} from "./SubscriberConnection";
|
||||
import {
|
||||
type FakeSignalling,
|
||||
fakeSignalling,
|
||||
fromPeer,
|
||||
type PeerMessage,
|
||||
installWebRtcFakes,
|
||||
lastPeerConnection,
|
||||
peer,
|
||||
settle,
|
||||
} from "./test";
|
||||
|
||||
describe("createSubscriberConnection", () => {
|
||||
let scope: ObservableScope;
|
||||
let signalling: FakeSignalling;
|
||||
let subscriber: SubscriberConnection;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
installWebRtcFakes();
|
||||
scope = new ObservableScope();
|
||||
signalling = fakeSignalling();
|
||||
subscriber = createSubscriberConnection({
|
||||
scope,
|
||||
peer,
|
||||
signalling,
|
||||
iceServers: async () => Promise.resolve([{ urls: ["turn:x"] }]),
|
||||
audioOutputDeviceId$: constant(undefined),
|
||||
logger,
|
||||
});
|
||||
await settle();
|
||||
});
|
||||
afterEach(() => {
|
||||
scope.end();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("asks the publisher for its media on a fresh connection", () => {
|
||||
expect(signalling.sent()).toEqual([{ type: "subscribe" }]);
|
||||
expect(signalling.sentConnectionIds()).toEqual([current()]);
|
||||
expect(lastPeerConnection().config.iceServers).toEqual([
|
||||
{ urls: ["turn:x"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("answers an offer and shows the tracks it brings", async () => {
|
||||
expect(subscriber.tracks$.value).toBeNull();
|
||||
const pc = lastPeerConnection();
|
||||
pc.receive("video", "1");
|
||||
pc.receive("video", "2");
|
||||
|
||||
subscriber.handle(
|
||||
offer(current(), {
|
||||
"1": { purpose: "m.usermedia", muted: true },
|
||||
"2": { purpose: "m.screenshare" },
|
||||
}),
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(pc.remoteDescription).toEqual({ type: "offer", sdp: "offer" });
|
||||
// How it shows them goes over the control channel once it is open
|
||||
expect(signalling.sent("description")).toEqual([
|
||||
{ type: "description", description: { type: "answer", sdp: "answer" } },
|
||||
]);
|
||||
const tracks = subscriber.tracks$.value!;
|
||||
expect(tracks.map((t) => [t.source, t.kind, t.muted$.value])).toEqual([
|
||||
["camera", "video", true],
|
||||
["screenShare", "video", false],
|
||||
]);
|
||||
});
|
||||
|
||||
it("names audio by its purpose", async () => {
|
||||
const pc = lastPeerConnection();
|
||||
pc.receive("audio", "0");
|
||||
pc.receive("audio", "3");
|
||||
subscriber.handle(
|
||||
offer(current(), {
|
||||
"0": { purpose: "m.usermedia" },
|
||||
"3": { purpose: "m.screenshare" },
|
||||
}),
|
||||
);
|
||||
await settle();
|
||||
expect(subscriber.tracks$.value!.map((t) => t.source)).toEqual([
|
||||
"microphone",
|
||||
"screenShareAudio",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves out tracks without metadata or with an unknown purpose", async () => {
|
||||
const pc = lastPeerConnection();
|
||||
pc.receive("video", "0");
|
||||
pc.receive("video", "1");
|
||||
subscriber.handle(offer(current(), { "1": { purpose: "m.whiteboard" } }));
|
||||
await settle();
|
||||
expect(subscriber.tracks$.value).toEqual([]);
|
||||
});
|
||||
|
||||
it("follows mute changes, keeping the same track", async () => {
|
||||
lastPeerConnection().receive("video", "1");
|
||||
subscriber.handle(offer(current(), { "1": { purpose: "m.usermedia" } }));
|
||||
await settle();
|
||||
const [track] = subscriber.tracks$.value!;
|
||||
expect(track.muted$.value).toBe(false);
|
||||
|
||||
lastPeerConnection()
|
||||
.control()
|
||||
.receive({
|
||||
type: "track_metadata",
|
||||
track_metadata: { "1": { purpose: "m.usermedia", muted: true } },
|
||||
});
|
||||
await settle();
|
||||
expect(subscriber.tracks$.value).toEqual([track]);
|
||||
expect(track.muted$.value).toBe(true);
|
||||
});
|
||||
|
||||
it("drops a track whose transceiver no longer receives", async () => {
|
||||
const pc = lastPeerConnection();
|
||||
const transceiver = pc.receive("video", "1");
|
||||
subscriber.handle(offer(current(), { "1": { purpose: "m.usermedia" } }));
|
||||
await settle();
|
||||
expect(subscriber.tracks$.value).toHaveLength(1);
|
||||
|
||||
transceiver.currentDirection = "inactive";
|
||||
subscriber.handle(offer(current(), { "1": { purpose: "m.usermedia" } }));
|
||||
await settle();
|
||||
expect(subscriber.tracks$.value).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores messages of another connection", async () => {
|
||||
subscriber.handle(offer("other", {}));
|
||||
await settle();
|
||||
expect(lastPeerConnection().remoteDescription).toBeNull();
|
||||
});
|
||||
|
||||
it("subscribes anew after the publisher closes, backing off", async () => {
|
||||
const first = current();
|
||||
subscriber.handle(fromPeer(first, { type: "close", reason: "nope" }));
|
||||
expect(subscriber.current$.value).toBeNull();
|
||||
expect(subscriber.tracks$.value).toBeNull();
|
||||
// It doesn't echo the publisher's close
|
||||
expect(signalling.sent("close")).toEqual([]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
const second = current();
|
||||
expect(second).not.toBe(first);
|
||||
expect(signalling.sentConnectionIds("subscribe")).toEqual([first, second]);
|
||||
|
||||
// A failed connection is closed and retried after twice the wait
|
||||
lastPeerConnection().setConnectionState("failed");
|
||||
expect(signalling.sent("close")).toEqual([
|
||||
{ type: "close", reason: "failed" },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(signalling.sent("subscribe")).toHaveLength(2);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(signalling.sent("subscribe")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("stops retrying once its scope ends", async () => {
|
||||
subscriber.handle(fromPeer(current(), { type: "close", reason: "nope" }));
|
||||
scope.end();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(signalling.sent("subscribe")).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe("the control channel", () => {
|
||||
it("is created once the offer is applied", async () => {
|
||||
const pc = lastPeerConnection();
|
||||
expect(pc.dataChannels).toEqual([]);
|
||||
subscriber.handle(offer(current(), {}));
|
||||
await settle();
|
||||
expect(pc.control().init).toEqual({ negotiated: true, id: 0 });
|
||||
});
|
||||
|
||||
it("failing leads to a new subscription", async () => {
|
||||
const first = current();
|
||||
subscriber.handle(offer(first, {}));
|
||||
await settle();
|
||||
const control = lastPeerConnection().control();
|
||||
control.open();
|
||||
control.close();
|
||||
expect(signalling.sent("close")).toEqual([
|
||||
{ type: "close", reason: "failed" },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(current()).not.toBe(first);
|
||||
});
|
||||
|
||||
it("carries the answers to later offers", async () => {
|
||||
subscriber.handle(offer(current(), {}));
|
||||
await settle();
|
||||
const control = lastPeerConnection().control();
|
||||
control.open();
|
||||
control.receive({
|
||||
type: "description",
|
||||
description: { type: "offer", sdp: "offer-2" },
|
||||
track_metadata: {},
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(control.sentMessages()).toContainEqual({
|
||||
type: "description",
|
||||
description: { type: "answer", sdp: "answer" },
|
||||
});
|
||||
expect(signalling.sent("description")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("consumption metadata", () => {
|
||||
it("tells the publisher how each video is shown, once the channel is open", async () => {
|
||||
const pc = lastPeerConnection();
|
||||
pc.receive("audio", "0");
|
||||
pc.receive("video", "1");
|
||||
subscriber.handle(
|
||||
offer(current(), {
|
||||
"0": { purpose: "m.usermedia" },
|
||||
"1": { purpose: "m.usermedia" },
|
||||
}),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
const control = pc.control();
|
||||
control.open();
|
||||
// Nothing shows the video yet; the audio is always in use
|
||||
expect(control.sentMessages()).toEqual([
|
||||
{
|
||||
type: "consumption_metadata",
|
||||
consumption_metadata: { "1": { in_use: false } },
|
||||
},
|
||||
]);
|
||||
|
||||
const video = subscriber.tracks$.value!.find((t) => t.kind === "video")!;
|
||||
const element = { paused: false } as HTMLMediaElement;
|
||||
video.attach(element);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
// Shown at a size the platform doesn't report: no constraint at all
|
||||
expect(control.sentMessages().at(-1)).toEqual({
|
||||
type: "consumption_metadata",
|
||||
consumption_metadata: {},
|
||||
});
|
||||
|
||||
video.detach(element);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(control.sentMessages().at(-1)).toEqual({
|
||||
type: "consumption_metadata",
|
||||
consumption_metadata: { "1": { in_use: false } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("the data channel", () => {
|
||||
it("delivers what the publisher sends on it", async () => {
|
||||
const received: string[] = [];
|
||||
subscriber.data$.subscribe((text) => received.push(text));
|
||||
subscriber.handle(offer(current(), {}));
|
||||
await settle();
|
||||
const pc = lastPeerConnection();
|
||||
pc.announceDataChannel(DATA_CHANNEL_LABEL, DATA_PROTOCOL).receive("hi");
|
||||
expect(received).toEqual(["hi"]);
|
||||
});
|
||||
|
||||
it("ignores a channel whose protocol it doesn't know, whatever its label", async () => {
|
||||
const received: string[] = [];
|
||||
subscriber.data$.subscribe((text) => received.push(text));
|
||||
subscriber.handle(offer(current(), {}));
|
||||
await settle();
|
||||
const pc = lastPeerConnection();
|
||||
pc.announceDataChannel(DATA_CHANNEL_LABEL, "x.other").receive("no");
|
||||
pc.announceDataChannel(DATA_CHANNEL_LABEL).receive("no");
|
||||
expect(received).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function offer(connectionId: string, metadata: TrackMetadata): PeerMessage {
|
||||
return fromPeer(connectionId, {
|
||||
type: "description",
|
||||
description: { type: "offer", sdp: "offer" },
|
||||
track_metadata: metadata,
|
||||
});
|
||||
}
|
||||
|
||||
/** The connection id of the current attempt. */
|
||||
function current(): string {
|
||||
const base = subscriber.current$.value;
|
||||
if (base === null) throw new Error("No current attempt");
|
||||
return base.connectionId;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
/*
|
||||
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 Logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
Subject,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { type MediaSource } from "../../media-api";
|
||||
import { type Behavior } from "../../reactive/Behavior";
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { MediaConnectionState } from "../api";
|
||||
import {
|
||||
createFullMeshMediaTrack,
|
||||
type FullMeshMediaTrack,
|
||||
rtpStats,
|
||||
type Viewport,
|
||||
} from "./MediaTrack";
|
||||
import {
|
||||
type ConsumptionMetadata,
|
||||
DATA_PROTOCOL,
|
||||
isMediaPurpose,
|
||||
type MediaPurpose,
|
||||
type TrackMetadata,
|
||||
} from "./metadata";
|
||||
import {
|
||||
createPeerConnectionBase,
|
||||
type PeerConnectionBase,
|
||||
} from "./PeerConnection";
|
||||
import {
|
||||
type ConnectionMessage,
|
||||
type Peer,
|
||||
type Signalling,
|
||||
} from "./signalling";
|
||||
|
||||
const RETRY_MIN_MS = 1_000;
|
||||
const RETRY_MAX_MS = 30_000;
|
||||
/** How long the shown size of the tracks settles before the publisher hears of it. */
|
||||
const CONSUMPTION_DEBOUNCE_MS = 200;
|
||||
|
||||
export interface SubscriberConnection {
|
||||
/** The current attempt; null while waiting to retry. */
|
||||
current$: Behavior<PeerConnectionBase | null>;
|
||||
/** The publisher's tracks; null until its first offer is applied. */
|
||||
tracks$: Behavior<FullMeshMediaTrack[] | null>;
|
||||
/** What the publisher sent on its data channel, on any attempt. */
|
||||
data$: Observable<string>;
|
||||
/** A to-device message from the publisher; dropped unless it is for the current attempt. */
|
||||
handle(message: ConnectionMessage & { connectionId: string }): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Our subscription to one publisher: asks it for its media and answers its
|
||||
* offers. A failed or closed attempt is retried with a fresh connection id,
|
||||
* backing off, for as long as `scope` lives.
|
||||
*/
|
||||
export function createSubscriberConnection({
|
||||
scope,
|
||||
peer,
|
||||
signalling,
|
||||
iceServers,
|
||||
audioOutputDeviceId$,
|
||||
logger: parentLogger,
|
||||
}: {
|
||||
scope: ObservableScope;
|
||||
peer: Peer;
|
||||
signalling: Signalling;
|
||||
iceServers: () => Promise<RTCIceServer[]>;
|
||||
audioOutputDeviceId$: Behavior<string | undefined>;
|
||||
logger: Logger;
|
||||
}): SubscriberConnection {
|
||||
const logger = parentLogger.getChild(`[Subscriber ← ${peer.memberId}]`);
|
||||
const attempt$ = new BehaviorSubject<Attempt | null>(null);
|
||||
const data$ = new Subject<string>();
|
||||
let retryMs = RETRY_MIN_MS;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let ended = false;
|
||||
scope.onEnd(() => (ended = true));
|
||||
|
||||
const startAttempt = async (): Promise<void> => {
|
||||
const servers = await iceServers();
|
||||
if (ended) return;
|
||||
const attemptScope = new ObservableScope();
|
||||
const attempt = createAttempt({
|
||||
scope: attemptScope,
|
||||
peer,
|
||||
signalling,
|
||||
iceServers: servers,
|
||||
audioOutputDeviceId$,
|
||||
onData: (text) => data$.next(text),
|
||||
onFailed: (reason) => {
|
||||
logger.info(`Attempt ${attempt.connectionId} ended: ${reason}`);
|
||||
attemptScope.end();
|
||||
if (attempt$.value === attempt) attempt$.next(null);
|
||||
if (ended) return;
|
||||
retryTimer = setTimeout(() => void startAttempt(), retryMs);
|
||||
retryMs = Math.min(retryMs * 2, RETRY_MAX_MS);
|
||||
},
|
||||
onConnected: () => (retryMs = RETRY_MIN_MS),
|
||||
logger,
|
||||
});
|
||||
attempt$.next(attempt);
|
||||
};
|
||||
void startAttempt();
|
||||
scope.onEnd(() => {
|
||||
clearTimeout(retryTimer);
|
||||
attempt$.value?.end();
|
||||
attempt$.next(null);
|
||||
data$.complete();
|
||||
});
|
||||
|
||||
return {
|
||||
current$: scope.behavior(attempt$.pipe(map((a) => a?.base ?? null))),
|
||||
tracks$: scope.behavior(
|
||||
attempt$.pipe(switchMap((a) => a?.tracks$ ?? of(null))),
|
||||
),
|
||||
data$,
|
||||
handle: (message) => {
|
||||
if (message.connectionId === attempt$.value?.connectionId)
|
||||
attempt$.value.handle(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface Attempt {
|
||||
connectionId: string;
|
||||
base: PeerConnectionBase;
|
||||
tracks$: Behavior<FullMeshMediaTrack[] | null>;
|
||||
handle(message: ConnectionMessage): void;
|
||||
end(): void;
|
||||
}
|
||||
|
||||
interface ReceivedTrack {
|
||||
track: FullMeshMediaTrack;
|
||||
mid: string;
|
||||
scope: ObservableScope;
|
||||
source: MediaSource;
|
||||
}
|
||||
|
||||
function createAttempt({
|
||||
scope,
|
||||
peer,
|
||||
signalling,
|
||||
iceServers,
|
||||
audioOutputDeviceId$,
|
||||
onData,
|
||||
onFailed,
|
||||
onConnected,
|
||||
logger,
|
||||
}: {
|
||||
scope: ObservableScope;
|
||||
peer: Peer;
|
||||
signalling: Signalling;
|
||||
iceServers: RTCIceServer[];
|
||||
audioOutputDeviceId$: Behavior<string | undefined>;
|
||||
onData: (text: string) => void;
|
||||
onFailed: (reason: string) => void;
|
||||
onConnected: () => void;
|
||||
logger: Logger;
|
||||
}): Attempt {
|
||||
const connectionId = uuidv4();
|
||||
let failed = false;
|
||||
const fail = (reason: string): void => {
|
||||
if (failed) return;
|
||||
failed = true;
|
||||
onFailed(reason);
|
||||
};
|
||||
const base = createPeerConnectionBase({
|
||||
scope,
|
||||
peer,
|
||||
connectionId,
|
||||
signalling,
|
||||
iceServers,
|
||||
onControlMessage: (message) => handle(message),
|
||||
logger,
|
||||
});
|
||||
const { pc, enqueue } = base;
|
||||
const metadata$ = new BehaviorSubject<TrackMetadata>({});
|
||||
const received$ = new BehaviorSubject<ReceivedTrack[] | null>(null);
|
||||
const wrappers = new Map<RTCRtpTransceiver, ReceivedTrack>();
|
||||
|
||||
pc.addEventListener("datachannel", ({ channel }) => {
|
||||
if (channel.protocol !== DATA_PROTOCOL) return;
|
||||
channel.addEventListener("message", ({ data }: MessageEvent) => {
|
||||
if (typeof data === "string") onData(data);
|
||||
});
|
||||
});
|
||||
|
||||
/** The tracks of the transceivers that receive, after each negotiation. */
|
||||
const updateTracks = (): void => {
|
||||
const metadata = metadata$.value;
|
||||
const live = new Set<RTCRtpTransceiver>();
|
||||
for (const transceiver of pc.getTransceivers()) {
|
||||
const { mid, receiver } = transceiver;
|
||||
const track = receiver.track;
|
||||
const entry = mid === null ? undefined : metadata[mid];
|
||||
if (
|
||||
transceiver.currentDirection !== "recvonly" ||
|
||||
mid === null ||
|
||||
entry === undefined ||
|
||||
!isMediaPurpose(entry.purpose)
|
||||
)
|
||||
continue;
|
||||
const kind = track.kind === "audio" ? "audio" : "video";
|
||||
const source = sourceOf(entry.purpose, kind);
|
||||
live.add(transceiver);
|
||||
const existing = wrappers.get(transceiver);
|
||||
if (existing?.source === source) continue;
|
||||
existing?.scope.end();
|
||||
const trackScope = new ObservableScope();
|
||||
wrappers.set(transceiver, {
|
||||
scope: trackScope,
|
||||
source,
|
||||
mid,
|
||||
track: createFullMeshMediaTrack(trackScope, {
|
||||
source,
|
||||
kind,
|
||||
id: `${connectionId}:${mid}`,
|
||||
track$: trackScope.behavior(of(track)),
|
||||
muted$: trackScope.behavior(
|
||||
metadata$.pipe(
|
||||
map((m) => m[mid]?.muted ?? false),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
),
|
||||
stats: async () => rtpStats(pc, track, "inbound-rtp"),
|
||||
audioOutputDeviceId$,
|
||||
observeViewport: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
for (const [transceiver, { scope: s }] of wrappers)
|
||||
if (!live.has(transceiver)) {
|
||||
s.end();
|
||||
wrappers.delete(transceiver);
|
||||
}
|
||||
received$.next([...wrappers.values()]);
|
||||
};
|
||||
|
||||
// How the videos are shown; the first goes out once the control channel
|
||||
// is open, as everything is sent at full size until then anyway
|
||||
received$
|
||||
.pipe(
|
||||
switchMap((received) => {
|
||||
const video = (received ?? []).filter((r) => r.track.kind === "video");
|
||||
return video.length === 0
|
||||
? of({})
|
||||
: combineLatest(video.map((r) => r.track.viewport$)).pipe(
|
||||
map((viewports) => consumptionOf(video, viewports)),
|
||||
);
|
||||
}),
|
||||
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
|
||||
debounceTime(CONSUMPTION_DEBOUNCE_MS),
|
||||
scope.bind(),
|
||||
)
|
||||
.subscribe((metadata) =>
|
||||
base.send({
|
||||
type: "consumption_metadata",
|
||||
consumption_metadata: metadata,
|
||||
}),
|
||||
);
|
||||
|
||||
base.state$
|
||||
.pipe(
|
||||
filter((s) => s instanceof Error || s === MediaConnectionState.Connected),
|
||||
scope.bind(),
|
||||
)
|
||||
.subscribe((s) => {
|
||||
if (s instanceof Error) {
|
||||
base.close("failed", true);
|
||||
fail(s.message);
|
||||
} else onConnected();
|
||||
});
|
||||
|
||||
function handle(message: ConnectionMessage): void {
|
||||
switch (message.type) {
|
||||
case "description":
|
||||
if (message.description.type !== "offer") return;
|
||||
enqueue(async () => {
|
||||
if (message.track_metadata) metadata$.next(message.track_metadata);
|
||||
await pc.setRemoteDescription(message.description);
|
||||
base.openControlChannel();
|
||||
await pc.setLocalDescription(await pc.createAnswer());
|
||||
updateTracks();
|
||||
base.send({
|
||||
type: "description",
|
||||
description: { type: "answer", sdp: pc.localDescription!.sdp },
|
||||
});
|
||||
});
|
||||
break;
|
||||
case "candidates":
|
||||
base.addCandidates(message.candidates);
|
||||
break;
|
||||
case "track_metadata":
|
||||
metadata$.next(message.track_metadata);
|
||||
// A track may only become known with its metadata
|
||||
enqueue(async () => Promise.resolve(updateTracks()));
|
||||
break;
|
||||
case "close":
|
||||
base.close(message.reason, false);
|
||||
fail(`closed by the publisher: ${message.reason}`);
|
||||
break;
|
||||
default:
|
||||
logger.debug(`Ignoring ${message.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
scope.onEnd(() => {
|
||||
for (const { scope: s } of wrappers.values()) s.end();
|
||||
wrappers.clear();
|
||||
});
|
||||
|
||||
signalling.send(peer, connectionId, { type: "subscribe" });
|
||||
|
||||
return {
|
||||
connectionId,
|
||||
base,
|
||||
tracks$: scope.behavior(
|
||||
received$.pipe(map((r) => r?.map(({ track }) => track) ?? null)),
|
||||
),
|
||||
handle,
|
||||
end: () => scope.end(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The consumption metadata for the received videos, given their viewports
|
||||
* in order: a video no element shows isn't in use, and one shown at a known
|
||||
* size needs no more than that.
|
||||
*/
|
||||
function consumptionOf(
|
||||
video: ReceivedTrack[],
|
||||
viewports: (Viewport | null)[],
|
||||
): ConsumptionMetadata {
|
||||
const metadata: ConsumptionMetadata = {};
|
||||
video.forEach(({ mid }, i) => {
|
||||
const viewport = viewports[i];
|
||||
if (viewport === null) metadata[mid] = { in_use: false };
|
||||
else if (viewport.width !== undefined && viewport.height !== undefined)
|
||||
metadata[mid] = {
|
||||
max_width: viewport.width,
|
||||
max_height: viewport.height,
|
||||
};
|
||||
});
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function sourceOf(purpose: MediaPurpose, kind: "audio" | "video"): MediaSource {
|
||||
if (purpose === "m.usermedia")
|
||||
return kind === "audio" ? "microphone" : "camera";
|
||||
return kind === "audio" ? "screenShareAudio" : "screenShare";
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
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 { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseConsumptionMetadata, parseTrackMetadata } from "./metadata";
|
||||
|
||||
describe("parseTrackMetadata", () => {
|
||||
it("keeps the well-formed entries", () => {
|
||||
expect(
|
||||
parseTrackMetadata({
|
||||
"0": { purpose: "m.usermedia", muted: true, extra: 1 },
|
||||
"1": { purpose: "m.screenshare", muted: "yes" },
|
||||
"2": { muted: true },
|
||||
"3": [],
|
||||
}),
|
||||
).toEqual({
|
||||
"0": { purpose: "m.usermedia", muted: true },
|
||||
"1": { purpose: "m.screenshare" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects what isn't metadata at all", () => {
|
||||
expect(parseTrackMetadata("x")).toBeNull();
|
||||
expect(parseTrackMetadata([])).toBeNull();
|
||||
});
|
||||
|
||||
it("reads no more entries than any member publishes", () => {
|
||||
const many = Object.fromEntries(
|
||||
Array.from({ length: 100 }, (_, i) => [i, { purpose: "m.usermedia" }]),
|
||||
);
|
||||
expect(Object.keys(parseTrackMetadata(many)!)).toHaveLength(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseConsumptionMetadata", () => {
|
||||
it("bounds the sizes to what makes sense", () => {
|
||||
expect(
|
||||
parseConsumptionMetadata({
|
||||
"0": { in_use: false, max_width: 1e9, max_height: -1 },
|
||||
"1": { max_width: 320.4, max_height: Number.NaN },
|
||||
}),
|
||||
).toEqual({
|
||||
"0": { in_use: false, max_width: 16_384 },
|
||||
"1": { max_width: 320 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The track and consumption metadata of the full mesh transport (MSC4575),
|
||||
* and their validation: both come from the other member and are untrusted.
|
||||
* Both are keyed by the `mid` of a track's transceiver.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The data channel every publisher connection has for `sendData`, and its
|
||||
* protocol: one JSON object `{ topic, text }` per message.
|
||||
*/
|
||||
export const DATA_CHANNEL_LABEL = "io.element.matrixrtc.data";
|
||||
export const DATA_PROTOCOL = "io.element.matrixrtc.topic_text";
|
||||
|
||||
export type MediaPurpose = "m.usermedia" | "m.screenshare";
|
||||
|
||||
export interface TrackMetadataEntry {
|
||||
purpose: string;
|
||||
muted?: boolean;
|
||||
}
|
||||
export type TrackMetadata = Record<string, TrackMetadataEntry>;
|
||||
|
||||
export interface ConsumptionMetadataEntry {
|
||||
in_use?: boolean;
|
||||
max_width?: number;
|
||||
max_height?: number;
|
||||
}
|
||||
export type ConsumptionMetadata = Record<string, ConsumptionMetadataEntry>;
|
||||
|
||||
/** Larger than any video we capture; a bound on what a subscriber can ask for. */
|
||||
const MAX_DIMENSION = 16_384;
|
||||
/** More tracks than any member publishes; a bound on the work a message causes. */
|
||||
const MAX_TRACKS = 64;
|
||||
|
||||
export function isMediaPurpose(purpose: string): purpose is MediaPurpose {
|
||||
return purpose === "m.usermedia" || purpose === "m.screenshare";
|
||||
}
|
||||
|
||||
/** The entries of `value` that are well formed, or null if it isn't metadata at all. */
|
||||
export function parseTrackMetadata(value: unknown): TrackMetadata | null {
|
||||
const entries = recordEntries(value);
|
||||
if (entries === null) return null;
|
||||
const metadata: TrackMetadata = {};
|
||||
for (const [mid, e] of entries) {
|
||||
if (typeof e.purpose !== "string") continue;
|
||||
const entry: TrackMetadataEntry = { purpose: e.purpose };
|
||||
if (typeof e.muted === "boolean") entry.muted = e.muted;
|
||||
metadata[mid] = entry;
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/** As `parseTrackMetadata`, with the sizes bounded to what makes sense. */
|
||||
export function parseConsumptionMetadata(
|
||||
value: unknown,
|
||||
): ConsumptionMetadata | null {
|
||||
const entries = recordEntries(value);
|
||||
if (entries === null) return null;
|
||||
const metadata: ConsumptionMetadata = {};
|
||||
for (const [mid, e] of entries) {
|
||||
const entry: ConsumptionMetadataEntry = {};
|
||||
if (typeof e.in_use === "boolean") entry.in_use = e.in_use;
|
||||
const width = bounded(e.max_width);
|
||||
if (width !== undefined) entry.max_width = width;
|
||||
const height = bounded(e.max_height);
|
||||
if (height !== undefined) entry.max_height = height;
|
||||
metadata[mid] = entry;
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function recordEntries(
|
||||
value: unknown,
|
||||
): [string, Record<string, unknown>][] | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
return null;
|
||||
return Object.entries(value)
|
||||
.filter(
|
||||
(e): e is [string, Record<string, unknown>] =>
|
||||
typeof e[1] === "object" && e[1] !== null && !Array.isArray(e[1]),
|
||||
)
|
||||
.slice(0, MAX_TRACKS);
|
||||
}
|
||||
|
||||
function bounded(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? Math.round(Math.min(value, MAX_DIMENSION))
|
||||
: undefined;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
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, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "events";
|
||||
import { ClientEvent, EventType, MatrixEvent } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import {
|
||||
createSignalling,
|
||||
SEQ_GAP_TIMEOUT_MS,
|
||||
SeqOrderer,
|
||||
SignallingEventType,
|
||||
type SignallingClient,
|
||||
type SignallingMessage,
|
||||
} from "./signalling";
|
||||
|
||||
describe("SeqOrderer", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("delivers in seq order", () => {
|
||||
const delivered: string[] = [];
|
||||
const orderer = new SeqOrderer<string>((s) => delivered.push(s));
|
||||
orderer.push(1, "b");
|
||||
orderer.push(2, "c");
|
||||
expect(delivered).toEqual([]);
|
||||
orderer.push(0, "a");
|
||||
expect(delivered).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("skips a gap that isn't filled in time, then drops the late message", () => {
|
||||
const delivered: string[] = [];
|
||||
const orderer = new SeqOrderer<string>((s) => delivered.push(s));
|
||||
orderer.push(0, "a");
|
||||
orderer.push(2, "c");
|
||||
vi.advanceTimersByTime(SEQ_GAP_TIMEOUT_MS);
|
||||
expect(delivered).toEqual(["a", "c"]);
|
||||
orderer.push(1, "b");
|
||||
expect(delivered).toEqual(["a", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSignalling", () => {
|
||||
const own = { userId: "@me:x", deviceId: "MINE", memberId: "me" };
|
||||
|
||||
function setup(): {
|
||||
client: SignallingClient & EventEmitter;
|
||||
received: SignallingMessage[];
|
||||
scope: ObservableScope;
|
||||
} {
|
||||
const client = Object.assign(new EventEmitter(), {
|
||||
encryptAndSendToDevice: vi.fn(async () => Promise.resolve()),
|
||||
}) as unknown as SignallingClient & EventEmitter;
|
||||
const scope = new ObservableScope();
|
||||
const signalling = createSignalling({
|
||||
scope,
|
||||
client,
|
||||
roomId: "!room:x",
|
||||
slotId: "m.call#ROOM",
|
||||
own,
|
||||
logger,
|
||||
});
|
||||
const received: SignallingMessage[] = [];
|
||||
signalling.messages$.subscribe((m) => received.push(m));
|
||||
return { client, received, scope };
|
||||
}
|
||||
|
||||
function toDevice(
|
||||
type: string,
|
||||
content: Record<string, unknown>,
|
||||
encrypted = true,
|
||||
): MatrixEvent {
|
||||
const event = new MatrixEvent({ type, sender: "@peer:x", content });
|
||||
if (encrypted)
|
||||
vi.spyOn(event, "getWireType").mockReturnValue(
|
||||
EventType.RoomMessageEncrypted,
|
||||
);
|
||||
return event;
|
||||
}
|
||||
|
||||
const common = {
|
||||
room_id: "!room:x",
|
||||
slot_id: "m.call#ROOM",
|
||||
member_id: "peer",
|
||||
target_member_id: "me",
|
||||
connection_id: "c1",
|
||||
device_id: "PEER",
|
||||
};
|
||||
|
||||
it("passes valid messages on and drops cleartext or misaddressed ones", () => {
|
||||
const { client, received, scope } = setup();
|
||||
client.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
toDevice(SignallingEventType.subscribe, { ...common, seq: 0 }, false),
|
||||
);
|
||||
client.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
toDevice(SignallingEventType.subscribe, {
|
||||
...common,
|
||||
target_member_id: "someone else",
|
||||
seq: 0,
|
||||
}),
|
||||
);
|
||||
expect(received).toEqual([]);
|
||||
client.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
toDevice(SignallingEventType.subscribe, { ...common, seq: 0 }),
|
||||
);
|
||||
expect(received).toEqual([
|
||||
{
|
||||
type: "subscribe",
|
||||
memberId: "peer",
|
||||
userId: "@peer:x",
|
||||
deviceId: "PEER",
|
||||
connectionId: "c1",
|
||||
},
|
||||
]);
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("orders by seq across connection ids", () => {
|
||||
const { client, received, scope } = setup();
|
||||
client.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
toDevice(SignallingEventType.subscribe, {
|
||||
...common,
|
||||
connection_id: "c2",
|
||||
seq: 2,
|
||||
}),
|
||||
);
|
||||
client.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
toDevice(SignallingEventType.close, { ...common, seq: 1 }),
|
||||
);
|
||||
client.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
toDevice(SignallingEventType.subscribe, { ...common, seq: 0 }),
|
||||
);
|
||||
expect(received.map((m) => [m.type, m.connectionId])).toEqual([
|
||||
["subscribe", "c1"],
|
||||
["close", "c1"],
|
||||
["subscribe", "c2"],
|
||||
]);
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("keeps the well-formed metadata of a description", () => {
|
||||
const { client, received, scope } = setup();
|
||||
client.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
toDevice(SignallingEventType.description, {
|
||||
...common,
|
||||
seq: 0,
|
||||
description: { type: "offer", sdp: "offer" },
|
||||
track_metadata: {
|
||||
"0": { purpose: "m.usermedia", muted: "yes" },
|
||||
"1": { muted: true },
|
||||
"2": "not an object",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(received).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "description",
|
||||
track_metadata: { "0": { purpose: "m.usermedia" } },
|
||||
}),
|
||||
]);
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("sends each payload as its event type", () => {
|
||||
const { client, scope } = setup();
|
||||
const signalling = createSignalling({
|
||||
scope,
|
||||
client,
|
||||
roomId: "!room:x",
|
||||
slotId: "m.call#ROOM",
|
||||
own,
|
||||
logger,
|
||||
});
|
||||
signalling.send(
|
||||
{ userId: "@peer:x", deviceId: "PEER", memberId: "peer" },
|
||||
"c1",
|
||||
{ type: "close", reason: "bye" },
|
||||
);
|
||||
expect(client.encryptAndSendToDevice).toHaveBeenCalledWith(
|
||||
SignallingEventType.close,
|
||||
[{ userId: "@peer:x", deviceId: "PEER" }],
|
||||
expect.objectContaining({ reason: "bye", connection_id: "c1", seq: 0 }),
|
||||
);
|
||||
scope.end();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The to-device signalling of the full mesh transport (MSC4575): four event
|
||||
* types, the fields every one carries, and the ordering by `seq`. It sets a
|
||||
* connection up; the control channel carries the rest.
|
||||
*/
|
||||
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
ClientEvent,
|
||||
EventType,
|
||||
type MatrixClient,
|
||||
type MatrixEvent,
|
||||
} from "matrix-js-sdk";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
import { fromEvent, Observable, share } from "rxjs";
|
||||
|
||||
import { type ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { type ControlMessage } from "./ControlChannel";
|
||||
import { parseTrackMetadata } from "./metadata";
|
||||
|
||||
export const FULL_MESH_TRANSPORT_TYPE = "org.matrix.msc4575.full_mesh";
|
||||
|
||||
const prefix = "org.matrix.msc4575.rtc.full_mesh";
|
||||
/** The event type of each payload type. */
|
||||
export const SignallingEventType = {
|
||||
subscribe: `${prefix}.subscribe`,
|
||||
description: `${prefix}.description`,
|
||||
candidates: `${prefix}.candidates`,
|
||||
close: `${prefix}.close`,
|
||||
} as const;
|
||||
|
||||
export const signallingEventTypes: string[] =
|
||||
Object.values(SignallingEventType);
|
||||
|
||||
/** How long a gap in `seq` is waited for before the missing messages are skipped. */
|
||||
export const SEQ_GAP_TIMEOUT_MS = 10_000;
|
||||
|
||||
interface CommonContent {
|
||||
room_id: string;
|
||||
slot_id: string;
|
||||
member_id: string;
|
||||
target_member_id: string;
|
||||
connection_id: string;
|
||||
seq: number;
|
||||
/**
|
||||
* Not in the MSC, which takes the device from the Olm session. The js-sdk
|
||||
* does not expose that for to-device events, so, like the key transport's
|
||||
* `claimed_device_id`, the sender states it.
|
||||
*/
|
||||
device_id: string;
|
||||
}
|
||||
|
||||
/** What a connection hears from its peer, over to-device messages or the control channel. */
|
||||
export type ConnectionMessage =
|
||||
| ControlMessage
|
||||
| { type: "close"; reason?: string };
|
||||
|
||||
export type SignallingPayload =
|
||||
| { type: "subscribe" }
|
||||
| Extract<
|
||||
ConnectionMessage,
|
||||
{ type: "description" | "candidates" | "close" }
|
||||
>;
|
||||
|
||||
export type SignallingMessage = SignallingPayload & {
|
||||
/** The member that sent it, as named in `member_id`. */
|
||||
memberId: string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
connectionId: string;
|
||||
};
|
||||
|
||||
/** Another member, as far as signalling needs to know it. */
|
||||
export interface Peer {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
memberId: string;
|
||||
}
|
||||
|
||||
export interface Signalling {
|
||||
send(peer: Peer, connectionId: string, payload: SignallingPayload): void;
|
||||
/** Every valid message for us, per sender in `seq` order. */
|
||||
messages$: Observable<SignallingMessage>;
|
||||
}
|
||||
|
||||
export type SignallingClient = Pick<
|
||||
MatrixClient,
|
||||
"encryptAndSendToDevice" | "on" | "off"
|
||||
>;
|
||||
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
client: SignallingClient;
|
||||
roomId: string;
|
||||
slotId: string;
|
||||
own: CallMembershipIdentityParts;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export function createSignalling({
|
||||
scope,
|
||||
client,
|
||||
roomId,
|
||||
slotId,
|
||||
own,
|
||||
logger: parentLogger,
|
||||
}: Props): Signalling {
|
||||
const logger = parentLogger.getChild("[Signalling]");
|
||||
const nextSeqTo = new Map<string, number>();
|
||||
|
||||
const send = (
|
||||
peer: Peer,
|
||||
connectionId: string,
|
||||
{ type, ...payload }: SignallingPayload,
|
||||
): void => {
|
||||
const eventType = SignallingEventType[type];
|
||||
const seq = nextSeqTo.get(peer.memberId) ?? 0;
|
||||
nextSeqTo.set(peer.memberId, seq + 1);
|
||||
const content: CommonContent & Record<string, unknown> = {
|
||||
...payload,
|
||||
room_id: roomId,
|
||||
slot_id: slotId,
|
||||
member_id: own.memberId,
|
||||
target_member_id: peer.memberId,
|
||||
connection_id: connectionId,
|
||||
seq,
|
||||
device_id: own.deviceId,
|
||||
};
|
||||
client
|
||||
.encryptAndSendToDevice(
|
||||
eventType,
|
||||
[{ userId: peer.userId, deviceId: peer.deviceId }],
|
||||
content,
|
||||
)
|
||||
.catch((e: unknown) => logger.warn(`Failed to send ${eventType}`, e));
|
||||
};
|
||||
|
||||
const messages$ = new Observable<SignallingMessage>((subscriber) => {
|
||||
const orderers = new Map<string, SeqOrderer<SignallingMessage>>();
|
||||
const sub = fromEvent<MatrixEvent>(client, ClientEvent.ToDeviceEvent)
|
||||
.pipe(scope.bind())
|
||||
.subscribe((event) => {
|
||||
const parsed = parse(event, roomId, slotId, own.memberId, logger);
|
||||
if (parsed === null) return;
|
||||
let orderer = orderers.get(parsed.message.memberId);
|
||||
if (orderer === undefined) {
|
||||
orderer = new SeqOrderer((m) => subscriber.next(m), logger);
|
||||
orderers.set(parsed.message.memberId, orderer);
|
||||
}
|
||||
orderer.push(parsed.seq, parsed.message);
|
||||
});
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
for (const orderer of orderers.values()) orderer.dispose();
|
||||
};
|
||||
}).pipe(share());
|
||||
|
||||
return { send, messages$ };
|
||||
}
|
||||
|
||||
function parse(
|
||||
event: MatrixEvent,
|
||||
roomId: string,
|
||||
slotId: string,
|
||||
ownMemberId: string,
|
||||
logger: Logger,
|
||||
): { seq: number; message: SignallingMessage } | null {
|
||||
const type = event.getType();
|
||||
if (!signallingEventTypes.includes(type)) return null;
|
||||
// Via the widget API, to-device events are reconstructed as encrypted
|
||||
if (event.getWireType() !== EventType.RoomMessageEncrypted) {
|
||||
logger.warn(`Dropping ${type} sent in clear from ${event.getSender()}`);
|
||||
return null;
|
||||
}
|
||||
const c = event.getContent<
|
||||
Partial<CommonContent> & Record<string, unknown>
|
||||
>();
|
||||
const userId = event.getSender();
|
||||
if (
|
||||
userId === undefined ||
|
||||
c.room_id !== roomId ||
|
||||
c.slot_id !== slotId ||
|
||||
c.target_member_id !== ownMemberId ||
|
||||
typeof c.member_id !== "string" ||
|
||||
typeof c.connection_id !== "string" ||
|
||||
typeof c.device_id !== "string" ||
|
||||
typeof c.seq !== "number"
|
||||
)
|
||||
return null;
|
||||
const common = {
|
||||
memberId: c.member_id,
|
||||
userId,
|
||||
deviceId: c.device_id,
|
||||
connectionId: c.connection_id,
|
||||
};
|
||||
let payload: SignallingPayload;
|
||||
switch (type) {
|
||||
case SignallingEventType.subscribe:
|
||||
payload = { type: "subscribe" };
|
||||
break;
|
||||
case SignallingEventType.description: {
|
||||
const d = c.description as { type?: unknown; sdp?: unknown } | undefined;
|
||||
if (
|
||||
(d?.type !== "offer" && d?.type !== "answer") ||
|
||||
typeof d.sdp !== "string"
|
||||
)
|
||||
return null;
|
||||
payload = {
|
||||
type: "description",
|
||||
description: { type: d.type, sdp: d.sdp },
|
||||
track_metadata: parseTrackMetadata(c.track_metadata) ?? undefined,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case SignallingEventType.candidates:
|
||||
if (!Array.isArray(c.candidates)) return null;
|
||||
payload = {
|
||||
type: "candidates",
|
||||
candidates: c.candidates as RTCIceCandidateInit[],
|
||||
};
|
||||
break;
|
||||
case SignallingEventType.close:
|
||||
payload = {
|
||||
type: "close",
|
||||
reason: typeof c.reason === "string" ? c.reason : undefined,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return { seq: c.seq, message: { ...payload, ...common } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivers the messages of one sender in `seq` order. A gap is waited for
|
||||
* `SEQ_GAP_TIMEOUT_MS`, then skipped. A message below the next expected `seq`
|
||||
* came after its gap was skipped, and is dropped.
|
||||
*/
|
||||
export class SeqOrderer<T> {
|
||||
private next = 0;
|
||||
private readonly buffer = new Map<number, T>();
|
||||
private gapTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
public constructor(
|
||||
private readonly deliver: (item: T) => void,
|
||||
private readonly logger?: Logger,
|
||||
private readonly gapTimeoutMs = SEQ_GAP_TIMEOUT_MS,
|
||||
) {}
|
||||
|
||||
public push(seq: number, item: T): void {
|
||||
if (seq < this.next) {
|
||||
this.logger?.warn(`Dropping seq ${seq}, already past it`);
|
||||
return;
|
||||
}
|
||||
this.buffer.set(seq, item);
|
||||
this.flush();
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
clearTimeout(this.gapTimer);
|
||||
this.buffer.clear();
|
||||
}
|
||||
|
||||
private flush(): void {
|
||||
let item = this.buffer.get(this.next);
|
||||
while (item !== undefined) {
|
||||
this.buffer.delete(this.next);
|
||||
this.next++;
|
||||
this.deliver(item);
|
||||
item = this.buffer.get(this.next);
|
||||
}
|
||||
clearTimeout(this.gapTimer);
|
||||
this.gapTimer = undefined;
|
||||
if (this.buffer.size > 0)
|
||||
this.gapTimer = setTimeout(() => this.skipGap(), this.gapTimeoutMs);
|
||||
}
|
||||
|
||||
private skipGap(): void {
|
||||
const lowest = Math.min(...this.buffer.keys());
|
||||
this.logger?.warn(`Skipping seq ${this.next} to ${lowest - 1}`);
|
||||
this.next = lowest;
|
||||
this.flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fakes for the parts of WebRTC that jsdom lacks, and for the signalling, so
|
||||
* that the full mesh connections can run in unit tests.
|
||||
*/
|
||||
|
||||
import { vi } from "vitest";
|
||||
import { BehaviorSubject, Subject } from "rxjs";
|
||||
|
||||
import { CONTROL_CHANNEL_LABEL } from "./ControlChannel";
|
||||
import { type LocalPublication } from "./LocalMedia";
|
||||
import {
|
||||
type ConnectionMessage,
|
||||
type Peer,
|
||||
type Signalling,
|
||||
type SignallingMessage,
|
||||
type SignallingPayload,
|
||||
} from "./signalling";
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
export class FakeMediaStream {
|
||||
public readonly id: string;
|
||||
public constructor(public readonly tracks: MediaStreamTrack[] = []) {
|
||||
this.id = `stream-${nextId++}`;
|
||||
}
|
||||
public getTracks(): MediaStreamTrack[] {
|
||||
return this.tracks;
|
||||
}
|
||||
}
|
||||
|
||||
export function fakeTrack(
|
||||
kind: "audio" | "video",
|
||||
settings: MediaTrackSettings = {},
|
||||
): MediaStreamTrack {
|
||||
return {
|
||||
id: `track-${nextId++}`,
|
||||
kind,
|
||||
enabled: true,
|
||||
stop: vi.fn(),
|
||||
getSettings: () => settings,
|
||||
} as unknown as MediaStreamTrack;
|
||||
}
|
||||
|
||||
/** A data channel whose opening and incoming messages the test drives. */
|
||||
export class FakeDataChannel extends EventTarget {
|
||||
public readyState: RTCDataChannelState = "connecting";
|
||||
public readonly sent: string[] = [];
|
||||
public readonly protocol: string;
|
||||
|
||||
public constructor(
|
||||
public readonly label: string,
|
||||
public readonly init: RTCDataChannelInit = {},
|
||||
) {
|
||||
super();
|
||||
this.protocol = init.protocol ?? "";
|
||||
}
|
||||
|
||||
public send(data: string): void {
|
||||
if (this.readyState !== "open") throw new Error("The channel isn't open");
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
/** What was sent, parsed. */
|
||||
public sentMessages(): unknown[] {
|
||||
return this.sent.map((data) => JSON.parse(data) as unknown);
|
||||
}
|
||||
|
||||
public open(): void {
|
||||
this.readyState = "open";
|
||||
this.dispatchEvent(new Event("open"));
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
if (this.readyState === "closed") return;
|
||||
this.readyState = "closed";
|
||||
this.dispatchEvent(new Event("close"));
|
||||
}
|
||||
|
||||
/** A message from the peer; an object is sent as JSON. */
|
||||
public receive(data: unknown): void {
|
||||
this.dispatchEvent(
|
||||
Object.assign(new Event("message"), {
|
||||
data: typeof data === "string" ? data : JSON.stringify(data),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface FakeTransceiver {
|
||||
kind: string;
|
||||
/** Null until a local description gives it one, as in a browser. */
|
||||
mid: string | null;
|
||||
direction: RTCRtpTransceiverDirection;
|
||||
currentDirection: RTCRtpTransceiverDirection | null;
|
||||
streams: MediaStream[];
|
||||
stopped: boolean;
|
||||
stop(): void;
|
||||
sender: {
|
||||
track: MediaStreamTrack | null;
|
||||
replaceTrack: ReturnType<typeof vi.fn>;
|
||||
parameters: RTCRtpSendParameters;
|
||||
getParameters(): RTCRtpSendParameters;
|
||||
setParameters: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
receiver: { track: MediaStreamTrack };
|
||||
}
|
||||
|
||||
/** Enough of `RTCPeerConnection` for one side of a connection, driven by the test. */
|
||||
export class FakePeerConnection extends EventTarget {
|
||||
public static instances: FakePeerConnection[] = [];
|
||||
|
||||
public signalingState: RTCSignalingState = "stable";
|
||||
public connectionState: RTCPeerConnectionState = "new";
|
||||
public iceConnectionState: RTCIceConnectionState = "new";
|
||||
public localDescription: RTCSessionDescriptionInit | null = null;
|
||||
public remoteDescription: RTCSessionDescriptionInit | null = null;
|
||||
public readonly transceivers: FakeTransceiver[] = [];
|
||||
public readonly addedCandidates: RTCIceCandidateInit[] = [];
|
||||
public readonly dataChannels: FakeDataChannel[] = [];
|
||||
public offers = 0;
|
||||
public closed = false;
|
||||
|
||||
public constructor(public readonly config: RTCConfiguration) {
|
||||
super();
|
||||
FakePeerConnection.instances.push(this);
|
||||
}
|
||||
|
||||
public addTransceiver(
|
||||
kind: string,
|
||||
init: RTCRtpTransceiverInit,
|
||||
): FakeTransceiver {
|
||||
const transceiver: FakeTransceiver = {
|
||||
kind,
|
||||
mid: null,
|
||||
direction: init.direction ?? "sendrecv",
|
||||
currentDirection: null,
|
||||
streams: init.streams ?? [],
|
||||
stopped: false,
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
},
|
||||
sender: {
|
||||
track: null,
|
||||
replaceTrack: vi.fn(async (track: MediaStreamTrack | null) => {
|
||||
transceiver.sender.track = track;
|
||||
return Promise.resolve();
|
||||
}),
|
||||
parameters: {
|
||||
encodings: [{}],
|
||||
} as unknown as RTCRtpSendParameters,
|
||||
getParameters: (): RTCRtpSendParameters =>
|
||||
structuredClone(transceiver.sender.parameters),
|
||||
setParameters: vi.fn(async (parameters: RTCRtpSendParameters) => {
|
||||
transceiver.sender.parameters = parameters;
|
||||
return Promise.resolve();
|
||||
}),
|
||||
},
|
||||
receiver: { track: fakeTrack(kind === "audio" ? "audio" : "video") },
|
||||
};
|
||||
this.transceivers.push(transceiver);
|
||||
return transceiver;
|
||||
}
|
||||
|
||||
public createDataChannel(
|
||||
label: string,
|
||||
init?: RTCDataChannelInit,
|
||||
): FakeDataChannel {
|
||||
const channel = new FakeDataChannel(label, init);
|
||||
this.dataChannels.push(channel);
|
||||
return channel;
|
||||
}
|
||||
|
||||
/** Our side of the control channel. */
|
||||
public control(): FakeDataChannel {
|
||||
const channel = this.dataChannels.find(
|
||||
(c) => c.label === CONTROL_CHANNEL_LABEL,
|
||||
);
|
||||
if (channel === undefined) throw new Error("No control channel");
|
||||
return channel;
|
||||
}
|
||||
|
||||
/** A data channel the remote side created, as `ondatachannel` brings it. */
|
||||
public announceDataChannel(label: string, protocol = ""): FakeDataChannel {
|
||||
const channel = new FakeDataChannel(label, { protocol });
|
||||
this.dispatchEvent(Object.assign(new Event("datachannel"), { channel }));
|
||||
return channel;
|
||||
}
|
||||
|
||||
public getTransceivers(): FakeTransceiver[] {
|
||||
return this.transceivers;
|
||||
}
|
||||
|
||||
public getSenders(): FakeTransceiver["sender"][] {
|
||||
return this.transceivers.map((t) => t.sender);
|
||||
}
|
||||
|
||||
public async createOffer(): Promise<RTCSessionDescriptionInit> {
|
||||
return Promise.resolve({ type: "offer", sdp: `offer-${++this.offers}` });
|
||||
}
|
||||
|
||||
public async createAnswer(): Promise<RTCSessionDescriptionInit> {
|
||||
return Promise.resolve({ type: "answer", sdp: "answer" });
|
||||
}
|
||||
|
||||
public async setLocalDescription(
|
||||
description: RTCSessionDescriptionInit,
|
||||
): Promise<void> {
|
||||
this.localDescription = description;
|
||||
this.transceivers.forEach((t, i) => (t.mid ??= String(i)));
|
||||
this.signalingState =
|
||||
description.type === "offer" ? "have-local-offer" : "stable";
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public async setRemoteDescription(
|
||||
description: RTCSessionDescriptionInit,
|
||||
): Promise<void> {
|
||||
this.remoteDescription = description;
|
||||
this.signalingState =
|
||||
description.type === "offer" ? "have-remote-offer" : "stable";
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
|
||||
if (candidate.candidate === "bad")
|
||||
return Promise.reject(new Error("bad candidate"));
|
||||
this.addedCandidates.push(candidate);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public async getStats(): Promise<RTCStatsReport> {
|
||||
return Promise.resolve(new Map() as unknown as RTCStatsReport);
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this.closed = true;
|
||||
this.signalingState = "closed";
|
||||
this.connectionState = "closed";
|
||||
for (const channel of this.dataChannels) channel.close();
|
||||
}
|
||||
|
||||
/** Gathers a local candidate; null ends the gathering. */
|
||||
public gather(candidate: RTCIceCandidateInit | null): void {
|
||||
this.dispatchEvent(
|
||||
Object.assign(new Event("icecandidate"), {
|
||||
candidate: candidate && { toJSON: () => candidate },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public setConnectionState(state: RTCPeerConnectionState): void {
|
||||
this.connectionState = state;
|
||||
this.dispatchEvent(new Event("connectionstatechange"));
|
||||
}
|
||||
|
||||
/** A track the remote side sends, as the next negotiation would bring it. */
|
||||
public receive(kind: "audio" | "video", mid: string): FakeTransceiver {
|
||||
const transceiver = this.addTransceiver(kind, { direction: "recvonly" });
|
||||
transceiver.currentDirection = "recvonly";
|
||||
transceiver.mid = mid;
|
||||
return transceiver;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeAudioContext {
|
||||
public destination = {};
|
||||
public createMediaStreamSource(): object {
|
||||
return { connect: vi.fn(), disconnect: vi.fn() };
|
||||
}
|
||||
public createAnalyser(): object {
|
||||
return {
|
||||
fftSize: 512,
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getFloatTimeDomainData: (samples: Float32Array): void => {
|
||||
samples.fill(0);
|
||||
},
|
||||
};
|
||||
}
|
||||
public createGain(): object {
|
||||
return { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() };
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces the WebRTC and Web Audio globals jsdom doesn't have. Undo with `vi.unstubAllGlobals()`. */
|
||||
export function installWebRtcFakes(): void {
|
||||
FakePeerConnection.instances = [];
|
||||
vi.stubGlobal("RTCPeerConnection", FakePeerConnection);
|
||||
vi.stubGlobal("MediaStream", FakeMediaStream);
|
||||
vi.stubGlobal("AudioContext", FakeAudioContext);
|
||||
}
|
||||
|
||||
/** The last peer connection created. */
|
||||
export function lastPeerConnection(): FakePeerConnection {
|
||||
const pc = FakePeerConnection.instances.at(-1);
|
||||
if (pc === undefined) throw new Error("No peer connection was created");
|
||||
return pc;
|
||||
}
|
||||
|
||||
/** Lets every queued signalling operation run. */
|
||||
export async function settle(): Promise<void> {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
export const peer: Peer = {
|
||||
userId: "@peer:example.org",
|
||||
deviceId: "PEERDEVICE",
|
||||
memberId: "peerMember",
|
||||
};
|
||||
|
||||
export interface FakeSignalling extends Signalling {
|
||||
send: ReturnType<typeof vi.fn<Signalling["send"]>>;
|
||||
messages$: Subject<SignallingMessage>;
|
||||
/** The payloads sent so far, optionally only those of one type. */
|
||||
sent(type?: string): SignallingPayload[];
|
||||
/** The connection ids sent with the payloads, in the same order. */
|
||||
sentConnectionIds(type?: string): string[];
|
||||
}
|
||||
|
||||
export function fakeSignalling(): FakeSignalling {
|
||||
const send = vi.fn<Signalling["send"]>();
|
||||
const calls = (type?: string): Parameters<Signalling["send"]>[] =>
|
||||
send.mock.calls.filter(([, , p]) => type === undefined || p.type === type);
|
||||
return {
|
||||
send,
|
||||
messages$: new Subject(),
|
||||
sent: (type) => calls(type).map(([, , p]) => p),
|
||||
sentConnectionIds: (type) => calls(type).map(([, id]) => id),
|
||||
};
|
||||
}
|
||||
|
||||
/** A to-device message of a connection, as signalling delivers it. */
|
||||
export type PeerMessage = ConnectionMessage & Omit<SignallingMessage, "type">;
|
||||
|
||||
/** A to-device message from `peer` on a connection. */
|
||||
export function fromPeer<P extends SignallingPayload>(
|
||||
connectionId: string,
|
||||
payload: P,
|
||||
): P & Omit<SignallingMessage, "type"> {
|
||||
return { ...payload, ...peer, connectionId };
|
||||
}
|
||||
|
||||
/** The local media a publisher connection reads. */
|
||||
export function fakeLocalMedia(): {
|
||||
publications$: BehaviorSubject<LocalPublication[]>;
|
||||
publishing$: BehaviorSubject<boolean>;
|
||||
} {
|
||||
return {
|
||||
publications$: new BehaviorSubject<LocalPublication[]>([]),
|
||||
publishing$: new BehaviorSubject(true),
|
||||
};
|
||||
}
|
||||
|
||||
export function fakePublication(
|
||||
kind: "audio" | "video",
|
||||
{
|
||||
source = kind === "audio" ? "microphone" : "camera",
|
||||
stream = new FakeMediaStream() as unknown as MediaStream,
|
||||
}: Partial<Pick<LocalPublication, "source" | "stream">> = {},
|
||||
): LocalPublication & {
|
||||
track$: BehaviorSubject<MediaStreamTrack | null>;
|
||||
muted$: BehaviorSubject<boolean>;
|
||||
} {
|
||||
const muted$ = new BehaviorSubject(false);
|
||||
return {
|
||||
source,
|
||||
kind,
|
||||
stream,
|
||||
track$: new BehaviorSubject<MediaStreamTrack | null>(fakeTrack(kind)),
|
||||
muted$,
|
||||
mediaTrack: { muted$ } as unknown as LocalPublication["mediaTrack"],
|
||||
};
|
||||
}
|
||||
@@ -101,8 +101,11 @@ export class ConnectionMap {
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
connectionFactory: ConnectionFactory;
|
||||
/** The transport we publish on; the one connection with the publisher role. */
|
||||
localTransport: TransportLocator;
|
||||
/**
|
||||
* The transport we publish on; the one connection with the publisher role.
|
||||
* Null for a backend that only subscribes, while another backend publishes.
|
||||
*/
|
||||
localTransport: TransportLocator | null;
|
||||
/** Every transport some member is on, deduplicated against the local one. */
|
||||
remoteTransports$: Behavior<Epoch<TransportLocator[]>>;
|
||||
logger: Logger;
|
||||
@@ -128,7 +131,11 @@ export function createConnectionMap$({
|
||||
scope.behavior(
|
||||
remoteTransports$.pipe(
|
||||
mapEpoch((remoteTransports) =>
|
||||
removeDuplicateTransports([localTransport, ...remoteTransports]),
|
||||
removeDuplicateTransports(
|
||||
localTransport
|
||||
? [localTransport, ...remoteTransports]
|
||||
: remoteTransports,
|
||||
),
|
||||
),
|
||||
tap((transports) =>
|
||||
logger.debug("localAndRemoteTransports$ = ", transports),
|
||||
@@ -136,7 +143,7 @@ export function createConnectionMap$({
|
||||
),
|
||||
);
|
||||
|
||||
const localTransportKey = keyFor(localTransport);
|
||||
const localTransportKey = localTransport && keyFor(localTransport);
|
||||
|
||||
const connections$ = scope.behavior(
|
||||
localAndRemoteTransports$.pipe(
|
||||
|
||||
@@ -115,9 +115,14 @@ export function createLivekitBackend(
|
||||
);
|
||||
|
||||
// A new map, in a scope of its own, whenever the local transport changes:
|
||||
// the publisher role sits on one connection, and which one is fixed per map
|
||||
// the publisher role sits on one connection, and which one is fixed per map.
|
||||
// Without a local transport the map only subscribes, for when another
|
||||
// backend publishes; boxed so that `mapScoped` builds a map for null too
|
||||
const boxedLocalTransport$ = scope.behavior(
|
||||
localTransport$.pipe(map((localTransport) => ({ localTransport }))),
|
||||
);
|
||||
const connectionMap$ = scope.behavior<Epoch<ConnectionMap>>(
|
||||
mapScoped(scope, localTransport$, (mapScope, localTransport) =>
|
||||
mapScoped(scope, boxedLocalTransport$, (mapScope, { localTransport }) =>
|
||||
createConnectionMap$({
|
||||
scope: mapScope,
|
||||
connectionFactory,
|
||||
@@ -139,12 +144,15 @@ export function createLivekitBackend(
|
||||
logger,
|
||||
});
|
||||
|
||||
// Probed once: the answer cannot change within a session
|
||||
const homeserverSupportsDelegation = supportsDelegation(
|
||||
`${client.baseUrl}/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`,
|
||||
"homeserver",
|
||||
logger,
|
||||
);
|
||||
// Probed once, when first needed: the answer cannot change within a
|
||||
// session, and a backend that only subscribes never needs it
|
||||
let homeserverProbe: Promise<boolean> | undefined;
|
||||
const homeserverSupportsDelegation = async (): Promise<boolean> =>
|
||||
(homeserverProbe ??= supportsDelegation(
|
||||
`${client.baseUrl}/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`,
|
||||
"homeserver",
|
||||
logger,
|
||||
));
|
||||
|
||||
const ownServerName = serverNameOf(ownMembershipIdentity.userId);
|
||||
|
||||
@@ -176,7 +184,7 @@ export function createLivekitBackend(
|
||||
`transport ${flow.serviceUrl}`,
|
||||
logger,
|
||||
)
|
||||
: homeserverSupportsDelegation;
|
||||
: homeserverSupportsDelegation();
|
||||
}
|
||||
localTransport$.next(locator);
|
||||
// The membership is only sent once the publisher connection holds its
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
padding-top: env(safe-area-inset-top);
|
||||
opacity: 1;
|
||||
transition: opacity 0.15s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Pseudo-element for the gradient background */
|
||||
@@ -52,7 +53,7 @@
|
||||
". . ."
|
||||
"primaryButton title secondaryButton"
|
||||
". . .";
|
||||
place-items: center;
|
||||
place-items: center stretch;
|
||||
column-gap: var(--cpd-space-2x);
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ import { ReactionsOverlay } from "./ReactionsOverlay";
|
||||
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
|
||||
import {
|
||||
createRTCSlot,
|
||||
FULL_MESH_TRANSPORT_TYPE,
|
||||
type RTCParticipationOptions,
|
||||
type RTCSlotOptions,
|
||||
constant,
|
||||
@@ -84,6 +85,7 @@ import {
|
||||
cameraFramerate,
|
||||
cameraResolution,
|
||||
customTransport,
|
||||
fullMeshPublishing,
|
||||
echoCancellationSetting,
|
||||
matrixRTCMode as matrixRTCModeSetting,
|
||||
noiseSuppressionSetting,
|
||||
@@ -823,7 +825,9 @@ export function rtcParticipationOptions(
|
||||
delegatedDelayedLeave: session.delegated_delayed_leave,
|
||||
},
|
||||
mediaQuality: config.media_quality,
|
||||
transport: customTransport.value$.value ?? undefined,
|
||||
transport: fullMeshPublishing.value$.value
|
||||
? { type: FULL_MESH_TRANSPORT_TYPE }
|
||||
: (customTransport.value$.value ?? undefined),
|
||||
fallbackTransport: config.livekit && {
|
||||
type: "livekit",
|
||||
livekit_service_url: config.livekit.livekit_service_url,
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
|
||||
matrixRTCMode as matrixRTCModeSetting,
|
||||
customTransport as customTransportSetting,
|
||||
fullMeshPublishing as fullMeshPublishingSetting,
|
||||
advancedScreenShare as advancedScreenShareSetting,
|
||||
screenShareResolution as screenShareResolutionSetting,
|
||||
screenShareFramerate as screenShareFramerateSetting,
|
||||
@@ -68,6 +69,7 @@ import { Slider } from "../Slider";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
import {
|
||||
authenticateWithTransport,
|
||||
FULL_MESH_TRANSPORT_TYPE,
|
||||
type TransportMetadata,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
@@ -175,6 +177,9 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
const [customTransportUpdateError, setCustomTransportUpdateError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [fullMeshPublishing, setFullMeshPublishing] = useSetting(
|
||||
fullMeshPublishingSetting,
|
||||
);
|
||||
const [customTransport, setCustomTransport] = useSetting(
|
||||
customTransportSetting,
|
||||
);
|
||||
@@ -417,6 +422,17 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="fullMeshPublishing"
|
||||
type="checkbox"
|
||||
checked={fullMeshPublishing}
|
||||
label={t("developer_mode.full_mesh_publishing")}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>): void =>
|
||||
setFullMeshPublishing(event.target.checked)
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="debugTileLayout"
|
||||
@@ -665,11 +681,21 @@ function transportText(transport: UnstableLivekitTransport | null): string {
|
||||
const TransportBox: FC<TransportInfo> = ({ transport, local }) => {
|
||||
const { t } = useTranslation();
|
||||
const resolved = useBehavior(transport.resolved$);
|
||||
const fullMesh = transport.type === FULL_MESH_TRANSPORT_TYPE;
|
||||
return (
|
||||
<div className={styles.livekit_room_box}>
|
||||
<h4>{t("developer_mode.livekit_sfu", { url: transport.id })}</h4>
|
||||
<h4>
|
||||
{fullMesh
|
||||
? t("developer_mode.full_mesh_transport", {
|
||||
device: transport.raw.device_id,
|
||||
})
|
||||
: t("developer_mode.livekit_sfu", { url: transport.id })}
|
||||
</h4>
|
||||
<p>
|
||||
{t("developer_mode.livekit_server_info")}({local ? "local" : "remote"})
|
||||
{fullMesh
|
||||
? t("developer_mode.full_mesh_peer_connections")
|
||||
: t("developer_mode.livekit_server_info")}
|
||||
({local ? "local" : "remote"})
|
||||
</p>
|
||||
<pre className={styles.pre}>
|
||||
{resolved === undefined
|
||||
|
||||
@@ -51,6 +51,43 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_2_"
|
||||
id="fullMeshPublishing"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
for="fullMeshPublishing"
|
||||
>
|
||||
<div
|
||||
class="_checkbox_1bd8c0"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="#000"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m20 6-11 11-5-5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
Publish on full mesh (MSC4575) instead of LiveKit
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="_fieldRow_1bd8c0"
|
||||
>
|
||||
<div
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_3_"
|
||||
id="debugTileLayout"
|
||||
type="checkbox"
|
||||
/>
|
||||
@@ -87,7 +124,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_3_"
|
||||
aria-describedby="_r_4_"
|
||||
id="showConnectionStats"
|
||||
type="checkbox"
|
||||
/>
|
||||
@@ -124,7 +161,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_4_"
|
||||
aria-describedby="_r_5_"
|
||||
id="muteAllAudio"
|
||||
type="checkbox"
|
||||
/>
|
||||
@@ -162,7 +199,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_5_"
|
||||
aria-describedby="_r_6_"
|
||||
id="alwaysShowIphoneEarpiece"
|
||||
type="checkbox"
|
||||
/>
|
||||
@@ -199,7 +236,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_6_"
|
||||
aria-describedby="_r_7_"
|
||||
id="enableLivekitExtendedLogs"
|
||||
type="checkbox"
|
||||
/>
|
||||
@@ -237,7 +274,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
>
|
||||
<label
|
||||
class="_label_1o4d9_60"
|
||||
for="radix-_r_7_"
|
||||
for="radix-_r_8_"
|
||||
>
|
||||
Custom transport JSON
|
||||
</label>
|
||||
@@ -245,9 +282,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_controls_17lij_8"
|
||||
>
|
||||
<input
|
||||
aria-describedby="radix-_r_8_"
|
||||
aria-describedby="radix-_r_9_"
|
||||
class="_control_d83jn_10"
|
||||
id="radix-_r_7_"
|
||||
id="radix-_r_8_"
|
||||
name="input"
|
||||
title=""
|
||||
value=""
|
||||
@@ -255,7 +292,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
</div>
|
||||
<span
|
||||
class="_message_1o4d9_86 _help-message_1o4d9_92"
|
||||
id="radix-_r_8_"
|
||||
id="radix-_r_9_"
|
||||
>
|
||||
Currently, no overwrite is set. Url from config is used.
|
||||
</span>
|
||||
@@ -285,10 +322,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_container_1ug7n_10"
|
||||
>
|
||||
<input
|
||||
aria-describedby="radix-_r_a_ radix-_r_c_"
|
||||
aria-describedby="radix-_r_b_ radix-_r_d_"
|
||||
checked=""
|
||||
class="_input_1ug7n_18"
|
||||
id="radix-_r_9_"
|
||||
id="radix-_r_a_"
|
||||
name="_r_0_"
|
||||
title=""
|
||||
type="radio"
|
||||
@@ -304,13 +341,13 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
>
|
||||
<label
|
||||
class="_label_1o4d9_60"
|
||||
for="radix-_r_9_"
|
||||
for="radix-_r_a_"
|
||||
>
|
||||
Compatibility: state events & multi SFU
|
||||
</label>
|
||||
<span
|
||||
class="_message_1o4d9_86 _help-message_1o4d9_92"
|
||||
id="radix-_r_a_"
|
||||
id="radix-_r_b_"
|
||||
>
|
||||
Compatible with homeservers that do not support sticky events (but all other EC clients are v0.17.0 or later)
|
||||
</span>
|
||||
@@ -326,9 +363,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_container_1ug7n_10"
|
||||
>
|
||||
<input
|
||||
aria-describedby="radix-_r_a_ radix-_r_c_"
|
||||
aria-describedby="radix-_r_b_ radix-_r_d_"
|
||||
class="_input_1ug7n_18"
|
||||
id="radix-_r_b_"
|
||||
id="radix-_r_c_"
|
||||
name="_r_0_"
|
||||
title=""
|
||||
type="radio"
|
||||
@@ -344,13 +381,13 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
>
|
||||
<label
|
||||
class="_label_1o4d9_60"
|
||||
for="radix-_r_b_"
|
||||
for="radix-_r_c_"
|
||||
>
|
||||
Matrix 2.0: sticky events & multi SFU
|
||||
</label>
|
||||
<span
|
||||
class="_message_1o4d9_86 _help-message_1o4d9_92"
|
||||
id="radix-_r_c_"
|
||||
id="radix-_r_d_"
|
||||
>
|
||||
Compatible only with homservers supporting sticky events and all EC clients v0.17.0 or later
|
||||
</span>
|
||||
@@ -415,7 +452,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_i_"
|
||||
aria-describedby="_r_j_"
|
||||
id="cameraToggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
@@ -445,7 +482,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
</label>
|
||||
<p
|
||||
class="_description_1bd8c0"
|
||||
id="_r_i_"
|
||||
id="_r_j_"
|
||||
>
|
||||
Configure resolution, framerate, bitrate, and codec for camera video. Changes apply on next call join.
|
||||
</p>
|
||||
@@ -467,7 +504,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_j_"
|
||||
aria-describedby="_r_k_"
|
||||
id="screenShareToggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
@@ -497,7 +534,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
</label>
|
||||
<p
|
||||
class="_description_1bd8c0"
|
||||
id="_r_j_"
|
||||
id="_r_k_"
|
||||
>
|
||||
Configure resolution, framerate, bitrate, and codec for screen sharing
|
||||
</p>
|
||||
@@ -522,7 +559,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_k_"
|
||||
aria-describedby="_r_l_"
|
||||
checked=""
|
||||
id="echoCancellation"
|
||||
type="checkbox"
|
||||
@@ -560,7 +597,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_l_"
|
||||
aria-describedby="_r_m_"
|
||||
checked=""
|
||||
id="noiseSuppression"
|
||||
type="checkbox"
|
||||
@@ -598,7 +635,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_m_"
|
||||
aria-describedby="_r_n_"
|
||||
checked=""
|
||||
id="autoGainControl"
|
||||
type="checkbox"
|
||||
|
||||
@@ -159,6 +159,15 @@ export const customTransport = new Setting<UnstableLivekitTransport | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
/**
|
||||
* Publish on the full mesh transport (MSC4575) instead of LiveKit. Members on
|
||||
* either transport are received whatever this says.
|
||||
*/
|
||||
export const fullMeshPublishing = new Setting<boolean>(
|
||||
"full-mesh-publishing",
|
||||
false,
|
||||
);
|
||||
|
||||
export type VideoCodec = "vp8" | "vp9" | "h264" | "av1";
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@ describe("widget", () => {
|
||||
receiveState,
|
||||
sendToDevice: sendRecvToDevice,
|
||||
receiveToDevice: sendRecvToDevice,
|
||||
turnServers: false,
|
||||
turnServers: true,
|
||||
rtcTransports: true,
|
||||
rtcLivekitGetToken: true,
|
||||
rtcLivekitDelegateDelayedLeave: true,
|
||||
|
||||
+11
-2
@@ -21,8 +21,12 @@ import type { IWidgetApiRequest } from "matrix-widget-api";
|
||||
import { LazyEventEmitter } from "./LazyEventEmitter";
|
||||
import { getUrlParams } from "./UrlParams";
|
||||
import { Config } from "./config/Config";
|
||||
import { seedSettingsFromConfig } from "./settings/settings";
|
||||
import {
|
||||
fullMeshPublishing,
|
||||
seedSettingsFromConfig,
|
||||
} from "./settings/settings";
|
||||
import { ElementCallReactionEventType } from "./reactions";
|
||||
import { fullMeshSignallingEventTypes } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
// Subset of the actions in element-web
|
||||
export enum ElementWidgetActions {
|
||||
@@ -162,6 +166,10 @@ export const initializeWidget = (
|
||||
EventType.CallSDPStreamMetadataChangedPrefix,
|
||||
EventType.CallReplaces,
|
||||
EventType.CallEncryptionKeysPrefix,
|
||||
// Full mesh signalling (MSC4575), only where it is turned on: Element
|
||||
// Web doesn't auto-approve these for Element Call yet, and would ask
|
||||
// the user on every call
|
||||
...(fullMeshPublishing.getValue() ? fullMeshSignallingEventTypes : []),
|
||||
];
|
||||
|
||||
const client = createRoomWidgetClient(
|
||||
@@ -173,7 +181,8 @@ export const initializeWidget = (
|
||||
receiveState,
|
||||
sendToDevice: sendRecvToDevice,
|
||||
receiveToDevice: sendRecvToDevice,
|
||||
turnServers: false,
|
||||
// Full mesh connects through the homeserver's TURN server
|
||||
turnServers: true,
|
||||
rtcTransports: true,
|
||||
rtcLivekitGetToken: true,
|
||||
rtcLivekitDelegateDelayedLeave: true,
|
||||
|
||||
Reference in New Issue
Block a user