mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8be229a438 | ||
|
|
ef7dce7b07 | ||
|
|
2de5ee2df5 | ||
|
|
a427206792 | ||
|
|
1913a099ab | ||
|
|
93b893527e | ||
|
|
2b26fd472f | ||
|
|
281fff0e9f | ||
|
|
bc557dfb5d | ||
|
|
4827ab5fdc | ||
|
|
7af6525f01 | ||
|
|
3b763d87f2 | ||
|
|
8638d4f159 | ||
|
|
4b73ce68e3 | ||
|
|
d37599a794 | ||
|
|
3f98e36c56 | ||
|
|
042a8cfbee | ||
|
|
e726c36f67 | ||
|
|
dd25238427 | ||
|
|
432ebadd44 | ||
|
|
289260531b | ||
|
|
f97dd2adab | ||
|
|
1700e9fe20 | ||
|
|
f638604fe7 | ||
|
|
b0659279ee | ||
|
|
79c1f82448 | ||
|
|
eaf747c107 | ||
|
|
aac60b74dc | ||
|
|
598ec42390 | ||
|
|
cec5031f62 | ||
|
|
a5a0efe92b | ||
|
|
7c5d1324e7 | ||
|
|
52aaf84c3e | ||
|
|
5f3db07b76 | ||
|
|
10b34b0218 | ||
|
|
d6b8207218 | ||
|
|
956671eef5 | ||
|
|
28e1a9e22f | ||
|
|
27dcfb8bb7 | ||
|
|
038991fa97 | ||
|
|
2994bb4ed0 | ||
|
|
d84dcc75de | ||
|
|
7c3f3d3ac7 | ||
|
|
7e4e260273 | ||
|
|
4aa249daf5 | ||
|
|
b4bd1f81a0 | ||
|
|
de8b5d98ec | ||
|
|
7685947a69 | ||
|
|
b158c4fcd7 | ||
|
|
dc8f8e585a | ||
|
|
441fe5accd | ||
|
|
558fbe2188 | ||
|
|
799619557b | ||
|
|
5f914b8a01 | ||
|
|
aa897ae9bc | ||
|
|
2bb870f6e7 | ||
|
|
e44dfe1898 | ||
|
|
b5fac4a2ff | ||
|
|
66f326fcf0 | ||
|
|
3cdb452d5d | ||
|
|
f49c1cd3d4 | ||
|
|
dc983e5493 | ||
|
|
9cc9eb0889 | ||
|
|
ca4f0cdeae | ||
|
|
4790af08a4 | ||
|
|
6ffab92f0f | ||
|
|
f1ea4cc5a2 | ||
|
|
5b13e1da81 | ||
|
|
d0a2ff12f1 | ||
|
|
64d7eb99cc | ||
|
|
cad00aef08 | ||
|
|
6bd6e3f953 | ||
|
|
b9f79be106 | ||
|
|
5a43eaf5a6 | ||
|
|
63ccfa50be | ||
|
|
18e0065086 | ||
|
|
d7ab2b27b6 | ||
|
|
3afe52bd4a | ||
|
|
d89c058275 | ||
|
|
2af87a760d | ||
|
|
5c6d2f0891 | ||
|
|
31eafb394b | ||
|
|
61e353d919 | ||
|
|
1a999eaa54 | ||
|
|
9910496b90 | ||
|
|
2b9fdfe4d5 |
@@ -64,5 +64,6 @@ jobs:
|
||||
with:
|
||||
name: build-output-${{ inputs.package }}
|
||||
path: dist
|
||||
if-no-files-found: error
|
||||
# We'll only use this in a triggered job, then we're done with it
|
||||
retention-days: 1
|
||||
|
||||
@@ -73,7 +73,10 @@ jobs:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
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
|
||||
with:
|
||||
package: sdk
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
],
|
||||
"element-call/no-observablescope-leak": "error",
|
||||
"element-call/no-top-level-logger-get-child": "error",
|
||||
"element-call/sdk-import-boundary": "error",
|
||||
"jsdoc/empty-tags": "error",
|
||||
"jsdoc/check-property-names": "error",
|
||||
"jsdoc/require-param-description": "warn",
|
||||
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
# Merging PR #4331 onto the SDK / Element Call split
|
||||
|
||||
How [element-hq/element-call#4331](https://github.com/element-hq/element-call/pull/4331)
|
||||
("LiveKit updates": MSC4195 client-server API tokens, `url` transports, delegation
|
||||
through the homeserver, a transport dev override) lands on `toger5/use-sdk`, where
|
||||
the MatrixRTC logic lives in `sdk/` and Element Call consumes it through
|
||||
`sdk/src/api.ts`. The PR was written against `main`, where all of this still sat
|
||||
in `src/state/CallViewModel/`. This document goes through the PR commit by commit,
|
||||
says whether each one lands in `sdk/` or `src/`, and how; the section
|
||||
[Changes to `api.ts`](#changes-to-apits) covers the public interface in full. The
|
||||
end state: this branch behaves exactly as the PR does, with the split and the
|
||||
`api.ts` seam intact.
|
||||
|
||||
The PR's branch is checked out at `agent-workspace/pr4331-merge/worktree` for
|
||||
comparison. The `matrix-js-sdk` dependency is pinned to the same `develop`
|
||||
commit the PR uses (`4df2f2bf`, 43.0.0), which brings `UnstableLivekitTransport`,
|
||||
`CallMembership.backendIdentities`, `_unstable_getLivekitToken` and
|
||||
`_unstable_delegateDelayedLeave`. That bump also renamed
|
||||
`MatrixRTCSession.leaveRoomSession` to `leave`, which the SDK now calls.
|
||||
|
||||
## Where each commit lands
|
||||
|
||||
| # | Commit | Lands in | How |
|
||||
| --- | ------------------------------------------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Remove stray pause and improve logging for hung e2e tests | e2e | Applied as is: `playwright.config.ts`, `playwright/widget/federated-call.test.ts`. |
|
||||
| 2 | Fix typo in the name of the createJWTToken Playwright fixture | e2e | Applied as is. |
|
||||
| 3 | Advertise the loopback address from the dev LiveKit SFUs | backend | Applied as is: `backend/dev_livekit*.yaml`. |
|
||||
| 4 | Match LiveKit participants against all backend identities | sdk | `RTCMember.backendIdentities` (new, see api.ts). `LivekitBackend.mediaFor` matches `member.backendIdentities.includes(p.identity)`; `RTCParticipation.data$` maps a packet's sender the same way. `mockRtcMembership` in both `sdk/src/utils/test.ts` and `src/utils/test.ts` takes the new 4-argument constructor and a `backendIdentities` override. |
|
||||
| 5 | Model ConnectionState as a discriminated union | sdk | `Connection.ts` state is `{state: "initialized" \| "authenticating" \| "authenticated" (+ `livekitState`) \| "error" (+ `error`) \| "stopped"}`. Internal to the LiveKit backend: `connectionState.ts` maps it onto the backend-neutral `MediaConnectionState`, so nothing above the backend changes. `DataChannel.ts` and `ConnectionFactory.ts` follow. |
|
||||
| 6 | Show the whole transport in debug UI and logs | sdk + src | SDK log lines use `JSON.stringify(transport)`. Element Call: `focusUrl$: Behavior<string>` on the member media view models becomes `transport$: Behavior<Transport>` (`TransportMetadata.raw`), carried through `GridTile`, `SpotlightTile`, `MediaView` to `RTCConnectionStats`, which prints the JSON. The old code showed `extractDomain(transport.id)`, which on this branch was already a JSON string, so this also fixes a latent bug. |
|
||||
| 7 | Group livekitRoomItems$ by connection | n/a | `livekitRoomItems$` and `LivekitRoomAudioRenderer` do not exist on this branch: audio renders from `tracks$` (`MemberAudioRenderer`). Nothing to port. |
|
||||
| 8 | Move SFU authentication into livekit/auth and choose the endpoint by role | sdk | `sdk/src/media-backend/livekit/auth/{index,openID,csApi,types}.ts` replace `openIDSFU.ts`. Publisher → legacy `/sfu/get`; subscriber → `/get_token`. `slotId` replaces the hard-coded `m.call#ROOM`: `MediaBackendContext.slotId`, taken from the js-sdk session in `RTCParticipation`. The `tokenEndpoint` option of `createLivekitBackend` and the derivation from `MatrixRTCMode` are gone. |
|
||||
| 9 | Let the publishing Connection authenticate itself | sdk | Every `Connection` has a `role` and fetches its own token; `existingSFUConfig` is gone. `prepareLocalTransport` no longer fetches a token itself: it starts the publisher connection (through the connection map) and resolves once that connection reports `authenticated`, rejecting with its error otherwise. Since `LocalMembership` joins only after `preparedTransport$` resolves, the join waits for the publisher's token exactly as the PR's `authenticated$` gate does. |
|
||||
| 10 | Reduce ConnectionManager to a ConnectionMap | sdk | `ConnectionMap.ts` replaces `ConnectionManager.ts`; `createConnectionMap$` takes a resolved local transport, and `LivekitBackend` wraps it in `mapScoped` over the local transport, as the PR's `CallViewModel` did. `LocalMedia.ts`, `DataChannel.ts` take `connectionMap$`. |
|
||||
| 11 | Use the unstable LiveKit transport type | sdk + src | `UnstableLivekitTransport` / `isUnstableLivekitTransport` throughout the backend; `src/settings/settings.ts`, `DeveloperSettingsTab`, test helpers. Transport discovery in `sdk/src/matrixrtc/` is now backend neutral (filters by `type`, see commit 15). |
|
||||
| 12 | Identify transports by server name as well as URLs | sdk | `TransportLocator {transport, serverName}` in `auth/types.ts`; `ConnectionMap.keyFor` keys on `(url, livekit_service_url, serverName)`. The server name is derived from the user id (`serverNameOf`) for remote members and from our own membership identity for the local transport, so neither `api.ts` nor the backend options need it. |
|
||||
| 13 | Pin matrix-js-sdk to the member_id branch | superseded | By commit 19; the lockfile pins the PR's final `develop` commit. |
|
||||
| 14 | Get SFU tokens and delegate delayed leaves through the MSC4195 CS API | sdk | `auth/csApi.ts` (`_unstable_getLivekitToken`, `_unstable_delegateDelayedLeave`), `auth/index.ts` facade with the fallback rules. `LivekitBackend.delegateDelayedLeave` goes through the facade; the delegation probe asks only the service the publisher flow uses. `LivekitBackendOptions.client` picks the two new client methods. |
|
||||
| 15 | Let developers override the whole transport | sdk + src | `RTCParticipationOptions.transport` / `fallbackTransport` take a `Transport` instead of `transportUrl` / `fallbackTransportUrl` (see api.ts). Element Call: setting `custom-transport` (JSON) replaces `custom-livekit-url`; `DeveloperSettingsTab` edits JSON and validates through `authenticateWithTransport`; `InCallView` turns `config.livekit.livekit_service_url` into the fallback transport. Locale keys `developer_mode.custom_transport.*`. |
|
||||
| 16 | Deprecate the livekit.livekit_service_url config option | src | Applied as is in `ConfigOptions.ts`. |
|
||||
| 17 | Cover url transports end to end | e2e + backend | `playwright/url-transport.spec.ts`, `backend/app-service*.yaml`, `docker-compose-dev.yml` (lk-jwt-service 0.8.0) copied as is. |
|
||||
| 18 | Retain compatibility in compatibility mode | superseded | Reverted by commit 23 inside the PR; only its e2e spec edits survive, in the final spec file. |
|
||||
| 19 | Switch JS SDK back to develop | deps | `pnpm-lock.yaml` pins `4df2f2bf` and `matrix-widget-api` 1.20.0; `package.json` keeps `#develop`. |
|
||||
| 20 | Fall back to get_token as subscriber on widget API errors | sdk | `isCsApiUnavailable` in `auth/index.ts`; `doNetworkOperationWithRetry(op, shouldRetry)` in `sdk/src/utils/network.ts` with `isNetworkError` in `csApi.ts`. |
|
||||
| 21 | Share a connection between homeservers using the same JWT service | sdk | `keyFor` ignores the server name for transports without a `url`; `removeDuplicateTransports` keeps the first locator so the local one wins. |
|
||||
| 22 | Avoid null in transport dev setting | src | `transportText()` in `DeveloperSettingsTab.tsx`. |
|
||||
| 23 | Make the auth choice mode independent again | sdk | `chooseAuthFlow(transport, role)` has no mode parameter; `config.ts` documents that `MatrixRTCMode` decides how the membership is written, not how the transport is authenticated. |
|
||||
| 24 | Cover identity matching and delegation across transport shapes | sdk + e2e | Unit tests in `LivekitBackend.test.ts` (see below); `playwright/url-transport.spec.ts` final version. |
|
||||
| 25 | Cover the subscriber token path, widget fallback and server names | sdk + e2e | `auth/openID.test.ts` subscriber cases; `playwright/widget/url-transport.test.ts`; the server-name test lives in `LivekitBackend.test.ts` ("connects to a remote member's transport on the member's homeserver"). |
|
||||
|
||||
## Changes to `api.ts`
|
||||
|
||||
Three changes to the public SDK interface, all forced by the PR's behaviour.
|
||||
|
||||
### `RTCMember.backendIdentities` (new)
|
||||
|
||||
```ts
|
||||
/** The membership's `rtcBackendIdentity`, `${userId}:${deviceId}`: a key and a debug label. */
|
||||
rtcBackendIdentity: string;
|
||||
/**
|
||||
* Every identity the 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.
|
||||
*/
|
||||
backendIdentities: readonly string[];
|
||||
```
|
||||
|
||||
Commit 4 of the PR. A member on a transport that carries both a `url` and a
|
||||
`livekit_service_url` may have authenticated through either flow and so has
|
||||
two candidate identities; the js-sdk now lists them on the membership. The
|
||||
backend matches media and data packets against the list.
|
||||
`rtcBackendIdentity` stays as the stable per-membership key Element Call uses
|
||||
for tile ids and the debug overlay, and its doc says that it is no longer
|
||||
what the backend matches on. Read off the membership, like the single identity
|
||||
before it; the backend will derive both once the identity scheme is its own.
|
||||
|
||||
### `RTCParticipationOptions.transport` / `fallbackTransport` replace the url options
|
||||
|
||||
```ts
|
||||
/**
|
||||
* 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;
|
||||
```
|
||||
|
||||
Commit 15 of the PR. The developer override has to carry a whole transport so
|
||||
that a `url`-only or combined shape can be tested against a homeserver that
|
||||
does not advertise one yet; a URL cannot say which shape it is. This also
|
||||
removes the one piece of LiveKit knowledge that sat outside the backend:
|
||||
`LocalTransport.ts` and `TransportAutoDiscovery.ts` no longer build a
|
||||
`{type: "livekit", livekit_service_url}` object and filter by `transportType`
|
||||
only, which `LocalTransport.ts` had marked as the moment to do this. The
|
||||
host turns its config URL into a transport (`InCallView.rtcParticipationOptions`).
|
||||
|
||||
### `TransportMetadata` is unchanged, with one documented limit
|
||||
|
||||
`TransportMetadata.id` is still the raw transport serialised with sorted keys.
|
||||
The LiveKit backend now keys its connections on the transport plus the server
|
||||
name where the transport has a `url` (commits 12 and 21), so two homeservers
|
||||
advertising the same `url` get two connections but one `TransportMetadata`.
|
||||
`connectedTransports$` deduplicates, and `resolved$` reports the first
|
||||
connection's resolution. This is diagnostics only; the media and data paths
|
||||
go through the backend's own keys. Extending `TransportMetadata` with the
|
||||
server name was left out to keep the type transport neutral; a developer panel
|
||||
that needs it can be given a `serverName` on `BackendConnection` later.
|
||||
|
||||
### Related, outside `api.ts`
|
||||
|
||||
- `media-backend/api.ts`: `MediaBackendContext.slotId` (commit 8). The
|
||||
client-server API issues a token for a slot, so the backend needs it;
|
||||
`RTCParticipation` reads it from the js-sdk session.
|
||||
- `sdk/index.ts`: `authenticateWithTransport` is now the auth facade's
|
||||
`getSFUConfig({client, membership, transport, serverName, roomId, slotId, role, logger})`
|
||||
(object parameter, whole transport, server name); `AuthenticateWithTransportParams`
|
||||
is exported for it; `TokenEndpoint` is gone.
|
||||
- `createLivekitBackend` options: `tokenEndpoint` is gone; `client` now picks
|
||||
`_unstable_getLivekitToken` and `_unstable_delegateDelayedLeave` as well.
|
||||
- `config.ts`: the `MatrixRTCMode` doc no longer claims the mode picks the
|
||||
JWT endpoint (commit 23).
|
||||
|
||||
## Behaviour, side by side
|
||||
|
||||
| Behaviour | PR on `main` | This branch |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Endpoint choice | `chooseAuthFlow` in `src/livekit/auth` | Same function, `sdk/src/media-backend/livekit/auth/index.ts` |
|
||||
| Join waits for the publisher's token | `authenticated$` gate in `LocalMember.ts` | `prepareLocalTransport` resolves on the publisher connection's `authenticated` state; `LocalMembership` joins after it |
|
||||
| Delegation probe | The flow's service only, in `LocalMember.joinParams$` | The flow's service only, in `prepareLocalTransport` |
|
||||
| Delegation handover | `delegateDelayedLeave` facade from `LocalMember` | Same facade from `LivekitBackend.delegateDelayedLeave`, with the delay timeout from the session timings instead of `Config` |
|
||||
| Transport keys and connection sharing | `ConnectionMap.keyFor` on `TransportLocator` | Same, moved |
|
||||
| Server names | `membershipsAndTransports$` from the user id; `getLocalTransport` from `client.getDomain()` | `serverNameOf(userId)` for members; our own membership identity's user id for the local transport |
|
||||
| Identity matching | `membership.backendIdentities.includes(p.identity)` | `member.backendIdentities.includes(p.identity)` in `mediaFor` and for data packets |
|
||||
| Dev transport override | `customTransport` setting → `getLocalTransport` | `customTransport` setting → `RTCParticipationOptions.transport` |
|
||||
| Config fallback | `RtcTransportAutoDiscovery.tryConfigTransport` | Host builds the transport, `RTCParticipationOptions.fallbackTransport` |
|
||||
| Error for a `url` host without MSC4195 | `NoMatrix2AuthorizationService` from the publisher connection | Same error, surfaced through `prepareLocalTransport` → `state$ failed` → `fromMatrixRTCError` |
|
||||
|
||||
One deliberate difference: on `main` the publisher connection fetches its token
|
||||
on every (re)creation and nothing else does; here it is the same, since the
|
||||
up-front token fetch in `prepareLocalTransport` is gone. There is no second
|
||||
token request per join.
|
||||
|
||||
## Where the PR's new tests went
|
||||
|
||||
| PR test | Here |
|
||||
| ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `src/livekit/auth/index.test.ts` | `sdk/src/media-backend/livekit/auth/index.test.ts`. Rewritten on the network layer (fetch-mock for the JWT service, mocked client methods) because a `vi.mock` of the sibling `openID` module does not reach the facade's own import under `sdk/`. Same cases, plus a table for `chooseAuthFlow`. |
|
||||
| `src/livekit/auth/openID.test.ts` | `sdk/src/media-backend/livekit/auth/openID.test.ts`, replacing `openIDSFU.test.ts` |
|
||||
| `ConnectionMap.test.ts` (incl. commit 21's sharing tests) | `sdk/src/media-backend/livekit/ConnectionMap.test.ts`, replacing `ConnectionManager.test.ts`; plus a role-tagging test |
|
||||
| `Connection.test.ts` changes | `sdk/src/media-backend/livekit/Connection.test.ts`; plus a publisher-through-the-JWT-service case |
|
||||
| `LocalMember.test.ts` "joins session with %s delegation support" (by transport shape) | `LivekitBackend.test.ts` "prepares the local transport and asks %s about delegation", "waits for the publisher connection to hold its token before the join", "fails the preparation with the publisher connection's error", "delegates the leave through the flow that authenticated the publisher" |
|
||||
| `MatrixLivekitMembers.test.ts` two-candidate-identity tests | `LivekitBackend.test.ts` › `mediaFor` |
|
||||
| `SessionBehaviors.test.ts` server-name test | `LivekitBackend.test.ts` "connects to a remote member's transport on the member's homeserver" |
|
||||
| `LocalTransport.test.ts` / `RtcTransportAutoDiscovery.test.ts` changes | `sdk/src/matrixrtc/TransportAutoDiscovery.test.ts` (fallback is a transport) |
|
||||
| `DeveloperSettingsTab.test.tsx` custom transport cases | `src/settings/DeveloperSettingsTab.test.tsx`, snapshot updated for the label |
|
||||
| `DataChannel`, `LivekitBackend` registry tests | Adapted to the state union and `TransportLocator` |
|
||||
| `playwright/url-transport.spec.ts`, `playwright/widget/url-transport.test.ts` | Copied as is: they drive the app, which behaves the same |
|
||||
|
||||
The PR's `CallViewModel.test.ts`, `CallViewModelTestUtils.ts`, `LocalMember.test.ts`
|
||||
(the rest) and `MatrixAudioRenderer.test.tsx` edits have no counterpart: those
|
||||
modules were replaced by the SDK before this merge.
|
||||
|
||||
## One fix the e2e run forced
|
||||
|
||||
The bumped js-sdk sets `rtcBackendIdentity` to the legacy `${userId}:${deviceId}`
|
||||
identity for every membership (it is deprecated there; the hashed one now only
|
||||
appears in `backendIdentities`). On this branch the call view model keyed its
|
||||
tiles by `[dup, mediaId, rtcBackendIdentity]`, so a device that rejoined after an
|
||||
improper leave, which has two memberships until the stale one expires, produced
|
||||
duplicate keys and crashed the host's page with `UNKNOWN_ERROR`
|
||||
(`spa-call-sticky.spec.ts`, "rejoin after improper leave"). The key is now the
|
||||
membership's own `memberId`, and `MemberAudioRenderer` and the SDK harness key
|
||||
by it too. The PR on `main` keyed by the participant object as well and did not
|
||||
hit this.
|
||||
|
||||
## Coverage added on the package branch, and one bug it found
|
||||
|
||||
The PR's tests mostly covered the auth layer; the SDK modules under them had
|
||||
little or none. Added on the package branch, which this branch is rebased onto:
|
||||
|
||||
| Module | Test |
|
||||
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `media-backend/livekit/Publisher.ts` | `Publisher.test.ts`: the cases from `main`'s `localMember/Publisher.test.ts` on a real `LocalParticipant` with faked device and signalling calls, plus publish, unpublish and destroy |
|
||||
| `media-backend/livekit/LocalMedia.ts` | `LocalMedia.test.ts`: on a fake connection map; publish before and after the connection, republish on a new connection, mutes remembered, first publish error, output device |
|
||||
| `RTCParticipation.ts` | `RTCParticipation.test.ts`: on a fake media backend; join options, state machine, publishing only while joined, delegation, member ids per mode, key replay, data sender check |
|
||||
| `matrixrtc/LocalTransport.ts` | `LocalTransport.test.ts`: override, homeserver, fallback, none |
|
||||
| `matrixrtc/joinJsSdkSession.ts` | `joinJsSdkSession.test.ts`: the js-sdk join options, the retry budget, the intent and sticky events |
|
||||
| `media-backend/livekit/publishOptions.ts` | `publishOptions.test.ts` |
|
||||
| `media-backend/livekit/ConnectionFactory.ts` | `ConnectionFactory.test.ts`: the room options from the requests and media quality (the cases from `main`'s `ECConnectionFactory.test.ts`), `resolved$` |
|
||||
| `LivekitBackend.ts` › `mediaFor` | A member on another transport, a member changing transport, a member without one (from `main`'s `MatrixLivekitMembers.test.ts`) |
|
||||
|
||||
`ConnectionFactory.test.ts` reads the options off the real LiveKit room the
|
||||
factory builds instead of mocking `livekit-client`: a `vi.mock` of the package
|
||||
reached the factory's import on the package branch but not on this one.
|
||||
|
||||
The `LocalMedia` tests found a race in `ObservableScope.reconcile`: the loop was
|
||||
marked as running until the promise of its last run had settled, a few
|
||||
microtasks after it had last checked for new values, so a value arriving in
|
||||
that window (a connection appearing at just that moment) was never processed.
|
||||
Fixed in `[SDK] Keep a value that reaches reconcile as its loop winds down`,
|
||||
with a test that drives the window microtask by microtask. Element Call's
|
||||
`src/state/ObservableScope.ts` re-exports the SDK's class, so the fix applies
|
||||
to the app as well.
|
||||
|
||||
## Checked
|
||||
|
||||
- `pnpm lint` (tsc, oxlint, knip, externals), `pnpm format:check`, `pnpm i18n:check`.
|
||||
- `pnpm test`: 119 unit files, 950 tests; 4 storybook files, 45 tests (after the coverage above).
|
||||
- Playwright on Chromium against the local Playwright backend, one worker:
|
||||
- `playwright/url-transport.spec.ts`: 10 of 10 pass, including delegation
|
||||
through the homeserver and the JWT service, the subscriber fallback, the
|
||||
publisher error and the federated token.
|
||||
- `playwright/widget/url-transport.test.ts`: passes.
|
||||
- The rest of `playwright/*.spec.ts`, `playwright/component`, `playwright/sdk`:
|
||||
48 pass, 1 skipped, 1 fails (`reconnect.spec.ts`, which fails on `main`
|
||||
locally too because the local JWT service is slower than the spec's wait).
|
||||
- `playwright/widget`: 7 of 10 pass, including `federated-call`. `huddle-call`
|
||||
fails before any call code runs (the Element Web develop image's room list no
|
||||
longer matches the helper's "Open room" selector), and `screen-share` looks
|
||||
for `video[data-lk-source="screen_share"]`, an attribute the LiveKit React
|
||||
components set and the SDK's plain `<video>` elements do not: a failure of this
|
||||
branch that predates the PR.
|
||||
- Local environment work needed for that: the othersite JWT service and nginx
|
||||
containers had to be recreated from the compose file, two stale containers held
|
||||
port 16080, and the widget helper's hard-coded `https://localhost:3000` was
|
||||
pointed at 3100 for the run only, since port 3000 is taken on this machine.
|
||||
- Standalone, widget and component all go through `InCallView.rtcParticipationOptions`
|
||||
and the SDK, so the change is the same in all three modes; the widget-specific
|
||||
fallback (a host without MSC4533) is in `isCsApiUnavailable`.
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
# Coverage comes from a single vitest run (see .github/workflows/test.yaml), so
|
||||
# the app and the SDK are split by path into codecov components rather than
|
||||
# uploaded under separate flags. Each component gets its own coverage figure in
|
||||
# the PR comment and on codecov.io.
|
||||
#
|
||||
# Validate with: curl --data-binary @codecov.yml https://codecov.io/validate
|
||||
|
||||
comment:
|
||||
layout: "reach, diff, flags, components, files"
|
||||
|
||||
component_management:
|
||||
individual_components:
|
||||
- component_id: app
|
||||
name: app
|
||||
paths:
|
||||
- src/**
|
||||
- component_id: sdk
|
||||
name: sdk
|
||||
paths:
|
||||
- sdk/**
|
||||
+1
-1
@@ -74,7 +74,7 @@ import {
|
||||
import { MediaDevicesContext } from "../src/MediaDevicesContext";
|
||||
import { MediaDevices } from "../src/state/MediaDevices";
|
||||
import { ObservableScope } from "../src/state/ObservableScope";
|
||||
import { ProcessorProvider } from "../src/livekit/TrackProcessorContext";
|
||||
import { ProcessorProvider } from "../src/tracks/TrackProcessorContext";
|
||||
import { Config } from "../src/config/Config";
|
||||
import { type ConfigOptions } from "../src/config/ConfigOptions";
|
||||
import { i18n } from "../src/utils/i18n";
|
||||
|
||||
@@ -19,7 +19,7 @@ services:
|
||||
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
||||
element-web:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:d43e8d26361b546a9ceed13602cc13892ff212df7024b2282147ee8ec370977e
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:b2a5a30afb4f583a7560448b005d8332bee29672f15826e4a995b393058f0291
|
||||
element-web-1:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:d43e8d26361b546a9ceed13602cc13892ff212df7024b2282147ee8ec370977e
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:b2a5a30afb4f583a7560448b005d8332bee29672f15826e4a995b393058f0291
|
||||
|
||||
@@ -72,7 +72,15 @@ Code that builds in only one is a bug.
|
||||
|
||||
- `build:full` — standalone app, also widget mode.
|
||||
- `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/`
|
||||
(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` /
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
- `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.
|
||||
|
||||
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 test:playwright # or :open
|
||||
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-top-level-logger-get-child": require("./NoTopLevelLoggerGetChild")
|
||||
.default,
|
||||
"sdk-import-boundary": require("./SdkImportBoundary").default,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,6 +13,8 @@ export default {
|
||||
"vite.config.ts",
|
||||
"vite-embedded.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-dev.config.ts",
|
||||
],
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@
|
||||
"insufficient_capacity_description": "The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.",
|
||||
"livekit_connection_error": "Failed to connect to Livekit server",
|
||||
"livekit_connection_error_description": "An error occurred while connecting to the Livekit server (<1>Reason:</1> <2>{{ reason }}</2>).",
|
||||
"matrix_rtc_transport_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).",
|
||||
"matrix_rtc_transport_missing": "The server is not configured to work with {{brand}} and no other call participant provides a usable transport. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).",
|
||||
"membership_manager": "Membership Manager Error",
|
||||
"membership_manager_description": "The Membership Manager had to shut down. This is caused by many consecutive failed network requests.",
|
||||
"no_matrix_2_authorization_service": "The authorization service for your media server (SFU) is out of date.",
|
||||
|
||||
+10
-6
@@ -7,6 +7,7 @@
|
||||
"dev:full": "vite",
|
||||
"dev:embedded": "vite --config vite-embedded.config.js",
|
||||
"dev:component": "vite --config vite-component-dev.config.ts",
|
||||
"dev:sdk": "vite --config vite-sdk-dev.config.ts",
|
||||
"build": "pnpm build:full",
|
||||
"build:full": "node --max-old-space-size=16384 node_modules/vite/bin/vite.js build",
|
||||
"build:full:production": "pnpm build:full",
|
||||
@@ -14,9 +15,12 @@
|
||||
"build:embedded": "pnpm build:full --config vite-embedded.config.js",
|
||||
"build:embedded:production": "pnpm build:embedded",
|
||||
"build:embedded:development": "pnpm build:embedded --mode development",
|
||||
"build:sdk:development": "pnpm build:sdk --mode development",
|
||||
"build:sdk": "pnpm build:full --config vite-sdk.config.js",
|
||||
"build:sdk:production": "pnpm build:sdk",
|
||||
"build:sdk": "pnpm build:sdk:js && pnpm build:sdk:types",
|
||||
"build:sdk:js": "pnpm build:full --config vite-sdk.config.js",
|
||||
"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:js": "pnpm build:full --config vite-component.config.js",
|
||||
"build:component:types": "tsc -p component/tsconfig.build.json",
|
||||
@@ -26,8 +30,8 @@
|
||||
"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",
|
||||
"lint": "pnpm lint:types && pnpm lint:oxlint && pnpm lint:knip && pnpm lint:externals",
|
||||
"lint:oxlint": "oxlint src component playwright",
|
||||
"lint:oxlint-fix": "oxlint --fix src component playwright",
|
||||
"lint:oxlint": "oxlint src component sdk playwright",
|
||||
"lint:oxlint-fix": "oxlint --fix src component sdk playwright",
|
||||
"lint:knip": "knip",
|
||||
"lint:externals": "node scripts/check-component-externals.mjs",
|
||||
"lint:types": "tsc",
|
||||
@@ -103,7 +107,7 @@
|
||||
"livekit-client": "^2.18.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"loglevel": "^1.9.1",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#3f05dc4d104a3dfc753c5e3549a18c74fab4b076",
|
||||
"matrix-widget-api": "^1.18.0",
|
||||
"node-stdlib-browser": "^1.3.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
|
||||
@@ -12,6 +12,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { COMPONENT_HARNESS_URL } from "./playwright/component/harness.ts";
|
||||
import { SDK_HARNESS_URL } from "./playwright/sdk/harness.ts";
|
||||
|
||||
const baseURL = process.env.USE_DOCKER
|
||||
? "http://localhost:8080"
|
||||
@@ -158,5 +159,17 @@ export default defineConfig({
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -76,9 +76,9 @@ widgetTest("Sharing screen in group call", async ({ addUser, browserName }) => {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
frame.locator('video[data-lk-source="screen_share"]'),
|
||||
).toHaveCount(1);
|
||||
await expect(frame.locator('video[data-source="screenShare"]')).toHaveCount(
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
// Alice should be in grid mode as she is local sharing
|
||||
@@ -117,9 +117,9 @@ widgetTest("Sharing screen in group call", async ({ addUser, browserName }) => {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
frame.locator('video[data-lk-source="screen_share"]'),
|
||||
).toHaveCount(2);
|
||||
await expect(frame.locator('video[data-source="screenShare"]')).toHaveCount(
|
||||
2,
|
||||
);
|
||||
|
||||
// Expect 2 indicators at the bottom
|
||||
await expect(frame.getByTestId("screenshare-indicator")).toHaveCount(2);
|
||||
|
||||
@@ -66,13 +66,9 @@ widgetTest(
|
||||
await TestHelpers.expectVisibleVideoCount(frame, 2);
|
||||
}
|
||||
|
||||
// Element Web does not expose the MSC4195 endpoints to widgets yet, so
|
||||
// neither side reached the CS API. The host published through the JWT
|
||||
// service's legacy endpoint and subscribed to the guest through its
|
||||
// default one; the guest published the same way and fell back to the
|
||||
// default endpoint for the host's transport.
|
||||
// Element Web used for Playwright does support MSC4195 for widgets.
|
||||
expect(hostFlows).toEqual(new Set(["service-legacy", "service-default"]));
|
||||
expect(guestFlows).toEqual(new Set(["service-legacy", "service-default"]));
|
||||
expect(guestFlows).toEqual(new Set(["service-legacy", "cs-api"]));
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Generated
+6
-6
@@ -191,8 +191,8 @@ importers:
|
||||
specifier: ^1.9.1
|
||||
version: 1.9.2
|
||||
matrix-js-sdk:
|
||||
specifier: github:matrix-org/matrix-js-sdk#develop
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/4df2f2bfb5826c25f05d9535a8556acecaf2e570
|
||||
specifier: github:matrix-org/matrix-js-sdk#3f05dc4d104a3dfc753c5e3549a18c74fab4b076
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3f05dc4d104a3dfc753c5e3549a18c74fab4b076
|
||||
matrix-widget-api:
|
||||
specifier: ^1.18.0
|
||||
version: 1.20.0
|
||||
@@ -4673,9 +4673,9 @@ packages:
|
||||
matrix-events-sdk@0.0.1:
|
||||
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/4df2f2bfb5826c25f05d9535a8556acecaf2e570:
|
||||
resolution: {gitHosted: true, integrity: sha512-b1o6ywaPY9jakj+3GIgx3samM9AbqohzPQ5nZKbyOGYB16IHQtNMyjEo/PY0fW/s0uwBVApEHNb/MMtN40c/rQ==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/4df2f2bfb5826c25f05d9535a8556acecaf2e570}
|
||||
version: 43.0.0
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3f05dc4d104a3dfc753c5e3549a18c74fab4b076:
|
||||
resolution: {gitHosted: true, integrity: sha512-Ly8eS7fNNKDo9KmPvTkE49KvD8iMUmrVWJMNERNuxc8UksMLMG36NLz3fs8HytdwrB7AKPfyVo392VZsxILDIg==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3f05dc4d104a3dfc753c5e3549a18c74fab4b076}
|
||||
version: 44.0.0
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
matrix-widget-api@1.20.0:
|
||||
@@ -10227,7 +10227,7 @@ snapshots:
|
||||
|
||||
matrix-events-sdk@0.0.1: {}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/4df2f2bfb5826c25f05d9535a8556acecaf2e570:
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3f05dc4d104a3dfc753c5e3549a18c74fab4b076:
|
||||
dependencies:
|
||||
'@babel/runtime': 8.0.0
|
||||
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
|
||||
|
||||
@@ -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
|
||||
* the host.
|
||||
* Checks that the library builds leave the packages a host must supply to the
|
||||
* host.
|
||||
*
|
||||
* 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
|
||||
* 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
|
||||
* names the host's stylesheet does not know. So the
|
||||
* component build lists them as external — but that list has to name every
|
||||
* subpath, since the bundler silently ignores the pattern and callback forms
|
||||
* of the option, and an import it does not cover is bundled with no warning at
|
||||
* all. That is the failure this guards against.
|
||||
* names the host's stylesheet does not know. So each library build lists them
|
||||
* as external — but that list has to name every subpath, since the bundler
|
||||
* silently ignores the pattern and callback forms of the option, and an import
|
||||
* it does not cover is bundled with no warning at all. That is the failure
|
||||
* this guards against.
|
||||
*
|
||||
* It reads the 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.
|
||||
* It reads each list from the build config itself, so there is one copy of it
|
||||
* 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`
|
||||
* rather than only the modules the component actually pulls in, so it will
|
||||
* sometimes ask for a subpath that only the standalone app imports. Listing
|
||||
* one the component never imports costs nothing — the bundler ignores it —
|
||||
* whereas missing one costs a duplicate package.
|
||||
* rather than only the modules a build actually pulls in, so it will sometimes
|
||||
* ask for a subpath that only the standalone app imports. Listing one a build
|
||||
* never imports costs nothing — the bundler ignores it — whereas missing one
|
||||
* costs a duplicate package.
|
||||
*/
|
||||
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { loadConfigFromFile } from "vite";
|
||||
|
||||
const CONFIG = "vite-component.config.ts";
|
||||
const SOURCES = ["src", "component"];
|
||||
// The development harness is a host, not part of the component: it is the
|
||||
// one that imports what a host supplies (Compound's stylesheets, say).
|
||||
const EXCLUDED = ["component/dev"];
|
||||
|
||||
/** The packages whose duplication would break a host, rather than merely enlarge it. */
|
||||
const MUST_BE_EXTERNAL = [
|
||||
"react",
|
||||
"react-dom",
|
||||
"matrix-js-sdk",
|
||||
"@vector-im/compound-web",
|
||||
"@vector-im/compound-design-tokens",
|
||||
/**
|
||||
* The library builds, each with the directories it is built from and the
|
||||
* packages whose duplication would break a host rather than merely enlarge it.
|
||||
* A development harness is a host, not part of the library: it is the one that
|
||||
* imports what a host supplies (Compound's stylesheets, say).
|
||||
*/
|
||||
const TARGETS = [
|
||||
{
|
||||
config: "vite-component.config.ts",
|
||||
what: "the component build",
|
||||
sources: ["src", "component"],
|
||||
excluded: ["component/dev"],
|
||||
mustBeExternal: [
|
||||
"react",
|
||||
"react-dom",
|
||||
"matrix-js-sdk",
|
||||
"@vector-im/compound-web",
|
||||
"@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) =>
|
||||
name.includes(".test.") || name.includes(".stories.");
|
||||
|
||||
/** 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 })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (EXCLUDED.includes(path)) continue;
|
||||
if (entry.isDirectory()) yield* sourceFiles(path);
|
||||
if (excluded.includes(path)) continue;
|
||||
if (entry.isDirectory()) yield* sourceFiles(path, excluded);
|
||||
else if (/\.(ts|tsx)$/.test(entry.name) && !isTestFile(entry.name))
|
||||
yield path;
|
||||
}
|
||||
@@ -88,60 +103,60 @@ function imports(source) {
|
||||
* with the host's copy of anything. Worker sub-builds do not inherit this
|
||||
* option anyway.
|
||||
*/
|
||||
const mustBeExternal = (specifier) =>
|
||||
const mustBeExternal = (specifier, packages) =>
|
||||
!specifier.includes("?") &&
|
||||
MUST_BE_EXTERNAL.some(
|
||||
(pkg) => specifier === pkg || specifier.startsWith(`${pkg}/`),
|
||||
);
|
||||
packages.some((pkg) => specifier === pkg || specifier.startsWith(`${pkg}/`));
|
||||
|
||||
const loaded = await loadConfigFromFile(
|
||||
{ command: "build", mode: "production" },
|
||||
CONFIG,
|
||||
);
|
||||
if (loaded === null) {
|
||||
console.error(`Could not load ${CONFIG}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const declared = new Set(loaded.config.build?.rollupOptions?.external ?? []);
|
||||
if (declared.size === 0) {
|
||||
console.error(
|
||||
`${CONFIG} declares nothing external. Either the option moved, or the ` +
|
||||
`list is empty; either way this check is not looking at what it thinks.`,
|
||||
/** @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" },
|
||||
config,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Where each missing specifier is imported, so the message can point at it
|
||||
const missing = new Map();
|
||||
const seen = new Set();
|
||||
for (const dir of SOURCES)
|
||||
for await (const file of sourceFiles(dir)) {
|
||||
const source = await readFile(file, "utf8");
|
||||
for (const specifier of imports(source)) {
|
||||
if (!mustBeExternal(specifier)) continue;
|
||||
seen.add(specifier);
|
||||
if (declared.has(specifier)) continue;
|
||||
const files = missing.get(specifier) ?? [];
|
||||
files.push(file);
|
||||
missing.set(specifier, files);
|
||||
}
|
||||
if (loaded === null) {
|
||||
console.error(`Could not load ${config}`);
|
||||
return false;
|
||||
}
|
||||
const declared = new Set(loaded.config.build?.rollupOptions?.external ?? []);
|
||||
if (declared.size === 0) {
|
||||
console.error(
|
||||
`${config} declares nothing external. Either the option moved, or the ` +
|
||||
`list is empty; either way this check is not looking at what it thinks.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (missing.size > 0) {
|
||||
// Where each missing specifier is imported, so the message can point at it
|
||||
const missing = new Map();
|
||||
for (const dir of sources)
|
||||
for await (const file of sourceFiles(dir, excluded)) {
|
||||
const source = await readFile(file, "utf8");
|
||||
for (const specifier of imports(source)) {
|
||||
if (!mustBeExternal(specifier, packages)) continue;
|
||||
if (declared.has(specifier)) continue;
|
||||
const files = missing.get(specifier) ?? [];
|
||||
files.push(file);
|
||||
missing.set(specifier, files);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.size === 0) return true;
|
||||
console.error(
|
||||
`${CONFIG} does not declare these imports external, so the component ` +
|
||||
`build would bundle its own copy of them:\n`,
|
||||
`${config} does not declare these imports external, so ${what} ` +
|
||||
`would bundle its own copy of them:\n`,
|
||||
);
|
||||
for (const [specifier, files] of [...missing].sort())
|
||||
console.error(` ${specifier}\n imported by ${files.join(", ")}`);
|
||||
console.error(`\nAdd each one to the \`external\` list in ${CONFIG}.`);
|
||||
process.exit(1);
|
||||
console.error(`\nAdd each one to the \`external\` list in ${config}.\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Deliberately no complaint about declarations nothing imports. Some of them
|
||||
// cannot be seen from the source at all — `react/jsx-runtime` is injected by
|
||||
// the JSX transform — and an extra declaration is inert, so there is nothing
|
||||
// to warn about.
|
||||
console.log(
|
||||
`${declared.size} external declarations cover all ${seen.size} imports of ${MUST_BE_EXTERNAL.join(", ")}.`,
|
||||
);
|
||||
let ok = true;
|
||||
for (const target of TARGETS) ok = (await check(target)) && ok;
|
||||
if (!ok) process.exit(1);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# SDK mode (LEGACY)
|
||||
|
||||
> Superseded by the `sdk/` package (`@element-hq/matrixrtc-sdk`) and its harness in
|
||||
> `sdk/dev`; see `sdk/SdkArchitecture.md`. This bundle is in production use, so it
|
||||
> keeps building as a thin layer over that package until its consumers have moved
|
||||
> to the package directly. It will then be removed. Its members are the package's
|
||||
> `RTCMember`s now, in place of its own wrapper around the raw membership.
|
||||
|
||||
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`)
|
||||
@@ -30,12 +30,12 @@
|
||||
div.appendChild(child);
|
||||
});
|
||||
|
||||
sdk.members$.subscribe((memberObjects) => {
|
||||
sdk.remoteMembers$.subscribe((memberObjects) => {
|
||||
const div = document.getElementById("members");
|
||||
div.innerHTML = "<h3>Members:</h3>";
|
||||
|
||||
// Create member list
|
||||
const members = memberObjects.map((member) => member.membership.sender);
|
||||
const members = memberObjects.map((member) => member.userId);
|
||||
console.info("members changed", members);
|
||||
for (const m of members) {
|
||||
console.info("member", m);
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
Copyright 2025-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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* LEGACY, in production use
|
||||
*
|
||||
* The entrypoint of `pnpm build:sdk-target-based-on-call-view-model`, a
|
||||
* bundle for widgets that exposes `createMatrixRTCSdk`: join a slot and
|
||||
* exchange realtime data, with the `@element-hq/matrixrtc-sdk` package and
|
||||
* Element Call's call view model doing the work underneath. New consumers use
|
||||
* the package directly; this bundle goes away once the existing ones have.
|
||||
*/
|
||||
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { EMPTY, filter, map, type Observable, of, switchMap, take } from "rxjs";
|
||||
import {
|
||||
type Behavior,
|
||||
createRTCSlot,
|
||||
E2eeType,
|
||||
type LocalRTCMember,
|
||||
ObservableScope,
|
||||
type RemoteRTCMember,
|
||||
type RTCParticipation,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
import {
|
||||
callViewModelOptionsFromParams,
|
||||
type CallViewModel,
|
||||
createCallViewModel$,
|
||||
initialPublishRequests,
|
||||
} from "../src/state/CallViewModel/CallViewModel";
|
||||
import {
|
||||
captureSettings,
|
||||
rtcParticipationOptions,
|
||||
rtcSlotOptions,
|
||||
} from "../src/room/InCallView";
|
||||
import { createWidgetHostBridge } from "../src/HostBridge";
|
||||
import { MediaDevices } from "../src/state/MediaDevices";
|
||||
import { MuteStates } from "../src/state/MuteStates";
|
||||
import { getUrlParams } from "../src/UrlParams";
|
||||
import { observeElementSize$ } from "../src/utils/elementSize";
|
||||
import { initializeWidget } from "../src/widget";
|
||||
|
||||
/** The data channel topic this bundle has always used, so that old and new builds talk. */
|
||||
const TOPIC = "matrixRTC";
|
||||
|
||||
export interface MatrixRTCSdk {
|
||||
/** Joins the slot; `connected$` tracks the progress. A second call does nothing. */
|
||||
join: () => void;
|
||||
leave: () => void;
|
||||
/** Ends everything. The sdk cannot be restarted; create a new one. */
|
||||
stop: () => void;
|
||||
connected$: Behavior<boolean>;
|
||||
/** Our own member, null until our membership has been seen in the room. */
|
||||
localMember$: Behavior<LocalRTCMember | null>;
|
||||
/** The other members of the slot, with the media the participation carries for them. */
|
||||
remoteMembers$: Behavior<RemoteRTCMember[]>;
|
||||
/** What other members sent with `sendData`. */
|
||||
data$: Observable<{ rtcBackendIdentity: string; data: string }>;
|
||||
/** Sends to every member on our transport. Rejects while not joined. */
|
||||
sendData: (data: unknown) => Promise<void>;
|
||||
sendRoomMessage: (message: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export async function createMatrixRTCSdk(
|
||||
application = "m.call",
|
||||
id = "",
|
||||
sticky = false,
|
||||
): Promise<MatrixRTCSdk> {
|
||||
const logger = rootLogger.getChild("[MatrixRTCSdk]");
|
||||
const scope = new ObservableScope();
|
||||
const widget = initializeWidget(application, true);
|
||||
if (!widget) throw new Error("No widget: this bundle only runs as a widget");
|
||||
const client = await widget.client;
|
||||
const hostBridge = createWidgetHostBridge(widget);
|
||||
const urlParams = getUrlParams();
|
||||
const { roomId, controlledAudioDevices, callIntent } = urlParams;
|
||||
const room = roomId === null ? null : client.getRoom(roomId);
|
||||
if (room === null) throw new Error("No room: the url needs a joined roomId");
|
||||
|
||||
const mediaDevices = new MediaDevices(scope, {
|
||||
controlledAudioDevices,
|
||||
callIntent,
|
||||
});
|
||||
const muteStates = new MuteStates(
|
||||
scope,
|
||||
mediaDevices,
|
||||
{ audioEnabled: false, videoEnabled: false },
|
||||
hostBridge,
|
||||
);
|
||||
const encryptionSystem = { kind: E2eeType.PER_PARTICIPANT } as const;
|
||||
const capture = captureSettings();
|
||||
const slot = createRTCSlot(scope, client, room, {
|
||||
...rtcSlotOptions(encryptionSystem),
|
||||
application,
|
||||
id,
|
||||
});
|
||||
let vm: CallViewModel | undefined;
|
||||
const failed =
|
||||
(what: string) =>
|
||||
(e: unknown): void =>
|
||||
logger.error(what, e);
|
||||
|
||||
const over = <T>(
|
||||
pick: (participation: RTCParticipation) => Observable<T>,
|
||||
fallback: T,
|
||||
): Behavior<T> =>
|
||||
scope.behavior(
|
||||
slot.participation$.pipe(
|
||||
switchMap((p) => (p === null ? of(fallback) : pick(p))),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
join: (): void => {
|
||||
if (slot.participation$.value !== null) return;
|
||||
if (sticky)
|
||||
widget.api
|
||||
.setAlwaysOnScreen(true)
|
||||
.catch(failed("Failed to make the widget sticky"));
|
||||
const participation = slot.join({
|
||||
...rtcParticipationOptions(urlParams),
|
||||
publish: initialPublishRequests(muteStates, mediaDevices, capture),
|
||||
});
|
||||
vm = createCallViewModel$(
|
||||
scope,
|
||||
participation,
|
||||
room,
|
||||
mediaDevices,
|
||||
muteStates,
|
||||
{
|
||||
...callViewModelOptionsFromParams(urlParams),
|
||||
encryptionSystem,
|
||||
capture,
|
||||
hostBridge,
|
||||
// The bundle owns its page, so the body is the space it has
|
||||
windowSize$: scope.behavior(observeElementSize$(document.body)),
|
||||
},
|
||||
of({}),
|
||||
of({}),
|
||||
);
|
||||
// Once the hang-up has gone through, unstick and ask the host to close us
|
||||
vm.leave$
|
||||
.pipe(
|
||||
switchMap(() =>
|
||||
participation.state$.pipe(
|
||||
filter((state) => state.kind === "left"),
|
||||
take(1),
|
||||
),
|
||||
),
|
||||
scope.bind(),
|
||||
)
|
||||
.subscribe(() => {
|
||||
hostBridge
|
||||
.setAlwaysOnScreen(false)
|
||||
.catch(failed("Failed to unstick the widget"))
|
||||
.then(() => hostBridge.close?.())
|
||||
.catch(failed("Failed to ask the host to close"));
|
||||
});
|
||||
},
|
||||
leave: (): void => vm?.leave(),
|
||||
stop: (): void => scope.end(),
|
||||
connected$: over(
|
||||
(p) => p.state$.pipe(map((state) => state.kind === "connected")),
|
||||
false,
|
||||
),
|
||||
localMember$: over((p) => p.localMember$, null),
|
||||
remoteMembers$: over((p) => p.remoteMembers$, []),
|
||||
// Attested members only: the SDK has matched the sender
|
||||
data$: slot.participation$.pipe(
|
||||
switchMap((p) => p?.data$ ?? EMPTY),
|
||||
filter(({ topic }) => topic === TOPIC),
|
||||
map(({ member, text }) => ({
|
||||
rtcBackendIdentity: member.rtcBackendIdentity,
|
||||
data: text,
|
||||
})),
|
||||
),
|
||||
sendData: async (data): Promise<void> => {
|
||||
const participation = slot.participation$.value;
|
||||
if (participation === null) throw new Error("Not joined");
|
||||
await participation.sendData(TOPIC, JSON.stringify(data));
|
||||
},
|
||||
sendRoomMessage: async (message): Promise<void> => {
|
||||
await client.sendTextMessage(room.roomId, message);
|
||||
},
|
||||
};
|
||||
}
|
||||
+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));
|
||||
});
|
||||
|
||||
```
|
||||
pnpm install
|
||||
pnpm build:sdk
|
||||
// later
|
||||
participation.leave();
|
||||
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.
|
||||
|
||||
```
|
||||
/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
|
||||
- [`src/api.ts`](./src/api.ts): the MatrixRTC side. An `RTCSlot` is the
|
||||
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 ...
|
||||
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
|
||||
```sh
|
||||
pnpm add github:element-hq/element-call#<ref>&path:/sdk
|
||||
```
|
||||
|
||||
`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`)
|
||||
The host supplies `matrix-js-sdk`, `livekit-client` and `rxjs`; the build leaves
|
||||
them external.
|
||||
|
||||
@@ -0,0 +1,829 @@
|
||||
# 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 │
|
||||
│ │ (none, LiveKit only: a member's SFU │ │
|
||||
│ │ that lets us publish, get_token) ─▶│ │
|
||||
│ │ 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) with, for the
|
||||
LiveKit backend only, a last resort of another member's SFU that lets us
|
||||
publish (`findRemotePublishLivekitTransport`, wired in by `RTCParticipation` rather
|
||||
than by the backend interface, since it is an MSC4195 matter), 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,
|
||||
};
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This file contains helper functions and types for the MatrixRTC SDK.
|
||||
*/
|
||||
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { scan } from "rxjs";
|
||||
|
||||
import { type WidgetHelpers } from "../src/widget";
|
||||
import { type LivekitRoomItem } from "../src/state/CallViewModel/CallViewModel";
|
||||
|
||||
export const tryMakeSticky = (widget: WidgetHelpers): void => {
|
||||
const logger = rootLogger.getChild("[MatrixRTCSdk]");
|
||||
logger.info("try making sticky MatrixRTCSdk");
|
||||
void widget.api
|
||||
.setAlwaysOnScreen(true)
|
||||
.then(() => {
|
||||
logger.info("sticky MatrixRTCSdk");
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error("failed to make sticky MatrixRTCSdk", error);
|
||||
});
|
||||
};
|
||||
export const TEXT_LK_TOPIC = "matrixRTC";
|
||||
/**
|
||||
* simple helper operator to combine the last emitted and the current emitted value of a rxjs observable
|
||||
*
|
||||
* I think there should be a builtin for this but i did not find it...
|
||||
*/
|
||||
export const currentAndPrev = scan<
|
||||
LivekitRoomItem[],
|
||||
{
|
||||
prev: LivekitRoomItem[];
|
||||
current: LivekitRoomItem[];
|
||||
}
|
||||
>(
|
||||
({ current: lastCurrentVal }, items) => ({
|
||||
prev: lastCurrentVal,
|
||||
current: items,
|
||||
}),
|
||||
{
|
||||
prev: [],
|
||||
current: [],
|
||||
},
|
||||
);
|
||||
@@ -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";
|
||||
-382
@@ -1,382 +0,0 @@
|
||||
/*
|
||||
Copyright 2025-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
|
||||
*
|
||||
* This file is the entrypoint for the sdk build of element call: `pnpm build:sdk`
|
||||
* use in widgets.
|
||||
* It exposes the `createMatrixRTCSdk` which creates the `MatrixRTCSdk` interface (see below) that
|
||||
* can be used to join a rtc session and exchange realtime data.
|
||||
* It takes care of all the tricky bits:
|
||||
* - sending delayed events
|
||||
* - finding the right sfu
|
||||
* - handling the media stream
|
||||
* - sending join/leave state or sticky events
|
||||
* - setting up encryption and scharing keys
|
||||
*/
|
||||
|
||||
import {
|
||||
combineLatest,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
shareReplay,
|
||||
Subject,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
import {
|
||||
type CallMembership,
|
||||
MatrixRTCSessionEvent,
|
||||
MatrixRTCSessionManager,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
type Room as LivekitRoom,
|
||||
type TextStreamReader,
|
||||
type LocalParticipant,
|
||||
type RemoteParticipant,
|
||||
} from "livekit-client";
|
||||
|
||||
// TODO how can this get fixed? to just be part of `livekit-client`
|
||||
// Can this be done in the tsconfig.json
|
||||
import { type TextStreamInfo } from "../node_modules/livekit-client/dist/src/room/types";
|
||||
import { type Behavior, constant } from "../src/state/Behavior";
|
||||
import {
|
||||
callViewModelOptionsFromParams,
|
||||
createCallViewModel$,
|
||||
} from "../src/state/CallViewModel/CallViewModel";
|
||||
import { ObservableScope } from "../src/state/ObservableScope";
|
||||
import { getUrlParams } from "../src/UrlParams";
|
||||
import { MuteStates } from "../src/state/MuteStates";
|
||||
import { MediaDevices } from "../src/state/MediaDevices";
|
||||
import { E2eeType } from "../src/e2ee/e2eeType";
|
||||
import { currentAndPrev, TEXT_LK_TOPIC, tryMakeSticky } from "./helper";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { initializeWidget } from "../src/widget";
|
||||
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
|
||||
import { createWidgetHostBridge } from "../src/HostBridge";
|
||||
import { observeElementSize$ } from "../src/utils/elementSize";
|
||||
|
||||
interface MatrixRTCSdk {
|
||||
/**
|
||||
* observe connected$ to track the state.
|
||||
* @returns
|
||||
*/
|
||||
join: () => void;
|
||||
/** @throws on leave errors */
|
||||
leave: () => void;
|
||||
/**
|
||||
* Ends the rtc sdk. This will unsubscribe any event listeners. And end the associated scope.
|
||||
* No updates can be received from the rtc sdk. The sdk cannot be restarted after.
|
||||
* A new sdk needs to be created via createMatrixRTCSdk.
|
||||
*/
|
||||
stop: () => void;
|
||||
data$: Observable<{ rtcBackendIdentity: string; data: string }>;
|
||||
/**
|
||||
* flattened list of remote members
|
||||
*/
|
||||
remoteMembers$: Behavior<
|
||||
{
|
||||
connection: Connection | null;
|
||||
membership: CallMembership;
|
||||
participant: LocalParticipant | RemoteParticipant | null;
|
||||
}[]
|
||||
>;
|
||||
/**
|
||||
* flattened local member
|
||||
*/
|
||||
localMember$: Behavior<{
|
||||
connection: Connection | null;
|
||||
membership: CallMembership;
|
||||
participant: LocalParticipant | null;
|
||||
} | null>;
|
||||
/** Use the LocalMemberConnectionState returned from `join` for a more detailed connection state */
|
||||
connected$: Behavior<boolean>;
|
||||
sendData?: (data: unknown) => Promise<void>;
|
||||
sendRoomMessage?: (message: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export async function createMatrixRTCSdk(
|
||||
application: string = "m.call",
|
||||
id: string = "",
|
||||
sticky: boolean = false,
|
||||
): Promise<MatrixRTCSdk> {
|
||||
const logger = rootLogger.getChild("[MatrixRTCSdk]");
|
||||
const scope = new ObservableScope();
|
||||
|
||||
// widget client
|
||||
const widget = initializeWidget(application, true);
|
||||
if (!widget) throw Error("No widget. This webapp can only start as a widget");
|
||||
const client = await widget.client;
|
||||
const hostBridge = createWidgetHostBridge(widget);
|
||||
logger.info("client created");
|
||||
|
||||
// url params
|
||||
const urlParams = getUrlParams();
|
||||
const { roomId, controlledAudioDevices, callIntent } = urlParams;
|
||||
if (roomId === null) throw Error("could not get roomId from url params");
|
||||
const room = client.getRoom(roomId);
|
||||
if (room === null) throw Error("could not get room from client");
|
||||
|
||||
// rtc session
|
||||
const slot = { application, id };
|
||||
const rtcSessionManager = new MatrixRTCSessionManager(logger, client, slot);
|
||||
rtcSessionManager.start();
|
||||
const rtcSession = rtcSessionManager.getRoomSession(room);
|
||||
|
||||
// media devices
|
||||
const mediaDevices = new MediaDevices(scope, {
|
||||
controlledAudioDevices,
|
||||
callIntent,
|
||||
});
|
||||
const muteStates = new MuteStates(
|
||||
scope,
|
||||
mediaDevices,
|
||||
{ audioEnabled: false, videoEnabled: false },
|
||||
hostBridge,
|
||||
);
|
||||
|
||||
// call view model
|
||||
const callViewModel = createCallViewModel$(
|
||||
scope,
|
||||
rtcSession,
|
||||
room,
|
||||
mediaDevices,
|
||||
muteStates,
|
||||
{
|
||||
...callViewModelOptionsFromParams(urlParams),
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
hostBridge,
|
||||
// The SDK owns its page, so the body is the space it has
|
||||
windowSize$: scope.behavior(observeElementSize$(document.body)),
|
||||
},
|
||||
of({}),
|
||||
of({}),
|
||||
constant({ supported: false, processor: undefined }),
|
||||
);
|
||||
logger.info("CallViewModelCreated");
|
||||
|
||||
// create data listener
|
||||
const data$ = new Subject<{ rtcBackendIdentity: string; data: string }>();
|
||||
|
||||
const lkTextStreamHandlerFunction = async (
|
||||
reader: TextStreamReader,
|
||||
participantInfo: { identity: string },
|
||||
livekitRoom: LivekitRoom,
|
||||
): Promise<void> => {
|
||||
const info = reader.info;
|
||||
logger.info(
|
||||
`Received text stream from ${participantInfo.identity}\n` +
|
||||
` Topic: ${info.topic}\n` +
|
||||
` Timestamp: ${info.timestamp}\n` +
|
||||
` ID: ${info.id}\n` +
|
||||
` Size: ${info.size}`, // Optional, only available if the stream was sent with `sendText`
|
||||
);
|
||||
|
||||
const participants = callViewModel.livekitRoomItems$.value.find(
|
||||
(i) => i.livekitRoom === livekitRoom,
|
||||
)?.participants;
|
||||
if (participants && participants.includes(participantInfo.identity)) {
|
||||
const text = await reader.readAll();
|
||||
logger.info(`Received text: ${text}`);
|
||||
data$.next({ rtcBackendIdentity: participantInfo.identity, data: text });
|
||||
} else {
|
||||
logger.warn(
|
||||
"Received text from unknown participant",
|
||||
participantInfo.identity,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const livekitRoomItemsSub = callViewModel.livekitRoomItems$
|
||||
.pipe(
|
||||
tap((beforecurrentAndPrev) => {
|
||||
logger.info(
|
||||
`LiveKit room items updated: ${beforecurrentAndPrev.length}`,
|
||||
beforecurrentAndPrev,
|
||||
);
|
||||
}),
|
||||
currentAndPrev,
|
||||
tap((aftercurrentAndPrev) => {
|
||||
logger.info(
|
||||
`LiveKit room items updated: ${aftercurrentAndPrev.current.length}, ${aftercurrentAndPrev.prev.length}`,
|
||||
aftercurrentAndPrev,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.subscribe({
|
||||
next: ({ prev, current }) => {
|
||||
const prevRooms = prev.map((i) => i.livekitRoom);
|
||||
const currentRooms = current.map((i) => i.livekitRoom);
|
||||
const addedRooms = currentRooms.filter((r) => !prevRooms.includes(r));
|
||||
const removedRooms = prevRooms.filter((r) => !currentRooms.includes(r));
|
||||
addedRooms.forEach((r) => {
|
||||
logger.info(`Registering text stream handler for room `);
|
||||
r.registerTextStreamHandler(
|
||||
TEXT_LK_TOPIC,
|
||||
(reader, participantInfo) =>
|
||||
void lkTextStreamHandlerFunction(reader, participantInfo, r),
|
||||
);
|
||||
});
|
||||
removedRooms.forEach((r) => {
|
||||
logger.info(`Unregistering text stream handler for room `);
|
||||
r.unregisterTextStreamHandler(TEXT_LK_TOPIC);
|
||||
});
|
||||
},
|
||||
complete: () => {
|
||||
logger.info("Livekit room items subscription completed");
|
||||
for (const item of callViewModel.livekitRoomItems$.value) {
|
||||
logger.info("unregistering room item from room", item.transport);
|
||||
item.livekitRoom.unregisterTextStreamHandler(TEXT_LK_TOPIC);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// create sendData function
|
||||
const sendFn: Behavior<(data: string) => Promise<TextStreamInfo>> =
|
||||
scope.behavior(
|
||||
callViewModel.localMatrixLivekitMember$.pipe(
|
||||
switchMap((m) => {
|
||||
if (!m)
|
||||
return of((data: string): never => {
|
||||
throw Error("local membership not yet ready.");
|
||||
});
|
||||
return m.participant.value$.pipe(
|
||||
map((p) => {
|
||||
if (p === null) {
|
||||
return (data: string): never => {
|
||||
throw Error("local participant not yet ready to send data.");
|
||||
};
|
||||
} else {
|
||||
return async (data: string): Promise<TextStreamInfo> =>
|
||||
p.sendText(data, { topic: TEXT_LK_TOPIC });
|
||||
}
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const sendData = async (data: unknown): Promise<void> => {
|
||||
const dataString = JSON.stringify(data);
|
||||
logger.info("try sending: ", dataString);
|
||||
try {
|
||||
await Promise.resolve();
|
||||
const info = await sendFn.value(dataString);
|
||||
logger.info(`Sent text with stream ID: ${info.id}`);
|
||||
} catch (e) {
|
||||
logger.error("failed sending: ", dataString, e);
|
||||
}
|
||||
};
|
||||
|
||||
const sendRoomMessage = async (message: string): Promise<void> => {
|
||||
const messageString = JSON.stringify(message);
|
||||
logger.info("try sending to room: ", messageString);
|
||||
try {
|
||||
await client.sendTextMessage(room.roomId, message);
|
||||
} catch (e) {
|
||||
logger.error("failed sending to room: ", messageString, e);
|
||||
}
|
||||
};
|
||||
|
||||
// after hangup gets called
|
||||
const leaveSubs = callViewModel.leave$.subscribe(() => {
|
||||
const scheduleWidgetCloseOnLeave = async (): Promise<void> => {
|
||||
const leaveResolver = Promise.withResolvers<void>();
|
||||
logger.info("waiting for RTC leave");
|
||||
rtcSession.on(MatrixRTCSessionEvent.JoinStateChanged, (isJoined) => {
|
||||
logger.info("received RTC join update: ", isJoined);
|
||||
if (!isJoined) leaveResolver.resolve();
|
||||
});
|
||||
await leaveResolver.promise;
|
||||
logger.info("send Unstick");
|
||||
await hostBridge
|
||||
.setAlwaysOnScreen(false)
|
||||
.catch((e: unknown) =>
|
||||
logger.error("Failed to set `alwaysOnScreen` to false", e),
|
||||
);
|
||||
logger.info("send Close");
|
||||
await hostBridge
|
||||
.close?.()
|
||||
.catch((e: unknown) =>
|
||||
logger.error("Failed to ask the host to close", e),
|
||||
);
|
||||
};
|
||||
|
||||
// schedule close first and then leave (scope.end)
|
||||
void scheduleWidgetCloseOnLeave();
|
||||
});
|
||||
|
||||
logger.info("createMatrixRTCSdk done");
|
||||
|
||||
return {
|
||||
join: (): void => {
|
||||
// first lets try making the widget sticky
|
||||
if (sticky) tryMakeSticky(widget);
|
||||
callViewModel.join();
|
||||
},
|
||||
leave: (): void => {
|
||||
callViewModel.leave();
|
||||
},
|
||||
stop: (): void => {
|
||||
leaveSubs.unsubscribe();
|
||||
livekitRoomItemsSub.unsubscribe();
|
||||
scope.end();
|
||||
},
|
||||
data$,
|
||||
localMember$: scope.behavior(
|
||||
callViewModel.localMatrixLivekitMember$.pipe(
|
||||
tap((member) =>
|
||||
logger.info("localMatrixLivekitMember$ next: ", member),
|
||||
),
|
||||
switchMap((member) => {
|
||||
if (member === null) return of(null);
|
||||
return combineLatest([
|
||||
member.connection$,
|
||||
member.membership$,
|
||||
member.participant.value$,
|
||||
]).pipe(
|
||||
map(([connection, membership, participant]) => ({
|
||||
connection,
|
||||
membership,
|
||||
participant,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
tap((member) => logger.info("localMember$ next: ", member)),
|
||||
),
|
||||
),
|
||||
connected$: callViewModel.connected$,
|
||||
remoteMembers$: scope.behavior(
|
||||
callViewModel.remoteMatrixLivekitMembers$.pipe(
|
||||
switchMap((members) => {
|
||||
const listOfMemberObservables = members.map((member) =>
|
||||
combineLatest([
|
||||
member.connection$,
|
||||
member.membership$,
|
||||
member.participant.value$,
|
||||
]).pipe(
|
||||
map(([connection, membership, participant]) => ({
|
||||
connection,
|
||||
membership,
|
||||
participant,
|
||||
})),
|
||||
// using shareReplay instead of a Behavior here because the behavior would need
|
||||
// a tricky scope.end() setup.
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
),
|
||||
);
|
||||
return combineLatest(listOfMemberObservables);
|
||||
}),
|
||||
),
|
||||
[],
|
||||
),
|
||||
sendData,
|
||||
sendRoomMessage,
|
||||
};
|
||||
}
|
||||
@@ -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,445 @@
|
||||
/*
|
||||
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 {
|
||||
type CallMembership,
|
||||
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,
|
||||
mockRtcMembership,
|
||||
MockRTCSession,
|
||||
} from "./utils/test";
|
||||
|
||||
const transport: Transport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
};
|
||||
const userId = "@alice:example.org";
|
||||
/** A token with the publish grant, as a homeserver issues it for its own SFU. */
|
||||
const publisherJWT = [
|
||||
{},
|
||||
{ sub: "identity", video: { room: "room", canPublish: true } },
|
||||
{},
|
||||
]
|
||||
.map((d) => global.btoa(JSON.stringify(d)))
|
||||
.join(".");
|
||||
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 } = {},
|
||||
{
|
||||
homeserverTransports = [transport],
|
||||
memberships = [],
|
||||
}: {
|
||||
homeserverTransports?: Transport[];
|
||||
memberships?: CallMembership[];
|
||||
} = {},
|
||||
): {
|
||||
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, memberships);
|
||||
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(homeserverTransports),
|
||||
_unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: publisherJWT }),
|
||||
} 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 on another member's SFU where the homeserver advertises none", async () => {
|
||||
const bobOnSfu = mockRtcMembership("@bob:example.org", "BOB", {
|
||||
fociPreferred: [{ type: "livekit", url: "wss://sfu.example.org" }],
|
||||
});
|
||||
const { join, fake } = setup(
|
||||
{},
|
||||
{},
|
||||
{ homeserverTransports: [], memberships: [bobOnSfu] },
|
||||
);
|
||||
fake.prepared.resolve({ canDelegateDelayedLeave: false });
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
|
||||
expect(join).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
[
|
||||
{
|
||||
type: "livekit",
|
||||
url: "wss://sfu.example.org",
|
||||
server_name: "example.org",
|
||||
},
|
||||
],
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
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,331 @@
|
||||
/*
|
||||
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,
|
||||
type Transport,
|
||||
} 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 { findRemotePublishLivekitTransport } from "./media-backend/livekit/RemotePublishTransport";
|
||||
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,
|
||||
});
|
||||
|
||||
// If the backend is LiveKit, we check it for transports as well, as a
|
||||
// fallback: another member's SFU may let us publish (MSC4195). LiveKit only,
|
||||
// so it stays out of the backend interface
|
||||
const transportFromBackends = async (): Promise<Transport | null> =>
|
||||
backend.transportType === "livekit"
|
||||
? findRemotePublishLivekitTransport({
|
||||
client,
|
||||
// A snapshot: if nobody with a usable SFU is here yet, the user has
|
||||
// to come back later rather than wait in a half-working lobby
|
||||
memberships: jsSdkSession.memberships,
|
||||
ownMembershipIdentity,
|
||||
roomId: room.roomId,
|
||||
slotId: jsSdkSession.slotId,
|
||||
logger,
|
||||
})
|
||||
: null;
|
||||
|
||||
const preparedTransport$ = from(
|
||||
discoverLocalTransport(
|
||||
client,
|
||||
backend.transportType,
|
||||
options,
|
||||
transportFromBackends,
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
+76
-43
@@ -11,9 +11,9 @@ import { EventEmitter } from "events";
|
||||
import { ClientEvent, SyncState } from "matrix-js-sdk";
|
||||
import { MembershipManagerEvent, Status } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { ObservableScope } from "../../ObservableScope";
|
||||
import { ObservableScope } from "../reactive/ObservableScope";
|
||||
import { createHomeserverConnected$ } from "./HomeserverConnected";
|
||||
import { testScope, withTestScheduler } from "../../../utils/test";
|
||||
import { testScope, withTestScheduler } from "../utils/test";
|
||||
|
||||
/**
|
||||
* Minimal stub of a Matrix client sufficient for our tests:
|
||||
@@ -100,13 +100,13 @@ describe("createHomeserverConnected$", () => {
|
||||
// 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.combined$.value).toEqual([false, "sync"]);
|
||||
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.combined$.value).toEqual([false, "membership"]);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual("membership");
|
||||
});
|
||||
|
||||
it("reports probablyLeft reason when membership transitions to Connected but ProbablyLeft is true", () => {
|
||||
@@ -116,17 +116,17 @@ describe("createHomeserverConnected$", () => {
|
||||
// Indicate probable leave before connection
|
||||
session.setProbablyLeft(true);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]);
|
||||
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.combined$.value).toEqual([false, "membership"]); // not yet membership connected
|
||||
expect(hsConnected.disconnectReason$.value).toEqual("membership"); // not yet membership connected
|
||||
// 2. Membership connected
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(hsConnected.combined$.value).toEqual([true, null]); // probablyLeft is false
|
||||
expect(hsConnected.disconnectReason$.value).toEqual(null); // probablyLeft is false
|
||||
});
|
||||
|
||||
it("returns syncing reason when sync loop leaves Syncing", () => {
|
||||
@@ -134,76 +134,76 @@ describe("createHomeserverConnected$", () => {
|
||||
// Reach connected state
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(hsConnected.combined$.value).toEqual([true, null]);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||
|
||||
// Sync loop error => should report syncing reason
|
||||
client.setSyncState(SyncState.Error);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "sync"]);
|
||||
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.combined$.value).toEqual([true, null]);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||
|
||||
session.setMembershipStatus(Status.Disconnected);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "membership"]);
|
||||
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.combined$.value).toEqual([true, null]);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||
|
||||
session.setProbablyLeft(true);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]);
|
||||
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.combined$.value).toEqual([true, null]);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||
|
||||
session.setProbablyLeft(true);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
|
||||
|
||||
// Simulate clearing the flag (in realistic scenario membership manager would update)
|
||||
session.setProbablyLeft(false);
|
||||
expect(hsConnected.combined$.value).toEqual([true, null]);
|
||||
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.combined$.value).toEqual([false, "sync"]);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual("sync");
|
||||
|
||||
// Fix sync only → membershipConnected is now the blocker
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "membership"]);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual("membership");
|
||||
|
||||
// Fix membership → all conditions satisfied
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(hsConnected.combined$.value).toEqual([true, null]);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||
|
||||
// Introduce probablyLeft → certainlyConnected
|
||||
session.setProbablyLeft(true);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
|
||||
|
||||
// Restore notProbablyLeft → connected again
|
||||
session.setProbablyLeft(false);
|
||||
expect(hsConnected.combined$.value).toEqual([true, null]);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||
|
||||
// Drop sync → syncing reason
|
||||
client.setSyncState(SyncState.Error);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "sync"]);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual("sync");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createHomeserverConnected$ - combined$ reason values", () => {
|
||||
describe("createHomeserverConnected$ - disconnectReason$ reason values", () => {
|
||||
let scope: ObservableScope;
|
||||
let client: MockMatrixClient;
|
||||
let session: MockMatrixRTCSession;
|
||||
@@ -222,57 +222,87 @@ describe("createHomeserverConnected$ - combined$ reason values", () => {
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("is [true, null] when all three conditions are satisfied", () => {
|
||||
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
|
||||
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(combined$.value).toEqual([true, null]);
|
||||
expect(disconnectReason$.value).toEqual(null);
|
||||
});
|
||||
|
||||
it("reports syncing when sync loop is not Syncing", () => {
|
||||
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
|
||||
const { disconnectReason$ } = createHomeserverConnected$(
|
||||
scope,
|
||||
client,
|
||||
session,
|
||||
0,
|
||||
);
|
||||
// client starts with SyncState.Error, membership also disconnected
|
||||
expect(combined$.value).toEqual([false, "sync"]);
|
||||
expect(disconnectReason$.value).toEqual("sync");
|
||||
});
|
||||
|
||||
it("reports membershipConnected when sync is fine but membership is not Connected", () => {
|
||||
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
|
||||
const { disconnectReason$ } = createHomeserverConnected$(
|
||||
scope,
|
||||
client,
|
||||
session,
|
||||
0,
|
||||
);
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
// session still Status.Disconnected
|
||||
expect(combined$.value).toEqual([false, "membership"]);
|
||||
expect(disconnectReason$.value).toEqual("membership");
|
||||
});
|
||||
|
||||
it("reports certainlyConnected when probablyLeft is true", () => {
|
||||
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
|
||||
const { disconnectReason$ } = createHomeserverConnected$(
|
||||
scope,
|
||||
client,
|
||||
session,
|
||||
0,
|
||||
);
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
session.setProbablyLeft(true);
|
||||
expect(combined$.value).toEqual([false, "probablyLeft"]);
|
||||
expect(disconnectReason$.value).toEqual("probablyLeft");
|
||||
});
|
||||
|
||||
it("prioritises syncing over membershipConnected when both fail", () => {
|
||||
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
|
||||
const { disconnectReason$ } = createHomeserverConnected$(
|
||||
scope,
|
||||
client,
|
||||
session,
|
||||
0,
|
||||
);
|
||||
// Both sync (Error) and membership (Disconnected) are failing
|
||||
expect(combined$.value).toEqual([false, "sync"]);
|
||||
expect(disconnectReason$.value).toEqual("sync");
|
||||
});
|
||||
|
||||
it("updates reason as conditions change", () => {
|
||||
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
|
||||
const { disconnectReason$ } = createHomeserverConnected$(
|
||||
scope,
|
||||
client,
|
||||
session,
|
||||
0,
|
||||
);
|
||||
// Initially: syncing fails
|
||||
expect(combined$.value).toEqual([false, "sync"]);
|
||||
expect(disconnectReason$.value).toEqual("sync");
|
||||
|
||||
// Fix sync → membershipConnected is now the blocker
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
expect(combined$.value).toEqual([false, "membership"]);
|
||||
expect(disconnectReason$.value).toEqual("membership");
|
||||
|
||||
// Fix membership → probablyLeft makes certainlyConnected fail
|
||||
session.setProbablyLeft(true);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(combined$.value).toEqual([false, "probablyLeft"]);
|
||||
expect(disconnectReason$.value).toEqual("probablyLeft");
|
||||
|
||||
// Clear probablyLeft → all conditions satisfied
|
||||
session.setProbablyLeft(false);
|
||||
expect(combined$.value).toEqual([true, null]);
|
||||
expect(disconnectReason$.value).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -303,10 +333,13 @@ describe("createHomeserverConnected$ - Grace Period", () => {
|
||||
session,
|
||||
GRACE_PERIOD,
|
||||
);
|
||||
expectObservable(hsConnected.combined$).toBe(expectedConnectedMarbles, {
|
||||
y: [true, null],
|
||||
n: [false, "sync"],
|
||||
});
|
||||
expectObservable(hsConnected.disconnectReason$).toBe(
|
||||
expectedConnectedMarbles,
|
||||
{
|
||||
y: null,
|
||||
n: "sync",
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
+25
-30
@@ -26,27 +26,28 @@ import {
|
||||
} from "rxjs";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { Config } from "../../../config/Config";
|
||||
import { type ObservableScope } from "../../ObservableScope";
|
||||
import { type Behavior } from "../../Behavior";
|
||||
import { type NodeStyleEventEmitter } from "../../../utils/test";
|
||||
import { type ObservableScope } from "../reactive/ObservableScope";
|
||||
import { type Behavior } from "../reactive/Behavior";
|
||||
import { defaultSessionTimings } from "../config";
|
||||
import { type MatrixDisconnectReason } from "../api";
|
||||
|
||||
export type HomeserverDisconnectReason = "sync" | "membership" | "probablyLeft";
|
||||
type NodeEventHandler = (...args: unknown[]) => void;
|
||||
|
||||
interface NodeStyleEventEmitter {
|
||||
addListener(eventName: string | symbol, handler: NodeEventHandler): this;
|
||||
removeListener(eventName: string | symbol, handler: NodeEventHandler): this;
|
||||
}
|
||||
|
||||
export interface HomeserverConnected {
|
||||
/**
|
||||
* Emits `[true, null]` when the homeserver connection is healthy, or
|
||||
* `[false, reason]` when one of the three sub-conditions fails.
|
||||
*/
|
||||
combined$: Behavior<[boolean, HomeserverDisconnectReason | null]>;
|
||||
rtsSession$: Behavior<Status>;
|
||||
/** 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.
|
||||
*
|
||||
* `combined$` emits `null` when all conditions are satisfied, or the first failing
|
||||
* `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"
|
||||
@@ -67,7 +68,8 @@ export function createHomeserverConnected$(
|
||||
): HomeserverConnected {
|
||||
const logger = rootLogger.getChild("[HomeserverConnected]");
|
||||
// Get grace period from parameter or config (default 10000ms)
|
||||
const graceMs = gracePeriodMs ?? Config.get().sync_disconnect_grace_period_ms;
|
||||
const graceMs =
|
||||
gracePeriodMs ?? defaultSessionTimings.syncDisconnectGracePeriodMs;
|
||||
|
||||
const syncing$ = (
|
||||
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
|
||||
@@ -111,26 +113,19 @@ export function createHomeserverConnected$(
|
||||
map(() => matrixRTCSession.probablyLeft !== true),
|
||||
);
|
||||
|
||||
const combined$ = scope.behavior(
|
||||
const disconnectReason$ = scope.behavior(
|
||||
combineLatest([syncing$, membershipConnected$, certainlyConnected$]).pipe(
|
||||
map(
|
||||
([syncing, membership, certainly]): [
|
||||
boolean,
|
||||
HomeserverDisconnectReason | null,
|
||||
] => {
|
||||
if (!syncing) return [false, "sync"];
|
||||
if (!membership) return [false, "membership"];
|
||||
if (!certainly) return [false, "probablyLeft"];
|
||||
return [true, null];
|
||||
},
|
||||
),
|
||||
tap(([connected, reason]) => {
|
||||
logger.info(
|
||||
`Homeserver connected update: ${connected ? "connected" : reason}`,
|
||||
);
|
||||
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 { combined$, rtsSession$ };
|
||||
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,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 { 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",
|
||||
};
|
||||
const bobsTransport: Transport = {
|
||||
type: "livekit",
|
||||
url: "wss://sfu.bob.com",
|
||||
server_name: "bob.com",
|
||||
};
|
||||
|
||||
function client(
|
||||
advertised: Transport[],
|
||||
): Parameters<typeof discoverLocalTransport>[0] {
|
||||
return {
|
||||
getDomain: () => "example.org",
|
||||
_unstable_getRTCTransports: vi.fn().mockResolvedValue(advertised),
|
||||
};
|
||||
}
|
||||
|
||||
function fromBackends(
|
||||
transport: Transport | null = null,
|
||||
): ReturnType<typeof vi.fn<() => Promise<Transport | null>>> {
|
||||
return vi.fn(async () => Promise.resolve(transport));
|
||||
}
|
||||
|
||||
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 },
|
||||
fromBackends(),
|
||||
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 },
|
||||
fromBackends(),
|
||||
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 },
|
||||
fromBackends(),
|
||||
logger,
|
||||
),
|
||||
).resolves.toEqual(hostTransport);
|
||||
});
|
||||
|
||||
it("prefers a local transport over the backends' without asking them", async () => {
|
||||
const backends = fromBackends(bobsTransport);
|
||||
|
||||
await expect(
|
||||
discoverLocalTransport(
|
||||
client([homeserverTransport]),
|
||||
"livekit",
|
||||
{},
|
||||
backends,
|
||||
logger,
|
||||
),
|
||||
).resolves.toEqual(homeserverTransport);
|
||||
expect(backends).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails naming the homeserver where there is no transport at all", async () => {
|
||||
const promise = discoverLocalTransport(
|
||||
client([]),
|
||||
"livekit",
|
||||
{},
|
||||
fromBackends(),
|
||||
logger,
|
||||
);
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(
|
||||
MatrixRTCTransportMissingError,
|
||||
);
|
||||
await expect(promise).rejects.toHaveProperty("domain", "example.org");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
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,
|
||||
* else the first the backends can provide, which are asked only then.
|
||||
*
|
||||
* @throws {MatrixRTCTransportMissingError} where none of these exists.
|
||||
*/
|
||||
export async function discoverLocalTransport(
|
||||
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports">,
|
||||
transportType: string,
|
||||
{
|
||||
transport: override,
|
||||
fallbackTransport,
|
||||
}: Pick<RTCParticipationOptions, "transport" | "fallbackTransport">,
|
||||
transportFromBackends: () => Promise<Transport | null>,
|
||||
logger: Logger,
|
||||
): Promise<Transport> {
|
||||
const transport =
|
||||
override ??
|
||||
(await new TransportAutoDiscovery({
|
||||
client,
|
||||
transportType,
|
||||
fallbackTransport,
|
||||
logger,
|
||||
}).discoverPreferredTransport()) ??
|
||||
(await transportFromBackends()) ??
|
||||
null;
|
||||
if (transport === null)
|
||||
throw new MatrixRTCTransportMissingError(client.getDomain() ?? "");
|
||||
return transport;
|
||||
}
|
||||
+2
-2
@@ -15,13 +15,13 @@ import {
|
||||
import EventEmitter from "events";
|
||||
import { it } from "vitest";
|
||||
|
||||
import { ObservableScope } from "../../ObservableScope.ts";
|
||||
import { ObservableScope } from "../reactive/ObservableScope";
|
||||
import type { Room as MatrixRoom } from "matrix-js-sdk/lib/models/room";
|
||||
import {
|
||||
mockRtcMembership,
|
||||
mockMatrixRoomMember,
|
||||
withTestScheduler,
|
||||
} from "../../../utils/test.ts";
|
||||
} from "../utils/test";
|
||||
import {
|
||||
createMatrixMemberMetadata$,
|
||||
createRoomMembers$,
|
||||
+3
-6
@@ -15,12 +15,9 @@ import {
|
||||
} from "matrix-js-sdk/lib/matrix";
|
||||
// eslint-disable-next-line rxjs/no-internal
|
||||
|
||||
import { type ObservableScope } from "../../ObservableScope";
|
||||
import {
|
||||
calculateDisplayName,
|
||||
shouldDisambiguate,
|
||||
} from "../../../utils/displayname";
|
||||
import { type Behavior } from "../../Behavior";
|
||||
import { type ObservableScope } from "../reactive/ObservableScope";
|
||||
import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname";
|
||||
import { type Behavior } from "../reactive/Behavior";
|
||||
|
||||
export type RoomMemberMap = Map<
|
||||
string,
|
||||
@@ -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 };
|
||||
}
|
||||
+21
-31
@@ -15,25 +15,21 @@ import {
|
||||
} 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 {
|
||||
type UnstableLivekitTransport,
|
||||
type Transport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
TransportAutoDiscovery,
|
||||
type TransportAutoDiscoveryProps,
|
||||
} from "./TransportAutoDiscovery.ts";
|
||||
|
||||
import type { ResolvedConfigOptions } from "../../../config/ConfigOptions.ts";
|
||||
import {
|
||||
RtcTransportAutoDiscovery,
|
||||
type RtcTransportAutoDiscoveryProps,
|
||||
} from "./RtcTransportAutoDiscovery.ts";
|
||||
type DiscoveryClient = TransportAutoDiscoveryProps["client"];
|
||||
|
||||
type DiscoveryClient = RtcTransportAutoDiscoveryProps["client"];
|
||||
|
||||
const backendTransport: UnstableLivekitTransport = {
|
||||
const backendTransport: Transport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://backend.example.org",
|
||||
};
|
||||
|
||||
const configTransport: UnstableLivekitTransport = {
|
||||
const configTransport: Transport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://config.example.org",
|
||||
};
|
||||
@@ -48,17 +44,7 @@ function makeClient(): MockedObject<DiscoveryClient> {
|
||||
} as unknown as MockedObject<DiscoveryClient>;
|
||||
}
|
||||
|
||||
function makeResolvedConfig(livekitServiceUrl?: string): ResolvedConfigOptions {
|
||||
return {
|
||||
livekit: livekitServiceUrl
|
||||
? {
|
||||
livekit_service_url: livekitServiceUrl,
|
||||
}
|
||||
: undefined,
|
||||
} as ResolvedConfigOptions;
|
||||
}
|
||||
|
||||
describe("RtcTransportAutoDiscovery", () => {
|
||||
describe("TransportAutoDiscovery", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -73,9 +59,10 @@ describe("RtcTransportAutoDiscovery", () => {
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports.mockResolvedValue(transports);
|
||||
|
||||
const discovery = new RtcTransportAutoDiscovery({
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
resolvedConfig: makeResolvedConfig(configTransport.livekit_service_url),
|
||||
transportType: "livekit",
|
||||
fallbackTransport: configTransport,
|
||||
logger: rootLogger,
|
||||
});
|
||||
|
||||
@@ -103,9 +90,10 @@ describe("RtcTransportAutoDiscovery", () => {
|
||||
)
|
||||
.mockResolvedValue([backendTransport]);
|
||||
|
||||
const discovery = new RtcTransportAutoDiscovery({
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
resolvedConfig: makeResolvedConfig("https://config.example.org"),
|
||||
transportType: "livekit",
|
||||
fallbackTransport: configTransport,
|
||||
logger: rootLogger,
|
||||
});
|
||||
|
||||
@@ -126,9 +114,10 @@ describe("RtcTransportAutoDiscovery", () => {
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports.mockResolvedValue(transports);
|
||||
|
||||
const discovery = new RtcTransportAutoDiscovery({
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
resolvedConfig: makeResolvedConfig(configTransport.livekit_service_url),
|
||||
transportType: "livekit",
|
||||
fallbackTransport: configTransport,
|
||||
logger: rootLogger,
|
||||
});
|
||||
|
||||
@@ -142,9 +131,10 @@ describe("RtcTransportAutoDiscovery", () => {
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports.mockResolvedValue([]);
|
||||
|
||||
const discovery = new RtcTransportAutoDiscovery({
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
resolvedConfig: makeResolvedConfig(undefined),
|
||||
transportType: "livekit",
|
||||
fallbackTransport: undefined,
|
||||
logger: rootLogger,
|
||||
});
|
||||
|
||||
+28
-36
@@ -1,46 +1,48 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-IdFentifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
import {
|
||||
isUnstableLivekitTransport,
|
||||
type UnstableLivekitTransport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
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 type { ResolvedConfigOptions } from "../../../config/ConfigOptions.ts";
|
||||
import { doNetworkOperationWithRetry } from "../../../utils/matrix.ts";
|
||||
import { doNetworkOperationWithRetry } from "../utils/network";
|
||||
|
||||
type TransportDiscoveryClient = Pick<
|
||||
MatrixClient,
|
||||
"getDomain" | "_unstable_getRTCTransports"
|
||||
>;
|
||||
|
||||
export interface RtcTransportAutoDiscoveryProps {
|
||||
export interface TransportAutoDiscoveryProps {
|
||||
client: TransportDiscoveryClient;
|
||||
resolvedConfig: ResolvedConfigOptions;
|
||||
/** 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 RtcTransportAutoDiscovery {
|
||||
export class TransportAutoDiscovery {
|
||||
private readonly client: TransportDiscoveryClient;
|
||||
private readonly resolvedConfig: ResolvedConfigOptions;
|
||||
private readonly transportType: string;
|
||||
private readonly fallbackTransport: Transport | undefined;
|
||||
private readonly logger: Logger;
|
||||
|
||||
public constructor({
|
||||
client,
|
||||
resolvedConfig,
|
||||
transportType,
|
||||
fallbackTransport,
|
||||
logger,
|
||||
}: RtcTransportAutoDiscoveryProps) {
|
||||
}: TransportAutoDiscoveryProps) {
|
||||
this.client = client;
|
||||
this.resolvedConfig = resolvedConfig;
|
||||
this.logger = logger.getChild("[RtcTransportAutoDiscovery]");
|
||||
this.transportType = transportType;
|
||||
this.fallbackTransport = fallbackTransport;
|
||||
this.logger = logger.getChild("[TransportAutoDiscovery]");
|
||||
}
|
||||
|
||||
public async discoverPreferredTransport(): Promise<UnstableLivekitTransport | null> {
|
||||
public async discoverPreferredTransport(): Promise<Transport | null> {
|
||||
// 1) backend transports
|
||||
const backendTransport = await this.tryBackendTransports();
|
||||
if (backendTransport) {
|
||||
@@ -50,24 +52,23 @@ export class RtcTransportAutoDiscovery {
|
||||
return backendTransport;
|
||||
}
|
||||
|
||||
// 2) app config URL
|
||||
const configTransport = this.tryConfigTransport();
|
||||
if (configTransport) {
|
||||
// 2) the host's fallback
|
||||
if (this.fallbackTransport) {
|
||||
this.logger.info(
|
||||
`Found app config transport: ${JSON.stringify(configTransport)}`,
|
||||
`Found fallback transport: ${JSON.stringify(this.fallbackTransport)}`,
|
||||
);
|
||||
return configTransport;
|
||||
return this.fallbackTransport;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the first rtc_foci from the backend.
|
||||
* 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<UnstableLivekitTransport | null> {
|
||||
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 ...");
|
||||
@@ -75,12 +76,14 @@ export class RtcTransportAutoDiscovery {
|
||||
const transportList = await doNetworkOperationWithRetry(async () =>
|
||||
client._unstable_getRTCTransports(),
|
||||
);
|
||||
const first = transportList.find(isUnstableLivekitTransport);
|
||||
const first = transportList.find(
|
||||
(transport) => transport.type === this.transportType,
|
||||
);
|
||||
if (first) {
|
||||
return first;
|
||||
} else {
|
||||
this.logger.info(
|
||||
`No livekit transport found in getRTCTransports end point`,
|
||||
`No ${this.transportType} transport found in getRTCTransports end point`,
|
||||
transportList,
|
||||
);
|
||||
}
|
||||
@@ -89,15 +92,4 @@ export class RtcTransportAutoDiscovery {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private tryConfigTransport(): UnstableLivekitTransport | null {
|
||||
const url = this.resolvedConfig.livekit?.livekit_service_url;
|
||||
if (url) {
|
||||
return {
|
||||
type: "livekit",
|
||||
livekit_service_url: url,
|
||||
};
|
||||
}
|
||||
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>;
|
||||
}
|
||||
+60
-96
@@ -8,7 +8,6 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
@@ -40,14 +39,12 @@ import {
|
||||
type ConnectionState,
|
||||
type ConnectionOpts,
|
||||
} from "./Connection.ts";
|
||||
import { ObservableScope } from "../../ObservableScope.ts";
|
||||
import { FailToGetOpenIdToken } from "../../../utils/errors.ts";
|
||||
import { testJWTToken } from "../../../utils/test-fixtures.ts";
|
||||
import { mockRemoteParticipant, ownMemberMock } from "../../../utils/test.ts";
|
||||
import {
|
||||
type ClientGetTokenParts,
|
||||
type ClientOpenIDParts,
|
||||
} from "../../../livekit/auth";
|
||||
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;
|
||||
|
||||
@@ -66,6 +63,21 @@ const transport: UnstableLivekitTransport = {
|
||||
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>({
|
||||
@@ -119,17 +131,7 @@ function setupTest(): void {
|
||||
}
|
||||
|
||||
function setupRemoteConnection(): Connection {
|
||||
const opts: ConnectionOpts = {
|
||||
client,
|
||||
roomId: ROOM_ID,
|
||||
slotId: SLOT_ID,
|
||||
role: "subscriber",
|
||||
transport,
|
||||
serverName: "m.localhost",
|
||||
scope: testScope,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
livekitRoomFactory: () => fakeLivekitRoom,
|
||||
};
|
||||
const opts = connectionOpts();
|
||||
|
||||
fakeLivekitRoom.connect.mockImplementation(async (): Promise<void> => {
|
||||
const changeEv = RoomEvent.ConnectionStateChanged;
|
||||
@@ -152,33 +154,10 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("Start connection states", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.post(
|
||||
`https://matrix-rtc.example.org/livekit/jwt/get_token`,
|
||||
() => {
|
||||
return {
|
||||
// Return a non-retryable error, if not, the retry logic will
|
||||
// wait and fail the test with a timeout.
|
||||
status: 404,
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("start in initialized state", () => {
|
||||
setupTest();
|
||||
|
||||
const opts: ConnectionOpts = {
|
||||
client,
|
||||
roomId: ROOM_ID,
|
||||
slotId: SLOT_ID,
|
||||
role: "subscriber",
|
||||
transport,
|
||||
serverName: "m.localhost",
|
||||
scope: testScope,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
livekitRoomFactory: () => fakeLivekitRoom,
|
||||
};
|
||||
const opts = connectionOpts();
|
||||
const connection = new Connection(opts, logger);
|
||||
|
||||
expect(connection.state$.getValue()).toEqual({ state: "initialized" });
|
||||
@@ -188,17 +167,7 @@ describe("Start connection states", () => {
|
||||
setupTest();
|
||||
vi.useFakeTimers();
|
||||
|
||||
const opts: ConnectionOpts = {
|
||||
client,
|
||||
roomId: ROOM_ID,
|
||||
slotId: SLOT_ID,
|
||||
role: "subscriber",
|
||||
transport,
|
||||
serverName: "m.localhost",
|
||||
scope: testScope,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
livekitRoomFactory: () => fakeLivekitRoom,
|
||||
};
|
||||
const opts = connectionOpts();
|
||||
|
||||
const connection = new Connection(opts, logger);
|
||||
|
||||
@@ -224,17 +193,17 @@ describe("Start connection states", () => {
|
||||
expect(capturedState).toBeDefined();
|
||||
expect(capturedState).toEqual({ state: "authenticating" });
|
||||
|
||||
deferred.reject(new FailToGetOpenIdToken(new Error("Failed to get token")));
|
||||
deferred.reject(new MatrixRTCError("Failed to get token"));
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
capturedState = capturedStates.pop();
|
||||
if (capturedState?.state === "error") {
|
||||
expect(capturedState.error.message).toEqual("Something went wrong");
|
||||
expect(capturedState.error.message).toEqual("Failed to get token");
|
||||
expect(connection.transport).toEqual(transport);
|
||||
} else {
|
||||
expect.fail(
|
||||
"Expected FailedToStart state but got " + JSON.stringify(capturedState),
|
||||
"Expected error state but got " + JSON.stringify(capturedState),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -243,17 +212,7 @@ describe("Start connection states", () => {
|
||||
setupTest();
|
||||
vi.useFakeTimers();
|
||||
|
||||
const opts: ConnectionOpts = {
|
||||
client,
|
||||
roomId: ROOM_ID,
|
||||
slotId: SLOT_ID,
|
||||
role: "subscriber",
|
||||
transport,
|
||||
serverName: "m.localhost",
|
||||
scope: testScope,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
livekitRoomFactory: () => fakeLivekitRoom,
|
||||
};
|
||||
const opts = connectionOpts();
|
||||
|
||||
const connection = new Connection(opts, logger);
|
||||
|
||||
@@ -298,7 +257,7 @@ describe("Start connection states", () => {
|
||||
expect(connection.transport).toEqual(transport);
|
||||
} else {
|
||||
expect.fail(
|
||||
"Expected FailedToStart state but got " + JSON.stringify(capturedState),
|
||||
"Expected error state but got " + JSON.stringify(capturedState),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -307,17 +266,7 @@ describe("Start connection states", () => {
|
||||
setupTest();
|
||||
vi.useFakeTimers();
|
||||
|
||||
const opts: ConnectionOpts = {
|
||||
client,
|
||||
roomId: ROOM_ID,
|
||||
slotId: SLOT_ID,
|
||||
role: "subscriber",
|
||||
transport,
|
||||
serverName: "m.localhost",
|
||||
scope: testScope,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
livekitRoomFactory: () => fakeLivekitRoom,
|
||||
};
|
||||
const opts = connectionOpts();
|
||||
|
||||
const connection = new Connection(opts, logger);
|
||||
|
||||
@@ -398,6 +347,34 @@ describe("Start connection states", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -423,7 +400,7 @@ describe("Start connection states", () => {
|
||||
// so a rejection would be unhandled).
|
||||
await expect(started).resolves.toBeUndefined();
|
||||
expect(capturedStates.at(-1)).toEqual({ state: "stopped" });
|
||||
expect(capturedStates.some((st) => st instanceof Error)).toBe(false);
|
||||
expect(capturedStates.some((st) => st.state === "error")).toBe(false);
|
||||
});
|
||||
|
||||
it("shutting down the scope should stop the connection", async () => {
|
||||
@@ -556,20 +533,7 @@ describe("remote track logging", () => {
|
||||
warn,
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
new Connection(
|
||||
{
|
||||
client,
|
||||
roomId: ROOM_ID,
|
||||
slotId: SLOT_ID,
|
||||
role: "subscriber",
|
||||
transport,
|
||||
serverName: "m.localhost",
|
||||
scope: testScope,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
livekitRoomFactory: () => fakeLivekitRoom,
|
||||
},
|
||||
testLogger,
|
||||
);
|
||||
new Connection(connectionOpts(), testLogger);
|
||||
info.mockClear(); // drop the constructor log line
|
||||
|
||||
const bob = mockRemoteParticipant({
|
||||
+33
-36
@@ -28,37 +28,38 @@ 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 Behavior } from "../../Behavior.ts";
|
||||
import { type ObservableScope } from "../../ObservableScope.ts";
|
||||
import {
|
||||
ElementCallError,
|
||||
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,
|
||||
UnknownCallError,
|
||||
} from "../../../utils/errors.ts";
|
||||
import {
|
||||
type ClientGetTokenParts,
|
||||
getSFUConfig,
|
||||
type ClientOpenIDParts,
|
||||
type SFUConfig,
|
||||
} from "../../../livekit/auth";
|
||||
UnknownRTCError,
|
||||
} from "../../errors";
|
||||
|
||||
export interface ConnectionOpts {
|
||||
/** The identity parts to use on this connection */
|
||||
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
/** Whether we want to publish or only subscribe on this connection. */
|
||||
role: "publisher" | "subscriber";
|
||||
/** Whether we publish on this connection or only subscribe. */
|
||||
role: ConnectionRole;
|
||||
/** The media transport to connect to. */
|
||||
transport: UnstableLivekitTransport;
|
||||
/** The name of the homeserver to which the {@link transport} belongs. */
|
||||
/** The homeserver the transport belongs to. */
|
||||
serverName: string;
|
||||
/** The Matrix client to use for OpenID and SFU config requests. */
|
||||
/** The Matrix client, for the token exchange. */
|
||||
client: ClientGetTokenParts & ClientOpenIDParts;
|
||||
/** The ID of the Matrix room in which the session takes place. */
|
||||
/** The Matrix room the session is in. */
|
||||
roomId: string;
|
||||
/** The ID of the MatrixRTC slot in which the session takes place. */
|
||||
/** The MatrixRTC slot the session is in. */
|
||||
slotId: string;
|
||||
/** The observable scope to use for this connection. */
|
||||
scope: ObservableScope;
|
||||
@@ -68,12 +69,13 @@ export interface ConnectionOpts {
|
||||
}
|
||||
|
||||
export type ConnectionState =
|
||||
// The start state of a connection. It has been created but nothing has loaded yet.
|
||||
/** Created, nothing started yet. */
|
||||
| { state: "initialized" }
|
||||
// `start` has been called on the connection. It acquires the JWT token to connect to the LK room.
|
||||
/** `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: ElementCallError }
|
||||
| { state: "error"; error: MatrixRTCError }
|
||||
| { state: "stopped" };
|
||||
|
||||
/**
|
||||
@@ -92,19 +94,15 @@ export class Connection {
|
||||
*/
|
||||
public readonly state$: Behavior<ConnectionState> = this._state$;
|
||||
|
||||
/**
|
||||
* Whether we want to publish or only subscribe on this connection.
|
||||
*/
|
||||
public readonly role: "publisher" | "subscriber";
|
||||
/** Whether we publish on this connection or only subscribe. */
|
||||
public readonly role: ConnectionRole;
|
||||
|
||||
/**
|
||||
* The media transport to connect to.
|
||||
*/
|
||||
public readonly transport: UnstableLivekitTransport;
|
||||
|
||||
/**
|
||||
* The name of the homeserver to which the {@link transport} belongs.
|
||||
*/
|
||||
/** The homeserver the transport belongs to. */
|
||||
public readonly serverName: string;
|
||||
|
||||
public readonly livekitRoom: LivekitRoom;
|
||||
@@ -134,7 +132,6 @@ export class Connection {
|
||||
* */
|
||||
protected stopped = false;
|
||||
|
||||
// TODO: can we just keep the ConnectionOpts object instead of spreading?
|
||||
private readonly client: ClientGetTokenParts & ClientOpenIDParts;
|
||||
private readonly roomId: string;
|
||||
private readonly slotId: string;
|
||||
@@ -288,9 +285,9 @@ export class Connection {
|
||||
* Starts the connection.
|
||||
*
|
||||
* This will:
|
||||
* 1. Request an OpenId token `request_token` (allows matrix users to verify their identity with a third-party service.)
|
||||
* 2. Use this token to request the SFU config to the MatrixRtc authentication service.
|
||||
* 3. Connect to the configured LiveKit room.
|
||||
* 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.
|
||||
@@ -374,11 +371,11 @@ export class Connection {
|
||||
this._state$.next({
|
||||
state: "error",
|
||||
error:
|
||||
error instanceof ElementCallError
|
||||
error instanceof MatrixRTCError
|
||||
? error
|
||||
: error instanceof Error
|
||||
? new UnknownCallError(error)
|
||||
: new UnknownCallError(new Error(`${error}`)),
|
||||
? new UnknownRTCError(error)
|
||||
: new UnknownRTCError(new Error(`${error}`)),
|
||||
});
|
||||
// Its okay to ignore the throw. The error is part of the state.
|
||||
throw error;
|
||||
@@ -405,13 +402,13 @@ export class Connection {
|
||||
* If the connection is already stopped, this is a no-op.
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
this.logger.debug("stop: disconnecing from lk room");
|
||||
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 disconnecing from lk room");
|
||||
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,
|
||||
);
|
||||
}
|
||||
+47
-7
@@ -11,7 +11,11 @@ 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 "../../ObservableScope.ts";
|
||||
import {
|
||||
Epoch,
|
||||
mapEpoch,
|
||||
ObservableScope,
|
||||
} from "../../reactive/ObservableScope";
|
||||
import {
|
||||
createConnectionMap$,
|
||||
ConnectionMap,
|
||||
@@ -19,11 +23,9 @@ import {
|
||||
} from "./ConnectionMap.ts";
|
||||
import { type ConnectionFactory } from "./ConnectionFactory.ts";
|
||||
import { type Connection } from "./Connection.ts";
|
||||
import { ownMemberMock, withTestScheduler } from "../../../utils/test.ts";
|
||||
import { type Behavior } from "../../Behavior.ts";
|
||||
import { type TransportLocator } from "../../../livekit/auth";
|
||||
|
||||
// Some test constants
|
||||
import { ownMemberMock, withTestScheduler } from "../../utils/test";
|
||||
import { type Behavior } from "../../reactive/Behavior";
|
||||
import { type TransportLocator } from "./auth";
|
||||
|
||||
const TRANSPORT_1: TransportLocator = {
|
||||
transport: {
|
||||
@@ -127,6 +129,44 @@ describe("connections$ stream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 = {
|
||||
@@ -365,7 +405,7 @@ describe("connectionMap$", () => {
|
||||
});
|
||||
|
||||
test("Should report connections with the remote participants", () => {
|
||||
withTestScheduler(({ expectObservable, schedule, behavior }) => {
|
||||
withTestScheduler(({ expectObservable, behavior }) => {
|
||||
// Setup the fake participants streams behavior
|
||||
// ==============================
|
||||
fakeRemoteParticipantsStreams.set(
|
||||
+27
-52
@@ -6,38 +6,36 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { combineLatest, map, of, switchMap, tap } from "rxjs";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
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 "../../Behavior.ts";
|
||||
import { type Connection } from "./Connection.ts";
|
||||
import { type Behavior } from "../../reactive/Behavior";
|
||||
import {
|
||||
Epoch,
|
||||
mapEpoch,
|
||||
type ObservableScope,
|
||||
} from "../../ObservableScope.ts";
|
||||
import { generateItemsWithEpoch } from "../../../utils/observable.ts";
|
||||
import { type ConnectionFactory } from "./ConnectionFactory.ts";
|
||||
import { type TransportLocator } from "../../../livekit/auth/types.ts";
|
||||
} 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 string type, so that only values returned by `keyFor` can be used
|
||||
// as keys
|
||||
// Branded, so that only values returned by `keyFor` can be used as keys
|
||||
export type TransportKey = string & { __transportKey: true };
|
||||
|
||||
/**
|
||||
* Converts a {@link TransportLocator} to a key suitable for identifying it in a
|
||||
* {@link Map} or {@link Set}. Locators with the same key share a connection.
|
||||
* 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 {
|
||||
// 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 must share a connection rather than each get their own.
|
||||
const hasUrl = "url" in transport;
|
||||
// Serialize the fields in a consistent order
|
||||
return JSON.stringify([
|
||||
@@ -59,6 +57,7 @@ function removeDuplicateTransports(
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
/** The connections at one moment, with the remote participants seen on each. */
|
||||
export class ConnectionMap {
|
||||
private readonly map = new Map<
|
||||
TransportKey,
|
||||
@@ -95,33 +94,25 @@ export class ConnectionMap {
|
||||
public getParticipantsForTransport(
|
||||
transport: TransportLocator,
|
||||
): RemoteParticipant[] {
|
||||
const existing = this.map.get(keyFor(transport));
|
||||
if (existing) {
|
||||
return existing.participants;
|
||||
}
|
||||
return [];
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dynamic map of connections and their associated backend participants.
|
||||
* @param props - Configuration object
|
||||
* @param props.scope - The observable scope used by this object
|
||||
* @param props.connectionFactory - Used to create new connections
|
||||
* @param props.localTransport - The transport to publish local media on. (deduplicated with remoteTransports$)
|
||||
* @param props.remoteTransports$ - All other transports. The connection manager will create connections for each transport. (deduplicated with localTransport)
|
||||
* @param props.ownMembershipIdentity - The own membership identity to use.
|
||||
* @param props.logger - The logger to use.
|
||||
* 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,
|
||||
@@ -132,12 +123,7 @@ export function createConnectionMap$({
|
||||
ownMembershipIdentity,
|
||||
}: Props): Behavior<Epoch<ConnectionMap>> {
|
||||
const logger = parentLogger.getChild("[ConnectionMap]");
|
||||
// TODO logger: only construct one logger from the client and make it compatible via a EC specific sing
|
||||
|
||||
/**
|
||||
* All transports currently represented in the connection map. This list does
|
||||
* not include duplicate transports.
|
||||
*/
|
||||
const localAndRemoteTransports$: Behavior<Epoch<TransportLocator[]>> =
|
||||
scope.behavior(
|
||||
remoteTransports$.pipe(
|
||||
@@ -152,24 +138,18 @@ export function createConnectionMap$({
|
||||
|
||||
const localTransportKey = keyFor(localTransport);
|
||||
|
||||
/**
|
||||
* Connections for each transport in use by one or more session members.
|
||||
*/
|
||||
const connections$ = scope.behavior(
|
||||
localAndRemoteTransports$.pipe(
|
||||
generateItemsWithEpoch(
|
||||
"ConnectionMap connections$",
|
||||
function* (transports) {
|
||||
for (const transport of transports) {
|
||||
const role =
|
||||
const role: ConnectionRole =
|
||||
keyFor(transport) === localTransportKey
|
||||
? ("publisher" as const)
|
||||
: ("subscriber" as const);
|
||||
yield {
|
||||
// The locator is an object, so a stable key stands in for it
|
||||
keys: [role, keyFor(transport)],
|
||||
data: transport,
|
||||
};
|
||||
? "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) => {
|
||||
@@ -182,10 +162,8 @@ export function createConnectionMap$({
|
||||
ownMembershipIdentity,
|
||||
logger,
|
||||
);
|
||||
// Start the connection immediately
|
||||
// Use connection state to track connection progress
|
||||
// The state carries the progress, errors included
|
||||
void connection.start();
|
||||
// TODO subscribe to connection state to retry or log issues?
|
||||
return connection;
|
||||
},
|
||||
),
|
||||
@@ -197,15 +175,12 @@ export function createConnectionMap$({
|
||||
switchMap(({ value: connections, epoch }) => {
|
||||
if (connections.length === 0)
|
||||
return of(new Epoch(new ConnectionMap(), epoch));
|
||||
|
||||
return combineLatest(
|
||||
// Map the connections to list of Observable<{connection, participants}>
|
||||
connections.map((connection) =>
|
||||
connection.remoteParticipants$.pipe(
|
||||
map((participants) => ({ connection, participants })),
|
||||
),
|
||||
),
|
||||
// Collect into a single ConnectionMap
|
||||
(...list) => {
|
||||
const map = new ConnectionMap(logger);
|
||||
for (const item of list)
|
||||
@@ -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,545 @@
|
||||
/*
|
||||
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("prepares a remote SFU's transport on its homeserver, where delegation is not possible", async () => {
|
||||
// Our own homeserver would delegate, so only the SFU being remote can
|
||||
// explain delegation being skipped
|
||||
fetchMock.post(homeserverEndpoint, 401);
|
||||
const remoteTransport: UnstableLivekitTransport = {
|
||||
type: "livekit",
|
||||
url: "wss://sfu.sample.com",
|
||||
server_name: "sample.com",
|
||||
};
|
||||
|
||||
const capabilities = await backend().prepareLocalTransport(remoteTransport);
|
||||
|
||||
expect(capabilities).toEqual({ canDelegateDelayedLeave: false });
|
||||
expect(connections[0].connection.role).toBe("publisher");
|
||||
expect(connections[0].connection.serverName).toBe("sample.com");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
it("connects to a remote publisher's transport on the SFU's homeserver", async () => {
|
||||
const b = await prepared();
|
||||
// Dave has no SFU of his own and publishes on sample.com's
|
||||
const remoteTransport: UnstableLivekitTransport = {
|
||||
type: "livekit",
|
||||
url: "wss://sfu.sample.com",
|
||||
server_name: "sample.com",
|
||||
};
|
||||
b.mediaFor(testScope(), {
|
||||
...bob(remoteTransport, ["dave-hashed"]),
|
||||
userId: "@dave:example.org",
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const remote = connections.find(
|
||||
(c) => c.connection.role === "subscriber",
|
||||
);
|
||||
expect(remote?.connection.transport).toEqual(remoteTransport);
|
||||
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,404 @@
|
||||
/*
|
||||
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, serverNameOfTransport } 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 ownServerName = serverNameOf(ownMembershipIdentity.userId);
|
||||
|
||||
const prepareLocalTransport = async (
|
||||
transport: Transport,
|
||||
): Promise<TransportCapabilities> => {
|
||||
if (!isUnstableLivekitTransport(transport))
|
||||
throw new MatrixRTCError(`Not a LiveKit transport: ${transport.type}`);
|
||||
const serverName = serverNameOfTransport(
|
||||
transport,
|
||||
ownMembershipIdentity.userId,
|
||||
);
|
||||
const locator: TransportLocator = { transport, serverName };
|
||||
let canDelegate: Promise<boolean>;
|
||||
if (serverName !== ownServerName) {
|
||||
// Our homeserver only takes over leaves for its own SFUs
|
||||
logger.info(
|
||||
`Publishing on a remote SFU of ${serverName}, delegation is not possible`,
|
||||
);
|
||||
canDelegate = Promise.resolve(false);
|
||||
} else {
|
||||
// Delegation goes through whichever service authenticates us as a
|
||||
// publisher, so only that service's support counts
|
||||
const flow = chooseAuthFlow(transport, "publisher");
|
||||
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: serverNameOfTransport(raw, 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,172 @@
|
||||
/*
|
||||
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 { MatrixError } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { beforeEach, describe, expect, it, type Mocked, vi } from "vitest";
|
||||
|
||||
import { mockRtcMembership, ownMemberMock } from "../../utils/test";
|
||||
import { type ClientGetTokenParts } from "./auth/csApi";
|
||||
import { findRemotePublishLivekitTransport } from "./RemotePublishTransport";
|
||||
|
||||
/** A JWT whose payload states whether the issuing server lets us publish. */
|
||||
function jwtWithPublishGrant(canPublish: boolean): string {
|
||||
return [
|
||||
{}, // header
|
||||
{ sub: "identity", video: { room: "room", canPublish } }, // payload
|
||||
{}, // signature
|
||||
]
|
||||
.map((d) => global.btoa(JSON.stringify(d)))
|
||||
.join(".");
|
||||
}
|
||||
|
||||
const bobSfu = { type: "livekit", url: "wss://sfu.bob.com" };
|
||||
const carolSfu = { type: "livekit", url: "wss://sfu.carol.com" };
|
||||
const bobsTransport = { ...bobSfu, server_name: "bob.com" };
|
||||
const carolsTransport = { ...carolSfu, server_name: "carol.com" };
|
||||
|
||||
// Bob is the oldest member, Carol joined after him
|
||||
const bob = mockRtcMembership("@bob:bob.com", "BOB", {
|
||||
fociPreferred: [bobSfu],
|
||||
membership: { created_ts: 100 },
|
||||
});
|
||||
const carol = mockRtcMembership("@carol:carol.com", "CAROL", {
|
||||
fociPreferred: [carolSfu],
|
||||
membership: { created_ts: 200 },
|
||||
});
|
||||
// Bob's other device, and Dave who publishes on Bob's SFU remotely
|
||||
const bobsLaptop = mockRtcMembership("@bob:bob.com", "LAPTOP", {
|
||||
fociPreferred: [bobSfu],
|
||||
membership: { created_ts: 150 },
|
||||
});
|
||||
const dave = mockRtcMembership("@dave:dave.com", "DAVE", {
|
||||
fociPreferred: [bobsTransport],
|
||||
membership: { created_ts: 160 },
|
||||
});
|
||||
const legacyErin = mockRtcMembership("@erin:erin.com", "ERIN", {
|
||||
fociPreferred: [
|
||||
{ type: "livekit", livekit_service_url: "https://jwt.erin.com" },
|
||||
],
|
||||
membership: { created_ts: 50 },
|
||||
});
|
||||
const ourselves = mockRtcMembership(
|
||||
ownMemberMock.userId,
|
||||
ownMemberMock.deviceId,
|
||||
{
|
||||
fociPreferred: [{ type: "livekit", url: "wss://sfu.example.org" }],
|
||||
membership: { created_ts: 10 },
|
||||
},
|
||||
);
|
||||
|
||||
const csApiMissing = new MatrixError(
|
||||
{ errcode: "M_UNRECOGNIZED", error: "" },
|
||||
404,
|
||||
);
|
||||
|
||||
describe("findRemotePublishLivekitTransport", () => {
|
||||
let client: Mocked<ClientGetTokenParts>;
|
||||
const find = async (
|
||||
memberships: CallMembership[],
|
||||
): ReturnType<typeof findRemotePublishLivekitTransport> =>
|
||||
findRemotePublishLivekitTransport({
|
||||
client,
|
||||
memberships,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
roomId: "!room:example.org",
|
||||
slotId: "m.call#ROOM",
|
||||
logger,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
client = {
|
||||
_unstable_getLivekitToken: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ jwt: jwtWithPublishGrant(true) }),
|
||||
};
|
||||
});
|
||||
|
||||
it("publishes on the oldest member's SFU with its server_name", async () => {
|
||||
await expect(find([carol, bob])).resolves.toEqual(bobsTransport);
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalledTimes(1);
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalledWith({
|
||||
url: "wss://sfu.bob.com",
|
||||
server_name: "bob.com",
|
||||
room_id: "!room:example.org",
|
||||
slot_id: "m.call#ROOM",
|
||||
member_id: ownMemberMock.memberId,
|
||||
});
|
||||
});
|
||||
|
||||
it("asks about an SFU once however many members are on it", async () => {
|
||||
client._unstable_getLivekitToken.mockResolvedValue({
|
||||
jwt: jwtWithPublishGrant(false),
|
||||
});
|
||||
|
||||
await expect(find([dave, bobsLaptop, bob])).resolves.toBeNull();
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips our own membership", async () => {
|
||||
await expect(find([ourselves, bob])).resolves.toEqual(bobsTransport);
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores members publishing through a legacy JWT service", async () => {
|
||||
await expect(find([legacyErin, bob])).resolves.toEqual(bobsTransport);
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("moves on when a homeserver does not let us publish", async () => {
|
||||
client._unstable_getLivekitToken.mockImplementation(
|
||||
async ({ server_name }) =>
|
||||
Promise.resolve({
|
||||
jwt: jwtWithPublishGrant(server_name === "carol.com"),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(find([bob, carol])).resolves.toEqual(carolsTransport);
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("moves on when a homeserver refuses to issue a token", async () => {
|
||||
client._unstable_getLivekitToken.mockImplementation(
|
||||
async ({ server_name }) =>
|
||||
server_name === "bob.com"
|
||||
? Promise.reject(
|
||||
new MatrixError(
|
||||
{ errcode: "M_FORBIDDEN", error: "Not joined" },
|
||||
403,
|
||||
),
|
||||
)
|
||||
: Promise.resolve({ jwt: jwtWithPublishGrant(true) }),
|
||||
);
|
||||
|
||||
await expect(find([bob, carol])).resolves.toEqual(carolsTransport);
|
||||
});
|
||||
|
||||
it("finds nothing once all candidates are exhausted", async () => {
|
||||
client._unstable_getLivekitToken.mockResolvedValue({
|
||||
jwt: jwtWithPublishGrant(false),
|
||||
});
|
||||
|
||||
await expect(find([bob, carol])).resolves.toBeNull();
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("gives up immediately if our homeserver lacks the CS API", async () => {
|
||||
client._unstable_getLivekitToken.mockRejectedValue(csApiMissing);
|
||||
|
||||
await expect(find([bob, carol])).resolves.toBeNull();
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("finds nothing where there are no other members", async () => {
|
||||
await expect(find([ourselves])).resolves.toBeNull();
|
||||
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
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 CallMembership,
|
||||
isUnstableLivekitTransport,
|
||||
type Transport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
|
||||
import { isCsApiUnavailable } from "./auth";
|
||||
import * as csApi from "./auth/csApi";
|
||||
import { decodeSFUJWTPayload } from "./auth/types";
|
||||
|
||||
export interface RemotePublishTransportParams {
|
||||
client: csApi.ClientGetTokenParts;
|
||||
/** The members whose SFUs are the candidates. */
|
||||
memberships: CallMembership[];
|
||||
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
roomId: string;
|
||||
slotId: string;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SFU of another member that its homeserver lets us publish on, as the
|
||||
* transport to advertise, or null where none does. Candidates are tried
|
||||
* oldest membership first, each SFU once, and each is checked by asking for a
|
||||
* publisher token through our homeserver (MSC4195): a homeserver may restrict
|
||||
* remote publishers, which shows as a token without the publish grant.
|
||||
*/
|
||||
export async function findRemotePublishLivekitTransport({
|
||||
client,
|
||||
memberships,
|
||||
ownMembershipIdentity: own,
|
||||
roomId,
|
||||
slotId,
|
||||
logger,
|
||||
}: RemotePublishTransportParams): Promise<Transport | null> {
|
||||
logger.info(
|
||||
"No local transport available, looking for another member's SFU to publish on",
|
||||
);
|
||||
const tried = new Set<string>();
|
||||
const byAge = [...memberships].sort((a, b) => a.createdTs() - b.createdTs());
|
||||
for (const membership of byAge) {
|
||||
if (
|
||||
membership.userId === own.userId &&
|
||||
membership.deviceId === own.deviceId
|
||||
)
|
||||
continue;
|
||||
for (const transport of membership.transports) {
|
||||
// Publishing remotely goes through the client-server API, which a JWT
|
||||
// service cannot stand in for, so only a `url` qualifies
|
||||
if (!isUnstableLivekitTransport(transport) || !("url" in transport))
|
||||
continue;
|
||||
const { url } = transport;
|
||||
const serverName = membership.getTransportServerName(transport);
|
||||
const key = JSON.stringify([url, serverName]);
|
||||
if (tried.has(key)) continue;
|
||||
tried.add(key);
|
||||
try {
|
||||
const { jwt } = await csApi.getSFUConfig({
|
||||
client,
|
||||
membership: own,
|
||||
url,
|
||||
serverName,
|
||||
roomId,
|
||||
slotId,
|
||||
});
|
||||
try {
|
||||
// The SFU treats an absent canPublish grant as publishing allowed.
|
||||
if (decodeSFUJWTPayload(jwt).video.canPublish !== false) {
|
||||
logger.info(`Publishing on remote SFU ${url} of ${serverName}`);
|
||||
return { type: "livekit", url, server_name: serverName };
|
||||
}
|
||||
logger.info(
|
||||
`${serverName} does not let us publish on ${url}, skipping`,
|
||||
);
|
||||
continue;
|
||||
} catch (e) {
|
||||
logger.info(
|
||||
`${serverName}: failed to decode SFUJWT payload: ${e}, skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
} catch (e) {
|
||||
if (isCsApiUnavailable(e)) {
|
||||
// Without the endpoint no further candidate can work
|
||||
logger.warn(
|
||||
`Cannot use the MSC4195 get_token endpoint (${e}), so no remote SFU can be used`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
logger.warn(`Failed to get a token for ${url} of ${serverName}:`, e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -8,13 +8,13 @@ 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 { extractFullConfigFromToken, type SFUConfig } from "./types.ts";
|
||||
import { doNetworkOperationWithRetry } from "../../utils/matrix.ts";
|
||||
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.
|
||||
* 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;
|
||||
@@ -26,36 +26,18 @@ export type ClientGetTokenParts = Pick<
|
||||
>;
|
||||
|
||||
export interface GetSFUConfigParams {
|
||||
/**
|
||||
* The Matrix client.
|
||||
*/
|
||||
client: ClientGetTokenParts;
|
||||
/**
|
||||
* Data identifying the local user's session membership.
|
||||
*/
|
||||
/** Our own membership identity; the member id is what the token is issued for. */
|
||||
membership: CallMembershipIdentityParts;
|
||||
/**
|
||||
* The WebSocket URL of the SFU for which we wish to get an access token.
|
||||
*/
|
||||
/** The WebSocket URL of the SFU to get a token for. */
|
||||
url: string;
|
||||
/**
|
||||
* The name of the homeserver to which the SFU belongs.
|
||||
*/
|
||||
/** The homeserver the SFU belongs to. */
|
||||
serverName: string;
|
||||
/**
|
||||
* The ID of the Matrix room in which the session takes place.
|
||||
*/
|
||||
roomId: string;
|
||||
/**
|
||||
* The ID of the MatrixRTC slot in which the session takes place.
|
||||
*/
|
||||
slotId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an {@link SFUConfig} appropriate for connecting to a given SFU, using
|
||||
* the MSC4195 LiveKit client-server API endpoints.
|
||||
*/
|
||||
/** Gets an SFU token through the homeserver's MSC4195 `get_token` endpoint. */
|
||||
export async function getSFUConfig({
|
||||
client,
|
||||
membership,
|
||||
@@ -84,36 +66,19 @@ export type ClientDelegationParts = Pick<
|
||||
>;
|
||||
|
||||
export interface DelegateDelayedLeaveParams {
|
||||
/**
|
||||
* The Matrix client.
|
||||
*/
|
||||
client: ClientDelegationParts;
|
||||
/**
|
||||
* Data identifying the local user's session membership.
|
||||
*/
|
||||
membership: CallMembershipIdentityParts;
|
||||
/**
|
||||
* The WebSocket URL of the SFU to which we are publishing and wish to
|
||||
* delegate the delayed leave event.
|
||||
*/
|
||||
/** The WebSocket URL of the SFU we publish on. */
|
||||
url: string;
|
||||
/**
|
||||
* The ID of the Matrix room in which the session takes place.
|
||||
*/
|
||||
roomId: string;
|
||||
/**
|
||||
* The ID of the MatrixRTC slot in which the session takes place.
|
||||
*/
|
||||
slotId: string;
|
||||
/**
|
||||
* The delay ID of the leave event to be delegated.
|
||||
*/
|
||||
/** The delay ID of the leave event to hand over. */
|
||||
delayId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegates a delayed leave event to a given SFU, so that the event will
|
||||
* be sent on our behalf whenever we disconnect from the SFU.
|
||||
* 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,
|
||||
+180
-79
@@ -5,36 +5,27 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, expect, type Mocked, vi, describe, it } from "vitest";
|
||||
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
aliceDeviceId,
|
||||
aliceId,
|
||||
aliceUserId,
|
||||
createJWTToken,
|
||||
} from "../../utils/test-fixtures";
|
||||
import type * as csApi from "./csApi";
|
||||
import * as openID from "./openID";
|
||||
import { delegateDelayedLeave, getSFUConfig } from ".";
|
||||
import { mocked } from "storybook/test";
|
||||
import { afterEach, describe, expect, it, type Mocked, vi } from "vitest";
|
||||
import fetchMock from "fetch-mock";
|
||||
import { ConnectionError, MatrixError } from "matrix-js-sdk";
|
||||
import { NoMatrix2AuthorizationService } from "../../utils/errors";
|
||||
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";
|
||||
|
||||
vi.mock("./openID", () => ({ getSFUConfig: vi.fn() }));
|
||||
vi.useFakeTimers();
|
||||
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 openIDJWTToken = createJWTToken(aliceId, "via OpenID");
|
||||
|
||||
mocked(openID).getSFUConfig.mockResolvedValue({
|
||||
url: "mock-openID-url",
|
||||
jwt: openIDJWTToken,
|
||||
livekitAlias: "via OpenID",
|
||||
livekitIdentity: aliceId,
|
||||
});
|
||||
const legacyJWTToken = createJWTToken(aliceId, "via legacy endpoint");
|
||||
const defaultJWTToken = createJWTToken(aliceId, "via default endpoint");
|
||||
|
||||
const client: Mocked<
|
||||
csApi.ClientGetTokenParts &
|
||||
@@ -65,9 +56,10 @@ const urlTransport: UnstableLivekitTransport = {
|
||||
url: "wss://livekit.example.org",
|
||||
};
|
||||
|
||||
const serviceUrl = "https://livekit-jwt.example.org";
|
||||
const serviceUrlTransport: UnstableLivekitTransport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://livekit-jwt.example.org",
|
||||
livekit_service_url: serviceUrl,
|
||||
};
|
||||
|
||||
const urlAndServiceUrlTransport: UnstableLivekitTransport = {
|
||||
@@ -75,7 +67,27 @@ const urlAndServiceUrlTransport: UnstableLivekitTransport = {
|
||||
...serviceUrlTransport,
|
||||
};
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
/** 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 = {
|
||||
@@ -89,6 +101,7 @@ describe("getSFUConfig", () => {
|
||||
};
|
||||
|
||||
it("uses CS API with url transport", async () => {
|
||||
mockJWTService();
|
||||
expect(
|
||||
await getSFUConfig({ ...defaultParams, transport: urlTransport }),
|
||||
).toEqual(
|
||||
@@ -97,33 +110,42 @@ describe("getSFUConfig", () => {
|
||||
livekitAlias: "via CS API",
|
||||
}),
|
||||
);
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).not.toHaveBeenCalled();
|
||||
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(openID.getSFUConfig).not.toHaveBeenCalled();
|
||||
expect(jwtServiceCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses OpenID with livekit_service_url transport", async () => {
|
||||
mockJWTService();
|
||||
expect(
|
||||
await getSFUConfig({ ...defaultParams, transport: serviceUrlTransport }),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
jwt: openIDJWTToken,
|
||||
livekitAlias: "via OpenID",
|
||||
jwt: defaultJWTToken,
|
||||
livekitAlias: "via default endpoint",
|
||||
}),
|
||||
);
|
||||
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).toHaveBeenCalled();
|
||||
expect(jwtServiceCalls()).toEqual(["/get_token"]);
|
||||
});
|
||||
|
||||
it("uses CS API with url + livekit_service_url transport", async () => {
|
||||
it("uses CS API when subscribing with url + livekit_service_url transport", async () => {
|
||||
mockJWTService();
|
||||
expect(
|
||||
await getSFUConfig({
|
||||
...defaultParams,
|
||||
@@ -136,10 +158,11 @@ describe("getSFUConfig", () => {
|
||||
}),
|
||||
);
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).not.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({
|
||||
@@ -148,15 +171,16 @@ describe("getSFUConfig", () => {
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
jwt: openIDJWTToken,
|
||||
livekitAlias: "via OpenID",
|
||||
jwt: defaultJWTToken,
|
||||
livekitAlias: "via default endpoint",
|
||||
}),
|
||||
);
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).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(
|
||||
@@ -169,11 +193,12 @@ describe("getSFUConfig", () => {
|
||||
...defaultParams,
|
||||
transport: urlAndServiceUrlTransport,
|
||||
}),
|
||||
).toEqual(expect.objectContaining({ jwt: openIDJWTToken }));
|
||||
expect(openID.getSFUConfig).toHaveBeenCalled();
|
||||
).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.",
|
||||
);
|
||||
@@ -181,10 +206,11 @@ describe("getSFUConfig", () => {
|
||||
await expect(
|
||||
getSFUConfig({ ...defaultParams, transport: urlTransport }),
|
||||
).rejects.toThrow(error);
|
||||
expect(openID.getSFUConfig).not.toHaveBeenCalled();
|
||||
expect(jwtServiceCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses CS API when publishing with url transport", async () => {
|
||||
mockJWTService();
|
||||
expect(
|
||||
await getSFUConfig({
|
||||
...defaultParams,
|
||||
@@ -193,10 +219,11 @@ describe("getSFUConfig", () => {
|
||||
}),
|
||||
).toEqual(expect.objectContaining({ jwt: csApiJWTToken }));
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).not.toHaveBeenCalled();
|
||||
expect(jwtServiceCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not fall back to OpenID when publishing", async () => {
|
||||
mockJWTService();
|
||||
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
|
||||
await expect(
|
||||
getSFUConfig({
|
||||
@@ -206,12 +233,13 @@ describe("getSFUConfig", () => {
|
||||
}),
|
||||
).rejects.toThrow(expect.any(NoMatrix2AuthorizationService));
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).not.toHaveBeenCalled();
|
||||
expect(jwtServiceCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses OpenID when publishing with url + livekit_service_url transport", async () => {
|
||||
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 state event, which only the JWT service assigns
|
||||
// anyone advertising a membership, which only the JWT service assigns
|
||||
expect(
|
||||
await getSFUConfig({
|
||||
...defaultParams,
|
||||
@@ -220,35 +248,39 @@ describe("getSFUConfig", () => {
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
jwt: openIDJWTToken,
|
||||
livekitAlias: "via OpenID",
|
||||
jwt: legacyJWTToken,
|
||||
livekitAlias: "via legacy endpoint",
|
||||
}),
|
||||
);
|
||||
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ role: "publisher" }),
|
||||
);
|
||||
expect(jwtServiceCalls()).toEqual(["/sfu/get"]);
|
||||
});
|
||||
|
||||
it("does not fall back to OpenID on connection error", async () => {
|
||||
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(openID.getSFUConfig).not.toHaveBeenCalled();
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -259,16 +291,35 @@ describe("delegateDelayedLeave", () => {
|
||||
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).toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).not.toHaveBeenCalled();
|
||||
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,
|
||||
);
|
||||
@@ -276,31 +327,81 @@ describe("delegateDelayedLeave", () => {
|
||||
delegateDelayedLeave({ ...defaultParams, transport: urlTransport }),
|
||||
).rejects.toThrow();
|
||||
expect(client._unstable_delegateDelayedLeave).toHaveBeenCalled();
|
||||
expect(openID.getSFUConfig).not.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(openID.getSFUConfig).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(openID.getSFUConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
role: "publisher",
|
||||
delayId: "delay123",
|
||||
delayEndpointBaseUrl: client.baseUrl,
|
||||
}),
|
||||
);
|
||||
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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -5,28 +5,32 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
/**
|
||||
* 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 * as openID from "./openID";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
|
||||
import { NoMatrix2AuthorizationService } from "../../../errors";
|
||||
import * as csApi from "./csApi";
|
||||
import { type SFUConfig } from "./types";
|
||||
import { NoMatrix2AuthorizationService } from "../../utils/errors";
|
||||
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";
|
||||
|
||||
/**
|
||||
* Checks whether an error means "this homeserver does not implement the
|
||||
* (unstable) MSC4195 endpoints".
|
||||
*
|
||||
* MSC4195 documents `M_NOT_FOUND` for this, but a homeserver without support
|
||||
* answers with whatever it uses for unknown endpoints (Synapse: 404 with
|
||||
* `M_UNRECOGNIZED`), so we accept any 404 as "unsupported".
|
||||
* @param e The error to check.
|
||||
* 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 (
|
||||
@@ -38,53 +42,30 @@ function isEndpointUnsupported(e: unknown): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an error means the MSC4195 CS API cannot be used from here:
|
||||
* the homeserver does not implement it, or the request never reached the
|
||||
* homeserver because in widget mode the host does not expose the endpoint
|
||||
* (MSC4533), which the widget API reports as a plain error rather than a
|
||||
* Matrix error. A connection error is neither, as the endpoint may well exist.
|
||||
* @param e The error to check.
|
||||
* 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 {
|
||||
export function isCsApiUnavailable(e: unknown): boolean {
|
||||
if (isEndpointUnsupported(e)) return true;
|
||||
return !(e instanceof MatrixError) && !(e instanceof ConnectionError);
|
||||
}
|
||||
|
||||
export interface GetSFUConfigParams {
|
||||
/**
|
||||
* The Matrix client.
|
||||
*/
|
||||
client: csApi.ClientGetTokenParts & openID.ClientOpenIDParts;
|
||||
/**
|
||||
* Data identifying the local user's session membership.
|
||||
*/
|
||||
membership: CallMembershipIdentityParts;
|
||||
/**
|
||||
* The transport for which we wish to get an access token.
|
||||
*/
|
||||
transport: UnstableLivekitTransport;
|
||||
/**
|
||||
* The name of the homeserver to which the {@link transport} belongs.
|
||||
*/
|
||||
/** The homeserver the transport belongs to. */
|
||||
serverName: string;
|
||||
/**
|
||||
* The ID of the Matrix room in which the session takes place.
|
||||
*/
|
||||
roomId: string;
|
||||
/**
|
||||
* The ID of the MatrixRTC slot in which the session takes place.
|
||||
*/
|
||||
slotId: string;
|
||||
/**
|
||||
* Whether we want to publish or only subscribe on the {@link transport}.
|
||||
*/
|
||||
role: "publisher" | "subscriber";
|
||||
role: ConnectionRole;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an {@link SFUConfig} appropriate for connecting to a given transport.
|
||||
*/
|
||||
/** Authenticates with a transport, through whichever flow suits it and our role. */
|
||||
export async function getSFUConfig({
|
||||
client,
|
||||
membership,
|
||||
@@ -119,10 +100,9 @@ export async function getSFUConfig({
|
||||
slotId,
|
||||
});
|
||||
} catch (e) {
|
||||
// Publishers should never fall back. If a homeserver advertises a `url`
|
||||
// but doesn't support MSC4195 itself, that's a misconfiguration which
|
||||
// the admin should know about so they can prepare for the future
|
||||
// deprecation of `livekit_service_url`.
|
||||
// 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)) {
|
||||
@@ -142,37 +122,22 @@ export async function getSFUConfig({
|
||||
}
|
||||
|
||||
export interface DelegateDelayedLeaveParams {
|
||||
/**
|
||||
* The Matrix client.
|
||||
*/
|
||||
client: csApi.ClientDelegationParts & openID.ClientOpenIDParts;
|
||||
/**
|
||||
* Data identifying the local user's session membership.
|
||||
*/
|
||||
membership: CallMembershipIdentityParts;
|
||||
/**
|
||||
* The transport to which we are publishing and wish to delegate the delayed
|
||||
* leave event.
|
||||
*/
|
||||
/** The transport we publish on. */
|
||||
transport: UnstableLivekitTransport;
|
||||
/**
|
||||
* The ID of the Matrix room in which the session takes place.
|
||||
*/
|
||||
roomId: string;
|
||||
/**
|
||||
* The ID of the MatrixRTC slot in which the session takes place.
|
||||
*/
|
||||
slotId: string;
|
||||
/**
|
||||
* The delay ID of the leave event to be delegated.
|
||||
*/
|
||||
delayId: string;
|
||||
/** How long the service waits before sending the leave, for the JWT service flow. */
|
||||
delayTimeoutMs: number;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegates a delayed leave event to a given transport, so that the event will
|
||||
* be sent on our behalf whenever we disconnect from the transport's SFU.
|
||||
* 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,
|
||||
@@ -181,11 +146,9 @@ export async function delegateDelayedLeave({
|
||||
roomId,
|
||||
slotId,
|
||||
delayId,
|
||||
delayTimeoutMs,
|
||||
logger,
|
||||
}: DelegateDelayedLeaveParams): Promise<void> {
|
||||
// It's important that we use the same flow (CS API or OpenID) as whatever we
|
||||
// would've used to get a JWT token for this transport, since the participant
|
||||
// identity computed by the service needs to match.
|
||||
const flow = chooseAuthFlow(transport, "publisher");
|
||||
if (flow.kind === "csApi") {
|
||||
try {
|
||||
@@ -205,8 +168,7 @@ export async function delegateDelayedLeave({
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
// This will technically cause the service to issue a new JWT token, but
|
||||
// it's safe to discard. We're only interested in triggering delegation.
|
||||
// The token this issues is discarded; the request is what hands the leave over
|
||||
await openID.getSFUConfig({
|
||||
client,
|
||||
membership,
|
||||
@@ -216,33 +178,30 @@ export async function delegateDelayedLeave({
|
||||
role: "publisher",
|
||||
delayEndpointBaseUrl: client.baseUrl,
|
||||
delayId,
|
||||
delayTimeoutMs,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The flow with which the local member authenticates against a transport.
|
||||
*/
|
||||
/** 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 CS API and the legacy OpenID flow against the
|
||||
* transport's JWT service.
|
||||
* 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 state event. So a publisher keeps using the JWT service wherever
|
||||
* the transport still offers one, even if it also has a `url`. Subscribers may
|
||||
* use the CS API regardless, as nobody matches their identity.
|
||||
* @param transport The transport to authenticate against.
|
||||
* @param role Whether we want to publish or only subscribe on the transport.
|
||||
* 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: "publisher" | "subscriber",
|
||||
role: ConnectionRole,
|
||||
): AuthFlow {
|
||||
if ("url" in transport) {
|
||||
const useCsApi =
|
||||
@@ -16,22 +16,23 @@ import {
|
||||
} 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 "../../utils/errors";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
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", () => {
|
||||
@@ -128,7 +129,6 @@ describe("getSFUConfig", () => {
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
// Note: Assuming getSFUConfig eventually calls getLiveKitJWT
|
||||
const config = await getSFUConfig({ ...params, ...delayParams });
|
||||
|
||||
expect(config.jwt).toBe(testJWTToken);
|
||||
@@ -162,7 +162,6 @@ describe("getSFUConfig", () => {
|
||||
|
||||
const config = await getSFUConfig({ ...params, ...delayParams });
|
||||
|
||||
// Prüfe das Ergebnis
|
||||
expect(config).toMatchObject({
|
||||
jwt: testJWTToken,
|
||||
url: serviceUrl,
|
||||
@@ -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,65 @@
|
||||
/*
|
||||
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. */
|
||||
export interface SFUJWTPayload {
|
||||
/** The subject: the participant identity the token carries. */
|
||||
sub: string;
|
||||
video: {
|
||||
room: string;
|
||||
/** Whether the token lets us publish; a homeserver may deny this to remote publishers. */
|
||||
canPublish?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/** Completes the SFU websocket url and the JWT with what the JWT payload says. */
|
||||
export function extractFullConfigFromToken(sfuConfig: {
|
||||
url: string;
|
||||
jwt: string;
|
||||
}): SFUConfig {
|
||||
const payload = decodeSFUJWTPayload(sfuConfig.jwt);
|
||||
return {
|
||||
jwt: sfuConfig.jwt,
|
||||
url: sfuConfig.url,
|
||||
livekitAlias: payload.video.room,
|
||||
livekitIdentity: payload.sub,
|
||||
};
|
||||
}
|
||||
|
||||
/** Decodes the payload of an SFU JWT, without verifying its signature. */
|
||||
export function decodeSFUJWTPayload(jwt: string): SFUJWTPayload {
|
||||
const [, payloadStr] = jwt.split(".");
|
||||
return JSON.parse(global.atob(payloadStr)) as SFUJWTPayload;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,10 @@ 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 { describe, expect, it } from "vitest";
|
||||
import { VideoPresets, type VideoPreset } from "livekit-client";
|
||||
|
||||
import { buildLiveKitOptions, getLiveKitOptions } from "./options";
|
||||
import { Config } from "../config/Config";
|
||||
|
||||
vi.mock("../config/Config", () => ({
|
||||
Config: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
import { buildLiveKitOptions } from "./livekitOptions";
|
||||
|
||||
describe("buildLiveKitOptions", () => {
|
||||
it("returns sensible defaults with no config", () => {
|
||||
@@ -194,20 +187,3 @@ describe("buildLiveKitOptions", () => {
|
||||
expect(backup.encoding).toEqual(VideoPresets.h720.encoding);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLiveKitOptions", () => {
|
||||
it("reads from Config singleton", () => {
|
||||
vi.mocked(Config.get).mockReturnValue({
|
||||
media_quality: { video_codec: "h264" },
|
||||
} as ReturnType<typeof Config.get>);
|
||||
const opts = getLiveKitOptions();
|
||||
expect(opts.publishDefaults?.videoCodec).toBe("h264");
|
||||
});
|
||||
|
||||
it("throws when Config is not initialized", () => {
|
||||
vi.mocked(Config.get).mockImplementation(() => {
|
||||
throw new Error("Config not initialized");
|
||||
});
|
||||
expect(() => getLiveKitOptions()).toThrow("Config not initialized");
|
||||
});
|
||||
});
|
||||
@@ -15,8 +15,7 @@ import {
|
||||
VideoPreset as VideoPresetClass,
|
||||
} from "livekit-client";
|
||||
|
||||
import { Config } from "../config/Config";
|
||||
import { DEFAULT_CONFIG, type ConfigOptions } from "../config/ConfigOptions";
|
||||
import { defaultMediaQuality, type MediaQuality } from "../../config";
|
||||
|
||||
/**
|
||||
* Find the closest matching VideoPreset for a given height.
|
||||
@@ -35,9 +34,9 @@ function videoPresetForHeight(height: number): VideoPreset {
|
||||
* Build LiveKit publish options from config, falling back to sensible defaults.
|
||||
*/
|
||||
function buildPublishOptions(
|
||||
mediaQuality: ConfigOptions["media_quality"],
|
||||
mediaQuality: MediaQuality | undefined,
|
||||
): TrackPublishDefaults {
|
||||
const defaults = DEFAULT_CONFIG.media_quality;
|
||||
const defaults = defaultMediaQuality;
|
||||
const videoConf = mediaQuality?.video;
|
||||
const screenConf = mediaQuality?.screen_share;
|
||||
const codec = mediaQuality?.video_codec ?? defaults.video_codec;
|
||||
@@ -109,15 +108,12 @@ function buildPublishOptions(
|
||||
}
|
||||
|
||||
/**
|
||||
* Build LiveKit RoomOptions from config.
|
||||
* Call this after Config.init() has resolved.
|
||||
* Build LiveKit RoomOptions from the media quality a host asks for.
|
||||
*/
|
||||
export function buildLiveKitOptions(
|
||||
mediaQuality?: ConfigOptions["media_quality"],
|
||||
): RoomOptions {
|
||||
export function buildLiveKitOptions(mediaQuality?: MediaQuality): RoomOptions {
|
||||
const videoHeight =
|
||||
mediaQuality?.video?.max_resolution ??
|
||||
DEFAULT_CONFIG.media_quality.video.max_resolution;
|
||||
defaultMediaQuality.video.max_resolution;
|
||||
const basePreset = videoPresetForHeight(videoHeight);
|
||||
|
||||
return {
|
||||
@@ -142,11 +138,3 @@ export function buildLiveKitOptions(
|
||||
webAudioMix: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get LiveKit options, reading from the loaded Config singleton.
|
||||
* Requires Config.init() to have resolved first.
|
||||
*/
|
||||
export function getLiveKitOptions(): RoomOptions {
|
||||
return buildLiveKitOptions(Config.get().media_quality);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user