Simplify local transport code

As of the removal of 'Legacy' mode, there can no longer be a difference between the transport you advertise in your membership and the transport you publish media on, so the local transport code can be simplified considerably. For instance, the function which gets the local transport can simply return a promise rather than being reactive.

I kept the local transport as an Observable in other modules so that they could easily be tested with existing marble tests.
This commit is contained in:
Robin
2026-09-21 12:51:28 +02:00
parent 9f7c35cd8f
commit c88f884fc9
12 changed files with 309 additions and 670 deletions
+14 -24
View File
@@ -16,7 +16,6 @@ import {
import { type Room as MatrixRoom } from "matrix-js-sdk"; import { type Room as MatrixRoom } from "matrix-js-sdk";
import { import {
BehaviorSubject, BehaviorSubject,
catchError,
combineLatest, combineLatest,
distinctUntilChanged, distinctUntilChanged,
filter, filter,
@@ -39,6 +38,7 @@ import {
throttleTime, throttleTime,
timer, timer,
takeUntil, takeUntil,
from,
} from "rxjs"; } from "rxjs";
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { import {
@@ -112,7 +112,7 @@ import {
TransportState, TransportState,
} from "./localMember/LocalMember.ts"; } from "./localMember/LocalMember.ts";
import { import {
createLocalTransport$, getLocalTransport,
type LocalTransport, type LocalTransport,
} from "./localMember/LocalTransport.ts"; } from "./localMember/LocalTransport.ts";
import { import {
@@ -573,16 +573,16 @@ export function createCallViewModel$(
: `${userId}:${deviceId}`, : `${userId}:${deviceId}`,
}; };
const localTransport = const localTransport$ = options.localTransport
options.localTransport ?? ? constant(options.localTransport)
createLocalTransport$({ : from(
scope: scope, getLocalTransport({
memberships$: memberships$, ownMembershipIdentity,
ownMembershipIdentity, client,
client, roomId: matrixRoom.roomId,
roomId: matrixRoom.roomId, matrixRTCMode,
matrixRTCMode, }),
}); );
const connectionFactory = const connectionFactory =
options.connectionFactory ?? options.connectionFactory ??
@@ -599,17 +599,7 @@ export function createCallViewModel$(
const connectionManager = createConnectionManager$({ const connectionManager = createConnectionManager$({
scope: scope, scope: scope,
connectionFactory: connectionFactory, connectionFactory: connectionFactory,
localTransport$: scope.behavior( localTransport$,
localTransport.active$.pipe(
catchError((e: unknown) => {
logger.info(
"could not pass local transport to createConnectionManager$. localTransport$ threw an error",
e,
);
return of(null);
}),
),
),
remoteTransports$: membershipsAndTransports.transports$, remoteTransports$: membershipsAndTransports.transports$,
logger: logger, logger: logger,
ownMembershipIdentity, ownMembershipIdentity,
@@ -665,7 +655,7 @@ export function createCallViewModel$(
connectionManager, connectionManager,
client, client,
matrixRTCSession, matrixRTCSession,
localTransport, localTransport$,
roomId: matrixRoom.roomId, roomId: matrixRoom.roomId,
hideScreensharing, hideScreensharing,
hostBridge, hostBridge,
@@ -210,11 +210,8 @@ export function withCallViewModel(mode: MatrixRTCMode) {
connectionState$, connectionState$,
windowSize$, windowSize$,
localTransport: { localTransport: {
active$: constant({ transport: exampleTransport,
transport: exampleTransport, sfuConfig: exampleSfuConfig,
sfuConfig: exampleSfuConfig,
}),
advertised$: constant(exampleTransport),
}, },
connectionFactory: { connectionFactory: {
createConnection( createConnection(
@@ -21,7 +21,7 @@ import {
beforeEach, beforeEach,
afterEach, afterEach,
} from "vitest"; } from "vitest";
import { BehaviorSubject, map, of } from "rxjs"; import { BehaviorSubject, map, of, Subject } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { type LocalParticipant, type LocalTrack } from "livekit-client"; import { type LocalParticipant, type LocalTrack } from "livekit-client";
import fetchMock from "fetch-mock"; import fetchMock from "fetch-mock";
@@ -50,10 +50,7 @@ import {
TrackState, TrackState,
watchScreenShareToggle, watchScreenShareToggle,
} from "./LocalMember"; } from "./LocalMember";
import { import { MatrixRTCTransportMissingError } from "../../../utils/errors";
FailToGetOpenIdToken,
MatrixRTCTransportMissingError,
} from "../../../utils/errors";
import { Epoch, ObservableScope } from "../../ObservableScope"; import { Epoch, ObservableScope } from "../../ObservableScope";
import { constant } from "../../Behavior"; import { constant } from "../../Behavior";
import { ConnectionManagerData } from "../remoteMembers/ConnectionManager"; import { ConnectionManagerData } from "../remoteMembers/ConnectionManager";
@@ -61,10 +58,7 @@ import { ConnectionState, type Connection } from "../remoteMembers/Connection";
import { type Publisher } from "./Publisher"; import { type Publisher } from "./Publisher";
import { initializeWidget } from "../../../widget"; import { initializeWidget } from "../../../widget";
import { nullHostBridge } from "../../../HostBridge"; import { nullHostBridge } from "../../../HostBridge";
import { import { type LocalTransport } from "./LocalTransport";
type LocalTransport,
type LocalTransportWithSFUConfig,
} from "./LocalTransport";
import * as openIDSFU from "../../../livekit/openIDSFU"; import * as openIDSFU from "../../../livekit/openIDSFU";
initializeWidget(); initializeWidget();
@@ -261,7 +255,7 @@ describe("LocalMembership", () => {
}); });
it("throws error on missing RTC config error", () => { it("throws error on missing RTC config error", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => { withTestScheduler(({ scope, hot, expectObservable }) => {
const localTransport$ = scope.behavior<null | LivekitTransportConfig>( const localTransport$ = scope.behavior<null | LivekitTransportConfig>(
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")), hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
null, null,
@@ -277,16 +271,15 @@ describe("LocalMembership", () => {
), ),
}; };
const aLocalTransport: LocalTransport = {
advertised$: localTransport$,
active$: behavior("a", { a: null }),
};
const localMembership = createLocalMembership$({ const localMembership = createLocalMembership$({
scope, scope,
...defaultCreateLocalMemberValues, ...defaultCreateLocalMemberValues,
connectionManager: mockConnectionManager, connectionManager: mockConnectionManager,
localTransport: aLocalTransport, localTransport$: hot(
"1ms #",
{},
new MatrixRTCTransportMissingError("domain.com"),
),
}); });
expectObservable(localMembership.localMemberState$).toBe("ne", { expectObservable(localMembership.localMemberState$).toBe("ne", {
@@ -296,61 +289,9 @@ describe("LocalMembership", () => {
}); });
}); });
it("Should not publish to active transport if advertised has errors", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
const advertised$ = scope.behavior<null | LivekitTransportConfig>(
hot("--#", {}, new FailToGetOpenIdToken(new Error("foo"))),
null,
);
// Populate a connection for active
const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportBConnected, []);
const mockConnectionManager = {
transports$: constant(new Epoch([bTransport])),
connectionManagerData$: constant(new Epoch(connectionManagerData)),
};
const aLocalTransport: LocalTransport = {
advertised$,
active$: behavior("a", { n: null, a: bTransportWithSFUConfig }),
};
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
() => {
return {} as unknown as Publisher;
},
);
const publisherFactory =
defaultCreateLocalMemberValues.createPublisherFactory as ReturnType<
typeof vi.fn
>;
const localMembership = createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
connectionManager: mockConnectionManager,
localTransport: aLocalTransport,
});
expectObservable(localMembership.localMemberState$).toBe("n-e", {
n: TransportState.Waiting,
e: expect.toSatisfy((e) => e instanceof FailToGetOpenIdToken),
});
// Should not have created any publisher
expect(publisherFactory).toHaveBeenCalledTimes(0);
});
});
it("logs if callIntent cannot be updated", async () => { it("logs if callIntent cannot be updated", async () => {
const scope = new ObservableScope(); const scope = new ObservableScope();
const aLocalTransport: LocalTransport = {
advertised$: constant(aTransport),
active$: constant(aTransportWithSFUConfig),
};
const mockConnectionManager = { const mockConnectionManager = {
transports$: constant(new Epoch([])), transports$: constant(new Epoch([])),
connectionManagerData$: constant(new Epoch(new ConnectionManagerData())), connectionManagerData$: constant(new Epoch(new ConnectionManagerData())),
@@ -366,7 +307,7 @@ describe("LocalMembership", () => {
leaveRoomSession: vi.fn(), leaveRoomSession: vi.fn(),
}, },
connectionManager: mockConnectionManager, connectionManager: mockConnectionManager,
localTransport: aLocalTransport, localTransport$: constant(mockTransport),
}); });
const expextedLog = const expextedLog =
"'not connected yet' while updating the call intent (this is expected on startup)"; "'not connected yet' while updating the call intent (this is expected on startup)";
@@ -378,33 +319,19 @@ describe("LocalMembership", () => {
scope.end(); scope.end();
}); });
const aTransport = { const mockTransportConfig = {
livekit_service_url: "a", livekit_service_url: "a",
} as LivekitTransportConfig; } as LivekitTransportConfig;
const aTransportWithSFUConfig = { const mockTransport = {
transport: aTransport, transport: mockTransportConfig,
sfuConfig: { sfuConfig: {
jwt: "foo", jwt: "foo",
livekitAlias: "bar", livekitAlias: "bar",
livekitIdentity: "baz", livekitIdentity: "baz",
url: "bro", url: "bro",
}, },
} as LocalTransportWithSFUConfig; } as LocalTransport;
const bTransport = {
livekit_service_url: "b",
} as LivekitTransportConfig;
const bTransportWithSFUConfig = {
transport: bTransport,
sfuConfig: {
jwt: "foo2",
livekitAlias: "bar2",
livekitIdentity: "baz2",
url: "bro2",
},
} as LocalTransportWithSFUConfig;
const connectionTransportAConnected = { const connectionTransportAConnected = {
livekitRoom: mockLivekitRoom({ livekitRoom: mockLivekitRoom({
@@ -414,18 +341,13 @@ describe("LocalMembership", () => {
} as unknown as LocalParticipant, } as unknown as LocalParticipant,
}), }),
state$: constant(ConnectionState.LivekitConnected), state$: constant(ConnectionState.LivekitConnected),
transport: aTransport, transport: mockTransportConfig,
} as unknown as Connection; } as Connection;
const connectionTransportAConnecting = { const connectionTransportAConnecting = {
...connectionTransportAConnected, ...connectionTransportAConnected,
state$: constant(ConnectionState.LivekitConnecting), state$: constant(ConnectionState.LivekitConnecting),
livekitRoom: mockLivekitRoom({}), livekitRoom: mockLivekitRoom({}),
} as unknown as Connection; } as unknown as Connection;
const connectionTransportBConnected = {
state$: constant(ConnectionState.LivekitConnected),
transport: bTransport,
livekitRoom: mockLivekitRoom({}),
} as unknown as Connection;
const authCallSpy = vi const authCallSpy = vi
.spyOn(openIDSFU, "getSFUConfigWithOpenID") .spyOn(openIDSFU, "getSFUConfigWithOpenID")
@@ -459,10 +381,7 @@ describe("LocalMembership", () => {
), ),
}, },
joinMatrixRTC, joinMatrixRTC,
localTransport: { localTransport$: constant(mockTransport),
advertised$: constant(aTransport),
active$: constant(aTransportWithSFUConfig),
},
delayId$, delayId$,
}); });
@@ -471,7 +390,7 @@ describe("LocalMembership", () => {
await flushPromises(); await flushPromises();
// Joins with timings appropriate for the level of delegation support // Joins with timings appropriate for the level of delegation support
expect(joinMatrixRTC).toHaveBeenCalledWith( expect(joinMatrixRTC).toHaveBeenCalledWith(
aTransport, mockTransportConfig,
delayedLeaveTimings, delayedLeaveTimings,
); );
@@ -505,75 +424,7 @@ describe("LocalMembership", () => {
}, },
); );
it("recreates publisher if new connection is used, always unpublish and end tracks", async () => { it("only starts tracks if requested", async () => {
const scope = new ObservableScope();
const activeTransport$ = new BehaviorSubject(aTransportWithSFUConfig);
const aLocalTransport: LocalTransport = {
advertised$: constant(aTransport),
active$: activeTransport$,
};
const publishers: Publisher[] = [];
let seed = 0;
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
() => {
const a = seed;
seed += 1;
logger.info(`creating [${a}]`);
const p = {
// It is enought to check if destroy is called. Destroy itself is tested in the publisher to make sure it does
// all the cleanup we need.
destroy: vi.fn(),
stopPublishing: vi.fn().mockImplementation(() => {
logger.info(`stopPublishing [${a}]`);
}),
stopTracks: vi.fn(),
};
publishers.push(p as unknown as Publisher);
return p;
},
);
const publisherFactory =
defaultCreateLocalMemberValues.createPublisherFactory as ReturnType<
typeof vi.fn
>;
const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportAConnected, []);
connectionManagerData.add(connectionTransportBConnected, []);
createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport: aLocalTransport,
});
await flushPromises();
activeTransport$.next({
...aTransportWithSFUConfig,
transport: bTransport,
});
await flushPromises();
expect(publisherFactory).toHaveBeenCalledTimes(2);
expect(publishers.length).toBe(2);
// stop the first Publisher and let the second one life.
expect(publishers[0].destroy).toHaveBeenCalled();
expect(publishers[1].destroy).not.toHaveBeenCalled();
expect(publisherFactory.mock.calls[0][0].transport).toBe(aTransport);
expect(publisherFactory.mock.calls[1][0].transport).toBe(bTransport);
scope.end();
await flushPromises();
// stop all tracks after ending scopes
expect(publishers[1].destroy).toHaveBeenCalled();
// expect(publishers[1].stopTracks).toHaveBeenCalled();
defaultCreateLocalMemberValues.createPublisherFactory.mockReset();
});
it("only start tracks if requested", async () => {
const scope = new ObservableScope(); const scope = new ObservableScope();
const publishers: Publisher[] = []; const publishers: Publisher[] = [];
@@ -602,11 +453,6 @@ describe("LocalMembership", () => {
typeof vi.fn typeof vi.fn
>; >;
const aLocalTransport: LocalTransport = {
advertised$: constant(aTransport),
active$: constant(aTransportWithSFUConfig),
};
const connectionManagerData = new ConnectionManagerData(); const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportAConnected, []); connectionManagerData.add(connectionTransportAConnected, []);
// connectionManagerData.add(connectionTransportB, []); // connectionManagerData.add(connectionTransportB, []);
@@ -616,7 +462,7 @@ describe("LocalMembership", () => {
connectionManager: { connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)), connectionManagerData$: constant(new Epoch(connectionManagerData)),
}, },
localTransport: aLocalTransport, localTransport$: constant(mockTransport),
}); });
await flushPromises(); await flushPromises();
expect(publisherFactory).toHaveBeenCalledOnce(); expect(publisherFactory).toHaveBeenCalledOnce();
@@ -637,16 +483,8 @@ describe("LocalMembership", () => {
// //
it("tracks livekit state correctly", async () => { it("tracks livekit state correctly", async () => {
const scope = new ObservableScope(); const scope = new ObservableScope();
const connectionManagerData = new ConnectionManagerData(); const connectionManagerData = new ConnectionManagerData();
const localTransport$ = new Subject<LocalTransport>();
const activeTransport$ =
new BehaviorSubject<null | LocalTransportWithSFUConfig>(null);
const aLocalTransport: LocalTransport = {
advertised$: constant(aTransport),
active$: activeTransport$,
};
const connectionManagerData$ = new BehaviorSubject( const connectionManagerData$ = new BehaviorSubject(
new Epoch(connectionManagerData), new Epoch(connectionManagerData),
@@ -687,14 +525,14 @@ describe("LocalMembership", () => {
connectionManager: { connectionManager: {
connectionManagerData$, connectionManagerData$,
}, },
localTransport: aLocalTransport, localTransport$,
}); });
await flushPromises(); await flushPromises();
expect(localMembership.localMemberState$.value).toStrictEqual( expect(localMembership.localMemberState$.value).toStrictEqual(
TransportState.Waiting, TransportState.Waiting,
); );
activeTransport$.next(aTransportWithSFUConfig); localTransport$.next(mockTransport);
await flushPromises(); await flushPromises();
expect(localMembership.localMemberState$.value).toStrictEqual({ expect(localMembership.localMemberState$.value).toStrictEqual({
matrix: RTCMemberStatus.Connected, matrix: RTCMemberStatus.Connected,
@@ -719,7 +557,7 @@ describe("LocalMembership", () => {
}); });
( (
connectionManagerData2.getConnectionForTransport(aTransport)! connectionManagerData2.getConnectionForTransport(mockTransportConfig)!
.state$ as BehaviorSubject<ConnectionState> .state$ as BehaviorSubject<ConnectionState>
).next(ConnectionState.LivekitConnected); ).next(ConnectionState.LivekitConnected);
expect(localMembership.localMemberState$.value).toStrictEqual({ expect(localMembership.localMemberState$.value).toStrictEqual({
@@ -822,10 +660,7 @@ describe("LocalMembership", () => {
connectionManager: { connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)), connectionManagerData$: constant(new Epoch(connectionManagerData)),
}, },
localTransport: { localTransport$: constant(mockTransport),
advertised$: constant(aTransport),
active$: constant(aTransportWithSFUConfig),
},
}); });
await flushPromises(); await flushPromises();
@@ -862,10 +697,7 @@ describe("LocalMembership", () => {
connectionManager: { connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)), connectionManagerData$: constant(new Epoch(connectionManagerData)),
}, },
localTransport: { localTransport$: constant(mockTransport),
advertised$: constant(aTransport),
active$: constant(aTransportWithSFUConfig),
},
}); });
await flushPromises(); await flushPromises();
@@ -913,10 +745,7 @@ describe("LocalMembership", () => {
connectionManager: { connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)), connectionManagerData$: constant(new Epoch(connectionManagerData)),
}, },
localTransport: { localTransport$: constant(mockTransport),
advertised$: constant(aTransport),
active$: constant(aTransportWithSFUConfig),
},
}); });
await flushPromises(); await flushPromises();
@@ -957,10 +786,7 @@ describe("LocalMembership", () => {
connectionManager: { connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)), connectionManagerData$: constant(new Epoch(connectionManagerData)),
}, },
localTransport: { localTransport$: constant(mockTransport),
advertised$: constant(aTransport),
active$: constant(aTransportWithSFUConfig),
},
}); });
await flushPromises(); await flushPromises();
@@ -1026,10 +852,7 @@ describe("LocalMembership", () => {
connectionManager: { connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)), connectionManagerData$: constant(new Epoch(connectionManagerData)),
}, },
localTransport: { localTransport$: constant(mockTransport),
advertised$: constant(aTransport),
active$: constant(aTransportWithSFUConfig),
},
}); });
return { scope, localMembership }; return { scope, localMembership };
}; };
@@ -1039,7 +862,7 @@ describe("LocalMembership", () => {
const setScreenShareEnabled = vi.fn().mockRejectedValue(error); const setScreenShareEnabled = vi.fn().mockRejectedValue(error);
const connection = { const connection = {
state$: constant(ConnectionState.LivekitConnected), state$: constant(ConnectionState.LivekitConnected),
transport: aTransport, transport: mockTransportConfig,
livekitRoom: mockLivekitRoom({ livekitRoom: mockLivekitRoom({
localParticipant: mockLocalParticipant({ localParticipant: mockLocalParticipant({
isScreenShareEnabled: false, isScreenShareEnabled: false,
@@ -38,6 +38,10 @@ import {
startWith, startWith,
switchMap, switchMap,
tap, tap,
NEVER,
concat,
race,
Subject,
} from "rxjs"; } from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger"; import { type Logger } from "matrix-js-sdk/lib/logger";
import { deepCompare } from "matrix-js-sdk/lib/utils"; import { deepCompare } from "matrix-js-sdk/lib/utils";
@@ -77,8 +81,6 @@ import {
} from "../remoteMembers/Connection.ts"; } from "../remoteMembers/Connection.ts";
import { type HomeserverConnected } from "./HomeserverConnected.ts"; import { type HomeserverConnected } from "./HomeserverConnected.ts";
import { type LocalTransport } from "./LocalTransport.ts"; import { type LocalTransport } from "./LocalTransport.ts";
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts";
import { or$ } from "../../../utils/observable.ts";
import { getSFUConfigWithOpenID } from "../../../livekit/openIDSFU.ts"; import { getSFUConfigWithOpenID } from "../../../livekit/openIDSFU.ts";
export enum TransportState { export enum TransportState {
@@ -148,7 +150,7 @@ interface Props {
homeserverConnected: HomeserverConnected; homeserverConnected: HomeserverConnected;
roomId: string; roomId: string;
ownMembershipIdentity: CallMembershipIdentityParts; ownMembershipIdentity: CallMembershipIdentityParts;
localTransport: LocalTransport; localTransport$: Observable<LocalTransport>;
client: Pick<MatrixClient, "getDeviceId" | "getOpenIdToken">; client: Pick<MatrixClient, "getDeviceId" | "getOpenIdToken">;
matrixRTCSession: Pick< matrixRTCSession: Pick<
MatrixRTCSession, MatrixRTCSession,
@@ -194,7 +196,7 @@ interface Props {
export const createLocalMembership$ = ({ export const createLocalMembership$ = ({
scope, scope,
connectionManager, connectionManager,
localTransport, localTransport$: localTransportWithErrors$,
homeserverConnected, homeserverConnected,
createPublisherFactory, createPublisherFactory,
joinMatrixRTC, joinMatrixRTC,
@@ -252,20 +254,24 @@ export const createLocalMembership$ = ({
const logger = parentLogger.getChild("[LocalMembership]"); const logger = parentLogger.getChild("[LocalMembership]");
logger.debug(`Creating local membership..`); logger.debug(`Creating local membership..`);
// We consider error on the transport as fatal. // Unwrap the local transport and set the state of the LocalMembership to error in case the transport is an error.
// Whether it is the active transport or the preferred transport. const fatalTransportError$ = new Subject<ElementCallError>();
const handleTransportError = (e: unknown): Observable<null> => { const localTransport$ = localTransportWithErrors$.pipe(
let error: ElementCallError; catchError((e: unknown) => {
if (e instanceof ElementCallError) { let error: ElementCallError;
error = e; if (e instanceof ElementCallError) {
} else { error = e;
error = new UnknownCallError( } else {
e instanceof Error ? e : new Error("Unknown error from localTransport"), error = new UnknownCallError(
); e instanceof Error
} ? e
setTransportError(error); : new Error("Unknown error from localTransport"),
return of(null); );
}; }
fatalTransportError$.next(error);
return NEVER; // Make this Observable swallow the error
}),
);
async function checkDelegationSupport( async function checkDelegationSupport(
endpointUrl: string, endpointUrl: string,
@@ -303,58 +309,40 @@ export const createLocalMembership$ = ({
// The transport that we will advertise in our membership, paired with info as // The transport that we will advertise in our membership, paired with info as
// to whether delayed event delegation is supported // to whether delayed event delegation is supported
const joinParams$ = localTransport.advertised$.pipe( const joinParams$ = scope.behavior(
catchError(handleTransportError), localTransport$.pipe(
distinctUntilChanged(areLivekitTransportsEqual), switchMap(async ({ transport }) => {
switchMap((transport) => { const transportSupportsDelegation = checkDelegationSupport(
if (transport === null) return of(null); transport.livekit_service_url + "/delegate_delayed_leave",
const transportSupportsDelegation = checkDelegationSupport( `transport ${transport.livekit_service_url}`,
transport.livekit_service_url + "/delegate_delayed_leave", );
`transport ${transport.livekit_service_url}`, return {
); transport,
return or$( delegationSupported:
from(homeserverSupportsDelegation), (await homeserverSupportsDelegation) ||
from(transportSupportsDelegation), (await transportSupportsDelegation),
).pipe( };
map((delegationSupported) => ({ transport, delegationSupported })),
startWith(null),
);
}),
);
// Unwrap the local transport and set the state of the LocalMembership to error in case the transport is an error.
const activeTransport$ = scope.behavior(
combineLatest([localTransport.active$, localTransport.advertised$]).pipe(
map(([active, advertised]) => {
// Our policy is to not publish to another transport if our prefered transport is miss-configured
if (advertised == null) return null;
return active?.transport ?? null;
}), }),
catchError(handleTransportError),
distinctUntilChanged(areLivekitTransportsEqual),
), ),
null,
); );
// Drop Epoch data here since we will not combine this anymore // Drop Epoch data here since we will not combine this anymore
const localConnection$ = scope.behavior( const localConnection$ = scope.behavior(
combineLatest([ combineLatest([
connectionManager.connectionManagerData$, connectionManager.connectionManagerData$,
activeTransport$, localTransport$,
]).pipe( ]).pipe(
map(([{ value: connectionData }, localTransport]) => { map(([{ value: connectionData }, { transport }]) =>
if (localTransport === null) { connectionData.getConnectionForTransport(transport),
return null; ),
}
return connectionData.getConnectionForTransport(localTransport);
}),
tap((connection) => { tap((connection) => {
logger.info( logger.info(
`Local connection updated: ${connection?.transport?.livekit_service_url}`, `Local connection updated: ${connection?.transport?.livekit_service_url}`,
); );
}), }),
), ),
null,
); );
// Tracks error that happen when creating the local tracks. // Tracks error that happen when creating the local tracks.
@@ -490,18 +478,6 @@ export const createLocalMembership$ = ({
} }
}; };
const fatalTransportError$ = new BehaviorSubject<ElementCallError | null>(
null,
);
const setTransportError = (e: ElementCallError): void => {
if (fatalTransportError$.value !== null) {
logger.error("Multiple Transport Errors:", e);
} else {
fatalTransportError$.next(e);
}
};
const localConnectionState$ = localConnection$.pipe( const localConnectionState$ = localConnection$.pipe(
switchMap((connection) => (connection ? connection.state$ : of(null))), switchMap((connection) => (connection ? connection.state$ : of(null))),
); );
@@ -509,38 +485,29 @@ export const createLocalMembership$ = ({
const mediaState$: Behavior<LocalMemberMediaState> = scope.behavior( const mediaState$: Behavior<LocalMemberMediaState> = scope.behavior(
combineLatest([ combineLatest([
localConnectionState$, localConnectionState$,
activeTransport$,
joinAndPublishRequested$, joinAndPublishRequested$,
from(trackStartRequested.promise).pipe( from(trackStartRequested.promise).pipe(
map(() => true), map(() => true),
startWith(false), startWith(false),
), ),
]).pipe( ]).pipe(
map( map(([localConnectionState, shouldPublish, shouldStartTracks]) => {
([ const trackState: TrackState = shouldStartTracks
localConnectionState, ? TrackState.Ready
localTransport, : TrackState.WaitingForUser;
shouldPublish,
shouldStartTracks,
]) => {
if (!localTransport) return null;
const trackState: TrackState = shouldStartTracks
? TrackState.Ready
: TrackState.WaitingForUser;
if ( if (
localConnectionState !== ConnectionState.LivekitConnected || localConnectionState !== ConnectionState.LivekitConnected ||
trackState !== TrackState.Ready trackState !== TrackState.Ready
) )
return { return {
connection: localConnectionState, connection: localConnectionState,
tracks: trackState, tracks: trackState,
}; };
if (!shouldPublish) return PublishState.WaitingForUser; if (!shouldPublish) return PublishState.WaitingForUser;
// if (!publishing) return PublishState.Starting; // if (!publishing) return PublishState.Starting;
return PublishState.Publishing; return PublishState.Publishing;
}, }),
),
distinctUntilChanged(deepCompare), distinctUntilChanged(deepCompare),
), ),
); );
@@ -554,30 +521,36 @@ export const createLocalMembership$ = ({
}; };
const localMemberState$ = scope.behavior<LocalMemberState>( const localMemberState$ = scope.behavior<LocalMemberState>(
combineLatest([ concat(
mediaState$, // Waiting until…
homeserverConnected.rtsSession$, of(TransportState.Waiting),
fatalMatrixError$, race(
fatalTransportError$, // either there is a fatal transport error
publishError$, fatalTransportError$,
]).pipe( // or the transport is available.
map( localTransport$.pipe(
([ switchMap(() =>
mediaState, // Once available, track session/media state.
rtcSessionStatus, combineLatest([
fatalMatrixError, mediaState$,
fatalTransportError, homeserverConnected.rtsSession$,
publishError, fatalMatrixError$,
]) => { publishError$,
if (fatalTransportError !== null) return fatalTransportError; ]).pipe(
// `mediaState` will be 'null' until the transport/connection appears. map(
if (mediaState && rtcSessionStatus) ([
return { mediaState,
matrix: fatalMatrixError ?? rtcSessionStatus, rtcSessionStatus,
media: publishError ?? mediaState, fatalMatrixError,
}; publishError,
return TransportState.Waiting; ]) => ({
}, matrix: fatalMatrixError ?? rtcSessionStatus,
media: publishError ?? mediaState,
}),
),
),
),
),
), ),
), ),
); );
@@ -14,19 +14,10 @@ import {
type MockedObject, type MockedObject,
vi, vi,
} from "vitest"; } from "vitest";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { lastValueFrom } from "rxjs";
import fetchMock from "fetch-mock"; import fetchMock from "fetch-mock";
import { import { mockConfig, ownMemberMock } from "../../../utils/test";
mockConfig, import { getLocalTransport } from "./LocalTransport";
flushPromises,
ownMemberMock,
testScope,
} from "../../../utils/test";
import { createLocalTransport$ } from "./LocalTransport";
import { constant } from "../../Behavior";
import { Epoch, ObservableScope } from "../../ObservableScope";
import { import {
MatrixRTCTransportMissingError, MatrixRTCTransportMissingError,
FailToGetOpenIdToken, FailToGetOpenIdToken,
@@ -47,118 +38,79 @@ describe("LocalTransport", () => {
beforeEach(() => vi.clearAllMocks()); beforeEach(() => vi.clearAllMocks());
it("throws if config is missing", async () => { it("throws if config is missing", async () => {
const { advertised$, active$ } = createLocalTransport$({ await expect(
scope: testScope(), getLocalTransport({
roomId: "!room:example.org", roomId: "!room:example.org",
memberships$: constant(new Epoch<CallMembership[]>([])), client: {
client: { // eslint-disable-next-line @typescript-eslint/naming-convention
// eslint-disable-next-line @typescript-eslint/naming-convention _unstable_getRTCTransports: async () => Promise.resolve([]),
_unstable_getRTCTransports: async () => Promise.resolve([]), getDomain: () => "example.org",
getDomain: () => "example.org", // These won't be called in this error path but satisfy the type
// These won't be called in this error path but satisfy the type getOpenIdToken: vi.fn(),
getOpenIdToken: vi.fn(), getDeviceId: vi.fn(),
getDeviceId: vi.fn(), },
}, ownMembershipIdentity: ownMemberMock,
ownMembershipIdentity: ownMemberMock, matrixRTCMode: MatrixRTCMode.Compatibility,
matrixRTCMode: MatrixRTCMode.Compatibility, }),
}); ).rejects.toThrow(new MatrixRTCTransportMissingError("example.org"));
await flushPromises();
expect(() => advertised$.value).toThrow(
new MatrixRTCTransportMissingError("example.org"),
);
expect(() => active$.value).toThrow(
new MatrixRTCTransportMissingError("example.org"),
);
}); });
it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => { it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => {
// Provide a valid config so makeTransportInternal resolves a transport // Provide a valid config so makeTransportInternal resolves a transport
const scope = new ObservableScope();
mockConfig({ mockConfig({
livekit: { livekit_service_url: "https://lk.example.org" }, livekit: { livekit_service_url: "https://lk.example.org" },
}); });
const resolver = Promise.withResolvers<void>(); vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockRejectedValue(
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockImplementation( new FailToGetOpenIdToken(new Error("no openid")),
async () => {
await resolver.promise;
throw new FailToGetOpenIdToken(new Error("no openid"));
},
); );
const observations: unknown[] = [];
const errors: Error[] = [];
const { advertised$, active$ } = createLocalTransport$({
scope,
roomId: "!example_room_id",
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
getDomain: () => "example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
ownMembershipIdentity: ownMemberMock,
matrixRTCMode: MatrixRTCMode.Compatibility,
});
active$.subscribe(
(o) => observations.push(o),
(e) => errors.push(e),
);
resolver.resolve();
await flushPromises();
const expectedError = new FailToGetOpenIdToken(new Error("no openid")); await expect(
expect(observations).toStrictEqual([null]); getLocalTransport({
expect(errors).toStrictEqual([expectedError]); roomId: "!example_room_id",
expect(() => advertised$.value).toThrow(expectedError); client: {
expect(() => active$.value).toThrow(expectedError); getDomain: () => "example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
ownMembershipIdentity: ownMemberMock,
matrixRTCMode: MatrixRTCMode.Compatibility,
}),
).rejects.toThrow(new FailToGetOpenIdToken(new Error("no openid")));
}); });
it("emits preferred transport after OpenID resolves", async () => { it("returns preferred transport", async () => {
// Use config so transport discovery succeeds, but delay OpenID JWT fetch // Use config so transport discovery succeeds, but delay OpenID JWT fetch
mockConfig({ mockConfig({
livekit: { livekit_service_url: "https://lk.example.org" }, livekit: { livekit_service_url: "https://lk.example.org" },
}); });
const openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>(); vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue({
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
openIdResolver.promise,
);
const { advertised$, active$ } = createLocalTransport$({
scope: testScope(),
roomId: "!room:example.org",
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getDomain: () => "example.org",
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
ownMembershipIdentity: ownMemberMock,
matrixRTCMode: MatrixRTCMode.Compatibility,
});
openIdResolver.resolve?.({
url: "https://lk.example.org", url: "https://lk.example.org",
jwt: "jwt", jwt: "jwt",
livekitAlias: "Akph4alDMhen", livekitAlias: "Akph4alDMhen",
livekitIdentity: ownMemberMock.userId + ":" + ownMemberMock.deviceId, livekitIdentity: ownMemberMock.userId + ":" + ownMemberMock.deviceId,
}); });
expect(advertised$.value).toBe(null);
expect(active$.value).toBe(null); expect(
await flushPromises(); await getLocalTransport({
// final roomId: "!room:example.org",
const expectedTransport = { client: {
livekit_service_url: "https://lk.example.org", // eslint-disable-next-line @typescript-eslint/naming-convention
type: "livekit", _unstable_getRTCTransports: async () => Promise.resolve([]),
}; getDomain: () => "example.org",
expect(advertised$.value).toStrictEqual(expectedTransport); getOpenIdToken: vi.fn(),
expect(active$.value).toStrictEqual({ getDeviceId: vi.fn(),
transport: expectedTransport, },
ownMembershipIdentity: ownMemberMock,
matrixRTCMode: MatrixRTCMode.Compatibility,
}),
).toStrictEqual({
transport: {
livekit_service_url: "https://lk.example.org",
type: "livekit",
},
sfuConfig: { sfuConfig: {
jwt: "jwt", jwt: "jwt",
livekitAlias: "Akph4alDMhen", livekitAlias: "Akph4alDMhen",
@@ -168,22 +120,19 @@ describe("LocalTransport", () => {
}); });
}); });
type LocalTransportProps = Parameters<typeof createLocalTransport$>[0]; type LocalTransportProps = Parameters<typeof getLocalTransport>[0];
describe("transport configuration mechanisms", () => { describe("transport configuration mechanisms", () => {
let localTransportOpts: LocalTransportProps & { let localTransportOpts: LocalTransportProps & {
client: MockedObject<LocalTransportProps["client"]>; client: MockedObject<LocalTransportProps["client"]>;
}; };
let openIdResolver: PromiseWithResolvers<openIDSFU.SFUConfig>;
beforeEach(() => { beforeEach(() => {
mockConfig({}); mockConfig({});
customLivekitUrl.setValue(customLivekitUrl.defaultValue); customLivekitUrl.setValue(customLivekitUrl.defaultValue);
localTransportOpts = { localTransportOpts = {
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
scope: testScope(),
roomId: "!example_room_id", roomId: "!example_room_id",
matrixRTCMode: MatrixRTCMode.Compatibility, matrixRTCMode: MatrixRTCMode.Compatibility,
memberships$: constant(new Epoch<CallMembership[]>([])),
client: { client: {
getDomain: vi.fn().mockReturnValue("example.org"), getDomain: vi.fn().mockReturnValue("example.org"),
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
@@ -192,10 +141,6 @@ describe("LocalTransport", () => {
getDeviceId: vi.fn(), getDeviceId: vi.fn(),
}, },
}; };
openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
openIdResolver.promise,
);
}); });
afterEach(() => { afterEach(() => {
@@ -206,19 +151,15 @@ describe("LocalTransport", () => {
mockConfig({ mockConfig({
livekit: { livekit_service_url: "https://lk.example.org" }, livekit: { livekit_service_url: "https://lk.example.org" },
}); });
const { advertised$, active$ } = vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue(
createLocalTransport$(localTransportOpts); openIdResponse,
openIdResolver.resolve?.(openIdResponse); );
expect(advertised$.value).toBe(null);
expect(active$.value).toBe(null); expect(await getLocalTransport(localTransportOpts)).toStrictEqual({
await flushPromises(); transport: {
const expectedTransport = { livekit_service_url: "https://lk.example.org",
livekit_service_url: "https://lk.example.org", type: "livekit",
type: "livekit", },
};
expect(advertised$.value).toStrictEqual(expectedTransport);
expect(active$.value).toStrictEqual({
transport: expectedTransport,
sfuConfig: { sfuConfig: {
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=", jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
livekitAlias: "Akph4alDMhen", livekitAlias: "Akph4alDMhen",
@@ -230,12 +171,11 @@ describe("LocalTransport", () => {
it("supports getting transport via user settings", async () => { it("supports getting transport via user settings", async () => {
customLivekitUrl.setValue("https://lk.example.org"); customLivekitUrl.setValue("https://lk.example.org");
const { advertised$, active$ } = vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue(
createLocalTransport$(localTransportOpts); openIdResponse,
openIdResolver.resolve?.(openIdResponse); );
expect(advertised$.value).toBe(null);
await flushPromises(); expect(await getLocalTransport(localTransportOpts)).toStrictEqual({
expect(active$.value).toStrictEqual({
transport: { transport: {
livekit_service_url: "https://lk.example.org", livekit_service_url: "https://lk.example.org",
type: "livekit", type: "livekit",
@@ -253,19 +193,15 @@ describe("LocalTransport", () => {
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([ localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
{ type: "livekit", livekit_service_url: "https://lk.example.org" }, { type: "livekit", livekit_service_url: "https://lk.example.org" },
]); ]);
const { advertised$, active$ } = vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue(
createLocalTransport$(localTransportOpts); openIdResponse,
openIdResolver.resolve?.(openIdResponse); );
expect(advertised$.value).toBe(null);
expect(active$.value).toBe(null); expect(await getLocalTransport(localTransportOpts)).toStrictEqual({
await flushPromises(); transport: {
const expectedTransport = { livekit_service_url: "https://lk.example.org",
livekit_service_url: "https://lk.example.org", type: "livekit",
type: "livekit", },
};
expect(advertised$.value).toStrictEqual(expectedTransport);
expect(active$.value).toStrictEqual({
transport: expectedTransport,
sfuConfig: { sfuConfig: {
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=", jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
livekitAlias: "Akph4alDMhen", livekitAlias: "Akph4alDMhen",
@@ -279,38 +215,31 @@ describe("LocalTransport", () => {
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([ localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
{ type: "livekit", livekit_service_url: "https://lk.example.org" }, { type: "livekit", livekit_service_url: "https://lk.example.org" },
]); ]);
openIdResolver.reject( vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockRejectedValue(
new FailToGetOpenIdToken(new Error("Test driven error")), new FailToGetOpenIdToken(new Error("Test driven error")),
); );
await expect(async () =>
lastValueFrom(createLocalTransport$(localTransportOpts).active$), await expect(getLocalTransport(localTransportOpts)).rejects.toThrow(
).rejects.toThrow(expect.any(FailToGetOpenIdToken)); expect.any(FailToGetOpenIdToken),
);
}); });
it("throws if no options are available", async () => { it("throws if no options are available", async () => {
const { advertised$, active$ } = createLocalTransport$({ await expect(
scope: testScope(), getLocalTransport({
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
roomId: "!example_room_id", roomId: "!example_room_id",
matrixRTCMode: MatrixRTCMode.Compatibility, matrixRTCMode: MatrixRTCMode.Compatibility,
memberships$: constant(new Epoch<CallMembership[]>([])), client: {
client: { getDomain: () => "example.org",
getDomain: () => "example.org", // eslint-disable-next-line @typescript-eslint/naming-convention
// eslint-disable-next-line @typescript-eslint/naming-convention _unstable_getRTCTransports: async () => Promise.resolve([]),
_unstable_getRTCTransports: async () => Promise.resolve([]), // These won't be called in this error path but satisfy the type
// These won't be called in this error path but satisfy the type getOpenIdToken: vi.fn(),
getOpenIdToken: vi.fn(), getDeviceId: vi.fn(),
getDeviceId: vi.fn(), },
}, }),
}); ).rejects.toThrow(new MatrixRTCTransportMissingError("example.org"));
await flushPromises();
expect(() => advertised$.value).toThrow(
new MatrixRTCTransportMissingError("example.org"),
);
expect(() => active$.value).toThrow(
new MatrixRTCTransportMissingError("example.org"),
);
}); });
}); });
}); });
@@ -5,17 +5,11 @@ SPDX-License-IdFentifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
type CallMembership,
type LivekitTransportConfig,
} from "matrix-js-sdk/lib/matrixrtc";
import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixClient } from "matrix-js-sdk";
import { distinctUntilChanged, from, map, of, switchMap } from "rxjs";
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger"; import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Behavior } from "../../Behavior.ts";
import { type Epoch, type ObservableScope } from "../../ObservableScope.ts";
import { Config } from "../../../config/Config.ts"; import { Config } from "../../../config/Config.ts";
import { import {
FailToGetOpenIdToken, FailToGetOpenIdToken,
@@ -27,19 +21,12 @@ import {
type SFUConfig, type SFUConfig,
type OpenIDClientParts, type OpenIDClientParts,
} from "../../../livekit/openIDSFU.ts"; } from "../../../livekit/openIDSFU.ts";
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts";
import { customLivekitUrl } from "../../../settings/settings.ts"; import { customLivekitUrl } from "../../../settings/settings.ts";
import { RtcTransportAutoDiscovery } from "./RtcTransportAutoDiscovery.ts"; import { RtcTransportAutoDiscovery } from "./RtcTransportAutoDiscovery.ts";
import { type MatrixRTCMode } from "../../../config/ConfigOptions.ts"; import { type MatrixRTCMode } from "../../../config/ConfigOptions.ts";
/*
* It figures out “which LiveKit focus URL/alias the local user should use,”
* and ensures the SFU path is primed before advertising that choice.
*/
interface Props { interface Props {
scope: ObservableScope;
ownMembershipIdentity: CallMembershipIdentityParts; ownMembershipIdentity: CallMembershipIdentityParts;
memberships$: Behavior<Epoch<CallMembership[]>>;
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports"> & client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports"> &
OpenIDClientParts; OpenIDClientParts;
// Used by the jwt service to create the livekit room and compute the livekit alias. // Used by the jwt service to create the livekit room and compute the livekit alias.
@@ -68,114 +55,65 @@ interface Props {
// //
// 2. // 2.
// We need to make sure we do not sent livekit_alias in sticky events and that we drop all code for sending state events! // We need to make sure we do not sent livekit_alias in sticky events and that we drop all code for sending state events!
export interface LocalTransportWithSFUConfig { export interface LocalTransport {
transport: LivekitTransportConfig; transport: LivekitTransportConfig;
sfuConfig: SFUConfig; sfuConfig: SFUConfig;
} }
export function isLocalTransportWithSFUConfig( export function isLocalTransport(
obj: LivekitTransportConfig | LocalTransportWithSFUConfig, obj: LivekitTransportConfig | LocalTransport,
): obj is LocalTransportWithSFUConfig { ): obj is LocalTransport {
return "transport" in obj && "sfuConfig" in obj; return "transport" in obj && "sfuConfig" in obj;
} }
export interface LocalTransport {
/**
* The transport to be advertised in our MatrixRTC membership. `null` when not
* yet fetched/validated.
*/
advertised$: Behavior<LivekitTransportConfig | null>;
/**
* The transport to connect to and publish media on. `null` when not yet known
* or available.
*/
active$: Behavior<LocalTransportWithSFUConfig | null>;
}
/** /**
* Connects to the JWT service and determines the transports that the local member should use. * Connects to the JWT service and determines the transport that the local member should use.
* *
* @prop useOldJwtEndpoint Whether to set forceOldJwtEndpoint on the returned transport and to use the old JWT endpoint. * @prop useOldJwtEndpoint Whether to set forceOldJwtEndpoint on the returned transport and to use the old JWT endpoint.
* This is used when the connection manager needs to know if it has to use the legacy endpoint which implies a string concatenated rtcBackendIdentity. * This is used when the connection manager needs to know if it has to use the legacy endpoint which implies a string concatenated rtcBackendIdentity.
* (which is expected for non sticky event based rtc member events) * (which is expected for non sticky event based rtc member events)
* @returns The transport to advertise in the local MatrixRTC membership, along with the transport to actively publish media to. * @returns The transport to advertise in our MatrixRTC membership and publish media on.
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken * @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
*/ */
export const createLocalTransport$ = ({ export async function getLocalTransport({
scope,
memberships$,
ownMembershipIdentity, ownMembershipIdentity,
client, client,
roomId, roomId,
matrixRTCMode, matrixRTCMode,
}: Props): LocalTransport => { }: Props): Promise<LocalTransport> {
const logger = rootLogger.getChild("[LocalTransport]"); const logger = rootLogger.getChild("[LocalTransport]");
const discovery = new RtcTransportAutoDiscovery({
const transportDiscovery = new RtcTransportAutoDiscovery({
client: client, client: client,
resolvedConfig: Config.get(), resolvedConfig: Config.get(),
logger: logger, logger: logger,
}); });
const customUrl = customLivekitUrl.value$.value;
// Get the preferred transport from the current deployment. // Respect the user's custom URL, if set
const discoveredTransport$ = from( const transport: LivekitTransportConfig | null = customUrl
transportDiscovery.discoverPreferredTransport(), ? { type: "livekit", livekit_service_url: customUrl }
); : await discovery.discoverPreferredTransport();
const preferredConfig$ = customLivekitUrl.value$.pipe( if (transport === null)
switchMap((customUrl) => { throw new MatrixRTCTransportMissingError(client.getDomain() ?? "");
if (customUrl) {
return of({
type: "livekit",
livekit_service_url: customUrl,
} as LivekitTransportConfig);
} else {
return discoveredTransport$;
}
}),
map((config) => {
if (!config) {
// Bubbled up from the preferredConfig$ observable.
throw new MatrixRTCTransportMissingError(client.getDomain() ?? "");
}
return config;
}),
distinctUntilChanged(areLivekitTransportsEqual),
);
const preferredTransport$ = preferredConfig$.pipe( try {
switchMap(async (transport) => { return await doOpenIdAndJWTFromUrl(
try { transport,
return await doOpenIdAndJWTFromUrl( matrixRTCMode,
transport, ownMembershipIdentity,
matrixRTCMode, roomId,
ownMembershipIdentity, client,
roomId, logger,
client, );
logger, } catch (e) {
); logger.error(
} catch (e) { `Failed to authenticate to transport ${transport.livekit_service_url}`,
logger.error( e,
`Failed to authenticate to transport ${transport.livekit_service_url}`, );
e, throw mapAuthErrorToUserFriendlyError(e);
); }
throw mapAuthErrorToUserFriendlyError(e); }
}
}),
);
// Always publish on and advertise the preferred transport.
return {
advertised$: scope.behavior(
preferredTransport$.pipe(
map((t) => t.transport),
distinctUntilChanged(areLivekitTransportsEqual),
),
null,
),
active$: scope.behavior(preferredTransport$, null),
};
};
/** /**
* Utility to ensure the user can authenticate with the SFU. * Utility to ensure the user can authenticate with the SFU.
@@ -199,7 +137,7 @@ async function doOpenIdAndJWTFromUrl(
roomId: string, roomId: string,
client: Pick<MatrixClient, "_unstable_getRTCTransports"> & OpenIDClientParts, client: Pick<MatrixClient, "_unstable_getRTCTransports"> & OpenIDClientParts,
logger?: Logger, logger?: Logger,
): Promise<LocalTransportWithSFUConfig> { ): Promise<LocalTransport> {
const sfuConfig = await getSFUConfigWithOpenID( const sfuConfig = await getSFUConfigWithOpenID(
client, client,
membership, membership,
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/ */
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject, NEVER } from "rxjs";
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc"; import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
import { type RemoteParticipant } from "livekit-client"; import { type RemoteParticipant } from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
@@ -20,7 +20,7 @@ import { type ConnectionFactory } from "./ConnectionFactory.ts";
import { type Connection } from "./Connection.ts"; import { type Connection } from "./Connection.ts";
import { ownMemberMock, withTestScheduler } from "../../../utils/test.ts"; import { ownMemberMock, withTestScheduler } from "../../../utils/test.ts";
import { areLivekitTransportsEqual } from "./MatrixLivekitMembers.ts"; import { areLivekitTransportsEqual } from "./MatrixLivekitMembers.ts";
import { constant, type Behavior } from "../../Behavior.ts"; import { type Behavior } from "../../Behavior.ts";
// Some test constants // Some test constants
@@ -74,7 +74,7 @@ describe("connections$ stream", () => {
const { connectionManagerData$ } = createConnectionManager$({ const { connectionManagerData$ } = createConnectionManager$({
scope: testScope, scope: testScope,
connectionFactory: fakeConnectionFactory, connectionFactory: fakeConnectionFactory,
localTransport$: constant(null), localTransport$: NEVER,
remoteTransports$: behavior("a", { remoteTransports$: behavior("a", {
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0), a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
}), }),
@@ -115,7 +115,7 @@ describe("connections$ stream", () => {
const { connectionManagerData$ } = createConnectionManager$({ const { connectionManagerData$ } = createConnectionManager$({
scope: testScope, scope: testScope,
connectionFactory: fakeConnectionFactory, connectionFactory: fakeConnectionFactory,
localTransport$: constant(null), localTransport$: NEVER,
remoteTransports$: behavior("abcdef", { remoteTransports$: behavior("abcdef", {
a: new Epoch([TRANSPORT_1], 0), a: new Epoch([TRANSPORT_1], 0),
b: new Epoch([TRANSPORT_1], 1), b: new Epoch([TRANSPORT_1], 1),
@@ -162,7 +162,7 @@ describe("connections$ stream", () => {
const { connectionManagerData$ } = createConnectionManager$({ const { connectionManagerData$ } = createConnectionManager$({
scope: testScope, scope: testScope,
connectionFactory: fakeConnectionFactory, connectionFactory: fakeConnectionFactory,
localTransport$: constant(null), localTransport$: NEVER,
remoteTransports$: behavior("abc", { remoteTransports$: behavior("abc", {
a: new Epoch([TRANSPORT_1], 0), a: new Epoch([TRANSPORT_1], 0),
b: new Epoch([TRANSPORT_1, TRANSPORT_2], 1), b: new Epoch([TRANSPORT_1, TRANSPORT_2], 1),
@@ -297,7 +297,7 @@ describe("connectionManagerData$ stream", () => {
const { connectionManagerData$ } = createConnectionManager$({ const { connectionManagerData$ } = createConnectionManager$({
scope: testScope, scope: testScope,
connectionFactory: fakeConnectionFactory, connectionFactory: fakeConnectionFactory,
localTransport$: constant(null), localTransport$: NEVER,
remoteTransports$: behavior("a", { remoteTransports$: behavior("a", {
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0), a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
}), }),
@@ -7,7 +7,14 @@ Please see LICENSE in the repository root for full details.
*/ */
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc"; import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
import { combineLatest, map, of, switchMap } from "rxjs"; import {
combineLatest,
map,
type Observable,
of,
switchMap,
startWith,
} from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger"; import { type Logger } from "matrix-js-sdk/lib/logger";
import { type RemoteParticipant } from "livekit-client"; import { type RemoteParticipant } from "livekit-client";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
@@ -19,8 +26,8 @@ import { generateItemsWithEpoch } from "../../../utils/observable.ts";
import { areLivekitTransportsEqual } from "./MatrixLivekitMembers.ts"; import { areLivekitTransportsEqual } from "./MatrixLivekitMembers.ts";
import { type ConnectionFactory } from "./ConnectionFactory.ts"; import { type ConnectionFactory } from "./ConnectionFactory.ts";
import { import {
isLocalTransportWithSFUConfig, isLocalTransport,
type LocalTransportWithSFUConfig, type LocalTransport,
} from "../localMember/LocalTransport.ts"; } from "../localMember/LocalTransport.ts";
import { type SFUConfig } from "../../../livekit/openIDSFU.ts"; import { type SFUConfig } from "../../../livekit/openIDSFU.ts";
@@ -78,7 +85,7 @@ export class ConnectionManagerData {
interface Props { interface Props {
scope: ObservableScope; scope: ObservableScope;
connectionFactory: ConnectionFactory; connectionFactory: ConnectionFactory;
localTransport$: Behavior<LocalTransportWithSFUConfig | null>; localTransport$: Observable<LocalTransport>;
remoteTransports$: Behavior<Epoch<LivekitTransportConfig[]>>; remoteTransports$: Behavior<Epoch<LivekitTransportConfig[]>>;
logger: Logger; logger: Logger;
@@ -121,6 +128,11 @@ export function createConnectionManager$({
const logger = parentLogger.getChild("[ConnectionManager]"); const logger = parentLogger.getChild("[ConnectionManager]");
// TODO logger: only construct one logger from the client and make it compatible via a EC specific sing // TODO logger: only construct one logger from the client and make it compatible via a EC specific sing
const localTransportAsArray$ = localTransport$.pipe(
map((transport) => [transport]),
startWith([]),
);
/** /**
* All transports currently managed by the ConnectionManager. * All transports currently managed by the ConnectionManager.
* *
@@ -130,16 +142,12 @@ export function createConnectionManager$({
* externally this is modified via `registerTransports()`. * externally this is modified via `registerTransports()`.
*/ */
const localAndRemoteTransports$: Behavior< const localAndRemoteTransports$: Behavior<
Epoch<(LivekitTransportConfig | LocalTransportWithSFUConfig)[]> Epoch<(LivekitTransportConfig | LocalTransport)[]>
> = scope.behavior( > = scope.behavior(
combineLatest([remoteTransports$, localTransport$]).pipe( combineLatest([localTransportAsArray$, remoteTransports$]).pipe(
// Combine local and remote transports into one transport array // Combine local and remote transports into one transport array
// and set the forceOldJwtEndpoint property on the local transport // and set the forceOldJwtEndpoint property on the local transport
map(([remoteTransports, localTransport]) => { map(([localTransportAsArray, remoteTransports]) => {
let localTransportAsArray: LocalTransportWithSFUConfig[] = [];
if (localTransport) {
localTransportAsArray = [localTransport];
}
const dedupedRemote = removeDuplicateTransports(remoteTransports.value); const dedupedRemote = removeDuplicateTransports(remoteTransports.value);
const remoteWithoutLocal = dedupedRemote.filter( const remoteWithoutLocal = dedupedRemote.filter(
(transport) => (transport) =>
@@ -170,7 +178,7 @@ export function createConnectionManager$({
"ConnectionManager connections$", "ConnectionManager connections$",
function* (transports) { function* (transports) {
for (const transport of transports) { for (const transport of transports) {
if (isLocalTransportWithSFUConfig(transport)) { if (isLocalTransport(transport)) {
// This is the local transport; only the `LocalTransportWithSFUConfig` has a `sfuConfig` field. // This is the local transport; only the `LocalTransportWithSFUConfig` has a `sfuConfig` field.
yield { yield {
keys: [ keys: [
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/ */
import { test, vi, expect, beforeEach, afterEach } from "vitest"; import { test, vi, expect, beforeEach, afterEach } from "vitest";
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject, NEVER } from "rxjs";
import { type Room as LivekitRoom } from "livekit-client"; import { type Room as LivekitRoom } from "livekit-client";
import EventEmitter from "events"; import EventEmitter from "events";
import fetchMock from "fetch-mock"; import fetchMock from "fetch-mock";
@@ -34,7 +34,6 @@ import {
} from "./MatrixLivekitMembers.ts"; } from "./MatrixLivekitMembers.ts";
import { createConnectionManager$ } from "./ConnectionManager.ts"; import { createConnectionManager$ } from "./ConnectionManager.ts";
import { membershipsAndTransports$ } from "../../SessionBehaviors.ts"; import { membershipsAndTransports$ } from "../../SessionBehaviors.ts";
import { constant } from "../../Behavior.ts";
import { localRtcMember, testJWTToken } from "../../../utils/test-fixtures.ts"; import { localRtcMember, testJWTToken } from "../../../utils/test-fixtures.ts";
// Test the integration of ConnectionManager and MatrixLivekitMerger // Test the integration of ConnectionManager and MatrixLivekitMerger
@@ -124,7 +123,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
const connectionManager = createConnectionManager$({ const connectionManager = createConnectionManager$({
scope: testScope, scope: testScope,
connectionFactory: ecConnectionFactory, connectionFactory: ecConnectionFactory,
localTransport$: constant(null), localTransport$: NEVER,
remoteTransports$: membershipsAndTransports.transports$, remoteTransports$: membershipsAndTransports.transports$,
logger: logger, logger: logger,
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
+12
View File
@@ -18,6 +18,7 @@ import {
share, share,
take, take,
takeUntil, takeUntil,
shareReplay,
} from "rxjs"; } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
@@ -67,6 +68,17 @@ export class ObservableScope {
public readonly share: MonoTypeOperator = (input$) => public readonly share: MonoTypeOperator = (input$) =>
input$.pipe(this.bindImpl, this.shareImpl); input$.pipe(this.bindImpl, this.shareImpl);
private readonly shareReplayImpl: MonoTypeOperator = shareReplay({
bufferSize: 1,
refCount: false,
});
/**
* Shares (multicasts) the Observable as a hot Observable, replaying the most
* recently emitted value upon subscription.
*/
public readonly shareReplay: MonoTypeOperator = (input$) =>
input$.pipe(this.bindImpl, this.shareReplayImpl);
/** /**
* Converts an Observable to a Behavior. If no initial value is specified, the * Converts an Observable to a Behavior. If no initial value is specified, the
* Observable must synchronously emit an initial value. * Observable must synchronously emit an initial value.
+1 -22
View File
@@ -9,30 +9,9 @@ import { expect, test } from "vitest";
import { type Observable, of, Subject, switchMap } from "rxjs"; import { type Observable, of, Subject, switchMap } from "rxjs";
import { withTestScheduler } from "./test"; import { withTestScheduler } from "./test";
import { or$, filterBehavior, generateItems, pauseWhen } from "./observable"; import { filterBehavior, generateItems, pauseWhen } from "./observable";
import { type Behavior } from "../state/Behavior"; import { type Behavior } from "../state/Behavior";
const yesNo = {
y: true,
n: false,
};
test("or$", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const input1Marbles = "ny--n--";
const input2Marbles = "n-y--n-";
const input3Marbles = "n--y--n";
const outputMarbles = "nyyyyyn";
expectObservable(
or$(
behavior(input1Marbles, yesNo),
behavior(input2Marbles, yesNo),
behavior(input3Marbles, yesNo),
),
).toBe(outputMarbles, yesNo);
});
});
test("pauseWhen", () => { test("pauseWhen", () => {
withTestScheduler(({ behavior, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const inputMarbles = " abcdefgh-i-jk-"; const inputMarbles = " abcdefgh-i-jk-";
-9
View File
@@ -8,7 +8,6 @@ Please see LICENSE in the repository root for full details.
import { import {
type Observable, type Observable,
audit, audit,
combineLatest,
concat, concat,
defer, defer,
filter, filter,
@@ -113,14 +112,6 @@ export function getValue<T>(state$: Observable<T>): T {
return value; return value;
} }
/**
* Creates an Observable that has a value of true whenever some of its inputs
* are true.
*/
export function or$(...inputs: Observable<boolean>[]): Observable<boolean> {
return combineLatest(inputs, (...flags) => flags.some((flag) => flag));
}
/** /**
* RxJS operator that pauses all changes in the input value whenever a Behavior * RxJS operator that pauses all changes in the input value whenever a Behavior
* is true. When the Behavior returns to being false, the most recently * is true. When the Behavior returns to being false, the most recently