mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
306 lines
9.5 KiB
TypeScript
306 lines
9.5 KiB
TypeScript
/*
|
|
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 MatrixClient, type Room } from "matrix-js-sdk";
|
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
|
import {
|
|
type MatrixRTCSession as JsSdkRTCSession,
|
|
MatrixRTCSessionEvent,
|
|
MembershipManagerEvent,
|
|
} from "matrix-js-sdk/lib/matrixrtc";
|
|
import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
|
|
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
import {
|
|
BehaviorSubject,
|
|
combineLatest,
|
|
filter,
|
|
from,
|
|
fromEvent,
|
|
map,
|
|
Observable,
|
|
} from "rxjs";
|
|
|
|
import { defaultSessionTimings, MatrixRTCMode } from "./config";
|
|
import { E2eeType } from "./encryption";
|
|
import { type Behavior } from "./reactive/Behavior";
|
|
import { type ObservableScope } from "./reactive/ObservableScope";
|
|
import { generateItems } from "./reactive/observable";
|
|
import { mapScoped } from "./utils/mapScoped";
|
|
import {
|
|
type DataMessage,
|
|
type LocalRTCMember,
|
|
type RemoteRTCMember,
|
|
type RTCMember,
|
|
type RTCParticipation,
|
|
type RTCParticipationOptions,
|
|
type RTCSlot,
|
|
type RTCSlotOptions,
|
|
} from "./api";
|
|
import {
|
|
type MediaBackend,
|
|
type MediaBackendContext,
|
|
type MediaKey,
|
|
} from "./media-backend/api";
|
|
import { createLivekitBackend } from "./media-backend/livekit/LivekitBackend";
|
|
import { createHomeserverConnected$ } from "./matrixrtc/HomeserverConnected";
|
|
import { joinJsSdkSession } from "./matrixrtc/joinJsSdkSession";
|
|
import {
|
|
createLocalMembership$,
|
|
type PreparedTransport,
|
|
} from "./matrixrtc/LocalMembership";
|
|
import { discoverLocalTransport } from "./matrixrtc/LocalTransport";
|
|
import {
|
|
createLocalRTCMember,
|
|
createRemoteRTCMember,
|
|
} from "./matrixrtc/Members";
|
|
import { createKeyRotationSuppressed$ } from "./matrixrtc/JsRtcSessionBehaviors";
|
|
import { fatalError, participationStatus } from "./matrixrtc/status";
|
|
import { type TransportRegistry } from "./matrixrtc/Transports";
|
|
|
|
/** What the slot lends a participation. */
|
|
export interface ParticipationDeps {
|
|
client: MatrixClient;
|
|
room: Room;
|
|
slot: RTCSlot;
|
|
jsSdkSession: JsSdkRTCSession;
|
|
members$: Behavior<RTCMember[]>;
|
|
transports: TransportRegistry;
|
|
slotOptions: Pick<RTCSlotOptions, "matrixRTCMode" | "encryptionSystem">;
|
|
logger: Logger;
|
|
}
|
|
|
|
/** The participation, plus what the slot reads from it without exposing it. */
|
|
export interface ParticipationInternals {
|
|
participation: RTCParticipation;
|
|
connections$: MediaBackend["connections$"];
|
|
}
|
|
|
|
/**
|
|
* Our span in a slot: the backend, the local transport, the join state
|
|
* machine and the members with the media it carries for them. Starts joining
|
|
* at once and ends with `leave()`, which also ends `scope`.
|
|
*/
|
|
export function createRTCParticipation(
|
|
scope: ObservableScope,
|
|
{
|
|
client,
|
|
room,
|
|
slot,
|
|
jsSdkSession,
|
|
members$,
|
|
transports,
|
|
slotOptions: { matrixRTCMode, encryptionSystem },
|
|
logger: parentLogger,
|
|
}: ParticipationDeps,
|
|
options: RTCParticipationOptions,
|
|
): ParticipationInternals {
|
|
const logger = parentLogger.getChild("[RTCParticipation]");
|
|
const userId = client.getUserId()!;
|
|
const deviceId = client.getDeviceId()!;
|
|
const timings = { ...defaultSessionTimings, ...options.timings };
|
|
|
|
const ownMembershipIdentity: CallMembershipIdentityParts = {
|
|
userId,
|
|
deviceId,
|
|
// A pre-sticky membership names itself `${userId}:${deviceId}`, and the
|
|
// key transport stamps this id into every key event, so a uuid there
|
|
// would name a member no peer can resolve
|
|
memberId:
|
|
matrixRTCMode === MatrixRTCMode.Matrix_2_0
|
|
? uuidv4()
|
|
: `${userId}:${deviceId}`,
|
|
};
|
|
|
|
const backend = createBackend(scope, client, options, matrixRTCMode, {
|
|
roomId: room.roomId,
|
|
ownMembershipIdentity,
|
|
publish: options.publish,
|
|
encryptionSystem,
|
|
mediaKeys$: mediaKeys$(jsSdkSession),
|
|
timings,
|
|
logger,
|
|
});
|
|
|
|
const preparedTransport$ = from(
|
|
discoverLocalTransport(client, backend.transportType, options, logger).then(
|
|
async (transport): Promise<PreparedTransport> => ({
|
|
transport,
|
|
...(await backend.prepareLocalTransport(transport)),
|
|
}),
|
|
),
|
|
);
|
|
|
|
const localMembership = createLocalMembership$({
|
|
scope,
|
|
local: backend.local,
|
|
delegateDelayedLeave: async (delayId) =>
|
|
backend.delegateDelayedLeave(delayId),
|
|
preparedTransport$,
|
|
homeserverConnected: createHomeserverConnected$(
|
|
scope,
|
|
client,
|
|
jsSdkSession,
|
|
timings.syncDisconnectGracePeriodMs,
|
|
),
|
|
joinMatrixRTC: (transport, delayedLeave) =>
|
|
joinJsSdkSession(jsSdkSession, ownMembershipIdentity, transport, {
|
|
encryptMedia: encryptionSystem.kind !== E2eeType.NONE,
|
|
matrixRTCMode,
|
|
sendNotificationType: options.sendNotificationType,
|
|
applicationData: options.applicationData,
|
|
timings,
|
|
delayedLeave,
|
|
}),
|
|
membershipManagerError$: fromEvent(
|
|
jsSdkSession,
|
|
MatrixRTCSessionEvent.MembershipManagerError,
|
|
),
|
|
matrixRTCSession: jsSdkSession,
|
|
delayId$: scope.behavior(
|
|
(
|
|
fromEvent(
|
|
jsSdkSession,
|
|
MembershipManagerEvent.DelayIdChanged,
|
|
// The re-emitted event carries the original emitter as the second argument
|
|
) as Observable<[string | undefined, IMembershipManager]>
|
|
).pipe(map(([delayId]) => delayId ?? null)),
|
|
jsSdkSession.delayId ?? null,
|
|
),
|
|
timings,
|
|
logger,
|
|
});
|
|
|
|
const remoteMembers$ = scope.behavior<RemoteRTCMember[]>(
|
|
members$.pipe(
|
|
generateItems(
|
|
"RTCParticipation remoteMembers",
|
|
function* (members) {
|
|
for (const member of members)
|
|
if (!member.local) yield { keys: [member] as const, data: member };
|
|
},
|
|
(memberScope, member$) =>
|
|
createRemoteRTCMember(
|
|
member$.value,
|
|
backend.mediaFor(memberScope, member$.value),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
// Matched by the identity we joined with: a stale membership of ours from
|
|
// an earlier participation is `local` in the slot but is not this one
|
|
const ownMember$ = scope.behavior(
|
|
members$.pipe(
|
|
map(
|
|
(members) =>
|
|
members.find(
|
|
(member) =>
|
|
member.local &&
|
|
member.memberId === ownMembershipIdentity.memberId,
|
|
) ?? null,
|
|
),
|
|
),
|
|
);
|
|
const localMember$ = scope.behavior<LocalRTCMember | null>(
|
|
mapScoped(scope, ownMember$, (_memberScope, member) =>
|
|
createLocalRTCMember(member, backend.local),
|
|
).pipe(map((member) => member ?? null)),
|
|
);
|
|
|
|
// A packet from an identity that is not a member is dropped, so a host only
|
|
// ever hears from attested members
|
|
const data$ = backend.data$.pipe(
|
|
map(({ senderId, topic, text }): DataMessage | null => {
|
|
const member = remoteMembers$.value.find(
|
|
(m) => m.rtcBackendIdentity === senderId,
|
|
);
|
|
if (member === undefined) {
|
|
logger.warn(`Dropping data from ${senderId}: not a member`);
|
|
return null;
|
|
}
|
|
return { member, topic, text };
|
|
}),
|
|
filter((message) => message !== null),
|
|
);
|
|
|
|
const left$ = new BehaviorSubject(false);
|
|
const status$ = scope.behavior(
|
|
combineLatest(
|
|
[
|
|
localMembership.state$,
|
|
localMembership.connected$,
|
|
localMembership.reconnecting$,
|
|
left$,
|
|
],
|
|
participationStatus,
|
|
),
|
|
);
|
|
|
|
const leave = (): void => {
|
|
if (left$.value) return;
|
|
left$.next(true);
|
|
localMembership.leave();
|
|
scope.end();
|
|
};
|
|
|
|
return {
|
|
connections$: backend.connections$,
|
|
participation: {
|
|
slot,
|
|
leave,
|
|
status$,
|
|
connected$: localMembership.connected$,
|
|
reconnecting$: localMembership.reconnecting$,
|
|
disconnectReason$: localMembership.disconnectReason$,
|
|
fatalError$: scope.behavior(localMembership.state$.pipe(map(fatalError))),
|
|
localMember$,
|
|
remoteMembers$,
|
|
publish: async (request) => backend.local.publish(request),
|
|
unpublish: async (source) => backend.local.unpublish(source),
|
|
keyRotationSuppressed$: createKeyRotationSuppressed$(scope, jsSdkSession),
|
|
connectedTransports$: transports.connected$,
|
|
setAudioOutputDeviceId: async (deviceId) =>
|
|
backend.setAudioOutputDeviceId(deviceId),
|
|
sendData: async (topic, text) => backend.sendData(topic, text),
|
|
data$,
|
|
},
|
|
};
|
|
}
|
|
|
|
/** The host's backend, or LiveKit configured from the participation options. */
|
|
function createBackend(
|
|
scope: ObservableScope,
|
|
client: MatrixClient,
|
|
{ backend, mediaQuality }: RTCParticipationOptions,
|
|
matrixRTCMode: MatrixRTCMode,
|
|
context: MediaBackendContext,
|
|
): MediaBackend {
|
|
if (backend) return backend(scope, context);
|
|
return createLivekitBackend(scope, context, {
|
|
client,
|
|
mediaQuality,
|
|
tokenEndpoint:
|
|
matrixRTCMode === MatrixRTCMode.Matrix_2_0 ? "msc4195" : "legacy",
|
|
});
|
|
}
|
|
|
|
/** The slot's per-participant keys: the ones it already holds on subscribe, then each new one. */
|
|
function mediaKeys$(session: JsSdkRTCSession): Observable<MediaKey> {
|
|
return new Observable((subscriber) => {
|
|
const onKey = (
|
|
key: Uint8Array<ArrayBuffer>,
|
|
index: number,
|
|
_membership: CallMembershipIdentityParts,
|
|
participantId: string,
|
|
): void => subscriber.next({ participantId, index, key });
|
|
session.on(MatrixRTCSessionEvent.EncryptionKeyChanged, onKey);
|
|
session.reemitEncryptionKeys();
|
|
return () => session.off(MatrixRTCSessionEvent.EncryptionKeyChanged, onKey);
|
|
});
|
|
}
|