diff --git a/playwright/restricted-sfu.spec.ts b/playwright/restricted-sfu.spec.ts new file mode 100644 index 00000000..a9e07d38 --- /dev/null +++ b/playwright/restricted-sfu.spec.ts @@ -0,0 +1,75 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { expect, test } from "@playwright/test"; +import { sleep } from "matrix-js-sdk/lib/utils.js"; + +test("Should request JWT token before starting the call", async ({ page }) => { + await page.goto("/"); + + let sfGetTimestamp = 0; + let sendStateEventTimestamp = 0; + await page.route( + "**/matrix-rtc.m.localhost/livekit/jwt/sfu/get", + async (route) => { + await sleep(2000); // Simulate very slow request + await route.continue(); + sfGetTimestamp = Date.now(); + }, + ); + + await page.route( + "**/state/org.matrix.msc3401.call.member/**", + async (route) => { + await route.continue(); + sendStateEventTimestamp = Date.now(); + }, + ); + + await page.getByTestId("home_callName").click(); + await page.getByTestId("home_callName").fill("HelloCall"); + await page.getByTestId("home_displayName").click(); + await page.getByTestId("home_displayName").fill("John Doe"); + await page.getByTestId("home_go").click(); + + // Join the call + await page.getByTestId("lobby_joinCall").click(); + await page.waitForTimeout(4000); + // Ensure that the call is connected + await page + .locator("div") + .filter({ hasText: /^HelloCall$/ }) + .click(); + + expect(sfGetTimestamp).toBeGreaterThan(0); + expect(sendStateEventTimestamp).toBeGreaterThan(0); + expect(sfGetTimestamp).toBeLessThan(sendStateEventTimestamp); +}); + +test("Error when pre-warming the focus are caught by the ErrorBoundary", async ({ + page, +}) => { + await page.goto("/"); + + await page.route("**/openid/request_token", async (route) => { + await route.fulfill({ + status: 418, // Simulate an error not retryable + }); + }); + + await page.getByTestId("home_callName").click(); + await page.getByTestId("home_callName").fill("HelloCall"); + await page.getByTestId("home_displayName").click(); + await page.getByTestId("home_displayName").fill("John Doe"); + await page.getByTestId("home_go").click(); + + // Join the call + await page.getByTestId("lobby_joinCall").click(); + + // Should fail + await expect(page.getByText("Something went wrong")).toBeVisible(); +}); diff --git a/playwright/sfu-reconnect-bug.spec.ts b/playwright/sfu-reconnect-bug.spec.ts index c756570a..6138eb78 100644 --- a/playwright/sfu-reconnect-bug.spec.ts +++ b/playwright/sfu-reconnect-bug.spec.ts @@ -100,5 +100,5 @@ test("When creator left, avoid reconnect to the same SFU", async ({ // https://github.com/element-hq/element-call/issues/3344 // The app used to request a new jwt token then to reconnect to the SFU expect(wsConnectionCount).toBe(1); - expect(sfuGetCallCount).toBe(1); + expect(sfuGetCallCount).toBe(2 /* the first one is for the warmup */); }); diff --git a/src/rtcSessionHelpers.ts b/src/rtcSessionHelpers.ts index be5eedf1..3e2ff9cd 100644 --- a/src/rtcSessionHelpers.ts +++ b/src/rtcSessionHelpers.ts @@ -5,14 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; -import { logger } from "matrix-js-sdk/lib/logger"; import { isLivekitFocus, isLivekitFocusConfig, type LivekitFocus, type LivekitFocusActive, + type MatrixRTCSession, } from "matrix-js-sdk/lib/matrixrtc"; +import { logger } from "matrix-js-sdk/lib/logger"; import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery"; import { PosthogAnalytics } from "./analytics/PosthogAnalytics"; @@ -20,6 +20,7 @@ import { Config } from "./config/Config"; import { ElementWidgetActions, widget, type WidgetHelpers } from "./widget"; import { MatrixRTCFocusMissingError } from "./utils/errors"; import { getUrlParams } from "./UrlParams"; +import { getSFUConfigWithOpenID } from "./livekit/openIDSFU.ts"; const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci"; @@ -51,32 +52,13 @@ async function makePreferredLivekitFoci( if (domain) { // we use AutoDiscovery instead of relying on the MatrixClient having already // been fully configured and started - const wellKnownFoci = (await AutoDiscovery.getRawClientConfig(domain))?.[ - FOCI_WK_KEY - ]; - if (Array.isArray(wellKnownFoci)) { - preferredFoci.push( - ...wellKnownFoci - .filter((f) => !!f) - .filter(isLivekitFocusConfig) - .map((wellKnownFocus) => { - logger.log( - "Adding livekit focus from well known: ", - wellKnownFocus, - ); - return { ...wellKnownFocus, livekit_alias: livekitAlias }; - }), - ); - } + const wellKnownFoci = await getFocusListFromWellKnown(domain, livekitAlias); + logger.log("Adding livekit focus from well known: ", wellKnownFoci); + preferredFoci.push(...wellKnownFoci); } - const urlFromConf = Config.get().livekit?.livekit_service_url; - if (urlFromConf) { - const focusFormConf: LivekitFocus = { - type: "livekit", - livekit_service_url: urlFromConf, - livekit_alias: livekitAlias, - }; + const focusFormConf = getFocusListFromConfig(livekitAlias); + if (focusFormConf) { logger.log("Adding livekit focus from config: ", focusFormConf); preferredFoci.push(focusFormConf); } @@ -94,6 +76,93 @@ async function makePreferredLivekitFoci( // if (focusOtherMembers) preferredFoci.push(focusOtherMembers); } +async function getFocusListFromWellKnown( + domain: string, + alias: string, +): Promise { + if (domain) { + // we use AutoDiscovery instead of relying on the MatrixClient having already + // been fully configured and started + const wellKnownFoci = (await AutoDiscovery.getRawClientConfig(domain))?.[ + FOCI_WK_KEY + ]; + if (Array.isArray(wellKnownFoci)) { + return wellKnownFoci + .filter((f) => !!f) + .filter(isLivekitFocusConfig) + .map((wellKnownFocus) => { + return { ...wellKnownFocus, livekit_alias: alias }; + }); + } + } + return []; +} + +function getFocusListFromConfig(livekitAlias: string): LivekitFocus | null { + const urlFromConf = Config.get().livekit?.livekit_service_url; + if (urlFromConf) { + return { + type: "livekit", + livekit_service_url: urlFromConf, + livekit_alias: livekitAlias, + }; + } + return null; +} + +export async function getMyPreferredLivekitFoci( + domain: string | null, + livekitAlias: string, +): Promise { + if (domain) { + // we use AutoDiscovery instead of relying on the MatrixClient having already + // been fully configured and started + const wellKnownFociList = await getFocusListFromWellKnown( + domain, + livekitAlias, + ); + if (wellKnownFociList.length > 0) { + return wellKnownFociList[0]; + } + } + + const urlFromConf = Config.get().livekit?.livekit_service_url; + if (urlFromConf) { + return { + type: "livekit", + livekit_service_url: urlFromConf, + livekit_alias: livekitAlias, + }; + } + throw new MatrixRTCFocusMissingError(domain ?? ""); +} + +// Stop-gap solution for pre-warming the SFU. +// This is needed to ensure that the livekit room is created before we try to join the rtc session. +// This is because the livekit room creation is done by the auth service and this can be restricted to +// only specific users, so we need to ensure that the room is created before we try to join it. +async function preWarmSFU(rtcSession: MatrixRTCSession, livekitAlias: string) { + const client = rtcSession.room.client; + // We need to make sure that the livekit room is created before sending the membership event + // because other joiners might not be able to join the call if the room does not exist yet. + const fociToWarmup = await getMyPreferredLivekitFoci( + client.getDomain(), + livekitAlias + ); + + // Request a token in advance to warm up the livekit room. + // Let it throw if it fails, errors will be handled by the ErrorBoundary, if it fails now + // it will fail later when we try to join the room. + await getSFUConfigWithOpenID(client, fociToWarmup); + // For now we don't do anything with the token returned by `getSFUConfigWithOpenID`, it is just to ensure that we + // call the `sfu/get` endpoint so that the auth service create the room in advance if it can. + // Note: This is not actually checking that the room was created! If the roon creation is + // not done by the auth service, the call will fail later when we try to join the room; that case + // is a miss-configuration of the auth service, you should be able to create room in your selected SFU. + // A solution could be to call the internal `/validate` endpoint to check that the room exists, but this needs + // to access livekit internal APIs, so we don't do it for now. +} + export async function enterRTCSession( rtcSession: MatrixRTCSession, encryptMedia: boolean, @@ -112,6 +181,10 @@ export async function enterRTCSession( const { features, matrix_rtc_session: matrixRtcSessionConfig } = Config.get(); const useDeviceSessionMemberEvents = features?.feature_use_device_session_member_events; + + // Pre-warm the SFU to ensure that the room is created before anyone tries to join it. + await preWarmSFU(rtcSession, livekitAlias); + rtcSession.joinRoomSession( await makePreferredLivekitFoci(rtcSession, livekitAlias), makeActiveFocus(),