mirror of
https://github.com/vector-im/element-call.git
synced 2026-11-04 00:42:28 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e02db20c9f | ||
|
|
9aabe05b34 | ||
|
|
b5165b8f3b | ||
|
|
9714412a43 |
@@ -1,135 +0,0 @@
|
||||
name: Build & publish the component package for releases
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
pull_request:
|
||||
types:
|
||||
- synchronize
|
||||
- opened
|
||||
- labeled
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
# Each job asks for what it needs
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
versioning:
|
||||
name: Versioning
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
DRY_RUN: ${{ steps.dry_run.outputs.DRY_RUN }}
|
||||
PREFIXED_VERSION: ${{ steps.prefixed_version.outputs.PREFIXED_VERSION }}
|
||||
TAG: ${{ steps.tag.outputs.TAG }}
|
||||
steps:
|
||||
- name: Calculate VERSION
|
||||
# Safely store dynamic values in environment variables
|
||||
# to prevent shell injection (template-injection)
|
||||
run: |
|
||||
if [ "$EVENT_NAME" = "release" ]; then
|
||||
echo "VERSION=$RELEASE_TAG" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "VERSION=v0.0.0-pre.0" >> "$GITHUB_ENV"
|
||||
fi
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
- id: dry_run
|
||||
name: Set DRY_RUN
|
||||
# A dry run for everything but releases, so that a broken package shows up
|
||||
# ahead of a release
|
||||
run: echo "DRY_RUN=${{ github.event_name != 'release' }}" >> "$GITHUB_OUTPUT"
|
||||
- id: prefixed_version
|
||||
name: Set PREFIXED_VERSION
|
||||
run: echo "PREFIXED_VERSION=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
- id: tag
|
||||
# latest = a proper release
|
||||
# other = anything else
|
||||
name: Set tag
|
||||
run: |
|
||||
if [[ "${VERSION}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "TAG=latest" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "${VERSION}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+\-rc\.[0-9]+$ ]]; then
|
||||
echo "TAG=rc" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "TAG=other" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
publish_npm:
|
||||
needs: versioning
|
||||
name: Publish NPM
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Allow npm to authenticate as a trusted publisher
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
# No dependency cache: a release build must not restore one another workflow could have poisoned
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
# Layered like build-element-call.yaml, so a PR paired with a matrix-js-sdk branch
|
||||
# builds against it; a release pins the SDK, so layered.sh leaves it as locked
|
||||
- name: Install dependencies
|
||||
run: scripts/layered.sh --ignore-pnpmfile
|
||||
env:
|
||||
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
|
||||
- name: Build component
|
||||
run: pnpm build:component:production
|
||||
env:
|
||||
VITE_APP_VERSION: component-${{ needs.versioning.outputs.PREFIXED_VERSION }}
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
|
||||
# `devEngines` (pnpm only, which npm refuses) and the `prepare` build exist for
|
||||
# git-dependency installs; the published package is already built
|
||||
- name: Publish npm
|
||||
working-directory: component
|
||||
run: |
|
||||
jq 'del(.devEngines, .scripts)' package.json > package.json.tmp && mv package.json.tmp package.json
|
||||
npm version ${NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION} --no-git-tag-version
|
||||
echo "ARTIFACT_VERSION=$(jq '.version' --raw-output package.json)" >> "$GITHUB_ENV"
|
||||
npm publish --provenance --access public --tag ${NEEDS_VERSIONING_OUTPUTS_TAG} ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }}
|
||||
env:
|
||||
NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION: ${{ needs.versioning.outputs.PREFIXED_VERSION }}
|
||||
NEEDS_VERSIONING_OUTPUTS_TAG: ${{ needs.versioning.outputs.TAG }}
|
||||
|
||||
- id: artifact_version
|
||||
name: Output artifact version
|
||||
run: echo "ARTIFACT_VERSION=${ARTIFACT_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
release_notes:
|
||||
needs: [versioning, publish_npm]
|
||||
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
|
||||
name: Update release notes
|
||||
runs-on: ubuntu-latest
|
||||
# Shared with publish-embedded-packages.yaml, which appends to the same release: two
|
||||
# appends at once would each write back the body without the other's section
|
||||
concurrency:
|
||||
group: release-notes-${{ github.event.release.tag_name || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: write # to update release notes
|
||||
steps:
|
||||
- name: Add release notes
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
|
||||
## React component (experimental)
|
||||
|
||||
Element Call as a React component, rendered in the host's page instead of in an iframe.
|
||||
|
||||
```
|
||||
npm install @element-hq/element-call-component@${{ needs.publish_npm.outputs.ARTIFACT_VERSION }}
|
||||
```
|
||||
@@ -283,11 +283,6 @@ jobs:
|
||||
if: always()
|
||||
name: Update release notes
|
||||
runs-on: ubuntu-latest
|
||||
# Shared with publish-component.yaml, which appends to the same release: two appends
|
||||
# at once would each write back the body without the other's section
|
||||
concurrency:
|
||||
group: release-notes-${{ github.event.release.tag_name || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: write # to update release notes
|
||||
steps:
|
||||
|
||||
@@ -289,14 +289,8 @@ callback; with one, it asks the host to unmount it instead. The
|
||||
[global JS controls](./docs/controls.md) on `window` are unchanged and remain
|
||||
page-wide, so with several instances on one page they apply to all of them.
|
||||
|
||||
Each release publishes the package to npm, with the same version as the release:
|
||||
|
||||
```sh
|
||||
npm install @element-hq/element-call-component
|
||||
```
|
||||
|
||||
To use an unreleased commit, a host can instead install it as a git dependency
|
||||
on the `component` directory of this repository,
|
||||
The package is not published yet. A host installs it as a git dependency on the
|
||||
`component` directory of this repository,
|
||||
|
||||
```json
|
||||
"@element-hq/element-call-component": "github:element-hq/element-call#main&path:/component"
|
||||
|
||||
@@ -1,661 +0,0 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -1,6 +0,0 @@
|
||||
Licensees holding a valid commercial license with Element may use this
|
||||
software in accordance with the terms contained in a written agreement
|
||||
between you and Element.
|
||||
|
||||
To purchase a commercial license please contact our sales team at
|
||||
licensing@element.io
|
||||
@@ -1,7 +0,0 @@
|
||||
# @element-hq/element-call-component
|
||||
|
||||
Element Call as a React component, rendered in the host application's page
|
||||
instead of in an iframe. Experimental.
|
||||
|
||||
See [Element Call as a component](https://github.com/element-hq/element-call#element-call-as-a-component-experimental)
|
||||
in the Element Call README for usage.
|
||||
@@ -109,12 +109,4 @@ describe("scopeStylesToRoot", () => {
|
||||
".a .b_abc12:hover { border: 0 }",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves an excluded stylesheet as it is", async () => {
|
||||
const css = "body { margin: 0 } .menu { color: red }";
|
||||
const result = await postcss([
|
||||
scopeStylesToRoot({ exclude: [/host\.css$/] }),
|
||||
]).process(css, { from: "/app/host.css" });
|
||||
expect(result.css).toBe(css);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,18 +60,12 @@ const IS_ROOT = `:where(${ROOT_SELECTOR})`;
|
||||
* tokens, the component styles, the fonts — is not in this build at all: the
|
||||
* host supplies it, unscoped, along with its copy of Compound.
|
||||
*/
|
||||
interface Options {
|
||||
/** Stylesheets left as they are: a host's own, which it doesn't scope. */
|
||||
exclude?: RegExp[];
|
||||
}
|
||||
|
||||
export function scopeStylesToRoot({ exclude = [] }: Options = {}): Plugin {
|
||||
export function scopeStylesToRoot(): Plugin {
|
||||
return {
|
||||
postcssPlugin: "element-call-scope-styles-to-root",
|
||||
Once(root) {
|
||||
const file = root.source?.input.file ?? "";
|
||||
if (exclude.some((pattern) => pattern.test(file))) return;
|
||||
const isModule = file.endsWith(".module.css");
|
||||
const isModule =
|
||||
root.source?.input.file?.endsWith(".module.css") ?? false;
|
||||
root.walkRules((rule) => {
|
||||
if (isRelative(rule)) return;
|
||||
rule.selector = (isModule ? scopeBare : scopeAll).processSync(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@element-hq/element-call-component",
|
||||
"version": "0.0.0",
|
||||
"description": "Element Call as a React component.",
|
||||
"license": "AGPL-3.0-only OR LicenseRef-Element-Commercial",
|
||||
"description": "Element Call as a React component. Consumed straight from the repository as a git dependency (github:element-hq/element-call#<ref>&path:/component): 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",
|
||||
@@ -15,8 +15,6 @@
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"LICENSE-AGPL-3.0",
|
||||
"LICENSE-COMMERCIAL",
|
||||
"dist"
|
||||
],
|
||||
"main": "./dist/element-call.js",
|
||||
|
||||
@@ -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:fc21073b3c20c5a80aab6787dcbf1ddb3ed416eabf7fdfd32a7f4cabc47b7846
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:79e47d8d5a93848403cb83042a405d8ed233a7727c499fa53b4b39a80f090be4
|
||||
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:fc21073b3c20c5a80aab6787dcbf1ddb3ed416eabf7fdfd32a7f4cabc47b7846
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:79e47d8d5a93848403cb83042a405d8ed233a7727c499fa53b4b39a80f090be4
|
||||
|
||||
@@ -9,4 +9,4 @@ android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref
|
||||
|
||||
[plugins]
|
||||
android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" }
|
||||
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.37.0" }
|
||||
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.36.0" }
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.6-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
+3
-4
@@ -7,13 +7,13 @@
|
||||
"features": ["plural_postfix_us", "filter_untranslated"],
|
||||
"files": [
|
||||
{
|
||||
"pattern": "locales/en/app.json",
|
||||
"pattern": "locales/en/*.json",
|
||||
"lang": "inherited"
|
||||
},
|
||||
{
|
||||
"group": "existing",
|
||||
"pattern": "locales/*/app.json",
|
||||
"excludes": ["locales/en/app.json"],
|
||||
"pattern": "locales/*/*.json",
|
||||
"excludes": ["locales/en/*.json"],
|
||||
"lang": "${autodetectLang}"
|
||||
}
|
||||
]
|
||||
@@ -22,7 +22,6 @@
|
||||
"download": {
|
||||
"files": [
|
||||
{
|
||||
"conditions": ["equals: ${file}, app.json"],
|
||||
"output": "locales/${langLsrDash}/${file}"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -71,6 +71,9 @@
|
||||
"device_id": "Device ID: {{id}}",
|
||||
"duplicate_tiles_label": "Number of additional tile copies per participant",
|
||||
"environment_variables": "Environment variables",
|
||||
"full_mesh_peer_connections": "Full mesh peer connections",
|
||||
"full_mesh_publishing": "Publish on full mesh (MSC4575) instead of LiveKit",
|
||||
"full_mesh_transport": "Full mesh: {{device}}",
|
||||
"hostname": "Hostname: {{hostname}}",
|
||||
"livekit_server_info": "LiveKit Server Info",
|
||||
"livekit_sfu": "LiveKit SFU: {{url}}",
|
||||
|
||||
+2
-2
@@ -88,7 +88,7 @@
|
||||
"@typescript-eslint/utils": "^8.61.0",
|
||||
"@use-gesture/react": "^10.2.11",
|
||||
"@vector-im/compound-design-tokens": "^11.0.0",
|
||||
"@vector-im/compound-web": "^10.5.0",
|
||||
"@vector-im/compound-web": "^10.4.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/browser-playwright": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
@@ -107,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#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01",
|
||||
"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",
|
||||
|
||||
@@ -125,24 +125,25 @@ test("leaves the host's own page unstyled", async ({ page }) => {
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
// Element Call's page-wide CSS (normalize.css and the tokens in base.css)
|
||||
// should not reach the host's page. Compound CSS that gets loaded to EW (host)
|
||||
// on the other hand should impact the host. So probe with styles only Element Call sets:
|
||||
// - A fresh <sub> keeps the browser's `vertical-align: sub` unless
|
||||
// normalize.css leaked; it turns that into `baseline`.
|
||||
// - `--font-scale` is Element Call's own token, so `:root` must not have it.
|
||||
// Element Call's stylesheet is written for a page of its own: normalize.css
|
||||
// gives `html` a line height, Compound gives `body` its font and feature
|
||||
// settings, and the design tokens live on `:root`. None of that may reach the
|
||||
// host's document — the harness sets none of these itself, so anything other
|
||||
// than the browser's defaults here came from us.
|
||||
const host = await page.evaluate(() => {
|
||||
const sub = document.body.appendChild(document.createElement("sub"));
|
||||
const verticalAlign = getComputedStyle(sub).verticalAlign;
|
||||
sub.remove();
|
||||
const html = getComputedStyle(document.documentElement);
|
||||
const body = getComputedStyle(document.body);
|
||||
return {
|
||||
verticalAlign,
|
||||
token: getComputedStyle(document.documentElement).getPropertyValue(
|
||||
"--font-scale",
|
||||
),
|
||||
lineHeight: html.lineHeight,
|
||||
fontFeatureSettings: body.fontFeatureSettings,
|
||||
token: html.getPropertyValue("--cpd-color-text-primary"),
|
||||
};
|
||||
});
|
||||
expect(host).toEqual({ verticalAlign: "sub", token: "" });
|
||||
expect(host).toEqual({
|
||||
lineHeight: "normal",
|
||||
fontFeatureSettings: "normal",
|
||||
token: "",
|
||||
});
|
||||
|
||||
// While inside the container, the same rules do apply
|
||||
const root = panes.first().locator("[data-element-call-root]");
|
||||
|
||||
@@ -133,7 +133,7 @@ async function disableLeaveDelegation(page: Page): Promise<void> {
|
||||
* Expect a certain number of video tiles to be present and visible.
|
||||
*/
|
||||
async function expectVideoTilesCount(page: Page, count: number): Promise<void> {
|
||||
await expect(page.getByTestId("videoTile")).toHaveCount(count);
|
||||
await expect(page.getByTestId("videoTile")).toHaveCount(2);
|
||||
|
||||
// No one should be waiting for media
|
||||
await expect(page.getByText("Waiting for media...")).not.toBeVisible({
|
||||
|
||||
@@ -31,11 +31,7 @@ interface Transport {
|
||||
*/
|
||||
const shapes = {
|
||||
url: { type: "livekit", url: sfuUrl },
|
||||
"url+legacy": {
|
||||
type: "livekit",
|
||||
url: sfuUrl,
|
||||
livekit_service_url: serviceUrl,
|
||||
},
|
||||
both: { type: "livekit", url: sfuUrl, livekit_service_url: serviceUrl },
|
||||
legacy: null,
|
||||
} satisfies Record<string, Transport | null>;
|
||||
type Shape = keyof typeof shapes;
|
||||
@@ -60,15 +56,15 @@ const cases: {
|
||||
{
|
||||
// Publishers keep the legacy participant identity that clients predating
|
||||
// MSC4195 expect, which only the JWT service assigns.
|
||||
hostShape: "url+legacy",
|
||||
guestShape: "url+legacy",
|
||||
hostShape: "both",
|
||||
guestShape: "both",
|
||||
hostFlows: ["service-legacy"],
|
||||
guestFlows: ["service-legacy"],
|
||||
},
|
||||
{
|
||||
// The MatrixRTC mode has no bearing on the choice.
|
||||
hostShape: "url+legacy",
|
||||
guestShape: "url+legacy",
|
||||
hostShape: "both",
|
||||
guestShape: "both",
|
||||
mode: "2_0",
|
||||
hostFlows: ["service-legacy"],
|
||||
guestFlows: ["service-legacy"],
|
||||
@@ -79,12 +75,6 @@ const cases: {
|
||||
hostFlows: ["cs-api", "service-default"],
|
||||
guestFlows: ["service-legacy", "cs-api"],
|
||||
},
|
||||
{
|
||||
hostShape: "url",
|
||||
guestShape: "url+legacy",
|
||||
hostFlows: ["cs-api", "service-default"],
|
||||
guestFlows: ["service-legacy", "cs-api"],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { hostShape, guestShape, mode, hostFlows, guestFlows } of cases) {
|
||||
@@ -136,6 +126,47 @@ for (const { hostShape, guestShape, mode, hostFlows, guestFlows } of cases) {
|
||||
});
|
||||
}
|
||||
|
||||
test("Subscriber falls back to the JWT service when the homeserver lacks MSC4195", async ({
|
||||
browser,
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
skipOnFirefox(browserName);
|
||||
|
||||
// The host starts the call with both transport shapes.
|
||||
await advertiseTransport(page, shapes.both);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Androl", "HelloCall", true);
|
||||
const inviteLink = await SpaHelpers.getCallInviteLink(page);
|
||||
|
||||
// The guest joins but their C-S requests fail.
|
||||
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const guestPage = await guestContext.newPage();
|
||||
await rejectCsApiTokens(guestPage);
|
||||
const guestRequests = recordAuthRequests(guestPage);
|
||||
await SpaHelpers.joinCallFromInviteLink(guestPage, inviteLink, "Pevara");
|
||||
|
||||
// Wait for the call to connect and render.
|
||||
await SpaHelpers.expectVideoTilesCount(page, 2);
|
||||
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
|
||||
|
||||
// The guest ran through all flows.
|
||||
expect(flowsOf(guestRequests)).toEqual(
|
||||
new Set<Flow>(["cs-api", "service-legacy", "service-default"]),
|
||||
);
|
||||
|
||||
// The C-S token requests failed.
|
||||
for (const request of guestRequests) {
|
||||
expect(request.status).toBe(request.flow === "cs-api" ? 404 : 200);
|
||||
}
|
||||
|
||||
// The C-S token requests preceeded the fallback request.
|
||||
const flows = guestRequests.map((r) => r.flow);
|
||||
expect(flows.indexOf("service-default")).toBeGreaterThan(
|
||||
flows.indexOf("cs-api"),
|
||||
);
|
||||
});
|
||||
|
||||
test("Publisher shows an error when the homeserver lacks MSC4195", async ({
|
||||
page,
|
||||
browserName,
|
||||
@@ -160,38 +191,6 @@ test("Publisher shows an error when the homeserver lacks MSC4195", async ({
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Group call with all possible transport shapes at once", async ({
|
||||
browser,
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
test.slow();
|
||||
skipOnFirefox(browserName);
|
||||
|
||||
// The host starts the call with a url transport.
|
||||
await advertiseTransport(page, shapes.url);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Alice", "HelloCall", true);
|
||||
const inviteLink = await SpaHelpers.getCallInviteLink(page);
|
||||
|
||||
// The guest with a legacy transport joins.
|
||||
const bobContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const bobPage = await bobContext.newPage();
|
||||
await advertiseTransport(bobPage, shapes.legacy);
|
||||
await SpaHelpers.joinCallFromInviteLink(bobPage, inviteLink, "Bob");
|
||||
|
||||
// The guest with a url+legacy transport joins.
|
||||
const carolContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const carolPage = await carolContext.newPage();
|
||||
await advertiseTransport(carolPage, shapes["url+legacy"]);
|
||||
await SpaHelpers.joinCallFromInviteLink(carolPage, inviteLink, "Carol");
|
||||
|
||||
// Wait for the call to connect and render.
|
||||
await SpaHelpers.expectVideoTilesCount(page, 3);
|
||||
await SpaHelpers.expectVideoTilesCount(bobPage, 3);
|
||||
await SpaHelpers.expectVideoTilesCount(carolPage, 3);
|
||||
});
|
||||
|
||||
/**
|
||||
* Delegation follows the flow that authenticated the publisher: the homeserver
|
||||
* for a `url`-only transport, the JWT service wherever there is one.
|
||||
@@ -199,7 +198,7 @@ test("Group call with all possible transport shapes at once", async ({
|
||||
const delegationCases: { shape: Shape; via: Delegation["via"] }[] = [
|
||||
{ shape: "url", via: "homeserver" },
|
||||
{ shape: "legacy", via: "service" },
|
||||
{ shape: "url+legacy", via: "service" },
|
||||
{ shape: "both", via: "service" },
|
||||
];
|
||||
|
||||
for (const { shape, via } of delegationCases) {
|
||||
|
||||
@@ -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 { expect, type Page, test } from "@playwright/test";
|
||||
|
||||
import { widgetTest } from "../fixtures/widget-user";
|
||||
import { HOST1, TestHelpers } from "./test-helpers";
|
||||
|
||||
/**
|
||||
* Records the LiveKit signalling websockets a page opens: the SFU is reached
|
||||
* with an access token in the url, which full mesh never needs.
|
||||
*/
|
||||
function recordSfuSockets(page: Page): string[] {
|
||||
const urls: string[] = [];
|
||||
page.on("websocket", (ws) => {
|
||||
if (ws.url().includes("access_token")) urls.push(ws.url());
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts recording the types of the to-device messages the widget on a page
|
||||
* asks its host to send, and returns a function reading them. On the wire
|
||||
* they are encrypted, so the type is only visible in the widget API.
|
||||
*/
|
||||
async function recordWidgetToDevice(
|
||||
page: Page,
|
||||
): Promise<() => Promise<string[]>> {
|
||||
await page.evaluate(() => {
|
||||
const types: string[] = [];
|
||||
Object.assign(window, { widgetToDeviceTypes: types });
|
||||
window.addEventListener("message", ({ data }) => {
|
||||
const {
|
||||
action,
|
||||
response,
|
||||
data: request,
|
||||
} = (data ?? {}) as {
|
||||
action?: string;
|
||||
response?: unknown;
|
||||
data?: { type?: string };
|
||||
};
|
||||
if (action === "send_to_device" && response === undefined)
|
||||
types.push(request?.type ?? "");
|
||||
});
|
||||
});
|
||||
return async () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window as unknown as { widgetToDeviceTypes: string[] })
|
||||
.widgetToDeviceTypes,
|
||||
);
|
||||
}
|
||||
|
||||
const cases: { name: string; fullMesh: [boolean, boolean] }[] = [
|
||||
// Both publish on full mesh: the media flows without any SFU
|
||||
{ name: "both on full mesh", fullMesh: [true, true] },
|
||||
// One on each: Florian receives Valere over LiveKit without publishing there
|
||||
{ name: "full mesh and LiveKit mixed", fullMesh: [true, false] },
|
||||
];
|
||||
|
||||
cases.forEach(({ name, fullMesh: [florianMesh, valereMesh] }) => {
|
||||
widgetTest(`Full mesh call: ${name}`, async ({ addUser, browserName }) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
test.slow();
|
||||
|
||||
// Distinct per case: the cases run in parallel and register at once
|
||||
const suffix = `${Number(florianMesh)}${Number(valereMesh)}`;
|
||||
const [florian, valere] = await Promise.all([
|
||||
addUser(`florian${suffix}`, HOST1),
|
||||
addUser(`valere${suffix}`, HOST1),
|
||||
]);
|
||||
// Element Web doesn't auto-approve the full mesh signalling for Element
|
||||
// Call yet, so it asks for it whenever a widget with full mesh opens
|
||||
for (const { page } of [florian, valere])
|
||||
await page.addLocatorHandler(
|
||||
page.getByRole("heading", { name: "Approve widget permissions" }),
|
||||
async () => page.getByRole("button", { name: "Approve" }).click(),
|
||||
);
|
||||
const sockets = {
|
||||
florian: recordSfuSockets(florian.page),
|
||||
valere: recordSfuSockets(valere.page),
|
||||
};
|
||||
|
||||
const roomName = "Full Mesh Room";
|
||||
await TestHelpers.createRoom(roomName, florian.page, [valere.mxId]);
|
||||
await TestHelpers.acceptRoomInvite(roomName, valere.page);
|
||||
|
||||
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
florian.page,
|
||||
"2_0",
|
||||
{ fullMesh: florianMesh },
|
||||
);
|
||||
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
valere.page,
|
||||
"2_0",
|
||||
{ fullMesh: valereMesh },
|
||||
);
|
||||
|
||||
await TestHelpers.startCallInCurrentRoom(florian.page, false);
|
||||
await TestHelpers.joinCallFromLobby(florian.page);
|
||||
await TestHelpers.joinCallInCurrentRoom(valere.page);
|
||||
|
||||
// A widget only asks for the full mesh signalling with full mesh turned
|
||||
// on, so a LiveKit publisher in Element Web can't receive full mesh yet:
|
||||
// the other's media arrives if it is on LiveKit or we have full mesh
|
||||
const receives = {
|
||||
florian: !valereMesh || florianMesh,
|
||||
valere: !florianMesh || valereMesh,
|
||||
};
|
||||
// In parallel: assertions on a page also run its permission handler
|
||||
await Promise.all(
|
||||
(["florian", "valere"] as const).map(async (name) => {
|
||||
const frame = { florian, valere }[name].page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await expect(frame.getByTestId("videoTile")).toHaveCount(2, {
|
||||
timeout: 15000,
|
||||
});
|
||||
const waiting = frame.getByText("Waiting for media...");
|
||||
if (receives[name]) {
|
||||
// Over whichever transport carries it
|
||||
await expect(waiting).not.toBeVisible({ timeout: 20000 });
|
||||
await TestHelpers.expectVisibleVideoCount(frame, 2);
|
||||
} else {
|
||||
await expect(waiting).toBeVisible({ timeout: 20000 });
|
||||
await TestHelpers.expectVisibleVideoCount(frame, 1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (florianMesh && valereMesh) {
|
||||
// Nobody published on LiveKit, so nobody connected to an SFU
|
||||
expect(sockets.florian).toEqual([]);
|
||||
expect(sockets.valere).toEqual([]);
|
||||
} else {
|
||||
// Florian receives Valere's media from the SFU without publishing there
|
||||
expect(sockets.florian.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Florian sees Valere's camera go off: over the track metadata for full
|
||||
// mesh, without renegotiating
|
||||
const toDevice = await recordWidgetToDevice(valere.page);
|
||||
const valereFrame = valere.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await valereFrame.getByRole("switch", { name: "Stop video" }).click();
|
||||
const florianFrame = florian.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await TestHelpers.expectVisibleVideoCount(florianFrame, 1);
|
||||
// On the connection's control channel, not through the homeserver.
|
||||
// Candidates may still trickle there until the channel is up
|
||||
if (florianMesh && valereMesh)
|
||||
expect(
|
||||
(await toDevice()).filter(
|
||||
(type) => type.includes("full_mesh") && !type.endsWith(".candidates"),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -292,6 +292,7 @@ export class TestHelpers {
|
||||
public static async openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
page: Page,
|
||||
mode: RtcMode,
|
||||
{ fullMesh = false }: { fullMesh?: boolean } = {},
|
||||
): Promise<void> {
|
||||
await page.getByRole("button", { name: "Video call" }).click({
|
||||
timeout: 5000,
|
||||
@@ -300,7 +301,7 @@ export class TestHelpers {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await TestHelpers.setEmbeddedElementCallRtcMode(page, mode);
|
||||
await TestHelpers.setEmbeddedElementCallRtcMode(page, mode, { fullMesh });
|
||||
await page.getByRole("button", { name: "Close lobby" }).click();
|
||||
}
|
||||
|
||||
@@ -313,6 +314,7 @@ export class TestHelpers {
|
||||
public static async setEmbeddedElementCallRtcMode(
|
||||
page: Page,
|
||||
mode: RtcMode,
|
||||
{ fullMesh = false }: { fullMesh?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const iframe = page.locator('iframe[title="Element Call"]').contentFrame();
|
||||
|
||||
@@ -330,6 +332,10 @@ export class TestHelpers {
|
||||
// compat
|
||||
await iframe.getByText("Compatibility: state events").click();
|
||||
}
|
||||
// Publish on the full mesh transport (MSC4575) instead of LiveKit
|
||||
await iframe
|
||||
.getByText("Publish on full mesh", { exact: false })
|
||||
.setChecked(fullMesh);
|
||||
await iframe.getByTestId("modal_close").click();
|
||||
}
|
||||
|
||||
|
||||
@@ -11,12 +11,13 @@ import { widgetTest } from "../fixtures/widget-user.ts";
|
||||
import { HOST1, TestHelpers } from "./test-helpers.ts";
|
||||
|
||||
const sfuUrl = "wss://matrix-rtc.m.localhost/livekit/sfu";
|
||||
const serviceUrl = "https://matrix-rtc.m.localhost/livekit/jwt";
|
||||
|
||||
/** Which endpoint a page asks for its SFU tokens. */
|
||||
type Flow = "cs-api" | "service-legacy" | "service-default";
|
||||
|
||||
widgetTest(
|
||||
"Call between a url transport and a legacy one",
|
||||
"Call between a url + livekit_service_url transport and a legacy one",
|
||||
async ({ addUser, browserName }) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
@@ -29,10 +30,12 @@ widgetTest(
|
||||
addUser("Pevara", HOST1),
|
||||
]);
|
||||
|
||||
// Host advertises a url transport, guest still advertises a legacy one
|
||||
// The host's homeserver has started advertising a `url` next to its JWT
|
||||
// service; the guest's still advertises the JWT service alone.
|
||||
await advertiseTransport(host.page, {
|
||||
type: "livekit",
|
||||
url: sfuUrl,
|
||||
livekit_service_url: serviceUrl,
|
||||
});
|
||||
// Element Web answers the widget from a cache it filled at login, so make
|
||||
// it fetch the transports again now that they are rerouted
|
||||
@@ -64,7 +67,7 @@ widgetTest(
|
||||
}
|
||||
|
||||
// Element Web used for Playwright does support MSC4195 for widgets.
|
||||
expect(hostFlows).toEqual(new Set(["cs-api", "service-default"]));
|
||||
expect(hostFlows).toEqual(new Set(["service-legacy", "service-default"]));
|
||||
expect(guestFlows).toEqual(new Set(["service-legacy", "cs-api"]));
|
||||
},
|
||||
);
|
||||
|
||||
Generated
+61
-38
@@ -66,7 +66,7 @@ importers:
|
||||
version: 10.1.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@rolldown/plugin-babel':
|
||||
specifier: ^0.2.3
|
||||
version: 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.8)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
|
||||
version: 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
|
||||
'@sentry/react':
|
||||
specifier: ^8.0.0
|
||||
version: 8.55.2(react@19.2.8)
|
||||
@@ -134,11 +134,11 @@ importers:
|
||||
specifier: ^11.0.0
|
||||
version: 11.3.1(@types/react@19.2.18)(react@19.2.8)
|
||||
'@vector-im/compound-web':
|
||||
specifier: ^10.5.0
|
||||
version: 10.5.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.3.1(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
specifier: ^10.4.0
|
||||
version: 10.4.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.3.1(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.0.2
|
||||
version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.8)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
|
||||
version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
|
||||
'@vitest/browser-playwright':
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.11(playwright@1.62.1)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11)
|
||||
@@ -191,8 +191,8 @@ importers:
|
||||
specifier: ^1.9.1
|
||||
version: 1.9.2
|
||||
matrix-js-sdk:
|
||||
specifier: github:matrix-org/matrix-js-sdk#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01
|
||||
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
|
||||
@@ -397,8 +397,8 @@ packages:
|
||||
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/runtime@8.0.8':
|
||||
resolution: {integrity: sha512-dEu8nGlRqjsJsvviUUoSMfcRHpwomCiRlXsc2mPfJ79+ffTpDm+JtfUtTMzUrqCApHQnCsVPD3gT1ZT8RgBRCQ==}
|
||||
'@babel/runtime@8.0.0':
|
||||
resolution: {integrity: sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==}
|
||||
|
||||
'@babel/template@7.29.7':
|
||||
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
|
||||
@@ -408,6 +408,10 @@ packages:
|
||||
resolution: {integrity: sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/traverse@7.29.8':
|
||||
resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@7.29.11':
|
||||
resolution: {integrity: sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -3310,8 +3314,8 @@ packages:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
'@vector-im/compound-web@10.5.0':
|
||||
resolution: {integrity: sha512-dcxIAKtTKBLKz2Y83mNkXyYx3shtSs80KvdGKN5ryFvo6OnVOhHPEHiQNbaPQ0EDjc6xStjY9+oz2Fh4c+kniQ==}
|
||||
'@vector-im/compound-web@10.4.0':
|
||||
resolution: {integrity: sha512-Tt5hsNK7ulhGdRuy9lzSm5C+T8M/4O4kLDFLkaU+Hi1kVWj6tj/5awW+U+GvtpWRh5llbHN2HRCh79+gQhek+A==}
|
||||
peerDependencies:
|
||||
'@fontsource/inconsolata': ^5
|
||||
'@fontsource/inter': ^5
|
||||
@@ -3417,6 +3421,11 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
acorn@8.18.0:
|
||||
resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
acorn@8.19.0:
|
||||
resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -4750,9 +4759,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/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01:
|
||||
resolution: {gitHosted: true, integrity: sha512-ZAhn/zB44MgrXqUzi9ffSFcmZY3+2nx7bEgfEJR7chYDFyT/XmlmfCJx7Ar4rOTAYZJRVnMxrEwiEHGDapUi0Q==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01}
|
||||
version: 45.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:
|
||||
@@ -6348,10 +6357,10 @@ snapshots:
|
||||
'@babel/helper-compilation-targets': 7.29.7
|
||||
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)
|
||||
'@babel/helpers': 7.29.7
|
||||
'@babel/parser': 7.29.11
|
||||
'@babel/parser': 7.29.8
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/traverse': 7.29.10(supports-color@7.2.0)
|
||||
'@babel/types': 7.29.11
|
||||
'@babel/traverse': 7.29.8(supports-color@7.2.0)
|
||||
'@babel/types': 7.29.8
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
@@ -6363,8 +6372,8 @@ snapshots:
|
||||
|
||||
'@babel/generator@7.29.8':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.11
|
||||
'@babel/types': 7.29.11
|
||||
'@babel/parser': 7.29.8
|
||||
'@babel/types': 7.29.8
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
jsesc: 3.1.0
|
||||
@@ -6381,8 +6390,8 @@ snapshots:
|
||||
|
||||
'@babel/helper-module-imports@7.29.7(supports-color@7.2.0)':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.10(supports-color@7.2.0)
|
||||
'@babel/types': 7.29.11
|
||||
'@babel/traverse': 7.29.8(supports-color@7.2.0)
|
||||
'@babel/types': 7.29.8
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -6391,7 +6400,7 @@ snapshots:
|
||||
'@babel/core': 7.29.7(supports-color@7.2.0)
|
||||
'@babel/helper-module-imports': 7.29.7(supports-color@7.2.0)
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
'@babel/traverse': 7.29.10(supports-color@7.2.0)
|
||||
'@babel/traverse': 7.29.8(supports-color@7.2.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -6408,7 +6417,7 @@ snapshots:
|
||||
'@babel/helpers@7.29.7':
|
||||
dependencies:
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/types': 7.29.11
|
||||
'@babel/types': 7.29.8
|
||||
|
||||
'@babel/parser@7.29.11':
|
||||
dependencies:
|
||||
@@ -6424,13 +6433,13 @@ snapshots:
|
||||
|
||||
'@babel/runtime@7.29.7': {}
|
||||
|
||||
'@babel/runtime@8.0.8': {}
|
||||
'@babel/runtime@8.0.0': {}
|
||||
|
||||
'@babel/template@7.29.7':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
'@babel/parser': 7.29.11
|
||||
'@babel/types': 7.29.11
|
||||
'@babel/parser': 7.29.8
|
||||
'@babel/types': 7.29.8
|
||||
|
||||
'@babel/traverse@7.29.10(supports-color@7.2.0)':
|
||||
dependencies:
|
||||
@@ -6439,7 +6448,19 @@ snapshots:
|
||||
'@babel/helper-globals': 7.29.7
|
||||
'@babel/parser': 7.29.11
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/types': 7.29.11
|
||||
'@babel/types': 7.29.8
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/traverse@7.29.8(supports-color@7.2.0)':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
'@babel/generator': 7.29.8
|
||||
'@babel/helper-globals': 7.29.7
|
||||
'@babel/parser': 7.29.8
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/types': 7.29.8
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -8146,13 +8167,13 @@ snapshots:
|
||||
'@rolldown/binding-win32-x64-msvc@1.2.15':
|
||||
optional: true
|
||||
|
||||
'@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.8)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))':
|
||||
'@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7(supports-color@7.2.0)
|
||||
picomatch: 4.0.4
|
||||
rolldown: 1.2.15
|
||||
optionalDependencies:
|
||||
'@babel/runtime': 8.0.8
|
||||
'@babel/runtime': 8.0.0
|
||||
vite: 8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)
|
||||
|
||||
'@rolldown/pluginutils@1.0.1': {}
|
||||
@@ -8882,7 +8903,7 @@ snapshots:
|
||||
'@types/react': 19.2.18
|
||||
react: 19.2.8
|
||||
|
||||
'@vector-im/compound-web@10.5.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.3.1(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
'@vector-im/compound-web@10.4.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.3.1(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
@@ -8904,12 +8925,12 @@ snapshots:
|
||||
- '@types/react-dom'
|
||||
- react-dom
|
||||
|
||||
'@vitejs/plugin-react@6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.8)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))':
|
||||
'@vitejs/plugin-react@6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)
|
||||
optionalDependencies:
|
||||
'@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.8)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
|
||||
'@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
|
||||
babel-plugin-react-compiler: 1.0.0
|
||||
|
||||
'@vitest/browser-playwright@4.1.11(playwright@1.62.1)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11)':
|
||||
@@ -9034,12 +9055,14 @@ snapshots:
|
||||
|
||||
'@webcontainer/env@1.1.1': {}
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.19.0):
|
||||
acorn-jsx@5.3.2(acorn@8.18.0):
|
||||
dependencies:
|
||||
acorn: 8.19.0
|
||||
acorn: 8.18.0
|
||||
|
||||
acorn@8.16.0: {}
|
||||
|
||||
acorn@8.18.0: {}
|
||||
|
||||
acorn@8.19.0: {}
|
||||
|
||||
agent-base@6.0.2(supports-color@7.2.0):
|
||||
@@ -9710,8 +9733,8 @@ snapshots:
|
||||
|
||||
espree@9.6.1:
|
||||
dependencies:
|
||||
acorn: 8.19.0
|
||||
acorn-jsx: 5.3.2(acorn@8.19.0)
|
||||
acorn: 8.18.0
|
||||
acorn-jsx: 5.3.2(acorn@8.18.0)
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
esprima@4.0.1: {}
|
||||
@@ -10445,9 +10468,9 @@ snapshots:
|
||||
|
||||
matrix-events-sdk@0.0.1: {}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01:
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3f05dc4d104a3dfc753c5e3549a18c74fab4b076:
|
||||
dependencies:
|
||||
'@babel/runtime': 8.0.8
|
||||
'@babel/runtime': 8.0.0
|
||||
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
|
||||
another-json: 0.2.0
|
||||
bs58: 6.0.0
|
||||
@@ -11678,7 +11701,7 @@ snapshots:
|
||||
terser@5.46.1:
|
||||
dependencies:
|
||||
'@jridgewell/source-map': 0.3.11
|
||||
acorn: 8.19.0
|
||||
acorn: 8.18.0
|
||||
commander: 2.20.3
|
||||
source-map-support: 0.5.21
|
||||
|
||||
|
||||
@@ -44,9 +44,8 @@ const TARGETS = [
|
||||
{
|
||||
config: "vite-component.config.ts",
|
||||
what: "the component build",
|
||||
// `sdk` too: the build resolves `@element-hq/matrixrtc-sdk` to its source
|
||||
sources: ["src", "component", "sdk"],
|
||||
excluded: ["component/dev", "sdk/dev"],
|
||||
sources: ["src", "component"],
|
||||
excluded: ["component/dev"],
|
||||
mustBeExternal: [
|
||||
"react",
|
||||
"react-dom",
|
||||
|
||||
+10
-5
@@ -66,9 +66,14 @@ media backend: whatever carries the media, behind the `MediaBackend` interface i
|
||||
`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.
|
||||
with the local media connection. Two backends exist, LiveKit (`livekit/`) and
|
||||
full mesh (`full-mesh/`, MSC4575), and the client always runs both, combined
|
||||
(`combineBackends.ts`). It publishes on the backend of the type of
|
||||
`options.transport`, LiveKit without one, and hands every remote member to the
|
||||
backend of that member's transport, so members on either are received. A
|
||||
backend nobody is on connects nowhere. Another transport is another folder
|
||||
under `sdk/src/media-backend/`, added to the combination, and nothing in the
|
||||
left box changes; `options.backend` replaces the combination altogether.
|
||||
|
||||
### How Element Call uses it
|
||||
|
||||
@@ -149,7 +154,7 @@ the sections below still describe `MatrixRTCClient` until that lands.
|
||||
| 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 |
|
||||
| media backend | a `MediaBackend`: what carries the media, behind `sdk/src/media-backend/api.ts`. One per participation: LiveKit and full mesh combined, publishing on the type of `transport` in the join options, unless `backend` replaces them | "SFU", a LiveKit room, a peer connection |
|
||||
|
||||
Names use `RTC` in capitals, as the js-sdk does: `RTCSlot`, `RTCParticipation`,
|
||||
`RTCMember`, `matrixRTCMode`.
|
||||
@@ -538,7 +543,7 @@ backend is handed the whole session's view and gives back media:
|
||||
type MemberMediaFields = Pick<RTCMember, "tracks$" | "encryptionError$">;
|
||||
|
||||
export interface MediaBackend {
|
||||
readonly transportTypes: readonly string[]; // ["m.livekit", "livekit"]
|
||||
readonly transportType: string; // "livekit"
|
||||
/** Authenticate with the transport the client found; the probe for delegation runs here. */
|
||||
prepareLocalTransport(
|
||||
transport: Transport,
|
||||
|
||||
@@ -61,6 +61,14 @@ export {
|
||||
createLivekitBackend,
|
||||
type LivekitBackendOptions,
|
||||
} from "./src/media-backend/livekit/LivekitBackend";
|
||||
// Join with `transport: { type: FULL_MESH_TRANSPORT_TYPE }` to publish on full mesh
|
||||
export {
|
||||
createFullMeshBackend,
|
||||
FULL_MESH_TRANSPORT_TYPE,
|
||||
type FullMeshBackendOptions,
|
||||
} from "./src/media-backend/full-mesh/FullMeshBackend";
|
||||
// For a widget host to allow the full mesh signalling
|
||||
export { signallingEventTypes as fullMeshSignallingEventTypes } from "./src/media-backend/full-mesh/signalling";
|
||||
// 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
|
||||
|
||||
@@ -164,7 +164,7 @@ function setup(
|
||||
context: MediaBackendContext,
|
||||
): MediaBackend => {
|
||||
const backend: MediaBackend = {
|
||||
transportTypes: ["m.livekit", "livekit"],
|
||||
transportType: "livekit",
|
||||
prepareLocalTransport: async () => fake.prepared.promise,
|
||||
delegateDelayedLeave: fake.delegateDelayedLeave,
|
||||
local: {
|
||||
|
||||
@@ -50,6 +50,11 @@ import {
|
||||
type MediaBackendContext,
|
||||
type MediaKey,
|
||||
} from "./media-backend/api";
|
||||
import { combineBackends } from "./media-backend/combineBackends";
|
||||
import {
|
||||
completeFullMeshTransport,
|
||||
createFullMeshBackend,
|
||||
} from "./media-backend/full-mesh/FullMeshBackend";
|
||||
import { createLivekitBackend } from "./media-backend/livekit/LivekitBackend";
|
||||
import { findRemotePublishLivekitTransport } from "./media-backend/livekit/RemotePublishTransport";
|
||||
import { createHomeserverConnected$ } from "./matrixrtc/HomeserverConnected";
|
||||
@@ -121,6 +126,13 @@ export function createRTCParticipation(
|
||||
: `${userId}:${deviceId}`,
|
||||
};
|
||||
|
||||
// The host names full mesh by its type alone; the device is ours
|
||||
if (options.transport)
|
||||
options = {
|
||||
...options,
|
||||
transport: completeFullMeshTransport(options.transport, deviceId),
|
||||
};
|
||||
|
||||
const backend = createBackend(scope, client, options, {
|
||||
roomId: room.roomId,
|
||||
slotId: jsSdkSession.slotId,
|
||||
@@ -136,8 +148,7 @@ export function createRTCParticipation(
|
||||
// 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.transportTypes.includes("m.livekit") ||
|
||||
backend.transportTypes.includes("livekit")
|
||||
backend.transportType === "livekit"
|
||||
? findRemotePublishLivekitTransport({
|
||||
client,
|
||||
// A snapshot: if nobody with a usable SFU is here yet, the user has
|
||||
@@ -153,7 +164,7 @@ export function createRTCParticipation(
|
||||
const preparedTransport$ = from(
|
||||
discoverLocalTransport(
|
||||
client,
|
||||
backend.transportTypes,
|
||||
backend.transportType,
|
||||
options,
|
||||
transportFromBackends,
|
||||
logger,
|
||||
@@ -311,15 +322,26 @@ export function createRTCParticipation(
|
||||
};
|
||||
}
|
||||
|
||||
/** The host's backend, or LiveKit configured from the participation options. */
|
||||
/**
|
||||
* The host's backend, or every backend the SDK has, combined: we publish on
|
||||
* the type of the transport the host named (LiveKit without one) and receive
|
||||
* from members on any of them.
|
||||
*/
|
||||
function createBackend(
|
||||
scope: ObservableScope,
|
||||
client: MatrixClient,
|
||||
{ backend, mediaQuality }: RTCParticipationOptions,
|
||||
{ backend, mediaQuality, transport }: RTCParticipationOptions,
|
||||
context: MediaBackendContext,
|
||||
): MediaBackend {
|
||||
if (backend) return backend(scope, context);
|
||||
return createLivekitBackend(scope, context, { client, mediaQuality });
|
||||
return combineBackends(
|
||||
scope,
|
||||
[
|
||||
createLivekitBackend(scope, context, { client, mediaQuality }),
|
||||
createFullMeshBackend(scope, context, { client }),
|
||||
],
|
||||
transport?.type ?? "livekit",
|
||||
);
|
||||
}
|
||||
|
||||
/** The slot's per-participant keys: the ones it already holds on subscribe, then each new one. */
|
||||
|
||||
+1
-1
@@ -230,7 +230,7 @@ export interface DataMessage {
|
||||
* backend-specific developer panel and for connection diagnostics.
|
||||
*/
|
||||
export interface TransportMetadata {
|
||||
/** `"m.livekit"` or `"livekit"` today. */
|
||||
/** `"livekit"` today. */
|
||||
type: string;
|
||||
/** Stable key, unique per transport in the slot: the raw transport, serialised with sorted keys. */
|
||||
id: string;
|
||||
|
||||
@@ -48,7 +48,7 @@ describe("discoverLocalTransport", () => {
|
||||
await expect(
|
||||
discoverLocalTransport(
|
||||
c,
|
||||
["m.livekit", "livekit"],
|
||||
"livekit",
|
||||
{ transport: hostTransport },
|
||||
fromBackends(),
|
||||
logger,
|
||||
@@ -61,7 +61,7 @@ describe("discoverLocalTransport", () => {
|
||||
await expect(
|
||||
discoverLocalTransport(
|
||||
client([homeserverTransport]),
|
||||
["m.livekit", "livekit"],
|
||||
"livekit",
|
||||
{ fallbackTransport: hostTransport },
|
||||
fromBackends(),
|
||||
logger,
|
||||
@@ -73,7 +73,7 @@ describe("discoverLocalTransport", () => {
|
||||
await expect(
|
||||
discoverLocalTransport(
|
||||
client([{ type: "not_livekit" }]),
|
||||
["m.livekit", "livekit"],
|
||||
"livekit",
|
||||
{ fallbackTransport: hostTransport },
|
||||
fromBackends(),
|
||||
logger,
|
||||
@@ -87,7 +87,7 @@ describe("discoverLocalTransport", () => {
|
||||
await expect(
|
||||
discoverLocalTransport(
|
||||
client([homeserverTransport]),
|
||||
["m.livekit", "livekit"],
|
||||
"livekit",
|
||||
{},
|
||||
backends,
|
||||
logger,
|
||||
@@ -99,7 +99,7 @@ describe("discoverLocalTransport", () => {
|
||||
it("fails naming the homeserver where there is no transport at all", async () => {
|
||||
const promise = discoverLocalTransport(
|
||||
client([]),
|
||||
["m.livekit", "livekit"],
|
||||
"livekit",
|
||||
{},
|
||||
fromBackends(),
|
||||
logger,
|
||||
|
||||
@@ -15,15 +15,14 @@ import { TransportAutoDiscovery } from "./TransportAutoDiscovery";
|
||||
|
||||
/**
|
||||
* The transport the local member advertises: the one the host names, else the
|
||||
* homeserver's first of the most preferred type the backend serves, else the
|
||||
* host's fallback, else the first the backends can provide, which are asked
|
||||
* only then.
|
||||
* 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">,
|
||||
transportTypes: readonly string[],
|
||||
transportType: string,
|
||||
{
|
||||
transport: override,
|
||||
fallbackTransport,
|
||||
@@ -35,7 +34,7 @@ export async function discoverLocalTransport(
|
||||
override ??
|
||||
(await new TransportAutoDiscovery({
|
||||
client,
|
||||
transportTypes,
|
||||
transportType,
|
||||
fallbackTransport,
|
||||
logger,
|
||||
}).discoverPreferredTransport()) ??
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
transportTypes: ["m.livekit", "livekit"],
|
||||
transportType: "livekit",
|
||||
fallbackTransport: configTransport,
|
||||
logger: rootLogger,
|
||||
});
|
||||
@@ -75,29 +75,6 @@ describe("TransportAutoDiscovery", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("prefers a transport of an earlier type over one listed before it", async () => {
|
||||
const mLivekit: Transport = {
|
||||
type: "m.livekit",
|
||||
url: "wss://sfu.example.org",
|
||||
};
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports.mockResolvedValue([
|
||||
backendTransport,
|
||||
mLivekit,
|
||||
]);
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
transportTypes: ["m.livekit", "livekit"],
|
||||
fallbackTransport: configTransport,
|
||||
logger: rootLogger,
|
||||
});
|
||||
|
||||
await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual(
|
||||
mLivekit,
|
||||
);
|
||||
});
|
||||
|
||||
it("Retries limit_exceeded backend transport", async () => {
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports
|
||||
@@ -115,7 +92,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
transportTypes: ["m.livekit", "livekit"],
|
||||
transportType: "livekit",
|
||||
fallbackTransport: configTransport,
|
||||
logger: rootLogger,
|
||||
});
|
||||
@@ -139,7 +116,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
transportTypes: ["m.livekit", "livekit"],
|
||||
transportType: "livekit",
|
||||
fallbackTransport: configTransport,
|
||||
logger: rootLogger,
|
||||
});
|
||||
@@ -156,7 +133,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
transportTypes: ["m.livekit", "livekit"],
|
||||
transportType: "livekit",
|
||||
fallbackTransport: undefined,
|
||||
logger: rootLogger,
|
||||
});
|
||||
|
||||
@@ -17,27 +17,27 @@ type TransportDiscoveryClient = Pick<
|
||||
|
||||
export interface TransportAutoDiscoveryProps {
|
||||
client: TransportDiscoveryClient;
|
||||
/** The transport types we want to discover. Those are the ones, the local media backend supports. most preferred first. */
|
||||
transportTypes: readonly string[];
|
||||
/** A transport to use when the homeserver advertises none of the types. */
|
||||
/** The transport type the media backend serves. */
|
||||
transportType: string;
|
||||
/** A transport to use when the homeserver advertises none of the type. */
|
||||
fallbackTransport?: Transport;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export class TransportAutoDiscovery {
|
||||
private readonly client: TransportDiscoveryClient;
|
||||
private readonly transportTypes: readonly string[];
|
||||
private readonly transportType: string;
|
||||
private readonly fallbackTransport: Transport | undefined;
|
||||
private readonly logger: Logger;
|
||||
|
||||
public constructor({
|
||||
client,
|
||||
transportTypes,
|
||||
transportType,
|
||||
fallbackTransport,
|
||||
logger,
|
||||
}: TransportAutoDiscoveryProps) {
|
||||
this.client = client;
|
||||
this.transportTypes = transportTypes;
|
||||
this.transportType = transportType;
|
||||
this.fallbackTransport = fallbackTransport;
|
||||
this.logger = logger.getChild("[TransportAutoDiscovery]");
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export class TransportAutoDiscovery {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the first transport of the backend's most preferred type from the homeserver.
|
||||
* 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
|
||||
*/
|
||||
@@ -76,16 +76,17 @@ export class TransportAutoDiscovery {
|
||||
const transportList = await doNetworkOperationWithRetry(async () =>
|
||||
client._unstable_getRTCTransports(),
|
||||
);
|
||||
for (const type of this.transportTypes) {
|
||||
const first = transportList.find(
|
||||
(transport) => transport.type === type,
|
||||
);
|
||||
if (first) return first;
|
||||
}
|
||||
this.logger.info(
|
||||
`No ${this.transportTypes.join(" or ")} transport found in getRTCTransports end point`,
|
||||
transportList,
|
||||
const first = transportList.find(
|
||||
(transport) => transport.type === this.transportType,
|
||||
);
|
||||
if (first) {
|
||||
return first;
|
||||
} else {
|
||||
this.logger.info(
|
||||
`No ${this.transportType} transport found in getRTCTransports end point`,
|
||||
transportList,
|
||||
);
|
||||
}
|
||||
} catch (ex) {
|
||||
this.logger.info(`Failed to use getRTCTransports end point: ${ex}`);
|
||||
}
|
||||
|
||||
@@ -113,11 +113,8 @@ export interface DataPacket {
|
||||
}
|
||||
|
||||
export interface MediaBackend {
|
||||
/**
|
||||
* The membership transport types this backend serves, ordered decreasingly
|
||||
* by preference.
|
||||
*/
|
||||
readonly transportTypes: readonly string[];
|
||||
/** The membership transport type this backend serves: "livekit". */
|
||||
readonly transportType: string;
|
||||
|
||||
/**
|
||||
* Makes `transport` usable as the one the local member publishes on. The
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
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 { BehaviorSubject, NEVER } from "rxjs";
|
||||
|
||||
import { type RTCMember, type TransportMetadata } from "../api";
|
||||
import { type MemberMedia } from "../media-api";
|
||||
import { constant } from "../reactive/Behavior";
|
||||
import { ObservableScope } from "../reactive/ObservableScope";
|
||||
import { type MediaBackend } from "./api";
|
||||
import { combineBackends } from "./combineBackends";
|
||||
|
||||
function fakeBackend(type: string): MediaBackend & { ended: string[] } {
|
||||
const ended: string[] = [];
|
||||
return {
|
||||
ended,
|
||||
transportType: type,
|
||||
prepareLocalTransport: vi.fn(async () =>
|
||||
Promise.resolve({ canDelegateDelayedLeave: false }),
|
||||
),
|
||||
delegateDelayedLeave: vi.fn(),
|
||||
local: {} as MediaBackend["local"],
|
||||
mediaFor: (scope, member): MemberMedia => {
|
||||
scope.onEnd(() => ended.push(member.memberId));
|
||||
return {
|
||||
tracks$: constant([{ id: `${type}:${member.memberId}` }] as never),
|
||||
encryptionError$: NEVER,
|
||||
};
|
||||
},
|
||||
connections$: constant([
|
||||
{ transport: { type }, state: "connected" as never, resolved: undefined },
|
||||
]),
|
||||
setAudioOutputDeviceId: vi.fn(),
|
||||
sendData: vi.fn(),
|
||||
data$: NEVER,
|
||||
};
|
||||
}
|
||||
|
||||
function transport(type: string): TransportMetadata {
|
||||
return { type, id: type, raw: { type }, resolved$: constant(undefined) };
|
||||
}
|
||||
|
||||
describe("combineBackends", () => {
|
||||
it("routes each member to the backend of its transport type", () => {
|
||||
const scope = new ObservableScope();
|
||||
const livekit = fakeBackend("livekit");
|
||||
const other = fakeBackend("other");
|
||||
const combined = combineBackends(scope, [livekit, other], "livekit");
|
||||
expect(combined.transportType).toBe("livekit");
|
||||
expect(combined.connections$.value).toHaveLength(2);
|
||||
|
||||
const transport$ = new BehaviorSubject<TransportMetadata | undefined>(
|
||||
transport("other"),
|
||||
);
|
||||
const memberScope = new ObservableScope();
|
||||
const media = combined.mediaFor(memberScope, {
|
||||
memberId: "m1",
|
||||
transport$,
|
||||
} as unknown as RTCMember);
|
||||
expect(media.tracks$.value).toEqual([{ id: "other:m1" }]);
|
||||
|
||||
transport$.next(transport("livekit"));
|
||||
expect(other.ended).toEqual(["m1"]);
|
||||
expect(media.tracks$.value).toEqual([{ id: "livekit:m1" }]);
|
||||
|
||||
transport$.next(transport("unknown"));
|
||||
expect(media.tracks$.value).toBeNull();
|
||||
// The publishing backend keeps every member for as long as it is one
|
||||
expect(livekit.ended).toEqual([]);
|
||||
memberScope.end();
|
||||
expect(livekit.ended).toEqual(["m1"]);
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("publishes on the backend of the publish type", () => {
|
||||
const scope = new ObservableScope();
|
||||
const livekit = fakeBackend("livekit");
|
||||
const other = fakeBackend("other");
|
||||
const combined = combineBackends(scope, [livekit, other], "other");
|
||||
expect(combined.transportType).toBe("other");
|
||||
expect(() => combineBackends(scope, [livekit, other], "unknown")).toThrow(
|
||||
"No media backend for transport unknown",
|
||||
);
|
||||
scope.end();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
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 { combineLatest, map, merge, NEVER, of, switchMap } from "rxjs";
|
||||
|
||||
import { type RTCMember } from "../api";
|
||||
import { MatrixRTCError } from "../errors";
|
||||
import { type MemberMedia } from "../media-api";
|
||||
import { type ObservableScope } from "../reactive/ObservableScope";
|
||||
import { mapScoped } from "../utils/mapScoped";
|
||||
import { type MediaBackend } from "./api";
|
||||
|
||||
/**
|
||||
* Several backends as one: we publish on the backend of `publishType`, and
|
||||
* receive from every member on a transport any of the backends serves. A
|
||||
* member is handed to the backend of its current transport type. A backend
|
||||
* nobody is on costs nothing: it connects only for the members handed to it.
|
||||
*/
|
||||
export function combineBackends(
|
||||
scope: ObservableScope,
|
||||
all: MediaBackend[],
|
||||
publishType: string,
|
||||
): MediaBackend {
|
||||
const byType = new Map(all.map((b) => [b.transportType, b]));
|
||||
const publish = byType.get(publishType);
|
||||
if (publish === undefined)
|
||||
throw new MatrixRTCError(`No media backend for transport ${publishType}`);
|
||||
|
||||
const mediaFor = (
|
||||
memberScope: ObservableScope,
|
||||
member: RTCMember,
|
||||
): MemberMedia => {
|
||||
// The publishing backend sees every member, whatever its transport, so
|
||||
// that it can tell members from strangers when a backend is asked for our
|
||||
// media by members on other transports. It connects nowhere for them
|
||||
const publishMedia = publish.mediaFor(memberScope, member);
|
||||
const type$ = memberScope.behavior(
|
||||
member.transport$.pipe(map((transport) => transport?.type)),
|
||||
);
|
||||
const media$ = mapScoped(memberScope, type$, (s, type) =>
|
||||
type === publish.transportType
|
||||
? publishMedia
|
||||
: byType.get(type)?.mediaFor(s, member),
|
||||
);
|
||||
return {
|
||||
tracks$: memberScope.behavior(
|
||||
media$.pipe(switchMap((media) => media?.tracks$ ?? of(null))),
|
||||
),
|
||||
encryptionError$: media$.pipe(
|
||||
switchMap((media) => media?.encryptionError$ ?? NEVER),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
transportType: publish.transportType,
|
||||
prepareLocalTransport: async (transport) =>
|
||||
publish.prepareLocalTransport(transport),
|
||||
delegateDelayedLeave: async (delayId) =>
|
||||
publish.delegateDelayedLeave(delayId),
|
||||
local: publish.local,
|
||||
mediaFor,
|
||||
connections$: scope.behavior(
|
||||
combineLatest(all.map((b) => b.connections$)).pipe(
|
||||
map((lists) => lists.flat()),
|
||||
),
|
||||
),
|
||||
setAudioOutputDeviceId: async (deviceId) => {
|
||||
await Promise.all(
|
||||
all.map(async (b) => b.setAudioOutputDeviceId(deviceId)),
|
||||
);
|
||||
},
|
||||
sendData: async (topic, text) => publish.sendData(topic, text),
|
||||
data$: merge(...all.map((b) => b.data$)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
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 control channel of a full mesh connection (MSC4575): a negotiated data
|
||||
* channel both sides create, which carries every message of the connection
|
||||
* once it is open, apart from `close`.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ConsumptionMetadata,
|
||||
parseConsumptionMetadata,
|
||||
parseTrackMetadata,
|
||||
type TrackMetadata,
|
||||
} from "./metadata";
|
||||
|
||||
export const CONTROL_CHANNEL_LABEL = "org.matrix.msc4575.rtc.full_mesh.control";
|
||||
const CONTROL_CHANNEL_ID = 0;
|
||||
|
||||
export type ControlMessage =
|
||||
| {
|
||||
type: "description";
|
||||
description: { type: "offer" | "answer"; sdp: string };
|
||||
track_metadata?: TrackMetadata;
|
||||
}
|
||||
| { type: "candidates"; candidates: RTCIceCandidateInit[] }
|
||||
| { type: "track_metadata"; track_metadata: TrackMetadata }
|
||||
| { type: "consumption_metadata"; consumption_metadata: ConsumptionMetadata };
|
||||
|
||||
export function createControlChannel(pc: RTCPeerConnection): RTCDataChannel {
|
||||
return pc.createDataChannel(CONTROL_CHANNEL_LABEL, {
|
||||
negotiated: true,
|
||||
id: CONTROL_CHANNEL_ID,
|
||||
});
|
||||
}
|
||||
|
||||
/** A received control message, or null for one to ignore: malformed, or of a type we don't know. */
|
||||
export function parseControlMessage(data: unknown): ControlMessage | null {
|
||||
if (typeof data !== "string") return null;
|
||||
let m: Record<string, unknown>;
|
||||
try {
|
||||
m = JSON.parse(data) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof m !== "object" || m === null) return null;
|
||||
switch (m.type) {
|
||||
case "description": {
|
||||
const d = m.description as { type?: unknown; sdp?: unknown } | undefined;
|
||||
if (
|
||||
(d?.type !== "offer" && d?.type !== "answer") ||
|
||||
typeof d.sdp !== "string"
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
type: "description",
|
||||
description: { type: d.type, sdp: d.sdp },
|
||||
track_metadata: parseTrackMetadata(m.track_metadata) ?? undefined,
|
||||
};
|
||||
}
|
||||
case "candidates":
|
||||
if (!Array.isArray(m.candidates)) return null;
|
||||
return {
|
||||
type: "candidates",
|
||||
candidates: m.candidates as RTCIceCandidateInit[],
|
||||
};
|
||||
case "track_metadata": {
|
||||
const metadata = parseTrackMetadata(m.track_metadata);
|
||||
return metadata && { type: "track_metadata", track_metadata: metadata };
|
||||
}
|
||||
case "consumption_metadata": {
|
||||
const metadata = parseConsumptionMetadata(m.consumption_metadata);
|
||||
return (
|
||||
metadata && {
|
||||
type: "consumption_metadata",
|
||||
consumption_metadata: metadata,
|
||||
}
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "events";
|
||||
import {
|
||||
ClientEvent,
|
||||
EventType,
|
||||
type ITurnServer,
|
||||
MatrixEvent,
|
||||
} from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { BehaviorSubject, NEVER } from "rxjs";
|
||||
|
||||
import { type RTCMember, type TransportMetadata } from "../../api";
|
||||
import { defaultSessionTimings } from "../../config";
|
||||
import { E2eeType } from "../../encryption";
|
||||
import { constant } from "../../reactive/Behavior";
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import {
|
||||
type DataPacket,
|
||||
type MediaBackend,
|
||||
MediaConnectionState,
|
||||
} from "../api";
|
||||
import {
|
||||
completeFullMeshTransport,
|
||||
createFullMeshBackend,
|
||||
type FullMeshBackendOptions,
|
||||
} from "./FullMeshBackend";
|
||||
import { DATA_CHANNEL_LABEL, DATA_PROTOCOL } from "./metadata";
|
||||
import { FULL_MESH_TRANSPORT_TYPE, SignallingEventType } from "./signalling";
|
||||
import {
|
||||
FakePeerConnection,
|
||||
installWebRtcFakes,
|
||||
lastPeerConnection,
|
||||
peer,
|
||||
settle,
|
||||
} from "./test";
|
||||
|
||||
const roomId = "!room:example.org";
|
||||
const slotId = "m.call#ROOM";
|
||||
const own = { userId: "@me:example.org", deviceId: "MYDEVICE", memberId: "me" };
|
||||
|
||||
const fullMesh = (deviceId: string): Transport => ({
|
||||
type: FULL_MESH_TRANSPORT_TYPE,
|
||||
device_id: deviceId,
|
||||
});
|
||||
const livekit: Transport = { type: "livekit", livekit_service_url: "x" };
|
||||
|
||||
function metadata(raw: Transport): TransportMetadata {
|
||||
return {
|
||||
type: raw.type,
|
||||
id: JSON.stringify(raw),
|
||||
raw,
|
||||
resolved$: constant(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe("createFullMeshBackend", () => {
|
||||
let scope: ObservableScope;
|
||||
let client: EventEmitter & FullMeshBackendOptions["client"];
|
||||
let send: ReturnType<typeof vi.fn>;
|
||||
let turnServers: ITurnServer[];
|
||||
let backend: MediaBackend;
|
||||
const seq = new Map<string, number>();
|
||||
|
||||
/** A to-device message from `peer` (or another sender), as the sync would deliver it. */
|
||||
function deliver(
|
||||
type: string,
|
||||
connectionId: string,
|
||||
content: Record<string, unknown> = {},
|
||||
from = peer,
|
||||
): void {
|
||||
const next = seq.get(from.memberId) ?? 0;
|
||||
seq.set(from.memberId, next + 1);
|
||||
const event = new MatrixEvent({
|
||||
type,
|
||||
sender: from.userId,
|
||||
content: {
|
||||
room_id: roomId,
|
||||
slot_id: slotId,
|
||||
member_id: from.memberId,
|
||||
target_member_id: own.memberId,
|
||||
device_id: from.deviceId,
|
||||
connection_id: connectionId,
|
||||
seq: next,
|
||||
...content,
|
||||
},
|
||||
});
|
||||
vi.spyOn(event, "getWireType").mockReturnValue(
|
||||
EventType.RoomMessageEncrypted,
|
||||
);
|
||||
client.emit(ClientEvent.ToDeviceEvent, event);
|
||||
}
|
||||
|
||||
/** What we sent, as [type, recipient device, content]. */
|
||||
function sent(type?: string): [string, string, Record<string, unknown>][] {
|
||||
return send.mock.calls
|
||||
.filter(([t]) => type === undefined || t === type)
|
||||
.map(([t, [target], content]) => [t, target.deviceId, content]);
|
||||
}
|
||||
|
||||
/** A member of the slot, as the session hands it to the backend. */
|
||||
function addMember(
|
||||
transport: Transport,
|
||||
who = peer,
|
||||
): { scope: ObservableScope; tracks: () => unknown } {
|
||||
const memberScope = new ObservableScope();
|
||||
const media = backend.mediaFor(memberScope, {
|
||||
...who,
|
||||
rtcBackendIdentity: `${who.userId}:${who.deviceId}`,
|
||||
transport$: new BehaviorSubject(metadata(transport)),
|
||||
} as unknown as RTCMember);
|
||||
return { scope: memberScope, tracks: () => media.tracks$.value };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
installWebRtcFakes();
|
||||
seq.clear();
|
||||
scope = new ObservableScope();
|
||||
send = vi.fn(async () => Promise.resolve());
|
||||
turnServers = [];
|
||||
client = Object.assign(new EventEmitter(), {
|
||||
encryptAndSendToDevice: send,
|
||||
getTurnServers: (): ITurnServer[] => turnServers,
|
||||
checkTurnServers: vi.fn(async () => {
|
||||
turnServers = [{ urls: ["turn:x"], username: "u", credential: "p" }];
|
||||
return Promise.resolve(true);
|
||||
}),
|
||||
}) as unknown as EventEmitter & FullMeshBackendOptions["client"];
|
||||
backend = createFullMeshBackend(
|
||||
scope,
|
||||
{
|
||||
roomId,
|
||||
slotId,
|
||||
ownMembershipIdentity: own,
|
||||
publish: [],
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
mediaKeys$: NEVER,
|
||||
timings: defaultSessionTimings,
|
||||
logger,
|
||||
},
|
||||
{ client },
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
scope.end();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("only prepares a full mesh transport", async () => {
|
||||
await expect(backend.prepareLocalTransport(livekit)).rejects.toThrow(
|
||||
"Not a full mesh transport: livekit",
|
||||
);
|
||||
await expect(
|
||||
backend.prepareLocalTransport(fullMesh(own.deviceId)),
|
||||
).resolves.toEqual({ canDelegateDelayedLeave: false });
|
||||
expect(backend.local.connectionState$.value).toBe(
|
||||
MediaConnectionState.Connected,
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches TURN servers when the client has none yet", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
expect(client.checkTurnServers).toHaveBeenCalledOnce();
|
||||
addMember(fullMesh(peer.deviceId));
|
||||
await settle();
|
||||
expect(lastPeerConnection().config.iceServers).toEqual([
|
||||
{ urls: ["turn:x"], username: "u", credential: "p" },
|
||||
]);
|
||||
});
|
||||
|
||||
describe("as a publisher", () => {
|
||||
it("turns subscribers away while it doesn't publish", async () => {
|
||||
addMember(livekit);
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await settle();
|
||||
expect(sent()).toEqual([
|
||||
[
|
||||
SignallingEventType.close,
|
||||
peer.deviceId,
|
||||
expect.objectContaining({
|
||||
reason: "not_publishing",
|
||||
connection_id: "c1",
|
||||
}),
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers to a member on any transport", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
addMember(livekit);
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await settle();
|
||||
expect(sent(SignallingEventType.description)).toEqual([
|
||||
[
|
||||
SignallingEventType.description,
|
||||
peer.deviceId,
|
||||
expect.objectContaining({
|
||||
connection_id: "c1",
|
||||
target_member_id: peer.memberId,
|
||||
description: { type: "offer", sdp: "offer-1" },
|
||||
}),
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("waits for the membership of a subscriber that is ahead of it", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(sent()).toEqual([]);
|
||||
addMember(livekit);
|
||||
await settle();
|
||||
expect(sent(SignallingEventType.description)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects a subscriber that never becomes a member", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(sent()).toEqual([
|
||||
[
|
||||
SignallingEventType.close,
|
||||
peer.deviceId,
|
||||
expect.objectContaining({ reason: "not_a_member" }),
|
||||
],
|
||||
]);
|
||||
expect(FakePeerConnection.instances).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a subscriber claiming another device of the member", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
addMember(livekit);
|
||||
deliver(
|
||||
SignallingEventType.subscribe,
|
||||
"c1",
|
||||
{},
|
||||
{
|
||||
...peer,
|
||||
deviceId: "OTHERDEVICE",
|
||||
},
|
||||
);
|
||||
await settle();
|
||||
expect(sent()).toEqual([
|
||||
[
|
||||
SignallingEventType.close,
|
||||
"OTHERDEVICE",
|
||||
expect.objectContaining({ reason: "not_a_member" }),
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("only serves the newest of several waiting subscribe requests", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
deliver(SignallingEventType.subscribe, "c2");
|
||||
addMember(livekit);
|
||||
await settle();
|
||||
expect(
|
||||
sent(SignallingEventType.description).map(([, , c]) => c.connection_id),
|
||||
).toEqual(["c2"]);
|
||||
});
|
||||
|
||||
it("replaces the connection of a member that subscribes again", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
addMember(livekit);
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await settle();
|
||||
const first = lastPeerConnection();
|
||||
deliver(SignallingEventType.subscribe, "c2");
|
||||
await settle();
|
||||
expect(first.closed).toBe(true);
|
||||
expect(lastPeerConnection()).not.toBe(first);
|
||||
});
|
||||
|
||||
it("hands the subscriber's messages to its connection", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
addMember(livekit);
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await settle();
|
||||
deliver(SignallingEventType.description, "c1", {
|
||||
description: { type: "answer", sdp: "answer" },
|
||||
});
|
||||
deliver(SignallingEventType.candidates, "c1", {
|
||||
candidates: [{ candidate: "x" }],
|
||||
});
|
||||
// A late message of an earlier connection goes nowhere
|
||||
deliver(SignallingEventType.candidates, "c0", {
|
||||
candidates: [{ candidate: "stale" }],
|
||||
});
|
||||
await settle();
|
||||
const pc = lastPeerConnection();
|
||||
expect(pc.remoteDescription).toEqual({ type: "answer", sdp: "answer" });
|
||||
expect(pc.addedCandidates).toEqual([{ candidate: "x" }]);
|
||||
});
|
||||
|
||||
it("stops sending to a member that leaves", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
const member = addMember(livekit);
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await settle();
|
||||
member.scope.end();
|
||||
expect(lastPeerConnection().closed).toBe(true);
|
||||
expect(sent(SignallingEventType.close)).toEqual([
|
||||
[
|
||||
SignallingEventType.close,
|
||||
peer.deviceId,
|
||||
expect.objectContaining({ connection_id: "c1" }),
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists its subscribers for the developer tools", async () => {
|
||||
expect(backend.connections$.value).toEqual([]);
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
expect(backend.connections$.value).toEqual([
|
||||
{
|
||||
transport: fullMesh(own.deviceId),
|
||||
state: MediaConnectionState.Connected,
|
||||
resolved: {
|
||||
type: FULL_MESH_TRANSPORT_TYPE,
|
||||
role: "publisher",
|
||||
peerConnections: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
addMember(livekit);
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await settle();
|
||||
expect(backend.connections$.value[0].resolved).toMatchObject({
|
||||
peerConnections: [
|
||||
{
|
||||
memberId: peer.memberId,
|
||||
connectionId: "c1",
|
||||
connectionState: "new",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("as a subscriber", () => {
|
||||
it("subscribes to a member publishing on full mesh", async () => {
|
||||
const member = addMember(fullMesh(peer.deviceId));
|
||||
await settle();
|
||||
expect(sent()).toEqual([
|
||||
[
|
||||
SignallingEventType.subscribe,
|
||||
peer.deviceId,
|
||||
expect.objectContaining({
|
||||
room_id: roomId,
|
||||
slot_id: slotId,
|
||||
member_id: own.memberId,
|
||||
device_id: own.deviceId,
|
||||
target_member_id: peer.memberId,
|
||||
seq: 0,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
expect(member.tracks()).toBeNull();
|
||||
expect(backend.connections$.value).toEqual([
|
||||
expect.objectContaining({
|
||||
transport: fullMesh(peer.deviceId),
|
||||
resolved: expect.objectContaining({ role: "subscriber" }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores a full mesh transport advertising another device", async () => {
|
||||
addMember(fullMesh("OTHERDEVICE"));
|
||||
await settle();
|
||||
expect(sent()).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves members on other transports alone", async () => {
|
||||
const member = addMember(livekit);
|
||||
await settle();
|
||||
expect(sent()).toEqual([]);
|
||||
expect(member.tracks()).toBeNull();
|
||||
});
|
||||
|
||||
it("hands the publisher's messages to the subscription", async () => {
|
||||
addMember(fullMesh(peer.deviceId));
|
||||
await settle();
|
||||
const [[, , subscribe]] = sent();
|
||||
lastPeerConnection().receive("video", "1");
|
||||
deliver(
|
||||
SignallingEventType.description,
|
||||
subscribe.connection_id as string,
|
||||
{
|
||||
description: { type: "offer", sdp: "offer" },
|
||||
track_metadata: { "1": { purpose: "m.usermedia" } },
|
||||
},
|
||||
);
|
||||
await settle();
|
||||
expect(sent(SignallingEventType.description)).toEqual([
|
||||
[
|
||||
SignallingEventType.description,
|
||||
peer.deviceId,
|
||||
expect.objectContaining({
|
||||
description: { type: "answer", sdp: "answer" },
|
||||
}),
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("closes the subscription when the member leaves", async () => {
|
||||
const member = addMember(fullMesh(peer.deviceId));
|
||||
await settle();
|
||||
member.scope.end();
|
||||
expect(lastPeerConnection().closed).toBe(true);
|
||||
expect(backend.connections$.value).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("doesn't take over the delayed leave", async () => {
|
||||
await expect(backend.delegateDelayedLeave("id")).rejects.toThrow();
|
||||
});
|
||||
|
||||
describe("data", () => {
|
||||
it("can't be sent while not publishing", async () => {
|
||||
await expect(backend.sendData("topic", "text")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("is sent to every subscriber on its data channel", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
addMember(livekit);
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await settle();
|
||||
const data = lastPeerConnection().dataChannels.find(
|
||||
(c) => c.label === DATA_CHANNEL_LABEL,
|
||||
)!;
|
||||
data.open();
|
||||
await backend.sendData("greeting", "hello");
|
||||
expect(data.sent.map((m) => JSON.parse(m) as unknown)).toEqual([
|
||||
{ topic: "greeting", text: "hello" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("is received from a publisher, named by its member", async () => {
|
||||
const received: DataPacket[] = [];
|
||||
backend.data$.subscribe((p) => received.push(p));
|
||||
addMember(fullMesh(peer.deviceId));
|
||||
await settle();
|
||||
const [[, , subscribe]] = sent();
|
||||
deliver(
|
||||
SignallingEventType.description,
|
||||
subscribe.connection_id as string,
|
||||
{
|
||||
description: { type: "offer", sdp: "offer" },
|
||||
track_metadata: {},
|
||||
},
|
||||
);
|
||||
await settle();
|
||||
const channel = lastPeerConnection().announceDataChannel(
|
||||
DATA_CHANNEL_LABEL,
|
||||
DATA_PROTOCOL,
|
||||
);
|
||||
channel.receive({ topic: "greeting", text: "hello" });
|
||||
channel.receive({ topic: 1 });
|
||||
expect(received).toEqual([
|
||||
{
|
||||
senderId: `${peer.userId}:${peer.deviceId}`,
|
||||
topic: "greeting",
|
||||
text: "hello",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeFullMeshTransport", () => {
|
||||
it("adds our device to a full mesh transport only", () => {
|
||||
expect(
|
||||
completeFullMeshTransport({ type: FULL_MESH_TRANSPORT_TYPE }, "DEV"),
|
||||
).toEqual({ type: FULL_MESH_TRANSPORT_TYPE, device_id: "DEV" });
|
||||
expect(completeFullMeshTransport(livekit, "DEV")).toBe(livekit);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,424 @@
|
||||
/*
|
||||
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 full mesh transport (MSC4575): one WebRTC connection per subscriber
|
||||
* and publisher, signalled over to-device messages, through the TURN server
|
||||
* of each side's homeserver. Works subscribe-only too: without
|
||||
* `prepareLocalTransport` it publishes nothing and still receives from every
|
||||
* member on full mesh.
|
||||
*/
|
||||
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
NEVER,
|
||||
type Observable,
|
||||
of,
|
||||
share,
|
||||
Subject,
|
||||
switchMap,
|
||||
timeout,
|
||||
} from "rxjs";
|
||||
|
||||
import { type RTCMember } from "../../api";
|
||||
import { MatrixRTCError } from "../../errors";
|
||||
import { type MemberMedia } from "../../media-api";
|
||||
import { type Behavior } from "../../reactive/Behavior";
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { mapScoped } from "../../utils/mapScoped";
|
||||
import {
|
||||
type BackendConnection,
|
||||
type DataPacket,
|
||||
type MediaBackend,
|
||||
type MediaBackendContext,
|
||||
MediaConnectionState,
|
||||
} from "../api";
|
||||
import { createFullMeshLocalMedia } from "./LocalMedia";
|
||||
import { type PeerConnectionBase } from "./PeerConnection";
|
||||
import {
|
||||
createPublisherConnection,
|
||||
type PublisherConnection,
|
||||
} from "./PublisherConnection";
|
||||
import {
|
||||
createSignalling,
|
||||
FULL_MESH_TRANSPORT_TYPE,
|
||||
type Peer,
|
||||
type SignallingClient,
|
||||
type SignallingMessage,
|
||||
} from "./signalling";
|
||||
import {
|
||||
createSubscriberConnection,
|
||||
type SubscriberConnection,
|
||||
} from "./SubscriberConnection";
|
||||
|
||||
export { FULL_MESH_TRANSPORT_TYPE } from "./signalling";
|
||||
|
||||
/** How long a subscribe request waits for the subscriber's membership to arrive. */
|
||||
const SUBSCRIBE_GRACE_MS = 10_000;
|
||||
|
||||
export interface FullMeshBackendOptions {
|
||||
client: SignallingClient &
|
||||
Pick<MatrixClient, "getTurnServers" | "checkTurnServers">;
|
||||
}
|
||||
|
||||
/**
|
||||
* The transport object a member publishing on full mesh advertises: a host
|
||||
* passes `{ type: FULL_MESH_TRANSPORT_TYPE }`, and the device is filled in.
|
||||
* Any other transport is returned as it is.
|
||||
*/
|
||||
export function completeFullMeshTransport(
|
||||
transport: Transport,
|
||||
deviceId: string,
|
||||
): Transport {
|
||||
return transport.type === FULL_MESH_TRANSPORT_TYPE
|
||||
? { ...transport, device_id: deviceId }
|
||||
: transport;
|
||||
}
|
||||
|
||||
export function createFullMeshBackend(
|
||||
scope: ObservableScope,
|
||||
{
|
||||
roomId,
|
||||
slotId,
|
||||
ownMembershipIdentity,
|
||||
publish,
|
||||
logger: parentLogger,
|
||||
}: MediaBackendContext,
|
||||
{ client }: FullMeshBackendOptions,
|
||||
): MediaBackend {
|
||||
const logger = parentLogger.getChild("[FullMeshBackend]");
|
||||
const signalling = createSignalling({
|
||||
scope,
|
||||
client,
|
||||
roomId,
|
||||
slotId,
|
||||
own: ownMembershipIdentity,
|
||||
logger,
|
||||
});
|
||||
|
||||
const iceServers = async (): Promise<RTCIceServer[]> => {
|
||||
if (client.getTurnServers().length === 0)
|
||||
await client
|
||||
.checkTurnServers()
|
||||
.catch((e: unknown) => logger.warn("No TURN servers", e));
|
||||
return client.getTurnServers().map(({ urls, username, credential }) => ({
|
||||
urls,
|
||||
username,
|
||||
credential,
|
||||
}));
|
||||
};
|
||||
|
||||
let localTransport: Transport | null = null;
|
||||
const prepared$ = new BehaviorSubject(false);
|
||||
const audioOutputDeviceId$ = new BehaviorSubject<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const data$ = new Subject<DataPacket>();
|
||||
scope.onEnd(() => data$.complete());
|
||||
|
||||
const members = new Map<string, RTCMember>();
|
||||
const memberAdded$ = new Subject<RTCMember>();
|
||||
const subscribers = new Map<
|
||||
string,
|
||||
{ transport: Transport; peer: Peer; connection: SubscriberConnection }
|
||||
>();
|
||||
const publishers = new Map<
|
||||
string,
|
||||
{ connection: PublisherConnection; peer: Peer; scope: ObservableScope }
|
||||
>();
|
||||
scope.onEnd(() => {
|
||||
for (const { scope: s } of Array.from(publishers.values())) s.end();
|
||||
});
|
||||
/** The connection id of the newest subscribe request per member. */
|
||||
const latestSubscribe = new Map<string, string>();
|
||||
|
||||
// For the developer tools: one entry per publisher we subscribe to, and one
|
||||
// for our own transport with every subscriber we send to. `resolved` lists
|
||||
// the peer connections, as an SFU's entry lists what it connected to
|
||||
const connectionsChanged$ = new BehaviorSubject<void>(undefined);
|
||||
const connections$ = scope.behavior<BackendConnection[]>(
|
||||
combineLatest([connectionsChanged$, prepared$]).pipe(
|
||||
switchMap(([, prepared]) => {
|
||||
const entries: Observable<BackendConnection>[] = [];
|
||||
for (const { transport, peer, connection } of subscribers.values()) {
|
||||
const base = connection.current$.value;
|
||||
if (base)
|
||||
entries.push(
|
||||
base.state$.pipe(
|
||||
map((state) => ({
|
||||
transport,
|
||||
state,
|
||||
resolved: {
|
||||
type: FULL_MESH_TRANSPORT_TYPE,
|
||||
role: "subscriber",
|
||||
peerConnections: [describe(peer, base)],
|
||||
},
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (localTransport && prepared) {
|
||||
const transport = localTransport;
|
||||
const outgoing = [...publishers.values()];
|
||||
entries.push(
|
||||
(outgoing.length === 0
|
||||
? of([])
|
||||
: combineLatest(outgoing.map((p) => p.connection.state$))
|
||||
).pipe(
|
||||
map(() => ({
|
||||
transport,
|
||||
// Publishing needs no server, so it is up once prepared
|
||||
state: MediaConnectionState.Connected,
|
||||
resolved: {
|
||||
type: FULL_MESH_TRANSPORT_TYPE,
|
||||
role: "publisher",
|
||||
peerConnections: outgoing.map(({ peer, connection }) =>
|
||||
describe(peer, connection),
|
||||
),
|
||||
},
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
return entries.length === 0 ? of([]) : combineLatest(entries);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const local = createFullMeshLocalMedia({
|
||||
scope,
|
||||
publish,
|
||||
connectionState$: scope.behavior(
|
||||
prepared$.pipe(
|
||||
map((prepared) =>
|
||||
prepared
|
||||
? MediaConnectionState.Connected
|
||||
: MediaConnectionState.Initialized,
|
||||
),
|
||||
),
|
||||
),
|
||||
peerConnections: () =>
|
||||
[...publishers.values()].map(({ connection }) => connection.pc),
|
||||
logger,
|
||||
});
|
||||
|
||||
const acceptSubscribe = async (message: SignallingMessage): Promise<void> => {
|
||||
const peer: Peer = {
|
||||
userId: message.userId,
|
||||
deviceId: message.deviceId,
|
||||
memberId: message.memberId,
|
||||
};
|
||||
// We don't advertise full mesh, so nobody should have asked
|
||||
if (!prepared$.value) {
|
||||
signalling.send(peer, message.connectionId, {
|
||||
type: "close",
|
||||
reason: "not_publishing",
|
||||
});
|
||||
return;
|
||||
}
|
||||
latestSubscribe.set(peer.memberId, message.connectionId);
|
||||
// The request may overtake the subscriber's membership
|
||||
const member =
|
||||
members.get(peer.memberId) ??
|
||||
(await firstValueFrom(
|
||||
memberAdded$.pipe(
|
||||
filter((m) => m.memberId === peer.memberId),
|
||||
timeout(SUBSCRIBE_GRACE_MS),
|
||||
),
|
||||
).catch(() => undefined));
|
||||
if (latestSubscribe.get(peer.memberId) !== message.connectionId) return;
|
||||
if (
|
||||
member === undefined ||
|
||||
member.userId !== peer.userId ||
|
||||
member.deviceId !== peer.deviceId
|
||||
) {
|
||||
logger.warn(`Rejecting a subscribe from ${peer.memberId}`);
|
||||
signalling.send(peer, message.connectionId, {
|
||||
type: "close",
|
||||
reason: "not_a_member",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// At most one connection per member pair: the new one replaces the old
|
||||
publishers.get(peer.memberId)?.scope.end();
|
||||
const pubScope = new ObservableScope();
|
||||
const connection = createPublisherConnection({
|
||||
scope: pubScope,
|
||||
peer,
|
||||
connectionId: message.connectionId,
|
||||
signalling,
|
||||
iceServers: await iceServers(),
|
||||
local,
|
||||
logger,
|
||||
});
|
||||
publishers.set(peer.memberId, { connection, peer, scope: pubScope });
|
||||
pubScope.onEnd(() => {
|
||||
if (publishers.get(peer.memberId)?.connection === connection) {
|
||||
publishers.delete(peer.memberId);
|
||||
connectionsChanged$.next();
|
||||
}
|
||||
});
|
||||
connectionsChanged$.next();
|
||||
};
|
||||
|
||||
signalling.messages$.pipe(scope.bind()).subscribe((message) => {
|
||||
if (message.type === "subscribe") return void acceptSubscribe(message);
|
||||
const subscriber = subscribers.get(message.memberId)?.connection;
|
||||
if (subscriber?.current$.value?.connectionId === message.connectionId)
|
||||
return subscriber.handle(message);
|
||||
const publisher = publishers.get(message.memberId)?.connection;
|
||||
if (publisher?.connectionId === message.connectionId)
|
||||
return publisher.handle(message);
|
||||
logger.debug(`Dropping ${message.type} of a stale connection`);
|
||||
});
|
||||
|
||||
const mediaFor = (
|
||||
memberScope: ObservableScope,
|
||||
member: RTCMember,
|
||||
): MemberMedia => {
|
||||
members.set(member.memberId, member);
|
||||
memberAdded$.next(member);
|
||||
memberScope.onEnd(() => {
|
||||
members.delete(member.memberId);
|
||||
// We stop sending to members that are no longer joined
|
||||
publishers.get(member.memberId)?.scope.end();
|
||||
});
|
||||
|
||||
// In encrypted rooms the advertised device has to be the one the
|
||||
// membership came from
|
||||
const fullMesh$ = memberScope.behavior(
|
||||
member.transport$.pipe(
|
||||
map((transport) =>
|
||||
transport?.type === FULL_MESH_TRANSPORT_TYPE &&
|
||||
transport.raw.device_id === member.deviceId
|
||||
? transport.raw
|
||||
: undefined,
|
||||
),
|
||||
distinctUntilChanged((a, b) => a?.device_id === b?.device_id),
|
||||
),
|
||||
);
|
||||
const subscriber$ = mapScoped(memberScope, fullMesh$, (s, transport) => {
|
||||
const connection = createSubscriberConnection({
|
||||
scope: s,
|
||||
peer: {
|
||||
userId: member.userId,
|
||||
deviceId: member.deviceId,
|
||||
memberId: member.memberId,
|
||||
},
|
||||
signalling,
|
||||
iceServers,
|
||||
audioOutputDeviceId$,
|
||||
logger,
|
||||
});
|
||||
subscribers.set(member.memberId, {
|
||||
transport,
|
||||
peer: {
|
||||
userId: member.userId,
|
||||
deviceId: member.deviceId,
|
||||
memberId: member.memberId,
|
||||
},
|
||||
connection,
|
||||
});
|
||||
connection.current$
|
||||
.pipe(s.bind())
|
||||
.subscribe(() => connectionsChanged$.next());
|
||||
connection.data$.pipe(s.bind()).subscribe((message) => {
|
||||
const packet = parseDataMessage(message);
|
||||
if (packet === null)
|
||||
logger.debug(`Ignoring a data message from ${member.memberId}`);
|
||||
else data$.next({ senderId: member.rtcBackendIdentity, ...packet });
|
||||
});
|
||||
s.onEnd(() => {
|
||||
subscribers.delete(member.memberId);
|
||||
connectionsChanged$.next();
|
||||
});
|
||||
return connection;
|
||||
});
|
||||
|
||||
return {
|
||||
tracks$: memberScope.behavior(
|
||||
subscriber$.pipe(switchMap((s) => s?.tracks$ ?? of(null))),
|
||||
),
|
||||
encryptionError$: NEVER,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
transportType: FULL_MESH_TRANSPORT_TYPE,
|
||||
prepareLocalTransport: async (transport) => {
|
||||
if (transport.type !== FULL_MESH_TRANSPORT_TYPE)
|
||||
throw new MatrixRTCError(
|
||||
`Not a full mesh transport: ${transport.type}`,
|
||||
);
|
||||
localTransport = transport;
|
||||
// Fetched now, so that the first subscriber isn't kept waiting
|
||||
await iceServers();
|
||||
prepared$.next(true);
|
||||
return { canDelegateDelayedLeave: false };
|
||||
},
|
||||
delegateDelayedLeave: async () =>
|
||||
Promise.reject(
|
||||
new MatrixRTCError("Full mesh cannot take over the delayed leave"),
|
||||
),
|
||||
local,
|
||||
mediaFor,
|
||||
connections$: connections$ as Behavior<BackendConnection[]>,
|
||||
setAudioOutputDeviceId: async (deviceId) => {
|
||||
audioOutputDeviceId$.next(deviceId);
|
||||
return Promise.resolve();
|
||||
},
|
||||
sendData: async (topic, text) => {
|
||||
if (!prepared$.value)
|
||||
throw new MatrixRTCError(
|
||||
"Cannot send data: not publishing on full mesh",
|
||||
);
|
||||
const message = JSON.stringify({ topic, text });
|
||||
for (const { connection } of publishers.values())
|
||||
connection.sendData(message);
|
||||
return Promise.resolve();
|
||||
},
|
||||
data$: data$.pipe(share()),
|
||||
};
|
||||
}
|
||||
|
||||
/** A message of the `sendData` data channel, or null for a malformed one. */
|
||||
function parseDataMessage(
|
||||
message: string,
|
||||
): Pick<DataPacket, "topic" | "text"> | null {
|
||||
try {
|
||||
const { topic, text } = JSON.parse(message) as Record<string, unknown>;
|
||||
return typeof topic === "string" && typeof text === "string"
|
||||
? { topic, text }
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** One peer connection as the developer tools show it. */
|
||||
function describe(
|
||||
{ userId, deviceId, memberId }: Peer,
|
||||
{ connectionId, pc }: PeerConnectionBase,
|
||||
): Record<string, string> {
|
||||
return {
|
||||
userId,
|
||||
deviceId,
|
||||
memberId,
|
||||
connectionId,
|
||||
connectionState: pc.connectionState,
|
||||
iceConnectionState: pc.iceConnectionState,
|
||||
signalingState: pc.signalingState,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
/*
|
||||
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 {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
NEVER,
|
||||
} from "rxjs";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { MatrixRTCError, toMatrixRTCError } from "../../errors";
|
||||
import {
|
||||
type LocalAudioMediaTrack,
|
||||
type LocalVideoMediaTrack,
|
||||
type MediaSource,
|
||||
type PublishRequest,
|
||||
type VideoProcessor,
|
||||
} from "../../media-api";
|
||||
import { type Behavior } from "../../reactive/Behavior";
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { type LocalMediaBackend, type MediaConnectionState } from "../api";
|
||||
import { createFullMeshMediaTrack, rtpStats } from "./MediaTrack";
|
||||
|
||||
type PublishedSource = Exclude<MediaSource, "unknown">;
|
||||
|
||||
/** One local track as the publisher connections send it. */
|
||||
export interface LocalPublication {
|
||||
source: PublishedSource;
|
||||
kind: "audio" | "video";
|
||||
/**
|
||||
* The `MediaStream` it is sent in: one for the user media, one for the
|
||||
* screen share, so that the subscriber plays each in sync.
|
||||
*/
|
||||
stream: MediaStream;
|
||||
/** What goes on the wire; null while the source is off. */
|
||||
track$: Behavior<MediaStreamTrack | null>;
|
||||
mediaTrack: LocalAudioMediaTrack | LocalVideoMediaTrack;
|
||||
}
|
||||
|
||||
export interface FullMeshLocalMedia extends LocalMediaBackend {
|
||||
/** Every local track, once the first `setPublishing(true)` started the capture. */
|
||||
publications$: Behavior<LocalPublication[]>;
|
||||
/** Whether the tracks reach the publisher connections. */
|
||||
publishing$: Behavior<boolean>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
publish: PublishRequest[];
|
||||
connectionState$: Behavior<MediaConnectionState | Error>;
|
||||
/** The publisher connections, for the outbound statistics of a track. */
|
||||
peerConnections: () => RTCPeerConnection[];
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
interface Capture {
|
||||
/** What was captured, before any processor. */
|
||||
raw: MediaStreamTrack;
|
||||
out: MediaStreamTrack;
|
||||
}
|
||||
|
||||
/** The local member's side: capture, the publication list and the controls. */
|
||||
export function createFullMeshLocalMedia({
|
||||
scope,
|
||||
publish: initialRequests,
|
||||
connectionState$,
|
||||
peerConnections,
|
||||
logger: parentLogger,
|
||||
}: Props): FullMeshLocalMedia {
|
||||
const logger = parentLogger.getChild("[LocalMedia]");
|
||||
// Only their ids matter: the tracks are added per connection
|
||||
const userMediaStream = new MediaStream();
|
||||
const screenShareStream = new MediaStream();
|
||||
const desired = new Map<PublishRequest["source"], PublishRequest>(
|
||||
initialRequests.map((r) => [r.source, r]),
|
||||
);
|
||||
const started$ = new BehaviorSubject(false);
|
||||
const publishing$ = new BehaviorSubject(false);
|
||||
const publishError$ = new BehaviorSubject<Error | null>(null);
|
||||
const publications$ = new BehaviorSubject<LocalPublication[]>([]);
|
||||
const teardowns = new Map<PublishedSource, () => void>();
|
||||
|
||||
const statsFor = async (track: MediaStreamTrack | null) => {
|
||||
for (const pc of peerConnections())
|
||||
if (pc.getSenders().some((s) => s.track === track && track !== null))
|
||||
return rtpStats(pc, track, "outbound-rtp");
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Adds a publication whose track is managed by `control`. */
|
||||
const addPublication = (
|
||||
source: PublishedSource,
|
||||
kind: "audio" | "video",
|
||||
stream: MediaStream,
|
||||
initial: MediaStreamTrack,
|
||||
control: {
|
||||
setEnabled: (
|
||||
enabled: boolean,
|
||||
track$: BehaviorSubject<MediaStreamTrack | null>,
|
||||
) => Promise<boolean>;
|
||||
setDevice?: (
|
||||
deviceId: string,
|
||||
track$: BehaviorSubject<MediaStreamTrack | null>,
|
||||
) => Promise<void>;
|
||||
switchFacingMode?: (
|
||||
track$: BehaviorSubject<MediaStreamTrack | null>,
|
||||
) => Promise<string | undefined>;
|
||||
setProcessor?: (
|
||||
p: VideoProcessor | undefined,
|
||||
track$: BehaviorSubject<MediaStreamTrack | null>,
|
||||
) => Promise<void>;
|
||||
stop: () => void;
|
||||
},
|
||||
): LocalPublication => {
|
||||
const pubScope = new ObservableScope();
|
||||
const track$ = new BehaviorSubject<MediaStreamTrack | null>(initial);
|
||||
const enabled$ = new BehaviorSubject(true);
|
||||
const base = createFullMeshMediaTrack(pubScope, {
|
||||
source,
|
||||
kind,
|
||||
id: uuidv4(),
|
||||
track$,
|
||||
muted$: pubScope.behavior(enabled$.pipe(map((e) => !e))),
|
||||
stats: async () => statsFor(track$.value),
|
||||
});
|
||||
const local = {
|
||||
setEnabled: async (enabled: boolean): Promise<boolean> => {
|
||||
const result = await control.setEnabled(enabled, track$);
|
||||
enabled$.next(result);
|
||||
return result;
|
||||
},
|
||||
setDevice: async (deviceId: string): Promise<void> => {
|
||||
if (!control.setDevice)
|
||||
throw new MatrixRTCError(`A ${source} track has no device`);
|
||||
await control.setDevice(deviceId, track$);
|
||||
},
|
||||
};
|
||||
const mediaTrack =
|
||||
base.kind === "audio"
|
||||
? { ...base, ...local }
|
||||
: {
|
||||
...base,
|
||||
...local,
|
||||
facingMode$: pubScope.behavior(
|
||||
track$.pipe(map((t) => facingModeOf(t))),
|
||||
),
|
||||
switchFacingMode: async () => control.switchFacingMode?.(track$),
|
||||
setProcessor: async (p: VideoProcessor | undefined) =>
|
||||
control.setProcessor?.(p, track$),
|
||||
};
|
||||
const publication: LocalPublication = {
|
||||
source,
|
||||
kind,
|
||||
stream,
|
||||
track$,
|
||||
mediaTrack: mediaTrack as LocalAudioMediaTrack | LocalVideoMediaTrack,
|
||||
};
|
||||
publications$.next([...publications$.value, publication]);
|
||||
teardowns.set(source, () => {
|
||||
control.stop();
|
||||
track$.value?.stop();
|
||||
pubScope.end();
|
||||
publications$.next(publications$.value.filter((p) => p !== publication));
|
||||
teardowns.delete(source);
|
||||
});
|
||||
return publication;
|
||||
};
|
||||
|
||||
const captureMicrophone = async (
|
||||
request: Extract<PublishRequest, { source: "microphone" }>,
|
||||
): Promise<void> => {
|
||||
const track = await getTrack({
|
||||
audio: { deviceId: exact(request.deviceId), ...request.capture },
|
||||
});
|
||||
addPublication("microphone", "audio", userMediaStream, track, {
|
||||
// Muting a microphone keeps the device: unmuting is instant
|
||||
setEnabled: async (enabled, track$) => {
|
||||
if (track$.value) track$.value.enabled = enabled;
|
||||
return Promise.resolve(enabled);
|
||||
},
|
||||
setDevice: async (deviceId, track$) => {
|
||||
const next = await getTrack({
|
||||
audio: { deviceId: { exact: deviceId }, ...request.capture },
|
||||
});
|
||||
next.enabled = track$.value?.enabled ?? true;
|
||||
track$.value?.stop();
|
||||
track$.next(next);
|
||||
},
|
||||
stop: () => {},
|
||||
});
|
||||
};
|
||||
|
||||
const captureCamera = async (
|
||||
request: Extract<PublishRequest, { source: "camera" }>,
|
||||
): Promise<void> => {
|
||||
let deviceId = request.deviceId;
|
||||
let facingMode: string | undefined;
|
||||
let processor = request.processor;
|
||||
let capture: Capture | null = null;
|
||||
let processorStarted = false;
|
||||
const constraints = (): MediaTrackConstraints => ({
|
||||
deviceId: exact(deviceId),
|
||||
facingMode,
|
||||
width: request.capture?.resolution?.width,
|
||||
height: request.capture?.resolution?.height,
|
||||
frameRate: request.capture?.resolution?.frameRate,
|
||||
});
|
||||
const open = async (): Promise<Capture> => {
|
||||
const raw = await getTrack({ video: constraints() });
|
||||
if (processor === undefined) return { raw, out: raw };
|
||||
const out = processorStarted
|
||||
? await processor.restart(raw)
|
||||
: await processor.init(raw);
|
||||
processorStarted = true;
|
||||
return { raw, out };
|
||||
};
|
||||
const close = (): void => {
|
||||
capture?.raw.stop();
|
||||
if (capture?.out !== capture?.raw) capture?.out.stop();
|
||||
capture = null;
|
||||
};
|
||||
// Reopens the camera with the current settings, if it is on
|
||||
const reopen = async (
|
||||
track$: BehaviorSubject<MediaStreamTrack | null>,
|
||||
): Promise<void> => {
|
||||
if (capture === null) return;
|
||||
close();
|
||||
capture = await open();
|
||||
track$.next(capture.out);
|
||||
};
|
||||
capture = await open();
|
||||
addPublication("camera", "video", userMediaStream, capture.out, {
|
||||
// Muting a camera releases it, so that its light goes off
|
||||
setEnabled: async (enabled, track$) => {
|
||||
if (enabled === (capture !== null)) return enabled;
|
||||
if (!enabled) {
|
||||
close();
|
||||
track$.next(null);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
capture = await open();
|
||||
track$.next(capture.out);
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.warn("Could not reopen the camera", e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
setDevice: async (id, track$) => {
|
||||
deviceId = id;
|
||||
facingMode = undefined;
|
||||
await reopen(track$);
|
||||
},
|
||||
switchFacingMode: async (track$) => {
|
||||
const current = facingModeOf(track$.value);
|
||||
if (current === undefined) return undefined;
|
||||
deviceId = undefined;
|
||||
facingMode = current === "user" ? "environment" : "user";
|
||||
await reopen(track$);
|
||||
return capture?.raw.getSettings().deviceId;
|
||||
},
|
||||
setProcessor: async (p, track$) => {
|
||||
await processor?.destroy();
|
||||
processor = p;
|
||||
processorStarted = false;
|
||||
if (capture === null) return;
|
||||
const out = p ? await p.init(capture.raw) : capture.raw;
|
||||
processorStarted = p !== undefined;
|
||||
if (capture.out !== capture.raw) capture.out.stop();
|
||||
capture = { raw: capture.raw, out };
|
||||
track$.next(out);
|
||||
},
|
||||
stop: () => {
|
||||
close();
|
||||
void processor?.destroy();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const captureScreen = async (
|
||||
request: Extract<PublishRequest, { source: "screenShare" }>,
|
||||
): Promise<void> => {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: {
|
||||
width: request.capture?.resolution?.width,
|
||||
height: request.capture?.resolution?.height,
|
||||
frameRate: request.capture?.resolution?.frameRate,
|
||||
},
|
||||
audio: request.audio ?? true,
|
||||
});
|
||||
const [video] = stream.getVideoTracks();
|
||||
const [audio] = stream.getAudioTracks();
|
||||
const toggle = async (
|
||||
enabled: boolean,
|
||||
track$: BehaviorSubject<MediaStreamTrack | null>,
|
||||
): Promise<boolean> => {
|
||||
if (track$.value) track$.value.enabled = enabled;
|
||||
return Promise.resolve(enabled);
|
||||
};
|
||||
addPublication("screenShare", "video", screenShareStream, video, {
|
||||
setEnabled: toggle,
|
||||
// The audio goes with its video
|
||||
stop: () => {
|
||||
audio?.stop();
|
||||
teardowns.get("screenShareAudio")?.();
|
||||
},
|
||||
});
|
||||
if (audio)
|
||||
addPublication("screenShareAudio", "audio", screenShareStream, audio, {
|
||||
setEnabled: toggle,
|
||||
stop: () => {},
|
||||
});
|
||||
// Stopped from the browser's own controls
|
||||
video.addEventListener("ended", () => void unpublish("screenShare"));
|
||||
};
|
||||
|
||||
const capture = async (request: PublishRequest): Promise<void> => {
|
||||
teardowns.get(request.source)?.();
|
||||
switch (request.source) {
|
||||
case "microphone":
|
||||
return captureMicrophone(request);
|
||||
case "camera":
|
||||
return captureCamera(request);
|
||||
case "screenShare":
|
||||
return captureScreen(request);
|
||||
}
|
||||
};
|
||||
|
||||
const start = async (): Promise<void> => {
|
||||
for (const request of desired.values())
|
||||
await capture(request).catch((e: unknown) => {
|
||||
logger.warn(`Could not capture ${request.source}`, e);
|
||||
if (publishError$.value === null)
|
||||
publishError$.next(toMatrixRTCError(e));
|
||||
});
|
||||
};
|
||||
|
||||
const setPublishing = (publish: boolean): void => {
|
||||
publishing$.next(publish);
|
||||
if (publish && !started$.value) {
|
||||
started$.next(true);
|
||||
void start();
|
||||
}
|
||||
};
|
||||
|
||||
const publish = async (
|
||||
request: PublishRequest,
|
||||
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack> => {
|
||||
desired.set(request.source, request);
|
||||
try {
|
||||
// Before the start, the start captures everything desired itself
|
||||
if (started$.value) await capture(request);
|
||||
return await firstValueFrom(
|
||||
publications$.pipe(
|
||||
map((ps) => ps.find((p) => p.source === request.source)),
|
||||
filter((p) => p !== undefined),
|
||||
map((p) => p.mediaTrack),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
desired.delete(request.source);
|
||||
throw toMatrixRTCError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const unpublish = async (source: MediaSource): Promise<void> => {
|
||||
const published = source === "screenShareAudio" ? "screenShare" : source;
|
||||
if (published === "unknown") return;
|
||||
desired.delete(published);
|
||||
teardowns.get(published)?.();
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
scope.onEnd(() => {
|
||||
// Each teardown removes itself from the map
|
||||
for (const teardown of Array.from(teardowns.values())) teardown();
|
||||
});
|
||||
|
||||
const tracks$ = scope.behavior(
|
||||
combineLatest([started$, publications$]).pipe(
|
||||
map(([started, ps]) => (started ? ps.map((p) => p.mediaTrack) : null)),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
connectionState$,
|
||||
tracks$,
|
||||
encryptionError$: NEVER,
|
||||
setPublishing,
|
||||
publishError$,
|
||||
publish,
|
||||
unpublish,
|
||||
publications$,
|
||||
publishing$,
|
||||
};
|
||||
}
|
||||
|
||||
function exact(deviceId: string | undefined): ConstrainDOMString | undefined {
|
||||
return deviceId === undefined ? undefined : { exact: deviceId };
|
||||
}
|
||||
|
||||
async function getTrack(
|
||||
constraints: MediaStreamConstraints,
|
||||
): Promise<MediaStreamTrack> {
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
const [track] = stream.getTracks();
|
||||
if (track === undefined) throw new MatrixRTCError("No track was captured");
|
||||
return track;
|
||||
}
|
||||
|
||||
function facingModeOf(
|
||||
track: MediaStreamTrack | null,
|
||||
): "user" | "environment" | undefined {
|
||||
const mode = track?.getSettings().facingMode;
|
||||
return mode === "user" || mode === "environment" ? mode : undefined;
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
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 {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
scan,
|
||||
switchMap,
|
||||
timer,
|
||||
} from "rxjs";
|
||||
|
||||
import { type Behavior, constant } from "../../reactive/Behavior";
|
||||
import { type ObservableScope } from "../../reactive/ObservableScope";
|
||||
import {
|
||||
type AudioMediaTrack,
|
||||
type MediaSource,
|
||||
type MediaStreamStats,
|
||||
type MediaTrack,
|
||||
type VideoMediaTrack,
|
||||
} from "../../media-api";
|
||||
|
||||
export interface TrackProps {
|
||||
source: MediaSource;
|
||||
kind: "audio" | "video";
|
||||
id: string;
|
||||
/** The track behind it; may change, as on a device switch, or be null while off. */
|
||||
track$: Behavior<MediaStreamTrack | null>;
|
||||
muted$: Behavior<boolean>;
|
||||
/** Statistics of the RTP stream carrying it, if there is one. */
|
||||
stats: () => Promise<MediaStreamStats>;
|
||||
/** Where attached elements play; only for tracks that are played. */
|
||||
audioOutputDeviceId$?: Behavior<string | undefined>;
|
||||
/** Whether to follow where attached video elements are shown, for `viewport$`. */
|
||||
observeViewport?: boolean;
|
||||
}
|
||||
|
||||
/** Where a video is shown: the largest of its visible elements, in device pixels if known. */
|
||||
export interface Viewport {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export type FullMeshMediaTrack = (AudioMediaTrack | VideoMediaTrack) & {
|
||||
/** Null while no attached element is visible; always null unless observed. */
|
||||
viewport$: Behavior<Viewport | null>;
|
||||
};
|
||||
|
||||
/** Above this level a track turns active; below `INACTIVE_LEVEL` for `INACTIVE_AFTER_MS` it turns inactive. */
|
||||
const ACTIVE_LEVEL = 0.04;
|
||||
const INACTIVE_LEVEL = 0.02;
|
||||
const LEVEL_INTERVAL_MS = 100;
|
||||
const INACTIVE_AFTER_MS = 500;
|
||||
|
||||
let levelContext: AudioContext | undefined;
|
||||
|
||||
/**
|
||||
* A `MediaStreamTrack` as a `MediaTrack`: the same object for the life of a
|
||||
* publication, with every attached element following the track behind it.
|
||||
*/
|
||||
export function createFullMeshMediaTrack(
|
||||
scope: ObservableScope,
|
||||
props: TrackProps,
|
||||
): FullMeshMediaTrack {
|
||||
const { source, kind, id, track$, muted$, stats, audioOutputDeviceId$ } =
|
||||
props;
|
||||
const attached = new Set<HTMLMediaElement>();
|
||||
const viewport =
|
||||
kind === "video" && props.observeViewport
|
||||
? observeViewport(scope)
|
||||
: undefined;
|
||||
let audioContext: AudioContext | undefined;
|
||||
let audioPlugins: AudioNode[] = [];
|
||||
let volume = 1;
|
||||
let webAudio: { nodes: AudioNode[]; gain: GainNode } | undefined;
|
||||
|
||||
const routeWebAudio = (track: MediaStreamTrack | null): void => {
|
||||
for (const node of webAudio?.nodes ?? []) node.disconnect();
|
||||
webAudio = undefined;
|
||||
if (audioContext === undefined || track === null) return;
|
||||
const input = audioContext.createMediaStreamSource(
|
||||
new MediaStream([track]),
|
||||
);
|
||||
const gain = audioContext.createGain();
|
||||
gain.gain.value = volume;
|
||||
const chain: AudioNode[] = [input, ...audioPlugins, gain];
|
||||
for (let i = 0; i < chain.length - 1; i++) chain[i].connect(chain[i + 1]);
|
||||
gain.connect(audioContext.destination);
|
||||
webAudio = { nodes: chain, gain };
|
||||
};
|
||||
|
||||
const apply = (element: HTMLMediaElement): void => {
|
||||
const track = track$.value;
|
||||
element.srcObject = track ? new MediaStream([track]) : null;
|
||||
if (kind === "audio") {
|
||||
// Played through Web Audio instead, where it is routed there
|
||||
element.muted = audioContext !== undefined;
|
||||
element.volume = volume;
|
||||
const sinkId = audioOutputDeviceId$?.value;
|
||||
if (sinkId !== undefined && "setSinkId" in element)
|
||||
void element.setSinkId(sinkId).catch(() => {});
|
||||
}
|
||||
if (element.paused) void element.play().catch(() => {});
|
||||
};
|
||||
|
||||
track$.pipe(scope.bind()).subscribe((track) => {
|
||||
for (const element of attached) apply(element);
|
||||
if (kind === "audio") routeWebAudio(track);
|
||||
});
|
||||
audioOutputDeviceId$?.pipe(scope.bind()).subscribe(() => {
|
||||
for (const element of attached) apply(element);
|
||||
});
|
||||
scope.onEnd(() => {
|
||||
for (const element of attached) element.srcObject = null;
|
||||
attached.clear();
|
||||
routeWebAudio(null);
|
||||
});
|
||||
|
||||
const base: MediaTrack = {
|
||||
source,
|
||||
kind,
|
||||
id,
|
||||
muted$,
|
||||
// DTLS-SRTP, its fingerprints exchanged over Olm
|
||||
encrypted: true,
|
||||
stats$: (intervalMs): Observable<MediaStreamStats> =>
|
||||
timer(0, intervalMs).pipe(
|
||||
switchMap(async () => stats()),
|
||||
scope.bind(),
|
||||
),
|
||||
attach: (element) => {
|
||||
if (attached.has(element)) return;
|
||||
attached.add(element);
|
||||
apply(element);
|
||||
viewport?.observe(element);
|
||||
},
|
||||
detach: (element) => {
|
||||
if (!attached.delete(element)) return;
|
||||
element.srcObject = null;
|
||||
viewport?.unobserve(element);
|
||||
},
|
||||
};
|
||||
|
||||
if (kind === "video")
|
||||
return {
|
||||
...base,
|
||||
kind: "video",
|
||||
viewport$: viewport?.viewport$ ?? constant(null),
|
||||
};
|
||||
|
||||
const audioLevel$ = scope.behavior(
|
||||
combineLatest([track$, muted$]).pipe(
|
||||
switchMap(([track, muted]) =>
|
||||
track === null || muted ? of(0) : level$(track),
|
||||
),
|
||||
),
|
||||
0,
|
||||
);
|
||||
const isActive$ = scope.behavior(
|
||||
audioLevel$.pipe(
|
||||
scan(
|
||||
({ active, quietMs }, level) => {
|
||||
if (level >= ACTIVE_LEVEL) return { active: true, quietMs: 0 };
|
||||
if (level >= INACTIVE_LEVEL) return { active, quietMs: 0 };
|
||||
const quiet = quietMs + LEVEL_INTERVAL_MS;
|
||||
return {
|
||||
active: active && quiet < INACTIVE_AFTER_MS,
|
||||
quietMs: quiet,
|
||||
};
|
||||
},
|
||||
{ active: false, quietMs: 0 },
|
||||
),
|
||||
map(({ active }) => active),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
return {
|
||||
...base,
|
||||
kind: "audio",
|
||||
viewport$: constant(null),
|
||||
audioLevel$,
|
||||
isActive$,
|
||||
setAudioContext: (ctx, plugins = []) => {
|
||||
audioContext = ctx;
|
||||
audioPlugins = plugins;
|
||||
routeWebAudio(track$.value);
|
||||
for (const element of attached) apply(element);
|
||||
},
|
||||
setVolume: (v) => {
|
||||
volume = v;
|
||||
if (webAudio) webAudio.gain.gain.value = v;
|
||||
for (const element of attached) element.volume = v;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows the size and visibility of elements. Where the platform has no
|
||||
* observers, an element counts as visible at an unknown size.
|
||||
*/
|
||||
function observeViewport(scope: ObservableScope): {
|
||||
viewport$: Behavior<Viewport | null>;
|
||||
observe: (element: Element) => void;
|
||||
unobserve: (element: Element) => void;
|
||||
} {
|
||||
const shown = new Map<
|
||||
Element,
|
||||
{ visible: boolean; width?: number; height?: number }
|
||||
>();
|
||||
const viewport$ = new BehaviorSubject<Viewport | null>(null);
|
||||
const update = (): void => {
|
||||
const visible = [...shown.values()].filter(
|
||||
({ visible, width, height }) => visible && width !== 0 && height !== 0,
|
||||
);
|
||||
// One element of unknown size leaves the size unknown
|
||||
const next: Viewport | null =
|
||||
visible.length === 0
|
||||
? null
|
||||
: visible.some((e) => e.width === undefined || e.height === undefined)
|
||||
? {}
|
||||
: {
|
||||
width: Math.max(...visible.map((e) => e.width!)),
|
||||
height: Math.max(...visible.map((e) => e.height!)),
|
||||
};
|
||||
const current = viewport$.value;
|
||||
if (
|
||||
(current === null) !== (next === null) ||
|
||||
current?.width !== next?.width ||
|
||||
current?.height !== next?.height
|
||||
)
|
||||
viewport$.next(next);
|
||||
};
|
||||
|
||||
const pixelRatio = globalThis.devicePixelRatio ?? 1;
|
||||
const resizes =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? undefined
|
||||
: new ResizeObserver((entries) => {
|
||||
for (const { target, contentRect } of entries) {
|
||||
const entry = shown.get(target);
|
||||
if (entry === undefined) continue;
|
||||
entry.width = Math.ceil(contentRect.width * pixelRatio);
|
||||
entry.height = Math.ceil(contentRect.height * pixelRatio);
|
||||
}
|
||||
update();
|
||||
});
|
||||
const intersections =
|
||||
typeof IntersectionObserver === "undefined"
|
||||
? undefined
|
||||
: new IntersectionObserver((entries) => {
|
||||
for (const { target, isIntersecting } of entries) {
|
||||
const entry = shown.get(target);
|
||||
if (entry !== undefined) entry.visible = isIntersecting;
|
||||
}
|
||||
update();
|
||||
});
|
||||
scope.onEnd(() => {
|
||||
resizes?.disconnect();
|
||||
intersections?.disconnect();
|
||||
});
|
||||
|
||||
return {
|
||||
viewport$: scope.behavior(viewport$),
|
||||
observe: (element): void => {
|
||||
// Visible until the observers say otherwise, so that nothing is paused
|
||||
// before they first report
|
||||
shown.set(element, { visible: true });
|
||||
resizes?.observe(element);
|
||||
intersections?.observe(element);
|
||||
update();
|
||||
},
|
||||
unobserve: (element): void => {
|
||||
shown.delete(element);
|
||||
resizes?.unobserve(element);
|
||||
intersections?.unobserve(element);
|
||||
update();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The RMS level of an audio track, 0 to 1, sampled while subscribed. */
|
||||
function level$(track: MediaStreamTrack): Observable<number> {
|
||||
return new Observable<number>((subscriber) => {
|
||||
levelContext ??= new AudioContext();
|
||||
const ctx = levelContext;
|
||||
const input = ctx.createMediaStreamSource(new MediaStream([track]));
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
input.connect(analyser);
|
||||
const samples = new Float32Array(analyser.fftSize);
|
||||
const sub = timer(0, LEVEL_INTERVAL_MS).subscribe(() => {
|
||||
analyser.getFloatTimeDomainData(samples);
|
||||
let sum = 0;
|
||||
for (const s of samples) sum += s * s;
|
||||
subscriber.next(Math.min(1, Math.sqrt(sum / samples.length) * 4));
|
||||
});
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
input.disconnect();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** The inbound or outbound RTP statistics of one track on a peer connection. */
|
||||
export async function rtpStats(
|
||||
pc: RTCPeerConnection | undefined,
|
||||
track: MediaStreamTrack | null,
|
||||
direction: "inbound-rtp" | "outbound-rtp",
|
||||
): Promise<MediaStreamStats> {
|
||||
if (pc === undefined || track === null) return undefined;
|
||||
const report = await pc.getStats(track);
|
||||
for (const stats of report.values())
|
||||
if ((stats as RTCStats).type === direction)
|
||||
return stats as RTCInboundRtpStreamStats & RTCOutboundRtpStreamStats;
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { MediaConnectionState } from "../api";
|
||||
import {
|
||||
CANDIDATE_BATCH_MS,
|
||||
createPeerConnectionBase,
|
||||
type PeerConnectionBase,
|
||||
} from "./PeerConnection";
|
||||
import { type ControlMessage, CONTROL_CHANNEL_LABEL } from "./ControlChannel";
|
||||
import {
|
||||
type FakeSignalling,
|
||||
fakeSignalling,
|
||||
installWebRtcFakes,
|
||||
lastPeerConnection,
|
||||
peer,
|
||||
settle,
|
||||
} from "./test";
|
||||
|
||||
describe("createPeerConnectionBase", () => {
|
||||
let scope: ObservableScope;
|
||||
let signalling: FakeSignalling;
|
||||
let base: PeerConnectionBase;
|
||||
let received: ControlMessage[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
installWebRtcFakes();
|
||||
scope = new ObservableScope();
|
||||
signalling = fakeSignalling();
|
||||
received = [];
|
||||
base = createPeerConnectionBase({
|
||||
scope,
|
||||
peer,
|
||||
connectionId: "c1",
|
||||
signalling,
|
||||
iceServers: [{ urls: ["turn:example.org"] }],
|
||||
onControlMessage: (message) => received.push(message),
|
||||
logger,
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
scope.end();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("uses the ICE servers it was given", () => {
|
||||
expect(lastPeerConnection().config.iceServers).toEqual([
|
||||
{ urls: ["turn:example.org"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends the candidates gathered within a batch in one message", () => {
|
||||
const pc = lastPeerConnection();
|
||||
pc.gather({ candidate: "a", sdpMid: "0", sdpMLineIndex: 0 });
|
||||
pc.gather({ candidate: "b", sdpMid: "0", sdpMLineIndex: 0 });
|
||||
pc.gather(null);
|
||||
expect(signalling.send).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(CANDIDATE_BATCH_MS);
|
||||
expect(signalling.sent()).toEqual([
|
||||
{
|
||||
type: "candidates",
|
||||
candidates: [
|
||||
{ candidate: "a", sdpMid: "0", sdpMLineIndex: 0 },
|
||||
{ candidate: "b", sdpMid: "0", sdpMLineIndex: 0 },
|
||||
// The end of the gathering
|
||||
{ candidate: "", sdpMid: null, sdpMLineIndex: null },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(signalling.sentConnectionIds()).toEqual(["c1"]);
|
||||
});
|
||||
|
||||
it("drops pending candidates when it closes", () => {
|
||||
lastPeerConnection().gather({ candidate: "a" });
|
||||
base.close("bye", false);
|
||||
vi.advanceTimersByTime(CANDIDATE_BATCH_MS);
|
||||
expect(signalling.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs queued operations in order, past a failing one", async () => {
|
||||
const order: string[] = [];
|
||||
base.enqueue(async () => {
|
||||
order.push("first");
|
||||
return Promise.reject(new Error("failed"));
|
||||
});
|
||||
base.enqueue(async () => {
|
||||
order.push("second");
|
||||
return Promise.resolve();
|
||||
});
|
||||
await settle();
|
||||
expect(order).toEqual(["first", "second"]);
|
||||
});
|
||||
|
||||
it("applies received candidates, ignoring one that fails", async () => {
|
||||
base.addCandidates([{ candidate: "bad" }, { candidate: "good" }]);
|
||||
await settle();
|
||||
expect(lastPeerConnection().addedCandidates).toEqual([
|
||||
{ candidate: "good" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("tells the peer once when it closes", () => {
|
||||
base.close("done", true);
|
||||
base.close("again", true);
|
||||
expect(lastPeerConnection().closed).toBe(true);
|
||||
expect(signalling.sent()).toEqual([{ type: "close", reason: "done" }]);
|
||||
});
|
||||
|
||||
it("doesn't tell a peer that closed first", () => {
|
||||
base.close("theirs", false);
|
||||
expect(lastPeerConnection().closed).toBe(true);
|
||||
expect(signalling.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes when its scope ends", () => {
|
||||
scope.end();
|
||||
expect(lastPeerConnection().closed).toBe(true);
|
||||
expect(signalling.sent()).toEqual([{ type: "close", reason: "ended" }]);
|
||||
});
|
||||
|
||||
it("maps the connection state", () => {
|
||||
const pc = lastPeerConnection();
|
||||
expect(base.state$.value).toBe(MediaConnectionState.Initialized);
|
||||
pc.setConnectionState("connecting");
|
||||
expect(base.state$.value).toBe(MediaConnectionState.Connecting);
|
||||
pc.setConnectionState("connected");
|
||||
expect(base.state$.value).toBe(MediaConnectionState.Connected);
|
||||
pc.setConnectionState("disconnected");
|
||||
expect(base.state$.value).toBe(MediaConnectionState.Reconnecting);
|
||||
pc.setConnectionState("failed");
|
||||
expect(base.state$.value).toBeInstanceOf(Error);
|
||||
pc.setConnectionState("closed");
|
||||
expect(base.state$.value).toBe(MediaConnectionState.Stopped);
|
||||
});
|
||||
|
||||
describe("the control channel", () => {
|
||||
const offer: ControlMessage = {
|
||||
type: "description",
|
||||
description: { type: "offer", sdp: "o" },
|
||||
};
|
||||
const metadata = (muted: boolean): ControlMessage => ({
|
||||
type: "track_metadata",
|
||||
track_metadata: { "0": { purpose: "m.usermedia", muted } },
|
||||
});
|
||||
|
||||
it("is negotiated, with the reserved label and id", () => {
|
||||
base.openControlChannel();
|
||||
base.openControlChannel();
|
||||
const [channel] = lastPeerConnection().dataChannels;
|
||||
expect(lastPeerConnection().dataChannels).toHaveLength(1);
|
||||
expect(channel.label).toBe(CONTROL_CHANNEL_LABEL);
|
||||
expect(channel.init).toEqual({ negotiated: true, id: 0 });
|
||||
});
|
||||
|
||||
it("carries every message once it is open, apart from close", () => {
|
||||
base.openControlChannel();
|
||||
base.send(offer);
|
||||
expect(signalling.sent()).toEqual([offer]);
|
||||
|
||||
const channel = lastPeerConnection().control();
|
||||
channel.open();
|
||||
base.answerReceived();
|
||||
base.send(offer);
|
||||
lastPeerConnection().gather({ candidate: "a" });
|
||||
vi.advanceTimersByTime(CANDIDATE_BATCH_MS);
|
||||
expect(channel.sentMessages()).toEqual([
|
||||
offer,
|
||||
{ type: "candidates", candidates: [{ candidate: "a" }] },
|
||||
]);
|
||||
|
||||
base.close("done", true);
|
||||
expect(signalling.sent()).toEqual([
|
||||
offer,
|
||||
{ type: "close", reason: "done" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("is only switched to once no to-device offer awaits its answer", () => {
|
||||
base.openControlChannel();
|
||||
base.send(offer);
|
||||
base.send(metadata(true));
|
||||
const channel = lastPeerConnection().control();
|
||||
channel.open();
|
||||
// A late to-device offer could otherwise overwrite this metadata
|
||||
lastPeerConnection().gather({ candidate: "a" });
|
||||
vi.advanceTimersByTime(CANDIDATE_BATCH_MS);
|
||||
expect(channel.sentMessages()).toEqual([]);
|
||||
expect(signalling.sent()).toEqual([
|
||||
offer,
|
||||
{ type: "candidates", candidates: [{ candidate: "a" }] },
|
||||
]);
|
||||
|
||||
base.answerReceived();
|
||||
expect(channel.sentMessages()).toEqual([metadata(true)]);
|
||||
});
|
||||
|
||||
it("holds the latest metadata until it opens", () => {
|
||||
base.openControlChannel();
|
||||
base.send(metadata(true));
|
||||
base.send(metadata(false));
|
||||
expect(signalling.send).not.toHaveBeenCalled();
|
||||
|
||||
const channel = lastPeerConnection().control();
|
||||
channel.open();
|
||||
expect(channel.sentMessages()).toEqual([metadata(false)]);
|
||||
});
|
||||
|
||||
it("hands over the messages it receives, ignoring what it doesn't know", () => {
|
||||
base.openControlChannel();
|
||||
const channel = lastPeerConnection().control();
|
||||
channel.open();
|
||||
channel.receive(metadata(true));
|
||||
channel.receive({ type: "future_message" });
|
||||
channel.receive("not json");
|
||||
channel.receive({ type: "description", description: { type: "x" } });
|
||||
expect(received).toEqual([metadata(true)]);
|
||||
});
|
||||
|
||||
it("fails the connection when it closes on its own", () => {
|
||||
base.openControlChannel();
|
||||
const pc = lastPeerConnection();
|
||||
pc.setConnectionState("connected");
|
||||
pc.control().open();
|
||||
pc.control().close();
|
||||
expect(base.state$.value).toEqual(
|
||||
new Error("The control channel closed"),
|
||||
);
|
||||
});
|
||||
|
||||
it("doesn't fail a connection we close", () => {
|
||||
base.openControlChannel();
|
||||
lastPeerConnection().control().open();
|
||||
base.close("done", true);
|
||||
expect(base.state$.value).not.toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/** What the publisher and the subscriber side of a connection share. */
|
||||
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
fromEvent,
|
||||
map,
|
||||
startWith,
|
||||
} from "rxjs";
|
||||
|
||||
import { type Behavior } from "../../reactive/Behavior";
|
||||
import { type ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { MediaConnectionState } from "../api";
|
||||
import {
|
||||
type ControlMessage,
|
||||
createControlChannel,
|
||||
parseControlMessage,
|
||||
} from "./ControlChannel";
|
||||
import { type Peer, type Signalling } from "./signalling";
|
||||
|
||||
/** How long trickled candidates are collected before they are sent in one message. */
|
||||
export const CANDIDATE_BATCH_MS = 200;
|
||||
|
||||
type MetadataMessage = Extract<
|
||||
ControlMessage,
|
||||
{ type: "track_metadata" | "consumption_metadata" }
|
||||
>;
|
||||
|
||||
export interface PeerConnectionBase {
|
||||
readonly connectionId: string;
|
||||
readonly pc: RTCPeerConnection;
|
||||
readonly state$: Behavior<MediaConnectionState | Error>;
|
||||
/** Runs `op` after every operation queued before it, so that SDP and candidates apply in order. */
|
||||
enqueue(op: () => Promise<void>): void;
|
||||
/**
|
||||
* Creates our side of the control channel. The publisher does so before
|
||||
* its first offer, the subscriber once it applied that offer.
|
||||
*/
|
||||
openControlChannel(): void;
|
||||
/**
|
||||
* Sends a message to the peer: over the control channel once we switched
|
||||
* to it, before that as a to-device message. Metadata has no to-device
|
||||
* message of its own: the latest waits for the switch.
|
||||
*/
|
||||
send(message: ControlMessage): void;
|
||||
/**
|
||||
* Tells that the answer to our latest offer arrived. We switch to the
|
||||
* control channel only once no to-device offer is waiting for its answer,
|
||||
* so that a late offer can't overwrite newer metadata.
|
||||
*/
|
||||
answerReceived(): void;
|
||||
/** Applies a received batch of candidates. */
|
||||
addCandidates(candidates: RTCIceCandidateInit[]): void;
|
||||
/** Closes the peer connection, telling the peer unless it closed first. */
|
||||
close(reason: string | undefined, notifyPeer: boolean): void;
|
||||
}
|
||||
|
||||
export function createPeerConnectionBase({
|
||||
scope,
|
||||
peer,
|
||||
connectionId,
|
||||
signalling,
|
||||
iceServers,
|
||||
onControlMessage,
|
||||
logger,
|
||||
}: {
|
||||
scope: ObservableScope;
|
||||
peer: Peer;
|
||||
connectionId: string;
|
||||
signalling: Signalling;
|
||||
iceServers: RTCIceServer[];
|
||||
/** A message the peer sent over the control channel. */
|
||||
onControlMessage: (message: ControlMessage) => void;
|
||||
logger: Logger;
|
||||
}): PeerConnectionBase {
|
||||
const pc = new RTCPeerConnection({ iceServers });
|
||||
const closed$ = new BehaviorSubject(false);
|
||||
const controlFailed$ = new BehaviorSubject(false);
|
||||
|
||||
let queue = Promise.resolve();
|
||||
const enqueue = (op: () => Promise<void>): void => {
|
||||
queue = queue.then(op).catch((e: unknown) => {
|
||||
logger.warn("Signalling operation failed", e);
|
||||
});
|
||||
};
|
||||
|
||||
let control: RTCDataChannel | null = null;
|
||||
let switched = false;
|
||||
let awaitingAnswer = false;
|
||||
const pendingMetadata = new Map<MetadataMessage["type"], MetadataMessage>();
|
||||
const trySwitch = (): void => {
|
||||
if (switched || awaitingAnswer || control?.readyState !== "open") return;
|
||||
switched = true;
|
||||
logger.debug("Switched to the control channel");
|
||||
for (const message of pendingMetadata.values())
|
||||
control.send(JSON.stringify(message));
|
||||
pendingMetadata.clear();
|
||||
};
|
||||
const openControlChannel = (): void => {
|
||||
if (control !== null) return;
|
||||
const channel = createControlChannel(pc);
|
||||
control = channel;
|
||||
channel.addEventListener("open", trySwitch);
|
||||
channel.addEventListener("message", ({ data }: MessageEvent) => {
|
||||
const message = parseControlMessage(data);
|
||||
if (message === null) logger.debug("Ignoring a control message", data);
|
||||
else onControlMessage(message);
|
||||
});
|
||||
// Ours to close only with the connection: closing otherwise is a failure
|
||||
channel.addEventListener("close", () => {
|
||||
if (!closed$.value) controlFailed$.next(true);
|
||||
});
|
||||
};
|
||||
|
||||
const send = (message: ControlMessage): void => {
|
||||
if (closed$.value) return;
|
||||
if (switched) control!.send(JSON.stringify(message));
|
||||
else if (
|
||||
message.type === "track_metadata" ||
|
||||
message.type === "consumption_metadata"
|
||||
)
|
||||
pendingMetadata.set(message.type, message);
|
||||
else {
|
||||
if (
|
||||
message.type === "description" &&
|
||||
message.description.type === "offer"
|
||||
)
|
||||
awaitingAnswer = true;
|
||||
signalling.send(peer, connectionId, message);
|
||||
}
|
||||
};
|
||||
|
||||
const answerReceived = (): void => {
|
||||
awaitingAnswer = false;
|
||||
trySwitch();
|
||||
};
|
||||
|
||||
// Trickled, in batches
|
||||
let pending: RTCIceCandidateInit[] = [];
|
||||
let batchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const flushCandidates = (): void => {
|
||||
batchTimer = undefined;
|
||||
if (pending.length === 0) return;
|
||||
send({ type: "candidates", candidates: pending });
|
||||
pending = [];
|
||||
};
|
||||
pc.addEventListener("icecandidate", ({ candidate }) => {
|
||||
// A null candidate ends the gathering: an empty one says so on the wire
|
||||
pending.push(
|
||||
candidate
|
||||
? candidate.toJSON()
|
||||
: { candidate: "", sdpMid: null, sdpMLineIndex: null },
|
||||
);
|
||||
batchTimer ??= setTimeout(flushCandidates, CANDIDATE_BATCH_MS);
|
||||
});
|
||||
|
||||
const addCandidates: PeerConnectionBase["addCandidates"] = (candidates) =>
|
||||
enqueue(async () => {
|
||||
for (const candidate of candidates)
|
||||
await pc
|
||||
.addIceCandidate(candidate)
|
||||
// A candidate of an outdated description, or a bad one: not fatal
|
||||
.catch((e: unknown) => logger.debug("Ignored a candidate", e));
|
||||
});
|
||||
|
||||
const close: PeerConnectionBase["close"] = (reason, notifyPeer) => {
|
||||
if (closed$.value) return;
|
||||
clearTimeout(batchTimer);
|
||||
// Always to-device: closing the peer connection would race a channel message
|
||||
if (notifyPeer)
|
||||
signalling.send(peer, connectionId, { type: "close", reason });
|
||||
closed$.next(true);
|
||||
pc.close();
|
||||
};
|
||||
scope.onEnd(() => close("ended", true));
|
||||
|
||||
const state$ = scope.behavior(
|
||||
combineLatest([
|
||||
fromEvent(pc, "connectionstatechange").pipe(startWith(null)),
|
||||
controlFailed$,
|
||||
]).pipe(
|
||||
map(([, controlFailed]) =>
|
||||
controlFailed && pc.connectionState !== "closed"
|
||||
? new Error("The control channel closed")
|
||||
: toState(pc.connectionState),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
connectionId,
|
||||
pc,
|
||||
state$,
|
||||
enqueue,
|
||||
openControlChannel,
|
||||
send,
|
||||
answerReceived,
|
||||
addCandidates,
|
||||
close,
|
||||
};
|
||||
}
|
||||
|
||||
function toState(state: RTCPeerConnectionState): MediaConnectionState | Error {
|
||||
switch (state) {
|
||||
case "new":
|
||||
return MediaConnectionState.Initialized;
|
||||
case "connecting":
|
||||
return MediaConnectionState.Connecting;
|
||||
case "connected":
|
||||
return MediaConnectionState.Connected;
|
||||
case "disconnected":
|
||||
return MediaConnectionState.Reconnecting;
|
||||
case "failed":
|
||||
return new Error("The peer connection failed");
|
||||
case "closed":
|
||||
return MediaConnectionState.Stopped;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { CONTROL_CHANNEL_LABEL } from "./ControlChannel";
|
||||
import { type FullMeshLocalMedia } from "./LocalMedia";
|
||||
import { DATA_CHANNEL_LABEL, DATA_PROTOCOL } from "./metadata";
|
||||
import {
|
||||
createPublisherConnection,
|
||||
type PublisherConnection,
|
||||
} from "./PublisherConnection";
|
||||
import {
|
||||
type FakeSignalling,
|
||||
fakeLocalMedia,
|
||||
fakePublication,
|
||||
fakeSignalling,
|
||||
fakeTrack,
|
||||
fromPeer,
|
||||
type PeerMessage,
|
||||
installWebRtcFakes,
|
||||
lastPeerConnection,
|
||||
peer,
|
||||
settle,
|
||||
} from "./test";
|
||||
|
||||
describe("createPublisherConnection", () => {
|
||||
let scope: ObservableScope;
|
||||
let signalling: FakeSignalling;
|
||||
let local: ReturnType<typeof fakeLocalMedia>;
|
||||
|
||||
function create(): PublisherConnection {
|
||||
return createPublisherConnection({
|
||||
scope,
|
||||
peer,
|
||||
connectionId: "c1",
|
||||
signalling,
|
||||
iceServers: [],
|
||||
local: local as unknown as FullMeshLocalMedia,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
const answer = (): PeerMessage =>
|
||||
fromPeer("c1", {
|
||||
type: "description",
|
||||
description: { type: "answer", sdp: "answer" },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
installWebRtcFakes();
|
||||
scope = new ObservableScope();
|
||||
signalling = fakeSignalling();
|
||||
local = fakeLocalMedia();
|
||||
});
|
||||
afterEach(() => {
|
||||
scope.end();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("offers right away, even without tracks", async () => {
|
||||
create();
|
||||
await settle();
|
||||
expect(signalling.sent("description")).toEqual([
|
||||
{
|
||||
type: "description",
|
||||
description: { type: "offer", sdp: "offer-1" },
|
||||
track_metadata: {},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("describes each track by the mid it gets with the offer", async () => {
|
||||
const mic = fakePublication("audio");
|
||||
const camera = fakePublication("video", { stream: mic.stream });
|
||||
const screen = fakePublication("video", { source: "screenShare" });
|
||||
camera.muted$.next(true);
|
||||
local.publications$.next([mic, camera, screen]);
|
||||
create();
|
||||
await settle();
|
||||
expect(signalling.sent("description")).toEqual([
|
||||
expect.objectContaining({
|
||||
track_metadata: {
|
||||
"0": { purpose: "m.usermedia", muted: false },
|
||||
"1": { purpose: "m.usermedia", muted: true },
|
||||
"2": { purpose: "m.screenshare", muted: false },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates the control channel and the data channel before its first offer", () => {
|
||||
create();
|
||||
const [control, data] = lastPeerConnection().dataChannels;
|
||||
expect(control.label).toBe(CONTROL_CHANNEL_LABEL);
|
||||
expect(data).toMatchObject({
|
||||
label: DATA_CHANNEL_LABEL,
|
||||
protocol: DATA_PROTOCOL,
|
||||
});
|
||||
expect(lastPeerConnection().offers).toBe(0);
|
||||
});
|
||||
|
||||
it("sends each publication on a sendonly transceiver in its stream", async () => {
|
||||
const mic = fakePublication("audio");
|
||||
const camera = fakePublication("video", { stream: mic.stream });
|
||||
local.publications$.next([mic, camera]);
|
||||
create();
|
||||
await settle();
|
||||
|
||||
const [a, v] = lastPeerConnection().transceivers;
|
||||
expect(a).toMatchObject({ kind: "audio", direction: "sendonly" });
|
||||
expect(a.streams).toEqual([mic.stream]);
|
||||
expect(a.sender.track).toBe(mic.track$.value);
|
||||
expect(v).toMatchObject({ kind: "video", direction: "sendonly" });
|
||||
expect(v.sender.track).toBe(camera.track$.value);
|
||||
// One offer for both
|
||||
expect(signalling.sent("description")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("follows a track change without renegotiating", async () => {
|
||||
const camera = fakePublication("video");
|
||||
local.publications$.next([camera]);
|
||||
create();
|
||||
await settle();
|
||||
const next = fakeTrack("video");
|
||||
camera.track$.next(next);
|
||||
expect(lastPeerConnection().transceivers[0].sender.track).toBe(next);
|
||||
expect(signalling.sent("description")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("sends nothing while not publishing", async () => {
|
||||
const camera = fakePublication("video");
|
||||
local.publications$.next([camera]);
|
||||
local.publishing$.next(false);
|
||||
create();
|
||||
await settle();
|
||||
const { sender } = lastPeerConnection().transceivers[0];
|
||||
expect(sender.track).toBeNull();
|
||||
|
||||
local.publishing$.next(true);
|
||||
expect(sender.track).toBe(camera.track$.value);
|
||||
});
|
||||
|
||||
it("holds changes while an offer is unanswered, then offers once for all", async () => {
|
||||
const connection = create();
|
||||
const pc = lastPeerConnection();
|
||||
await settle();
|
||||
expect(pc.offers).toBe(1);
|
||||
|
||||
local.publications$.next([fakePublication("audio")]);
|
||||
local.publications$.next([
|
||||
...local.publications$.value,
|
||||
fakePublication("video"),
|
||||
]);
|
||||
await settle();
|
||||
expect(pc.offers).toBe(1);
|
||||
|
||||
connection.handle(answer());
|
||||
await settle();
|
||||
expect(pc.offers).toBe(2);
|
||||
expect(pc.transceivers).toHaveLength(2);
|
||||
|
||||
// Nothing changed since: the next answer doesn't lead to another offer
|
||||
connection.handle(answer());
|
||||
await settle();
|
||||
expect(pc.offers).toBe(2);
|
||||
});
|
||||
|
||||
it("stops the transceiver of a removed publication and renegotiates", async () => {
|
||||
const camera = fakePublication("video");
|
||||
local.publications$.next([camera]);
|
||||
const connection = create();
|
||||
await settle();
|
||||
connection.handle(answer());
|
||||
await settle();
|
||||
|
||||
local.publications$.next([]);
|
||||
await settle();
|
||||
const pc = lastPeerConnection();
|
||||
expect(pc.transceivers[0].stopped).toBe(true);
|
||||
expect(pc.offers).toBe(2);
|
||||
});
|
||||
|
||||
it("signals mute changes over the control channel without renegotiating", async () => {
|
||||
const mic = fakePublication("audio");
|
||||
local.publications$.next([mic]);
|
||||
const connection = create();
|
||||
await settle();
|
||||
connection.handle(answer());
|
||||
await settle();
|
||||
const control = lastPeerConnection().control();
|
||||
control.open();
|
||||
mic.muted$.next(true);
|
||||
expect(control.sentMessages()).toEqual([
|
||||
{
|
||||
type: "track_metadata",
|
||||
track_metadata: { "0": { purpose: "m.usermedia", muted: true } },
|
||||
},
|
||||
]);
|
||||
expect(lastPeerConnection().offers).toBe(1);
|
||||
});
|
||||
|
||||
it("holds a mute change until its to-device offer is answered", async () => {
|
||||
const mic = fakePublication("audio");
|
||||
local.publications$.next([mic]);
|
||||
const connection = create();
|
||||
await settle();
|
||||
const control = lastPeerConnection().control();
|
||||
control.open();
|
||||
mic.muted$.next(true);
|
||||
expect(control.sentMessages()).toEqual([]);
|
||||
|
||||
connection.handle(answer());
|
||||
await settle();
|
||||
expect(control.sentMessages()).toEqual([
|
||||
{
|
||||
type: "track_metadata",
|
||||
track_metadata: { "0": { purpose: "m.usermedia", muted: true } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("renegotiates over the control channel once it is open", async () => {
|
||||
const connection = create();
|
||||
await settle();
|
||||
connection.handle(answer());
|
||||
await settle();
|
||||
const control = lastPeerConnection().control();
|
||||
control.open();
|
||||
|
||||
local.publications$.next([fakePublication("audio")]);
|
||||
await settle();
|
||||
expect(control.sentMessages()).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "description",
|
||||
description: { type: "offer", sdp: "offer-2" },
|
||||
}),
|
||||
);
|
||||
expect(signalling.sent("description")).toHaveLength(1);
|
||||
|
||||
// The answer comes back the same way
|
||||
control.receive({
|
||||
type: "description",
|
||||
description: { type: "answer", sdp: "answer-2" },
|
||||
});
|
||||
await settle();
|
||||
expect(lastPeerConnection().remoteDescription).toEqual({
|
||||
type: "answer",
|
||||
sdp: "answer-2",
|
||||
});
|
||||
});
|
||||
|
||||
describe("consumption metadata", () => {
|
||||
/** A connection sending one camera track, with its control channel open. */
|
||||
async function sendingCamera(settings: MediaTrackSettings = {}): Promise<{
|
||||
camera: ReturnType<typeof fakePublication>;
|
||||
consume: (metadata: unknown) => Promise<void>;
|
||||
}> {
|
||||
const camera = fakePublication("video");
|
||||
camera.track$.next(fakeTrack("video", settings));
|
||||
local.publications$.next([camera]);
|
||||
const connection = create();
|
||||
await settle();
|
||||
connection.handle(answer());
|
||||
await settle();
|
||||
const control = lastPeerConnection().control();
|
||||
control.open();
|
||||
return {
|
||||
camera,
|
||||
consume: async (metadata): Promise<void> => {
|
||||
control.receive({
|
||||
type: "consumption_metadata",
|
||||
consumption_metadata: metadata,
|
||||
});
|
||||
await settle();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("stops sending a track that isn't in use, without renegotiating", async () => {
|
||||
const { camera, consume } = await sendingCamera();
|
||||
const { sender } = lastPeerConnection().transceivers[0];
|
||||
await consume({ "0": { in_use: false } });
|
||||
expect(sender.track).toBeNull();
|
||||
|
||||
await consume({});
|
||||
expect(sender.track).toBe(camera.track$.value);
|
||||
expect(lastPeerConnection().offers).toBe(1);
|
||||
});
|
||||
|
||||
it("scales the video down to the largest size shown", async () => {
|
||||
const { consume } = await sendingCamera({ width: 1280, height: 720 });
|
||||
const { sender } = lastPeerConnection().transceivers[0];
|
||||
await consume({ "0": { max_width: 320, max_height: 320 } });
|
||||
expect(sender.parameters.encodings).toEqual([
|
||||
{ scaleResolutionDownBy: 4 },
|
||||
]);
|
||||
|
||||
// Larger than the camera: sent as it is
|
||||
await consume({ "0": { max_width: 4000, max_height: 4000 } });
|
||||
expect(sender.parameters.encodings).toEqual([
|
||||
{ scaleResolutionDownBy: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("sends data once the data channel is open", () => {
|
||||
const connection = create();
|
||||
expect(connection.sendData("early")).toBe(false);
|
||||
const data = lastPeerConnection().dataChannels[1];
|
||||
data.open();
|
||||
expect(connection.sendData("hello")).toBe(true);
|
||||
expect(data.sent).toEqual(["hello"]);
|
||||
});
|
||||
|
||||
it("closes, telling the subscriber, when the control channel fails", async () => {
|
||||
create();
|
||||
await settle();
|
||||
const pc = lastPeerConnection();
|
||||
pc.control().open();
|
||||
pc.control().close();
|
||||
expect(pc.closed).toBe(true);
|
||||
expect(signalling.sent("close")).toEqual([
|
||||
{ type: "close", reason: "failed" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies the subscriber's candidates", async () => {
|
||||
const connection = create();
|
||||
await settle();
|
||||
connection.handle(answer());
|
||||
connection.handle(
|
||||
fromPeer("c1", {
|
||||
type: "candidates",
|
||||
candidates: [{ candidate: "x" }],
|
||||
}),
|
||||
);
|
||||
await settle();
|
||||
expect(lastPeerConnection().addedCandidates).toEqual([{ candidate: "x" }]);
|
||||
});
|
||||
|
||||
it("ends without answering when the subscriber closes", async () => {
|
||||
const connection = create();
|
||||
await settle();
|
||||
connection.handle(fromPeer("c1", { type: "close", reason: "bye" }));
|
||||
const pc = lastPeerConnection();
|
||||
expect(pc.closed).toBe(true);
|
||||
expect(signalling.sent("close")).toEqual([]);
|
||||
// Its scope ended: no more offers go out
|
||||
local.publications$.next([fakePublication("audio")]);
|
||||
await settle();
|
||||
expect(pc.offers).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
/*
|
||||
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 {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
map,
|
||||
merge,
|
||||
type Observable,
|
||||
skip,
|
||||
type Subscription,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { type ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { type FullMeshLocalMedia, type LocalPublication } from "./LocalMedia";
|
||||
import {
|
||||
type ConsumptionMetadata,
|
||||
type ConsumptionMetadataEntry,
|
||||
DATA_CHANNEL_LABEL,
|
||||
DATA_PROTOCOL,
|
||||
type MediaPurpose,
|
||||
type TrackMetadata,
|
||||
} from "./metadata";
|
||||
import {
|
||||
createPeerConnectionBase,
|
||||
type PeerConnectionBase,
|
||||
} from "./PeerConnection";
|
||||
import {
|
||||
type ConnectionMessage,
|
||||
type Peer,
|
||||
type Signalling,
|
||||
} from "./signalling";
|
||||
|
||||
export interface PublisherConnection extends PeerConnectionBase {
|
||||
/** A to-device message of this connection from the subscriber. */
|
||||
handle(message: ConnectionMessage): void;
|
||||
/** Sends one message on the data channel; false while it isn't open. */
|
||||
sendData(message: string): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Our side of a connection a subscriber asked for: we only send, and we are
|
||||
* the only side that makes offers, so there is no glare.
|
||||
*/
|
||||
export function createPublisherConnection({
|
||||
scope,
|
||||
peer,
|
||||
connectionId,
|
||||
signalling,
|
||||
iceServers,
|
||||
local,
|
||||
logger: parentLogger,
|
||||
}: {
|
||||
scope: ObservableScope;
|
||||
peer: Peer;
|
||||
connectionId: string;
|
||||
signalling: Signalling;
|
||||
iceServers: RTCIceServer[];
|
||||
local: FullMeshLocalMedia;
|
||||
logger: Logger;
|
||||
}): PublisherConnection {
|
||||
const logger = parentLogger.getChild(
|
||||
`[Publisher → ${peer.memberId} ${connectionId}]`,
|
||||
);
|
||||
const base = createPeerConnectionBase({
|
||||
scope,
|
||||
peer,
|
||||
connectionId,
|
||||
signalling,
|
||||
iceServers,
|
||||
onControlMessage: (message) => handle(message),
|
||||
logger,
|
||||
});
|
||||
const { pc, enqueue } = base;
|
||||
const consumption$ = new BehaviorSubject<ConsumptionMetadata>({});
|
||||
|
||||
// Both before the first offer, which so has the connection's data section
|
||||
base.openControlChannel();
|
||||
const data = pc.createDataChannel(DATA_CHANNEL_LABEL, {
|
||||
protocol: DATA_PROTOCOL,
|
||||
});
|
||||
|
||||
// One sendonly transceiver per publication, for as long as it exists
|
||||
const transceivers = new Map<
|
||||
LocalPublication,
|
||||
{ transceiver: RTCRtpTransceiver; tracks: Subscription }
|
||||
>();
|
||||
|
||||
/** The metadata of every track with a `mid`, which it gets with the offer that adds it. */
|
||||
const trackMetadata = (): TrackMetadata => {
|
||||
const metadata: TrackMetadata = {};
|
||||
for (const [publication, { transceiver }] of transceivers)
|
||||
if (transceiver.mid !== null)
|
||||
metadata[transceiver.mid] = {
|
||||
purpose: purposeOf(publication),
|
||||
muted: publication.mediaTrack.muted$.value,
|
||||
};
|
||||
return metadata;
|
||||
};
|
||||
|
||||
// A new offer has to wait while one is unanswered; changes made meanwhile
|
||||
// go out together in the next one
|
||||
let offerPending = false;
|
||||
let renegotiate = false;
|
||||
const negotiate = (): void => {
|
||||
if (offerPending) {
|
||||
renegotiate = true;
|
||||
return;
|
||||
}
|
||||
offerPending = true;
|
||||
renegotiate = false;
|
||||
enqueue(async () => {
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
base.send({
|
||||
type: "description",
|
||||
description: { type: "offer", sdp: pc.localDescription!.sdp },
|
||||
track_metadata: trackMetadata(),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
scope.onEnd(() => {
|
||||
for (const { tracks } of transceivers.values()) tracks.unsubscribe();
|
||||
});
|
||||
local.publications$.pipe(scope.bind()).subscribe((publications) => {
|
||||
let changed = false;
|
||||
for (const [publication, { transceiver, tracks }] of transceivers)
|
||||
if (!publications.includes(publication)) {
|
||||
tracks.unsubscribe();
|
||||
if (pc.signalingState !== "closed") transceiver.stop();
|
||||
transceivers.delete(publication);
|
||||
changed = true;
|
||||
}
|
||||
for (const publication of publications)
|
||||
if (!transceivers.has(publication)) {
|
||||
const transceiver = pc.addTransceiver(publication.kind, {
|
||||
direction: "sendonly",
|
||||
streams: [publication.stream],
|
||||
});
|
||||
const tracks = sendPublication(transceiver, publication);
|
||||
transceivers.set(publication, { transceiver, tracks });
|
||||
changed = true;
|
||||
}
|
||||
if (changed) negotiate();
|
||||
});
|
||||
// Even without tracks: the subscriber learns that we are there
|
||||
if (transceivers.size === 0) negotiate();
|
||||
|
||||
/**
|
||||
* Sends the track of a publication as the subscriber wants it. None while
|
||||
* we don't publish or it isn't in use: the local preview stays live, and
|
||||
* turning it back on needs no renegotiation.
|
||||
*/
|
||||
function sendPublication(
|
||||
transceiver: RTCRtpTransceiver,
|
||||
publication: LocalPublication,
|
||||
): Subscription {
|
||||
const { sender } = transceiver;
|
||||
return combineLatest([
|
||||
publication.track$,
|
||||
local.publishing$,
|
||||
consumptionOf(transceiver),
|
||||
]).subscribe(([track, publishing, consumption]) => {
|
||||
const inUse = consumption.in_use ?? true;
|
||||
void sender
|
||||
.replaceTrack(publishing && inUse ? track : null)
|
||||
.then(async () => {
|
||||
if (publication.kind === "video" && track !== null)
|
||||
await constrainVideo(sender, track, consumption);
|
||||
})
|
||||
.catch((e: unknown) => logger.warn("Could not send a track", e));
|
||||
});
|
||||
}
|
||||
|
||||
// The mid is only set with the local description
|
||||
function consumptionOf(
|
||||
transceiver: RTCRtpTransceiver,
|
||||
): Observable<ConsumptionMetadataEntry> {
|
||||
return consumption$.pipe(
|
||||
map((c) => (transceiver.mid === null ? {} : (c[transceiver.mid] ?? {}))),
|
||||
distinctUntilChanged(
|
||||
(a, b) =>
|
||||
a.in_use === b.in_use &&
|
||||
a.max_width === b.max_width &&
|
||||
a.max_height === b.max_height,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Mute changes without renegotiating; an offer carries the state at its time
|
||||
local.publications$
|
||||
.pipe(
|
||||
switchMap((ps) =>
|
||||
merge(...ps.map((p) => p.mediaTrack.muted$.pipe(skip(1)))),
|
||||
),
|
||||
scope.bind(),
|
||||
)
|
||||
.subscribe(() =>
|
||||
base.send({ type: "track_metadata", track_metadata: trackMetadata() }),
|
||||
);
|
||||
|
||||
base.state$
|
||||
.pipe(
|
||||
filter((s) => s instanceof Error),
|
||||
scope.bind(),
|
||||
)
|
||||
.subscribe((e) => {
|
||||
// The subscriber retries; a publisher doesn't
|
||||
logger.info(`Connection failed: ${e.message}`);
|
||||
base.close("failed", true);
|
||||
scope.end();
|
||||
});
|
||||
|
||||
function handle(message: ConnectionMessage): void {
|
||||
switch (message.type) {
|
||||
case "description":
|
||||
if (message.description.type !== "answer") return;
|
||||
enqueue(async () => {
|
||||
await pc.setRemoteDescription(message.description);
|
||||
base.answerReceived();
|
||||
offerPending = false;
|
||||
if (renegotiate) negotiate();
|
||||
});
|
||||
break;
|
||||
case "candidates":
|
||||
base.addCandidates(message.candidates);
|
||||
break;
|
||||
case "consumption_metadata":
|
||||
consumption$.next(message.consumption_metadata);
|
||||
break;
|
||||
case "close":
|
||||
logger.info(`Closed by the subscriber: ${message.reason}`);
|
||||
base.close(message.reason, false);
|
||||
scope.end();
|
||||
break;
|
||||
default:
|
||||
logger.debug(`Ignoring ${message.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
const sendData = (message: string): boolean => {
|
||||
if (data.readyState !== "open") return false;
|
||||
data.send(message);
|
||||
return true;
|
||||
};
|
||||
|
||||
return { ...base, handle, sendData };
|
||||
}
|
||||
|
||||
function purposeOf({ source }: LocalPublication): MediaPurpose {
|
||||
return source === "microphone" || source === "camera"
|
||||
? "m.usermedia"
|
||||
: "m.screenshare";
|
||||
}
|
||||
|
||||
/** Scales the video down to the size the subscriber shows it at. */
|
||||
async function constrainVideo(
|
||||
sender: RTCRtpSender,
|
||||
track: MediaStreamTrack,
|
||||
{ max_width, max_height }: ConsumptionMetadataEntry,
|
||||
): Promise<void> {
|
||||
const parameters = sender.getParameters();
|
||||
const [encoding] = parameters.encodings ?? [];
|
||||
if (encoding === undefined) return;
|
||||
const { width, height } = track.getSettings();
|
||||
encoding.scaleResolutionDownBy = Math.max(
|
||||
1,
|
||||
width && max_width ? width / max_width : 1,
|
||||
height && max_height ? height / max_height : 1,
|
||||
);
|
||||
await sender.setParameters(parameters);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Full mesh media backend
|
||||
|
||||
The MatrixRTC full mesh transport, as specified in
|
||||
[MSC4575: Full mesh transport for MatrixRTC](https://github.com/matrix-org/matrix-spec-proposals/pull/4575).
|
||||
|
||||
Every subscriber opens a WebRTC connection to every publisher it receives from. To-device messages set
|
||||
the connection up; after that, the connection's control channel carries all signalling.
|
||||
|
||||
The MSC is not merged, so all identifiers use its unstable prefix `org.matrix.msc4575`.
|
||||
|
||||
| File | What it implements |
|
||||
| ------------------------- | ------------------------------------------------------------------- |
|
||||
| `FullMeshBackend.ts` | The `MediaBackend`: subscribing to members, accepting subscribers |
|
||||
| `signalling.ts` | The to-device messages and their ordering by `seq` |
|
||||
| `ControlChannel.ts` | The control channel and its messages |
|
||||
| `PeerConnection.ts` | What both sides of a connection share, including the channel switch |
|
||||
| `PublisherConnection.ts` | Sending our tracks and the data channel to one subscriber |
|
||||
| `SubscriberConnection.ts` | Receiving one publisher's tracks, with retries |
|
||||
| `metadata.ts` | Track and consumption metadata, and their validation |
|
||||
| `LocalMedia.ts` | Capturing the local tracks every publisher connection sends |
|
||||
| `MediaTrack.ts` | A WebRTC track as the SDK's `MediaTrack` |
|
||||
@@ -0,0 +1,307 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { constant } from "../../reactive/Behavior";
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import {
|
||||
DATA_CHANNEL_LABEL,
|
||||
DATA_PROTOCOL,
|
||||
type TrackMetadata,
|
||||
} from "./metadata";
|
||||
import {
|
||||
createSubscriberConnection,
|
||||
type SubscriberConnection,
|
||||
} from "./SubscriberConnection";
|
||||
import {
|
||||
type FakeSignalling,
|
||||
fakeSignalling,
|
||||
fromPeer,
|
||||
type PeerMessage,
|
||||
installWebRtcFakes,
|
||||
lastPeerConnection,
|
||||
peer,
|
||||
settle,
|
||||
} from "./test";
|
||||
|
||||
describe("createSubscriberConnection", () => {
|
||||
let scope: ObservableScope;
|
||||
let signalling: FakeSignalling;
|
||||
let subscriber: SubscriberConnection;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
installWebRtcFakes();
|
||||
scope = new ObservableScope();
|
||||
signalling = fakeSignalling();
|
||||
subscriber = createSubscriberConnection({
|
||||
scope,
|
||||
peer,
|
||||
signalling,
|
||||
iceServers: async () => Promise.resolve([{ urls: ["turn:x"] }]),
|
||||
audioOutputDeviceId$: constant(undefined),
|
||||
logger,
|
||||
});
|
||||
await settle();
|
||||
});
|
||||
afterEach(() => {
|
||||
scope.end();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("asks the publisher for its media on a fresh connection", () => {
|
||||
expect(signalling.sent()).toEqual([{ type: "subscribe" }]);
|
||||
expect(signalling.sentConnectionIds()).toEqual([current()]);
|
||||
expect(lastPeerConnection().config.iceServers).toEqual([
|
||||
{ urls: ["turn:x"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("answers an offer and shows the tracks it brings", async () => {
|
||||
expect(subscriber.tracks$.value).toBeNull();
|
||||
const pc = lastPeerConnection();
|
||||
pc.receive("video", "1");
|
||||
pc.receive("video", "2");
|
||||
|
||||
subscriber.handle(
|
||||
offer(current(), {
|
||||
"1": { purpose: "m.usermedia", muted: true },
|
||||
"2": { purpose: "m.screenshare" },
|
||||
}),
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(pc.remoteDescription).toEqual({ type: "offer", sdp: "offer" });
|
||||
// How it shows them goes over the control channel once it is open
|
||||
expect(signalling.sent("description")).toEqual([
|
||||
{ type: "description", description: { type: "answer", sdp: "answer" } },
|
||||
]);
|
||||
const tracks = subscriber.tracks$.value!;
|
||||
expect(tracks.map((t) => [t.source, t.kind, t.muted$.value])).toEqual([
|
||||
["camera", "video", true],
|
||||
["screenShare", "video", false],
|
||||
]);
|
||||
});
|
||||
|
||||
it("names audio by its purpose", async () => {
|
||||
const pc = lastPeerConnection();
|
||||
pc.receive("audio", "0");
|
||||
pc.receive("audio", "3");
|
||||
subscriber.handle(
|
||||
offer(current(), {
|
||||
"0": { purpose: "m.usermedia" },
|
||||
"3": { purpose: "m.screenshare" },
|
||||
}),
|
||||
);
|
||||
await settle();
|
||||
expect(subscriber.tracks$.value!.map((t) => t.source)).toEqual([
|
||||
"microphone",
|
||||
"screenShareAudio",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves out tracks without metadata or with an unknown purpose", async () => {
|
||||
const pc = lastPeerConnection();
|
||||
pc.receive("video", "0");
|
||||
pc.receive("video", "1");
|
||||
subscriber.handle(offer(current(), { "1": { purpose: "m.whiteboard" } }));
|
||||
await settle();
|
||||
expect(subscriber.tracks$.value).toEqual([]);
|
||||
});
|
||||
|
||||
it("follows mute changes, keeping the same track", async () => {
|
||||
lastPeerConnection().receive("video", "1");
|
||||
subscriber.handle(offer(current(), { "1": { purpose: "m.usermedia" } }));
|
||||
await settle();
|
||||
const [track] = subscriber.tracks$.value!;
|
||||
expect(track.muted$.value).toBe(false);
|
||||
|
||||
lastPeerConnection()
|
||||
.control()
|
||||
.receive({
|
||||
type: "track_metadata",
|
||||
track_metadata: { "1": { purpose: "m.usermedia", muted: true } },
|
||||
});
|
||||
await settle();
|
||||
expect(subscriber.tracks$.value).toEqual([track]);
|
||||
expect(track.muted$.value).toBe(true);
|
||||
});
|
||||
|
||||
it("drops a track whose transceiver no longer receives", async () => {
|
||||
const pc = lastPeerConnection();
|
||||
const transceiver = pc.receive("video", "1");
|
||||
subscriber.handle(offer(current(), { "1": { purpose: "m.usermedia" } }));
|
||||
await settle();
|
||||
expect(subscriber.tracks$.value).toHaveLength(1);
|
||||
|
||||
transceiver.currentDirection = "inactive";
|
||||
subscriber.handle(offer(current(), { "1": { purpose: "m.usermedia" } }));
|
||||
await settle();
|
||||
expect(subscriber.tracks$.value).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores messages of another connection", async () => {
|
||||
subscriber.handle(offer("other", {}));
|
||||
await settle();
|
||||
expect(lastPeerConnection().remoteDescription).toBeNull();
|
||||
});
|
||||
|
||||
it("subscribes anew after the publisher closes, backing off", async () => {
|
||||
const first = current();
|
||||
subscriber.handle(fromPeer(first, { type: "close", reason: "nope" }));
|
||||
expect(subscriber.current$.value).toBeNull();
|
||||
expect(subscriber.tracks$.value).toBeNull();
|
||||
// It doesn't echo the publisher's close
|
||||
expect(signalling.sent("close")).toEqual([]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
const second = current();
|
||||
expect(second).not.toBe(first);
|
||||
expect(signalling.sentConnectionIds("subscribe")).toEqual([first, second]);
|
||||
|
||||
// A failed connection is closed and retried after twice the wait
|
||||
lastPeerConnection().setConnectionState("failed");
|
||||
expect(signalling.sent("close")).toEqual([
|
||||
{ type: "close", reason: "failed" },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(signalling.sent("subscribe")).toHaveLength(2);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(signalling.sent("subscribe")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("stops retrying once its scope ends", async () => {
|
||||
subscriber.handle(fromPeer(current(), { type: "close", reason: "nope" }));
|
||||
scope.end();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(signalling.sent("subscribe")).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe("the control channel", () => {
|
||||
it("is created once the offer is applied", async () => {
|
||||
const pc = lastPeerConnection();
|
||||
expect(pc.dataChannels).toEqual([]);
|
||||
subscriber.handle(offer(current(), {}));
|
||||
await settle();
|
||||
expect(pc.control().init).toEqual({ negotiated: true, id: 0 });
|
||||
});
|
||||
|
||||
it("failing leads to a new subscription", async () => {
|
||||
const first = current();
|
||||
subscriber.handle(offer(first, {}));
|
||||
await settle();
|
||||
const control = lastPeerConnection().control();
|
||||
control.open();
|
||||
control.close();
|
||||
expect(signalling.sent("close")).toEqual([
|
||||
{ type: "close", reason: "failed" },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(current()).not.toBe(first);
|
||||
});
|
||||
|
||||
it("carries the answers to later offers", async () => {
|
||||
subscriber.handle(offer(current(), {}));
|
||||
await settle();
|
||||
const control = lastPeerConnection().control();
|
||||
control.open();
|
||||
control.receive({
|
||||
type: "description",
|
||||
description: { type: "offer", sdp: "offer-2" },
|
||||
track_metadata: {},
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(control.sentMessages()).toContainEqual({
|
||||
type: "description",
|
||||
description: { type: "answer", sdp: "answer" },
|
||||
});
|
||||
expect(signalling.sent("description")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("consumption metadata", () => {
|
||||
it("tells the publisher how each video is shown, once the channel is open", async () => {
|
||||
const pc = lastPeerConnection();
|
||||
pc.receive("audio", "0");
|
||||
pc.receive("video", "1");
|
||||
subscriber.handle(
|
||||
offer(current(), {
|
||||
"0": { purpose: "m.usermedia" },
|
||||
"1": { purpose: "m.usermedia" },
|
||||
}),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
const control = pc.control();
|
||||
control.open();
|
||||
// Nothing shows the video yet; the audio is always in use
|
||||
expect(control.sentMessages()).toEqual([
|
||||
{
|
||||
type: "consumption_metadata",
|
||||
consumption_metadata: { "1": { in_use: false } },
|
||||
},
|
||||
]);
|
||||
|
||||
const video = subscriber.tracks$.value!.find((t) => t.kind === "video")!;
|
||||
const element = { paused: false } as HTMLMediaElement;
|
||||
video.attach(element);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
// Shown at a size the platform doesn't report: no constraint at all
|
||||
expect(control.sentMessages().at(-1)).toEqual({
|
||||
type: "consumption_metadata",
|
||||
consumption_metadata: {},
|
||||
});
|
||||
|
||||
video.detach(element);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(control.sentMessages().at(-1)).toEqual({
|
||||
type: "consumption_metadata",
|
||||
consumption_metadata: { "1": { in_use: false } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("the data channel", () => {
|
||||
it("delivers what the publisher sends on it", async () => {
|
||||
const received: string[] = [];
|
||||
subscriber.data$.subscribe((text) => received.push(text));
|
||||
subscriber.handle(offer(current(), {}));
|
||||
await settle();
|
||||
const pc = lastPeerConnection();
|
||||
pc.announceDataChannel(DATA_CHANNEL_LABEL, DATA_PROTOCOL).receive("hi");
|
||||
expect(received).toEqual(["hi"]);
|
||||
});
|
||||
|
||||
it("ignores a channel whose protocol it doesn't know, whatever its label", async () => {
|
||||
const received: string[] = [];
|
||||
subscriber.data$.subscribe((text) => received.push(text));
|
||||
subscriber.handle(offer(current(), {}));
|
||||
await settle();
|
||||
const pc = lastPeerConnection();
|
||||
pc.announceDataChannel(DATA_CHANNEL_LABEL, "x.other").receive("no");
|
||||
pc.announceDataChannel(DATA_CHANNEL_LABEL).receive("no");
|
||||
expect(received).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function offer(connectionId: string, metadata: TrackMetadata): PeerMessage {
|
||||
return fromPeer(connectionId, {
|
||||
type: "description",
|
||||
description: { type: "offer", sdp: "offer" },
|
||||
track_metadata: metadata,
|
||||
});
|
||||
}
|
||||
|
||||
/** The connection id of the current attempt. */
|
||||
function current(): string {
|
||||
const base = subscriber.current$.value;
|
||||
if (base === null) throw new Error("No current attempt");
|
||||
return base.connectionId;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
/*
|
||||
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 {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
Subject,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { type MediaSource } from "../../media-api";
|
||||
import { type Behavior } from "../../reactive/Behavior";
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { MediaConnectionState } from "../api";
|
||||
import {
|
||||
createFullMeshMediaTrack,
|
||||
type FullMeshMediaTrack,
|
||||
rtpStats,
|
||||
type Viewport,
|
||||
} from "./MediaTrack";
|
||||
import {
|
||||
type ConsumptionMetadata,
|
||||
DATA_PROTOCOL,
|
||||
isMediaPurpose,
|
||||
type MediaPurpose,
|
||||
type TrackMetadata,
|
||||
} from "./metadata";
|
||||
import {
|
||||
createPeerConnectionBase,
|
||||
type PeerConnectionBase,
|
||||
} from "./PeerConnection";
|
||||
import {
|
||||
type ConnectionMessage,
|
||||
type Peer,
|
||||
type Signalling,
|
||||
} from "./signalling";
|
||||
|
||||
const RETRY_MIN_MS = 1_000;
|
||||
const RETRY_MAX_MS = 30_000;
|
||||
/** How long the shown size of the tracks settles before the publisher hears of it. */
|
||||
const CONSUMPTION_DEBOUNCE_MS = 200;
|
||||
|
||||
export interface SubscriberConnection {
|
||||
/** The current attempt; null while waiting to retry. */
|
||||
current$: Behavior<PeerConnectionBase | null>;
|
||||
/** The publisher's tracks; null until its first offer is applied. */
|
||||
tracks$: Behavior<FullMeshMediaTrack[] | null>;
|
||||
/** What the publisher sent on its data channel, on any attempt. */
|
||||
data$: Observable<string>;
|
||||
/** A to-device message from the publisher; dropped unless it is for the current attempt. */
|
||||
handle(message: ConnectionMessage & { connectionId: string }): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Our subscription to one publisher: asks it for its media and answers its
|
||||
* offers. A failed or closed attempt is retried with a fresh connection id,
|
||||
* backing off, for as long as `scope` lives.
|
||||
*/
|
||||
export function createSubscriberConnection({
|
||||
scope,
|
||||
peer,
|
||||
signalling,
|
||||
iceServers,
|
||||
audioOutputDeviceId$,
|
||||
logger: parentLogger,
|
||||
}: {
|
||||
scope: ObservableScope;
|
||||
peer: Peer;
|
||||
signalling: Signalling;
|
||||
iceServers: () => Promise<RTCIceServer[]>;
|
||||
audioOutputDeviceId$: Behavior<string | undefined>;
|
||||
logger: Logger;
|
||||
}): SubscriberConnection {
|
||||
const logger = parentLogger.getChild(`[Subscriber ← ${peer.memberId}]`);
|
||||
const attempt$ = new BehaviorSubject<Attempt | null>(null);
|
||||
const data$ = new Subject<string>();
|
||||
let retryMs = RETRY_MIN_MS;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let ended = false;
|
||||
scope.onEnd(() => (ended = true));
|
||||
|
||||
const startAttempt = async (): Promise<void> => {
|
||||
const servers = await iceServers();
|
||||
if (ended) return;
|
||||
const attemptScope = new ObservableScope();
|
||||
const attempt = createAttempt({
|
||||
scope: attemptScope,
|
||||
peer,
|
||||
signalling,
|
||||
iceServers: servers,
|
||||
audioOutputDeviceId$,
|
||||
onData: (text) => data$.next(text),
|
||||
onFailed: (reason) => {
|
||||
logger.info(`Attempt ${attempt.connectionId} ended: ${reason}`);
|
||||
attemptScope.end();
|
||||
if (attempt$.value === attempt) attempt$.next(null);
|
||||
if (ended) return;
|
||||
retryTimer = setTimeout(() => void startAttempt(), retryMs);
|
||||
retryMs = Math.min(retryMs * 2, RETRY_MAX_MS);
|
||||
},
|
||||
onConnected: () => (retryMs = RETRY_MIN_MS),
|
||||
logger,
|
||||
});
|
||||
attempt$.next(attempt);
|
||||
};
|
||||
void startAttempt();
|
||||
scope.onEnd(() => {
|
||||
clearTimeout(retryTimer);
|
||||
attempt$.value?.end();
|
||||
attempt$.next(null);
|
||||
data$.complete();
|
||||
});
|
||||
|
||||
return {
|
||||
current$: scope.behavior(attempt$.pipe(map((a) => a?.base ?? null))),
|
||||
tracks$: scope.behavior(
|
||||
attempt$.pipe(switchMap((a) => a?.tracks$ ?? of(null))),
|
||||
),
|
||||
data$,
|
||||
handle: (message) => {
|
||||
if (message.connectionId === attempt$.value?.connectionId)
|
||||
attempt$.value.handle(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface Attempt {
|
||||
connectionId: string;
|
||||
base: PeerConnectionBase;
|
||||
tracks$: Behavior<FullMeshMediaTrack[] | null>;
|
||||
handle(message: ConnectionMessage): void;
|
||||
end(): void;
|
||||
}
|
||||
|
||||
interface ReceivedTrack {
|
||||
track: FullMeshMediaTrack;
|
||||
mid: string;
|
||||
scope: ObservableScope;
|
||||
source: MediaSource;
|
||||
}
|
||||
|
||||
function createAttempt({
|
||||
scope,
|
||||
peer,
|
||||
signalling,
|
||||
iceServers,
|
||||
audioOutputDeviceId$,
|
||||
onData,
|
||||
onFailed,
|
||||
onConnected,
|
||||
logger,
|
||||
}: {
|
||||
scope: ObservableScope;
|
||||
peer: Peer;
|
||||
signalling: Signalling;
|
||||
iceServers: RTCIceServer[];
|
||||
audioOutputDeviceId$: Behavior<string | undefined>;
|
||||
onData: (text: string) => void;
|
||||
onFailed: (reason: string) => void;
|
||||
onConnected: () => void;
|
||||
logger: Logger;
|
||||
}): Attempt {
|
||||
const connectionId = uuidv4();
|
||||
let failed = false;
|
||||
const fail = (reason: string): void => {
|
||||
if (failed) return;
|
||||
failed = true;
|
||||
onFailed(reason);
|
||||
};
|
||||
const base = createPeerConnectionBase({
|
||||
scope,
|
||||
peer,
|
||||
connectionId,
|
||||
signalling,
|
||||
iceServers,
|
||||
onControlMessage: (message) => handle(message),
|
||||
logger,
|
||||
});
|
||||
const { pc, enqueue } = base;
|
||||
const metadata$ = new BehaviorSubject<TrackMetadata>({});
|
||||
const received$ = new BehaviorSubject<ReceivedTrack[] | null>(null);
|
||||
const wrappers = new Map<RTCRtpTransceiver, ReceivedTrack>();
|
||||
|
||||
pc.addEventListener("datachannel", ({ channel }) => {
|
||||
if (channel.protocol !== DATA_PROTOCOL) return;
|
||||
channel.addEventListener("message", ({ data }: MessageEvent) => {
|
||||
if (typeof data === "string") onData(data);
|
||||
});
|
||||
});
|
||||
|
||||
/** The tracks of the transceivers that receive, after each negotiation. */
|
||||
const updateTracks = (): void => {
|
||||
const metadata = metadata$.value;
|
||||
const live = new Set<RTCRtpTransceiver>();
|
||||
for (const transceiver of pc.getTransceivers()) {
|
||||
const { mid, receiver } = transceiver;
|
||||
const track = receiver.track;
|
||||
const entry = mid === null ? undefined : metadata[mid];
|
||||
if (
|
||||
transceiver.currentDirection !== "recvonly" ||
|
||||
mid === null ||
|
||||
entry === undefined ||
|
||||
!isMediaPurpose(entry.purpose)
|
||||
)
|
||||
continue;
|
||||
const kind = track.kind === "audio" ? "audio" : "video";
|
||||
const source = sourceOf(entry.purpose, kind);
|
||||
live.add(transceiver);
|
||||
const existing = wrappers.get(transceiver);
|
||||
if (existing?.source === source) continue;
|
||||
existing?.scope.end();
|
||||
const trackScope = new ObservableScope();
|
||||
wrappers.set(transceiver, {
|
||||
scope: trackScope,
|
||||
source,
|
||||
mid,
|
||||
track: createFullMeshMediaTrack(trackScope, {
|
||||
source,
|
||||
kind,
|
||||
id: `${connectionId}:${mid}`,
|
||||
track$: trackScope.behavior(of(track)),
|
||||
muted$: trackScope.behavior(
|
||||
metadata$.pipe(
|
||||
map((m) => m[mid]?.muted ?? false),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
),
|
||||
stats: async () => rtpStats(pc, track, "inbound-rtp"),
|
||||
audioOutputDeviceId$,
|
||||
observeViewport: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
for (const [transceiver, { scope: s }] of wrappers)
|
||||
if (!live.has(transceiver)) {
|
||||
s.end();
|
||||
wrappers.delete(transceiver);
|
||||
}
|
||||
received$.next([...wrappers.values()]);
|
||||
};
|
||||
|
||||
// How the videos are shown; the first goes out once the control channel
|
||||
// is open, as everything is sent at full size until then anyway
|
||||
received$
|
||||
.pipe(
|
||||
switchMap((received) => {
|
||||
const video = (received ?? []).filter((r) => r.track.kind === "video");
|
||||
return video.length === 0
|
||||
? of({})
|
||||
: combineLatest(video.map((r) => r.track.viewport$)).pipe(
|
||||
map((viewports) => consumptionOf(video, viewports)),
|
||||
);
|
||||
}),
|
||||
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
|
||||
debounceTime(CONSUMPTION_DEBOUNCE_MS),
|
||||
scope.bind(),
|
||||
)
|
||||
.subscribe((metadata) =>
|
||||
base.send({
|
||||
type: "consumption_metadata",
|
||||
consumption_metadata: metadata,
|
||||
}),
|
||||
);
|
||||
|
||||
base.state$
|
||||
.pipe(
|
||||
filter((s) => s instanceof Error || s === MediaConnectionState.Connected),
|
||||
scope.bind(),
|
||||
)
|
||||
.subscribe((s) => {
|
||||
if (s instanceof Error) {
|
||||
base.close("failed", true);
|
||||
fail(s.message);
|
||||
} else onConnected();
|
||||
});
|
||||
|
||||
function handle(message: ConnectionMessage): void {
|
||||
switch (message.type) {
|
||||
case "description":
|
||||
if (message.description.type !== "offer") return;
|
||||
enqueue(async () => {
|
||||
if (message.track_metadata) metadata$.next(message.track_metadata);
|
||||
await pc.setRemoteDescription(message.description);
|
||||
base.openControlChannel();
|
||||
await pc.setLocalDescription(await pc.createAnswer());
|
||||
updateTracks();
|
||||
base.send({
|
||||
type: "description",
|
||||
description: { type: "answer", sdp: pc.localDescription!.sdp },
|
||||
});
|
||||
});
|
||||
break;
|
||||
case "candidates":
|
||||
base.addCandidates(message.candidates);
|
||||
break;
|
||||
case "track_metadata":
|
||||
metadata$.next(message.track_metadata);
|
||||
// A track may only become known with its metadata
|
||||
enqueue(async () => Promise.resolve(updateTracks()));
|
||||
break;
|
||||
case "close":
|
||||
base.close(message.reason, false);
|
||||
fail(`closed by the publisher: ${message.reason}`);
|
||||
break;
|
||||
default:
|
||||
logger.debug(`Ignoring ${message.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
scope.onEnd(() => {
|
||||
for (const { scope: s } of wrappers.values()) s.end();
|
||||
wrappers.clear();
|
||||
});
|
||||
|
||||
signalling.send(peer, connectionId, { type: "subscribe" });
|
||||
|
||||
return {
|
||||
connectionId,
|
||||
base,
|
||||
tracks$: scope.behavior(
|
||||
received$.pipe(map((r) => r?.map(({ track }) => track) ?? null)),
|
||||
),
|
||||
handle,
|
||||
end: () => scope.end(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The consumption metadata for the received videos, given their viewports
|
||||
* in order: a video no element shows isn't in use, and one shown at a known
|
||||
* size needs no more than that.
|
||||
*/
|
||||
function consumptionOf(
|
||||
video: ReceivedTrack[],
|
||||
viewports: (Viewport | null)[],
|
||||
): ConsumptionMetadata {
|
||||
const metadata: ConsumptionMetadata = {};
|
||||
video.forEach(({ mid }, i) => {
|
||||
const viewport = viewports[i];
|
||||
if (viewport === null) metadata[mid] = { in_use: false };
|
||||
else if (viewport.width !== undefined && viewport.height !== undefined)
|
||||
metadata[mid] = {
|
||||
max_width: viewport.width,
|
||||
max_height: viewport.height,
|
||||
};
|
||||
});
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function sourceOf(purpose: MediaPurpose, kind: "audio" | "video"): MediaSource {
|
||||
if (purpose === "m.usermedia")
|
||||
return kind === "audio" ? "microphone" : "camera";
|
||||
return kind === "audio" ? "screenShareAudio" : "screenShare";
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
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 { parseConsumptionMetadata, parseTrackMetadata } from "./metadata";
|
||||
|
||||
describe("parseTrackMetadata", () => {
|
||||
it("keeps the well-formed entries", () => {
|
||||
expect(
|
||||
parseTrackMetadata({
|
||||
"0": { purpose: "m.usermedia", muted: true, extra: 1 },
|
||||
"1": { purpose: "m.screenshare", muted: "yes" },
|
||||
"2": { muted: true },
|
||||
"3": [],
|
||||
}),
|
||||
).toEqual({
|
||||
"0": { purpose: "m.usermedia", muted: true },
|
||||
"1": { purpose: "m.screenshare" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects what isn't metadata at all", () => {
|
||||
expect(parseTrackMetadata("x")).toBeNull();
|
||||
expect(parseTrackMetadata([])).toBeNull();
|
||||
});
|
||||
|
||||
it("reads no more entries than any member publishes", () => {
|
||||
const many = Object.fromEntries(
|
||||
Array.from({ length: 100 }, (_, i) => [i, { purpose: "m.usermedia" }]),
|
||||
);
|
||||
expect(Object.keys(parseTrackMetadata(many)!)).toHaveLength(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseConsumptionMetadata", () => {
|
||||
it("bounds the sizes to what makes sense", () => {
|
||||
expect(
|
||||
parseConsumptionMetadata({
|
||||
"0": { in_use: false, max_width: 1e9, max_height: -1 },
|
||||
"1": { max_width: 320.4, max_height: Number.NaN },
|
||||
}),
|
||||
).toEqual({
|
||||
"0": { in_use: false, max_width: 16_384 },
|
||||
"1": { max_width: 320 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The track and consumption metadata of the full mesh transport (MSC4575),
|
||||
* and their validation: both come from the other member and are untrusted.
|
||||
* Both are keyed by the `mid` of a track's transceiver.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The data channel every publisher connection has for `sendData`, and its
|
||||
* protocol: one JSON object `{ topic, text }` per message.
|
||||
*/
|
||||
export const DATA_CHANNEL_LABEL = "io.element.matrixrtc.data";
|
||||
export const DATA_PROTOCOL = "io.element.matrixrtc.topic_text";
|
||||
|
||||
export type MediaPurpose = "m.usermedia" | "m.screenshare";
|
||||
|
||||
export interface TrackMetadataEntry {
|
||||
purpose: string;
|
||||
muted?: boolean;
|
||||
}
|
||||
export type TrackMetadata = Record<string, TrackMetadataEntry>;
|
||||
|
||||
export interface ConsumptionMetadataEntry {
|
||||
in_use?: boolean;
|
||||
max_width?: number;
|
||||
max_height?: number;
|
||||
}
|
||||
export type ConsumptionMetadata = Record<string, ConsumptionMetadataEntry>;
|
||||
|
||||
/** Larger than any video we capture; a bound on what a subscriber can ask for. */
|
||||
const MAX_DIMENSION = 16_384;
|
||||
/** More tracks than any member publishes; a bound on the work a message causes. */
|
||||
const MAX_TRACKS = 64;
|
||||
|
||||
export function isMediaPurpose(purpose: string): purpose is MediaPurpose {
|
||||
return purpose === "m.usermedia" || purpose === "m.screenshare";
|
||||
}
|
||||
|
||||
/** The entries of `value` that are well formed, or null if it isn't metadata at all. */
|
||||
export function parseTrackMetadata(value: unknown): TrackMetadata | null {
|
||||
const entries = recordEntries(value);
|
||||
if (entries === null) return null;
|
||||
const metadata: TrackMetadata = {};
|
||||
for (const [mid, e] of entries) {
|
||||
if (typeof e.purpose !== "string") continue;
|
||||
const entry: TrackMetadataEntry = { purpose: e.purpose };
|
||||
if (typeof e.muted === "boolean") entry.muted = e.muted;
|
||||
metadata[mid] = entry;
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/** As `parseTrackMetadata`, with the sizes bounded to what makes sense. */
|
||||
export function parseConsumptionMetadata(
|
||||
value: unknown,
|
||||
): ConsumptionMetadata | null {
|
||||
const entries = recordEntries(value);
|
||||
if (entries === null) return null;
|
||||
const metadata: ConsumptionMetadata = {};
|
||||
for (const [mid, e] of entries) {
|
||||
const entry: ConsumptionMetadataEntry = {};
|
||||
if (typeof e.in_use === "boolean") entry.in_use = e.in_use;
|
||||
const width = bounded(e.max_width);
|
||||
if (width !== undefined) entry.max_width = width;
|
||||
const height = bounded(e.max_height);
|
||||
if (height !== undefined) entry.max_height = height;
|
||||
metadata[mid] = entry;
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function recordEntries(
|
||||
value: unknown,
|
||||
): [string, Record<string, unknown>][] | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
return null;
|
||||
return Object.entries(value)
|
||||
.filter(
|
||||
(e): e is [string, Record<string, unknown>] =>
|
||||
typeof e[1] === "object" && e[1] !== null && !Array.isArray(e[1]),
|
||||
)
|
||||
.slice(0, MAX_TRACKS);
|
||||
}
|
||||
|
||||
function bounded(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? Math.round(Math.min(value, MAX_DIMENSION))
|
||||
: undefined;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "events";
|
||||
import { ClientEvent, EventType, MatrixEvent } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import {
|
||||
createSignalling,
|
||||
SEQ_GAP_TIMEOUT_MS,
|
||||
SeqOrderer,
|
||||
SignallingEventType,
|
||||
type SignallingClient,
|
||||
type SignallingMessage,
|
||||
} from "./signalling";
|
||||
|
||||
describe("SeqOrderer", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("delivers in seq order", () => {
|
||||
const delivered: string[] = [];
|
||||
const orderer = new SeqOrderer<string>((s) => delivered.push(s));
|
||||
orderer.push(1, "b");
|
||||
orderer.push(2, "c");
|
||||
expect(delivered).toEqual([]);
|
||||
orderer.push(0, "a");
|
||||
expect(delivered).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("skips a gap that isn't filled in time, then drops the late message", () => {
|
||||
const delivered: string[] = [];
|
||||
const orderer = new SeqOrderer<string>((s) => delivered.push(s));
|
||||
orderer.push(0, "a");
|
||||
orderer.push(2, "c");
|
||||
vi.advanceTimersByTime(SEQ_GAP_TIMEOUT_MS);
|
||||
expect(delivered).toEqual(["a", "c"]);
|
||||
orderer.push(1, "b");
|
||||
expect(delivered).toEqual(["a", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSignalling", () => {
|
||||
const own = { userId: "@me:x", deviceId: "MINE", memberId: "me" };
|
||||
|
||||
function setup(): {
|
||||
client: SignallingClient & EventEmitter;
|
||||
received: SignallingMessage[];
|
||||
scope: ObservableScope;
|
||||
} {
|
||||
const client = Object.assign(new EventEmitter(), {
|
||||
encryptAndSendToDevice: vi.fn(async () => Promise.resolve()),
|
||||
}) as unknown as SignallingClient & EventEmitter;
|
||||
const scope = new ObservableScope();
|
||||
const signalling = createSignalling({
|
||||
scope,
|
||||
client,
|
||||
roomId: "!room:x",
|
||||
slotId: "m.call#ROOM",
|
||||
own,
|
||||
logger,
|
||||
});
|
||||
const received: SignallingMessage[] = [];
|
||||
signalling.messages$.subscribe((m) => received.push(m));
|
||||
return { client, received, scope };
|
||||
}
|
||||
|
||||
function toDevice(
|
||||
type: string,
|
||||
content: Record<string, unknown>,
|
||||
encrypted = true,
|
||||
): MatrixEvent {
|
||||
const event = new MatrixEvent({ type, sender: "@peer:x", content });
|
||||
if (encrypted)
|
||||
vi.spyOn(event, "getWireType").mockReturnValue(
|
||||
EventType.RoomMessageEncrypted,
|
||||
);
|
||||
return event;
|
||||
}
|
||||
|
||||
const common = {
|
||||
room_id: "!room:x",
|
||||
slot_id: "m.call#ROOM",
|
||||
member_id: "peer",
|
||||
target_member_id: "me",
|
||||
connection_id: "c1",
|
||||
device_id: "PEER",
|
||||
};
|
||||
|
||||
it("passes valid messages on and drops cleartext or misaddressed ones", () => {
|
||||
const { client, received, scope } = setup();
|
||||
client.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
toDevice(SignallingEventType.subscribe, { ...common, seq: 0 }, false),
|
||||
);
|
||||
client.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
toDevice(SignallingEventType.subscribe, {
|
||||
...common,
|
||||
target_member_id: "someone else",
|
||||
seq: 0,
|
||||
}),
|
||||
);
|
||||
expect(received).toEqual([]);
|
||||
client.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
toDevice(SignallingEventType.subscribe, { ...common, seq: 0 }),
|
||||
);
|
||||
expect(received).toEqual([
|
||||
{
|
||||
type: "subscribe",
|
||||
memberId: "peer",
|
||||
userId: "@peer:x",
|
||||
deviceId: "PEER",
|
||||
connectionId: "c1",
|
||||
},
|
||||
]);
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("orders by seq across connection ids", () => {
|
||||
const { client, received, scope } = setup();
|
||||
client.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
toDevice(SignallingEventType.subscribe, {
|
||||
...common,
|
||||
connection_id: "c2",
|
||||
seq: 2,
|
||||
}),
|
||||
);
|
||||
client.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
toDevice(SignallingEventType.close, { ...common, seq: 1 }),
|
||||
);
|
||||
client.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
toDevice(SignallingEventType.subscribe, { ...common, seq: 0 }),
|
||||
);
|
||||
expect(received.map((m) => [m.type, m.connectionId])).toEqual([
|
||||
["subscribe", "c1"],
|
||||
["close", "c1"],
|
||||
["subscribe", "c2"],
|
||||
]);
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("keeps the well-formed metadata of a description", () => {
|
||||
const { client, received, scope } = setup();
|
||||
client.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
toDevice(SignallingEventType.description, {
|
||||
...common,
|
||||
seq: 0,
|
||||
description: { type: "offer", sdp: "offer" },
|
||||
track_metadata: {
|
||||
"0": { purpose: "m.usermedia", muted: "yes" },
|
||||
"1": { muted: true },
|
||||
"2": "not an object",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(received).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "description",
|
||||
track_metadata: { "0": { purpose: "m.usermedia" } },
|
||||
}),
|
||||
]);
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("sends each payload as its event type", () => {
|
||||
const { client, scope } = setup();
|
||||
const signalling = createSignalling({
|
||||
scope,
|
||||
client,
|
||||
roomId: "!room:x",
|
||||
slotId: "m.call#ROOM",
|
||||
own,
|
||||
logger,
|
||||
});
|
||||
signalling.send(
|
||||
{ userId: "@peer:x", deviceId: "PEER", memberId: "peer" },
|
||||
"c1",
|
||||
{ type: "close", reason: "bye" },
|
||||
);
|
||||
expect(client.encryptAndSendToDevice).toHaveBeenCalledWith(
|
||||
SignallingEventType.close,
|
||||
[{ userId: "@peer:x", deviceId: "PEER" }],
|
||||
expect.objectContaining({ reason: "bye", connection_id: "c1", seq: 0 }),
|
||||
);
|
||||
scope.end();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
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 to-device signalling of the full mesh transport (MSC4575): four event
|
||||
* types, the fields every one carries, and the ordering by `seq`. It sets a
|
||||
* connection up; the control channel carries the rest.
|
||||
*/
|
||||
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
ClientEvent,
|
||||
EventType,
|
||||
type MatrixClient,
|
||||
type MatrixEvent,
|
||||
} from "matrix-js-sdk";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
import { fromEvent, Observable, share } from "rxjs";
|
||||
|
||||
import { type ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { type ControlMessage } from "./ControlChannel";
|
||||
import { parseTrackMetadata } from "./metadata";
|
||||
|
||||
export const FULL_MESH_TRANSPORT_TYPE = "org.matrix.msc4575.full_mesh";
|
||||
|
||||
const prefix = "org.matrix.msc4575.rtc.full_mesh";
|
||||
/** The event type of each payload type. */
|
||||
export const SignallingEventType = {
|
||||
subscribe: `${prefix}.subscribe`,
|
||||
description: `${prefix}.description`,
|
||||
candidates: `${prefix}.candidates`,
|
||||
close: `${prefix}.close`,
|
||||
} as const;
|
||||
|
||||
export const signallingEventTypes: string[] =
|
||||
Object.values(SignallingEventType);
|
||||
|
||||
/** How long a gap in `seq` is waited for before the missing messages are skipped. */
|
||||
export const SEQ_GAP_TIMEOUT_MS = 10_000;
|
||||
|
||||
interface CommonContent {
|
||||
room_id: string;
|
||||
slot_id: string;
|
||||
member_id: string;
|
||||
target_member_id: string;
|
||||
connection_id: string;
|
||||
seq: number;
|
||||
/**
|
||||
* Not in the MSC, which takes the device from the Olm session. The js-sdk
|
||||
* does not expose that for to-device events, so, like the key transport's
|
||||
* `claimed_device_id`, the sender states it.
|
||||
*/
|
||||
device_id: string;
|
||||
}
|
||||
|
||||
/** What a connection hears from its peer, over to-device messages or the control channel. */
|
||||
export type ConnectionMessage =
|
||||
| ControlMessage
|
||||
| { type: "close"; reason?: string };
|
||||
|
||||
export type SignallingPayload =
|
||||
| { type: "subscribe" }
|
||||
| Extract<
|
||||
ConnectionMessage,
|
||||
{ type: "description" | "candidates" | "close" }
|
||||
>;
|
||||
|
||||
export type SignallingMessage = SignallingPayload & {
|
||||
/** The member that sent it, as named in `member_id`. */
|
||||
memberId: string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
connectionId: string;
|
||||
};
|
||||
|
||||
/** Another member, as far as signalling needs to know it. */
|
||||
export interface Peer {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
memberId: string;
|
||||
}
|
||||
|
||||
export interface Signalling {
|
||||
send(peer: Peer, connectionId: string, payload: SignallingPayload): void;
|
||||
/** Every valid message for us, per sender in `seq` order. */
|
||||
messages$: Observable<SignallingMessage>;
|
||||
}
|
||||
|
||||
export type SignallingClient = Pick<
|
||||
MatrixClient,
|
||||
"encryptAndSendToDevice" | "on" | "off"
|
||||
>;
|
||||
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
client: SignallingClient;
|
||||
roomId: string;
|
||||
slotId: string;
|
||||
own: CallMembershipIdentityParts;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export function createSignalling({
|
||||
scope,
|
||||
client,
|
||||
roomId,
|
||||
slotId,
|
||||
own,
|
||||
logger: parentLogger,
|
||||
}: Props): Signalling {
|
||||
const logger = parentLogger.getChild("[Signalling]");
|
||||
const nextSeqTo = new Map<string, number>();
|
||||
|
||||
const send = (
|
||||
peer: Peer,
|
||||
connectionId: string,
|
||||
{ type, ...payload }: SignallingPayload,
|
||||
): void => {
|
||||
const eventType = SignallingEventType[type];
|
||||
const seq = nextSeqTo.get(peer.memberId) ?? 0;
|
||||
nextSeqTo.set(peer.memberId, seq + 1);
|
||||
const content: CommonContent & Record<string, unknown> = {
|
||||
...payload,
|
||||
room_id: roomId,
|
||||
slot_id: slotId,
|
||||
member_id: own.memberId,
|
||||
target_member_id: peer.memberId,
|
||||
connection_id: connectionId,
|
||||
seq,
|
||||
device_id: own.deviceId,
|
||||
};
|
||||
client
|
||||
.encryptAndSendToDevice(
|
||||
eventType,
|
||||
[{ userId: peer.userId, deviceId: peer.deviceId }],
|
||||
content,
|
||||
)
|
||||
.catch((e: unknown) => logger.warn(`Failed to send ${eventType}`, e));
|
||||
};
|
||||
|
||||
const messages$ = new Observable<SignallingMessage>((subscriber) => {
|
||||
const orderers = new Map<string, SeqOrderer<SignallingMessage>>();
|
||||
const sub = fromEvent<MatrixEvent>(client, ClientEvent.ToDeviceEvent)
|
||||
.pipe(scope.bind())
|
||||
.subscribe((event) => {
|
||||
const parsed = parse(event, roomId, slotId, own.memberId, logger);
|
||||
if (parsed === null) return;
|
||||
let orderer = orderers.get(parsed.message.memberId);
|
||||
if (orderer === undefined) {
|
||||
orderer = new SeqOrderer((m) => subscriber.next(m), logger);
|
||||
orderers.set(parsed.message.memberId, orderer);
|
||||
}
|
||||
orderer.push(parsed.seq, parsed.message);
|
||||
});
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
for (const orderer of orderers.values()) orderer.dispose();
|
||||
};
|
||||
}).pipe(share());
|
||||
|
||||
return { send, messages$ };
|
||||
}
|
||||
|
||||
function parse(
|
||||
event: MatrixEvent,
|
||||
roomId: string,
|
||||
slotId: string,
|
||||
ownMemberId: string,
|
||||
logger: Logger,
|
||||
): { seq: number; message: SignallingMessage } | null {
|
||||
const type = event.getType();
|
||||
if (!signallingEventTypes.includes(type)) return null;
|
||||
// Via the widget API, to-device events are reconstructed as encrypted
|
||||
if (event.getWireType() !== EventType.RoomMessageEncrypted) {
|
||||
logger.warn(`Dropping ${type} sent in clear from ${event.getSender()}`);
|
||||
return null;
|
||||
}
|
||||
const c = event.getContent<
|
||||
Partial<CommonContent> & Record<string, unknown>
|
||||
>();
|
||||
const userId = event.getSender();
|
||||
if (
|
||||
userId === undefined ||
|
||||
c.room_id !== roomId ||
|
||||
c.slot_id !== slotId ||
|
||||
c.target_member_id !== ownMemberId ||
|
||||
typeof c.member_id !== "string" ||
|
||||
typeof c.connection_id !== "string" ||
|
||||
typeof c.device_id !== "string" ||
|
||||
typeof c.seq !== "number"
|
||||
)
|
||||
return null;
|
||||
const common = {
|
||||
memberId: c.member_id,
|
||||
userId,
|
||||
deviceId: c.device_id,
|
||||
connectionId: c.connection_id,
|
||||
};
|
||||
let payload: SignallingPayload;
|
||||
switch (type) {
|
||||
case SignallingEventType.subscribe:
|
||||
payload = { type: "subscribe" };
|
||||
break;
|
||||
case SignallingEventType.description: {
|
||||
const d = c.description as { type?: unknown; sdp?: unknown } | undefined;
|
||||
if (
|
||||
(d?.type !== "offer" && d?.type !== "answer") ||
|
||||
typeof d.sdp !== "string"
|
||||
)
|
||||
return null;
|
||||
payload = {
|
||||
type: "description",
|
||||
description: { type: d.type, sdp: d.sdp },
|
||||
track_metadata: parseTrackMetadata(c.track_metadata) ?? undefined,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case SignallingEventType.candidates:
|
||||
if (!Array.isArray(c.candidates)) return null;
|
||||
payload = {
|
||||
type: "candidates",
|
||||
candidates: c.candidates as RTCIceCandidateInit[],
|
||||
};
|
||||
break;
|
||||
case SignallingEventType.close:
|
||||
payload = {
|
||||
type: "close",
|
||||
reason: typeof c.reason === "string" ? c.reason : undefined,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return { seq: c.seq, message: { ...payload, ...common } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivers the messages of one sender in `seq` order. A gap is waited for
|
||||
* `SEQ_GAP_TIMEOUT_MS`, then skipped. A message below the next expected `seq`
|
||||
* came after its gap was skipped, and is dropped.
|
||||
*/
|
||||
export class SeqOrderer<T> {
|
||||
private next = 0;
|
||||
private readonly buffer = new Map<number, T>();
|
||||
private gapTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
public constructor(
|
||||
private readonly deliver: (item: T) => void,
|
||||
private readonly logger?: Logger,
|
||||
private readonly gapTimeoutMs = SEQ_GAP_TIMEOUT_MS,
|
||||
) {}
|
||||
|
||||
public push(seq: number, item: T): void {
|
||||
if (seq < this.next) {
|
||||
this.logger?.warn(`Dropping seq ${seq}, already past it`);
|
||||
return;
|
||||
}
|
||||
this.buffer.set(seq, item);
|
||||
this.flush();
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
clearTimeout(this.gapTimer);
|
||||
this.buffer.clear();
|
||||
}
|
||||
|
||||
private flush(): void {
|
||||
let item = this.buffer.get(this.next);
|
||||
while (item !== undefined) {
|
||||
this.buffer.delete(this.next);
|
||||
this.next++;
|
||||
this.deliver(item);
|
||||
item = this.buffer.get(this.next);
|
||||
}
|
||||
clearTimeout(this.gapTimer);
|
||||
this.gapTimer = undefined;
|
||||
if (this.buffer.size > 0)
|
||||
this.gapTimer = setTimeout(() => this.skipGap(), this.gapTimeoutMs);
|
||||
}
|
||||
|
||||
private skipGap(): void {
|
||||
const lowest = Math.min(...this.buffer.keys());
|
||||
this.logger?.warn(`Skipping seq ${this.next} to ${lowest - 1}`);
|
||||
this.next = lowest;
|
||||
this.flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fakes for the parts of WebRTC that jsdom lacks, and for the signalling, so
|
||||
* that the full mesh connections can run in unit tests.
|
||||
*/
|
||||
|
||||
import { vi } from "vitest";
|
||||
import { BehaviorSubject, Subject } from "rxjs";
|
||||
|
||||
import { CONTROL_CHANNEL_LABEL } from "./ControlChannel";
|
||||
import { type LocalPublication } from "./LocalMedia";
|
||||
import {
|
||||
type ConnectionMessage,
|
||||
type Peer,
|
||||
type Signalling,
|
||||
type SignallingMessage,
|
||||
type SignallingPayload,
|
||||
} from "./signalling";
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
export class FakeMediaStream {
|
||||
public readonly id: string;
|
||||
public constructor(public readonly tracks: MediaStreamTrack[] = []) {
|
||||
this.id = `stream-${nextId++}`;
|
||||
}
|
||||
public getTracks(): MediaStreamTrack[] {
|
||||
return this.tracks;
|
||||
}
|
||||
}
|
||||
|
||||
export function fakeTrack(
|
||||
kind: "audio" | "video",
|
||||
settings: MediaTrackSettings = {},
|
||||
): MediaStreamTrack {
|
||||
return {
|
||||
id: `track-${nextId++}`,
|
||||
kind,
|
||||
enabled: true,
|
||||
stop: vi.fn(),
|
||||
getSettings: () => settings,
|
||||
} as unknown as MediaStreamTrack;
|
||||
}
|
||||
|
||||
/** A data channel whose opening and incoming messages the test drives. */
|
||||
export class FakeDataChannel extends EventTarget {
|
||||
public readyState: RTCDataChannelState = "connecting";
|
||||
public readonly sent: string[] = [];
|
||||
public readonly protocol: string;
|
||||
|
||||
public constructor(
|
||||
public readonly label: string,
|
||||
public readonly init: RTCDataChannelInit = {},
|
||||
) {
|
||||
super();
|
||||
this.protocol = init.protocol ?? "";
|
||||
}
|
||||
|
||||
public send(data: string): void {
|
||||
if (this.readyState !== "open") throw new Error("The channel isn't open");
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
/** What was sent, parsed. */
|
||||
public sentMessages(): unknown[] {
|
||||
return this.sent.map((data) => JSON.parse(data) as unknown);
|
||||
}
|
||||
|
||||
public open(): void {
|
||||
this.readyState = "open";
|
||||
this.dispatchEvent(new Event("open"));
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
if (this.readyState === "closed") return;
|
||||
this.readyState = "closed";
|
||||
this.dispatchEvent(new Event("close"));
|
||||
}
|
||||
|
||||
/** A message from the peer; an object is sent as JSON. */
|
||||
public receive(data: unknown): void {
|
||||
this.dispatchEvent(
|
||||
Object.assign(new Event("message"), {
|
||||
data: typeof data === "string" ? data : JSON.stringify(data),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface FakeTransceiver {
|
||||
kind: string;
|
||||
/** Null until a local description gives it one, as in a browser. */
|
||||
mid: string | null;
|
||||
direction: RTCRtpTransceiverDirection;
|
||||
currentDirection: RTCRtpTransceiverDirection | null;
|
||||
streams: MediaStream[];
|
||||
stopped: boolean;
|
||||
stop(): void;
|
||||
sender: {
|
||||
track: MediaStreamTrack | null;
|
||||
replaceTrack: ReturnType<typeof vi.fn>;
|
||||
parameters: RTCRtpSendParameters;
|
||||
getParameters(): RTCRtpSendParameters;
|
||||
setParameters: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
receiver: { track: MediaStreamTrack };
|
||||
}
|
||||
|
||||
/** Enough of `RTCPeerConnection` for one side of a connection, driven by the test. */
|
||||
export class FakePeerConnection extends EventTarget {
|
||||
public static instances: FakePeerConnection[] = [];
|
||||
|
||||
public signalingState: RTCSignalingState = "stable";
|
||||
public connectionState: RTCPeerConnectionState = "new";
|
||||
public iceConnectionState: RTCIceConnectionState = "new";
|
||||
public localDescription: RTCSessionDescriptionInit | null = null;
|
||||
public remoteDescription: RTCSessionDescriptionInit | null = null;
|
||||
public readonly transceivers: FakeTransceiver[] = [];
|
||||
public readonly addedCandidates: RTCIceCandidateInit[] = [];
|
||||
public readonly dataChannels: FakeDataChannel[] = [];
|
||||
public offers = 0;
|
||||
public closed = false;
|
||||
|
||||
public constructor(public readonly config: RTCConfiguration) {
|
||||
super();
|
||||
FakePeerConnection.instances.push(this);
|
||||
}
|
||||
|
||||
public addTransceiver(
|
||||
kind: string,
|
||||
init: RTCRtpTransceiverInit,
|
||||
): FakeTransceiver {
|
||||
const transceiver: FakeTransceiver = {
|
||||
kind,
|
||||
mid: null,
|
||||
direction: init.direction ?? "sendrecv",
|
||||
currentDirection: null,
|
||||
streams: init.streams ?? [],
|
||||
stopped: false,
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
},
|
||||
sender: {
|
||||
track: null,
|
||||
replaceTrack: vi.fn(async (track: MediaStreamTrack | null) => {
|
||||
transceiver.sender.track = track;
|
||||
return Promise.resolve();
|
||||
}),
|
||||
parameters: {
|
||||
encodings: [{}],
|
||||
} as unknown as RTCRtpSendParameters,
|
||||
getParameters: (): RTCRtpSendParameters =>
|
||||
structuredClone(transceiver.sender.parameters),
|
||||
setParameters: vi.fn(async (parameters: RTCRtpSendParameters) => {
|
||||
transceiver.sender.parameters = parameters;
|
||||
return Promise.resolve();
|
||||
}),
|
||||
},
|
||||
receiver: { track: fakeTrack(kind === "audio" ? "audio" : "video") },
|
||||
};
|
||||
this.transceivers.push(transceiver);
|
||||
return transceiver;
|
||||
}
|
||||
|
||||
public createDataChannel(
|
||||
label: string,
|
||||
init?: RTCDataChannelInit,
|
||||
): FakeDataChannel {
|
||||
const channel = new FakeDataChannel(label, init);
|
||||
this.dataChannels.push(channel);
|
||||
return channel;
|
||||
}
|
||||
|
||||
/** Our side of the control channel. */
|
||||
public control(): FakeDataChannel {
|
||||
const channel = this.dataChannels.find(
|
||||
(c) => c.label === CONTROL_CHANNEL_LABEL,
|
||||
);
|
||||
if (channel === undefined) throw new Error("No control channel");
|
||||
return channel;
|
||||
}
|
||||
|
||||
/** A data channel the remote side created, as `ondatachannel` brings it. */
|
||||
public announceDataChannel(label: string, protocol = ""): FakeDataChannel {
|
||||
const channel = new FakeDataChannel(label, { protocol });
|
||||
this.dispatchEvent(Object.assign(new Event("datachannel"), { channel }));
|
||||
return channel;
|
||||
}
|
||||
|
||||
public getTransceivers(): FakeTransceiver[] {
|
||||
return this.transceivers;
|
||||
}
|
||||
|
||||
public getSenders(): FakeTransceiver["sender"][] {
|
||||
return this.transceivers.map((t) => t.sender);
|
||||
}
|
||||
|
||||
public async createOffer(): Promise<RTCSessionDescriptionInit> {
|
||||
return Promise.resolve({ type: "offer", sdp: `offer-${++this.offers}` });
|
||||
}
|
||||
|
||||
public async createAnswer(): Promise<RTCSessionDescriptionInit> {
|
||||
return Promise.resolve({ type: "answer", sdp: "answer" });
|
||||
}
|
||||
|
||||
public async setLocalDescription(
|
||||
description: RTCSessionDescriptionInit,
|
||||
): Promise<void> {
|
||||
this.localDescription = description;
|
||||
this.transceivers.forEach((t, i) => (t.mid ??= String(i)));
|
||||
this.signalingState =
|
||||
description.type === "offer" ? "have-local-offer" : "stable";
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public async setRemoteDescription(
|
||||
description: RTCSessionDescriptionInit,
|
||||
): Promise<void> {
|
||||
this.remoteDescription = description;
|
||||
this.signalingState =
|
||||
description.type === "offer" ? "have-remote-offer" : "stable";
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
|
||||
if (candidate.candidate === "bad")
|
||||
return Promise.reject(new Error("bad candidate"));
|
||||
this.addedCandidates.push(candidate);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public async getStats(): Promise<RTCStatsReport> {
|
||||
return Promise.resolve(new Map() as unknown as RTCStatsReport);
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this.closed = true;
|
||||
this.signalingState = "closed";
|
||||
this.connectionState = "closed";
|
||||
for (const channel of this.dataChannels) channel.close();
|
||||
}
|
||||
|
||||
/** Gathers a local candidate; null ends the gathering. */
|
||||
public gather(candidate: RTCIceCandidateInit | null): void {
|
||||
this.dispatchEvent(
|
||||
Object.assign(new Event("icecandidate"), {
|
||||
candidate: candidate && { toJSON: () => candidate },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public setConnectionState(state: RTCPeerConnectionState): void {
|
||||
this.connectionState = state;
|
||||
this.dispatchEvent(new Event("connectionstatechange"));
|
||||
}
|
||||
|
||||
/** A track the remote side sends, as the next negotiation would bring it. */
|
||||
public receive(kind: "audio" | "video", mid: string): FakeTransceiver {
|
||||
const transceiver = this.addTransceiver(kind, { direction: "recvonly" });
|
||||
transceiver.currentDirection = "recvonly";
|
||||
transceiver.mid = mid;
|
||||
return transceiver;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeAudioContext {
|
||||
public destination = {};
|
||||
public createMediaStreamSource(): object {
|
||||
return { connect: vi.fn(), disconnect: vi.fn() };
|
||||
}
|
||||
public createAnalyser(): object {
|
||||
return {
|
||||
fftSize: 512,
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getFloatTimeDomainData: (samples: Float32Array): void => {
|
||||
samples.fill(0);
|
||||
},
|
||||
};
|
||||
}
|
||||
public createGain(): object {
|
||||
return { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() };
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces the WebRTC and Web Audio globals jsdom doesn't have. Undo with `vi.unstubAllGlobals()`. */
|
||||
export function installWebRtcFakes(): void {
|
||||
FakePeerConnection.instances = [];
|
||||
vi.stubGlobal("RTCPeerConnection", FakePeerConnection);
|
||||
vi.stubGlobal("MediaStream", FakeMediaStream);
|
||||
vi.stubGlobal("AudioContext", FakeAudioContext);
|
||||
}
|
||||
|
||||
/** The last peer connection created. */
|
||||
export function lastPeerConnection(): FakePeerConnection {
|
||||
const pc = FakePeerConnection.instances.at(-1);
|
||||
if (pc === undefined) throw new Error("No peer connection was created");
|
||||
return pc;
|
||||
}
|
||||
|
||||
/** Lets every queued signalling operation run. */
|
||||
export async function settle(): Promise<void> {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
export const peer: Peer = {
|
||||
userId: "@peer:example.org",
|
||||
deviceId: "PEERDEVICE",
|
||||
memberId: "peerMember",
|
||||
};
|
||||
|
||||
export interface FakeSignalling extends Signalling {
|
||||
send: ReturnType<typeof vi.fn<Signalling["send"]>>;
|
||||
messages$: Subject<SignallingMessage>;
|
||||
/** The payloads sent so far, optionally only those of one type. */
|
||||
sent(type?: string): SignallingPayload[];
|
||||
/** The connection ids sent with the payloads, in the same order. */
|
||||
sentConnectionIds(type?: string): string[];
|
||||
}
|
||||
|
||||
export function fakeSignalling(): FakeSignalling {
|
||||
const send = vi.fn<Signalling["send"]>();
|
||||
const calls = (type?: string): Parameters<Signalling["send"]>[] =>
|
||||
send.mock.calls.filter(([, , p]) => type === undefined || p.type === type);
|
||||
return {
|
||||
send,
|
||||
messages$: new Subject(),
|
||||
sent: (type) => calls(type).map(([, , p]) => p),
|
||||
sentConnectionIds: (type) => calls(type).map(([, id]) => id),
|
||||
};
|
||||
}
|
||||
|
||||
/** A to-device message of a connection, as signalling delivers it. */
|
||||
export type PeerMessage = ConnectionMessage & Omit<SignallingMessage, "type">;
|
||||
|
||||
/** A to-device message from `peer` on a connection. */
|
||||
export function fromPeer<P extends SignallingPayload>(
|
||||
connectionId: string,
|
||||
payload: P,
|
||||
): P & Omit<SignallingMessage, "type"> {
|
||||
return { ...payload, ...peer, connectionId };
|
||||
}
|
||||
|
||||
/** The local media a publisher connection reads. */
|
||||
export function fakeLocalMedia(): {
|
||||
publications$: BehaviorSubject<LocalPublication[]>;
|
||||
publishing$: BehaviorSubject<boolean>;
|
||||
} {
|
||||
return {
|
||||
publications$: new BehaviorSubject<LocalPublication[]>([]),
|
||||
publishing$: new BehaviorSubject(true),
|
||||
};
|
||||
}
|
||||
|
||||
export function fakePublication(
|
||||
kind: "audio" | "video",
|
||||
{
|
||||
source = kind === "audio" ? "microphone" : "camera",
|
||||
stream = new FakeMediaStream() as unknown as MediaStream,
|
||||
}: Partial<Pick<LocalPublication, "source" | "stream">> = {},
|
||||
): LocalPublication & {
|
||||
track$: BehaviorSubject<MediaStreamTrack | null>;
|
||||
muted$: BehaviorSubject<boolean>;
|
||||
} {
|
||||
const muted$ = new BehaviorSubject(false);
|
||||
return {
|
||||
source,
|
||||
kind,
|
||||
stream,
|
||||
track$: new BehaviorSubject<MediaStreamTrack | null>(fakeTrack(kind)),
|
||||
muted$,
|
||||
mediaTrack: { muted$ } as unknown as LocalPublication["mediaTrack"],
|
||||
};
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
vi,
|
||||
} from "vitest";
|
||||
import {
|
||||
ConnectionError,
|
||||
type LocalParticipant,
|
||||
type RemoteParticipant,
|
||||
type RemoteTrack,
|
||||
@@ -42,7 +41,7 @@ import {
|
||||
} from "./Connection.ts";
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { type ClientGetTokenParts, type ClientOpenIDParts } from "./auth";
|
||||
import { LivekitConnectionError, MatrixRTCError } from "../../errors";
|
||||
import { MatrixRTCError } from "../../errors";
|
||||
import { testJWTToken } from "../../utils/test-fixtures";
|
||||
import { ownMemberMock } from "../../utils/test";
|
||||
import { mockRemoteParticipant } from "./test";
|
||||
@@ -58,10 +57,6 @@ let fakeLocalParticipant: MockedObject<LocalParticipant>;
|
||||
|
||||
const ROOM_ID = "!roomID:example.org";
|
||||
const SLOT_ID = "m.call#ROOM";
|
||||
const FIREFOX_USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0";
|
||||
const CHROME_USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36";
|
||||
|
||||
const transport: UnstableLivekitTransport = {
|
||||
type: "livekit",
|
||||
@@ -152,13 +147,6 @@ function setupRemoteConnection(): Connection {
|
||||
return new Connection(opts, logger);
|
||||
}
|
||||
|
||||
function mockUserAgent(userAgent: string): void {
|
||||
const spy = vi
|
||||
.spyOn(navigator, "userAgent", "get")
|
||||
.mockReturnValue(userAgent);
|
||||
onTestFinished(() => spy.mockRestore());
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
@@ -387,89 +375,6 @@ describe("Start connection states", () => {
|
||||
expect(connection.livekitAlias).toBe("!example_room_id");
|
||||
});
|
||||
|
||||
it("retries a join that failed on the WebSocket in Firefox", async () => {
|
||||
setupTest();
|
||||
mockUserAgent(FIREFOX_USER_AGENT);
|
||||
const connection = setupRemoteConnection();
|
||||
const capturedStates: ConnectionState[] = [];
|
||||
const s = connection.state$.subscribe((value) => {
|
||||
capturedStates.push(value);
|
||||
});
|
||||
onTestFinished(() => s.unsubscribe());
|
||||
const firstJoin = Promise.withResolvers<void>();
|
||||
fakeLivekitRoom.connect.mockReturnValueOnce(firstJoin.promise);
|
||||
|
||||
const started = connection.start();
|
||||
await vi.waitFor(() => expect(fakeLivekitRoom.connect).toHaveBeenCalled());
|
||||
expect(fakeLivekitRoom.connect).toHaveBeenCalledTimes(1);
|
||||
firstJoin.reject(ConnectionError.websocket("WS closed unexpectedly"));
|
||||
await started;
|
||||
|
||||
expect(fakeLivekitRoom.connect).toHaveBeenCalledTimes(2);
|
||||
expect(capturedStates.at(-1)).toEqual({
|
||||
state: "authenticated",
|
||||
livekitState: LivekitConnectionState.Connected,
|
||||
});
|
||||
expect(capturedStates.some((st) => st.state === "error")).toBe(false);
|
||||
});
|
||||
|
||||
it("retries a join in Firefox only once", async () => {
|
||||
setupTest();
|
||||
mockUserAgent(FIREFOX_USER_AGENT);
|
||||
const connection = setupRemoteConnection();
|
||||
fakeLivekitRoom.connect.mockRejectedValue(
|
||||
ConnectionError.websocket("WS closed unexpectedly"),
|
||||
);
|
||||
|
||||
await expect(connection.start()).rejects.toThrow(LivekitConnectionError);
|
||||
|
||||
expect(fakeLivekitRoom.connect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not retry a join that failed for another reason in Firefox", async () => {
|
||||
setupTest();
|
||||
mockUserAgent(FIREFOX_USER_AGENT);
|
||||
const connection = setupRemoteConnection();
|
||||
fakeLivekitRoom.connect.mockRejectedValueOnce(
|
||||
ConnectionError.serverUnreachable("server was not reachable"),
|
||||
);
|
||||
|
||||
await expect(connection.start()).rejects.toThrow(LivekitConnectionError);
|
||||
|
||||
expect(fakeLivekitRoom.connect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry a join that failed on the WebSocket in other browsers", async () => {
|
||||
setupTest();
|
||||
mockUserAgent(CHROME_USER_AGENT);
|
||||
const connection = setupRemoteConnection();
|
||||
fakeLivekitRoom.connect.mockRejectedValueOnce(
|
||||
ConnectionError.websocket("WS closed unexpectedly"),
|
||||
);
|
||||
|
||||
await expect(connection.start()).rejects.toThrow(LivekitConnectionError);
|
||||
|
||||
expect(fakeLivekitRoom.connect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry a join in Firefox once stopped", async () => {
|
||||
setupTest();
|
||||
mockUserAgent(FIREFOX_USER_AGENT);
|
||||
const connection = setupRemoteConnection();
|
||||
const firstJoin = Promise.withResolvers<void>();
|
||||
fakeLivekitRoom.connect.mockReturnValueOnce(firstJoin.promise);
|
||||
fakeLivekitRoom.disconnect.mockResolvedValue(undefined);
|
||||
|
||||
const started = connection.start();
|
||||
await vi.waitFor(() => expect(fakeLivekitRoom.connect).toHaveBeenCalled());
|
||||
const stopping = connection.stop();
|
||||
firstJoin.reject(ConnectionError.websocket("WS closed unexpectedly"));
|
||||
await stopping;
|
||||
|
||||
await expect(started).resolves.toBeUndefined();
|
||||
expect(fakeLivekitRoom.connect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stopping while connecting does not report an error", async () => {
|
||||
setupTest();
|
||||
const connection = setupRemoteConnection();
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
ConnectionError,
|
||||
ConnectionErrorReason,
|
||||
type ConnectionState as LivekitConnectionState,
|
||||
getBrowser,
|
||||
type Participant,
|
||||
type RemoteParticipant,
|
||||
type RemoteTrackPublication,
|
||||
@@ -323,7 +322,7 @@ export class Connection {
|
||||
|
||||
try {
|
||||
this.logger.info(`livekitRoom.connect ${url}`);
|
||||
await this.connectLivekitRoom(url, jwt);
|
||||
await this.livekitRoom.connect(url, jwt);
|
||||
this.logger.info(`livekitRoom.connect SUCCESS ${url}`);
|
||||
} catch (e) {
|
||||
this.logger.info(`livekitRoom.connect FAILED ${url}`, e);
|
||||
@@ -383,23 +382,6 @@ export class Connection {
|
||||
}
|
||||
}
|
||||
|
||||
private async connectLivekitRoom(url: string, jwt: string): Promise<void> {
|
||||
try {
|
||||
await this.livekitRoom.connect(url, jwt);
|
||||
} catch (e) {
|
||||
// Behind a proxy, Firefox fails the first WebSocket to a host after trying
|
||||
// it over HTTP/2, so a join that fails on the WebSocket gets a second try.
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=2015411
|
||||
const failedOnFirefoxWebSocket =
|
||||
getBrowser()?.name === "Firefox" &&
|
||||
e instanceof ConnectionError &&
|
||||
e.reason === ConnectionErrorReason.WebSocket;
|
||||
if (this.stopped || !failedOnFirefoxWebSocket) throw e;
|
||||
this.logger.info(`livekitRoom.connect RETRY ${url}`, e);
|
||||
await this.livekitRoom.connect(url, jwt);
|
||||
}
|
||||
}
|
||||
|
||||
protected async getSFUConfig(): Promise<SFUConfig> {
|
||||
return await getSFUConfig({
|
||||
client: this.client,
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { type ConnectionFactory } from "./ConnectionFactory.ts";
|
||||
import { type Connection } from "./Connection.ts";
|
||||
import { ownMemberMock, withTestScheduler } from "../../utils/test";
|
||||
import { constant, type Behavior } from "../../reactive/Behavior";
|
||||
import { type Behavior } from "../../reactive/Behavior";
|
||||
import { type TransportLocator } from "./auth";
|
||||
|
||||
const TRANSPORT_1: TransportLocator = {
|
||||
@@ -207,7 +207,7 @@ describe("connections$ stream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("Keeps one connection per homeserver for url transports", () => {
|
||||
test("Keeps one connection per homeserver for transports with a url", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
const urlTransport: TransportLocator = {
|
||||
transport: { type: "livekit", url: "wss://sfu.example.org" },
|
||||
@@ -243,94 +243,6 @@ describe("connections$ stream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("Shares connection between local livekit_service_url transport and remote url + livekit_service_url transport", () => {
|
||||
withTestScheduler(({ behavior, expectObservable, scope }) => {
|
||||
const serviceUrlTransport: TransportLocator = {
|
||||
transport: {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://jwt.example.org",
|
||||
},
|
||||
serverName: "example.org",
|
||||
};
|
||||
const urlAndServiceUrlTransport: TransportLocator = {
|
||||
transport: {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://jwt.example.org",
|
||||
url: "wss://sfu.elsewhere.org",
|
||||
},
|
||||
// Should pass even if local server != remote server
|
||||
serverName: "elsewhere.org",
|
||||
};
|
||||
const connectionMap$ = createConnectionMap$({
|
||||
scope,
|
||||
connectionFactory: fakeConnectionFactory,
|
||||
localTransport: serviceUrlTransport,
|
||||
remoteTransports$: constant(new Epoch([urlAndServiceUrlTransport])),
|
||||
logger,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
});
|
||||
|
||||
expectObservable(
|
||||
connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
|
||||
).toBe("a", {
|
||||
a: expect.toSatisfy(({ value: cs }: Epoch<Connection[]>) => {
|
||||
// One connection total, since local and remote connections are shared
|
||||
expect(cs.length).toBe(1);
|
||||
expect(findConnection(serviceUrlTransport, cs)).toBeDefined();
|
||||
expect(findConnection(serviceUrlTransport, cs)).toBe(
|
||||
findConnection(urlAndServiceUrlTransport, cs),
|
||||
);
|
||||
return true;
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Shares connection between remote livekit_service_url transport and remote url + livekit_service_url transport", () => {
|
||||
withTestScheduler(({ behavior, expectObservable, scope }) => {
|
||||
const serviceUrlTransport: TransportLocator = {
|
||||
transport: {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://jwt.example.org",
|
||||
},
|
||||
serverName: "example.org",
|
||||
};
|
||||
const urlAndServiceUrlTransport: TransportLocator = {
|
||||
transport: {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://jwt.example.org",
|
||||
url: "wss://sfu.elsewhere.org",
|
||||
},
|
||||
// Should pass even if local server != remote server
|
||||
serverName: "elsewhere.org",
|
||||
};
|
||||
const connectionMap$ = createConnectionMap$({
|
||||
scope,
|
||||
connectionFactory: fakeConnectionFactory,
|
||||
localTransport: TRANSPORT_2,
|
||||
remoteTransports$: constant(
|
||||
new Epoch([serviceUrlTransport, urlAndServiceUrlTransport]),
|
||||
),
|
||||
logger,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
});
|
||||
|
||||
expectObservable(
|
||||
connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
|
||||
).toBe("a", {
|
||||
a: expect.toSatisfy(({ value: cs }: Epoch<Connection[]>) => {
|
||||
// Two connections total: a local connection and a shared remote connection
|
||||
expect(cs.length).toBe(2);
|
||||
expect(findConnection(serviceUrlTransport, cs)).toBeDefined();
|
||||
expect(findConnection(serviceUrlTransport, cs)).toBe(
|
||||
findConnection(urlAndServiceUrlTransport, cs),
|
||||
);
|
||||
return true;
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Should start connection only once", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
const connectionMap$ = createConnectionMap$({
|
||||
@@ -432,7 +344,7 @@ describe("ConnectionMap", () => {
|
||||
data.add({ ...connection } as Connection, [p("b")]);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'second connection to ["openID","https://lk.example.org"]: existing [a], adding [b]',
|
||||
'second connection to [null,"https://lk.example.org",null]: existing [a], adding [b]',
|
||||
),
|
||||
);
|
||||
expect(data.getParticipantsForTransport(TRANSPORT_1)).toHaveLength(2);
|
||||
|
||||
@@ -18,11 +18,7 @@ import {
|
||||
type ObservableScope,
|
||||
} from "../../reactive/ObservableScope";
|
||||
import { generateItemsWithEpoch } from "../../reactive/observable";
|
||||
import {
|
||||
chooseAuthFlow,
|
||||
type ConnectionRole,
|
||||
type TransportLocator,
|
||||
} from "./auth";
|
||||
import { type ConnectionRole, type TransportLocator } from "./auth";
|
||||
import { type Connection } from "./Connection";
|
||||
import { type ConnectionFactory } from "./ConnectionFactory";
|
||||
|
||||
@@ -30,25 +26,22 @@ import { type ConnectionFactory } from "./ConnectionFactory";
|
||||
export type TransportKey = string & { __transportKey: true };
|
||||
|
||||
/**
|
||||
* Converts a {@link TransportLocator} to a key suitable for identifying it in a
|
||||
* map of connections.
|
||||
* 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 {
|
||||
// Base the key on the auth flow we would use, so that we never create two
|
||||
// connections that share the same auth details. (That would be bad, as we
|
||||
// might then try to connect to the same SFU twice with the same participant
|
||||
// ID, resulting in only one working connection.)
|
||||
// TODO: This strategy will not work once `chooseAuthFlow` begins allowing for
|
||||
// falling back from the CS API flow to the OpenID flow.
|
||||
const flow = chooseAuthFlow(transport);
|
||||
const hasUrl = "url" in transport;
|
||||
// Serialize the fields in a consistent order
|
||||
return JSON.stringify([
|
||||
flow.kind,
|
||||
// With the CS API flow, we additionally need to distinguish server names
|
||||
...(flow.kind === "csApi" ? [flow.url, serverName] : [flow.serviceUrl]),
|
||||
hasUrl ? transport.url : null,
|
||||
"livekit_service_url" in transport ? transport.livekit_service_url : null,
|
||||
hasUrl ? serverName : null,
|
||||
]) as TransportKey;
|
||||
}
|
||||
|
||||
@@ -108,8 +101,11 @@ export class ConnectionMap {
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
connectionFactory: ConnectionFactory;
|
||||
/** The transport we publish on; the one connection with the publisher role. */
|
||||
localTransport: TransportLocator;
|
||||
/**
|
||||
* The transport we publish on; the one connection with the publisher role.
|
||||
* Null for a backend that only subscribes, while another backend publishes.
|
||||
*/
|
||||
localTransport: TransportLocator | null;
|
||||
/** Every transport some member is on, deduplicated against the local one. */
|
||||
remoteTransports$: Behavior<Epoch<TransportLocator[]>>;
|
||||
logger: Logger;
|
||||
@@ -135,7 +131,11 @@ export function createConnectionMap$({
|
||||
scope.behavior(
|
||||
remoteTransports$.pipe(
|
||||
mapEpoch((remoteTransports) =>
|
||||
removeDuplicateTransports([localTransport, ...remoteTransports]),
|
||||
removeDuplicateTransports(
|
||||
localTransport
|
||||
? [localTransport, ...remoteTransports]
|
||||
: remoteTransports,
|
||||
),
|
||||
),
|
||||
tap((transports) =>
|
||||
logger.debug("localAndRemoteTransports$ = ", transports),
|
||||
@@ -143,7 +143,7 @@ export function createConnectionMap$({
|
||||
),
|
||||
);
|
||||
|
||||
const localTransportKey = keyFor(localTransport);
|
||||
const localTransportKey = localTransport && keyFor(localTransport);
|
||||
|
||||
const connections$ = scope.behavior(
|
||||
localAndRemoteTransports$.pipe(
|
||||
|
||||
@@ -60,10 +60,7 @@ import {
|
||||
import { toMediaConnectionState } from "./connectionState";
|
||||
import { createDataChannel$ } from "./DataChannel";
|
||||
import { createKeyProvider } from "./KeyProvider";
|
||||
import {
|
||||
createLivekitMemberMedia,
|
||||
participantSourcesEqual,
|
||||
} from "./LivekitMemberMedia";
|
||||
import { createLivekitMemberMedia } from "./LivekitMemberMedia";
|
||||
import { createLivekitLocalMedia } from "./LocalMedia";
|
||||
|
||||
export interface LivekitBackendOptions {
|
||||
@@ -118,9 +115,14 @@ export function createLivekitBackend(
|
||||
);
|
||||
|
||||
// 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
|
||||
// the publisher role sits on one connection, and which one is fixed per map.
|
||||
// Without a local transport the map only subscribes, for when another
|
||||
// backend publishes; boxed so that `mapScoped` builds a map for null too
|
||||
const boxedLocalTransport$ = scope.behavior(
|
||||
localTransport$.pipe(map((localTransport) => ({ localTransport }))),
|
||||
);
|
||||
const connectionMap$ = scope.behavior<Epoch<ConnectionMap>>(
|
||||
mapScoped(scope, localTransport$, (mapScope, localTransport) =>
|
||||
mapScoped(scope, boxedLocalTransport$, (mapScope, { localTransport }) =>
|
||||
createConnectionMap$({
|
||||
scope: mapScope,
|
||||
connectionFactory,
|
||||
@@ -142,12 +144,15 @@ export function createLivekitBackend(
|
||||
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,
|
||||
);
|
||||
// Probed once, when first needed: the answer cannot change within a
|
||||
// session, and a backend that only subscribes never needs it
|
||||
let homeserverProbe: Promise<boolean> | undefined;
|
||||
const homeserverSupportsDelegation = async (): Promise<boolean> =>
|
||||
(homeserverProbe ??= supportsDelegation(
|
||||
`${client.baseUrl}/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`,
|
||||
"homeserver",
|
||||
logger,
|
||||
));
|
||||
|
||||
const ownServerName = serverNameOf(ownMembershipIdentity.userId);
|
||||
|
||||
@@ -169,8 +174,9 @@ export function createLivekitBackend(
|
||||
);
|
||||
canDelegate = Promise.resolve(false);
|
||||
} else {
|
||||
// Check for support using the same auth flow that we intend to use later
|
||||
const flow = chooseAuthFlow(transport);
|
||||
// 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(
|
||||
@@ -178,7 +184,7 @@ export function createLivekitBackend(
|
||||
`transport ${flow.serviceUrl}`,
|
||||
logger,
|
||||
)
|
||||
: homeserverSupportsDelegation;
|
||||
: homeserverSupportsDelegation();
|
||||
}
|
||||
localTransport$.next(locator);
|
||||
// The membership is only sent once the publisher connection holds its
|
||||
@@ -236,28 +242,22 @@ export function createLivekitBackend(
|
||||
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 participants = connections
|
||||
const participant = connections
|
||||
.getParticipantsForTransport(locator)
|
||||
.filter((p) => backendIdentities.includes(p.identity));
|
||||
return connection && participants.length
|
||||
? { participants, room: connection.livekitRoom }
|
||||
.find((p) => backendIdentities.includes(p.identity));
|
||||
return connection && participant
|
||||
? { participant, room: connection.livekitRoom }
|
||||
: null;
|
||||
}),
|
||||
distinctUntilChanged(participantSourcesEqual),
|
||||
distinctUntilChanged(
|
||||
(a, b) => a?.participant === b?.participant && a?.room === b?.room,
|
||||
),
|
||||
),
|
||||
);
|
||||
source$.pipe(memberScope.bind()).subscribe((source) => {
|
||||
const transportString = locator$.value
|
||||
? JSON.stringify(locator$.value.transport)
|
||||
: "no transport";
|
||||
if (source)
|
||||
logger.info(
|
||||
`${member.rtcBackendIdentity}: ${source.participants.length} LiveKit participant(s) matched (${source.participants.map((p) => p.sid).join(", ")}) on ${transportString}`,
|
||||
);
|
||||
else
|
||||
logger.info(
|
||||
`${member.rtcBackendIdentity}: LiveKit participant missing on ${transportString}`,
|
||||
);
|
||||
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);
|
||||
};
|
||||
@@ -279,7 +279,7 @@ export function createLivekitBackend(
|
||||
});
|
||||
|
||||
return {
|
||||
transportTypes: ["m.livekit", "livekit"],
|
||||
transportType: "livekit",
|
||||
prepareLocalTransport,
|
||||
delegateDelayedLeave: delegateLeave,
|
||||
local: localMedia_,
|
||||
|
||||
@@ -25,7 +25,7 @@ describe("createLivekitMemberMedia", () => {
|
||||
const { participant, publications, room, emitter } = fakes();
|
||||
const media = createLivekitMemberMedia(
|
||||
testScope(),
|
||||
constant({ participants: [participant], room }),
|
||||
constant({ participant, room }),
|
||||
{ kind: E2eeType.NONE },
|
||||
);
|
||||
expect(media.tracks$.value).toEqual([]);
|
||||
@@ -52,7 +52,7 @@ describe("createLivekitMemberMedia", () => {
|
||||
const { participant, publications, room, emitter } = fakes();
|
||||
const media = createLivekitMemberMedia(
|
||||
testScope(),
|
||||
constant({ participants: [participant], room }),
|
||||
constant({ participant, room }),
|
||||
{ kind: E2eeType.NONE },
|
||||
);
|
||||
emitter.emit(
|
||||
|
||||
@@ -16,20 +16,13 @@ import {
|
||||
RoomEvent,
|
||||
type TrackPublication,
|
||||
} from "livekit-client";
|
||||
import {
|
||||
EMPTY,
|
||||
filter,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
switchMap,
|
||||
combineLatest,
|
||||
} from "rxjs";
|
||||
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,
|
||||
@@ -40,27 +33,13 @@ import {
|
||||
createLivekitMediaTrack,
|
||||
createLocalLivekitMediaTrack,
|
||||
} from "./LivekitMediaTrack";
|
||||
import { shallowEquals as arrayShallowEquals } from "../../utils/array";
|
||||
|
||||
/** Where a member's media comes from: its participant on a room, null while it has none. */
|
||||
export type ParticipantSource<P extends Participant = Participant> = {
|
||||
participants: P[];
|
||||
participant: P;
|
||||
room: LivekitRoom;
|
||||
} | null;
|
||||
|
||||
export function participantSourcesEqual<P extends Participant = Participant>(
|
||||
a: ParticipantSource<P>,
|
||||
b: ParticipantSource<P>,
|
||||
): boolean {
|
||||
return (
|
||||
a === b ||
|
||||
(a !== null &&
|
||||
b !== null &&
|
||||
arrayShallowEquals(a.participants, b.participants) &&
|
||||
a.room === b.room)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A member's media from its participant. The only place, with
|
||||
* `LivekitMediaTrack`, that reads a LiveKit participant; everything above
|
||||
@@ -120,28 +99,21 @@ function memberTracks$<P extends Participant, T>(
|
||||
) => T,
|
||||
): Behavior<T[] | null> {
|
||||
return scope.behavior(
|
||||
source$.pipe(
|
||||
switchMap((source) => {
|
||||
if (source === null) return of(null);
|
||||
const { participants, room } = source;
|
||||
return combineLatest(
|
||||
participants.map((participant) =>
|
||||
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),
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
(...tracks) => tracks.flat(1),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
).pipe(switchMap((tracks$) => tracks$ ?? of(null))),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,7 +133,7 @@ function encryptionErrors$(
|
||||
filter(
|
||||
(message) =>
|
||||
encryptionSystem.kind === E2eeType.SHARED_KEY ||
|
||||
source.participants.some((p) => message.includes(p.identity)),
|
||||
message.includes(source.participant.identity),
|
||||
),
|
||||
map((message): EncryptionError | undefined =>
|
||||
message.includes("MissingKey")
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type LocalParticipant,
|
||||
type LocalTrackPublication,
|
||||
ParticipantEvent,
|
||||
type Room as LivekitRoom,
|
||||
} from "livekit-client";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
@@ -42,11 +43,7 @@ import { type LocalMediaBackend, MediaConnectionState } from "../api";
|
||||
import { type TransportLocator } from "./auth";
|
||||
import { type Connection } from "./Connection";
|
||||
import { type ConnectionMap } from "./ConnectionMap";
|
||||
import {
|
||||
createLocalLivekitMemberMedia,
|
||||
type ParticipantSource,
|
||||
participantSourcesEqual,
|
||||
} from "./LivekitMemberMedia";
|
||||
import { createLocalLivekitMemberMedia } from "./LivekitMemberMedia";
|
||||
import { livekitSources } from "./LivekitMediaTrack";
|
||||
import { type DesiredMedia, Publisher } from "./Publisher";
|
||||
import { toMediaConnectionState } from "./connectionState";
|
||||
@@ -220,15 +217,17 @@ function participantAndRoom$(
|
||||
scope: ObservableScope,
|
||||
participant$: Behavior<LocalParticipant | null>,
|
||||
connection$: Behavior<Connection | null>,
|
||||
): Behavior<ParticipantSource<LocalParticipant>> {
|
||||
): Behavior<{ participant: LocalParticipant; room: LivekitRoom } | null> {
|
||||
return scope.behavior(
|
||||
combineLatest([participant$, connection$]).pipe(
|
||||
map(([participant, connection]) =>
|
||||
participant && connection
|
||||
? { participants: [participant], room: connection.livekitRoom }
|
||||
? { participant, room: connection.livekitRoom }
|
||||
: null,
|
||||
),
|
||||
distinctUntilChanged(participantSourcesEqual),
|
||||
distinctUntilChanged(
|
||||
(a, b) => a?.participant === b?.participant && a?.room === b?.room,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
|
||||
import { serverNameOfTransport } from "../../utils/serverName";
|
||||
import { isCsApiUnavailable } from "./auth";
|
||||
import * as csApi from "./auth/csApi";
|
||||
import { decodeSFUJWTPayload } from "./auth/types";
|
||||
@@ -60,7 +59,7 @@ export async function findRemotePublishLivekitTransport({
|
||||
if (!isUnstableLivekitTransport(transport) || !("url" in transport))
|
||||
continue;
|
||||
const { url } = transport;
|
||||
const serverName = serverNameOfTransport(transport, membership.userId);
|
||||
const serverName = membership.getTransportServerName(transport);
|
||||
const key = JSON.stringify([url, serverName]);
|
||||
if (tried.has(key)) continue;
|
||||
tried.add(key);
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { afterEach, describe, expect, it, type Mocked, vi } from "vitest";
|
||||
import fetchMock from "fetch-mock";
|
||||
import { MatrixError } from "matrix-js-sdk";
|
||||
import { ConnectionError, MatrixError } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
@@ -144,8 +144,26 @@ describe("getSFUConfig", () => {
|
||||
expect(jwtServiceCalls()).toEqual(["/get_token"]);
|
||||
});
|
||||
|
||||
it("uses OpenID when subscribing 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,
|
||||
transport: urlAndServiceUrlTransport,
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
jwt: csApiJWTToken,
|
||||
livekitAlias: "via CS API",
|
||||
}),
|
||||
);
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
||||
expect(jwtServiceCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to OpenID with url + livekit_service_url transport", async () => {
|
||||
mockJWTService();
|
||||
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
|
||||
expect(
|
||||
await getSFUConfig({
|
||||
...defaultParams,
|
||||
@@ -157,7 +175,25 @@ describe("getSFUConfig", () => {
|
||||
livekitAlias: "via default endpoint",
|
||||
}),
|
||||
);
|
||||
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
|
||||
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
|
||||
expect(jwtServiceCalls()).toEqual(["/get_token"]);
|
||||
});
|
||||
|
||||
it("falls back to OpenID when the widget host refuses the CS API", async () => {
|
||||
mockJWTService();
|
||||
// In widget mode a host without MSC4533 rejects the request before it
|
||||
// reaches the homeserver, with a plain error rather than a Matrix error
|
||||
client._unstable_getLivekitToken.mockRejectedValueOnce(
|
||||
new Error(
|
||||
"The rtc_livekit_get_token action is not supported by the client.",
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await getSFUConfig({
|
||||
...defaultParams,
|
||||
transport: urlAndServiceUrlTransport,
|
||||
}),
|
||||
).toEqual(expect.objectContaining({ jwt: defaultJWTToken }));
|
||||
expect(jwtServiceCalls()).toEqual(["/get_token"]);
|
||||
});
|
||||
|
||||
@@ -186,7 +222,7 @@ describe("getSFUConfig", () => {
|
||||
expect(jwtServiceCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not fall back to OpenID when publishing with url transport", async () => {
|
||||
it("does not fall back to OpenID when publishing", async () => {
|
||||
mockJWTService();
|
||||
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
|
||||
await expect(
|
||||
@@ -219,6 +255,33 @@ describe("getSFUConfig", () => {
|
||||
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
|
||||
expect(jwtServiceCalls()).toEqual(["/sfu/get"]);
|
||||
});
|
||||
|
||||
it("does not fall back to OpenID on connection error", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
mockJWTService();
|
||||
const client_ = {
|
||||
...client,
|
||||
_unstable_getLivekitToken: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new ConnectionError("No connection")),
|
||||
};
|
||||
await Promise.all([
|
||||
expect(
|
||||
getSFUConfig({
|
||||
...defaultParams,
|
||||
client: client_,
|
||||
transport: urlAndServiceUrlTransport,
|
||||
}),
|
||||
).rejects.toThrow(expect.any(ConnectionError)),
|
||||
vi.runAllTimersAsync(), // Allow request to be retried
|
||||
]);
|
||||
expect(client_._unstable_getLivekitToken).toHaveBeenCalled();
|
||||
expect(jwtServiceCalls()).toEqual([]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("delegateDelayedLeave", () => {
|
||||
@@ -299,21 +362,46 @@ describe("delegateDelayedLeave", () => {
|
||||
|
||||
describe("chooseAuthFlow", () => {
|
||||
it.each([
|
||||
["url", urlTransport, { kind: "csApi", url: urlTransport.url }],
|
||||
[
|
||||
"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, transport, flow) => {
|
||||
expect(chooseAuthFlow(transport)).toEqual(flow);
|
||||
(_shape, role, transport, flow) => {
|
||||
expect(chooseAuthFlow(transport, role)).toEqual(flow);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -65,10 +65,7 @@ export interface GetSFUConfigParams {
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an {@link SFUConfig} appropriate for connecting to a given transport,
|
||||
* using whichever auth flow is appropriate.
|
||||
*/
|
||||
/** Authenticates with a transport, through whichever flow suits it and our role. */
|
||||
export async function getSFUConfig({
|
||||
client,
|
||||
membership,
|
||||
@@ -79,36 +76,48 @@ export async function getSFUConfig({
|
||||
role,
|
||||
logger,
|
||||
}: GetSFUConfigParams): Promise<SFUConfig> {
|
||||
const flow = chooseAuthFlow(transport);
|
||||
if (flow.kind === "openID") {
|
||||
return await openID.getSFUConfig({
|
||||
const viaOpenID = async (serviceUrl: string): Promise<SFUConfig> =>
|
||||
await openID.getSFUConfig({
|
||||
client,
|
||||
membership,
|
||||
serviceUrl: flow.serviceUrl,
|
||||
serviceUrl,
|
||||
roomId,
|
||||
slotId,
|
||||
role,
|
||||
logger,
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
return await csApi.getSFUConfig({
|
||||
client,
|
||||
membership,
|
||||
url: flow.url,
|
||||
serverName,
|
||||
roomId,
|
||||
slotId,
|
||||
});
|
||||
} catch (e) {
|
||||
if (isEndpointUnsupported(e)) {
|
||||
logger.error(
|
||||
`Homeserver does not support the MSC4195 get_token endpoint, cannot get token for transport ${flow.url}`,
|
||||
);
|
||||
throw new NoMatrix2AuthorizationService(e as Error);
|
||||
}
|
||||
throw e;
|
||||
|
||||
const flow = chooseAuthFlow(transport, role);
|
||||
if (flow.kind === "openID") return await viaOpenID(flow.serviceUrl);
|
||||
|
||||
try {
|
||||
return await csApi.getSFUConfig({
|
||||
client,
|
||||
membership,
|
||||
url: flow.url,
|
||||
serverName,
|
||||
roomId,
|
||||
slotId,
|
||||
});
|
||||
} catch (e) {
|
||||
// A publisher never falls back: a homeserver that advertises a `url` but
|
||||
// does not implement MSC4195 is misconfigured, and the admin should hear
|
||||
// about it before `livekit_service_url` is deprecated
|
||||
const mayFallBack =
|
||||
role === "subscriber" && "livekit_service_url" in transport;
|
||||
if (mayFallBack && isCsApiUnavailable(e)) {
|
||||
logger.warn(
|
||||
`Cannot use the MSC4195 get_token endpoint (${e}), falling back to the OpenID flow with service ${transport.livekit_service_url}`,
|
||||
);
|
||||
return await viaOpenID(transport.livekit_service_url);
|
||||
}
|
||||
if (isEndpointUnsupported(e)) {
|
||||
logger.error(
|
||||
`Homeserver does not support the MSC4195 get_token endpoint, cannot get token for transport ${flow.url}`,
|
||||
);
|
||||
throw new NoMatrix2AuthorizationService(e as Error);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +149,7 @@ export async function delegateDelayedLeave({
|
||||
delayTimeoutMs,
|
||||
logger,
|
||||
}: DelegateDelayedLeaveParams): Promise<void> {
|
||||
const flow = chooseAuthFlow(transport);
|
||||
const flow = chooseAuthFlow(transport, "publisher");
|
||||
if (flow.kind === "csApi") {
|
||||
try {
|
||||
await csApi.delegateDelayedLeave({
|
||||
@@ -181,18 +190,23 @@ export type AuthFlow =
|
||||
| { kind: "openID"; serviceUrl: string };
|
||||
|
||||
/**
|
||||
* Chooses which auth flow to use for a given transport. For now this returns a
|
||||
* single auth flow, with no provision for a fallback behavior.
|
||||
* Chooses between the MSC4195 client-server API and the OpenID flow against
|
||||
* the transport's JWT service.
|
||||
*
|
||||
* Only the JWT service's `/sfu/get` endpoint assigns the legacy participant
|
||||
* identity that clients predating MSC4195 expect from anyone advertising a
|
||||
* membership, so a publisher keeps using the JWT service wherever the
|
||||
* transport still offers one, even next to a `url`. A subscriber may use the
|
||||
* client-server API regardless, as nobody matches its identity.
|
||||
*/
|
||||
export function chooseAuthFlow(transport: UnstableLivekitTransport): AuthFlow {
|
||||
if ("url" in transport && !("livekit_service_url" in transport))
|
||||
return { kind: "csApi", url: transport.url };
|
||||
// All other transports use the OpenID flow, even if they contain a `url`.
|
||||
// This is necessary since the auth flow determines the participant identity
|
||||
// format, and we still have older clients out in the wild which would not yet
|
||||
// know to look for a hashed participant identity in case `url` is present in
|
||||
// someone else's transport.
|
||||
// TODO: Once users have updated their clients, choose the CS API flow if
|
||||
// available, keeping OpenID as a fallback.
|
||||
export function chooseAuthFlow(
|
||||
transport: UnstableLivekitTransport,
|
||||
role: ConnectionRole,
|
||||
): AuthFlow {
|
||||
if ("url" in transport) {
|
||||
const useCsApi =
|
||||
!("livekit_service_url" in transport) || role === "subscriber";
|
||||
if (useCsApi) return { kind: "csApi", url: transport.url };
|
||||
}
|
||||
return { kind: "openID", serviceUrl: transport.livekit_service_url };
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Determine whether two arrays are equal by shallow comparison.
|
||||
*/
|
||||
export function shallowEquals<A>(first: A[], second: A[]): boolean {
|
||||
if (first.length !== second.length) return false;
|
||||
for (let i = 0; i < first.length; i++)
|
||||
if (first[i] !== second[i]) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -36,15 +36,20 @@ Please see LICENSE in the repository root for full details.
|
||||
}
|
||||
}
|
||||
|
||||
/* The width design sets: a long device name wraps. */
|
||||
.menu {
|
||||
inline-size: 296px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Its share of the measured call area: the menu is portalled out of the root,
|
||||
so CSS can't size it against the call. */
|
||||
/* Only the device lists scroll, bounded by the measured call area: the menu is
|
||||
portalled out of the root, so CSS can't size it against the call. */
|
||||
.deviceList {
|
||||
overflow-y: auto;
|
||||
min-block-size: 0;
|
||||
max-block-size: var(--device-list-max-height);
|
||||
/* Keeps a row reached by keyboard clear of the sticky heading and meter. */
|
||||
scroll-padding-block: var(--device-list-scroll-padding-start, 0)
|
||||
var(--device-list-scroll-padding-end, 0);
|
||||
}
|
||||
|
||||
/* Compound's heading rule heads each section; the design adds space below it. */
|
||||
@@ -57,6 +62,25 @@ Please see LICENSE in the repository root for full details.
|
||||
margin-block-start: var(--cpd-space-7x);
|
||||
}
|
||||
|
||||
/* Sticky within its own section. Opaque and inset a border width so it doesn't
|
||||
paint over the menu's outline. */
|
||||
.sectionHeading {
|
||||
position: sticky;
|
||||
inset-block-start: 0;
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
margin-inline: var(--cpd-border-width-1);
|
||||
margin-block-start: var(--cpd-border-width-1);
|
||||
}
|
||||
|
||||
/* Sticky at the foot of the microphone section; opaque and inset as above. */
|
||||
.stickyMeter {
|
||||
position: sticky;
|
||||
inset-block-end: 0;
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
margin-inline: var(--cpd-border-width-1);
|
||||
margin-block-end: var(--cpd-border-width-1);
|
||||
}
|
||||
|
||||
/* Radix focuses whatever the pointer is over, so the browser's ring would
|
||||
follow the mouse. Replaced by one shown only for keyboard focus. */
|
||||
.menu [role^="menuitem"]:focus,
|
||||
|
||||
@@ -65,10 +65,8 @@ const WithACallArea: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
<div
|
||||
ref={setCallArea}
|
||||
style={{
|
||||
// A call's size: anything smaller makes a short device list scroll, and
|
||||
// a narrower one narrows the menu.
|
||||
// A call's size: anything smaller makes a short device list scroll.
|
||||
blockSize: 720,
|
||||
inlineSize: 1024,
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "center",
|
||||
@@ -99,10 +97,6 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// By place: Compound's section heading is aria-hidden, as its group carries
|
||||
// the name.
|
||||
const SECTION_HEADING = "[role='group'] > [aria-hidden]";
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
iconsAndLabels: "audio",
|
||||
@@ -225,8 +219,9 @@ export const SpeakerAndMicrophoneSections: Story = {
|
||||
await expect(
|
||||
document.body.querySelectorAll("[role='separator']"),
|
||||
).toHaveLength(0);
|
||||
const headings =
|
||||
document.body.querySelectorAll<HTMLElement>(SECTION_HEADING);
|
||||
const headings = document.body.querySelectorAll<HTMLElement>(
|
||||
`.${styles.sectionHeading}`,
|
||||
);
|
||||
await expect(headings).toHaveLength(2);
|
||||
const frame = menu.getBoundingClientRect();
|
||||
for (const heading of headings) {
|
||||
@@ -261,37 +256,6 @@ export const SpeakerAndMicrophoneSections: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** A long name wraps rather than widening the menu, which design sets. */
|
||||
export const LongDeviceNameWraps: Story = {
|
||||
args: {
|
||||
...SpeakerAndMicrophoneSections.args,
|
||||
outputOptions: [
|
||||
{
|
||||
label: {
|
||||
type: "name",
|
||||
name: "Logitech BRIO 4K Ultra HD Pro Business Webcam Speakers (046d:085e)",
|
||||
},
|
||||
id: "long",
|
||||
},
|
||||
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
||||
],
|
||||
selectedOutputOption: "long",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
const body = within(document.body);
|
||||
const long = await body.findByRole("menuitemradio", { name: /Logitech/ });
|
||||
const short = await body.findByRole("menuitemradio", { name: "Headset" });
|
||||
|
||||
const menu = document.body.querySelector("[role='menu']")!;
|
||||
await expect(Math.round(menu.getBoundingClientRect().width)).toBe(296);
|
||||
await expect(long.getBoundingClientRect().height).toBeGreaterThan(
|
||||
short.getBoundingClientRect().height,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const OutputCannotBeChosen: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
@@ -438,14 +402,14 @@ export const ManyDevices: Story = {
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
const menu = within(document.body);
|
||||
|
||||
// The meter's box rather than its level: without a fake microphone (as on
|
||||
// The sticky wrapper rather than the meter: without a fake microphone (as on
|
||||
// WebKit) the meter shows a message instead of a level.
|
||||
const list = document.body.querySelector<HTMLElement>(
|
||||
`.${styles.deviceList}`,
|
||||
)!;
|
||||
const sticky = await waitFor(() => {
|
||||
const element = document.body.querySelector<HTMLElement>(
|
||||
`.${meterStyles.meter}`,
|
||||
`.${styles.stickyMeter}`,
|
||||
);
|
||||
if (element === null) throw new Error("the meter has not rendered yet");
|
||||
return element;
|
||||
@@ -472,6 +436,10 @@ export const ManyDevices: Story = {
|
||||
.getBoundingClientRect();
|
||||
await expect(frame.top).toBeGreaterThanOrEqual(0);
|
||||
await expect(frame.bottom).toBeLessThanOrEqual(window.innerHeight + 1);
|
||||
|
||||
// The meter is the menu's one opaque part, so it must stay inside the frame.
|
||||
await expect(pinned.left).toBeGreaterThan(frame.left);
|
||||
await expect(pinned.right).toBeLessThan(frame.right);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -568,7 +536,7 @@ export const KeyboardReachesEveryDevice: Story = {
|
||||
|
||||
const meter = await waitFor(() => {
|
||||
const element = document.body.querySelector<HTMLElement>(
|
||||
`.${meterStyles.meter}`,
|
||||
`.${styles.stickyMeter}`,
|
||||
);
|
||||
if (element === null) throw new Error("the meter has not rendered yet");
|
||||
return element;
|
||||
@@ -577,7 +545,9 @@ export const KeyboardReachesEveryDevice: Story = {
|
||||
// Drawn over the list: the sticky headings and the meter.
|
||||
const overlays = [
|
||||
meter,
|
||||
...document.body.querySelectorAll<HTMLElement>(SECTION_HEADING),
|
||||
...document.body.querySelectorAll<HTMLElement>(
|
||||
`.${styles.sectionHeading}`,
|
||||
),
|
||||
];
|
||||
const items = within(document.body).getAllByRole("menuitemradio");
|
||||
|
||||
@@ -621,7 +591,10 @@ export const HeadingsStayWhileScrolling: Story = {
|
||||
`.${styles.deviceList}`,
|
||||
)!;
|
||||
const group = await menu.findByRole("group", { name: "Microphone" });
|
||||
const heading = group.querySelector<HTMLElement>(":scope > [aria-hidden]")!;
|
||||
// By class: the heading is aria-hidden, and its group carries the name.
|
||||
const heading = group.querySelector<HTMLElement>(
|
||||
`.${styles.sectionHeading}`,
|
||||
)!;
|
||||
|
||||
// Far enough that the heading is only on screen if it is stuck there.
|
||||
list.scrollTop +=
|
||||
@@ -634,6 +607,13 @@ export const HeadingsStayWhileScrolling: Story = {
|
||||
await expect(heading.getBoundingClientRect().bottom).toBeGreaterThan(
|
||||
scrollport.top,
|
||||
);
|
||||
// Clear of the menu's frame.
|
||||
const frame = document.body
|
||||
.querySelector("[role='menu']")!
|
||||
.getBoundingClientRect();
|
||||
await expect(heading.getBoundingClientRect().left).toBeGreaterThan(
|
||||
frame.left,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -17,8 +17,7 @@ import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuScrollArea,
|
||||
MenuScrollSection,
|
||||
MenuTitle,
|
||||
RadioInput,
|
||||
ToggleMenuItem,
|
||||
} from "@vector-im/compound-web";
|
||||
@@ -85,9 +84,6 @@ const LIST_SHARE_OF_CALL = 0.6;
|
||||
/** Smallest device list height in px, so a short call still shows more than one device. */
|
||||
const MIN_LIST_HEIGHT = 160;
|
||||
|
||||
/** Space kept between the menu and the call area's sides. */
|
||||
const MENU_MARGIN = 16;
|
||||
|
||||
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
enabled,
|
||||
busy,
|
||||
@@ -161,18 +157,18 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
return (): void => subscription.unsubscribe();
|
||||
}, [menuOpen, rootElement]);
|
||||
|
||||
// Kept clear at the list's foot, so a row reached by keyboard isn't under
|
||||
// the meter.
|
||||
const [meterHeight, meter] = useMeasuredHeight();
|
||||
|
||||
// Likewise at its head, for the sticky headings.
|
||||
const [headingHeight, heading] = useMeasuredHeight();
|
||||
|
||||
useEffect(() => {
|
||||
if (menuOpen) devices.requestDeviceNames(); // No-op after the first call
|
||||
}, [menuOpen, devices]);
|
||||
|
||||
const audioMeter = iconsAndLabels === "audio" && (
|
||||
<LiveMicrophoneLevelMeter
|
||||
deviceId={selectedOption}
|
||||
// Only while open, so the microphone isn't held all call.
|
||||
active={menuOpen}
|
||||
/>
|
||||
);
|
||||
const MuteButton = audioMeter ? MicButton : VideoButton;
|
||||
const MuteButton = iconsAndLabels === "audio" ? MicButton : VideoButton;
|
||||
const button = (
|
||||
<MuteButton
|
||||
enabled={enabled ?? false}
|
||||
@@ -183,7 +179,9 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
e.stopPropagation();
|
||||
}}
|
||||
disabled={isBusy || onMuteClick === undefined}
|
||||
data-testid={audioMeter ? "incall_mute" : "incall_videomute"}
|
||||
data-testid={
|
||||
iconsAndLabels === "audio" ? "incall_mute" : "incall_videomute"
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -328,8 +326,6 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
open={menuOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
side="top"
|
||||
collisionBoundary={rootElement}
|
||||
collisionPadding={MENU_MARGIN}
|
||||
trigger={
|
||||
<Button
|
||||
iconOnly
|
||||
@@ -344,41 +340,66 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MenuScrollArea
|
||||
<div
|
||||
ref={trackFocusSource}
|
||||
// Keeps the items the menu's own children for assistive tech.
|
||||
role="none"
|
||||
className={styles.deviceList}
|
||||
data-testid="device-list"
|
||||
style={
|
||||
{
|
||||
"--device-list-max-height":
|
||||
listMaxHeight === undefined ? undefined : `${listMaxHeight}px`,
|
||||
"--device-list-scroll-padding-end":
|
||||
meterHeight === undefined ? undefined : `${meterHeight}px`,
|
||||
"--device-list-scroll-padding-start":
|
||||
headingHeight === undefined ? undefined : `${headingHeight}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{audioMeter && speakerOptions && (
|
||||
<MenuScrollSection title={t("settings.devices.speaker")}>
|
||||
{deviceItems(
|
||||
"output",
|
||||
speakerOptions,
|
||||
selectedSpeaker,
|
||||
onSelectOutput,
|
||||
(n) => t("settings.devices.speaker_numbered", { n }),
|
||||
)}
|
||||
</MenuScrollSection>
|
||||
{iconsAndLabels === "audio" && speakerOptions && (
|
||||
<>
|
||||
{/* A menu may only contain items, separators and groups, so each
|
||||
heading is a hidden part of a named group. */}
|
||||
<div role="group" aria-label={t("settings.devices.speaker")}>
|
||||
<div aria-hidden className={styles.sectionHeading}>
|
||||
<MenuTitle title={t("settings.devices.speaker")} />
|
||||
</div>
|
||||
{deviceItems(
|
||||
"output",
|
||||
speakerOptions,
|
||||
selectedSpeaker,
|
||||
onSelectOutput,
|
||||
(n) => t("settings.devices.speaker_numbered", { n }),
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<MenuScrollSection
|
||||
title={optionsButtonLabel}
|
||||
stickyBottomItem={audioMeter}
|
||||
>
|
||||
{deviceItems(
|
||||
"input",
|
||||
options,
|
||||
selectedOption,
|
||||
onSelect,
|
||||
numberedLabel,
|
||||
)}
|
||||
</MenuScrollSection>
|
||||
</MenuScrollArea>
|
||||
<div role="group" aria-label={optionsButtonLabel}>
|
||||
<div ref={heading} aria-hidden className={styles.sectionHeading}>
|
||||
<MenuTitle title={optionsButtonLabel} />
|
||||
</div>
|
||||
{/* Apart from the heading, so the sticky meter can't ride over it. */}
|
||||
<div role="none">
|
||||
{deviceItems(
|
||||
"input",
|
||||
options,
|
||||
selectedOption,
|
||||
onSelect,
|
||||
numberedLabel,
|
||||
)}
|
||||
{iconsAndLabels === "audio" && (
|
||||
<LiveMicrophoneLevelMeter
|
||||
ref={meter}
|
||||
deviceId={selectedOption}
|
||||
// Only while open, so the microphone isn't held all call.
|
||||
active={menuOpen}
|
||||
className={styles.stickyMeter}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{toggles.length > 0 && <hr />}
|
||||
{toggles.map((toggle) => (
|
||||
<ToggleMenuItem
|
||||
@@ -395,3 +416,25 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Follows an element's height. */
|
||||
function useMeasuredHeight(): [
|
||||
number | undefined,
|
||||
(element: HTMLElement | null) => (() => void) | undefined,
|
||||
] {
|
||||
const [height, setHeight] = useState<number>();
|
||||
const ref = useCallback(
|
||||
(element: HTMLElement | null): (() => void) | undefined => {
|
||||
if (element === null) return;
|
||||
const subscription = observeElementSize$(element)
|
||||
.pipe(
|
||||
map((size) => size.height),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
.subscribe(setHeight);
|
||||
return (): void => subscription.unsubscribe();
|
||||
},
|
||||
[],
|
||||
);
|
||||
return [height, ref];
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@ Please see LICENSE in the repository root for full details.
|
||||
align-items: center;
|
||||
gap: var(--cpd-space-3x);
|
||||
padding-block: var(--cpd-space-2x);
|
||||
/* Centres the icon on the radio column: less half the icon's extra width.
|
||||
Checked in the MeterAlignsWithTheDeviceRows story. */
|
||||
/* Centres the icon on the radio column: less the row's inset border and half
|
||||
the icon's extra width. Checked in the MeterAlignsWithTheDeviceRows story. */
|
||||
padding-inline-start: calc(
|
||||
var(--cpd-space-4x) -
|
||||
var(--cpd-space-4x) - var(--cpd-border-width-1) -
|
||||
(var(--meter-icon-size) - var(--device-control-size)) / 2
|
||||
);
|
||||
padding-inline-end: calc(var(--cpd-space-4x) * 2 + var(--cpd-space-2x));
|
||||
|
||||
@@ -5,9 +5,17 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useCallback, useLayoutEffect, useRef, useState, type FC } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type FC,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import { Text } from "@vector-im/compound-web";
|
||||
import { MicOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { distinctUntilChanged, map } from "rxjs";
|
||||
@@ -22,6 +30,8 @@ export interface LiveMicrophoneLevelMeterProps {
|
||||
deviceId: string | undefined;
|
||||
/** Whether to hold a capture at all. */
|
||||
active: boolean;
|
||||
className?: string;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,18 +41,24 @@ export interface LiveMicrophoneLevelMeterProps {
|
||||
export const LiveMicrophoneLevelMeter: FC<LiveMicrophoneLevelMeterProps> = ({
|
||||
deviceId,
|
||||
active,
|
||||
className,
|
||||
ref,
|
||||
}) => {
|
||||
const state = useMicrophoneLevel(deviceId, active);
|
||||
return <MicrophoneLevelMeter state={state} />;
|
||||
return <MicrophoneLevelMeter state={state} className={className} ref={ref} />;
|
||||
};
|
||||
|
||||
export interface MicrophoneLevelMeterProps {
|
||||
state: MicrophoneState;
|
||||
className?: string;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/** The input level of a microphone, or why there is none. */
|
||||
export const MicrophoneLevelMeter: FC<MicrophoneLevelMeterProps> = ({
|
||||
state,
|
||||
className,
|
||||
ref,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// Bars keep one size, so their count follows the width. Starts full so the
|
||||
@@ -88,7 +104,7 @@ export const MicrophoneLevelMeter: FC<MicrophoneLevelMeterProps> = ({
|
||||
}, [state, barCount, t]);
|
||||
|
||||
return (
|
||||
<div className={styles.meter}>
|
||||
<div ref={ref} className={classNames(styles.meter, className)}>
|
||||
<MicOnIcon className={styles.icon} aria-hidden />
|
||||
{state.type !== "level" ? (
|
||||
<Text size="sm" className={styles.message}>
|
||||
|
||||
@@ -5,24 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { E2eeType } from "@element-hq/matrixrtc-sdk";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
getKeyForRoom,
|
||||
saveKeyForRoom,
|
||||
useRoomEncryptionSystem,
|
||||
} from "./sharedKeyManagement";
|
||||
|
||||
vi.mock("../ClientContext", () => {
|
||||
const room = { hasEncryptionStateEvent: (): boolean => false };
|
||||
const client = { getRoom: (): typeof room => room };
|
||||
return { useClient: (): { client: typeof client } => ({ client }) };
|
||||
});
|
||||
import { getKeyForRoom, saveKeyForRoom } from "./sharedKeyManagement";
|
||||
|
||||
const roomId = "!room:example.org";
|
||||
const otherRoomId = "!other:example.org";
|
||||
|
||||
describe("getKeyForRoom", () => {
|
||||
afterEach(() => {
|
||||
@@ -46,58 +33,4 @@ describe("getKeyForRoom", () => {
|
||||
test("has no key to offer for a room it has never seen", () => {
|
||||
expect(getKeyForRoom(roomId)).toBeNull();
|
||||
});
|
||||
|
||||
test("prefers the stored key when the parameters are for another room", () => {
|
||||
saveKeyForRoom(roomId, "stored");
|
||||
window.location.hash = `#?roomId=${encodeURIComponent(otherRoomId)}&password=other-room`;
|
||||
|
||||
expect(getKeyForRoom(roomId)).toBe("stored");
|
||||
});
|
||||
|
||||
test("ignores the password in the parameters for another room", () => {
|
||||
window.location.hash = `#?roomId=${encodeURIComponent(otherRoomId)}&password=other-room`;
|
||||
|
||||
expect(getKeyForRoom(roomId)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useRoomEncryptionSystem", () => {
|
||||
afterEach(() => {
|
||||
window.location.hash = "#";
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
test("picks up a key saved for the room after it rendered", () => {
|
||||
const { result } = renderHook(() => useRoomEncryptionSystem(roomId));
|
||||
expect(result.current).toEqual({ kind: E2eeType.NONE });
|
||||
|
||||
act(() => saveKeyForRoom(roomId, "stored"));
|
||||
|
||||
expect(result.current).toEqual({
|
||||
kind: E2eeType.SHARED_KEY,
|
||||
secret: "stored",
|
||||
});
|
||||
});
|
||||
|
||||
test("stores a key from the parameters under the room's key only", () => {
|
||||
window.location.hash = `#?roomId=${encodeURIComponent(roomId)}&password=from-the-link`;
|
||||
|
||||
renderHook(() => useRoomEncryptionSystem(roomId));
|
||||
|
||||
expect(Object.keys(localStorage)).toEqual([`room-shared-key-${roomId}`]);
|
||||
expect(getKeyForRoom(roomId)).toBe("from-the-link");
|
||||
});
|
||||
|
||||
test("keeps the stored key when the parameters are for another room", () => {
|
||||
saveKeyForRoom(roomId, "stored");
|
||||
window.location.hash = `#?roomId=${encodeURIComponent(otherRoomId)}&password=other-room`;
|
||||
|
||||
const { result } = renderHook(() => useRoomEncryptionSystem(roomId));
|
||||
|
||||
expect(result.current).toEqual({
|
||||
kind: E2eeType.SHARED_KEY,
|
||||
secret: "stored",
|
||||
});
|
||||
expect(localStorage.getItem(`room-shared-key-${roomId}`)).toBe("stored");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,27 +59,22 @@ const useRoomSharedKey = (
|
||||
|
||||
/**
|
||||
* The shared key for a room, preferring one supplied in the parameters Element
|
||||
* Call was started with over whatever is in local storage. If the parameters
|
||||
* are for another room, only the stored key is used, because the password in
|
||||
* the parameters belongs to that other room.
|
||||
* Call was started with over whatever is in local storage.
|
||||
*/
|
||||
function keyForRoom(
|
||||
roomId: string,
|
||||
paramsRoomId: string | null,
|
||||
password: string | null,
|
||||
): string | null {
|
||||
const storedKey = localStorage.getItem(
|
||||
getRoomSharedKeyLocalStorageKey(roomId),
|
||||
);
|
||||
if (roomId !== paramsRoomId) {
|
||||
if (roomId !== paramsRoomId)
|
||||
logger.warn(
|
||||
"requested key for a roomId which is not the current call room id (from the URL)",
|
||||
roomId,
|
||||
paramsRoomId,
|
||||
);
|
||||
return storedKey;
|
||||
}
|
||||
return password ?? storedKey;
|
||||
return (
|
||||
password ?? localStorage.getItem(getRoomSharedKeyLocalStorageKey(roomId))
|
||||
);
|
||||
}
|
||||
|
||||
export function getKeyForRoom(roomId: string): string | null {
|
||||
@@ -94,12 +89,16 @@ export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
|
||||
const { roomId: paramsRoomId, password } = useUrlParams();
|
||||
|
||||
const [storedPassword] = useRoomSharedKey(
|
||||
roomId,
|
||||
// TODO: this passes an already-prefixed key where a room ID is expected, so
|
||||
// the local storage key ends up prefixed twice and never matches what
|
||||
// saveKeyForRoom writes. Preserved as-is here to keep this commit a pure
|
||||
// refactor; the reactive read is effectively dead until it is fixed.
|
||||
getRoomSharedKeyLocalStorageKey(roomId),
|
||||
keyForRoom(roomId, paramsRoomId, password) ?? undefined,
|
||||
);
|
||||
|
||||
const room = client?.getRoom(roomId);
|
||||
const e2eeSystem = useMemo<EncryptionSystem>(() => {
|
||||
const e2eeSystem = <EncryptionSystem>useMemo(() => {
|
||||
if (!room) return { kind: E2eeType.NONE };
|
||||
if (storedPassword)
|
||||
return {
|
||||
|
||||
@@ -70,6 +70,7 @@ import { ReactionsOverlay } from "./ReactionsOverlay";
|
||||
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
|
||||
import {
|
||||
createRTCSlot,
|
||||
FULL_MESH_TRANSPORT_TYPE,
|
||||
type RTCParticipationOptions,
|
||||
type RTCSlotOptions,
|
||||
constant,
|
||||
@@ -84,6 +85,7 @@ import {
|
||||
cameraFramerate,
|
||||
cameraResolution,
|
||||
customTransport,
|
||||
fullMeshPublishing,
|
||||
echoCancellationSetting,
|
||||
matrixRTCMode as matrixRTCModeSetting,
|
||||
noiseSuppressionSetting,
|
||||
@@ -823,7 +825,9 @@ export function rtcParticipationOptions(
|
||||
delegatedDelayedLeave: session.delegated_delayed_leave,
|
||||
},
|
||||
mediaQuality: config.media_quality,
|
||||
transport: customTransport.value$.value ?? undefined,
|
||||
transport: fullMeshPublishing.value$.value
|
||||
? { type: FULL_MESH_TRANSPORT_TYPE }
|
||||
: (customTransport.value$.value ?? undefined),
|
||||
fallbackTransport: config.livekit && {
|
||||
type: "livekit",
|
||||
livekit_service_url: config.livekit.livekit_service_url,
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
|
||||
matrixRTCMode as matrixRTCModeSetting,
|
||||
customTransport as customTransportSetting,
|
||||
fullMeshPublishing as fullMeshPublishingSetting,
|
||||
advancedScreenShare as advancedScreenShareSetting,
|
||||
screenShareResolution as screenShareResolutionSetting,
|
||||
screenShareFramerate as screenShareFramerateSetting,
|
||||
@@ -68,6 +69,7 @@ import { Slider } from "../Slider";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
import {
|
||||
authenticateWithTransport,
|
||||
FULL_MESH_TRANSPORT_TYPE,
|
||||
type TransportMetadata,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
@@ -175,6 +177,9 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
const [customTransportUpdateError, setCustomTransportUpdateError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [fullMeshPublishing, setFullMeshPublishing] = useSetting(
|
||||
fullMeshPublishingSetting,
|
||||
);
|
||||
const [customTransport, setCustomTransport] = useSetting(
|
||||
customTransportSetting,
|
||||
);
|
||||
@@ -417,6 +422,17 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="fullMeshPublishing"
|
||||
type="checkbox"
|
||||
checked={fullMeshPublishing}
|
||||
label={t("developer_mode.full_mesh_publishing")}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>): void =>
|
||||
setFullMeshPublishing(event.target.checked)
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="debugTileLayout"
|
||||
@@ -665,11 +681,21 @@ function transportText(transport: UnstableLivekitTransport | null): string {
|
||||
const TransportBox: FC<TransportInfo> = ({ transport, local }) => {
|
||||
const { t } = useTranslation();
|
||||
const resolved = useBehavior(transport.resolved$);
|
||||
const fullMesh = transport.type === FULL_MESH_TRANSPORT_TYPE;
|
||||
return (
|
||||
<div className={styles.livekit_room_box}>
|
||||
<h4>{t("developer_mode.livekit_sfu", { url: transport.id })}</h4>
|
||||
<h4>
|
||||
{fullMesh
|
||||
? t("developer_mode.full_mesh_transport", {
|
||||
device: transport.raw.device_id,
|
||||
})
|
||||
: t("developer_mode.livekit_sfu", { url: transport.id })}
|
||||
</h4>
|
||||
<p>
|
||||
{t("developer_mode.livekit_server_info")}({local ? "local" : "remote"})
|
||||
{fullMesh
|
||||
? t("developer_mode.full_mesh_peer_connections")
|
||||
: t("developer_mode.livekit_server_info")}
|
||||
({local ? "local" : "remote"})
|
||||
</p>
|
||||
<pre className={styles.pre}>
|
||||
{resolved === undefined
|
||||
|
||||
@@ -51,6 +51,43 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_2_"
|
||||
id="fullMeshPublishing"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
for="fullMeshPublishing"
|
||||
>
|
||||
<div
|
||||
class="_checkbox_1bd8c0"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="#000"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m20 6-11 11-5-5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
Publish on full mesh (MSC4575) instead of LiveKit
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="_fieldRow_1bd8c0"
|
||||
>
|
||||
<div
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_3_"
|
||||
id="debugTileLayout"
|
||||
type="checkbox"
|
||||
/>
|
||||
@@ -87,7 +124,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_3_"
|
||||
aria-describedby="_r_4_"
|
||||
id="showConnectionStats"
|
||||
type="checkbox"
|
||||
/>
|
||||
@@ -124,7 +161,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_4_"
|
||||
aria-describedby="_r_5_"
|
||||
id="muteAllAudio"
|
||||
type="checkbox"
|
||||
/>
|
||||
@@ -162,7 +199,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_5_"
|
||||
aria-describedby="_r_6_"
|
||||
id="alwaysShowIphoneEarpiece"
|
||||
type="checkbox"
|
||||
/>
|
||||
@@ -199,7 +236,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_6_"
|
||||
aria-describedby="_r_7_"
|
||||
id="enableLivekitExtendedLogs"
|
||||
type="checkbox"
|
||||
/>
|
||||
@@ -237,7 +274,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
>
|
||||
<label
|
||||
class="_label_1o4d9_60"
|
||||
for="radix-_r_7_"
|
||||
for="radix-_r_8_"
|
||||
>
|
||||
Custom transport JSON
|
||||
</label>
|
||||
@@ -245,9 +282,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_controls_17lij_8"
|
||||
>
|
||||
<input
|
||||
aria-describedby="radix-_r_8_"
|
||||
aria-describedby="radix-_r_9_"
|
||||
class="_control_d83jn_10"
|
||||
id="radix-_r_7_"
|
||||
id="radix-_r_8_"
|
||||
name="input"
|
||||
title=""
|
||||
value=""
|
||||
@@ -255,7 +292,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
</div>
|
||||
<span
|
||||
class="_message_1o4d9_86 _help-message_1o4d9_92"
|
||||
id="radix-_r_8_"
|
||||
id="radix-_r_9_"
|
||||
>
|
||||
Currently, no overwrite is set. Url from config is used.
|
||||
</span>
|
||||
@@ -285,10 +322,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_container_1ug7n_10"
|
||||
>
|
||||
<input
|
||||
aria-describedby="radix-_r_a_ radix-_r_c_"
|
||||
aria-describedby="radix-_r_b_ radix-_r_d_"
|
||||
checked=""
|
||||
class="_input_1ug7n_18"
|
||||
id="radix-_r_9_"
|
||||
id="radix-_r_a_"
|
||||
name="_r_0_"
|
||||
title=""
|
||||
type="radio"
|
||||
@@ -304,13 +341,13 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
>
|
||||
<label
|
||||
class="_label_1o4d9_60"
|
||||
for="radix-_r_9_"
|
||||
for="radix-_r_a_"
|
||||
>
|
||||
Compatibility: state events & multi SFU
|
||||
</label>
|
||||
<span
|
||||
class="_message_1o4d9_86 _help-message_1o4d9_92"
|
||||
id="radix-_r_a_"
|
||||
id="radix-_r_b_"
|
||||
>
|
||||
Compatible with homeservers that do not support sticky events (but all other EC clients are v0.17.0 or later)
|
||||
</span>
|
||||
@@ -326,9 +363,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_container_1ug7n_10"
|
||||
>
|
||||
<input
|
||||
aria-describedby="radix-_r_a_ radix-_r_c_"
|
||||
aria-describedby="radix-_r_b_ radix-_r_d_"
|
||||
class="_input_1ug7n_18"
|
||||
id="radix-_r_b_"
|
||||
id="radix-_r_c_"
|
||||
name="_r_0_"
|
||||
title=""
|
||||
type="radio"
|
||||
@@ -344,13 +381,13 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
>
|
||||
<label
|
||||
class="_label_1o4d9_60"
|
||||
for="radix-_r_b_"
|
||||
for="radix-_r_c_"
|
||||
>
|
||||
Matrix 2.0: sticky events & multi SFU
|
||||
</label>
|
||||
<span
|
||||
class="_message_1o4d9_86 _help-message_1o4d9_92"
|
||||
id="radix-_r_c_"
|
||||
id="radix-_r_d_"
|
||||
>
|
||||
Compatible only with homservers supporting sticky events and all EC clients v0.17.0 or later
|
||||
</span>
|
||||
@@ -415,7 +452,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_i_"
|
||||
aria-describedby="_r_j_"
|
||||
id="cameraToggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
@@ -445,7 +482,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
</label>
|
||||
<p
|
||||
class="_description_1bd8c0"
|
||||
id="_r_i_"
|
||||
id="_r_j_"
|
||||
>
|
||||
Configure resolution, framerate, bitrate, and codec for camera video. Changes apply on next call join.
|
||||
</p>
|
||||
@@ -467,7 +504,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_j_"
|
||||
aria-describedby="_r_k_"
|
||||
id="screenShareToggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
@@ -497,7 +534,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
</label>
|
||||
<p
|
||||
class="_description_1bd8c0"
|
||||
id="_r_j_"
|
||||
id="_r_k_"
|
||||
>
|
||||
Configure resolution, framerate, bitrate, and codec for screen sharing
|
||||
</p>
|
||||
@@ -522,7 +559,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_k_"
|
||||
aria-describedby="_r_l_"
|
||||
checked=""
|
||||
id="echoCancellation"
|
||||
type="checkbox"
|
||||
@@ -560,7 +597,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_l_"
|
||||
aria-describedby="_r_m_"
|
||||
checked=""
|
||||
id="noiseSuppression"
|
||||
type="checkbox"
|
||||
@@ -598,7 +635,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_m_"
|
||||
aria-describedby="_r_n_"
|
||||
checked=""
|
||||
id="autoGainControl"
|
||||
type="checkbox"
|
||||
|
||||
@@ -159,6 +159,15 @@ export const customTransport = new Setting<UnstableLivekitTransport | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
/**
|
||||
* Publish on the full mesh transport (MSC4575) instead of LiveKit. Members on
|
||||
* either transport are received whatever this says.
|
||||
*/
|
||||
export const fullMeshPublishing = new Setting<boolean>(
|
||||
"full-mesh-publishing",
|
||||
false,
|
||||
);
|
||||
|
||||
export type VideoCodec = "vp8" | "vp9" | "h264" | "av1";
|
||||
|
||||
/**
|
||||
|
||||
@@ -235,35 +235,22 @@ test("audio and video follow the tracks' mute state", () => {
|
||||
|
||||
test("user media polls stream stats only while the setting is on", () => {
|
||||
onTestFinished(() => showConnectionStats.setValue(false));
|
||||
|
||||
withTestScheduler(({ cold, expectObservable, schedule }) => {
|
||||
const stats = { type: "inbound-rtp" } as RTCInboundRtpStreamStats;
|
||||
const vm = mockRemoteMedia(rtcMembership, {}, [
|
||||
mockAudioTrack({ stats$: () => cold("-s", { s: { ...stats } }) }),
|
||||
mockAudioTrack({
|
||||
stats$: () => cold("-s", { s: stats }),
|
||||
}),
|
||||
]);
|
||||
schedule("-t-f-t", {
|
||||
t: () => showConnectionStats.setValue(true),
|
||||
f: () => showConnectionStats.setValue(false),
|
||||
schedule("-a-b", {
|
||||
a() {
|
||||
showConnectionStats.setValue(true);
|
||||
},
|
||||
b() {
|
||||
showConnectionStats.setValue(false);
|
||||
},
|
||||
});
|
||||
|
||||
expectObservable(vm.audioStreamStats$).toBe("u-s---s", {
|
||||
u: undefined,
|
||||
s: stats,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("stream stats can be enabled before creating media", () => {
|
||||
showConnectionStats.setValue(true);
|
||||
onTestFinished(() => showConnectionStats.setValue(false));
|
||||
|
||||
withTestScheduler(({ cold, expectObservable }) => {
|
||||
const stats = { type: "inbound-rtp" } as RTCInboundRtpStreamStats;
|
||||
const vm = mockRemoteMedia(rtcMembership, {}, [
|
||||
mockAudioTrack({ stats$: () => cold("-s", { s: stats }) }),
|
||||
]);
|
||||
|
||||
expectObservable(vm.audioStreamStats$).toBe("us", {
|
||||
expectObservable(vm.audioStreamStats$).toBe("u-su", {
|
||||
u: undefined,
|
||||
s: stats,
|
||||
});
|
||||
|
||||
@@ -16,9 +16,7 @@ import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
map,
|
||||
NEVER,
|
||||
of,
|
||||
startWith,
|
||||
Subject,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
@@ -107,9 +105,10 @@ export function createBaseUserMedia(
|
||||
showConnectionStats.value$,
|
||||
]).pipe(
|
||||
switchMap(([track, show]) =>
|
||||
track !== undefined && show ? track.stats$(statsIntervalMs) : NEVER,
|
||||
track !== undefined && show
|
||||
? track.stats$(statsIntervalMs)
|
||||
: of(undefined),
|
||||
),
|
||||
startWith<MediaStreamStats>(undefined),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@ describe("widget", () => {
|
||||
receiveState,
|
||||
sendToDevice: sendRecvToDevice,
|
||||
receiveToDevice: sendRecvToDevice,
|
||||
turnServers: false,
|
||||
turnServers: true,
|
||||
rtcTransports: true,
|
||||
rtcLivekitGetToken: true,
|
||||
rtcLivekitDelegateDelayedLeave: true,
|
||||
|
||||
+11
-2
@@ -21,8 +21,12 @@ import type { IWidgetApiRequest } from "matrix-widget-api";
|
||||
import { LazyEventEmitter } from "./LazyEventEmitter";
|
||||
import { getUrlParams } from "./UrlParams";
|
||||
import { Config } from "./config/Config";
|
||||
import { seedSettingsFromConfig } from "./settings/settings";
|
||||
import {
|
||||
fullMeshPublishing,
|
||||
seedSettingsFromConfig,
|
||||
} from "./settings/settings";
|
||||
import { ElementCallReactionEventType } from "./reactions";
|
||||
import { fullMeshSignallingEventTypes } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
// Subset of the actions in element-web
|
||||
export enum ElementWidgetActions {
|
||||
@@ -162,6 +166,10 @@ export const initializeWidget = (
|
||||
EventType.CallSDPStreamMetadataChangedPrefix,
|
||||
EventType.CallReplaces,
|
||||
EventType.CallEncryptionKeysPrefix,
|
||||
// Full mesh signalling (MSC4575), only where it is turned on: Element
|
||||
// Web doesn't auto-approve these for Element Call yet, and would ask
|
||||
// the user on every call
|
||||
...(fullMeshPublishing.getValue() ? fullMeshSignallingEventTypes : []),
|
||||
];
|
||||
|
||||
const client = createRoomWidgetClient(
|
||||
@@ -173,7 +181,8 @@ export const initializeWidget = (
|
||||
receiveState,
|
||||
sendToDevice: sendRecvToDevice,
|
||||
receiveToDevice: sendRecvToDevice,
|
||||
turnServers: false,
|
||||
// Full mesh connects through the homeserver's TURN server
|
||||
turnServers: true,
|
||||
rtcTransports: true,
|
||||
rtcLivekitGetToken: true,
|
||||
rtcLivekitDelegateDelayedLeave: true,
|
||||
|
||||
@@ -38,21 +38,8 @@ export default defineConfig(({ mode }) => {
|
||||
...base,
|
||||
root: "component/dev",
|
||||
// The same scoping the library build applies, so the harness shows what a
|
||||
// host will get — including whether its own page is left alone. The host's
|
||||
// own stylesheets, Compound's among them, stay unscoped, as on a real host.
|
||||
css: {
|
||||
postcss: {
|
||||
plugins: [
|
||||
scopeStylesToRoot({
|
||||
exclude: [
|
||||
/component[\\/]dev[\\/]/,
|
||||
/@vector-im[\\/]compound-/,
|
||||
/@fontsource[\\/]/,
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
// host will get — including whether its own page is left alone
|
||||
css: { postcss: { plugins: [scopeStylesToRoot()] } },
|
||||
// So that the harness can read the same config.json the standalone app
|
||||
// does, if the developer has written one
|
||||
publicDir: "../../public",
|
||||
|
||||
@@ -89,7 +89,6 @@ export default defineConfig(({ mode }) => {
|
||||
"react-dom",
|
||||
"react-dom/client",
|
||||
"matrix-js-sdk",
|
||||
"matrix-js-sdk/lib/@types/event",
|
||||
"matrix-js-sdk/lib/browser-index",
|
||||
"matrix-js-sdk/lib/client",
|
||||
"matrix-js-sdk/lib/crypto-api",
|
||||
@@ -100,10 +99,8 @@ export default defineConfig(({ mode }) => {
|
||||
"matrix-js-sdk/lib/matrixrtc/EncryptionManager",
|
||||
"matrix-js-sdk/lib/matrixrtc/IKeyTransport",
|
||||
"matrix-js-sdk/lib/matrixrtc/IMembershipManager",
|
||||
"matrix-js-sdk/lib/models/event",
|
||||
"matrix-js-sdk/lib/models/relations-container",
|
||||
"matrix-js-sdk/lib/models/room",
|
||||
"matrix-js-sdk/lib/models/room-state",
|
||||
"matrix-js-sdk/lib/models/typed-event-emitter",
|
||||
"matrix-js-sdk/lib/randomstring",
|
||||
"matrix-js-sdk/lib/sync",
|
||||
|
||||
Reference in New Issue
Block a user