allow to participate in full_mesh without publishing

This commit is contained in:
Timo K.
2026-11-06 14:04:27 +01:00
committed by GitHub
parent 682edd25a9
commit 39d6a0b690
9 changed files with 160 additions and 51 deletions
+3 -1
View File
@@ -78,8 +78,10 @@
"device_id": "Device ID: {{id}}",
"duplicate_tiles_label": "Number of additional tile copies per participant",
"environment_variables": "Environment variables",
"full_mesh_capabilities": "Take part in full mesh (MSC4575)",
"full_mesh_capabilities_description": "Receive members publishing on full mesh. As a widget, this asks the host for the signalling permissions.",
"full_mesh_peer_connections": "Full mesh peer connections",
"full_mesh_publishing": "Publish on full mesh (MSC4575) instead of LiveKit",
"full_mesh_publishing": "Publish on full mesh instead of LiveKit",
"full_mesh_transport": "Full mesh: {{device}}",
"hostname": "Hostname: {{hostname}}",
"livekit_server_info": "LiveKit Server Info",
+23 -11
View File
@@ -55,14 +55,28 @@ async function recordWidgetToDevice(
);
}
const cases: { name: string; fullMesh: [boolean, boolean] }[] = [
const cases: {
name: string;
/** Who publishes on full mesh; both have the capabilities unless said. */
fullMesh: [boolean, boolean];
capabilities?: [boolean, boolean];
}[] = [
// Both publish on full mesh: the media flows without any SFU
{ name: "both on full mesh", fullMesh: [true, true] },
// One on each: Florian receives Valere over LiveKit without publishing there
// One on each: Florian receives Valere over LiveKit without publishing
// there, and Valere receives Florian over full mesh without publishing there
{ name: "full mesh and LiveKit mixed", fullMesh: [true, false] },
// Without the capabilities a widget can't be subscribed to full mesh
{
name: "LiveKit side without the full mesh capabilities",
fullMesh: [true, false],
capabilities: [true, false],
},
];
cases.forEach(({ name, fullMesh: [florianMesh, valereMesh] }) => {
cases.forEach(({ name, fullMesh, capabilities = [true, true] }) => {
const [florianMesh, valereMesh] = fullMesh;
const [florianCaps, valereCaps] = capabilities;
widgetTest(`Full mesh call: ${name}`, async ({ addUser, browserName }) => {
test.skip(
browserName === "firefox",
@@ -71,7 +85,7 @@ cases.forEach(({ name, fullMesh: [florianMesh, valereMesh] }) => {
test.slow();
// Distinct per case: the cases run in parallel and register at once
const suffix = `${Number(florianMesh)}${Number(valereMesh)}`;
const suffix = `${Number(florianMesh)}${Number(valereMesh)}${Number(florianCaps)}${Number(valereCaps)}`;
const [florian, valere] = await Promise.all([
addUser(`florian${suffix}`, HOST1),
addUser(`valere${suffix}`, HOST1),
@@ -95,24 +109,22 @@ cases.forEach(({ name, fullMesh: [florianMesh, valereMesh] }) => {
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
florian.page,
"2_0",
{ fullMesh: florianMesh },
{ capabilities: florianCaps, publish: florianMesh },
);
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
valere.page,
"2_0",
{ fullMesh: valereMesh },
{ capabilities: valereCaps, publish: valereMesh },
);
await TestHelpers.startCallInCurrentRoom(florian.page, false);
await TestHelpers.joinCallFromLobby(florian.page);
await TestHelpers.joinCallInCurrentRoom(valere.page);
// A widget only asks for the full mesh signalling with full mesh turned
// on, so a LiveKit publisher in Element Web can't receive full mesh yet:
// the other's media arrives if it is on LiveKit or we have full mesh
// The other's media arrives if it is on LiveKit, or we can signal full mesh
const receives = {
florian: !valereMesh || florianMesh,
valere: !florianMesh || valereMesh,
florian: !valereMesh || florianCaps,
valere: !florianMesh || valereCaps,
};
// In parallel: assertions on a page also run its permission handler
await Promise.all(
+18 -6
View File
@@ -24,6 +24,14 @@ export const HOST2 = "https://app.othersite.m.localhost/#/welcome";
export type RtcMode = "compat" | "2_0";
/** Full mesh (MSC4575) in the widget's developer settings. */
export interface FullMeshOptions {
/** Ask the host for the signalling capabilities, to receive full mesh. */
capabilities?: boolean;
/** Publish on full mesh instead of LiveKit; needs the capabilities. */
publish?: boolean;
}
export class TestHelpers {
public static async startCallInCurrentRoom(
page: Page,
@@ -306,7 +314,7 @@ export class TestHelpers {
public static async openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
page: Page,
mode: RtcMode,
{ fullMesh = false }: { fullMesh?: boolean } = {},
fullMesh: FullMeshOptions = {},
): Promise<void> {
await page.getByRole("button", { name: "Video call" }).click({
timeout: 5000,
@@ -315,7 +323,7 @@ export class TestHelpers {
timeout: 10000,
});
await TestHelpers.setEmbeddedElementCallRtcMode(page, mode, { fullMesh });
await TestHelpers.setEmbeddedElementCallRtcMode(page, mode, fullMesh);
await page.getByRole("button", { name: "Close lobby" }).click();
}
@@ -328,7 +336,7 @@ export class TestHelpers {
public static async setEmbeddedElementCallRtcMode(
page: Page,
mode: RtcMode,
{ fullMesh = false }: { fullMesh?: boolean } = {},
{ capabilities = false, publish = false }: FullMeshOptions = {},
): Promise<void> {
const iframe = page.locator('iframe[title="Element Call"]').contentFrame();
@@ -346,10 +354,14 @@ export class TestHelpers {
// compat
await iframe.getByText("Compatibility: state events").click();
}
// Publish on the full mesh transport (MSC4575) instead of LiveKit
// Full mesh (MSC4575): the capabilities first, as publishing needs them
await iframe
.getByText("Publish on full mesh", { exact: false })
.setChecked(fullMesh);
.getByText("Take part in full mesh", { exact: false })
.setChecked(capabilities);
if (capabilities)
await iframe
.getByText("Publish on full mesh", { exact: false })
.setChecked(publish);
await iframe.getByTestId("modal_close").click();
}
+5 -3
View File
@@ -86,6 +86,7 @@ import {
cameraFramerate,
cameraResolution,
customTransport,
fullMeshCapabilities,
fullMeshPublishing,
echoCancellationSetting,
matrixRTCMode as matrixRTCModeSetting,
@@ -839,9 +840,10 @@ export function rtcParticipationOptions(
delegatedDelayedLeave: session.delegated_delayed_leave,
},
mediaQuality: config.media_quality,
transport: fullMeshPublishing.value$.value
? { type: FULL_MESH_TRANSPORT_TYPE }
: (customTransport.value$.value ?? undefined),
transport:
fullMeshCapabilities.value$.value && fullMeshPublishing.value$.value
? { type: FULL_MESH_TRANSPORT_TYPE }
: (customTransport.value$.value ?? undefined),
fallbackTransport: config.livekit && {
type: "livekit",
livekit_service_url: config.livekit.livekit_service_url,
@@ -9,6 +9,11 @@ Please see LICENSE in the repository root for full details.
font-size: var(--font-size-micro);
}
/* An option that depends on the one above it */
.subOption {
padding-inline-start: var(--cpd-space-8x);
}
.livekit_room_box {
border: 3px solid var(--cpd-color-bg-subtle-secondary);
border-radius: var(--cpd-space-8x);
+20 -1
View File
@@ -45,6 +45,7 @@ import {
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
matrixRTCMode as matrixRTCModeSetting,
customTransport as customTransportSetting,
fullMeshCapabilities as fullMeshCapabilitiesSetting,
fullMeshPublishing as fullMeshPublishingSetting,
advancedScreenShare as advancedScreenShareSetting,
screenShareResolution as screenShareResolutionSetting,
@@ -177,6 +178,9 @@ export const DeveloperSettingsTab: FC<Props> = ({
const [customTransportUpdateError, setCustomTransportUpdateError] = useState<
string | null
>(null);
const [fullMeshCapabilities, setFullMeshCapabilities] = useSetting(
fullMeshCapabilitiesSetting,
);
const [fullMeshPublishing, setFullMeshPublishing] = useSetting(
fullMeshPublishingSetting,
);
@@ -423,10 +427,25 @@ export const DeveloperSettingsTab: FC<Props> = ({
/>
</FieldRow>
<FieldRow>
<InputField
id="fullMeshCapabilities"
type="checkbox"
checked={fullMeshCapabilities}
label={t("developer_mode.full_mesh_capabilities")}
description={t("developer_mode.full_mesh_capabilities_description")}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
setFullMeshCapabilities(event.target.checked);
// Publishing needs the capabilities
if (!event.target.checked) setFullMeshPublishing(false);
}}
/>
</FieldRow>
<FieldRow className={styles.subOption}>
<InputField
id="fullMeshPublishing"
type="checkbox"
checked={fullMeshPublishing}
checked={fullMeshPublishing && fullMeshCapabilities}
disabled={!fullMeshCapabilities}
label={t("developer_mode.full_mesh_publishing")}
onChange={(event: ChangeEvent<HTMLInputElement>): void =>
setFullMeshPublishing(event.target.checked)
@@ -51,6 +51,50 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
>
<input
aria-describedby="_r_2_"
id="fullMeshCapabilities"
type="checkbox"
/>
<label
for="fullMeshCapabilities"
>
<div
class="_checkbox_1bd8c0"
>
<svg
fill="none"
height="24"
stroke="#000"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m20 6-11 11-5-5"
/>
</svg>
</div>
Take part in full mesh (MSC4575)
</label>
<p
class="_description_1bd8c0"
id="_r_2_"
>
Receive members publishing on full mesh. As a widget, this asks the host for the signalling permissions.
</p>
</div>
</div>
<div
class="_fieldRow_1bd8c0 _subOption_2ddec4"
>
<div
class="_field_1bd8c0 _checkboxField_1bd8c0 _disabled_1bd8c0"
>
<input
aria-describedby="_r_3_"
disabled=""
id="fullMeshPublishing"
type="checkbox"
/>
@@ -76,7 +120,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
/>
</svg>
</div>
Publish on full mesh (MSC4575) instead of LiveKit
Publish on full mesh instead of LiveKit
</label>
</div>
</div>
@@ -87,7 +131,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_3_"
aria-describedby="_r_4_"
id="debugTileLayout"
type="checkbox"
/>
@@ -124,7 +168,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_4_"
aria-describedby="_r_5_"
id="showConnectionStats"
type="checkbox"
/>
@@ -161,7 +205,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_5_"
aria-describedby="_r_6_"
id="muteAllAudio"
type="checkbox"
/>
@@ -199,7 +243,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_6_"
aria-describedby="_r_7_"
id="alwaysShowIphoneEarpiece"
type="checkbox"
/>
@@ -236,7 +280,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_7_"
aria-describedby="_r_8_"
id="enableLivekitExtendedLogs"
type="checkbox"
/>
@@ -274,7 +318,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
>
<label
class="_label_1o4d9_60"
for="radix-_r_8_"
for="radix-_r_9_"
>
Custom transport JSON
</label>
@@ -282,9 +326,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_controls_17lij_8"
>
<input
aria-describedby="radix-_r_9_"
aria-describedby="radix-_r_a_"
class="_control_d83jn_10"
id="radix-_r_8_"
id="radix-_r_9_"
name="input"
title=""
value=""
@@ -292,7 +336,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</div>
<span
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-_r_9_"
id="radix-_r_a_"
>
Currently, no overwrite is set. Url from config is used.
</span>
@@ -322,10 +366,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_container_1ug7n_10"
>
<input
aria-describedby="radix-_r_b_ radix-_r_d_"
aria-describedby="radix-_r_c_ radix-_r_e_"
checked=""
class="_input_1ug7n_18"
id="radix-_r_a_"
id="radix-_r_b_"
name="_r_0_"
title=""
type="radio"
@@ -341,13 +385,13 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
>
<label
class="_label_1o4d9_60"
for="radix-_r_a_"
for="radix-_r_b_"
>
Compatibility: state events & multi SFU
</label>
<span
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-_r_b_"
id="radix-_r_c_"
>
Compatible with homeservers that do not support sticky events (but all other EC clients are v0.17.0 or later)
</span>
@@ -363,9 +407,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_container_1ug7n_10"
>
<input
aria-describedby="radix-_r_b_ radix-_r_d_"
aria-describedby="radix-_r_c_ radix-_r_e_"
class="_input_1ug7n_18"
id="radix-_r_c_"
id="radix-_r_d_"
name="_r_0_"
title=""
type="radio"
@@ -381,13 +425,13 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
>
<label
class="_label_1o4d9_60"
for="radix-_r_c_"
for="radix-_r_d_"
>
Matrix 2.0: sticky events & multi SFU
</label>
<span
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-_r_d_"
id="radix-_r_e_"
>
Compatible only with homservers supporting sticky events and all EC clients v0.17.0 or later
</span>
@@ -452,7 +496,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_j_"
aria-describedby="_r_k_"
id="cameraToggle"
type="checkbox"
/>
@@ -482,7 +526,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</label>
<p
class="_description_1bd8c0"
id="_r_j_"
id="_r_k_"
>
Configure resolution, framerate, bitrate, and codec for camera video. Changes apply on next call join.
</p>
@@ -504,7 +548,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_k_"
aria-describedby="_r_l_"
id="screenShareToggle"
type="checkbox"
/>
@@ -534,7 +578,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</label>
<p
class="_description_1bd8c0"
id="_r_k_"
id="_r_l_"
>
Configure resolution, framerate, bitrate, and codec for screen sharing
</p>
@@ -559,7 +603,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_l_"
aria-describedby="_r_m_"
checked=""
id="echoCancellation"
type="checkbox"
@@ -597,7 +641,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_m_"
aria-describedby="_r_n_"
checked=""
id="noiseSuppression"
type="checkbox"
@@ -635,7 +679,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_n_"
aria-describedby="_r_o_"
checked=""
id="autoGainControl"
type="checkbox"
+13 -2
View File
@@ -168,8 +168,19 @@ export const customTransport = new Setting<UnstableLivekitTransport | null>(
);
/**
* Publish on the full mesh transport (MSC4575) instead of LiveKit. Members on
* either transport are received whatever this says.
* Take part in the full mesh transport (MSC4575): as a widget, ask the host
* for the signalling capabilities, so that members publishing on full mesh
* are received. Element Web doesn't auto-approve them for Element Call yet
* and asks the user whenever the widget opens, hence a setting.
*/
export const fullMeshCapabilities = new Setting<boolean>(
"full-mesh-capabilities",
false,
);
/**
* Publish on the full mesh transport instead of LiveKit. Only with
* `fullMeshCapabilities`: a widget without them couldn't be subscribed to.
*/
export const fullMeshPublishing = new Setting<boolean>(
"full-mesh-publishing",
+4 -2
View File
@@ -22,7 +22,7 @@ import { LazyEventEmitter } from "./LazyEventEmitter";
import { getUrlParams } from "./UrlParams";
import { Config } from "./config/Config";
import {
fullMeshPublishing,
fullMeshCapabilities,
seedSettingsFromConfig,
} from "./settings/settings";
import { ElementCallReactionEventType } from "./reactions";
@@ -169,7 +169,9 @@ export const initializeWidget = (
// Full mesh signalling (MSC4575), only where it is turned on: Element
// Web doesn't auto-approve these for Element Call yet, and would ask
// the user on every call
...(fullMeshPublishing.getValue() ? fullMeshSignallingEventTypes : []),
...(fullMeshCapabilities.getValue()
? fullMeshSignallingEventTypes
: []),
];
const client = createRoomWidgetClient(