Compare commits

..
Author SHA1 Message Date
Robin 63fb243e21 Remove hard-coded video tile count from end-to-end tests 2026-10-28 19:49:29 +01:00
Robin 298ae99edd Share connections for transports that resolve to same auth flow
By keying the connections by the transport's resolved auth flow. This is important because connecting to the same SFU twice with the same participant ID will cause a 'duplicate participant ID' error that stops one of the connections.
2026-10-28 19:49:29 +01:00
Robin b27c11c381 Always use OpenID auth flow if available
This removes the notion of an auth flow fallback for now. We will no longer try the CS API when authenticating with transports that include both a `url` and `livekit_service_url`.
2026-10-28 19:16:32 +01:00
Robin c233ac6e70 Improve names of transport shape end-to-end tests 2026-10-28 14:51:27 +01:00
renovate[bot] 6793a4b46e Update ghcr.io/element-hq/element-web:develop Docker digest to ee8ce55 (#4380)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-28 02:32:09 +00:00
Robin 619f5ac3a2 Merge pull request #4375 from element-hq/robin/stats-crash
Fix crash when joining call with connection stats enabled
2026-10-27 18:08:31 +01:00
Robin 5780597b83 Fix crash when joining call with connection stats enabled
As demonstrated by the test. This crashed with "Behavior failed to synchronously emit an initial value."
2026-10-27 16:17:58 +01:00
Robin 76da59be40 Merge pull request #4301 from element-hq/floating-tile
Make drag & drop of floating tiles less janky
2026-10-27 12:16:12 +01:00
Robin ed6a0e1341 Merge pull request #4371 from element-hq/ellipsize-title
Ellipsise app bar text on mobile
2026-10-26 13:45:06 +01:00
renovate[bot]andJohannes Marbach 90ccf62d5c Update ghcr.io/element-hq/element-web:develop Docker digest to c9ffe3e (#4356)
* Update ghcr.io/element-hq/element-web:develop Docker digest to 79e47d8

* Adapt to new recovery popup button title

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-10-26 12:44:39 +00:00
Robin dde07f6d54 Ellipsise app bar text on mobile
We already had text-overflow: ellipsis set on the title and subtitle elements of the app bar, but due to an annoying quirk of place-items: center, this had no effect. place-items: center stretch seems to give the text elements the right size constraints.
2026-10-26 13:25:13 +01:00
13 changed files with 245 additions and 237 deletions
+2 -2
View File
@@ -19,7 +19,7 @@ services:
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
element-web:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:b2a5a30afb4f583a7560448b005d8332bee29672f15826e4a995b393058f0291
image: ghcr.io/element-hq/element-web:develop@sha256:ee8ce55012dda2c097147859aece32802ce7a9174e40c3711c56f75ccfe1b169
element-web-1:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:b2a5a30afb4f583a7560448b005d8332bee29672f15826e4a995b393058f0291
image: ghcr.io/element-hq/element-web:develop@sha256:ee8ce55012dda2c097147859aece32802ce7a9174e40c3711c56f75ccfe1b169
+1 -1
View File
@@ -133,7 +133,7 @@ async function disableLeaveDelegation(page: Page): Promise<void> {
* Expect a certain number of video tiles to be present and visible.
*/
async function expectVideoTilesCount(page: Page, count: number): Promise<void> {
await expect(page.getByTestId("videoTile")).toHaveCount(2);
await expect(page.getByTestId("videoTile")).toHaveCount(count);
// No one should be waiting for media
await expect(page.getByText("Waiting for media...")).not.toBeVisible({
+47 -47
View File
@@ -31,7 +31,11 @@ interface Transport {
*/
const shapes = {
url: { type: "livekit", url: sfuUrl },
both: { type: "livekit", url: sfuUrl, livekit_service_url: serviceUrl },
"url+legacy": {
type: "livekit",
url: sfuUrl,
livekit_service_url: serviceUrl,
},
legacy: null,
} satisfies Record<string, Transport | null>;
type Shape = keyof typeof shapes;
@@ -56,15 +60,15 @@ const cases: {
{
// Publishers keep the legacy participant identity that clients predating
// MSC4195 expect, which only the JWT service assigns.
hostShape: "both",
guestShape: "both",
hostShape: "url+legacy",
guestShape: "url+legacy",
hostFlows: ["service-legacy"],
guestFlows: ["service-legacy"],
},
{
// The MatrixRTC mode has no bearing on the choice.
hostShape: "both",
guestShape: "both",
hostShape: "url+legacy",
guestShape: "url+legacy",
mode: "2_0",
hostFlows: ["service-legacy"],
guestFlows: ["service-legacy"],
@@ -75,6 +79,12 @@ const cases: {
hostFlows: ["cs-api", "service-default"],
guestFlows: ["service-legacy", "cs-api"],
},
{
hostShape: "url",
guestShape: "url+legacy",
hostFlows: ["cs-api", "service-default"],
guestFlows: ["service-legacy", "cs-api"],
},
];
for (const { hostShape, guestShape, mode, hostFlows, guestFlows } of cases) {
@@ -126,47 +136,6 @@ for (const { hostShape, guestShape, mode, hostFlows, guestFlows } of cases) {
});
}
test("Subscriber falls back to the JWT service when the homeserver lacks MSC4195", async ({
browser,
page,
browserName,
}) => {
skipOnFirefox(browserName);
// The host starts the call with both transport shapes.
await advertiseTransport(page, shapes.both);
await page.goto("/");
await SpaHelpers.createCall(page, "Androl", "HelloCall", true);
const inviteLink = await SpaHelpers.getCallInviteLink(page);
// The guest joins but their C-S requests fail.
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
const guestPage = await guestContext.newPage();
await rejectCsApiTokens(guestPage);
const guestRequests = recordAuthRequests(guestPage);
await SpaHelpers.joinCallFromInviteLink(guestPage, inviteLink, "Pevara");
// Wait for the call to connect and render.
await SpaHelpers.expectVideoTilesCount(page, 2);
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
// The guest ran through all flows.
expect(flowsOf(guestRequests)).toEqual(
new Set<Flow>(["cs-api", "service-legacy", "service-default"]),
);
// The C-S token requests failed.
for (const request of guestRequests) {
expect(request.status).toBe(request.flow === "cs-api" ? 404 : 200);
}
// The C-S token requests preceeded the fallback request.
const flows = guestRequests.map((r) => r.flow);
expect(flows.indexOf("service-default")).toBeGreaterThan(
flows.indexOf("cs-api"),
);
});
test("Publisher shows an error when the homeserver lacks MSC4195", async ({
page,
browserName,
@@ -191,6 +160,37 @@ test("Publisher shows an error when the homeserver lacks MSC4195", async ({
).toBeVisible();
});
test("Group call with all possible transport shapes at once", async ({
browser,
page,
browserName,
}) => {
skipOnFirefox(browserName);
// The host starts the call with a url transport.
await advertiseTransport(page, shapes.url);
await page.goto("/");
await SpaHelpers.createCall(page, "Alice", "HelloCall", true);
const inviteLink = await SpaHelpers.getCallInviteLink(page);
// The guest with a legacy transport joins.
const bobContext = await browser.newContext({ reducedMotion: "reduce" });
const bobPage = await bobContext.newPage();
await advertiseTransport(bobPage, shapes.legacy);
await SpaHelpers.joinCallFromInviteLink(bobPage, inviteLink, "Bob");
// The guest with a url+legacy transport joins.
const carolContext = await browser.newContext({ reducedMotion: "reduce" });
const carolPage = await carolContext.newPage();
await advertiseTransport(carolPage, shapes["url+legacy"]);
await SpaHelpers.joinCallFromInviteLink(carolPage, inviteLink, "Carol");
// Wait for the call to connect and render.
await SpaHelpers.expectVideoTilesCount(page, 3);
await SpaHelpers.expectVideoTilesCount(bobPage, 3);
await SpaHelpers.expectVideoTilesCount(carolPage, 3);
});
/**
* Delegation follows the flow that authenticated the publisher: the homeserver
* for a `url`-only transport, the JWT service wherever there is one.
@@ -198,7 +198,7 @@ test("Publisher shows an error when the homeserver lacks MSC4195", async ({
const delegationCases: { shape: Shape; via: Delegation["via"] }[] = [
{ shape: "url", via: "homeserver" },
{ shape: "legacy", via: "service" },
{ shape: "both", via: "service" },
{ shape: "url+legacy", via: "service" },
];
for (const { shape, via } of delegationCases) {
+6 -2
View File
@@ -159,7 +159,7 @@ export class TestHelpers {
public static async dismissStartupToasts(page: Page): Promise<void> {
const expectedToasts = [
{ title: "Failed to load service worker", button: "OK" },
{ title: "Back up your chats", button: "Dismiss" },
{ title: "Back up your chats", button: "Not now" },
{ title: "Turn on key storage", button: "Dismiss" },
{ title: "Element does not support this browser", button: "Dismiss" },
];
@@ -178,7 +178,11 @@ export class TestHelpers {
);
if (toastConfig) {
await toast.getByRole("button", { name: toastConfig.button }).click();
// Bounded so that a renamed button leaves the toast in place and the
// next step reports it, instead of hanging until the test timeout.
await toast
.getByRole("button", { name: toastConfig.button })
.click({ timeout: 5000 });
} else {
// Unknown toast. We don't want to act on unknown toasts
break;
+3 -6
View File
@@ -11,13 +11,12 @@ import { widgetTest } from "../fixtures/widget-user.ts";
import { HOST1, TestHelpers } from "./test-helpers.ts";
const sfuUrl = "wss://matrix-rtc.m.localhost/livekit/sfu";
const serviceUrl = "https://matrix-rtc.m.localhost/livekit/jwt";
/** Which endpoint a page asks for its SFU tokens. */
type Flow = "cs-api" | "service-legacy" | "service-default";
widgetTest(
"Call between a url + livekit_service_url transport and a legacy one",
"Call between a url transport and a legacy one",
async ({ addUser, browserName }) => {
test.skip(
browserName === "firefox",
@@ -30,12 +29,10 @@ widgetTest(
addUser("Pevara", HOST1),
]);
// The host's homeserver has started advertising a `url` next to its JWT
// service; the guest's still advertises the JWT service alone.
// Host advertises a url transport, guest still advertises a legacy one
await advertiseTransport(host.page, {
type: "livekit",
url: sfuUrl,
livekit_service_url: serviceUrl,
});
// Element Web answers the widget from a cache it filled at login, so make
// it fetch the transports again now that they are rerouted
@@ -67,7 +64,7 @@ widgetTest(
}
// Element Web used for Playwright does support MSC4195 for widgets.
expect(hostFlows).toEqual(new Set(["service-legacy", "service-default"]));
expect(hostFlows).toEqual(new Set(["cs-api", "service-default"]));
expect(guestFlows).toEqual(new Set(["service-legacy", "cs-api"]));
},
);
@@ -24,7 +24,7 @@ import {
import { type ConnectionFactory } from "./ConnectionFactory.ts";
import { type Connection } from "./Connection.ts";
import { ownMemberMock, withTestScheduler } from "../../utils/test";
import { type Behavior } from "../../reactive/Behavior";
import { constant, type Behavior } from "../../reactive/Behavior";
import { type TransportLocator } from "./auth";
const TRANSPORT_1: TransportLocator = {
@@ -207,7 +207,7 @@ describe("connections$ stream", () => {
});
});
test("Keeps one connection per homeserver for transports with a url", () => {
test("Keeps one connection per homeserver for url transports", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const urlTransport: TransportLocator = {
transport: { type: "livekit", url: "wss://sfu.example.org" },
@@ -243,6 +243,94 @@ describe("connections$ stream", () => {
});
});
test("Shares connection between local livekit_service_url transport and remote url + livekit_service_url transport", () => {
withTestScheduler(({ behavior, expectObservable, scope }) => {
const serviceUrlTransport: TransportLocator = {
transport: {
type: "livekit",
livekit_service_url: "https://jwt.example.org",
},
serverName: "example.org",
};
const urlAndServiceUrlTransport: TransportLocator = {
transport: {
type: "livekit",
livekit_service_url: "https://jwt.example.org",
url: "wss://sfu.elsewhere.org",
},
// Should pass even if local server != remote server
serverName: "elsewhere.org",
};
const connectionMap$ = createConnectionMap$({
scope,
connectionFactory: fakeConnectionFactory,
localTransport: serviceUrlTransport,
remoteTransports$: constant(new Epoch([urlAndServiceUrlTransport])),
logger,
ownMembershipIdentity: ownMemberMock,
});
expectObservable(
connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
).toBe("a", {
a: expect.toSatisfy(({ value: cs }: Epoch<Connection[]>) => {
// One connection total, since local and remote connections are shared
expect(cs.length).toBe(1);
expect(findConnection(serviceUrlTransport, cs)).toBeDefined();
expect(findConnection(serviceUrlTransport, cs)).toBe(
findConnection(urlAndServiceUrlTransport, cs),
);
return true;
}),
});
});
});
test("Shares connection between remote livekit_service_url transport and remote url + livekit_service_url transport", () => {
withTestScheduler(({ behavior, expectObservable, scope }) => {
const serviceUrlTransport: TransportLocator = {
transport: {
type: "livekit",
livekit_service_url: "https://jwt.example.org",
},
serverName: "example.org",
};
const urlAndServiceUrlTransport: TransportLocator = {
transport: {
type: "livekit",
livekit_service_url: "https://jwt.example.org",
url: "wss://sfu.elsewhere.org",
},
// Should pass even if local server != remote server
serverName: "elsewhere.org",
};
const connectionMap$ = createConnectionMap$({
scope,
connectionFactory: fakeConnectionFactory,
localTransport: TRANSPORT_2,
remoteTransports$: constant(
new Epoch([serviceUrlTransport, urlAndServiceUrlTransport]),
),
logger,
ownMembershipIdentity: ownMemberMock,
});
expectObservable(
connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
).toBe("a", {
a: expect.toSatisfy(({ value: cs }: Epoch<Connection[]>) => {
// Two connections total: a local connection and a shared remote connection
expect(cs.length).toBe(2);
expect(findConnection(serviceUrlTransport, cs)).toBeDefined();
expect(findConnection(serviceUrlTransport, cs)).toBe(
findConnection(urlAndServiceUrlTransport, cs),
);
return true;
}),
});
});
});
test("Should start connection only once", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const connectionMap$ = createConnectionMap$({
@@ -344,7 +432,7 @@ describe("ConnectionMap", () => {
data.add({ ...connection } as Connection, [p("b")]);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining(
'second connection to [null,"https://lk.example.org",null]: existing [a], adding [b]',
'second connection to ["openID","https://lk.example.org"]: existing [a], adding [b]',
),
);
expect(data.getParticipantsForTransport(TRANSPORT_1)).toHaveLength(2);
+17 -10
View File
@@ -18,7 +18,11 @@ import {
type ObservableScope,
} from "../../reactive/ObservableScope";
import { generateItemsWithEpoch } from "../../reactive/observable";
import { type ConnectionRole, type TransportLocator } from "./auth";
import {
chooseAuthFlow,
type ConnectionRole,
type TransportLocator,
} from "./auth";
import { type Connection } from "./Connection";
import { type ConnectionFactory } from "./ConnectionFactory";
@@ -26,22 +30,25 @@ import { type ConnectionFactory } from "./ConnectionFactory";
export type TransportKey = string & { __transportKey: true };
/**
* The key a locator is identified by in a map or set. Locators with the same
* key share a connection: the server name only takes part in authentication
* for a transport with a `url`, where our homeserver asks that server for the
* token. A JWT service is reached directly, so members of several homeservers
* advertising the same one share a connection rather than each get their own.
* Converts a {@link TransportLocator} to a key suitable for identifying it in a
* map of connections.
*/
export function keyFor({
transport,
serverName,
}: TransportLocator): TransportKey {
const hasUrl = "url" in transport;
// Base the key on the auth flow we would use, so that we never create two
// connections that share the same auth details. (That would be bad, as we
// might then try to connect to the same SFU twice with the same participant
// ID, resulting in only one working connection.)
// TODO: This strategy will not work once `chooseAuthFlow` begins allowing for
// falling back from the CS API flow to the OpenID flow.
const flow = chooseAuthFlow(transport);
// Serialize the fields in a consistent order
return JSON.stringify([
hasUrl ? transport.url : null,
"livekit_service_url" in transport ? transport.livekit_service_url : null,
hasUrl ? serverName : null,
flow.kind,
// With the CS API flow, we additionally need to distinguish server names
...(flow.kind === "csApi" ? [flow.url, serverName] : [flow.serviceUrl]),
]) as TransportKey;
}
@@ -166,9 +166,8 @@ export function createLivekitBackend(
);
canDelegate = Promise.resolve(false);
} else {
// Delegation goes through whichever service authenticates us as a
// publisher, so only that service's support counts
const flow = chooseAuthFlow(transport, "publisher");
// Check for support using the same auth flow that we intend to use later
const flow = chooseAuthFlow(transport);
canDelegate =
flow.kind === "openID"
? supportsDelegation(
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import { afterEach, describe, expect, it, type Mocked, vi } from "vitest";
import fetchMock from "fetch-mock";
import { ConnectionError, MatrixError } from "matrix-js-sdk";
import { MatrixError } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
@@ -144,26 +144,8 @@ describe("getSFUConfig", () => {
expect(jwtServiceCalls()).toEqual(["/get_token"]);
});
it("uses CS API when subscribing with url + livekit_service_url transport", async () => {
it("uses OpenID when subscribing with url + livekit_service_url transport", async () => {
mockJWTService();
expect(
await getSFUConfig({
...defaultParams,
transport: urlAndServiceUrlTransport,
}),
).toEqual(
expect.objectContaining({
jwt: csApiJWTToken,
livekitAlias: "via CS API",
}),
);
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
expect(jwtServiceCalls()).toEqual([]);
});
it("falls back to OpenID with url + livekit_service_url transport", async () => {
mockJWTService();
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
expect(
await getSFUConfig({
...defaultParams,
@@ -175,25 +157,7 @@ describe("getSFUConfig", () => {
livekitAlias: "via default endpoint",
}),
);
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
expect(jwtServiceCalls()).toEqual(["/get_token"]);
});
it("falls back to OpenID when the widget host refuses the CS API", async () => {
mockJWTService();
// In widget mode a host without MSC4533 rejects the request before it
// reaches the homeserver, with a plain error rather than a Matrix error
client._unstable_getLivekitToken.mockRejectedValueOnce(
new Error(
"The rtc_livekit_get_token action is not supported by the client.",
),
);
expect(
await getSFUConfig({
...defaultParams,
transport: urlAndServiceUrlTransport,
}),
).toEqual(expect.objectContaining({ jwt: defaultJWTToken }));
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
expect(jwtServiceCalls()).toEqual(["/get_token"]);
});
@@ -222,7 +186,7 @@ describe("getSFUConfig", () => {
expect(jwtServiceCalls()).toEqual([]);
});
it("does not fall back to OpenID when publishing", async () => {
it("does not fall back to OpenID when publishing with url transport", async () => {
mockJWTService();
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
await expect(
@@ -255,33 +219,6 @@ describe("getSFUConfig", () => {
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
expect(jwtServiceCalls()).toEqual(["/sfu/get"]);
});
it("does not fall back to OpenID on connection error", async () => {
vi.useFakeTimers();
try {
mockJWTService();
const client_ = {
...client,
_unstable_getLivekitToken: vi
.fn()
.mockRejectedValue(new ConnectionError("No connection")),
};
await Promise.all([
expect(
getSFUConfig({
...defaultParams,
client: client_,
transport: urlAndServiceUrlTransport,
}),
).rejects.toThrow(expect.any(ConnectionError)),
vi.runAllTimersAsync(), // Allow request to be retried
]);
expect(client_._unstable_getLivekitToken).toHaveBeenCalled();
expect(jwtServiceCalls()).toEqual([]);
} finally {
vi.useRealTimers();
}
});
});
describe("delegateDelayedLeave", () => {
@@ -362,46 +299,21 @@ describe("delegateDelayedLeave", () => {
describe("chooseAuthFlow", () => {
it.each([
[
"url",
"publisher",
urlTransport,
{ kind: "csApi", url: urlTransport.url },
],
[
"url",
"subscriber",
urlTransport,
{ kind: "csApi", url: urlTransport.url },
],
["url", urlTransport, { kind: "csApi", url: urlTransport.url }],
[
"livekit_service_url",
"publisher",
serviceUrlTransport,
{ kind: "openID", serviceUrl },
],
[
"livekit_service_url",
"subscriber",
serviceUrlTransport,
{ kind: "openID", serviceUrl },
],
[
"url + livekit_service_url",
"publisher",
urlAndServiceUrlTransport,
{ kind: "openID", serviceUrl },
],
[
"url + livekit_service_url",
"subscriber",
urlAndServiceUrlTransport,
{ kind: "csApi", url: urlTransport.url },
],
] as const)(
"chooses the flow for a %s transport as %s",
(_shape, role, transport, flow) => {
expect(chooseAuthFlow(transport, role)).toEqual(flow);
(_shape, transport, flow) => {
expect(chooseAuthFlow(transport)).toEqual(flow);
},
);
});
+39 -53
View File
@@ -65,7 +65,10 @@ export interface GetSFUConfigParams {
logger: Logger;
}
/** Authenticates with a transport, through whichever flow suits it and our role. */
/**
* Gets an {@link SFUConfig} appropriate for connecting to a given transport,
* using whichever auth flow is appropriate.
*/
export async function getSFUConfig({
client,
membership,
@@ -76,48 +79,36 @@ export async function getSFUConfig({
role,
logger,
}: GetSFUConfigParams): Promise<SFUConfig> {
const viaOpenID = async (serviceUrl: string): Promise<SFUConfig> =>
await openID.getSFUConfig({
const flow = chooseAuthFlow(transport);
if (flow.kind === "openID") {
return await openID.getSFUConfig({
client,
membership,
serviceUrl,
serviceUrl: flow.serviceUrl,
roomId,
slotId,
role,
logger,
});
const flow = chooseAuthFlow(transport, role);
if (flow.kind === "openID") return await viaOpenID(flow.serviceUrl);
try {
return await csApi.getSFUConfig({
client,
membership,
url: flow.url,
serverName,
roomId,
slotId,
});
} catch (e) {
// A publisher never falls back: a homeserver that advertises a `url` but
// does not implement MSC4195 is misconfigured, and the admin should hear
// about it before `livekit_service_url` is deprecated
const mayFallBack =
role === "subscriber" && "livekit_service_url" in transport;
if (mayFallBack && isCsApiUnavailable(e)) {
logger.warn(
`Cannot use the MSC4195 get_token endpoint (${e}), falling back to the OpenID flow with service ${transport.livekit_service_url}`,
);
return await viaOpenID(transport.livekit_service_url);
} else {
try {
return await csApi.getSFUConfig({
client,
membership,
url: flow.url,
serverName,
roomId,
slotId,
});
} catch (e) {
if (isEndpointUnsupported(e)) {
logger.error(
`Homeserver does not support the MSC4195 get_token endpoint, cannot get token for transport ${flow.url}`,
);
throw new NoMatrix2AuthorizationService(e as Error);
}
throw e;
}
if (isEndpointUnsupported(e)) {
logger.error(
`Homeserver does not support the MSC4195 get_token endpoint, cannot get token for transport ${flow.url}`,
);
throw new NoMatrix2AuthorizationService(e as Error);
}
throw e;
}
}
@@ -149,7 +140,7 @@ export async function delegateDelayedLeave({
delayTimeoutMs,
logger,
}: DelegateDelayedLeaveParams): Promise<void> {
const flow = chooseAuthFlow(transport, "publisher");
const flow = chooseAuthFlow(transport);
if (flow.kind === "csApi") {
try {
await csApi.delegateDelayedLeave({
@@ -190,23 +181,18 @@ export type AuthFlow =
| { kind: "openID"; serviceUrl: string };
/**
* Chooses between the MSC4195 client-server API and the OpenID flow against
* the transport's JWT service.
*
* Only the JWT service's `/sfu/get` endpoint assigns the legacy participant
* identity that clients predating MSC4195 expect from anyone advertising a
* membership, so a publisher keeps using the JWT service wherever the
* transport still offers one, even next to a `url`. A subscriber may use the
* client-server API regardless, as nobody matches its identity.
* Chooses which auth flow to use for a given transport. For now this returns a
* single auth flow, with no provision for a fallback behavior.
*/
export function chooseAuthFlow(
transport: UnstableLivekitTransport,
role: ConnectionRole,
): AuthFlow {
if ("url" in transport) {
const useCsApi =
!("livekit_service_url" in transport) || role === "subscriber";
if (useCsApi) return { kind: "csApi", url: transport.url };
}
export function chooseAuthFlow(transport: UnstableLivekitTransport): AuthFlow {
if ("url" in transport && !("livekit_service_url" in transport))
return { kind: "csApi", url: transport.url };
// All other transports use the OpenID flow, even if they contain a `url`.
// This is necessary since the auth flow determines the participant identity
// format, and we still have older clients out in the wild which would not yet
// know to look for a hashed participant identity in case `url` is present in
// someone else's transport.
// TODO: Once users have updated their clients, choose the CS API flow if
// available, keeping OpenID as a fallback.
return { kind: "openID", serviceUrl: transport.livekit_service_url };
}
+2 -1
View File
@@ -7,6 +7,7 @@
padding-top: env(safe-area-inset-top);
opacity: 1;
transition: opacity 0.15s;
text-align: center;
}
/* Pseudo-element for the gradient background */
@@ -52,7 +53,7 @@
". . ."
"primaryButton title secondaryButton"
". . .";
place-items: center;
place-items: center stretch;
column-gap: var(--cpd-space-2x);
}
+24 -11
View File
@@ -235,22 +235,35 @@ test("audio and video follow the tracks' mute state", () => {
test("user media polls stream stats only while the setting is on", () => {
onTestFinished(() => showConnectionStats.setValue(false));
withTestScheduler(({ cold, expectObservable, schedule }) => {
const stats = { type: "inbound-rtp" } as RTCInboundRtpStreamStats;
const vm = mockRemoteMedia(rtcMembership, {}, [
mockAudioTrack({
stats$: () => cold("-s", { s: stats }),
}),
mockAudioTrack({ stats$: () => cold("-s", { s: { ...stats } }) }),
]);
schedule("-a-b", {
a() {
showConnectionStats.setValue(true);
},
b() {
showConnectionStats.setValue(false);
},
schedule("-t-f-t", {
t: () => showConnectionStats.setValue(true),
f: () => showConnectionStats.setValue(false),
});
expectObservable(vm.audioStreamStats$).toBe("u-su", {
expectObservable(vm.audioStreamStats$).toBe("u-s---s", {
u: undefined,
s: stats,
});
});
});
test("stream stats can be enabled before creating media", () => {
showConnectionStats.setValue(true);
onTestFinished(() => showConnectionStats.setValue(false));
withTestScheduler(({ cold, expectObservable }) => {
const stats = { type: "inbound-rtp" } as RTCInboundRtpStreamStats;
const vm = mockRemoteMedia(rtcMembership, {}, [
mockAudioTrack({ stats$: () => cold("-s", { s: stats }) }),
]);
expectObservable(vm.audioStreamStats$).toBe("us", {
u: undefined,
s: stats,
});
+4 -3
View File
@@ -16,7 +16,9 @@ import {
BehaviorSubject,
combineLatest,
map,
NEVER,
of,
startWith,
Subject,
switchMap,
} from "rxjs";
@@ -105,10 +107,9 @@ export function createBaseUserMedia(
showConnectionStats.value$,
]).pipe(
switchMap(([track, show]) =>
track !== undefined && show
? track.stats$(statsIntervalMs)
: of(undefined),
track !== undefined && show ? track.stats$(statsIntervalMs) : NEVER,
),
startWith<MediaStreamStats>(undefined),
),
);