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": {
"always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms",
"crypto_version": "Crypto version: {{version}}",
"custom_livekit_url": {
"current_url": "Currently set to: ",
"custom_transport": {
"current": "Currently set to: ",
"from_config": "Currently, no overwrite is set. Url from config is used.",
"label": "Custom Livekit-url",
"label": "Custom transport JSON",
"reset": "Reset overwrite",
"save": "Save",
"saving": "Saving..."
+13 -12
View File
@@ -694,18 +694,19 @@ export const InCallView: FC<InCallViewProps> = ({
tab={settingsTab}
onTabChange={setSettingsTab}
developerSettingsVm={developerSettingsVm}
livekitRooms={allConnections.map((connectionItem) => ({
room: connectionItem.livekitRoom,
livekitAlias: connectionItem.livekitAlias,
// TODO compute is local or tag it in the livekit room items already
isLocal: undefined,
url:
// It should be relatively clear to the user which kind of URL
// this is, based on the scheme (ws:// vs. http://)
"url" in connectionItem.transport
? connectionItem.transport.url
: connectionItem.transport.livekit_service_url,
}))}
livekitRooms={allConnections.map(
({ livekitRoom: room, livekitAlias, transport, role }) => ({
room,
livekitAlias,
role,
url:
// It should be relatively clear to the user which kind of URL
// this is, based on the scheme (ws:// vs. http://)
"url" in transport
? transport.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 { 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 type { MatrixClient } from "matrix-js-sdk";
@@ -19,12 +19,12 @@ import {
import { outOfCallDeveloperSettingsTabViewModel } from "./DeveloperSettingsTabViewModel";
import { createStaticViewModel } from "../state/ViewModel";
import {
customLivekitUrl as customLivekitUrlSetting,
customTransport as customTransportSetting,
enableExtendedLivekitLogs as enableExtendedLivekitLogsSetting,
matrixRTCMode as matrixRTCModeSetting,
} from "./settings";
import { MatrixRTCMode } from "../config/ConfigOptions";
import { mockConfig } from "../utils/test";
import { exampleTransport, mockConfig } from "../utils/test";
import { getSFUConfig } from "../livekit/auth";
// Mock url params hook to avoid environment-dependent snapshot churn.
@@ -77,6 +77,21 @@ function createMockMatrixClient(): 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", () => {
it("renders and matches snapshot", async () => {
const client = createMockMatrixClient();
@@ -124,9 +139,9 @@ describe("DeveloperSettingsTab", () => {
expect(container).toMatchSnapshot();
});
describe("custom livekit url", () => {
describe("custom transport", () => {
afterEach(() => {
customLivekitUrlSetting.setValue(null);
customTransportSetting.setValue(null);
});
const client = {
doesServerSupportUnstableFeature: vi.fn().mockResolvedValue(true),
@@ -135,7 +150,7 @@ describe("DeveloperSettingsTab", () => {
getDeviceId: () => "DEVICE",
getDomain: () => "hs",
} 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();
render(
@@ -148,17 +163,17 @@ describe("DeveloperSettingsTab", () => {
</TooltipProvider>,
);
const input = screen.getByLabelText("Custom Livekit-url");
const input = screen.getByLabelText("Custom transport JSON");
await user.clear(input);
await user.type(input, "wss://example.livekit.invalid");
await typeJson(user, input, exampleTransport);
const saveButton = screen.getByRole("button", { name: "Save" });
await user.click(saveButton);
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();
render(
@@ -172,16 +187,16 @@ describe("DeveloperSettingsTab", () => {
</TooltipProvider>,
);
const input = screen.getByLabelText("Custom Livekit-url");
const input = screen.getByLabelText("Custom transport JSON");
await user.clear(input);
const saveButton = screen.getByRole("button", { name: "Save" });
await user.click(saveButton);
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();
render(
@@ -195,7 +210,7 @@ describe("DeveloperSettingsTab", () => {
</TooltipProvider>,
);
const input = screen.getByLabelText("Custom Livekit-url");
const input = screen.getByLabelText("Custom transport JSON");
await user.clear(input);
await user.type(input, "wss://example.livekit.invalid");
@@ -205,9 +220,9 @@ describe("DeveloperSettingsTab", () => {
await user.click(cancelButton);
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();
render(
@@ -221,27 +236,28 @@ describe("DeveloperSettingsTab", () => {
</TooltipProvider>,
);
const input = screen.getByLabelText("Custom Livekit-url");
const input = screen.getByLabelText("Custom transport JSON");
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" });
await user.click(saveButton);
expect(getSFUConfig).toHaveBeenCalledWith(
expect.objectContaining({
transport: {
type: "livekit",
livekit_service_url: "wss://example.livekit.valid",
},
transport: { type: "livekit", url: "wss://example.livekit.valid" },
roomId: "#testRoom",
}),
);
expect(customLivekitUrlSetting.getValue()).toBe(
"wss://example.livekit.valid",
);
expect(customTransportSetting.getValue()).toEqual({
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();
render(
@@ -255,24 +271,27 @@ describe("DeveloperSettingsTab", () => {
</TooltipProvider>,
);
const input = screen.getByLabelText("Custom Livekit-url");
const input = screen.getByLabelText("Custom transport JSON");
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" });
(getSFUConfig as Mock).mockImplementation(() => {
throw new Error("Invalid URL");
throw new Error("Failed to get SFU config");
});
await user.click(saveButton);
expect(
screen.getByText("invalid URL (did not update)"),
screen.getByText("invalid transport (did not update)"),
).toBeInTheDocument();
expect(customLivekitUrlSetting.getValue()).toBe(null);
expect(customTransportSetting.getValue()).toBe(null);
});
});
// Add this test inside the describe("DeveloperSettingsTab", () => { block,
// after the custom livekit url tests:
// after the custom transport tests:
describe("enable extended livekit logs", () => {
afterEach(() => {
+36 -33
View File
@@ -45,7 +45,7 @@ import {
muteAllAudio as muteAllAudioSetting,
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
matrixRTCMode as matrixRTCModeSetting,
customLivekitUrl as customLivekitUrlSetting,
customTransport as customTransportSetting,
advancedScreenShare as advancedScreenShareSetting,
screenShareResolution as screenShareResolutionSetting,
screenShareFramerate as screenShareFramerateSetting,
@@ -167,16 +167,18 @@ export const DeveloperSettingsTab: FC<Props> = ({
enableExtendedLivekitLogsSetting,
);
const [customLivekitUrlUpdateError, setCustomLivekitUrlUpdateError] =
useState<string | null>(null);
const [customLivekitUrl, setCustomLivekitUrl] = useSetting(
customLivekitUrlSetting,
const [customTransportUpdateError, setCustomLivekitUrlUpdateError] = useState<
string | null
>(null);
const [customTransport, setCustomTransport] = useSetting(
customTransportSetting,
);
const [customTransportTextBuffer, setCustomLivekitUrlTextBuffer] = useState(
() => JSON.stringify(customTransport),
);
const [customLivekitUrlTextBuffer, setCustomLivekitUrlTextBuffer] =
useState(customLivekitUrl);
useEffect(() => {
setCustomLivekitUrlTextBuffer(customLivekitUrl);
}, [customLivekitUrl]);
setCustomLivekitUrlTextBuffer(JSON.stringify(customTransport));
}, [customTransport]);
const [muteAllAudio, setMuteAllAudio] = useSetting(muteAllAudioSetting);
@@ -491,23 +493,23 @@ export const DeveloperSettingsTab: FC<Props> = ({
<EditInPlace
onSubmit={(e) => e.preventDefault()}
helpLabel={
customLivekitUrl === null
? t("developer_mode.custom_livekit_url.from_config")
: t("developer_mode.custom_livekit_url.current_url") +
customLivekitUrl
customTransport === null
? t("developer_mode.custom_transport.from_config")
: t("developer_mode.custom_transport.current") +
JSON.stringify(customTransport)
}
label={t("developer_mode.custom_livekit_url.label")}
saveButtonLabel={t("developer_mode.custom_livekit_url.save")}
savingLabel={t("developer_mode.custom_livekit_url.saving")}
cancelButtonLabel={t("developer_mode.custom_livekit_url.reset")}
label={t("developer_mode.custom_transport.label")}
saveButtonLabel={t("developer_mode.custom_transport.save")}
savingLabel={t("developer_mode.custom_transport.saving")}
cancelButtonLabel={t("developer_mode.custom_transport.reset")}
onSave={useCallback(
async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
if (
roomId === undefined ||
customLivekitUrlTextBuffer === "" ||
customLivekitUrlTextBuffer === null
customTransportTextBuffer === "" ||
customTransportTextBuffer === null
) {
setCustomLivekitUrl(null);
setCustomTransport(null);
return;
}
@@ -518,13 +520,12 @@ export const DeveloperSettingsTab: FC<Props> = ({
if (userId === null || deviceId === null) {
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({
client,
membership: { userId, deviceId, memberId: "" },
transport: {
type: "livekit",
livekit_service_url: customLivekitUrlTextBuffer,
},
transport,
serverName: client.getDomain()!,
roomId,
slotId: "org.example.fake-slot#room",
@@ -532,14 +533,16 @@ export const DeveloperSettingsTab: FC<Props> = ({
logger,
});
setCustomLivekitUrlUpdateError(null);
setCustomLivekitUrl(customLivekitUrlTextBuffer);
setCustomTransport(transport);
} 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(
(event: ChangeEvent<HTMLInputElement>): void => {
setCustomLivekitUrlTextBuffer(event.target.value);
@@ -548,14 +551,14 @@ export const DeveloperSettingsTab: FC<Props> = ({
)}
onCancel={useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
setCustomLivekitUrl(null);
setCustomTransport(null);
},
[setCustomLivekitUrl],
[setCustomTransport],
)}
serverInvalid={customLivekitUrlUpdateError !== null}
serverInvalid={customTransportUpdateError !== null}
>
{customLivekitUrlUpdateError !== null && (
<ErrorMessage>{customLivekitUrlUpdateError}</ErrorMessage>
{customTransportUpdateError !== null && (
<ErrorMessage>{customTransportUpdateError}</ErrorMessage>
)}
</EditInPlace>
<Separator />
+1 -1
View File
@@ -59,7 +59,7 @@ interface Props {
livekitRooms?: {
room: LivekitRoom;
url: string;
isLocal?: boolean;
role: "publisher" | "subscriber";
}[];
/** Only available while in a call. Used by the developer tab. */
developerSettingsVm?: ViewModel<DeveloperSettingsSnapshot>;
@@ -239,7 +239,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_label_1o4d9_60"
for="radix-_r_7_"
>
Custom Livekit-url
Custom transport JSON
</label>
<div
class="_controls_17lij_8"
@@ -250,7 +250,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
id="radix-_r_7_"
name="input"
title=""
value=""
value="null"
/>
</div>
<span
+3 -2
View File
@@ -12,6 +12,7 @@ import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { type Behavior } from "../state/Behavior";
import { useBehavior } from "../useBehavior";
import { MatrixRTCMode } from "../config/ConfigOptions";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
export class Setting<T> {
public constructor(
@@ -153,8 +154,8 @@ export const matrixRTCMode = new Setting<MatrixRTCMode>(
MatrixRTCMode.Compatibility,
);
export const customLivekitUrl = new Setting<string | null>(
"custom-livekit-url",
export const customTransport = new Setting<UnstableLivekitTransport | null>(
"custom-transport",
null,
);
@@ -19,7 +19,7 @@ import fetchMock from "fetch-mock";
import { mockConfig } from "../../../utils/test";
import { getLocalTransport } from "./LocalTransport";
import { MatrixRTCTransportMissingError } from "../../../utils/errors";
import { customLivekitUrl } from "../../../settings/settings";
import { customTransport } from "../../../settings/settings";
import { type MatrixClient } from "matrix-js-sdk";
describe("LocalTransport", () => {
@@ -60,7 +60,7 @@ describe("LocalTransport", () => {
>;
beforeEach(() => {
mockConfig({});
customLivekitUrl.setValue(customLivekitUrl.defaultValue);
customTransport.setValue(customTransport.defaultValue);
client = {
getDomain: vi.fn().mockReturnValue("example.org"),
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
@@ -86,7 +86,10 @@ describe("LocalTransport", () => {
});
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({
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.
*/
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { type MatrixClient } from "matrix-js-sdk";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { Config } from "../../../config/Config.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 { type TransportLocator } from "../../../livekit/auth/types.ts";
@@ -30,12 +29,11 @@ export async function getLocalTransport(
resolvedConfig: Config.get(),
logger: rootLogger.getChild("[LocalTransport]"),
});
const customUrl = customLivekitUrl.value$.value;
// Respect the user's custom URL, if set
const transport: UnstableLivekitTransport | null = customUrl
? { type: "livekit", livekit_service_url: customUrl }
: await discovery.discoverPreferredTransport();
// Respect the user's custom transport, if set
const transport =
customTransportSetting.value$.value ??
(await discovery.discoverPreferredTransport());
if (transport === null) throw new MatrixRTCTransportMissingError(serverName);