mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
Merge pull request #4316 from element-hq/toger5/matrixrtc-sdk-package
[MatrixRTCSdk] add the sdk
This commit is contained in:
@@ -64,5 +64,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: build-output-${{ inputs.package }}
|
name: build-output-${{ inputs.package }}
|
||||||
path: dist
|
path: dist
|
||||||
|
if-no-files-found: error
|
||||||
# We'll only use this in a triggered job, then we're done with it
|
# We'll only use this in a triggered job, then we're done with it
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|||||||
@@ -73,7 +73,10 @@ jobs:
|
|||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
build_sdk_element_call:
|
build_sdk_element_call:
|
||||||
# Use the embedded package vite build
|
# The 'sdk' package is the CallViewModel bundle that predates the sdk/
|
||||||
|
# package (build:sdk:production, sdk-target-based-on-call-view-model/), not
|
||||||
|
# the package itself. The name is shared with pr-deploy.yaml, which always
|
||||||
|
# runs from main, so it stays until the bundle's consumers have moved over.
|
||||||
uses: ./.github/workflows/build-element-call.yaml
|
uses: ./.github/workflows/build-element-call.yaml
|
||||||
with:
|
with:
|
||||||
package: sdk
|
package: sdk
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
],
|
],
|
||||||
"element-call/no-observablescope-leak": "error",
|
"element-call/no-observablescope-leak": "error",
|
||||||
"element-call/no-top-level-logger-get-child": "error",
|
"element-call/no-top-level-logger-get-child": "error",
|
||||||
|
"element-call/sdk-import-boundary": "error",
|
||||||
"jsdoc/empty-tags": "error",
|
"jsdoc/empty-tags": "error",
|
||||||
"jsdoc/check-property-names": "error",
|
"jsdoc/check-property-names": "error",
|
||||||
"jsdoc/require-param-description": "warn",
|
"jsdoc/require-param-description": "warn",
|
||||||
|
|||||||
@@ -72,7 +72,15 @@ Code that builds in only one is a bug.
|
|||||||
|
|
||||||
- `build:full` — standalone app, also widget mode.
|
- `build:full` — standalone app, also widget mode.
|
||||||
- `build:embedded` — `@element-hq/element-call-embedded`.
|
- `build:embedded` — `@element-hq/element-call-embedded`.
|
||||||
- `build:sdk` — SDK library, entry `sdk/main.ts`.
|
- `build:sdk` — `@element-hq/matrixrtc-sdk`, sources in `sdk/`, entry `sdk/index.ts`.
|
||||||
|
Packaged exactly like the component (own pnpm project, run pnpm from the repo
|
||||||
|
root, types from `sdk/tsconfig.build.json`, externals checked by
|
||||||
|
`pnpm lint:externals`). Interface only so far; the design is `sdk-plan.md`.
|
||||||
|
- `build:sdk-target-based-on-call-view-model` — the SDK demo that predates the
|
||||||
|
package, one bundle of the `CallViewModel`; entry
|
||||||
|
`sdk-target-based-on-call-view-model/main.ts`. CI's `sdk` package
|
||||||
|
(`build:sdk:production` / `build:sdk:development`, the Netlify `-sdk` preview)
|
||||||
|
is still this bundle, not `build:sdk`.
|
||||||
- `build:component` — `@element-hq/element-call-component`, sources in `component/`
|
- `build:component` — `@element-hq/element-call-component`, sources in `component/`
|
||||||
(its own pnpm project; run pnpm from the repo root). Host API is in the README;
|
(its own pnpm project; run pnpm from the repo root). Host API is in the README;
|
||||||
`pnpm lint:externals` rejects an import of a `react` / `react-dom` /
|
`pnpm lint:externals` rejects an import of a `react` / `react-dom` /
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ translations. The browser is Playwright's, so a fresh clone needs
|
|||||||
- `playwright/component/` — the component in a host page, via the harness on port
|
- `playwright/component/` — the component in a host page, via the harness on port
|
||||||
3001 that Playwright starts as a second web server. Catches container-relative
|
3001 that Playwright starts as a second web server. Catches container-relative
|
||||||
layout, styles escaping the root, two instances on a page, host-bridge reports.
|
layout, styles escaping the root, two instances on a page, host-bridge reports.
|
||||||
|
- `playwright/sdk/` — the MatrixRTC SDK through its harness on port 3002, a page
|
||||||
|
with none of Element Call on it. The smoke test is what the implementation is
|
||||||
|
built against.
|
||||||
- `playwright/mobile/` — Pixel 7, `mobile` project only.
|
- `playwright/mobile/` — Pixel 7, `mobile` project only.
|
||||||
|
|
||||||
Test what a user observes: the peer sees the change, it survives a reconnect, it is
|
Test what a user observes: the peer sees the change, it survives a reconnect, it is
|
||||||
@@ -50,4 +53,5 @@ pnpm test # unit + storybook
|
|||||||
pnpm backend # Synapse + LiveKit, required for e2e
|
pnpm backend # Synapse + LiveKit, required for e2e
|
||||||
pnpm test:playwright # or :open
|
pnpm test:playwright # or :open
|
||||||
pnpm dev:component # component harness, port 3001
|
pnpm dev:component # component harness, port 3001
|
||||||
|
pnpm dev:sdk # SDK harness, port 3002
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { dirname, resolve, sep } from "node:path";
|
||||||
|
import { ESLintUtils } from "@typescript-eslint/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Packages a host supplies and the SDK must not know about: UI, i18n and
|
||||||
|
* Element Call's design system.
|
||||||
|
*/
|
||||||
|
const BANNED_PACKAGES = [
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"react-i18next",
|
||||||
|
"i18next",
|
||||||
|
"@vector-im/",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Packages only a media backend may import. The session speaks MatrixRTC and
|
||||||
|
* sees media through `sdk/src/media-backend/api.ts`; a backend under
|
||||||
|
* `sdk/src/media-backend/<name>/` is the one place its protocol's library appears.
|
||||||
|
*/
|
||||||
|
const BACKEND_PACKAGES = ["livekit-client", "@livekit/"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the SDK free of Element Call: nothing under `sdk/` may import a file
|
||||||
|
* outside `sdk/` by relative path (that is `src/`, `component/`,
|
||||||
|
* `playwright/`) or one of the packages above. The development harness under
|
||||||
|
* `sdk/dev/` is a host, not part of the library, and is exempt. Keeps the
|
||||||
|
* session free of any one media backend, too: outside `sdk/src/media-backend/`,
|
||||||
|
* a backend's packages may only be imported for their types, and only in
|
||||||
|
* the public API, the errors and the test helpers that have to name them.
|
||||||
|
*/
|
||||||
|
const rule = ESLintUtils.RuleCreator(
|
||||||
|
() => "https://github.com/element-hq/element-call",
|
||||||
|
)({
|
||||||
|
name: "sdk-import-boundary",
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
"Disallow the SDK importing Element Call (anything outside sdk/) or host-supplied packages.",
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
outsideSdk:
|
||||||
|
"The SDK must not import '{{specifier}}': it resolves outside sdk/. Copy or move what it needs into sdk/src.",
|
||||||
|
bannedPackage:
|
||||||
|
"The SDK must not import '{{specifier}}': a host supplies that, the SDK has no UI.",
|
||||||
|
backendPackage:
|
||||||
|
"Only a media backend under sdk/src/media-backend/ may import '{{specifier}}'; the session sees media through media-backend/api.ts.",
|
||||||
|
},
|
||||||
|
schema: [],
|
||||||
|
},
|
||||||
|
defaultOptions: [],
|
||||||
|
create(context) {
|
||||||
|
const filename = context.filename;
|
||||||
|
const sdkRoot = resolve(context.cwd, "sdk") + sep;
|
||||||
|
if (!filename.startsWith(sdkRoot)) return {};
|
||||||
|
if (filename.startsWith(resolve(sdkRoot, "dev") + sep)) return {};
|
||||||
|
const inBackend = filename.startsWith(
|
||||||
|
resolve(sdkRoot, "src", "media-backend") + sep,
|
||||||
|
);
|
||||||
|
|
||||||
|
const matchesPackage = (specifier, pkg) =>
|
||||||
|
specifier === pkg ||
|
||||||
|
specifier.startsWith(pkg.endsWith("/") ? pkg : `${pkg}/`);
|
||||||
|
|
||||||
|
const check = (node, specifier, typeOnly = false) => {
|
||||||
|
if (typeof specifier !== "string") return;
|
||||||
|
if (
|
||||||
|
!inBackend &&
|
||||||
|
!typeOnly &&
|
||||||
|
BACKEND_PACKAGES.some((pkg) => matchesPackage(specifier, pkg))
|
||||||
|
) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: "backendPackage",
|
||||||
|
data: { specifier },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (specifier.startsWith(".")) {
|
||||||
|
const target = resolve(dirname(filename), specifier);
|
||||||
|
if (!target.startsWith(sdkRoot))
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: "outsideSdk",
|
||||||
|
data: { specifier },
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
BANNED_PACKAGES.some((pkg) => matchesPackage(specifier, pkg))
|
||||||
|
) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: "bannedPackage",
|
||||||
|
data: { specifier },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
ImportDeclaration(node) {
|
||||||
|
check(
|
||||||
|
node,
|
||||||
|
node.source.value,
|
||||||
|
node.importKind === "type" ||
|
||||||
|
node.specifiers.every((s) => s.importKind === "type"),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
ExportNamedDeclaration(node) {
|
||||||
|
if (node.source) check(node, node.source.value);
|
||||||
|
},
|
||||||
|
ExportAllDeclaration(node) {
|
||||||
|
check(node, node.source.value);
|
||||||
|
},
|
||||||
|
ImportExpression(node) {
|
||||||
|
if (node.source.type === "Literal") check(node, node.source.value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default rule;
|
||||||
@@ -4,5 +4,6 @@ module.exports = {
|
|||||||
"no-observablescope-leak": require("./NoObservableScopeLeak").default,
|
"no-observablescope-leak": require("./NoObservableScopeLeak").default,
|
||||||
"no-top-level-logger-get-child": require("./NoTopLevelLoggerGetChild")
|
"no-top-level-logger-get-child": require("./NoTopLevelLoggerGetChild")
|
||||||
.default,
|
.default,
|
||||||
|
"sdk-import-boundary": require("./SdkImportBoundary").default,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export default {
|
|||||||
"vite.config.ts",
|
"vite.config.ts",
|
||||||
"vite-embedded.config.ts",
|
"vite-embedded.config.ts",
|
||||||
"vite-sdk.config.ts",
|
"vite-sdk.config.ts",
|
||||||
|
"vite-sdk-dev.config.ts",
|
||||||
|
"vite-sdk-target-based-on-call-view-model.config.ts",
|
||||||
"vite-component.config.ts",
|
"vite-component.config.ts",
|
||||||
"vite-component-dev.config.ts",
|
"vite-component-dev.config.ts",
|
||||||
],
|
],
|
||||||
|
|||||||
+9
-5
@@ -7,6 +7,7 @@
|
|||||||
"dev:full": "vite",
|
"dev:full": "vite",
|
||||||
"dev:embedded": "vite --config vite-embedded.config.js",
|
"dev:embedded": "vite --config vite-embedded.config.js",
|
||||||
"dev:component": "vite --config vite-component-dev.config.ts",
|
"dev:component": "vite --config vite-component-dev.config.ts",
|
||||||
|
"dev:sdk": "vite --config vite-sdk-dev.config.ts",
|
||||||
"build": "pnpm build:full",
|
"build": "pnpm build:full",
|
||||||
"build:full": "node --max-old-space-size=16384 node_modules/vite/bin/vite.js build",
|
"build:full": "node --max-old-space-size=16384 node_modules/vite/bin/vite.js build",
|
||||||
"build:full:production": "pnpm build:full",
|
"build:full:production": "pnpm build:full",
|
||||||
@@ -14,9 +15,12 @@
|
|||||||
"build:embedded": "pnpm build:full --config vite-embedded.config.js",
|
"build:embedded": "pnpm build:full --config vite-embedded.config.js",
|
||||||
"build:embedded:production": "pnpm build:embedded",
|
"build:embedded:production": "pnpm build:embedded",
|
||||||
"build:embedded:development": "pnpm build:embedded --mode development",
|
"build:embedded:development": "pnpm build:embedded --mode development",
|
||||||
"build:sdk:development": "pnpm build:sdk --mode development",
|
"build:sdk": "pnpm build:sdk:js && pnpm build:sdk:types",
|
||||||
"build:sdk": "pnpm build:full --config vite-sdk.config.js",
|
"build:sdk:js": "pnpm build:full --config vite-sdk.config.js",
|
||||||
"build:sdk:production": "pnpm build:sdk",
|
"build:sdk:types": "tsc -p sdk/tsconfig.build.json",
|
||||||
|
"build:sdk-target-based-on-call-view-model": "pnpm build:full --config vite-sdk-target-based-on-call-view-model.config.js",
|
||||||
|
"build:sdk:production": "pnpm build:sdk-target-based-on-call-view-model",
|
||||||
|
"build:sdk:development": "pnpm build:sdk-target-based-on-call-view-model --mode development",
|
||||||
"build:component": "pnpm build:component:js && pnpm build:component:types",
|
"build:component": "pnpm build:component:js && pnpm build:component:types",
|
||||||
"build:component:js": "pnpm build:full --config vite-component.config.js",
|
"build:component:js": "pnpm build:full --config vite-component.config.js",
|
||||||
"build:component:types": "tsc -p component/tsconfig.build.json",
|
"build:component:types": "tsc -p component/tsconfig.build.json",
|
||||||
@@ -26,8 +30,8 @@
|
|||||||
"format": "oxfmt",
|
"format": "oxfmt",
|
||||||
"format:check": "oxfmt --check; rc=$?; [[ $rc -ne 0 ]] && printf '\\033[46;30m INFO \\033[0m To fix, run: pnpm format\\n' >&2; exit $rc",
|
"format:check": "oxfmt --check; rc=$?; [[ $rc -ne 0 ]] && printf '\\033[46;30m INFO \\033[0m To fix, run: pnpm format\\n' >&2; exit $rc",
|
||||||
"lint": "pnpm lint:types && pnpm lint:oxlint && pnpm lint:knip && pnpm lint:externals",
|
"lint": "pnpm lint:types && pnpm lint:oxlint && pnpm lint:knip && pnpm lint:externals",
|
||||||
"lint:oxlint": "oxlint src component playwright",
|
"lint:oxlint": "oxlint src component sdk playwright",
|
||||||
"lint:oxlint-fix": "oxlint --fix src component playwright",
|
"lint:oxlint-fix": "oxlint --fix src component sdk playwright",
|
||||||
"lint:knip": "knip",
|
"lint:knip": "knip",
|
||||||
"lint:externals": "node scripts/check-component-externals.mjs",
|
"lint:externals": "node scripts/check-component-externals.mjs",
|
||||||
"lint:types": "tsc",
|
"lint:types": "tsc",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import path from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import { COMPONENT_HARNESS_URL } from "./playwright/component/harness.ts";
|
import { COMPONENT_HARNESS_URL } from "./playwright/component/harness.ts";
|
||||||
|
import { SDK_HARNESS_URL } from "./playwright/sdk/harness.ts";
|
||||||
|
|
||||||
const baseURL = process.env.USE_DOCKER
|
const baseURL = process.env.USE_DOCKER
|
||||||
? "http://localhost:8080"
|
? "http://localhost:8080"
|
||||||
@@ -158,5 +159,17 @@ export default defineConfig({
|
|||||||
timeout: 500,
|
timeout: 500,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// The harness that uses the MatrixRTC SDK without Element Call. A Vite
|
||||||
|
// dev server for the same reason as the component's.
|
||||||
|
command: "pnpm dev:sdk",
|
||||||
|
url: SDK_HARNESS_URL,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
gracefulShutdown: {
|
||||||
|
signal: "SIGTERM",
|
||||||
|
timeout: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations 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 { startPair, tileOf } from "./harness";
|
||||||
|
|
||||||
|
test.describe.configure({ timeout: 300_000 });
|
||||||
|
|
||||||
|
test("a message sent beside the media reaches the peer, and only the peer", async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const { pages, users } = await startPair(browser, "sdkdata");
|
||||||
|
const [sender, receiver] = pages;
|
||||||
|
// Both are in the call before anything is sent, so the receiver knows the
|
||||||
|
// sender as a member and does not drop the message
|
||||||
|
for (const page of pages)
|
||||||
|
await expect(page.getByTestId("member")).toHaveCount(2, {
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
await expect(tileOf(receiver, users[0])).toBeVisible();
|
||||||
|
|
||||||
|
await sender.getByPlaceholder("message").fill("hello from the SDK");
|
||||||
|
await sender.getByRole("button", { name: "Send" }).click();
|
||||||
|
|
||||||
|
const message = receiver.getByTestId("message");
|
||||||
|
await expect(message).toHaveText(
|
||||||
|
`${users[0].displayName}: hello from the SDK`,
|
||||||
|
{
|
||||||
|
timeout: 30_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await expect(message).toHaveAttribute("data-topic", "chat");
|
||||||
|
await expect(message).toHaveAttribute("data-user-id", users[0].userId);
|
||||||
|
// Our own messages are not echoed back
|
||||||
|
await expect(sender.getByTestId("message")).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type Browser,
|
||||||
|
expect,
|
||||||
|
type Locator,
|
||||||
|
type Page,
|
||||||
|
} from "@playwright/test";
|
||||||
|
|
||||||
|
import { SynapseAdmin } from "../utils/synapse-admin.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the SDK harness is served — `sdk/dev`, a page that uses the SDK the way
|
||||||
|
* a host would, with none of Element Call in it.
|
||||||
|
*/
|
||||||
|
export const SDK_HARNESS_URL = "https://localhost:3002";
|
||||||
|
|
||||||
|
const HOMESERVER_URL = "https://synapse.m.localhost";
|
||||||
|
const PASSWORD = "foobarbaz1!";
|
||||||
|
|
||||||
|
/** A registered user, with the session the registration opened. */
|
||||||
|
export interface User {
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
accessToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers two users through the Synapse admin API and has the first create a
|
||||||
|
* public room, so that the second can join it by id without an invite. The
|
||||||
|
* registration logs each user in, which spares the harness a `/login` call
|
||||||
|
* that the homeserver rate-limits.
|
||||||
|
*/
|
||||||
|
export async function createUsersAndRoom(
|
||||||
|
name: string,
|
||||||
|
): Promise<{ users: [User, User]; roomId: string }> {
|
||||||
|
const admin = SynapseAdmin.forHomeserver(HOMESERVER_URL);
|
||||||
|
const users = (await Promise.all(
|
||||||
|
["a", "b"].map(async (letter, index): Promise<User> => {
|
||||||
|
const username = `${name}_${letter}_${Date.now()}`;
|
||||||
|
const displayName = `${name} ${"AB"[index]}`;
|
||||||
|
const registration = await admin.registerUser(
|
||||||
|
username,
|
||||||
|
PASSWORD,
|
||||||
|
displayName,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
username,
|
||||||
|
displayName,
|
||||||
|
userId: registration.user_id,
|
||||||
|
deviceId: registration.device_id,
|
||||||
|
accessToken: registration.access_token,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
)) as [User, User];
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${HOMESERVER_URL}/_matrix/client/v3/createRoom`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${users[0].accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: `${name}'s session`,
|
||||||
|
preset: "public_chat",
|
||||||
|
// Per-participant media keys travel in encrypted to-device messages,
|
||||||
|
// which only reach devices the sender's crypto tracks, and it tracks
|
||||||
|
// the members of encrypted rooms
|
||||||
|
initial_state: [
|
||||||
|
{
|
||||||
|
type: "m.room.encryption",
|
||||||
|
state_key: "",
|
||||||
|
content: { algorithm: "m.megolm.v1.aes-sha2" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Every member has to be allowed to write its own membership, which
|
||||||
|
// a public room does not grant by default; the same levels Element
|
||||||
|
// Call gives a room it creates for a call
|
||||||
|
power_level_content_override: {
|
||||||
|
state_default: 0,
|
||||||
|
events_default: 0,
|
||||||
|
users_default: 0,
|
||||||
|
events: { "org.matrix.msc3401.call.member": 0 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(
|
||||||
|
`Could not create a room: ${response.status} ${await response.text()}`,
|
||||||
|
);
|
||||||
|
const { room_id: roomId } = (await response.json()) as { room_id: string };
|
||||||
|
|
||||||
|
return { users, roomId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the harness signed in as the given user and waits until the SDK
|
||||||
|
* reports the session as connected. The status line carries the SDK's error
|
||||||
|
* if it does not get there, so the failure says why.
|
||||||
|
*/
|
||||||
|
export async function startHarness(
|
||||||
|
page: Page,
|
||||||
|
user: User,
|
||||||
|
roomId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
homeserver: HOMESERVER_URL,
|
||||||
|
accessToken: user.accessToken,
|
||||||
|
userId: user.userId,
|
||||||
|
deviceId: user.deviceId,
|
||||||
|
room: roomId,
|
||||||
|
});
|
||||||
|
await page.goto(`${SDK_HARNESS_URL}/?${query.toString()}`);
|
||||||
|
await page.getByRole("button", { name: "Start" }).click();
|
||||||
|
await waitForConnected(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A login, a crypto setup and an initial sync happen before the session can
|
||||||
|
* connect. An error is final, so it is not worth waiting out the timeout for
|
||||||
|
* "connected" after one.
|
||||||
|
*/
|
||||||
|
export async function waitForConnected(page: Page): Promise<void> {
|
||||||
|
const status = page.getByTestId("status");
|
||||||
|
await expect(status).toHaveText(/^(connected|Error)/, { timeout: 120_000 });
|
||||||
|
const text = await status.textContent();
|
||||||
|
if (text !== "connected")
|
||||||
|
throw new Error(`The harness did not connect to the session: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A context of its own per user, since a browser profile holds one login. No
|
||||||
|
* permissions to grant: each browser is launched with fake media that is
|
||||||
|
* handed out without asking (see playwright.config.ts).
|
||||||
|
*/
|
||||||
|
export async function newPage(browser: Browser): Promise<Page> {
|
||||||
|
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||||
|
return context.newPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two users in one session, each on a page of their own. */
|
||||||
|
export async function startPair(
|
||||||
|
browser: Browser,
|
||||||
|
name: string,
|
||||||
|
): Promise<{ pages: [Page, Page]; users: [User, User]; roomId: string }> {
|
||||||
|
const { users, roomId } = await createUsersAndRoom(name);
|
||||||
|
const pages = await Promise.all([newPage(browser), newPage(browser)]);
|
||||||
|
await Promise.all(
|
||||||
|
pages.map(async (page, i) => startHarness(page, users[i], roomId)),
|
||||||
|
);
|
||||||
|
return { pages: pages as [Page, Page], users, roomId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tile a page shows for a user. */
|
||||||
|
export function tileOf(page: Page, user: User): Locator {
|
||||||
|
return page.locator(`[data-testid="member"][data-user-id="${user.userId}"]`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations 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 { startPair, tileOf, waitForConnected } from "./harness.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Members coming and going: a member that leaves disappears for the peer, one
|
||||||
|
* that comes back is shown again, and the harness is left in the state its
|
||||||
|
* status line claims.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test.describe.configure({ timeout: 300_000 });
|
||||||
|
|
||||||
|
test("a member that leaves disappears for the peer", async ({ browser }) => {
|
||||||
|
const {
|
||||||
|
pages: [leaver, stayer],
|
||||||
|
users: [leaving, staying],
|
||||||
|
} = await startPair(browser, "sdkleave");
|
||||||
|
await expect(stayer.getByTestId("member")).toHaveCount(2, {
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await leaver.getByRole("button", { name: "Leave" }).click();
|
||||||
|
await expect(leaver.getByTestId("status")).toHaveText("Left");
|
||||||
|
await expect(leaver.getByTestId("member")).toHaveCount(0);
|
||||||
|
|
||||||
|
await expect(tileOf(stayer, leaving)).toHaveCount(0, { timeout: 60_000 });
|
||||||
|
await expect(tileOf(stayer, staying)).toHaveCount(1);
|
||||||
|
await expect(stayer.getByTestId("status")).toHaveText("connected");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a member that reloads is shown again with its media", async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
pages: [reloader, watcher],
|
||||||
|
users: [reloading],
|
||||||
|
} = await startPair(browser, "sdkreload");
|
||||||
|
await expect(watcher.getByTestId("member")).toHaveCount(2, {
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The page comes back on the same device, so the new membership replaces
|
||||||
|
// the old one rather than sitting next to it
|
||||||
|
await reloader.reload();
|
||||||
|
await reloader.getByRole("button", { name: "Start" }).click();
|
||||||
|
await waitForConnected(reloader);
|
||||||
|
|
||||||
|
await expect(tileOf(watcher, reloading)).toHaveCount(1, {
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
const video = tileOf(watcher, reloading).locator("video");
|
||||||
|
await expect
|
||||||
|
.poll(async () => video.evaluate((v: HTMLVideoElement) => v.videoWidth), {
|
||||||
|
timeout: 60_000,
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
await expect(reloader.getByTestId("member")).toHaveCount(2, {
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { expect, type Locator, type Page, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { startPair, tileOf, type User } from "./harness.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a member's media looks like from the other side: it plays, it is
|
||||||
|
* encrypted, it follows the mute switches, and the SFU sends only as much as
|
||||||
|
* the tile on screen can show. Each test reads the labels the harness puts on
|
||||||
|
* its media elements, which come straight from the SDK's track behaviors.
|
||||||
|
*
|
||||||
|
* One session serves every test here: logging two browsers in and connecting
|
||||||
|
* them takes most of a minute, and none of the tests leaves the session in a
|
||||||
|
* state the next cannot start from.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test.describe.configure({ mode: "serial", timeout: 300_000 });
|
||||||
|
|
||||||
|
let pages: [Page, Page];
|
||||||
|
let users: [User, User];
|
||||||
|
|
||||||
|
test.beforeAll(async ({ browser }) => {
|
||||||
|
({ pages, users } = await startPair(browser, "sdkmedia"));
|
||||||
|
for (const page of pages)
|
||||||
|
await expect(page.getByTestId("member")).toHaveCount(2, {
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
for (const page of pages ?? []) await page.context().close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the peer's camera and microphone play", async () => {
|
||||||
|
const peer = remoteTile(1, 0);
|
||||||
|
const video = peer.locator("video");
|
||||||
|
await expect
|
||||||
|
.poll(async () => video.evaluate((v: HTMLVideoElement) => v.videoWidth), {
|
||||||
|
timeout: 60_000,
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
await expect(video).toHaveJSProperty("paused", false);
|
||||||
|
const audio = peer.locator("audio");
|
||||||
|
await expect
|
||||||
|
.poll(async () =>
|
||||||
|
audio.evaluate((a: HTMLAudioElement) => a.srcObject !== null),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
await expect(audio).toHaveJSProperty("paused", false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the peer's tracks are encrypted", async () => {
|
||||||
|
const peer = remoteTile(1, 0);
|
||||||
|
await expect(peer.locator("video")).toHaveAttribute("data-encrypted", "true");
|
||||||
|
await expect(peer.locator("audio")).toHaveAttribute("data-encrypted", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("muting the microphone and the camera is seen by the peer", async () => {
|
||||||
|
const [page] = pages;
|
||||||
|
const peer = remoteTile(1, 0);
|
||||||
|
const video = peer.locator("video");
|
||||||
|
const audio = peer.locator("audio");
|
||||||
|
await expect(video).toHaveAttribute("data-muted", "false");
|
||||||
|
await expect(audio).toHaveAttribute("data-muted", "false");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Microphone" }).click();
|
||||||
|
await expect(audio).toHaveAttribute("data-muted", "true", {
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
await expect(video).toHaveAttribute("data-muted", "false");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Camera" }).click();
|
||||||
|
await expect(video).toHaveAttribute("data-muted", "true", {
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Microphone" }).click();
|
||||||
|
await page.getByRole("button", { name: "Camera" }).click();
|
||||||
|
await expect(audio).toHaveAttribute("data-muted", "false", {
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
await expect(video).toHaveAttribute("data-muted", "false", {
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the peer's microphone is active while it sends sound, and not while muted", async () => {
|
||||||
|
// Chromium's fake microphone never registers as a speaker at the SFU, and
|
||||||
|
// Firefox's steady tone does so in about two runs out of three, with or
|
||||||
|
// without the browser's audio processing. The pipeline is proven when it
|
||||||
|
// passes; it needs a louder fake device or a lower SFU threshold to be
|
||||||
|
// relied on.
|
||||||
|
test.fixme(true, "the fake microphones do not reliably register at the SFU");
|
||||||
|
const [page] = pages;
|
||||||
|
const audio = remoteTile(1, 0).locator("audio");
|
||||||
|
// The SFU only announces a change of speakers, so a tone that has been
|
||||||
|
// playing since before the peer subscribed is not announced to it. Muting
|
||||||
|
// and unmuting is the change that is.
|
||||||
|
await page.getByRole("button", { name: "Microphone" }).click();
|
||||||
|
await expect(audio).toHaveAttribute("data-muted", "true", {
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
await expect(audio).toHaveAttribute("data-active", "false");
|
||||||
|
await page.getByRole("button", { name: "Microphone" }).click();
|
||||||
|
await expect(audio).toHaveAttribute("data-active", "true", {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "Microphone" }).click();
|
||||||
|
await expect(audio).toHaveAttribute("data-active", "false", {
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "Microphone" }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a larger tile receives a higher resolution", async () => {
|
||||||
|
const peer = remoteTile(1, 0);
|
||||||
|
const video = peer.locator("video");
|
||||||
|
// The tile starts small enough for the lowest simulcast layer
|
||||||
|
const small = await receivedWidth(video, (width) => width > 0);
|
||||||
|
await resize(peer, 1280);
|
||||||
|
const large = await receivedWidth(video, (width) => width > small);
|
||||||
|
expect(large).toBeGreaterThan(small);
|
||||||
|
await resize(peer, 240);
|
||||||
|
await receivedWidth(video, (width) => width < large);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a hidden tile stops receiving video, and a shown one resumes", async () => {
|
||||||
|
const peer = remoteTile(1, 0);
|
||||||
|
const video = peer.locator("video");
|
||||||
|
await expect(frames(video)).resolves.toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await peer.evaluate((tile) => (tile.style.display = "none"));
|
||||||
|
await expect
|
||||||
|
.poll(async () => framesStill(video), { timeout: 60_000 })
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
await peer.evaluate((tile) => (tile.style.display = ""));
|
||||||
|
await expect
|
||||||
|
.poll(async () => !(await framesStill(video)), { timeout: 60_000 })
|
||||||
|
.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The tile on one page for the user of the other page. */
|
||||||
|
function remoteTile(viewer: 0 | 1, shown: 0 | 1): Locator {
|
||||||
|
return tileOf(pages[viewer], users[shown]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resize(tile: Locator, width: number): Promise<void> {
|
||||||
|
await tile.evaluate((t, w) => (t.style.width = `${w}px`), width);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The width of the video as decoded, once it satisfies the condition. */
|
||||||
|
async function receivedWidth(
|
||||||
|
video: Locator,
|
||||||
|
until: (width: number) => boolean,
|
||||||
|
): Promise<number> {
|
||||||
|
// Switching simulcast layers takes the SFU a few seconds
|
||||||
|
let width = 0;
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
width = await video.evaluate((v: HTMLVideoElement) => v.videoWidth);
|
||||||
|
return until(width);
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function frames(video: Locator): Promise<number> {
|
||||||
|
return Number(await video.getAttribute("data-frames"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether no frame arrived over a couple of seconds. */
|
||||||
|
async function framesStill(video: Locator): Promise<boolean> {
|
||||||
|
const before = await frames(video);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2500));
|
||||||
|
return (await frames(video)) === before;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations 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 { startPair, tileOf } from "./harness.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MatrixRTC SDK driven through its harness in `sdk/dev`: no Element Call
|
||||||
|
* on the page, only the SDK's public API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Two browsers each log in, set up crypto and sync before anything is on
|
||||||
|
// screen, then wait for media to connect
|
||||||
|
test.describe.configure({ timeout: 300_000 });
|
||||||
|
|
||||||
|
test("two browsers see each other in one session", async ({ browser }) => {
|
||||||
|
const { pages, users } = await startPair(browser, "sdksmoke");
|
||||||
|
|
||||||
|
// Each page shows itself and the other, each tile named by its user
|
||||||
|
for (const page of pages) {
|
||||||
|
await expect(page.getByTestId("member")).toHaveCount(2, {
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
for (const user of users)
|
||||||
|
await expect(tileOf(page, user)).toContainText(user.displayName);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -6,57 +6,72 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks that the component build leaves the packages a host must supply to
|
* Checks that the library builds leave the packages a host must supply to the
|
||||||
* the host.
|
* host.
|
||||||
*
|
*
|
||||||
* A host application already has React, the Matrix SDK and Compound,
|
* A host application already has React, the Matrix SDK and Compound,
|
||||||
* and a second copy of any of them is worse than dead weight: React would hold
|
* and a second copy of any of them is worse than dead weight: React would hold
|
||||||
* two sets of hooks, the Matrix client would run two sync loops, and a second
|
* two sets of hooks, the Matrix client would run two sync loops, and a second
|
||||||
* Compound would style the tooltips it floats into the host's body with class
|
* Compound would style the tooltips it floats into the host's body with class
|
||||||
* names the host's stylesheet does not know. So the
|
* names the host's stylesheet does not know. So each library build lists them
|
||||||
* component build lists them as external — but that list has to name every
|
* as external — but that list has to name every subpath, since the bundler
|
||||||
* subpath, since the bundler silently ignores the pattern and callback forms
|
* silently ignores the pattern and callback forms of the option, and an import
|
||||||
* of the option, and an import it does not cover is bundled with no warning at
|
* it does not cover is bundled with no warning at all. That is the failure
|
||||||
* all. That is the failure this guards against.
|
* this guards against.
|
||||||
*
|
*
|
||||||
* It reads the list from the build config itself, so there is one copy of it,
|
* It reads each list from the build config itself, so there is one copy of it
|
||||||
* and compares it against every import of those packages in the source.
|
* per build, and compares it against every import of those packages in the
|
||||||
|
* source.
|
||||||
*
|
*
|
||||||
* The comparison is deliberately over-approximate: it looks at all of `src`
|
* The comparison is deliberately over-approximate: it looks at all of `src`
|
||||||
* rather than only the modules the component actually pulls in, so it will
|
* rather than only the modules a build actually pulls in, so it will sometimes
|
||||||
* sometimes ask for a subpath that only the standalone app imports. Listing
|
* ask for a subpath that only the standalone app imports. Listing one a build
|
||||||
* one the component never imports costs nothing — the bundler ignores it —
|
* never imports costs nothing — the bundler ignores it — whereas missing one
|
||||||
* whereas missing one costs a duplicate package.
|
* costs a duplicate package.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readdir, readFile } from "node:fs/promises";
|
import { readdir, readFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { loadConfigFromFile } from "vite";
|
import { loadConfigFromFile } from "vite";
|
||||||
|
|
||||||
const CONFIG = "vite-component.config.ts";
|
/**
|
||||||
const SOURCES = ["src", "component"];
|
* The library builds, each with the directories it is built from and the
|
||||||
// The development harness is a host, not part of the component: it is the
|
* packages whose duplication would break a host rather than merely enlarge it.
|
||||||
// one that imports what a host supplies (Compound's stylesheets, say).
|
* A development harness is a host, not part of the library: it is the one that
|
||||||
const EXCLUDED = ["component/dev"];
|
* imports what a host supplies (Compound's stylesheets, say).
|
||||||
|
*/
|
||||||
/** The packages whose duplication would break a host, rather than merely enlarge it. */
|
const TARGETS = [
|
||||||
const MUST_BE_EXTERNAL = [
|
{
|
||||||
|
config: "vite-component.config.ts",
|
||||||
|
what: "the component build",
|
||||||
|
sources: ["src", "component"],
|
||||||
|
excluded: ["component/dev"],
|
||||||
|
mustBeExternal: [
|
||||||
"react",
|
"react",
|
||||||
"react-dom",
|
"react-dom",
|
||||||
"matrix-js-sdk",
|
"matrix-js-sdk",
|
||||||
"@vector-im/compound-web",
|
"@vector-im/compound-web",
|
||||||
"@vector-im/compound-design-tokens",
|
"@vector-im/compound-design-tokens",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
config: "vite-sdk.config.ts",
|
||||||
|
what: "the SDK build",
|
||||||
|
sources: ["src", "sdk"],
|
||||||
|
excluded: ["sdk/dev"],
|
||||||
|
mustBeExternal: ["matrix-js-sdk", "livekit-client", "rxjs"],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const isTestFile = (name) =>
|
const isTestFile = (name) =>
|
||||||
name.includes(".test.") || name.includes(".stories.");
|
name.includes(".test.") || name.includes(".stories.");
|
||||||
|
|
||||||
/** Every source file under the given directories, recursively. */
|
/** Every source file under the given directories, recursively. */
|
||||||
async function* sourceFiles(dir) {
|
async function* sourceFiles(dir, excluded) {
|
||||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||||
const path = join(dir, entry.name);
|
const path = join(dir, entry.name);
|
||||||
if (EXCLUDED.includes(path)) continue;
|
if (excluded.includes(path)) continue;
|
||||||
if (entry.isDirectory()) yield* sourceFiles(path);
|
if (entry.isDirectory()) yield* sourceFiles(path, excluded);
|
||||||
else if (/\.(ts|tsx)$/.test(entry.name) && !isTestFile(entry.name))
|
else if (/\.(ts|tsx)$/.test(entry.name) && !isTestFile(entry.name))
|
||||||
yield path;
|
yield path;
|
||||||
}
|
}
|
||||||
@@ -88,38 +103,42 @@ function imports(source) {
|
|||||||
* with the host's copy of anything. Worker sub-builds do not inherit this
|
* with the host's copy of anything. Worker sub-builds do not inherit this
|
||||||
* option anyway.
|
* option anyway.
|
||||||
*/
|
*/
|
||||||
const mustBeExternal = (specifier) =>
|
const mustBeExternal = (specifier, packages) =>
|
||||||
!specifier.includes("?") &&
|
!specifier.includes("?") &&
|
||||||
MUST_BE_EXTERNAL.some(
|
packages.some((pkg) => specifier === pkg || specifier.startsWith(`${pkg}/`));
|
||||||
(pkg) => specifier === pkg || specifier.startsWith(`${pkg}/`),
|
|
||||||
);
|
|
||||||
|
|
||||||
const loaded = await loadConfigFromFile(
|
/** @returns Whether the target's externals list covers its imports. */
|
||||||
|
async function check({
|
||||||
|
config,
|
||||||
|
what,
|
||||||
|
sources,
|
||||||
|
excluded,
|
||||||
|
mustBeExternal: packages,
|
||||||
|
}) {
|
||||||
|
const loaded = await loadConfigFromFile(
|
||||||
{ command: "build", mode: "production" },
|
{ command: "build", mode: "production" },
|
||||||
CONFIG,
|
config,
|
||||||
);
|
);
|
||||||
if (loaded === null) {
|
if (loaded === null) {
|
||||||
console.error(`Could not load ${CONFIG}`);
|
console.error(`Could not load ${config}`);
|
||||||
process.exit(1);
|
return false;
|
||||||
}
|
}
|
||||||
const declared = new Set(loaded.config.build?.rollupOptions?.external ?? []);
|
const declared = new Set(loaded.config.build?.rollupOptions?.external ?? []);
|
||||||
if (declared.size === 0) {
|
if (declared.size === 0) {
|
||||||
console.error(
|
console.error(
|
||||||
`${CONFIG} declares nothing external. Either the option moved, or the ` +
|
`${config} declares nothing external. Either the option moved, or the ` +
|
||||||
`list is empty; either way this check is not looking at what it thinks.`,
|
`list is empty; either way this check is not looking at what it thinks.`,
|
||||||
);
|
);
|
||||||
process.exit(1);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Where each missing specifier is imported, so the message can point at it
|
// Where each missing specifier is imported, so the message can point at it
|
||||||
const missing = new Map();
|
const missing = new Map();
|
||||||
const seen = new Set();
|
for (const dir of sources)
|
||||||
for (const dir of SOURCES)
|
for await (const file of sourceFiles(dir, excluded)) {
|
||||||
for await (const file of sourceFiles(dir)) {
|
|
||||||
const source = await readFile(file, "utf8");
|
const source = await readFile(file, "utf8");
|
||||||
for (const specifier of imports(source)) {
|
for (const specifier of imports(source)) {
|
||||||
if (!mustBeExternal(specifier)) continue;
|
if (!mustBeExternal(specifier, packages)) continue;
|
||||||
seen.add(specifier);
|
|
||||||
if (declared.has(specifier)) continue;
|
if (declared.has(specifier)) continue;
|
||||||
const files = missing.get(specifier) ?? [];
|
const files = missing.get(specifier) ?? [];
|
||||||
files.push(file);
|
files.push(file);
|
||||||
@@ -127,21 +146,17 @@ for (const dir of SOURCES)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (missing.size > 0) {
|
if (missing.size === 0) return true;
|
||||||
console.error(
|
console.error(
|
||||||
`${CONFIG} does not declare these imports external, so the component ` +
|
`${config} does not declare these imports external, so ${what} ` +
|
||||||
`build would bundle its own copy of them:\n`,
|
`would bundle its own copy of them:\n`,
|
||||||
);
|
);
|
||||||
for (const [specifier, files] of [...missing].sort())
|
for (const [specifier, files] of [...missing].sort())
|
||||||
console.error(` ${specifier}\n imported by ${files.join(", ")}`);
|
console.error(` ${specifier}\n imported by ${files.join(", ")}`);
|
||||||
console.error(`\nAdd each one to the \`external\` list in ${CONFIG}.`);
|
console.error(`\nAdd each one to the \`external\` list in ${config}.\n`);
|
||||||
process.exit(1);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deliberately no complaint about declarations nothing imports. Some of them
|
let ok = true;
|
||||||
// cannot be seen from the source at all — `react/jsx-runtime` is injected by
|
for (const target of TARGETS) ok = (await check(target)) && ok;
|
||||||
// the JSX transform — and an extra declaration is inert, so there is nothing
|
if (!ok) process.exit(1);
|
||||||
// to warn about.
|
|
||||||
console.log(
|
|
||||||
`${declared.size} external declarations cover all ${seen.size} imports of ${MUST_BE_EXTERNAL.join(", ")}.`,
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# SDK mode (EXPERIMENTAL, superseded)
|
||||||
|
|
||||||
|
> Superseded by the `sdk/` package (`@element-hq/matrixrtc-sdk`) and its harness in
|
||||||
|
> `sdk/dev`; see `sdk-plan.md`. This folder still builds, with its own script, until
|
||||||
|
> that package can do what this does.
|
||||||
|
|
||||||
|
EC can be build in sdk mode. This will result in a compiled js file that can be imported in very simple webapps.
|
||||||
|
|
||||||
|
It allows to use matrixRTC in combination with livekit without relying on element call.
|
||||||
|
|
||||||
|
This is done by instantiating the call view model and exposing some useful behaviors (observables) and methods.
|
||||||
|
|
||||||
|
This folder contains an example index.html file that showcases the sdk in use (hosted on localhost:8123 with a webserver allowing cors (for example `npx serve -l 81234 --cors`)) as a godot engine HTML export template.
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
To get started run
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm install
|
||||||
|
pnpm build:sdk-target-based-on-call-view-model
|
||||||
|
```
|
||||||
|
|
||||||
|
in the repository root. (`pnpm build:sdk` builds the `sdk/` package instead.)
|
||||||
|
|
||||||
|
CI's `sdk` package (`build:sdk:production`, `build:sdk:development`, the
|
||||||
|
`build-output-sdk` artifact and the `pr<N>-sdk` Netlify preview) is still this
|
||||||
|
bundle, not the `sdk/` package, until its consumers have moved over.
|
||||||
|
|
||||||
|
It will create `dist` containing the compiled js file, `matrixrtc-sdk.js`.
|
||||||
|
|
||||||
|
This file needs to be hosted. Locally (via `npx serve -l 81234 --cors`) or on a remote server.
|
||||||
|
|
||||||
|
Now you just need to add the widget to element web via:
|
||||||
|
|
||||||
|
```
|
||||||
|
/addwidget http://localhost:3000?widgetId=$matrix_widget_id&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
|
||||||
|
```
|
||||||
|
|
||||||
|
## Widgets
|
||||||
|
|
||||||
|
The sdk mode is particularly interesting to be used in widgets. In widgets you do not need to pay attention to matrix login/cs api ...
|
||||||
|
To create a widget see the example `index.html` file in this folder. And add it to EW via:
|
||||||
|
`/addwidget <widgetUrl>` (see **url parameters** for more details on `<widgetUrl>`)
|
||||||
|
|
||||||
|
### url parameters
|
||||||
|
|
||||||
|
The url parameters are needed to pass initial data to the widget. They will automatically be used
|
||||||
|
by the matrixRTCSdk to start the postmessage widget api (communication between the client (e.g. Element Web) and the widget)
|
||||||
|
|
||||||
|
```
|
||||||
|
widgetId = $matrix_widget_id
|
||||||
|
userId = $matrix_user_id
|
||||||
|
deviceId = $org.matrix.msc3819.matrix_device_id
|
||||||
|
baseUrl = $org.matrix.msc4039.matrix_base_url
|
||||||
|
```
|
||||||
|
|
||||||
|
`parentUrl = // will be inserted automatically`
|
||||||
|
|
||||||
|
Full template use as `<widgetUrl>`:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:3000?widgetId=$matrix_widget_id&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
|
||||||
|
```
|
||||||
|
|
||||||
|
the `$` prefixed variables will be replaced by EW on widget instantiation. (e.g. `$matrix_user_id` -> `@user:example.com` (url encoding will also be applied automatically by EW) -> `%40user%3Aexample.com`)
|
||||||
@@ -8,7 +8,8 @@ Please see LICENSE in the repository root for full details.
|
|||||||
/**
|
/**
|
||||||
* EXPERIMENTAL
|
* EXPERIMENTAL
|
||||||
*
|
*
|
||||||
* This file is the entrypoint for the sdk build of element call: `pnpm build:sdk`
|
* This file is the entrypoint for the sdk build of element call:
|
||||||
|
* `pnpm build:sdk-target-based-on-call-view-model`
|
||||||
* use in widgets.
|
* use in widgets.
|
||||||
* It exposes the `createMatrixRTCSdk` which creates the `MatrixRTCSdk` interface (see below) that
|
* It exposes the `createMatrixRTCSdk` which creates the `MatrixRTCSdk` interface (see below) that
|
||||||
* can be used to join a rtc session and exchange realtime data.
|
* can be used to join a rtc session and exchange realtime data.
|
||||||
+94
-40
@@ -1,58 +1,112 @@
|
|||||||
# SDK mode (EXPERIMENTAL)
|
# MatrixRTC SDK (EXPERIMENTAL)
|
||||||
|
|
||||||
EC can be build in sdk mode. This will result in a compiled js file that can be imported in very simple webapps.
|
`@element-hq/matrixrtc-sdk` is the call model under Element Call, on its own:
|
||||||
|
it joins a MatrixRTC slot in a Matrix room, writes and watches the memberships,
|
||||||
|
connects to the media transport (LiveKit today), publishes the local media and
|
||||||
|
exposes every member and their tracks as observables. It has no UI. Element
|
||||||
|
Call's own `CallViewModel` is one consumer of it; any host can build a different
|
||||||
|
view on top. The design is in [`SdkArchitecture.md`](./SdkArchitecture.md), what
|
||||||
|
moved out of Element Call's `src/` in [`SdkMigration.md`](./SdkMigration.md).
|
||||||
|
|
||||||
It allows to use matrixRTC in combination with livekit without relying on element call.
|
**Status:** first implementation. `createRTCSlot` and `slot.join()` join the
|
||||||
|
session, connect to the transport, publish the local media and expose every
|
||||||
|
member's media; the development harness and its e2e tests in `playwright/sdk`
|
||||||
|
drive it, and Element Call's own `CallViewModel` is built on it. It depends on
|
||||||
|
nothing in Element Call's `src/`.
|
||||||
|
|
||||||
This is done by instantiating the call view model and exposing some useful behaviors (observables) and methods.
|
## Using it
|
||||||
|
|
||||||
This folder contains an example index.html file that showcases the sdk in use (hosted on localhost:8123 with a webserver allowing cors (for example `npx serve -l 81234 --cors`)) as a godot engine HTML export template.
|
```ts
|
||||||
|
import {
|
||||||
|
createRTCSlot,
|
||||||
|
E2eeType,
|
||||||
|
MatrixRTCMode,
|
||||||
|
ObservableScope,
|
||||||
|
trackBySource$,
|
||||||
|
} from "@element-hq/matrixrtc-sdk";
|
||||||
|
|
||||||
## Getting started
|
const scope = new ObservableScope();
|
||||||
|
const slot = createRTCSlot(
|
||||||
|
scope,
|
||||||
|
client, // a matrix-js-sdk MatrixClient, logged in and syncing
|
||||||
|
room, // the matrix-js-sdk Room, from client.getRoom() once the join has synced
|
||||||
|
{
|
||||||
|
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||||
|
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const participation = slot.join({
|
||||||
|
publish: [{ source: "microphone" }, { source: "camera" }],
|
||||||
|
});
|
||||||
|
|
||||||
To get started run
|
// A <video> for each remote member's camera and an <audio> for its microphone.
|
||||||
|
// A track plays on an element once attached to it; detach before the element
|
||||||
|
// leaves the DOM. (`tileFor`, `setName` and `removeTilesExcept` are the host's.)
|
||||||
|
participation.remoteMembers$.subscribe((members) => {
|
||||||
|
for (const member of members) {
|
||||||
|
const { video, audio } = tileFor(member.memberId);
|
||||||
|
member.displayName$.subscribe((name) => setName(member.memberId, name));
|
||||||
|
trackBySource$(scope, member.tracks$, "camera").subscribe((track) =>
|
||||||
|
track?.attach(video),
|
||||||
|
);
|
||||||
|
trackBySource$(scope, member.tracks$, "microphone").subscribe((track) =>
|
||||||
|
track?.attach(audio),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
removeTilesExcept(members.map((m) => m.memberId));
|
||||||
|
});
|
||||||
|
|
||||||
```
|
// later
|
||||||
pnpm install
|
participation.leave();
|
||||||
pnpm build:sdk
|
scope.end();
|
||||||
```
|
```
|
||||||
|
|
||||||
in the repository root.
|
`participation.localMember$` is one member of the same shape, null until our own
|
||||||
|
membership is in the room; its microphone would only echo.
|
||||||
|
|
||||||
It will create a `dist` folder containing the compiled js file.
|
[`dev/main.ts`](./dev/main.ts) is this example as a working page.
|
||||||
|
|
||||||
This file needs to be hosted. Locally (via `npx serve -l 81234 --cors`) or on a remote server.
|
## Reading the code
|
||||||
|
|
||||||
Now you just need to add the widget to element web via:
|
Three files define the surface. Read them in this order.
|
||||||
|
|
||||||
```
|
- [`src/api.ts`](./src/api.ts): the MatrixRTC side. An `RTCSlot` is the
|
||||||
/addwidget http://localhost:3000?widgetId=$matrix_widget_id&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
|
namespace in a room that memberships join into; `join()` returns an
|
||||||
|
`RTCParticipation`, which lasts until its `leave()` and exposes `state$`,
|
||||||
|
`localMember$` and `remoteMembers$`. Members, transports and the participation
|
||||||
|
state machine live here. `createRTCSlot` in `RTCSlot.ts` is the only way to get
|
||||||
|
an implementation.
|
||||||
|
- [`src/media-api.ts`](./src/media-api.ts): media as a host sees it, on
|
||||||
|
`remoteMembers$` and `localMember$`. `PublishRequest` says what to publish
|
||||||
|
(microphone, camera, screen share); `MediaTrack` is how it arrives. The
|
||||||
|
underlying tracks stay hidden: the host hands an element to `attach` and the
|
||||||
|
SDK plays into it. The local variants carry the controls (`setEnabled`,
|
||||||
|
`setDevice`, `setProcessor`).
|
||||||
|
- [`src/media-backend/api.ts`](./src/media-backend/api.ts): internal, the seam
|
||||||
|
between the session and whatever carries the media. The session speaks
|
||||||
|
MatrixRTC; a `MediaBackend` speaks one media protocol and hands back the
|
||||||
|
`MemberMedia` of each member through `mediaFor()`. A host rarely touches it;
|
||||||
|
it matters when working on the LiveKit backend under
|
||||||
|
`src/media-backend/livekit/`, the only place that imports `livekit-client`, or
|
||||||
|
when writing a new backend.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Everything runs from the repository root. This directory is a pnpm project of its
|
||||||
|
own only so that a host can install it as a git dependency (see
|
||||||
|
`pnpm-workspace.yaml`).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm dev:sdk # the harness, https://localhost:3002
|
||||||
|
pnpm build:sdk # dist/matrixrtc-sdk.js and dist/types
|
||||||
|
pnpm test:playwright playwright/sdk # needs `pnpm backend`
|
||||||
```
|
```
|
||||||
|
|
||||||
## Widgets
|
## Installing
|
||||||
|
|
||||||
The sdk mode is particularly interesting to be used in widgets. In widgets you do not need to pay attention to matrix login/cs api ...
|
```sh
|
||||||
To create a widget see the example `index.html` file in this folder. And add it to EW via:
|
pnpm add github:element-hq/element-call#<ref>&path:/sdk
|
||||||
`/addwidget <widgetUrl>` (see **url parameters** for more details on `<widgetUrl>`)
|
|
||||||
|
|
||||||
### url parameters
|
|
||||||
|
|
||||||
The url parameters are needed to pass initial data to the widget. They will automatically be used
|
|
||||||
by the matrixRTCSdk to start the postmessage widget api (communication between the client (e.g. Element Web) and the widget)
|
|
||||||
|
|
||||||
```
|
|
||||||
widgetId = $matrix_widget_id
|
|
||||||
userId = $matrix_user_id
|
|
||||||
deviceId = $org.matrix.msc3819.matrix_device_id
|
|
||||||
baseUrl = $org.matrix.msc4039.matrix_base_url
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`parentUrl = // will be inserted automatically`
|
The host supplies `matrix-js-sdk`, `livekit-client` and `rxjs`; the build leaves
|
||||||
|
them external.
|
||||||
Full template use as `<widgetUrl>`:
|
|
||||||
|
|
||||||
```
|
|
||||||
http://localhost:3000?widgetId=$matrix_widget_id&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
|
|
||||||
```
|
|
||||||
|
|
||||||
the `$` prefixed variables will be replaced by EW on widget instantiation. (e.g. `$matrix_user_id` -> `@user:example.com` (url encoding will also be applied automatically by EW) -> `%40user%3Aexample.com`)
|
|
||||||
|
|||||||
@@ -0,0 +1,824 @@
|
|||||||
|
# MatrixRTC SDK architecture
|
||||||
|
|
||||||
|
`@element-hq/matrixrtc-sdk` is the model under Element Call, on its own: one
|
||||||
|
`MatrixRTCClient` per room that joins and leaves the MatrixRTC session, discovers
|
||||||
|
and connects to the media transports, publishes the local media, distributes the
|
||||||
|
encryption keys, and hands out every member with its media as observables. It has
|
||||||
|
no UI, reads no configuration and no settings, and never names LiveKit in its
|
||||||
|
interface.
|
||||||
|
|
||||||
|
Element Call becomes one consumer of it: `CallViewModel` keeps what makes a session
|
||||||
|
a _call_ (ringing, auto-leave, layout, sounds, reactions, settings, the host
|
||||||
|
bridge) and takes everything else from the client. The dependency direction is
|
||||||
|
therefore `Element Call → sdk`, never the reverse. What moved out of Element
|
||||||
|
Call's `src/` to get there is listed in [`SdkMigration.md`](./SdkMigration.md).
|
||||||
|
|
||||||
|
## The architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
host application (Element Call, a whiteboard, watch-together, …)
|
||||||
|
│
|
||||||
|
│ createMatrixRTCClient(scope, matrixClient, room, options)
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ MatrixRTCClient │
|
||||||
|
│ │
|
||||||
|
│ public API, all Behaviors: │
|
||||||
|
│ join() leave() state$ │
|
||||||
|
│ localMember$ remoteMembers$ memberCount$ sendData() data$ │
|
||||||
|
│ keyRotationSuppressed$ connectedTransports$ │
|
||||||
|
│ │
|
||||||
|
│ per member RTCMember { id userId deviceId membership$ displayName$ │
|
||||||
|
│ avatarUrl$ transport$ tracks$ encryptionError$ }│
|
||||||
|
│ per track MediaTrack { source kind muted$ encrypted stats$ attach() } │
|
||||||
|
│ │
|
||||||
|
│ ┌───────────────────────────┐ MediaBackend ┌───────────────────────────┐ │
|
||||||
|
│ │ session: MatrixRTC │ media-backend/ │ media backend │ │
|
||||||
|
│ │ │ api.ts │ media-backend/livekit/ │ │
|
||||||
|
│ │ js-sdk MatrixRTCSession, │ │ today, or options.backend │ │
|
||||||
|
│ │ later the rust-rtc │ prepareLocal │ │ │
|
||||||
|
│ │ crate behind a bridge │ Transport ───▶│ auth: MSC4195 CS API or │ │
|
||||||
|
│ │ memberships from room │ delegateDelayed │ JWT service, by shape │ │
|
||||||
|
│ │ state and sticky events │ Leave ───────▶│ and role; delegation │ │
|
||||||
|
│ │ own membership, delayed │ │ ConnectionMap: one │ │
|
||||||
|
│ │ leave, retries │ mediaKeys$ ────▶│ LiveKit room per │ │
|
||||||
|
│ │ discovery of the local │ mediaFor$ ─────▶│ transport locator, │ │
|
||||||
|
│ │ transport (homeserver) │ ◀─ tracks$, │ local published, │ │
|
||||||
|
│ │ membership state machine │ encryptionError$│ remote subscribed │ │
|
||||||
|
│ │ (LocalMembership) │ local.publish, │ Publisher: local tracks, │ │
|
||||||
|
│ │ transport registry │ setPublishing▶│ upstream paused until │ │
|
||||||
|
│ │ (TransportMetadata) │ ◀─ connections$ │ joined │ │
|
||||||
|
│ │ data packet → member │ ◀─ data$ │ LivekitMemberMedia: │ │
|
||||||
|
│ │ │ │ participant → tracks │ │
|
||||||
|
│ └───────────────────────────┘ └───────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ RTCMember = membership + mediaFor$(membership): the backend matches the │
|
||||||
|
│ member's backendIdentities to the identity it sees on the transport │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
MatrixClient (sync, room state, to-device, OpenID) LiveKit SFU
|
||||||
|
```
|
||||||
|
|
||||||
|
The left box is MatrixRTC: who is in the session, with which transport, the
|
||||||
|
keys, the delayed leave, and the membership state machine. The right box is a
|
||||||
|
media backend: whatever carries the media, behind the `MediaBackend` interface in
|
||||||
|
`sdk/src/media-backend/api.ts`. The client is the join of the two: a member exists
|
||||||
|
once its membership does, and gets its media once the backend has something
|
||||||
|
for that membership on its transport; `state$` is the membership state combined
|
||||||
|
with the local media connection. LiveKit is the one backend today; a
|
||||||
|
cascading SFU or full mesh is another folder under `sdk/src/media-backend/`, handed
|
||||||
|
in through `options.backend`, and nothing in the left box changes.
|
||||||
|
|
||||||
|
### How Element Call uses it
|
||||||
|
|
||||||
|
```
|
||||||
|
Element Call, as app, widget or component
|
||||||
|
┌────────────────────────────────────────────────────────────────┐
|
||||||
|
│ views: InCallView, tiles, header, footer, settings │
|
||||||
|
│ ▲ Behaviors │
|
||||||
|
│ CallViewModel │
|
||||||
|
│ layout, ringing and notifications, auto-leave, sounds, │
|
||||||
|
│ reactions, hand raise, settings, audio routing, host bridge │
|
||||||
|
│ ▲ state$, localMember$, remoteMembers$, tracks$ │
|
||||||
|
│ MatrixRTCClient @element-hq/matrixrtc-sdk │
|
||||||
|
└────────────────────────────────────────────────────────────────┘
|
||||||
|
│ MatrixClient + Room │ livekit-client
|
||||||
|
▼ ▼
|
||||||
|
homeserver SFU
|
||||||
|
```
|
||||||
|
|
||||||
|
All three modes already hold a `MatrixClient` and a `Room` by the time a call
|
||||||
|
starts: standalone after `useLoadGroupCall`, the component from the host's `client`
|
||||||
|
prop, the widget from `createRoomWidgetClient`. The view model passes them on and
|
||||||
|
builds its media view models from `RTCMember` and its `tracks$` instead of from
|
||||||
|
LiveKit participants.
|
||||||
|
|
||||||
|
### Joining, in order
|
||||||
|
|
||||||
|
```
|
||||||
|
host MatrixRTCClient / backend homeserver / JWT service LiveKit SFU
|
||||||
|
│ create ───▶│ │ │
|
||||||
|
│ │ GET rtc/transports (session) ────────▶│ │
|
||||||
|
│ │◀── first transport of the backend's type │
|
||||||
|
│ │ prepareLocalTransport (backend): │ │
|
||||||
|
│ │ publisher token: CS API get_token, │ │
|
||||||
|
│ │ or OpenID token then /sfu/get ─────▶│ │
|
||||||
|
│ │◀── sfu url, jwt, alias, identity ─────│ = resolved$ of the │
|
||||||
|
│ │ probe delegate_delayed_leave ──────▶│ local transport │
|
||||||
|
│ │ connect(url, jwt) ───────────────────────────────────────────▶│
|
||||||
|
│ join() ───▶│ (once the publisher holds its token)│ │
|
||||||
|
│ │ membership state event + delayed leave ──▶│ │
|
||||||
|
│ │ delegateDelayedLeave(delayId) (backend) ─▶│ │
|
||||||
|
│ │ create tracks, resume upstream ──────────────────────────────▶│
|
||||||
|
│ │ media key to every other member (to-device) ──▶│ │
|
||||||
|
│ │◀── other memberships (sync) ──────────│◀── participants ────────│
|
||||||
|
│◀─ state$ connected, localMember$, remoteMembers$ with tracks$ ───────────────│
|
||||||
|
```
|
||||||
|
|
||||||
|
The session discovers the transport from the homeserver and the backend prepares
|
||||||
|
it, both before `join()`, so that the membership can name it and the first frames
|
||||||
|
go out as soon as the state event is sent. Preparing is the backend's own
|
||||||
|
business: for LiveKit the token exchange for the publisher connection, and the
|
||||||
|
probe that decides whether the service that issued the token can take over the
|
||||||
|
delayed leave, which picks the delayed leave timings the join uses. The join
|
||||||
|
waits for the publisher to hold its token, so that the LiveKit room exists
|
||||||
|
before a subscriber, who lacks the permission to create it, asks for a token
|
||||||
|
of its own. Until `join()` the tracks exist but their upstream is paused: a host
|
||||||
|
can show a preview without anyone hearing it. The upstream flows exactly while the
|
||||||
|
membership is `joined`: a sync outage pauses it, and `leave()` pauses it for good
|
||||||
|
and sends the leave.
|
||||||
|
|
||||||
|
## Vocabulary
|
||||||
|
|
||||||
|
The words the SDK is written in. They name the shape after the slot and
|
||||||
|
participation split in
|
||||||
|
[`client-into-slot-session-split-plan.md`](./client-into-slot-session-split-plan.md);
|
||||||
|
the sections below still describe `MatrixRTCClient` until that lands.
|
||||||
|
|
||||||
|
| Word | Meaning here | Elsewhere |
|
||||||
|
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
|
||||||
|
| slot | an `RTCSlot`: the namespace in a room that memberships join into, `application` plus `id`. It exists while the room does, with nobody in it too. Holds `members$`, its open or closed `status$`, and `join()` | the js-sdk `MatrixRTCSession` is one per slot; the MSC4143 slot event |
|
||||||
|
| participation | an `RTCParticipation`: the span between our `join()` of a slot and our `leave()`. Holds the connection and its `state$`, `localMember$`, `remoteMembers$`, the connected transports and the data channel. Made by `slot.join()`, never reused | "session" is not used here, because in the js-sdk it means the slot |
|
||||||
|
| member | an `RTCMember`: one membership in a slot with what the SDK derives from it, `rtcBackendIdentity`, `userId`, `deviceId`, `memberId`, `displayName$`, `avatarUrl$`, `transport$` and `applicationData$`. No media. What the slot hands out in `members$` | the js-sdk `CallMembership` behind it, which never leaves the SDK; Element Call's old "matrixLivekitMember" |
|
||||||
|
| local member, remote member | `LocalRTCMember` and `RemoteRTCMember`: an `RTCMember` plus its `MemberMedia`, which only a participation can carry. The local one's tracks carry the controls. What the participation hands out in `localMember$` and `remoteMembers$` | LiveKit `LocalParticipant` and `RemoteParticipant`, which never leave the SDK |
|
||||||
|
| media | a `MemberMedia`: what a member sends once it has arrived on its transport, `tracks$`, null while waiting, and `encryptionError$` beside it. The media backend supplies exactly these two fields per member | Element Call's `MediaViewModel` is a view of it |
|
||||||
|
| track | a `MediaTrack`: one publication of a member, audio or video, with `source`, `kind`, `id` and the behaviors on it. `LocalAudioMediaTrack` and `LocalVideoMediaTrack` add the controls over our own | LiveKit `TrackPublication` and `Track`, which never leave the SDK |
|
||||||
|
| publish request | a `PublishRequest`: a source, where to capture it from and how to encode it. Given at the join in `publish`, or later to `publish` on the participation | |
|
||||||
|
| attach, detach | handing a `<video>` or `<audio>` element to a track and taking it back. The SDK owns the stream and the observers on the element | LiveKit `Track.attach` |
|
||||||
|
| transport | an `RTCTransport`: where media is exchanged, a LiveKit service url today. Carries the raw js-sdk `Transport` from the membership and, in `resolved$`, what the backend fetched to connect to it | MSC4143 "focus" |
|
||||||
|
| media backend | a `MediaBackend`: what carries the media, behind `sdk/src/media-backend/api.ts`. One per participation, chosen with `backend` in the join options; LiveKit under `sdk/src/media-backend/livekit/` is the one that exists | "SFU", a LiveKit room, a peer connection |
|
||||||
|
|
||||||
|
Names use `RTC` in capitals, as the js-sdk does: `RTCSlot`, `RTCParticipation`,
|
||||||
|
`RTCMember`, `matrixRTCMode`.
|
||||||
|
|
||||||
|
## Reactive primitives
|
||||||
|
|
||||||
|
The whole public API is made of `Behavior`s: an rxjs observable that always holds a
|
||||||
|
current value (`.value`) and replays it on subscription. A consumer subscribes to
|
||||||
|
what it renders and reads `.value` where it needs the present state. Everything is
|
||||||
|
bound to an `ObservableScope` the host creates; ending the scope ends the client,
|
||||||
|
its connections and its tracks. Both come from the SDK (`Behavior`, `constant`,
|
||||||
|
`ObservableScope`); the host needs rxjs and nothing else.
|
||||||
|
|
||||||
|
## What a host must provide
|
||||||
|
|
||||||
|
Found the hard way while building the harness; each one fails in a way that looks
|
||||||
|
like an SDK bug.
|
||||||
|
|
||||||
|
- **The room the sync loop maintains.** `client.getRoom(roomId)` once the join has
|
||||||
|
synced. `joinRoom` returns a detached copy for a room joined just now, and a
|
||||||
|
client built on it never sees a member.
|
||||||
|
- **Power to send the membership event.** Every member needs to write
|
||||||
|
`org.matrix.msc3401.call.member` (and the sticky event in Matrix 2.0 mode). A
|
||||||
|
plain room only lets its creator send state; Element Call's rooms set the level
|
||||||
|
to 0.
|
||||||
|
- **An encrypted room for per-participant keys.** Media keys travel in encrypted
|
||||||
|
to-device messages, which the crypto only delivers to devices it tracks, which
|
||||||
|
are the members of encrypted rooms. In an unencrypted room the key never arrives
|
||||||
|
and every remote track decodes nothing.
|
||||||
|
- **A logged-in, syncing client with crypto set up**, and the transport advertised
|
||||||
|
by the homeserver (`rtc/transports`). The SDK has no fallback url of its own.
|
||||||
|
|
||||||
|
## The client: `MatrixRTCClient`
|
||||||
|
|
||||||
|
Rule of thumb for what belongs here: if it would be the same for a different
|
||||||
|
application on top of the same MatrixRTC session, it is in the client. If it is
|
||||||
|
specific to calls, or depends on the window, a setting, a platform, or a React
|
||||||
|
context, it is in the view model.
|
||||||
|
|
||||||
|
### Creating one
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { type MatrixClient, type Room } from "matrix-js-sdk";
|
||||||
|
import { type RTCNotificationType } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
|
||||||
|
export interface MatrixRTCClientOptions {
|
||||||
|
encryptionSystem: EncryptionSystem;
|
||||||
|
/** Resolved by the host; the SDK reads neither config.json nor settings. */
|
||||||
|
matrixRTCMode: MatrixRTCMode;
|
||||||
|
/** Published at the join; `publish` on the local member adds to it from then on. */
|
||||||
|
publish: PublishRequest[];
|
||||||
|
/**
|
||||||
|
* MSC4075 notification sent with the join. A parameter of the MatrixRTC
|
||||||
|
* join itself, so it is here even though it is named after calls; reacting
|
||||||
|
* to a notification (ringing, timeouts, declines) is the application's job.
|
||||||
|
*/
|
||||||
|
sendNotificationType?: RTCNotificationType;
|
||||||
|
/** The application the session belongs to, as named in the membership. Default `m.call`. */
|
||||||
|
application?: string;
|
||||||
|
/** The application's slot in the room. Default `ROOM`. */
|
||||||
|
slot?: string;
|
||||||
|
/**
|
||||||
|
* Whatever the application wants to say about itself in the membership.
|
||||||
|
* Opaque to the SDK. Today the js-sdk carries one key, `m.call.intent`
|
||||||
|
* (`"audio"` or `"video"`); anything else is dropped until it can.
|
||||||
|
*/
|
||||||
|
applicationData?: Record<string, unknown>;
|
||||||
|
/** Session timings the host has configured; the defaults otherwise. */
|
||||||
|
timings?: Partial<SessionTimings>;
|
||||||
|
/** Limits on what is published; LiveKit's defaults otherwise. */
|
||||||
|
mediaQuality?: MediaQuality;
|
||||||
|
/** Use this transport instead of asking the homeserver. */
|
||||||
|
transport?: Transport;
|
||||||
|
/** Use this transport when the homeserver advertises none of the backend's type. */
|
||||||
|
fallbackTransport?: Transport;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One thing to publish: a source, where to capture it from and how to encode
|
||||||
|
* it. Where the device and the settings are left out, the browser's and
|
||||||
|
* LiveKit's defaults apply.
|
||||||
|
*/
|
||||||
|
export type PublishRequest =
|
||||||
|
| { source: "microphone"; deviceId?: string; capture?: AudioCaptureSettings }
|
||||||
|
| {
|
||||||
|
source: "camera";
|
||||||
|
deviceId?: string;
|
||||||
|
/** Background blur and the like. */
|
||||||
|
processor?: VideoProcessor;
|
||||||
|
capture?: VideoCaptureSettings;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
source: "screenShare";
|
||||||
|
/** Whether to capture the screen's audio too. Default true. */
|
||||||
|
audio?: boolean;
|
||||||
|
capture?: VideoCaptureSettings;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Device enumeration, permission prompts and the lobby preview stay with the
|
||||||
|
host: it hands over publish requests naming the devices it chose.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Takes the whole MatrixClient, not a narrow pick, and finds the MatrixRTC
|
||||||
|
* session itself. The js-sdk is the MatrixRTC implementation today; when the
|
||||||
|
* rust-rtc crate replaces it, the SDK has to bridge the crate to the js-sdk
|
||||||
|
* client (events, state, to-device, delayed events, OpenID), and only the SDK
|
||||||
|
* knows what that bridge needs. Holding the client keeps that change inside
|
||||||
|
* the SDK. The js-sdk session is not part of the public API.
|
||||||
|
*
|
||||||
|
* The room has to be the one the client's sync loop maintains; see "What a
|
||||||
|
* host must provide".
|
||||||
|
*/
|
||||||
|
export function createMatrixRTCClient(
|
||||||
|
scope: ObservableScope,
|
||||||
|
client: MatrixClient,
|
||||||
|
room: Room,
|
||||||
|
options: MatrixRTCClientOptions,
|
||||||
|
): MatrixRTCClient;
|
||||||
|
```
|
||||||
|
|
||||||
|
### The client
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** The homeserver link that is down, in the order they depend on each other. */
|
||||||
|
export type MatrixDisconnectReason = "sync" | "membership" | "probablyLeft";
|
||||||
|
|
||||||
|
/** Why the participation is not connected: the first failing link, in dependency order. */
|
||||||
|
export type DisconnectReason = MatrixDisconnectReason | "media";
|
||||||
|
|
||||||
|
export type ParticipationState =
|
||||||
|
| { kind: "waitingForTransport" }
|
||||||
|
| { kind: "connecting" }
|
||||||
|
| { kind: "connected" }
|
||||||
|
| { kind: "reconnecting"; reason: DisconnectReason }
|
||||||
|
/** A failed publication is not fatal: the member can still receive, so it stays on the local member's media. */
|
||||||
|
| { kind: "failed"; error: MatrixRTCError }
|
||||||
|
/** `leave()` was called. Terminal. */
|
||||||
|
| { kind: "left" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An error raised by the client. `code` and `category` say what went wrong in
|
||||||
|
* a form a host can translate; `cause` holds the backend's error.
|
||||||
|
*/
|
||||||
|
export class MatrixRTCError extends Error {
|
||||||
|
code: ErrorCode;
|
||||||
|
category: ErrorCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatrixRTCClient {
|
||||||
|
join(): void;
|
||||||
|
leave(): void;
|
||||||
|
/**
|
||||||
|
* The Matrix membership combined with the local media connection. One
|
||||||
|
* value: a reason exists only while `reconnecting`, an error only once
|
||||||
|
* `failed`.
|
||||||
|
*/
|
||||||
|
state$: Behavior<ParticipationState>;
|
||||||
|
|
||||||
|
/** Null until our own membership has been seen in the room. */
|
||||||
|
localMember$: Behavior<LocalRTCMember | null>;
|
||||||
|
remoteMembers$: Behavior<RemoteRTCMember[]>;
|
||||||
|
/** `remoteMembers.length`, plus one for the local member once it exists. One per membership, so one user may count several times. */
|
||||||
|
memberCount$: Behavior<number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the session has grown large enough that MatrixRTC has stopped
|
||||||
|
* rotating the media encryption key.
|
||||||
|
*/
|
||||||
|
keyRotationSuppressed$: Behavior<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transports the client currently holds a live connection to. The
|
||||||
|
* connections themselves stay internal; this is what a developer panel
|
||||||
|
* needs.
|
||||||
|
*/
|
||||||
|
connectedTransports$: Behavior<TransportMetadata[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays the members' audio on this device from now on. Rejects where the
|
||||||
|
* browser cannot switch to it. A host that routes audio itself, or leaves
|
||||||
|
* the browser's choice, never calls it.
|
||||||
|
*/
|
||||||
|
setAudioOutputDeviceId(deviceId: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a short text to every member on the local transport, over a
|
||||||
|
* reliable data channel beside the media. A packet holds about 15 KiB;
|
||||||
|
* anything larger belongs in a room event. Rejects while the local
|
||||||
|
* transport is not connected. Encrypted on the wire like the media, but
|
||||||
|
* not end to end with the media key.
|
||||||
|
*/
|
||||||
|
sendData(topic: string, text: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* What remote members sent with `sendData`, on every transport the client
|
||||||
|
* is connected to. A message from an identity that is not a member is
|
||||||
|
* dropped, so a host only ever hears from attested members.
|
||||||
|
*/
|
||||||
|
data$: Observable<DataMessage>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One `sendData` call as it arrives at a remote member. */
|
||||||
|
export interface DataMessage {
|
||||||
|
member: RemoteRTCMember;
|
||||||
|
topic: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`state$` is two state machines, one per box in the diagram, combined by one pure
|
||||||
|
function. `LocalMembership` knows only the Matrix side: `waitingForTransport`,
|
||||||
|
`joining`, `joined`, `reconnecting` with a `MatrixDisconnectReason`, `failed`,
|
||||||
|
`left`. The client folds the local media connection in: `joined` with the media
|
||||||
|
up is `connected`, a media drop after that is `reconnecting: "media"`, a
|
||||||
|
connection error is `failed`. Each step is computed in priority order from the
|
||||||
|
previous state, so `left` and `failed` absorb everything after them, and
|
||||||
|
`reconnecting` is only ever reached from `connected`; a membership that drops
|
||||||
|
before the media ever came up stays `connecting`. One reason, not a set, because
|
||||||
|
the links are a chain: without sync the membership is unknowable, and the media
|
||||||
|
is worth little while the Matrix side is down, so the first broken link in that
|
||||||
|
order is what a host shows and what Element Call's analytics count. A failed
|
||||||
|
publication is not in here at all: the member can still receive, so it stays on
|
||||||
|
the local member's media.
|
||||||
|
|
||||||
|
### Members
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* What a membership says, as hosts read it. The js-sdk `CallMembership` is
|
||||||
|
* behind it; anything beyond this is the js-sdk's API, not the SDK's.
|
||||||
|
*/
|
||||||
|
export type RTCMembership = Pick<
|
||||||
|
CallMembership,
|
||||||
|
| "userId"
|
||||||
|
| "deviceId"
|
||||||
|
| "memberId"
|
||||||
|
| "rtcBackendIdentity"
|
||||||
|
| "application"
|
||||||
|
| "applicationData"
|
||||||
|
| "getTransport"
|
||||||
|
| "transports"
|
||||||
|
| "createdTs"
|
||||||
|
| "getAbsoluteExpiry"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface RTCMember {
|
||||||
|
local: boolean;
|
||||||
|
/** The membership's `rtcBackendIdentity`, `${userId}:${deviceId}`: a key and a debug label. */
|
||||||
|
rtcBackendIdentity: string;
|
||||||
|
/**
|
||||||
|
* Every identity the member may publish under on its transport, one per
|
||||||
|
* authentication flow the transport allows: the hashed identity the
|
||||||
|
* MSC4195 client-server API assigns, the legacy one the JWT service's
|
||||||
|
* `/sfu/get` assigns, or both for a transport carrying both a `url` and a
|
||||||
|
* `livekit_service_url`. The backend matches whichever shows up.
|
||||||
|
*/
|
||||||
|
backendIdentities: readonly string[];
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
membership$: Behavior<RTCMembership>;
|
||||||
|
/**
|
||||||
|
* Matrix room state rather than MatrixRTC, but the SDK already holds the
|
||||||
|
* room and every consumer wants them next to the membership. Disambiguated
|
||||||
|
* among the members of the session.
|
||||||
|
*/
|
||||||
|
displayName$: Behavior<string>;
|
||||||
|
avatarUrl$: Behavior<string | undefined>;
|
||||||
|
/** Which transport this member is on; undefined when the membership names none. */
|
||||||
|
transport$: Behavior<TransportMetadata | undefined>;
|
||||||
|
/**
|
||||||
|
* The member's tracks, in publication order, once it has shown up on its
|
||||||
|
* transport; null until then ("waiting for media"). An entry stays the same
|
||||||
|
* object for as long as the same publication is behind it. Which track is
|
||||||
|
* which is in its `source`; `trackBySource$` picks one out. Hand raise and
|
||||||
|
* reactions are room events keyed by member and stay with the host for now;
|
||||||
|
* they are the obvious next fields here.
|
||||||
|
*/
|
||||||
|
tracks$: Behavior<(AudioMediaTrack | VideoMediaTrack)[] | null>;
|
||||||
|
/** Emits when the SFU reports a key problem for this member. */
|
||||||
|
encryptionError$: Observable<EncryptionError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteRTCMember extends RTCMember {
|
||||||
|
local: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalRTCMember extends RTCMember {
|
||||||
|
local: true;
|
||||||
|
tracks$: Behavior<(LocalAudioMediaTrack | LocalVideoMediaTrack)[] | null>;
|
||||||
|
/**
|
||||||
|
* Publishes a source and resolves with its track once it is in `tracks$`.
|
||||||
|
* Before the transport is connected the request is remembered and applied
|
||||||
|
* once it is. Rejects where the device could not be used, including the
|
||||||
|
* user closing the picker. One publication per source: publishing a source
|
||||||
|
* again unmutes it.
|
||||||
|
*/
|
||||||
|
publish(
|
||||||
|
request: PublishRequest,
|
||||||
|
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
|
||||||
|
/** Removes one of our tracks; a screen share takes its audio with it. */
|
||||||
|
unpublish(id: string): Promise<void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transports
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One transport advertised in a membership. Transport independent: `type` and
|
||||||
|
* `id` are all the SDK needs; `raw` and `resolved$` are there for a
|
||||||
|
* backend-specific developer panel and for connection diagnostics. One object
|
||||||
|
* per transport, however many memberships name it.
|
||||||
|
*/
|
||||||
|
export interface TransportMetadata {
|
||||||
|
/** `"livekit"` today. */
|
||||||
|
type: string;
|
||||||
|
/** Stable key, unique per transport in the session: the raw transport, serialised with sorted keys. */
|
||||||
|
id: string;
|
||||||
|
/** The transport object as it appears in the membership, e.g. `{ type, livekit_service_url }`. */
|
||||||
|
raw: Transport;
|
||||||
|
/**
|
||||||
|
* What the backend had to fetch before it could connect. Undefined until
|
||||||
|
* the connection has resolved it, and again after the connection stops or
|
||||||
|
* fails. The local transport resolves before the join, since the membership
|
||||||
|
* needs it; remote ones when their connection starts.
|
||||||
|
*/
|
||||||
|
resolved$: Behavior<ResolvedTransport | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResolvedTransport =
|
||||||
|
| {
|
||||||
|
type: "livekit";
|
||||||
|
/** The SFU websocket url, as opposed to the JWT service url in `raw`. */
|
||||||
|
url: string;
|
||||||
|
/** A secret: fit for a developer panel, never for a log line. */
|
||||||
|
token: string;
|
||||||
|
roomAlias: string;
|
||||||
|
identity: string;
|
||||||
|
}
|
||||||
|
| { type: string; [key: string]: unknown };
|
||||||
|
```
|
||||||
|
|
||||||
|
### Call-specific things kept out of the client
|
||||||
|
|
||||||
|
Checked against `CallViewModel` and its options. These stay in Element Call because
|
||||||
|
they only make sense for a call:
|
||||||
|
|
||||||
|
| Today | Why it is call-specific | Where it lives instead |
|
||||||
|
| ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||||
|
| `autoLeave$` / `AutoLeaveReason` (`"allOthersLeft" \| "timeout" \| "decline"`) | hanging up because the other side declined or everyone left is call etiquette, not session semantics | `CallViewModel.leave$`, built from `remoteMembers$` and the room timeline |
|
||||||
|
| `ringAttempts$`, `createCallNotificationLifecycle$`, `createSentCallNotification$`, `createReceivedDecline$` | ringing, pickup timeouts and decline events are MSC4075 call-notify UX | `src/state/CallViewModel/CallNotificationLifecycle.ts`, fed by `remoteMembers$` and the Matrix room |
|
||||||
|
| `waitForCallPickup`, `autoLeaveWhenOthersLeft` options | inputs to the above | `CallViewModelOptions` |
|
||||||
|
| `hideScreensharing` option | a UI policy; the session can always share a screen if the platform can | `CallViewModel` offers no screen share control |
|
||||||
|
| `ringingVm$`, `ringingStatusLocation` | view models | `CallViewModel` |
|
||||||
|
| `hostBridge.hangUp$`, `hangup()`, `userHangup$` | widget / host integration | `CallViewModel` |
|
||||||
|
| `ElementCallError` | carries Element Call translation keys | the view model wraps `MatrixRTCError`, reading `cause` |
|
||||||
|
| `participantCount$` sound limits, join/leave sounds | call UX | `CallViewModel` |
|
||||||
|
| `callIntent` | what a membership says about the call | `applicationData["m.call.intent"]`, set by the host |
|
||||||
|
|
||||||
|
Kept despite the name: `sendNotificationType`, because the js-sdk join sends the
|
||||||
|
notification as part of entering the session. Only _reacting_ to it is
|
||||||
|
call-specific.
|
||||||
|
|
||||||
|
## Media backend: `MediaBackend`
|
||||||
|
|
||||||
|
The client speaks MatrixRTC: memberships, the transports named in them, the
|
||||||
|
delayed leave, the keys. What carries the media is a backend behind one
|
||||||
|
interface, `MediaBackend` in `sdk/src/media-backend/api.ts`, created once per client.
|
||||||
|
LiveKit under `sdk/src/media-backend/livekit/` is the only backend today, and the only
|
||||||
|
place in the SDK that imports `livekit-client`; the import boundary lint rule
|
||||||
|
enforces that outside `sdk/src/media-backend/` the LiveKit packages appear in type
|
||||||
|
imports only.
|
||||||
|
|
||||||
|
The seam sits above the connection manager on purpose. One connection per
|
||||||
|
transport url, with a subscribe-only connection to every remote SFU, is the
|
||||||
|
LiveKit multi-SFU strategy, not a MatrixRTC concept: a cascading SFU keeps the
|
||||||
|
membership transport as it is and only changes which SFUs the client connects to,
|
||||||
|
and full mesh has one peer connection per member and no token service. So a
|
||||||
|
backend is handed the whole session's view and gives back media:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** The fields of a member the backend supplies, as the member exposes them. */
|
||||||
|
type MemberMediaFields = Pick<RTCMember, "tracks$" | "encryptionError$">;
|
||||||
|
|
||||||
|
export interface MediaBackend {
|
||||||
|
readonly transportType: string; // "livekit"
|
||||||
|
/** Authenticate with the transport the client found; the probe for delegation runs here. */
|
||||||
|
prepareLocalTransport(
|
||||||
|
transport: Transport,
|
||||||
|
): Promise<{ canDelegateDelayedLeave: boolean }>;
|
||||||
|
/** Hand over the delayed leave, each time the membership manager has a new delay id. */
|
||||||
|
delegateDelayedLeave(delayId: string): Promise<void>;
|
||||||
|
readonly local: LocalMediaBackend; // tracks$ encryptionError$ connectionState$ setPublishing publishError$ publish unpublish
|
||||||
|
/** A remote member's media; also how the backend learns which transports to connect to. */
|
||||||
|
mediaFor$(
|
||||||
|
scope: ObservableScope,
|
||||||
|
membership$: Behavior<RTCMembership>,
|
||||||
|
): MemberMediaFields;
|
||||||
|
readonly connections$: Behavior<BackendConnection[]>; // transport, state, resolved; diagnostics
|
||||||
|
sendData(topic: string, text: string): Promise<void>;
|
||||||
|
readonly data$: Observable<DataPacket>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
What stays on the client's side: discovery of the local transport from the
|
||||||
|
homeserver's list (a homeserver endpoint, so not a backend's), the membership
|
||||||
|
state machine in `LocalMembership.ts` and its combination with
|
||||||
|
`local.connectionState$` into `state$`, the choice of delayed leave timings,
|
||||||
|
mapping a data packet's sender to a member, and the transport registry, which keys on the raw
|
||||||
|
transport serialised with sorted keys and derives `connectedTransports$` and each
|
||||||
|
`TransportMetadata.resolved$` from `connections$`.
|
||||||
|
|
||||||
|
The backend's context carries no Matrix client and no membership list. A backend
|
||||||
|
that needs the client takes a `Pick` of exactly what it calls in its own options
|
||||||
|
(`createLivekitBackend(scope, context, { client, mediaQuality })`),
|
||||||
|
and learns of remote members only through `mediaFor$`, registering each
|
||||||
|
membership's transport for as long as the member's scope lives. Media keys reach
|
||||||
|
it as a stream, `mediaKeys$`, replayed on subscribe; the js-sdk session never
|
||||||
|
does. A host supplies its own backend through `MatrixRTCClientOptions.backend`;
|
||||||
|
without one, LiveKit is built from the LiveKit-flavoured client options.
|
||||||
|
|
||||||
|
### Authenticating with a LiveKit transport
|
||||||
|
|
||||||
|
A LiveKit transport comes in three shapes while MSC4195 settles: an SFU `url`,
|
||||||
|
a JWT service `livekit_service_url`, or both. `media-backend/livekit/auth/`
|
||||||
|
fronts the two flows and chooses between them per connection, from the shape
|
||||||
|
and from the role we take on the transport:
|
||||||
|
|
||||||
|
| Transport | Publisher | Subscriber |
|
||||||
|
| -------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `url` only | client-server API `get_token`, no fallback | client-server API `get_token`, no fallback |
|
||||||
|
| `livekit_service_url` only | JWT service `/sfu/get` (legacy identity) | JWT service `/get_token` (hashed identity) |
|
||||||
|
| both | JWT service `/sfu/get` (legacy identity) | client-server API, falling back to the JWT service `/get_token` where the homeserver, or a widget host, has no MSC4195 |
|
||||||
|
|
||||||
|
A publisher on a transport with a service URL keeps the legacy
|
||||||
|
`<user_id>:<device_id>` identity, because clients predating MSC4195 match a
|
||||||
|
membership against that and nothing else; a subscriber is matched by nobody and
|
||||||
|
takes the more private hashed identity. A publisher never falls back: a
|
||||||
|
homeserver that advertises a `url` without implementing MSC4195 is misconfigured
|
||||||
|
and fails with `NoMatrix2AuthorizationService`, so the admin hears about it.
|
||||||
|
The MatrixRTC mode plays no part in the choice. The delayed leave is delegated
|
||||||
|
through the same flow that issued the publisher's token, so that the identity
|
||||||
|
the service watches for on the SFU is the one we publish under, and the
|
||||||
|
delegation probe before the join asks only that service.
|
||||||
|
|
||||||
|
The client-server API needs the name of the homeserver the transport belongs to:
|
||||||
|
our own homeserver asks that server for the token, over federation where it is
|
||||||
|
not ours. The backend derives it from the member's user id, and from our own for
|
||||||
|
the local transport, and keys its connections on the transport plus the server
|
||||||
|
name where the transport has a `url`. A JWT service is reached directly, so
|
||||||
|
members of several homeservers advertising the same one share a connection.
|
||||||
|
|
||||||
|
## Media: `tracks$` and `MediaTrack`
|
||||||
|
|
||||||
|
Every member has media. Today, in Element Call, that media _is_ a `livekit-client`
|
||||||
|
participant, handed straight through to the view models. In the SDK the participant
|
||||||
|
becomes the member's `tracks$`, a list of `MediaTrack`s, so that nothing outside
|
||||||
|
the SDK imports `Participant`, `TrackPublication`, `Track` or `TrackReference`.
|
||||||
|
The adapter in `sdk/src/media-backend/livekit/` is the only place that reads a
|
||||||
|
participant. The backend supplies the same two fields through `mediaFor$`, so
|
||||||
|
there is one shape from the LiveKit participant to the host and nobody has to
|
||||||
|
subscribe through one Behavior to reach another.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type MediaSource =
|
||||||
|
| "microphone"
|
||||||
|
| "camera"
|
||||||
|
| "screenShare"
|
||||||
|
| "screenShareAudio"
|
||||||
|
/** Published without a source; the application knows what it is. */
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
export type MediaStreamStats =
|
||||||
|
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined;
|
||||||
|
|
||||||
|
/** One published track of a member. */
|
||||||
|
export interface MediaTrack {
|
||||||
|
source: MediaSource;
|
||||||
|
kind: "audio" | "video";
|
||||||
|
/** Stable for the life of the track. */
|
||||||
|
id: string;
|
||||||
|
muted$: Behavior<boolean>;
|
||||||
|
/** False when the SFU reports the track as unencrypted. Fixed for the life of the track. */
|
||||||
|
encrypted: boolean;
|
||||||
|
/**
|
||||||
|
* Inbound statistics for a remote track, outbound for a local one, polled
|
||||||
|
* every `intervalMs` while the observable is subscribed and not at all
|
||||||
|
* otherwise, so a statistics panel costs nothing while closed.
|
||||||
|
*/
|
||||||
|
stats$(intervalMs: number): Observable<MediaStreamStats>;
|
||||||
|
/**
|
||||||
|
* Rendering. The view hands its <video> or <audio> element over; the SDK
|
||||||
|
* sets its stream and, for video, registers the size and on-screen
|
||||||
|
* observers that pick a simulcast layer and pause the subscription while
|
||||||
|
* the element is hidden. attach is idempotent per element; detach has to be
|
||||||
|
* called before the element leaves the DOM so those observers are released.
|
||||||
|
*/
|
||||||
|
attach(element: HTMLMediaElement): void;
|
||||||
|
detach(element: HTMLMediaElement): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioMediaTrack extends MediaTrack {
|
||||||
|
kind: "audio";
|
||||||
|
/**
|
||||||
|
* Whether the track carries sound right now, as the backend measures it.
|
||||||
|
* False while muted. A call reads this on the microphone track and calls it
|
||||||
|
* "speaking". LiveKit measures it per member, so every audio track of a
|
||||||
|
* member reports the same value.
|
||||||
|
*/
|
||||||
|
isActive$: Behavior<boolean>;
|
||||||
|
/**
|
||||||
|
* How loud the track is right now, 0 to 1, as the backend measures it. 0
|
||||||
|
* while muted. Not what decides `isActive$`: the backend applies its own
|
||||||
|
* threshold and hysteresis to that.
|
||||||
|
*/
|
||||||
|
audioLevel$: Behavior<number>;
|
||||||
|
/** Route playback through Web Audio, for earpiece pan and gain. Undefined resets. */
|
||||||
|
setAudioContext(ctx: AudioContext | undefined, plugins?: AudioNode[]): void;
|
||||||
|
/** No-op for our own audio, which is never played back. */
|
||||||
|
setVolume(volume: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoMediaTrack extends MediaTrack {
|
||||||
|
kind: "video";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The controls a member has over a track it publishes itself. */
|
||||||
|
export interface LocalMediaTrack {
|
||||||
|
/** Mutes or unmutes. Resolves with the state that resulted. */
|
||||||
|
setEnabled(enabled: boolean): Promise<boolean>;
|
||||||
|
/** Captures from another device. Rejects for a screen share, which has none. */
|
||||||
|
setDevice(deviceId: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalAudioMediaTrack
|
||||||
|
extends AudioMediaTrack, LocalMediaTrack {}
|
||||||
|
|
||||||
|
export interface LocalVideoMediaTrack extends VideoMediaTrack, LocalMediaTrack {
|
||||||
|
/** For mirroring; undefined where the camera does not say which way it faces. */
|
||||||
|
facingMode$: Behavior<"user" | "environment" | undefined>;
|
||||||
|
/** Restarts the camera facing the other way; resolves with the device now in use. */
|
||||||
|
switchFacingMode(): Promise<string | undefined>;
|
||||||
|
/** Background blur and the like; undefined removes the processor. */
|
||||||
|
setProcessor(processor: VideoProcessor | undefined): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EncryptionError = "MissingKey" | "InvalidKey";
|
||||||
|
|
||||||
|
/** The member's first track of a source; undefined while there is none, or no media yet. */
|
||||||
|
export function trackBySource$<S extends MediaSource>(
|
||||||
|
scope: ObservableScope,
|
||||||
|
tracks$: Behavior<MediaTrack[] | null>,
|
||||||
|
source: S,
|
||||||
|
): Behavior<TrackOfSource<S> | undefined>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rendering: `attach` and `detach`
|
||||||
|
|
||||||
|
The view passes its DOM element into the SDK and the SDK does the rest; there is no
|
||||||
|
`MediaStreamTrack` on the interface. The reason is adaptive stream: Element Call
|
||||||
|
runs with `adaptiveStream: true`, which means LiveKit decides the simulcast layer
|
||||||
|
from the size of the attached video element and pauses the subscription at the
|
||||||
|
SFU while no attached element is on screen. Those size and visibility observers are
|
||||||
|
set up inside LiveKit's `attach`. Handing the element over is the only way to keep
|
||||||
|
that without reimplementing the observers outside the SDK. The e2e tests check both
|
||||||
|
effects: a larger tile receives a higher resolution, a hidden tile stops receiving
|
||||||
|
frames.
|
||||||
|
|
||||||
|
What `attach` covers for free, so the host does not have to: `srcObject` and
|
||||||
|
`autoplay` setup with the Safari and Firefox black-video workarounds; `play()` and
|
||||||
|
the autoplay-blocked signal; following the track through mute, reconnect and
|
||||||
|
re-subscription into every attached element; the Web Audio graph for earpiece pan
|
||||||
|
and gain; the adaptive stream observers.
|
||||||
|
|
||||||
|
The interface is web-only. A non-DOM host would need a different media surface; if
|
||||||
|
one shows up, a `MediaStreamTrack` accessor is added next to `attach`, not instead
|
||||||
|
of it.
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
`sdk/` is packaged exactly as `component/` is: a `package.json` with peer
|
||||||
|
dependencies (`matrix-js-sdk`, `livekit-client`, `rxjs`), `exports` with types,
|
||||||
|
type emission through `sdk/tsconfig.build.json`, an externals list in
|
||||||
|
`vite-sdk.config.ts` that `pnpm lint:externals` enforces, and installation from the
|
||||||
|
repository as a git dependency with a `path:` suffix. `pnpm build:sdk` produces
|
||||||
|
`sdk/dist`. It is not a pnpm workspace package yet; the root is not a workspace, and
|
||||||
|
moving to one later carries the package name, entry point and externals over
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
Element Call consumes the SDK by its package name from day one, so the swap to a
|
||||||
|
published dependency is one line: `tsconfig.json` `paths` and the `resolve.alias`
|
||||||
|
in `vitePluginsConfig` both map `@element-hq/matrixrtc-sdk` to `sdk/index.ts`. The
|
||||||
|
alias has to live in `vitePluginsConfig`, which every config is built on. Put only
|
||||||
|
into the app's own config, the harness resolves the name through `sdk/package.json`
|
||||||
|
to whatever stale `sdk/dist` is lying around.
|
||||||
|
|
||||||
|
Layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
sdk/
|
||||||
|
index.ts the entry point: re-exports the API and the primitives
|
||||||
|
src/api.ts the public types above: client, members, transports
|
||||||
|
src/media-api.ts the public media types: publish requests, tracks
|
||||||
|
src/errors.ts MatrixRTCError and its codes
|
||||||
|
src/config.ts MatrixRTCMode, session timings, media quality
|
||||||
|
src/encryption.ts E2eeType, EncryptionSystem
|
||||||
|
src/MatrixRTCClient.ts createMatrixRTCClient: wires the two folders below
|
||||||
|
src/participationState.ts the membership state combined with the media connection
|
||||||
|
src/reactive/ Behavior, ObservableScope, the observable operators
|
||||||
|
src/matrixrtc/ the MatrixRTC side: the local membership, memberships, member
|
||||||
|
metadata, transports, discovery, the js-sdk join
|
||||||
|
src/media-backend/ the MediaBackend interface in api.ts; livekit/ is the one
|
||||||
|
backend: connections, publisher, JWT, keys, the tracks
|
||||||
|
src/utils/ mapScoped, network retry, display names, test helpers
|
||||||
|
dev/ the harness (below)
|
||||||
|
SdkArchitecture.md this document
|
||||||
|
SdkMigration.md what moved from Element Call's src/ into the SDK
|
||||||
|
```
|
||||||
|
|
||||||
|
The lint rule `element-call/sdk-import-boundary` keeps it that way: nothing under
|
||||||
|
`sdk/` may import a file outside `sdk/` (that is `src/`, `component/`,
|
||||||
|
`playwright/`) or React, i18n or Compound. The harness under `sdk/dev/` is a host
|
||||||
|
and exempt.
|
||||||
|
|
||||||
|
## Dev harness: `sdk/dev/`
|
||||||
|
|
||||||
|
A web app that uses the SDK and nothing else: plain DOM, no React, no Compound, no
|
||||||
|
i18n, imports only `@element-hq/matrixrtc-sdk`, `matrix-js-sdk` and `rxjs`. It is
|
||||||
|
where the SDK is run during development and what the e2e tests in `playwright/sdk`
|
||||||
|
drive. If something cannot be done in the harness without reaching into `src/`, the
|
||||||
|
SDK API is missing something.
|
||||||
|
|
||||||
|
What it does: log in with a password, or with an access token the URL hands it (the
|
||||||
|
tests register users through the admin API and skip `/login`, which the dev
|
||||||
|
homeserver rate-limits); join the room and wait for the sync to hold it; create the
|
||||||
|
client with microphone and camera enabled; show one tile per member with the display
|
||||||
|
name, a `<video>` attached from the member's `"camera"` track and, for remote
|
||||||
|
members, an `<audio>` from the `"microphone"` track, both picked out of
|
||||||
|
`tracks$` with `trackBySource$`; toggle the microphone and camera; leave.
|
||||||
|
|
||||||
|
Every media element carries the track's state as `data-*` attributes (`muted`,
|
||||||
|
`encrypted`, `active`, `frameWidth`, `frames`), which is what the tests read to
|
||||||
|
check mute propagation, encryption, adaptive resolution and paused subscriptions
|
||||||
|
(the speaker detection check is a `fixme`: the fake microphones do not reliably
|
||||||
|
register at the SFU). That is more
|
||||||
|
than the smallest consumer needs, and the price of testing the SDK through its
|
||||||
|
public API only.
|
||||||
|
|
||||||
|
## Open decisions
|
||||||
|
|
||||||
|
- **Publishing before `join()`.** The initial requests are published at the
|
||||||
|
join; creating the tracks earlier, for a lobby preview, is still missing.
|
||||||
|
- **`applicationData` after the join.** The option is read once; there is no
|
||||||
|
way to update it on a live membership yet.
|
||||||
|
- **Raw membership state.** The Matrix side's own state machine
|
||||||
|
(`MembershipState` in `LocalMembership.ts`) stays internal; `state$` on the
|
||||||
|
participation is its public view combined with the media connection. A
|
||||||
|
developer panel may want it as `LocalRTCMember.membershipState$`.
|
||||||
|
- **Developer panel.** `resolved$` on each connected transport covers what the
|
||||||
|
panel shows today. Anything beyond that (LiveKit room state, connection
|
||||||
|
quality) needs an opaque `debug$` per transport.
|
||||||
|
- **Data beside the media.** `sendData`/`data$` ride on LiveKit's reliable data
|
||||||
|
packets: topic-agnostic, matched to members by identity, received on every
|
||||||
|
connection but sent only on the local transport, so a member on another
|
||||||
|
transport does not hear us. They are transport-encrypted, not end to end:
|
||||||
|
LiveKit can encrypt data packets with the media key, but that is not wired
|
||||||
|
through the key provider yet. A host that needs either reach or end-to-end
|
||||||
|
encryption uses room events.
|
||||||
|
- **Unencrypted rooms with per-participant keys.** Keys only reach devices the
|
||||||
|
crypto tracks, which are the members of encrypted rooms. If the SDK is to work
|
||||||
|
in an unencrypted room, tracking the members' devices becomes its job.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# What moved from `src/` into the SDK
|
||||||
|
|
||||||
|
Element Call's `CallViewModel` used to do everything; now it consumes the SDK and
|
||||||
|
keeps only what makes a session a call. This is the inventory of what moved out
|
||||||
|
of `src/` on the way, so that a reader of either side knows where to look. It
|
||||||
|
names source files, so it ages with the code; the shape they make is in
|
||||||
|
[`SdkArchitecture.md`](./SdkArchitecture.md).
|
||||||
|
|
||||||
|
Everything in the table is gone from `src/`. Each module came with its tests;
|
||||||
|
`sdk/src/utils/test.ts` and `test-fixtures.ts` are the subset of Element Call's
|
||||||
|
test helpers they need. `Behavior`, `ObservableScope`, the observable operators,
|
||||||
|
`E2eeType`, `EncryptionSystem` and `MatrixRTCMode` are re-exported from the SDK
|
||||||
|
by their old `src/` paths, so the rest of Element Call did not have to change.
|
||||||
|
|
||||||
|
| Was in `src/` | Is in `sdk/src/` | What changed on the way |
|
||||||
|
| ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `state/Behavior.ts`, `state/ObservableScope.ts`, `utils/observable.ts` | `reactive/` | as is |
|
||||||
|
| `state/SessionBehaviors.ts` | `matrixrtc/JsRtcSessionBehaviors.ts` | the memberships and key rotation behaviors only |
|
||||||
|
| `state/CallViewModel/localMember/HomeserverConnected.ts` | `matrixrtc/HomeserverConnected.ts` | the grace period defaults to `defaultSessionTimings` instead of `Config.get()` |
|
||||||
|
| `state/CallViewModel/localMember/LocalMember.ts` | `matrixrtc/LocalMembership.ts`, `media-backend/livekit/LocalMedia.ts`, `participationState.ts` | split into the Matrix side, the media side and the state machine that combines them |
|
||||||
|
| `state/CallViewModel/localMember/LocalTransport.ts`, `RtcTransportAutoDiscovery.ts` | `matrixrtc/LocalTransport.ts`, `TransportAutoDiscovery.ts` | the override and the fallback are transports from the options, not a setting and a resolved config |
|
||||||
|
| `state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts` | `media-backend/livekit/LivekitBackend.ts` (`mediaFor`) | matches participants to members inside the backend; the SDK hands out `RTCMember`s |
|
||||||
|
| `state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts`, `utils/displayname.ts` | `matrixrtc/MatrixMemberMetadata.ts`, `utils/displayname.ts` | as is |
|
||||||
|
| `state/CallViewModel/remoteMembers/Connection.ts` | `media-backend/livekit/Connection.ts` | throws `MatrixRTCError`s; `ConnectionState` is a discriminated union; fetches its own token for its role |
|
||||||
|
| `state/CallViewModel/remoteMembers/ConnectionFactory.ts` | `media-backend/livekit/ConnectionFactory.ts` | `LivekitConnectionFactory` builds the room options from the publish requests and `mediaQuality`, not from settings |
|
||||||
|
| `state/CallViewModel/remoteMembers/ConnectionManager.ts` | `media-backend/livekit/ConnectionMap.ts` | keyed by `TransportLocator`; the publisher role is the local transport's |
|
||||||
|
| `state/CallViewModel/localMember/Publisher.ts` | `media-backend/livekit/Publisher.ts` | rewritten on publish requests instead of `MuteStates` |
|
||||||
|
| `livekit/openIDSFU.ts`, later `livekit/auth/` | `media-backend/livekit/auth/` | the flow is chosen by the transport's shape and our role; the delegated leave timeout is a parameter |
|
||||||
|
| `livekit/options.ts` | `media-backend/livekit/livekitOptions.ts` | `buildLiveKitOptions(mediaQuality?)` only; the `Config` singleton is gone |
|
||||||
|
| `e2ee/matrixKeyProvider.ts`, the key provider selection | `media-backend/livekit/MatrixKeyProvider.ts`, `KeyProvider.ts` | as is; selection keyed off `encryptionSystem` |
|
||||||
|
| `e2ee/e2eeType.ts`, `EncryptionSystem` from `e2ee/sharedKeyManagement.ts` | `encryption.ts` | the React hooks stayed behind |
|
||||||
|
| `MatrixRTCMode`, the timings and media quality types from `config/ConfigOptions.ts` | `config.ts` | `defaultSessionTimings` and `defaultMediaQuality` replace `DEFAULT_CONFIG` |
|
||||||
|
| `utils/errors.ts` | `errors.ts` | `MatrixRTCError` with `code` and `category` replaces `ElementCallError`; messages are plain English, the host translates by code |
|
||||||
|
| `doNetworkOperationWithRetry` from `utils/matrix.ts` | `utils/network.ts` | takes a `shouldRetry` predicate |
|
||||||
|
| the join in `CallViewModel.ts` (`enterRTCSession`) | `matrixrtc/joinJsSdkSession.ts` | the timings and the delayed leave variant are parameters |
|
||||||
|
| `livekit/MatrixAudioRenderer.tsx`, the participant reads in `state/media/` | `media-backend/livekit/LivekitMediaTrack.ts`, `LivekitMemberMedia.ts` | the view models get `MediaTrack`s with `attach`; nothing in `src/` imports a LiveKit participant any more |
|
||||||
|
|
||||||
|
## What still binds Element Call to LiveKit
|
||||||
|
|
||||||
|
Each is a gap in the SDK's API rather than something to rename; the import goes
|
||||||
|
away when the SDK grows the piece. The names Element Call owes to the outside
|
||||||
|
stay as they are: the `livekit.livekit_service_url` key in `config.json`, the
|
||||||
|
persisted setting keys `extended-livekit-logs` and `custom-transport`, the
|
||||||
|
Posthog fields `callBackend` and `callReconnectingCountLivekit`, the rageshake
|
||||||
|
field `call_backend`, and the translated error strings.
|
||||||
|
|
||||||
|
| Gap | Where it shows |
|
||||||
|
| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||||
|
| Preview before `join()`: local tracks for the lobby, handed out as `MediaTrack`s | `LobbyView` (`usePreviewTracks`), `VideoPreview` (`LocalVideoTrack`, `facingModeFromLocalTrack`) |
|
||||||
|
| Device enumeration without LiveKit's helpers | `MediaDevices` (`createMediaDeviceObserver`), `CallView` (`Room.getLocalDevices`) |
|
||||||
|
| A `logging` option: log level and a sink for the transport's logs | `initializer.tsx`, the `extended-livekit-logs` setting |
|
||||||
|
| A `capabilities` field: output selection and background processors, asked of the SDK rather than of LiveKit | `CallFooterViewModel` (`supportsAudioOutputSelection`, `supportsBackgroundProcessors`) |
|
||||||
|
| Element Call's error and analytics types on the SDK's | `PosthogEvents` (`DisconnectReason`), `errors.ts` (`ConnectionError`, `LivekitConnectionError`) |
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
-->
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>MatrixRTC SDK harness</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 1rem;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
#members {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
#members section {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
#members video {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
background: #222;
|
||||||
|
}
|
||||||
|
#members h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form>
|
||||||
|
<input
|
||||||
|
name="homeserver"
|
||||||
|
placeholder="homeserver"
|
||||||
|
value="https://synapse.m.localhost"
|
||||||
|
/>
|
||||||
|
<input name="username" placeholder="username" />
|
||||||
|
<input name="password" type="password" placeholder="password" />
|
||||||
|
<input name="room" placeholder="room id or alias" />
|
||||||
|
<!-- A host that already holds a session passes it instead of a password -->
|
||||||
|
<input name="accessToken" type="hidden" />
|
||||||
|
<input name="userId" type="hidden" />
|
||||||
|
<input name="deviceId" type="hidden" />
|
||||||
|
<button type="submit">Start</button>
|
||||||
|
<button type="button" id="microphone" aria-pressed="true" hidden>
|
||||||
|
Microphone
|
||||||
|
</button>
|
||||||
|
<button type="button" id="camera" aria-pressed="true" hidden>
|
||||||
|
Camera
|
||||||
|
</button>
|
||||||
|
<button type="button" id="screenShare" aria-pressed="false" hidden>
|
||||||
|
Share screen
|
||||||
|
</button>
|
||||||
|
<button type="button" id="leave" hidden>Leave</button>
|
||||||
|
</form>
|
||||||
|
<p id="status" data-testid="status"></p>
|
||||||
|
<!-- A line of text to the other members, beside the media -->
|
||||||
|
<form id="data" hidden>
|
||||||
|
<input name="text" placeholder="message" />
|
||||||
|
<button type="submit">Send</button>
|
||||||
|
</form>
|
||||||
|
<ul id="messages"></ul>
|
||||||
|
<main id="members"></main>
|
||||||
|
<script type="module" src="./main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+289
@@ -0,0 +1,289 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The smallest consumer of the SDK: log in, join a room, show every member's
|
||||||
|
* camera and play every remote member's microphone, mute, leave. What a host
|
||||||
|
* writes, and nothing a host would not.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import { combineLatest, type Observable, of, switchMap } from "rxjs";
|
||||||
|
import {
|
||||||
|
type AudioMediaTrack,
|
||||||
|
createRTCSlot,
|
||||||
|
E2eeType,
|
||||||
|
type LocalRTCMember,
|
||||||
|
MatrixRTCMode,
|
||||||
|
ObservableScope,
|
||||||
|
type RemoteRTCMember,
|
||||||
|
type RTCParticipation,
|
||||||
|
trackBySource$,
|
||||||
|
type VideoMediaTrack,
|
||||||
|
} from "@element-hq/matrixrtc-sdk";
|
||||||
|
|
||||||
|
import { createSession, joinRoom, type Login } from "./session";
|
||||||
|
|
||||||
|
const form = document.querySelector("form")!;
|
||||||
|
const status = document.getElementById("status")!;
|
||||||
|
const members = document.getElementById("members")!;
|
||||||
|
const dataForm = document.getElementById("data") as HTMLFormElement;
|
||||||
|
const messages = document.getElementById("messages")!;
|
||||||
|
const buttons = {
|
||||||
|
microphone: document.getElementById("microphone") as HTMLButtonElement,
|
||||||
|
camera: document.getElementById("camera") as HTMLButtonElement,
|
||||||
|
screenShare: document.getElementById("screenShare") as HTMLButtonElement,
|
||||||
|
leave: document.getElementById("leave") as HTMLButtonElement,
|
||||||
|
};
|
||||||
|
|
||||||
|
// So that a test, or a bookmark, can fill the form from the URL
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
for (const input of form.querySelectorAll("input"))
|
||||||
|
input.value = params.get(input.name) ?? input.value;
|
||||||
|
|
||||||
|
form.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const fields = new FormData(form);
|
||||||
|
const field = (name: string): string => fields.get(name) as string;
|
||||||
|
const login: Login = field("accessToken")
|
||||||
|
? {
|
||||||
|
accessToken: field("accessToken"),
|
||||||
|
userId: field("userId"),
|
||||||
|
deviceId: field("deviceId"),
|
||||||
|
}
|
||||||
|
: { username: field("username"), password: field("password") };
|
||||||
|
void start(field("homeserver"), login, field("room"));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function start(
|
||||||
|
homeserver: string,
|
||||||
|
login: Login,
|
||||||
|
roomIdOrAlias: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
status.textContent = "Logging in";
|
||||||
|
const client = await createSession(homeserver, login);
|
||||||
|
const room = await joinRoom(client, roomIdOrAlias);
|
||||||
|
|
||||||
|
const scope = new ObservableScope();
|
||||||
|
const slot = createRTCSlot(scope, client, room, {
|
||||||
|
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||||
|
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||||
|
});
|
||||||
|
const rtcParticipation = slot.join({
|
||||||
|
publish: [{ source: "microphone" }, { source: "camera" }],
|
||||||
|
});
|
||||||
|
rtcParticipation.state$.pipe(scope.bind()).subscribe((state) => {
|
||||||
|
status.textContent =
|
||||||
|
state.kind === "failed" ? `Error: ${state.error.message}` : state.kind;
|
||||||
|
});
|
||||||
|
showMembers(scope, rtcParticipation);
|
||||||
|
showMessages(scope, rtcParticipation);
|
||||||
|
|
||||||
|
toggle(buttons.microphone, async (enabled) =>
|
||||||
|
setPublished(rtcParticipation, "microphone", enabled),
|
||||||
|
);
|
||||||
|
toggle(buttons.camera, async (enabled) =>
|
||||||
|
setPublished(rtcParticipation, "camera", enabled),
|
||||||
|
);
|
||||||
|
toggle(buttons.screenShare, async (enabled) =>
|
||||||
|
setPublished(rtcParticipation, "screenShare", enabled),
|
||||||
|
);
|
||||||
|
buttons.leave.hidden = false;
|
||||||
|
buttons.leave.onclick = (): void => {
|
||||||
|
rtcParticipation.leave();
|
||||||
|
scope.end();
|
||||||
|
members.replaceChildren();
|
||||||
|
messages.replaceChildren();
|
||||||
|
dataForm.hidden = true;
|
||||||
|
for (const button of Object.values(buttons)) button.hidden = true;
|
||||||
|
status.textContent = "Left";
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
status.textContent = `Error: ${e}`;
|
||||||
|
logger.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns one of our sources on or off: a microphone or camera is muted and
|
||||||
|
* unmuted while it exists and published when it does not; a screen share is
|
||||||
|
* published and unpublished.
|
||||||
|
*/
|
||||||
|
async function setPublished(
|
||||||
|
rtcParticipation: RTCParticipation,
|
||||||
|
source: "microphone" | "camera" | "screenShare",
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const track = rtcParticipation.localMember$.value?.tracks$.value?.find(
|
||||||
|
(t) => t.source === source,
|
||||||
|
);
|
||||||
|
if (track && source === "screenShare") {
|
||||||
|
if (!enabled) await rtcParticipation.unpublish(source);
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
if (track) return track.setEnabled(enabled);
|
||||||
|
if (!enabled) return false;
|
||||||
|
await rtcParticipation.publish({ source });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pressed button means the source is on; the SDK says what it could do. */
|
||||||
|
function toggle(
|
||||||
|
button: HTMLButtonElement,
|
||||||
|
set: (enabled: boolean) => Promise<boolean>,
|
||||||
|
): void {
|
||||||
|
button.hidden = false;
|
||||||
|
button.onclick = (): void => {
|
||||||
|
void set(button.ariaPressed !== "true").then((enabled) => {
|
||||||
|
button.ariaPressed = String(enabled);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends what the form holds on the "chat" topic, and lists what arrives. */
|
||||||
|
function showMessages(
|
||||||
|
scope: ObservableScope,
|
||||||
|
rtcParticipation: RTCParticipation,
|
||||||
|
): void {
|
||||||
|
dataForm.hidden = false;
|
||||||
|
dataForm.onsubmit = (event): void => {
|
||||||
|
event.preventDefault();
|
||||||
|
const text = new FormData(dataForm).get("text") as string;
|
||||||
|
rtcParticipation.sendData("chat", text).then(
|
||||||
|
() => dataForm.reset(),
|
||||||
|
(e: unknown) => {
|
||||||
|
status.textContent = `Error: ${e}`;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
rtcParticipation.data$
|
||||||
|
.pipe(scope.bind())
|
||||||
|
.subscribe(({ member, topic, text }) => {
|
||||||
|
const line = messages.appendChild(document.createElement("li"));
|
||||||
|
line.dataset.testid = "message";
|
||||||
|
line.dataset.topic = topic;
|
||||||
|
line.dataset.userId = member.userId;
|
||||||
|
line.textContent = `${member.displayName$.value}: ${text}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMembers(
|
||||||
|
scope: ObservableScope,
|
||||||
|
rtcParticipation: RTCParticipation,
|
||||||
|
): void {
|
||||||
|
const tiles = new Map<string, HTMLElement>();
|
||||||
|
combineLatest([
|
||||||
|
rtcParticipation.localMember$,
|
||||||
|
rtcParticipation.remoteMembers$,
|
||||||
|
])
|
||||||
|
.pipe(scope.bind())
|
||||||
|
.subscribe(([local, remote]) => {
|
||||||
|
const current = local === null ? remote : [local, ...remote];
|
||||||
|
for (const [id, tile] of tiles)
|
||||||
|
if (!current.some((m) => m.memberId === id)) {
|
||||||
|
tile.remove();
|
||||||
|
tiles.delete(id);
|
||||||
|
}
|
||||||
|
for (const member of current)
|
||||||
|
if (!tiles.has(member.memberId)) {
|
||||||
|
const tile = memberTile(scope, member);
|
||||||
|
tiles.set(member.memberId, tile);
|
||||||
|
members.append(tile);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function memberTile(
|
||||||
|
scope: ObservableScope,
|
||||||
|
member: LocalRTCMember | RemoteRTCMember,
|
||||||
|
): HTMLElement {
|
||||||
|
const tile = document.createElement("section");
|
||||||
|
tile.dataset.testid = "member";
|
||||||
|
tile.dataset.userId = member.userId;
|
||||||
|
tile.dataset.local = String(member.local);
|
||||||
|
|
||||||
|
const name = tile.appendChild(document.createElement("h2"));
|
||||||
|
member.displayName$.pipe(scope.bind()).subscribe((n) => {
|
||||||
|
name.textContent = n;
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
scope,
|
||||||
|
trackBySource$(scope, member.tracks$, "camera"),
|
||||||
|
tile.appendChild(document.createElement("video")),
|
||||||
|
);
|
||||||
|
// Our own microphone would only echo
|
||||||
|
if (!member.local)
|
||||||
|
render(
|
||||||
|
scope,
|
||||||
|
trackBySource$(scope, member.tracks$, "microphone"),
|
||||||
|
tile.appendChild(document.createElement("audio")),
|
||||||
|
);
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays a track on an element and labels the element with the track's state,
|
||||||
|
* so that the page shows what the member is sending and how well it arrives.
|
||||||
|
*/
|
||||||
|
function render(
|
||||||
|
scope: ObservableScope,
|
||||||
|
track$: Observable<AudioMediaTrack | VideoMediaTrack | undefined>,
|
||||||
|
element: HTMLMediaElement,
|
||||||
|
): void {
|
||||||
|
let attached: AudioMediaTrack | VideoMediaTrack | undefined;
|
||||||
|
track$.pipe(scope.bind()).subscribe((track) => {
|
||||||
|
attached?.detach(element);
|
||||||
|
attached = track;
|
||||||
|
track?.attach(element);
|
||||||
|
});
|
||||||
|
scope.onEnd(() => attached?.detach(element));
|
||||||
|
|
||||||
|
const label = (
|
||||||
|
key: string,
|
||||||
|
value$: Observable<string | number | boolean | undefined>,
|
||||||
|
): void => {
|
||||||
|
value$.pipe(scope.bind()).subscribe((value) => {
|
||||||
|
if (value === undefined) delete element.dataset[key];
|
||||||
|
else element.dataset[key] = String(value);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const of$ = <T>(
|
||||||
|
pick: (track: AudioMediaTrack | VideoMediaTrack) => Observable<T>,
|
||||||
|
): Observable<T | undefined> =>
|
||||||
|
track$.pipe(switchMap((track) => (track ? pick(track) : of(undefined))));
|
||||||
|
label(
|
||||||
|
"muted",
|
||||||
|
of$((t) => t.muted$),
|
||||||
|
);
|
||||||
|
label(
|
||||||
|
"encrypted",
|
||||||
|
of$((t) => of(t.encrypted)),
|
||||||
|
);
|
||||||
|
label(
|
||||||
|
"active",
|
||||||
|
of$((t) => (t.kind === "audio" ? t.isActive$ : of(undefined))),
|
||||||
|
);
|
||||||
|
const stats$ = of$((t) => t.stats$(1000));
|
||||||
|
label("frameWidth", stats$.pipe(switchMap((s) => of(frames(s)?.frameWidth))));
|
||||||
|
label("frames", stats$.pipe(switchMap((s) => of(frames(s)?.count))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The frame counters an RTP stream reports, from either end of it. */
|
||||||
|
function frames(
|
||||||
|
stats: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined,
|
||||||
|
): { frameWidth: number | undefined; count: number | undefined } | undefined {
|
||||||
|
if (stats === undefined) return undefined;
|
||||||
|
return {
|
||||||
|
frameWidth: stats.frameWidth,
|
||||||
|
count:
|
||||||
|
stats.type === "inbound-rtp"
|
||||||
|
? (stats as RTCInboundRtpStreamStats).framesDecoded
|
||||||
|
: (stats as RTCOutboundRtpStreamStats).framesEncoded,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ClientEvent,
|
||||||
|
createClient,
|
||||||
|
type MatrixClient,
|
||||||
|
MemoryStore,
|
||||||
|
type Room,
|
||||||
|
SyncState,
|
||||||
|
} from "matrix-js-sdk";
|
||||||
|
import { KnownMembership } from "matrix-js-sdk/lib/types";
|
||||||
|
|
||||||
|
export type Login =
|
||||||
|
| { username: string; password: string }
|
||||||
|
| { accessToken: string; userId: string; deviceId: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a client that has finished its first sync, logging in with a
|
||||||
|
* password unless the caller already holds a token.
|
||||||
|
*/
|
||||||
|
export async function createSession(
|
||||||
|
homeserver: string,
|
||||||
|
login: Login,
|
||||||
|
): Promise<MatrixClient> {
|
||||||
|
const credentials =
|
||||||
|
"accessToken" in login ? login : await logIn(homeserver, login);
|
||||||
|
|
||||||
|
const client = createClient({
|
||||||
|
baseUrl: homeserver,
|
||||||
|
...credentials,
|
||||||
|
store: new MemoryStore(),
|
||||||
|
useAuthorizationHeader: true,
|
||||||
|
fallbackICEServerAllowed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.initRustCrypto({ useIndexedDB: false });
|
||||||
|
await client.startClient();
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const onSync = (state: SyncState): void => {
|
||||||
|
if (state !== SyncState.Prepared && state !== SyncState.Syncing) return;
|
||||||
|
client.off(ClientEvent.Sync, onSync);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
client.on(ClientEvent.Sync, onSync);
|
||||||
|
});
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joins a room and returns it as the sync loop maintains it. The room object
|
||||||
|
* `joinRoom` itself returns for a room joined just now is a detached copy
|
||||||
|
* that never receives the state the sync delivers, and a session built on
|
||||||
|
* it would never see a member.
|
||||||
|
*/
|
||||||
|
export async function joinRoom(
|
||||||
|
client: MatrixClient,
|
||||||
|
roomIdOrAlias: string,
|
||||||
|
): Promise<Room> {
|
||||||
|
const { roomId } = await client.joinRoom(roomIdOrAlias);
|
||||||
|
const joinedRoom = (): Room | undefined => {
|
||||||
|
const room = client.getRoom(roomId);
|
||||||
|
return room?.hasMembershipState(client.getUserId()!, KnownMembership.Join)
|
||||||
|
? room
|
||||||
|
: undefined;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
joinedRoom() ??
|
||||||
|
new Promise<Room>((resolve) => {
|
||||||
|
const onSync = (): void => {
|
||||||
|
const room = joinedRoom();
|
||||||
|
if (room === undefined) return;
|
||||||
|
client.off(ClientEvent.Sync, onSync);
|
||||||
|
resolve(room);
|
||||||
|
};
|
||||||
|
client.on(ClientEvent.Sync, onSync);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logIn(
|
||||||
|
homeserver: string,
|
||||||
|
{ username, password }: { username: string; password: string },
|
||||||
|
): Promise<{ accessToken: string; userId: string; deviceId: string }> {
|
||||||
|
const login = await createClient({ baseUrl: homeserver }).login(
|
||||||
|
"m.login.password",
|
||||||
|
{ identifier: { type: "m.id.user", user: username }, password },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
accessToken: login.access_token,
|
||||||
|
userId: login.user_id,
|
||||||
|
deviceId: login.device_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EXPERIMENTAL
|
||||||
|
*
|
||||||
|
* MatrixRTC sessions with LiveKit media, without Element Call's UI: the call
|
||||||
|
* model Element Call's own view model is built on, for hosts that want to
|
||||||
|
* build a different one. The design is in `SdkArchitecture.md` next to this
|
||||||
|
* file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { type Behavior, constant } from "./src/reactive/Behavior";
|
||||||
|
export {
|
||||||
|
Epoch,
|
||||||
|
mapEpoch,
|
||||||
|
ObservableScope,
|
||||||
|
trackEpoch,
|
||||||
|
} from "./src/reactive/ObservableScope";
|
||||||
|
export {
|
||||||
|
filterBehavior,
|
||||||
|
finalizeValue,
|
||||||
|
generateItems,
|
||||||
|
generateItemsWithEpoch,
|
||||||
|
pauseWhen,
|
||||||
|
} from "./src/reactive/observable";
|
||||||
|
export { E2eeType, type EncryptionSystem } from "./src/encryption";
|
||||||
|
export {
|
||||||
|
defaultMediaQuality,
|
||||||
|
defaultSessionTimings,
|
||||||
|
type DelayedLeaveTimings,
|
||||||
|
MatrixRTCMode,
|
||||||
|
type MediaQuality,
|
||||||
|
type SessionTimings,
|
||||||
|
} from "./src/config";
|
||||||
|
export {
|
||||||
|
ErrorCategory,
|
||||||
|
ErrorCode,
|
||||||
|
FailToGetOpenIdToken,
|
||||||
|
FailToStartLivekitConnection,
|
||||||
|
InsufficientCapacityError,
|
||||||
|
LivekitConnectionError,
|
||||||
|
MatrixRTCTransportMissingError,
|
||||||
|
MembershipManagerError,
|
||||||
|
NoMatrix2AuthorizationService,
|
||||||
|
PeerConnectionTimeoutError,
|
||||||
|
SFURoomCreationRestrictedError,
|
||||||
|
UnknownRTCError,
|
||||||
|
} from "./src/errors";
|
||||||
|
|
||||||
|
export * from "./src/api";
|
||||||
|
export * from "./src/media-api";
|
||||||
|
export * from "./src/media-backend/api";
|
||||||
|
export { trackBySource$, type TrackOfSource } from "./src/utils/tracks";
|
||||||
|
export { createRTCSlot } from "./src/RTCSlot";
|
||||||
|
export {
|
||||||
|
createLivekitBackend,
|
||||||
|
type LivekitBackendOptions,
|
||||||
|
} from "./src/media-backend/livekit/LivekitBackend";
|
||||||
|
// For a host that creates LiveKit tracks itself, such as a preview before joining
|
||||||
|
export { convertToLivekitProcessor } from "./src/media-backend/livekit/videoProcessor";
|
||||||
|
// For a developer panel to check a transport before using it
|
||||||
|
export {
|
||||||
|
getSFUConfig as authenticateWithTransport,
|
||||||
|
type GetSFUConfigParams as AuthenticateWithTransportParams,
|
||||||
|
} from "./src/media-backend/livekit/auth";
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "@element-hq/matrixrtc-sdk",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"description": "MatrixRTC sessions with LiveKit media, without Element Call's UI. Consumed straight from the repository as a git dependency (github:element-hq/element-call#<ref>&path:/sdk): the host's package manager runs `prepare`, which builds `dist/`.",
|
||||||
|
"license": "SEE LICENSE IN ../README.md",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/element-hq/element-call",
|
||||||
|
"directory": "sdk"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"devEngines": {
|
||||||
|
"packageManager": {
|
||||||
|
"name": "pnpm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"main": "./dist/matrixrtc-sdk.js",
|
||||||
|
"module": "./dist/matrixrtc-sdk.js",
|
||||||
|
"types": "./dist/types/sdk/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/types/sdk/index.d.ts",
|
||||||
|
"default": "./dist/matrixrtc-sdk.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"prepare": "cd .. && pnpm install --frozen-lockfile && pnpm build:sdk"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"livekit-client": "^2.18.1",
|
||||||
|
"matrix-js-sdk": "*",
|
||||||
|
"rxjs": "^7.8.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Makes `sdk/` a pnpm project of its own rather than a directory inside the
|
||||||
|
# repository's workspace. That matters when a host installs the SDK straight
|
||||||
|
# from this repository (`github:element-hq/element-call#…&path:/sdk`): pnpm
|
||||||
|
# prepares such a dependency by running `pnpm install` in this directory, and
|
||||||
|
# only a project root gets its `prepare` script (see package.json) run, which is
|
||||||
|
# what builds `dist/`. Inside the repository's own workspace the install would
|
||||||
|
# silently target the repository instead and build nothing.
|
||||||
|
#
|
||||||
|
# Consequence for development: pnpm commands run from within this directory see
|
||||||
|
# this project, not the repository; run them from the repository root.
|
||||||
|
|
||||||
|
# Nothing to install here: the peers in package.json are the host's, and the
|
||||||
|
# build runs against the repository's own node_modules (see `prepare`).
|
||||||
|
autoInstallPeers: false
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type MatrixClient, type Room, SyncState } from "matrix-js-sdk";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import {
|
||||||
|
MatrixRTCSessionEvent,
|
||||||
|
MembershipManagerEvent,
|
||||||
|
type Transport,
|
||||||
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { BehaviorSubject, EMPTY, Subject } from "rxjs";
|
||||||
|
import { describe, expect, it, type Mock, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type DataMessage,
|
||||||
|
type ParticipationState,
|
||||||
|
type RTCMember,
|
||||||
|
type RTCParticipationOptions,
|
||||||
|
type RTCSlot,
|
||||||
|
type TransportMetadata,
|
||||||
|
} from "./api";
|
||||||
|
import { defaultSessionTimings, MatrixRTCMode } from "./config";
|
||||||
|
import { E2eeType } from "./encryption";
|
||||||
|
import { MatrixRTCError } from "./errors";
|
||||||
|
import {
|
||||||
|
type DataPacket,
|
||||||
|
type MediaBackend,
|
||||||
|
type MediaBackendContext,
|
||||||
|
MediaConnectionState,
|
||||||
|
type MediaKey,
|
||||||
|
} from "./media-backend/api";
|
||||||
|
import { type MemberMedia } from "./media-api";
|
||||||
|
import { constant } from "./reactive/Behavior";
|
||||||
|
import { ObservableScope } from "./reactive/ObservableScope";
|
||||||
|
import { createRTCParticipation } from "./RTCParticipation";
|
||||||
|
import { transportId } from "./matrixrtc/Transports";
|
||||||
|
import { flushPromises, mockEmitter, MockRTCSession } from "./utils/test";
|
||||||
|
|
||||||
|
const transport: Transport = {
|
||||||
|
type: "livekit",
|
||||||
|
livekit_service_url: "https://lk.example.org",
|
||||||
|
};
|
||||||
|
const userId = "@alice:example.org";
|
||||||
|
const deviceId = "AAAA";
|
||||||
|
const legacyMemberId = `${userId}:${deviceId}`;
|
||||||
|
|
||||||
|
function metadata(raw: Transport): TransportMetadata {
|
||||||
|
return {
|
||||||
|
type: raw.type,
|
||||||
|
id: transportId(raw),
|
||||||
|
raw,
|
||||||
|
resolved$: constant(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function member(
|
||||||
|
overrides: Partial<RTCMember> & Pick<RTCMember, "userId" | "memberId">,
|
||||||
|
): RTCMember {
|
||||||
|
return {
|
||||||
|
local: false,
|
||||||
|
rtcBackendIdentity: overrides.memberId,
|
||||||
|
backendIdentities: [overrides.memberId],
|
||||||
|
deviceId: "DEV",
|
||||||
|
transport$: constant(metadata(transport)),
|
||||||
|
applicationData$: constant({}),
|
||||||
|
displayName$: constant(overrides.userId),
|
||||||
|
avatarUrl$: constant(undefined),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const bob = member({ userId: "@bob:example.org", memberId: "bob-member" });
|
||||||
|
const own = member({ userId, deviceId, memberId: legacyMemberId, local: true });
|
||||||
|
|
||||||
|
/** A backend the test drives: it prepares when told, and reports what the participation asked of it. */
|
||||||
|
interface FakeBackend {
|
||||||
|
backend: MediaBackend;
|
||||||
|
context: MediaBackendContext;
|
||||||
|
prepared: PromiseWithResolvers<{ canDelegateDelayedLeave: boolean }>;
|
||||||
|
connectionState$: BehaviorSubject<MediaConnectionState | Error>;
|
||||||
|
data$: Subject<DataPacket>;
|
||||||
|
setPublishing: Mock<(publish: boolean) => void>;
|
||||||
|
delegateDelayedLeave: Mock<(delayId: string) => Promise<void>>;
|
||||||
|
mediaFor: Mock<(scope: ObservableScope, member: RTCMember) => MemberMedia>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(
|
||||||
|
options: Partial<RTCParticipationOptions> = {},
|
||||||
|
slotOptions: { matrixRTCMode?: MatrixRTCMode } = {},
|
||||||
|
): {
|
||||||
|
scope: ObservableScope;
|
||||||
|
participation: ReturnType<typeof createRTCParticipation>["participation"];
|
||||||
|
session: MockRTCSession;
|
||||||
|
mockedSession: ReturnType<MockRTCSession["asMockedSession"]>;
|
||||||
|
join: ReturnType<typeof vi.spyOn>;
|
||||||
|
members$: BehaviorSubject<RTCMember[]>;
|
||||||
|
fake: FakeBackend;
|
||||||
|
states: ParticipationState[];
|
||||||
|
} {
|
||||||
|
const scope = new ObservableScope();
|
||||||
|
const room = { roomId: "!room:example.org" } as Room;
|
||||||
|
const session = new MockRTCSession(room);
|
||||||
|
const mockedSession = session.asMockedSession();
|
||||||
|
const join = vi.spyOn(session, "join");
|
||||||
|
const client = {
|
||||||
|
...mockEmitter(),
|
||||||
|
getUserId: () => userId,
|
||||||
|
getDeviceId: () => deviceId,
|
||||||
|
getDomain: () => "example.org",
|
||||||
|
getSyncState: () => SyncState.Syncing,
|
||||||
|
_unstable_getRTCTransports: vi.fn().mockResolvedValue([transport]),
|
||||||
|
} as unknown as MatrixClient;
|
||||||
|
const members$ = new BehaviorSubject<RTCMember[]>([]);
|
||||||
|
|
||||||
|
const fake: Omit<FakeBackend, "backend" | "context"> &
|
||||||
|
Partial<Pick<FakeBackend, "backend" | "context">> = {
|
||||||
|
prepared: Promise.withResolvers(),
|
||||||
|
connectionState$: new BehaviorSubject<MediaConnectionState | Error>(
|
||||||
|
MediaConnectionState.Initialized,
|
||||||
|
),
|
||||||
|
data$: new Subject<DataPacket>(),
|
||||||
|
setPublishing: vi.fn(),
|
||||||
|
delegateDelayedLeave: vi.fn(async () => Promise.resolve()),
|
||||||
|
mediaFor: vi.fn(() => ({
|
||||||
|
tracks$: constant(null),
|
||||||
|
encryptionError$: EMPTY,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const backendFactory = (
|
||||||
|
backendScope: ObservableScope,
|
||||||
|
context: MediaBackendContext,
|
||||||
|
): MediaBackend => {
|
||||||
|
const backend: MediaBackend = {
|
||||||
|
transportType: "livekit",
|
||||||
|
prepareLocalTransport: async () => fake.prepared.promise,
|
||||||
|
delegateDelayedLeave: fake.delegateDelayedLeave,
|
||||||
|
local: {
|
||||||
|
connectionState$: backendScope.behavior(fake.connectionState$),
|
||||||
|
setPublishing: fake.setPublishing,
|
||||||
|
publishError$: constant(null),
|
||||||
|
publish: vi.fn(),
|
||||||
|
unpublish: vi.fn(),
|
||||||
|
tracks$: constant(null),
|
||||||
|
encryptionError$: EMPTY,
|
||||||
|
},
|
||||||
|
mediaFor: fake.mediaFor,
|
||||||
|
connections$: constant([]),
|
||||||
|
setAudioOutputDeviceId: vi.fn(),
|
||||||
|
sendData: vi.fn(),
|
||||||
|
data$: fake.data$,
|
||||||
|
};
|
||||||
|
fake.context = context;
|
||||||
|
fake.backend = backend;
|
||||||
|
return backend;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { participation } = createRTCParticipation(
|
||||||
|
scope,
|
||||||
|
{
|
||||||
|
client,
|
||||||
|
room,
|
||||||
|
slot: {} as RTCSlot,
|
||||||
|
jsSdkSession: mockedSession,
|
||||||
|
members$: scope.behavior(members$),
|
||||||
|
transports: { get: metadata, connected$: constant([]) },
|
||||||
|
slotOptions: {
|
||||||
|
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||||
|
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||||
|
...slotOptions,
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
},
|
||||||
|
{ publish: [], backend: backendFactory, ...options },
|
||||||
|
);
|
||||||
|
const states: ParticipationState[] = [];
|
||||||
|
participation.state$.subscribe((state) => states.push(state));
|
||||||
|
return {
|
||||||
|
scope,
|
||||||
|
participation,
|
||||||
|
session,
|
||||||
|
mockedSession,
|
||||||
|
join,
|
||||||
|
members$,
|
||||||
|
fake: fake as FakeBackend,
|
||||||
|
states,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createRTCParticipation", () => {
|
||||||
|
it("joins the session once the transport is prepared, and is connected once the media is", async () => {
|
||||||
|
const { participation, join, fake, states } = setup();
|
||||||
|
expect(states).toEqual([{ kind: "waitingForTransport" }]);
|
||||||
|
await flushPromises();
|
||||||
|
expect(join).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fake.prepared.resolve({ canDelegateDelayedLeave: false });
|
||||||
|
await flushPromises();
|
||||||
|
expect(join).toHaveBeenCalledWith(
|
||||||
|
{ userId, deviceId, memberId: legacyMemberId },
|
||||||
|
[transport],
|
||||||
|
expect.objectContaining({
|
||||||
|
manageMediaKeys: true,
|
||||||
|
unstableSendStickyEvents: false,
|
||||||
|
delayedLeaveEventDelayMs: defaultSessionTimings.delayedLeave.delay_ms,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(participation.state$.value).toEqual({ kind: "connecting" });
|
||||||
|
|
||||||
|
fake.connectionState$.next(MediaConnectionState.Connected);
|
||||||
|
expect(participation.state$.value).toEqual({ kind: "connected" });
|
||||||
|
|
||||||
|
fake.connectionState$.next(MediaConnectionState.Reconnecting);
|
||||||
|
expect(participation.state$.value).toEqual({
|
||||||
|
kind: "reconnecting",
|
||||||
|
reason: "media",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes only while joined", async () => {
|
||||||
|
const { participation, session, fake, scope } = setup();
|
||||||
|
const ended = vi.fn();
|
||||||
|
scope.onEnd(ended);
|
||||||
|
await flushPromises();
|
||||||
|
expect(fake.setPublishing).toHaveBeenLastCalledWith(false);
|
||||||
|
|
||||||
|
fake.prepared.resolve({ canDelegateDelayedLeave: false });
|
||||||
|
await flushPromises();
|
||||||
|
expect(fake.setPublishing).toHaveBeenLastCalledWith(true);
|
||||||
|
|
||||||
|
participation.leave();
|
||||||
|
expect(fake.setPublishing).toHaveBeenLastCalledWith(false);
|
||||||
|
expect(participation.state$.value).toEqual({ kind: "left" });
|
||||||
|
await flushPromises();
|
||||||
|
expect(session.leave).toHaveBeenCalled();
|
||||||
|
expect(ended).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails where the transport cannot be prepared", async () => {
|
||||||
|
const { participation, join, fake } = setup();
|
||||||
|
const error = new MatrixRTCError("no token");
|
||||||
|
|
||||||
|
fake.prepared.reject(error);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(participation.state$.value).toEqual({ kind: "failed", error });
|
||||||
|
expect(join).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails where the media connection fails, and stays failed", async () => {
|
||||||
|
const { participation, fake } = setup();
|
||||||
|
fake.prepared.resolve({ canDelegateDelayedLeave: false });
|
||||||
|
await flushPromises();
|
||||||
|
const error = new MatrixRTCError("SFU gone");
|
||||||
|
|
||||||
|
fake.connectionState$.next(error);
|
||||||
|
expect(participation.state$.value).toEqual({ kind: "failed", error });
|
||||||
|
|
||||||
|
fake.connectionState$.next(MediaConnectionState.Connected);
|
||||||
|
expect(participation.state$.value).toEqual({ kind: "failed", error });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins with the delegated timings where the backend delegates, and hands it each delay id", async () => {
|
||||||
|
const { session, join, fake } = setup({
|
||||||
|
timings: { delegatedDelayedLeave: { delay_ms: 7_200_000 } },
|
||||||
|
});
|
||||||
|
fake.prepared.resolve({ canDelegateDelayedLeave: true });
|
||||||
|
await flushPromises();
|
||||||
|
expect(join).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
[transport],
|
||||||
|
expect.objectContaining({ delayedLeaveEventDelayMs: 7_200_000 }),
|
||||||
|
);
|
||||||
|
expect(fake.delegateDelayedLeave).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Re-emitted by the js-sdk session with the membership manager appended
|
||||||
|
(
|
||||||
|
session as unknown as { emit(event: string, ...args: unknown[]): void }
|
||||||
|
).emit(MembershipManagerEvent.DelayIdChanged, "delay-1", {});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(fake.delegateDelayedLeave).toHaveBeenCalledWith("delay-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the membership `${userId}:${deviceId}` in compatibility mode and by a uuid in Matrix 2.0", () => {
|
||||||
|
const compatibility = setup();
|
||||||
|
expect(compatibility.fake.context.ownMembershipIdentity).toEqual({
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
memberId: legacyMemberId,
|
||||||
|
});
|
||||||
|
compatibility.scope.end();
|
||||||
|
|
||||||
|
const matrix2 = setup({}, { matrixRTCMode: MatrixRTCMode.Matrix_2_0 });
|
||||||
|
const { memberId } = matrix2.fake.context.ownMembershipIdentity;
|
||||||
|
expect(memberId).not.toBe(legacyMemberId);
|
||||||
|
expect(memberId).toMatch(/^[0-9a-f-]{36}$/);
|
||||||
|
matrix2.scope.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the slot and the join options on to the backend", () => {
|
||||||
|
const { fake, scope } = setup({
|
||||||
|
publish: [{ source: "camera" }],
|
||||||
|
timings: { networkErrorRetryMs: 250 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.context).toMatchObject({
|
||||||
|
roomId: "!room:example.org",
|
||||||
|
slotId: "m.call#ROOM",
|
||||||
|
publish: [{ source: "camera" }],
|
||||||
|
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||||
|
timings: { ...defaultSessionTimings, networkErrorRetryMs: 250 },
|
||||||
|
});
|
||||||
|
scope.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the backend the keys the session holds and the ones that follow", () => {
|
||||||
|
const { fake, session, mockedSession, scope } = setup();
|
||||||
|
const keys: MediaKey[] = [];
|
||||||
|
fake.context.mediaKeys$.subscribe((key) => keys.push(key));
|
||||||
|
expect(mockedSession.reemitEncryptionKeys).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
const key = new Uint8Array([1, 2, 3]);
|
||||||
|
session.emit(
|
||||||
|
MatrixRTCSessionEvent.EncryptionKeyChanged,
|
||||||
|
key,
|
||||||
|
2,
|
||||||
|
{ userId: "@bob:example.org", deviceId: "DEV", memberId: "bob-member" },
|
||||||
|
"bob-member",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(keys).toEqual([{ participantId: "bob-member", index: 2, key }]);
|
||||||
|
scope.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds the remote members on the backend's media and the local member on our own membership", () => {
|
||||||
|
const { participation, members$, fake, scope } = setup();
|
||||||
|
const stale = member({
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
memberId: "old-uuid",
|
||||||
|
local: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
members$.next([own, bob, stale]);
|
||||||
|
|
||||||
|
expect(fake.mediaFor).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fake.mediaFor).toHaveBeenCalledWith(expect.anything(), bob);
|
||||||
|
expect(participation.remoteMembers$.value.map((m) => m.memberId)).toEqual([
|
||||||
|
"bob-member",
|
||||||
|
]);
|
||||||
|
expect(participation.localMember$.value).toMatchObject({
|
||||||
|
local: true,
|
||||||
|
memberId: legacyMemberId,
|
||||||
|
});
|
||||||
|
scope.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delivers data from a member with its member and drops the rest", () => {
|
||||||
|
const { participation, members$, fake, scope } = setup();
|
||||||
|
members$.next([
|
||||||
|
member({
|
||||||
|
userId: "@bob:example.org",
|
||||||
|
memberId: "bob-member",
|
||||||
|
backendIdentities: ["bob-hashed", "bob-member"],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const messages: DataMessage[] = [];
|
||||||
|
participation.data$.subscribe((message) => messages.push(message));
|
||||||
|
|
||||||
|
fake.data$.next({ senderId: "bob-hashed", topic: "chat", text: "hi" });
|
||||||
|
fake.data$.next({ senderId: "stranger", topic: "chat", text: "boo" });
|
||||||
|
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
expect(messages[0]).toMatchObject({
|
||||||
|
topic: "chat",
|
||||||
|
text: "hi",
|
||||||
|
member: { memberId: "bob-member" },
|
||||||
|
});
|
||||||
|
scope.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves at most once", async () => {
|
||||||
|
const { participation, session, fake } = setup();
|
||||||
|
fake.prepared.resolve({ canDelegateDelayedLeave: false });
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
participation.leave();
|
||||||
|
participation.leave();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(session.leave).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type MatrixClient, type Room } from "matrix-js-sdk";
|
||||||
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import {
|
||||||
|
type MatrixRTCSession as JsSdkRTCSession,
|
||||||
|
MatrixRTCSessionEvent,
|
||||||
|
MembershipManagerEvent,
|
||||||
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
|
||||||
|
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||||
|
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import {
|
||||||
|
combineLatest,
|
||||||
|
distinctUntilChanged,
|
||||||
|
filter,
|
||||||
|
from,
|
||||||
|
fromEvent,
|
||||||
|
map,
|
||||||
|
Observable,
|
||||||
|
scan,
|
||||||
|
} from "rxjs";
|
||||||
|
|
||||||
|
import { defaultSessionTimings, MatrixRTCMode } from "./config";
|
||||||
|
import { E2eeType } from "./encryption";
|
||||||
|
import { type Behavior } from "./reactive/Behavior";
|
||||||
|
import { type ObservableScope } from "./reactive/ObservableScope";
|
||||||
|
import { generateItems } from "./reactive/observable";
|
||||||
|
import { mapScoped } from "./utils/mapScoped";
|
||||||
|
import {
|
||||||
|
type DataMessage,
|
||||||
|
type LocalRTCMember,
|
||||||
|
type ParticipationState,
|
||||||
|
type RemoteRTCMember,
|
||||||
|
type RTCMember,
|
||||||
|
type RTCParticipation,
|
||||||
|
type RTCParticipationOptions,
|
||||||
|
type RTCSlot,
|
||||||
|
type RTCSlotOptions,
|
||||||
|
} from "./api";
|
||||||
|
import {
|
||||||
|
type MediaBackend,
|
||||||
|
type MediaBackendContext,
|
||||||
|
type MediaKey,
|
||||||
|
} from "./media-backend/api";
|
||||||
|
import { createLivekitBackend } from "./media-backend/livekit/LivekitBackend";
|
||||||
|
import { createHomeserverConnected$ } from "./matrixrtc/HomeserverConnected";
|
||||||
|
import { joinJsSdkSession } from "./matrixrtc/joinJsSdkSession";
|
||||||
|
import {
|
||||||
|
createLocalMembership$,
|
||||||
|
type PreparedTransport,
|
||||||
|
} from "./matrixrtc/LocalMembership";
|
||||||
|
import { discoverLocalTransport } from "./matrixrtc/LocalTransport";
|
||||||
|
import {
|
||||||
|
createLocalRTCMember,
|
||||||
|
createRemoteRTCMember,
|
||||||
|
} from "./matrixrtc/Members";
|
||||||
|
import { createKeyRotationSuppressed$ } from "./matrixrtc/JsRtcSessionBehaviors";
|
||||||
|
import { type TransportRegistry } from "./matrixrtc/Transports";
|
||||||
|
import { participationState } from "./participationState";
|
||||||
|
|
||||||
|
/** What the slot lends a participation. */
|
||||||
|
export interface ParticipationDeps {
|
||||||
|
client: MatrixClient;
|
||||||
|
room: Room;
|
||||||
|
slot: RTCSlot;
|
||||||
|
jsSdkSession: JsSdkRTCSession;
|
||||||
|
members$: Behavior<RTCMember[]>;
|
||||||
|
transports: TransportRegistry;
|
||||||
|
slotOptions: Pick<RTCSlotOptions, "matrixRTCMode" | "encryptionSystem">;
|
||||||
|
logger: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The participation, plus what the slot reads from it without exposing it. */
|
||||||
|
export interface ParticipationInternals {
|
||||||
|
participation: RTCParticipation;
|
||||||
|
connections$: MediaBackend["connections$"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Our span in a slot: the backend, the local transport, the join state
|
||||||
|
* machine and the members with the media it carries for them. Starts joining
|
||||||
|
* at once and ends with `leave()`, which also ends `scope`.
|
||||||
|
*/
|
||||||
|
export function createRTCParticipation(
|
||||||
|
scope: ObservableScope,
|
||||||
|
{
|
||||||
|
client,
|
||||||
|
room,
|
||||||
|
slot,
|
||||||
|
jsSdkSession,
|
||||||
|
members$,
|
||||||
|
transports,
|
||||||
|
slotOptions: { matrixRTCMode, encryptionSystem },
|
||||||
|
logger: parentLogger,
|
||||||
|
}: ParticipationDeps,
|
||||||
|
options: RTCParticipationOptions,
|
||||||
|
): ParticipationInternals {
|
||||||
|
const logger = parentLogger.getChild("[RTCParticipation]");
|
||||||
|
const userId = client.getUserId()!;
|
||||||
|
const deviceId = client.getDeviceId()!;
|
||||||
|
const timings = { ...defaultSessionTimings, ...options.timings };
|
||||||
|
|
||||||
|
const ownMembershipIdentity: CallMembershipIdentityParts = {
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
// A pre-sticky membership names itself `${userId}:${deviceId}`, and the
|
||||||
|
// key transport stamps this id into every key event, so a uuid there
|
||||||
|
// would name a member no peer can resolve
|
||||||
|
memberId:
|
||||||
|
matrixRTCMode === MatrixRTCMode.Matrix_2_0
|
||||||
|
? uuidv4()
|
||||||
|
: `${userId}:${deviceId}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const backend = createBackend(scope, client, options, {
|
||||||
|
roomId: room.roomId,
|
||||||
|
slotId: jsSdkSession.slotId,
|
||||||
|
ownMembershipIdentity,
|
||||||
|
publish: options.publish,
|
||||||
|
encryptionSystem,
|
||||||
|
mediaKeys$: mediaKeys$(jsSdkSession),
|
||||||
|
timings,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
const preparedTransport$ = from(
|
||||||
|
discoverLocalTransport(client, backend.transportType, options, logger).then(
|
||||||
|
async (transport): Promise<PreparedTransport> => ({
|
||||||
|
transport,
|
||||||
|
...(await backend.prepareLocalTransport(transport)),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const localMembership = createLocalMembership$({
|
||||||
|
scope,
|
||||||
|
delegateDelayedLeave: async (delayId) =>
|
||||||
|
backend.delegateDelayedLeave(delayId),
|
||||||
|
preparedTransport$,
|
||||||
|
homeserverConnected: createHomeserverConnected$(
|
||||||
|
scope,
|
||||||
|
client,
|
||||||
|
jsSdkSession,
|
||||||
|
timings.syncDisconnectGracePeriodMs,
|
||||||
|
),
|
||||||
|
joinMatrixRTC: (transport, delayedLeave) =>
|
||||||
|
joinJsSdkSession(jsSdkSession, ownMembershipIdentity, transport, {
|
||||||
|
encryptMedia: encryptionSystem.kind !== E2eeType.NONE,
|
||||||
|
matrixRTCMode,
|
||||||
|
sendNotificationType: options.sendNotificationType,
|
||||||
|
applicationData: options.applicationData,
|
||||||
|
timings,
|
||||||
|
delayedLeave,
|
||||||
|
}),
|
||||||
|
membershipManagerError$: fromEvent(
|
||||||
|
jsSdkSession,
|
||||||
|
MatrixRTCSessionEvent.MembershipManagerError,
|
||||||
|
),
|
||||||
|
matrixRTCSession: jsSdkSession,
|
||||||
|
delayId$: scope.behavior(
|
||||||
|
(
|
||||||
|
fromEvent(
|
||||||
|
jsSdkSession,
|
||||||
|
MembershipManagerEvent.DelayIdChanged,
|
||||||
|
// The re-emitted event carries the original emitter as the second argument
|
||||||
|
) as Observable<[string | undefined, IMembershipManager]>
|
||||||
|
).pipe(map(([delayId]) => delayId ?? null)),
|
||||||
|
jsSdkSession.delayId ?? null,
|
||||||
|
),
|
||||||
|
timings,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
const remoteMembers$ = scope.behavior<RemoteRTCMember[]>(
|
||||||
|
members$.pipe(
|
||||||
|
generateItems(
|
||||||
|
"RTCParticipation remoteMembers",
|
||||||
|
function* (members) {
|
||||||
|
for (const member of members)
|
||||||
|
if (!member.local) yield { keys: [member] as const, data: member };
|
||||||
|
},
|
||||||
|
(memberScope, member$) =>
|
||||||
|
createRemoteRTCMember(
|
||||||
|
member$.value,
|
||||||
|
backend.mediaFor(memberScope, member$.value),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Matched by the identity we joined with: a stale membership of ours from
|
||||||
|
// an earlier participation is `local` in the slot but is not this one
|
||||||
|
const ownMember$ = scope.behavior(
|
||||||
|
members$.pipe(
|
||||||
|
map(
|
||||||
|
(members) =>
|
||||||
|
members.find(
|
||||||
|
(member) =>
|
||||||
|
member.local &&
|
||||||
|
member.memberId === ownMembershipIdentity.memberId,
|
||||||
|
) ?? null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const localMember$ = scope.behavior<LocalRTCMember | null>(
|
||||||
|
mapScoped(scope, ownMember$, (_memberScope, member) =>
|
||||||
|
createLocalRTCMember(member, backend.local),
|
||||||
|
).pipe(map((member) => member ?? null)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A packet from an identity that is not a member is dropped, so a host only
|
||||||
|
// ever hears from attested members
|
||||||
|
const data$ = backend.data$.pipe(
|
||||||
|
map(({ senderId, topic, text }): DataMessage | null => {
|
||||||
|
const member = remoteMembers$.value.find((m) =>
|
||||||
|
m.backendIdentities.includes(senderId),
|
||||||
|
);
|
||||||
|
if (member === undefined) {
|
||||||
|
logger.warn(`Dropping data from ${senderId}: not a member`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { member, topic, text };
|
||||||
|
}),
|
||||||
|
filter((message) => message !== null),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Nothing leaves this device while it may already have been dropped from
|
||||||
|
// the session: the member would show as away while still being heard
|
||||||
|
localMembership.state$
|
||||||
|
.pipe(
|
||||||
|
map((membership) => membership.kind === "joined"),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
scope.bind(),
|
||||||
|
)
|
||||||
|
.subscribe((publish) => backend.local.setPublishing(publish));
|
||||||
|
|
||||||
|
const state$ = scope.behavior<ParticipationState>(
|
||||||
|
combineLatest([
|
||||||
|
localMembership.state$,
|
||||||
|
backend.local.connectionState$,
|
||||||
|
]).pipe(
|
||||||
|
scan(
|
||||||
|
(previous, [membership, media]) =>
|
||||||
|
participationState(previous, membership, media),
|
||||||
|
{ kind: "waitingForTransport" } as ParticipationState,
|
||||||
|
),
|
||||||
|
distinctUntilChanged(deepCompare),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const leave = (): void => {
|
||||||
|
if (state$.value.kind === "left") return;
|
||||||
|
localMembership.leave();
|
||||||
|
scope.end();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
connections$: backend.connections$,
|
||||||
|
participation: {
|
||||||
|
slot,
|
||||||
|
leave,
|
||||||
|
state$,
|
||||||
|
localMember$,
|
||||||
|
remoteMembers$,
|
||||||
|
publish: async (request) => backend.local.publish(request),
|
||||||
|
unpublish: async (source) => backend.local.unpublish(source),
|
||||||
|
keyRotationSuppressed$: createKeyRotationSuppressed$(scope, jsSdkSession),
|
||||||
|
connectedTransports$: transports.connected$,
|
||||||
|
setAudioOutputDeviceId: async (deviceId) =>
|
||||||
|
backend.setAudioOutputDeviceId(deviceId),
|
||||||
|
sendData: async (topic, text) => backend.sendData(topic, text),
|
||||||
|
data$,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The host's backend, or LiveKit configured from the participation options. */
|
||||||
|
function createBackend(
|
||||||
|
scope: ObservableScope,
|
||||||
|
client: MatrixClient,
|
||||||
|
{ backend, mediaQuality }: RTCParticipationOptions,
|
||||||
|
context: MediaBackendContext,
|
||||||
|
): MediaBackend {
|
||||||
|
if (backend) return backend(scope, context);
|
||||||
|
return createLivekitBackend(scope, context, { client, mediaQuality });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The slot's per-participant keys: the ones it already holds on subscribe, then each new one. */
|
||||||
|
function mediaKeys$(session: JsSdkRTCSession): Observable<MediaKey> {
|
||||||
|
return new Observable((subscriber) => {
|
||||||
|
const onKey = (
|
||||||
|
key: Uint8Array<ArrayBuffer>,
|
||||||
|
index: number,
|
||||||
|
_membership: CallMembershipIdentityParts,
|
||||||
|
participantId: string,
|
||||||
|
): void => subscriber.next({ participantId, index, key });
|
||||||
|
session.on(MatrixRTCSessionEvent.EncryptionKeyChanged, onKey);
|
||||||
|
session.reemitEncryptionKeys();
|
||||||
|
return () => session.off(MatrixRTCSessionEvent.EncryptionKeyChanged, onKey);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { type MatrixClient, type Room } from "matrix-js-sdk";
|
||||||
|
import { EventType } from "matrix-js-sdk/lib/@types/event";
|
||||||
|
import { type MatrixEvent } from "matrix-js-sdk/lib/models/event";
|
||||||
|
import {
|
||||||
|
type RoomState,
|
||||||
|
RoomStateEvent,
|
||||||
|
} from "matrix-js-sdk/lib/models/room-state";
|
||||||
|
|
||||||
|
import { createRTCSlot } from "./RTCSlot";
|
||||||
|
import { type RTCSlotOptions } from "./api";
|
||||||
|
import { MatrixRTCMode } from "./config";
|
||||||
|
import { E2eeType } from "./encryption";
|
||||||
|
import { mockEmitter, MockRTCSession, testScope } from "./utils/test";
|
||||||
|
|
||||||
|
const options: RTCSlotOptions = {
|
||||||
|
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||||
|
encryptionSystem: { kind: E2eeType.NONE },
|
||||||
|
};
|
||||||
|
|
||||||
|
function setup(): {
|
||||||
|
client: MatrixClient;
|
||||||
|
room: Room;
|
||||||
|
session: MockRTCSession;
|
||||||
|
} {
|
||||||
|
const room = {
|
||||||
|
...mockEmitter(),
|
||||||
|
roomId: "!room:example.org",
|
||||||
|
getMembersWithMembership: (): [] => [],
|
||||||
|
} as unknown as Room;
|
||||||
|
const session = new MockRTCSession(room);
|
||||||
|
const client = {
|
||||||
|
getUserId: (): string => "@alice:example.org",
|
||||||
|
getDeviceId: (): string => "AAAA",
|
||||||
|
matrixRTC: { getRoomSession: (): MockRTCSession => session },
|
||||||
|
} as unknown as MatrixClient;
|
||||||
|
return { client, room, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createRTCSlot", () => {
|
||||||
|
describe("status$", () => {
|
||||||
|
it("is undefined while the room has no slot event", () => {
|
||||||
|
const { client, room } = setup();
|
||||||
|
const slot = createRTCSlot(testScope(), client, room, options);
|
||||||
|
expect(slot.status$.value).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows the slot event", () => {
|
||||||
|
const { client, room, session } = setup();
|
||||||
|
session.rtcSlot = { status: "open" };
|
||||||
|
const slot = createRTCSlot(testScope(), client, room, options);
|
||||||
|
expect(slot.status$.value).toBe("open");
|
||||||
|
|
||||||
|
session.rtcSlot = { status: "closed" };
|
||||||
|
room.emit(
|
||||||
|
RoomStateEvent.Events,
|
||||||
|
{ getType: (): string => EventType.RTCSlot } as MatrixEvent,
|
||||||
|
{} as RoomState,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(slot.status$.value).toBe("closed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type MatrixClient, type Room } from "matrix-js-sdk";
|
||||||
|
import { EventType } from "matrix-js-sdk/lib/@types/event";
|
||||||
|
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import {
|
||||||
|
type MatrixRTCSession as JsSdkRTCSession,
|
||||||
|
MatrixRTCSessionManager,
|
||||||
|
type SlotDescription,
|
||||||
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { type MatrixEvent } from "matrix-js-sdk/lib/models/event";
|
||||||
|
import { RoomStateEvent } from "matrix-js-sdk/lib/models/room-state";
|
||||||
|
import {
|
||||||
|
BehaviorSubject,
|
||||||
|
filter,
|
||||||
|
fromEvent,
|
||||||
|
map,
|
||||||
|
of,
|
||||||
|
startWith,
|
||||||
|
switchMap,
|
||||||
|
} from "rxjs";
|
||||||
|
|
||||||
|
import { ObservableScope } from "./reactive/ObservableScope";
|
||||||
|
import { generateItems } from "./reactive/observable";
|
||||||
|
import {
|
||||||
|
type RTCMember,
|
||||||
|
type RTCParticipation,
|
||||||
|
type RTCParticipationOptions,
|
||||||
|
type RTCSlot,
|
||||||
|
type RTCSlotOptions,
|
||||||
|
} from "./api";
|
||||||
|
import { MatrixRTCError } from "./errors";
|
||||||
|
import {
|
||||||
|
createRTCParticipation,
|
||||||
|
type ParticipationInternals,
|
||||||
|
} from "./RTCParticipation";
|
||||||
|
import { createMemberships$ } from "./matrixrtc/JsRtcSessionBehaviors";
|
||||||
|
import {
|
||||||
|
createMatrixMemberMetadata$,
|
||||||
|
createRoomMembers$,
|
||||||
|
} from "./matrixrtc/MatrixMemberMetadata";
|
||||||
|
import {
|
||||||
|
createRTCMember,
|
||||||
|
type MemberContext,
|
||||||
|
membershipKeys,
|
||||||
|
} from "./matrixrtc/Members";
|
||||||
|
import { createTransportRegistry } from "./matrixrtc/Transports";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes the whole `MatrixClient` rather than a slice of it, and finds the
|
||||||
|
* MatrixRTC session itself. The js-sdk is the MatrixRTC implementation today;
|
||||||
|
* when the rust-rtc crate replaces it, the SDK has to bridge the crate to the
|
||||||
|
* js-sdk client, and only the SDK knows what that bridge needs. Holding the
|
||||||
|
* client keeps that change inside the SDK.
|
||||||
|
*/
|
||||||
|
export function createRTCSlot(
|
||||||
|
scope: ObservableScope,
|
||||||
|
client: MatrixClient,
|
||||||
|
room: Room,
|
||||||
|
options: RTCSlotOptions,
|
||||||
|
): RTCSlot {
|
||||||
|
const logger = rootLogger.getChild("[RTCSlot]");
|
||||||
|
const userId = client.getUserId();
|
||||||
|
const deviceId = client.getDeviceId();
|
||||||
|
if (!(userId && deviceId))
|
||||||
|
throw new MatrixRTCError("The client has to be logged in");
|
||||||
|
const { application = "m.call", id = "ROOM" } = options;
|
||||||
|
|
||||||
|
const jsSdkSession = sessionManager(
|
||||||
|
scope,
|
||||||
|
client,
|
||||||
|
{ application, id },
|
||||||
|
logger,
|
||||||
|
).getRoomSession(room);
|
||||||
|
const memberships$ = createMemberships$(scope, jsSdkSession);
|
||||||
|
|
||||||
|
const active$ = new BehaviorSubject<ParticipationInternals | null>(null);
|
||||||
|
const participationScopes = new Set<ObservableScope>();
|
||||||
|
scope.onEnd(() => {
|
||||||
|
for (const participationScope of participationScopes)
|
||||||
|
participationScope.end();
|
||||||
|
});
|
||||||
|
const transports = createTransportRegistry(
|
||||||
|
scope,
|
||||||
|
scope.behavior(
|
||||||
|
active$.pipe(switchMap((active) => active?.connections$ ?? of([]))),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const context: MemberContext = {
|
||||||
|
metadata: createMatrixMemberMetadata$(
|
||||||
|
scope,
|
||||||
|
scope.behavior(memberships$.pipe(map(({ value }) => value))),
|
||||||
|
createRoomMembers$(scope, room),
|
||||||
|
),
|
||||||
|
transports,
|
||||||
|
own: { userId, deviceId },
|
||||||
|
};
|
||||||
|
|
||||||
|
const members$ = scope.behavior<RTCMember[]>(
|
||||||
|
memberships$.pipe(
|
||||||
|
map(({ value }) => value),
|
||||||
|
generateItems(
|
||||||
|
"RTCSlot members",
|
||||||
|
function* (memberships) {
|
||||||
|
for (const membership of memberships)
|
||||||
|
yield { keys: membershipKeys(membership), data: membership };
|
||||||
|
},
|
||||||
|
(memberScope, membership$) =>
|
||||||
|
createRTCMember(memberScope, membership$, context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The status is undefined while the room has no slot event, and an explicit
|
||||||
|
// undefined is not an initial value to `behavior`, so it goes in the stream
|
||||||
|
const status$ = scope.behavior(
|
||||||
|
fromEvent(room, RoomStateEvent.Events, (event: MatrixEvent) => event).pipe(
|
||||||
|
filter((event) => event.getType() === EventType.RTCSlot),
|
||||||
|
map(() => slotStatus(jsSdkSession)),
|
||||||
|
startWith(slotStatus(jsSdkSession)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const slot: RTCSlot = {
|
||||||
|
roomId: room.roomId,
|
||||||
|
application,
|
||||||
|
id,
|
||||||
|
status$,
|
||||||
|
members$,
|
||||||
|
participation$: scope.behavior(
|
||||||
|
active$.pipe(map((active) => active?.participation ?? null)),
|
||||||
|
),
|
||||||
|
join: (participationOptions: RTCParticipationOptions): RTCParticipation => {
|
||||||
|
if (active$.value !== null)
|
||||||
|
throw new MatrixRTCError(
|
||||||
|
"Already in this slot: leave the participation before joining again",
|
||||||
|
);
|
||||||
|
const participationScope = new ObservableScope();
|
||||||
|
participationScopes.add(participationScope);
|
||||||
|
const internals = createRTCParticipation(
|
||||||
|
participationScope,
|
||||||
|
{
|
||||||
|
client,
|
||||||
|
room,
|
||||||
|
slot,
|
||||||
|
jsSdkSession,
|
||||||
|
members$,
|
||||||
|
transports,
|
||||||
|
slotOptions: options,
|
||||||
|
logger,
|
||||||
|
},
|
||||||
|
participationOptions,
|
||||||
|
);
|
||||||
|
participationScope.onEnd(() => {
|
||||||
|
participationScopes.delete(participationScope);
|
||||||
|
if (active$.value === internals) active$.next(null);
|
||||||
|
});
|
||||||
|
active$.next(internals);
|
||||||
|
return internals.participation;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotStatus(
|
||||||
|
session: Pick<JsSdkRTCSession, "getRtcSlot">,
|
||||||
|
): "open" | "closed" | undefined {
|
||||||
|
return session.getRtcSlot()?.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The js-sdk keeps one session manager per slot. The client comes with the
|
||||||
|
* manager for the default slot; any other slot gets a manager of its own,
|
||||||
|
* which lives as long as the scope.
|
||||||
|
*/
|
||||||
|
function sessionManager(
|
||||||
|
scope: ObservableScope,
|
||||||
|
client: MatrixClient,
|
||||||
|
{ application, id }: SlotDescription,
|
||||||
|
logger: Logger,
|
||||||
|
): MatrixRTCSessionManager {
|
||||||
|
if (application === "m.call" && id === "ROOM") return client.matrixRTC;
|
||||||
|
const manager = new MatrixRTCSessionManager(logger, client, {
|
||||||
|
application,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
manager.start();
|
||||||
|
scope.onEnd(() => manager.stop());
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
+300
@@ -0,0 +1,300 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public types of the SDK: the slot, the participation, their members and
|
||||||
|
* transports. What a member sends is in `media-api.ts`. `createRTCSlot` in
|
||||||
|
* `RTCSlot.ts` is the only way to obtain an implementation of them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type RTCNotificationType,
|
||||||
|
type Transport,
|
||||||
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { type Observable } from "rxjs";
|
||||||
|
|
||||||
|
import { type Behavior } from "./reactive/Behavior";
|
||||||
|
import {
|
||||||
|
type LocalAudioMediaTrack,
|
||||||
|
type LocalMemberMedia,
|
||||||
|
type LocalVideoMediaTrack,
|
||||||
|
type MediaSource,
|
||||||
|
type MemberMedia,
|
||||||
|
type PublishRequest,
|
||||||
|
} from "./media-api";
|
||||||
|
import { type MediaBackendFactory } from "./media-backend/api";
|
||||||
|
import { type EncryptionSystem } from "./encryption";
|
||||||
|
import {
|
||||||
|
type MatrixRTCMode,
|
||||||
|
type MediaQuality,
|
||||||
|
type SessionTimings,
|
||||||
|
} from "./config";
|
||||||
|
import { type MatrixRTCError } from "./errors";
|
||||||
|
|
||||||
|
export { MatrixRTCError } from "./errors";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Slot
|
||||||
|
|
||||||
|
export interface RTCSlotOptions {
|
||||||
|
/** The application the slot belongs to, as named in the membership. Default `m.call`. */
|
||||||
|
application?: string;
|
||||||
|
/** The application's slot in the room. Default `ROOM`. */
|
||||||
|
id?: string;
|
||||||
|
/** How memberships and keys in this slot are written. Resolved by the host. */
|
||||||
|
matrixRTCMode: MatrixRTCMode;
|
||||||
|
encryptionSystem: EncryptionSystem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The namespace in a room that memberships join into. It exists while the
|
||||||
|
* room does, with nobody in it too. The room has to be the one the client's
|
||||||
|
* sync loop maintains (`client.getRoom(roomId)`, once the join has synced):
|
||||||
|
* the detached copy `joinRoom` returns for a room joined just now never
|
||||||
|
* receives the state the members are read from.
|
||||||
|
*/
|
||||||
|
export interface RTCSlot {
|
||||||
|
readonly roomId: string;
|
||||||
|
readonly application: string;
|
||||||
|
readonly id: string;
|
||||||
|
/** From the slot state event; undefined while the room has none. */
|
||||||
|
status$: Behavior<"open" | "closed" | undefined>;
|
||||||
|
/**
|
||||||
|
* Everyone in the slot, one object per membership, kept for as long as the
|
||||||
|
* membership is. Our own membership is in here too, with `local: true`,
|
||||||
|
* once the room has seen it. No media: that is the participation's.
|
||||||
|
*/
|
||||||
|
members$: Behavior<RTCMember[]>;
|
||||||
|
/** The participation we are in, null between `leave()` and the next `join()`. */
|
||||||
|
participation$: Behavior<RTCParticipation | null>;
|
||||||
|
/**
|
||||||
|
* Joins the slot and returns the participation that lasts until its
|
||||||
|
* `leave()`. Throws while `participation$` is not null: a slot has one local
|
||||||
|
* member.
|
||||||
|
*/
|
||||||
|
join(options: RTCParticipationOptions): RTCParticipation; // Maybe MediaSession,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Participation
|
||||||
|
|
||||||
|
export interface RTCParticipationOptions {
|
||||||
|
/**
|
||||||
|
* Published at the join; `publish` on the participation adds to it from then
|
||||||
|
* on. Kept as an option, rather than only the method, because the LiveKit
|
||||||
|
* backend reads the initial requests to build the room's default capture
|
||||||
|
* options before the first connection exists.
|
||||||
|
*/
|
||||||
|
publish: PublishRequest[];
|
||||||
|
/**
|
||||||
|
* LEGACY
|
||||||
|
*
|
||||||
|
* Conside already removing it. just skips code in js-sdk. But rust sdk will not have this feature.
|
||||||
|
* MSC4075 notification sent with the join. A parameter of the MatrixRTC
|
||||||
|
* join itself, so it is here even though it is named after calls; reacting
|
||||||
|
* to a notification (ringing, timeouts, declines) is the application's job.
|
||||||
|
*/
|
||||||
|
sendNotificationType?: RTCNotificationType;
|
||||||
|
/**
|
||||||
|
* Whatever the application wants to say about itself in the membership,
|
||||||
|
* under namespaced keys. Opaque to the SDK, apart from `m.call.intent`
|
||||||
|
* (`"audio"` or `"video"`), which the membership format carries as a
|
||||||
|
* field of its own.
|
||||||
|
*/
|
||||||
|
applicationData?: Record<string, unknown>;
|
||||||
|
/** Timings the host has configured; the defaults otherwise. */
|
||||||
|
timings?: Partial<SessionTimings>;
|
||||||
|
/** Limits on what is published; LiveKit's defaults otherwise. */
|
||||||
|
mediaQuality?: MediaQuality;
|
||||||
|
/**
|
||||||
|
* Use this transport instead of asking the homeserver. Any shape the
|
||||||
|
* backend serves: for LiveKit a `url`, a `livekit_service_url` or both.
|
||||||
|
*/
|
||||||
|
transport?: Transport;
|
||||||
|
/** Use this transport when the homeserver advertises none of the backend's type. */
|
||||||
|
fallbackTransport?: Transport;
|
||||||
|
/**
|
||||||
|
* What carries the media. LiveKit, configured from the options above, when
|
||||||
|
* left out; see `createLivekitBackend` for building one with other options.
|
||||||
|
*/
|
||||||
|
backend?: MediaBackendFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The homeserver link that is down, in the order they depend on each other:
|
||||||
|
* without sync the membership is unknowable, and without a confirmed
|
||||||
|
* membership the delayed leave cannot be refreshed.
|
||||||
|
*/
|
||||||
|
export type MatrixDisconnectReason = "sync" | "membership" | "probablyLeft";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why the participation is not connected: the first failing link, in the
|
||||||
|
* order they depend on each other. The media connection comes last because
|
||||||
|
* it is worth little while the Matrix side is down.
|
||||||
|
*/
|
||||||
|
export type DisconnectReason = MatrixDisconnectReason | "media";
|
||||||
|
|
||||||
|
export type ParticipationState =
|
||||||
|
/** Asking the homeserver for a transport, or preparing it. */
|
||||||
|
| { kind: "waitingForTransport" }
|
||||||
|
/** Transport known; joining the session and connecting the media for the first time. */
|
||||||
|
| { kind: "connecting" }
|
||||||
|
/** Membership joined and the local media connection up. */
|
||||||
|
| { kind: "connected" }
|
||||||
|
/** Was connected and one link is down. Back to `connected` or on to `failed`. */
|
||||||
|
| { kind: "reconnecting"; reason: DisconnectReason }
|
||||||
|
/**
|
||||||
|
* The membership or the media connection ended with an error. Terminal
|
||||||
|
* unless `left`. A failed publication is not fatal: the member can still
|
||||||
|
* receive, so it stays on the local member's media.
|
||||||
|
*/
|
||||||
|
| { kind: "failed"; error: MatrixRTCError }
|
||||||
|
/** `leave()` was called. Terminal. */
|
||||||
|
| { kind: "left" };
|
||||||
|
|
||||||
|
/** The span between our `join()` of a slot and our `leave()`. */
|
||||||
|
export interface RTCParticipation {
|
||||||
|
readonly slot: RTCSlot;
|
||||||
|
/** Sends the leave, tears the connections down and ends the participation. */
|
||||||
|
leave(): void;
|
||||||
|
state$: Behavior<ParticipationState>;
|
||||||
|
|
||||||
|
/** Null until our own membership has been seen in the room. */
|
||||||
|
localMember$: Behavior<LocalRTCMember | null>;
|
||||||
|
/**
|
||||||
|
* The slot's other members, each with the media this participation carries
|
||||||
|
* for it. A call view needs nothing but this and `localMember$` to render.
|
||||||
|
*/
|
||||||
|
remoteMembers$: Behavior<RemoteRTCMember[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes a source and resolves with its track, which also shows up in
|
||||||
|
* `localMember$.tracks$`. Works from the moment `join()` returns: before the
|
||||||
|
* transport is connected.
|
||||||
|
*/
|
||||||
|
publish(
|
||||||
|
request: PublishRequest,
|
||||||
|
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
|
||||||
|
/** Removes one of our publications; a screen share takes its audio with it. */
|
||||||
|
unpublish(source: MediaSource): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the slot has grown large enough that MatrixRTC has stopped
|
||||||
|
* rotating the media encryption key.
|
||||||
|
*/
|
||||||
|
keyRotationSuppressed$: Behavior<boolean>;
|
||||||
|
|
||||||
|
/** Transports the participation currently holds a live connection to. */
|
||||||
|
connectedTransports$: Behavior<TransportMetadata[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays the members' audio on this device from now on. Rejects where the
|
||||||
|
* browser cannot switch to it. A host that routes audio itself, or leaves
|
||||||
|
* the browser's choice, never calls it.
|
||||||
|
*/
|
||||||
|
setAudioOutputDeviceId(deviceId: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a short text to every member on the local transport, over a
|
||||||
|
* reliable data channel beside the media. A packet holds about 15 KiB;
|
||||||
|
* anything larger belongs in a room event. Rejects while the local
|
||||||
|
* transport is not connected. Encrypted on the wire like the media, but
|
||||||
|
* not end to end with the media key.
|
||||||
|
*/
|
||||||
|
sendData(topic: string, text: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* What remote members sent with `sendData`, on every transport the
|
||||||
|
* participation is connected to. A message from an identity that is not a
|
||||||
|
* member is dropped, so a host only ever hears from attested members.
|
||||||
|
*/
|
||||||
|
data$: Observable<DataMessage>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One `sendData` call as it arrives at a remote member. */
|
||||||
|
export interface DataMessage {
|
||||||
|
member: RemoteRTCMember;
|
||||||
|
topic: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Transports
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One transport advertised in a membership. Transport independent: `type` and
|
||||||
|
* `id` are all the SDK needs; `raw` and `resolved$` are there for a
|
||||||
|
* backend-specific developer panel and for connection diagnostics.
|
||||||
|
*/
|
||||||
|
export interface TransportMetadata {
|
||||||
|
/** `"livekit"` today. */
|
||||||
|
type: string;
|
||||||
|
/** Stable key, unique per transport in the slot: the raw transport, serialised with sorted keys. */
|
||||||
|
id: string;
|
||||||
|
/** The transport object as it appears in the membership. */
|
||||||
|
raw: Transport;
|
||||||
|
/**
|
||||||
|
* What the backend had to fetch before it could connect. Undefined until the
|
||||||
|
* connection has resolved it, and again after the connection stops.
|
||||||
|
*/
|
||||||
|
resolved$: Behavior<ResolvedTransport | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResolvedTransport =
|
||||||
|
| {
|
||||||
|
type: "livekit";
|
||||||
|
/** The SFU websocket url, as opposed to the JWT service url in `raw`. */
|
||||||
|
url: string;
|
||||||
|
/** A secret: fit for a developer panel, never for a log line. */
|
||||||
|
token: string;
|
||||||
|
roomAlias: string;
|
||||||
|
identity: string;
|
||||||
|
}
|
||||||
|
| { type: string; [key: string]: unknown };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Members
|
||||||
|
|
||||||
|
/** A membership in the slot, with what the SDK derives from it. No media. */
|
||||||
|
export interface RTCMember {
|
||||||
|
local: boolean;
|
||||||
|
/**
|
||||||
|
* The identity this member is known by on its transport for matching
|
||||||
|
* purposes, read off the membership: `${userId}:${deviceId}`. A stable key
|
||||||
|
* per membership and a debug label for a host; the SDK matches media
|
||||||
|
* against `backendIdentities`, not this.
|
||||||
|
*/
|
||||||
|
rtcBackendIdentity: string;
|
||||||
|
/**
|
||||||
|
* Every identity this member may publish under on its transport: the
|
||||||
|
* participant identity the SFU sees, and the sender of its data packets.
|
||||||
|
* One per authentication flow the member's transport allows, so a
|
||||||
|
* transport with both a `url` and a `livekit_service_url` lists the hashed
|
||||||
|
* and the legacy identity, and the backend matches whichever shows up.
|
||||||
|
* Opaque to a host.
|
||||||
|
*/
|
||||||
|
backendIdentities: readonly string[];
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
/** The membership's own id; what the Matrix 2.0 identity is derived from. */
|
||||||
|
memberId: string;
|
||||||
|
/** Which transport this member is on; undefined when the membership has none. */
|
||||||
|
transport$: Behavior<TransportMetadata | undefined>;
|
||||||
|
/** What the member's application says about itself, e.g. `m.call.intent`. */
|
||||||
|
applicationData$: Behavior<Record<string, unknown>>;
|
||||||
|
|
||||||
|
displayName$: Behavior<string>;
|
||||||
|
avatarUrl$: Behavior<string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteRTCMember extends RTCMember, MemberMedia {
|
||||||
|
local: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Our member: the same as a remote one, with tracks that carry the controls. */
|
||||||
|
export interface LocalRTCMember extends RTCMember, LocalMemberMedia {
|
||||||
|
local: true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MatrixRTC mode determines how the client interacts with the MatrixRTC
|
||||||
|
* backend and other participants. Resolved by the host.
|
||||||
|
*/
|
||||||
|
export enum MatrixRTCMode {
|
||||||
|
/** Multi-SFU transport, membership state events. */
|
||||||
|
Compatibility = "compatibility",
|
||||||
|
/**
|
||||||
|
* Multi-SFU transport with sticky membership events and a uuid member id.
|
||||||
|
* The mode decides how the membership is written, not how the media
|
||||||
|
* transport is authenticated with: that follows the transport's shape and
|
||||||
|
* our role on it.
|
||||||
|
*/
|
||||||
|
Matrix_2_0 = "matrix_2_0",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DelayedLeaveTimings {
|
||||||
|
/** How long (in milliseconds) the homeserver waits before sending the delayed leave. */
|
||||||
|
delay_ms: number;
|
||||||
|
/** How often (in milliseconds) the client restarts the delayed leave. */
|
||||||
|
restart_ms?: number;
|
||||||
|
/** How long (in milliseconds) a restart may take before it counts as failed. */
|
||||||
|
restart_timeout_ms?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The session's timings. A host that has them in its configuration passes
|
||||||
|
* them in; these are the defaults a MatrixRTC backend is expected to accept.
|
||||||
|
*/
|
||||||
|
export interface SessionTimings {
|
||||||
|
/** How long the sync may be down before the client counts as disconnected. */
|
||||||
|
syncDisconnectGracePeriodMs: number;
|
||||||
|
/** How long to wait before retrying after a network error on any request. */
|
||||||
|
networkErrorRetryMs: number;
|
||||||
|
/** How long to wait before rotating the media key when someone leaves. */
|
||||||
|
waitForKeyRotationMs?: number;
|
||||||
|
/** When the membership expires if the client stops renewing it. */
|
||||||
|
membershipEventExpiryMs?: number;
|
||||||
|
/** The session size at which the media key stops being rotated. */
|
||||||
|
keyRotationParticipantLimit?: number;
|
||||||
|
delayedLeave: DelayedLeaveTimings;
|
||||||
|
/**
|
||||||
|
* The timings used where the SFU restarts the delayed leave on the
|
||||||
|
* client's behalf, which lets the client stop renewing it itself.
|
||||||
|
*/
|
||||||
|
delegatedDelayedLeave: DelayedLeaveTimings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultSessionTimings: SessionTimings = {
|
||||||
|
syncDisconnectGracePeriodMs: 10_000,
|
||||||
|
networkErrorRetryMs: 1_000,
|
||||||
|
delayedLeave: { delay_ms: 18_000, restart_ms: 4_000 },
|
||||||
|
delegatedDelayedLeave: { delay_ms: 3_600_000, restart_ms: 300_000 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Media quality settings a host may pass; everything is optional. */
|
||||||
|
export interface MediaQuality {
|
||||||
|
/** Video codec preference. The server must also have the codec enabled. Default `vp8`. */
|
||||||
|
video_codec?: "vp8" | "vp9" | "h264" | "av1";
|
||||||
|
video?: {
|
||||||
|
/** Max resolution height in pixels. Default 720. */
|
||||||
|
max_resolution?: number;
|
||||||
|
/** Max bitrate in bits per second. Default 1700000. */
|
||||||
|
max_bitrate?: number;
|
||||||
|
/** Max framerate. Default 30. */
|
||||||
|
max_framerate?: number;
|
||||||
|
/** Simulcast layers, lowest quality first. Default 180p and 360p. */
|
||||||
|
simulcast_layers?: Array<{ height: number; bitrate: number }>;
|
||||||
|
};
|
||||||
|
screen_share?: {
|
||||||
|
/** Max resolution height in pixels. Default 1080. */
|
||||||
|
max_resolution?: number;
|
||||||
|
/** Max bitrate in bits per second. Default 5000000. */
|
||||||
|
max_bitrate?: number;
|
||||||
|
/** Max framerate. Default 30. */
|
||||||
|
max_framerate?: number;
|
||||||
|
/** Simulcast layers, lowest quality first. Without them LiveKit adds one layer at half resolution. */
|
||||||
|
simulcast_layers?: Array<{
|
||||||
|
height: number;
|
||||||
|
bitrate: number;
|
||||||
|
framerate?: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultMediaQuality = {
|
||||||
|
video_codec: "vp8",
|
||||||
|
video: { max_resolution: 720, max_bitrate: 1_700_000, max_framerate: 30 },
|
||||||
|
screen_share: {
|
||||||
|
max_resolution: 1080,
|
||||||
|
max_bitrate: 5_000_000,
|
||||||
|
max_framerate: 30,
|
||||||
|
},
|
||||||
|
} as const satisfies MediaQuality;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export enum E2eeType {
|
||||||
|
NONE = 0,
|
||||||
|
PER_PARTICIPANT = 1,
|
||||||
|
SHARED_KEY = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Unencrypted {
|
||||||
|
kind: E2eeType.NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedSecret {
|
||||||
|
kind: E2eeType.SHARED_KEY;
|
||||||
|
secret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PerParticipantE2EE {
|
||||||
|
kind: E2eeType.PER_PARTICIPANT;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EncryptionSystem = Unencrypted | SharedSecret | PerParticipantE2EE;
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type ConnectionError } from "livekit-client";
|
||||||
|
|
||||||
|
export enum ErrorCode {
|
||||||
|
/** No MatrixRTC transport provided by the homeserver and no fallback configured. */
|
||||||
|
MISSING_MATRIX_RTC_TRANSPORT = "MISSING_MATRIX_RTC_TRANSPORT",
|
||||||
|
CONNECTION_LOST_ERROR = "CONNECTION_LOST_ERROR",
|
||||||
|
INTERNAL_MEMBERSHIP_MANAGER = "INTERNAL_MEMBERSHIP_MANAGER",
|
||||||
|
FAILED_TO_START_LIVEKIT = "FAILED_TO_START_LIVEKIT",
|
||||||
|
/** LiveKit indicates that the server has hit its track limits. */
|
||||||
|
INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR",
|
||||||
|
OPEN_ID_ERROR = "OPEN_ID_ERROR",
|
||||||
|
NO_MATRIX_2_AUTHORIZATION_SERVICE = "NO_MATRIX_2_0_AUTHORIZATION_SERVICE",
|
||||||
|
SFU_ERROR = "SFU_ERROR",
|
||||||
|
UNKNOWN_ERROR = "UNKNOWN_ERROR",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ErrorCategory {
|
||||||
|
/** Calling is not supported, the server is misconfigured (JWT service missing, no MSC support). */
|
||||||
|
CONFIGURATION_ISSUE = "CONFIGURATION_ISSUE",
|
||||||
|
NETWORK_CONNECTIVITY = "NETWORK_CONNECTIVITY",
|
||||||
|
CLIENT_CONFIGURATION = "CLIENT_CONFIGURATION",
|
||||||
|
UNKNOWN = "UNKNOWN",
|
||||||
|
SYSTEM_FAILURE = "SYSTEM_FAILURE",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An error raised by the client. `code` says what went wrong in a form a host
|
||||||
|
* can translate; `cause` holds the underlying error where there is one.
|
||||||
|
*/
|
||||||
|
export class MatrixRTCError extends Error {
|
||||||
|
public constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly code: ErrorCode = ErrorCode.UNKNOWN_ERROR,
|
||||||
|
public readonly category: ErrorCategory = ErrorCategory.UNKNOWN,
|
||||||
|
options?: ErrorOptions,
|
||||||
|
) {
|
||||||
|
super(message, options);
|
||||||
|
this.name = "MatrixRTCError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wraps whatever a backend threw into the SDK's error type, keeping it as the cause. */
|
||||||
|
export function toMatrixRTCError(error: unknown): MatrixRTCError {
|
||||||
|
if (error instanceof MatrixRTCError) return error;
|
||||||
|
if (error instanceof Error)
|
||||||
|
return new MatrixRTCError(
|
||||||
|
error.message || error.name,
|
||||||
|
ErrorCode.UNKNOWN_ERROR,
|
||||||
|
ErrorCategory.UNKNOWN,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
return new MatrixRTCError(String(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MatrixRTCTransportMissingError extends MatrixRTCError {
|
||||||
|
public constructor(public readonly domain: string) {
|
||||||
|
super(
|
||||||
|
`The homeserver ${domain} advertises no MatrixRTC transport`,
|
||||||
|
ErrorCode.MISSING_MATRIX_RTC_TRANSPORT,
|
||||||
|
ErrorCategory.CONFIGURATION_ISSUE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MembershipManagerError extends MatrixRTCError {
|
||||||
|
public constructor(cause: Error) {
|
||||||
|
super(
|
||||||
|
`The membership manager stopped: ${cause.message}`,
|
||||||
|
ErrorCode.INTERNAL_MEMBERSHIP_MANAGER,
|
||||||
|
ErrorCategory.SYSTEM_FAILURE,
|
||||||
|
{ cause },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnknownRTCError extends MatrixRTCError {
|
||||||
|
public constructor(cause: Error) {
|
||||||
|
super(
|
||||||
|
cause.message || "Unknown error",
|
||||||
|
ErrorCode.UNKNOWN_ERROR,
|
||||||
|
ErrorCategory.UNKNOWN,
|
||||||
|
{
|
||||||
|
cause,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FailToGetOpenIdToken extends MatrixRTCError {
|
||||||
|
public constructor(cause: Error) {
|
||||||
|
super(
|
||||||
|
cause instanceof MatrixRTCError
|
||||||
|
? cause.message
|
||||||
|
: `Could not authenticate with the transport: ${cause.message}`,
|
||||||
|
ErrorCode.OPEN_ID_ERROR,
|
||||||
|
ErrorCategory.CONFIGURATION_ISSUE,
|
||||||
|
{ cause },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NoMatrix2AuthorizationService extends MatrixRTCError {
|
||||||
|
public constructor(cause: Error) {
|
||||||
|
super(
|
||||||
|
"The transport's authorization service does not support Matrix 2.0",
|
||||||
|
ErrorCode.NO_MATRIX_2_AUTHORIZATION_SERVICE,
|
||||||
|
ErrorCategory.CONFIGURATION_ISSUE,
|
||||||
|
{ cause },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FailToStartLivekitConnection extends MatrixRTCError {
|
||||||
|
public constructor(detail?: string) {
|
||||||
|
super(
|
||||||
|
`Failed to start the media connection${detail ? `: ${detail}` : ""}`,
|
||||||
|
ErrorCode.FAILED_TO_START_LIVEKIT,
|
||||||
|
ErrorCategory.NETWORK_CONNECTIVITY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InsufficientCapacityError extends MatrixRTCError {
|
||||||
|
public constructor() {
|
||||||
|
super(
|
||||||
|
"The media server has no capacity left",
|
||||||
|
ErrorCode.INSUFFICIENT_CAPACITY_ERROR,
|
||||||
|
ErrorCategory.UNKNOWN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SFURoomCreationRestrictedError extends MatrixRTCError {
|
||||||
|
public constructor() {
|
||||||
|
super(
|
||||||
|
"The media server did not let this client create the room",
|
||||||
|
ErrorCode.SFU_ERROR,
|
||||||
|
ErrorCategory.CONFIGURATION_ISSUE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PeerConnectionTimeoutError extends MatrixRTCError {
|
||||||
|
public constructor() {
|
||||||
|
super(
|
||||||
|
"Timed out establishing the media connection",
|
||||||
|
ErrorCode.SFU_ERROR,
|
||||||
|
ErrorCategory.NETWORK_CONNECTIVITY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LivekitConnectionError extends MatrixRTCError {
|
||||||
|
public constructor(cause: ConnectionError) {
|
||||||
|
super(
|
||||||
|
`Could not connect to the media server: ${cause.message}`,
|
||||||
|
ErrorCode.SFU_ERROR,
|
||||||
|
ErrorCategory.NETWORK_CONNECTIVITY,
|
||||||
|
{ cause },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 Element Creations Ltd.
|
||||||
|
Copyright 2024 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 { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { EventEmitter } from "events";
|
||||||
|
import { ClientEvent, SyncState } from "matrix-js-sdk";
|
||||||
|
import { MembershipManagerEvent, Status } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
|
||||||
|
import { ObservableScope } from "../reactive/ObservableScope";
|
||||||
|
import { createHomeserverConnected$ } from "./HomeserverConnected";
|
||||||
|
import { testScope, withTestScheduler } from "../utils/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal stub of a Matrix client sufficient for our tests:
|
||||||
|
```
|
||||||
|
createHomeserverConnected$(
|
||||||
|
scope: ObservableScope,
|
||||||
|
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
|
||||||
|
matrixRTCSession: NodeStyleEventEmitter &
|
||||||
|
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
*/
|
||||||
|
class MockMatrixClient extends EventEmitter {
|
||||||
|
private syncState: SyncState;
|
||||||
|
public constructor(initial: SyncState) {
|
||||||
|
super();
|
||||||
|
this.syncState = initial;
|
||||||
|
}
|
||||||
|
public setSyncState(state: SyncState): void {
|
||||||
|
this.syncState = state;
|
||||||
|
// Matrix's Sync event in createHomeserverConnected$ expects [SyncState]
|
||||||
|
this.emit(ClientEvent.Sync, [state]);
|
||||||
|
}
|
||||||
|
public getSyncState(): SyncState {
|
||||||
|
return this.syncState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal stub of MatrixRTCSession (membership manager):
|
||||||
|
```
|
||||||
|
createHomeserverConnected$(
|
||||||
|
scope: ObservableScope,
|
||||||
|
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
|
||||||
|
matrixRTCSession: NodeStyleEventEmitter &
|
||||||
|
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
*/
|
||||||
|
class MockMatrixRTCSession extends EventEmitter {
|
||||||
|
public membershipStatus: Status;
|
||||||
|
public probablyLeft: boolean;
|
||||||
|
|
||||||
|
public constructor(props: {
|
||||||
|
membershipStatus: Status;
|
||||||
|
probablyLeft: boolean;
|
||||||
|
}) {
|
||||||
|
super();
|
||||||
|
this.membershipStatus = props.membershipStatus;
|
||||||
|
this.probablyLeft = props.probablyLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setMembershipStatus(status: Status): void {
|
||||||
|
this.membershipStatus = status;
|
||||||
|
this.emit(MembershipManagerEvent.StatusChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setProbablyLeft(flag: boolean): void {
|
||||||
|
this.probablyLeft = flag;
|
||||||
|
this.emit(MembershipManagerEvent.ProbablyLeft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createHomeserverConnected$", () => {
|
||||||
|
let scope: ObservableScope;
|
||||||
|
let client: MockMatrixClient;
|
||||||
|
let session: MockMatrixRTCSession;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
scope = new ObservableScope();
|
||||||
|
client = new MockMatrixClient(SyncState.Error); // start disconnected
|
||||||
|
session = new MockMatrixRTCSession({
|
||||||
|
membershipStatus: Status.Disconnected,
|
||||||
|
probablyLeft: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
scope.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// LLM generated test cases. They are a bit overkill but I improved the mocking so it is
|
||||||
|
// easy enough to read them so I think they can stay.
|
||||||
|
// Note: gracePeriodMs is set to 0 to avoid debouncing delays in tests
|
||||||
|
it("reports syncing reason when sync state is not Syncing", () => {
|
||||||
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
|
expect(hsConnected.disconnectReason$.value).toEqual("sync");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports membership reason when sync is Syncing but membership is not Connected", () => {
|
||||||
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
|
client.setSyncState(SyncState.Syncing);
|
||||||
|
expect(hsConnected.disconnectReason$.value).toEqual("membership");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports probablyLeft reason when membership transitions to Connected but ProbablyLeft is true", () => {
|
||||||
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
|
// Make sync loop OK
|
||||||
|
client.setSyncState(SyncState.Syncing);
|
||||||
|
// Indicate probable leave before connection
|
||||||
|
session.setProbablyLeft(true);
|
||||||
|
session.setMembershipStatus(Status.Connected);
|
||||||
|
expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("becomes null (connected) only when all three conditions are satisfied", () => {
|
||||||
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
|
// 1. Sync loop connected
|
||||||
|
client.setSyncState(SyncState.Syncing);
|
||||||
|
expect(hsConnected.disconnectReason$.value).toEqual("membership"); // not yet membership connected
|
||||||
|
// 2. Membership connected
|
||||||
|
session.setMembershipStatus(Status.Connected);
|
||||||
|
expect(hsConnected.disconnectReason$.value).toEqual(null); // probablyLeft is false
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns syncing reason when sync loop leaves Syncing", () => {
|
||||||
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
|
// Reach connected state
|
||||||
|
client.setSyncState(SyncState.Syncing);
|
||||||
|
session.setMembershipStatus(Status.Connected);
|
||||||
|
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||||
|
|
||||||
|
// Sync loop error => should report syncing reason
|
||||||
|
client.setSyncState(SyncState.Error);
|
||||||
|
expect(hsConnected.disconnectReason$.value).toEqual("sync");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns membershipConnected reason when membership status becomes disconnected", () => {
|
||||||
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
|
client.setSyncState(SyncState.Syncing);
|
||||||
|
session.setMembershipStatus(Status.Connected);
|
||||||
|
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||||
|
|
||||||
|
session.setMembershipStatus(Status.Disconnected);
|
||||||
|
expect(hsConnected.disconnectReason$.value).toEqual("membership");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns certainlyConnected reason when ProbablyLeft is emitted", () => {
|
||||||
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
|
client.setSyncState(SyncState.Syncing);
|
||||||
|
session.setMembershipStatus(Status.Connected);
|
||||||
|
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||||
|
|
||||||
|
session.setProbablyLeft(true);
|
||||||
|
expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers to null (connected) if ProbablyLeft becomes false again while other conditions remain true", () => {
|
||||||
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
|
client.setSyncState(SyncState.Syncing);
|
||||||
|
session.setMembershipStatus(Status.Connected);
|
||||||
|
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||||
|
|
||||||
|
session.setProbablyLeft(true);
|
||||||
|
expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
|
||||||
|
|
||||||
|
// Simulate clearing the flag (in realistic scenario membership manager would update)
|
||||||
|
session.setProbablyLeft(false);
|
||||||
|
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("composite sequence reflects each individual failure reason", () => {
|
||||||
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
|
|
||||||
|
// Initially: sync error + membership disconnected → syncing wins (highest priority)
|
||||||
|
expect(hsConnected.disconnectReason$.value).toEqual("sync");
|
||||||
|
|
||||||
|
// Fix sync only → membershipConnected is now the blocker
|
||||||
|
client.setSyncState(SyncState.Syncing);
|
||||||
|
expect(hsConnected.disconnectReason$.value).toEqual("membership");
|
||||||
|
|
||||||
|
// Fix membership → all conditions satisfied
|
||||||
|
session.setMembershipStatus(Status.Connected);
|
||||||
|
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||||
|
|
||||||
|
// Introduce probablyLeft → certainlyConnected
|
||||||
|
session.setProbablyLeft(true);
|
||||||
|
expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
|
||||||
|
|
||||||
|
// Restore notProbablyLeft → connected again
|
||||||
|
session.setProbablyLeft(false);
|
||||||
|
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||||
|
|
||||||
|
// Drop sync → syncing reason
|
||||||
|
client.setSyncState(SyncState.Error);
|
||||||
|
expect(hsConnected.disconnectReason$.value).toEqual("sync");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createHomeserverConnected$ - disconnectReason$ reason values", () => {
|
||||||
|
let scope: ObservableScope;
|
||||||
|
let client: MockMatrixClient;
|
||||||
|
let session: MockMatrixRTCSession;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
scope = new ObservableScope();
|
||||||
|
// Start with sync failing and membership disconnected
|
||||||
|
client = new MockMatrixClient(SyncState.Error);
|
||||||
|
session = new MockMatrixRTCSession({
|
||||||
|
membershipStatus: Status.Disconnected,
|
||||||
|
probablyLeft: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
scope.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is null when all three conditions are satisfied", () => {
|
||||||
|
const { disconnectReason$ } = createHomeserverConnected$(
|
||||||
|
scope,
|
||||||
|
client,
|
||||||
|
session,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
client.setSyncState(SyncState.Syncing);
|
||||||
|
session.setMembershipStatus(Status.Connected);
|
||||||
|
expect(disconnectReason$.value).toEqual(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports syncing when sync loop is not Syncing", () => {
|
||||||
|
const { disconnectReason$ } = createHomeserverConnected$(
|
||||||
|
scope,
|
||||||
|
client,
|
||||||
|
session,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
// client starts with SyncState.Error, membership also disconnected
|
||||||
|
expect(disconnectReason$.value).toEqual("sync");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports membershipConnected when sync is fine but membership is not Connected", () => {
|
||||||
|
const { disconnectReason$ } = createHomeserverConnected$(
|
||||||
|
scope,
|
||||||
|
client,
|
||||||
|
session,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
client.setSyncState(SyncState.Syncing);
|
||||||
|
// session still Status.Disconnected
|
||||||
|
expect(disconnectReason$.value).toEqual("membership");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports certainlyConnected when probablyLeft is true", () => {
|
||||||
|
const { disconnectReason$ } = createHomeserverConnected$(
|
||||||
|
scope,
|
||||||
|
client,
|
||||||
|
session,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
client.setSyncState(SyncState.Syncing);
|
||||||
|
session.setMembershipStatus(Status.Connected);
|
||||||
|
session.setProbablyLeft(true);
|
||||||
|
expect(disconnectReason$.value).toEqual("probablyLeft");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prioritises syncing over membershipConnected when both fail", () => {
|
||||||
|
const { disconnectReason$ } = createHomeserverConnected$(
|
||||||
|
scope,
|
||||||
|
client,
|
||||||
|
session,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
// Both sync (Error) and membership (Disconnected) are failing
|
||||||
|
expect(disconnectReason$.value).toEqual("sync");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates reason as conditions change", () => {
|
||||||
|
const { disconnectReason$ } = createHomeserverConnected$(
|
||||||
|
scope,
|
||||||
|
client,
|
||||||
|
session,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
// Initially: syncing fails
|
||||||
|
expect(disconnectReason$.value).toEqual("sync");
|
||||||
|
|
||||||
|
// Fix sync → membershipConnected is now the blocker
|
||||||
|
client.setSyncState(SyncState.Syncing);
|
||||||
|
expect(disconnectReason$.value).toEqual("membership");
|
||||||
|
|
||||||
|
// Fix membership → probablyLeft makes certainlyConnected fail
|
||||||
|
session.setProbablyLeft(true);
|
||||||
|
session.setMembershipStatus(Status.Connected);
|
||||||
|
expect(disconnectReason$.value).toEqual("probablyLeft");
|
||||||
|
|
||||||
|
// Clear probablyLeft → all conditions satisfied
|
||||||
|
session.setProbablyLeft(false);
|
||||||
|
expect(disconnectReason$.value).toEqual(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createHomeserverConnected$ - Grace Period", () => {
|
||||||
|
const GRACE_PERIOD = 5;
|
||||||
|
|
||||||
|
function marbleTest(
|
||||||
|
syncStateMarbles: string,
|
||||||
|
expectedConnectedMarbles: string,
|
||||||
|
): void {
|
||||||
|
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||||
|
const syncState$ = behavior(syncStateMarbles, {
|
||||||
|
s: SyncState.Syncing,
|
||||||
|
e: SyncState.Error,
|
||||||
|
});
|
||||||
|
const client = new MockMatrixClient(syncState$.value);
|
||||||
|
schedule(syncStateMarbles, {
|
||||||
|
s: () => client.setSyncState(SyncState.Syncing),
|
||||||
|
e: () => client.setSyncState(SyncState.Error),
|
||||||
|
});
|
||||||
|
const session = new MockMatrixRTCSession({
|
||||||
|
membershipStatus: Status.Connected,
|
||||||
|
probablyLeft: false,
|
||||||
|
});
|
||||||
|
const hsConnected = createHomeserverConnected$(
|
||||||
|
testScope(),
|
||||||
|
client,
|
||||||
|
session,
|
||||||
|
GRACE_PERIOD,
|
||||||
|
);
|
||||||
|
expectObservable(hsConnected.disconnectReason$).toBe(
|
||||||
|
expectedConnectedMarbles,
|
||||||
|
{
|
||||||
|
y: null,
|
||||||
|
n: "sync",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("respects gracePeriodMs: stays true during grace period and flips false after", () => {
|
||||||
|
// - Initial state: Everything is connected
|
||||||
|
// - Sync error occurs -> should remain connected due to grace period
|
||||||
|
// - After grace period, not connected
|
||||||
|
marbleTest("se", "y-----n");
|
||||||
|
// If the sync error takes longer to occur, it should take equally long for
|
||||||
|
// the connection state to change
|
||||||
|
marbleTest("s--e", "y-------n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers immediately if sync returns during grace period", () => {
|
||||||
|
// - Initial state: Connected
|
||||||
|
// - Sync error occurs
|
||||||
|
// - Sync recovers BEFORE the grace period expires
|
||||||
|
// - Connection state remains constant
|
||||||
|
marbleTest("se--s", "y");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flips to true IMMEDIATELY even if a grace period was pending", () => {
|
||||||
|
// - Initial error: connection eventually flips to false
|
||||||
|
// - Back to Syncing -> Must be connected immediately (synchronously)
|
||||||
|
marbleTest("e-----s", "y----ny");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 Element Creations Ltd.
|
||||||
|
Copyright 2024 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 {
|
||||||
|
MembershipManagerEvent,
|
||||||
|
Status,
|
||||||
|
type MatrixRTCSession,
|
||||||
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
|
||||||
|
import {
|
||||||
|
fromEvent,
|
||||||
|
startWith,
|
||||||
|
map,
|
||||||
|
tap,
|
||||||
|
type Observable,
|
||||||
|
distinctUntilChanged,
|
||||||
|
switchMap,
|
||||||
|
of,
|
||||||
|
delay,
|
||||||
|
combineLatest,
|
||||||
|
} from "rxjs";
|
||||||
|
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
|
import { type ObservableScope } from "../reactive/ObservableScope";
|
||||||
|
import { type Behavior } from "../reactive/Behavior";
|
||||||
|
import { defaultSessionTimings } from "../config";
|
||||||
|
import { type MatrixDisconnectReason } from "../api";
|
||||||
|
|
||||||
|
type NodeEventHandler = (...args: unknown[]) => void;
|
||||||
|
|
||||||
|
interface NodeStyleEventEmitter {
|
||||||
|
addListener(eventName: string | symbol, handler: NodeEventHandler): this;
|
||||||
|
removeListener(eventName: string | symbol, handler: NodeEventHandler): this;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeserverConnected {
|
||||||
|
/** Null while the homeserver connection is healthy, the first failing link otherwise. */
|
||||||
|
disconnectReason$: Behavior<MatrixDisconnectReason | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Behavior representing whether we consider ourselves connected to the Matrix homeserver
|
||||||
|
* for the purposes of a MatrixRTC session.
|
||||||
|
*
|
||||||
|
* `disconnectReason$` emits `null` when all conditions are satisfied, or the first failing
|
||||||
|
* reason (priority: syncing > membershipConnected > certainlyConnected):
|
||||||
|
* 1. Sync loop is not in SyncState.Syncing (after grace period) → "sync"
|
||||||
|
* 2. membershipStatus !== Status.Connected → "membership"
|
||||||
|
* 3. probablyLeft === true → "probablyLeft"
|
||||||
|
*
|
||||||
|
* @param scope - The observable scope for lifecycle management.
|
||||||
|
* @param client - The Matrix client to monitor sync state.
|
||||||
|
* @param matrixRTCSession - The RTC session to monitor membership.
|
||||||
|
* @param gracePeriodMs - Grace period in milliseconds to wait before reporting sync disconnect.
|
||||||
|
* If not provided, uses the config value (default 10000ms).
|
||||||
|
*/
|
||||||
|
export function createHomeserverConnected$(
|
||||||
|
scope: ObservableScope,
|
||||||
|
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
|
||||||
|
matrixRTCSession: NodeStyleEventEmitter &
|
||||||
|
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
|
||||||
|
gracePeriodMs?: number,
|
||||||
|
): HomeserverConnected {
|
||||||
|
const logger = rootLogger.getChild("[HomeserverConnected]");
|
||||||
|
// Get grace period from parameter or config (default 10000ms)
|
||||||
|
const graceMs =
|
||||||
|
gracePeriodMs ?? defaultSessionTimings.syncDisconnectGracePeriodMs;
|
||||||
|
|
||||||
|
const syncing$ = (
|
||||||
|
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
|
||||||
|
).pipe(
|
||||||
|
startWith([client.getSyncState()]),
|
||||||
|
map(([state]) => state === SyncState.Syncing),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
switchMap((isSyncing) => {
|
||||||
|
if (isSyncing || graceMs <= 0) {
|
||||||
|
return of(isSyncing);
|
||||||
|
}
|
||||||
|
return of(false).pipe(delay(graceMs), startWith(true));
|
||||||
|
}),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rtsSession$ = scope.behavior<Status>(
|
||||||
|
fromEvent(matrixRTCSession, MembershipManagerEvent.StatusChanged).pipe(
|
||||||
|
map(() => matrixRTCSession.membershipStatus ?? Status.Unknown),
|
||||||
|
),
|
||||||
|
matrixRTCSession.membershipStatus ?? Status.Unknown,
|
||||||
|
);
|
||||||
|
|
||||||
|
const membershipConnected$ = rtsSession$.pipe(
|
||||||
|
map((status) => status === Status.Connected),
|
||||||
|
);
|
||||||
|
|
||||||
|
// This is basically notProbablyLeft$
|
||||||
|
//
|
||||||
|
// probablyLeft is computed by a local timer that mimics the server delayed event.
|
||||||
|
// If we locally predict our server event timed out. We consider ourselves as probablyLeft
|
||||||
|
// even though we might not yet have received the delayed event leave.
|
||||||
|
//
|
||||||
|
// If that is not the case we certainly still have a valid membership on the matrix network
|
||||||
|
// independet if the sync currently works.
|
||||||
|
const certainlyConnected$ = fromEvent(
|
||||||
|
matrixRTCSession,
|
||||||
|
MembershipManagerEvent.ProbablyLeft,
|
||||||
|
).pipe(
|
||||||
|
startWith(null),
|
||||||
|
map(() => matrixRTCSession.probablyLeft !== true),
|
||||||
|
);
|
||||||
|
|
||||||
|
const disconnectReason$ = scope.behavior(
|
||||||
|
combineLatest([syncing$, membershipConnected$, certainlyConnected$]).pipe(
|
||||||
|
map(([syncing, membership, certainly]): MatrixDisconnectReason | null => {
|
||||||
|
if (!syncing) return "sync";
|
||||||
|
if (!membership) return "membership";
|
||||||
|
if (!certainly) return "probablyLeft";
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
tap((reason) => {
|
||||||
|
logger.info(`Homeserver connected update: ${reason ?? "connected"}`);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { disconnectReason$ };
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { MatrixRTCSessionEvent } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { EventEmitter } from "events";
|
||||||
|
|
||||||
|
import { createKeyRotationSuppressed$ } from "./JsRtcSessionBehaviors";
|
||||||
|
import { testScope } from "../utils/test";
|
||||||
|
|
||||||
|
describe("JsRtcSessionBehaviors", () => {
|
||||||
|
describe("createKeyRotationSuppressed$", () => {
|
||||||
|
it("emits initial value from isKeyRotationSuppressed and updates when KeyRotationSuppressedChanged event is emitted", () => {
|
||||||
|
const scope = testScope();
|
||||||
|
const emitter = new EventEmitter();
|
||||||
|
const mockSession = Object.assign(emitter, {
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
isKeyRotationSuppressed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const keyRotationSuppressed$ = createKeyRotationSuppressed$(
|
||||||
|
scope,
|
||||||
|
mockSession as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(keyRotationSuppressed$.value).toBe(false);
|
||||||
|
emitter.emit(MatrixRTCSessionEvent.KeyRotationSuppressedChanged, true);
|
||||||
|
|
||||||
|
expect(keyRotationSuppressed$.value).toBe(true);
|
||||||
|
|
||||||
|
emitter.emit(MatrixRTCSessionEvent.KeyRotationSuppressedChanged, false);
|
||||||
|
|
||||||
|
expect(keyRotationSuppressed$.value).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CallMembership,
|
||||||
|
type MatrixRTCSession,
|
||||||
|
MatrixRTCSessionEvent,
|
||||||
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { fromEvent } from "rxjs";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Epoch,
|
||||||
|
trackEpoch,
|
||||||
|
type ObservableScope,
|
||||||
|
} from "../reactive/ObservableScope";
|
||||||
|
import { type Behavior } from "../reactive/Behavior";
|
||||||
|
|
||||||
|
export const createMemberships$ = (
|
||||||
|
scope: ObservableScope,
|
||||||
|
matrixRTCSession: MatrixRTCSession,
|
||||||
|
): Behavior<Epoch<CallMembership[]>> => {
|
||||||
|
return scope.behavior(
|
||||||
|
fromEvent(
|
||||||
|
matrixRTCSession,
|
||||||
|
MatrixRTCSessionEvent.MembershipsChanged,
|
||||||
|
(_, memberships: CallMembership[]) => memberships,
|
||||||
|
).pipe(trackEpoch()),
|
||||||
|
new Epoch(matrixRTCSession.memberships),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the session has grown large enough that MatrixRTC has stopped rotating the media
|
||||||
|
* encryption key. While this is true the key in use is still shared with new joiners, but no new
|
||||||
|
* key is generated when someone joins or leaves.
|
||||||
|
*/
|
||||||
|
export const createKeyRotationSuppressed$ = (
|
||||||
|
scope: ObservableScope,
|
||||||
|
matrixRTCSession: MatrixRTCSession,
|
||||||
|
): Behavior<boolean> => {
|
||||||
|
return scope.behavior(
|
||||||
|
fromEvent(
|
||||||
|
matrixRTCSession,
|
||||||
|
MatrixRTCSessionEvent.KeyRotationSuppressedChanged,
|
||||||
|
(suppressed: boolean) => suppressed,
|
||||||
|
),
|
||||||
|
matrixRTCSession.isKeyRotationSuppressed,
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import { BehaviorSubject, type Observable, Subject, throwError } from "rxjs";
|
||||||
|
|
||||||
|
import { defaultSessionTimings } from "../config";
|
||||||
|
import { MatrixRTCError } from "../errors";
|
||||||
|
import { type HomeserverConnected } from "./HomeserverConnected";
|
||||||
|
import {
|
||||||
|
createLocalMembership$,
|
||||||
|
type LocalMembership,
|
||||||
|
type PreparedTransport,
|
||||||
|
} from "./LocalMembership";
|
||||||
|
import { flushPromises, testScope } from "../utils/test";
|
||||||
|
|
||||||
|
const transport: Transport = { type: "livekit", livekit_service_url: "u" };
|
||||||
|
|
||||||
|
describe("createLocalMembership$", () => {
|
||||||
|
it("waits for the transport, joins, and leaves on leave()", async () => {
|
||||||
|
const { membership, homeserver, prepared$ } = setup();
|
||||||
|
expect(membership.state$.value).toEqual({ kind: "waitingForTransport" });
|
||||||
|
|
||||||
|
homeserver.disconnectReason$.next("membership");
|
||||||
|
prepared$.next({ transport, canDelegateDelayedLeave: false });
|
||||||
|
expect(membership.state$.value).toEqual({ kind: "joining" });
|
||||||
|
|
||||||
|
homeserver.disconnectReason$.next(null);
|
||||||
|
expect(membership.state$.value).toEqual({ kind: "joined" });
|
||||||
|
|
||||||
|
membership.leave();
|
||||||
|
await flushPromises();
|
||||||
|
expect(membership.state$.value).toEqual({ kind: "left" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconnects only after it was joined once", () => {
|
||||||
|
const { membership, homeserver, prepared$ } = setup();
|
||||||
|
prepared$.next({ transport, canDelegateDelayedLeave: false });
|
||||||
|
expect(membership.state$.value).toEqual({ kind: "joined" });
|
||||||
|
|
||||||
|
homeserver.disconnectReason$.next("sync");
|
||||||
|
expect(membership.state$.value).toEqual({
|
||||||
|
kind: "reconnecting",
|
||||||
|
reason: "sync",
|
||||||
|
});
|
||||||
|
homeserver.disconnectReason$.next("probablyLeft");
|
||||||
|
expect(membership.state$.value).toEqual({
|
||||||
|
kind: "reconnecting",
|
||||||
|
reason: "probablyLeft",
|
||||||
|
});
|
||||||
|
homeserver.disconnectReason$.next(null);
|
||||||
|
expect(membership.state$.value).toEqual({ kind: "joined" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins with the delegated timings and hands over each delay id where the backend can", async () => {
|
||||||
|
const { prepared$, delayId$, joinMatrixRTC, delegate } = setup();
|
||||||
|
prepared$.next({ transport, canDelegateDelayedLeave: true });
|
||||||
|
await flushPromises();
|
||||||
|
expect(joinMatrixRTC).toHaveBeenCalledWith(
|
||||||
|
transport,
|
||||||
|
defaultSessionTimings.delegatedDelayedLeave,
|
||||||
|
);
|
||||||
|
|
||||||
|
delayId$.next("delay-1");
|
||||||
|
await flushPromises();
|
||||||
|
expect(delegate).toHaveBeenCalledWith("delay-1");
|
||||||
|
delayId$.next("delay-2");
|
||||||
|
await flushPromises();
|
||||||
|
expect(delegate).toHaveBeenLastCalledWith("delay-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never hands the leave over where the backend cannot take it", async () => {
|
||||||
|
const { prepared$, delayId$, joinMatrixRTC, delegate } = setup();
|
||||||
|
prepared$.next({ transport, canDelegateDelayedLeave: false });
|
||||||
|
delayId$.next("delay-1");
|
||||||
|
await flushPromises();
|
||||||
|
expect(joinMatrixRTC).toHaveBeenCalledWith(
|
||||||
|
transport,
|
||||||
|
defaultSessionTimings.delayedLeave,
|
||||||
|
);
|
||||||
|
expect(delegate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a transport that could not be prepared as failed", () => {
|
||||||
|
const error = new MatrixRTCError("no token");
|
||||||
|
const { membership } = setup(throwError(() => error));
|
||||||
|
expect(membership.state$.value).toEqual({ kind: "failed", error });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the membership manager giving up as failed, and stays failed", () => {
|
||||||
|
const { membership, homeserver, prepared$, membershipManagerError$ } =
|
||||||
|
setup();
|
||||||
|
prepared$.next({ transport, canDelegateDelayedLeave: false });
|
||||||
|
membershipManagerError$.next(new Error("gave up"));
|
||||||
|
const state = membership.state$.value;
|
||||||
|
expect(state.kind).toBe("failed");
|
||||||
|
|
||||||
|
homeserver.disconnectReason$.next("sync");
|
||||||
|
homeserver.disconnectReason$.next(null);
|
||||||
|
expect(membership.state$.value).toBe(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is left after a failure once leave() is called", () => {
|
||||||
|
const { membership } = setup(throwError(() => new MatrixRTCError("x")));
|
||||||
|
membership.leave();
|
||||||
|
expect(membership.state$.value).toEqual({ kind: "left" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function setup(preparedTransportWithErrors$?: Observable<PreparedTransport>): {
|
||||||
|
membership: LocalMembership;
|
||||||
|
homeserver: FakeHomeserverConnected;
|
||||||
|
prepared$: Subject<PreparedTransport>;
|
||||||
|
delayId$: BehaviorSubject<string | null>;
|
||||||
|
membershipManagerError$: Subject<unknown>;
|
||||||
|
joinMatrixRTC: ReturnType<typeof vi.fn>;
|
||||||
|
delegate: ReturnType<typeof vi.fn>;
|
||||||
|
} {
|
||||||
|
const prepared$ = new Subject<PreparedTransport>();
|
||||||
|
const homeserver = fakeHomeserverConnected();
|
||||||
|
const delayId$ = new BehaviorSubject<string | null>(null);
|
||||||
|
const membershipManagerError$ = new Subject<unknown>();
|
||||||
|
const joinMatrixRTC = vi.fn();
|
||||||
|
const delegate = vi.fn(async () => Promise.resolve());
|
||||||
|
const membership = createLocalMembership$({
|
||||||
|
scope: testScope(),
|
||||||
|
delegateDelayedLeave: delegate,
|
||||||
|
preparedTransport$: preparedTransportWithErrors$ ?? prepared$,
|
||||||
|
homeserverConnected: homeserver,
|
||||||
|
joinMatrixRTC,
|
||||||
|
membershipManagerError$,
|
||||||
|
matrixRTCSession: {
|
||||||
|
leave: vi.fn(async () => Promise.resolve(true)),
|
||||||
|
},
|
||||||
|
delayId$,
|
||||||
|
timings: defaultSessionTimings,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
membership,
|
||||||
|
homeserver,
|
||||||
|
prepared$,
|
||||||
|
delayId$,
|
||||||
|
membershipManagerError$,
|
||||||
|
joinMatrixRTC,
|
||||||
|
delegate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type FakeHomeserverConnected = {
|
||||||
|
disconnectReason$: BehaviorSubject<
|
||||||
|
HomeserverConnected["disconnectReason$"]["value"]
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function fakeHomeserverConnected(): FakeHomeserverConnected {
|
||||||
|
return {
|
||||||
|
disconnectReason$: new BehaviorSubject<
|
||||||
|
HomeserverConnected["disconnectReason$"]["value"]
|
||||||
|
>(null),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import {
|
||||||
|
type MatrixRTCSession as JsSdkRTCSession,
|
||||||
|
type Transport,
|
||||||
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||||
|
import {
|
||||||
|
BehaviorSubject,
|
||||||
|
catchError,
|
||||||
|
combineLatest,
|
||||||
|
distinctUntilChanged,
|
||||||
|
NEVER,
|
||||||
|
type Observable,
|
||||||
|
scan,
|
||||||
|
} from "rxjs";
|
||||||
|
|
||||||
|
import { type Behavior } from "../reactive/Behavior";
|
||||||
|
import { type ObservableScope } from "../reactive/ObservableScope";
|
||||||
|
import { type DelayedLeaveTimings, type SessionTimings } from "../config";
|
||||||
|
import { type MatrixDisconnectReason } from "../api";
|
||||||
|
import {
|
||||||
|
type MatrixRTCError,
|
||||||
|
MembershipManagerError,
|
||||||
|
toMatrixRTCError,
|
||||||
|
} from "../errors";
|
||||||
|
import {
|
||||||
|
type MediaBackend,
|
||||||
|
type TransportCapabilities,
|
||||||
|
} from "../media-backend/api";
|
||||||
|
import { type HomeserverConnected } from "./HomeserverConnected";
|
||||||
|
|
||||||
|
export type MembershipState =
|
||||||
|
/** Asking the homeserver for a transport, or preparing it. */
|
||||||
|
| { kind: "waitingForTransport" }
|
||||||
|
/** Transport known; sending the membership for the first time. */
|
||||||
|
| { kind: "joining" }
|
||||||
|
/** Syncing, membership confirmed by the server, delayed leave still being refreshed. */
|
||||||
|
| { kind: "joined" }
|
||||||
|
/** Was joined and one homeserver link is down; the membership manager is retrying. */
|
||||||
|
| { kind: "reconnecting"; reason: MatrixDisconnectReason }
|
||||||
|
/** A transport, join or membership manager error ended the membership. Terminal unless `left`. */
|
||||||
|
| { kind: "failed"; error: MatrixRTCError }
|
||||||
|
/** `leave()` was called. Terminal. */
|
||||||
|
| { kind: "left" };
|
||||||
|
|
||||||
|
/** The local transport, found and prepared, with what the join needs to know about it. */
|
||||||
|
export interface PreparedTransport extends TransportCapabilities {
|
||||||
|
transport: Transport;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
scope: ObservableScope;
|
||||||
|
delegateDelayedLeave: MediaBackend["delegateDelayedLeave"];
|
||||||
|
preparedTransport$: Observable<PreparedTransport>;
|
||||||
|
homeserverConnected: HomeserverConnected;
|
||||||
|
joinMatrixRTC: (
|
||||||
|
transport: Transport,
|
||||||
|
delayedLeave: DelayedLeaveTimings,
|
||||||
|
) => void;
|
||||||
|
/** The membership manager giving up on keeping the membership alive. */
|
||||||
|
membershipManagerError$: Observable<unknown>;
|
||||||
|
matrixRTCSession: Pick<JsSdkRTCSession, "leave">;
|
||||||
|
/** The id of the delayed leave event, for the backend to take over. */
|
||||||
|
delayId$: Behavior<string | null>;
|
||||||
|
timings: SessionTimings;
|
||||||
|
logger: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalMembership {
|
||||||
|
/** Sends the leave. Final: there is no joining again. */
|
||||||
|
leave: () => void;
|
||||||
|
state$: Behavior<MembershipState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Matrix side of the local member: waits for the transport, enters the
|
||||||
|
* MatrixRTC session, keeps the delayed leave delegated, and leaves on
|
||||||
|
* `leave()`. Knows nothing about the media.
|
||||||
|
*/
|
||||||
|
export function createLocalMembership$({
|
||||||
|
scope,
|
||||||
|
delegateDelayedLeave,
|
||||||
|
preparedTransport$: preparedTransportWithErrors$,
|
||||||
|
homeserverConnected,
|
||||||
|
joinMatrixRTC,
|
||||||
|
membershipManagerError$,
|
||||||
|
matrixRTCSession,
|
||||||
|
delayId$,
|
||||||
|
timings,
|
||||||
|
logger: parentLogger,
|
||||||
|
}: Props): LocalMembership {
|
||||||
|
const logger = parentLogger.getChild("[LocalMembership]");
|
||||||
|
|
||||||
|
const transportError$ = new BehaviorSubject<MatrixRTCError | null>(null);
|
||||||
|
const transport$ = scope.behavior<PreparedTransport | null>(
|
||||||
|
preparedTransportWithErrors$.pipe(
|
||||||
|
catchError((e: unknown) => {
|
||||||
|
transportError$.next(toMatrixRTCError(e));
|
||||||
|
return NEVER;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const joined$ = new BehaviorSubject(true);
|
||||||
|
const matrixError$ = new BehaviorSubject<MatrixRTCError | null>(null);
|
||||||
|
|
||||||
|
scope.reconcile(
|
||||||
|
scope.behavior(combineLatest([transport$, joined$])),
|
||||||
|
async ([prepared, joined]) => {
|
||||||
|
if (prepared === null || !joined) return;
|
||||||
|
try {
|
||||||
|
joinMatrixRTC(
|
||||||
|
prepared.transport,
|
||||||
|
prepared.canDelegateDelayedLeave
|
||||||
|
? timings.delegatedDelayedLeave
|
||||||
|
: timings.delayedLeave,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Failed to enter the session", e);
|
||||||
|
if (matrixError$.value === null) matrixError$.next(toMatrixRTCError(e));
|
||||||
|
}
|
||||||
|
return Promise.resolve(async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await matrixRTCSession.leave(1000);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Failed to leave the session", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
scope.reconcile(
|
||||||
|
scope.behavior(combineLatest([transport$, delayId$])),
|
||||||
|
async ([prepared, delayId]) => {
|
||||||
|
if (!prepared?.canDelegateDelayedLeave || delayId === null) return;
|
||||||
|
try {
|
||||||
|
await delegateDelayedLeave(delayId);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Failed to delegate the leave", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
membershipManagerError$.pipe(scope.bind()).subscribe((e) => {
|
||||||
|
logger.error("The membership manager stopped", e);
|
||||||
|
if (matrixError$.value === null)
|
||||||
|
matrixError$.next(
|
||||||
|
new MembershipManagerError(
|
||||||
|
e instanceof Error ? e : new Error(String(e)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const state$ = scope.behavior<MembershipState>(
|
||||||
|
combineLatest([
|
||||||
|
joined$,
|
||||||
|
transportError$,
|
||||||
|
matrixError$,
|
||||||
|
transport$,
|
||||||
|
homeserverConnected.disconnectReason$,
|
||||||
|
]).pipe(
|
||||||
|
scan(
|
||||||
|
(previous, [joined, transportError, matrixError, transport, reason]) =>
|
||||||
|
membershipState(previous, {
|
||||||
|
joined,
|
||||||
|
error: transportError ?? matrixError,
|
||||||
|
hasTransport: transport !== null,
|
||||||
|
reason,
|
||||||
|
}),
|
||||||
|
{ kind: "waitingForTransport" } as MembershipState,
|
||||||
|
),
|
||||||
|
distinctUntilChanged(deepCompare),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
leave: () => joined$.next(false),
|
||||||
|
state$,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MembershipInputs {
|
||||||
|
joined: boolean;
|
||||||
|
error: MatrixRTCError | null;
|
||||||
|
hasTransport: boolean;
|
||||||
|
reason: MatrixDisconnectReason | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The next state, in priority order; `left` and `failed` are absorbing. */
|
||||||
|
function membershipState(
|
||||||
|
previous: MembershipState,
|
||||||
|
{ joined, error, hasTransport, reason }: MembershipInputs,
|
||||||
|
): MembershipState {
|
||||||
|
if (!joined) return { kind: "left" };
|
||||||
|
if (previous.kind === "failed") return previous;
|
||||||
|
if (error !== null) return { kind: "failed", error };
|
||||||
|
if (!hasTransport) return { kind: "waitingForTransport" };
|
||||||
|
if (reason === null) return { kind: "joined" };
|
||||||
|
const wasJoined =
|
||||||
|
previous.kind === "joined" || previous.kind === "reconnecting";
|
||||||
|
return wasJoined ? { kind: "reconnecting", reason } : { kind: "joining" };
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { MatrixRTCTransportMissingError } from "../errors";
|
||||||
|
import { discoverLocalTransport } from "./LocalTransport";
|
||||||
|
|
||||||
|
const homeserverTransport: Transport = {
|
||||||
|
type: "livekit",
|
||||||
|
livekit_service_url: "https://homeserver.example.org",
|
||||||
|
};
|
||||||
|
const hostTransport: Transport = {
|
||||||
|
type: "livekit",
|
||||||
|
livekit_service_url: "https://host.example.org",
|
||||||
|
};
|
||||||
|
|
||||||
|
function client(
|
||||||
|
advertised: Transport[],
|
||||||
|
): Parameters<typeof discoverLocalTransport>[0] {
|
||||||
|
return {
|
||||||
|
getDomain: () => "example.org",
|
||||||
|
_unstable_getRTCTransports: vi.fn().mockResolvedValue(advertised),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("discoverLocalTransport", () => {
|
||||||
|
it("takes the transport the host names without asking the homeserver", async () => {
|
||||||
|
const c = client([homeserverTransport]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
discoverLocalTransport(
|
||||||
|
c,
|
||||||
|
"livekit",
|
||||||
|
{ transport: hostTransport },
|
||||||
|
logger,
|
||||||
|
),
|
||||||
|
).resolves.toBe(hostTransport);
|
||||||
|
expect(c._unstable_getRTCTransports).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the homeserver's transport over the host's fallback", async () => {
|
||||||
|
await expect(
|
||||||
|
discoverLocalTransport(
|
||||||
|
client([homeserverTransport]),
|
||||||
|
"livekit",
|
||||||
|
{ fallbackTransport: hostTransport },
|
||||||
|
logger,
|
||||||
|
),
|
||||||
|
).resolves.toEqual(homeserverTransport);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the host's transport where the homeserver advertises none of the type", async () => {
|
||||||
|
await expect(
|
||||||
|
discoverLocalTransport(
|
||||||
|
client([{ type: "not_livekit" }]),
|
||||||
|
"livekit",
|
||||||
|
{ fallbackTransport: hostTransport },
|
||||||
|
logger,
|
||||||
|
),
|
||||||
|
).resolves.toEqual(hostTransport);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails naming the homeserver where there is no transport at all", async () => {
|
||||||
|
const promise = discoverLocalTransport(client([]), "livekit", {}, logger);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toBeInstanceOf(
|
||||||
|
MatrixRTCTransportMissingError,
|
||||||
|
);
|
||||||
|
await expect(promise).rejects.toHaveProperty("domain", "example.org");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type MatrixClient } from "matrix-js-sdk";
|
||||||
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
|
||||||
|
import { type RTCParticipationOptions } from "../api";
|
||||||
|
import { MatrixRTCTransportMissingError } from "../errors";
|
||||||
|
import { TransportAutoDiscovery } from "./TransportAutoDiscovery";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The transport the local member advertises: the one the host names, else the
|
||||||
|
* homeserver's first of the type the backend serves, else the host's fallback.
|
||||||
|
*/
|
||||||
|
export async function discoverLocalTransport(
|
||||||
|
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports">,
|
||||||
|
transportType: string,
|
||||||
|
{
|
||||||
|
transport: override,
|
||||||
|
fallbackTransport,
|
||||||
|
}: Pick<RTCParticipationOptions, "transport" | "fallbackTransport">,
|
||||||
|
logger: Logger,
|
||||||
|
): Promise<Transport> {
|
||||||
|
const transport =
|
||||||
|
override ??
|
||||||
|
(await new TransportAutoDiscovery({
|
||||||
|
client,
|
||||||
|
transportType,
|
||||||
|
fallbackTransport,
|
||||||
|
logger,
|
||||||
|
}).discoverPreferredTransport());
|
||||||
|
if (transport === null)
|
||||||
|
throw new MatrixRTCTransportMissingError(client.getDomain() ?? "");
|
||||||
|
return transport;
|
||||||
|
}
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
type MatrixEvent,
|
||||||
|
type RoomMember,
|
||||||
|
type RoomState,
|
||||||
|
RoomStateEvent,
|
||||||
|
} from "matrix-js-sdk";
|
||||||
|
import EventEmitter from "events";
|
||||||
|
import { it } from "vitest";
|
||||||
|
|
||||||
|
import { ObservableScope } from "../reactive/ObservableScope";
|
||||||
|
import type { Room as MatrixRoom } from "matrix-js-sdk/lib/models/room";
|
||||||
|
import {
|
||||||
|
mockRtcMembership,
|
||||||
|
mockMatrixRoomMember,
|
||||||
|
withTestScheduler,
|
||||||
|
} from "../utils/test";
|
||||||
|
import {
|
||||||
|
createMatrixMemberMetadata$,
|
||||||
|
createRoomMembers$,
|
||||||
|
} from "./MatrixMemberMetadata.ts";
|
||||||
|
let testScope: ObservableScope;
|
||||||
|
let mockMatrixRoom: MatrixRoom;
|
||||||
|
|
||||||
|
describe("MatrixMemberMetadata", () => {
|
||||||
|
/*
|
||||||
|
* To be populated in the test setup.
|
||||||
|
* Maps userId to a partial/mock RoomMember object.
|
||||||
|
*/
|
||||||
|
let fakeMembersMap: Map<string, Partial<RoomMember>>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
testScope = new ObservableScope();
|
||||||
|
fakeMembersMap = new Map<string, Partial<RoomMember>>();
|
||||||
|
|
||||||
|
const roomEmitter = new EventEmitter();
|
||||||
|
mockMatrixRoom = {
|
||||||
|
on: roomEmitter.on.bind(roomEmitter),
|
||||||
|
off: roomEmitter.off.bind(roomEmitter),
|
||||||
|
emit: roomEmitter.emit.bind(roomEmitter),
|
||||||
|
// addListener: roomEmitter.addListener.bind(roomEmitter),
|
||||||
|
// removeListener: roomEmitter.removeListener.bind(roomEmitter),
|
||||||
|
getMember: vi.fn().mockImplementation((userId: string) => {
|
||||||
|
const member = fakeMembersMap.get(userId);
|
||||||
|
if (member) {
|
||||||
|
return member as RoomMember;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
getMembers: vi.fn().mockImplementation(() => {
|
||||||
|
const members = Array.from(fakeMembersMap.values());
|
||||||
|
return members;
|
||||||
|
}),
|
||||||
|
getMembersWithMembership: vi.fn().mockImplementation(() => {
|
||||||
|
const members = Array.from(fakeMembersMap.values());
|
||||||
|
return members;
|
||||||
|
}),
|
||||||
|
} as unknown as MatrixRoom;
|
||||||
|
});
|
||||||
|
|
||||||
|
function fakeMemberWith(data: Partial<RoomMember>): void {
|
||||||
|
const userId = data.userId || "@alice:example.com";
|
||||||
|
const member: Partial<RoomMember> = {
|
||||||
|
userId: userId,
|
||||||
|
rawDisplayName: data.rawDisplayName ?? userId,
|
||||||
|
getMxcAvatarUrl:
|
||||||
|
data.getMxcAvatarUrl ||
|
||||||
|
vi.fn().mockImplementation(() => {
|
||||||
|
return `mxc://example.com/${userId}`;
|
||||||
|
}),
|
||||||
|
...data,
|
||||||
|
} as unknown as RoomMember;
|
||||||
|
fakeMembersMap.set(userId, member);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fakeMembersMap.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("displayname", () => {
|
||||||
|
function updateDisplayName(
|
||||||
|
userId: `@${string}:${string}`,
|
||||||
|
newDisplayName: string,
|
||||||
|
): void {
|
||||||
|
const member = fakeMembersMap.get(userId);
|
||||||
|
if (member) {
|
||||||
|
member.rawDisplayName = newDisplayName;
|
||||||
|
// Emit the event to notify listeners
|
||||||
|
mockMatrixRoom.emit(
|
||||||
|
RoomStateEvent.Members,
|
||||||
|
{} as unknown as MatrixEvent,
|
||||||
|
{} as unknown as RoomState,
|
||||||
|
member as RoomMember,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new Error(`No member found with userId: ${userId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should show our own user if present in rtc session and room", () => {
|
||||||
|
withTestScheduler(({ scope, behavior, expectObservable }) => {
|
||||||
|
fakeMemberWith({
|
||||||
|
userId: "@local:example.com",
|
||||||
|
rawDisplayName: "it's a me",
|
||||||
|
});
|
||||||
|
const memberships$ = behavior("a", {
|
||||||
|
a: [mockRtcMembership("@local:example.com", "DEVICE1")],
|
||||||
|
});
|
||||||
|
const metadataStore = createMatrixMemberMetadata$(
|
||||||
|
testScope,
|
||||||
|
memberships$,
|
||||||
|
createRoomMembers$(testScope, mockMatrixRoom),
|
||||||
|
);
|
||||||
|
const dn$ = metadataStore.createDisplayNameBehavior$(
|
||||||
|
scope,
|
||||||
|
"@local:example.com",
|
||||||
|
);
|
||||||
|
|
||||||
|
expectObservable(dn$).toBe("a", {
|
||||||
|
a: "it's a me",
|
||||||
|
});
|
||||||
|
expectObservable(metadataStore.displaynameMap$).toBe("a", {
|
||||||
|
a: new Map<string, string>([["@local:example.com", "it's a me"]]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function setUpBasicRoom(): void {
|
||||||
|
fakeMemberWith({
|
||||||
|
userId: "@local:example.com",
|
||||||
|
rawDisplayName: "it's a me",
|
||||||
|
});
|
||||||
|
fakeMemberWith({ userId: "@alice:example.com", rawDisplayName: "Alice" });
|
||||||
|
fakeMemberWith({ userId: "@bob:example.com", rawDisplayName: "Bob" });
|
||||||
|
fakeMemberWith({ userId: "@carl:example.com", rawDisplayName: "Carl" });
|
||||||
|
fakeMemberWith({ userId: "@evil:example.com", rawDisplayName: "Carl" });
|
||||||
|
fakeMemberWith({ userId: "@bob:foo.bar", rawDisplayName: "Bob" });
|
||||||
|
fakeMemberWith({ userId: "@no-name:foo.bar" });
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should get displayName for users", () => {
|
||||||
|
setUpBasicRoom();
|
||||||
|
|
||||||
|
withTestScheduler(({ scope, behavior, expectObservable }) => {
|
||||||
|
const memberships$ = behavior("a", {
|
||||||
|
a: [
|
||||||
|
mockRtcMembership("@alice:example.com", "DEVICE1"),
|
||||||
|
mockRtcMembership("@bob:example.com", "DEVICE1"),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const metadataStore = createMatrixMemberMetadata$(
|
||||||
|
testScope,
|
||||||
|
memberships$,
|
||||||
|
createRoomMembers$(testScope, mockMatrixRoom),
|
||||||
|
);
|
||||||
|
const aliceDispName$ = metadataStore.createDisplayNameBehavior$(
|
||||||
|
scope,
|
||||||
|
"@alice:example.com",
|
||||||
|
);
|
||||||
|
|
||||||
|
expectObservable(aliceDispName$).toBe("a", {
|
||||||
|
a: "Alice",
|
||||||
|
});
|
||||||
|
|
||||||
|
expectObservable(metadataStore.displaynameMap$).toBe("a", {
|
||||||
|
a: new Map<string, string>([
|
||||||
|
["@alice:example.com", "Alice"],
|
||||||
|
["@bob:example.com", "Bob"],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use userId if no display name", () => {
|
||||||
|
withTestScheduler(({ behavior, expectObservable }) => {
|
||||||
|
setUpBasicRoom();
|
||||||
|
|
||||||
|
const memberships$ = behavior("a", {
|
||||||
|
a: [mockRtcMembership("@no-name:foo.bar", "D000")],
|
||||||
|
});
|
||||||
|
const metadataStore = createMatrixMemberMetadata$(
|
||||||
|
testScope,
|
||||||
|
memberships$,
|
||||||
|
createRoomMembers$(testScope, mockMatrixRoom),
|
||||||
|
);
|
||||||
|
|
||||||
|
expectObservable(metadataStore.displaynameMap$).toBe("a", {
|
||||||
|
a: new Map<string, string>([
|
||||||
|
["@no-name:foo.bar", "@no-name:foo.bar"],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should disambiguate users with same display name", () => {
|
||||||
|
withTestScheduler(({ behavior, expectObservable }) => {
|
||||||
|
setUpBasicRoom();
|
||||||
|
|
||||||
|
const memberships$ = behavior("a", {
|
||||||
|
a: [
|
||||||
|
mockRtcMembership("@bob:example.com", "DEVICE1"),
|
||||||
|
mockRtcMembership("@bob:example.com", "DEVICE2"),
|
||||||
|
mockRtcMembership("@bob:foo.bar", "BOB000"),
|
||||||
|
mockRtcMembership("@carl:example.com", "C000"),
|
||||||
|
mockRtcMembership("@evil:example.com", "E000"),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadataStore = createMatrixMemberMetadata$(
|
||||||
|
testScope,
|
||||||
|
memberships$,
|
||||||
|
createRoomMembers$(testScope, mockMatrixRoom),
|
||||||
|
);
|
||||||
|
|
||||||
|
expectObservable(metadataStore.displaynameMap$).toBe("a", {
|
||||||
|
a: new Map<string, string>([
|
||||||
|
// ["@local:example.com", "it's a me"],
|
||||||
|
["@bob:example.com", "Bob (@bob:example.com)"],
|
||||||
|
["@bob:example.com", "Bob (@bob:example.com)"],
|
||||||
|
["@bob:foo.bar", "Bob (@bob:foo.bar)"],
|
||||||
|
["@carl:example.com", "Carl (@carl:example.com)"],
|
||||||
|
["@evil:example.com", "Carl (@evil:example.com)"],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should start to disambiguate reactivly when needed", () => {
|
||||||
|
withTestScheduler(({ behavior, expectObservable }) => {
|
||||||
|
setUpBasicRoom();
|
||||||
|
|
||||||
|
const memberships$ = behavior("ab", {
|
||||||
|
a: [mockRtcMembership("@bob:example.com", "DEVICE1")],
|
||||||
|
b: [
|
||||||
|
mockRtcMembership("@bob:example.com", "DEVICE1"),
|
||||||
|
mockRtcMembership("@bob:foo.bar", "BOB000"),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadataStore = createMatrixMemberMetadata$(
|
||||||
|
testScope,
|
||||||
|
memberships$,
|
||||||
|
createRoomMembers$(testScope, mockMatrixRoom),
|
||||||
|
);
|
||||||
|
|
||||||
|
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
|
||||||
|
a: new Map<string, string>([["@bob:example.com", "Bob"]]),
|
||||||
|
b: new Map<string, string>([
|
||||||
|
["@bob:example.com", "Bob (@bob:example.com)"],
|
||||||
|
["@bob:foo.bar", "Bob (@bob:foo.bar)"],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should keep disambiguated name when other leave", () => {
|
||||||
|
withTestScheduler(({ behavior, expectObservable }) => {
|
||||||
|
setUpBasicRoom();
|
||||||
|
|
||||||
|
const memberships$ = behavior("ab", {
|
||||||
|
a: [
|
||||||
|
mockRtcMembership("@bob:example.com", "DEVICE1"),
|
||||||
|
mockRtcMembership("@bob:foo.bar", "BOB000"),
|
||||||
|
],
|
||||||
|
b: [mockRtcMembership("@bob:example.com", "DEVICE1")],
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadataStore = createMatrixMemberMetadata$(
|
||||||
|
testScope,
|
||||||
|
memberships$,
|
||||||
|
createRoomMembers$(testScope, mockMatrixRoom),
|
||||||
|
);
|
||||||
|
|
||||||
|
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
|
||||||
|
a: new Map<string, string>([
|
||||||
|
["@bob:example.com", "Bob (@bob:example.com)"],
|
||||||
|
["@bob:foo.bar", "Bob (@bob:foo.bar)"],
|
||||||
|
]),
|
||||||
|
b: new Map<string, string>([
|
||||||
|
["@bob:example.com", "Bob (@bob:example.com)"],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should disambiguate on name change", () => {
|
||||||
|
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||||
|
setUpBasicRoom();
|
||||||
|
|
||||||
|
const memberships$ = behavior("a", {
|
||||||
|
a: [
|
||||||
|
mockRtcMembership("@bob:example.com", "B000"),
|
||||||
|
mockRtcMembership("@carl:example.com", "C000"),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const metadataStore = createMatrixMemberMetadata$(
|
||||||
|
testScope,
|
||||||
|
memberships$,
|
||||||
|
createRoomMembers$(testScope, mockMatrixRoom),
|
||||||
|
);
|
||||||
|
|
||||||
|
schedule("-a", {
|
||||||
|
a: () => {
|
||||||
|
updateDisplayName("@carl:example.com", "Bob");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
|
||||||
|
a: new Map<string, string>([
|
||||||
|
["@bob:example.com", "Bob"],
|
||||||
|
["@carl:example.com", "Carl"],
|
||||||
|
]),
|
||||||
|
b: new Map<string, string>([
|
||||||
|
["@bob:example.com", "Bob (@bob:example.com)"],
|
||||||
|
["@carl:example.com", "Bob (@carl:example.com)"],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should track individual member id with createDisplayNameBehavior", () => {
|
||||||
|
withTestScheduler(({ scope, behavior, schedule, expectObservable }) => {
|
||||||
|
setUpBasicRoom();
|
||||||
|
const BOB = "@bob:example.com";
|
||||||
|
const CARL = "@carl:example.com";
|
||||||
|
// for this test we build a mock environment that does all possible changes:
|
||||||
|
// - memberships join/leave
|
||||||
|
// - room join/leave
|
||||||
|
// - disambiguate
|
||||||
|
const memberships$ = behavior("ab-d", {
|
||||||
|
a: [mockRtcMembership(CARL, "C000")],
|
||||||
|
b: [
|
||||||
|
mockRtcMembership(CARL, "C000"),
|
||||||
|
// bob joins
|
||||||
|
mockRtcMembership(BOB, "B000"),
|
||||||
|
],
|
||||||
|
// c carl gets renamed to BOB
|
||||||
|
d: [
|
||||||
|
// carl leaves
|
||||||
|
mockRtcMembership(BOB, "B000"),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
schedule("--a-", {
|
||||||
|
a: () => {
|
||||||
|
// carl renames
|
||||||
|
updateDisplayName(CARL, "Bob");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadataStore = createMatrixMemberMetadata$(
|
||||||
|
testScope,
|
||||||
|
memberships$,
|
||||||
|
createRoomMembers$(testScope, mockMatrixRoom),
|
||||||
|
);
|
||||||
|
|
||||||
|
const bob$ = metadataStore.createDisplayNameBehavior$(scope, BOB);
|
||||||
|
const carl$ = metadataStore.createDisplayNameBehavior$(scope, CARL);
|
||||||
|
|
||||||
|
expectObservable(bob$).toBe("abc-", {
|
||||||
|
a: undefined,
|
||||||
|
b: "Bob",
|
||||||
|
c: "Bob (@bob:example.com)",
|
||||||
|
// bob stays disambiguate even though carl left
|
||||||
|
// d: "Bob (@bob:example.com)",
|
||||||
|
});
|
||||||
|
|
||||||
|
expectObservable(carl$).toBe("a-cd", {
|
||||||
|
a: "Carl",
|
||||||
|
// b: "Carl",
|
||||||
|
// carl gets renamed and disambiguate
|
||||||
|
c: "Bob (@carl:example.com)",
|
||||||
|
d: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should disambiguate users with invisible characters", () => {
|
||||||
|
withTestScheduler(({ scope, behavior, expectObservable }) => {
|
||||||
|
const bobRtcMember = mockRtcMembership("@bob:example.org", "BBBB");
|
||||||
|
const bobZeroWidthSpaceRtcMember = mockRtcMembership(
|
||||||
|
"@bob2:example.org",
|
||||||
|
"BBBB",
|
||||||
|
);
|
||||||
|
const bob = mockMatrixRoomMember(bobRtcMember, {
|
||||||
|
rawDisplayName: "Bob",
|
||||||
|
});
|
||||||
|
const bobZeroWidthSpace = mockMatrixRoomMember(
|
||||||
|
bobZeroWidthSpaceRtcMember,
|
||||||
|
{
|
||||||
|
rawDisplayName: "Bo\u200bb",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
fakeMemberWith(bob);
|
||||||
|
fakeMemberWith(bobZeroWidthSpace);
|
||||||
|
fakeMemberWith({ userId: "@carol:example.org" });
|
||||||
|
const memberships$ = behavior("ab", {
|
||||||
|
a: [mockRtcMembership("@carol:example.org", "1111"), bobRtcMember],
|
||||||
|
b: [
|
||||||
|
mockRtcMembership("@carol:example.org", "1111"),
|
||||||
|
bobRtcMember,
|
||||||
|
bobZeroWidthSpaceRtcMember,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadataStore = createMatrixMemberMetadata$(
|
||||||
|
testScope,
|
||||||
|
memberships$,
|
||||||
|
createRoomMembers$(testScope, mockMatrixRoom),
|
||||||
|
);
|
||||||
|
|
||||||
|
const bob$ = metadataStore.createDisplayNameBehavior$(
|
||||||
|
scope,
|
||||||
|
"@bob:example.org",
|
||||||
|
);
|
||||||
|
const bob2$ = metadataStore.createDisplayNameBehavior$(
|
||||||
|
scope,
|
||||||
|
"@bob2:example.org",
|
||||||
|
);
|
||||||
|
const carol$ = metadataStore.createDisplayNameBehavior$(
|
||||||
|
scope,
|
||||||
|
"@carol:example.org",
|
||||||
|
);
|
||||||
|
expectObservable(bob$).toBe("ab", {
|
||||||
|
a: "Bob",
|
||||||
|
b: "Bob (@bob:example.org)",
|
||||||
|
});
|
||||||
|
expectObservable(bob2$).toBe("ab", {
|
||||||
|
a: undefined,
|
||||||
|
b: "Bo\u200bb (@bob2:example.org)",
|
||||||
|
});
|
||||||
|
expectObservable(carol$).toBe("a-", {
|
||||||
|
a: "@carol:example.org",
|
||||||
|
});
|
||||||
|
|
||||||
|
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
|
||||||
|
// Carol has no displayname - So userId is used.
|
||||||
|
a: new Map([
|
||||||
|
["@carol:example.org", "@carol:example.org"],
|
||||||
|
["@bob:example.org", "Bob"],
|
||||||
|
]),
|
||||||
|
// Other Bob joins, and should handle zero width hacks.
|
||||||
|
b: new Map([
|
||||||
|
["@carol:example.org", "@carol:example.org"],
|
||||||
|
[bobRtcMember.userId, `Bob (@bob:example.org)`],
|
||||||
|
[
|
||||||
|
bobZeroWidthSpace.userId,
|
||||||
|
`${bobZeroWidthSpace.rawDisplayName} (${bobZeroWidthSpace.userId})`,
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should strip RTL characters from displayname", () => {
|
||||||
|
withTestScheduler(({ behavior, expectObservable }) => {
|
||||||
|
const daveRtcMember = mockRtcMembership("@dave:example.org", "DDDD");
|
||||||
|
const daveRTLRtcMember = mockRtcMembership(
|
||||||
|
"@dave2:example.org",
|
||||||
|
"DDDD",
|
||||||
|
);
|
||||||
|
const dave = mockMatrixRoomMember(daveRtcMember, {
|
||||||
|
rawDisplayName: "Dave",
|
||||||
|
});
|
||||||
|
const daveRTL = mockMatrixRoomMember(daveRTLRtcMember, {
|
||||||
|
rawDisplayName: "\u202eevaD",
|
||||||
|
});
|
||||||
|
|
||||||
|
fakeMemberWith({ userId: "@carol:example.org" });
|
||||||
|
fakeMemberWith(daveRTL);
|
||||||
|
fakeMemberWith(dave);
|
||||||
|
const memberships$ = behavior("ab", {
|
||||||
|
a: [mockRtcMembership("@carol:example.org", "DDDD")],
|
||||||
|
b: [
|
||||||
|
mockRtcMembership("@carol:example.org", "DDDD"),
|
||||||
|
daveRtcMember,
|
||||||
|
daveRTLRtcMember,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadataStore = createMatrixMemberMetadata$(
|
||||||
|
testScope,
|
||||||
|
memberships$,
|
||||||
|
createRoomMembers$(testScope, mockMatrixRoom),
|
||||||
|
);
|
||||||
|
|
||||||
|
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
|
||||||
|
// Carol has no displayname - So userId is used.
|
||||||
|
a: new Map([["@carol:example.org", "@carol:example.org"]]),
|
||||||
|
// Both Dave's join. Since after stripping
|
||||||
|
b: new Map([
|
||||||
|
["@carol:example.org", "@carol:example.org"],
|
||||||
|
// Not disambiguated
|
||||||
|
["@dave:example.org", "Dave"],
|
||||||
|
// This one is, since it's using RTL.
|
||||||
|
["@dave2:example.org", "evaD (@dave2:example.org)"],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("avatarUrl", () => {
|
||||||
|
function updateAvatarUrl(
|
||||||
|
userId: `@${string}:${string}`,
|
||||||
|
avatarUrl: string,
|
||||||
|
): void {
|
||||||
|
const member = fakeMembersMap.get(userId);
|
||||||
|
if (member) {
|
||||||
|
member.getMxcAvatarUrl = vi.fn().mockReturnValue(avatarUrl);
|
||||||
|
// Emit the event to notify listeners
|
||||||
|
mockMatrixRoom.emit(
|
||||||
|
RoomStateEvent.Members,
|
||||||
|
{} as unknown as MatrixEvent,
|
||||||
|
{} as unknown as RoomState,
|
||||||
|
member as RoomMember,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new Error(`No member found with userId: ${userId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should use avatar url from room members", () => {
|
||||||
|
withTestScheduler(({ scope, behavior, expectObservable }) => {
|
||||||
|
fakeMemberWith({
|
||||||
|
userId: "@local:example.com",
|
||||||
|
});
|
||||||
|
fakeMemberWith({
|
||||||
|
userId: "@alice:example.com",
|
||||||
|
getMxcAvatarUrl: vi.fn().mockReturnValue("mxc://custom.url/avatar"),
|
||||||
|
});
|
||||||
|
const memberships$ = behavior("a", {
|
||||||
|
a: [
|
||||||
|
mockRtcMembership("@local:example.com", "DEVICE1"),
|
||||||
|
mockRtcMembership("@alice:example.com", "DEVICE1"),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const metadataStore = createMatrixMemberMetadata$(
|
||||||
|
testScope,
|
||||||
|
memberships$,
|
||||||
|
createRoomMembers$(testScope, mockMatrixRoom),
|
||||||
|
);
|
||||||
|
const local$ = metadataStore.createAvatarUrlBehavior$(
|
||||||
|
scope,
|
||||||
|
"@local:example.com",
|
||||||
|
);
|
||||||
|
|
||||||
|
const alice$ = metadataStore.createAvatarUrlBehavior$(
|
||||||
|
scope,
|
||||||
|
"@alice:example.com",
|
||||||
|
);
|
||||||
|
|
||||||
|
expectObservable(local$).toBe("a", {
|
||||||
|
a: "mxc://example.com/@local:example.com",
|
||||||
|
});
|
||||||
|
expectObservable(alice$).toBe("a", {
|
||||||
|
a: "mxc://custom.url/avatar",
|
||||||
|
});
|
||||||
|
expectObservable(metadataStore.avatarMap$).toBe("a", {
|
||||||
|
a: new Map<string, string>([
|
||||||
|
["@local:example.com", "mxc://example.com/@local:example.com"],
|
||||||
|
["@alice:example.com", "mxc://custom.url/avatar"],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update on avatar change and user join/leave", () => {
|
||||||
|
withTestScheduler(({ scope, behavior, schedule, expectObservable }) => {
|
||||||
|
fakeMemberWith({ userId: "@carl:example.com" });
|
||||||
|
fakeMemberWith({ userId: "@bob:example.com" });
|
||||||
|
const memberships$ = behavior("ab-d", {
|
||||||
|
a: [mockRtcMembership("@bob:example.com", "B000")],
|
||||||
|
b: [
|
||||||
|
mockRtcMembership("@bob:example.com", "B000"),
|
||||||
|
mockRtcMembership("@carl:example.com", "C000"),
|
||||||
|
],
|
||||||
|
d: [mockRtcMembership("@carl:example.com", "C000")],
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadataStore = createMatrixMemberMetadata$(
|
||||||
|
testScope,
|
||||||
|
memberships$,
|
||||||
|
createRoomMembers$(testScope, mockMatrixRoom),
|
||||||
|
);
|
||||||
|
|
||||||
|
schedule("--c-", {
|
||||||
|
c: () => {
|
||||||
|
updateAvatarUrl(
|
||||||
|
"@carl:example.com",
|
||||||
|
"mxc://updated.me/updatedAvatar",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const bob$ = metadataStore.createAvatarUrlBehavior$(
|
||||||
|
scope,
|
||||||
|
"@bob:example.com",
|
||||||
|
);
|
||||||
|
const carl$ = metadataStore.createAvatarUrlBehavior$(
|
||||||
|
scope,
|
||||||
|
"@carl:example.com",
|
||||||
|
);
|
||||||
|
expectObservable(bob$).toBe("a---", {
|
||||||
|
a: "mxc://example.com/@bob:example.com",
|
||||||
|
});
|
||||||
|
expectObservable(carl$).toBe("a-c-", {
|
||||||
|
a: "mxc://example.com/@carl:example.com",
|
||||||
|
|
||||||
|
c: "mxc://updated.me/updatedAvatar",
|
||||||
|
});
|
||||||
|
expectObservable(metadataStore.avatarMap$).toBe("a-c-", {
|
||||||
|
a: new Map<string, string>([
|
||||||
|
["@bob:example.com", "mxc://example.com/@bob:example.com"],
|
||||||
|
["@carl:example.com", "mxc://example.com/@carl:example.com"],
|
||||||
|
]),
|
||||||
|
// expect an update once we update the avatar URL
|
||||||
|
c: new Map<string, string>([
|
||||||
|
["@bob:example.com", "mxc://example.com/@bob:example.com"],
|
||||||
|
["@carl:example.com", "mxc://updated.me/updatedAvatar"],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type RoomMember, RoomStateEvent } from "matrix-js-sdk";
|
||||||
|
import { combineLatest, fromEvent, map } from "rxjs";
|
||||||
|
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import {
|
||||||
|
KnownMembership,
|
||||||
|
type Room as MatrixRoom,
|
||||||
|
} from "matrix-js-sdk/lib/matrix";
|
||||||
|
// eslint-disable-next-line rxjs/no-internal
|
||||||
|
|
||||||
|
import { type ObservableScope } from "../reactive/ObservableScope";
|
||||||
|
import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname";
|
||||||
|
import { type Behavior } from "../reactive/Behavior";
|
||||||
|
|
||||||
|
export type RoomMemberMap = Map<
|
||||||
|
string,
|
||||||
|
Pick<RoomMember, "userId" | "getMxcAvatarUrl" | "rawDisplayName">
|
||||||
|
>;
|
||||||
|
export function roomToMembersMap(matrixRoom: MatrixRoom): RoomMemberMap {
|
||||||
|
const members = matrixRoom
|
||||||
|
.getMembersWithMembership(KnownMembership.Join)
|
||||||
|
.concat(matrixRoom.getMembersWithMembership(KnownMembership.Invite));
|
||||||
|
return members.reduce((acc, member) => {
|
||||||
|
acc.set(member.userId, {
|
||||||
|
userId: member.userId,
|
||||||
|
getMxcAvatarUrl: member.getMxcAvatarUrl.bind(member),
|
||||||
|
rawDisplayName: member.rawDisplayName,
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
}, new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRoomMembers$(
|
||||||
|
scope: ObservableScope,
|
||||||
|
matrixRoom: MatrixRoom,
|
||||||
|
): Behavior<RoomMemberMap> {
|
||||||
|
return scope.behavior(
|
||||||
|
fromEvent(matrixRoom, RoomStateEvent.Members).pipe(
|
||||||
|
map(() => roomToMembersMap(matrixRoom)),
|
||||||
|
),
|
||||||
|
roomToMembersMap(matrixRoom),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displayname for each member of the call. This will disambiguate
|
||||||
|
* any displayname that clashes with another member. Only members
|
||||||
|
* joined to the call are considered here.
|
||||||
|
*
|
||||||
|
* @returns Map<userId, displayname> uses the Matrix user ID as the key.
|
||||||
|
*/
|
||||||
|
// don't do this work more times than we need to. This is achieved by converting to a behavior:
|
||||||
|
export const memberDisplaynames$ = (
|
||||||
|
scope: ObservableScope,
|
||||||
|
memberships$: Behavior<Pick<CallMembership, "userId">[]>,
|
||||||
|
roomMembers$: Behavior<RoomMemberMap>,
|
||||||
|
): Behavior<Map<string, string>> => {
|
||||||
|
const logger = rootLogger.getChild("[MatrixMemberMetadata]");
|
||||||
|
// This map tracks userIds that at some point needed disambiguation.
|
||||||
|
// This is a memory leak bound to the number of participants.
|
||||||
|
// A call application will always increase the memory if there have been more members in a call.
|
||||||
|
// Its capped by room member participants.
|
||||||
|
const shouldDisambiguateTrackerMap = new Set<string>();
|
||||||
|
return scope.behavior(
|
||||||
|
combineLatest([
|
||||||
|
// Handle call membership changes
|
||||||
|
memberships$,
|
||||||
|
// Additionally handle display name changes (implicitly reacting to them)
|
||||||
|
roomMembers$,
|
||||||
|
// TODO: do we need: pauseWhen(this.pretendToBeDisconnected$),
|
||||||
|
]).pipe(
|
||||||
|
map(([memberships, roomMembers]) => {
|
||||||
|
const displaynameMap = new Map<string, string>();
|
||||||
|
// We only consider RTC members for disambiguation as they are the only visible members.
|
||||||
|
for (const rtcMember of memberships) {
|
||||||
|
const member = roomMembers.get(rtcMember.userId);
|
||||||
|
if (!member) {
|
||||||
|
logger.error(`Could not find member for user ${rtcMember.userId}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const disambiguateComputed = shouldDisambiguate(
|
||||||
|
member,
|
||||||
|
memberships,
|
||||||
|
roomMembers,
|
||||||
|
);
|
||||||
|
|
||||||
|
const disambiguate =
|
||||||
|
shouldDisambiguateTrackerMap.has(rtcMember.userId) ||
|
||||||
|
disambiguateComputed;
|
||||||
|
if (disambiguate) shouldDisambiguateTrackerMap.add(rtcMember.userId);
|
||||||
|
displaynameMap.set(
|
||||||
|
rtcMember.userId,
|
||||||
|
calculateDisplayName(member, disambiguate),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return displaynameMap;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createMatrixMemberMetadata$ = (
|
||||||
|
scope: ObservableScope,
|
||||||
|
memberships$: Behavior<Pick<CallMembership, "userId">[]>,
|
||||||
|
roomMembers$: Behavior<RoomMemberMap>,
|
||||||
|
): {
|
||||||
|
createDisplayNameBehavior$: (
|
||||||
|
scope: ObservableScope,
|
||||||
|
userId: string,
|
||||||
|
) => Behavior<string | undefined>;
|
||||||
|
createAvatarUrlBehavior$: (
|
||||||
|
scope: ObservableScope,
|
||||||
|
userId: string,
|
||||||
|
) => Behavior<string | undefined>;
|
||||||
|
displaynameMap$: Behavior<Map<string, string>>;
|
||||||
|
avatarMap$: Behavior<Map<string, string | undefined>>;
|
||||||
|
} => {
|
||||||
|
const displaynameMap$ = memberDisplaynames$(
|
||||||
|
scope,
|
||||||
|
memberships$,
|
||||||
|
roomMembers$,
|
||||||
|
);
|
||||||
|
const avatarMap$ = scope.behavior(
|
||||||
|
roomMembers$.pipe(
|
||||||
|
map((roomMembers) =>
|
||||||
|
Array.from(roomMembers.keys()).reduce((acc, key) => {
|
||||||
|
acc.set(key, roomMembers.get(key)?.getMxcAvatarUrl());
|
||||||
|
return acc;
|
||||||
|
}, new Map<string, string | undefined>()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
createDisplayNameBehavior$: (scope: ObservableScope, userId: string) =>
|
||||||
|
scope.behavior(
|
||||||
|
displaynameMap$.pipe(
|
||||||
|
map((displaynameMap) => displaynameMap.get(userId)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
createAvatarUrlBehavior$: (scope: ObservableScope, userId: string) =>
|
||||||
|
scope.behavior(
|
||||||
|
roomMembers$.pipe(
|
||||||
|
map((roomMembers) => roomMembers.get(userId)?.getMxcAvatarUrl()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// mostly for testing purposes
|
||||||
|
displaynameMap$,
|
||||||
|
avatarMap$,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||||
|
import { distinctUntilChanged, map } from "rxjs";
|
||||||
|
|
||||||
|
import { type Behavior } from "../reactive/Behavior";
|
||||||
|
import { type ObservableScope } from "../reactive/ObservableScope";
|
||||||
|
import { type createMatrixMemberMetadata$ } from "./MatrixMemberMetadata";
|
||||||
|
import {
|
||||||
|
type LocalRTCMember,
|
||||||
|
type RemoteRTCMember,
|
||||||
|
type RTCMember,
|
||||||
|
} from "../api";
|
||||||
|
import { type LocalMemberMedia, type MemberMedia } from "../media-api";
|
||||||
|
import { type TransportRegistry } from "./Transports";
|
||||||
|
|
||||||
|
/** What every member is built from, besides its own membership. */
|
||||||
|
export interface MemberContext {
|
||||||
|
metadata: ReturnType<typeof createMatrixMemberMetadata$>;
|
||||||
|
transports: TransportRegistry;
|
||||||
|
/** Whose memberships are `local`. */
|
||||||
|
own: { userId: string; deviceId: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything that tells one membership from another, for keying items. */
|
||||||
|
export function membershipKeys(
|
||||||
|
membership: CallMembership,
|
||||||
|
): [string, string, string, string] {
|
||||||
|
return [
|
||||||
|
membership.userId,
|
||||||
|
membership.deviceId,
|
||||||
|
membership.memberId,
|
||||||
|
membership.rtcBackendIdentity,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The slot's view of a membership: identity, room state and transport, no media. */
|
||||||
|
export function createRTCMember(
|
||||||
|
scope: ObservableScope,
|
||||||
|
membership$: Behavior<CallMembership>,
|
||||||
|
{ metadata, transports, own }: MemberContext,
|
||||||
|
): RTCMember {
|
||||||
|
const { userId, deviceId, memberId, rtcBackendIdentity, backendIdentities } =
|
||||||
|
membership$.value;
|
||||||
|
return {
|
||||||
|
local: userId === own.userId && deviceId === own.deviceId,
|
||||||
|
// Read off the membership for now; the backend will derive them once the
|
||||||
|
// identity scheme is its own and not the js-sdk's
|
||||||
|
rtcBackendIdentity,
|
||||||
|
backendIdentities,
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
memberId,
|
||||||
|
displayName$: scope.behavior(
|
||||||
|
metadata
|
||||||
|
.createDisplayNameBehavior$(scope, userId)
|
||||||
|
.pipe(map((name) => name ?? userId)),
|
||||||
|
),
|
||||||
|
avatarUrl$: metadata.createAvatarUrlBehavior$(scope, userId),
|
||||||
|
transport$: scope.behavior(
|
||||||
|
membership$.pipe(
|
||||||
|
map((membership) => membership.getTransport()),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
map((transport) => transport && transports.get(transport)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
applicationData$: scope.behavior(
|
||||||
|
membership$.pipe(
|
||||||
|
map((membership) => membership.applicationData),
|
||||||
|
distinctUntilChanged(deepCompare),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The participation's view of a remote member: the slot's member plus its media. */
|
||||||
|
export function createRemoteRTCMember(
|
||||||
|
member: RTCMember,
|
||||||
|
media: MemberMedia,
|
||||||
|
): RemoteRTCMember {
|
||||||
|
return { ...member, local: false, ...media };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLocalRTCMember(
|
||||||
|
member: RTCMember,
|
||||||
|
media: LocalMemberMedia,
|
||||||
|
): LocalRTCMember {
|
||||||
|
return { ...member, local: true, ...media };
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
type MockedObject,
|
||||||
|
vi,
|
||||||
|
} from "vitest";
|
||||||
|
import { MatrixError } from "matrix-js-sdk";
|
||||||
|
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
|
||||||
|
import {
|
||||||
|
TransportAutoDiscovery,
|
||||||
|
type TransportAutoDiscoveryProps,
|
||||||
|
} from "./TransportAutoDiscovery.ts";
|
||||||
|
|
||||||
|
type DiscoveryClient = TransportAutoDiscoveryProps["client"];
|
||||||
|
|
||||||
|
const backendTransport: Transport = {
|
||||||
|
type: "livekit",
|
||||||
|
livekit_service_url: "https://backend.example.org",
|
||||||
|
};
|
||||||
|
|
||||||
|
const configTransport: Transport = {
|
||||||
|
type: "livekit",
|
||||||
|
livekit_service_url: "https://config.example.org",
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeClient(): MockedObject<DiscoveryClient> {
|
||||||
|
return {
|
||||||
|
getDomain: vi.fn().mockReturnValue("example.org"),
|
||||||
|
baseUrl: "https://matrix.example.org",
|
||||||
|
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
|
||||||
|
getOpenIdToken: vi.fn(),
|
||||||
|
getDeviceId: vi.fn(),
|
||||||
|
} as unknown as MockedObject<DiscoveryClient>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TransportAutoDiscovery", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
const VALID_TEST_CASES: Array<{ transports: Transport[] }> = [
|
||||||
|
{ transports: [backendTransport] },
|
||||||
|
// will pick the first livekit transport in the list, even if there are other non-livekit transports
|
||||||
|
{ transports: [{ type: "not_livekit" }, backendTransport] },
|
||||||
|
];
|
||||||
|
it.each(VALID_TEST_CASES)(
|
||||||
|
"prefers backend transport other app config $transports",
|
||||||
|
async ({ transports }) => {
|
||||||
|
const client = makeClient();
|
||||||
|
client._unstable_getRTCTransports.mockResolvedValue(transports);
|
||||||
|
|
||||||
|
const discovery = new TransportAutoDiscovery({
|
||||||
|
client,
|
||||||
|
transportType: "livekit",
|
||||||
|
fallbackTransport: configTransport,
|
||||||
|
logger: rootLogger,
|
||||||
|
});
|
||||||
|
|
||||||
|
const discoveredTransport = await discovery.discoverPreferredTransport();
|
||||||
|
|
||||||
|
expect(discoveredTransport).toStrictEqual(backendTransport);
|
||||||
|
expect(discoveredTransport).not.toStrictEqual(configTransport);
|
||||||
|
|
||||||
|
expect(client._unstable_getRTCTransports).toHaveBeenCalledTimes(1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("Retries limit_exceeded backend transport", async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
client._unstable_getRTCTransports
|
||||||
|
.mockRejectedValueOnce(
|
||||||
|
new MatrixError(
|
||||||
|
{
|
||||||
|
errcode: "M_LIMIT_EXCEEDED",
|
||||||
|
error: "Too many requests",
|
||||||
|
retry_after_ms: 100,
|
||||||
|
},
|
||||||
|
429,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.mockResolvedValue([backendTransport]);
|
||||||
|
|
||||||
|
const discovery = new TransportAutoDiscovery({
|
||||||
|
client,
|
||||||
|
transportType: "livekit",
|
||||||
|
fallbackTransport: configTransport,
|
||||||
|
logger: rootLogger,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual(
|
||||||
|
backendTransport,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(client._unstable_getRTCTransports).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const INVALID_TEST_CASES: Array<{ transports: Transport[] }> = [
|
||||||
|
{ transports: [] },
|
||||||
|
{ transports: [{ type: "not_livekit" }] },
|
||||||
|
];
|
||||||
|
it.each(INVALID_TEST_CASES)(
|
||||||
|
"falls back to config when backend has no (valid) livekit transports $transports",
|
||||||
|
async ({ transports }) => {
|
||||||
|
const client = makeClient();
|
||||||
|
client._unstable_getRTCTransports.mockResolvedValue(transports);
|
||||||
|
|
||||||
|
const discovery = new TransportAutoDiscovery({
|
||||||
|
client,
|
||||||
|
transportType: "livekit",
|
||||||
|
fallbackTransport: configTransport,
|
||||||
|
logger: rootLogger,
|
||||||
|
});
|
||||||
|
|
||||||
|
const discoveredTransport = await discovery.discoverPreferredTransport();
|
||||||
|
expect(discoveredTransport).not.toStrictEqual(backendTransport);
|
||||||
|
expect(discoveredTransport).toStrictEqual(configTransport);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("returns null when backend and config are all unavailable", async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
client._unstable_getRTCTransports.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const discovery = new TransportAutoDiscovery({
|
||||||
|
client,
|
||||||
|
transportType: "livekit",
|
||||||
|
fallbackTransport: undefined,
|
||||||
|
logger: rootLogger,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(discovery.discoverPreferredTransport()).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { type MatrixClient } from "matrix-js-sdk";
|
||||||
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
|
import { doNetworkOperationWithRetry } from "../utils/network";
|
||||||
|
|
||||||
|
type TransportDiscoveryClient = Pick<
|
||||||
|
MatrixClient,
|
||||||
|
"getDomain" | "_unstable_getRTCTransports"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface TransportAutoDiscoveryProps {
|
||||||
|
client: TransportDiscoveryClient;
|
||||||
|
/** The transport type the media backend serves. */
|
||||||
|
transportType: string;
|
||||||
|
/** A transport to use when the homeserver advertises none of the type. */
|
||||||
|
fallbackTransport?: Transport;
|
||||||
|
logger: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TransportAutoDiscovery {
|
||||||
|
private readonly client: TransportDiscoveryClient;
|
||||||
|
private readonly transportType: string;
|
||||||
|
private readonly fallbackTransport: Transport | undefined;
|
||||||
|
private readonly logger: Logger;
|
||||||
|
|
||||||
|
public constructor({
|
||||||
|
client,
|
||||||
|
transportType,
|
||||||
|
fallbackTransport,
|
||||||
|
logger,
|
||||||
|
}: TransportAutoDiscoveryProps) {
|
||||||
|
this.client = client;
|
||||||
|
this.transportType = transportType;
|
||||||
|
this.fallbackTransport = fallbackTransport;
|
||||||
|
this.logger = logger.getChild("[TransportAutoDiscovery]");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async discoverPreferredTransport(): Promise<Transport | null> {
|
||||||
|
// 1) backend transports
|
||||||
|
const backendTransport = await this.tryBackendTransports();
|
||||||
|
if (backendTransport) {
|
||||||
|
this.logger.info(
|
||||||
|
`Found backend transport: ${JSON.stringify(backendTransport)}`,
|
||||||
|
);
|
||||||
|
return backendTransport;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) the host's fallback
|
||||||
|
if (this.fallbackTransport) {
|
||||||
|
this.logger.info(
|
||||||
|
`Found fallback transport: ${JSON.stringify(this.fallbackTransport)}`,
|
||||||
|
);
|
||||||
|
return this.fallbackTransport;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the first transport of the backend's type from the homeserver.
|
||||||
|
* This will not throw errors, but instead just log them and return null if the expected config is not found or malformed.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async tryBackendTransports(): Promise<Transport | null> {
|
||||||
|
const client = this.client;
|
||||||
|
// MSC4143: Attempt to fetch transports from backend.
|
||||||
|
this.logger.info("First try to use getRTCTransports end point ...");
|
||||||
|
try {
|
||||||
|
const transportList = await doNetworkOperationWithRetry(async () =>
|
||||||
|
client._unstable_getRTCTransports(),
|
||||||
|
);
|
||||||
|
const first = transportList.find(
|
||||||
|
(transport) => transport.type === this.transportType,
|
||||||
|
);
|
||||||
|
if (first) {
|
||||||
|
return first;
|
||||||
|
} else {
|
||||||
|
this.logger.info(
|
||||||
|
`No ${this.transportType} transport found in getRTCTransports end point`,
|
||||||
|
transportList,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
this.logger.info(`Failed to use getRTCTransports end point: ${ex}`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { type ResolvedTransport } from "../api";
|
||||||
|
import {
|
||||||
|
type BackendConnection,
|
||||||
|
MediaConnectionState,
|
||||||
|
} from "../media-backend/api";
|
||||||
|
import { testScope } from "../utils/test";
|
||||||
|
import { createTransportRegistry, transportId } from "./Transports";
|
||||||
|
|
||||||
|
const sfu: Transport = { type: "livekit", livekit_service_url: "https://sfu" };
|
||||||
|
const resolved: ResolvedTransport = {
|
||||||
|
type: "livekit",
|
||||||
|
url: "wss://sfu",
|
||||||
|
token: "jwt",
|
||||||
|
roomAlias: "room",
|
||||||
|
identity: "me",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("transportId", () => {
|
||||||
|
it("does not depend on the order a sender wrote the keys in", () => {
|
||||||
|
expect(transportId({ type: "livekit", livekit_service_url: "u" })).toBe(
|
||||||
|
transportId({ livekit_service_url: "u", type: "livekit" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tells transports of the same type apart", () => {
|
||||||
|
expect(transportId(sfu)).not.toBe(
|
||||||
|
transportId({ ...sfu, livekit_service_url: "https://other" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createTransportRegistry", () => {
|
||||||
|
it("hands out one metadata object per transport", () => {
|
||||||
|
const registry = createTransportRegistry(
|
||||||
|
testScope(),
|
||||||
|
new BehaviorSubject<BackendConnection[]>([]),
|
||||||
|
);
|
||||||
|
expect(registry.get(sfu)).toBe(registry.get({ ...sfu }));
|
||||||
|
expect(registry.get(sfu).id).toBe(transportId(sfu));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives the connected transports and what was resolved from the backend's connections", () => {
|
||||||
|
const connections$ = new BehaviorSubject<BackendConnection[]>([]);
|
||||||
|
const registry = createTransportRegistry(testScope(), connections$);
|
||||||
|
const metadata = registry.get(sfu);
|
||||||
|
expect(registry.connected$.value).toEqual([]);
|
||||||
|
expect(metadata.resolved$.value).toBeUndefined();
|
||||||
|
|
||||||
|
connections$.next([
|
||||||
|
{ transport: sfu, state: MediaConnectionState.Connecting, resolved },
|
||||||
|
]);
|
||||||
|
expect(registry.connected$.value).toEqual([]);
|
||||||
|
expect(metadata.resolved$.value).toBe(resolved);
|
||||||
|
|
||||||
|
connections$.next([
|
||||||
|
{ transport: sfu, state: MediaConnectionState.Connected, resolved },
|
||||||
|
]);
|
||||||
|
expect(registry.connected$.value).toEqual([metadata]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { distinctUntilChanged, map } from "rxjs";
|
||||||
|
|
||||||
|
import { type Behavior } from "../reactive/Behavior";
|
||||||
|
import { type ObservableScope } from "../reactive/ObservableScope";
|
||||||
|
import { type TransportMetadata } from "../api";
|
||||||
|
import { type MediaBackend, MediaConnectionState } from "../media-backend/api";
|
||||||
|
|
||||||
|
export interface TransportRegistry {
|
||||||
|
/** The one `TransportMetadata` for a transport, however many memberships name it. */
|
||||||
|
get(raw: Transport): TransportMetadata;
|
||||||
|
/** Transports with a live connection. */
|
||||||
|
connected$: Behavior<TransportMetadata[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTransportRegistry(
|
||||||
|
scope: ObservableScope,
|
||||||
|
connections$: MediaBackend["connections$"],
|
||||||
|
): TransportRegistry {
|
||||||
|
const transports = new Map<string, TransportMetadata>();
|
||||||
|
|
||||||
|
const get = (raw: Transport): TransportMetadata => {
|
||||||
|
const id = transportId(raw);
|
||||||
|
let transport = transports.get(id);
|
||||||
|
if (transport === undefined) {
|
||||||
|
transport = createTransportMetadata(scope, connections$, raw, id);
|
||||||
|
transports.set(id, transport);
|
||||||
|
}
|
||||||
|
return transport;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deduplicated: a backend may hold more than one connection to a transport,
|
||||||
|
// one per homeserver it has to authenticate through
|
||||||
|
const connected$ = scope.behavior(
|
||||||
|
connections$.pipe(
|
||||||
|
map((connections) => [
|
||||||
|
...new Set(
|
||||||
|
connections
|
||||||
|
.filter(({ state }) => state === MediaConnectionState.Connected)
|
||||||
|
.map(({ transport }) => get(transport)),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { get, connected$ };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTransportMetadata(
|
||||||
|
scope: ObservableScope,
|
||||||
|
connections$: MediaBackend["connections$"],
|
||||||
|
raw: Transport,
|
||||||
|
id: string,
|
||||||
|
): TransportMetadata {
|
||||||
|
return {
|
||||||
|
type: raw.type,
|
||||||
|
id,
|
||||||
|
raw,
|
||||||
|
resolved$: scope.behavior(
|
||||||
|
connections$.pipe(
|
||||||
|
map(
|
||||||
|
(connections) =>
|
||||||
|
connections.find((c) => transportId(c.transport) === id)?.resolved,
|
||||||
|
),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transports are plain JSON out of membership events, so the same transport
|
||||||
|
* serialises the same way wherever it appears. Keys are sorted so that the
|
||||||
|
* order a sender wrote them in does not matter.
|
||||||
|
*/
|
||||||
|
export function transportId(raw: Transport): string {
|
||||||
|
return JSON.stringify(raw, Object.keys(raw).sort());
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type MatrixRTCSession,
|
||||||
|
type Transport,
|
||||||
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { describe, expect, it, type Mock, vi } from "vitest";
|
||||||
|
|
||||||
|
import { defaultSessionTimings, MatrixRTCMode } from "../config";
|
||||||
|
import { ownMemberMock } from "../utils/test";
|
||||||
|
import { joinJsSdkSession } from "./joinJsSdkSession";
|
||||||
|
|
||||||
|
const transport: Transport = {
|
||||||
|
type: "livekit",
|
||||||
|
livekit_service_url: "https://lk.example.org",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A session that only records the join; nothing else of it is touched. */
|
||||||
|
function session(): MatrixRTCSession & {
|
||||||
|
join: Mock<MatrixRTCSession["join"]>;
|
||||||
|
} {
|
||||||
|
return { join: vi.fn() } as unknown as MatrixRTCSession & {
|
||||||
|
join: Mock<MatrixRTCSession["join"]>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("joinJsSdkSession", () => {
|
||||||
|
it("joins with the identity, the transport and the delayed leave timings it was given", () => {
|
||||||
|
const s = session();
|
||||||
|
const timings = {
|
||||||
|
...defaultSessionTimings,
|
||||||
|
waitForKeyRotationMs: 500,
|
||||||
|
membershipEventExpiryMs: 60_000,
|
||||||
|
keyRotationParticipantLimit: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
joinJsSdkSession(s, ownMemberMock, transport, {
|
||||||
|
encryptMedia: true,
|
||||||
|
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||||
|
sendNotificationType: "ring",
|
||||||
|
timings,
|
||||||
|
delayedLeave: {
|
||||||
|
delay_ms: 3_600_000,
|
||||||
|
restart_ms: 300_000,
|
||||||
|
restart_timeout_ms: 5_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(s.join).toHaveBeenCalledWith(ownMemberMock, [transport], {
|
||||||
|
notificationType: "ring",
|
||||||
|
callIntent: undefined,
|
||||||
|
applicationData: {},
|
||||||
|
manageMediaKeys: true,
|
||||||
|
delayedLeaveEventRestartMs: 300_000,
|
||||||
|
delayedLeaveEventDelayMs: 3_600_000,
|
||||||
|
delayedLeaveEventRestartLocalTimeoutMs: 5_000,
|
||||||
|
networkErrorRetryMs: 1_000,
|
||||||
|
makeKeyDelay: 500,
|
||||||
|
membershipEventExpiryMs: 60_000,
|
||||||
|
keyRotationParticipantLimit: 20,
|
||||||
|
unstableSendStickyEvents: false,
|
||||||
|
// The sync grace period is the shorter wait: 10 s at 1 s per retry, plus one
|
||||||
|
maximumNetworkErrorRetryCount: 11,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives up on the network once the delayed leave has probably been sent", () => {
|
||||||
|
const s = session();
|
||||||
|
|
||||||
|
joinJsSdkSession(s, ownMemberMock, transport, {
|
||||||
|
encryptMedia: false,
|
||||||
|
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||||
|
timings: { ...defaultSessionTimings, networkErrorRetryMs: 4_000 },
|
||||||
|
delayedLeave: { delay_ms: 18_000 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(s.join).toHaveBeenCalledWith(
|
||||||
|
ownMemberMock,
|
||||||
|
[transport],
|
||||||
|
expect.objectContaining({
|
||||||
|
manageMediaKeys: false,
|
||||||
|
maximumNetworkErrorRetryCount: 4,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lifts the call intent out of the application data and sends sticky events in Matrix 2.0 mode", () => {
|
||||||
|
const s = session();
|
||||||
|
|
||||||
|
joinJsSdkSession(s, ownMemberMock, transport, {
|
||||||
|
encryptMedia: true,
|
||||||
|
matrixRTCMode: MatrixRTCMode.Matrix_2_0,
|
||||||
|
applicationData: { "m.call.intent": "audio", custom: 1 },
|
||||||
|
timings: defaultSessionTimings,
|
||||||
|
delayedLeave: defaultSessionTimings.delayedLeave,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(s.join).toHaveBeenCalledWith(
|
||||||
|
ownMemberMock,
|
||||||
|
[transport],
|
||||||
|
expect.objectContaining({
|
||||||
|
callIntent: "audio",
|
||||||
|
applicationData: { custom: 1 },
|
||||||
|
unstableSendStickyEvents: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type MatrixRTCSession as JsSdkRTCSession,
|
||||||
|
type RTCCallIntent,
|
||||||
|
type RTCNotificationType,
|
||||||
|
type Transport,
|
||||||
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type DelayedLeaveTimings,
|
||||||
|
MatrixRTCMode,
|
||||||
|
type SessionTimings,
|
||||||
|
} from "../config";
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
encryptMedia: boolean;
|
||||||
|
matrixRTCMode: MatrixRTCMode;
|
||||||
|
sendNotificationType?: RTCNotificationType;
|
||||||
|
applicationData?: Record<string, unknown>;
|
||||||
|
timings: SessionTimings;
|
||||||
|
/** The delayed leave timings to use: delegated to the SFU or not. */
|
||||||
|
delayedLeave: DelayedLeaveTimings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the membership and starts the membership manager, which keeps it
|
||||||
|
* alive and retries on failure.
|
||||||
|
* @throws If the join could not be started.
|
||||||
|
*/
|
||||||
|
export function joinJsSdkSession(
|
||||||
|
session: JsSdkRTCSession,
|
||||||
|
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||||
|
transport: Transport,
|
||||||
|
{
|
||||||
|
encryptMedia,
|
||||||
|
matrixRTCMode,
|
||||||
|
sendNotificationType,
|
||||||
|
applicationData,
|
||||||
|
timings,
|
||||||
|
delayedLeave,
|
||||||
|
}: Options,
|
||||||
|
): void {
|
||||||
|
// The membership format carries the intent as a field of its own
|
||||||
|
const { "m.call.intent": intent, ...rest } = applicationData ?? {};
|
||||||
|
const callIntent =
|
||||||
|
typeof intent === "string" ? (intent as RTCCallIntent) : undefined;
|
||||||
|
// Give up on the network as soon as either the sync has been down for the
|
||||||
|
// grace period or the delayed leave has probably been sent, whichever is
|
||||||
|
// sooner
|
||||||
|
const maxWaitMs = Math.min(
|
||||||
|
timings.syncDisconnectGracePeriodMs,
|
||||||
|
delayedLeave.delay_ms,
|
||||||
|
);
|
||||||
|
session.join(ownMembershipIdentity, [transport], {
|
||||||
|
notificationType: sendNotificationType,
|
||||||
|
callIntent,
|
||||||
|
applicationData: rest,
|
||||||
|
manageMediaKeys: encryptMedia,
|
||||||
|
delayedLeaveEventRestartMs: delayedLeave.restart_ms,
|
||||||
|
delayedLeaveEventDelayMs: delayedLeave.delay_ms,
|
||||||
|
delayedLeaveEventRestartLocalTimeoutMs: delayedLeave.restart_timeout_ms,
|
||||||
|
networkErrorRetryMs: timings.networkErrorRetryMs,
|
||||||
|
makeKeyDelay: timings.waitForKeyRotationMs,
|
||||||
|
membershipEventExpiryMs: timings.membershipEventExpiryMs,
|
||||||
|
keyRotationParticipantLimit: timings.keyRotationParticipantLimit,
|
||||||
|
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
|
||||||
|
maximumNetworkErrorRetryCount:
|
||||||
|
Math.ceil(maxWaitMs / timings.networkErrorRetryMs) + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public media types of the SDK: what a member publishes, the tracks it
|
||||||
|
* arrives as and how a host renders them. The client and member types are in
|
||||||
|
* `api.ts`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type Observable } from "rxjs";
|
||||||
|
|
||||||
|
import { type Behavior } from "./reactive/Behavior";
|
||||||
|
|
||||||
|
export interface AudioCaptureSettings {
|
||||||
|
echoCancellation?: boolean;
|
||||||
|
noiseSuppression?: boolean;
|
||||||
|
autoGainControl?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VideoCodec = "vp8" | "h264" | "vp9" | "av1" | "h265";
|
||||||
|
|
||||||
|
export interface VideoCaptureSettings {
|
||||||
|
resolution?: { width: number; height: number; frameRate?: number };
|
||||||
|
maxBitrate?: number;
|
||||||
|
maxFramerate?: number;
|
||||||
|
codec?: VideoCodec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms a camera track before it is published: background blur and the
|
||||||
|
* like. `init` resolves with the track to publish in place of the captured
|
||||||
|
* one; `restart` does the same for a new capture, where the camera changes.
|
||||||
|
*/
|
||||||
|
export interface VideoProcessor {
|
||||||
|
name: string;
|
||||||
|
init(
|
||||||
|
track: MediaStreamTrack,
|
||||||
|
element?: HTMLMediaElement,
|
||||||
|
): Promise<MediaStreamTrack>;
|
||||||
|
restart(
|
||||||
|
track: MediaStreamTrack,
|
||||||
|
element?: HTMLMediaElement,
|
||||||
|
): Promise<MediaStreamTrack>;
|
||||||
|
destroy(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One thing to publish: a source, where to capture it from and how to encode
|
||||||
|
* it. Where the device and the settings are left out, the browser's and
|
||||||
|
* LiveKit's defaults apply.
|
||||||
|
*/
|
||||||
|
export type PublishRequest =
|
||||||
|
| { source: "microphone"; deviceId?: string; capture?: AudioCaptureSettings }
|
||||||
|
| {
|
||||||
|
source: "camera";
|
||||||
|
deviceId?: string;
|
||||||
|
/** Background blur and the like, applied before the first frame is published. */
|
||||||
|
processor?: VideoProcessor;
|
||||||
|
capture?: VideoCaptureSettings;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
source: "screenShare";
|
||||||
|
/** Whether to capture the screen's audio too. Default true. */
|
||||||
|
audio?: boolean;
|
||||||
|
capture?: VideoCaptureSettings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MediaSource =
|
||||||
|
| "microphone"
|
||||||
|
| "camera"
|
||||||
|
| "screenShare"
|
||||||
|
| "screenShareAudio"
|
||||||
|
/** Published without a source; the application knows what it is. */
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
export type MediaStreamStats =
|
||||||
|
| RTCInboundRtpStreamStats
|
||||||
|
| RTCOutboundRtpStreamStats
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
/** One published track of a member. */
|
||||||
|
export interface MediaTrack {
|
||||||
|
source: MediaSource;
|
||||||
|
kind: "audio" | "video";
|
||||||
|
/** Stable for the life of the track. */
|
||||||
|
id: string;
|
||||||
|
muted$: Behavior<boolean>;
|
||||||
|
/**
|
||||||
|
* False when the SFU reports the track as unencrypted. Fixed for the life
|
||||||
|
* of the track: a publisher that changes its encryption publishes anew.
|
||||||
|
*/
|
||||||
|
encrypted: boolean;
|
||||||
|
/**
|
||||||
|
* Inbound statistics for a remote track, outbound for a local one, polled
|
||||||
|
* every `intervalMs` while the observable is subscribed and not at all
|
||||||
|
* otherwise. Each subscription runs its own poll, so a view shares one.
|
||||||
|
*/
|
||||||
|
stats$(intervalMs: number): Observable<MediaStreamStats>;
|
||||||
|
/**
|
||||||
|
* Rendering. The view hands its `<video>` or `<audio>` element over; the SDK
|
||||||
|
* sets its stream and, for video, registers the size and on-screen observers
|
||||||
|
* that pick a simulcast layer and pause the subscription while the element is
|
||||||
|
* hidden. `attach` is idempotent per element; `detach` has to be called
|
||||||
|
* before the element leaves the DOM so those observers are released.
|
||||||
|
*/
|
||||||
|
attach(element: HTMLMediaElement): void;
|
||||||
|
detach(element: HTMLMediaElement): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioMediaTrack extends MediaTrack {
|
||||||
|
kind: "audio";
|
||||||
|
/**
|
||||||
|
* Whether the track carries sound right now, as the backend measures it.
|
||||||
|
* False while muted. A call reads this on the microphone track and calls it
|
||||||
|
* "speaking".
|
||||||
|
* This is different to audioLevel = 0. The backend might run some hysteresis on it.
|
||||||
|
*/
|
||||||
|
isActive$: Behavior<boolean>;
|
||||||
|
/**
|
||||||
|
* How loud the track is right now, 0 to 1, as the backend measures it. 0
|
||||||
|
* while muted. Not what decides `isActive$`: the backend applies its own
|
||||||
|
* threshold and hysteresis to that, so a level above 0 may not be active and
|
||||||
|
* an active track may briefly sit at 0.
|
||||||
|
*/
|
||||||
|
audioLevel$: Behavior<number>;
|
||||||
|
/** Route playback through Web Audio, for earpiece pan and gain. Undefined resets. */
|
||||||
|
setAudioContext(ctx: AudioContext | undefined, plugins?: AudioNode[]): void;
|
||||||
|
setVolume(volume: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoMediaTrack extends MediaTrack {
|
||||||
|
kind: "video";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The controls a member has over a track it publishes itself. */
|
||||||
|
export interface LocalMediaTrack {
|
||||||
|
/**
|
||||||
|
* Mutes or unmutes. Resolves with the state that resulted, which differs
|
||||||
|
* from the request where the device could not be used.
|
||||||
|
*/
|
||||||
|
setEnabled(enabled: boolean): Promise<boolean>;
|
||||||
|
/** Captures from another device. Rejects for a screen share, which has none. */
|
||||||
|
setDevice(deviceId: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalAudioMediaTrack
|
||||||
|
extends AudioMediaTrack, LocalMediaTrack {}
|
||||||
|
|
||||||
|
export interface LocalVideoMediaTrack extends VideoMediaTrack, LocalMediaTrack {
|
||||||
|
/** For mirroring; undefined where the camera does not say which way it faces. */
|
||||||
|
facingMode$: Behavior<"user" | "environment" | undefined>;
|
||||||
|
/**
|
||||||
|
* Restarts the camera facing the other way, on devices with a front and a
|
||||||
|
* back camera, and resolves with the id of the device now in use. Does
|
||||||
|
* nothing where the facing mode is unknown.
|
||||||
|
*/
|
||||||
|
switchFacingMode(): Promise<string | undefined>;
|
||||||
|
/** Background blur and the like; undefined removes the processor. */
|
||||||
|
setProcessor(processor: VideoProcessor | undefined): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EncryptionError = "MissingKey" | "InvalidKey";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a member sends, once it has arrived on its transport: its tracks, null
|
||||||
|
* while nothing has arrived for it, and the key errors beside them. The media
|
||||||
|
* backend supplies exactly these two fields per member.
|
||||||
|
*/
|
||||||
|
export interface MemberMedia {
|
||||||
|
/**
|
||||||
|
* The member's tracks, in publication order, once it has shown up on its
|
||||||
|
* transport; null until then ("waiting for media"). An entry stays the same
|
||||||
|
* object for as long as the same publication is behind it. Which track is
|
||||||
|
* which is in its `source`; `trackBySource$` picks one out.
|
||||||
|
*/
|
||||||
|
tracks$: Behavior<(AudioMediaTrack | VideoMediaTrack)[] | null>;
|
||||||
|
/** Emits when the SFU reports a key problem for this member. */
|
||||||
|
encryptionError$: Observable<EncryptionError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalMemberMedia extends MemberMedia {
|
||||||
|
tracks$: Behavior<(LocalAudioMediaTrack | LocalVideoMediaTrack)[] | null>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The seam between the session and whatever carries its media. The session
|
||||||
|
* speaks MatrixRTC: memberships, transports as they appear in them, the
|
||||||
|
* delayed leave. A backend speaks one media protocol and hands back the
|
||||||
|
* media of each member. `media-backend/livekit/` is the one backend today.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||||
|
import { type Observable } from "rxjs";
|
||||||
|
|
||||||
|
import { type ResolvedTransport, type RTCMember } from "../api";
|
||||||
|
import {
|
||||||
|
type LocalAudioMediaTrack,
|
||||||
|
type LocalMemberMedia,
|
||||||
|
type LocalVideoMediaTrack,
|
||||||
|
type MediaSource,
|
||||||
|
type MemberMedia,
|
||||||
|
type PublishRequest,
|
||||||
|
} from "../media-api";
|
||||||
|
import { type SessionTimings } from "../config";
|
||||||
|
import { type EncryptionSystem } from "../encryption";
|
||||||
|
import { type Behavior } from "../reactive/Behavior";
|
||||||
|
import { type ObservableScope } from "../reactive/ObservableScope";
|
||||||
|
|
||||||
|
export enum MediaConnectionState {
|
||||||
|
/** No connection attempt yet, or no connection at all. */
|
||||||
|
Initialized = "initialized",
|
||||||
|
/** Fetching whatever the connection needs first; the SFU token for LiveKit. */
|
||||||
|
Preparing = "preparing",
|
||||||
|
Connecting = "connecting",
|
||||||
|
Connected = "connected",
|
||||||
|
Reconnecting = "reconnecting",
|
||||||
|
Disconnected = "disconnected",
|
||||||
|
Stopped = "stopped",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One per-participant media key, as the session's key exchange delivers it. */
|
||||||
|
export interface MediaKey {
|
||||||
|
/** The `rtcBackendIdentity` of the member the key belongs to. */
|
||||||
|
participantId: string;
|
||||||
|
index: number;
|
||||||
|
key: Uint8Array<ArrayBuffer>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaBackendContext {
|
||||||
|
roomId: string;
|
||||||
|
/** The slot the session is in, `application#id`; a token is issued for it. */
|
||||||
|
slotId: string;
|
||||||
|
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||||
|
/** What the local member publishes at the join. */
|
||||||
|
publish: PublishRequest[];
|
||||||
|
encryptionSystem: EncryptionSystem;
|
||||||
|
/**
|
||||||
|
* Every media key the session knows: the current ones replayed on
|
||||||
|
* subscribe, new ones as they arrive. Empty for a shared key, which is in
|
||||||
|
* `encryptionSystem`.
|
||||||
|
*/
|
||||||
|
mediaKeys$: Observable<MediaKey>;
|
||||||
|
timings: SessionTimings;
|
||||||
|
logger: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of the `backend` option: the session calls it once with its scope
|
||||||
|
* and the context. A backend's own factory keeps the shape of every other
|
||||||
|
* factory here, `createLivekitBackend(scope, context, options)`, and a host
|
||||||
|
* closes over its options. The Matrix client is not in the context: a backend
|
||||||
|
* that needs it takes a `Pick` of exactly what it calls in its own options.
|
||||||
|
*/
|
||||||
|
export type MediaBackendFactory = (
|
||||||
|
scope: ObservableScope,
|
||||||
|
context: MediaBackendContext,
|
||||||
|
) => MediaBackend;
|
||||||
|
|
||||||
|
/** What preparing the local transport established; what the MatrixRTC join needs from the media side. */
|
||||||
|
export interface TransportCapabilities {
|
||||||
|
/**
|
||||||
|
* Whether the backend can restart the delayed leave on the client's behalf,
|
||||||
|
* so that a client that vanishes is removed by the backend rather than by
|
||||||
|
* the timeout. Decides which delayed leave timings the join uses, and
|
||||||
|
* whether `delegateDelayedLeave` is ever called.
|
||||||
|
*/
|
||||||
|
canDelegateDelayedLeave: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One connection a backend holds, for diagnostics. */
|
||||||
|
export interface BackendConnection {
|
||||||
|
/** The transport as it appears in memberships; what the registry keys on. */
|
||||||
|
transport: Transport;
|
||||||
|
state: MediaConnectionState | Error;
|
||||||
|
/**
|
||||||
|
* What the backend fetched to connect; undefined until it has, and again
|
||||||
|
* after the connection stops. One object per fetch, so that a change of
|
||||||
|
* state does not read as a change of this.
|
||||||
|
*/
|
||||||
|
resolved: ResolvedTransport | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataPacket {
|
||||||
|
/** The backend identity of the sender. */
|
||||||
|
senderId: string;
|
||||||
|
topic: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaBackend {
|
||||||
|
/** The membership transport type this backend serves: "livekit". */
|
||||||
|
readonly transportType: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes `transport` usable as the one the local member publishes on. The
|
||||||
|
* session found it; the backend does whatever has to succeed before it is
|
||||||
|
* advertised in the membership. Rejects with the error the session reports
|
||||||
|
* as fatal. Called once per session.
|
||||||
|
*/
|
||||||
|
prepareLocalTransport(transport: Transport): Promise<TransportCapabilities>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands the delayed leave event to the backend, which restarts it from
|
||||||
|
* then on. Called each time the membership manager reports a new delay id,
|
||||||
|
* only when `canDelegateDelayedLeave` is true. A rejection is logged by
|
||||||
|
* the session, not fatal.
|
||||||
|
*/
|
||||||
|
delegateDelayedLeave(delayId: string): Promise<void>;
|
||||||
|
|
||||||
|
readonly local: LocalMediaBackend;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The media of one remote member: its tracks, null while nothing has
|
||||||
|
* arrived for it on its transport, and its key errors. Built in `scope`,
|
||||||
|
* which the caller ends with the member or the participation, whichever
|
||||||
|
* goes first. This is also how the backend learns which remote transports
|
||||||
|
* exist: it follows the member's `transport$` for as long as `scope` lives,
|
||||||
|
* and connects to every transport some member is on.
|
||||||
|
*/
|
||||||
|
mediaFor(scope: ObservableScope, member: RTCMember): MemberMedia;
|
||||||
|
|
||||||
|
/** Every connection the backend holds. For debugging and devtool purposes.*/
|
||||||
|
readonly connections$: Behavior<BackendConnection[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays the members' audio on this device: on the local transport's
|
||||||
|
* connection, whether it is connected yet or not, and on every connection
|
||||||
|
* created from then on. A connection to a remote transport that already
|
||||||
|
* exists keeps the device it was created with.
|
||||||
|
*/
|
||||||
|
setAudioOutputDeviceId(deviceId: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A text channel beside the media: sent on the local transport, received on every one.
|
||||||
|
* This could well be just another track kind. Not needed for element call. just for the sdk target.
|
||||||
|
* Keeping it on the top level for visibility. Should be tackled in a future refactor.
|
||||||
|
*/
|
||||||
|
sendData(topic: string, text: string): Promise<void>;
|
||||||
|
readonly data$: Observable<DataPacket>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `tracks$` is null until the connection carries a local participant. */
|
||||||
|
export interface LocalMediaBackend extends LocalMemberMedia {
|
||||||
|
/**
|
||||||
|
* State of the connection the local member publishes on. `Initialized`
|
||||||
|
* while there is none; an `Error` is the connection's failure.
|
||||||
|
*/
|
||||||
|
readonly connectionState$: Behavior<MediaConnectionState | Error>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the local tracks reach the transport. The session sets it to
|
||||||
|
* "joined and the homeserver is reachable". Remembered across connections:
|
||||||
|
* the tracks are created on the first `true`, and their upstream is paused
|
||||||
|
* while `false`, so the local preview stays live while nothing is heard.
|
||||||
|
*/
|
||||||
|
setPublishing(publish: boolean): void;
|
||||||
|
/** The first failure to publish, kept until the session ends. Not fatal. */
|
||||||
|
readonly publishError$: Behavior<Error | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes a source and resolves with its track once it is in `tracks$`.
|
||||||
|
* Remembered across connections, so a request before the
|
||||||
|
* transport is up is applied once it is, and republished after a
|
||||||
|
* reconnection. Rejects where the device could not be used.
|
||||||
|
*/
|
||||||
|
publish(
|
||||||
|
request: PublishRequest,
|
||||||
|
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
|
||||||
|
/** Removes one of our publications; a screen share takes its audio with it. */
|
||||||
|
unpublish(source: MediaSource): Promise<void>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,713 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 Element Creations Ltd.
|
||||||
|
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 {
|
||||||
|
afterEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
type MockedObject,
|
||||||
|
onTestFinished,
|
||||||
|
vi,
|
||||||
|
} from "vitest";
|
||||||
|
import {
|
||||||
|
type LocalParticipant,
|
||||||
|
type RemoteParticipant,
|
||||||
|
type RemoteTrack,
|
||||||
|
type RemoteTrackPublication,
|
||||||
|
type Room as LivekitRoom,
|
||||||
|
RoomEvent,
|
||||||
|
Track,
|
||||||
|
ConnectionState as LivekitConnectionState,
|
||||||
|
} from "livekit-client";
|
||||||
|
import fetchMock from "fetch-mock";
|
||||||
|
import EventEmitter from "events";
|
||||||
|
import { MatrixError } from "matrix-js-sdk";
|
||||||
|
import { logger, type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import {
|
||||||
|
type LivekitGetTokenResponse,
|
||||||
|
type UnstableLivekitTransport,
|
||||||
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Connection,
|
||||||
|
type ConnectionState,
|
||||||
|
type ConnectionOpts,
|
||||||
|
} from "./Connection.ts";
|
||||||
|
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||||
|
import { type ClientGetTokenParts, type ClientOpenIDParts } from "./auth";
|
||||||
|
import { MatrixRTCError } from "../../errors";
|
||||||
|
import { testJWTToken } from "../../utils/test-fixtures";
|
||||||
|
import { ownMemberMock } from "../../utils/test";
|
||||||
|
import { mockRemoteParticipant } from "./test";
|
||||||
|
|
||||||
|
let testScope: ObservableScope;
|
||||||
|
|
||||||
|
let client: MockedObject<ClientGetTokenParts & ClientOpenIDParts>;
|
||||||
|
|
||||||
|
let fakeLivekitRoom: MockedObject<LivekitRoom>;
|
||||||
|
|
||||||
|
let localParticipantEventEmiter: EventEmitter;
|
||||||
|
let fakeLocalParticipant: MockedObject<LocalParticipant>;
|
||||||
|
|
||||||
|
const ROOM_ID = "!roomID:example.org";
|
||||||
|
const SLOT_ID = "m.call#ROOM";
|
||||||
|
|
||||||
|
const transport: UnstableLivekitTransport = {
|
||||||
|
type: "livekit",
|
||||||
|
url: "wss://matrix-rtc.m.localhost/livekit/sfu",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The options of a subscriber connection to `transport`. */
|
||||||
|
function connectionOpts(): ConnectionOpts {
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
roomId: ROOM_ID,
|
||||||
|
slotId: SLOT_ID,
|
||||||
|
role: "subscriber",
|
||||||
|
transport,
|
||||||
|
serverName: "m.localhost",
|
||||||
|
scope: testScope,
|
||||||
|
ownMembershipIdentity: ownMemberMock,
|
||||||
|
livekitRoomFactory: () => fakeLivekitRoom,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupTest(): void {
|
||||||
|
testScope = new ObservableScope();
|
||||||
|
client = vi.mocked<ClientGetTokenParts & ClientOpenIDParts>({
|
||||||
|
getOpenIdToken: vi.fn().mockResolvedValue({
|
||||||
|
access_token: "rYsmGUEwNjKgJYyeNUkZseJN",
|
||||||
|
token_type: "Bearer",
|
||||||
|
matrix_server_name: "example.org",
|
||||||
|
expires_in: 3600,
|
||||||
|
}),
|
||||||
|
_unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: testJWTToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
localParticipantEventEmiter = new EventEmitter();
|
||||||
|
|
||||||
|
fakeLocalParticipant = vi.mocked<LocalParticipant>({
|
||||||
|
identity: "@me:example.org",
|
||||||
|
isMicrophoneEnabled: vi.fn().mockReturnValue(true),
|
||||||
|
getTrackPublication: vi.fn().mockReturnValue(undefined),
|
||||||
|
on: localParticipantEventEmiter.on.bind(localParticipantEventEmiter),
|
||||||
|
off: localParticipantEventEmiter.off.bind(localParticipantEventEmiter),
|
||||||
|
addListener: localParticipantEventEmiter.addListener.bind(
|
||||||
|
localParticipantEventEmiter,
|
||||||
|
),
|
||||||
|
removeListener: localParticipantEventEmiter.removeListener.bind(
|
||||||
|
localParticipantEventEmiter,
|
||||||
|
),
|
||||||
|
removeAllListeners: localParticipantEventEmiter.removeAllListeners.bind(
|
||||||
|
localParticipantEventEmiter,
|
||||||
|
),
|
||||||
|
} as unknown as LocalParticipant);
|
||||||
|
|
||||||
|
const fakeRoomEventEmitter = new EventEmitter();
|
||||||
|
fakeLivekitRoom = vi.mocked<LivekitRoom>({
|
||||||
|
connect: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
remoteParticipants: new Map(),
|
||||||
|
localParticipant: fakeLocalParticipant,
|
||||||
|
state: LivekitConnectionState.Disconnected,
|
||||||
|
on: fakeRoomEventEmitter.on.bind(fakeRoomEventEmitter),
|
||||||
|
off: fakeRoomEventEmitter.off.bind(fakeRoomEventEmitter),
|
||||||
|
addListener: fakeRoomEventEmitter.addListener.bind(fakeRoomEventEmitter),
|
||||||
|
removeListener:
|
||||||
|
fakeRoomEventEmitter.removeListener.bind(fakeRoomEventEmitter),
|
||||||
|
removeAllListeners:
|
||||||
|
fakeRoomEventEmitter.removeAllListeners.bind(fakeRoomEventEmitter),
|
||||||
|
setE2EEEnabled: vi.fn().mockResolvedValue(undefined),
|
||||||
|
emit: (eventName: string | symbol, ...args: unknown[]) => {
|
||||||
|
fakeRoomEventEmitter.emit(eventName, ...args);
|
||||||
|
},
|
||||||
|
} as unknown as LivekitRoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupRemoteConnection(): Connection {
|
||||||
|
const opts = connectionOpts();
|
||||||
|
|
||||||
|
fakeLivekitRoom.connect.mockImplementation(async (): Promise<void> => {
|
||||||
|
const changeEv = RoomEvent.ConnectionStateChanged;
|
||||||
|
|
||||||
|
fakeLivekitRoom.state = LivekitConnectionState.Connecting;
|
||||||
|
fakeLivekitRoom.emit(changeEv, fakeLivekitRoom.state);
|
||||||
|
fakeLivekitRoom.state = LivekitConnectionState.Connected;
|
||||||
|
fakeLivekitRoom.emit(changeEv, fakeLivekitRoom.state);
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Connection(opts, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
fetchMock.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Start connection states", () => {
|
||||||
|
it("start in initialized state", () => {
|
||||||
|
setupTest();
|
||||||
|
|
||||||
|
const opts = connectionOpts();
|
||||||
|
const connection = new Connection(opts, logger);
|
||||||
|
|
||||||
|
expect(connection.state$.getValue()).toEqual({ state: "initialized" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fail to get SFU token then error state", async () => {
|
||||||
|
setupTest();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const opts = connectionOpts();
|
||||||
|
|
||||||
|
const connection = new Connection(opts, logger);
|
||||||
|
|
||||||
|
const capturedStates: ConnectionState[] = [];
|
||||||
|
const s = connection.state$.subscribe((value) => {
|
||||||
|
capturedStates.push(value);
|
||||||
|
});
|
||||||
|
onTestFinished(() => s.unsubscribe());
|
||||||
|
|
||||||
|
const deferred = Promise.withResolvers<LivekitGetTokenResponse>();
|
||||||
|
|
||||||
|
client._unstable_getLivekitToken.mockImplementation(
|
||||||
|
async (): Promise<LivekitGetTokenResponse> => {
|
||||||
|
return await deferred.promise;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
connection.start().catch(() => {
|
||||||
|
// expected to throw
|
||||||
|
});
|
||||||
|
|
||||||
|
let capturedState = capturedStates.pop();
|
||||||
|
expect(capturedState).toBeDefined();
|
||||||
|
expect(capturedState).toEqual({ state: "authenticating" });
|
||||||
|
|
||||||
|
deferred.reject(new MatrixRTCError("Failed to get token"));
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
capturedState = capturedStates.pop();
|
||||||
|
if (capturedState?.state === "error") {
|
||||||
|
expect(capturedState.error.message).toEqual("Failed to get token");
|
||||||
|
expect(connection.transport).toEqual(transport);
|
||||||
|
} else {
|
||||||
|
expect.fail(
|
||||||
|
"Expected error state but got " + JSON.stringify(capturedState),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fail to get JWT token and error state", async () => {
|
||||||
|
setupTest();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const opts = connectionOpts();
|
||||||
|
|
||||||
|
const connection = new Connection(opts, logger);
|
||||||
|
|
||||||
|
const capturedStates: ConnectionState[] = [];
|
||||||
|
const s = connection.state$.subscribe((value) => {
|
||||||
|
capturedStates.push(value);
|
||||||
|
});
|
||||||
|
onTestFinished(() => s.unsubscribe());
|
||||||
|
|
||||||
|
const deferredSFU = Promise.withResolvers<void>();
|
||||||
|
client._unstable_getLivekitToken.mockImplementation(async () => {
|
||||||
|
await deferredSFU.promise;
|
||||||
|
throw new MatrixError(
|
||||||
|
{
|
||||||
|
errcode: "M_LOOKUP_FAILED",
|
||||||
|
error: "Failed to look up user info from homeserver",
|
||||||
|
},
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.start().catch(() => {
|
||||||
|
// expected to throw
|
||||||
|
});
|
||||||
|
|
||||||
|
let capturedState = capturedStates.pop();
|
||||||
|
expect(capturedState).toBeDefined();
|
||||||
|
expect(capturedState).toEqual({ state: "authenticating" });
|
||||||
|
|
||||||
|
deferredSFU.resolve();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
capturedState = capturedStates.pop();
|
||||||
|
|
||||||
|
if (
|
||||||
|
capturedState?.state === "error" &&
|
||||||
|
capturedState.error.cause instanceof Error
|
||||||
|
) {
|
||||||
|
expect(capturedState.error.cause.message).toContain(
|
||||||
|
"Failed to look up user info from homeserver",
|
||||||
|
);
|
||||||
|
expect(connection.transport).toEqual(transport);
|
||||||
|
} else {
|
||||||
|
expect.fail(
|
||||||
|
"Expected error state but got " + JSON.stringify(capturedState),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fail to connect to livekit error state", async () => {
|
||||||
|
setupTest();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const opts = connectionOpts();
|
||||||
|
|
||||||
|
const connection = new Connection(opts, logger);
|
||||||
|
|
||||||
|
const capturedStates: ConnectionState[] = [];
|
||||||
|
const s = connection.state$.subscribe((value) => {
|
||||||
|
capturedStates.push(value);
|
||||||
|
});
|
||||||
|
onTestFinished(() => s.unsubscribe());
|
||||||
|
|
||||||
|
const deferredSFU = Promise.withResolvers<void>();
|
||||||
|
fakeLivekitRoom.connect.mockImplementation(async () => {
|
||||||
|
await deferredSFU.promise;
|
||||||
|
throw new Error("Failed to connect to livekit");
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.start().catch(() => {
|
||||||
|
// expected to throw
|
||||||
|
});
|
||||||
|
|
||||||
|
let capturedState = capturedStates.pop();
|
||||||
|
expect(capturedState).toBeDefined();
|
||||||
|
|
||||||
|
expect(capturedState).toEqual({ state: "authenticating" });
|
||||||
|
|
||||||
|
deferredSFU.resolve();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
capturedState = capturedStates.pop();
|
||||||
|
|
||||||
|
if (
|
||||||
|
capturedState?.state === "error" &&
|
||||||
|
capturedState.error.cause instanceof Error
|
||||||
|
) {
|
||||||
|
expect(capturedState.error.cause.message).toContain(
|
||||||
|
"Failed to connect to livekit",
|
||||||
|
);
|
||||||
|
expect(connection.transport).toEqual(transport);
|
||||||
|
} else {
|
||||||
|
expect.fail(
|
||||||
|
"Expected FailedToStart state but got " + JSON.stringify(capturedState),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("connection states happy path", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
setupTest();
|
||||||
|
|
||||||
|
const connection = setupRemoteConnection();
|
||||||
|
|
||||||
|
const capturedStates: ConnectionState[] = [];
|
||||||
|
const s = connection.state$.subscribe((value) => {
|
||||||
|
capturedStates.push(value);
|
||||||
|
});
|
||||||
|
onTestFinished(() => s.unsubscribe());
|
||||||
|
|
||||||
|
await connection.start();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
const initialState = capturedStates.shift();
|
||||||
|
expect(initialState).toEqual({ state: "initialized" });
|
||||||
|
const fetchingState = capturedStates.shift();
|
||||||
|
expect(fetchingState).toEqual({ state: "authenticating" });
|
||||||
|
const disconnectedState = capturedStates.shift();
|
||||||
|
expect(disconnectedState).toEqual({
|
||||||
|
state: "authenticated",
|
||||||
|
livekitState: LivekitConnectionState.Disconnected,
|
||||||
|
});
|
||||||
|
const connectingState = capturedStates.shift();
|
||||||
|
expect(connectingState).toEqual({
|
||||||
|
state: "authenticated",
|
||||||
|
livekitState: LivekitConnectionState.Connecting,
|
||||||
|
});
|
||||||
|
const connectedState = capturedStates.shift();
|
||||||
|
expect(connectedState).toEqual({
|
||||||
|
state: "authenticated",
|
||||||
|
livekitState: LivekitConnectionState.Connected,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("authenticates a publisher on a livekit_service_url transport through the JWT service", async () => {
|
||||||
|
setupTest();
|
||||||
|
const serviceUrl = "https://matrix-rtc.example.org/livekit/jwt";
|
||||||
|
fetchMock.post(`${serviceUrl}/sfu/get`, {
|
||||||
|
status: 200,
|
||||||
|
body: { url: "wss://sfu", jwt: testJWTToken },
|
||||||
|
});
|
||||||
|
fakeLivekitRoom.connect.mockResolvedValue(undefined);
|
||||||
|
const connection = new Connection(
|
||||||
|
{
|
||||||
|
...connectionOpts(),
|
||||||
|
role: "publisher",
|
||||||
|
transport: { type: "livekit", livekit_service_url: serviceUrl },
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
await connection.start();
|
||||||
|
|
||||||
|
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
|
||||||
|
expect(fetchMock.called(`${serviceUrl}/sfu/get`)).toBe(true);
|
||||||
|
expect(fakeLivekitRoom.connect).toHaveBeenCalledWith(
|
||||||
|
"wss://sfu",
|
||||||
|
testJWTToken,
|
||||||
|
);
|
||||||
|
expect(connection.livekitAlias).toBe("!example_room_id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stopping while connecting does not report an error", async () => {
|
||||||
|
setupTest();
|
||||||
|
const connection = setupRemoteConnection();
|
||||||
|
|
||||||
|
const capturedStates: ConnectionState[] = [];
|
||||||
|
const s = connection.state$.subscribe((value) => {
|
||||||
|
capturedStates.push(value);
|
||||||
|
});
|
||||||
|
onTestFinished(() => s.unsubscribe());
|
||||||
|
|
||||||
|
// livekit-client rejects a pending connect() when disconnect() is called.
|
||||||
|
const pendingConnect = Promise.withResolvers<void>();
|
||||||
|
fakeLivekitRoom.connect.mockReturnValue(pendingConnect.promise);
|
||||||
|
fakeLivekitRoom.disconnect.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const started = connection.start();
|
||||||
|
await vi.waitFor(() => expect(fakeLivekitRoom.connect).toHaveBeenCalled());
|
||||||
|
const stopping = connection.stop();
|
||||||
|
pendingConnect.reject(new Error("Client initiated disconnect"));
|
||||||
|
await stopping;
|
||||||
|
|
||||||
|
// start() resolves rather than rejecting (it is not awaited by the caller,
|
||||||
|
// so a rejection would be unhandled).
|
||||||
|
await expect(started).resolves.toBeUndefined();
|
||||||
|
expect(capturedStates.at(-1)).toEqual({ state: "stopped" });
|
||||||
|
expect(capturedStates.some((st) => st.state === "error")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shutting down the scope should stop the connection", async () => {
|
||||||
|
setupTest();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const connection = setupRemoteConnection();
|
||||||
|
await connection.start();
|
||||||
|
|
||||||
|
const stopSpy = vi.spyOn(connection, "stop");
|
||||||
|
testScope.end();
|
||||||
|
|
||||||
|
expect(stopSpy).toHaveBeenCalled();
|
||||||
|
expect(fakeLivekitRoom.disconnect).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("remote participants", () => {
|
||||||
|
it("emits the list of remote participants", () => {
|
||||||
|
setupTest();
|
||||||
|
|
||||||
|
const connection = setupRemoteConnection();
|
||||||
|
|
||||||
|
const observedParticipants: RemoteParticipant[][] = [];
|
||||||
|
const s = connection.remoteParticipants$.subscribe((participants) => {
|
||||||
|
observedParticipants.push(participants);
|
||||||
|
});
|
||||||
|
onTestFinished(() => s.unsubscribe());
|
||||||
|
// The remoteParticipants$ observable is derived from the current members of the
|
||||||
|
// livekitRoom and the rtc membership in order to publish the members that are publishing
|
||||||
|
// on this connection.
|
||||||
|
|
||||||
|
let participants: RemoteParticipant[] = [
|
||||||
|
mockRemoteParticipant({ identity: "@alice:example.org:DEV000" }),
|
||||||
|
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
|
||||||
|
mockRemoteParticipant({ identity: "@carol:example.org:DEV222" }),
|
||||||
|
// Mock Dan to have no published tracks. We want him to still show show up
|
||||||
|
// in the participants list.
|
||||||
|
mockRemoteParticipant({
|
||||||
|
identity: "@dan:example.org:DEV333",
|
||||||
|
getTrackPublication: () => undefined,
|
||||||
|
getTrackPublications: () => [],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Let's simulate 3 members on the livekitRoom
|
||||||
|
vi.spyOn(fakeLivekitRoom, "remoteParticipants", "get").mockImplementation(
|
||||||
|
() => new Map(participants.map((p) => [p.identity, p])),
|
||||||
|
);
|
||||||
|
|
||||||
|
participants.forEach((p) =>
|
||||||
|
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, p),
|
||||||
|
);
|
||||||
|
|
||||||
|
// At this point there should be ~~no~~ publishers
|
||||||
|
// We do have publisher now, since we do not filter for publishers anymore (to also have participants with only data tracks)
|
||||||
|
// The filtering we do is just based on the matrixRTC member events.
|
||||||
|
expect(observedParticipants.pop()!.length).toEqual(4);
|
||||||
|
|
||||||
|
participants = [
|
||||||
|
mockRemoteParticipant({ identity: "@alice:example.org:DEV000" }),
|
||||||
|
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
|
||||||
|
mockRemoteParticipant({ identity: "@carol:example.org:DEV222" }),
|
||||||
|
mockRemoteParticipant({ identity: "@dan:example.org:DEV333" }),
|
||||||
|
];
|
||||||
|
participants.forEach((p) =>
|
||||||
|
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, p),
|
||||||
|
);
|
||||||
|
|
||||||
|
// At this point there should be no publishers
|
||||||
|
expect(observedParticipants.pop()!.length).toEqual(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be scoped to parent scope", (): void => {
|
||||||
|
setupTest();
|
||||||
|
|
||||||
|
const connection = setupRemoteConnection();
|
||||||
|
|
||||||
|
let observedParticipants: RemoteParticipant[][] = [];
|
||||||
|
const s = connection.remoteParticipants$.subscribe((participants) => {
|
||||||
|
observedParticipants.push(participants);
|
||||||
|
});
|
||||||
|
onTestFinished(() => s.unsubscribe());
|
||||||
|
|
||||||
|
let participants: RemoteParticipant[] = [
|
||||||
|
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Let's simulate 3 members on the livekitRoom
|
||||||
|
vi.spyOn(fakeLivekitRoom, "remoteParticipants", "get").mockImplementation(
|
||||||
|
() => new Map(participants.map((p) => [p.identity, p])),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const participant of participants) {
|
||||||
|
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, participant);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We should have bob as a participant now
|
||||||
|
const ps = observedParticipants.pop();
|
||||||
|
expect(ps?.length).toEqual(1);
|
||||||
|
expect(ps?.[0]?.identity).toEqual("@bob:example.org:DEV111");
|
||||||
|
|
||||||
|
// end the parent scope
|
||||||
|
testScope.end();
|
||||||
|
observedParticipants = [];
|
||||||
|
|
||||||
|
// SHOULD NOT emit any more participants as the scope is ended
|
||||||
|
participants = participants.filter(
|
||||||
|
(p) => p.identity !== "@bob:example.org:DEV111",
|
||||||
|
);
|
||||||
|
|
||||||
|
fakeLivekitRoom.emit(
|
||||||
|
RoomEvent.ParticipantDisconnected,
|
||||||
|
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(observedParticipants.length).toEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("remote track logging", () => {
|
||||||
|
it("logs remote participant and track events on the connection logger", () => {
|
||||||
|
setupTest();
|
||||||
|
const info = vi.fn();
|
||||||
|
const warn = vi.fn();
|
||||||
|
const testLogger = {
|
||||||
|
getChild: (): unknown => testLogger,
|
||||||
|
info,
|
||||||
|
debug: vi.fn(),
|
||||||
|
warn,
|
||||||
|
error: vi.fn(),
|
||||||
|
} as unknown as Logger;
|
||||||
|
new Connection(connectionOpts(), testLogger);
|
||||||
|
info.mockClear(); // drop the constructor log line
|
||||||
|
|
||||||
|
const bob = mockRemoteParticipant({
|
||||||
|
identity: "@bob:example.org:DEV111",
|
||||||
|
sid: "PA_bob",
|
||||||
|
});
|
||||||
|
const pub = {
|
||||||
|
kind: "audio",
|
||||||
|
source: "microphone",
|
||||||
|
trackSid: "TR_mic",
|
||||||
|
isMuted: false,
|
||||||
|
isEncrypted: true,
|
||||||
|
} as unknown as RemoteTrackPublication;
|
||||||
|
const messages = (): string[] => info.mock.calls.map((c) => c[0] as string);
|
||||||
|
|
||||||
|
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, bob);
|
||||||
|
fakeLivekitRoom.emit(
|
||||||
|
RoomEvent.TrackSubscribed,
|
||||||
|
{} as RemoteTrack,
|
||||||
|
pub,
|
||||||
|
bob,
|
||||||
|
);
|
||||||
|
fakeLivekitRoom.emit(RoomEvent.TrackMuted, pub, bob);
|
||||||
|
fakeLivekitRoom.emit(
|
||||||
|
RoomEvent.TrackStreamStateChanged,
|
||||||
|
pub,
|
||||||
|
Track.StreamState.Paused,
|
||||||
|
bob,
|
||||||
|
);
|
||||||
|
expect(messages()).toEqual([
|
||||||
|
"Participant connected: @bob:example.org:DEV111 (PA_bob)",
|
||||||
|
"Subscribed: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true muted=false",
|
||||||
|
"Muted: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true",
|
||||||
|
"Stream paused: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Encryption status changes are logged; cryptor errors are warnings
|
||||||
|
fakeLivekitRoom.emit(
|
||||||
|
RoomEvent.ParticipantEncryptionStatusChanged,
|
||||||
|
false,
|
||||||
|
bob,
|
||||||
|
);
|
||||||
|
expect(messages().at(-1)).toBe(
|
||||||
|
"Encryption status of @bob:example.org:DEV111: encrypted=false",
|
||||||
|
);
|
||||||
|
const cryptorError = new Error("missing key at index 3");
|
||||||
|
fakeLivekitRoom.emit(RoomEvent.EncryptionError, cryptorError, bob);
|
||||||
|
expect(warn).toHaveBeenCalledWith(
|
||||||
|
"Encryption error for @bob:example.org:DEV111:",
|
||||||
|
cryptorError,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Local mute events are already logged by the Publisher
|
||||||
|
fakeLivekitRoom.emit(RoomEvent.TrackMuted, pub, {
|
||||||
|
...fakeLocalParticipant,
|
||||||
|
isLocal: true,
|
||||||
|
} as unknown as LocalParticipant);
|
||||||
|
expect(messages().filter((m) => m.startsWith("Muted"))).toHaveLength(1);
|
||||||
|
|
||||||
|
// Listeners are removed when the scope ends
|
||||||
|
testScope.end();
|
||||||
|
fakeLivekitRoom.emit(RoomEvent.ParticipantDisconnected, bob);
|
||||||
|
expect(
|
||||||
|
messages().filter((m) => m.startsWith("Participant disconnected")),
|
||||||
|
).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
//
|
||||||
|
// NOT USED ANYMORE ?
|
||||||
|
//
|
||||||
|
// This setup look like sth for the Publisher. Not a connection.
|
||||||
|
|
||||||
|
// describe("PublishConnection", () => {
|
||||||
|
// // let fakeBlurProcessor: ProcessorWrapper<BackgroundOptions>;
|
||||||
|
// let roomFactoryMock: Mock<() => LivekitRoom>;
|
||||||
|
// let muteStates: MockedObject<MuteStates>;
|
||||||
|
|
||||||
|
// function setUpPublishConnection(): void {
|
||||||
|
// setupTest();
|
||||||
|
|
||||||
|
// roomFactoryMock = vi.fn().mockReturnValue(fakeLivekitRoom);
|
||||||
|
|
||||||
|
// muteStates = mockMuteStates();
|
||||||
|
|
||||||
|
// // fakeBlurProcessor = vi.mocked<ProcessorWrapper<BackgroundOptions>>({
|
||||||
|
// // name: "BackgroundBlur",
|
||||||
|
// // restart: vi.fn().mockResolvedValue(undefined),
|
||||||
|
// // setOptions: vi.fn().mockResolvedValue(undefined),
|
||||||
|
// // getOptions: vi.fn().mockReturnValue({ strength: 0.5 }),
|
||||||
|
// // isRunning: vi.fn().mockReturnValue(false)
|
||||||
|
// // });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// describe("Livekit room creation", () => {
|
||||||
|
// function createSetup(): void {
|
||||||
|
// setUpPublishConnection();
|
||||||
|
|
||||||
|
// const fakeTrackProcessorSubject$ = new BehaviorSubject<ProcessorState>({
|
||||||
|
// supported: true,
|
||||||
|
// processor: undefined,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const opts: ConnectionOpts = {
|
||||||
|
// client: client,
|
||||||
|
// transport: livekitFocus,
|
||||||
|
// scope: testScope,
|
||||||
|
// livekitRoomFactory: roomFactoryMock,
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const audioInput = {
|
||||||
|
// available$: of(new Map([["mic1", { id: "mic1" }]])),
|
||||||
|
// selected$: new BehaviorSubject({ id: "mic1" }),
|
||||||
|
// select(): void {},
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const videoInput = {
|
||||||
|
// available$: of(new Map([["cam1", { id: "cam1" }]])),
|
||||||
|
// selected$: new BehaviorSubject({ id: "cam1" }),
|
||||||
|
// select(): void {},
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const audioOutput = {
|
||||||
|
// available$: of(new Map([["speaker", { id: "speaker" }]])),
|
||||||
|
// selected$: new BehaviorSubject({ id: "speaker" }),
|
||||||
|
// select(): void {},
|
||||||
|
// };
|
||||||
|
|
||||||
|
// // TODO understand what is wrong with our mocking that requires ts-expect-error
|
||||||
|
// const fakeDevices = mockMediaDevices({
|
||||||
|
// // @ts-expect-error Mocking only
|
||||||
|
// audioInput,
|
||||||
|
// // @ts-expect-error Mocking only
|
||||||
|
// videoInput,
|
||||||
|
// // @ts-expect-error Mocking only
|
||||||
|
// audioOutput,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// new Connection(
|
||||||
|
// opts,
|
||||||
|
// fakeDevices,
|
||||||
|
// muteStates,
|
||||||
|
// undefined,
|
||||||
|
// fakeTrackProcessorSubject$,
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// it("should create room with proper initial audio and video settings", () => {
|
||||||
|
// createSetup();
|
||||||
|
|
||||||
|
// expect(roomFactoryMock).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// const lastCallArgs =
|
||||||
|
// roomFactoryMock.mock.calls[roomFactoryMock.mock.calls.length - 1];
|
||||||
|
|
||||||
|
// const roomOptions = lastCallArgs.pop() as unknown as RoomOptions;
|
||||||
|
// expect(roomOptions).toBeDefined();
|
||||||
|
|
||||||
|
// expect(roomOptions!.videoCaptureDefaults?.deviceId).toEqual("cam1");
|
||||||
|
// expect(roomOptions!.audioCaptureDefaults?.deviceId).toEqual("mic1");
|
||||||
|
// expect(roomOptions!.audioOutput?.deviceId).toEqual("speaker");
|
||||||
|
// });
|
||||||
|
|
||||||
|
// it("respect controlledAudioDevices", () => {
|
||||||
|
// // TODO: Refactor the code to make it testable.
|
||||||
|
// // The UrlParams module is a singleton has a cache and is very hard to test.
|
||||||
|
// // This breaks other tests as well if not handled properly.
|
||||||
|
// // vi.mock(import("./../UrlParams"), () => {
|
||||||
|
// // return {
|
||||||
|
// // getUrlParams: vi.fn().mockReturnValue({
|
||||||
|
// // controlledAudioDevices: true
|
||||||
|
// // })
|
||||||
|
// // };
|
||||||
|
// // });
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
// });
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 Element Creations Ltd.
|
||||||
|
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 {
|
||||||
|
connectedParticipantsObserver,
|
||||||
|
connectionStateObserver,
|
||||||
|
} from "@livekit/components-core";
|
||||||
|
import {
|
||||||
|
ConnectionError,
|
||||||
|
ConnectionErrorReason,
|
||||||
|
type ConnectionState as LivekitConnectionState,
|
||||||
|
type Participant,
|
||||||
|
type RemoteParticipant,
|
||||||
|
type RemoteTrackPublication,
|
||||||
|
type Room as LivekitRoom,
|
||||||
|
RoomEvent,
|
||||||
|
type SubscriptionError,
|
||||||
|
type Track,
|
||||||
|
type TrackPublication,
|
||||||
|
} from "livekit-client";
|
||||||
|
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type ClientGetTokenParts,
|
||||||
|
type ClientOpenIDParts,
|
||||||
|
type ConnectionRole,
|
||||||
|
getSFUConfig,
|
||||||
|
type SFUConfig,
|
||||||
|
} from "./auth";
|
||||||
|
import { type Behavior } from "../../reactive/Behavior";
|
||||||
|
import { type ObservableScope } from "../../reactive/ObservableScope";
|
||||||
|
import {
|
||||||
|
MatrixRTCError,
|
||||||
|
InsufficientCapacityError,
|
||||||
|
LivekitConnectionError,
|
||||||
|
PeerConnectionTimeoutError,
|
||||||
|
SFURoomCreationRestrictedError,
|
||||||
|
UnknownRTCError,
|
||||||
|
} from "../../errors";
|
||||||
|
|
||||||
|
export interface ConnectionOpts {
|
||||||
|
/** The identity parts to use on this connection */
|
||||||
|
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||||
|
/** Whether we publish on this connection or only subscribe. */
|
||||||
|
role: ConnectionRole;
|
||||||
|
/** The media transport to connect to. */
|
||||||
|
transport: UnstableLivekitTransport;
|
||||||
|
/** The homeserver the transport belongs to. */
|
||||||
|
serverName: string;
|
||||||
|
/** The Matrix client, for the token exchange. */
|
||||||
|
client: ClientGetTokenParts & ClientOpenIDParts;
|
||||||
|
/** The Matrix room the session is in. */
|
||||||
|
roomId: string;
|
||||||
|
/** The MatrixRTC slot the session is in. */
|
||||||
|
slotId: string;
|
||||||
|
/** The observable scope to use for this connection. */
|
||||||
|
scope: ObservableScope;
|
||||||
|
|
||||||
|
/** Optional factory to create the LiveKit room, mainly for testing purposes. */
|
||||||
|
livekitRoomFactory: () => LivekitRoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectionState =
|
||||||
|
/** Created, nothing started yet. */
|
||||||
|
| { state: "initialized" }
|
||||||
|
/** `start` was called; the SFU token is being fetched. */
|
||||||
|
| { state: "authenticating" }
|
||||||
|
/** The token is in hand; the LiveKit room's own state from here. */
|
||||||
|
| { state: "authenticated"; livekitState: LivekitConnectionState }
|
||||||
|
| { state: "error"; error: MatrixRTCError }
|
||||||
|
| { state: "stopped" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A connection to a Matrix RTC LiveKit backend.
|
||||||
|
*
|
||||||
|
* Expose observables for participants and connection state.
|
||||||
|
*/
|
||||||
|
export class Connection {
|
||||||
|
// Private Behavior
|
||||||
|
private readonly _state$ = new BehaviorSubject<ConnectionState>({
|
||||||
|
state: "initialized",
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current state of the connection to the media transport.
|
||||||
|
*/
|
||||||
|
public readonly state$: Behavior<ConnectionState> = this._state$;
|
||||||
|
|
||||||
|
/** Whether we publish on this connection or only subscribe. */
|
||||||
|
public readonly role: ConnectionRole;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The media transport to connect to.
|
||||||
|
*/
|
||||||
|
public readonly transport: UnstableLivekitTransport;
|
||||||
|
|
||||||
|
/** The homeserver the transport belongs to. */
|
||||||
|
public readonly serverName: string;
|
||||||
|
|
||||||
|
public readonly livekitRoom: LivekitRoom;
|
||||||
|
|
||||||
|
private scope: ObservableScope;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The remote LiveKit participants that are visible on this connection.
|
||||||
|
*
|
||||||
|
* Note that this may include participants that are connected only to
|
||||||
|
* subscribe, or publishers that are otherwise unattested in MatrixRTC state.
|
||||||
|
* It is therefore more low-level than what should be presented to the user.
|
||||||
|
*/
|
||||||
|
public readonly remoteParticipants$: Behavior<RemoteParticipant[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The alias of the LiveKit room.
|
||||||
|
*/
|
||||||
|
public get livekitAlias(): string | undefined {
|
||||||
|
return this._livekitAlias;
|
||||||
|
}
|
||||||
|
private _livekitAlias?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the connection has been stopped.
|
||||||
|
* @see Connection.stop
|
||||||
|
* */
|
||||||
|
protected stopped = false;
|
||||||
|
|
||||||
|
private readonly client: ClientGetTokenParts & ClientOpenIDParts;
|
||||||
|
private readonly roomId: string;
|
||||||
|
private readonly slotId: string;
|
||||||
|
private readonly logger: Logger;
|
||||||
|
private readonly ownMembershipIdentity: CallMembershipIdentityParts;
|
||||||
|
/**
|
||||||
|
* Creates a new connection to a matrix RTC LiveKit backend.
|
||||||
|
*
|
||||||
|
* @param opts - Connection options {@link ConnectionOpts}.
|
||||||
|
*
|
||||||
|
* @param logger - The logger to use.
|
||||||
|
*/
|
||||||
|
public constructor(opts: ConnectionOpts, logger: Logger) {
|
||||||
|
this.ownMembershipIdentity = opts.ownMembershipIdentity;
|
||||||
|
this.roomId = opts.roomId;
|
||||||
|
this.slotId = opts.slotId;
|
||||||
|
this.logger = logger.getChild(
|
||||||
|
`[Connection ${JSON.stringify(opts.transport)}]`,
|
||||||
|
);
|
||||||
|
this.logger.debug(`constructor called`);
|
||||||
|
const { role, transport, serverName, client, scope } = opts;
|
||||||
|
|
||||||
|
this.scope = scope;
|
||||||
|
this.livekitRoom = opts.livekitRoomFactory();
|
||||||
|
this.role = role;
|
||||||
|
this.transport = transport;
|
||||||
|
this.serverName = serverName;
|
||||||
|
this.client = client;
|
||||||
|
|
||||||
|
this.remoteParticipants$ = scope.behavior(
|
||||||
|
// Only tracks remote participants
|
||||||
|
connectedParticipantsObserver(this.livekitRoom),
|
||||||
|
);
|
||||||
|
this.logRemoteTrackEvents();
|
||||||
|
|
||||||
|
scope.onEnd(() => {
|
||||||
|
this.logger.info(`Connection scope ended, stopping connection`);
|
||||||
|
void this.stop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs the lifecycle of remote participants and their tracks as seen by
|
||||||
|
* livekit-client, so that rageshakes show what a tile should have been
|
||||||
|
* rendering (published, subscribed, muted, paused) for each remote member.
|
||||||
|
*/
|
||||||
|
private logRemoteTrackEvents(): void {
|
||||||
|
const room = this.livekitRoom;
|
||||||
|
const log = this.logger.getChild("[RemoteTracks]");
|
||||||
|
// The encryption flag matters: if the publisher encrypts but this client
|
||||||
|
// believes the track is unencrypted, the cryptor is bypassed and raw
|
||||||
|
// ciphertext reaches the decoder (audible as loud noise bursts).
|
||||||
|
const track = (pub: TrackPublication, p: Participant): string =>
|
||||||
|
`${pub.kind} ${pub.source} ${pub.trackSid} of ${p.identity} encrypted=${pub.isEncrypted}`;
|
||||||
|
|
||||||
|
const onParticipantConnected = (p: RemoteParticipant): void =>
|
||||||
|
log.info(`Participant connected: ${p.identity} (${p.sid})`);
|
||||||
|
const onParticipantDisconnected = (p: RemoteParticipant): void =>
|
||||||
|
log.info(`Participant disconnected: ${p.identity} (${p.sid})`);
|
||||||
|
const onTrackPublished = (
|
||||||
|
pub: RemoteTrackPublication,
|
||||||
|
p: RemoteParticipant,
|
||||||
|
): void => log.info(`Published: ${track(pub, p)} muted=${pub.isMuted}`);
|
||||||
|
const onTrackUnpublished = (
|
||||||
|
pub: RemoteTrackPublication,
|
||||||
|
p: RemoteParticipant,
|
||||||
|
): void => log.info(`Unpublished: ${track(pub, p)}`);
|
||||||
|
const onTrackSubscribed = (
|
||||||
|
_t: Track,
|
||||||
|
pub: RemoteTrackPublication,
|
||||||
|
p: RemoteParticipant,
|
||||||
|
): void => log.info(`Subscribed: ${track(pub, p)} muted=${pub.isMuted}`);
|
||||||
|
const onTrackUnsubscribed = (
|
||||||
|
_t: Track,
|
||||||
|
pub: RemoteTrackPublication,
|
||||||
|
p: RemoteParticipant,
|
||||||
|
): void => log.info(`Unsubscribed: ${track(pub, p)}`);
|
||||||
|
const onTrackSubscriptionFailed = (
|
||||||
|
trackSid: string,
|
||||||
|
p: RemoteParticipant,
|
||||||
|
reason?: SubscriptionError,
|
||||||
|
): void =>
|
||||||
|
log.warn(
|
||||||
|
`Subscription failed: ${trackSid} of ${p.identity} reason=${reason}`,
|
||||||
|
);
|
||||||
|
// Mute events also fire for the local participant, which the Publisher
|
||||||
|
// already logs.
|
||||||
|
const onTrackMuted = (pub: TrackPublication, p: Participant): void => {
|
||||||
|
if (!p.isLocal) log.info(`Muted: ${track(pub, p)}`);
|
||||||
|
};
|
||||||
|
const onTrackUnmuted = (pub: TrackPublication, p: Participant): void => {
|
||||||
|
if (!p.isLocal) log.info(`Unmuted: ${track(pub, p)}`);
|
||||||
|
};
|
||||||
|
const onTrackStreamStateChanged = (
|
||||||
|
pub: RemoteTrackPublication,
|
||||||
|
state: Track.StreamState,
|
||||||
|
p: RemoteParticipant,
|
||||||
|
): void => log.info(`Stream ${state}: ${track(pub, p)}`);
|
||||||
|
const onEncryptionStatusChanged = (
|
||||||
|
encrypted: boolean,
|
||||||
|
p?: Participant,
|
||||||
|
): void =>
|
||||||
|
log.info(
|
||||||
|
`Encryption status of ${p?.identity ?? "unknown participant"}: encrypted=${encrypted}`,
|
||||||
|
);
|
||||||
|
// livekit-client throttles these per cryptor; they indicate frames being
|
||||||
|
// dropped (missing/invalid key).
|
||||||
|
const onEncryptionError = (error: Error, p?: Participant): void =>
|
||||||
|
log.warn(
|
||||||
|
`Encryption error for ${p?.identity ?? "unknown participant"}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
|
||||||
|
room
|
||||||
|
.on(RoomEvent.ParticipantConnected, onParticipantConnected)
|
||||||
|
.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
||||||
|
.on(RoomEvent.TrackPublished, onTrackPublished)
|
||||||
|
.on(RoomEvent.TrackUnpublished, onTrackUnpublished)
|
||||||
|
.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
||||||
|
.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
||||||
|
.on(RoomEvent.TrackSubscriptionFailed, onTrackSubscriptionFailed)
|
||||||
|
.on(RoomEvent.TrackMuted, onTrackMuted)
|
||||||
|
.on(RoomEvent.TrackUnmuted, onTrackUnmuted)
|
||||||
|
.on(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged)
|
||||||
|
.on(
|
||||||
|
RoomEvent.ParticipantEncryptionStatusChanged,
|
||||||
|
onEncryptionStatusChanged,
|
||||||
|
)
|
||||||
|
.on(RoomEvent.EncryptionError, onEncryptionError);
|
||||||
|
this.scope.onEnd(() => {
|
||||||
|
room
|
||||||
|
.off(RoomEvent.ParticipantConnected, onParticipantConnected)
|
||||||
|
.off(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
||||||
|
.off(RoomEvent.TrackPublished, onTrackPublished)
|
||||||
|
.off(RoomEvent.TrackUnpublished, onTrackUnpublished)
|
||||||
|
.off(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
||||||
|
.off(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
||||||
|
.off(RoomEvent.TrackSubscriptionFailed, onTrackSubscriptionFailed)
|
||||||
|
.off(RoomEvent.TrackMuted, onTrackMuted)
|
||||||
|
.off(RoomEvent.TrackUnmuted, onTrackUnmuted)
|
||||||
|
.off(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged)
|
||||||
|
.off(
|
||||||
|
RoomEvent.ParticipantEncryptionStatusChanged,
|
||||||
|
onEncryptionStatusChanged,
|
||||||
|
)
|
||||||
|
.off(RoomEvent.EncryptionError, onEncryptionError);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the connection.
|
||||||
|
*
|
||||||
|
* This will:
|
||||||
|
* 1. Authenticate with the transport for our role, through the homeserver
|
||||||
|
* or the JWT service.
|
||||||
|
* 2. Connect to the LiveKit room the token is for.
|
||||||
|
*
|
||||||
|
* The errors are also represented as a state in the `state$` observable.
|
||||||
|
* It is safe to ignore those errors and handle them accordingly via the `state$` observable.
|
||||||
|
* @throws {InsufficientCapacityError} if the LiveKit server indicates that it has insufficient capacity to accept the connection.
|
||||||
|
* @throws {SFURoomCreationRestrictedError} if the LiveKit server indicates that the room does not exist and cannot be created.
|
||||||
|
*/
|
||||||
|
// TODO consider an autostart pattern...
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
this.logger.debug("Starting Connection");
|
||||||
|
this.stopped = false;
|
||||||
|
try {
|
||||||
|
this._state$.next({ state: "authenticating" });
|
||||||
|
const { url, jwt, livekitAlias } = await this.getSFUConfig();
|
||||||
|
this.logger.debug(
|
||||||
|
"Starting Connection - jwt: ",
|
||||||
|
jwt,
|
||||||
|
"wss: ",
|
||||||
|
url,
|
||||||
|
"livekitAlias: ",
|
||||||
|
livekitAlias,
|
||||||
|
);
|
||||||
|
this._livekitAlias = livekitAlias;
|
||||||
|
// If we were stopped while fetching the config, don't proceed to connect
|
||||||
|
if (this.stopped) return;
|
||||||
|
|
||||||
|
// Setup observer once we are done with getSFUConfig
|
||||||
|
connectionStateObserver(this.livekitRoom)
|
||||||
|
.pipe(this.scope.bind())
|
||||||
|
.subscribe((livekitState) =>
|
||||||
|
this._state$.next({ state: "authenticated", livekitState }),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.logger.info(`livekitRoom.connect ${url}`);
|
||||||
|
await this.livekitRoom.connect(url, jwt);
|
||||||
|
this.logger.info(`livekitRoom.connect SUCCESS ${url}`);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.info(`livekitRoom.connect FAILED ${url}`, e);
|
||||||
|
// LiveKit uses 503 to indicate that the server has hit its track limits.
|
||||||
|
// https://github.com/livekit/livekit/blob/fcb05e97c5a31812ecf0ca6f7efa57c485cea9fb/pkg/service/rtcservice.go#L171
|
||||||
|
// It also errors with a status code of 200 (yes, really) for room
|
||||||
|
// participant limits.
|
||||||
|
// LiveKit Cloud uses 429 for connection limits.
|
||||||
|
// Either way, all these errors can be explained as "insufficient capacity".
|
||||||
|
if (e instanceof ConnectionError) {
|
||||||
|
if (e.status === 503 || e.status === 200 || e.status === 429) {
|
||||||
|
throw new InsufficientCapacityError();
|
||||||
|
}
|
||||||
|
if (e.status === 404) {
|
||||||
|
// error msg is "Failed to create call"
|
||||||
|
// error description is "Call creation might be restricted to authorized users only. Try again later, or contact your server admin if the problem persists."
|
||||||
|
// The room does not exist. There are two different modes of operation for the SFU:
|
||||||
|
// - the room is created on the fly when connecting (livekit `auto_create` option)
|
||||||
|
// - Only authorized users can create rooms, so the room must exist before connecting (done by the auth jwt service)
|
||||||
|
// In the first case there will not be a 404, so we are in the second case.
|
||||||
|
throw new SFURoomCreationRestrictedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.reason === ConnectionErrorReason.Timeout) {
|
||||||
|
// Unabled to establish peer connection within the timeout
|
||||||
|
throw new PeerConnectionTimeoutError();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new LivekitConnectionError(e);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
// If we were stopped while connecting, don't proceed to update state.
|
||||||
|
if (this.stopped) return;
|
||||||
|
} catch (error) {
|
||||||
|
if (this.stopped) {
|
||||||
|
// stop() was called while we were connecting, which makes the pending
|
||||||
|
// connect reject. That is the abort we asked for, not a failure, so
|
||||||
|
// don't record an error state on a stopped connection or rethrow it
|
||||||
|
// (start() is not necessarily awaited by the caller, so a throw here
|
||||||
|
// becomes an unhandled promise rejection).
|
||||||
|
this.logger.debug(`Connect aborted because the connection was stopped`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logger.debug(`Failed to connect to LiveKit room: ${error}`);
|
||||||
|
this._state$.next({
|
||||||
|
state: "error",
|
||||||
|
error:
|
||||||
|
error instanceof MatrixRTCError
|
||||||
|
? error
|
||||||
|
: error instanceof Error
|
||||||
|
? new UnknownRTCError(error)
|
||||||
|
: new UnknownRTCError(new Error(`${error}`)),
|
||||||
|
});
|
||||||
|
// Its okay to ignore the throw. The error is part of the state.
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getSFUConfig(): Promise<SFUConfig> {
|
||||||
|
return await getSFUConfig({
|
||||||
|
client: this.client,
|
||||||
|
membership: this.ownMembershipIdentity,
|
||||||
|
role: this.role,
|
||||||
|
transport: this.transport,
|
||||||
|
serverName: this.serverName,
|
||||||
|
roomId: this.roomId,
|
||||||
|
slotId: this.slotId,
|
||||||
|
logger: this.logger,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the connection.
|
||||||
|
*
|
||||||
|
* This will disconnect from the LiveKit room.
|
||||||
|
* If the connection is already stopped, this is a no-op.
|
||||||
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
this.logger.debug("stop: disconnecting from lk room");
|
||||||
|
if (this.stopped) return;
|
||||||
|
// Mark as stopped before disconnecting so that a connect() aborted by the
|
||||||
|
// disconnect sees the flag and does not report the abort as an error.
|
||||||
|
this.stopped = true;
|
||||||
|
await this.livekitRoom.disconnect();
|
||||||
|
this._state$.next({ state: "stopped" });
|
||||||
|
this.logger.debug("stop: DONE disconnecting from lk room");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ConnectionState as LivekitConnectionState,
|
||||||
|
type RoomOptions,
|
||||||
|
} from "livekit-client";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { type MediaQuality } from "../../config";
|
||||||
|
import { type PublishRequest } from "../../media-api";
|
||||||
|
import { constant } from "../../reactive/Behavior";
|
||||||
|
import { ownMemberMock, testScope } from "../../utils/test";
|
||||||
|
import { testJWTToken } from "../../utils/test-fixtures";
|
||||||
|
import { type ClientGetTokenParts, type ClientOpenIDParts } from "./auth";
|
||||||
|
import {
|
||||||
|
LivekitConnectionFactory,
|
||||||
|
type ResolvedConnection,
|
||||||
|
} from "./ConnectionFactory";
|
||||||
|
import { exampleTransport } from "./test";
|
||||||
|
|
||||||
|
const urlTransport: UnstableLivekitTransport = {
|
||||||
|
type: "livekit",
|
||||||
|
url: "wss://sfu.example.org",
|
||||||
|
};
|
||||||
|
|
||||||
|
function client(): ClientGetTokenParts & ClientOpenIDParts {
|
||||||
|
return {
|
||||||
|
getOpenIdToken: vi.fn(),
|
||||||
|
_unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: testJWTToken }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a subscriber connection and hands back the options its LiveKit room
|
||||||
|
* was built with. The room is a real one: it never connects, and its options
|
||||||
|
* are what the factory gave it, filled in with LiveKit's defaults.
|
||||||
|
*/
|
||||||
|
function create(
|
||||||
|
publish: PublishRequest[],
|
||||||
|
mediaQuality?: MediaQuality,
|
||||||
|
audioOutputDeviceId?: string,
|
||||||
|
transport = exampleTransport,
|
||||||
|
): { connection: ResolvedConnection; options: RoomOptions } {
|
||||||
|
const factory = new LivekitConnectionFactory(
|
||||||
|
client(),
|
||||||
|
"!room:example.org",
|
||||||
|
"m.call#ROOM",
|
||||||
|
publish,
|
||||||
|
constant(audioOutputDeviceId),
|
||||||
|
undefined,
|
||||||
|
mediaQuality,
|
||||||
|
);
|
||||||
|
const connection = factory.createConnection(
|
||||||
|
testScope(),
|
||||||
|
"subscriber",
|
||||||
|
transport,
|
||||||
|
"example.org",
|
||||||
|
ownMemberMock,
|
||||||
|
logger,
|
||||||
|
) as ResolvedConnection;
|
||||||
|
return { connection, options: connection.livekitRoom.options };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("LivekitConnectionFactory", () => {
|
||||||
|
it("builds the room on the defaults where nothing is requested", () => {
|
||||||
|
const { options } = create([]);
|
||||||
|
|
||||||
|
expect(options.publishDefaults).toMatchObject({ videoCodec: "vp8" });
|
||||||
|
expect(options.videoCaptureDefaults).toMatchObject({
|
||||||
|
resolution: expect.objectContaining({ height: 720 }),
|
||||||
|
});
|
||||||
|
expect(options.audioOutput).toEqual({ deviceId: undefined });
|
||||||
|
expect(options.e2ee).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes the initial microphone request the room's audio defaults", () => {
|
||||||
|
const { options } = create([
|
||||||
|
{
|
||||||
|
source: "microphone",
|
||||||
|
deviceId: "mic-1",
|
||||||
|
capture: {
|
||||||
|
echoCancellation: false,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(options.audioCaptureDefaults).toMatchObject({
|
||||||
|
deviceId: "mic-1",
|
||||||
|
echoCancellation: false,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes the initial camera request the room's video and publish defaults", () => {
|
||||||
|
const { options } = create(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
source: "camera",
|
||||||
|
deviceId: "cam-1",
|
||||||
|
capture: {
|
||||||
|
resolution: { width: 1920, height: 1080, frameRate: 60 },
|
||||||
|
maxBitrate: 4_000_000,
|
||||||
|
maxFramerate: 60,
|
||||||
|
codec: "vp9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ video_codec: "h264" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(options.videoCaptureDefaults).toMatchObject({
|
||||||
|
deviceId: "cam-1",
|
||||||
|
resolution: { width: 1920, height: 1080, frameRate: 60 },
|
||||||
|
});
|
||||||
|
// The request wins over the media quality settings
|
||||||
|
expect(options.publishDefaults).toMatchObject({
|
||||||
|
videoEncoding: { maxBitrate: 4_000_000, maxFramerate: 60 },
|
||||||
|
videoCodec: "vp9",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the media quality settings where the requests leave the defaults", () => {
|
||||||
|
const { options } = create([{ source: "camera" }], {
|
||||||
|
video_codec: "av1",
|
||||||
|
video: { max_bitrate: 900_000, max_framerate: 24 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(options.publishDefaults).toMatchObject({
|
||||||
|
videoEncoding: { maxBitrate: 900_000, maxFramerate: 24 },
|
||||||
|
videoCodec: "av1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("plays on the output device selected when the room is built", () => {
|
||||||
|
const { options } = create([], undefined, "speaker");
|
||||||
|
expect(options.audioOutput).toEqual({ deviceId: "speaker" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports what it fetched while connected, and nothing before or after", async () => {
|
||||||
|
const { connection } = create([], undefined, undefined, urlTransport);
|
||||||
|
// The room must not reach for the network; its state is left as it is
|
||||||
|
vi.spyOn(connection.livekitRoom, "connect").mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(connection.livekitRoom, "disconnect").mockResolvedValue(undefined);
|
||||||
|
expect(connection.resolved$.value).toBeUndefined();
|
||||||
|
|
||||||
|
await connection.start();
|
||||||
|
|
||||||
|
expect(connection.state$.value).toEqual({
|
||||||
|
state: "authenticated",
|
||||||
|
livekitState: LivekitConnectionState.Disconnected,
|
||||||
|
});
|
||||||
|
expect(connection.resolved$.value).toEqual({
|
||||||
|
type: "livekit",
|
||||||
|
url: "wss://sfu.example.org",
|
||||||
|
token: testJWTToken,
|
||||||
|
roomAlias: "!example_room_id",
|
||||||
|
identity: "@me:example.org:ABCDEF",
|
||||||
|
});
|
||||||
|
|
||||||
|
await connection.stop();
|
||||||
|
expect(connection.resolved$.value).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type BaseKeyProvider,
|
||||||
|
Room as LivekitRoom,
|
||||||
|
type RoomOptions,
|
||||||
|
} from "livekit-client";
|
||||||
|
// Inline so that the worker also loads when the SDK is served from another
|
||||||
|
// origin than the page
|
||||||
|
import E2EEWorker from "livekit-client/e2ee-worker?worker&inline";
|
||||||
|
import { type 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";
|
||||||
|
import { BehaviorSubject, combineLatest, map } from "rxjs";
|
||||||
|
|
||||||
|
import { type ResolvedTransport } from "../../api";
|
||||||
|
import { type MediaQuality } from "../../config";
|
||||||
|
import { type PublishRequest } from "../../media-api";
|
||||||
|
import { type Behavior } from "../../reactive/Behavior";
|
||||||
|
import { type ObservableScope } from "../../reactive/ObservableScope";
|
||||||
|
import {
|
||||||
|
type ClientGetTokenParts,
|
||||||
|
type ClientOpenIDParts,
|
||||||
|
type ConnectionRole,
|
||||||
|
type SFUConfig,
|
||||||
|
} from "./auth";
|
||||||
|
import { Connection, type ConnectionOpts } from "./Connection";
|
||||||
|
import { buildLiveKitOptions } from "./livekitOptions";
|
||||||
|
import {
|
||||||
|
audioCaptureOptions,
|
||||||
|
type PublishRequestFor,
|
||||||
|
videoCaptureOptions,
|
||||||
|
videoPublishOptions,
|
||||||
|
} from "./publishOptions";
|
||||||
|
|
||||||
|
export interface ConnectionFactory {
|
||||||
|
createConnection(
|
||||||
|
scope: ObservableScope,
|
||||||
|
role: ConnectionRole,
|
||||||
|
transport: UnstableLivekitTransport,
|
||||||
|
serverName: string,
|
||||||
|
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||||
|
logger: Logger,
|
||||||
|
): Connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A connection that keeps what it fetched when authenticating, so that the
|
||||||
|
* transport can report it as `resolved$`. Nothing while the token is still
|
||||||
|
* being fetched, and nothing again once the connection has stopped or failed.
|
||||||
|
*/
|
||||||
|
export class ResolvedConnection extends Connection {
|
||||||
|
private readonly sfuConfig$ = new BehaviorSubject<SFUConfig | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
public readonly resolved$: Behavior<ResolvedTransport | undefined>;
|
||||||
|
|
||||||
|
public constructor(opts: ConnectionOpts, logger: Logger) {
|
||||||
|
super(opts, logger);
|
||||||
|
// One object per fetch, so that a change of state does not read as a
|
||||||
|
// change of what was fetched
|
||||||
|
const resolved$ = this.sfuConfig$.pipe(
|
||||||
|
map((config) => config && resolvedTransport(config)),
|
||||||
|
);
|
||||||
|
this.resolved$ = opts.scope.behavior(
|
||||||
|
combineLatest([resolved$, this.state$]).pipe(
|
||||||
|
map(([resolved, state]) =>
|
||||||
|
state.state === "stopped" || state.state === "error"
|
||||||
|
? undefined
|
||||||
|
: resolved,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async getSFUConfig(): Promise<SFUConfig> {
|
||||||
|
const config = await super.getSFUConfig();
|
||||||
|
this.sfuConfig$.next(config);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvedTransport(config: SFUConfig): ResolvedTransport {
|
||||||
|
return {
|
||||||
|
type: "livekit",
|
||||||
|
url: config.url,
|
||||||
|
token: config.jwt,
|
||||||
|
roomAlias: config.livekitAlias,
|
||||||
|
identity: config.livekitIdentity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a `ResolvedConnection` per transport, each with a LiveKit room of
|
||||||
|
* its own that captures from the devices the host selected.
|
||||||
|
*/
|
||||||
|
export class LivekitConnectionFactory implements ConnectionFactory {
|
||||||
|
public constructor(
|
||||||
|
private readonly client: ClientGetTokenParts & ClientOpenIDParts,
|
||||||
|
private readonly roomId: string,
|
||||||
|
private readonly slotId: string,
|
||||||
|
private readonly publish: PublishRequest[],
|
||||||
|
private readonly audioOutputDeviceId$: Behavior<string | undefined>,
|
||||||
|
private readonly keyProvider: BaseKeyProvider | undefined,
|
||||||
|
private readonly mediaQuality: MediaQuality | undefined,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public createConnection(
|
||||||
|
scope: ObservableScope,
|
||||||
|
role: ConnectionRole,
|
||||||
|
transport: UnstableLivekitTransport,
|
||||||
|
serverName: string,
|
||||||
|
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||||
|
logger: Logger,
|
||||||
|
): Connection {
|
||||||
|
return new ResolvedConnection(
|
||||||
|
{
|
||||||
|
roomId: this.roomId,
|
||||||
|
slotId: this.slotId,
|
||||||
|
role,
|
||||||
|
transport,
|
||||||
|
serverName,
|
||||||
|
client: this.client,
|
||||||
|
scope,
|
||||||
|
livekitRoomFactory: () =>
|
||||||
|
new LivekitRoom(
|
||||||
|
roomOptions(
|
||||||
|
this.publish,
|
||||||
|
this.audioOutputDeviceId$.value,
|
||||||
|
this.keyProvider,
|
||||||
|
this.mediaQuality,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ownMembershipIdentity,
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The initial requests become the room's defaults, so that one permission prompt covers both. */
|
||||||
|
function roomOptions(
|
||||||
|
publish: PublishRequest[],
|
||||||
|
audioOutputDeviceId: string | undefined,
|
||||||
|
keyProvider: BaseKeyProvider | undefined,
|
||||||
|
mediaQuality: MediaQuality | undefined,
|
||||||
|
): RoomOptions {
|
||||||
|
const base = buildLiveKitOptions(mediaQuality);
|
||||||
|
const microphone = initialRequest(publish, "microphone");
|
||||||
|
const camera = initialRequest(publish, "camera");
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
videoCaptureDefaults: {
|
||||||
|
...base.videoCaptureDefaults,
|
||||||
|
...(camera && videoCaptureOptions(camera)),
|
||||||
|
},
|
||||||
|
publishDefaults: {
|
||||||
|
...base.publishDefaults,
|
||||||
|
...(camera && videoPublishOptions(camera.capture)),
|
||||||
|
},
|
||||||
|
audioCaptureDefaults: {
|
||||||
|
...base.audioCaptureDefaults,
|
||||||
|
...(microphone && audioCaptureOptions(microphone)),
|
||||||
|
},
|
||||||
|
audioOutput: { deviceId: audioOutputDeviceId },
|
||||||
|
// Every room needs a worker of its own: one gets confused by streams from
|
||||||
|
// several rooms
|
||||||
|
e2ee: keyProvider && { keyProvider, worker: new E2EEWorker() },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialRequest<S extends "microphone" | "camera">(
|
||||||
|
publish: PublishRequest[],
|
||||||
|
source: S,
|
||||||
|
): PublishRequestFor<S> | undefined {
|
||||||
|
return publish.find(
|
||||||
|
(request): request is PublishRequestFor<S> => request.source === source,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { type RemoteParticipant } from "livekit-client";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Epoch,
|
||||||
|
mapEpoch,
|
||||||
|
ObservableScope,
|
||||||
|
} from "../../reactive/ObservableScope";
|
||||||
|
import {
|
||||||
|
createConnectionMap$,
|
||||||
|
ConnectionMap,
|
||||||
|
keyFor,
|
||||||
|
} from "./ConnectionMap.ts";
|
||||||
|
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 { type TransportLocator } from "./auth";
|
||||||
|
|
||||||
|
const TRANSPORT_1: TransportLocator = {
|
||||||
|
transport: {
|
||||||
|
type: "livekit",
|
||||||
|
livekit_service_url: "https://lk.example.org",
|
||||||
|
},
|
||||||
|
serverName: "example.org",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRANSPORT_2: TransportLocator = {
|
||||||
|
transport: {
|
||||||
|
type: "livekit",
|
||||||
|
livekit_service_url: "https://lk.sample.com",
|
||||||
|
},
|
||||||
|
serverName: "sample.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
function findConnection(
|
||||||
|
transport: TransportLocator,
|
||||||
|
connections: Connection[],
|
||||||
|
): Connection | undefined {
|
||||||
|
const key = keyFor(transport);
|
||||||
|
return connections.find((c) => keyFor(c) === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
let fakeConnectionFactory: ConnectionFactory;
|
||||||
|
let testScope: ObservableScope;
|
||||||
|
|
||||||
|
// Can be useful to track all created connections in tests, even the disposed ones
|
||||||
|
let allCreatedConnections: Connection[];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
testScope = new ObservableScope();
|
||||||
|
allCreatedConnections = [];
|
||||||
|
fakeConnectionFactory = {} as unknown as ConnectionFactory;
|
||||||
|
vi.mocked(fakeConnectionFactory).createConnection = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(
|
||||||
|
(
|
||||||
|
scope: ObservableScope,
|
||||||
|
_role: unknown,
|
||||||
|
transport: UnstableLivekitTransport,
|
||||||
|
serverName: string,
|
||||||
|
) => {
|
||||||
|
const mockConnection = {
|
||||||
|
transport,
|
||||||
|
serverName,
|
||||||
|
remoteParticipants$: new BehaviorSubject([]),
|
||||||
|
} as unknown as Connection;
|
||||||
|
vi.mocked(mockConnection).start = vi.fn();
|
||||||
|
vi.mocked(mockConnection).stop = vi.fn();
|
||||||
|
// Tie the connection's lifecycle to the scope to test scope lifecycle management
|
||||||
|
scope.onEnd(() => {
|
||||||
|
void mockConnection.stop();
|
||||||
|
});
|
||||||
|
allCreatedConnections.push(mockConnection);
|
||||||
|
return mockConnection;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
testScope.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("connections$ stream", () => {
|
||||||
|
test("Should create and start new connections for each transports", () => {
|
||||||
|
withTestScheduler(({ behavior, expectObservable }) => {
|
||||||
|
const connectionMap$ = createConnectionMap$({
|
||||||
|
scope: testScope,
|
||||||
|
connectionFactory: fakeConnectionFactory,
|
||||||
|
localTransport: TRANSPORT_1,
|
||||||
|
remoteTransports$: behavior("a", {
|
||||||
|
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
|
||||||
|
}),
|
||||||
|
logger: logger,
|
||||||
|
ownMembershipIdentity: ownMemberMock,
|
||||||
|
});
|
||||||
|
|
||||||
|
expectObservable(
|
||||||
|
connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
|
||||||
|
).toBe("a", {
|
||||||
|
a: expect.toSatisfy((e: Epoch<Connection[]>) => {
|
||||||
|
const connections = e.value;
|
||||||
|
expect(connections.length).toBe(2);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
vi.mocked(fakeConnectionFactory).createConnection,
|
||||||
|
).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
const conn1 = findConnection(TRANSPORT_1, connections);
|
||||||
|
expect(conn1).toBeDefined();
|
||||||
|
expect(conn1!.start).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const conn2 = findConnection(TRANSPORT_2, connections);
|
||||||
|
expect(conn2).toBeDefined();
|
||||||
|
expect(conn2!.start).toHaveBeenCalled();
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Tags the local transport as the publisher and the rest as subscribers", () => {
|
||||||
|
withTestScheduler(({ behavior, expectObservable }) => {
|
||||||
|
const connectionMap$ = createConnectionMap$({
|
||||||
|
scope: testScope,
|
||||||
|
connectionFactory: fakeConnectionFactory,
|
||||||
|
localTransport: TRANSPORT_1,
|
||||||
|
remoteTransports$: behavior("a", {
|
||||||
|
a: new Epoch([TRANSPORT_2], 0),
|
||||||
|
}),
|
||||||
|
logger: logger,
|
||||||
|
ownMembershipIdentity: ownMemberMock,
|
||||||
|
});
|
||||||
|
|
||||||
|
expectObservable(connectionMap$).toBe("a", {
|
||||||
|
a: expect.toSatisfy(() => {
|
||||||
|
const { createConnection } = vi.mocked(fakeConnectionFactory);
|
||||||
|
expect(createConnection).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
"publisher",
|
||||||
|
TRANSPORT_1.transport,
|
||||||
|
TRANSPORT_1.serverName,
|
||||||
|
ownMemberMock,
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(createConnection).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
"subscriber",
|
||||||
|
TRANSPORT_2.transport,
|
||||||
|
TRANSPORT_2.serverName,
|
||||||
|
ownMemberMock,
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Shares one connection between homeservers advertising the same JWT service", () => {
|
||||||
|
withTestScheduler(({ behavior, expectObservable }) => {
|
||||||
|
const sameServiceElsewhere: TransportLocator = {
|
||||||
|
transport: TRANSPORT_1.transport,
|
||||||
|
serverName: "elsewhere.org",
|
||||||
|
};
|
||||||
|
const connectionMap$ = createConnectionMap$({
|
||||||
|
scope: testScope,
|
||||||
|
connectionFactory: fakeConnectionFactory,
|
||||||
|
localTransport: TRANSPORT_1,
|
||||||
|
remoteTransports$: behavior("a", {
|
||||||
|
a: new Epoch([sameServiceElsewhere], 0),
|
||||||
|
}),
|
||||||
|
logger: logger,
|
||||||
|
ownMembershipIdentity: ownMemberMock,
|
||||||
|
});
|
||||||
|
|
||||||
|
expectObservable(
|
||||||
|
connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
|
||||||
|
).toBe("a", {
|
||||||
|
a: expect.toSatisfy((e: Epoch<Connection[]>) => {
|
||||||
|
expect(e.value.length).toBe(1);
|
||||||
|
// The JWT service is reached directly, so the server name plays no
|
||||||
|
// part and the local locator, which came first, is the one used
|
||||||
|
expect(
|
||||||
|
vi.mocked(fakeConnectionFactory).createConnection,
|
||||||
|
).toHaveBeenCalledExactlyOnceWith(
|
||||||
|
expect.anything(),
|
||||||
|
"publisher",
|
||||||
|
TRANSPORT_1.transport,
|
||||||
|
"example.org",
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Keeps one connection per homeserver for transports with a url", () => {
|
||||||
|
withTestScheduler(({ behavior, expectObservable }) => {
|
||||||
|
const urlTransport: TransportLocator = {
|
||||||
|
transport: { type: "livekit", url: "wss://sfu.example.org" },
|
||||||
|
serverName: "example.org",
|
||||||
|
};
|
||||||
|
const sameUrlElsewhere: TransportLocator = {
|
||||||
|
transport: urlTransport.transport,
|
||||||
|
serverName: "elsewhere.org",
|
||||||
|
};
|
||||||
|
const connectionMap$ = createConnectionMap$({
|
||||||
|
scope: testScope,
|
||||||
|
connectionFactory: fakeConnectionFactory,
|
||||||
|
localTransport: urlTransport,
|
||||||
|
remoteTransports$: behavior("a", {
|
||||||
|
a: new Epoch([sameUrlElsewhere], 0),
|
||||||
|
}),
|
||||||
|
logger: logger,
|
||||||
|
ownMembershipIdentity: ownMemberMock,
|
||||||
|
});
|
||||||
|
|
||||||
|
expectObservable(
|
||||||
|
connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
|
||||||
|
).toBe("a", {
|
||||||
|
a: expect.toSatisfy((e: Epoch<Connection[]>) => {
|
||||||
|
// Our homeserver asks the named server for the token, so each
|
||||||
|
// server name needs its own connection
|
||||||
|
expect(e.value.length).toBe(2);
|
||||||
|
expect(findConnection(urlTransport, e.value)).toBeDefined();
|
||||||
|
expect(findConnection(sameUrlElsewhere, e.value)).toBeDefined();
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Should start connection only once", () => {
|
||||||
|
withTestScheduler(({ behavior, expectObservable }) => {
|
||||||
|
const connectionMap$ = createConnectionMap$({
|
||||||
|
scope: testScope,
|
||||||
|
connectionFactory: fakeConnectionFactory,
|
||||||
|
localTransport: TRANSPORT_1,
|
||||||
|
remoteTransports$: behavior("abcdef", {
|
||||||
|
a: new Epoch([TRANSPORT_1], 0),
|
||||||
|
b: new Epoch([TRANSPORT_1], 1),
|
||||||
|
c: new Epoch([TRANSPORT_1], 2),
|
||||||
|
d: new Epoch([TRANSPORT_1], 3),
|
||||||
|
e: new Epoch([TRANSPORT_1], 4),
|
||||||
|
f: new Epoch([TRANSPORT_1, TRANSPORT_2], 5),
|
||||||
|
}),
|
||||||
|
logger: logger,
|
||||||
|
ownMembershipIdentity: ownMemberMock,
|
||||||
|
});
|
||||||
|
|
||||||
|
expectObservable(
|
||||||
|
connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
|
||||||
|
).toBe("xxxxxa", {
|
||||||
|
x: expect.anything(),
|
||||||
|
a: expect.toSatisfy((e: Epoch<Connection[]>) => {
|
||||||
|
const connections = e.value;
|
||||||
|
|
||||||
|
expect(connections.length).toBe(2);
|
||||||
|
expect(
|
||||||
|
vi.mocked(fakeConnectionFactory).createConnection,
|
||||||
|
).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
const conn2 = findConnection(TRANSPORT_2, connections);
|
||||||
|
expect(conn2).toBeDefined();
|
||||||
|
|
||||||
|
const conn1 = findConnection(TRANSPORT_1, connections);
|
||||||
|
expect(conn1).toBeDefined();
|
||||||
|
expect(conn1!.start).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Should cleanup connections when not needed anymore", () => {
|
||||||
|
withTestScheduler(({ behavior, expectObservable }) => {
|
||||||
|
const connectionMap$ = createConnectionMap$({
|
||||||
|
scope: testScope,
|
||||||
|
connectionFactory: fakeConnectionFactory,
|
||||||
|
localTransport: TRANSPORT_1,
|
||||||
|
remoteTransports$: behavior("abc", {
|
||||||
|
a: new Epoch([TRANSPORT_1], 0),
|
||||||
|
b: new Epoch([TRANSPORT_1, TRANSPORT_2], 1),
|
||||||
|
c: new Epoch([TRANSPORT_1], 2),
|
||||||
|
}),
|
||||||
|
logger: logger,
|
||||||
|
ownMembershipIdentity: ownMemberMock,
|
||||||
|
});
|
||||||
|
|
||||||
|
expectObservable(
|
||||||
|
connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
|
||||||
|
).toBe("xab", {
|
||||||
|
x: expect.anything(),
|
||||||
|
a: expect.toSatisfy((e: Epoch<Connection[]>) => {
|
||||||
|
const connections = e.value;
|
||||||
|
expect(connections.length).toBe(2);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
b: expect.toSatisfy((e: Epoch<Connection[]>) => {
|
||||||
|
const connections = e.value;
|
||||||
|
|
||||||
|
expect(connections.length).toBe(1);
|
||||||
|
// The second connection should have been stopped has it is no longer needed.
|
||||||
|
const connection2 = findConnection(
|
||||||
|
TRANSPORT_2,
|
||||||
|
allCreatedConnections,
|
||||||
|
);
|
||||||
|
expect(connection2).toBeDefined();
|
||||||
|
expect(connection2!.stop).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// The first connection should still be active
|
||||||
|
const conn1 = connections[0];
|
||||||
|
expect(conn1.stop).not.toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ConnectionMap", () => {
|
||||||
|
test("warns when a second connection to the same URL is merged", () => {
|
||||||
|
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
||||||
|
const data = new ConnectionMap(logger);
|
||||||
|
const connection = TRANSPORT_1 as unknown as Connection;
|
||||||
|
const p = (identity: string): RemoteParticipant =>
|
||||||
|
({ identity }) as unknown as RemoteParticipant;
|
||||||
|
data.add(connection, [p("a")]);
|
||||||
|
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]',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(data.getParticipantsForTransport(TRANSPORT_1)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("connectionMap$", () => {
|
||||||
|
// Used in test to control fake connections' remoteParticipants$ streams
|
||||||
|
let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fakeRemoteParticipantsStreams = new Map();
|
||||||
|
|
||||||
|
function getRemoteParticipantsFor(
|
||||||
|
transport: TransportLocator,
|
||||||
|
): Behavior<RemoteParticipant[]> {
|
||||||
|
return (
|
||||||
|
fakeRemoteParticipantsStreams.get(keyFor(transport)) ??
|
||||||
|
new BehaviorSubject([])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// need a more advanced fake connection factory
|
||||||
|
vi.mocked(fakeConnectionFactory).createConnection = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(
|
||||||
|
(
|
||||||
|
scope: ObservableScope,
|
||||||
|
_role: unknown,
|
||||||
|
transport: UnstableLivekitTransport,
|
||||||
|
serverName: string,
|
||||||
|
) => {
|
||||||
|
const fakeRemoteParticipants$ = new BehaviorSubject<
|
||||||
|
RemoteParticipant[]
|
||||||
|
>([]);
|
||||||
|
const mockConnection = {
|
||||||
|
transport,
|
||||||
|
serverName,
|
||||||
|
remoteParticipants$: getRemoteParticipantsFor({
|
||||||
|
transport,
|
||||||
|
serverName,
|
||||||
|
}),
|
||||||
|
} as unknown as Connection;
|
||||||
|
vi.mocked(mockConnection).start = vi.fn();
|
||||||
|
vi.mocked(mockConnection).stop = vi.fn();
|
||||||
|
// Tie the connection's lifecycle to the scope to test scope lifecycle management
|
||||||
|
scope.onEnd(() => {
|
||||||
|
void mockConnection.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
fakeRemoteParticipantsStreams.set(
|
||||||
|
keyFor({ transport, serverName }),
|
||||||
|
fakeRemoteParticipants$,
|
||||||
|
);
|
||||||
|
return mockConnection;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Should report connections with the remote participants", () => {
|
||||||
|
withTestScheduler(({ expectObservable, behavior }) => {
|
||||||
|
// Setup the fake participants streams behavior
|
||||||
|
// ==============================
|
||||||
|
fakeRemoteParticipantsStreams.set(
|
||||||
|
keyFor(TRANSPORT_1),
|
||||||
|
behavior("oa-b", {
|
||||||
|
o: [],
|
||||||
|
a: [{ identity: "user1A" } as RemoteParticipant],
|
||||||
|
b: [
|
||||||
|
{ identity: "user1A" } as RemoteParticipant,
|
||||||
|
{ identity: "user1B" } as RemoteParticipant,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
fakeRemoteParticipantsStreams.set(
|
||||||
|
keyFor(TRANSPORT_2),
|
||||||
|
behavior("o-a", {
|
||||||
|
o: [],
|
||||||
|
a: [{ identity: "user2A" } as RemoteParticipant],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// ==============================
|
||||||
|
|
||||||
|
const connectionMap$ = createConnectionMap$({
|
||||||
|
scope: testScope,
|
||||||
|
connectionFactory: fakeConnectionFactory,
|
||||||
|
localTransport: TRANSPORT_1,
|
||||||
|
remoteTransports$: behavior("a", {
|
||||||
|
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
|
||||||
|
}),
|
||||||
|
logger,
|
||||||
|
ownMembershipIdentity: ownMemberMock,
|
||||||
|
});
|
||||||
|
|
||||||
|
expectObservable(connectionMap$).toBe("abcd", {
|
||||||
|
a: expect.toSatisfy((e) => {
|
||||||
|
const data: ConnectionMap = e.value;
|
||||||
|
expect(data.getConnections().length).toBe(2);
|
||||||
|
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(0);
|
||||||
|
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(0);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
b: expect.toSatisfy((e) => {
|
||||||
|
const data: ConnectionMap = e.value;
|
||||||
|
expect(data.getConnections().length).toBe(2);
|
||||||
|
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(1);
|
||||||
|
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(0);
|
||||||
|
expect(
|
||||||
|
data.getParticipantsForTransport(TRANSPORT_1)[0].identity,
|
||||||
|
).toBe("user1A");
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
c: expect.toSatisfy((e) => {
|
||||||
|
const data: ConnectionMap = e.value;
|
||||||
|
expect(data.getConnections().length).toBe(2);
|
||||||
|
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(1);
|
||||||
|
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(1);
|
||||||
|
expect(
|
||||||
|
data.getParticipantsForTransport(TRANSPORT_1)[0].identity,
|
||||||
|
).toBe("user1A");
|
||||||
|
expect(
|
||||||
|
data.getParticipantsForTransport(TRANSPORT_2)[0].identity,
|
||||||
|
).toBe("user2A");
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
d: expect.toSatisfy((e) => {
|
||||||
|
const data: ConnectionMap = e.value;
|
||||||
|
expect(data.getConnections().length).toBe(2);
|
||||||
|
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(2);
|
||||||
|
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(1);
|
||||||
|
expect(
|
||||||
|
data.getParticipantsForTransport(TRANSPORT_1)[0].identity,
|
||||||
|
).toBe("user1A");
|
||||||
|
expect(
|
||||||
|
data.getParticipantsForTransport(TRANSPORT_1)[1].identity,
|
||||||
|
).toBe("user1B");
|
||||||
|
expect(
|
||||||
|
data.getParticipantsForTransport(TRANSPORT_2)[0].identity,
|
||||||
|
).toBe("user2A");
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025-2026 Element Creations Ltd.
|
||||||
|
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 { type RemoteParticipant } from "livekit-client";
|
||||||
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||||
|
import { combineLatest, map, of, switchMap, tap } from "rxjs";
|
||||||
|
|
||||||
|
import { type Behavior } from "../../reactive/Behavior";
|
||||||
|
import {
|
||||||
|
Epoch,
|
||||||
|
mapEpoch,
|
||||||
|
type ObservableScope,
|
||||||
|
} from "../../reactive/ObservableScope";
|
||||||
|
import { generateItemsWithEpoch } from "../../reactive/observable";
|
||||||
|
import { type ConnectionRole, type TransportLocator } from "./auth";
|
||||||
|
import { type Connection } from "./Connection";
|
||||||
|
import { type ConnectionFactory } from "./ConnectionFactory";
|
||||||
|
|
||||||
|
// Branded, so that only values returned by `keyFor` can be used as keys
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
export function keyFor({
|
||||||
|
transport,
|
||||||
|
serverName,
|
||||||
|
}: TransportLocator): TransportKey {
|
||||||
|
const hasUrl = "url" in 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,
|
||||||
|
]) as TransportKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDuplicateTransports(
|
||||||
|
transports: TransportLocator[],
|
||||||
|
): TransportLocator[] {
|
||||||
|
// The first locator wins, so the local one stays first in the list
|
||||||
|
const map = new Map<TransportKey, TransportLocator>();
|
||||||
|
for (const transport of transports) {
|
||||||
|
const key = keyFor(transport);
|
||||||
|
if (!map.has(key)) map.set(key, transport);
|
||||||
|
}
|
||||||
|
return [...map.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The connections at one moment, with the remote participants seen on each. */
|
||||||
|
export class ConnectionMap {
|
||||||
|
private readonly map = new Map<
|
||||||
|
TransportKey,
|
||||||
|
{ connection: Connection; participants: RemoteParticipant[] }
|
||||||
|
>();
|
||||||
|
|
||||||
|
public constructor(private readonly logger?: Logger) {}
|
||||||
|
|
||||||
|
public add(connection: Connection, participants: RemoteParticipant[]): void {
|
||||||
|
const key = keyFor(connection);
|
||||||
|
const existing = this.map.get(key);
|
||||||
|
if (!existing) {
|
||||||
|
this.map.set(key, { connection, participants });
|
||||||
|
} else {
|
||||||
|
// Transports are deduplicated by key upstream, so this should never
|
||||||
|
// happen; if it does, members may be matched against the wrong room.
|
||||||
|
this.logger?.warn(
|
||||||
|
`Merging participants from a second connection to ${key}: existing [${existing.participants.map((p) => p.identity).join(", ")}], adding [${participants.map((p) => p.identity).join(", ")}]`,
|
||||||
|
);
|
||||||
|
existing.participants.push(...participants);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getConnections(): Connection[] {
|
||||||
|
return Array.from(this.map.values()).map(({ connection }) => connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getConnectionForTransport(
|
||||||
|
transport: TransportLocator,
|
||||||
|
): Connection | null {
|
||||||
|
return this.map.get(keyFor(transport))?.connection ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getParticipantsForTransport(
|
||||||
|
transport: TransportLocator,
|
||||||
|
): RemoteParticipant[] {
|
||||||
|
return this.map.get(keyFor(transport))?.participants ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
scope: ObservableScope;
|
||||||
|
connectionFactory: ConnectionFactory;
|
||||||
|
/** The transport we publish on; the one connection with the publisher role. */
|
||||||
|
localTransport: TransportLocator;
|
||||||
|
/** Every transport some member is on, deduplicated against the local one. */
|
||||||
|
remoteTransports$: Behavior<Epoch<TransportLocator[]>>;
|
||||||
|
logger: Logger;
|
||||||
|
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One connection per transport in use by one or more session members, with
|
||||||
|
* the remote participants each one sees. The connections start as they are
|
||||||
|
* created and stop as their transport drops out of the list.
|
||||||
|
*/
|
||||||
|
export function createConnectionMap$({
|
||||||
|
scope,
|
||||||
|
connectionFactory,
|
||||||
|
localTransport,
|
||||||
|
remoteTransports$,
|
||||||
|
logger: parentLogger,
|
||||||
|
ownMembershipIdentity,
|
||||||
|
}: Props): Behavior<Epoch<ConnectionMap>> {
|
||||||
|
const logger = parentLogger.getChild("[ConnectionMap]");
|
||||||
|
|
||||||
|
const localAndRemoteTransports$: Behavior<Epoch<TransportLocator[]>> =
|
||||||
|
scope.behavior(
|
||||||
|
remoteTransports$.pipe(
|
||||||
|
mapEpoch((remoteTransports) =>
|
||||||
|
removeDuplicateTransports([localTransport, ...remoteTransports]),
|
||||||
|
),
|
||||||
|
tap((transports) =>
|
||||||
|
logger.debug("localAndRemoteTransports$ = ", transports),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const localTransportKey = keyFor(localTransport);
|
||||||
|
|
||||||
|
const connections$ = scope.behavior(
|
||||||
|
localAndRemoteTransports$.pipe(
|
||||||
|
generateItemsWithEpoch(
|
||||||
|
"ConnectionMap connections$",
|
||||||
|
function* (transports) {
|
||||||
|
for (const transport of transports) {
|
||||||
|
const role: ConnectionRole =
|
||||||
|
keyFor(transport) === localTransportKey
|
||||||
|
? "publisher"
|
||||||
|
: "subscriber";
|
||||||
|
// The locator is an object, so a stable key stands in for it
|
||||||
|
yield { keys: [role, keyFor(transport)], data: transport };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(scope, locator$, role, _key) => {
|
||||||
|
const { transport, serverName } = locator$.value;
|
||||||
|
const connection = connectionFactory.createConnection(
|
||||||
|
scope,
|
||||||
|
role,
|
||||||
|
transport,
|
||||||
|
serverName,
|
||||||
|
ownMembershipIdentity,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
// The state carries the progress, errors included
|
||||||
|
void connection.start();
|
||||||
|
return connection;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return scope.behavior(
|
||||||
|
connections$.pipe(
|
||||||
|
switchMap(({ value: connections, epoch }) => {
|
||||||
|
if (connections.length === 0)
|
||||||
|
return of(new Epoch(new ConnectionMap(), epoch));
|
||||||
|
return combineLatest(
|
||||||
|
connections.map((connection) =>
|
||||||
|
connection.remoteParticipants$.pipe(
|
||||||
|
map((participants) => ({ connection, participants })),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(...list) => {
|
||||||
|
const map = new ConnectionMap(logger);
|
||||||
|
for (const item of list)
|
||||||
|
map.add(item.connection, item.participants);
|
||||||
|
return new Epoch(map, epoch);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from "events";
|
||||||
|
import {
|
||||||
|
ConnectionState as LivekitConnectionState,
|
||||||
|
type RemoteParticipant,
|
||||||
|
RoomEvent,
|
||||||
|
} from "livekit-client";
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { Epoch } from "../../reactive/ObservableScope";
|
||||||
|
import { constant } from "../../reactive/Behavior";
|
||||||
|
import { type DataPacket } from "../api";
|
||||||
|
import { type Connection, type ConnectionState } from "./Connection";
|
||||||
|
import { ConnectionMap } from "./ConnectionMap";
|
||||||
|
import { createDataChannel$ } from "./DataChannel";
|
||||||
|
import { testScope } from "../../utils/test";
|
||||||
|
import { exampleTransport } from "./test";
|
||||||
|
|
||||||
|
const encode = (text: string): Uint8Array => new TextEncoder().encode(text);
|
||||||
|
|
||||||
|
const connected: ConnectionState = {
|
||||||
|
state: "authenticated",
|
||||||
|
livekitState: LivekitConnectionState.Connected,
|
||||||
|
};
|
||||||
|
|
||||||
|
function fakeConnection(
|
||||||
|
url: string,
|
||||||
|
state: ConnectionState = connected,
|
||||||
|
): {
|
||||||
|
connection: Connection;
|
||||||
|
room: EventEmitter;
|
||||||
|
publishData: ReturnType<typeof vi.fn>;
|
||||||
|
} {
|
||||||
|
const room = new EventEmitter();
|
||||||
|
const publishData = vi.fn(async () => Promise.resolve());
|
||||||
|
const connection = {
|
||||||
|
transport: { ...exampleTransport, livekit_service_url: url },
|
||||||
|
serverName: "example.org",
|
||||||
|
state$: constant(state),
|
||||||
|
livekitRoom: Object.assign(room, { localParticipant: { publishData } }),
|
||||||
|
} as unknown as Connection;
|
||||||
|
return { connection, room, publishData };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(connections: Connection[]): {
|
||||||
|
received: DataPacket[];
|
||||||
|
connections$: BehaviorSubject<Epoch<ConnectionMap>>;
|
||||||
|
sendData: (topic: string, text: string) => Promise<void>;
|
||||||
|
} {
|
||||||
|
const data = new ConnectionMap();
|
||||||
|
for (const connection of connections) data.add(connection, []);
|
||||||
|
const connections$ = new BehaviorSubject(new Epoch(data));
|
||||||
|
const { data$, sendData } = createDataChannel$({
|
||||||
|
scope: testScope(),
|
||||||
|
connectionMap$: connections$,
|
||||||
|
connection$: constant(connections[0] ?? null),
|
||||||
|
});
|
||||||
|
const received: DataPacket[] = [];
|
||||||
|
data$.subscribe((message) => received.push(message));
|
||||||
|
return { received, connections$, sendData };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("data$", () => {
|
||||||
|
it("delivers a packet with its sender, topic and text", () => {
|
||||||
|
const { connection, room } = fakeConnection("https://sfu");
|
||||||
|
const { received } = setup([connection]);
|
||||||
|
|
||||||
|
room.emit(
|
||||||
|
RoomEvent.DataReceived,
|
||||||
|
encode("hello"),
|
||||||
|
{ identity: "alice" } as RemoteParticipant,
|
||||||
|
undefined,
|
||||||
|
"chat",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(received).toEqual([
|
||||||
|
{ senderId: "alice", topic: "chat", text: "hello" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops packets from the server", () => {
|
||||||
|
const { connection, room } = fakeConnection("https://sfu");
|
||||||
|
const { received } = setup([connection]);
|
||||||
|
|
||||||
|
room.emit(RoomEvent.DataReceived, encode("server"), undefined);
|
||||||
|
|
||||||
|
expect(received).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("listens on every connection, including ones added later", () => {
|
||||||
|
const first = fakeConnection("https://sfu-1");
|
||||||
|
const second = fakeConnection("https://sfu-2");
|
||||||
|
const { received, connections$ } = setup([first.connection]);
|
||||||
|
|
||||||
|
const data = new ConnectionMap();
|
||||||
|
data.add(first.connection, []);
|
||||||
|
data.add(second.connection, []);
|
||||||
|
connections$.next(new Epoch(data, 1));
|
||||||
|
|
||||||
|
first.room.emit(RoomEvent.DataReceived, encode("one"), {
|
||||||
|
identity: "alice",
|
||||||
|
} as RemoteParticipant);
|
||||||
|
second.room.emit(RoomEvent.DataReceived, encode("two"), {
|
||||||
|
identity: "bob",
|
||||||
|
} as RemoteParticipant);
|
||||||
|
|
||||||
|
expect(received.map((m) => [m.senderId, m.text])).toEqual([
|
||||||
|
["alice", "one"],
|
||||||
|
["bob", "two"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendData", () => {
|
||||||
|
it("publishes the text as a reliable packet on the local connection", async () => {
|
||||||
|
const { connection, publishData } = fakeConnection("https://sfu");
|
||||||
|
const { sendData } = setup([connection]);
|
||||||
|
|
||||||
|
await sendData("chat", "hello");
|
||||||
|
|
||||||
|
expect(publishData).toHaveBeenCalledWith(encode("hello"), {
|
||||||
|
reliable: true,
|
||||||
|
topic: "chat",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects while the local transport is not connected", async () => {
|
||||||
|
const { connection, publishData } = fakeConnection("https://sfu", {
|
||||||
|
state: "initialized",
|
||||||
|
});
|
||||||
|
const { sendData } = setup([connection]);
|
||||||
|
|
||||||
|
await expect(sendData("chat", "hello")).rejects.toThrow("not connected");
|
||||||
|
expect(publishData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ConnectionState as LivekitConnectionState,
|
||||||
|
type RemoteParticipant,
|
||||||
|
type Room as LivekitRoom,
|
||||||
|
RoomEvent,
|
||||||
|
} from "livekit-client";
|
||||||
|
import {
|
||||||
|
distinctUntilChanged,
|
||||||
|
filter,
|
||||||
|
fromEvent,
|
||||||
|
map,
|
||||||
|
merge,
|
||||||
|
type Observable,
|
||||||
|
share,
|
||||||
|
switchMap,
|
||||||
|
} from "rxjs";
|
||||||
|
|
||||||
|
import { type Behavior } from "../../reactive/Behavior";
|
||||||
|
import {
|
||||||
|
type Epoch,
|
||||||
|
type ObservableScope,
|
||||||
|
} from "../../reactive/ObservableScope";
|
||||||
|
import { type DataPacket } from "../api";
|
||||||
|
import { type Connection } from "./Connection";
|
||||||
|
import { type ConnectionMap } from "./ConnectionMap";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
scope: ObservableScope;
|
||||||
|
connectionMap$: Behavior<Epoch<ConnectionMap>>;
|
||||||
|
/** The local member's connection, which carries what we send. */
|
||||||
|
connection$: Behavior<Connection | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataChannel {
|
||||||
|
data$: Observable<DataPacket>;
|
||||||
|
sendData: (topic: string, text: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A text channel beside the media: LiveKit's reliable data packets, received
|
||||||
|
* on every connection the client holds and sent on the local member's. The
|
||||||
|
* sender is reported by its identity; the packet's own claim of who sent it
|
||||||
|
* never reaches the host.
|
||||||
|
*/
|
||||||
|
export function createDataChannel$({
|
||||||
|
scope,
|
||||||
|
connectionMap$,
|
||||||
|
connection$,
|
||||||
|
}: Props): DataChannel {
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
const rooms$ = connectionMap$.pipe(
|
||||||
|
map(({ value }) => value.getConnections().map((c) => c.livekitRoom)),
|
||||||
|
distinctUntilChanged(
|
||||||
|
(a, b) => a.length === b.length && a.every((room, i) => room === b[i]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const data$ = rooms$.pipe(
|
||||||
|
switchMap((rooms) => merge(...rooms.map(receivedOn))),
|
||||||
|
map(({ payload, participant, topic }): DataPacket | null =>
|
||||||
|
// Only the server sends without a participant; a host has no use for it
|
||||||
|
participant === undefined
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
senderId: participant.identity,
|
||||||
|
topic: topic ?? "",
|
||||||
|
text: decoder.decode(payload),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
filter((packet) => packet !== null),
|
||||||
|
scope.bind(),
|
||||||
|
share(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendData = async (topic: string, text: string): Promise<void> => {
|
||||||
|
const connection = connection$.value;
|
||||||
|
const state = connection?.state$.value;
|
||||||
|
if (
|
||||||
|
connection === null ||
|
||||||
|
state?.state !== "authenticated" ||
|
||||||
|
state.livekitState !== LivekitConnectionState.Connected
|
||||||
|
)
|
||||||
|
throw new Error("Cannot send data: the local transport is not connected");
|
||||||
|
await connection.livekitRoom.localParticipant.publishData(
|
||||||
|
encoder.encode(text),
|
||||||
|
{ reliable: true, topic },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { data$, sendData };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Received {
|
||||||
|
payload: Uint8Array;
|
||||||
|
participant: RemoteParticipant | undefined;
|
||||||
|
topic: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function receivedOn(room: LivekitRoom): Observable<Received> {
|
||||||
|
return fromEvent(
|
||||||
|
room,
|
||||||
|
RoomEvent.DataReceived,
|
||||||
|
(
|
||||||
|
payload: Uint8Array,
|
||||||
|
participant?: RemoteParticipant,
|
||||||
|
_kind?: unknown,
|
||||||
|
topic?: string,
|
||||||
|
): Received => ({ payload, participant, topic }),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type BaseKeyProvider, ExternalE2EEKeyProvider } from "livekit-client";
|
||||||
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import { type Observable } from "rxjs";
|
||||||
|
|
||||||
|
import { E2eeType, type EncryptionSystem } from "../../encryption";
|
||||||
|
import { type MediaKey } from "../api";
|
||||||
|
import { MatrixKeyProvider } from "./MatrixKeyProvider";
|
||||||
|
|
||||||
|
/** The LiveKit key provider for the encryption the host asked for, or none. */
|
||||||
|
export function createKeyProvider(
|
||||||
|
encryptionSystem: EncryptionSystem,
|
||||||
|
mediaKeys$: Observable<MediaKey>,
|
||||||
|
logger: Logger,
|
||||||
|
): BaseKeyProvider | undefined {
|
||||||
|
switch (encryptionSystem.kind) {
|
||||||
|
case E2eeType.NONE:
|
||||||
|
return undefined;
|
||||||
|
case E2eeType.PER_PARTICIPANT: {
|
||||||
|
const keyProvider = new MatrixKeyProvider();
|
||||||
|
keyProvider.setMediaKeys(mediaKeys$);
|
||||||
|
return keyProvider;
|
||||||
|
}
|
||||||
|
case E2eeType.SHARED_KEY: {
|
||||||
|
const keyProvider = new ExternalE2EEKeyProvider();
|
||||||
|
keyProvider
|
||||||
|
.setKey(encryptionSystem.secret)
|
||||||
|
.catch((e) => logger.error("Failed to set the shared E2EE key", e));
|
||||||
|
return keyProvider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ConnectionState as LivekitConnectionState,
|
||||||
|
type Room as LivekitRoom,
|
||||||
|
type RemoteParticipant,
|
||||||
|
} from "livekit-client";
|
||||||
|
import fetchMock from "fetch-mock";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { BehaviorSubject, NEVER } from "rxjs";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { E2eeType } from "../../encryption";
|
||||||
|
import { MatrixRTCError, NoMatrix2AuthorizationService } from "../../errors";
|
||||||
|
import { constant } from "../../reactive/Behavior";
|
||||||
|
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||||
|
import { type RTCMember } from "../../api";
|
||||||
|
import { defaultSessionTimings } from "../../config";
|
||||||
|
import {
|
||||||
|
flushPromises,
|
||||||
|
mockEmitter,
|
||||||
|
ownMemberMock,
|
||||||
|
testScope,
|
||||||
|
} from "../../utils/test";
|
||||||
|
import { MediaConnectionState } from "../api";
|
||||||
|
import * as auth from "./auth";
|
||||||
|
import { type TransportLocator } from "./auth";
|
||||||
|
import { type Connection, type ConnectionState } from "./Connection";
|
||||||
|
import * as connectionFactory from "./ConnectionFactory";
|
||||||
|
import { toMediaConnectionState } from "./connectionState";
|
||||||
|
import {
|
||||||
|
createLivekitBackend,
|
||||||
|
createTransportRegistry,
|
||||||
|
supportsDelegation,
|
||||||
|
} from "./LivekitBackend";
|
||||||
|
import {
|
||||||
|
exampleServiceUrl,
|
||||||
|
exampleTransport,
|
||||||
|
mockRemoteParticipant,
|
||||||
|
} from "./test";
|
||||||
|
|
||||||
|
const locator = (
|
||||||
|
transport: UnstableLivekitTransport,
|
||||||
|
serverName = "example.org",
|
||||||
|
): TransportLocator => ({ transport, serverName });
|
||||||
|
|
||||||
|
const otherTransport: UnstableLivekitTransport = {
|
||||||
|
type: "livekit",
|
||||||
|
livekit_service_url: "https://other.example.org",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("createTransportRegistry", () => {
|
||||||
|
it("connects to a transport once however many members are on it, and drops it with the last", () => {
|
||||||
|
const registry = createTransportRegistry(testScope());
|
||||||
|
const transports = (): UnstableLivekitTransport[] =>
|
||||||
|
registry.transports$.value.value.map((t) => t.transport);
|
||||||
|
const alice = new ObservableScope();
|
||||||
|
const bob = new ObservableScope();
|
||||||
|
|
||||||
|
registry.follow(alice, new BehaviorSubject(locator(exampleTransport)));
|
||||||
|
registry.follow(bob, new BehaviorSubject(locator(exampleTransport)));
|
||||||
|
expect(transports()).toEqual([exampleTransport]);
|
||||||
|
|
||||||
|
alice.end();
|
||||||
|
expect(transports()).toEqual([exampleTransport]);
|
||||||
|
|
||||||
|
bob.end();
|
||||||
|
expect(transports()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows a member that changes transport", () => {
|
||||||
|
const registry = createTransportRegistry(testScope());
|
||||||
|
const transport$ = new BehaviorSubject<TransportLocator | undefined>(
|
||||||
|
locator(exampleTransport),
|
||||||
|
);
|
||||||
|
|
||||||
|
registry.follow(testScope(), transport$);
|
||||||
|
transport$.next(locator(otherTransport));
|
||||||
|
expect(registry.transports$.value.value).toEqual([locator(otherTransport)]);
|
||||||
|
|
||||||
|
transport$.next(undefined);
|
||||||
|
expect(registry.transports$.value.value).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is safe to release after the registry's own scope has ended", () => {
|
||||||
|
const scope = new ObservableScope();
|
||||||
|
const registry = createTransportRegistry(scope);
|
||||||
|
const member = new ObservableScope();
|
||||||
|
registry.follow(member, new BehaviorSubject(locator(exampleTransport)));
|
||||||
|
|
||||||
|
scope.end();
|
||||||
|
expect(() => member.end()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("supportsDelegation", () => {
|
||||||
|
afterEach(() => fetchMock.reset());
|
||||||
|
const endpoint = `${exampleServiceUrl}/delegate_delayed_leave`;
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["anything but a 404", 401, true],
|
||||||
|
["a 404", 404, false],
|
||||||
|
])("reads %s as the answer", async (_name, status, expected) => {
|
||||||
|
fetchMock.post(endpoint, status);
|
||||||
|
await expect(
|
||||||
|
supportsDelegation(endpoint, "transport", logger),
|
||||||
|
).resolves.toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assumes no support where the probe itself fails", async () => {
|
||||||
|
fetchMock.post(endpoint, { throws: new TypeError("CORS") });
|
||||||
|
await expect(
|
||||||
|
supportsDelegation(endpoint, "transport", logger),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toMediaConnectionState", () => {
|
||||||
|
it("maps every state and passes an error through", () => {
|
||||||
|
expect(toMediaConnectionState({ state: "authenticating" })).toBe(
|
||||||
|
MediaConnectionState.Preparing,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
toMediaConnectionState({
|
||||||
|
state: "authenticated",
|
||||||
|
livekitState: LivekitConnectionState.Connected,
|
||||||
|
}),
|
||||||
|
).toBe(MediaConnectionState.Connected);
|
||||||
|
expect(
|
||||||
|
toMediaConnectionState({
|
||||||
|
state: "authenticated",
|
||||||
|
livekitState: LivekitConnectionState.SignalReconnecting,
|
||||||
|
}),
|
||||||
|
).toBe(MediaConnectionState.Reconnecting);
|
||||||
|
expect(toMediaConnectionState({ state: "stopped" })).toBe(
|
||||||
|
MediaConnectionState.Stopped,
|
||||||
|
);
|
||||||
|
const error = new MatrixRTCError("gone");
|
||||||
|
expect(toMediaConnectionState({ state: "error", error })).toBe(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createLivekitBackend", () => {
|
||||||
|
const homeserver = "https://matrix.example.org";
|
||||||
|
const homeserverEndpoint = `${homeserver}/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`;
|
||||||
|
const urlTransport: UnstableLivekitTransport = {
|
||||||
|
type: "livekit",
|
||||||
|
url: "wss://sfu.example.org",
|
||||||
|
};
|
||||||
|
const bothTransport: UnstableLivekitTransport = {
|
||||||
|
...urlTransport,
|
||||||
|
...exampleTransport,
|
||||||
|
};
|
||||||
|
const client = {
|
||||||
|
baseUrl: homeserver,
|
||||||
|
getOpenIdToken: vi.fn(),
|
||||||
|
_unstable_getLivekitToken: vi.fn(),
|
||||||
|
_unstable_delegateDelayedLeave: vi.fn(),
|
||||||
|
};
|
||||||
|
/** The connections the stubbed factory handed out, with their participants to drive. */
|
||||||
|
let connections: {
|
||||||
|
connection: Connection;
|
||||||
|
participants$: BehaviorSubject<RemoteParticipant[]>;
|
||||||
|
state$: BehaviorSubject<ConnectionState>;
|
||||||
|
}[];
|
||||||
|
const connected: ConnectionState = {
|
||||||
|
state: "authenticated",
|
||||||
|
livekitState: LivekitConnectionState.Connected,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Stands in for the factory: connections whose state and participants the test drives. */
|
||||||
|
function stubFactory(initialState: ConnectionState): void {
|
||||||
|
vi.spyOn(connectionFactory, "LivekitConnectionFactory").mockImplementation(
|
||||||
|
function (this: unknown) {
|
||||||
|
return {
|
||||||
|
createConnection: (
|
||||||
|
scope: ObservableScope,
|
||||||
|
role: auth.ConnectionRole,
|
||||||
|
transport: UnstableLivekitTransport,
|
||||||
|
serverName: string,
|
||||||
|
): Connection => {
|
||||||
|
const participants$ = new BehaviorSubject<RemoteParticipant[]>([]);
|
||||||
|
const state$ = new BehaviorSubject<ConnectionState>(initialState);
|
||||||
|
const connection = {
|
||||||
|
role,
|
||||||
|
transport,
|
||||||
|
serverName,
|
||||||
|
state$,
|
||||||
|
remoteParticipants$: scope.behavior(participants$),
|
||||||
|
livekitRoom: fakeLivekitRoom(),
|
||||||
|
start: vi.fn(async () => Promise.resolve()),
|
||||||
|
stop: vi.fn(async () => Promise.resolve()),
|
||||||
|
} as unknown as Connection;
|
||||||
|
connections.push({ connection, participants$, state$ });
|
||||||
|
return connection;
|
||||||
|
},
|
||||||
|
} as unknown as connectionFactory.LivekitConnectionFactory;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
connections = [];
|
||||||
|
stubFactory(connected);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
fetchMock.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
function backend(): ReturnType<typeof createLivekitBackend> {
|
||||||
|
return createLivekitBackend(
|
||||||
|
testScope(),
|
||||||
|
{
|
||||||
|
roomId: "!room:example.org",
|
||||||
|
slotId: "m.call#ROOM",
|
||||||
|
ownMembershipIdentity: ownMemberMock,
|
||||||
|
publish: [],
|
||||||
|
encryptionSystem: { kind: E2eeType.NONE },
|
||||||
|
mediaKeys$: NEVER,
|
||||||
|
timings: defaultSessionTimings,
|
||||||
|
logger,
|
||||||
|
},
|
||||||
|
{ client },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegation goes through whichever service authenticates us as a
|
||||||
|
// publisher: the JWT service wherever the transport names one, the
|
||||||
|
// homeserver for a `url` alone
|
||||||
|
it.each([
|
||||||
|
["the JWT service", exampleTransport, 404, 401, true],
|
||||||
|
["the JWT service, and only it", bothTransport, 401, 404, false],
|
||||||
|
["the homeserver", urlTransport, 401, 404, true],
|
||||||
|
["neither", exampleTransport, 404, 404, false],
|
||||||
|
])(
|
||||||
|
"prepares the local transport and asks %s about delegation",
|
||||||
|
async (_name, transport, homeserverStatus, serviceStatus, expected) => {
|
||||||
|
fetchMock.post(homeserverEndpoint, homeserverStatus);
|
||||||
|
fetchMock.post(
|
||||||
|
`${exampleServiceUrl}/delegate_delayed_leave`,
|
||||||
|
serviceStatus,
|
||||||
|
);
|
||||||
|
|
||||||
|
const capabilities = await backend().prepareLocalTransport(transport);
|
||||||
|
|
||||||
|
expect(capabilities).toEqual({ canDelegateDelayedLeave: expected });
|
||||||
|
expect(connections).toHaveLength(1);
|
||||||
|
expect(connections[0].connection.role).toBe("publisher");
|
||||||
|
expect(connections[0].connection.transport).toEqual(transport);
|
||||||
|
expect(connections[0].connection.serverName).toBe("example.org");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("waits for the publisher connection to hold its token before the join", async () => {
|
||||||
|
fetchMock.post(homeserverEndpoint, 404);
|
||||||
|
fetchMock.post(`${exampleServiceUrl}/delegate_delayed_leave`, 404);
|
||||||
|
stubFactory({ state: "authenticating" });
|
||||||
|
|
||||||
|
let prepared = false;
|
||||||
|
const preparing = backend()
|
||||||
|
.prepareLocalTransport(exampleTransport)
|
||||||
|
.then(() => (prepared = true));
|
||||||
|
await flushPromises();
|
||||||
|
expect(prepared).toBe(false);
|
||||||
|
|
||||||
|
connections[0].state$.next(connected);
|
||||||
|
await preparing;
|
||||||
|
expect(prepared).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails the preparation with the publisher connection's error", async () => {
|
||||||
|
fetchMock.post(homeserverEndpoint, 404);
|
||||||
|
fetchMock.post(`${exampleServiceUrl}/delegate_delayed_leave`, 404);
|
||||||
|
stubFactory({ state: "authenticating" });
|
||||||
|
const preparing = backend().prepareLocalTransport(exampleTransport);
|
||||||
|
await flushPromises();
|
||||||
|
const error = new NoMatrix2AuthorizationService(new Error("404"));
|
||||||
|
connections[0].state$.next({ state: "error", error });
|
||||||
|
await expect(preparing).rejects.toBe(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delegates the leave through the flow that authenticated the publisher", async () => {
|
||||||
|
fetchMock.post(homeserverEndpoint, 401);
|
||||||
|
const delegate = vi.spyOn(auth, "delegateDelayedLeave").mockResolvedValue();
|
||||||
|
const b = backend();
|
||||||
|
await expect(b.delegateDelayedLeave("delay-1")).rejects.toThrow(
|
||||||
|
"No local transport",
|
||||||
|
);
|
||||||
|
|
||||||
|
await b.prepareLocalTransport(urlTransport);
|
||||||
|
await b.delegateDelayedLeave("delay-1");
|
||||||
|
|
||||||
|
expect(delegate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
client,
|
||||||
|
membership: ownMemberMock,
|
||||||
|
transport: urlTransport,
|
||||||
|
roomId: "!room:example.org",
|
||||||
|
slotId: "m.call#ROOM",
|
||||||
|
delayId: "delay-1",
|
||||||
|
delayTimeoutMs: defaultSessionTimings.delegatedDelayedLeave.delay_ms,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mediaFor", () => {
|
||||||
|
// A member advertising both a `url` and a `livekit_service_url` may have
|
||||||
|
// authenticated through either flow, so it has two candidate identities
|
||||||
|
const legacyId = "@bob:example.org:DEV000";
|
||||||
|
const hashedId = "2qkXfWz1S7jQmU0bJ6yN8aVtR3pLcDgHe9oZxKwMiA4";
|
||||||
|
|
||||||
|
function bob(
|
||||||
|
transport: UnstableLivekitTransport,
|
||||||
|
backendIdentities: string[],
|
||||||
|
): RTCMember {
|
||||||
|
return {
|
||||||
|
local: false,
|
||||||
|
rtcBackendIdentity: legacyId,
|
||||||
|
backendIdentities,
|
||||||
|
userId: "@bob:example.org",
|
||||||
|
deviceId: "DEV000",
|
||||||
|
memberId: legacyId,
|
||||||
|
transport$: constant({
|
||||||
|
type: "livekit",
|
||||||
|
id: JSON.stringify(transport),
|
||||||
|
raw: transport,
|
||||||
|
resolved$: constant(undefined),
|
||||||
|
}),
|
||||||
|
applicationData$: constant({}),
|
||||||
|
displayName$: constant("Bob"),
|
||||||
|
avatarUrl$: constant(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepared(): Promise<
|
||||||
|
ReturnType<typeof createLivekitBackend>
|
||||||
|
> {
|
||||||
|
fetchMock.post(homeserverEndpoint, 404);
|
||||||
|
fetchMock.post(`${exampleServiceUrl}/delegate_delayed_leave`, 404);
|
||||||
|
const b = backend();
|
||||||
|
await b.prepareLocalTransport(bothTransport);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["legacy", legacyId],
|
||||||
|
["hashed", hashedId],
|
||||||
|
])(
|
||||||
|
"matches a member with two candidate identities publishing under the %s one",
|
||||||
|
async (_name, identity) => {
|
||||||
|
const b = await prepared();
|
||||||
|
const media = b.mediaFor(
|
||||||
|
testScope(),
|
||||||
|
bob(bothTransport, [hashedId, legacyId]),
|
||||||
|
);
|
||||||
|
await flushPromises();
|
||||||
|
// Bob is on our transport, so his media arrives on the publisher
|
||||||
|
// connection
|
||||||
|
expect(connections).toHaveLength(1);
|
||||||
|
expect(media.tracks$.value).toBeNull();
|
||||||
|
|
||||||
|
connections[0].participants$.next([
|
||||||
|
mockRemoteParticipant({ identity, trackPublications: new Map() }),
|
||||||
|
]);
|
||||||
|
await flushPromises();
|
||||||
|
expect(media.tracks$.value).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("does not match a member with two candidate identities to anyone else", async () => {
|
||||||
|
const b = await prepared();
|
||||||
|
const media = b.mediaFor(
|
||||||
|
testScope(),
|
||||||
|
bob(bothTransport, [hashedId, legacyId]),
|
||||||
|
);
|
||||||
|
connections[0].participants$.next([
|
||||||
|
mockRemoteParticipant({
|
||||||
|
identity: "@stranger:example.org:DEV999",
|
||||||
|
trackPublications: new Map(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
await flushPromises();
|
||||||
|
expect(media.tracks$.value).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no media for a member on another transport until it shows up there", async () => {
|
||||||
|
const b = await prepared();
|
||||||
|
const media = b.mediaFor(testScope(), bob(otherTransport, [legacyId]));
|
||||||
|
await flushPromises();
|
||||||
|
const remote = connections.find(
|
||||||
|
(c) => c.connection.role === "subscriber",
|
||||||
|
);
|
||||||
|
expect(remote?.connection.transport).toEqual(otherTransport);
|
||||||
|
|
||||||
|
// The same identity on our own transport is someone else's room
|
||||||
|
connections[0].participants$.next([
|
||||||
|
mockRemoteParticipant({
|
||||||
|
identity: legacyId,
|
||||||
|
trackPublications: new Map(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
await flushPromises();
|
||||||
|
expect(media.tracks$.value).toBeNull();
|
||||||
|
|
||||||
|
remote!.participants$.next([
|
||||||
|
mockRemoteParticipant({
|
||||||
|
identity: legacyId,
|
||||||
|
trackPublications: new Map(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
await flushPromises();
|
||||||
|
expect(media.tracks$.value).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows a member to a new transport, and drops the old connection with it", async () => {
|
||||||
|
const b = await prepared();
|
||||||
|
const transport$ = new BehaviorSubject(
|
||||||
|
bob(bothTransport, [legacyId]).transport$.value,
|
||||||
|
);
|
||||||
|
const media = b.mediaFor(testScope(), {
|
||||||
|
...bob(bothTransport, [legacyId]),
|
||||||
|
transport$,
|
||||||
|
});
|
||||||
|
connections[0].participants$.next([
|
||||||
|
mockRemoteParticipant({
|
||||||
|
identity: legacyId,
|
||||||
|
trackPublications: new Map(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
await flushPromises();
|
||||||
|
expect(media.tracks$.value).toEqual([]);
|
||||||
|
|
||||||
|
transport$.next(bob(otherTransport, [legacyId]).transport$.value);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(media.tracks$.value).toBeNull();
|
||||||
|
const remote = connections.find(
|
||||||
|
(c) => c.connection.role === "subscriber",
|
||||||
|
);
|
||||||
|
expect(remote?.connection.transport).toEqual(otherTransport);
|
||||||
|
expect(b.connections$.value.map(({ transport }) => transport)).toEqual([
|
||||||
|
bothTransport,
|
||||||
|
otherTransport,
|
||||||
|
]);
|
||||||
|
|
||||||
|
transport$.next(bob(bothTransport, [legacyId]).transport$.value);
|
||||||
|
await flushPromises();
|
||||||
|
expect(media.tracks$.value).toEqual([]);
|
||||||
|
expect(b.connections$.value.map(({ transport }) => transport)).toEqual([
|
||||||
|
bothTransport,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no media for a member without a transport, and opens no connection", async () => {
|
||||||
|
const b = await prepared();
|
||||||
|
const media = b.mediaFor(testScope(), {
|
||||||
|
...bob(bothTransport, [legacyId]),
|
||||||
|
transport$: constant(undefined),
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(media.tracks$.value).toBeNull();
|
||||||
|
expect(connections).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("connects to a remote member's transport on the member's homeserver", async () => {
|
||||||
|
const b = await prepared();
|
||||||
|
const carlsTransport: UnstableLivekitTransport = {
|
||||||
|
type: "livekit",
|
||||||
|
url: "wss://sfu.sample.com",
|
||||||
|
};
|
||||||
|
b.mediaFor(testScope(), {
|
||||||
|
...bob(carlsTransport, ["carl-hashed"]),
|
||||||
|
userId: "@carl:sample.com",
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const remote = connections.find(
|
||||||
|
(c) => c.connection.role === "subscriber",
|
||||||
|
);
|
||||||
|
expect(remote?.connection.transport).toEqual(carlsTransport);
|
||||||
|
expect(remote?.connection.serverName).toBe("sample.com");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The parts of a LiveKit room the local media and the member media read. */
|
||||||
|
function fakeLivekitRoom(): LivekitRoom {
|
||||||
|
return {
|
||||||
|
options: {},
|
||||||
|
setE2EEEnabled: vi.fn(),
|
||||||
|
localParticipant: {
|
||||||
|
...mockEmitter(),
|
||||||
|
trackPublications: new Map(),
|
||||||
|
getTrackPublication: () => undefined,
|
||||||
|
},
|
||||||
|
remoteParticipants: new Map(),
|
||||||
|
...mockEmitter(),
|
||||||
|
} as unknown as LivekitRoom;
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type MatrixClient } from "matrix-js-sdk";
|
||||||
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import {
|
||||||
|
isUnstableLivekitTransport,
|
||||||
|
type Transport,
|
||||||
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import {
|
||||||
|
BehaviorSubject,
|
||||||
|
combineLatest,
|
||||||
|
distinctUntilChanged,
|
||||||
|
filter,
|
||||||
|
firstValueFrom,
|
||||||
|
map,
|
||||||
|
type Observable,
|
||||||
|
of,
|
||||||
|
switchMap,
|
||||||
|
} from "rxjs";
|
||||||
|
|
||||||
|
import { type ResolvedTransport, type RTCMember } from "../../api";
|
||||||
|
import { type MediaQuality } from "../../config";
|
||||||
|
import { MatrixRTCError } from "../../errors";
|
||||||
|
import { type MemberMedia } from "../../media-api";
|
||||||
|
import { type Behavior } from "../../reactive/Behavior";
|
||||||
|
import {
|
||||||
|
Epoch,
|
||||||
|
type ObservableScope,
|
||||||
|
trackEpoch,
|
||||||
|
} from "../../reactive/ObservableScope";
|
||||||
|
import { mapScoped } from "../../utils/mapScoped";
|
||||||
|
import { serverNameOf } from "../../utils/serverName";
|
||||||
|
import {
|
||||||
|
type BackendConnection,
|
||||||
|
type MediaBackend,
|
||||||
|
type MediaBackendContext,
|
||||||
|
type TransportCapabilities,
|
||||||
|
} from "../api";
|
||||||
|
import {
|
||||||
|
chooseAuthFlow,
|
||||||
|
delegateDelayedLeave,
|
||||||
|
type TransportLocator,
|
||||||
|
} from "./auth";
|
||||||
|
import { type Connection } from "./Connection";
|
||||||
|
import {
|
||||||
|
ConnectionMap,
|
||||||
|
createConnectionMap$,
|
||||||
|
keyFor,
|
||||||
|
type TransportKey,
|
||||||
|
} from "./ConnectionMap";
|
||||||
|
import {
|
||||||
|
LivekitConnectionFactory,
|
||||||
|
ResolvedConnection,
|
||||||
|
} from "./ConnectionFactory";
|
||||||
|
import { toMediaConnectionState } from "./connectionState";
|
||||||
|
import { createDataChannel$ } from "./DataChannel";
|
||||||
|
import { createKeyProvider } from "./KeyProvider";
|
||||||
|
import { createLivekitMemberMedia } from "./LivekitMemberMedia";
|
||||||
|
import { createLivekitLocalMedia } from "./LocalMedia";
|
||||||
|
|
||||||
|
export interface LivekitBackendOptions {
|
||||||
|
/**
|
||||||
|
* `getOpenIdToken` for the JWT service flow, the MSC4195 methods for the
|
||||||
|
* client-server API flow, `baseUrl` for the delegation probe and handover.
|
||||||
|
*/
|
||||||
|
client: Pick<
|
||||||
|
MatrixClient,
|
||||||
|
| "getOpenIdToken"
|
||||||
|
| "_unstable_getLivekitToken"
|
||||||
|
| "_unstable_delegateDelayedLeave"
|
||||||
|
| "baseUrl"
|
||||||
|
>;
|
||||||
|
mediaQuality?: MediaQuality;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MatrixRTC media over LiveKit SFUs: one room per transport, the local one
|
||||||
|
* published on, every remote one subscribed to, each authenticated for its
|
||||||
|
* role through the homeserver or the transport's JWT service.
|
||||||
|
*/
|
||||||
|
export function createLivekitBackend(
|
||||||
|
scope: ObservableScope,
|
||||||
|
{
|
||||||
|
roomId,
|
||||||
|
slotId,
|
||||||
|
ownMembershipIdentity,
|
||||||
|
publish,
|
||||||
|
encryptionSystem,
|
||||||
|
mediaKeys$,
|
||||||
|
timings,
|
||||||
|
logger: parentLogger,
|
||||||
|
}: MediaBackendContext,
|
||||||
|
{ client, mediaQuality }: LivekitBackendOptions,
|
||||||
|
): MediaBackend {
|
||||||
|
const logger = parentLogger.getChild("[LivekitBackend]");
|
||||||
|
const keyProvider = createKeyProvider(encryptionSystem, mediaKeys$, logger);
|
||||||
|
const transports = createTransportRegistry(scope);
|
||||||
|
const localTransport$ = new BehaviorSubject<TransportLocator | null>(null);
|
||||||
|
const audioOutputDeviceId$ = new BehaviorSubject<string | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const connectionFactory = new LivekitConnectionFactory(
|
||||||
|
client,
|
||||||
|
roomId,
|
||||||
|
slotId,
|
||||||
|
publish,
|
||||||
|
audioOutputDeviceId$,
|
||||||
|
keyProvider,
|
||||||
|
mediaQuality,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A new map, in a scope of its own, whenever the local transport changes:
|
||||||
|
// the publisher role sits on one connection, and which one is fixed per map
|
||||||
|
const connectionMap$ = scope.behavior<Epoch<ConnectionMap>>(
|
||||||
|
mapScoped(scope, localTransport$, (mapScope, localTransport) =>
|
||||||
|
createConnectionMap$({
|
||||||
|
scope: mapScope,
|
||||||
|
connectionFactory,
|
||||||
|
localTransport,
|
||||||
|
remoteTransports$: transports.transports$,
|
||||||
|
logger,
|
||||||
|
ownMembershipIdentity,
|
||||||
|
}),
|
||||||
|
).pipe(switchMap((map$) => map$ ?? of(new Epoch(new ConnectionMap())))),
|
||||||
|
);
|
||||||
|
|
||||||
|
const localMedia_ = createLivekitLocalMedia({
|
||||||
|
scope,
|
||||||
|
connectionMap$,
|
||||||
|
localTransport$,
|
||||||
|
publish,
|
||||||
|
audioOutputDeviceId$,
|
||||||
|
encryptionSystem,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Probed once: the answer cannot change within a session
|
||||||
|
const homeserverSupportsDelegation = supportsDelegation(
|
||||||
|
`${client.baseUrl}/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`,
|
||||||
|
"homeserver",
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
const prepareLocalTransport = async (
|
||||||
|
transport: Transport,
|
||||||
|
): Promise<TransportCapabilities> => {
|
||||||
|
if (!isUnstableLivekitTransport(transport))
|
||||||
|
throw new MatrixRTCError(`Not a LiveKit transport: ${transport.type}`);
|
||||||
|
const locator: TransportLocator = {
|
||||||
|
transport,
|
||||||
|
serverName: serverNameOf(ownMembershipIdentity.userId),
|
||||||
|
};
|
||||||
|
// Delegation goes through whichever service authenticates us as a
|
||||||
|
// publisher, so only that service's support counts
|
||||||
|
const flow = chooseAuthFlow(transport, "publisher");
|
||||||
|
const canDelegate =
|
||||||
|
flow.kind === "openID"
|
||||||
|
? supportsDelegation(
|
||||||
|
`${flow.serviceUrl}/delegate_delayed_leave`,
|
||||||
|
`transport ${flow.serviceUrl}`,
|
||||||
|
logger,
|
||||||
|
)
|
||||||
|
: homeserverSupportsDelegation;
|
||||||
|
localTransport$.next(locator);
|
||||||
|
// The membership is only sent once the publisher connection holds its
|
||||||
|
// token, so that the LiveKit room exists before anyone subscribes to it:
|
||||||
|
// a subscriber lacks the permission to create it and would fail without
|
||||||
|
// retrying
|
||||||
|
const [, canDelegateDelayedLeave] = await Promise.all([
|
||||||
|
authenticated(localMedia_.connection$),
|
||||||
|
canDelegate,
|
||||||
|
]);
|
||||||
|
return { canDelegateDelayedLeave };
|
||||||
|
};
|
||||||
|
|
||||||
|
const delegateLeave = async (delayId: string): Promise<void> => {
|
||||||
|
const local = localTransport$.value;
|
||||||
|
if (local === null)
|
||||||
|
throw new MatrixRTCError("No local transport to delegate the leave to");
|
||||||
|
await delegateDelayedLeave({
|
||||||
|
client,
|
||||||
|
membership: ownMembershipIdentity,
|
||||||
|
transport: local.transport,
|
||||||
|
roomId,
|
||||||
|
slotId,
|
||||||
|
delayId,
|
||||||
|
delayTimeoutMs: timings.delegatedDelayedLeave.delay_ms,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const mediaFor = (
|
||||||
|
memberScope: ObservableScope,
|
||||||
|
member: RTCMember,
|
||||||
|
): MemberMedia => {
|
||||||
|
const { backendIdentities } = member;
|
||||||
|
const locator$ = memberScope.behavior(
|
||||||
|
member.transport$.pipe(
|
||||||
|
map((transport): TransportLocator | undefined => {
|
||||||
|
const raw = transport?.raw;
|
||||||
|
return isUnstableLivekitTransport(raw)
|
||||||
|
? { transport: raw, serverName: serverNameOf(member.userId) }
|
||||||
|
: undefined;
|
||||||
|
}),
|
||||||
|
distinctUntilChanged((a, b) => (a && keyFor(a)) === (b && keyFor(b))),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
transports.follow(memberScope, locator$);
|
||||||
|
|
||||||
|
const source$ = memberScope.behavior(
|
||||||
|
combineLatest([locator$, connectionMap$]).pipe(
|
||||||
|
map(([locator, { value: connections }]) => {
|
||||||
|
if (locator === undefined) return null;
|
||||||
|
const connection = connections.getConnectionForTransport(locator);
|
||||||
|
// A member advertising both a `url` and a `livekit_service_url` may
|
||||||
|
// have authenticated through either flow, under either identity
|
||||||
|
const participant = connections
|
||||||
|
.getParticipantsForTransport(locator)
|
||||||
|
.find((p) => backendIdentities.includes(p.identity));
|
||||||
|
return connection && participant
|
||||||
|
? { participant, room: connection.livekitRoom }
|
||||||
|
: null;
|
||||||
|
}),
|
||||||
|
distinctUntilChanged(
|
||||||
|
(a, b) => a?.participant === b?.participant && a?.room === b?.room,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
source$.pipe(memberScope.bind()).subscribe((source) => {
|
||||||
|
logger.info(
|
||||||
|
`${member.rtcBackendIdentity}: LiveKit participant ${source ? `matched (${source.participant.sid})` : "missing"} on ${locator$.value ? JSON.stringify(locator$.value.transport) : "no transport"}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return createLivekitMemberMedia(memberScope, source$, encryptionSystem);
|
||||||
|
};
|
||||||
|
|
||||||
|
const connections$ = scope.behavior<BackendConnection[]>(
|
||||||
|
connectionMap$.pipe(
|
||||||
|
switchMap(({ value }) => {
|
||||||
|
const connections = value.getConnections();
|
||||||
|
if (connections.length === 0) return of([]);
|
||||||
|
return combineLatest(connections.map(backendConnection$));
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data$, sendData } = createDataChannel$({
|
||||||
|
scope,
|
||||||
|
connectionMap$,
|
||||||
|
connection$: localMedia_.connection$,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
transportType: "livekit",
|
||||||
|
prepareLocalTransport,
|
||||||
|
delegateDelayedLeave: delegateLeave,
|
||||||
|
local: localMedia_,
|
||||||
|
mediaFor,
|
||||||
|
connections$,
|
||||||
|
setAudioOutputDeviceId: localMedia_.setAudioOutputDeviceId,
|
||||||
|
sendData,
|
||||||
|
data$,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves once the connection holds its token; rejects with its error otherwise. */
|
||||||
|
async function authenticated(
|
||||||
|
connection$: Behavior<Connection | null>,
|
||||||
|
): Promise<void> {
|
||||||
|
const state = await firstValueFrom(
|
||||||
|
connection$.pipe(
|
||||||
|
switchMap((connection) => connection?.state$ ?? of(null)),
|
||||||
|
filter(
|
||||||
|
(state) =>
|
||||||
|
state !== null &&
|
||||||
|
(state.state === "authenticated" ||
|
||||||
|
state.state === "error" ||
|
||||||
|
state.state === "stopped"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (state?.state === "error") throw state.error;
|
||||||
|
if (state?.state === "stopped")
|
||||||
|
throw new MatrixRTCError(
|
||||||
|
"The local connection stopped before it was authenticated",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function backendConnection$(
|
||||||
|
connection: Connection,
|
||||||
|
): Observable<BackendConnection> {
|
||||||
|
const resolved$: Observable<ResolvedTransport | undefined> =
|
||||||
|
connection instanceof ResolvedConnection
|
||||||
|
? connection.resolved$
|
||||||
|
: of(undefined);
|
||||||
|
return combineLatest([connection.state$, resolved$]).pipe(
|
||||||
|
map(([state, resolved]): BackendConnection => ({
|
||||||
|
transport: connection.transport,
|
||||||
|
state: toMediaConnectionState(state),
|
||||||
|
resolved,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransportRegistry {
|
||||||
|
/** Keeps a member's transport registered for as long as its scope lives. */
|
||||||
|
follow(
|
||||||
|
scope: ObservableScope,
|
||||||
|
locator$: Behavior<TransportLocator | undefined>,
|
||||||
|
): void;
|
||||||
|
/** Every transport some member is on, for the connection map. */
|
||||||
|
transports$: Behavior<Epoch<TransportLocator[]>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The remote transports to connect to, counted per member so that a transport
|
||||||
|
* stays while any member names it. A release after the scope has ended only
|
||||||
|
* touches the map, so it is safe in any order of teardown.
|
||||||
|
*/
|
||||||
|
export function createTransportRegistry(
|
||||||
|
scope: ObservableScope,
|
||||||
|
): TransportRegistry {
|
||||||
|
const counts = new Map<
|
||||||
|
TransportKey,
|
||||||
|
{ locator: TransportLocator; count: number }
|
||||||
|
>();
|
||||||
|
const transports = new BehaviorSubject<TransportLocator[]>([]);
|
||||||
|
const publish = (): void =>
|
||||||
|
transports.next([...counts.values()].map(({ locator }) => locator));
|
||||||
|
|
||||||
|
const register = (locator: TransportLocator): (() => void) => {
|
||||||
|
const key = keyFor(locator);
|
||||||
|
const entry = counts.get(key) ?? { locator, count: 0 };
|
||||||
|
entry.count++;
|
||||||
|
if (entry.count === 1) {
|
||||||
|
counts.set(key, entry);
|
||||||
|
publish();
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (--entry.count > 0) return;
|
||||||
|
counts.delete(key);
|
||||||
|
publish();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
follow: (memberScope, locator$) => {
|
||||||
|
let release: (() => void) | undefined;
|
||||||
|
locator$.pipe(memberScope.bind()).subscribe((locator) => {
|
||||||
|
release?.();
|
||||||
|
release = locator && register(locator);
|
||||||
|
});
|
||||||
|
memberScope.onEnd(() => release?.());
|
||||||
|
},
|
||||||
|
transports$: scope.behavior(transports.pipe(trackEpoch())),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a service can take over restarting the delayed leave. The endpoint
|
||||||
|
* is hit without credentials and read for a 404, not retried: many servers
|
||||||
|
* predate the endpoint altogether and answer with a CORS failure that a retry
|
||||||
|
* loop would only repeat.
|
||||||
|
*/
|
||||||
|
export async function supportsDelegation(
|
||||||
|
url: string,
|
||||||
|
serviceName: string,
|
||||||
|
logger: Logger,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { method: "POST" });
|
||||||
|
const supported = res.status !== 404;
|
||||||
|
logger.info(
|
||||||
|
`${serviceName} ${supported ? "supports" : "does not support"} delegation`,
|
||||||
|
);
|
||||||
|
return supported;
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(
|
||||||
|
`Failed to determine whether ${serviceName} supports delegation, assuming no support`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from "events";
|
||||||
|
import {
|
||||||
|
type LocalParticipant,
|
||||||
|
ParticipantEvent,
|
||||||
|
RemoteAudioTrack,
|
||||||
|
type RemoteParticipant,
|
||||||
|
type Room as LivekitRoom,
|
||||||
|
RoomEvent,
|
||||||
|
Track,
|
||||||
|
TrackEvent,
|
||||||
|
type TrackPublication,
|
||||||
|
} from "livekit-client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||||
|
import { type AudioMediaTrack } from "../../media-api";
|
||||||
|
import {
|
||||||
|
createLivekitMediaTrack,
|
||||||
|
createLocalLivekitMediaTrack,
|
||||||
|
} from "./LivekitMediaTrack";
|
||||||
|
|
||||||
|
describe("createLivekitMediaTrack", () => {
|
||||||
|
let scope: ObservableScope;
|
||||||
|
beforeEach(() => {
|
||||||
|
scope = new ObservableScope();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
scope.end();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("describes the publication", () => {
|
||||||
|
const { participant, publication, room } = fakes();
|
||||||
|
const track = createLivekitMediaTrack(
|
||||||
|
scope,
|
||||||
|
participant,
|
||||||
|
publication as unknown as TrackPublication,
|
||||||
|
room,
|
||||||
|
);
|
||||||
|
expect(track.kind).toBe("video");
|
||||||
|
expect(track.source).toBe("camera");
|
||||||
|
expect(track.id).toBe("TR_1");
|
||||||
|
expect(track.encrypted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches elements to the track, even one that arrives later", () => {
|
||||||
|
const { participant, publication, room, emitter } = fakes({
|
||||||
|
track: undefined,
|
||||||
|
});
|
||||||
|
const element = document.createElement("video");
|
||||||
|
const track = createLivekitMediaTrack(
|
||||||
|
scope,
|
||||||
|
participant,
|
||||||
|
publication as unknown as TrackPublication,
|
||||||
|
room,
|
||||||
|
);
|
||||||
|
track.attach(element);
|
||||||
|
track.attach(element);
|
||||||
|
|
||||||
|
const livekitTrack = fakeTrack();
|
||||||
|
publication.track = livekitTrack;
|
||||||
|
emitter.emit(TrackEvent.Subscribed, livekitTrack);
|
||||||
|
expect(livekitTrack.attach).toHaveBeenCalledTimes(1);
|
||||||
|
expect(livekitTrack.attach).toHaveBeenCalledWith(element);
|
||||||
|
|
||||||
|
track.detach(element);
|
||||||
|
expect(livekitTrack.detach).toHaveBeenCalledWith(element);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detaches everything when its scope ends", () => {
|
||||||
|
const { participant, publication, room } = fakes();
|
||||||
|
const element = document.createElement("video");
|
||||||
|
const scope = new ObservableScope();
|
||||||
|
const track = createLivekitMediaTrack(
|
||||||
|
scope,
|
||||||
|
participant,
|
||||||
|
publication as unknown as TrackPublication,
|
||||||
|
room,
|
||||||
|
);
|
||||||
|
track.attach(element);
|
||||||
|
scope.end();
|
||||||
|
expect(publication.track!.detach).toHaveBeenCalledWith(element);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows the mute state", () => {
|
||||||
|
const { participant, publication, room, emitter } = fakes();
|
||||||
|
const track = createLivekitMediaTrack(
|
||||||
|
scope,
|
||||||
|
participant,
|
||||||
|
publication as unknown as TrackPublication,
|
||||||
|
room,
|
||||||
|
);
|
||||||
|
expect(track.muted$.value).toBe(false);
|
||||||
|
publication.isMuted = true;
|
||||||
|
emitter.emit(ParticipantEvent.TrackMuted, publication);
|
||||||
|
expect(track.muted$.value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scales a remote member's volume on the participant", () => {
|
||||||
|
const { participant, publication, room } = fakes({
|
||||||
|
kind: Track.Kind.Audio,
|
||||||
|
});
|
||||||
|
const track = createLivekitMediaTrack(
|
||||||
|
scope,
|
||||||
|
participant,
|
||||||
|
publication as unknown as TrackPublication,
|
||||||
|
room,
|
||||||
|
) as AudioMediaTrack;
|
||||||
|
track.setVolume(0.5);
|
||||||
|
expect(publication.track!.setVolume).toHaveBeenCalledWith(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is active while the member speaks and the track is not muted", () => {
|
||||||
|
const { participant, publication, room, emitter } = fakes({
|
||||||
|
kind: Track.Kind.Audio,
|
||||||
|
});
|
||||||
|
const track = createLivekitMediaTrack(
|
||||||
|
scope,
|
||||||
|
participant,
|
||||||
|
publication as unknown as TrackPublication,
|
||||||
|
room,
|
||||||
|
) as AudioMediaTrack;
|
||||||
|
expect(track.isActive$.value).toBe(false);
|
||||||
|
participant.isSpeaking = true;
|
||||||
|
emitter.emit(ParticipantEvent.IsSpeakingChanged, true);
|
||||||
|
expect(track.isActive$.value).toBe(true);
|
||||||
|
publication.isMuted = true;
|
||||||
|
emitter.emit(ParticipantEvent.TrackMuted, publication);
|
||||||
|
expect(track.isActive$.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the audio level the room announces, 0 while muted", () => {
|
||||||
|
const { participant, publication, room, emitter } = fakes({
|
||||||
|
kind: Track.Kind.Audio,
|
||||||
|
});
|
||||||
|
const track = createLivekitMediaTrack(
|
||||||
|
scope,
|
||||||
|
participant,
|
||||||
|
publication as unknown as TrackPublication,
|
||||||
|
room,
|
||||||
|
) as AudioMediaTrack;
|
||||||
|
expect(track.audioLevel$.value).toBe(0);
|
||||||
|
participant.audioLevel = 0.4;
|
||||||
|
(room as unknown as EventEmitter).emit(RoomEvent.ActiveSpeakersChanged, [
|
||||||
|
participant,
|
||||||
|
]);
|
||||||
|
expect(track.audioLevel$.value).toBe(0.4);
|
||||||
|
publication.isMuted = true;
|
||||||
|
emitter.emit(ParticipantEvent.TrackMuted, publication);
|
||||||
|
expect(track.audioLevel$.value).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("polls statistics at the given interval, only while subscribed", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { participant, publication, room } = fakes();
|
||||||
|
const stats = { type: "inbound-rtp" } as RTCInboundRtpStreamStats;
|
||||||
|
publication.track!.getRTCStatsReport.mockResolvedValue(
|
||||||
|
new Map([["s", stats]]),
|
||||||
|
);
|
||||||
|
const track = createLivekitMediaTrack(
|
||||||
|
scope,
|
||||||
|
participant,
|
||||||
|
publication as unknown as TrackPublication,
|
||||||
|
room,
|
||||||
|
);
|
||||||
|
const stats$ = track.stats$(500);
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
expect(publication.track!.getRTCStatsReport).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const seen = vi.fn();
|
||||||
|
const subscription = stats$.subscribe(seen);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(publication.track!.getRTCStatsReport).toHaveBeenCalledTimes(1);
|
||||||
|
expect(seen).toHaveBeenLastCalledWith(stats);
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
expect(publication.track!.getRTCStatsReport).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
expect(publication.track!.getRTCStatsReport).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives our own track its controls", async () => {
|
||||||
|
const { participant, publication, room } = fakes({
|
||||||
|
kind: Track.Kind.Audio,
|
||||||
|
});
|
||||||
|
const setEnabled = vi.fn().mockResolvedValue(false);
|
||||||
|
const switchActiveDevice = vi.fn().mockResolvedValue(true);
|
||||||
|
const track = createLocalLivekitMediaTrack(
|
||||||
|
scope,
|
||||||
|
Object.assign(participant, {
|
||||||
|
isLocal: true,
|
||||||
|
}) as unknown as LocalParticipant,
|
||||||
|
publication as unknown as TrackPublication,
|
||||||
|
Object.assign(room, { switchActiveDevice }),
|
||||||
|
setEnabled,
|
||||||
|
);
|
||||||
|
await expect(track.setEnabled(false)).resolves.toBe(false);
|
||||||
|
expect(setEnabled).toHaveBeenCalledWith("microphone", false);
|
||||||
|
await track.setDevice("mic-2");
|
||||||
|
expect(switchActiveDevice).toHaveBeenCalledWith("audioinput", "mic-2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
type FakeTrack = Track & {
|
||||||
|
attach: ReturnType<typeof vi.fn>;
|
||||||
|
detach: ReturnType<typeof vi.fn>;
|
||||||
|
setVolume: ReturnType<typeof vi.fn>;
|
||||||
|
getRTCStatsReport: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FakePublication = Omit<TrackPublication, "track" | "isMuted"> & {
|
||||||
|
track: FakeTrack | undefined;
|
||||||
|
isMuted: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function fakeTrack(): FakeTrack {
|
||||||
|
return Object.assign(Object.create(RemoteAudioTrack.prototype), {
|
||||||
|
attach: vi.fn(),
|
||||||
|
detach: vi.fn(),
|
||||||
|
setVolume: vi.fn(),
|
||||||
|
setAudioContext: vi.fn(),
|
||||||
|
setWebAudioPlugins: vi.fn(),
|
||||||
|
getRTCStatsReport: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}) as FakeTrack;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakes({
|
||||||
|
track = fakeTrack(),
|
||||||
|
kind = Track.Kind.Video,
|
||||||
|
}: { track?: FakeTrack | undefined; kind?: Track.Kind } = {}): {
|
||||||
|
participant: RemoteParticipant & { isSpeaking: boolean; audioLevel: number };
|
||||||
|
publication: FakePublication;
|
||||||
|
room: LivekitRoom;
|
||||||
|
emitter: EventEmitter;
|
||||||
|
} {
|
||||||
|
const emitter = new EventEmitter();
|
||||||
|
const participant = Object.assign(emitter, {
|
||||||
|
isLocal: false,
|
||||||
|
isSpeaking: false,
|
||||||
|
audioLevel: 0,
|
||||||
|
identity: "@alice:example.org:DEVICE",
|
||||||
|
getTrackPublication: (): TrackPublication =>
|
||||||
|
publication as unknown as TrackPublication,
|
||||||
|
}) as unknown as RemoteParticipant & {
|
||||||
|
isSpeaking: boolean;
|
||||||
|
audioLevel: number;
|
||||||
|
};
|
||||||
|
const publication = Object.assign(emitter, {
|
||||||
|
kind,
|
||||||
|
source:
|
||||||
|
kind === Track.Kind.Audio ? Track.Source.Microphone : Track.Source.Camera,
|
||||||
|
trackSid: "TR_1",
|
||||||
|
isMuted: false,
|
||||||
|
isEncrypted: true,
|
||||||
|
track,
|
||||||
|
}) as unknown as FakePublication;
|
||||||
|
const room = new EventEmitter() as unknown as LivekitRoom;
|
||||||
|
return { participant, publication, room, emitter };
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
observeParticipantEvents,
|
||||||
|
observeParticipantMedia,
|
||||||
|
roomEventSelector,
|
||||||
|
} from "@livekit/components-core";
|
||||||
|
import {
|
||||||
|
facingModeFromLocalTrack,
|
||||||
|
type LocalParticipant,
|
||||||
|
LocalTrack,
|
||||||
|
LocalVideoTrack,
|
||||||
|
type Participant,
|
||||||
|
ParticipantEvent,
|
||||||
|
RemoteAudioTrack,
|
||||||
|
RemoteTrack,
|
||||||
|
type Room as LivekitRoom,
|
||||||
|
RoomEvent,
|
||||||
|
Track,
|
||||||
|
TrackEvent,
|
||||||
|
type TrackPublication,
|
||||||
|
} from "livekit-client";
|
||||||
|
import {
|
||||||
|
combineLatest,
|
||||||
|
distinctUntilChanged,
|
||||||
|
fromEvent,
|
||||||
|
map,
|
||||||
|
merge,
|
||||||
|
type Observable,
|
||||||
|
of,
|
||||||
|
startWith,
|
||||||
|
switchMap,
|
||||||
|
timer,
|
||||||
|
} from "rxjs";
|
||||||
|
|
||||||
|
import { type Behavior } from "../../reactive/Behavior";
|
||||||
|
import { type ObservableScope } from "../../reactive/ObservableScope";
|
||||||
|
import {
|
||||||
|
type AudioMediaTrack,
|
||||||
|
type LocalAudioMediaTrack,
|
||||||
|
type LocalVideoMediaTrack,
|
||||||
|
type MediaSource,
|
||||||
|
type MediaStreamStats,
|
||||||
|
type MediaTrack,
|
||||||
|
type VideoMediaTrack,
|
||||||
|
} from "../../media-api";
|
||||||
|
import { MatrixRTCError } from "../../errors";
|
||||||
|
import { convertToLivekitProcessor } from "./videoProcessor";
|
||||||
|
|
||||||
|
export const mediaSources: Record<Track.Source, MediaSource> = {
|
||||||
|
[Track.Source.Microphone]: "microphone",
|
||||||
|
[Track.Source.Camera]: "camera",
|
||||||
|
[Track.Source.ScreenShare]: "screenShare",
|
||||||
|
[Track.Source.ScreenShareAudio]: "screenShareAudio",
|
||||||
|
[Track.Source.Unknown]: "unknown",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const livekitSources: Record<MediaSource, Track.Source> = {
|
||||||
|
microphone: Track.Source.Microphone,
|
||||||
|
camera: Track.Source.Camera,
|
||||||
|
screenShare: Track.Source.ScreenShare,
|
||||||
|
screenShareAudio: Track.Source.ScreenShareAudio,
|
||||||
|
unknown: Track.Source.Unknown,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One publication of a participant as a `MediaTrack`. The publication is
|
||||||
|
* fixed; the track behind it may come and go (a remote track arrives on
|
||||||
|
* subscription), and every attached element follows it.
|
||||||
|
*/
|
||||||
|
export function createLivekitMediaTrack(
|
||||||
|
scope: ObservableScope,
|
||||||
|
participant: Participant,
|
||||||
|
publication: TrackPublication,
|
||||||
|
room: LivekitRoom,
|
||||||
|
): AudioMediaTrack | VideoMediaTrack {
|
||||||
|
const mediaChanged$ = observeParticipantMedia(participant);
|
||||||
|
const track$ = publicationTrack$(scope, participant, publication);
|
||||||
|
|
||||||
|
const attached = new Set<HTMLMediaElement>();
|
||||||
|
let audioContext: AudioContext | undefined;
|
||||||
|
let audioPlugins: AudioNode[] = [];
|
||||||
|
let volume: number | undefined;
|
||||||
|
const applyAudioSettings = (track: Track | undefined): void => {
|
||||||
|
if (!(track instanceof RemoteAudioTrack)) return;
|
||||||
|
track.setAudioContext(audioContext);
|
||||||
|
track.setWebAudioPlugins(audioPlugins);
|
||||||
|
if (volume !== undefined) track.setVolume(volume);
|
||||||
|
};
|
||||||
|
|
||||||
|
let current = track$.value;
|
||||||
|
track$.pipe(scope.bind()).subscribe((track) => {
|
||||||
|
for (const element of attached) current?.detach(element);
|
||||||
|
current = track;
|
||||||
|
applyAudioSettings(track);
|
||||||
|
for (const element of attached) track?.attach(element);
|
||||||
|
});
|
||||||
|
scope.onEnd(() => {
|
||||||
|
for (const element of attached) current?.detach(element);
|
||||||
|
attached.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
const muted$ = scope.behavior(
|
||||||
|
mediaChanged$.pipe(map(() => publication.isMuted)),
|
||||||
|
publication.isMuted,
|
||||||
|
);
|
||||||
|
const base: MediaTrack = {
|
||||||
|
source: mediaSources[publication.source],
|
||||||
|
kind: publication.kind === Track.Kind.Audio ? "audio" : "video",
|
||||||
|
id: publication.trackSid,
|
||||||
|
muted$,
|
||||||
|
encrypted: publication.isEncrypted,
|
||||||
|
stats$: (intervalMs): Observable<MediaStreamStats> =>
|
||||||
|
timer(0, intervalMs).pipe(
|
||||||
|
switchMap(async () => rtpStreamStats(publication, participant.isLocal)),
|
||||||
|
scope.bind(),
|
||||||
|
),
|
||||||
|
attach: (element) => {
|
||||||
|
if (attached.has(element)) return;
|
||||||
|
attached.add(element);
|
||||||
|
current?.attach(element);
|
||||||
|
},
|
||||||
|
detach: (element) => {
|
||||||
|
if (!attached.delete(element)) return;
|
||||||
|
current?.detach(element);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (base.kind === "audio")
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
kind: "audio",
|
||||||
|
// LiveKit detects speakers per participant, so this is the member's
|
||||||
|
// activity, narrowed to the tracks that can be carrying it
|
||||||
|
isActive$: scope.behavior(
|
||||||
|
combineLatest(
|
||||||
|
[
|
||||||
|
observeParticipantEvents(
|
||||||
|
participant,
|
||||||
|
ParticipantEvent.IsSpeakingChanged,
|
||||||
|
).pipe(map((p) => p.isSpeaking)),
|
||||||
|
muted$,
|
||||||
|
],
|
||||||
|
(speaking, muted) => speaking && !muted,
|
||||||
|
),
|
||||||
|
participant.isSpeaking && !publication.isMuted,
|
||||||
|
),
|
||||||
|
// The level has no event of its own: it is written to the participant
|
||||||
|
// with each speaker update, which the room announces
|
||||||
|
audioLevel$: scope.behavior(
|
||||||
|
combineLatest(
|
||||||
|
[
|
||||||
|
roomEventSelector(room, RoomEvent.ActiveSpeakersChanged).pipe(
|
||||||
|
map(() => participant.audioLevel),
|
||||||
|
startWith(participant.audioLevel),
|
||||||
|
),
|
||||||
|
muted$,
|
||||||
|
],
|
||||||
|
(level, muted) => (muted ? 0 : level),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
setAudioContext: (ctx, plugins = []) => {
|
||||||
|
audioContext = ctx;
|
||||||
|
audioPlugins = plugins;
|
||||||
|
applyAudioSettings(current);
|
||||||
|
},
|
||||||
|
setVolume: (v) => {
|
||||||
|
// Our own audio is never played back, so there is nothing to scale
|
||||||
|
volume = v;
|
||||||
|
applyAudioSettings(current);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...base, kind: "video" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of our own publications, with the controls over it. `setEnabled` goes
|
||||||
|
* through the local member so that what it remembers for a reconnection
|
||||||
|
* stays in step.
|
||||||
|
*/
|
||||||
|
export function createLocalLivekitMediaTrack(
|
||||||
|
scope: ObservableScope,
|
||||||
|
participant: LocalParticipant,
|
||||||
|
publication: TrackPublication,
|
||||||
|
room: LivekitRoom,
|
||||||
|
setEnabled: (source: MediaSource, enabled: boolean) => Promise<boolean>,
|
||||||
|
): LocalAudioMediaTrack | LocalVideoMediaTrack {
|
||||||
|
const base = createLivekitMediaTrack(scope, participant, publication, room);
|
||||||
|
const local = {
|
||||||
|
setEnabled: async (enabled: boolean) => setEnabled(base.source, enabled),
|
||||||
|
setDevice: async (deviceId: string): Promise<void> => {
|
||||||
|
if (base.source !== "microphone" && base.source !== "camera")
|
||||||
|
throw new MatrixRTCError(`A ${base.source} track has no device`);
|
||||||
|
await room.switchActiveDevice(
|
||||||
|
base.kind === "audio" ? "audioinput" : "videoinput",
|
||||||
|
deviceId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (base.kind === "audio") return { ...base, ...local };
|
||||||
|
|
||||||
|
const videoTrack = (): LocalVideoTrack | undefined =>
|
||||||
|
publication.track instanceof LocalVideoTrack
|
||||||
|
? publication.track
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...local,
|
||||||
|
facingMode$: facingMode$(
|
||||||
|
scope,
|
||||||
|
publicationTrack$(scope, participant, publication),
|
||||||
|
),
|
||||||
|
switchFacingMode: async () => {
|
||||||
|
const track = videoTrack();
|
||||||
|
if (!track) return;
|
||||||
|
const { facingMode } = facingModeFromLocalTrack(track);
|
||||||
|
if (facingMode !== "user" && facingMode !== "environment") return;
|
||||||
|
await track.restartTrack({
|
||||||
|
facingMode: facingMode === "user" ? "environment" : "user",
|
||||||
|
});
|
||||||
|
return track.mediaStreamTrack.getSettings().deviceId;
|
||||||
|
},
|
||||||
|
setProcessor: async (processor) => {
|
||||||
|
const track = videoTrack();
|
||||||
|
// A processor cannot be built on a track that has already ended
|
||||||
|
if (!track || track.mediaStreamTrack.readyState === "ended") return;
|
||||||
|
if (processor)
|
||||||
|
await track.setProcessor(convertToLivekitProcessor(processor));
|
||||||
|
else if (track.getProcessor()) await track.stopProcessor();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The track behind a publication; a remote one arrives on subscription. */
|
||||||
|
function publicationTrack$(
|
||||||
|
scope: ObservableScope,
|
||||||
|
participant: Participant,
|
||||||
|
publication: TrackPublication,
|
||||||
|
): Behavior<Track | undefined> {
|
||||||
|
return scope.behavior(
|
||||||
|
merge(
|
||||||
|
observeParticipantMedia(participant),
|
||||||
|
fromEvent(publication, TrackEvent.Subscribed),
|
||||||
|
fromEvent(publication, TrackEvent.Unsubscribed),
|
||||||
|
).pipe(
|
||||||
|
map(() => publication.track),
|
||||||
|
startWith(publication.track),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rtpStreamStats(
|
||||||
|
publication: TrackPublication,
|
||||||
|
local: boolean,
|
||||||
|
): Promise<MediaStreamStats> {
|
||||||
|
const track = publication.track;
|
||||||
|
if (!(track instanceof RemoteTrack || track instanceof LocalTrack))
|
||||||
|
return undefined;
|
||||||
|
const report = await track.getRTCStatsReport();
|
||||||
|
if (!report) return undefined;
|
||||||
|
const type = local ? "outbound-rtp" : "inbound-rtp";
|
||||||
|
for (const stats of report.values()) if (stats.type === type) return stats;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function facingMode$(
|
||||||
|
scope: ObservableScope,
|
||||||
|
track$: Behavior<Track | undefined>,
|
||||||
|
): Behavior<"user" | "environment" | undefined> {
|
||||||
|
return scope.behavior(
|
||||||
|
track$.pipe(
|
||||||
|
switchMap((track) => {
|
||||||
|
if (!(track instanceof LocalVideoTrack)) return of(undefined);
|
||||||
|
return fromEvent(track, TrackEvent.Restarted).pipe(
|
||||||
|
startWith(null),
|
||||||
|
map(() => {
|
||||||
|
const { facingMode } = facingModeFromLocalTrack(track);
|
||||||
|
return facingMode === "user" || facingMode === "environment"
|
||||||
|
? facingMode
|
||||||
|
: undefined;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from "events";
|
||||||
|
import {
|
||||||
|
ParticipantEvent,
|
||||||
|
type RemoteParticipant,
|
||||||
|
type Room as LivekitRoom,
|
||||||
|
Track,
|
||||||
|
type TrackPublication,
|
||||||
|
} from "livekit-client";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { E2eeType } from "../../encryption";
|
||||||
|
import { constant } from "../../reactive/Behavior";
|
||||||
|
import { testScope } from "../../utils/test";
|
||||||
|
import { createLivekitMemberMedia } from "./LivekitMemberMedia";
|
||||||
|
|
||||||
|
describe("createLivekitMemberMedia", () => {
|
||||||
|
it("lists one stable track per publication, in publication order", () => {
|
||||||
|
const { participant, publications, room, emitter } = fakes();
|
||||||
|
const media = createLivekitMemberMedia(
|
||||||
|
testScope(),
|
||||||
|
constant({ participant, room }),
|
||||||
|
{ kind: E2eeType.NONE },
|
||||||
|
);
|
||||||
|
expect(media.tracks$.value).toEqual([]);
|
||||||
|
|
||||||
|
const microphone = publish(publications, "TR_1", Track.Source.Microphone);
|
||||||
|
const camera = publish(publications, "TR_2", Track.Source.Camera);
|
||||||
|
emitter.emit(ParticipantEvent.TrackPublished, camera);
|
||||||
|
const [first, second] = media.tracks$.value!;
|
||||||
|
expect(first.source).toBe("microphone");
|
||||||
|
expect(second.source).toBe("camera");
|
||||||
|
|
||||||
|
// An unrelated media event keeps the same objects
|
||||||
|
microphone.isMuted = true;
|
||||||
|
emitter.emit(ParticipantEvent.TrackMuted, microphone);
|
||||||
|
expect(media.tracks$.value![0]).toBe(first);
|
||||||
|
expect(media.tracks$.value![1]).toBe(second);
|
||||||
|
|
||||||
|
publications.delete("TR_1");
|
||||||
|
emitter.emit(ParticipantEvent.TrackUnpublished, microphone);
|
||||||
|
expect(media.tracks$.value).toEqual([second]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes a track published without a source", () => {
|
||||||
|
const { participant, publications, room, emitter } = fakes();
|
||||||
|
const media = createLivekitMemberMedia(
|
||||||
|
testScope(),
|
||||||
|
constant({ participant, room }),
|
||||||
|
{ kind: E2eeType.NONE },
|
||||||
|
);
|
||||||
|
emitter.emit(
|
||||||
|
ParticipantEvent.TrackPublished,
|
||||||
|
publish(publications, "TR_1", Track.Source.Unknown),
|
||||||
|
);
|
||||||
|
expect(media.tracks$.value!.map((t) => t.source)).toEqual(["unknown"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
type FakePublication = { isMuted: boolean } & Omit<TrackPublication, "isMuted">;
|
||||||
|
|
||||||
|
function publish(
|
||||||
|
publications: Map<string, TrackPublication>,
|
||||||
|
trackSid: string,
|
||||||
|
source: Track.Source,
|
||||||
|
): FakePublication {
|
||||||
|
const publication = Object.assign(new EventEmitter(), {
|
||||||
|
trackSid,
|
||||||
|
source,
|
||||||
|
kind:
|
||||||
|
source === Track.Source.Camera || source === Track.Source.ScreenShare
|
||||||
|
? Track.Kind.Video
|
||||||
|
: Track.Kind.Audio,
|
||||||
|
isMuted: false,
|
||||||
|
isEncrypted: true,
|
||||||
|
track: undefined,
|
||||||
|
}) as unknown as FakePublication;
|
||||||
|
publications.set(trackSid, publication as unknown as TrackPublication);
|
||||||
|
return publication;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakes(): {
|
||||||
|
participant: RemoteParticipant;
|
||||||
|
publications: Map<string, TrackPublication>;
|
||||||
|
room: LivekitRoom;
|
||||||
|
emitter: EventEmitter;
|
||||||
|
} {
|
||||||
|
const emitter = new EventEmitter();
|
||||||
|
const publications = new Map<string, TrackPublication>();
|
||||||
|
const participant = Object.assign(emitter, {
|
||||||
|
isLocal: false,
|
||||||
|
isSpeaking: false,
|
||||||
|
identity: "@alice:example.org:DEVICE",
|
||||||
|
trackPublications: publications,
|
||||||
|
getTrackPublication: (source: Track.Source) =>
|
||||||
|
[...publications.values()].find((p) => p.source === source),
|
||||||
|
}) as unknown as RemoteParticipant;
|
||||||
|
const room = new EventEmitter() as unknown as LivekitRoom;
|
||||||
|
return { participant, publications, room, emitter };
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
observeParticipantMedia,
|
||||||
|
roomEventSelector,
|
||||||
|
} from "@livekit/components-core";
|
||||||
|
import {
|
||||||
|
type LocalParticipant,
|
||||||
|
type Participant,
|
||||||
|
type Room as LivekitRoom,
|
||||||
|
RoomEvent,
|
||||||
|
type TrackPublication,
|
||||||
|
} from "livekit-client";
|
||||||
|
import { EMPTY, filter, map, type Observable, of, switchMap } from "rxjs";
|
||||||
|
|
||||||
|
import { type ObservableScope } from "../../reactive/ObservableScope";
|
||||||
|
import { generateItems } from "../../reactive/observable";
|
||||||
|
import { E2eeType, type EncryptionSystem } from "../../encryption";
|
||||||
|
import { type Behavior } from "../../reactive/Behavior";
|
||||||
|
import { mapScoped } from "../../utils/mapScoped";
|
||||||
|
import {
|
||||||
|
type EncryptionError,
|
||||||
|
type LocalMemberMedia,
|
||||||
|
type MediaSource,
|
||||||
|
type MemberMedia,
|
||||||
|
} from "../../media-api";
|
||||||
|
import {
|
||||||
|
createLivekitMediaTrack,
|
||||||
|
createLocalLivekitMediaTrack,
|
||||||
|
} from "./LivekitMediaTrack";
|
||||||
|
|
||||||
|
/** Where a member's media comes from: its participant on a room, null while it has none. */
|
||||||
|
export type ParticipantSource<P extends Participant = Participant> = {
|
||||||
|
participant: P;
|
||||||
|
room: LivekitRoom;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A member's media from its participant. The only place, with
|
||||||
|
* `LivekitMediaTrack`, that reads a LiveKit participant; everything above
|
||||||
|
* sees tracks and behaviors.
|
||||||
|
*/
|
||||||
|
export function createLivekitMemberMedia(
|
||||||
|
scope: ObservableScope,
|
||||||
|
source$: Behavior<ParticipantSource>,
|
||||||
|
encryptionSystem: EncryptionSystem,
|
||||||
|
): MemberMedia {
|
||||||
|
return {
|
||||||
|
tracks$: memberTracks$(
|
||||||
|
scope,
|
||||||
|
source$,
|
||||||
|
(trackScope, participant, publication, room) =>
|
||||||
|
createLivekitMediaTrack(trackScope, participant, publication, room),
|
||||||
|
),
|
||||||
|
encryptionError$: encryptionErrors$(source$, encryptionSystem),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLocalLivekitMemberMedia(
|
||||||
|
scope: ObservableScope,
|
||||||
|
source$: Behavior<ParticipantSource<LocalParticipant>>,
|
||||||
|
encryptionSystem: EncryptionSystem,
|
||||||
|
setEnabled: (source: MediaSource, enabled: boolean) => Promise<boolean>,
|
||||||
|
): LocalMemberMedia {
|
||||||
|
return {
|
||||||
|
tracks$: memberTracks$(
|
||||||
|
scope,
|
||||||
|
source$,
|
||||||
|
(trackScope, participant, publication, room) =>
|
||||||
|
createLocalLivekitMediaTrack(
|
||||||
|
trackScope,
|
||||||
|
participant,
|
||||||
|
publication,
|
||||||
|
room,
|
||||||
|
setEnabled,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
encryptionError$: encryptionErrors$(source$, encryptionSystem),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One track per publication, each living as long as its publication is in
|
||||||
|
* the participant's map; null while there is no participant.
|
||||||
|
*/
|
||||||
|
function memberTracks$<P extends Participant, T>(
|
||||||
|
scope: ObservableScope,
|
||||||
|
source$: Behavior<ParticipantSource<P>>,
|
||||||
|
factory: (
|
||||||
|
scope: ObservableScope,
|
||||||
|
participant: P,
|
||||||
|
publication: TrackPublication,
|
||||||
|
room: LivekitRoom,
|
||||||
|
) => T,
|
||||||
|
): Behavior<T[] | null> {
|
||||||
|
return scope.behavior(
|
||||||
|
mapScoped(scope, source$, (participantScope, { participant, room }) =>
|
||||||
|
participantScope.behavior(
|
||||||
|
observeParticipantMedia(participant).pipe(
|
||||||
|
generateItems(
|
||||||
|
`${participant.identity} tracks$`,
|
||||||
|
function* () {
|
||||||
|
for (const publication of participant.trackPublications.values())
|
||||||
|
yield { keys: [publication.trackSid], data: publication };
|
||||||
|
},
|
||||||
|
(trackScope, publication$) =>
|
||||||
|
factory(trackScope, participant, publication$.value, room),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).pipe(switchMap((tracks$) => tracks$ ?? of(null))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encryptionErrors$(
|
||||||
|
source$: Behavior<ParticipantSource>,
|
||||||
|
encryptionSystem: EncryptionSystem,
|
||||||
|
): Observable<EncryptionError> {
|
||||||
|
return source$.pipe(
|
||||||
|
switchMap((source) =>
|
||||||
|
source === null
|
||||||
|
? EMPTY
|
||||||
|
: roomEventSelector(source.room, RoomEvent.EncryptionError).pipe(
|
||||||
|
map(([error]) => error?.message ?? ""),
|
||||||
|
// The participant the error is about does not survive the trip
|
||||||
|
// from the worker, so the identity is matched in the message. A
|
||||||
|
// shared key is the same for everyone, so its errors are too.
|
||||||
|
filter(
|
||||||
|
(message) =>
|
||||||
|
encryptionSystem.kind === E2eeType.SHARED_KEY ||
|
||||||
|
message.includes(source.participant.identity),
|
||||||
|
),
|
||||||
|
map((message): EncryptionError | undefined =>
|
||||||
|
message.includes("MissingKey")
|
||||||
|
? "MissingKey"
|
||||||
|
: message.includes("InvalidKey")
|
||||||
|
? "InvalidKey"
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
|
filter((error) => error !== undefined),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ConnectionState as LivekitConnectionState,
|
||||||
|
type LocalParticipant,
|
||||||
|
type LocalTrack,
|
||||||
|
type LocalTrackPublication,
|
||||||
|
ParticipantEvent,
|
||||||
|
type Room as LivekitRoom,
|
||||||
|
Track,
|
||||||
|
} from "livekit-client";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { E2eeType } from "../../encryption";
|
||||||
|
import { FailToStartLivekitConnection, MatrixRTCError } from "../../errors";
|
||||||
|
import { Epoch, type ObservableScope } from "../../reactive/ObservableScope";
|
||||||
|
import { flushPromises, mockEmitter, testScope } from "../../utils/test";
|
||||||
|
import { MediaConnectionState } from "../api";
|
||||||
|
import { type TransportLocator } from "./auth";
|
||||||
|
import { type Connection, type ConnectionState } from "./Connection";
|
||||||
|
import { ConnectionMap } from "./ConnectionMap";
|
||||||
|
import { createLivekitLocalMedia } from "./LocalMedia";
|
||||||
|
import { exampleTransport } from "./test";
|
||||||
|
|
||||||
|
const localTransport: TransportLocator = {
|
||||||
|
transport: exampleTransport,
|
||||||
|
serverName: "example.org",
|
||||||
|
};
|
||||||
|
const connected: ConnectionState = {
|
||||||
|
state: "authenticated",
|
||||||
|
livekitState: LivekitConnectionState.Connected,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FakeConnection {
|
||||||
|
connection: Connection;
|
||||||
|
participant: LocalParticipant;
|
||||||
|
room: LivekitRoom;
|
||||||
|
state$: BehaviorSubject<ConnectionState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A connection to the local transport with a participant that publishes on demand. */
|
||||||
|
function fakeConnection(state: ConnectionState = connected): FakeConnection {
|
||||||
|
const participant = fakeLocalParticipant();
|
||||||
|
const room = {
|
||||||
|
options: {},
|
||||||
|
setE2EEEnabled: vi.fn(),
|
||||||
|
localParticipant: participant,
|
||||||
|
remoteParticipants: new Map(),
|
||||||
|
getActiveDevice: vi.fn(),
|
||||||
|
switchActiveDevice: vi.fn(async () => Promise.resolve(true)),
|
||||||
|
...mockEmitter(),
|
||||||
|
} as unknown as LivekitRoom;
|
||||||
|
const state$ = new BehaviorSubject<ConnectionState>(state);
|
||||||
|
const connection = {
|
||||||
|
role: "publisher",
|
||||||
|
transport: exampleTransport,
|
||||||
|
serverName: "example.org",
|
||||||
|
state$,
|
||||||
|
livekitRoom: room,
|
||||||
|
} as unknown as Connection;
|
||||||
|
return { connection, participant, room, state$ };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapOf(...connections: Connection[]): Epoch<ConnectionMap> {
|
||||||
|
const map = new ConnectionMap();
|
||||||
|
for (const connection of connections) map.add(connection, []);
|
||||||
|
return new Epoch(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(
|
||||||
|
scope: ObservableScope = testScope(),
|
||||||
|
publish = [{ source: "microphone" as const }],
|
||||||
|
): {
|
||||||
|
media: ReturnType<typeof createLivekitLocalMedia>;
|
||||||
|
connectionMap$: BehaviorSubject<Epoch<ConnectionMap>>;
|
||||||
|
localTransport$: BehaviorSubject<TransportLocator | null>;
|
||||||
|
audioOutputDeviceId$: BehaviorSubject<string | undefined>;
|
||||||
|
} {
|
||||||
|
const connectionMap$ = new BehaviorSubject(mapOf());
|
||||||
|
const localTransport$ = new BehaviorSubject<TransportLocator | null>(null);
|
||||||
|
const audioOutputDeviceId$ = new BehaviorSubject<string | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const media = createLivekitLocalMedia({
|
||||||
|
scope,
|
||||||
|
connectionMap$: scope.behavior(connectionMap$),
|
||||||
|
localTransport$: scope.behavior(localTransport$),
|
||||||
|
publish,
|
||||||
|
audioOutputDeviceId$,
|
||||||
|
encryptionSystem: { kind: E2eeType.NONE },
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
return { media, connectionMap$, localTransport$, audioOutputDeviceId$ };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createLivekitLocalMedia", () => {
|
||||||
|
it("finds the connection to the local transport once both are known", () => {
|
||||||
|
const { media, connectionMap$, localTransport$ } = setup();
|
||||||
|
const { connection } = fakeConnection();
|
||||||
|
expect(media.connection$.value).toBeNull();
|
||||||
|
expect(media.connectionState$.value).toBe(MediaConnectionState.Initialized);
|
||||||
|
|
||||||
|
connectionMap$.next(mapOf(connection));
|
||||||
|
expect(media.connection$.value).toBeNull();
|
||||||
|
|
||||||
|
localTransport$.next(localTransport);
|
||||||
|
expect(media.connection$.value).toBe(connection);
|
||||||
|
expect(media.connectionState$.value).toBe(MediaConnectionState.Connected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows the connection's state", () => {
|
||||||
|
const { media, connectionMap$, localTransport$ } = setup();
|
||||||
|
const { connection, state$ } = fakeConnection({ state: "authenticating" });
|
||||||
|
connectionMap$.next(mapOf(connection));
|
||||||
|
localTransport$.next(localTransport);
|
||||||
|
expect(media.connectionState$.value).toBe(MediaConnectionState.Preparing);
|
||||||
|
|
||||||
|
const error = new MatrixRTCError("no token");
|
||||||
|
state$.next({ state: "error", error });
|
||||||
|
expect(media.connectionState$.value).toBe(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates the tracks on the first publish and holds them back until then", async () => {
|
||||||
|
const { media, connectionMap$, localTransport$ } = setup();
|
||||||
|
const { connection, participant } = fakeConnection();
|
||||||
|
connectionMap$.next(mapOf(connection));
|
||||||
|
localTransport$.next(localTransport);
|
||||||
|
await flushPromises();
|
||||||
|
expect(participant.setMicrophoneEnabled).not.toHaveBeenCalled();
|
||||||
|
expect(media.tracks$.value).toEqual([]);
|
||||||
|
|
||||||
|
media.setPublishing(true);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(participant.setMicrophoneEnabled).toHaveBeenCalledOnce();
|
||||||
|
expect(media.tracks$.value).toHaveLength(1);
|
||||||
|
expect(media.tracks$.value![0].source).toBe("microphone");
|
||||||
|
expect(track(participant, Track.Source.Microphone).isUpstreamPaused).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pauses the upstream while the member is not publishing", async () => {
|
||||||
|
const { media, connectionMap$, localTransport$ } = setup();
|
||||||
|
const { connection, participant } = fakeConnection();
|
||||||
|
connectionMap$.next(mapOf(connection));
|
||||||
|
localTransport$.next(localTransport);
|
||||||
|
media.setPublishing(true);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
media.setPublishing(false);
|
||||||
|
await flushPromises();
|
||||||
|
expect(track(participant, Track.Source.Microphone).isUpstreamPaused).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
media.setPublishing(true);
|
||||||
|
await flushPromises();
|
||||||
|
expect(track(participant, Track.Source.Microphone).isUpstreamPaused).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a publish made before the connection exists once it is up", async () => {
|
||||||
|
const { media, connectionMap$, localTransport$ } = setup();
|
||||||
|
let published: string | undefined;
|
||||||
|
const publishing = media
|
||||||
|
.publish({ source: "camera" })
|
||||||
|
.then((t) => (published = t.source));
|
||||||
|
await flushPromises();
|
||||||
|
expect(published).toBeUndefined();
|
||||||
|
|
||||||
|
const { connection, participant } = fakeConnection();
|
||||||
|
connectionMap$.next(mapOf(connection));
|
||||||
|
localTransport$.next(localTransport);
|
||||||
|
media.setPublishing(true);
|
||||||
|
await publishing;
|
||||||
|
|
||||||
|
expect(published).toBe("camera");
|
||||||
|
// Both were requested before the start, so one prompt covers both
|
||||||
|
expect(participant.enableCameraAndMicrophone).toHaveBeenCalledOnce();
|
||||||
|
expect(media.tracks$.value?.map((t) => t.source)).toEqual([
|
||||||
|
"microphone",
|
||||||
|
"camera",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes a request at once on a started publisher", async () => {
|
||||||
|
const { media, connectionMap$, localTransport$ } = setup();
|
||||||
|
const { connection, participant } = fakeConnection();
|
||||||
|
connectionMap$.next(mapOf(connection));
|
||||||
|
localTransport$.next(localTransport);
|
||||||
|
media.setPublishing(true);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const t = await media.publish({ source: "screenShare" });
|
||||||
|
|
||||||
|
expect(t.source).toBe("screenShare");
|
||||||
|
expect(participant.setScreenShareEnabled).toHaveBeenCalledWith(
|
||||||
|
true,
|
||||||
|
expect.objectContaining({ selfBrowserSurface: "include" }),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a publish the device refuses, and forgets the request", async () => {
|
||||||
|
const { media, connectionMap$, localTransport$ } = setup();
|
||||||
|
const { connection, participant } = fakeConnection();
|
||||||
|
connectionMap$.next(mapOf(connection));
|
||||||
|
localTransport$.next(localTransport);
|
||||||
|
media.setPublishing(true);
|
||||||
|
await flushPromises();
|
||||||
|
vi.mocked(participant.setCameraEnabled).mockRejectedValueOnce(
|
||||||
|
new Error("NotAllowedError"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rejection = media.publish({ source: "camera" });
|
||||||
|
await expect(rejection).rejects.toBeInstanceOf(MatrixRTCError);
|
||||||
|
await expect(rejection).rejects.toThrow("NotAllowedError");
|
||||||
|
|
||||||
|
// A new connection does not try the camera again
|
||||||
|
const next = fakeConnection();
|
||||||
|
connectionMap$.next(mapOf(next.connection));
|
||||||
|
await flushPromises();
|
||||||
|
expect(next.participant.setCameraEnabled).not.toHaveBeenCalled();
|
||||||
|
expect(next.participant.setMicrophoneEnabled).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes again on a new connection and stops the old tracks", async () => {
|
||||||
|
const { media, connectionMap$, localTransport$ } = setup();
|
||||||
|
const first = fakeConnection();
|
||||||
|
connectionMap$.next(mapOf(first.connection));
|
||||||
|
localTransport$.next(localTransport);
|
||||||
|
media.setPublishing(true);
|
||||||
|
await flushPromises();
|
||||||
|
await media.publish({ source: "camera" });
|
||||||
|
const firstTracks = [
|
||||||
|
track(first.participant, Track.Source.Microphone),
|
||||||
|
track(first.participant, Track.Source.Camera),
|
||||||
|
];
|
||||||
|
|
||||||
|
const second = fakeConnection();
|
||||||
|
connectionMap$.next(mapOf(second.connection));
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
for (const t of firstTracks)
|
||||||
|
expect(first.participant.unpublishTrack).toHaveBeenCalledWith(t, true);
|
||||||
|
expect(second.participant.enableCameraAndMicrophone).toHaveBeenCalledOnce();
|
||||||
|
expect(media.tracks$.value?.map((t) => t.source)).toEqual([
|
||||||
|
"microphone",
|
||||||
|
"camera",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
track(second.participant, Track.Source.Camera).isUpstreamPaused,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remembers a mute across connections", async () => {
|
||||||
|
const { media, connectionMap$, localTransport$ } = setup();
|
||||||
|
const first = fakeConnection();
|
||||||
|
connectionMap$.next(mapOf(first.connection));
|
||||||
|
localTransport$.next(localTransport);
|
||||||
|
media.setPublishing(true);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
await expect(media.tracks$.value![0].setEnabled(false)).resolves.toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(first.participant.setMicrophoneEnabled).toHaveBeenLastCalledWith(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const second = fakeConnection();
|
||||||
|
connectionMap$.next(mapOf(second.connection));
|
||||||
|
await flushPromises();
|
||||||
|
// Muted means not created at all, to spare the permission prompt
|
||||||
|
expect(second.participant.setMicrophoneEnabled).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unpublishes a source, the screen share audio with its video", async () => {
|
||||||
|
const { media, connectionMap$, localTransport$ } = setup();
|
||||||
|
const { connection, participant } = fakeConnection();
|
||||||
|
connectionMap$.next(mapOf(connection));
|
||||||
|
localTransport$.next(localTransport);
|
||||||
|
media.setPublishing(true);
|
||||||
|
await flushPromises();
|
||||||
|
await media.publish({ source: "screenShare" });
|
||||||
|
|
||||||
|
await media.unpublish("screenShareAudio");
|
||||||
|
expect(participant.setScreenShareEnabled).toHaveBeenLastCalledWith(false);
|
||||||
|
|
||||||
|
await media.unpublish("microphone");
|
||||||
|
expect(participant.unpublishTrack).toHaveBeenCalledOnce();
|
||||||
|
expect(media.tracks$.value).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the first failure to publish", async () => {
|
||||||
|
const { media, connectionMap$, localTransport$ } = setup();
|
||||||
|
const { connection, participant } = fakeConnection();
|
||||||
|
connectionMap$.next(mapOf(connection));
|
||||||
|
localTransport$.next(localTransport);
|
||||||
|
media.setPublishing(true);
|
||||||
|
await flushPromises();
|
||||||
|
expect(media.publishError$.value).toBeNull();
|
||||||
|
|
||||||
|
const microphone = track(participant, Track.Source.Microphone);
|
||||||
|
vi.mocked(microphone.resumeUpstream).mockRejectedValue(
|
||||||
|
new Error("no upstream"),
|
||||||
|
);
|
||||||
|
media.setPublishing(false);
|
||||||
|
await flushPromises();
|
||||||
|
media.setPublishing(true);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(media.publishError$.value).toBeInstanceOf(
|
||||||
|
FailToStartLivekitConnection,
|
||||||
|
);
|
||||||
|
expect(media.publishError$.value?.message).toContain("no upstream");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("plays on the device given, on the current room and the rooms to come", async () => {
|
||||||
|
const { media, connectionMap$, localTransport$, audioOutputDeviceId$ } =
|
||||||
|
setup();
|
||||||
|
await media.setAudioOutputDeviceId("speaker");
|
||||||
|
expect(audioOutputDeviceId$.value).toBe("speaker");
|
||||||
|
|
||||||
|
const { connection, room } = fakeConnection();
|
||||||
|
connectionMap$.next(mapOf(connection));
|
||||||
|
localTransport$.next(localTransport);
|
||||||
|
await flushPromises();
|
||||||
|
await media.setAudioOutputDeviceId("headset");
|
||||||
|
|
||||||
|
expect(room.switchActiveDevice).toHaveBeenCalledWith(
|
||||||
|
"audiooutput",
|
||||||
|
"headset",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function track(
|
||||||
|
participant: LocalParticipant,
|
||||||
|
source: Track.Source,
|
||||||
|
): LocalTrack {
|
||||||
|
const t = participant.getTrackPublication(source)?.track;
|
||||||
|
if (t === undefined) throw new Error(`No ${source} track`);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeLocalTrack(source: Track.Source): LocalTrack {
|
||||||
|
const state = { isMuted: false, isUpstreamPaused: false };
|
||||||
|
const t = {
|
||||||
|
source,
|
||||||
|
get isMuted() {
|
||||||
|
return state.isMuted;
|
||||||
|
},
|
||||||
|
get isUpstreamPaused() {
|
||||||
|
return state.isUpstreamPaused;
|
||||||
|
},
|
||||||
|
attach: vi.fn(),
|
||||||
|
detach: vi.fn(),
|
||||||
|
mute: vi.fn(async () => {
|
||||||
|
state.isMuted = true;
|
||||||
|
return Promise.resolve(t);
|
||||||
|
}),
|
||||||
|
unmute: vi.fn(async () => {
|
||||||
|
state.isMuted = false;
|
||||||
|
return Promise.resolve(t);
|
||||||
|
}),
|
||||||
|
pauseUpstream: vi.fn(async () => {
|
||||||
|
state.isUpstreamPaused = true;
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
resumeUpstream: vi.fn(async () => {
|
||||||
|
state.isUpstreamPaused = false;
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return t as unknown as LocalTrack;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The slice of a local participant the publisher and the local tracks use:
|
||||||
|
* enabling a source publishes a fake track at once, muting an existing one
|
||||||
|
* mutes it, and the publications map and events are kept as LiveKit would.
|
||||||
|
*/
|
||||||
|
function fakeLocalParticipant(): LocalParticipant {
|
||||||
|
const emitter = mockEmitter<LocalParticipant>();
|
||||||
|
const trackPublications = new Map<string, LocalTrackPublication>();
|
||||||
|
let nextSid = 0;
|
||||||
|
|
||||||
|
const getTrackPublication = (
|
||||||
|
source: Track.Source,
|
||||||
|
): LocalTrackPublication | undefined =>
|
||||||
|
[...trackPublications.values()].find((p) => p.source === source);
|
||||||
|
|
||||||
|
const publish = (source: Track.Source): LocalTrackPublication => {
|
||||||
|
const t = fakeLocalTrack(source);
|
||||||
|
const publication = {
|
||||||
|
track: t,
|
||||||
|
source,
|
||||||
|
kind:
|
||||||
|
source === Track.Source.Microphone
|
||||||
|
? Track.Kind.Audio
|
||||||
|
: Track.Kind.Video,
|
||||||
|
trackSid: `TR_${nextSid++}`,
|
||||||
|
isEncrypted: false,
|
||||||
|
isSubscribed: true,
|
||||||
|
get isMuted() {
|
||||||
|
return t.isMuted;
|
||||||
|
},
|
||||||
|
mute: t.mute,
|
||||||
|
unmute: t.unmute,
|
||||||
|
...mockEmitter(),
|
||||||
|
} as unknown as LocalTrackPublication;
|
||||||
|
trackPublications.set(publication.trackSid, publication);
|
||||||
|
emitter.emit(ParticipantEvent.LocalTrackPublished, publication);
|
||||||
|
return publication;
|
||||||
|
};
|
||||||
|
const unpublish = (publication: LocalTrackPublication): void => {
|
||||||
|
trackPublications.delete(publication.trackSid);
|
||||||
|
emitter.emit(ParticipantEvent.LocalTrackUnpublished, publication);
|
||||||
|
};
|
||||||
|
const setEnabled = async (
|
||||||
|
source: Track.Source,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<LocalTrackPublication | undefined> => {
|
||||||
|
const existing = getTrackPublication(source);
|
||||||
|
if (enabled) {
|
||||||
|
if (existing) await existing.unmute();
|
||||||
|
return existing ?? publish(source);
|
||||||
|
}
|
||||||
|
await existing?.mute();
|
||||||
|
return existing;
|
||||||
|
};
|
||||||
|
|
||||||
|
const participant = {
|
||||||
|
...emitter,
|
||||||
|
identity: "@alice:example.org:DEVICE",
|
||||||
|
isLocal: true,
|
||||||
|
isSpeaking: false,
|
||||||
|
audioLevel: 0,
|
||||||
|
trackPublications,
|
||||||
|
getTrackPublication,
|
||||||
|
setMicrophoneEnabled: vi.fn(async (enabled: boolean) =>
|
||||||
|
setEnabled(Track.Source.Microphone, enabled),
|
||||||
|
),
|
||||||
|
setCameraEnabled: vi.fn(async (enabled: boolean) =>
|
||||||
|
setEnabled(Track.Source.Camera, enabled),
|
||||||
|
),
|
||||||
|
setScreenShareEnabled: vi.fn(async (enabled: boolean) => {
|
||||||
|
if (enabled) return setEnabled(Track.Source.ScreenShare, true);
|
||||||
|
const existing = getTrackPublication(Track.Source.ScreenShare);
|
||||||
|
if (existing) unpublish(existing);
|
||||||
|
return Promise.resolve(existing);
|
||||||
|
}),
|
||||||
|
enableCameraAndMicrophone: vi.fn(async () => {
|
||||||
|
publish(Track.Source.Microphone);
|
||||||
|
publish(Track.Source.Camera);
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
unpublishTrack: vi.fn(async (t: LocalTrack) => {
|
||||||
|
const publication = [...trackPublications.values()].find(
|
||||||
|
(p) => p.track === t,
|
||||||
|
);
|
||||||
|
if (publication !== undefined) unpublish(publication);
|
||||||
|
return Promise.resolve(publication);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return participant as unknown as LocalParticipant;
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { observeParticipantEvents } from "@livekit/components-core";
|
||||||
|
import {
|
||||||
|
type LocalParticipant,
|
||||||
|
type LocalTrackPublication,
|
||||||
|
ParticipantEvent,
|
||||||
|
type Room as LivekitRoom,
|
||||||
|
} from "livekit-client";
|
||||||
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import {
|
||||||
|
BehaviorSubject,
|
||||||
|
combineLatest,
|
||||||
|
distinctUntilChanged,
|
||||||
|
filter,
|
||||||
|
firstValueFrom,
|
||||||
|
map,
|
||||||
|
NEVER,
|
||||||
|
type Observable,
|
||||||
|
of,
|
||||||
|
switchMap,
|
||||||
|
} from "rxjs";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type LocalAudioMediaTrack,
|
||||||
|
type LocalVideoMediaTrack,
|
||||||
|
type MediaSource,
|
||||||
|
type PublishRequest,
|
||||||
|
} from "../../media-api";
|
||||||
|
import { type EncryptionSystem } from "../../encryption";
|
||||||
|
import { FailToStartLivekitConnection, toMatrixRTCError } from "../../errors";
|
||||||
|
import { type Behavior } from "../../reactive/Behavior";
|
||||||
|
import {
|
||||||
|
type Epoch,
|
||||||
|
type ObservableScope,
|
||||||
|
} from "../../reactive/ObservableScope";
|
||||||
|
import { type LocalMediaBackend, MediaConnectionState } from "../api";
|
||||||
|
import { type TransportLocator } from "./auth";
|
||||||
|
import { type Connection } from "./Connection";
|
||||||
|
import { type ConnectionMap } from "./ConnectionMap";
|
||||||
|
import { createLocalLivekitMemberMedia } from "./LivekitMemberMedia";
|
||||||
|
import { livekitSources } from "./LivekitMediaTrack";
|
||||||
|
import { type DesiredMedia, Publisher } from "./Publisher";
|
||||||
|
import { toMediaConnectionState } from "./connectionState";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
scope: ObservableScope;
|
||||||
|
connectionMap$: Behavior<Epoch<ConnectionMap>>;
|
||||||
|
/** The transport we publish on; null until the session has found it. */
|
||||||
|
localTransport$: Behavior<TransportLocator | null>;
|
||||||
|
publish: PublishRequest[];
|
||||||
|
/** Read by the connection factory for the rooms it creates from now on. */
|
||||||
|
audioOutputDeviceId$: BehaviorSubject<string | undefined>;
|
||||||
|
encryptionSystem: EncryptionSystem;
|
||||||
|
logger: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LivekitLocalMedia extends LocalMediaBackend {
|
||||||
|
/** The connection the local member publishes on; the data channel sends on it. */
|
||||||
|
connection$: Behavior<Connection | null>;
|
||||||
|
setAudioOutputDeviceId(deviceId: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The local member's side of the LiveKit backend: one `Publisher` per
|
||||||
|
* connection to the local transport, driven by the publish intent and the
|
||||||
|
* requests the host made, both of which outlive any one connection.
|
||||||
|
*/
|
||||||
|
export function createLivekitLocalMedia({
|
||||||
|
scope,
|
||||||
|
connectionMap$,
|
||||||
|
localTransport$,
|
||||||
|
publish: initialRequests,
|
||||||
|
audioOutputDeviceId$,
|
||||||
|
encryptionSystem,
|
||||||
|
logger: parentLogger,
|
||||||
|
}: Props): LivekitLocalMedia {
|
||||||
|
const logger = parentLogger.getChild("[LocalMedia]");
|
||||||
|
|
||||||
|
const connection$ = scope.behavior(
|
||||||
|
combineLatest(
|
||||||
|
[connectionMap$, localTransport$],
|
||||||
|
({ value: connections }, transport) =>
|
||||||
|
transport && connections.getConnectionForTransport(transport),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const connectionState$ = scope.behavior<MediaConnectionState | Error>(
|
||||||
|
connection$.pipe(
|
||||||
|
switchMap(
|
||||||
|
(connection) =>
|
||||||
|
connection?.state$.pipe(map(toMediaConnectionState)) ??
|
||||||
|
of(MediaConnectionState.Initialized),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const desired: DesiredMedia = new Map(
|
||||||
|
initialRequests.map((request) => [
|
||||||
|
request.source,
|
||||||
|
{ request, enabled: true },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const publishing$ = new BehaviorSubject(false);
|
||||||
|
const publisher$ = new BehaviorSubject<Publisher | null>(null);
|
||||||
|
const publishError$ = new BehaviorSubject<Error | null>(null);
|
||||||
|
|
||||||
|
scope.reconcile(connection$, async (connection) => {
|
||||||
|
if (connection === null) return;
|
||||||
|
const publisher = new Publisher(
|
||||||
|
connection.livekitRoom,
|
||||||
|
desired,
|
||||||
|
logger.getChild(`[Publisher ${JSON.stringify(connection.transport)}]`),
|
||||||
|
);
|
||||||
|
publisher$.next(publisher);
|
||||||
|
return Promise.resolve(async (): Promise<void> => {
|
||||||
|
publisher$.next(null);
|
||||||
|
await publisher.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.reconcile(
|
||||||
|
scope.behavior(combineLatest([publisher$, publishing$])),
|
||||||
|
async ([publisher, publish]) => {
|
||||||
|
if (publisher === null) return;
|
||||||
|
try {
|
||||||
|
if (publish) {
|
||||||
|
publisher.start();
|
||||||
|
await publisher.startPublishing();
|
||||||
|
} else if (publisher.shouldPublish) await publisher.stopPublishing();
|
||||||
|
} catch (e) {
|
||||||
|
if (publishError$.value === null)
|
||||||
|
publishError$.next(
|
||||||
|
new FailToStartLivekitConnection(
|
||||||
|
e instanceof Error ? e.message : String(e),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
else logger.error("Another publish error", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const participant$ = scope.behavior(
|
||||||
|
connection$.pipe(map((c) => c?.livekitRoom.localParticipant ?? null)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const setEnabled = async (
|
||||||
|
source: MediaSource,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const wanted = desired.get(source);
|
||||||
|
if (wanted) wanted.enabled = enabled;
|
||||||
|
// Without a publisher the request waits for the tracks to be created
|
||||||
|
const publisher = publisher$.value;
|
||||||
|
if (publisher === null) return enabled;
|
||||||
|
const result = await publisher.setEnabled(source, enabled);
|
||||||
|
if (wanted && result !== enabled) wanted.enabled = result;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { tracks$, encryptionError$ } = createLocalLivekitMemberMedia(
|
||||||
|
scope,
|
||||||
|
participantAndRoom$(scope, participant$, connection$),
|
||||||
|
encryptionSystem,
|
||||||
|
setEnabled,
|
||||||
|
);
|
||||||
|
|
||||||
|
const publish = async (
|
||||||
|
request: PublishRequest,
|
||||||
|
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack> => {
|
||||||
|
desired.set(request.source, { request, enabled: true });
|
||||||
|
const publisher = publisher$.value;
|
||||||
|
try {
|
||||||
|
// Before the start, the publisher publishes everything desired itself
|
||||||
|
const publication =
|
||||||
|
(publisher?.started ? await publisher.publish(request) : undefined) ??
|
||||||
|
(await firstValueFrom(publication$(participant$, request.source)));
|
||||||
|
return await firstValueFrom(trackWithId$(tracks$, publication.trackSid));
|
||||||
|
} catch (e) {
|
||||||
|
desired.delete(request.source);
|
||||||
|
throw toMatrixRTCError(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unpublish = async (source: MediaSource): Promise<void> => {
|
||||||
|
const published = publishedSource(source);
|
||||||
|
if (published === undefined) return;
|
||||||
|
desired.delete(published);
|
||||||
|
await publisher$.value?.unpublish(published);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAudioOutputDeviceId = async (deviceId: string): Promise<void> => {
|
||||||
|
audioOutputDeviceId$.next(deviceId);
|
||||||
|
await publisher$.value?.setAudioOutputDevice(deviceId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
connection$,
|
||||||
|
connectionState$,
|
||||||
|
tracks$,
|
||||||
|
encryptionError$,
|
||||||
|
setPublishing: (publish) => publishing$.next(publish),
|
||||||
|
publishError$,
|
||||||
|
publish,
|
||||||
|
unpublish,
|
||||||
|
setAudioOutputDeviceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pair the local tracks are built on, changing only when one of the two does. */
|
||||||
|
function participantAndRoom$(
|
||||||
|
scope: ObservableScope,
|
||||||
|
participant$: Behavior<LocalParticipant | null>,
|
||||||
|
connection$: Behavior<Connection | null>,
|
||||||
|
): Behavior<{ participant: LocalParticipant; room: LivekitRoom } | null> {
|
||||||
|
return scope.behavior(
|
||||||
|
combineLatest([participant$, connection$]).pipe(
|
||||||
|
map(([participant, connection]) =>
|
||||||
|
participant && connection
|
||||||
|
? { participant, room: connection.livekitRoom }
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
distinctUntilChanged(
|
||||||
|
(a, b) => a?.participant === b?.participant && a?.room === b?.room,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The publication of a source, once the current participant has made it. */
|
||||||
|
function publication$(
|
||||||
|
participant$: Behavior<LocalParticipant | null>,
|
||||||
|
source: MediaSource,
|
||||||
|
): Observable<LocalTrackPublication> {
|
||||||
|
return participant$.pipe(
|
||||||
|
switchMap((participant) =>
|
||||||
|
participant === null
|
||||||
|
? NEVER
|
||||||
|
: observeParticipantEvents(
|
||||||
|
participant,
|
||||||
|
ParticipantEvent.LocalTrackPublished,
|
||||||
|
).pipe(
|
||||||
|
map(() => participant.getTrackPublication(livekitSources[source])),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
filter((publication) => publication !== undefined),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackWithId$(
|
||||||
|
tracks$: Behavior<(LocalAudioMediaTrack | LocalVideoMediaTrack)[] | null>,
|
||||||
|
id: string,
|
||||||
|
): Observable<LocalAudioMediaTrack | LocalVideoMediaTrack> {
|
||||||
|
return tracks$.pipe(
|
||||||
|
map((tracks) => tracks?.find((track) => track.id === id)),
|
||||||
|
filter((track) => track !== undefined),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The publication a source belongs to: the screen share audio goes with its video. */
|
||||||
|
function publishedSource(
|
||||||
|
source: MediaSource,
|
||||||
|
): PublishRequest["source"] | undefined {
|
||||||
|
switch (source) {
|
||||||
|
case "screenShareAudio":
|
||||||
|
return "screenShare";
|
||||||
|
case "unknown":
|
||||||
|
return undefined;
|
||||||
|
default:
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 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 { KeyProviderEvent } from "livekit-client";
|
||||||
|
import { Subject } from "rxjs";
|
||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import { type MediaKey } from "../api";
|
||||||
|
import { MatrixKeyProvider } from "./MatrixKeyProvider";
|
||||||
|
|
||||||
|
describe("matrixKeyProvider", () => {
|
||||||
|
test("initializes", () => {
|
||||||
|
const keyProvider = new MatrixKeyProvider();
|
||||||
|
expect(keyProvider).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("follows the key stream it is given", async () => {
|
||||||
|
// jsdom has no HKDF; the derived material is not what is under test
|
||||||
|
vi.spyOn(crypto.subtle, "importKey").mockResolvedValue({} as CryptoKey);
|
||||||
|
const keyProvider = new MatrixKeyProvider();
|
||||||
|
const setKey = vi.fn();
|
||||||
|
keyProvider.on(KeyProviderEvent.SetKey, setKey);
|
||||||
|
const keys$ = new Subject<MediaKey>();
|
||||||
|
|
||||||
|
keyProvider.setMediaKeys(keys$);
|
||||||
|
keys$.next({ participantId: "alice", index: 2, key: new Uint8Array(16) });
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(setKey.mock.lastCall?.[0]).toMatchObject({
|
||||||
|
participantIdentity: "alice",
|
||||||
|
keyIndex: 2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stops following a stream when given another", () => {
|
||||||
|
const keyProvider = new MatrixKeyProvider();
|
||||||
|
const first$ = new Subject<MediaKey>();
|
||||||
|
const second$ = new Subject<MediaKey>();
|
||||||
|
|
||||||
|
keyProvider.setMediaKeys(first$);
|
||||||
|
expect(first$.observed).toBe(true);
|
||||||
|
|
||||||
|
keyProvider.setMediaKeys(second$);
|
||||||
|
expect(first$.observed).toBe(false);
|
||||||
|
expect(second$.observed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2023, 2024 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 { BaseKeyProvider } from "livekit-client";
|
||||||
|
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import { type Observable, type Subscription } from "rxjs";
|
||||||
|
|
||||||
|
import { type MediaKey } from "../api";
|
||||||
|
|
||||||
|
/** LiveKit's key provider, fed from the session's per-participant media keys. */
|
||||||
|
export class MatrixKeyProvider extends BaseKeyProvider {
|
||||||
|
private subscription?: Subscription;
|
||||||
|
private logger: Logger;
|
||||||
|
public constructor() {
|
||||||
|
super({ ratchetWindowSize: 10, keyringSize: 256 });
|
||||||
|
this.logger = rootLogger.getChild("[MatrixKeyProvider]");
|
||||||
|
}
|
||||||
|
|
||||||
|
public setMediaKeys(mediaKeys$: Observable<MediaKey>): void {
|
||||||
|
this.subscription?.unsubscribe();
|
||||||
|
this.subscription = mediaKeys$.subscribe(this.onMediaKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMediaKey = ({ key, index, participantId }: MediaKey): void => {
|
||||||
|
crypto.subtle
|
||||||
|
.importKey("raw", key, "HKDF", false, ["deriveBits", "deriveKey"])
|
||||||
|
.then(
|
||||||
|
(keyMaterial) => {
|
||||||
|
this.onSetEncryptionKey(keyMaterial, participantId, index);
|
||||||
|
this.logger.debug(
|
||||||
|
`Sent new key to livekit participantId=${participantId} encryptionKeyIndex=${index}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
(e) => {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to create key material from buffer for livekit participantId=${participantId} encryptionKeyIndex=${index}`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from "events";
|
||||||
|
import {
|
||||||
|
ConnectionState,
|
||||||
|
LocalParticipant,
|
||||||
|
type LocalTrack,
|
||||||
|
type LocalTrackPublication,
|
||||||
|
ParticipantEvent,
|
||||||
|
type Room as LivekitRoom,
|
||||||
|
Track,
|
||||||
|
} from "livekit-client";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { type PublishRequest } from "../../media-api";
|
||||||
|
import { flushPromises } from "../../utils/test";
|
||||||
|
import { type DesiredMedia, Publisher } from "./Publisher";
|
||||||
|
|
||||||
|
describe("Publisher", () => {
|
||||||
|
describe("setAudioOutputDevice", () => {
|
||||||
|
it("switches the room's output while the room is still connecting", async () => {
|
||||||
|
const { room, switchActiveDevice } = fakeRoom(ConnectionState.Connecting);
|
||||||
|
const publisher = new Publisher(room, new Map(), logger);
|
||||||
|
|
||||||
|
await publisher.setAudioOutputDevice("speaker");
|
||||||
|
expect(switchActiveDevice).toHaveBeenCalledWith("audiooutput", "speaker");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a room alone that already plays on the device", async () => {
|
||||||
|
const { room, switchActiveDevice } = fakeRoom(
|
||||||
|
ConnectionState.Connected,
|
||||||
|
"speaker",
|
||||||
|
);
|
||||||
|
const publisher = new Publisher(room, new Map(), logger);
|
||||||
|
|
||||||
|
await publisher.setAudioOutputDevice("speaker");
|
||||||
|
expect(switchActiveDevice).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects where the room cannot switch", async () => {
|
||||||
|
const { room, switchActiveDevice } = fakeRoom(ConnectionState.Connected);
|
||||||
|
switchActiveDevice.mockRejectedValue(new Error("no setSinkId"));
|
||||||
|
const publisher = new Publisher(room, new Map(), logger);
|
||||||
|
|
||||||
|
await expect(publisher.setAudioOutputDevice("speaker")).rejects.toThrow(
|
||||||
|
"no setSinkId",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The publisher drives a real LocalParticipant, so that LiveKit's own
|
||||||
|
// bookkeeping of pending publications is part of what is tested; only the
|
||||||
|
// device access and the signalling are faked.
|
||||||
|
describe("publishing", () => {
|
||||||
|
let participant: LocalParticipant;
|
||||||
|
let room: LivekitRoom;
|
||||||
|
let desired: DesiredMedia;
|
||||||
|
let publisher: Publisher;
|
||||||
|
// Resolved by default; a test takes hold of it to stretch the track creation
|
||||||
|
let createTracksLock: Promise<void>;
|
||||||
|
|
||||||
|
const microphone: PublishRequest = { source: "microphone" };
|
||||||
|
const camera: PublishRequest = { source: "camera" };
|
||||||
|
|
||||||
|
function want(...requests: PublishRequest[]): void {
|
||||||
|
for (const request of requests)
|
||||||
|
desired.set(request.source, { request, enabled: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function track(source: Track.Source): LocalTrack | undefined {
|
||||||
|
return participant.getTrackPublication(source)?.track;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
createTracksLock = Promise.resolve();
|
||||||
|
participant = fakeLocalParticipant(async () => createTracksLock);
|
||||||
|
room = {
|
||||||
|
options: {},
|
||||||
|
setE2EEEnabled: () => undefined,
|
||||||
|
localParticipant: participant,
|
||||||
|
} as unknown as LivekitRoom;
|
||||||
|
desired = new Map();
|
||||||
|
publisher = new Publisher(room, desired, logger);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await publisher.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates no tracks where everything starts muted, to spare the permission prompt", async () => {
|
||||||
|
want(microphone, camera);
|
||||||
|
desired.get("microphone")!.enabled = false;
|
||||||
|
desired.get("camera")!.enabled = false;
|
||||||
|
|
||||||
|
publisher.start();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(participant.createTracks).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for the camera and the microphone in one call", async () => {
|
||||||
|
const enableBoth = vi.spyOn(participant, "enableCameraAndMicrophone");
|
||||||
|
want(microphone, camera);
|
||||||
|
|
||||||
|
publisher.start();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(enableBoth).toHaveBeenCalledOnce();
|
||||||
|
expect(participant.createTracks).toHaveBeenCalledOnce();
|
||||||
|
expect(participant.createTracks).toHaveBeenCalledWith({
|
||||||
|
audio: true,
|
||||||
|
video: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ enabled: microphone, muted: camera, options: { audio: true } },
|
||||||
|
{ enabled: camera, muted: microphone, options: { video: true } },
|
||||||
|
])(
|
||||||
|
"creates only the $enabled.source where the $muted.source is muted",
|
||||||
|
async ({ enabled, muted, options }) => {
|
||||||
|
want(enabled, muted);
|
||||||
|
desired.get(muted.source)!.enabled = false;
|
||||||
|
|
||||||
|
publisher.start();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(participant.createTracks).toHaveBeenCalledOnce();
|
||||||
|
expect(participant.createTracks).toHaveBeenCalledWith(options);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("starts again at most once", async () => {
|
||||||
|
want(microphone);
|
||||||
|
|
||||||
|
publisher.start();
|
||||||
|
publisher.start();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(participant.createTracks).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the upstream paused until the member publishes", async () => {
|
||||||
|
want(microphone);
|
||||||
|
|
||||||
|
publisher.start();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const microphoneTrack = track(Track.Source.Microphone);
|
||||||
|
expect(microphoneTrack).toBeDefined();
|
||||||
|
expect(microphoneTrack!.pauseUpstream).toHaveBeenCalled();
|
||||||
|
expect(microphoneTrack!.isUpstreamPaused).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resumes the upstream once the member publishes", async () => {
|
||||||
|
want(camera);
|
||||||
|
publisher.start();
|
||||||
|
await flushPromises();
|
||||||
|
expect(track(Track.Source.Camera)!.isUpstreamPaused).toBe(true);
|
||||||
|
|
||||||
|
await publisher.startPublishing();
|
||||||
|
|
||||||
|
expect(track(Track.Source.Camera)!.isUpstreamPaused).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pauses the upstream again when the member stops publishing", async () => {
|
||||||
|
want(camera);
|
||||||
|
publisher.start();
|
||||||
|
await flushPromises();
|
||||||
|
await publisher.startPublishing();
|
||||||
|
|
||||||
|
await publisher.stopPublishing();
|
||||||
|
|
||||||
|
expect(track(Track.Source.Camera)!.isUpstreamPaused).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a track published while it publishes, paused or not", async () => {
|
||||||
|
want(microphone);
|
||||||
|
await publisher.startPublishing();
|
||||||
|
|
||||||
|
publisher.start();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(track(Track.Source.Microphone)!.isUpstreamPaused).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mutes and unmutes a track", async () => {
|
||||||
|
want(microphone);
|
||||||
|
publisher.start();
|
||||||
|
await flushPromises();
|
||||||
|
await publisher.startPublishing();
|
||||||
|
|
||||||
|
await expect(publisher.setEnabled("microphone", false)).resolves.toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(track(Track.Source.Microphone)!.isMuted).toBe(true);
|
||||||
|
|
||||||
|
await expect(publisher.setEnabled("microphone", true)).resolves.toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(track(Track.Source.Microphone)!.isMuted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the upstream paused when a track is unmuted before the member publishes", async () => {
|
||||||
|
want(microphone);
|
||||||
|
publisher.start();
|
||||||
|
await flushPromises();
|
||||||
|
await publisher.setEnabled("microphone", false);
|
||||||
|
|
||||||
|
await publisher.setEnabled("microphone", true);
|
||||||
|
|
||||||
|
expect(track(Track.Source.Microphone)!.isUpstreamPaused).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the state LiveKit is left in where a mute fails", async () => {
|
||||||
|
want(camera);
|
||||||
|
publisher.start();
|
||||||
|
await flushPromises();
|
||||||
|
vi.spyOn(participant, "setCameraEnabled").mockRejectedValueOnce(
|
||||||
|
new Error("device gone"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(publisher.setEnabled("camera", false)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a screen share that was never published as muted", async () => {
|
||||||
|
await expect(publisher.setEnabled("screenShare", true)).resolves.toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Creating the tracks takes a while, and the host may mute in the
|
||||||
|
// meantime: LiveKit's setMicrophoneEnabled(false) does not see a track
|
||||||
|
// that is still pending, so the publisher has to apply the mute once the
|
||||||
|
// track is published.
|
||||||
|
it("mutes a track that was muted while it was being created", async () => {
|
||||||
|
want(microphone);
|
||||||
|
const lock = Promise.withResolvers<void>();
|
||||||
|
createTracksLock = lock.promise;
|
||||||
|
|
||||||
|
publisher.start();
|
||||||
|
await publisher.startPublishing();
|
||||||
|
desired.get("microphone")!.enabled = false;
|
||||||
|
void publisher.setEnabled("microphone", false);
|
||||||
|
await flushPromises();
|
||||||
|
expect(track(Track.Source.Microphone)).toBeUndefined();
|
||||||
|
|
||||||
|
lock.resolve();
|
||||||
|
await flushPromises();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const microphoneTrack = track(Track.Source.Microphone);
|
||||||
|
expect(microphoneTrack).toBeDefined();
|
||||||
|
expect(microphoneTrack!.isMuted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes a request on its own once started", async () => {
|
||||||
|
want(microphone);
|
||||||
|
publisher.start();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const publication = await publisher.publish(camera);
|
||||||
|
|
||||||
|
expect(publication?.source).toBe(Track.Source.Camera);
|
||||||
|
expect(track(Track.Source.Camera)!.isUpstreamPaused).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unpublishes a source and stops its track", async () => {
|
||||||
|
want(microphone, camera);
|
||||||
|
publisher.start();
|
||||||
|
await flushPromises();
|
||||||
|
const cameraTrack = track(Track.Source.Camera)!;
|
||||||
|
|
||||||
|
await publisher.unpublish("camera");
|
||||||
|
|
||||||
|
expect(participant.unpublishTrack).toHaveBeenCalledWith(
|
||||||
|
cameraTrack,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(track(Track.Source.Camera)).toBeUndefined();
|
||||||
|
expect(track(Track.Source.Microphone)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unpublishes a screen share through LiveKit, so that its audio goes with it", async () => {
|
||||||
|
await publisher.unpublish("screenShare");
|
||||||
|
expect(participant.setScreenShareEnabled).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops every track on destroy", async () => {
|
||||||
|
want(microphone, camera);
|
||||||
|
publisher.start();
|
||||||
|
await flushPromises();
|
||||||
|
const tracks = [
|
||||||
|
track(Track.Source.Microphone),
|
||||||
|
track(Track.Source.Camera),
|
||||||
|
];
|
||||||
|
|
||||||
|
await publisher.destroy();
|
||||||
|
|
||||||
|
for (const t of tracks)
|
||||||
|
expect(participant.unpublishTrack).toHaveBeenCalledWith(t, true);
|
||||||
|
expect(participant.trackPublications.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The slice of a LiveKit room the publisher touches at construction and for the output device. */
|
||||||
|
function fakeRoom(
|
||||||
|
state: ConnectionState,
|
||||||
|
activeOutput?: string,
|
||||||
|
): { room: LivekitRoom; switchActiveDevice: ReturnType<typeof vi.fn> } {
|
||||||
|
const switchActiveDevice = vi.fn(async () => Promise.resolve(true));
|
||||||
|
const room = {
|
||||||
|
state,
|
||||||
|
options: {},
|
||||||
|
setE2EEEnabled: () => undefined,
|
||||||
|
localParticipant: new EventEmitter(),
|
||||||
|
getActiveDevice: () => activeOutput,
|
||||||
|
switchActiveDevice,
|
||||||
|
} as unknown as LivekitRoom;
|
||||||
|
return { room, switchActiveDevice };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeLocalTrack(source: Track.Source): LocalTrack {
|
||||||
|
const state = { isMuted: false, isUpstreamPaused: false };
|
||||||
|
const track = {
|
||||||
|
source,
|
||||||
|
get isMuted() {
|
||||||
|
return state.isMuted;
|
||||||
|
},
|
||||||
|
get isUpstreamPaused() {
|
||||||
|
return state.isUpstreamPaused;
|
||||||
|
},
|
||||||
|
stop: vi.fn(),
|
||||||
|
mute: vi.fn(async () => {
|
||||||
|
state.isMuted = true;
|
||||||
|
return Promise.resolve(track);
|
||||||
|
}),
|
||||||
|
unmute: vi.fn(async () => {
|
||||||
|
state.isMuted = false;
|
||||||
|
return Promise.resolve(track);
|
||||||
|
}),
|
||||||
|
pauseUpstream: vi.fn(async () => {
|
||||||
|
state.isUpstreamPaused = true;
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
resumeUpstream: vi.fn(async () => {
|
||||||
|
state.isUpstreamPaused = false;
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return track as unknown as LocalTrack;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A real LocalParticipant on a fake engine: the track creation, publication
|
||||||
|
* and unpublication are replaced so that no device or signalling is touched,
|
||||||
|
* while the enable/disable logic and the pending publications stay LiveKit's.
|
||||||
|
*/
|
||||||
|
function fakeLocalParticipant(
|
||||||
|
createTracksLock: () => Promise<void>,
|
||||||
|
): LocalParticipant {
|
||||||
|
const engine = {
|
||||||
|
client: { sendUpdateLocalMetadata: vi.fn() },
|
||||||
|
on: vi.fn().mockReturnThis(),
|
||||||
|
sendDataPacket: vi.fn(),
|
||||||
|
};
|
||||||
|
const participant = new LocalParticipant(
|
||||||
|
"local-sid",
|
||||||
|
"local-identity",
|
||||||
|
// @ts-expect-error - an engine that answers nothing is enough here
|
||||||
|
engine,
|
||||||
|
{
|
||||||
|
adaptiveStream: true,
|
||||||
|
dynacast: false,
|
||||||
|
audioCaptureDefaults: {},
|
||||||
|
videoCaptureDefaults: {},
|
||||||
|
stopLocalTrackOnUnpublish: true,
|
||||||
|
reconnectPolicy: "always",
|
||||||
|
disconnectOnPageLeave: true,
|
||||||
|
},
|
||||||
|
new Map(),
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
let nextSid = 0;
|
||||||
|
|
||||||
|
vi.spyOn(participant, "createTracks").mockImplementation(async (opts) => {
|
||||||
|
const tracks: LocalTrack[] = [];
|
||||||
|
if (opts?.audio) tracks.push(fakeLocalTrack(Track.Source.Microphone));
|
||||||
|
if (opts?.video) tracks.push(fakeLocalTrack(Track.Source.Camera));
|
||||||
|
await createTracksLock();
|
||||||
|
return tracks;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.spyOn(participant, "publishTrack").mockImplementation(async (t) => {
|
||||||
|
const track = t as LocalTrack;
|
||||||
|
const publication = {
|
||||||
|
track,
|
||||||
|
source: track.source,
|
||||||
|
trackSid: `TR_${nextSid++}`,
|
||||||
|
get isMuted() {
|
||||||
|
return track.isMuted;
|
||||||
|
},
|
||||||
|
mute: track.mute,
|
||||||
|
unmute: track.unmute,
|
||||||
|
} as Partial<LocalTrackPublication> as LocalTrackPublication;
|
||||||
|
participant.trackPublications.set(publication.trackSid, publication);
|
||||||
|
participant.emit(ParticipantEvent.LocalTrackPublished, publication);
|
||||||
|
return Promise.resolve(publication);
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.spyOn(participant, "unpublishTrack").mockImplementation(async (t) => {
|
||||||
|
const publication = [...participant.trackPublications.values()].find(
|
||||||
|
(p) => p.track === t,
|
||||||
|
);
|
||||||
|
if (publication === undefined) return undefined;
|
||||||
|
participant.trackPublications.delete(publication.trackSid);
|
||||||
|
participant.emit(ParticipantEvent.LocalTrackUnpublished, publication);
|
||||||
|
return Promise.resolve(publication);
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.spyOn(participant, "getTrackPublication").mockImplementation((source) =>
|
||||||
|
[...participant.trackPublications.values()].find(
|
||||||
|
(p) => p.source === source,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.spyOn(participant, "setScreenShareEnabled").mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
return participant;
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type LocalTrackPublication,
|
||||||
|
ParticipantEvent,
|
||||||
|
type Room as LivekitRoom,
|
||||||
|
type Track,
|
||||||
|
} from "livekit-client";
|
||||||
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
|
import { type MediaSource, type PublishRequest } from "../../media-api";
|
||||||
|
import { livekitSources, mediaSources } from "./LivekitMediaTrack";
|
||||||
|
import {
|
||||||
|
audioCaptureOptions,
|
||||||
|
screenShareCaptureOptions,
|
||||||
|
screenSharePublishOptions,
|
||||||
|
videoCaptureOptions,
|
||||||
|
videoPublishOptions,
|
||||||
|
} from "./publishOptions";
|
||||||
|
|
||||||
|
/** What the host asked to publish, kept across publishers for a reconnection. */
|
||||||
|
export interface DesiredPublication {
|
||||||
|
request: PublishRequest;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DesiredMedia = Map<MediaSource, DesiredPublication>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes the local media on one LiveKit room, following the host's
|
||||||
|
* requests.
|
||||||
|
*
|
||||||
|
* LiveKit publishes a track the moment it is created, but a member must not be
|
||||||
|
* heard before it has joined the MatrixRTC session, nor after it has left. The
|
||||||
|
* tracks are therefore kept, and their upstream paused, while `shouldPublish`
|
||||||
|
* is false: the local preview stays live while nothing reaches the room.
|
||||||
|
*/
|
||||||
|
export class Publisher {
|
||||||
|
public shouldPublish = false;
|
||||||
|
/** Whether the initial requests have been published; `publish` is immediate from then on. */
|
||||||
|
public started = false;
|
||||||
|
private readonly room: LivekitRoom;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
room: LivekitRoom,
|
||||||
|
private readonly desired: DesiredMedia,
|
||||||
|
private readonly logger: Logger,
|
||||||
|
) {
|
||||||
|
this.room = room;
|
||||||
|
room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => {
|
||||||
|
this.logger.error("Failed to enable E2EE on the room", e);
|
||||||
|
});
|
||||||
|
this.onLocalTrackPublished = this.onLocalTrackPublished.bind(this);
|
||||||
|
room.localParticipant.on(
|
||||||
|
ParticipantEvent.LocalTrackPublished,
|
||||||
|
this.onLocalTrackPublished,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
this.room.localParticipant.off(
|
||||||
|
ParticipantEvent.LocalTrackPublished,
|
||||||
|
this.onLocalTrackPublished,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
for (const { track } of this.publications())
|
||||||
|
if (track) await this.room.localParticipant.unpublishTrack(track, true);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error("Failed to stop the local tracks", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publishes what the host asked for, less what it has muted since. Safe to call more than once. */
|
||||||
|
public start(): void {
|
||||||
|
if (this.started) return;
|
||||||
|
this.started = true;
|
||||||
|
const requests = [...this.desired.values()]
|
||||||
|
.filter(({ enabled }) => enabled)
|
||||||
|
.map(({ request }) => request);
|
||||||
|
const sources = new Set(requests.map((request) => request.source));
|
||||||
|
// Microphone and camera in one call so that the browser asks for
|
||||||
|
// permission once; the room defaults carry their options. LiveKit
|
||||||
|
// resolves these once the track is published, which may block on the
|
||||||
|
// connection; LocalTrackPublished is what tells us a track exists.
|
||||||
|
if (sources.has("microphone") && sources.has("camera"))
|
||||||
|
void this.room.localParticipant.enableCameraAndMicrophone();
|
||||||
|
for (const request of requests)
|
||||||
|
if (
|
||||||
|
request.source === "screenShare" ||
|
||||||
|
!(sources.has("microphone") && sources.has("camera"))
|
||||||
|
)
|
||||||
|
this.publish(request).catch((e) => {
|
||||||
|
this.logger.error(`Failed to publish the ${request.source}`, e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publishes one request and resolves with its publication once LiveKit has it. */
|
||||||
|
public async publish(
|
||||||
|
request: PublishRequest,
|
||||||
|
): Promise<LocalTrackPublication | undefined> {
|
||||||
|
const participant = this.room.localParticipant;
|
||||||
|
let publication: LocalTrackPublication | undefined;
|
||||||
|
if (request.source === "microphone")
|
||||||
|
publication = await participant.setMicrophoneEnabled(
|
||||||
|
true,
|
||||||
|
audioCaptureOptions(request),
|
||||||
|
);
|
||||||
|
else if (request.source === "camera")
|
||||||
|
publication = await participant.setCameraEnabled(
|
||||||
|
true,
|
||||||
|
videoCaptureOptions(request),
|
||||||
|
videoPublishOptions(request.capture),
|
||||||
|
);
|
||||||
|
else
|
||||||
|
publication = await participant.setScreenShareEnabled(
|
||||||
|
true,
|
||||||
|
screenShareCaptureOptions(request),
|
||||||
|
screenSharePublishOptions(request.capture),
|
||||||
|
);
|
||||||
|
// Until the member has joined, the upstream has to stay paused
|
||||||
|
if (!this.shouldPublish) await this.pauseUpstreams();
|
||||||
|
return publication;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async unpublish(source: MediaSource): Promise<void> {
|
||||||
|
const participant = this.room.localParticipant;
|
||||||
|
// Takes the screen share audio with it
|
||||||
|
if (source === "screenShare") {
|
||||||
|
await participant.setScreenShareEnabled(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const track = participant.getTrackPublication(
|
||||||
|
livekitSources[source],
|
||||||
|
)?.track;
|
||||||
|
if (track) await participant.unpublishTrack(track, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutes or unmutes a source and reports what resulted: the request where
|
||||||
|
* it succeeded, the state LiveKit is left in where it did not (a denied
|
||||||
|
* permission, a missing device).
|
||||||
|
*/
|
||||||
|
public async setEnabled(
|
||||||
|
source: MediaSource,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const participant = this.room.localParticipant;
|
||||||
|
const livekitSource = livekitSources[source];
|
||||||
|
try {
|
||||||
|
if (source === "microphone")
|
||||||
|
await participant.setMicrophoneEnabled(enabled);
|
||||||
|
else if (source === "camera") await participant.setCameraEnabled(enabled);
|
||||||
|
else {
|
||||||
|
const publication = participant.getTrackPublication(livekitSource);
|
||||||
|
if (!publication) return false;
|
||||||
|
if (enabled) await publication.unmute();
|
||||||
|
else await publication.mute();
|
||||||
|
}
|
||||||
|
// Unmuting restarts the upstream; until the member has joined, it has
|
||||||
|
// to stay paused
|
||||||
|
if (enabled && !this.shouldPublish) await this.pauseUpstreams();
|
||||||
|
return enabled;
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(`Failed to set ${source} enabled=${enabled}`, e);
|
||||||
|
return !(participant.getTrackPublication(livekitSource)?.isMuted ?? true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async startPublishing(): Promise<void> {
|
||||||
|
if (this.shouldPublish) return;
|
||||||
|
this.shouldPublish = true;
|
||||||
|
// Enabling a track does not resume an upstream that was paused while it
|
||||||
|
// was already enabled, so it is done explicitly
|
||||||
|
for (const { track } of this.publications())
|
||||||
|
if (track?.isUpstreamPaused) await track.resumeUpstream();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stopPublishing(): Promise<void> {
|
||||||
|
this.shouldPublish = false;
|
||||||
|
await this.pauseUpstreams();
|
||||||
|
}
|
||||||
|
|
||||||
|
private onLocalTrackPublished(publication: LocalTrackPublication): void {
|
||||||
|
this.logger.info(`Local ${publication.source} track published`);
|
||||||
|
if (!this.shouldPublish)
|
||||||
|
this.pauseUpstreams().catch((e) => {
|
||||||
|
this.logger.error("Failed to pause the upstream", e);
|
||||||
|
});
|
||||||
|
// The host may have changed its mind while the track was being created
|
||||||
|
const source = mediaSources[publication.source as Track.Source];
|
||||||
|
if (this.desired.get(source)?.enabled === false)
|
||||||
|
void this.setEnabled(source, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pauseUpstreams(): Promise<void> {
|
||||||
|
for (const { track } of this.publications())
|
||||||
|
if (track && !track.isUpstreamPaused) await track.pauseUpstream();
|
||||||
|
}
|
||||||
|
|
||||||
|
private publications(): LocalTrackPublication[] {
|
||||||
|
return [...this.room.localParticipant.trackPublications.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Works on a room that is still connecting too: LiveKit keeps the device
|
||||||
|
* in the room's options and gives it to every participant that arrives.
|
||||||
|
*/
|
||||||
|
public async setAudioOutputDevice(deviceId: string): Promise<void> {
|
||||||
|
if (this.room.getActiveDevice("audiooutput") === deviceId) return;
|
||||||
|
await this.room.switchActiveDevice("audiooutput", deviceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ConnectionError, HTTPError, type MatrixClient } from "matrix-js-sdk";
|
||||||
|
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||||
|
|
||||||
|
import { doNetworkOperationWithRetry } from "../../../utils/network";
|
||||||
|
import { extractFullConfigFromToken, type SFUConfig } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In widget mode the host may refuse an action before any request is made,
|
||||||
|
* which surfaces as a plain error. Asking again cannot change its answer, so
|
||||||
|
* only errors from the network are worth retrying.
|
||||||
|
*/
|
||||||
|
function isNetworkError(e: unknown): boolean {
|
||||||
|
return e instanceof HTTPError || e instanceof ConnectionError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientGetTokenParts = Pick<
|
||||||
|
MatrixClient,
|
||||||
|
"_unstable_getLivekitToken"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface GetSFUConfigParams {
|
||||||
|
client: ClientGetTokenParts;
|
||||||
|
/** Our own membership identity; the member id is what the token is issued for. */
|
||||||
|
membership: CallMembershipIdentityParts;
|
||||||
|
/** The WebSocket URL of the SFU to get a token for. */
|
||||||
|
url: string;
|
||||||
|
/** The homeserver the SFU belongs to. */
|
||||||
|
serverName: string;
|
||||||
|
roomId: string;
|
||||||
|
slotId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets an SFU token through the homeserver's MSC4195 `get_token` endpoint. */
|
||||||
|
export async function getSFUConfig({
|
||||||
|
client,
|
||||||
|
membership,
|
||||||
|
url,
|
||||||
|
serverName,
|
||||||
|
roomId,
|
||||||
|
slotId,
|
||||||
|
}: GetSFUConfigParams): Promise<SFUConfig> {
|
||||||
|
const res = await doNetworkOperationWithRetry(
|
||||||
|
async () =>
|
||||||
|
client._unstable_getLivekitToken({
|
||||||
|
url,
|
||||||
|
server_name: serverName,
|
||||||
|
room_id: roomId,
|
||||||
|
slot_id: slotId,
|
||||||
|
member_id: membership.memberId,
|
||||||
|
}),
|
||||||
|
isNetworkError,
|
||||||
|
);
|
||||||
|
return extractFullConfigFromToken({ url, jwt: res.jwt });
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientDelegationParts = Pick<
|
||||||
|
MatrixClient,
|
||||||
|
"_unstable_delegateDelayedLeave" | "baseUrl"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface DelegateDelayedLeaveParams {
|
||||||
|
client: ClientDelegationParts;
|
||||||
|
membership: CallMembershipIdentityParts;
|
||||||
|
/** The WebSocket URL of the SFU we publish on. */
|
||||||
|
url: string;
|
||||||
|
roomId: string;
|
||||||
|
slotId: string;
|
||||||
|
/** The delay ID of the leave event to hand over. */
|
||||||
|
delayId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands the delayed leave to the homeserver through the MSC4195
|
||||||
|
* `delegate_delayed_leave` endpoint, which sends it once we drop off the SFU.
|
||||||
|
*/
|
||||||
|
export async function delegateDelayedLeave({
|
||||||
|
client,
|
||||||
|
membership,
|
||||||
|
url,
|
||||||
|
roomId,
|
||||||
|
slotId,
|
||||||
|
delayId,
|
||||||
|
}: DelegateDelayedLeaveParams): Promise<void> {
|
||||||
|
await doNetworkOperationWithRetry(
|
||||||
|
async () =>
|
||||||
|
client._unstable_delegateDelayedLeave({
|
||||||
|
url,
|
||||||
|
room_id: roomId,
|
||||||
|
slot_id: slotId,
|
||||||
|
member_id: membership.memberId,
|
||||||
|
delay_id: delayId,
|
||||||
|
}),
|
||||||
|
isNetworkError,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
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 { 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";
|
||||||
|
|
||||||
|
import { NoMatrix2AuthorizationService } from "../../../errors";
|
||||||
|
import {
|
||||||
|
aliceDeviceId,
|
||||||
|
aliceUserId,
|
||||||
|
createJWTToken,
|
||||||
|
} from "../../../utils/test-fixtures";
|
||||||
|
import type * as csApi from "./csApi";
|
||||||
|
import type * as openID from "./openID";
|
||||||
|
import { chooseAuthFlow, delegateDelayedLeave, getSFUConfig } from ".";
|
||||||
|
|
||||||
|
const aliceId = `${aliceUserId}:${aliceDeviceId}`;
|
||||||
|
const csApiJWTToken = createJWTToken(aliceId, "via CS API");
|
||||||
|
const legacyJWTToken = createJWTToken(aliceId, "via legacy endpoint");
|
||||||
|
const defaultJWTToken = createJWTToken(aliceId, "via default endpoint");
|
||||||
|
|
||||||
|
const client: Mocked<
|
||||||
|
csApi.ClientGetTokenParts &
|
||||||
|
csApi.ClientDelegationParts &
|
||||||
|
openID.ClientOpenIDParts
|
||||||
|
> = {
|
||||||
|
baseUrl: "https://matrix.example.org",
|
||||||
|
_unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: csApiJWTToken }),
|
||||||
|
_unstable_delegateDelayedLeave: vi.fn().mockResolvedValue({}),
|
||||||
|
getOpenIdToken: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ access_token: "mock-openID-token" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const membership: CallMembershipIdentityParts = {
|
||||||
|
userId: aliceUserId,
|
||||||
|
deviceId: aliceDeviceId,
|
||||||
|
memberId: "123",
|
||||||
|
};
|
||||||
|
|
||||||
|
const roomId = "!room:example.org";
|
||||||
|
const slotId = "m.call#ROOM";
|
||||||
|
|
||||||
|
const unsupportedError = new MatrixError({ errcode: "M_UNSUPPORTED" }, 404);
|
||||||
|
|
||||||
|
const urlTransport: UnstableLivekitTransport = {
|
||||||
|
type: "livekit",
|
||||||
|
url: "wss://livekit.example.org",
|
||||||
|
};
|
||||||
|
|
||||||
|
const serviceUrl = "https://livekit-jwt.example.org";
|
||||||
|
const serviceUrlTransport: UnstableLivekitTransport = {
|
||||||
|
type: "livekit",
|
||||||
|
livekit_service_url: serviceUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
const urlAndServiceUrlTransport: UnstableLivekitTransport = {
|
||||||
|
...urlTransport,
|
||||||
|
...serviceUrlTransport,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The JWT service, answering each endpoint with a token that names it. */
|
||||||
|
function mockJWTService(): void {
|
||||||
|
fetchMock.post(`${serviceUrl}/sfu/get`, {
|
||||||
|
status: 200,
|
||||||
|
body: { url: "wss://sfu", jwt: legacyJWTToken },
|
||||||
|
});
|
||||||
|
fetchMock.post(`${serviceUrl}/get_token`, {
|
||||||
|
status: 200,
|
||||||
|
body: { url: "wss://sfu", jwt: defaultJWTToken },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the JWT service was asked at all, and which endpoint. */
|
||||||
|
function jwtServiceCalls(): string[] {
|
||||||
|
return fetchMock.calls().map(([url]) => url.replace(serviceUrl, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
fetchMock.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSFUConfig", () => {
|
||||||
|
const defaultParams = {
|
||||||
|
client,
|
||||||
|
membership,
|
||||||
|
serverName: "example.org",
|
||||||
|
role: "subscriber" as const,
|
||||||
|
roomId,
|
||||||
|
slotId,
|
||||||
|
logger,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("uses CS API with url transport", async () => {
|
||||||
|
mockJWTService();
|
||||||
|
expect(
|
||||||
|
await getSFUConfig({ ...defaultParams, transport: urlTransport }),
|
||||||
|
).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
jwt: csApiJWTToken,
|
||||||
|
livekitAlias: "via CS API",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(client._unstable_getLivekitToken).toHaveBeenCalledWith({
|
||||||
|
url: urlTransport.url,
|
||||||
|
server_name: "example.org",
|
||||||
|
room_id: roomId,
|
||||||
|
slot_id: slotId,
|
||||||
|
member_id: "123",
|
||||||
|
});
|
||||||
|
expect(jwtServiceCalls()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fall back to OpenID with url transport", async () => {
|
||||||
|
mockJWTService();
|
||||||
|
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
|
||||||
|
await expect(
|
||||||
|
getSFUConfig({ ...defaultParams, transport: urlTransport }),
|
||||||
|
).rejects.toThrow(expect.any(NoMatrix2AuthorizationService));
|
||||||
|
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
||||||
|
expect(jwtServiceCalls()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses OpenID with livekit_service_url transport", async () => {
|
||||||
|
mockJWTService();
|
||||||
|
expect(
|
||||||
|
await getSFUConfig({ ...defaultParams, transport: serviceUrlTransport }),
|
||||||
|
).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
jwt: defaultJWTToken,
|
||||||
|
livekitAlias: "via default endpoint",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
|
||||||
|
expect(jwtServiceCalls()).toEqual(["/get_token"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses CS API 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,
|
||||||
|
transport: urlAndServiceUrlTransport,
|
||||||
|
}),
|
||||||
|
).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
jwt: defaultJWTToken,
|
||||||
|
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(jwtServiceCalls()).toEqual(["/get_token"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces the widget host's refusal with url transport", async () => {
|
||||||
|
mockJWTService();
|
||||||
|
const error = new Error(
|
||||||
|
"The rtc_livekit_get_token action is not supported by the client.",
|
||||||
|
);
|
||||||
|
client._unstable_getLivekitToken.mockRejectedValueOnce(error);
|
||||||
|
await expect(
|
||||||
|
getSFUConfig({ ...defaultParams, transport: urlTransport }),
|
||||||
|
).rejects.toThrow(error);
|
||||||
|
expect(jwtServiceCalls()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses CS API when publishing with url transport", async () => {
|
||||||
|
mockJWTService();
|
||||||
|
expect(
|
||||||
|
await getSFUConfig({
|
||||||
|
...defaultParams,
|
||||||
|
transport: urlTransport,
|
||||||
|
role: "publisher",
|
||||||
|
}),
|
||||||
|
).toEqual(expect.objectContaining({ jwt: csApiJWTToken }));
|
||||||
|
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
||||||
|
expect(jwtServiceCalls()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fall back to OpenID when publishing", async () => {
|
||||||
|
mockJWTService();
|
||||||
|
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
|
||||||
|
await expect(
|
||||||
|
getSFUConfig({
|
||||||
|
...defaultParams,
|
||||||
|
transport: urlTransport,
|
||||||
|
role: "publisher",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(expect.any(NoMatrix2AuthorizationService));
|
||||||
|
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
||||||
|
expect(jwtServiceCalls()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the legacy OpenID endpoint when publishing with url + livekit_service_url transport", async () => {
|
||||||
|
mockJWTService();
|
||||||
|
// Subscribers predating MSC4195 expect a legacy participant identity from
|
||||||
|
// anyone advertising a membership, which only the JWT service assigns
|
||||||
|
expect(
|
||||||
|
await getSFUConfig({
|
||||||
|
...defaultParams,
|
||||||
|
transport: urlAndServiceUrlTransport,
|
||||||
|
role: "publisher",
|
||||||
|
}),
|
||||||
|
).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
jwt: legacyJWTToken,
|
||||||
|
livekitAlias: "via legacy endpoint",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
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", () => {
|
||||||
|
const defaultParams = {
|
||||||
|
client,
|
||||||
|
membership,
|
||||||
|
roomId,
|
||||||
|
slotId,
|
||||||
|
delayId: "delay123",
|
||||||
|
delayTimeoutMs: 3_600_000,
|
||||||
|
logger,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The delay fields the JWT service was handed, if any. */
|
||||||
|
function delegationViaJWTService(): object | undefined {
|
||||||
|
const call = fetchMock.calls().find(([url]) => url.endsWith("/sfu/get"));
|
||||||
|
if (call === undefined) return undefined;
|
||||||
|
const { delay_id, delay_timeout, delay_cs_api_url } = JSON.parse(
|
||||||
|
call[1]?.body as string,
|
||||||
|
);
|
||||||
|
return { delay_id, delay_timeout, delay_cs_api_url };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("uses CS API with url transport", async () => {
|
||||||
|
mockJWTService();
|
||||||
|
await delegateDelayedLeave({ ...defaultParams, transport: urlTransport });
|
||||||
|
expect(client._unstable_delegateDelayedLeave).toHaveBeenCalledWith({
|
||||||
|
url: urlTransport.url,
|
||||||
|
room_id: roomId,
|
||||||
|
slot_id: slotId,
|
||||||
|
member_id: "123",
|
||||||
|
delay_id: "delay123",
|
||||||
|
});
|
||||||
|
expect(jwtServiceCalls()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fall back to OpenID with url transport", async () => {
|
||||||
|
mockJWTService();
|
||||||
|
client._unstable_delegateDelayedLeave.mockRejectedValueOnce(
|
||||||
|
unsupportedError,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
delegateDelayedLeave({ ...defaultParams, transport: urlTransport }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
expect(client._unstable_delegateDelayedLeave).toHaveBeenCalled();
|
||||||
|
expect(jwtServiceCalls()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses OpenID with livekit_service_url transport", async () => {
|
||||||
|
mockJWTService();
|
||||||
|
await delegateDelayedLeave({
|
||||||
|
...defaultParams,
|
||||||
|
transport: serviceUrlTransport,
|
||||||
|
});
|
||||||
|
expect(client._unstable_delegateDelayedLeave).not.toHaveBeenCalled();
|
||||||
|
expect(delegationViaJWTService()).toEqual({
|
||||||
|
delay_id: "delay123",
|
||||||
|
delay_timeout: 3_600_000,
|
||||||
|
delay_cs_api_url: client.baseUrl,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses OpenID with url + livekit_service_url transport", async () => {
|
||||||
|
mockJWTService();
|
||||||
|
// Must match the flow that got the token, so the identities line up
|
||||||
|
await delegateDelayedLeave({
|
||||||
|
...defaultParams,
|
||||||
|
transport: urlAndServiceUrlTransport,
|
||||||
|
});
|
||||||
|
expect(client._unstable_delegateDelayedLeave).not.toHaveBeenCalled();
|
||||||
|
expect(delegationViaJWTService()).toEqual({
|
||||||
|
delay_id: "delay123",
|
||||||
|
delay_timeout: 3_600_000,
|
||||||
|
delay_cs_api_url: client.baseUrl,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chooseAuthFlow", () => {
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
"url",
|
||||||
|
"publisher",
|
||||||
|
urlTransport,
|
||||||
|
{ kind: "csApi", url: urlTransport.url },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"url",
|
||||||
|
"subscriber",
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication with a LiveKit transport: the MSC4195 client-server API
|
||||||
|
* through our homeserver for a transport with an SFU `url`, the OpenID flow
|
||||||
|
* against the JWT service for one with a `livekit_service_url`, and the
|
||||||
|
* choice between them where a transport carries both.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ConnectionError, MatrixError } from "matrix-js-sdk";
|
||||||
|
import { type 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";
|
||||||
|
|
||||||
|
import { NoMatrix2AuthorizationService } from "../../../errors";
|
||||||
|
import * as csApi from "./csApi";
|
||||||
|
import * as openID from "./openID";
|
||||||
|
import { type ConnectionRole, type SFUConfig } from "./types";
|
||||||
|
|
||||||
|
export type { ClientOpenIDParts } from "./openID";
|
||||||
|
export type { ClientGetTokenParts, ClientDelegationParts } from "./csApi";
|
||||||
|
export type * from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an error means the homeserver does not implement the MSC4195
|
||||||
|
* endpoints. The MSC says `M_NOT_FOUND`; a homeserver without support answers
|
||||||
|
* with whatever it uses for unknown routes (Synapse: 404 with
|
||||||
|
* `M_UNRECOGNIZED`), so any 404 counts.
|
||||||
|
*/
|
||||||
|
function isEndpointUnsupported(e: unknown): boolean {
|
||||||
|
return (
|
||||||
|
e instanceof MatrixError &&
|
||||||
|
(e.httpStatus === 404 ||
|
||||||
|
e.errcode === "M_NOT_FOUND" ||
|
||||||
|
e.errcode === "M_UNRECOGNIZED")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an error means the MSC4195 client-server API cannot be used from
|
||||||
|
* here: the homeserver does not implement it, or the request never reached
|
||||||
|
* the homeserver because a widget host does not expose the endpoint
|
||||||
|
* (MSC4533), which the widget API reports as a plain error. A connection
|
||||||
|
* error is neither: the endpoint may well exist.
|
||||||
|
*/
|
||||||
|
function isCsApiUnavailable(e: unknown): boolean {
|
||||||
|
if (isEndpointUnsupported(e)) return true;
|
||||||
|
return !(e instanceof MatrixError) && !(e instanceof ConnectionError);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetSFUConfigParams {
|
||||||
|
client: csApi.ClientGetTokenParts & openID.ClientOpenIDParts;
|
||||||
|
membership: CallMembershipIdentityParts;
|
||||||
|
transport: UnstableLivekitTransport;
|
||||||
|
/** The homeserver the transport belongs to. */
|
||||||
|
serverName: string;
|
||||||
|
roomId: string;
|
||||||
|
slotId: string;
|
||||||
|
role: ConnectionRole;
|
||||||
|
logger: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authenticates with a transport, through whichever flow suits it and our role. */
|
||||||
|
export async function getSFUConfig({
|
||||||
|
client,
|
||||||
|
membership,
|
||||||
|
transport,
|
||||||
|
serverName,
|
||||||
|
roomId,
|
||||||
|
slotId,
|
||||||
|
role,
|
||||||
|
logger,
|
||||||
|
}: GetSFUConfigParams): Promise<SFUConfig> {
|
||||||
|
const viaOpenID = async (serviceUrl: string): Promise<SFUConfig> =>
|
||||||
|
await openID.getSFUConfig({
|
||||||
|
client,
|
||||||
|
membership,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DelegateDelayedLeaveParams {
|
||||||
|
client: csApi.ClientDelegationParts & openID.ClientOpenIDParts;
|
||||||
|
membership: CallMembershipIdentityParts;
|
||||||
|
/** The transport we publish on. */
|
||||||
|
transport: UnstableLivekitTransport;
|
||||||
|
roomId: string;
|
||||||
|
slotId: string;
|
||||||
|
delayId: string;
|
||||||
|
/** How long the service waits before sending the leave, for the JWT service flow. */
|
||||||
|
delayTimeoutMs: number;
|
||||||
|
logger: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands the delayed leave to the service that authenticated us as a
|
||||||
|
* publisher, so that the identity it watches for on the SFU is the one we
|
||||||
|
* publish under.
|
||||||
|
*/
|
||||||
|
export async function delegateDelayedLeave({
|
||||||
|
client,
|
||||||
|
membership,
|
||||||
|
transport,
|
||||||
|
roomId,
|
||||||
|
slotId,
|
||||||
|
delayId,
|
||||||
|
delayTimeoutMs,
|
||||||
|
logger,
|
||||||
|
}: DelegateDelayedLeaveParams): Promise<void> {
|
||||||
|
const flow = chooseAuthFlow(transport, "publisher");
|
||||||
|
if (flow.kind === "csApi") {
|
||||||
|
try {
|
||||||
|
await csApi.delegateDelayedLeave({
|
||||||
|
client,
|
||||||
|
membership,
|
||||||
|
url: flow.url,
|
||||||
|
roomId,
|
||||||
|
slotId,
|
||||||
|
delayId,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (isEndpointUnsupported(e))
|
||||||
|
logger.error(
|
||||||
|
"Server misconfigured: transport contains a 'url' field but server does not support the MSC4195 delegate_delayed_leave endpoint",
|
||||||
|
);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// The token this issues is discarded; the request is what hands the leave over
|
||||||
|
await openID.getSFUConfig({
|
||||||
|
client,
|
||||||
|
membership,
|
||||||
|
serviceUrl: flow.serviceUrl,
|
||||||
|
roomId,
|
||||||
|
slotId,
|
||||||
|
role: "publisher",
|
||||||
|
delayEndpointBaseUrl: client.baseUrl,
|
||||||
|
delayId,
|
||||||
|
delayTimeoutMs,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The flow with which the local member authenticates against a transport. */
|
||||||
|
export type AuthFlow =
|
||||||
|
| { kind: "csApi"; url: string }
|
||||||
|
| { 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.
|
||||||
|
*/
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
return { kind: "openID", serviceUrl: transport.livekit_service_url };
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
afterEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
type MockedObject,
|
||||||
|
vitest,
|
||||||
|
} from "vitest";
|
||||||
|
import fetchMock from "fetch-mock";
|
||||||
|
import { MatrixError } from "matrix-js-sdk";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getSFUConfig,
|
||||||
|
type GetSFUConfigParams,
|
||||||
|
type ClientOpenIDParts,
|
||||||
|
} from "./openID";
|
||||||
|
import { testJWTToken } from "../../../utils/test-fixtures";
|
||||||
|
import { ownMemberMock } from "../../../utils/test";
|
||||||
|
import { FailToGetOpenIdToken } from "../../../errors";
|
||||||
|
|
||||||
|
const serviceUrl = "https://sfu.example.org";
|
||||||
|
|
||||||
|
const delayParams = {
|
||||||
|
delayEndpointBaseUrl: "https://matrix.homeserverserver.org",
|
||||||
|
delayId: "mock_delay_id",
|
||||||
|
delayTimeoutMs: 3_600_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("getSFUConfig", () => {
|
||||||
|
let matrixClient: MockedObject<ClientOpenIDParts>;
|
||||||
|
let params: GetSFUConfigParams;
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock.catch(404);
|
||||||
|
matrixClient = { getOpenIdToken: vitest.fn() };
|
||||||
|
params = {
|
||||||
|
client: matrixClient,
|
||||||
|
membership: ownMemberMock,
|
||||||
|
serviceUrl: "https://sfu.example.org",
|
||||||
|
roomId: "!example_room_id",
|
||||||
|
slotId: "m.call#ROOM",
|
||||||
|
role: "publisher",
|
||||||
|
logger,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
void (await fetchMock.flush());
|
||||||
|
vitest.clearAllMocks();
|
||||||
|
fetchMock.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle fetching a token", async () => {
|
||||||
|
fetchMock.post("https://sfu.example.org/sfu/get", () => {
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { url: serviceUrl, jwt: testJWTToken },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const config = await getSFUConfig(params);
|
||||||
|
expect(config).toEqual({
|
||||||
|
jwt: testJWTToken,
|
||||||
|
url: serviceUrl,
|
||||||
|
livekitIdentity: "@me:example.org:ABCDEF",
|
||||||
|
livekitAlias: "!example_room_id",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail if the SFU errors", async () => {
|
||||||
|
fetchMock.post("https://sfu.example.org/sfu/get", () => {
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
body: {
|
||||||
|
errcode: "M_LOOKUP_FAILED",
|
||||||
|
error: "Failed to look up user info from homeserver",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await getSFUConfig(params);
|
||||||
|
} catch (ex: unknown) {
|
||||||
|
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
|
||||||
|
expect((ex as FailToGetOpenIdToken).cause).toBeInstanceOf(MatrixError);
|
||||||
|
const mxError = (ex as Error).cause as MatrixError;
|
||||||
|
expect(mxError.message).toEqual(
|
||||||
|
"MatrixError: [500] Failed to look up user info from homeserver",
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect.fail("Expected test to throw;");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should retry without delay params if the JWT service legacy endpoint returns M_BAD_JSON 400", async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
|
||||||
|
fetchMock.post(
|
||||||
|
"https://sfu.example.org/sfu/get",
|
||||||
|
(url, opts) => {
|
||||||
|
callCount++;
|
||||||
|
const body = JSON.parse(opts.body as string);
|
||||||
|
|
||||||
|
// First call: check if it has delay parts and return 400
|
||||||
|
if (callCount === 1) {
|
||||||
|
expect(body).toHaveProperty("delay_id", "mock_delay_id");
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: { errcode: "M_BAD_JSON", error: "Unsupported parameters" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second call: check if delay parts were stripped and return success
|
||||||
|
expect(body).not.toHaveProperty("delay_id");
|
||||||
|
expect(body).not.toHaveProperty("delay_timeout");
|
||||||
|
expect(body).not.toHaveProperty("delay_cs_api_url");
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { url: serviceUrl, jwt: testJWTToken },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ overwriteRoutes: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = await getSFUConfig({ ...params, ...delayParams });
|
||||||
|
|
||||||
|
expect(config.jwt).toBe(testJWTToken);
|
||||||
|
expect(callCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should successfully send delay parameters to the JWT service legacy endpoint", async () => {
|
||||||
|
fetchMock.post(
|
||||||
|
"https://sfu.example.org/sfu/get",
|
||||||
|
(url, opts) => {
|
||||||
|
const body = JSON.parse(opts.body as string);
|
||||||
|
|
||||||
|
// Verify, that the request contains the expected delay parameters
|
||||||
|
if (
|
||||||
|
body.delay_id === "mock_delay_id" &&
|
||||||
|
body.delay_timeout === 3600000 &&
|
||||||
|
body.delay_cs_api_url === "https://matrix.homeserverserver.org"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { url: serviceUrl, jwt: testJWTToken },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: { error: "Missing expected delay params" },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ overwriteRoutes: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = await getSFUConfig({ ...params, ...delayParams });
|
||||||
|
|
||||||
|
expect(config).toMatchObject({
|
||||||
|
jwt: testJWTToken,
|
||||||
|
url: serviceUrl,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use the default endpoint when subscribing", async () => {
|
||||||
|
fetchMock.post("https://sfu.example.org/get_token", (url, opts) => {
|
||||||
|
const body = JSON.parse(opts.body as string);
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
room_id: "!example_room_id",
|
||||||
|
slot_id: "m.call#ROOM",
|
||||||
|
member: {
|
||||||
|
id: ownMemberMock.memberId,
|
||||||
|
claimed_user_id: ownMemberMock.userId,
|
||||||
|
claimed_device_id: ownMemberMock.deviceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { status: 200, body: { url: serviceUrl, jwt: testJWTToken } };
|
||||||
|
});
|
||||||
|
const config = await getSFUConfig({ ...params, role: "subscriber" });
|
||||||
|
expect(config.jwt).toBe(testJWTToken);
|
||||||
|
expect(fetchMock.called("https://sfu.example.org/sfu/get")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail when subscribing if the default endpoint is missing", async () => {
|
||||||
|
// Nothing but the catch-all 404 answers the default endpoint. Subscribers
|
||||||
|
// must not fall back to the legacy endpoint, which would hand them a
|
||||||
|
// legacy identity.
|
||||||
|
await expect(
|
||||||
|
getSFUConfig({ ...params, role: "subscriber" }),
|
||||||
|
).rejects.toThrow(FailToGetOpenIdToken);
|
||||||
|
expect(fetchMock.called("https://sfu.example.org/get_token")).toBe(true);
|
||||||
|
expect(fetchMock.called("https://sfu.example.org/sfu/get")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should retry fetching the openid token", async () => {
|
||||||
|
let count = 0;
|
||||||
|
matrixClient.getOpenIdToken.mockImplementation(async () => {
|
||||||
|
count++;
|
||||||
|
if (count < 2) {
|
||||||
|
throw Error("Test failure");
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
token_type: "Bearer",
|
||||||
|
access_token: "foobar",
|
||||||
|
matrix_server_name: "example.org",
|
||||||
|
expires_in: 30,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
fetchMock.post("https://sfu.example.org/sfu/get", () => {
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { url: serviceUrl, jwt: testJWTToken },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const config = await getSFUConfig({ ...params, ...delayParams });
|
||||||
|
expect(config).toEqual({
|
||||||
|
jwt: testJWTToken,
|
||||||
|
url: serviceUrl,
|
||||||
|
livekitIdentity: "@me:example.org:ABCDEF",
|
||||||
|
livekitAlias: "!example_room_id",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2023, 2024 New Vector Ltd.
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type IOpenIDToken,
|
||||||
|
type MatrixClient,
|
||||||
|
parseErrorResponse,
|
||||||
|
} from "matrix-js-sdk";
|
||||||
|
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||||
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
|
import { FailToGetOpenIdToken } from "../../../errors";
|
||||||
|
import { doNetworkOperationWithRetry } from "../../../utils/network";
|
||||||
|
import {
|
||||||
|
type ConnectionRole,
|
||||||
|
extractFullConfigFromToken,
|
||||||
|
type SFUConfig,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export type ClientOpenIDParts = Pick<MatrixClient, "getOpenIdToken">;
|
||||||
|
|
||||||
|
export interface GetSFUConfigParams {
|
||||||
|
client: ClientOpenIDParts;
|
||||||
|
/** Our own membership identity, as the JWT service wants it. */
|
||||||
|
membership: CallMembershipIdentityParts;
|
||||||
|
/** The base URL of the LiveKit JWT service. */
|
||||||
|
serviceUrl: string;
|
||||||
|
/** The Matrix room the session is in; the JWT service maps it to a LiveKit room. */
|
||||||
|
roomId: string;
|
||||||
|
/** The MatrixRTC slot the session is in, e.g. `m.call#ROOM`. */
|
||||||
|
slotId: string;
|
||||||
|
role: ConnectionRole;
|
||||||
|
/** The base URL of the Matrix homeserver, for delayed leave delegation. */
|
||||||
|
delayEndpointBaseUrl?: string;
|
||||||
|
/** The delay ID of the leave event to delegate to the JWT service. */
|
||||||
|
delayId?: string;
|
||||||
|
/** How long the SFU waits before sending the delegated leave. Needed with `delayId`. */
|
||||||
|
delayTimeoutMs?: number;
|
||||||
|
logger: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates with a LiveKit JWT service through the OpenID flow: an OpenID
|
||||||
|
* token from the homeserver, exchanged at the service for an SFU token.
|
||||||
|
*
|
||||||
|
* A publisher uses the legacy `/sfu/get` endpoint, the only one that assigns
|
||||||
|
* the `<user_id>:<device_id>` identity that clients predating MSC4195 match a
|
||||||
|
* membership against. A subscriber uses the `/get_token` endpoint, which
|
||||||
|
* hashes the identity: nobody matches a subscriber.
|
||||||
|
* @throws {@link FailToGetOpenIdToken}
|
||||||
|
*/
|
||||||
|
export async function getSFUConfig({
|
||||||
|
client,
|
||||||
|
membership,
|
||||||
|
serviceUrl,
|
||||||
|
roomId,
|
||||||
|
slotId,
|
||||||
|
role,
|
||||||
|
delayEndpointBaseUrl,
|
||||||
|
delayId,
|
||||||
|
delayTimeoutMs,
|
||||||
|
logger,
|
||||||
|
}: GetSFUConfigParams): Promise<SFUConfig> {
|
||||||
|
let openIdToken: IOpenIDToken;
|
||||||
|
try {
|
||||||
|
openIdToken = await doNetworkOperationWithRetry(async () =>
|
||||||
|
client.getOpenIdToken(),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
throw new FailToGetOpenIdToken(
|
||||||
|
error instanceof Error ? error : new Error("Unknown error"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.debug("Got openID token", openIdToken);
|
||||||
|
|
||||||
|
const delay: DelayParams =
|
||||||
|
// Also check for empty string
|
||||||
|
delayId && delayEndpointBaseUrl
|
||||||
|
? {
|
||||||
|
delay_id: delayId,
|
||||||
|
delay_timeout: delayTimeoutMs,
|
||||||
|
delay_cs_api_url: delayEndpointBaseUrl,
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
const endpoint = role === "publisher" ? "legacy" : "default";
|
||||||
|
try {
|
||||||
|
logger.info(
|
||||||
|
`Trying to get JWT with ${endpoint} endpoint for focus ${serviceUrl}...`,
|
||||||
|
);
|
||||||
|
const sfuConfig = await (
|
||||||
|
endpoint === "legacy" ? getLiveKitJWTLegacy : getLiveKitJWT
|
||||||
|
)(membership, serviceUrl, roomId, slotId, openIdToken, delay);
|
||||||
|
logger.info(`Got JWT from call's active focus URL.`);
|
||||||
|
return extractFullConfigFromToken(sfuConfig);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Failed fetching jwt with ${endpoint} endpoint:`, e);
|
||||||
|
throw new FailToGetOpenIdToken(
|
||||||
|
e instanceof Error ? e : new Error(`Unknown error ${e}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DelayParams {
|
||||||
|
delay_id?: string;
|
||||||
|
delay_timeout?: number;
|
||||||
|
delay_cs_api_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The legacy `/sfu/get` endpoint, which assigns the `<user_id>:<device_id>` identity. */
|
||||||
|
async function getLiveKitJWTLegacy(
|
||||||
|
membership: CallMembershipIdentityParts,
|
||||||
|
livekitServiceURL: string,
|
||||||
|
matrixRoomId: string,
|
||||||
|
_slotId: string, // The legacy endpoint predates slots
|
||||||
|
openIDToken: IOpenIDToken,
|
||||||
|
delay: DelayParams,
|
||||||
|
): Promise<{ url: string; jwt: string }> {
|
||||||
|
const makeRequest = async (delayParts: DelayParams): Promise<Response> =>
|
||||||
|
await fetch(livekitServiceURL + "/sfu/get", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
// The legacy JWT endpoint uses only the matrix room id to calculate
|
||||||
|
// the livekit room alias, which comes back in the JWT payload
|
||||||
|
room: matrixRoomId,
|
||||||
|
openid_token: openIDToken,
|
||||||
|
device_id: membership.deviceId,
|
||||||
|
...delayParts,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await doNetworkOperationWithRetry(async () => {
|
||||||
|
let response = await makeRequest(delay);
|
||||||
|
// A service from before delegation rejects the delay fields as bad JSON;
|
||||||
|
// it still issues a token without them
|
||||||
|
if (response.status === 400 && Object.keys(delay).length > 0) {
|
||||||
|
try {
|
||||||
|
const errorBody = await response.json();
|
||||||
|
if (errorBody.errcode === "M_BAD_JSON")
|
||||||
|
response = await makeRequest({});
|
||||||
|
} catch {
|
||||||
|
// Not a JSON error body, so a real error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw parseErrorResponse(res, await res.text());
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotSupportedError extends Error {
|
||||||
|
public constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "NotSupported";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `/get_token` endpoint, which assigns a hashed identity. */
|
||||||
|
async function getLiveKitJWT(
|
||||||
|
membership: CallMembershipIdentityParts,
|
||||||
|
livekitServiceURL: string,
|
||||||
|
matrixRoomId: string,
|
||||||
|
slotId: string,
|
||||||
|
openIDToken: IOpenIDToken,
|
||||||
|
delay: DelayParams,
|
||||||
|
): Promise<{ url: string; jwt: string }> {
|
||||||
|
const { userId, deviceId, memberId } = membership;
|
||||||
|
const res = await doNetworkOperationWithRetry(
|
||||||
|
async () =>
|
||||||
|
await fetch(livekitServiceURL + "/get_token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
room_id: matrixRoomId,
|
||||||
|
slot_id: slotId,
|
||||||
|
openid_token: openIDToken,
|
||||||
|
member: {
|
||||||
|
id: memberId,
|
||||||
|
claimed_user_id: userId,
|
||||||
|
claimed_device_id: deviceId,
|
||||||
|
},
|
||||||
|
...delay,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = "SFU Config fetch failed with status code " + res.status;
|
||||||
|
if (res.status === 404) throw new NotSupportedError(msg);
|
||||||
|
throw parseErrorResponse(res, await res.text());
|
||||||
|
}
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: 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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A transport and the homeserver it belongs to. The server name is what our
|
||||||
|
* homeserver asks for a token for a transport with a `url`, over federation
|
||||||
|
* where the server is not ours; a JWT service is reached directly.
|
||||||
|
*/
|
||||||
|
export interface TransportLocator {
|
||||||
|
transport: UnstableLivekitTransport;
|
||||||
|
serverName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether we publish on a transport or only subscribe to it. */
|
||||||
|
export type ConnectionRole = "publisher" | "subscriber";
|
||||||
|
|
||||||
|
/** What authenticating with a transport yields: where to connect and with what. */
|
||||||
|
export interface SFUConfig {
|
||||||
|
/**
|
||||||
|
* The WebSocket URL of the SFU, as opposed to the JWT service URL in the
|
||||||
|
* transport. The legacy JWT service returns it; the client-server API flow
|
||||||
|
* has it from the transport.
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
jwt: string;
|
||||||
|
livekitAlias: string;
|
||||||
|
livekitIdentity: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The parts of the JWT payload the backend reads. */
|
||||||
|
interface SFUJWTPayload {
|
||||||
|
/** The subject: the participant identity the token carries. */
|
||||||
|
sub: string;
|
||||||
|
video: {
|
||||||
|
room: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Completes the SFU websocket url and the JWT with what the JWT payload says. */
|
||||||
|
export function extractFullConfigFromToken(sfuConfig: {
|
||||||
|
url: string;
|
||||||
|
jwt: string;
|
||||||
|
}): SFUConfig {
|
||||||
|
const [, payloadStr] = sfuConfig.jwt.split(".");
|
||||||
|
const payload = JSON.parse(global.atob(payloadStr)) as SFUJWTPayload;
|
||||||
|
return {
|
||||||
|
jwt: sfuConfig.jwt,
|
||||||
|
url: sfuConfig.url,
|
||||||
|
livekitAlias: payload.video.room,
|
||||||
|
livekitIdentity: payload.sub,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ConnectionState as LivekitConnectionState } from "livekit-client";
|
||||||
|
|
||||||
|
import { MediaConnectionState } from "../api";
|
||||||
|
import { type ConnectionState } from "./Connection";
|
||||||
|
|
||||||
|
const livekitStates: Record<LivekitConnectionState, MediaConnectionState> = {
|
||||||
|
[LivekitConnectionState.Disconnected]: MediaConnectionState.Disconnected,
|
||||||
|
[LivekitConnectionState.Connecting]: MediaConnectionState.Connecting,
|
||||||
|
[LivekitConnectionState.Connected]: MediaConnectionState.Connected,
|
||||||
|
[LivekitConnectionState.Reconnecting]: MediaConnectionState.Reconnecting,
|
||||||
|
[LivekitConnectionState.SignalReconnecting]:
|
||||||
|
MediaConnectionState.Reconnecting,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A connection's state as the session sees it; an error passes through. */
|
||||||
|
export function toMediaConnectionState(
|
||||||
|
state: ConnectionState,
|
||||||
|
): MediaConnectionState | Error {
|
||||||
|
switch (state.state) {
|
||||||
|
case "initialized":
|
||||||
|
return MediaConnectionState.Initialized;
|
||||||
|
case "authenticating":
|
||||||
|
return MediaConnectionState.Preparing;
|
||||||
|
case "authenticated":
|
||||||
|
return livekitStates[state.livekitState];
|
||||||
|
case "error":
|
||||||
|
return state.error;
|
||||||
|
case "stopped":
|
||||||
|
return MediaConnectionState.Stopped;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { VideoPresets, type VideoPreset } from "livekit-client";
|
||||||
|
|
||||||
|
import { buildLiveKitOptions } from "./livekitOptions";
|
||||||
|
|
||||||
|
describe("buildLiveKitOptions", () => {
|
||||||
|
it("returns sensible defaults with no config", () => {
|
||||||
|
const opts = buildLiveKitOptions();
|
||||||
|
expect(opts.adaptiveStream).toBe(true);
|
||||||
|
expect(opts.dynacast).toBe(true);
|
||||||
|
expect(opts.videoCaptureDefaults?.resolution).toEqual(
|
||||||
|
VideoPresets.h720.resolution,
|
||||||
|
);
|
||||||
|
expect(opts.publishDefaults?.videoCodec).toBe("vp8");
|
||||||
|
expect(opts.publishDefaults?.videoEncoding).toEqual({
|
||||||
|
maxBitrate: 1_700_000,
|
||||||
|
maxFramerate: 30,
|
||||||
|
});
|
||||||
|
expect(opts.publishDefaults?.screenShareEncoding).toEqual({
|
||||||
|
maxBitrate: 5_000_000,
|
||||||
|
maxFramerate: 30,
|
||||||
|
});
|
||||||
|
expect(opts.publishDefaults?.videoSimulcastLayers).toEqual([
|
||||||
|
VideoPresets.h180,
|
||||||
|
VideoPresets.h360,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies video codec from config", () => {
|
||||||
|
const opts = buildLiveKitOptions({ video_codec: "vp9" });
|
||||||
|
expect(opts.publishDefaults?.videoCodec).toBe("vp9");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies video resolution and encoding from config", () => {
|
||||||
|
const baseVideoConfig = {
|
||||||
|
max_resolution: 1080,
|
||||||
|
max_bitrate: 3_000_000,
|
||||||
|
max_framerate: 60,
|
||||||
|
};
|
||||||
|
const opts1080 = buildLiveKitOptions({
|
||||||
|
video: baseVideoConfig,
|
||||||
|
});
|
||||||
|
const opts1440 = buildLiveKitOptions({
|
||||||
|
video: { ...baseVideoConfig, max_resolution: 1440 },
|
||||||
|
});
|
||||||
|
const opts2160 = buildLiveKitOptions({
|
||||||
|
video: { ...baseVideoConfig, max_resolution: 2160 },
|
||||||
|
});
|
||||||
|
expect(opts1080.videoCaptureDefaults?.resolution).toEqual(
|
||||||
|
VideoPresets.h1080.resolution,
|
||||||
|
);
|
||||||
|
expect(opts1440.videoCaptureDefaults?.resolution).toEqual(
|
||||||
|
VideoPresets.h1440.resolution,
|
||||||
|
);
|
||||||
|
expect(opts2160.videoCaptureDefaults?.resolution).toEqual(
|
||||||
|
VideoPresets.h2160.resolution,
|
||||||
|
);
|
||||||
|
expect(opts1080.publishDefaults?.videoEncoding).toEqual({
|
||||||
|
maxBitrate: 3_000_000,
|
||||||
|
maxFramerate: 60,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies screen share encoding from config", () => {
|
||||||
|
const opts = buildLiveKitOptions({
|
||||||
|
screen_share: {
|
||||||
|
max_bitrate: 8_000_000,
|
||||||
|
max_framerate: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(opts.publishDefaults?.screenShareEncoding).toEqual({
|
||||||
|
maxBitrate: 8_000_000,
|
||||||
|
maxFramerate: 15,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses DEFAULT_CONFIG defaults when only resolution is set", () => {
|
||||||
|
const opts = buildLiveKitOptions({
|
||||||
|
screen_share: {
|
||||||
|
max_resolution: 720,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Bitrate and framerate fall back to DEFAULT_CONFIG, not the preset
|
||||||
|
expect(opts.publishDefaults?.screenShareEncoding).toEqual({
|
||||||
|
maxBitrate: 5_000_000,
|
||||||
|
maxFramerate: 30,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps low resolutions to the closest preset, rounding up", () => {
|
||||||
|
const expectations: [number, VideoPreset][] = [
|
||||||
|
[180, VideoPresets.h180],
|
||||||
|
[360, VideoPresets.h360],
|
||||||
|
[480, VideoPresets.h540],
|
||||||
|
[540, VideoPresets.h540],
|
||||||
|
[720, VideoPresets.h720],
|
||||||
|
];
|
||||||
|
for (const [height, preset] of expectations) {
|
||||||
|
const opts = buildLiveKitOptions({ video: { max_resolution: height } });
|
||||||
|
expect(opts.videoCaptureDefaults?.resolution).toEqual(preset.resolution);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("screen share layers fall back to max_framerate, then 30", () => {
|
||||||
|
const fromMax = buildLiveKitOptions({
|
||||||
|
screen_share: {
|
||||||
|
max_framerate: 15,
|
||||||
|
simulcast_layers: [{ height: 540, bitrate: 1_000_000 }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
fromMax.publishDefaults?.screenShareSimulcastLayers?.[0],
|
||||||
|
).toMatchObject({ encoding: { maxFramerate: 15 } });
|
||||||
|
|
||||||
|
const fromDefault = buildLiveKitOptions({
|
||||||
|
screen_share: {
|
||||||
|
simulcast_layers: [{ height: 540, bitrate: 1_000_000 }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
fromDefault.publishDefaults?.screenShareSimulcastLayers?.[0],
|
||||||
|
).toMatchObject({ encoding: { maxFramerate: 30 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies custom video simulcast layers", () => {
|
||||||
|
const opts = buildLiveKitOptions({
|
||||||
|
video: {
|
||||||
|
simulcast_layers: [
|
||||||
|
{ height: 180, bitrate: 100_000 },
|
||||||
|
{ height: 360, bitrate: 300_000 },
|
||||||
|
{ height: 540, bitrate: 600_000 },
|
||||||
|
],
|
||||||
|
max_framerate: 24,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const layers = opts.publishDefaults?.videoSimulcastLayers;
|
||||||
|
expect(layers).toHaveLength(3);
|
||||||
|
expect(layers?.[0]).toMatchObject({
|
||||||
|
width: 320,
|
||||||
|
height: 180,
|
||||||
|
encoding: { maxBitrate: 100_000, maxFramerate: 24 },
|
||||||
|
});
|
||||||
|
expect(layers?.[2]).toMatchObject({
|
||||||
|
width: 960,
|
||||||
|
height: 540,
|
||||||
|
encoding: { maxBitrate: 600_000, maxFramerate: 24 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies custom screen share simulcast layers", () => {
|
||||||
|
const opts = buildLiveKitOptions({
|
||||||
|
screen_share: {
|
||||||
|
simulcast_layers: [{ height: 540, bitrate: 1_000_000, framerate: 5 }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const layers = opts.publishDefaults?.screenShareSimulcastLayers;
|
||||||
|
expect(layers).toHaveLength(1);
|
||||||
|
expect(layers?.[0]).toMatchObject({
|
||||||
|
width: 960,
|
||||||
|
height: 540,
|
||||||
|
encoding: { maxBitrate: 1_000_000, maxFramerate: 5 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not include screenShareSimulcastLayers when not configured", () => {
|
||||||
|
const opts = buildLiveKitOptions();
|
||||||
|
expect(opts.publishDefaults?.screenShareSimulcastLayers).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("backupCodec always uses stock VP8 720p encoding", () => {
|
||||||
|
const opts = buildLiveKitOptions({
|
||||||
|
video_codec: "av1",
|
||||||
|
video: { max_bitrate: 10_000_000, max_framerate: 60 },
|
||||||
|
});
|
||||||
|
const backup = opts.publishDefaults?.backupCodec as {
|
||||||
|
codec: string;
|
||||||
|
encoding: { maxBitrate: number; maxFramerate: number };
|
||||||
|
};
|
||||||
|
expect(backup.codec).toBe("vp8");
|
||||||
|
expect(backup.encoding).toEqual(VideoPresets.h720.encoding);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2023, 2024 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 {
|
||||||
|
AudioPresets,
|
||||||
|
DefaultReconnectPolicy,
|
||||||
|
type RoomOptions,
|
||||||
|
type TrackPublishDefaults,
|
||||||
|
type VideoPreset,
|
||||||
|
VideoPresets,
|
||||||
|
VideoPreset as VideoPresetClass,
|
||||||
|
} from "livekit-client";
|
||||||
|
|
||||||
|
import { defaultMediaQuality, type MediaQuality } from "../../config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the closest matching VideoPreset for a given height.
|
||||||
|
*/
|
||||||
|
function videoPresetForHeight(height: number): VideoPreset {
|
||||||
|
if (height <= 180) return VideoPresets.h180;
|
||||||
|
if (height <= 360) return VideoPresets.h360;
|
||||||
|
if (height <= 540) return VideoPresets.h540;
|
||||||
|
if (height <= 720) return VideoPresets.h720;
|
||||||
|
if (height <= 1080) return VideoPresets.h1080;
|
||||||
|
if (height <= 1440) return VideoPresets.h1440;
|
||||||
|
return VideoPresets.h2160;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build LiveKit publish options from config, falling back to sensible defaults.
|
||||||
|
*/
|
||||||
|
function buildPublishOptions(
|
||||||
|
mediaQuality: MediaQuality | undefined,
|
||||||
|
): TrackPublishDefaults {
|
||||||
|
const defaults = defaultMediaQuality;
|
||||||
|
const videoConf = mediaQuality?.video;
|
||||||
|
const screenConf = mediaQuality?.screen_share;
|
||||||
|
const codec = mediaQuality?.video_codec ?? defaults.video_codec;
|
||||||
|
|
||||||
|
// Camera video encoding
|
||||||
|
const videoEncoding = {
|
||||||
|
maxBitrate: videoConf?.max_bitrate ?? defaults.video.max_bitrate,
|
||||||
|
maxFramerate: videoConf?.max_framerate ?? defaults.video.max_framerate,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Camera simulcast layers
|
||||||
|
let videoSimulcastLayers: VideoPreset[];
|
||||||
|
if (videoConf?.simulcast_layers) {
|
||||||
|
videoSimulcastLayers = videoConf.simulcast_layers.map(
|
||||||
|
(layer) =>
|
||||||
|
new VideoPresetClass(
|
||||||
|
Math.round((layer.height * 16) / 9),
|
||||||
|
layer.height,
|
||||||
|
layer.bitrate,
|
||||||
|
videoConf?.max_framerate ?? defaults.video.max_framerate,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
videoSimulcastLayers = [VideoPresets.h180, VideoPresets.h360];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screen share encoding
|
||||||
|
const screenShareEncoding = {
|
||||||
|
maxBitrate: screenConf?.max_bitrate ?? defaults.screen_share.max_bitrate,
|
||||||
|
maxFramerate:
|
||||||
|
screenConf?.max_framerate ?? defaults.screen_share.max_framerate,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Screen share simulcast layers
|
||||||
|
let screenShareSimulcastLayers: VideoPreset[] | undefined;
|
||||||
|
if (screenConf?.simulcast_layers) {
|
||||||
|
screenShareSimulcastLayers = screenConf.simulcast_layers.map(
|
||||||
|
(layer) =>
|
||||||
|
new VideoPresetClass(
|
||||||
|
Math.round((layer.height * 16) / 9),
|
||||||
|
layer.height,
|
||||||
|
layer.bitrate,
|
||||||
|
layer.framerate ?? screenConf?.max_framerate ?? 30,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioPreset: AudioPresets.music,
|
||||||
|
dtx: true,
|
||||||
|
// disable red because the livekit server strips out red packets for clients
|
||||||
|
// that don't support it (firefox) but of course that doesn't work with e2ee.
|
||||||
|
red: false,
|
||||||
|
forceStereo: false,
|
||||||
|
simulcast: true,
|
||||||
|
videoSimulcastLayers: videoSimulcastLayers as VideoPreset[],
|
||||||
|
screenShareEncoding,
|
||||||
|
...(screenShareSimulcastLayers && {
|
||||||
|
screenShareSimulcastLayers: screenShareSimulcastLayers as VideoPreset[],
|
||||||
|
}),
|
||||||
|
stopMicTrackOnMute: false,
|
||||||
|
videoCodec: codec,
|
||||||
|
videoEncoding,
|
||||||
|
backupCodec: {
|
||||||
|
codec: "vp8",
|
||||||
|
encoding: VideoPresets.h720.encoding,
|
||||||
|
},
|
||||||
|
} as TrackPublishDefaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build LiveKit RoomOptions from the media quality a host asks for.
|
||||||
|
*/
|
||||||
|
export function buildLiveKitOptions(mediaQuality?: MediaQuality): RoomOptions {
|
||||||
|
const videoHeight =
|
||||||
|
mediaQuality?.video?.max_resolution ??
|
||||||
|
defaultMediaQuality.video.max_resolution;
|
||||||
|
const basePreset = videoPresetForHeight(videoHeight);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// automatically manage subscribed video quality
|
||||||
|
adaptiveStream: true,
|
||||||
|
|
||||||
|
// optimize publishing bandwidth and CPU for published tracks
|
||||||
|
dynacast: true,
|
||||||
|
|
||||||
|
// capture settings
|
||||||
|
videoCaptureDefaults: {
|
||||||
|
resolution: basePreset.resolution,
|
||||||
|
},
|
||||||
|
|
||||||
|
// publish settings
|
||||||
|
publishDefaults: buildPublishOptions(mediaQuality),
|
||||||
|
|
||||||
|
// default LiveKit options that seem to be sane
|
||||||
|
stopLocalTrackOnUnpublish: true,
|
||||||
|
reconnectPolicy: new DefaultReconnectPolicy(),
|
||||||
|
disconnectOnPageLeave: true,
|
||||||
|
webAudioMix: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
audioCaptureOptions,
|
||||||
|
screenShareCaptureOptions,
|
||||||
|
screenSharePublishOptions,
|
||||||
|
videoCaptureOptions,
|
||||||
|
videoPublishOptions,
|
||||||
|
} from "./publishOptions";
|
||||||
|
|
||||||
|
describe("audioCaptureOptions", () => {
|
||||||
|
it("leaves a bare request to the room defaults", () => {
|
||||||
|
expect(audioCaptureOptions({ source: "microphone" })).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the device and the processing flags", () => {
|
||||||
|
expect(
|
||||||
|
audioCaptureOptions({
|
||||||
|
source: "microphone",
|
||||||
|
deviceId: "mic-1",
|
||||||
|
capture: { echoCancellation: false, noiseSuppression: true },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
deviceId: "mic-1",
|
||||||
|
echoCancellation: false,
|
||||||
|
noiseSuppression: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops an empty device id", () => {
|
||||||
|
expect(
|
||||||
|
audioCaptureOptions({
|
||||||
|
source: "microphone",
|
||||||
|
deviceId: "",
|
||||||
|
capture: { autoGainControl: false },
|
||||||
|
}),
|
||||||
|
).toEqual({ autoGainControl: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("videoCaptureOptions", () => {
|
||||||
|
it("leaves a request without device, processor or resolution to the room defaults", () => {
|
||||||
|
expect(
|
||||||
|
videoCaptureOptions({
|
||||||
|
source: "camera",
|
||||||
|
capture: { maxBitrate: 1_000_000 },
|
||||||
|
}),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the device and the resolution, but not the encoding", () => {
|
||||||
|
expect(
|
||||||
|
videoCaptureOptions({
|
||||||
|
source: "camera",
|
||||||
|
deviceId: "cam-1",
|
||||||
|
capture: {
|
||||||
|
resolution: { width: 1920, height: 1080, frameRate: 60 },
|
||||||
|
maxBitrate: 4_000_000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
deviceId: "cam-1",
|
||||||
|
resolution: { width: 1920, height: 1080, frameRate: 60 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("videoPublishOptions", () => {
|
||||||
|
it("is nothing without capture settings", () => {
|
||||||
|
expect(videoPublishOptions(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is empty for settings that only set the resolution", () => {
|
||||||
|
expect(
|
||||||
|
videoPublishOptions({ resolution: { width: 640, height: 360 } }),
|
||||||
|
).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps the bitrate, framerate and codec to the encoding", () => {
|
||||||
|
expect(
|
||||||
|
videoPublishOptions({
|
||||||
|
maxBitrate: 4_000_000,
|
||||||
|
maxFramerate: 60,
|
||||||
|
codec: "vp9",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
videoEncoding: { maxBitrate: 4_000_000, maxFramerate: 60 },
|
||||||
|
videoCodec: "vp9",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("screenShareCaptureOptions", () => {
|
||||||
|
it("captures the audio without processing by default", () => {
|
||||||
|
expect(screenShareCaptureOptions({ source: "screenShare" })).toEqual({
|
||||||
|
audio: {
|
||||||
|
autoGainControl: false,
|
||||||
|
noiseSuppression: false,
|
||||||
|
voiceIsolation: false,
|
||||||
|
},
|
||||||
|
selfBrowserSurface: "include",
|
||||||
|
surfaceSwitching: "include",
|
||||||
|
systemAudio: "include",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the audio out on request and carries the resolution", () => {
|
||||||
|
expect(
|
||||||
|
screenShareCaptureOptions({
|
||||||
|
source: "screenShare",
|
||||||
|
audio: false,
|
||||||
|
capture: { resolution: { width: 1280, height: 720 } },
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
audio: false,
|
||||||
|
resolution: { width: 1280, height: 720 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("screenSharePublishOptions", () => {
|
||||||
|
it("is nothing without capture settings", () => {
|
||||||
|
expect(screenSharePublishOptions(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps the bitrate, framerate and codec to the screen share encoding", () => {
|
||||||
|
expect(
|
||||||
|
screenSharePublishOptions({
|
||||||
|
maxBitrate: 5_000_000,
|
||||||
|
maxFramerate: 15,
|
||||||
|
codec: "av1",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
screenShareEncoding: { maxBitrate: 5_000_000, maxFramerate: 15 },
|
||||||
|
videoCodec: "av1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AudioCaptureOptions,
|
||||||
|
type ScreenShareCaptureOptions,
|
||||||
|
type TrackPublishOptions,
|
||||||
|
type VideoCaptureOptions,
|
||||||
|
} from "livekit-client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type PublishRequest,
|
||||||
|
type VideoCaptureSettings,
|
||||||
|
} from "../../media-api";
|
||||||
|
import { convertToLivekitProcessor } from "./videoProcessor";
|
||||||
|
|
||||||
|
export type PublishRequestFor<S extends PublishRequest["source"]> = Extract<
|
||||||
|
PublishRequest,
|
||||||
|
{ source: S }
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** LiveKit fills in the room defaults for whatever a request leaves out. */
|
||||||
|
export function audioCaptureOptions({
|
||||||
|
deviceId,
|
||||||
|
capture,
|
||||||
|
}: PublishRequestFor<"microphone">): AudioCaptureOptions | undefined {
|
||||||
|
if (deviceId === undefined && capture === undefined) return undefined;
|
||||||
|
return { ...(deviceId && { deviceId }), ...capture };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function videoCaptureOptions({
|
||||||
|
deviceId,
|
||||||
|
processor,
|
||||||
|
capture,
|
||||||
|
}: PublishRequestFor<"camera">): VideoCaptureOptions | undefined {
|
||||||
|
if (!deviceId && !processor && !capture?.resolution) return undefined;
|
||||||
|
return {
|
||||||
|
...(deviceId && { deviceId }),
|
||||||
|
...(processor && { processor: convertToLivekitProcessor(processor) }),
|
||||||
|
...(capture?.resolution && { resolution: capture.resolution }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function videoPublishOptions(
|
||||||
|
settings: VideoCaptureSettings | undefined,
|
||||||
|
): TrackPublishOptions | undefined {
|
||||||
|
if (settings === undefined) return undefined;
|
||||||
|
return {
|
||||||
|
...(settings.maxBitrate !== undefined && {
|
||||||
|
videoEncoding: {
|
||||||
|
maxBitrate: settings.maxBitrate,
|
||||||
|
maxFramerate: settings.maxFramerate,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(settings.codec && { videoCodec: settings.codec }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function screenShareCaptureOptions({
|
||||||
|
audio = true,
|
||||||
|
capture,
|
||||||
|
}: PublishRequestFor<"screenShare">): ScreenShareCaptureOptions {
|
||||||
|
return {
|
||||||
|
// No echo cancellation: it would cancel the other members' voices out of
|
||||||
|
// the shared audio
|
||||||
|
audio: audio && {
|
||||||
|
autoGainControl: false,
|
||||||
|
noiseSuppression: false,
|
||||||
|
voiceIsolation: false,
|
||||||
|
},
|
||||||
|
selfBrowserSurface: "include",
|
||||||
|
surfaceSwitching: "include",
|
||||||
|
systemAudio: "include",
|
||||||
|
...(capture?.resolution && { resolution: capture.resolution }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function screenSharePublishOptions(
|
||||||
|
settings: VideoCaptureSettings | undefined,
|
||||||
|
): TrackPublishOptions | undefined {
|
||||||
|
if (settings === undefined) return undefined;
|
||||||
|
return {
|
||||||
|
...(settings.maxBitrate !== undefined && {
|
||||||
|
screenShareEncoding: {
|
||||||
|
maxBitrate: settings.maxBitrate,
|
||||||
|
maxFramerate: settings.maxFramerate,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(settings.codec && { videoCodec: settings.codec }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** LiveKit test doubles shared by the backend's unit tests. Not part of the package. */
|
||||||
|
|
||||||
|
import {
|
||||||
|
type RemoteParticipant,
|
||||||
|
type RemoteTrackPublication,
|
||||||
|
} from "livekit-client";
|
||||||
|
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
|
||||||
|
import { mockEmitter } from "../../utils/test";
|
||||||
|
|
||||||
|
export const exampleServiceUrl = "https://lk.example.org";
|
||||||
|
|
||||||
|
export const exampleTransport: UnstableLivekitTransport = {
|
||||||
|
type: "livekit",
|
||||||
|
livekit_service_url: exampleServiceUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mockRemoteParticipant(
|
||||||
|
participant: Partial<RemoteParticipant>,
|
||||||
|
): RemoteParticipant {
|
||||||
|
return {
|
||||||
|
isLocal: false,
|
||||||
|
setVolume() {},
|
||||||
|
getTrackPublication: () =>
|
||||||
|
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
|
||||||
|
// this will only get used for `getTrackPublications().length`
|
||||||
|
getTrackPublications: () => [0],
|
||||||
|
...mockEmitter(),
|
||||||
|
...participant,
|
||||||
|
} as RemoteParticipant;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Track, type VideoProcessorOptions } from "livekit-client";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { type VideoProcessor } from "../../media-api";
|
||||||
|
import { convertToLivekitProcessor } from "./videoProcessor";
|
||||||
|
|
||||||
|
describe("convertToLivekitProcessor", () => {
|
||||||
|
const captured = { id: "captured" } as MediaStreamTrack;
|
||||||
|
const processed = { id: "processed" } as MediaStreamTrack;
|
||||||
|
const restarted = { id: "restarted" } as MediaStreamTrack;
|
||||||
|
const element = document.createElement("video");
|
||||||
|
const options: VideoProcessorOptions = {
|
||||||
|
kind: Track.Kind.Video,
|
||||||
|
track: captured,
|
||||||
|
element,
|
||||||
|
};
|
||||||
|
|
||||||
|
function fakeProcessor(): VideoProcessor {
|
||||||
|
return {
|
||||||
|
name: "blur",
|
||||||
|
init: vi.fn().mockResolvedValue(processed),
|
||||||
|
restart: vi.fn().mockResolvedValue(restarted),
|
||||||
|
destroy: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("holds the processed track where LiveKit reads it", async () => {
|
||||||
|
const processor = fakeProcessor();
|
||||||
|
const adapter = convertToLivekitProcessor(processor);
|
||||||
|
expect(adapter.name).toBe("blur");
|
||||||
|
expect(adapter.processedTrack).toBeUndefined();
|
||||||
|
|
||||||
|
await adapter.init(options);
|
||||||
|
expect(processor.init).toHaveBeenCalledWith(captured, element);
|
||||||
|
expect(adapter.processedTrack).toBe(processed);
|
||||||
|
|
||||||
|
await adapter.restart(options);
|
||||||
|
expect(adapter.processedTrack).toBe(restarted);
|
||||||
|
|
||||||
|
await adapter.destroy();
|
||||||
|
expect(processor.destroy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type Track, type TrackProcessor } from "livekit-client";
|
||||||
|
|
||||||
|
import { type VideoProcessor } from "../../media-api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `VideoProcessor` as livekit-client wants it. LiveKit reads the processed
|
||||||
|
* track off the processor after `init` and `restart` rather than taking it as
|
||||||
|
* their result, so the adapter keeps it there.
|
||||||
|
*/
|
||||||
|
export function convertToLivekitProcessor(
|
||||||
|
processor: VideoProcessor,
|
||||||
|
): TrackProcessor<Track.Kind.Video> {
|
||||||
|
const adapter: TrackProcessor<Track.Kind.Video> = {
|
||||||
|
name: processor.name,
|
||||||
|
init: async (opts) => {
|
||||||
|
adapter.processedTrack = await processor.init(opts.track, opts.element);
|
||||||
|
},
|
||||||
|
restart: async (opts) => {
|
||||||
|
adapter.processedTrack = await processor.restart(
|
||||||
|
opts.track,
|
||||||
|
opts.element,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
destroy: async () => processor.destroy(),
|
||||||
|
};
|
||||||
|
return adapter;
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { MatrixRTCError, type ParticipationState } from "./api";
|
||||||
|
import { type MembershipState } from "./matrixrtc/LocalMembership";
|
||||||
|
import { MediaConnectionState } from "./media-backend/api";
|
||||||
|
import { participationState } from "./participationState";
|
||||||
|
|
||||||
|
const error = new MatrixRTCError("gone");
|
||||||
|
const waiting: ParticipationState = { kind: "waitingForTransport" };
|
||||||
|
const connecting: ParticipationState = { kind: "connecting" };
|
||||||
|
const connected: ParticipationState = { kind: "connected" };
|
||||||
|
const reconnecting: ParticipationState = {
|
||||||
|
kind: "reconnecting",
|
||||||
|
reason: "media",
|
||||||
|
};
|
||||||
|
const joined: MembershipState = { kind: "joined" };
|
||||||
|
|
||||||
|
describe("participationState", () => {
|
||||||
|
it.each<
|
||||||
|
[
|
||||||
|
string,
|
||||||
|
ParticipationState,
|
||||||
|
MembershipState,
|
||||||
|
MediaConnectionState | Error,
|
||||||
|
ParticipationState,
|
||||||
|
]
|
||||||
|
>([
|
||||||
|
[
|
||||||
|
"no transport yet",
|
||||||
|
waiting,
|
||||||
|
{ kind: "waitingForTransport" },
|
||||||
|
MediaConnectionState.Initialized,
|
||||||
|
waiting,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"joining",
|
||||||
|
waiting,
|
||||||
|
{ kind: "joining" },
|
||||||
|
MediaConnectionState.Connecting,
|
||||||
|
connecting,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"joined, media still connecting",
|
||||||
|
connecting,
|
||||||
|
joined,
|
||||||
|
MediaConnectionState.Connecting,
|
||||||
|
connecting,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"both links up",
|
||||||
|
connecting,
|
||||||
|
joined,
|
||||||
|
MediaConnectionState.Connected,
|
||||||
|
connected,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"media dropped after connecting",
|
||||||
|
connected,
|
||||||
|
joined,
|
||||||
|
MediaConnectionState.Reconnecting,
|
||||||
|
reconnecting,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"homeserver dropped after connecting",
|
||||||
|
connected,
|
||||||
|
{ kind: "reconnecting", reason: "sync" },
|
||||||
|
MediaConnectionState.Connected,
|
||||||
|
{ kind: "reconnecting", reason: "sync" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"the matrix side wins over the media side",
|
||||||
|
reconnecting,
|
||||||
|
{ kind: "reconnecting", reason: "membership" },
|
||||||
|
MediaConnectionState.Reconnecting,
|
||||||
|
{ kind: "reconnecting", reason: "membership" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"membership reconnecting before the media ever came up",
|
||||||
|
connecting,
|
||||||
|
{ kind: "reconnecting", reason: "sync" },
|
||||||
|
MediaConnectionState.Connecting,
|
||||||
|
connecting,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"back from a reconnect",
|
||||||
|
reconnecting,
|
||||||
|
joined,
|
||||||
|
MediaConnectionState.Connected,
|
||||||
|
connected,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a membership error",
|
||||||
|
connected,
|
||||||
|
{ kind: "failed", error },
|
||||||
|
MediaConnectionState.Connected,
|
||||||
|
{ kind: "failed", error },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a media connection error",
|
||||||
|
connected,
|
||||||
|
joined,
|
||||||
|
error,
|
||||||
|
{ kind: "failed", error },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"failed stays failed",
|
||||||
|
{ kind: "failed", error },
|
||||||
|
joined,
|
||||||
|
MediaConnectionState.Connected,
|
||||||
|
{ kind: "failed", error },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"left",
|
||||||
|
connected,
|
||||||
|
{ kind: "left" },
|
||||||
|
MediaConnectionState.Stopped,
|
||||||
|
{ kind: "left" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"left after an error",
|
||||||
|
{ kind: "failed", error },
|
||||||
|
{ kind: "left" },
|
||||||
|
MediaConnectionState.Stopped,
|
||||||
|
{ kind: "left" },
|
||||||
|
],
|
||||||
|
])("%s", (_name, previous, membership, media, expected) => {
|
||||||
|
expect(participationState(previous, membership, media)).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps a plain media error", () => {
|
||||||
|
const state = participationState(connected, joined, new Error("boom"));
|
||||||
|
expect(state.kind).toBe("failed");
|
||||||
|
if (state.kind === "failed") {
|
||||||
|
expect(state.error).toBeInstanceOf(MatrixRTCError);
|
||||||
|
expect(state.error.cause).toBeInstanceOf(Error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type ParticipationState } from "./api";
|
||||||
|
import { toMatrixRTCError } from "./errors";
|
||||||
|
import { type MembershipState } from "./matrixrtc/LocalMembership";
|
||||||
|
import { MediaConnectionState } from "./media-backend/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The membership combined with the local media connection, in priority
|
||||||
|
* order; `left` and `failed` are absorbing. "Connected once" is tracked here
|
||||||
|
* rather than in the membership because `connected` needs both links.
|
||||||
|
*/
|
||||||
|
export function participationState(
|
||||||
|
previous: ParticipationState,
|
||||||
|
membership: MembershipState,
|
||||||
|
media: MediaConnectionState | Error,
|
||||||
|
): ParticipationState {
|
||||||
|
if (membership.kind === "left") return { kind: "left" };
|
||||||
|
if (previous.kind === "failed") return previous;
|
||||||
|
if (membership.kind === "failed") return membership;
|
||||||
|
if (media instanceof Error)
|
||||||
|
return { kind: "failed", error: toMatrixRTCError(media) };
|
||||||
|
if (membership.kind === "waitingForTransport") return membership;
|
||||||
|
if (membership.kind === "joining") return { kind: "connecting" };
|
||||||
|
const reason =
|
||||||
|
membership.kind === "reconnecting"
|
||||||
|
? membership.reason
|
||||||
|
: media === MediaConnectionState.Connected
|
||||||
|
? null
|
||||||
|
: "media";
|
||||||
|
if (reason === null) return { kind: "connected" };
|
||||||
|
const wasConnected =
|
||||||
|
previous.kind === "connected" || previous.kind === "reconnecting";
|
||||||
|
return wasConnected
|
||||||
|
? { kind: "reconnecting", reason }
|
||||||
|
: { kind: "connecting" };
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
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 { BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stateful, read-only reactive value. As an Observable, it is "hot" and
|
||||||
|
* always replays the current value upon subscription.
|
||||||
|
*
|
||||||
|
* A Behavior is to BehaviorSubject what Observable is to Subject; it does not
|
||||||
|
* provide a way to imperatively set new values. For more info on the
|
||||||
|
* distinction between Behaviors and Observables, see
|
||||||
|
* https://monoid.dk/post/behaviors-and-streams-why-both/.
|
||||||
|
*/
|
||||||
|
export type Behavior<T> = Omit<
|
||||||
|
BehaviorSubject<T>,
|
||||||
|
"next" | "observers" | "error"
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Behavior which never changes in value.
|
||||||
|
*/
|
||||||
|
export function constant<T>(value: T): Behavior<T> {
|
||||||
|
return new BehaviorSubject(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { BehaviorSubject, combineLatest, Subject } from "rxjs";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import { sleep } from "matrix-js-sdk/lib/utils";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Epoch,
|
||||||
|
mapEpoch,
|
||||||
|
ObservableScope,
|
||||||
|
trackEpoch,
|
||||||
|
} from "./ObservableScope";
|
||||||
|
import { testScope, withTestScheduler } from "../utils/test";
|
||||||
|
|
||||||
|
describe("Epoch", () => {
|
||||||
|
it("should map the value correctly", () => {
|
||||||
|
const epoch = new Epoch(1);
|
||||||
|
const mappedEpoch = epoch.mapInner((v) => v + 1);
|
||||||
|
expect(mappedEpoch.value).toBe(2);
|
||||||
|
expect(mappedEpoch.epoch).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be tracked from an observable", () => {
|
||||||
|
withTestScheduler(({ expectObservable, behavior }) => {
|
||||||
|
const observable$ = behavior("abc", {
|
||||||
|
a: 1,
|
||||||
|
b: 2,
|
||||||
|
c: 3,
|
||||||
|
});
|
||||||
|
const epochObservable$ = observable$.pipe(trackEpoch());
|
||||||
|
expectObservable(epochObservable$).toBe("abc", {
|
||||||
|
a: expect.toSatisfy((e) => e.epoch === 0 && e.value === 1),
|
||||||
|
b: expect.toSatisfy((e) => e.epoch === 1 && e.value === 2),
|
||||||
|
c: expect.toSatisfy((e) => e.epoch === 2 && e.value === 3),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can be mapped without loosing epoch information", () => {
|
||||||
|
withTestScheduler(({ expectObservable, behavior }) => {
|
||||||
|
const observable$ = behavior("abc", {
|
||||||
|
a: "A",
|
||||||
|
b: "B",
|
||||||
|
c: "C",
|
||||||
|
});
|
||||||
|
const epochObservable$ = observable$.pipe(trackEpoch());
|
||||||
|
const derivedEpoch$ = epochObservable$.pipe(
|
||||||
|
mapEpoch((e) => e + "-mapped"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expectObservable(derivedEpoch$).toBe("abc", {
|
||||||
|
a: new Epoch("A-mapped", 0),
|
||||||
|
b: new Epoch("B-mapped", 1),
|
||||||
|
c: new Epoch("C-mapped", 2),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("diamonds emits in a predictable order", () => {
|
||||||
|
const sb$ = new BehaviorSubject("initial");
|
||||||
|
const root$ = sb$.pipe(trackEpoch());
|
||||||
|
const derivedA$ = root$.pipe(mapEpoch((e) => e + "-A"));
|
||||||
|
const derivedB$ = root$.pipe(mapEpoch((e) => e + "-B"));
|
||||||
|
combineLatest([root$, derivedB$, derivedA$]).subscribe(
|
||||||
|
([root, derivedA, derivedB]) => {
|
||||||
|
logger.log(
|
||||||
|
"combined" +
|
||||||
|
root.epoch +
|
||||||
|
root.value +
|
||||||
|
"\n" +
|
||||||
|
derivedA.epoch +
|
||||||
|
derivedA.value +
|
||||||
|
"\n" +
|
||||||
|
derivedB.epoch +
|
||||||
|
derivedB.value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
sb$.next("updated");
|
||||||
|
sb$.next("ANOTERUPDATE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("behavior test", () => {
|
||||||
|
const scope = testScope();
|
||||||
|
const s$ = new Subject();
|
||||||
|
const behavior$ = scope.behavior(s$, 0);
|
||||||
|
behavior$.subscribe((value) => {
|
||||||
|
logger.log(`Received value: ${value}`);
|
||||||
|
});
|
||||||
|
s$.next(1);
|
||||||
|
s$.next(2);
|
||||||
|
s$.next(3);
|
||||||
|
s$.next(3);
|
||||||
|
s$.next(3);
|
||||||
|
s$.next(3);
|
||||||
|
s$.next(3);
|
||||||
|
s$.complete();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Reconcile", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should wait clean up before processing next", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const scope = testScope();
|
||||||
|
const behavior$ = new BehaviorSubject<number>(0);
|
||||||
|
|
||||||
|
const setup = vi.fn().mockImplementation(async () => await sleep(100));
|
||||||
|
const cleanup = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(async (n: number) => await sleep(100));
|
||||||
|
scope.reconcile(behavior$, async (value) => {
|
||||||
|
await setup();
|
||||||
|
return async (): Promise<void> => {
|
||||||
|
await cleanup(value);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// Let the initial setup process
|
||||||
|
await vi.advanceTimersByTimeAsync(120);
|
||||||
|
expect(setup).toHaveBeenCalledTimes(1);
|
||||||
|
expect(cleanup).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
|
// Send next value
|
||||||
|
behavior$.next(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
// Should not have started setup for 1 yet
|
||||||
|
expect(setup).toHaveBeenCalledTimes(1);
|
||||||
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||||
|
expect(cleanup).toHaveBeenCalledWith(0);
|
||||||
|
|
||||||
|
// Let cleanup finish
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
// Now setup for 1 should have started
|
||||||
|
expect(setup).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should skip intermediates values that are not setup", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const scope = testScope();
|
||||||
|
const behavior$ = new BehaviorSubject<number>(0);
|
||||||
|
|
||||||
|
const setup = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(async (n: number) => await sleep(100));
|
||||||
|
|
||||||
|
const cleanupLock = Promise.withResolvers();
|
||||||
|
const cleanup = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(async (n: number) => await cleanupLock.promise);
|
||||||
|
|
||||||
|
scope.reconcile(behavior$, async (value) => {
|
||||||
|
await setup(value);
|
||||||
|
return async (): Promise<void> => {
|
||||||
|
await cleanup(value);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// Let the initial setup process (0)
|
||||||
|
await vi.advanceTimersByTimeAsync(120);
|
||||||
|
|
||||||
|
// Send 4 next values quickly
|
||||||
|
behavior$.next(1);
|
||||||
|
behavior$.next(2);
|
||||||
|
behavior$.next(3);
|
||||||
|
behavior$.next(4);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(3000);
|
||||||
|
// should have only called cleanup for 0
|
||||||
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||||
|
expect(cleanup).toHaveBeenCalledWith(0);
|
||||||
|
// Let cleanup finish
|
||||||
|
cleanupLock.resolve(undefined);
|
||||||
|
await vi.advanceTimersByTimeAsync(120);
|
||||||
|
|
||||||
|
// Now setup for 4 should have started, skipping 1,2,3
|
||||||
|
expect(setup).toHaveBeenCalledTimes(2);
|
||||||
|
expect(setup).toHaveBeenCalledWith(4);
|
||||||
|
expect(setup).not.toHaveBeenCalledWith(1);
|
||||||
|
expect(setup).not.toHaveBeenCalledWith(2);
|
||||||
|
expect(setup).not.toHaveBeenCalledWith(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The loop ends a few microtasks before its promise settles; a value
|
||||||
|
// arriving in that window used to be dropped
|
||||||
|
it.each([0, 1, 2, 3, 4, 5, 6])(
|
||||||
|
"picks up a value that arrives %i microtasks after the previous loop ended",
|
||||||
|
async (ticks) => {
|
||||||
|
const scope = testScope();
|
||||||
|
const behavior$ = new BehaviorSubject<number>(0);
|
||||||
|
const setup = vi.fn();
|
||||||
|
scope.reconcile(behavior$, async (value) => {
|
||||||
|
setup(value);
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < ticks; i++) await Promise.resolve();
|
||||||
|
behavior$.next(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
|
|
||||||
|
expect(setup).toHaveBeenCalledWith(1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("should wait for setup to complete before starting cleanup", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const scope = testScope();
|
||||||
|
const behavior$ = new BehaviorSubject<number>(0);
|
||||||
|
|
||||||
|
const setup = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(async (n: number) => await sleep(3000));
|
||||||
|
|
||||||
|
const cleanupLock = Promise.withResolvers();
|
||||||
|
const cleanup = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(async (n: number) => await cleanupLock.promise);
|
||||||
|
|
||||||
|
scope.reconcile(behavior$, async (value) => {
|
||||||
|
await setup(value);
|
||||||
|
return async (): Promise<void> => {
|
||||||
|
await cleanup(value);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
|
// Setup for 0 should be in progress
|
||||||
|
expect(setup).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
behavior$.next(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
|
|
||||||
|
// Should not have started setup for 1 yet
|
||||||
|
expect(setup).not.toHaveBeenCalledWith(1);
|
||||||
|
// Should not have called cleanup yet, because the setup for 0 is not done
|
||||||
|
expect(cleanup).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
|
// Let setup for 0 finish
|
||||||
|
await vi.advanceTimersByTimeAsync(2500 + 100);
|
||||||
|
// Now cleanup for 0 should have started
|
||||||
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||||
|
expect(cleanup).toHaveBeenCalledWith(0);
|
||||||
|
|
||||||
|
cleanupLock.resolve(undefined);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
// Now setup for 1 should have started
|
||||||
|
expect(setup).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("behavior", () => {
|
||||||
|
it("warns with the tag and nesting depth, logging each new depth once", () => {
|
||||||
|
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
||||||
|
const scope = testScope();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const behavior$ = scope.behavior(source$, 0, "tagged");
|
||||||
|
// Re-enter once on value 1 (depth 2), then a nested re-entry on value 2
|
||||||
|
// (depth 3), which is the shape needed to strand a middle subscriber.
|
||||||
|
behavior$.subscribe((v) => {
|
||||||
|
if (v === 1) source$.next(2);
|
||||||
|
});
|
||||||
|
behavior$.subscribe((v) => {
|
||||||
|
if (v === 2) source$.next(3);
|
||||||
|
});
|
||||||
|
const seen: number[] = [];
|
||||||
|
behavior$.subscribe((v) => seen.push(v));
|
||||||
|
|
||||||
|
source$.next(1);
|
||||||
|
|
||||||
|
const messages = warn.mock.calls.map((c) => c[0] as string);
|
||||||
|
expect(messages).toEqual([
|
||||||
|
expect.stringContaining("Behavior (tagged) re-entered at depth 2"),
|
||||||
|
expect.stringContaining("Behavior (tagged) re-entered at depth 3"),
|
||||||
|
]);
|
||||||
|
warn.mock.calls.forEach((c) => expect(c[1]).toEqual(expect.any(String)));
|
||||||
|
// The behavior settles on the newest value while the last subscriber is
|
||||||
|
// left stranded on the oldest.
|
||||||
|
expect(behavior$.value).toBe(3);
|
||||||
|
expect(seen.at(-1)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns when the scope is ended while a behavior is mid-delivery", () => {
|
||||||
|
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
||||||
|
const scope = new ObservableScope();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const behavior$ = scope.behavior(source$, 0);
|
||||||
|
behavior$.subscribe((v) => {
|
||||||
|
if (v === 1) scope.end();
|
||||||
|
});
|
||||||
|
// A derived behavior is bound to the scope, so ending the scope
|
||||||
|
// mid-delivery unsubscribes it before the value reaches it.
|
||||||
|
const derived$ = scope.behavior(behavior$);
|
||||||
|
|
||||||
|
source$.next(1);
|
||||||
|
|
||||||
|
expect(warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Scope ended while 1 of its behaviors"),
|
||||||
|
expect.any(String),
|
||||||
|
);
|
||||||
|
expect(behavior$.value).toBe(1);
|
||||||
|
expect(derived$.value).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 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 {
|
||||||
|
BehaviorSubject,
|
||||||
|
catchError,
|
||||||
|
distinctUntilChanged,
|
||||||
|
EMPTY,
|
||||||
|
endWith,
|
||||||
|
filter,
|
||||||
|
map,
|
||||||
|
type Observable,
|
||||||
|
type OperatorFunction,
|
||||||
|
share,
|
||||||
|
take,
|
||||||
|
takeUntil,
|
||||||
|
} from "rxjs";
|
||||||
|
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
|
import { type Behavior } from "./Behavior";
|
||||||
|
|
||||||
|
type MonoTypeOperator = <T>(o: Observable<T>) => Observable<T>;
|
||||||
|
|
||||||
|
type SplitBehavior<T> = keyof T extends string | number
|
||||||
|
? { [K in keyof T as `${K}$`]: Behavior<T[K]> }
|
||||||
|
: never;
|
||||||
|
|
||||||
|
const nothing = Symbol("nothing");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A scope which limits the execution lifetime of its bound Observables.
|
||||||
|
*/
|
||||||
|
export class ObservableScope {
|
||||||
|
/**
|
||||||
|
* Number of this scope's behaviors currently mid-delivery. Used to detect a
|
||||||
|
* scope being torn down synchronously from within one of its own emissions.
|
||||||
|
*/
|
||||||
|
private delivering = 0;
|
||||||
|
|
||||||
|
private readonly ended$ = new BehaviorSubject(false);
|
||||||
|
|
||||||
|
private readonly bindImpl: MonoTypeOperator = takeUntil(
|
||||||
|
this.ended$.pipe(filter((ended) => ended)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds an Observable to this scope, so that it completes when the scope
|
||||||
|
* ends.
|
||||||
|
*/
|
||||||
|
public bind(): MonoTypeOperator {
|
||||||
|
return this.bindImpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly shareImpl: MonoTypeOperator = share({
|
||||||
|
resetOnError: false,
|
||||||
|
resetOnComplete: false,
|
||||||
|
resetOnRefCountZero: false,
|
||||||
|
});
|
||||||
|
/**
|
||||||
|
* Shares (multicasts) the Observable as a hot Observable.
|
||||||
|
*/
|
||||||
|
public readonly share: MonoTypeOperator = (input$) =>
|
||||||
|
input$.pipe(this.bindImpl, this.shareImpl);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an Observable to a Behavior. If no initial value is specified, the
|
||||||
|
* Observable must synchronously emit an initial value.
|
||||||
|
*/
|
||||||
|
public behavior<T>(
|
||||||
|
setValue$: Observable<T>,
|
||||||
|
initialValue: T | typeof nothing = nothing,
|
||||||
|
// An optional label used only in the re-entrancy warning below, so that a
|
||||||
|
// torn behavior can be identified from a rageshake.
|
||||||
|
tag?: string,
|
||||||
|
): Behavior<T> {
|
||||||
|
const subject$ = new BehaviorSubject(initialValue);
|
||||||
|
// Push values from the Observable into the BehaviorSubject.
|
||||||
|
// BehaviorSubjects have an undesirable feature where if you call 'complete',
|
||||||
|
// they will no longer re-emit their current value upon subscription. We want
|
||||||
|
// to support Observables that complete (for example `of({})`), so we have to
|
||||||
|
// take care to not propagate the completion event.
|
||||||
|
// If a subscriber synchronously causes this same behavior to emit again,
|
||||||
|
// rxjs delivers the nested value to every subscriber first and then
|
||||||
|
// resumes delivering the outer (older) value to the remaining subscribers,
|
||||||
|
// leaving them permanently out of sync with the others. A single such
|
||||||
|
// re-entry can only strand a contiguous run of subscribers; stranding a
|
||||||
|
// subscriber in the middle of the list needs a nested (deeper) re-entry, so
|
||||||
|
// we report the depth and log each new depth (with a stack trace) rather
|
||||||
|
// than only the first occurrence, to make a nested re-entry visible.
|
||||||
|
let depth = 0;
|
||||||
|
let maxReportedDepth = 1;
|
||||||
|
setValue$.pipe(this.bind(), distinctUntilChanged()).subscribe({
|
||||||
|
next: (value) => {
|
||||||
|
if (depth > 0 && depth + 1 > maxReportedDepth) {
|
||||||
|
maxReportedDepth = depth + 1;
|
||||||
|
logger.warn(
|
||||||
|
`Behavior${tag ? ` (${tag})` : ""} re-entered at depth ${
|
||||||
|
depth + 1
|
||||||
|
} while delivering a value; later subscribers will be left with a stale value`,
|
||||||
|
new Error().stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
depth++;
|
||||||
|
this.delivering++;
|
||||||
|
try {
|
||||||
|
subject$.next(value);
|
||||||
|
} finally {
|
||||||
|
depth--;
|
||||||
|
this.delivering--;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err: unknown) => {
|
||||||
|
subject$.error(err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (subject$.value === nothing)
|
||||||
|
throw new Error("Behavior failed to synchronously emit an initial value");
|
||||||
|
return subject$ as Behavior<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ends the scope, causing any bound Observables to complete.
|
||||||
|
*/
|
||||||
|
public end(): void {
|
||||||
|
if (this.delivering > 0)
|
||||||
|
logger.warn(
|
||||||
|
`Scope ended while ${this.delivering} of its behaviors were still delivering a value; later subscribers will be left with a stale value`,
|
||||||
|
new Error().stack,
|
||||||
|
);
|
||||||
|
this.ended$.next(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a callback to be executed when the scope is ended.
|
||||||
|
*/
|
||||||
|
public onEnd(callback: () => void): void {
|
||||||
|
this.ended$
|
||||||
|
.pipe(
|
||||||
|
filter((ended) => ended),
|
||||||
|
take(1),
|
||||||
|
)
|
||||||
|
.subscribe(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For the duration of the scope, sync some external state with the value of
|
||||||
|
* the provided Behavior by way of an async function which attempts to update
|
||||||
|
* (reconcile) the external state. The reconciliation function may return a
|
||||||
|
* clean-up callback which will be called and awaited before the next change
|
||||||
|
* in value (or the end of the scope).
|
||||||
|
*
|
||||||
|
* All calls to the function and its clean-up callbacks are serialized. If the
|
||||||
|
* value changes faster than the handlers can keep up with, intermediate
|
||||||
|
* values may be skipped.
|
||||||
|
*
|
||||||
|
* Basically, this is like React's useEffect but async and for Behaviors.
|
||||||
|
*/
|
||||||
|
public reconcile<T>(
|
||||||
|
value$: Behavior<T>,
|
||||||
|
callback: (value: T) => Promise<(() => Promise<void>) | void>,
|
||||||
|
): void {
|
||||||
|
let latestValue: T | typeof nothing = nothing;
|
||||||
|
let running = false;
|
||||||
|
let cleanUp: (() => Promise<void>) | void = undefined;
|
||||||
|
let prevVal: T | typeof nothing = nothing;
|
||||||
|
|
||||||
|
// While this loop runs it will process the latest from `value$` until it caught up with the updates.
|
||||||
|
// It might skip updates from `value$` and only process the newest value after callback has resolved.
|
||||||
|
const reconcileLoop = async (): Promise<void> => {
|
||||||
|
running = true;
|
||||||
|
try {
|
||||||
|
while (latestValue !== prevVal) {
|
||||||
|
await cleanUp?.(); // Call the previous value's clean-up handler
|
||||||
|
prevVal = latestValue;
|
||||||
|
|
||||||
|
if (latestValue !== nothing) cleanUp = await callback(latestValue); // Sync current value...
|
||||||
|
// `latestValue` might have gotten updated during the `await callback`. That is why we loop here
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Cleared the moment the loop is done, not when its promise settles:
|
||||||
|
// a value arriving in between would otherwise find the loop still
|
||||||
|
// marked as running and never be processed
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
value$
|
||||||
|
.pipe(
|
||||||
|
catchError(() => EMPTY), // Ignore errors
|
||||||
|
this.bind(), // Limit to the duration of the scope
|
||||||
|
endWith(nothing), // Clean up when the scope ends
|
||||||
|
)
|
||||||
|
.subscribe((value) => {
|
||||||
|
// Always track the latest value! The `reconcileLoop` will run until it "processed" the "last" `latestValue`.
|
||||||
|
latestValue = value;
|
||||||
|
// There's already an instance of the below 'reconcileLoop' loop running
|
||||||
|
// concurrently. So lets let the loop handle it. NEVER instanciate two `reconcileLoop`s.
|
||||||
|
if (running) return;
|
||||||
|
void reconcileLoop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a Behavior of objects with static properties into an object with
|
||||||
|
* Behavior properties.
|
||||||
|
*
|
||||||
|
* For example, splitting a `Behavior<{ name: string; age: number }>` results
|
||||||
|
* in an object of type `{ name$: Behavior<string>; age$: Behavior<number> }`.
|
||||||
|
*/
|
||||||
|
public splitBehavior<T extends object>(
|
||||||
|
input$: Behavior<T>,
|
||||||
|
): SplitBehavior<T> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.keys(input$.value).map((key) => [
|
||||||
|
`${key}$`,
|
||||||
|
this.behavior(
|
||||||
|
input$.pipe(map((input) => input[key as keyof T])),
|
||||||
|
nothing,
|
||||||
|
key,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
) as SplitBehavior<T>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `Epoch`'s can be used to create `Behavior`s and `Observable`s which derivitives can be merged
|
||||||
|
* with `combinedLatest` without duplicated emissions.
|
||||||
|
*
|
||||||
|
* This is useful in the following example:
|
||||||
|
* ```
|
||||||
|
* const rootObs$ = of("red","green","blue");
|
||||||
|
* const derivedObs$ = rootObs$.pipe(
|
||||||
|
* map((v)=> {red:"fire", green:"grass", blue:"water"}[v])
|
||||||
|
* );
|
||||||
|
* const otherDerivedObs$ = rootObs$.pipe(
|
||||||
|
* map((v)=> {red:"tomatoes", green:"leaves", blue:"sky"}[v])
|
||||||
|
* );
|
||||||
|
* const mergedObs$ = combineLatest([rootObs$, derivedObs$, otherDerivedObs$]).pipe(
|
||||||
|
* map(([color, a,b]) => color + " like " + a + " and " + b)
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* will result in 6 emissions with mismatching items like "red like fire and leaves"
|
||||||
|
*
|
||||||
|
* # Use Epoch
|
||||||
|
* ```
|
||||||
|
* const ancestorObs$ = of(1,2,3).pipe(trackEpoch());
|
||||||
|
* const derivedObs$ = ancestorObs$.pipe(
|
||||||
|
* mapEpoch((v)=> "this number: " + v)
|
||||||
|
* );
|
||||||
|
* const otherDerivedObs$ = ancestorObs$.pipe(
|
||||||
|
* mapEpoch((v)=> "multiplied by: " + v)
|
||||||
|
* );
|
||||||
|
* const mergedObs$ = combineLatest([derivedObs$, otherDerivedObs$]).pipe(
|
||||||
|
* filter((values) => values.every((v) => v.epoch === values[0].v)),
|
||||||
|
* map(([color, a, b]) => color + " like " + a + " and " + b)
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* will result in 3 emissions all matching (e.g. "blue like water and sky")
|
||||||
|
*/
|
||||||
|
export class Epoch<T> {
|
||||||
|
public readonly epoch: number;
|
||||||
|
public readonly value: T;
|
||||||
|
|
||||||
|
public constructor(value: T, epoch?: number) {
|
||||||
|
this.value = value;
|
||||||
|
this.epoch = epoch ?? 0;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Maps the value inside the epoch to a new value while keeping the epoch number.
|
||||||
|
* # usage
|
||||||
|
* ```
|
||||||
|
* const myEpoch$ = myObservable$.pipe(
|
||||||
|
* map(trackEpoch()),
|
||||||
|
* // this is the preferred way using mapEpoch
|
||||||
|
* mapEpoch((v)=> v+1)
|
||||||
|
* // This is how inner map can be used:
|
||||||
|
* map((epoch) => epoch.innerMap((v)=> v+1))
|
||||||
|
* // It is equivalent to:
|
||||||
|
* map((epoch) => new Epoch(epoch.value + 1, epoch.epoch))
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
* See also `Epoch<T>`
|
||||||
|
*/
|
||||||
|
public mapInner<U>(map: (value: T) => U): Epoch<U> {
|
||||||
|
return new Epoch<U>(map(this.value), this.epoch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `pipe` compatible map oparator that keeps the epoch in tact but allows mapping the value.
|
||||||
|
* # usage
|
||||||
|
* ```
|
||||||
|
* const myEpoch$ = myObservable$.pipe(
|
||||||
|
* map(trackEpoch()),
|
||||||
|
* // this is the preferred way using mapEpoch
|
||||||
|
* mapEpoch((v)=> v+1)
|
||||||
|
* // This is how inner map can be used:
|
||||||
|
* map((epoch) => epoch.innerMap((v)=> v+1))
|
||||||
|
* // It is equivalent to:
|
||||||
|
* map((epoch) => new Epoch(epoch.value + 1, epoch.epoch))
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
* See also `Epoch<T>`
|
||||||
|
*/
|
||||||
|
export function mapEpoch<T, U>(
|
||||||
|
mapFn: (value: T) => U,
|
||||||
|
): OperatorFunction<Epoch<T>, Epoch<U>> {
|
||||||
|
return map((e) => e.mapInner(mapFn));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # usage
|
||||||
|
* ```
|
||||||
|
* const myEpoch$ = myObservable$.pipe(
|
||||||
|
* map(trackEpoch()),
|
||||||
|
* map((epoch) => epoch.innerMap((v)=> v+1))
|
||||||
|
* )
|
||||||
|
* const derived = myEpoch$.pipe(
|
||||||
|
* mapEpoch((v)=>v^2)
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
* See also `Epoch<T>`
|
||||||
|
*/
|
||||||
|
export function trackEpoch<T>(): OperatorFunction<T, Epoch<T>> {
|
||||||
|
return map<T, Epoch<T>>((value, number) => new Epoch(value, number));
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/*
|
||||||
|
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 "vitest";
|
||||||
|
import { type Observable, of, Subject, switchMap } from "rxjs";
|
||||||
|
|
||||||
|
import { withTestScheduler } from "../utils/test";
|
||||||
|
import { filterBehavior, generateItems, pauseWhen } from "./observable";
|
||||||
|
import { type Behavior } from "./Behavior";
|
||||||
|
|
||||||
|
test("pauseWhen", () => {
|
||||||
|
withTestScheduler(({ behavior, expectObservable }) => {
|
||||||
|
const inputMarbles = " abcdefgh-i-jk-";
|
||||||
|
const pauseMarbles = " n-y--n-yn-y--n";
|
||||||
|
const outputMarbles = "abc--fgh-i---k";
|
||||||
|
expectObservable(
|
||||||
|
behavior(inputMarbles).pipe(
|
||||||
|
pauseWhen(behavior(pauseMarbles, { y: true, n: false })),
|
||||||
|
),
|
||||||
|
).toBe(outputMarbles);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generateItems", () => {
|
||||||
|
const scope1$ = new Subject<string>();
|
||||||
|
const scope2$ = new Subject<string>();
|
||||||
|
const scope3$ = new Subject<string>();
|
||||||
|
const scope4$ = new Subject<string>();
|
||||||
|
const scopeSubjects = [scope1$, scope2$, scope3$, scope4$];
|
||||||
|
|
||||||
|
withTestScheduler(({ hot, expectObservable }) => {
|
||||||
|
// Each scope should start when the input number reaches or surpasses their
|
||||||
|
// number and end when the input number drops back below their number.
|
||||||
|
// At the very end, unsubscribing should end all remaining scopes.
|
||||||
|
const inputMarbles = " 123242";
|
||||||
|
const outputMarbles = " abcbdb";
|
||||||
|
const subscriptionMarbles = "^-----!";
|
||||||
|
const scope1Marbles = " y-----n";
|
||||||
|
const scope2Marbles = " -y----n";
|
||||||
|
const scope3Marbles = " --ynyn";
|
||||||
|
const scope4Marbles = " ----yn";
|
||||||
|
|
||||||
|
expectObservable(
|
||||||
|
hot<string>(inputMarbles).pipe(
|
||||||
|
generateItems(
|
||||||
|
"test items",
|
||||||
|
function* (input) {
|
||||||
|
for (let i = 1; i <= +input; i++) {
|
||||||
|
yield { keys: [i], data: undefined };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(scope, data$, i) => {
|
||||||
|
scopeSubjects[i - 1].next("y");
|
||||||
|
scope.onEnd(() => scopeSubjects[i - 1].next("n"));
|
||||||
|
return i.toString();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subscriptionMarbles,
|
||||||
|
).toBe(outputMarbles, {
|
||||||
|
a: ["1"],
|
||||||
|
b: ["1", "2"],
|
||||||
|
c: ["1", "2", "3"],
|
||||||
|
d: ["1", "2", "3", "4"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expectObservable(scope1$).toBe(scope1Marbles);
|
||||||
|
expectObservable(scope2$).toBe(scope2Marbles);
|
||||||
|
expectObservable(scope3$).toBe(scope3Marbles);
|
||||||
|
expectObservable(scope4$).toBe(scope4Marbles);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filterBehavior", () => {
|
||||||
|
withTestScheduler(({ behavior, expectObservable }) => {
|
||||||
|
// Filtering the input should segment it into 2 modes of non-null behavior.
|
||||||
|
const inputMarbles = " abcxabx";
|
||||||
|
const filteredMarbles = "a--xa-x";
|
||||||
|
|
||||||
|
const input$ = behavior(inputMarbles, {
|
||||||
|
a: "a",
|
||||||
|
b: "b",
|
||||||
|
c: "c",
|
||||||
|
x: null,
|
||||||
|
});
|
||||||
|
const filtered$: Observable<Behavior<string> | null> = input$.pipe(
|
||||||
|
filterBehavior((value) => typeof value === "string"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expectObservable(filtered$).toBe(filteredMarbles, {
|
||||||
|
a: expect.any(Object),
|
||||||
|
x: null,
|
||||||
|
});
|
||||||
|
expectObservable(
|
||||||
|
filtered$.pipe(
|
||||||
|
switchMap((value$) => (value$ === null ? of(null) : value$)),
|
||||||
|
),
|
||||||
|
).toBe(inputMarbles, { a: "a", b: "b", c: "c", x: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2023, 2024 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 {
|
||||||
|
type Observable,
|
||||||
|
audit,
|
||||||
|
defer,
|
||||||
|
filter,
|
||||||
|
finalize,
|
||||||
|
map,
|
||||||
|
of,
|
||||||
|
scan,
|
||||||
|
tap,
|
||||||
|
withLatestFrom,
|
||||||
|
BehaviorSubject,
|
||||||
|
type OperatorFunction,
|
||||||
|
distinctUntilChanged,
|
||||||
|
} from "rxjs";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
|
import { type Behavior } from "./Behavior";
|
||||||
|
import { Epoch, ObservableScope } from "./ObservableScope";
|
||||||
|
|
||||||
|
const nothing = Symbol("nothing");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RxJS operator that invokes a callback when the Observable is finalized,
|
||||||
|
* passing the most recently emitted value. If no value was emitted, the
|
||||||
|
* callback will not be invoked.
|
||||||
|
*/
|
||||||
|
export function finalizeValue<T>(callback: (finalValue: T) => void) {
|
||||||
|
return (source$: Observable<T>): Observable<T> =>
|
||||||
|
defer(() => {
|
||||||
|
let finalValue: T | typeof nothing = nothing;
|
||||||
|
return source$.pipe(
|
||||||
|
tap((value) => (finalValue = value)),
|
||||||
|
finalize(() => {
|
||||||
|
if (finalValue !== nothing) callback(finalValue);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RxJS operator that pauses all changes in the input value whenever a Behavior
|
||||||
|
* is true. When the Behavior returns to being false, the most recently
|
||||||
|
* suppressed change is emitted as the most recent value.
|
||||||
|
*/
|
||||||
|
export function pauseWhen<T>(pause$: Behavior<boolean>) {
|
||||||
|
return (value$: Observable<T>): Observable<T> =>
|
||||||
|
value$.pipe(
|
||||||
|
withLatestFrom(pause$),
|
||||||
|
audit(([, pause]) =>
|
||||||
|
pause ? pause$.pipe(filter((pause) => !pause)) : of(null),
|
||||||
|
),
|
||||||
|
map(([value]) => value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItemHandle<Keys extends unknown[], Data, Item> {
|
||||||
|
scope: ObservableScope;
|
||||||
|
keys: readonly [...Keys];
|
||||||
|
data$: BehaviorSubject<Data>;
|
||||||
|
item: Item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a changing input value to a collection of items that each capture some
|
||||||
|
* dynamic data and are tied to a key. Items will be automatically created when
|
||||||
|
* their key is requested for the first time, reused when the same key is
|
||||||
|
* requested at a later time, and destroyed (have their scope ended) when the
|
||||||
|
* key is no longer requested.
|
||||||
|
*
|
||||||
|
* @param name A name for this collection to use in debug logs.
|
||||||
|
* @param generator A generator function yielding a tuple of keys and the
|
||||||
|
* currently associated data for each item that it wants to exist.
|
||||||
|
* @param factory A function constructing an individual item, given the item's key,
|
||||||
|
* dynamic data, and an automatically managed ObservableScope for the item.
|
||||||
|
*/
|
||||||
|
export function generateItems<
|
||||||
|
Input,
|
||||||
|
Keys extends [unknown, ...unknown[]],
|
||||||
|
Data,
|
||||||
|
Item,
|
||||||
|
>(
|
||||||
|
name: string,
|
||||||
|
generator: (
|
||||||
|
input: Input,
|
||||||
|
) => Iterable<{ keys: readonly [...Keys]; data: Data }, void, void>,
|
||||||
|
factory: (
|
||||||
|
scope: ObservableScope,
|
||||||
|
data$: Behavior<Data>,
|
||||||
|
...keys: Keys
|
||||||
|
) => Item,
|
||||||
|
): OperatorFunction<Input, Item[]> {
|
||||||
|
return generateItemsInternal(name, generator, factory, (items) => items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as generateItems, but preserves epoch data.
|
||||||
|
*/
|
||||||
|
export function generateItemsWithEpoch<
|
||||||
|
Input,
|
||||||
|
Keys extends [unknown, ...unknown[]],
|
||||||
|
Data,
|
||||||
|
Item,
|
||||||
|
>(
|
||||||
|
name: string,
|
||||||
|
generator: (
|
||||||
|
input: Input,
|
||||||
|
) => Iterable<{ keys: readonly [...Keys]; data: Data }, void, void>,
|
||||||
|
factory: (
|
||||||
|
scope: ObservableScope,
|
||||||
|
data$: Behavior<Data>,
|
||||||
|
...keys: Keys
|
||||||
|
) => Item,
|
||||||
|
): OperatorFunction<Epoch<Input>, Epoch<Item[]>> {
|
||||||
|
return generateItemsInternal(
|
||||||
|
name,
|
||||||
|
function* (input) {
|
||||||
|
yield* generator(input.value);
|
||||||
|
},
|
||||||
|
factory,
|
||||||
|
(items, input) => new Epoch(items, input.epoch),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Segments a behavior into periods during which its value matches the filter
|
||||||
|
* (outputting a behavior with a narrowed type) and periods during which it does
|
||||||
|
* not match (outputting null).
|
||||||
|
*/
|
||||||
|
export function filterBehavior<T, S extends T>(
|
||||||
|
predicate: (value: T) => value is S,
|
||||||
|
): OperatorFunction<T, Behavior<S> | null> {
|
||||||
|
return (input$) =>
|
||||||
|
input$.pipe(
|
||||||
|
scan<T, BehaviorSubject<S> | null>((acc$, input) => {
|
||||||
|
if (predicate(input)) {
|
||||||
|
const output$ = acc$ ?? new BehaviorSubject(input);
|
||||||
|
output$.next(input);
|
||||||
|
return output$;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, null),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateItemsInternal<
|
||||||
|
Input,
|
||||||
|
Keys extends [unknown, ...unknown[]],
|
||||||
|
Data,
|
||||||
|
Item,
|
||||||
|
Output,
|
||||||
|
>(
|
||||||
|
name: string,
|
||||||
|
generator: (
|
||||||
|
input: Input,
|
||||||
|
) => Iterable<{ keys: readonly [...Keys]; data: Data }, void, void>,
|
||||||
|
factory: (
|
||||||
|
scope: ObservableScope,
|
||||||
|
data$: Behavior<Data>,
|
||||||
|
...keys: Keys
|
||||||
|
) => Item,
|
||||||
|
project: (items: Item[], input: Input) => Output,
|
||||||
|
): OperatorFunction<Input, Output> {
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
return (input$) =>
|
||||||
|
input$.pipe(
|
||||||
|
// Keep track of the existing items over time, so they can persist
|
||||||
|
scan<
|
||||||
|
Input,
|
||||||
|
{
|
||||||
|
map: Map<any, any>;
|
||||||
|
items: Set<ItemHandle<Keys, Data, Item>>;
|
||||||
|
input: Input;
|
||||||
|
},
|
||||||
|
{ map: Map<any, any>; items: Set<ItemHandle<Keys, Data, Item>> }
|
||||||
|
>(
|
||||||
|
({ map: prevMap, items: prevItems }, input) => {
|
||||||
|
const nextMap = new Map();
|
||||||
|
const nextItems = new Set<ItemHandle<Keys, Data, Item>>();
|
||||||
|
|
||||||
|
for (const { keys, data } of generator(input)) {
|
||||||
|
// Disable type checks for a second to grab the item out of a nested map
|
||||||
|
let i: any = prevMap;
|
||||||
|
for (const key of keys) i = i?.get(key);
|
||||||
|
let item = i as ItemHandle<Keys, Data, Item> | undefined;
|
||||||
|
|
||||||
|
if (item === undefined) {
|
||||||
|
// First time requesting the key; create the item
|
||||||
|
const scope = new ObservableScope();
|
||||||
|
const data$ = new BehaviorSubject(data);
|
||||||
|
logger.debug(
|
||||||
|
`[${name}] Creating item with keys ${keys.join(", ")}`,
|
||||||
|
);
|
||||||
|
item = {
|
||||||
|
scope,
|
||||||
|
keys,
|
||||||
|
data$,
|
||||||
|
item: factory(scope, data$, ...keys),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
item.data$.next(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Likewise, disable type checks to insert the item in the nested map
|
||||||
|
let m: Map<any, any> = nextMap;
|
||||||
|
for (let i = 0; i < keys.length - 1; i++) {
|
||||||
|
let inner = m.get(keys[i]);
|
||||||
|
if (inner === undefined) {
|
||||||
|
inner = new Map();
|
||||||
|
m.set(keys[i], inner);
|
||||||
|
}
|
||||||
|
m = inner;
|
||||||
|
}
|
||||||
|
const finalKey = keys[keys.length - 1];
|
||||||
|
if (m.has(finalKey))
|
||||||
|
throw new Error(
|
||||||
|
`Keys must be unique (tried to generate multiple items for key ${keys.join(", ")})`,
|
||||||
|
);
|
||||||
|
m.set(keys[keys.length - 1], item);
|
||||||
|
nextItems.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destroy all items that are no longer being requested
|
||||||
|
for (const item of prevItems)
|
||||||
|
if (!nextItems.has(item)) {
|
||||||
|
logger.debug(
|
||||||
|
`[${name}] Destroying item with keys ${item.keys.join(", ")}`,
|
||||||
|
);
|
||||||
|
item.scope.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { map: nextMap, items: nextItems, input };
|
||||||
|
},
|
||||||
|
{ map: new Map(), items: new Set() },
|
||||||
|
),
|
||||||
|
finalizeValue(({ items }) => {
|
||||||
|
// Destroy all remaining items when no longer subscribed
|
||||||
|
logger.debug(
|
||||||
|
`[${name}] End of scope, destroying all ${items.size} items…`,
|
||||||
|
);
|
||||||
|
for (const item of items) {
|
||||||
|
logger.debug(
|
||||||
|
`[${name}] Destroying item with keys ${item.keys.join(", ")}`,
|
||||||
|
);
|
||||||
|
item.scope.end();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
map(({ items, input }) =>
|
||||||
|
project(
|
||||||
|
[...items].map(({ item }) => item),
|
||||||
|
input,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/*
|
||||||
|
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 { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { calculateDisplayName, shouldDisambiguate } from "./displayname";
|
||||||
|
import {
|
||||||
|
alice,
|
||||||
|
aliceDoppelganger,
|
||||||
|
aliceDoppelgangerRtcMember,
|
||||||
|
aliceRtcMember,
|
||||||
|
bob,
|
||||||
|
bobRtcMember,
|
||||||
|
bobZeroWidthSpace,
|
||||||
|
bobZeroWidthSpaceRtcMember,
|
||||||
|
daveRTL,
|
||||||
|
} from "./test-fixtures";
|
||||||
|
import { mockMatrixRoom } from "./test";
|
||||||
|
import { roomToMembersMap } from "../matrixrtc/MatrixMemberMetadata";
|
||||||
|
|
||||||
|
describe("shouldDisambiguate", () => {
|
||||||
|
test("should not disambiguate a solo member", () => {
|
||||||
|
const room = mockMatrixRoom({
|
||||||
|
getMembersWithMembership: () => [],
|
||||||
|
});
|
||||||
|
expect(shouldDisambiguate(alice, [], roomToMembersMap(room))).toEqual(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("should not disambiguate a member with an empty displayname", () => {
|
||||||
|
const room = mockMatrixRoom({
|
||||||
|
getMembersWithMembership: () => [alice, aliceDoppelganger],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
shouldDisambiguate(
|
||||||
|
{ rawDisplayName: "", userId: alice.userId },
|
||||||
|
[aliceRtcMember, aliceDoppelgangerRtcMember],
|
||||||
|
roomToMembersMap(room),
|
||||||
|
),
|
||||||
|
).toEqual(false);
|
||||||
|
});
|
||||||
|
test("should disambiguate a member with RTL characters", () => {
|
||||||
|
const room = mockMatrixRoom({ getMembersWithMembership: () => [] });
|
||||||
|
expect(shouldDisambiguate(daveRTL, [], roomToMembersMap(room))).toEqual(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("should disambiguate a member with a matching displayname", () => {
|
||||||
|
const room = mockMatrixRoom({
|
||||||
|
getMembersWithMembership: () => [alice, aliceDoppelganger],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
shouldDisambiguate(
|
||||||
|
alice,
|
||||||
|
[aliceRtcMember, aliceDoppelgangerRtcMember],
|
||||||
|
roomToMembersMap(room),
|
||||||
|
),
|
||||||
|
).toEqual(true);
|
||||||
|
expect(
|
||||||
|
shouldDisambiguate(
|
||||||
|
aliceDoppelganger,
|
||||||
|
[aliceRtcMember, aliceDoppelgangerRtcMember],
|
||||||
|
roomToMembersMap(room),
|
||||||
|
),
|
||||||
|
).toEqual(true);
|
||||||
|
});
|
||||||
|
test("should disambiguate a member with a matching displayname with hidden spaces", () => {
|
||||||
|
const room = mockMatrixRoom({
|
||||||
|
getMembersWithMembership: () => [bob, bobZeroWidthSpace],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
shouldDisambiguate(
|
||||||
|
bob,
|
||||||
|
[bobRtcMember, bobZeroWidthSpaceRtcMember],
|
||||||
|
roomToMembersMap(room),
|
||||||
|
),
|
||||||
|
).toEqual(true);
|
||||||
|
expect(
|
||||||
|
shouldDisambiguate(
|
||||||
|
bobZeroWidthSpace,
|
||||||
|
[bobRtcMember, bobZeroWidthSpaceRtcMember],
|
||||||
|
roomToMembersMap(room),
|
||||||
|
),
|
||||||
|
).toEqual(true);
|
||||||
|
});
|
||||||
|
test.for(["Alice @foo:bar", "@foo:b", "A@foo:lice", "A @f oo: ba r"])(
|
||||||
|
"should disambiguate a member with a displayname containing a mxid-like string '%s'",
|
||||||
|
(rawDisplayName) => {
|
||||||
|
const room = mockMatrixRoom({
|
||||||
|
getMembersWithMembership: () => [alice, aliceDoppelganger],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
shouldDisambiguate(
|
||||||
|
{ rawDisplayName, userId: alice.userId },
|
||||||
|
[],
|
||||||
|
roomToMembersMap(room),
|
||||||
|
),
|
||||||
|
).toEqual(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calculateDisplayName", () => {
|
||||||
|
test.for<[{ rawDisplayName?: string; userId: string }, boolean, string]>([
|
||||||
|
[alice, false, alice.rawDisplayName],
|
||||||
|
[alice, true, `${alice.rawDisplayName} (${alice.userId})`],
|
||||||
|
// Empty strings and zero width strings that are effectively empty are resolved as userIds
|
||||||
|
[{ rawDisplayName: "", userId: alice.userId }, false, alice.userId],
|
||||||
|
[
|
||||||
|
{ rawDisplayName: "\u200b\u200b\u200b", userId: alice.userId },
|
||||||
|
false,
|
||||||
|
alice.userId,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ rawDisplayName: alice.userId, userId: alice.userId },
|
||||||
|
false,
|
||||||
|
alice.userId,
|
||||||
|
],
|
||||||
|
// Zero width strings are kept intact
|
||||||
|
[bobZeroWidthSpace, false, bobZeroWidthSpace.rawDisplayName],
|
||||||
|
// Directional characters are stripped.
|
||||||
|
[daveRTL, false, daveRTL.rawDisplayName.slice(1)],
|
||||||
|
[daveRTL, true, `${daveRTL.rawDisplayName.slice(1)} (${daveRTL.userId})`],
|
||||||
|
// Ensure we do NOT unhomoglyth
|
||||||
|
[{ ...alice, rawDisplayName: "alice m" }, false, "alice m"],
|
||||||
|
])("correctly calculates displayname", ([member, disambiguate, result]) =>
|
||||||
|
expect(calculateDisplayName(member, disambiguate)).toEqual(result),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/*
|
||||||
|
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 {
|
||||||
|
removeDirectionOverrideChars,
|
||||||
|
removeHiddenChars as removeHiddenCharsUncached,
|
||||||
|
} from "matrix-js-sdk/lib/utils";
|
||||||
|
|
||||||
|
import type { RoomMember } from "matrix-js-sdk";
|
||||||
|
import type { CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
|
||||||
|
// Calling removeHiddenChars() can be slow on Safari, so we cache the results.
|
||||||
|
// To illustrate a simple benchmark:
|
||||||
|
// Chrome: 10,000 calls took 2.599ms
|
||||||
|
// Safari: 10,000 calls took 242ms
|
||||||
|
// See: https://github.com/element-hq/element-call/issues/3065
|
||||||
|
|
||||||
|
const removeHiddenCharsCache = new Map<string, string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls removeHiddenCharsUncached and caches the result
|
||||||
|
*/
|
||||||
|
function removeHiddenChars(str: string): string {
|
||||||
|
if (removeHiddenCharsCache.has(str)) {
|
||||||
|
return removeHiddenCharsCache.get(str)!;
|
||||||
|
}
|
||||||
|
const result = removeHiddenCharsUncached(str);
|
||||||
|
// this is naive but should be good enough for our purposes
|
||||||
|
if (removeHiddenCharsCache.size > 500) {
|
||||||
|
removeHiddenCharsCache.clear();
|
||||||
|
}
|
||||||
|
removeHiddenCharsCache.set(str, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Borrowed from https://github.com/matrix-org/matrix-js-sdk/blob/f10deb5ef2e8f061ff005af0476034382ea128ca/src/models/room-member.ts#L409
|
||||||
|
export function shouldDisambiguate(
|
||||||
|
member: { rawDisplayName?: string; userId: string },
|
||||||
|
memberships: Pick<CallMembership, "userId">[],
|
||||||
|
roomMembers: Map<string, Pick<RoomMember, "userId">>,
|
||||||
|
): boolean {
|
||||||
|
const { rawDisplayName: displayName, userId } = member;
|
||||||
|
if (!displayName || displayName === userId) return false;
|
||||||
|
|
||||||
|
// First check if the displayname is something we consider truthy
|
||||||
|
// after stripping it of zero width characters and padding spaces
|
||||||
|
const strippedDisplayName = removeHiddenChars(displayName);
|
||||||
|
if (!strippedDisplayName) return false;
|
||||||
|
|
||||||
|
// Next check if the name contains something that look like a mxid
|
||||||
|
// If it does, it may be someone trying to impersonate someone else
|
||||||
|
// Show full mxid in this case
|
||||||
|
if (/@.+:.+/.test(displayName)) return true;
|
||||||
|
|
||||||
|
// Also show mxid if the display name contains any LTR/RTL characters as these
|
||||||
|
// make it very difficult for us to find similar *looking* display names
|
||||||
|
// E.g "Mark" could be cloned by writing "kraM" but in RTL.
|
||||||
|
if (/[\u200E\u200F\u202A-\u202F]/.test(displayName)) return true;
|
||||||
|
|
||||||
|
// Also show mxid if there are other people with the same or similar
|
||||||
|
// displayname, after hidden character removal.
|
||||||
|
return (
|
||||||
|
memberships
|
||||||
|
.map((m) => m.userId && roomMembers.get(m.userId))
|
||||||
|
// NOTE: We *should* have a room member for everyone.
|
||||||
|
.filter((m) => !!m)
|
||||||
|
.filter((m) => m.userId !== userId)
|
||||||
|
.some(
|
||||||
|
(m) =>
|
||||||
|
removeHiddenChars(calculateDisplayName(m, false)) ===
|
||||||
|
strippedDisplayName,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates a display name for a member, optionally disambiguating it.
|
||||||
|
* @param member - The member to calculate the display name for.
|
||||||
|
* @param member.rawDisplayName - The raw display name of the member
|
||||||
|
* @param member.userId - The user ID of the member
|
||||||
|
* @param disambiguate - Whether to disambiguate the display name.
|
||||||
|
*/
|
||||||
|
export function calculateDisplayName(
|
||||||
|
member: { rawDisplayName?: string; userId: string },
|
||||||
|
disambiguate: boolean,
|
||||||
|
): string {
|
||||||
|
const { rawDisplayName: displayName, userId } = member;
|
||||||
|
if (!displayName || displayName === userId) return userId;
|
||||||
|
|
||||||
|
const resultDisplayname = removeDirectionOverrideChars(displayName);
|
||||||
|
|
||||||
|
if (disambiguate) return resultDisplayname + " (" + userId + ")";
|
||||||
|
|
||||||
|
// First check if the displayname is something we consider truthy
|
||||||
|
// after stripping it of zero width characters and padding spaces
|
||||||
|
if (!removeHiddenChars(displayName)) return userId;
|
||||||
|
|
||||||
|
// We always strip the direction override characters (LRO and RLO).
|
||||||
|
// These override the text direction for all subsequent characters
|
||||||
|
// in the paragraph so if display names contained these, they'd
|
||||||
|
// need to be wrapped in something to prevent this from leaking out
|
||||||
|
// (which we can do in HTML but not text) or we'd need to add
|
||||||
|
// control characters to the string to reset any overrides (eg.
|
||||||
|
// adding PDF characters at the end). As far as we can see,
|
||||||
|
// there should be no reason these would be necessary - rtl display
|
||||||
|
// names should flip into the correct direction automatically based on
|
||||||
|
// the characters, and you can still embed rtl in ltr or vice versa
|
||||||
|
// with the embed chars or marker chars.
|
||||||
|
return resultDisplayname;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user