Files
element-call-Github/src/driver/MockRtcMatrixDriver.ts
T
Timo K. 36ed60d95c plan oxidation
- add the drivers
 - add the uniffi build toolchain
 - create the plan document
2026-09-15 19:06:04 +02:00

677 lines
19 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.
*/
/**
* An {@link RtcMatrixDriver} with a homeserver made of arrays, for tests and
* stories.
*
* It models what sync would do: it records every outbound call, echoes
* accepted sticky and state events back through the room-event sink (so our
* own membership reaches the roster like anybody else's), answers `readState`
* from `roomState`, mints tokens, reports homeserver connectivity, and hosts
* simulated peers that answer our media key with theirs.
*
* A port of the crate's `web-test-app/src/mockDriver.ts`, which is the
* source of truth for the fabricated wire shapes below.
*/
import {
FfiEventOrigin,
RtcError,
type ConnectivitySinkLike,
type FfiLivekitToken,
type FfiLivekitTokenRequest,
type FfiRtcTransport,
type FfiSendEventResponse,
type FfiToDeviceDelivery,
type FfiToDeviceRecipient,
type RoomEventSinkLike,
type StateUpdateSinkLike,
type ToDeviceSinkLike,
} from "../matrix-rtc-sdk";
import { type RtcMatrixDriver } from "./RtcMatrixDriver";
export const MOCK_LK_SERVICE_URL = "https://lk.example.org";
export const MOCK_ROOM_ID = "!room:example.org";
/** MSC4143: a slot id is `{application_type}#{id}`; Element Call's is this. */
export const MOCK_SLOT_ID = "m.call#ROOM";
export const MOCK_OWN_USER_ID = "@me:example.org";
export const MOCK_OWN_DEVICE_ID = "MYDEV";
/** Every call the crate made on the driver, in order. */
export type OutboundCall =
| {
kind: "stickyEvent";
roomId: string;
eventType: string;
content: Record<string, unknown>;
durationMs: bigint;
}
| {
kind: "stateEvent";
roomId: string;
eventType: string;
stateKey: string;
content: Record<string, unknown>;
}
| {
kind: "delayedEvent";
roomId: string;
eventType: string;
content: Record<string, unknown>;
delayMs: bigint;
stickyDurationMs: bigint | undefined;
delayId: string;
}
| {
kind: "delayedStateEvent";
roomId: string;
eventType: string;
stateKey: string;
content: Record<string, unknown>;
delayMs: bigint;
delayId: string;
}
| { kind: "restartDelayed"; roomId: string; delayId: string }
| { kind: "cancelDelayed"; roomId: string; delayId: string }
| {
kind: "delegateDelayedLeave";
roomId: string;
slotId: string;
delayId: string;
livekitServiceUrl: string | undefined;
delayMs: bigint;
}
| {
kind: "toDevice";
recipients: FfiToDeviceRecipient[];
eventType: string;
content: Record<string, unknown>;
}
| { kind: "getRtcTransports" }
| {
kind: "getLivekitToken";
url: string;
roomId: string;
slotId: string;
member: Record<string, unknown>;
legacySfuGet: boolean;
};
/** A simulated remote participant. */
export interface RemotePeer {
userId: string;
deviceId: string;
memberId: string;
/** 32 key bytes; defaults to a constant pattern. */
key?: Uint8Array;
}
/** A raw Matrix event as the crate reads it. */
export type RawEvent = Record<string, unknown>;
export interface MockRtcMatrixDriverOptions {
userId?: string;
deviceId?: string;
roomId?: string;
/** Room state answered by `readState` (the crate's session seed). */
roomState?: RawEvent[];
/** Advertised by `getRtcTransports`; an empty list is "none". */
transports?: FfiRtcTransport[];
}
export class MockRtcMatrixDriver implements RtcMatrixDriver {
/** Who this driver publishes as — not part of the contract, handy in tests. */
public readonly userId: string;
public readonly deviceId: string;
public readonly roomId: string;
public readonly outbound: OutboundCall[] = [];
/** Refuse delayed events like a homeserver without MSC4140 (404). */
public refuseDelayedEvents = false;
/** Refuse sticky events like a homeserver without MSC4354 (404). */
public refuseStickyEvents = false;
/** Make `getRtcTransports` fail rather than answer. */
public failTransportDiscovery = false;
public roomState: RawEvent[];
public transports: FfiRtcTransport[];
/** Simulated peers answer our media key with theirs (index 0). */
public readonly peers: RemotePeer[] = [];
private roomEventSink?: RoomEventSinkLike;
private toDeviceSink?: ToDeviceSinkLike;
private stateUpdateSink?: StateUpdateSinkLike;
private connectivitySink?: ConnectivitySinkLike;
private homeserverConnected = true;
private nextDelayId = 0;
private nextEventId = 0;
public constructor(options: MockRtcMatrixDriverOptions = {}) {
this.userId = options.userId ?? MOCK_OWN_USER_ID;
this.deviceId = options.deviceId ?? MOCK_OWN_DEVICE_ID;
this.roomId = options.roomId ?? MOCK_ROOM_ID;
this.roomState = options.roomState ?? [];
this.transports = options.transports ?? [
{
transportType: "livekit",
propertiesJson: JSON.stringify({
livekit_service_url: MOCK_LK_SERVICE_URL,
}),
},
];
}
// --- assertions ----------------------------------------------------------
public calls<K extends OutboundCall["kind"]>(
kind: K,
): Extract<OutboundCall, { kind: K }>[] {
return this.outbound.filter((c) => c.kind === kind) as Extract<
OutboundCall,
{ kind: K }
>[];
}
// --- outbound --------------------------------------------------------------
public async sendStickyEvent(
roomId: string,
eventType: string,
contentJson: string,
durationMs: bigint,
): Promise<FfiSendEventResponse> {
const content = parse(contentJson);
this.record({
kind: "stickyEvent",
roomId,
eventType,
content,
durationMs,
});
if (this.refuseStickyEvents)
throw new RtcError.Unsupported(
"M_UNRECOGNIZED: sticky events are not supported",
);
const eventId = this.eventId();
// The homeserver echoes our event through sync.
this.echo(
{
type: eventType,
sender: this.userId,
event_id: eventId,
room_id: roomId,
origin_server_ts: Date.now(),
msc4354_sticky: { duration_ms: Number(durationMs) },
content,
},
new FfiEventOrigin.Encrypted({ senderDeviceId: this.deviceId }),
);
return Promise.resolve({ eventId, delayId: undefined });
}
public async sendStateEvent(
roomId: string,
eventType: string,
stateKey: string,
contentJson: string,
): Promise<FfiSendEventResponse> {
const content = parse(contentJson);
this.record({ kind: "stateEvent", roomId, eventType, stateKey, content });
const eventId = this.eventId();
this.echo(
{
type: eventType,
sender: this.userId,
event_id: eventId,
room_id: roomId,
state_key: stateKey,
origin_server_ts: Date.now(),
content,
},
new FfiEventOrigin.Cleartext(),
);
return Promise.resolve({ eventId, delayId: undefined });
}
public async sendDelayedEvent(
roomId: string,
eventType: string,
contentJson: string,
delayMs: bigint,
stickyDurationMs: bigint | undefined,
): Promise<string> {
const delayId = `delay-${this.nextDelayId++}`;
this.record({
kind: "delayedEvent",
roomId,
eventType,
content: parse(contentJson),
delayMs,
stickyDurationMs,
delayId,
});
if (this.refuseDelayedEvents)
// 404 M_UNRECOGNIZED: "this homeserver will never do delayed events".
throw new RtcError.Unsupported(
"M_UNRECOGNIZED: delayed events are not supported",
);
return Promise.resolve(delayId);
}
public async sendDelayedStateEvent(
roomId: string,
eventType: string,
stateKey: string,
contentJson: string,
delayMs: bigint,
): Promise<string> {
const delayId = `delay-${this.nextDelayId++}`;
this.record({
kind: "delayedStateEvent",
roomId,
eventType,
stateKey,
content: parse(contentJson),
delayMs,
delayId,
});
return Promise.resolve(delayId);
}
public async restartDelayedEvent(
roomId: string,
delayId: string,
): Promise<void> {
this.record({ kind: "restartDelayed", roomId, delayId });
return Promise.resolve();
}
public async cancelDelayedEvent(
roomId: string,
delayId: string,
): Promise<void> {
this.record({ kind: "cancelDelayed", roomId, delayId });
return Promise.resolve();
}
public async delegateLivekitDelayedLeave(
roomId: string,
slotId: string,
_memberJson: string,
delayId: string,
livekitServiceUrl: string | undefined,
delayMs: bigint,
): Promise<void> {
this.record({
kind: "delegateDelayedLeave",
roomId,
slotId,
delayId,
livekitServiceUrl,
delayMs,
});
return Promise.resolve();
}
public async sendToDevice(
recipients: FfiToDeviceRecipient[],
eventType: string,
contentJson: string,
): Promise<FfiToDeviceDelivery[]> {
this.record({
kind: "toDevice",
recipients,
eventType,
content: parse(contentJson),
});
// Simulated peers answer with their own key.
for (const recipient of recipients) {
const peer = this.peers.find(
(p) =>
p.userId === recipient.userId && p.deviceId === recipient.deviceId,
);
if (peer) queueMicrotask(() => this.peerSendsKey(peer, 0));
}
// every recipient reachable
return Promise.resolve(
recipients.map((recipient) => ({ recipient, error: undefined })),
);
}
public async getRtcTransports(): Promise<FfiRtcTransport[]> {
this.record({ kind: "getRtcTransports" });
if (this.failTransportDiscovery)
throw new RtcError.Http("500: transports endpoint unavailable");
return Promise.resolve(this.transports);
}
public async getLivekitToken(
request: FfiLivekitTokenRequest,
): Promise<FfiLivekitToken> {
this.record({
kind: "getLivekitToken",
url: request.url,
roomId: request.roomId,
slotId: request.slotId,
member: parse(request.memberJson),
legacySfuGet: request.legacySfuGet,
});
return Promise.resolve({
jwt: "jwt-for-" + request.url,
url: request.url.replace("https", "wss"),
});
}
public async readEvents(): Promise<string[]> {
return Promise.resolve([]);
}
public async readState(
eventType: string,
stateKey: string | undefined,
): Promise<string[]> {
return Promise.resolve(
this.roomState
.filter(
(e) =>
e.type === eventType &&
(stateKey === undefined || e.state_key === stateKey),
)
.map((e) => JSON.stringify(e)),
);
}
// --- inbound sinks -----------------------------------------------------------
// The crate subscribes exactly once, when its FfiMatrixDriver is built, and
// hands over sinks; a real driver hooks client listeners onto them. The
// mock stores them so tests can emit fabricated events.
public subscribeRoomEvents(sink: RoomEventSinkLike): void {
this.roomEventSink = sink;
}
public subscribeToDeviceEvents(sink: ToDeviceSinkLike): void {
this.toDeviceSink = sink;
}
public subscribeStateUpdates(sink: StateUpdateSinkLike): void {
this.stateUpdateSink = sink;
}
public subscribeConnectivity(sink: ConnectivitySinkLike): void {
this.connectivitySink = sink;
}
public isHomeserverConnected(): boolean {
return this.homeserverConnected;
}
/** The homeserver comes or goes, as a syncing client would report it. */
public setHomeserverConnected(connected: boolean): void {
this.homeserverConnected = connected;
this.connectivitySink?.emit(connected);
}
/** Emit any room event — sticky or state; the crate dispatches on type. */
public emitRoomEvent(event: RawEvent, origin: FfiEventOrigin): boolean {
if (!this.roomEventSink)
throw new Error("The SDK has not subscribed to room events");
return this.roomEventSink.emit(JSON.stringify(event), origin);
}
/** `senderCrossSigned` is the MSC4153 verdict; peers are cross-signed by default. */
public emitToDevice(
eventType: string,
sender: string,
content: RawEvent,
origin: FfiEventOrigin,
senderCrossSigned: boolean | undefined = true,
): boolean {
if (!this.toDeviceSink)
throw new Error("The SDK has not subscribed to to-device events");
return this.toDeviceSink.emit(
eventType,
sender,
JSON.stringify(content),
origin,
senderCrossSigned,
);
}
public emitStateUpdate(events: RawEvent[]): boolean {
if (!this.stateUpdateSink)
throw new Error("The SDK has not subscribed to state updates");
return this.stateUpdateSink.emit(events.map((e) => JSON.stringify(e)));
}
// --- simulated peers --------------------------------------------------------
public addPeer(peer: RemotePeer): RemotePeer {
this.peers.push(peer);
return peer;
}
/** The peer publishes a join (on `MOCK_LK_SERVICE_URL` unless given). */
public peerJoins(
peer: RemotePeer,
opts: { lkServiceUrl?: string; durationMs?: number } = {},
): boolean {
return this.emitRoomEvent(
memberJoinEvent({
roomId: this.roomId,
userId: peer.userId,
memberId: peer.memberId,
...opts,
}),
new FfiEventOrigin.Encrypted({ senderDeviceId: peer.deviceId }),
);
}
public peerLeaves(peer: RemotePeer): boolean {
return this.emitRoomEvent(
memberLeaveEvent({
roomId: this.roomId,
userId: peer.userId,
memberId: peer.memberId,
}),
new FfiEventOrigin.Encrypted({ senderDeviceId: peer.deviceId }),
);
}
public peerSendsKey(peer: RemotePeer, index: number): boolean {
return this.emitToDevice(
"m.rtc.encryption_key",
peer.userId,
encryptionKeyContent({
roomId: this.roomId,
memberId: peer.memberId,
index,
key: peer.key,
}),
new FfiEventOrigin.Encrypted({ senderDeviceId: peer.deviceId }),
);
}
// --- internals -----------------------------------------------------------------
private record(call: OutboundCall): void {
this.outbound.push(call);
}
private eventId(): string {
return `$echo-${this.nextEventId++}`;
}
private echo(event: RawEvent, origin: FfiEventOrigin): void {
this.roomEventSink?.emit(JSON.stringify(event), origin);
}
}
function parse(json: string): Record<string, unknown> {
return JSON.parse(json) as Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// Inbound event fabrication — the MSC4143/MSC4354 wire shapes the crate's
// dispatch reads (see its src/session/dispatch.rs). Adjust here, not in
// every test.
// ---------------------------------------------------------------------------
let eventCounter = 0;
export function memberJoinEvent(opts: {
roomId?: string;
userId: string;
memberId: string;
lkServiceUrl?: string;
durationMs?: number;
}): RawEvent {
return {
type: "m.rtc.member",
sender: opts.userId,
event_id: `$ev-${eventCounter++}`,
room_id: opts.roomId ?? MOCK_ROOM_ID,
origin_server_ts: Date.now(),
msc4354_sticky: { duration_ms: opts.durationMs ?? 240_000 },
content: {
slot_id: MOCK_SLOT_ID,
// MSC4354: the sticky key lives in the content and equals member.id.
msc4354_sticky_key: opts.memberId,
member: { id: opts.memberId, membership: "join" },
application: { type: "m.call" },
transports: {
published: [
{
type: "livekit",
livekit_service_url: opts.lkServiceUrl ?? MOCK_LK_SERVICE_URL,
},
],
can_subscribe: ["livekit"],
},
},
};
}
export function memberLeaveEvent(opts: {
roomId?: string;
userId: string;
memberId: string;
}): RawEvent {
return {
type: "m.rtc.member",
sender: opts.userId,
event_id: `$ev-${eventCounter++}`,
room_id: opts.roomId ?? MOCK_ROOM_ID,
origin_server_ts: Date.now(),
msc4354_sticky: { duration_ms: 240_000 },
content: {
slot_id: MOCK_SLOT_ID,
msc4354_sticky_key: opts.memberId,
member: { id: opts.memberId, membership: "leave" },
leave_reason: { code: "leave" },
},
};
}
export function slotEvent(
opts: { roomId?: string; status: "open" | "closed"; encrypted?: boolean } = {
status: "open",
},
): RawEvent {
const content: Record<string, unknown> = {
status: opts.status,
application: { type: "m.call" },
};
if (opts.encrypted) content.encryption = { type: "m.per_member" };
return {
type: "m.rtc.slot",
sender: "@admin:example.org",
event_id: `$ev-${eventCounter++}`,
room_id: opts.roomId ?? MOCK_ROOM_ID,
state_key: MOCK_SLOT_ID,
origin_server_ts: Date.now(),
content,
};
}
export function roomEncryptionEvent(roomId = MOCK_ROOM_ID): RawEvent {
return {
type: "m.room.encryption",
sender: "@admin:example.org",
event_id: `$ev-${eventCounter++}`,
room_id: roomId,
state_key: "",
origin_server_ts: Date.now(),
content: { algorithm: "m.megolm.v1.aes-sha2" },
};
}
/** `m.room.member` state with the profile fields the crate puts on a member. */
export function roomMemberEvent(opts: {
roomId?: string;
userId: string;
membership?: "join" | "invite" | "leave";
displayName?: string;
avatarUrl?: string;
}): RawEvent {
const content: Record<string, unknown> = {
membership: opts.membership ?? "join",
};
if (opts.displayName !== undefined) content.displayname = opts.displayName;
if (opts.avatarUrl !== undefined) content.avatar_url = opts.avatarUrl;
return {
type: "m.room.member",
sender: opts.userId,
event_id: `$ev-${eventCounter++}`,
room_id: opts.roomId ?? MOCK_ROOM_ID,
state_key: opts.userId,
origin_server_ts: Date.now(),
content,
};
}
const DEFAULT_KEY = new Uint8Array(32).fill(7);
function base64(bytes: Uint8Array): string {
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replace(/=+$/, "");
}
/** MSC4143 `m.rtc.encryption_key` content. */
export function encryptionKeyContent(opts: {
roomId?: string;
memberId: string;
index: number;
key?: Uint8Array;
}): RawEvent {
return {
room_id: opts.roomId ?? MOCK_ROOM_ID,
member_id: opts.memberId,
media_key: { index: opts.index, key: base64(opts.key ?? DEFAULT_KEY) },
format: 0,
};
}
/** One timer tick: the crate's listener callbacks arrive after the emitting task yields. */
export const tick = async (): Promise<void> =>
new Promise<void>((resolve) => setTimeout(resolve, 0));
export async function waitFor(
what: string,
cond: () => boolean,
timeoutMs = 3000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!cond()) {
if (Date.now() > deadline)
throw new Error(`Timed out waiting for: ${what}`);
await new Promise((r) => setTimeout(r, 10));
}
}