Let developers override the whole transport

The developer setting that overrode the LiveKit service URL becomes a
JSON transport, so that a `url`-only or combined transport can be tested
against a homeserver that does not advertise one yet. The settings modal
shows each LiveKit room's role instead of a never-populated isLocal flag.
This commit is contained in:
Robin
2026-10-12 10:33:19 +02:00
committed by Johannes Marbach
parent e336ee5cfa
commit f366e7cbe2
9 changed files with 120 additions and 95 deletions
+3 -3
View File
@@ -59,10 +59,10 @@
"developer_mode": { "developer_mode": {
"always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms", "always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms",
"crypto_version": "Crypto version: {{version}}", "crypto_version": "Crypto version: {{version}}",
"custom_livekit_url": { "custom_transport": {
"current_url": "Currently set to: ", "current": "Currently set to: ",
"from_config": "Currently, no overwrite is set. Url from config is used.", "from_config": "Currently, no overwrite is set. Url from config is used.",
"label": "Custom Livekit-url", "label": "Custom transport JSON",
"reset": "Reset overwrite", "reset": "Reset overwrite",
"save": "Save", "save": "Save",
"saving": "Saving..." "saving": "Saving..."
+10 -9
View File
@@ -694,18 +694,19 @@ export const InCallView: FC<InCallViewProps> = ({
tab={settingsTab} tab={settingsTab}
onTabChange={setSettingsTab} onTabChange={setSettingsTab}
developerSettingsVm={developerSettingsVm} developerSettingsVm={developerSettingsVm}
livekitRooms={allConnections.map((connectionItem) => ({ livekitRooms={allConnections.map(
room: connectionItem.livekitRoom, ({ livekitRoom: room, livekitAlias, transport, role }) => ({
livekitAlias: connectionItem.livekitAlias, room,
// TODO compute is local or tag it in the livekit room items already livekitAlias,
isLocal: undefined, role,
url: url:
// It should be relatively clear to the user which kind of URL // It should be relatively clear to the user which kind of URL
// this is, based on the scheme (ws:// vs. http://) // this is, based on the scheme (ws:// vs. http://)
"url" in connectionItem.transport "url" in transport
? connectionItem.transport.url ? transport.url
: connectionItem.transport.livekit_service_url, : transport.livekit_service_url,
}))} }),
)}
/> />
</> </>
)} )}
+51 -32
View File
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import { afterEach, describe, expect, it, type Mock, vi } from "vitest"; import { afterEach, describe, expect, it, type Mock, vi } from "vitest";
import { render, waitFor, screen } from "@testing-library/react"; import { render, waitFor, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent, { type UserEvent } from "@testing-library/user-event";
import { TooltipProvider } from "@vector-im/compound-web"; import { TooltipProvider } from "@vector-im/compound-web";
import type { MatrixClient } from "matrix-js-sdk"; import type { MatrixClient } from "matrix-js-sdk";
@@ -19,12 +19,12 @@ import {
import { outOfCallDeveloperSettingsTabViewModel } from "./DeveloperSettingsTabViewModel"; import { outOfCallDeveloperSettingsTabViewModel } from "./DeveloperSettingsTabViewModel";
import { createStaticViewModel } from "../state/ViewModel"; import { createStaticViewModel } from "../state/ViewModel";
import { import {
customLivekitUrl as customLivekitUrlSetting, customTransport as customTransportSetting,
enableExtendedLivekitLogs as enableExtendedLivekitLogsSetting, enableExtendedLivekitLogs as enableExtendedLivekitLogsSetting,
matrixRTCMode as matrixRTCModeSetting, matrixRTCMode as matrixRTCModeSetting,
} from "./settings"; } from "./settings";
import { MatrixRTCMode } from "../config/ConfigOptions"; import { MatrixRTCMode } from "../config/ConfigOptions";
import { mockConfig } from "../utils/test"; import { exampleTransport, mockConfig } from "../utils/test";
import { getSFUConfig } from "../livekit/auth"; import { getSFUConfig } from "../livekit/auth";
// Mock url params hook to avoid environment-dependent snapshot churn. // Mock url params hook to avoid environment-dependent snapshot churn.
@@ -77,6 +77,21 @@ function createMockMatrixClient(): MatrixClient {
} as unknown as MatrixClient; } as unknown as MatrixClient;
} }
/**
* Types the JSON representation of an object into the given element.
*/
async function typeJson(
user: UserEvent,
element: Element,
object: object,
): Promise<void> {
// `user.type` requires us to escape brackets by doubling them
await user.type(
element,
JSON.stringify(object).replace(/([{[\]])/g, (c) => c + c),
);
}
describe("DeveloperSettingsTab", () => { describe("DeveloperSettingsTab", () => {
it("renders and matches snapshot", async () => { it("renders and matches snapshot", async () => {
const client = createMockMatrixClient(); const client = createMockMatrixClient();
@@ -124,9 +139,9 @@ describe("DeveloperSettingsTab", () => {
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
describe("custom livekit url", () => { describe("custom transport", () => {
afterEach(() => { afterEach(() => {
customLivekitUrlSetting.setValue(null); customTransportSetting.setValue(null);
}); });
const client = { const client = {
doesServerSupportUnstableFeature: vi.fn().mockResolvedValue(true), doesServerSupportUnstableFeature: vi.fn().mockResolvedValue(true),
@@ -135,7 +150,7 @@ describe("DeveloperSettingsTab", () => {
getDeviceId: () => "DEVICE", getDeviceId: () => "DEVICE",
getDomain: () => "hs", getDomain: () => "hs",
} as unknown as MatrixClient; } as unknown as MatrixClient;
it("will not update custom livekit url without roomId", async () => { it("will not update custom transport without roomId", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render( render(
@@ -148,17 +163,17 @@ describe("DeveloperSettingsTab", () => {
</TooltipProvider>, </TooltipProvider>,
); );
const input = screen.getByLabelText("Custom Livekit-url"); const input = screen.getByLabelText("Custom transport JSON");
await user.clear(input); await user.clear(input);
await user.type(input, "wss://example.livekit.invalid"); await typeJson(user, input, exampleTransport);
const saveButton = screen.getByRole("button", { name: "Save" }); const saveButton = screen.getByRole("button", { name: "Save" });
await user.click(saveButton); await user.click(saveButton);
expect(getSFUConfig).not.toHaveBeenCalled(); expect(getSFUConfig).not.toHaveBeenCalled();
expect(customLivekitUrlSetting.getValue()).toBe(null); expect(customTransportSetting.getValue()).toBe(null);
}); });
it("will not update custom livekit url without text in input", async () => { it("will not update custom transport without text in input", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render( render(
@@ -172,16 +187,16 @@ describe("DeveloperSettingsTab", () => {
</TooltipProvider>, </TooltipProvider>,
); );
const input = screen.getByLabelText("Custom Livekit-url"); const input = screen.getByLabelText("Custom transport JSON");
await user.clear(input); await user.clear(input);
const saveButton = screen.getByRole("button", { name: "Save" }); const saveButton = screen.getByRole("button", { name: "Save" });
await user.click(saveButton); await user.click(saveButton);
expect(getSFUConfig).not.toHaveBeenCalled(); expect(getSFUConfig).not.toHaveBeenCalled();
expect(customLivekitUrlSetting.getValue()).toBe(null); expect(customTransportSetting.getValue()).toBe(null);
}); });
it("will not update custom livekit url when pressing cancel", async () => { it("will not update custom transport when pressing cancel", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render( render(
@@ -195,7 +210,7 @@ describe("DeveloperSettingsTab", () => {
</TooltipProvider>, </TooltipProvider>,
); );
const input = screen.getByLabelText("Custom Livekit-url"); const input = screen.getByLabelText("Custom transport JSON");
await user.clear(input); await user.clear(input);
await user.type(input, "wss://example.livekit.invalid"); await user.type(input, "wss://example.livekit.invalid");
@@ -205,9 +220,9 @@ describe("DeveloperSettingsTab", () => {
await user.click(cancelButton); await user.click(cancelButton);
expect(getSFUConfig).not.toHaveBeenCalled(); expect(getSFUConfig).not.toHaveBeenCalled();
expect(customLivekitUrlSetting.getValue()).toBe(null); expect(customTransportSetting.getValue()).toBe(null);
}); });
it("will update custom livekit url", async () => { it("will update custom transport", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render( render(
@@ -221,27 +236,28 @@ describe("DeveloperSettingsTab", () => {
</TooltipProvider>, </TooltipProvider>,
); );
const input = screen.getByLabelText("Custom Livekit-url"); const input = screen.getByLabelText("Custom transport JSON");
await user.clear(input); await user.clear(input);
await user.type(input, "wss://example.livekit.valid"); await typeJson(user, input, {
type: "livekit",
url: "wss://example.livekit.valid",
});
const saveButton = screen.getByRole("button", { name: "Save" }); const saveButton = screen.getByRole("button", { name: "Save" });
await user.click(saveButton); await user.click(saveButton);
expect(getSFUConfig).toHaveBeenCalledWith( expect(getSFUConfig).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
transport: { transport: { type: "livekit", url: "wss://example.livekit.valid" },
type: "livekit",
livekit_service_url: "wss://example.livekit.valid",
},
roomId: "#testRoom", roomId: "#testRoom",
}), }),
); );
expect(customLivekitUrlSetting.getValue()).toBe( expect(customTransportSetting.getValue()).toEqual({
"wss://example.livekit.valid", type: "livekit",
); url: "wss://example.livekit.valid",
}); });
it("will show error on invalid url", async () => { });
it("will show error on unreachable transport", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render( render(
@@ -255,24 +271,27 @@ describe("DeveloperSettingsTab", () => {
</TooltipProvider>, </TooltipProvider>,
); );
const input = screen.getByLabelText("Custom Livekit-url"); const input = screen.getByLabelText("Custom transport JSON");
await user.clear(input); await user.clear(input);
await user.type(input, "wss://example.livekit.valid"); await typeJson(user, input, {
type: "livekit",
url: "wss://example.livekit.valid",
});
const saveButton = screen.getByRole("button", { name: "Save" }); const saveButton = screen.getByRole("button", { name: "Save" });
(getSFUConfig as Mock).mockImplementation(() => { (getSFUConfig as Mock).mockImplementation(() => {
throw new Error("Invalid URL"); throw new Error("Failed to get SFU config");
}); });
await user.click(saveButton); await user.click(saveButton);
expect( expect(
screen.getByText("invalid URL (did not update)"), screen.getByText("invalid transport (did not update)"),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(customLivekitUrlSetting.getValue()).toBe(null); expect(customTransportSetting.getValue()).toBe(null);
}); });
}); });
// Add this test inside the describe("DeveloperSettingsTab", () => { block, // Add this test inside the describe("DeveloperSettingsTab", () => { block,
// after the custom livekit url tests: // after the custom transport tests:
describe("enable extended livekit logs", () => { describe("enable extended livekit logs", () => {
afterEach(() => { afterEach(() => {
+36 -33
View File
@@ -45,7 +45,7 @@ import {
muteAllAudio as muteAllAudioSetting, muteAllAudio as muteAllAudioSetting,
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting, alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
matrixRTCMode as matrixRTCModeSetting, matrixRTCMode as matrixRTCModeSetting,
customLivekitUrl as customLivekitUrlSetting, customTransport as customTransportSetting,
advancedScreenShare as advancedScreenShareSetting, advancedScreenShare as advancedScreenShareSetting,
screenShareResolution as screenShareResolutionSetting, screenShareResolution as screenShareResolutionSetting,
screenShareFramerate as screenShareFramerateSetting, screenShareFramerate as screenShareFramerateSetting,
@@ -167,16 +167,18 @@ export const DeveloperSettingsTab: FC<Props> = ({
enableExtendedLivekitLogsSetting, enableExtendedLivekitLogsSetting,
); );
const [customLivekitUrlUpdateError, setCustomLivekitUrlUpdateError] = const [customTransportUpdateError, setCustomLivekitUrlUpdateError] = useState<
useState<string | null>(null); string | null
const [customLivekitUrl, setCustomLivekitUrl] = useSetting( >(null);
customLivekitUrlSetting, const [customTransport, setCustomTransport] = useSetting(
customTransportSetting,
);
const [customTransportTextBuffer, setCustomLivekitUrlTextBuffer] = useState(
() => JSON.stringify(customTransport),
); );
const [customLivekitUrlTextBuffer, setCustomLivekitUrlTextBuffer] =
useState(customLivekitUrl);
useEffect(() => { useEffect(() => {
setCustomLivekitUrlTextBuffer(customLivekitUrl); setCustomLivekitUrlTextBuffer(JSON.stringify(customTransport));
}, [customLivekitUrl]); }, [customTransport]);
const [muteAllAudio, setMuteAllAudio] = useSetting(muteAllAudioSetting); const [muteAllAudio, setMuteAllAudio] = useSetting(muteAllAudioSetting);
@@ -491,23 +493,23 @@ export const DeveloperSettingsTab: FC<Props> = ({
<EditInPlace <EditInPlace
onSubmit={(e) => e.preventDefault()} onSubmit={(e) => e.preventDefault()}
helpLabel={ helpLabel={
customLivekitUrl === null customTransport === null
? t("developer_mode.custom_livekit_url.from_config") ? t("developer_mode.custom_transport.from_config")
: t("developer_mode.custom_livekit_url.current_url") + : t("developer_mode.custom_transport.current") +
customLivekitUrl JSON.stringify(customTransport)
} }
label={t("developer_mode.custom_livekit_url.label")} label={t("developer_mode.custom_transport.label")}
saveButtonLabel={t("developer_mode.custom_livekit_url.save")} saveButtonLabel={t("developer_mode.custom_transport.save")}
savingLabel={t("developer_mode.custom_livekit_url.saving")} savingLabel={t("developer_mode.custom_transport.saving")}
cancelButtonLabel={t("developer_mode.custom_livekit_url.reset")} cancelButtonLabel={t("developer_mode.custom_transport.reset")}
onSave={useCallback( onSave={useCallback(
async (e: React.FormEvent<HTMLFormElement>): Promise<void> => { async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
if ( if (
roomId === undefined || roomId === undefined ||
customLivekitUrlTextBuffer === "" || customTransportTextBuffer === "" ||
customLivekitUrlTextBuffer === null customTransportTextBuffer === null
) { ) {
setCustomLivekitUrl(null); setCustomTransport(null);
return; return;
} }
@@ -518,13 +520,12 @@ export const DeveloperSettingsTab: FC<Props> = ({
if (userId === null || deviceId === null) { if (userId === null || deviceId === null) {
throw new Error("Invalid user or device ID"); throw new Error("Invalid user or device ID");
} }
const transport = JSON.parse(customTransportTextBuffer);
// Validate the transport by attempting to get a token for it
await getSFUConfig({ await getSFUConfig({
client, client,
membership: { userId, deviceId, memberId: "" }, membership: { userId, deviceId, memberId: "" },
transport: { transport,
type: "livekit",
livekit_service_url: customLivekitUrlTextBuffer,
},
serverName: client.getDomain()!, serverName: client.getDomain()!,
roomId, roomId,
slotId: "org.example.fake-slot#room", slotId: "org.example.fake-slot#room",
@@ -532,14 +533,16 @@ export const DeveloperSettingsTab: FC<Props> = ({
logger, logger,
}); });
setCustomLivekitUrlUpdateError(null); setCustomLivekitUrlUpdateError(null);
setCustomLivekitUrl(customLivekitUrlTextBuffer); setCustomTransport(transport);
} catch { } catch {
setCustomLivekitUrlUpdateError("invalid URL (did not update)"); setCustomLivekitUrlUpdateError(
"invalid transport (did not update)",
);
} }
}, },
[customLivekitUrlTextBuffer, setCustomLivekitUrl, client, roomId], [customTransportTextBuffer, setCustomTransport, client, roomId],
)} )}
value={customLivekitUrlTextBuffer ?? ""} value={customTransportTextBuffer ?? ""}
onChange={useCallback( onChange={useCallback(
(event: ChangeEvent<HTMLInputElement>): void => { (event: ChangeEvent<HTMLInputElement>): void => {
setCustomLivekitUrlTextBuffer(event.target.value); setCustomLivekitUrlTextBuffer(event.target.value);
@@ -548,14 +551,14 @@ export const DeveloperSettingsTab: FC<Props> = ({
)} )}
onCancel={useCallback( onCancel={useCallback(
(e: React.FormEvent<HTMLFormElement>) => { (e: React.FormEvent<HTMLFormElement>) => {
setCustomLivekitUrl(null); setCustomTransport(null);
}, },
[setCustomLivekitUrl], [setCustomTransport],
)} )}
serverInvalid={customLivekitUrlUpdateError !== null} serverInvalid={customTransportUpdateError !== null}
> >
{customLivekitUrlUpdateError !== null && ( {customTransportUpdateError !== null && (
<ErrorMessage>{customLivekitUrlUpdateError}</ErrorMessage> <ErrorMessage>{customTransportUpdateError}</ErrorMessage>
)} )}
</EditInPlace> </EditInPlace>
<Separator /> <Separator />
+1 -1
View File
@@ -59,7 +59,7 @@ interface Props {
livekitRooms?: { livekitRooms?: {
room: LivekitRoom; room: LivekitRoom;
url: string; url: string;
isLocal?: boolean; role: "publisher" | "subscriber";
}[]; }[];
/** Only available while in a call. Used by the developer tab. */ /** Only available while in a call. Used by the developer tab. */
developerSettingsVm?: ViewModel<DeveloperSettingsSnapshot>; developerSettingsVm?: ViewModel<DeveloperSettingsSnapshot>;
@@ -239,7 +239,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_label_1o4d9_60" class="_label_1o4d9_60"
for="radix-_r_7_" for="radix-_r_7_"
> >
Custom Livekit-url Custom transport JSON
</label> </label>
<div <div
class="_controls_17lij_8" class="_controls_17lij_8"
@@ -250,7 +250,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
id="radix-_r_7_" id="radix-_r_7_"
name="input" name="input"
title="" title=""
value="" value="null"
/> />
</div> </div>
<span <span
+3 -2
View File
@@ -12,6 +12,7 @@ import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { type Behavior } from "../state/Behavior"; import { type Behavior } from "../state/Behavior";
import { useBehavior } from "../useBehavior"; import { useBehavior } from "../useBehavior";
import { MatrixRTCMode } from "../config/ConfigOptions"; import { MatrixRTCMode } from "../config/ConfigOptions";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
export class Setting<T> { export class Setting<T> {
public constructor( public constructor(
@@ -153,8 +154,8 @@ export const matrixRTCMode = new Setting<MatrixRTCMode>(
MatrixRTCMode.Compatibility, MatrixRTCMode.Compatibility,
); );
export const customLivekitUrl = new Setting<string | null>( export const customTransport = new Setting<UnstableLivekitTransport | null>(
"custom-livekit-url", "custom-transport",
null, null,
); );
@@ -19,7 +19,7 @@ import fetchMock from "fetch-mock";
import { mockConfig } from "../../../utils/test"; import { mockConfig } from "../../../utils/test";
import { getLocalTransport } from "./LocalTransport"; import { getLocalTransport } from "./LocalTransport";
import { MatrixRTCTransportMissingError } from "../../../utils/errors"; import { MatrixRTCTransportMissingError } from "../../../utils/errors";
import { customLivekitUrl } from "../../../settings/settings"; import { customTransport } from "../../../settings/settings";
import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixClient } from "matrix-js-sdk";
describe("LocalTransport", () => { describe("LocalTransport", () => {
@@ -60,7 +60,7 @@ describe("LocalTransport", () => {
>; >;
beforeEach(() => { beforeEach(() => {
mockConfig({}); mockConfig({});
customLivekitUrl.setValue(customLivekitUrl.defaultValue); customTransport.setValue(customTransport.defaultValue);
client = { client = {
getDomain: vi.fn().mockReturnValue("example.org"), getDomain: vi.fn().mockReturnValue("example.org"),
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]), _unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
@@ -86,7 +86,10 @@ describe("LocalTransport", () => {
}); });
it("supports getting transport via user settings", async () => { it("supports getting transport via user settings", async () => {
customLivekitUrl.setValue("https://lk.example.org"); customTransport.setValue({
type: "livekit",
livekit_service_url: "https://lk.example.org",
});
expect(await getLocalTransport(client)).toStrictEqual({ expect(await getLocalTransport(client)).toStrictEqual({
transport: { transport: {
@@ -5,13 +5,12 @@ 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 { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixClient } from "matrix-js-sdk";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { Config } from "../../../config/Config.ts"; import { Config } from "../../../config/Config.ts";
import { MatrixRTCTransportMissingError } from "../../../utils/errors.ts"; import { MatrixRTCTransportMissingError } from "../../../utils/errors.ts";
import { customLivekitUrl } from "../../../settings/settings.ts"; import { customTransport as customTransportSetting } from "../../../settings/settings.ts";
import { RtcTransportAutoDiscovery } from "./RtcTransportAutoDiscovery.ts"; import { RtcTransportAutoDiscovery } from "./RtcTransportAutoDiscovery.ts";
import { type TransportLocator } from "../../../livekit/auth/types.ts"; import { type TransportLocator } from "../../../livekit/auth/types.ts";
@@ -30,12 +29,11 @@ export async function getLocalTransport(
resolvedConfig: Config.get(), resolvedConfig: Config.get(),
logger: rootLogger.getChild("[LocalTransport]"), logger: rootLogger.getChild("[LocalTransport]"),
}); });
const customUrl = customLivekitUrl.value$.value;
// Respect the user's custom URL, if set // Respect the user's custom transport, if set
const transport: UnstableLivekitTransport | null = customUrl const transport =
? { type: "livekit", livekit_service_url: customUrl } customTransportSetting.value$.value ??
: await discovery.discoverPreferredTransport(); (await discovery.discoverPreferredTransport());
if (transport === null) throw new MatrixRTCTransportMissingError(serverName); if (transport === null) throw new MatrixRTCTransportMissingError(serverName);