Compare commits
119 Commits
v37.3.0
...
v37.11.0-rc.0
| Author | SHA1 | Date | |
|---|---|---|---|
| b7cce93edc | |||
| e5c8c20a34 | |||
| 137379b7b7 | |||
| 4981efa54b | |||
| 6e9740d787 | |||
| 70257e0ab4 | |||
| 9baba151c6 | |||
| b4672e26ec | |||
| 940d358b0e | |||
| 161c12f5d5 | |||
| de659d6431 | |||
| b095aa600d | |||
| 18ea8befdc | |||
| 4f9ca2c697 | |||
| 57a4dc8841 | |||
| 841c02e56d | |||
| 6ece4c3c16 | |||
| 5a3c07f91d | |||
| 3ed4b3ed50 | |||
| 9ca9bd9baf | |||
| 0265f6ea2d | |||
| 8f597f0f87 | |||
| 67df2a53c4 | |||
| 8367277894 | |||
| 4efb27354f | |||
| 28bc90563e | |||
| f9be1bf57a | |||
| adaf921623 | |||
| cdece6cb9f | |||
| d438e25f87 | |||
| 73d8f4384d | |||
| b0cb6aa724 | |||
| dfc26f8aa1 | |||
| 286fb8f752 | |||
| 921de6807d | |||
| d4e7b949e7 | |||
| 40bc833bb7 | |||
| a46ad75440 | |||
| 1e80538cfb | |||
| 73cbcfa4ee | |||
| 99972ce0a9 | |||
| 44399f6017 | |||
| c387f30e5c | |||
| eb9867a5ba | |||
| 12a9875c46 | |||
| 74f5efc4ef | |||
| 43d47982ed | |||
| 94fb489952 | |||
| 75ae05e5eb | |||
| 9058b79c39 | |||
| 671dd2ca40 | |||
| 34f35393ff | |||
| e206a12902 | |||
| 76eabb2efa | |||
| bf6dc16ad3 | |||
| 9398271695 | |||
| ef7a818f70 | |||
| b8903ddf3e | |||
| c35c7d1a3b | |||
| 286e00c500 | |||
| 26922a61f3 | |||
| 457a300c95 | |||
| be04f003ce | |||
| 67b445d40d | |||
| 27f28d5558 | |||
| 52bac9648b | |||
| 7e0e5a3243 | |||
| 337d1791cf | |||
| 622998e949 | |||
| 673806ecaa | |||
| b2232289a6 | |||
| 344c8fad9d | |||
| cccaaf6e56 | |||
| 4dbca983b4 | |||
| 54e8f3c9d0 | |||
| 1fcc375dd5 | |||
| d24c5d8b2b | |||
| bb9280ad6b | |||
| 00bd7f0f02 | |||
| a29b8736f3 | |||
| 1c8a1cd5a1 | |||
| fea619d34c | |||
| f322f32a07 | |||
| bc3abd4394 | |||
| 6721aca293 | |||
| 80b51c0e30 | |||
| 91aa1dc092 | |||
| 78972e81d1 | |||
| 663136a95a | |||
| d67b19fa88 | |||
| 6ec200adcf | |||
| 19b1b901f5 | |||
| 1f52fa0c43 | |||
| b08c083c46 | |||
| 69f1bea89b | |||
| a52c64b747 | |||
| de9eb0e7f2 | |||
| a97d7f1133 | |||
| bc399abf6e | |||
| 1085a2469a | |||
| 52a9c52b12 | |||
| ac49137c0e | |||
| a179227bf1 | |||
| 64e27f5d3c | |||
| 634651859b | |||
| 480c8e86a4 | |||
| 1ba4412260 | |||
| e3a3a52f2a | |||
| 3f03c1da89 | |||
| eb793aaa08 | |||
| b3fd92ad16 | |||
| ba71235539 | |||
| d6ede767c9 | |||
| 43f388d89a | |||
| 1d46b182c6 | |||
| 352f6986cd | |||
| 0ca72ec466 | |||
| 375f086f44 | |||
| 5976083e32 |
@@ -22,7 +22,7 @@ runs:
|
||||
|
||||
- name: Upload tarball signature
|
||||
if: ${{ inputs.upload-url }}
|
||||
uses: shogo82148/actions-upload-release-asset@8482bd769644976d847e96fb4b9354228885e7b4 # v1
|
||||
uses: shogo82148/actions-upload-release-asset@610b1987249a69a79de9565777e112fb38f22436 # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ env.VERSION }}.tar.gz.asc
|
||||
|
||||
@@ -29,13 +29,13 @@ runs:
|
||||
|
||||
- name: Upload asset signatures
|
||||
if: inputs.gpg-fingerprint
|
||||
uses: shogo82148/actions-upload-release-asset@8482bd769644976d847e96fb4b9354228885e7b4 # v1
|
||||
uses: shogo82148/actions-upload-release-asset@610b1987249a69a79de9565777e112fb38f22436 # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ inputs.asset-path }}.asc
|
||||
|
||||
- name: Upload assets
|
||||
uses: shogo82148/actions-upload-release-asset@8482bd769644976d847e96fb4b9354228885e7b4 # v1
|
||||
uses: shogo82148/actions-upload-release-asset@610b1987249a69a79de9565777e112fb38f22436 # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ inputs.asset-path }}
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
deployments: write
|
||||
steps:
|
||||
- name: 📥 Download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
path: docs
|
||||
|
||||
- name: 📤 Deploy to Netlify
|
||||
uses: matrix-org/netlify-pr-preview@v3
|
||||
uses: matrix-org/netlify-pr-preview@9805cd123fc9a7e421e35340a05e1ebc5dee46b5 # v3
|
||||
with:
|
||||
path: docs
|
||||
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
name: Preview Changelog
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: mheap/github-action-required-labels@388fd6af37b34cdfe5a23b37060e763217e58b03 # v5
|
||||
- uses: mheap/github-action-required-labels@8afbe8ae6ab7647d0c9f0cfa7c2f939650d22509 # v5
|
||||
if: github.event_name != 'merge_group'
|
||||
with:
|
||||
labels: |
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Add notice
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
if: contains(github.event.pull_request.labels.*.name, 'X-Blocked')
|
||||
with:
|
||||
script: |
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
|
||||
- name: Add label
|
||||
if: steps.teams.outputs.isTeamMember == 'false'
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.addLabels({
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
github.event.pull_request.head.repo.full_name != github.repository
|
||||
steps:
|
||||
- name: Close pull request
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Check for X-Release-Blocker label on any open issues or PRs
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
env:
|
||||
REPO: ${{ inputs.repository }}
|
||||
with:
|
||||
|
||||
@@ -16,12 +16,12 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: 🧮 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
ref: staging
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: "yarn"
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
disable-autolabeler: true
|
||||
|
||||
- name: Get actions scripts
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
repository: matrix-org/matrix-js-sdk
|
||||
persist-credentials: false
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
- name: Ingest upstream changes
|
||||
if: inputs.include-changes
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
|
||||
|
||||
@@ -18,14 +18,14 @@ jobs:
|
||||
merge:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
# We will be pushing to this branch and want the CI to run after we do so we cannot use the GITHUB_TOKEN
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get actions scripts
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
repository: matrix-org/matrix-js-sdk
|
||||
persist-credentials: false
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
sparse-checkout: |
|
||||
scripts/release
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
- name: Load GPG key
|
||||
id: gpg
|
||||
if: inputs.gpg-fingerprint
|
||||
uses: crazy-max/ghaction-import-gpg@cb9bde2e2525e640591a934b1fd28eef1dcaf5e5 # v6
|
||||
uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6
|
||||
with:
|
||||
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
passphrase: ${{ secrets.GPG_PASSPHRASE }}
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
draft: true
|
||||
latest: true
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
ref: staging
|
||||
# We will be pushing to this branch and want the CI to run after we do so we cannot use the GITHUB_TOKEN
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get actions scripts
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
repository: matrix-org/matrix-js-sdk
|
||||
persist-credentials: false
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
run: echo "VERSION=$(echo $VERSION | cut -d- -f1)" >> $GITHUB_ENV
|
||||
|
||||
- name: Check version number not in use
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
script: |
|
||||
const { VERSION } = process.env;
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
git config --global user.email "releases@riot.im"
|
||||
git config --global user.name "RiotRobot"
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -218,7 +218,7 @@ jobs:
|
||||
|
||||
- name: Validate release has expected assets
|
||||
if: inputs.expected-asset-count
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
env:
|
||||
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
|
||||
EXPECTED_ASSET_COUNT: ${{ inputs.expected-asset-count }}
|
||||
@@ -246,7 +246,7 @@ jobs:
|
||||
git push origin master
|
||||
|
||||
- name: Publish release
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
env:
|
||||
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
|
||||
FINAL: ${{ inputs.final }}
|
||||
|
||||
@@ -20,12 +20,12 @@ jobs:
|
||||
id: ${{ steps.npm-publish.outputs.id }}
|
||||
steps:
|
||||
- name: 🧮 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
ref: staging
|
||||
|
||||
- name: 🔧 Yarn cache
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
@@ -44,13 +44,13 @@ jobs:
|
||||
repo:
|
||||
- element-hq/element-web
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
repository: ${{ matrix.repo }}
|
||||
ref: staging
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
@@ -73,10 +73,10 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: 🧮 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: 🔧 Yarn cache
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
run: yarn gendoc
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
|
||||
with:
|
||||
path: _docs
|
||||
|
||||
@@ -106,4 +106,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
steps:
|
||||
# We create the status here and then update it to success/failure in the `report` stage
|
||||
# This provides an easy link to this workflow_run from the PR before Sonarcloud is done.
|
||||
- uses: guibranco/github-status-action-v2@fe98467f9071758c7fc214af9dbac7f301bd23d4
|
||||
- uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: pending
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
|
||||
- name: 📥 Download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
if: ${{ !inputs.sharded }}
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
name: coverage
|
||||
path: coverage
|
||||
- name: 📥 Download sharded artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
if: inputs.sharded
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
|
||||
- name: "🩻 SonarCloud Scan"
|
||||
id: sonarcloud
|
||||
uses: matrix-org/sonarcloud-workflow-action@v4.0
|
||||
uses: matrix-org/sonarcloud-workflow-action@820f7c2e9e94ba9e35add0f739691e5c7e23fa25 # v4.0
|
||||
# workflow_run fails report against the develop commit always, we don't want that for PRs
|
||||
continue-on-error: ${{ github.event.workflow_run.head_branch != 'develop' }}
|
||||
with:
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
revision: ${{ github.event.workflow_run.head_sha }}
|
||||
token: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
- uses: guibranco/github-status-action-v2@fe98467f9071758c7fc214af9dbac7f301bd23d4
|
||||
- uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
|
||||
if: always()
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -14,9 +14,9 @@ jobs:
|
||||
name: "Typescript Syntax Check"
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -31,9 +31,9 @@ jobs:
|
||||
name: "ESLint"
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -48,9 +48,9 @@ jobs:
|
||||
name: "Node.js example"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
- name: Build Types
|
||||
run: "yarn build:types"
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "npm"
|
||||
node-version-file: "examples/node/package.json"
|
||||
@@ -83,9 +83,9 @@ jobs:
|
||||
name: "Workflow Lint"
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -100,9 +100,9 @@ jobs:
|
||||
name: "JSDoc Checker"
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
run: "yarn run gendoc --treatWarningsAsErrors --suppressCommentWarningsInDeclarationFiles"
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: docs
|
||||
path: _docs
|
||||
@@ -125,9 +125,9 @@ jobs:
|
||||
name: "Analyse Dead Code"
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -143,11 +143,11 @@ jobs:
|
||||
if: github.event_name == 'merge_group'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
repository: element-hq/element-web
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
|
||||
@@ -22,11 +22,11 @@ jobs:
|
||||
node: ["lts/*", 22]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Setup Node
|
||||
id: setupNode
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: ${{ matrix.node }}
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
- name: Upload Artifact
|
||||
if: env.ENABLE_COVERAGE == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: coverage-${{ matrix.specs }}-${{ matrix.node == 'lts/*' && 'lts' || matrix.node }}
|
||||
path: |
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
steps:
|
||||
- name: Skip SonarCloud on merge queues
|
||||
if: env.ENABLE_COVERAGE == 'false'
|
||||
uses: guibranco/github-status-action-v2@fe98467f9071758c7fc214af9dbac7f301bd23d4
|
||||
uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: success
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
|
||||
with:
|
||||
operations-per-run: 250
|
||||
days-before-issue-stale: -1
|
||||
|
||||
+1
-2
@@ -13,8 +13,7 @@ out
|
||||
/dist
|
||||
/lib
|
||||
|
||||
# version file and tarball created by `npm pack` / `yarn pack`
|
||||
/git-revision.txt
|
||||
# tarball created by `npm pack` / `yarn pack`
|
||||
/matrix-js-sdk-*.tgz
|
||||
|
||||
.vscode
|
||||
|
||||
@@ -1,3 +1,86 @@
|
||||
Changes in [37.10.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.10.0) (2025-07-01)
|
||||
====================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Update matrix-sdk-crypto-wasm to `15.0.0` ([#4882](https://github.com/matrix-org/matrix-js-sdk/pull/4882)). Contributed by @richvdh.
|
||||
* Allow customizing the IndexedDB database prefix used by Rust crypto. ([#4878](https://github.com/matrix-org/matrix-js-sdk/pull/4878)). Contributed by @clokep.
|
||||
* Remove `@matrix-org/olm` from dependency list ([#4876](https://github.com/matrix-org/matrix-js-sdk/pull/4876)). Contributed by @richvdh.
|
||||
* Redact on ban: Client implementation ([#4867](https://github.com/matrix-org/matrix-js-sdk/pull/4867)). Contributed by @turt2live.
|
||||
|
||||
|
||||
Changes in [37.9.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.9.0) (2025-06-17)
|
||||
==================================================================================================
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Ensure we send spec-compliant filter strings by stripping out null values ([#4865](https://github.com/matrix-org/matrix-js-sdk/pull/4865)). Contributed by @t3chguy.
|
||||
* Fix MatrixRTC membership manager failing to rejoin in a race condition (sync vs not found response) ([#4861](https://github.com/matrix-org/matrix-js-sdk/pull/4861)). Contributed by @toger5.
|
||||
* Include extraParams in all HTTP requests ([#4860](https://github.com/matrix-org/matrix-js-sdk/pull/4860)). Contributed by @rsb-tbg.
|
||||
|
||||
|
||||
Changes in [37.8.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.8.0) (2025-06-10)
|
||||
==================================================================================================
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* [Backport staging] Update dependency @matrix-org/matrix-sdk-crypto-wasm to v14.2.1 ([#4869](https://github.com/matrix-org/matrix-js-sdk/pull/4869)). Contributed by @RiotRobot.
|
||||
|
||||
|
||||
Changes in [37.7.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.7.0) (2025-06-03)
|
||||
==================================================================================================
|
||||
## 🦖 Deprecations
|
||||
|
||||
* MatrixRTC: Rename `MembershipConfig` parameters ([#4714](https://github.com/matrix-org/matrix-js-sdk/pull/4714)). Contributed by @toger5.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* Allow the embedded client to work without update\_state support ([#4849](https://github.com/matrix-org/matrix-js-sdk/pull/4849)). Contributed by @robintown.
|
||||
* Check for `unknown variant` on to-device sending and fall back to room event encryption. ([#4847](https://github.com/matrix-org/matrix-js-sdk/pull/4847)). Contributed by @toger5.
|
||||
* Reapply "Distinguish room state and timeline events in embedded clients" ([#4790](https://github.com/matrix-org/matrix-js-sdk/pull/4790)). Contributed by @robintown.
|
||||
|
||||
|
||||
Changes in [37.6.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.6.0) (2025-05-20)
|
||||
==================================================================================================
|
||||
## 🦖 Deprecations
|
||||
|
||||
* Deprecate utils function `defer` in favour of `Promise.withResolvers` ([#4829](https://github.com/matrix-org/matrix-js-sdk/pull/4829)). Contributed by @t3chguy.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* Update to Node 22 LTS ([#4832](https://github.com/matrix-org/matrix-js-sdk/pull/4832)). Contributed by @t3chguy.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Fix autodiscovery handling of 2xx (non-200) codes ([#4833](https://github.com/matrix-org/matrix-js-sdk/pull/4833)). Contributed by @t3chguy.
|
||||
|
||||
|
||||
Changes in [37.5.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.5.0) (2025-05-06)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Stabilise MSC3765 ([#4767](https://github.com/matrix-org/matrix-js-sdk/pull/4767)). Contributed by @Johennes.
|
||||
* Inherit `methodFactory` extensions from the parent to the child loggers. ([#4809](https://github.com/matrix-org/matrix-js-sdk/pull/4809)). Contributed by @toger5.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* [Backport staging] Fix token refresh behaviour for non-expired tokens ([#4827](https://github.com/matrix-org/matrix-js-sdk/pull/4827)). Contributed by @RiotRobot.
|
||||
* Refactor how token refreshing works to be more resilient ([#4819](https://github.com/matrix-org/matrix-js-sdk/pull/4819)). Contributed by @t3chguy.
|
||||
|
||||
|
||||
Changes in [37.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.4.0) (2025-04-22)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* MatrixRTC: Add combined `toDeviceAndRoomKeyTransport` ([#4792](https://github.com/matrix-org/matrix-js-sdk/pull/4792)). Contributed by @toger5.
|
||||
* Make logging consistent for matrixRTC ([#4788](https://github.com/matrix-org/matrix-js-sdk/pull/4788)). Contributed by @toger5.
|
||||
* MatrixRTC: ToDevice distribution for media stream keys ([#4785](https://github.com/matrix-org/matrix-js-sdk/pull/4785)). Contributed by @BillCarsonFr.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Fix token refresh racing with other requests and not using new token ([#4798](https://github.com/matrix-org/matrix-js-sdk/pull/4798)). Contributed by @t3chguy.
|
||||
* Fix fallback to MemoryCryptoStore when LocalStorage unavailable ([#4797](https://github.com/matrix-org/matrix-js-sdk/pull/4797)). Contributed by @t3chguy.
|
||||
* Remove duplicate `deleteSecretStorage` in `RustCrypto.resetEncryption` ([#4789](https://github.com/matrix-org/matrix-js-sdk/pull/4789)). Contributed by @florianduros.
|
||||
* Fix `RustCrypto.resetEncryption` failure ([#4772](https://github.com/matrix-org/matrix-js-sdk/pull/4772)). Contributed by @florianduros.
|
||||
|
||||
|
||||
Changes in [37.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.3.0) (2025-04-08)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
@@ -11,6 +11,22 @@
|
||||
This is the [Matrix](https://matrix.org) Client-Server SDK for JavaScript and TypeScript. This SDK can be run in a
|
||||
browser or in Node.js.
|
||||
|
||||
---
|
||||
|
||||
<picture>
|
||||
<source srcset="contrib/element-logo-light.png" media="(prefers-color-scheme: dark)">
|
||||
<source srcset="contrib/element-logo-dark.png" media="(prefers-color-scheme: light)">
|
||||
<img src="contrib/element-logo-fallback.png" alt="Element logo">
|
||||
</picture>
|
||||
|
||||
<br>
|
||||
|
||||
Development and maintenance is proudly sponsored by [Element](https://element.io). Element uses the SDK in their flagship [web](https://github.com/element-hq/element-web) and [desktop](https://github.com/element-hq/element-desktop) clients.
|
||||
|
||||
The SDK is also the basis for multiple Matrix projects and we welcome contributions from all.
|
||||
|
||||
---
|
||||
|
||||
#### Minimum Matrix server version: v1.1
|
||||
|
||||
The Matrix specification is constantly evolving - while this SDK aims for maximum backwards compatibility, it only
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -9,6 +9,7 @@ export default {
|
||||
"src/crypto-api/index.ts",
|
||||
"src/testing.ts",
|
||||
"src/matrix.ts",
|
||||
"src/utils.ts", // not really an entrypoint but we have deprecated `defer` there
|
||||
"scripts/**",
|
||||
"spec/**",
|
||||
// XXX: these look entirely unused
|
||||
|
||||
+9
-11
@@ -1,16 +1,15 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "37.3.0",
|
||||
"version": "37.11.0-rc.0",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"prepack": "yarn build",
|
||||
"prepare": "yarn build",
|
||||
"start": "echo THIS IS FOR LEGACY PURPOSES ONLY. && babel src -w -s -d lib --verbose --extensions \".ts,.js\"",
|
||||
"clean": "rimraf lib",
|
||||
"build": "yarn build:dev",
|
||||
"build:dev": "yarn clean && git rev-parse HEAD > git-revision.txt && yarn build:compile && yarn build:types",
|
||||
"build": "yarn clean && yarn build:compile && yarn build:types",
|
||||
"build:types": "tsc -p tsconfig-build.json --emitDeclarationOnly",
|
||||
"build:compile": "babel -d lib --verbose --extensions \".ts,.js\" src",
|
||||
"gendoc": "typedoc",
|
||||
@@ -50,13 +49,12 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^14.0.1",
|
||||
"@matrix-org/olm": "3.2.15",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^15.0.0",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^6.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"loglevel": "^1.7.1",
|
||||
"loglevel": "^1.9.2",
|
||||
"matrix-events-sdk": "0.0.1",
|
||||
"matrix-widget-api": "^1.10.0",
|
||||
"oidc-client-ts": "^3.0.1",
|
||||
@@ -81,6 +79,7 @@
|
||||
"@babel/preset-env": "^7.12.11",
|
||||
"@babel/preset-typescript": "^7.12.7",
|
||||
"@casualbot/jest-sonar-reporter": "2.2.7",
|
||||
"@matrix-org/olm": "3.2.15",
|
||||
"@peculiar/webcrypto": "^1.4.5",
|
||||
"@stylistic/eslint-plugin": "^4.0.0",
|
||||
"@types/content-type": "^1.1.5",
|
||||
@@ -88,7 +87,6 @@
|
||||
"@types/jest": "^29.0.0",
|
||||
"@types/node": "18",
|
||||
"@types/sdp-transform": "^2.4.5",
|
||||
"@types/uuid": "10",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"babel-jest": "^29.0.0",
|
||||
@@ -114,14 +112,14 @@
|
||||
"jest-localstorage-mock": "^2.4.6",
|
||||
"jest-mock": "^29.0.0",
|
||||
"knip": "^5.0.0",
|
||||
"lint-staged": "^15.0.2",
|
||||
"lint-staged": "^16.0.0",
|
||||
"matrix-mock-request": "^2.5.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"prettier": "3.5.3",
|
||||
"rimraf": "^6.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typedoc": "^0.28.1",
|
||||
"typedoc-plugin-coverage": "^3.0.0",
|
||||
"typedoc-plugin-coverage": "^4.0.0",
|
||||
"typedoc-plugin-mdn-links": "^5.0.0",
|
||||
"typedoc-plugin-missing-exports": "^4.0.0",
|
||||
"typescript": "^5.4.2"
|
||||
|
||||
@@ -19,9 +19,6 @@ limitations under the License.
|
||||
// `expect` is allowed in helper functions which are called within `test`/`it` blocks
|
||||
/* eslint-disable jest/no-standalone-expect */
|
||||
|
||||
// load olm before the sdk if possible
|
||||
import "./olm-loader";
|
||||
|
||||
import MockHttpBackend from "matrix-mock-request";
|
||||
|
||||
import type { IDeviceKeys, IOneTimeKey } from "../src/@types/crypto";
|
||||
|
||||
@@ -19,9 +19,9 @@ import anotherjson from "another-json";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import Olm from "@matrix-org/olm";
|
||||
|
||||
import type FetchMock from "fetch-mock";
|
||||
import type Olm from "@matrix-org/olm";
|
||||
import * as testUtils from "../../test-utils/test-utils";
|
||||
import {
|
||||
emitPromise,
|
||||
@@ -59,7 +59,7 @@ import {
|
||||
} from "../../../src/matrix";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { type ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import { defer, escapeRegExp } from "../../../src/utils";
|
||||
import { escapeRegExp } from "../../../src/utils";
|
||||
import { downloadDeviceToJsDevice } from "../../../src/rust-crypto/device-converter";
|
||||
import { flushPromises } from "../../test-utils/flushPromises";
|
||||
import {
|
||||
@@ -124,7 +124,6 @@ async function expectSendRoomKey(
|
||||
recipientOlmAccount: Olm.Account,
|
||||
recipientOlmSession: Olm.Session | null = null,
|
||||
): Promise<Olm.InboundGroupSession> {
|
||||
const Olm = globalThis.Olm;
|
||||
const testRecipientKey = JSON.parse(recipientOlmAccount.identity_keys())["curve25519"];
|
||||
|
||||
function onSendRoomKey(content: any): Olm.InboundGroupSession {
|
||||
@@ -207,14 +206,6 @@ async function expectSendMegolmMessage(
|
||||
}
|
||||
|
||||
describe("crypto", () => {
|
||||
if (!globalThis.Olm) {
|
||||
// currently we use libolm to implement the crypto in the tests, so need it to be present.
|
||||
logger.warn("not running megolm tests: Olm not present");
|
||||
return;
|
||||
}
|
||||
|
||||
const Olm = globalThis.Olm;
|
||||
|
||||
let testOlmAccount = {} as unknown as Olm.Account;
|
||||
let testSenderKey = "";
|
||||
|
||||
@@ -1283,13 +1274,13 @@ describe("crypto", () => {
|
||||
const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount);
|
||||
|
||||
// ... and finally, send the room key. We block the response until `sendRoomMessageDefer` completes.
|
||||
const sendRoomMessageDefer = defer<FetchMock.MockResponse>();
|
||||
const sendRoomMessageResolvers = Promise.withResolvers<FetchMock.MockResponse>();
|
||||
const reqProm = new Promise<IContent>((resolve) => {
|
||||
fetchMock.putOnce(
|
||||
new RegExp("/send/m.room.encrypted/"),
|
||||
async (url: string, opts: RequestInit): Promise<FetchMock.MockResponse> => {
|
||||
resolve(JSON.parse(opts.body as string));
|
||||
return await sendRoomMessageDefer.promise;
|
||||
return await sendRoomMessageResolvers.promise;
|
||||
},
|
||||
{
|
||||
// append to the list of intercepts on this path (since we have some tests that call
|
||||
@@ -1318,7 +1309,7 @@ describe("crypto", () => {
|
||||
|
||||
// release the send request
|
||||
const resp = { event_id: "$event_id" };
|
||||
sendRoomMessageDefer.resolve(resp);
|
||||
sendRoomMessageResolvers.resolve(resp);
|
||||
expect(await sendProm).toEqual(resp);
|
||||
|
||||
// still pending at this point
|
||||
|
||||
@@ -36,7 +36,6 @@ import { advanceTimersUntil, awaitDecryption, syncPromise } from "../../test-uti
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import { type KeyBackupInfo, type KeyBackupSession } from "../../../src/crypto-api/keybackup";
|
||||
import { flushPromises } from "../../test-utils/flushPromises";
|
||||
import { defer, type IDeferred } from "../../../src/utils";
|
||||
import { decodeRecoveryKey, DecryptionFailureCode, CryptoEvent, type CryptoApi } from "../../../src/crypto-api";
|
||||
import { type KeyBackup } from "../../../src/rust-crypto/backup.ts";
|
||||
|
||||
@@ -861,7 +860,7 @@ describe("megolm-keys backup", () => {
|
||||
expect(await aliceCrypto.getKeyBackupInfo()).toStrictEqual(testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
// Delete the backup and we are expecting the key backup to be disabled
|
||||
const keyBackupStatus = defer<boolean>();
|
||||
const keyBackupStatus = Promise.withResolvers<boolean>();
|
||||
aliceClient.once(CryptoEvent.KeyBackupStatus, (enabled) => keyBackupStatus.resolve(enabled));
|
||||
await aliceCrypto.deleteKeyBackupVersion(testData.SIGNED_BACKUP_DATA.version!);
|
||||
expect(await keyBackupStatus.promise).toBe(false);
|
||||
@@ -1158,7 +1157,7 @@ describe("megolm-keys backup", () => {
|
||||
// A check backup should happen at some point
|
||||
await aliceCrypto.checkKeyBackupAndEnable();
|
||||
|
||||
const awaitHasQueriedNewBackup: IDeferred<void> = defer<void>();
|
||||
const awaitHasQueriedNewBackup: PromiseWithResolvers<void> = Promise.withResolvers<void>();
|
||||
|
||||
fetchMock.get(
|
||||
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
|
||||
|
||||
@@ -93,15 +93,15 @@ export function bootstrapCrossSigningTestOlmAccount(
|
||||
deviceId: string,
|
||||
keyBackupInfo: KeyBackupInfo[] = [],
|
||||
): Partial<IDownloadKeyResult> {
|
||||
const olmAliceMSK = new globalThis.Olm.PkSigning();
|
||||
const olmAliceMSK = new Olm.PkSigning();
|
||||
const masterPrivkey = olmAliceMSK.generate_seed();
|
||||
const masterPubkey = olmAliceMSK.init_with_seed(masterPrivkey);
|
||||
|
||||
const olmAliceUSK = new globalThis.Olm.PkSigning();
|
||||
const olmAliceUSK = new Olm.PkSigning();
|
||||
const userPrivkey = olmAliceUSK.generate_seed();
|
||||
const userPubkey = olmAliceUSK.init_with_seed(userPrivkey);
|
||||
|
||||
const olmAliceSSK = new globalThis.Olm.PkSigning();
|
||||
const olmAliceSSK = new Olm.PkSigning();
|
||||
const sskPrivkey = olmAliceSSK.generate_seed();
|
||||
const sskPubkey = olmAliceSSK.init_with_seed(sskPrivkey);
|
||||
|
||||
@@ -189,7 +189,7 @@ export async function createOlmSession(
|
||||
const otkId = Object.keys(keys)[0];
|
||||
const otk = keys[otkId];
|
||||
|
||||
const session = new globalThis.Olm.Session();
|
||||
const session = new Olm.Session();
|
||||
session.create_outbound(olmAccount, recipientTestClient.getDeviceKey(), otk.key);
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,23 @@ describe("MatrixClient.initRustCrypto", () => {
|
||||
expect(databaseNames).toEqual(expect.arrayContaining(["matrix-js-sdk::matrix-sdk-crypto"]));
|
||||
});
|
||||
|
||||
it("should create the indexed db with a custom prefix", async () => {
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: "@alice:localhost",
|
||||
deviceId: "aliceDevice",
|
||||
});
|
||||
|
||||
// No databases.
|
||||
expect(await indexedDB.databases()).toHaveLength(0);
|
||||
|
||||
await matrixClient.initRustCrypto({ cryptoDatabasePrefix: "my-prefix" });
|
||||
|
||||
// should have an indexed db now
|
||||
const databaseNames = (await indexedDB.databases()).map((db) => db.name);
|
||||
expect(databaseNames).toEqual(expect.arrayContaining(["my-prefix::matrix-sdk-crypto"]));
|
||||
});
|
||||
|
||||
it("should create the meta db if given a storageKey", async () => {
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
@@ -475,4 +492,22 @@ describe("MatrixClient.clearStores", () => {
|
||||
await matrixClient.clearStores();
|
||||
// No error thrown in clearStores
|
||||
});
|
||||
|
||||
it("should clear the indexeddbs with a custom prefix", async () => {
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: "@alice:localhost",
|
||||
deviceId: "aliceDevice",
|
||||
});
|
||||
|
||||
// No databases.
|
||||
expect(await indexedDB.databases()).toHaveLength(0);
|
||||
|
||||
await matrixClient.initRustCrypto({ cryptoDatabasePrefix: "my-prefix" });
|
||||
expect(await indexedDB.databases()).toHaveLength(1);
|
||||
await matrixClient.stopClient();
|
||||
|
||||
await matrixClient.clearStores({ cryptoDatabasePrefix: "my-prefix" });
|
||||
expect(await indexedDB.databases()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,13 +17,22 @@ limitations under the License.
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import Olm from "@matrix-org/olm";
|
||||
|
||||
import { getSyncResponse, syncPromise } from "../../test-utils/test-utils";
|
||||
import { createClient, type MatrixClient } from "../../../src";
|
||||
import {
|
||||
ClientEvent,
|
||||
createClient,
|
||||
type IToDeviceEvent,
|
||||
type MatrixClient,
|
||||
type MatrixEvent,
|
||||
type ReceivedToDeviceMessage,
|
||||
} from "../../../src";
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
import { SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { encryptOlmEvent, establishOlmSession, getTestOlmAccountKeys } from "./olm-utils.ts";
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
@@ -43,6 +52,8 @@ describe("to-device-messages", () => {
|
||||
|
||||
/** an object which intercepts `/keys/query` requests on the test homeserver */
|
||||
let e2eKeyResponder: E2EKeyResponder;
|
||||
let e2eKeyReceiver: E2EKeyReceiver;
|
||||
let syncResponder: SyncResponder;
|
||||
|
||||
beforeEach(
|
||||
async () => {
|
||||
@@ -59,8 +70,8 @@ describe("to-device-messages", () => {
|
||||
});
|
||||
|
||||
e2eKeyResponder = new E2EKeyResponder(homeserverUrl);
|
||||
new E2EKeyReceiver(homeserverUrl);
|
||||
const syncResponder = new SyncResponder(homeserverUrl);
|
||||
e2eKeyReceiver = new E2EKeyReceiver(homeserverUrl);
|
||||
syncResponder = new SyncResponder(homeserverUrl);
|
||||
|
||||
// add bob as known user
|
||||
syncResponder.sendOrQueueSyncResponse(getSyncResponse([testData.BOB_TEST_USER_ID]));
|
||||
@@ -149,4 +160,111 @@ describe("to-device-messages", () => {
|
||||
// for future: check that bob's device can decrypt the ciphertext?
|
||||
});
|
||||
});
|
||||
|
||||
describe("receive to-device-messages", () => {
|
||||
it("Should receive decrypted to-device message via ClientEvent", async () => {
|
||||
// create a test olm device which we will use to communicate with alice. We use libolm to implement this.
|
||||
await Olm.init();
|
||||
const testOlmAccount = new Olm.Account();
|
||||
testOlmAccount.create();
|
||||
|
||||
const testDeviceKeys = getTestOlmAccountKeys(testOlmAccount, "@bob:xyz", "DEVICE_ID");
|
||||
e2eKeyResponder.addDeviceKeys(testDeviceKeys);
|
||||
|
||||
await aliceClient.startClient();
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"]));
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const p2pSession = await establishOlmSession(aliceClient, e2eKeyReceiver, syncResponder, testOlmAccount);
|
||||
|
||||
const toDeviceEvent = encryptOlmEvent({
|
||||
sender: "@bob:xyz",
|
||||
senderKey: testDeviceKeys.keys[`curve25519:DEVICE_ID`],
|
||||
senderSigningKey: testDeviceKeys.keys[`ed25519:DEVICE_ID`],
|
||||
p2pSession: p2pSession,
|
||||
recipient: aliceClient.getUserId()!,
|
||||
recipientCurve25519Key: e2eKeyReceiver.getDeviceKey(),
|
||||
recipientEd25519Key: e2eKeyReceiver.getSigningKey(),
|
||||
plaincontent: {
|
||||
body: "foo",
|
||||
},
|
||||
plaintype: "m.test.type",
|
||||
});
|
||||
|
||||
const processedToDeviceResolver: PromiseWithResolvers<ReceivedToDeviceMessage> = Promise.withResolvers();
|
||||
|
||||
aliceClient.on(ClientEvent.ReceivedToDeviceMessage, (payload) => {
|
||||
processedToDeviceResolver.resolve(payload);
|
||||
});
|
||||
|
||||
const oldToDeviceResolver: PromiseWithResolvers<MatrixEvent> = Promise.withResolvers();
|
||||
|
||||
aliceClient.on(ClientEvent.ToDeviceEvent, (event) => {
|
||||
oldToDeviceResolver.resolve(event);
|
||||
});
|
||||
|
||||
expect(toDeviceEvent.type).toBe("m.room.encrypted");
|
||||
|
||||
syncResponder.sendOrQueueSyncResponse({ to_device: { events: [toDeviceEvent] } });
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const { message, encryptionInfo } = await processedToDeviceResolver.promise;
|
||||
|
||||
expect(message.type).toBe("m.test.type");
|
||||
expect(message.content["body"]).toBe("foo");
|
||||
|
||||
expect(encryptionInfo).not.toBeNull();
|
||||
expect(encryptionInfo!.senderVerified).toBe(false);
|
||||
expect(encryptionInfo!.sender).toBe("@bob:xyz");
|
||||
expect(encryptionInfo!.senderDevice).toBe("DEVICE_ID");
|
||||
|
||||
const oldFormat = await oldToDeviceResolver.promise;
|
||||
expect(oldFormat.isEncrypted()).toBe(true);
|
||||
expect(oldFormat.getType()).toBe("m.test.type");
|
||||
expect(oldFormat.getContent()["body"]).toBe("foo");
|
||||
});
|
||||
|
||||
it("Should receive clear to-device message via ClientEvent", async () => {
|
||||
await aliceClient.startClient();
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const toDeviceEvent: IToDeviceEvent = {
|
||||
sender: "@bob:xyz",
|
||||
type: "m.test.type",
|
||||
content: {
|
||||
body: "foo",
|
||||
},
|
||||
};
|
||||
|
||||
const processedToDeviceResolver: PromiseWithResolvers<ReceivedToDeviceMessage> = Promise.withResolvers();
|
||||
|
||||
aliceClient.on(ClientEvent.ReceivedToDeviceMessage, (payload) => {
|
||||
processedToDeviceResolver.resolve(payload);
|
||||
});
|
||||
|
||||
const oldToDeviceResolver: PromiseWithResolvers<MatrixEvent> = Promise.withResolvers();
|
||||
|
||||
aliceClient.on(ClientEvent.ToDeviceEvent, (event) => {
|
||||
oldToDeviceResolver.resolve(event);
|
||||
});
|
||||
|
||||
syncResponder.sendOrQueueSyncResponse({ to_device: { events: [toDeviceEvent] } });
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const { message, encryptionInfo } = await processedToDeviceResolver.promise;
|
||||
|
||||
expect(message.type).toBe("m.test.type");
|
||||
expect(message.content["body"]).toBe("foo");
|
||||
|
||||
// When the message is not encrypted, we don't have the encryptionInfo.
|
||||
expect(encryptionInfo).toBeNull();
|
||||
|
||||
const oldFormat = await oldToDeviceResolver.promise;
|
||||
expect(oldFormat.isEncrypted()).toBe(false);
|
||||
expect(oldFormat.getType()).toBe("m.test.type");
|
||||
expect(oldFormat.getContent()["body"]).toBe("foo");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
type Verifier,
|
||||
VerifierEvent,
|
||||
} from "../../../src/crypto-api/verification";
|
||||
import { defer, escapeRegExp } from "../../../src/utils";
|
||||
import { escapeRegExp } from "../../../src/utils";
|
||||
import { awaitDecryption, emitPromise, getSyncResponse, syncPromise } from "../../test-utils/test-utils";
|
||||
import { SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import {
|
||||
@@ -84,7 +84,7 @@ jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
|
||||
|
||||
beforeAll(async () => {
|
||||
// we use the libolm primitives in the test, so init the Olm library
|
||||
await globalThis.Olm.init();
|
||||
await Olm.init();
|
||||
});
|
||||
|
||||
// load the rust library. This can take a few seconds on a slow GH worker.
|
||||
@@ -110,7 +110,6 @@ const TEST_HOMESERVER_URL = "https://alice-server.com";
|
||||
* These tests work by intercepting HTTP requests via fetch-mock rather than mocking out bits of the client, so as
|
||||
* to provide the most effective integration tests possible.
|
||||
*/
|
||||
// we test with both crypto stacks...
|
||||
describe("verification", () => {
|
||||
/** the client under test */
|
||||
let aliceClient: MatrixClient;
|
||||
@@ -254,7 +253,7 @@ describe("verification", () => {
|
||||
|
||||
// The dummy device makes up a curve25519 keypair and sends the public bit back in an `m.key.verification.key'
|
||||
// We use the Curve25519, HMAC and HKDF implementations in libolm, for now
|
||||
const olmSAS = new globalThis.Olm.SAS();
|
||||
const olmSAS = new Olm.SAS();
|
||||
returnToDeviceMessageFromSync(buildSasKeyMessage(transactionId, olmSAS.get_pubkey()));
|
||||
|
||||
// alice responds with a 'key' ...
|
||||
@@ -348,7 +347,7 @@ describe("verification", () => {
|
||||
|
||||
// The dummy device makes up a curve25519 keypair and uses the hash in an 'm.key.verification.accept'
|
||||
// We use the Curve25519, HMAC and HKDF implementations in libolm, for now
|
||||
const olmSAS = new globalThis.Olm.SAS();
|
||||
const olmSAS = new Olm.SAS();
|
||||
const commitmentStr = olmSAS.get_pubkey() + anotherjson.stringify(toDeviceMessage);
|
||||
|
||||
sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.key");
|
||||
@@ -1540,10 +1539,10 @@ function expectSendToDeviceMessage(msgtype: string): Promise<{ messages: any }>
|
||||
* @returns a map of secret name to promise that will resolve (with the id of the secret request) when the secret is requested.
|
||||
*/
|
||||
function mockSecretRequestAndGetPromises(): Map<string, Promise<string>> {
|
||||
const mskRequestDefer = defer<string>();
|
||||
const sskRequestDefer = defer<string>();
|
||||
const uskRequestDefer = defer<string>();
|
||||
const backupKeyRequestDefer = defer<string>();
|
||||
const mskRequestResolvers = Promise.withResolvers<string>();
|
||||
const sskRequestResolvers = Promise.withResolvers<string>();
|
||||
const uskRequestResolvers = Promise.withResolvers<string>();
|
||||
const backupKeyRequestResolvers = Promise.withResolvers<string>();
|
||||
|
||||
fetchMock.put(
|
||||
new RegExp(`/_matrix/client/(r0|v3)/sendToDevice/m.secret.request`),
|
||||
@@ -1555,13 +1554,13 @@ function mockSecretRequestAndGetPromises(): Map<string, Promise<string>> {
|
||||
const name = content.name;
|
||||
const requestId = content.request_id;
|
||||
if (name == "m.cross_signing.user_signing") {
|
||||
uskRequestDefer.resolve(requestId);
|
||||
uskRequestResolvers.resolve(requestId);
|
||||
} else if (name == "m.cross_signing.master") {
|
||||
mskRequestDefer.resolve(requestId);
|
||||
mskRequestResolvers.resolve(requestId);
|
||||
} else if (name == "m.cross_signing.self_signing") {
|
||||
sskRequestDefer.resolve(requestId);
|
||||
sskRequestResolvers.resolve(requestId);
|
||||
} else if (name == "m.megolm_backup.v1") {
|
||||
backupKeyRequestDefer.resolve(requestId);
|
||||
backupKeyRequestResolvers.resolve(requestId);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
@@ -1570,10 +1569,10 @@ function mockSecretRequestAndGetPromises(): Map<string, Promise<string>> {
|
||||
);
|
||||
|
||||
const promiseMap = new Map<string, Promise<string>>();
|
||||
promiseMap.set("m.cross_signing.master", mskRequestDefer.promise);
|
||||
promiseMap.set("m.cross_signing.self_signing", sskRequestDefer.promise);
|
||||
promiseMap.set("m.cross_signing.user_signing", uskRequestDefer.promise);
|
||||
promiseMap.set("m.megolm_backup.v1", backupKeyRequestDefer.promise);
|
||||
promiseMap.set("m.cross_signing.master", mskRequestResolvers.promise);
|
||||
promiseMap.set("m.cross_signing.self_signing", sskRequestResolvers.promise);
|
||||
promiseMap.set("m.cross_signing.user_signing", uskRequestResolvers.promise);
|
||||
promiseMap.set("m.megolm_backup.v1", backupKeyRequestResolvers.promise);
|
||||
return promiseMap;
|
||||
}
|
||||
|
||||
@@ -1604,7 +1603,7 @@ function sha256(commitmentStr: string): string {
|
||||
return encodeUnpaddedBase64(createHash("sha256").update(commitmentStr, "utf8").digest());
|
||||
}
|
||||
|
||||
function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string {
|
||||
function encodeUnpaddedBase64(uint8Array: ArrayLike<number>): string {
|
||||
return Buffer.from(uint8Array).toString("base64").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
@@ -1638,7 +1637,7 @@ function buildReadyMessage(
|
||||
}
|
||||
|
||||
/** build an m.key.verification.start to-device message suitable for the m.reciprocate.v1 flow, originating from the dummy device */
|
||||
function buildReciprocateStartMessage(transactionId: string, sharedSecret: ArrayBuffer) {
|
||||
function buildReciprocateStartMessage(transactionId: string, sharedSecret: ArrayLike<number>) {
|
||||
return {
|
||||
type: "m.key.verification.start",
|
||||
content: {
|
||||
|
||||
@@ -1796,6 +1796,27 @@ describe("MatrixClient", function () {
|
||||
expect(client.getUserIdLocalpart()).toBe("alice");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setRoomMutePushRule", () => {
|
||||
it("should set room push rule to muted", async () => {
|
||||
const roomId = "!roomId:server";
|
||||
const client = new MatrixClient({
|
||||
baseUrl: "http://localhost",
|
||||
fetchFn: httpBackend.fetchFn as typeof globalThis.fetch,
|
||||
});
|
||||
client.pushRules = {
|
||||
global: {
|
||||
room: [{ rule_id: roomId, actions: [], default: false, enabled: false }],
|
||||
},
|
||||
};
|
||||
|
||||
const path = `/pushrules/global/room/${encodeURIComponent(roomId)}`;
|
||||
httpBackend.when("DELETE", path).respond(200, {});
|
||||
httpBackend.when("PUT", path).respond(200, {});
|
||||
client.setRoomMutePushRule("global", roomId, true);
|
||||
await httpBackend.flush("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function withThreadId(event: MatrixEvent, newThreadId: string): MatrixEvent {
|
||||
|
||||
@@ -205,4 +205,109 @@ describe("MatrixClient opts", function () {
|
||||
expect(res.event_id).toEqual("foo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("with opts.queryParams", function () {
|
||||
let client: MatrixClient;
|
||||
let httpBackend: HttpBackend;
|
||||
const userId = "@rsb-tbg:localhost";
|
||||
|
||||
beforeEach(function () {
|
||||
httpBackend = new HttpBackend();
|
||||
client = new MatrixClient({
|
||||
fetchFn: httpBackend.fetchFn as typeof globalThis.fetch,
|
||||
store: new MemoryStore() as IStore,
|
||||
baseUrl: baseUrl,
|
||||
userId: userId,
|
||||
accessToken: accessToken,
|
||||
queryParams: { user_id: userId },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
client.stopClient();
|
||||
httpBackend.verifyNoOutstandingExpectation();
|
||||
return httpBackend.stop();
|
||||
});
|
||||
|
||||
it("should include queryParams in matrix server requests", async () => {
|
||||
const eventId = "$test:event";
|
||||
httpBackend
|
||||
.when("PUT", "/txn1")
|
||||
.check((req) => {
|
||||
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
|
||||
return true;
|
||||
})
|
||||
.respond(200, {
|
||||
event_id: eventId,
|
||||
});
|
||||
|
||||
const [res] = await Promise.all([
|
||||
client.sendTextMessage("!foo:bar", "test message", "txn1"),
|
||||
httpBackend.flush("/txn1", 1),
|
||||
]);
|
||||
|
||||
expect(res.event_id).toEqual(eventId);
|
||||
});
|
||||
|
||||
it("should include queryParams in sync requests", async () => {
|
||||
httpBackend
|
||||
.when("GET", "/versions")
|
||||
.check((req) => {
|
||||
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
|
||||
return true;
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
httpBackend
|
||||
.when("GET", "/pushrules")
|
||||
.check((req) => {
|
||||
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
|
||||
return true;
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
httpBackend
|
||||
.when("POST", "/filter")
|
||||
.check((req) => {
|
||||
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
|
||||
return true;
|
||||
})
|
||||
.respond(200, { filter_id: "foo" });
|
||||
|
||||
httpBackend
|
||||
.when("GET", "/sync")
|
||||
.check((req) => {
|
||||
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
|
||||
return true;
|
||||
})
|
||||
.respond(200, syncData);
|
||||
|
||||
client.startClient();
|
||||
await httpBackend.flush("/versions", 1);
|
||||
await httpBackend.flush("/pushrules", 1);
|
||||
await httpBackend.flush("/filter", 1);
|
||||
await Promise.all([httpBackend.flush("/sync", 1), utils.syncPromise(client)]);
|
||||
});
|
||||
|
||||
it("should merge queryParams with request-specific params", async () => {
|
||||
const eventId = "$test:event";
|
||||
httpBackend
|
||||
.when("PUT", "/txn1")
|
||||
.check((req) => {
|
||||
// Should contain both global queryParams and request-specific params
|
||||
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
|
||||
return true;
|
||||
})
|
||||
.respond(200, {
|
||||
event_id: eventId,
|
||||
});
|
||||
|
||||
const [res] = await Promise.all([
|
||||
client.sendTextMessage("!foo:bar", "test message", "txn1"),
|
||||
httpBackend.flush("/txn1", 1),
|
||||
]);
|
||||
|
||||
expect(res.event_id).toEqual(eventId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
PayloadType,
|
||||
RendezvousError,
|
||||
} from "../../../src/rendezvous";
|
||||
import { defer } from "../../../src/utils";
|
||||
import {
|
||||
ClientPrefix,
|
||||
DEVICE_CODE_SCOPE,
|
||||
@@ -112,8 +111,8 @@ describe("MSC4108SignInWithQR", () => {
|
||||
let opponentLogin: MSC4108SignInWithQR;
|
||||
|
||||
beforeEach(async () => {
|
||||
let ourData = defer<string>();
|
||||
let opponentData = defer<string>();
|
||||
let ourData = Promise.withResolvers<string>();
|
||||
let opponentData = Promise.withResolvers<string>();
|
||||
|
||||
const ourMockSession = {
|
||||
send: jest.fn(async (newData) => {
|
||||
@@ -122,7 +121,7 @@ describe("MSC4108SignInWithQR", () => {
|
||||
receive: jest.fn(() => {
|
||||
const prom = opponentData.promise;
|
||||
prom.then(() => {
|
||||
opponentData = defer();
|
||||
opponentData = Promise.withResolvers();
|
||||
});
|
||||
return prom;
|
||||
}),
|
||||
@@ -141,7 +140,7 @@ describe("MSC4108SignInWithQR", () => {
|
||||
receive: jest.fn(() => {
|
||||
const prom = ourData.promise;
|
||||
prom.then(() => {
|
||||
ourData = defer();
|
||||
ourData = Promise.withResolvers();
|
||||
});
|
||||
return prom;
|
||||
}),
|
||||
@@ -334,11 +333,11 @@ describe("MSC4108SignInWithQR", () => {
|
||||
// @ts-ignore
|
||||
await opponentLogin.receive();
|
||||
|
||||
const deferred = defer<IMyDevice>();
|
||||
mocked(client.getDevice).mockReturnValue(deferred.promise);
|
||||
const deviceResolvers = Promise.withResolvers<IMyDevice>();
|
||||
mocked(client.getDevice).mockReturnValue(deviceResolvers.promise);
|
||||
|
||||
ourLogin.cancel(MSC4108FailureReason.UserCancelled).catch(() => {});
|
||||
deferred.resolve({} as IMyDevice);
|
||||
deviceResolvers.resolve({} as IMyDevice);
|
||||
|
||||
const secrets = {
|
||||
cross_signing: { master_key: "mk", user_signing_key: "usk", self_signing_key: "ssk" },
|
||||
|
||||
@@ -48,7 +48,6 @@ import { type SyncApiOptions, SyncState } from "../../src/sync";
|
||||
import { type IStoredClientOpts } from "../../src";
|
||||
import { logger } from "../../src/logger";
|
||||
import { emitPromise } from "../test-utils/test-utils";
|
||||
import { defer } from "../../src/utils";
|
||||
import { KnownMembership } from "../../src/@types/membership";
|
||||
import { type SyncCryptoCallbacks } from "../../src/common-crypto/CryptoBackend";
|
||||
|
||||
@@ -127,7 +126,7 @@ describe("SlidingSyncSdk", () => {
|
||||
// assign client/httpBackend globals
|
||||
const setupClient = async (testOpts?: Partial<IStoredClientOpts & { withCrypto: boolean }>) => {
|
||||
testOpts = testOpts || {};
|
||||
const syncOpts: SyncApiOptions = {};
|
||||
const syncOpts: SyncApiOptions = { logger };
|
||||
const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken);
|
||||
httpBackend = testClient.httpBackend;
|
||||
client = testClient.client;
|
||||
@@ -369,7 +368,7 @@ describe("SlidingSyncSdk", () => {
|
||||
});
|
||||
|
||||
it("can be created with live events", async () => {
|
||||
const seenLiveEventDeferred = defer<boolean>();
|
||||
const seenLiveEventDeferred = Promise.withResolvers<boolean>();
|
||||
const listener = (
|
||||
ev: MatrixEvent,
|
||||
room?: Room,
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/*
|
||||
Copyright 2017 Vector creations Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger } from "../src/logger";
|
||||
|
||||
// try to load the olm library.
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
globalThis.Olm = require("@matrix-org/olm");
|
||||
logger.log("loaded libolm");
|
||||
} catch (e) {
|
||||
logger.warn("unable to run crypto tests: libolm not available", e);
|
||||
}
|
||||
@@ -19,3 +19,8 @@ jest.mock("../src/http-api/utils", () => ({
|
||||
// We mock timeoutSignal otherwise it causes tests to leave timers running
|
||||
timeoutSignal: () => new AbortController().signal,
|
||||
}));
|
||||
|
||||
// Dont make test fail too soon due to timeouts while debugging.
|
||||
if (process.env.VSCODE_INSPECTOR_OPTIONS) {
|
||||
jest.setTimeout(60 * 1000 * 5); // 5 minutes
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// load olm before the sdk if possible
|
||||
import "../olm-loader";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import type EventEmitter from "events";
|
||||
import { logger } from "../../src/logger";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getMockClientWithEventEmitter } from "../test-utils/client";
|
||||
import { StubStore } from "../../src/store/stub";
|
||||
import { type IndexedToDeviceBatch } from "../../src/models/ToDeviceMessage";
|
||||
import { SyncState } from "../../src/sync";
|
||||
import { defer } from "../../src/utils";
|
||||
import { logger } from "../../src/logger.ts";
|
||||
|
||||
describe("onResumedSync", () => {
|
||||
let batch: IndexedToDeviceBatch | null;
|
||||
@@ -56,11 +56,11 @@ describe("onResumedSync", () => {
|
||||
}
|
||||
});
|
||||
|
||||
queue = new ToDeviceMessageQueue(mockClient);
|
||||
queue = new ToDeviceMessageQueue(mockClient, logger);
|
||||
});
|
||||
|
||||
it("resends queue after connectivity restored", async () => {
|
||||
const deferred = defer();
|
||||
const successResolvers = Promise.withResolvers<void>();
|
||||
|
||||
onSendToDeviceFailure = () => {
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
|
||||
@@ -73,15 +73,15 @@ describe("onResumedSync", () => {
|
||||
onSendToDeviceSuccess = () => {
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(3);
|
||||
expect(store.removeToDeviceBatch).toHaveBeenCalled();
|
||||
deferred.resolve();
|
||||
successResolvers.resolve();
|
||||
};
|
||||
|
||||
queue.start();
|
||||
return deferred.promise;
|
||||
return successResolvers.promise;
|
||||
});
|
||||
|
||||
it("does not resend queue if client sync still catching up", async () => {
|
||||
const deferred = defer();
|
||||
const successResolvers = Promise.withResolvers<void>();
|
||||
|
||||
onSendToDeviceFailure = () => {
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
|
||||
@@ -89,15 +89,15 @@ describe("onResumedSync", () => {
|
||||
|
||||
resumeSync(SyncState.Catchup, SyncState.Catchup);
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
|
||||
deferred.resolve();
|
||||
successResolvers.resolve();
|
||||
};
|
||||
|
||||
queue.start();
|
||||
return deferred.promise;
|
||||
return successResolvers.promise;
|
||||
});
|
||||
|
||||
it("does not resend queue if connectivity restored after queue stopped", async () => {
|
||||
const deferred = defer();
|
||||
const successResolvers = Promise.withResolvers<void>();
|
||||
|
||||
onSendToDeviceFailure = () => {
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
|
||||
@@ -107,10 +107,10 @@ describe("onResumedSync", () => {
|
||||
|
||||
resumeSync(SyncState.Syncing, SyncState.Catchup);
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
|
||||
deferred.resolve();
|
||||
successResolvers.resolve();
|
||||
};
|
||||
|
||||
queue.start();
|
||||
return deferred.promise;
|
||||
return successResolvers.promise;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,14 +196,14 @@ describe("Topic content helpers", () => {
|
||||
expect(makeTopicContent("pizza", "<b>pizza</b>")).toEqual({
|
||||
topic: "pizza",
|
||||
[M_TOPIC.name]: [
|
||||
{
|
||||
body: "pizza",
|
||||
mimetype: "text/plain",
|
||||
},
|
||||
{
|
||||
body: "<b>pizza</b>",
|
||||
mimetype: "text/html",
|
||||
},
|
||||
{
|
||||
body: "pizza",
|
||||
mimetype: "text/plain",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
+159
-21
@@ -28,10 +28,12 @@ import {
|
||||
WidgetApiToWidgetAction,
|
||||
MatrixCapabilities,
|
||||
type ITurnServer,
|
||||
type IRoomEvent,
|
||||
type IOpenIDCredentials,
|
||||
type ISendEventFromWidgetResponseData,
|
||||
WidgetApiResponseError,
|
||||
UnstableApiVersion,
|
||||
type ApiVersion,
|
||||
type IRoomEvent,
|
||||
} from "matrix-widget-api";
|
||||
|
||||
import { createRoomWidgetClient, MatrixError, MsgType, UpdateDelayedEventAction } from "../../src/matrix";
|
||||
@@ -41,6 +43,9 @@ import { type ICapabilities, type RoomWidgetClient } from "../../src/embedded";
|
||||
import { MatrixEvent } from "../../src/models/event";
|
||||
import { type ToDeviceBatch } from "../../src/models/ToDeviceMessage";
|
||||
import { sleep } from "../../src/utils";
|
||||
import { SlidingSync } from "../../src/sliding-sync";
|
||||
import { logger } from "../../src/logger";
|
||||
import { flushPromises } from "../test-utils/flushPromises";
|
||||
|
||||
const testOIDCToken = {
|
||||
access_token: "12345678",
|
||||
@@ -50,6 +55,7 @@ const testOIDCToken = {
|
||||
};
|
||||
class MockWidgetApi extends EventEmitter {
|
||||
public start = jest.fn().mockResolvedValue(undefined);
|
||||
public getClientVersions = jest.fn();
|
||||
public requestCapability = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapabilities = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapabilityForRoomTimeline = jest.fn().mockResolvedValue(undefined);
|
||||
@@ -97,6 +103,15 @@ class MockWidgetApi extends EventEmitter {
|
||||
send: jest.fn(),
|
||||
sendComplete: jest.fn(),
|
||||
};
|
||||
|
||||
/**
|
||||
* This mocks the widget's view of what is supported by its environment.
|
||||
* @param clientVersions The versions that the widget believes are supported by the host client's widget driver.
|
||||
*/
|
||||
public constructor(clientVersions: ApiVersion[]) {
|
||||
super();
|
||||
this.getClientVersions.mockResolvedValue(clientVersions);
|
||||
}
|
||||
}
|
||||
|
||||
declare module "../../src/types" {
|
||||
@@ -118,7 +133,7 @@ describe("RoomWidgetClient", () => {
|
||||
let client: MatrixClient;
|
||||
|
||||
beforeEach(() => {
|
||||
widgetApi = new MockWidgetApi() as unknown as MockedObject<WidgetApi>;
|
||||
widgetApi = new MockWidgetApi([UnstableApiVersion.MSC2762_UPDATE_STATE]) as unknown as MockedObject<WidgetApi>;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -129,6 +144,7 @@ describe("RoomWidgetClient", () => {
|
||||
capabilities: ICapabilities,
|
||||
sendContentLoaded: boolean | undefined = undefined,
|
||||
userId?: string,
|
||||
useSlidingSync?: boolean,
|
||||
): Promise<void> => {
|
||||
const baseUrl = "https://example.org";
|
||||
client = createRoomWidgetClient(
|
||||
@@ -140,7 +156,7 @@ describe("RoomWidgetClient", () => {
|
||||
);
|
||||
expect(widgetApi.start).toHaveBeenCalled(); // needs to have been called early in order to not miss messages
|
||||
widgetApi.emit("ready");
|
||||
await client.startClient();
|
||||
await client.startClient(useSlidingSync ? { slidingSync: new SlidingSync("", new Map(), {}, client, 0) } : {});
|
||||
};
|
||||
|
||||
describe("events", () => {
|
||||
@@ -634,12 +650,20 @@ describe("RoomWidgetClient", () => {
|
||||
});
|
||||
|
||||
it("receives", async () => {
|
||||
await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
|
||||
const init = makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
|
||||
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
||||
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
|
||||
// Client needs to be told that the room state is loaded
|
||||
widgetApi.emit(
|
||||
`action:${WidgetApiToWidgetAction.UpdateState}`,
|
||||
new CustomEvent(`action:${WidgetApiToWidgetAction.UpdateState}`, { detail: { data: { state: [] } } }),
|
||||
);
|
||||
await init;
|
||||
|
||||
const emittedEvent = new Promise<MatrixEvent>((resolve) => client.once(ClientEvent.Event, resolve));
|
||||
const emittedSync = new Promise<SyncState>((resolve) => client.once(ClientEvent.Sync, resolve));
|
||||
// Let's assume that a state event comes in but it doesn't actually
|
||||
// update the state of the room just yet (maybe it's unauthorized)
|
||||
widgetApi.emit(
|
||||
`action:${WidgetApiToWidgetAction.SendEvent}`,
|
||||
new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, { detail: { data: event } }),
|
||||
@@ -648,26 +672,139 @@ describe("RoomWidgetClient", () => {
|
||||
// The client should've emitted about the received event
|
||||
expect((await emittedEvent).getEffectiveEvent()).toEqual(event);
|
||||
expect(await emittedSync).toEqual(SyncState.Syncing);
|
||||
// It should've also inserted the event into the room object
|
||||
// However it should not have changed the room state
|
||||
const room = client.getRoom("!1:example.org");
|
||||
expect(room).not.toBeNull();
|
||||
expect(room!.currentState.getStateEvents("org.example.foo", "bar")).toBe(null);
|
||||
|
||||
// Now assume that the state event becomes favored by state
|
||||
// resolution for whatever reason and enters into the current state
|
||||
// of the room
|
||||
widgetApi.emit(
|
||||
`action:${WidgetApiToWidgetAction.UpdateState}`,
|
||||
new CustomEvent(`action:${WidgetApiToWidgetAction.UpdateState}`, {
|
||||
detail: { data: { state: [event] } },
|
||||
}),
|
||||
);
|
||||
// Allow the getClientVersions promise to resolve
|
||||
await flushPromises();
|
||||
// It should now have changed the room state
|
||||
expect(room!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event);
|
||||
});
|
||||
|
||||
it("backfills", async () => {
|
||||
widgetApi.readStateEvents.mockImplementation(async (eventType, limit, stateKey) =>
|
||||
eventType === "org.example.foo" && (limit ?? Infinity) > 0 && stateKey === "bar"
|
||||
? [event as IRoomEvent]
|
||||
: [],
|
||||
describe("without support for update_state", () => {
|
||||
beforeEach(() => {
|
||||
widgetApi = new MockWidgetApi([]) as unknown as MockedObject<WidgetApi>;
|
||||
});
|
||||
|
||||
it("receives", async () => {
|
||||
await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
|
||||
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
||||
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
|
||||
|
||||
const emittedEvent = new Promise<MatrixEvent>((resolve) => client.once(ClientEvent.Event, resolve));
|
||||
const emittedSync = new Promise<SyncState>((resolve) => client.once(ClientEvent.Sync, resolve));
|
||||
widgetApi.emit(
|
||||
`action:${WidgetApiToWidgetAction.SendEvent}`,
|
||||
new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, { detail: { data: event } }),
|
||||
);
|
||||
|
||||
// The client should've emitted about the received event
|
||||
expect((await emittedEvent).getEffectiveEvent()).toEqual(event);
|
||||
expect(await emittedSync).toEqual(SyncState.Syncing);
|
||||
// It should've also inserted the event into the room object
|
||||
const room = client.getRoom("!1:example.org");
|
||||
expect(room).not.toBeNull();
|
||||
expect(room!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event);
|
||||
});
|
||||
|
||||
it("does not receive with sliding sync (update_state is needed for sliding sync)", async () => {
|
||||
await makeClient(
|
||||
{ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] },
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
||||
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
|
||||
|
||||
const emittedEvent = new Promise<MatrixEvent>((resolve) => client.once(ClientEvent.Event, resolve));
|
||||
const emittedSync = new Promise<SyncState>((resolve) => client.once(ClientEvent.Sync, resolve));
|
||||
const logSpy = jest.spyOn(logger, "error");
|
||||
widgetApi.emit(
|
||||
`action:${WidgetApiToWidgetAction.SendEvent}`,
|
||||
new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, { detail: { data: event } }),
|
||||
);
|
||||
|
||||
// The client should've emitted about the received event
|
||||
expect((await emittedEvent).getEffectiveEvent()).toEqual(event);
|
||||
expect(await emittedSync).toEqual(SyncState.Syncing);
|
||||
|
||||
// The incompatibility of sliding sync without update_state to get logged.
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
"slididng sync cannot be used in widget mode if the client widget driver does not support the version: 'org.matrix.msc2762_update_state'",
|
||||
);
|
||||
// It should not have inserted the event into the room object
|
||||
const room = client.getRoom("!1:example.org");
|
||||
expect(room).not.toBeNull();
|
||||
expect(room!.currentState.getStateEvents("org.example.foo", "bar")).toEqual(null);
|
||||
});
|
||||
|
||||
it("backfills", async () => {
|
||||
widgetApi.readStateEvents.mockImplementation(async (eventType, limit, stateKey) =>
|
||||
eventType === "org.example.foo" && (limit ?? Infinity) > 0 && stateKey === "bar"
|
||||
? [event as IRoomEvent]
|
||||
: [],
|
||||
);
|
||||
|
||||
await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
|
||||
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
||||
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
|
||||
|
||||
const room = client.getRoom("!1:example.org");
|
||||
expect(room).not.toBeNull();
|
||||
expect(room!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event);
|
||||
});
|
||||
it("backfills with sliding sync", async () => {
|
||||
widgetApi.readStateEvents.mockImplementation(async (eventType, limit, stateKey) =>
|
||||
eventType === "org.example.foo" && (limit ?? Infinity) > 0 && stateKey === "bar"
|
||||
? [event as IRoomEvent]
|
||||
: [],
|
||||
);
|
||||
await makeClient(
|
||||
{ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] },
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
||||
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
|
||||
|
||||
const room = client.getRoom("!1:example.org");
|
||||
expect(room).not.toBeNull();
|
||||
expect(room!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event);
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores state updates for other rooms", async () => {
|
||||
const init = makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
|
||||
// Client needs to be told that the room state is loaded
|
||||
widgetApi.emit(
|
||||
`action:${WidgetApiToWidgetAction.UpdateState}`,
|
||||
new CustomEvent(`action:${WidgetApiToWidgetAction.UpdateState}`, { detail: { data: { state: [] } } }),
|
||||
);
|
||||
await init;
|
||||
|
||||
await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
|
||||
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
||||
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
|
||||
|
||||
const room = client.getRoom("!1:example.org");
|
||||
expect(room).not.toBeNull();
|
||||
expect(room!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event);
|
||||
// Now a room we're not interested in receives a state update
|
||||
widgetApi.emit(
|
||||
`action:${WidgetApiToWidgetAction.UpdateState}`,
|
||||
new CustomEvent(`action:${WidgetApiToWidgetAction.UpdateState}`, {
|
||||
detail: { data: { state: [{ ...event, room_id: "!other-room:example.org" }] } },
|
||||
}),
|
||||
);
|
||||
// No change to the room state
|
||||
for (const room of client.getRooms()) {
|
||||
expect(room.currentState.getStateEvents("org.example.foo", "bar")).toBe(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -722,13 +859,14 @@ describe("RoomWidgetClient", () => {
|
||||
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, expectedRequestData);
|
||||
});
|
||||
|
||||
it("sends encrypted (encryptAndSendToDevices)", async () => {
|
||||
it("sends encrypted (encryptAndSendToDevice)", async () => {
|
||||
await makeClient({ sendToDevice: ["org.example.foo"] });
|
||||
expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo");
|
||||
|
||||
const payload = { type: "org.example.foo", hello: "world" };
|
||||
const payload = { hello: "world" };
|
||||
const embeddedClient = client as RoomWidgetClient;
|
||||
await embeddedClient.encryptAndSendToDevices(
|
||||
await embeddedClient.encryptAndSendToDevice(
|
||||
"org.example.foo",
|
||||
[
|
||||
{ userId: "@alice:example.org", deviceId: "aliceWeb" },
|
||||
{ userId: "@bob:example.org", deviceId: "bobDesktop" },
|
||||
|
||||
@@ -170,4 +170,11 @@ describe("Filter Component", function () {
|
||||
expect(filter.check(noMatchEvent)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toJSON", () => {
|
||||
it("should omit empty values", () => {
|
||||
const filter = new FilterComponent({ types: ["m.room.message"], senders: ["@alice:example.com"] });
|
||||
expect(filter.toJSON()).toEqual({ types: ["m.room.message"], senders: ["@alice:example.com"] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,13 +29,18 @@ import {
|
||||
Method,
|
||||
} from "../../../src";
|
||||
import { emitPromise } from "../../test-utils/test-utils";
|
||||
import { defer, type QueryDict } from "../../../src/utils";
|
||||
import { type QueryDict, sleep } from "../../../src/utils";
|
||||
import { type Logger } from "../../../src/logger";
|
||||
|
||||
describe("FetchHttpApi", () => {
|
||||
const baseUrl = "http://baseUrl";
|
||||
const idBaseUrl = "http://idBaseUrl";
|
||||
const prefix = ClientPrefix.V3;
|
||||
const tokenInactiveError = new MatrixError({ errcode: "M_UNKNOWN_TOKEN", error: "Token is not active" }, 401);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("should support aborting multiple times", () => {
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
|
||||
@@ -351,7 +356,9 @@ describe("FetchHttpApi", () => {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
});
|
||||
const result = await api.authedRequest(Method.Post, "/account/password");
|
||||
const result = await api.authedRequest(Method.Post, "/account/password", undefined, undefined, {
|
||||
headers: {},
|
||||
});
|
||||
expect(result).toEqual(okayResponse);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
|
||||
|
||||
@@ -361,12 +368,22 @@ describe("FetchHttpApi", () => {
|
||||
expect(emitter.emit).not.toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
|
||||
});
|
||||
|
||||
it("should only try to refresh the token once", async () => {
|
||||
it("should not try to refresh the token if it has plenty of time left before expiry", async () => {
|
||||
// We can't specify an expiry for the initial token, so this should:
|
||||
// * Try once, fail
|
||||
// * Attempt a refresh, get a token that's not expired
|
||||
// * Try again, still fail
|
||||
// * Not refresh the token because it's not expired
|
||||
// ...which is TWO attempts and ONE refresh (which doesn't really
|
||||
// count because it's only to get a token with an expiry)
|
||||
const newAccessToken = "new-access-token";
|
||||
const newRefreshToken = "new-refresh-token";
|
||||
const tokenRefreshFunction = jest.fn().mockResolvedValue({
|
||||
const tokenRefreshFunction = jest.fn().mockReturnValue({
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
// This needs to be sufficiently high that it's over the threshold for
|
||||
// 'plenty of time' (which is a minute in practice).
|
||||
expiry: new Date(Date.now() + 5 * 60 * 1000),
|
||||
});
|
||||
|
||||
// fetch doesn't like our new or old tokens
|
||||
@@ -386,7 +403,7 @@ describe("FetchHttpApi", () => {
|
||||
unknownTokenErr,
|
||||
);
|
||||
|
||||
// tried to refresh the token once
|
||||
// tried to refresh the token once (to get the one with an expiry)
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -397,6 +414,54 @@ describe("FetchHttpApi", () => {
|
||||
// logged out after refreshed access token is rejected
|
||||
expect(emitter.emit).toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
|
||||
});
|
||||
|
||||
it("should try to refresh the token if it will expire soon", async () => {
|
||||
const newAccessToken = "new-access-token";
|
||||
const newRefreshToken = "new-refresh-token";
|
||||
|
||||
// first refresh is to get a token with an expiry at all, because we
|
||||
// can't specify an expiry on the token we inject
|
||||
const tokenRefreshFunction = jest.fn().mockResolvedValueOnce({
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
expiry: new Date(Date.now() + 1000),
|
||||
});
|
||||
|
||||
// next refresh is to return a token that will expire 'soon'
|
||||
tokenRefreshFunction.mockResolvedValueOnce({
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
expiry: new Date(Date.now() + 1000),
|
||||
});
|
||||
|
||||
// ...and finally we return a token that has adequate time left
|
||||
// so that it will cease retrying and fail the request.
|
||||
tokenRefreshFunction.mockResolvedValueOnce({
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
expiry: new Date(Date.now() + 5 * 60 * 1000),
|
||||
});
|
||||
|
||||
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
|
||||
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
jest.spyOn(emitter, "emit");
|
||||
const api = new FetchHttpApi(emitter, {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
tokenRefreshFunction,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
});
|
||||
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
|
||||
unknownTokenErr,
|
||||
);
|
||||
|
||||
// We should have seen the 3 token refreshes, as above.
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -456,12 +521,89 @@ describe("FetchHttpApi", () => {
|
||||
describe("when fetch.opts.baseUrl does have a trailing slash", () => {
|
||||
runTests(baseUrlWithTrailingSlash);
|
||||
});
|
||||
|
||||
describe("extraParams handling", () => {
|
||||
const makeApiWithExtraParams = (extraParams: QueryDict): FetchHttpApi<any> => {
|
||||
const fetchFn = jest.fn();
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
return new FetchHttpApi(emitter, { baseUrl: localBaseUrl, prefix, fetchFn, extraParams });
|
||||
};
|
||||
|
||||
const userId = "@rsb-tbg:localhost";
|
||||
const encodedUserId = encodeURIComponent(userId);
|
||||
|
||||
it("should include extraParams in URL when no queryParams provided", () => {
|
||||
const extraParams = { user_id: userId, version: "1.0" };
|
||||
const api = makeApiWithExtraParams(extraParams);
|
||||
|
||||
const result = api.getUrl("/test");
|
||||
expect(result.toString()).toBe(`${localBaseUrl}${prefix}/test?user_id=${encodedUserId}&version=1.0`);
|
||||
});
|
||||
|
||||
it("should merge extraParams with queryParams", () => {
|
||||
const extraParams = { user_id: userId, version: "1.0" };
|
||||
const api = makeApiWithExtraParams(extraParams);
|
||||
|
||||
const queryParams = { userId: "123", filter: "active" };
|
||||
const result = api.getUrl("/test", queryParams);
|
||||
|
||||
expect(result.searchParams.get("user_id")!).toBe(userId);
|
||||
expect(result.searchParams.get("version")!).toBe("1.0");
|
||||
expect(result.searchParams.get("userId")!).toBe("123");
|
||||
expect(result.searchParams.get("filter")!).toBe("active");
|
||||
});
|
||||
|
||||
it("should allow queryParams to override extraParams", () => {
|
||||
const extraParams = { user_id: "@default:localhost", version: "1.0" };
|
||||
const api = makeApiWithExtraParams(extraParams);
|
||||
|
||||
const queryParams = { user_id: "@override:localhost", userId: "123" };
|
||||
const result = api.getUrl("/test", queryParams);
|
||||
|
||||
expect(result.searchParams.get("user_id")).toBe("@override:localhost");
|
||||
expect(result.searchParams.get("version")!).toBe("1.0");
|
||||
expect(result.searchParams.get("userId")!).toBe("123");
|
||||
});
|
||||
|
||||
it("should handle empty extraParams", () => {
|
||||
const extraParams = {};
|
||||
const api = makeApiWithExtraParams(extraParams);
|
||||
|
||||
const queryParams = { userId: "123" };
|
||||
const result = api.getUrl("/test", queryParams);
|
||||
|
||||
expect(result.searchParams.get("userId")!).toBe("123");
|
||||
expect(result.searchParams.has("user_id")).toBe(false);
|
||||
});
|
||||
|
||||
it("should work when extraParams is undefined", () => {
|
||||
const fetchFn = jest.fn();
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
const api = new FetchHttpApi(emitter, { baseUrl: localBaseUrl, prefix, fetchFn });
|
||||
|
||||
const queryParams = { userId: "123" };
|
||||
const result = api.getUrl("/test", queryParams);
|
||||
|
||||
expect(result.searchParams.get("userId")!).toBe("123");
|
||||
expect(result.toString()).toBe(`${localBaseUrl}${prefix}/test?userId=123`);
|
||||
});
|
||||
|
||||
it("should work when queryParams is undefined", () => {
|
||||
const extraParams = { user_id: userId, version: "1.0" };
|
||||
const api = makeApiWithExtraParams(extraParams);
|
||||
|
||||
const result = api.getUrl("/test");
|
||||
|
||||
expect(result.searchParams.get("user_id")!).toBe(userId);
|
||||
expect(result.toString()).toBe(`${localBaseUrl}${prefix}/test?user_id=${encodedUserId}&version=1.0`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should not log query parameters", async () => {
|
||||
jest.useFakeTimers();
|
||||
const deferred = defer<Response>();
|
||||
const fetchFn = jest.fn().mockReturnValue(deferred.promise);
|
||||
const responseResolvers = Promise.withResolvers<Response>();
|
||||
const fetchFn = jest.fn().mockReturnValue(responseResolvers.promise);
|
||||
const mockLogger = {
|
||||
debug: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
@@ -473,7 +615,7 @@ describe("FetchHttpApi", () => {
|
||||
});
|
||||
const prom = api.requestOtherUrl(Method.Get, "https://server:8448/some/path?query=param#fragment");
|
||||
jest.advanceTimersByTime(1234);
|
||||
deferred.resolve({ ok: true, status: 200, text: () => Promise.resolve("RESPONSE") } as Response);
|
||||
responseResolvers.resolve({ ok: true, status: 200, text: () => Promise.resolve("RESPONSE") } as Response);
|
||||
await prom;
|
||||
expect(mockLogger.debug).not.toHaveBeenCalledWith("fragment");
|
||||
expect(mockLogger.debug).not.toHaveBeenCalledWith("query");
|
||||
@@ -492,9 +634,7 @@ describe("FetchHttpApi", () => {
|
||||
});
|
||||
|
||||
it("should not make multiple concurrent refresh token requests", async () => {
|
||||
const tokenInactiveError = new MatrixError({ errcode: "M_UNKNOWN_TOKEN", error: "Token is not active" }, 401);
|
||||
|
||||
const deferredTokenRefresh = defer<{ accessToken: string; refreshToken: string }>();
|
||||
const deferredTokenRefresh = Promise.withResolvers<{ accessToken: string; refreshToken: string }>();
|
||||
const fetchFn = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: tokenInactiveError.httpStatus,
|
||||
@@ -523,7 +663,7 @@ describe("FetchHttpApi", () => {
|
||||
const prom1 = api.authedRequest(Method.Get, "/path1");
|
||||
const prom2 = api.authedRequest(Method.Get, "/path2");
|
||||
|
||||
await jest.advanceTimersByTimeAsync(10); // wait for requests to fire
|
||||
await sleep(0); // wait for requests to fire
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
fetchFn.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -547,4 +687,66 @@ describe("FetchHttpApi", () => {
|
||||
expect(api.opts.accessToken).toBe("NEW_ACCESS_TOKEN");
|
||||
expect(api.opts.refreshToken).toBe("NEW_REFRESH_TOKEN");
|
||||
});
|
||||
|
||||
it("should use newly refreshed token if request starts mid-refresh", async () => {
|
||||
const deferredTokenRefresh = Promise.withResolvers<{ accessToken: string; refreshToken: string }>();
|
||||
const fetchFn = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: tokenInactiveError.httpStatus,
|
||||
async text() {
|
||||
return JSON.stringify(tokenInactiveError.data);
|
||||
},
|
||||
async json() {
|
||||
return tokenInactiveError.data;
|
||||
},
|
||||
headers: {
|
||||
get: jest.fn().mockReturnValue("application/json"),
|
||||
},
|
||||
});
|
||||
const tokenRefreshFunction = jest.fn().mockReturnValue(deferredTokenRefresh.promise);
|
||||
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
doNotAttemptTokenRefresh: false,
|
||||
tokenRefreshFunction,
|
||||
accessToken: "ACCESS_TOKEN",
|
||||
refreshToken: "REFRESH_TOKEN",
|
||||
});
|
||||
|
||||
const prom1 = api.authedRequest(Method.Get, "/path1");
|
||||
await sleep(0); // wait for request to fire
|
||||
|
||||
const prom2 = api.authedRequest(Method.Get, "/path2");
|
||||
await sleep(0); // wait for request to fire
|
||||
|
||||
deferredTokenRefresh.resolve({ accessToken: "NEW_ACCESS_TOKEN", refreshToken: "NEW_REFRESH_TOKEN" });
|
||||
fetchFn.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
async text() {
|
||||
return "{}";
|
||||
},
|
||||
async json() {
|
||||
return {};
|
||||
},
|
||||
headers: {
|
||||
get: jest.fn().mockReturnValue("application/json"),
|
||||
},
|
||||
});
|
||||
|
||||
await prom1;
|
||||
await prom2;
|
||||
expect(fetchFn).toHaveBeenCalledTimes(3); // 2 original calls + 1 retry
|
||||
expect(fetchFn.mock.calls[0][1]).toEqual(
|
||||
expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer ACCESS_TOKEN" }) }),
|
||||
);
|
||||
expect(fetchFn.mock.calls[2][1]).toEqual(
|
||||
expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer NEW_ACCESS_TOKEN" }) }),
|
||||
);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledTimes(1);
|
||||
expect(api.opts.accessToken).toBe("NEW_ACCESS_TOKEN");
|
||||
expect(api.opts.refreshToken).toBe("NEW_REFRESH_TOKEN");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
PolicyRecommendation,
|
||||
PolicyScope,
|
||||
} from "../../src/models/invites-ignorer";
|
||||
import { defer, type QueryDict } from "../../src/utils";
|
||||
import { type QueryDict } from "../../src/utils";
|
||||
import { type SyncState } from "../../src/sync";
|
||||
import * as featureUtils from "../../src/feature";
|
||||
import { StubStore } from "../../src/store/stub";
|
||||
@@ -1997,8 +1997,8 @@ describe("MatrixClient", function () {
|
||||
});
|
||||
|
||||
it("should cancel an event which is encrypting", async () => {
|
||||
const encryptEventDefer = defer();
|
||||
mockCrypto.encryptEvent.mockReturnValue(encryptEventDefer.promise);
|
||||
const encryptEventResolvers = Promise.withResolvers<void>();
|
||||
mockCrypto.encryptEvent.mockReturnValue(encryptEventResolvers.promise);
|
||||
|
||||
const statusPromise = testUtils.emitPromise(event, "Event.status");
|
||||
// @ts-ignore protected method access
|
||||
@@ -2009,7 +2009,7 @@ describe("MatrixClient", function () {
|
||||
assertCancelled();
|
||||
|
||||
// now let the encryption complete, and check that the message is not sent.
|
||||
encryptEventDefer.resolve();
|
||||
encryptEventResolvers.resolve();
|
||||
await encryptAndSendPromise;
|
||||
assertCancelled();
|
||||
});
|
||||
|
||||
@@ -16,11 +16,12 @@ limitations under the License.
|
||||
|
||||
import { encodeBase64, EventType, MatrixClient, type MatrixError, type MatrixEvent, type Room } from "../../../src";
|
||||
import { KnownMembership } from "../../../src/@types/membership";
|
||||
import { DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
import { type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
|
||||
import { type EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
|
||||
import { secureRandomString } from "../../../src/randomstring";
|
||||
import { makeMockRoom, makeMockRoomState, membershipTemplate } from "./mocks";
|
||||
import { makeMockEvent, makeMockRoom, makeMockRoomState, membershipTemplate, makeKey } from "./mocks";
|
||||
import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts";
|
||||
|
||||
const mockFocus = { type: "mock" };
|
||||
|
||||
@@ -34,6 +35,8 @@ describe("MatrixRTCSession", () => {
|
||||
client = new MatrixClient({ baseUrl: "base_url" });
|
||||
client.getUserId = jest.fn().mockReturnValue("@alice:example.org");
|
||||
client.getDeviceId = jest.fn().mockReturnValue("AAAAAAA");
|
||||
client.sendEvent = jest.fn().mockResolvedValue({ event_id: "success" });
|
||||
client.decryptEventIfNeeded = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -199,58 +202,6 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateCallMembershipEvent", () => {
|
||||
const mockFocus = { type: "livekit", livekit_service_url: "https://test.org" };
|
||||
const joinSessionConfig = {};
|
||||
|
||||
const sessionMembershipData: SessionMembershipData = {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: "AAAAAAA_session",
|
||||
focus_active: mockFocus,
|
||||
foci_preferred: [mockFocus],
|
||||
};
|
||||
|
||||
let sendStateEventMock: jest.Mock;
|
||||
let sendDelayedStateMock: jest.Mock;
|
||||
|
||||
let sentStateEvent: Promise<void>;
|
||||
let sentDelayedState: Promise<void>;
|
||||
|
||||
beforeEach(() => {
|
||||
sentStateEvent = new Promise((resolve) => {
|
||||
sendStateEventMock = jest.fn(resolve);
|
||||
});
|
||||
sentDelayedState = new Promise((resolve) => {
|
||||
sendDelayedStateMock = jest.fn(() => {
|
||||
resolve();
|
||||
return {
|
||||
delay_id: "id",
|
||||
};
|
||||
});
|
||||
});
|
||||
client.sendStateEvent = sendStateEventMock;
|
||||
client._unstable_sendDelayedStateEvent = sendDelayedStateMock;
|
||||
});
|
||||
|
||||
async function testSession(membershipData: SessionMembershipData): Promise<void> {
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, makeMockRoom(membershipData));
|
||||
|
||||
sess.joinRoomSession([mockFocus], mockFocus, joinSessionConfig);
|
||||
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 500))]);
|
||||
|
||||
expect(sendStateEventMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await Promise.race([sentDelayedState, new Promise((resolve) => setTimeout(resolve, 500))]);
|
||||
expect(sendDelayedStateMock).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
|
||||
it("sends events", async () => {
|
||||
await testSession(sessionMembershipData);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOldestMembership", () => {
|
||||
it("returns the oldest membership event", () => {
|
||||
jest.useFakeTimers();
|
||||
@@ -318,28 +269,10 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
describe("joining", () => {
|
||||
let mockRoom: Room;
|
||||
let sendStateEventMock: jest.Mock;
|
||||
let sendDelayedStateMock: jest.Mock;
|
||||
let sendEventMock: jest.Mock;
|
||||
|
||||
let sentStateEvent: Promise<void>;
|
||||
let sentDelayedState: Promise<void>;
|
||||
|
||||
beforeEach(() => {
|
||||
sentStateEvent = new Promise((resolve) => {
|
||||
sendStateEventMock = jest.fn(resolve);
|
||||
});
|
||||
sentDelayedState = new Promise((resolve) => {
|
||||
sendDelayedStateMock = jest.fn(() => {
|
||||
resolve();
|
||||
return {
|
||||
delay_id: "id",
|
||||
};
|
||||
});
|
||||
});
|
||||
sendEventMock = jest.fn();
|
||||
client.sendStateEvent = sendStateEventMock;
|
||||
client._unstable_sendDelayedStateEvent = sendDelayedStateMock;
|
||||
client.sendEvent = sendEventMock;
|
||||
|
||||
client._unstable_updateDelayedEvent = jest.fn();
|
||||
@@ -365,67 +298,6 @@ describe("MatrixRTCSession", () => {
|
||||
sess!.joinRoomSession([mockFocus], mockFocus);
|
||||
expect(sess!.isJoined()).toEqual(true);
|
||||
});
|
||||
|
||||
it("sends a membership event when joining a call", async () => {
|
||||
const realSetTimeout = setTimeout;
|
||||
jest.useFakeTimers();
|
||||
sess!.joinRoomSession([mockFocus], mockFocus);
|
||||
await Promise.race([sentStateEvent, new Promise((resolve) => realSetTimeout(resolve, 500))]);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
mockRoom!.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
application: "m.call",
|
||||
scope: "m.room",
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
expires: DEFAULT_EXPIRE_DURATION,
|
||||
foci_preferred: [mockFocus],
|
||||
focus_active: {
|
||||
focus_selection: "oldest_membership",
|
||||
type: "livekit",
|
||||
},
|
||||
},
|
||||
"_@alice:example.org_AAAAAAA",
|
||||
);
|
||||
await Promise.race([sentDelayedState, new Promise((resolve) => realSetTimeout(resolve, 500))]);
|
||||
// Because we actually want to send the state
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
// For checking if the delayed event is still there or got removed while sending the state.
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
|
||||
// For scheduling the delayed event
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||
// This returns no error so we do not check if we reschedule the event again. this is done in another test.
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("uses membershipExpiryTimeout from join config", async () => {
|
||||
const realSetTimeout = setTimeout;
|
||||
jest.useFakeTimers();
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { membershipExpiryTimeout: 60000 });
|
||||
await Promise.race([sentStateEvent, new Promise((resolve) => realSetTimeout(resolve, 500))]);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
mockRoom!.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
application: "m.call",
|
||||
scope: "m.room",
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 60000,
|
||||
foci_preferred: [mockFocus],
|
||||
focus_active: {
|
||||
focus_selection: "oldest_membership",
|
||||
type: "livekit",
|
||||
},
|
||||
},
|
||||
"_@alice:example.org_AAAAAAA",
|
||||
);
|
||||
await Promise.race([sentDelayedState, new Promise((resolve) => realSetTimeout(resolve, 500))]);
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("onMembershipsChanged", () => {
|
||||
@@ -478,19 +350,23 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
describe("key management", () => {
|
||||
// TODO make this test suit only test the encryption manager. And mock the transport directly not the session.
|
||||
describe("sending", () => {
|
||||
let mockRoom: Room;
|
||||
let sendStateEventMock: jest.Mock;
|
||||
let sendDelayedStateMock: jest.Mock;
|
||||
let sendEventMock: jest.Mock;
|
||||
let sendToDeviceMock: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
sendStateEventMock = jest.fn();
|
||||
sendDelayedStateMock = jest.fn();
|
||||
sendEventMock = jest.fn();
|
||||
sendStateEventMock = jest.fn().mockResolvedValue({ event_id: "id" });
|
||||
sendDelayedStateMock = jest.fn().mockResolvedValue({ event_id: "id" });
|
||||
sendEventMock = jest.fn().mockResolvedValue({ event_id: "id" });
|
||||
sendToDeviceMock = jest.fn();
|
||||
client.sendStateEvent = sendStateEventMock;
|
||||
client._unstable_sendDelayedStateEvent = sendDelayedStateMock;
|
||||
client.sendEvent = sendEventMock;
|
||||
client.encryptAndSendToDevice = sendToDeviceMock;
|
||||
|
||||
mockRoom = makeMockRoom([]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
@@ -531,12 +407,7 @@ describe("MatrixRTCSession", () => {
|
||||
{
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
keys: [makeKey(0, expect.stringMatching(".*"))],
|
||||
sent_ts: Date.now(),
|
||||
},
|
||||
);
|
||||
@@ -568,24 +439,22 @@ describe("MatrixRTCSession", () => {
|
||||
let firstEventSent = false;
|
||||
|
||||
try {
|
||||
const eventSentPromise = new Promise<void>((resolve) => {
|
||||
const eventSentPromise = new Promise<{ event_id: string }>((resolve) => {
|
||||
sendEventMock.mockImplementation(() => {
|
||||
if (!firstEventSent) {
|
||||
jest.advanceTimersByTime(10000);
|
||||
|
||||
firstEventSent = true;
|
||||
const e = new Error() as MatrixError;
|
||||
e.data = {};
|
||||
throw e;
|
||||
} else {
|
||||
resolve();
|
||||
resolve({ event_id: "id" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
jest.advanceTimersByTime(10000);
|
||||
|
||||
// wait for the encryption event to get sent
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
await eventSentPromise;
|
||||
|
||||
expect(sendEventMock).toHaveBeenCalledTimes(2);
|
||||
@@ -739,12 +608,7 @@ describe("MatrixRTCSession", () => {
|
||||
{
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
keys: [makeKey(0, expect.stringMatching(".*"))],
|
||||
sent_ts: Date.now(),
|
||||
},
|
||||
);
|
||||
@@ -793,12 +657,7 @@ describe("MatrixRTCSession", () => {
|
||||
{
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
keys: [makeKey(0, expect.stringMatching(".*"))],
|
||||
sent_ts: Date.now(),
|
||||
},
|
||||
);
|
||||
@@ -831,12 +690,7 @@ describe("MatrixRTCSession", () => {
|
||||
{
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
keys: [makeKey(0, expect.stringMatching(".*"))],
|
||||
sent_ts: Date.now(),
|
||||
},
|
||||
);
|
||||
@@ -849,6 +703,7 @@ describe("MatrixRTCSession", () => {
|
||||
it("rotates key if a member leaves", async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const KEY_DELAY = 3000;
|
||||
const member2 = Object.assign({}, membershipTemplate, {
|
||||
device_id: "BBBBBBB",
|
||||
});
|
||||
@@ -869,7 +724,8 @@ describe("MatrixRTCSession", () => {
|
||||
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
|
||||
});
|
||||
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true, makeKeyDelay: KEY_DELAY });
|
||||
const sendKeySpy = jest.spyOn((sess as unknown as any).encryptionManager.transport, "sendKey");
|
||||
const firstKeysPayload = await keysSentPromise1;
|
||||
expect(firstKeysPayload.keys).toHaveLength(1);
|
||||
expect(firstKeysPayload.keys[0].index).toEqual(0);
|
||||
@@ -886,14 +742,40 @@ describe("MatrixRTCSession", () => {
|
||||
.mockReturnValue(makeMockRoomState([membershipTemplate], mockRoom.roomId));
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
|
||||
jest.advanceTimersByTime(10000);
|
||||
jest.advanceTimersByTime(KEY_DELAY);
|
||||
expect(sendKeySpy).toHaveBeenCalledTimes(1);
|
||||
// check that we send the key with index 1 even though the send gets delayed when leaving.
|
||||
// this makes sure we do not use an index that is one too old.
|
||||
expect(sendKeySpy).toHaveBeenLastCalledWith(
|
||||
expect.any(String),
|
||||
1,
|
||||
sess.memberships.map((m) => ({
|
||||
userId: m.sender,
|
||||
deviceId: m.deviceId,
|
||||
membershipTs: m.createdTs(),
|
||||
})),
|
||||
);
|
||||
// fake a condition in which we send another encryption key event.
|
||||
// this could happen do to someone joining the call.
|
||||
(sess as unknown as any).encryptionManager.sendEncryptionKeysEvent();
|
||||
expect(sendKeySpy).toHaveBeenLastCalledWith(
|
||||
expect.any(String),
|
||||
1,
|
||||
sess.memberships.map((m) => ({
|
||||
userId: m.sender,
|
||||
deviceId: m.deviceId,
|
||||
membershipTs: m.createdTs(),
|
||||
})),
|
||||
);
|
||||
jest.advanceTimersByTime(7000);
|
||||
|
||||
const secondKeysPayload = await keysSentPromise2;
|
||||
|
||||
expect(secondKeysPayload.keys).toHaveLength(1);
|
||||
expect(secondKeysPayload.keys[0].index).toEqual(1);
|
||||
expect(onMyEncryptionKeyChanged).toHaveBeenCalledTimes(2);
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(2);
|
||||
// initial, on leave and the fake one we do with: `(sess as unknown as any).encryptionManager.sendEncryptionKeysEvent();`
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(3);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
@@ -982,64 +864,78 @@ describe("MatrixRTCSession", () => {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("send key as to device", async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const keySentPromise = new Promise((resolve) => {
|
||||
sendToDeviceMock.mockImplementation(resolve);
|
||||
});
|
||||
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, {
|
||||
manageMediaKeys: true,
|
||||
useExperimentalToDeviceTransport: true,
|
||||
});
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
|
||||
await keySentPromise;
|
||||
|
||||
expect(sendToDeviceMock).toHaveBeenCalled();
|
||||
|
||||
// Access private to test
|
||||
expect(sess["encryptionManager"]).toBeInstanceOf(RTCEncryptionManager);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("receiving", () => {
|
||||
it("collects keys from encryption events", () => {
|
||||
it("collects keys from encryption events", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
keys: [makeKey(0, "dGhpcyBpcyB0aGUga2V5")],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
} as unknown as MatrixEvent);
|
||||
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
const encryptionKeyChangedListener = jest.fn();
|
||||
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
|
||||
sess!.reemitEncryptionKeys();
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("this is the key"),
|
||||
0,
|
||||
"@bob:example.org:bobsphone",
|
||||
);
|
||||
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1);
|
||||
});
|
||||
|
||||
it("collects keys at non-zero indices", () => {
|
||||
it("collects keys at non-zero indices", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 4,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
keys: [makeKey(4, "dGhpcyBpcyB0aGUga2V5")],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
} as unknown as MatrixEvent);
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
const encryptionKeyChangedListener = jest.fn();
|
||||
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
|
||||
sess!.reemitEncryptionKeys();
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("this is the key"),
|
||||
4,
|
||||
@@ -1049,61 +945,48 @@ describe("MatrixRTCSession", () => {
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1);
|
||||
});
|
||||
|
||||
it("collects keys by merging", () => {
|
||||
it("collects keys by merging", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
keys: [makeKey(0, "dGhpcyBpcyB0aGUga2V5")],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
} as unknown as MatrixEvent);
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
const encryptionKeyChangedListener = jest.fn();
|
||||
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
|
||||
sess!.reemitEncryptionKeys();
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("this is the key"),
|
||||
0,
|
||||
"@bob:example.org:bobsphone",
|
||||
);
|
||||
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1);
|
||||
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 4,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
} as unknown as MatrixEvent);
|
||||
|
||||
encryptionKeyChangedListener.mockClear();
|
||||
sess!.reemitEncryptionKeys();
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("this is the key"),
|
||||
0,
|
||||
"@bob:example.org:bobsphone",
|
||||
);
|
||||
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1);
|
||||
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [makeKey(4, "dGhpcyBpcyB0aGUga2V5")],
|
||||
}),
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
encryptionKeyChangedListener.mockClear();
|
||||
sess!.reemitEncryptionKeys();
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(3);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("this is the key"),
|
||||
0,
|
||||
"@bob:example.org:bobsphone",
|
||||
);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("this is the key"),
|
||||
4,
|
||||
@@ -1113,93 +996,102 @@ describe("MatrixRTCSession", () => {
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(2);
|
||||
});
|
||||
|
||||
it("ignores older keys at same index", () => {
|
||||
it("ignores older keys at same index", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: encodeBase64(Buffer.from("newer key", "utf-8")),
|
||||
},
|
||||
],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(2000),
|
||||
} as unknown as MatrixEvent);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent(
|
||||
"io.element.call.encryption_keys",
|
||||
"@bob:example.org",
|
||||
"1234roomId",
|
||||
{
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [makeKey(0, encodeBase64(Buffer.from("newer key", "utf-8")))],
|
||||
},
|
||||
2000,
|
||||
),
|
||||
);
|
||||
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: encodeBase64(Buffer.from("older key", "utf-8")),
|
||||
},
|
||||
],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(1000), // earlier timestamp than the newer key
|
||||
} as unknown as MatrixEvent);
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent(
|
||||
"io.element.call.encryption_keys",
|
||||
"@bob:example.org",
|
||||
"1234roomId",
|
||||
{
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [makeKey(0, encodeBase64(Buffer.from("newer key", "utf-8")))],
|
||||
},
|
||||
2000,
|
||||
),
|
||||
);
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent(
|
||||
"io.element.call.encryption_keys",
|
||||
"@bob:example.org",
|
||||
"1234roomId",
|
||||
{
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [makeKey(0, encodeBase64(Buffer.from("older key", "utf-8")))],
|
||||
},
|
||||
1000,
|
||||
),
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
const encryptionKeyChangedListener = jest.fn();
|
||||
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
|
||||
sess!.reemitEncryptionKeys();
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("newer key"),
|
||||
0,
|
||||
"@bob:example.org:bobsphone",
|
||||
);
|
||||
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(2);
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(3);
|
||||
});
|
||||
|
||||
it("key timestamps are treated as monotonic", () => {
|
||||
it("key timestamps are treated as monotonic", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: encodeBase64(Buffer.from("first key", "utf-8")),
|
||||
},
|
||||
],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
} as unknown as MatrixEvent);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent(
|
||||
"io.element.call.encryption_keys",
|
||||
"@bob:example.org",
|
||||
"1234roomId",
|
||||
{
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [makeKey(0, encodeBase64(Buffer.from("older key", "utf-8")))],
|
||||
},
|
||||
1000,
|
||||
),
|
||||
);
|
||||
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: encodeBase64(Buffer.from("second key", "utf-8")),
|
||||
},
|
||||
],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(1000), // same timestamp as the first key
|
||||
} as unknown as MatrixEvent);
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent(
|
||||
"io.element.call.encryption_keys",
|
||||
"@bob:example.org",
|
||||
"1234roomId",
|
||||
{
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [makeKey(0, encodeBase64(Buffer.from("second key", "utf-8")))],
|
||||
},
|
||||
1000,
|
||||
),
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
const encryptionKeyChangedListener = jest.fn();
|
||||
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
|
||||
sess!.reemitEncryptionKeys();
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("second key"),
|
||||
0,
|
||||
@@ -1210,31 +1102,25 @@ describe("MatrixRTCSession", () => {
|
||||
it("ignores keys event for the local participant", () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", client.getUserId()!, "1234roomId", {
|
||||
device_id: client.getDeviceId(),
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 4,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
keys: [makeKey(4, "dGhpcyBpcyB0aGUga2V5")],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue(client.getUserId()),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
} as unknown as MatrixEvent);
|
||||
);
|
||||
|
||||
const encryptionKeyChangedListener = jest.fn();
|
||||
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
|
||||
sess!.reemitEncryptionKeys();
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(0);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(0);
|
||||
});
|
||||
|
||||
it("tracks total age statistics for collected keys", () => {
|
||||
it("tracks total age statistics for collected keys", async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
@@ -1242,59 +1128,49 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
// defaults to getTs()
|
||||
jest.setSystemTime(1000);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(0),
|
||||
} as unknown as MatrixEvent);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent(
|
||||
"io.element.call.encryption_keys",
|
||||
"@bob:example.org",
|
||||
"1234roomId",
|
||||
{
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "dGhpcyBpcyB0aGUga2V5")],
|
||||
},
|
||||
0,
|
||||
),
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
expect(sess!.statistics.totals.roomEventEncryptionKeysReceivedTotalAge).toEqual(1000);
|
||||
|
||||
jest.setSystemTime(2000);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
keys: [makeKey(0, "dGhpcyBpcyB0aGUga2V5")],
|
||||
sent_ts: 0,
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
} as unknown as MatrixEvent);
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
expect(sess!.statistics.totals.roomEventEncryptionKeysReceivedTotalAge).toEqual(3000);
|
||||
|
||||
jest.setSystemTime(3000);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
keys: [makeKey(0, "dGhpcyBpcyB0aGUga2V5")],
|
||||
sent_ts: 1000,
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
} as unknown as MatrixEvent);
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
expect(sess!.statistics.totals.roomEventEncryptionKeysReceivedTotalAge).toEqual(5000);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
|
||||
@@ -16,15 +16,7 @@ limitations under the License.
|
||||
|
||||
import { type Mock } from "jest-mock";
|
||||
|
||||
import {
|
||||
ClientEvent,
|
||||
EventTimeline,
|
||||
EventType,
|
||||
type IRoomTimelineData,
|
||||
MatrixClient,
|
||||
type MatrixEvent,
|
||||
RoomEvent,
|
||||
} from "../../../src";
|
||||
import { ClientEvent, EventTimeline, MatrixClient } from "../../../src";
|
||||
import { RoomStateEvent } from "../../../src/models/room-state";
|
||||
import { MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
|
||||
import { makeMockRoom, makeMockRoomState, membershipTemplate } from "./mocks";
|
||||
@@ -77,117 +69,4 @@ describe("MatrixRTCSessionManager", () => {
|
||||
|
||||
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
|
||||
it("Calls onCallEncryption on encryption keys event", async () => {
|
||||
const room1 = makeMockRoom([membershipTemplate]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
const onCallEncryptionMock = jest.fn();
|
||||
client.matrixRTC.getRoomSession(room1).onCallEncryption = onCallEncryptionMock;
|
||||
client.decryptEventIfNeeded = () => Promise.resolve();
|
||||
const timelineEvent = {
|
||||
getType: jest.fn().mockReturnValue(EventType.CallEncryptionKeysPrefix),
|
||||
getContent: jest.fn().mockReturnValue({}),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getRoomId: jest.fn().mockReturnValue("!room:id"),
|
||||
isDecryptionFailure: jest.fn().mockReturnValue(false),
|
||||
sender: {
|
||||
userId: "@mock:user.example",
|
||||
},
|
||||
} as unknown as MatrixEvent;
|
||||
client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
await new Promise(process.nextTick);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("event decryption", () => {
|
||||
it("Retries decryption and processes success", async () => {
|
||||
try {
|
||||
jest.useFakeTimers();
|
||||
const room1 = makeMockRoom([membershipTemplate]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
const onCallEncryptionMock = jest.fn();
|
||||
client.matrixRTC.getRoomSession(room1).onCallEncryption = onCallEncryptionMock;
|
||||
let isDecryptionFailure = true;
|
||||
client.decryptEventIfNeeded = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(Promise.resolve())
|
||||
.mockImplementation(() => {
|
||||
isDecryptionFailure = false;
|
||||
return Promise.resolve();
|
||||
});
|
||||
const timelineEvent = {
|
||||
getType: jest.fn().mockReturnValue(EventType.CallEncryptionKeysPrefix),
|
||||
getContent: jest.fn().mockReturnValue({}),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getRoomId: jest.fn().mockReturnValue("!room:id"),
|
||||
isDecryptionFailure: jest.fn().mockImplementation(() => isDecryptionFailure),
|
||||
getId: jest.fn().mockReturnValue("event_id"),
|
||||
sender: {
|
||||
userId: "@mock:user.example",
|
||||
},
|
||||
} as unknown as MatrixEvent;
|
||||
client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// should retry after one second:
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("Retries decryption and processes failure", async () => {
|
||||
try {
|
||||
jest.useFakeTimers();
|
||||
const room1 = makeMockRoom([membershipTemplate]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
const onCallEncryptionMock = jest.fn();
|
||||
client.matrixRTC.getRoomSession(room1).onCallEncryption = onCallEncryptionMock;
|
||||
client.decryptEventIfNeeded = jest.fn().mockReturnValue(Promise.resolve());
|
||||
const timelineEvent = {
|
||||
getType: jest.fn().mockReturnValue(EventType.CallEncryptionKeysPrefix),
|
||||
getContent: jest.fn().mockReturnValue({}),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getRoomId: jest.fn().mockReturnValue("!room:id"),
|
||||
isDecryptionFailure: jest.fn().mockReturnValue(true), // always fail
|
||||
getId: jest.fn().mockReturnValue("event_id"),
|
||||
sender: {
|
||||
userId: "@mock:user.example",
|
||||
},
|
||||
} as unknown as MatrixEvent;
|
||||
client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// should retry after one second:
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// doesn't retry again:
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
/**
|
||||
* @jest-environment ./spec/unit/matrixrtc/memberManagerTestEnvironment.ts
|
||||
*/
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
@@ -27,43 +24,47 @@ import {
|
||||
type LivekitFocusActive,
|
||||
type SessionMembershipData,
|
||||
} from "../../../src/matrixrtc";
|
||||
import { LegacyMembershipManager } from "../../../src/matrixrtc/LegacyMembershipManager";
|
||||
import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks";
|
||||
import { MembershipManager } from "../../../src/matrixrtc/NewMembershipManager";
|
||||
import { defer } from "../../../src/utils";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
import { MembershipManager } from "../../../src/matrixrtc/MembershipManager.ts";
|
||||
|
||||
function waitForMockCall(method: MockedFunction<any>, returnVal?: Promise<any>) {
|
||||
return new Promise<void>((resolve) => {
|
||||
method.mockImplementation(() => {
|
||||
resolve();
|
||||
return returnVal ?? Promise.resolve();
|
||||
});
|
||||
/**
|
||||
* Create a promise that will resolve once a mocked method is called.
|
||||
* @param method The method to wait for.
|
||||
* @param returnVal Provide an optional value that the mocked method should return. (use Promise.resolve(val) or Promise.reject(err))
|
||||
* @returns The promise that resolves once the method is called.
|
||||
*/
|
||||
function waitForMockCall(method: MockedFunction<any>, returnVal?: Promise<any>): Promise<void> {
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
method.mockImplementation(() => {
|
||||
resolve();
|
||||
return returnVal ?? Promise.resolve();
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** See waitForMockCall */
|
||||
function waitForMockCallOnce(method: MockedFunction<any>, returnVal?: Promise<any>) {
|
||||
return new Promise<void>((resolve) => {
|
||||
method.mockImplementationOnce(() => {
|
||||
resolve();
|
||||
return returnVal ?? Promise.resolve();
|
||||
});
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
method.mockImplementationOnce(() => {
|
||||
resolve();
|
||||
return returnVal ?? Promise.resolve();
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
function createAsyncHandle(method: MockedFunction<any>) {
|
||||
const { reject, resolve, promise } = defer();
|
||||
/**
|
||||
* A handle to control when in the test flow the provided method resolves (or gets rejected).
|
||||
* @param method The method to control the resolve timing.
|
||||
* @returns
|
||||
*/
|
||||
function createAsyncHandle<T>(method: MockedFunction<any>) {
|
||||
const { reject, resolve, promise } = Promise.withResolvers<T>();
|
||||
method.mockImplementation(() => promise);
|
||||
return { reject, resolve };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests different MembershipManager implementations. Some tests don't apply to `LegacyMembershipManager`
|
||||
* use !FailsForLegacy to skip those. See: testEnvironment for more details.
|
||||
*/
|
||||
|
||||
describe.each([
|
||||
{ TestMembershipManager: LegacyMembershipManager, description: "LegacyMembershipManager" },
|
||||
{ TestMembershipManager: MembershipManager, description: "MembershipManager" },
|
||||
])("$description", ({ TestMembershipManager }) => {
|
||||
describe("MembershipManager", () => {
|
||||
let client: MockClient;
|
||||
let room: Room;
|
||||
const focusActive: LivekitFocusActive = {
|
||||
@@ -94,12 +95,12 @@ describe.each([
|
||||
|
||||
describe("isActivated()", () => {
|
||||
it("defaults to false", () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
expect(manager.isActivated()).toEqual(false);
|
||||
});
|
||||
|
||||
it("returns true after join()", () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([]);
|
||||
expect(manager.isActivated()).toEqual(true);
|
||||
});
|
||||
@@ -110,13 +111,13 @@ describe.each([
|
||||
it("sends a membership event and schedules delayed leave when joining a call", async () => {
|
||||
// Spys/Mocks
|
||||
|
||||
const updateDelayedEventHandle = createAsyncHandle(client._unstable_updateDelayedEvent as Mock);
|
||||
const updateDelayedEventHandle = createAsyncHandle<void>(client._unstable_updateDelayedEvent as Mock);
|
||||
|
||||
// Test
|
||||
const memberManager = new TestMembershipManager(undefined, room, client, () => undefined);
|
||||
const memberManager = new MembershipManager(undefined, room, client, () => undefined);
|
||||
memberManager.join([focus], focusActive);
|
||||
// expects
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" }));
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
room.roomId,
|
||||
"org.matrix.msc3401.call.member",
|
||||
@@ -143,7 +144,7 @@ describe.each([
|
||||
});
|
||||
|
||||
it("reschedules delayed leave event if sending state cancels it", async () => {
|
||||
const memberManager = new TestMembershipManager(undefined, room, client, () => undefined);
|
||||
const memberManager = new MembershipManager(undefined, room, client, () => undefined);
|
||||
const waitForSendState = waitForMockCall(client.sendStateEvent);
|
||||
const waitForUpdateDelaye = waitForMockCallOnce(
|
||||
client._unstable_updateDelayedEvent,
|
||||
@@ -212,9 +213,9 @@ describe.each([
|
||||
return Promise.reject(error);
|
||||
});
|
||||
});
|
||||
const manager = new TestMembershipManager(
|
||||
const manager = new MembershipManager(
|
||||
{
|
||||
membershipServerSideExpiryTimeout: 9000,
|
||||
delayedLeaveEventDelayMs: 9000,
|
||||
},
|
||||
room,
|
||||
client,
|
||||
@@ -275,7 +276,7 @@ describe.each([
|
||||
describe("delayed leave event", () => {
|
||||
it("does not try again to schedule a delayed leave event if not supported", () => {
|
||||
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive);
|
||||
delayedHandle.reject?.(
|
||||
new UnsupportedDelayedEventsEndpointError(
|
||||
@@ -287,15 +288,15 @@ describe.each([
|
||||
});
|
||||
it("does try to schedule a delayed leave event again if rate limited", async () => {
|
||||
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive);
|
||||
delayedHandle.reject?.(new HTTPError("rate limited", 429, undefined));
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("uses membershipServerSideExpiryTimeout from config", () => {
|
||||
const manager = new TestMembershipManager(
|
||||
{ membershipServerSideExpiryTimeout: 123456 },
|
||||
it("uses delayedLeaveEventDelayMs from config", () => {
|
||||
const manager = new MembershipManager(
|
||||
{ delayedLeaveEventDelayMs: 123456 },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
@@ -311,13 +312,46 @@ describe.each([
|
||||
});
|
||||
});
|
||||
|
||||
it("uses membershipExpiryTimeout from config", async () => {
|
||||
const manager = new TestMembershipManager(
|
||||
{ membershipExpiryTimeout: 1234567 },
|
||||
it("rejoins if delayed event is not found (404)", async () => {
|
||||
const RESTART_DELAY = 15000;
|
||||
const manager = new MembershipManager(
|
||||
{ delayedLeaveEventRestartMs: RESTART_DELAY },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
);
|
||||
// Join with the membership manager
|
||||
manager.join([focus], focusActive);
|
||||
expect(manager.status).toBe(Status.Connecting);
|
||||
// Let the scheduler run one iteration so that we can send the join state event
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
expect(manager.status).toBe(Status.Connected);
|
||||
// Now that we are connected, we set up the mocks.
|
||||
// We enforce the following scenario where we simulate that the delayed event activated and caused the user to leave:
|
||||
// - We wait until the delayed event gets sent and then mock its response to be "not found."
|
||||
// - We enforce a race condition between the sync that informs us that our call membership state event was set to "left"
|
||||
// and the "not found" response from the delayed event: we receive the sync while we are waiting for the delayed event to be sent.
|
||||
// - While the delayed leave event is being sent, we inform the manager that our membership state event was set to "left."
|
||||
// (onRTCSessionMemberUpdate)
|
||||
// - Only then do we resolve the sending of the delayed event.
|
||||
// - We test that the manager acknowledges the leave and sends a new membership state event.
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValueOnce(
|
||||
new MatrixError({ errcode: "M_NOT_FOUND" }),
|
||||
);
|
||||
|
||||
const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
await jest.advanceTimersByTimeAsync(RESTART_DELAY);
|
||||
// first simulate the sync, then resolve sending the delayed event.
|
||||
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
|
||||
resolve({ delay_id: "id" });
|
||||
// Let the scheduler run one iteration so that the new join gets sent
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses membershipEventExpiryMs from config", async () => {
|
||||
const manager = new MembershipManager({ membershipEventExpiryMs: 1234567 }, room, client, () => undefined);
|
||||
|
||||
manager.join([focus], focusActive);
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
@@ -341,7 +375,7 @@ describe.each([
|
||||
});
|
||||
|
||||
it("does nothing if join called when already joined", async () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive);
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
@@ -353,7 +387,7 @@ describe.each([
|
||||
describe("leave()", () => {
|
||||
// TODO add rate limit cases.
|
||||
it("resolves delayed leave event when leave is called", async () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
await manager.leave();
|
||||
@@ -361,7 +395,7 @@ describe.each([
|
||||
expect(client.sendStateEvent).toHaveBeenCalled();
|
||||
});
|
||||
it("send leave event when leave is called and resolving delayed leave fails", async () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue("unknown");
|
||||
@@ -375,9 +409,8 @@ describe.each([
|
||||
"_@alice:example.org_AAAAAAA",
|
||||
);
|
||||
});
|
||||
// FailsForLegacy because legacy implementation always sends the empty state event even though it isn't needed
|
||||
it("does nothing if not joined !FailsForLegacy", () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
it("does nothing if not joined", () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
expect(async () => await manager.leave()).not.toThrow();
|
||||
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
@@ -387,7 +420,7 @@ describe.each([
|
||||
describe("getsActiveFocus", () => {
|
||||
it("gets the correct active focus with oldest_membership", () => {
|
||||
const getOldestMembership = jest.fn();
|
||||
const manager = new TestMembershipManager({}, room, client, getOldestMembership);
|
||||
const manager = new MembershipManager({}, room, client, getOldestMembership);
|
||||
// Before joining the active focus should be undefined (see FocusInUse on MatrixRTCSession)
|
||||
expect(manager.getActiveFocus()).toBe(undefined);
|
||||
manager.join([focus], focusActive);
|
||||
@@ -422,7 +455,7 @@ describe.each([
|
||||
});
|
||||
|
||||
it("does not provide focus if the selection method is unknown", () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], Object.assign(focusActive, { type: "unknown_type" }));
|
||||
expect(manager.getActiveFocus()).toBe(undefined);
|
||||
});
|
||||
@@ -430,7 +463,7 @@ describe.each([
|
||||
|
||||
describe("onRTCSessionMemberUpdate()", () => {
|
||||
it("does nothing if not joined", async () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
@@ -438,7 +471,7 @@ describe.each([
|
||||
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does nothing if own membership still present", async () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
const myMembership = (client.sendStateEvent as Mock).mock.calls[0][2];
|
||||
@@ -459,7 +492,7 @@ describe.each([
|
||||
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
it("recreates membership if it is missing", async () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
|
||||
@@ -479,9 +512,9 @@ describe.each([
|
||||
|
||||
// TODO: Not sure about this name
|
||||
describe("background timers", () => {
|
||||
it("sends only one keep-alive for delayed leave event per `membershipKeepAlivePeriod`", async () => {
|
||||
const manager = new TestMembershipManager(
|
||||
{ membershipKeepAlivePeriod: 10_000, membershipServerSideExpiryTimeout: 30_000 },
|
||||
it("sends only one keep-alive for delayed leave event per `delayedLeaveEventRestartMs`", async () => {
|
||||
const manager = new MembershipManager(
|
||||
{ delayedLeaveEventRestartMs: 10_000, delayedLeaveEventDelayMs: 30_000 },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
@@ -506,13 +539,13 @@ describe.each([
|
||||
}
|
||||
});
|
||||
|
||||
// !FailsForLegacy because the expires logic was removed for the legacy call manager.
|
||||
// because the expires logic was removed for the legacy call manager.
|
||||
// Delayed events should replace it entirely but before they have wide adoption
|
||||
// the expiration logic still makes sense.
|
||||
// TODO: Add git commit when we removed it.
|
||||
async function testExpires(expire: number, headroom?: number) {
|
||||
const manager = new TestMembershipManager(
|
||||
{ membershipExpiryTimeout: expire, membershipExpiryTimeoutHeadroom: headroom },
|
||||
const manager = new MembershipManager(
|
||||
{ membershipEventExpiryMs: expire, membershipEventExpiryHeadroomMs: headroom },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
@@ -529,23 +562,23 @@ describe.each([
|
||||
expect(sentMembership.expires).toBe(expire * i);
|
||||
}
|
||||
}
|
||||
it("extends `expires` when call still active !FailsForLegacy", async () => {
|
||||
it("extends `expires` when call still active", async () => {
|
||||
await testExpires(10_000);
|
||||
});
|
||||
it("extends `expires` using headroom configuration !FailsForLegacy", async () => {
|
||||
it("extends `expires` using headroom configuration", async () => {
|
||||
await testExpires(10_000, 1_000);
|
||||
});
|
||||
});
|
||||
describe("status updates", () => {
|
||||
it("starts 'Disconnected' !FailsForLegacy", () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
it("starts 'Disconnected'", () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
expect(manager.status).toBe(Status.Disconnected);
|
||||
});
|
||||
it("emits 'Connection' and 'Connected' after join !FailsForLegacy", async () => {
|
||||
const handleDelayedEvent = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
const handleStateEvent = createAsyncHandle(client.sendStateEvent);
|
||||
it("emits 'Connection' and 'Connected' after join", async () => {
|
||||
const handleDelayedEvent = createAsyncHandle<void>(client._unstable_sendDelayedStateEvent);
|
||||
const handleStateEvent = createAsyncHandle<void>(client.sendStateEvent);
|
||||
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
expect(manager.status).toBe(Status.Disconnected);
|
||||
const connectEmit = jest.fn();
|
||||
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
|
||||
@@ -558,8 +591,8 @@ describe.each([
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected);
|
||||
});
|
||||
it("emits 'Disconnecting' and 'Disconnected' after leave !FailsForLegacy", async () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
it("emits 'Disconnecting' and 'Disconnected' after leave", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
const connectEmit = jest.fn();
|
||||
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
|
||||
manager.join([focus], focusActive);
|
||||
@@ -575,7 +608,7 @@ describe.each([
|
||||
it("sends retry if call membership event is still valid at time of retry", async () => {
|
||||
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive);
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -592,9 +625,8 @@ describe.each([
|
||||
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
// FailsForLegacy as implementation does not re-check membership before retrying.
|
||||
it("abandons retry loop and sends new own membership if not present anymore !FailsForLegacy", async () => {
|
||||
(client._unstable_sendDelayedStateEvent as any).mockRejectedValue(
|
||||
it("abandons retry loop and sends new own membership if not present anymore", async () => {
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
|
||||
new MatrixError(
|
||||
{ errcode: "M_LIMIT_EXCEEDED" },
|
||||
429,
|
||||
@@ -603,7 +635,7 @@ describe.each([
|
||||
new Headers({ "Retry-After": "1" }),
|
||||
),
|
||||
);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
// Should call _unstable_sendDelayedStateEvent but not sendStateEvent because of the
|
||||
// RateLimit error.
|
||||
manager.join([focus], focusActive);
|
||||
@@ -620,11 +652,10 @@ describe.each([
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// FailsForLegacy as implementation does not re-check membership before retrying.
|
||||
it("abandons retry loop if leave() was called before sending state event !FailsForLegacy", async () => {
|
||||
it("abandons retry loop if leave() was called before sending state event", async () => {
|
||||
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive);
|
||||
handle.reject?.(
|
||||
new MatrixError(
|
||||
@@ -649,7 +680,7 @@ describe.each([
|
||||
});
|
||||
});
|
||||
describe("retries sending update delayed leave event restart", () => {
|
||||
it("resends the initial check delayed update event !FailsForLegacy", async () => {
|
||||
it("resends the initial check delayed update event", async () => {
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue(
|
||||
new MatrixError(
|
||||
{ errcode: "M_LIMIT_EXCEEDED" },
|
||||
@@ -659,7 +690,7 @@ describe.each([
|
||||
new Headers({ "Retry-After": "1" }),
|
||||
),
|
||||
);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive);
|
||||
|
||||
// Hit rate limit
|
||||
@@ -680,8 +711,8 @@ describe.each([
|
||||
});
|
||||
});
|
||||
describe("unrecoverable errors", () => {
|
||||
// !FailsForLegacy because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
|
||||
it("throws, when reaching maximum number of retries for initial delayed event creation !FailsForLegacy", async () => {
|
||||
// because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
|
||||
it("throws, when reaching maximum number of retries for initial delayed event creation", async () => {
|
||||
const delayEventSendError = jest.fn();
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
|
||||
new MatrixError(
|
||||
@@ -692,7 +723,7 @@ describe.each([
|
||||
new Headers({ "Retry-After": "2" }),
|
||||
),
|
||||
);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive, delayEventSendError);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
@@ -700,8 +731,8 @@ describe.each([
|
||||
}
|
||||
expect(delayEventSendError).toHaveBeenCalled();
|
||||
});
|
||||
// !FailsForLegacy because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
|
||||
it("throws, when reaching maximum number of retries !FailsForLegacy", async () => {
|
||||
// because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
|
||||
it("throws, when reaching maximum number of retries", async () => {
|
||||
const delayEventRestartError = jest.fn();
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue(
|
||||
new MatrixError(
|
||||
@@ -712,7 +743,7 @@ describe.each([
|
||||
new Headers({ "Retry-After": "1" }),
|
||||
),
|
||||
);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive, delayEventRestartError);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
@@ -720,20 +751,20 @@ describe.each([
|
||||
}
|
||||
expect(delayEventRestartError).toHaveBeenCalled();
|
||||
});
|
||||
it("falls back to using pure state events when some error occurs while sending delayed events !FailsForLegacy", async () => {
|
||||
it("falls back to using pure state events when some error occurs while sending delayed events", async () => {
|
||||
const unrecoverableError = jest.fn();
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 601));
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive, unrecoverableError);
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
expect(unrecoverableError).not.toHaveBeenCalledWith();
|
||||
expect(client.sendStateEvent).toHaveBeenCalled();
|
||||
});
|
||||
it("retries before failing in case its a network error !FailsForLegacy", async () => {
|
||||
it("retries before failing in case its a network error", async () => {
|
||||
const unrecoverableError = jest.fn();
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 501));
|
||||
const manager = new TestMembershipManager(
|
||||
{ callMemberEventRetryDelayMinimum: 1000, maximumNetworkErrorRetryCount: 7 },
|
||||
const manager = new MembershipManager(
|
||||
{ networkErrorRetryMs: 1000, maximumNetworkErrorRetryCount: 7 },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
@@ -749,12 +780,12 @@ describe.each([
|
||||
);
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
it("falls back to using pure state events when UnsupportedDelayedEventsEndpointError encountered for delayed events !FailsForLegacy", async () => {
|
||||
it("falls back to using pure state events when UnsupportedDelayedEventsEndpointError encountered for delayed events", async () => {
|
||||
const unrecoverableError = jest.fn();
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
|
||||
new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"),
|
||||
);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive, unrecoverableError);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
@@ -763,3 +794,19 @@ describe.each([
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("Should prefix log with MembershipManager used", () => {
|
||||
const client = makeMockClient("@alice:example.org", "AAAAAAA");
|
||||
const room = makeMockRoom(membershipTemplate);
|
||||
|
||||
const membershipManager = new MembershipManager(undefined, room, client, () => undefined, logger);
|
||||
|
||||
const spy = jest.spyOn(console, "error");
|
||||
// Double join
|
||||
membershipManager.join([]);
|
||||
membershipManager.join([]);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
const logline: string = spy.mock.calls[0][0];
|
||||
expect(logline.startsWith("[MembershipManager]")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { OutdatedKeyFilter } from "../../../src/matrixrtc/utils.ts";
|
||||
import { type InboundEncryptionSession } from "../../../src/matrixrtc";
|
||||
|
||||
describe("OutdatedKeyFilter Test", () => {
|
||||
it("Should buffer and disambiguate keys by timestamp", () => {
|
||||
const filter = new OutdatedKeyFilter();
|
||||
|
||||
const aKey = fakeInboundSessionWithTimestamp(1000);
|
||||
const olderKey = fakeInboundSessionWithTimestamp(300);
|
||||
// Simulate receiving out of order keys
|
||||
|
||||
expect(filter.isOutdated(aKey.participantId, aKey)).toBe(false);
|
||||
// Then we receive the most recent key out of order
|
||||
const isOutdated = filter.isOutdated(aKey.participantId, olderKey);
|
||||
// this key is older and should be ignored even if received after
|
||||
expect(isOutdated).toBe(true);
|
||||
});
|
||||
|
||||
function fakeInboundSessionWithTimestamp(ts: number): InboundEncryptionSession {
|
||||
return {
|
||||
keyIndex: 0,
|
||||
creationTS: ts,
|
||||
participantId: "@alice:localhost|ABCDE",
|
||||
key: new Uint8Array(16),
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,598 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type Mocked } from "jest-mock";
|
||||
|
||||
import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts";
|
||||
import { type CallMembership, type Statistics } from "../../../src/matrixrtc";
|
||||
import { type ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
|
||||
import { KeyTransportEvents, type KeyTransportEventsHandlerMap } from "../../../src/matrixrtc/IKeyTransport.ts";
|
||||
import { membershipTemplate, mockCallMembership } from "./mocks.ts";
|
||||
import { decodeBase64, TypedEventEmitter } from "../../../src";
|
||||
import { RoomAndToDeviceTransport } from "../../../src/matrixrtc/RoomAndToDeviceKeyTransport.ts";
|
||||
import { type RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport.ts";
|
||||
import type { Logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("RTCEncryptionManager", () => {
|
||||
// The manager being tested
|
||||
let encryptionManager: RTCEncryptionManager;
|
||||
let getMembershipMock: jest.Mock;
|
||||
let mockTransport: Mocked<ToDeviceKeyTransport>;
|
||||
let statistics: Statistics;
|
||||
let onEncryptionKeysChanged: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
getMembershipMock = jest.fn().mockReturnValue([]);
|
||||
onEncryptionKeysChanged = jest.fn();
|
||||
mockTransport = {
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
sendKey: jest.fn().mockResolvedValue(undefined),
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
} as unknown as Mocked<ToDeviceKeyTransport>;
|
||||
|
||||
encryptionManager = new RTCEncryptionManager(
|
||||
"@alice:example.org",
|
||||
"DEVICE01",
|
||||
getMembershipMock,
|
||||
mockTransport,
|
||||
statistics,
|
||||
onEncryptionKeysChanged,
|
||||
);
|
||||
});
|
||||
|
||||
it("should start and stop the transport properly", () => {
|
||||
encryptionManager.join(undefined);
|
||||
|
||||
expect(mockTransport.start).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.on).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.on).toHaveBeenCalledWith(KeyTransportEvents.ReceivedKeys, expect.any(Function));
|
||||
encryptionManager.leave();
|
||||
expect(mockTransport.stop).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.off).toHaveBeenCalledWith(KeyTransportEvents.ReceivedKeys, expect.any(Function));
|
||||
});
|
||||
|
||||
describe("Sharing Keys", () => {
|
||||
it("Set up my key asap even if no key distribution is needed", () => {
|
||||
getMembershipMock.mockReturnValue([]);
|
||||
|
||||
encryptionManager.join(undefined);
|
||||
// After join it is too early, key might be lost as no one is listening yet
|
||||
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
// The key should have been rolled out immediately
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Should distribute keys to members on join", async () => {
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It is the first key
|
||||
0,
|
||||
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
|
||||
);
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
// The key should have been rolled out immediately
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalled();
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array<ArrayBufferLike>),
|
||||
0,
|
||||
"@alice:example.org:DEVICE01",
|
||||
);
|
||||
});
|
||||
|
||||
it("Should re-distribute keys to members whom callMemberhsip ts has changed", async () => {
|
||||
let members = [aCallMembership("@bob:example.org", "BOBDEVICE", 1000)];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It is the first key
|
||||
0,
|
||||
[
|
||||
{
|
||||
userId: "@bob:example.org",
|
||||
deviceId: "BOBDEVICE",
|
||||
membershipTs: 1000,
|
||||
},
|
||||
],
|
||||
);
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
// The key should have been rolled out immediately
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalled();
|
||||
|
||||
mockTransport.sendKey.mockClear();
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
|
||||
members = [aCallMembership("@bob:example.org", "BOBDEVICE", 2000)];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// There are no membership change but the callMembership ts has changed (reset?)
|
||||
// Resend the key
|
||||
encryptionManager.onMembershipsUpdate(members);
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// Re send the same key to that user
|
||||
0,
|
||||
[
|
||||
{
|
||||
userId: "@bob:example.org",
|
||||
deviceId: "BOBDEVICE",
|
||||
membershipTs: 2000,
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("Should not rotate key when a user join", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// initial rollout
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It is the first key
|
||||
0,
|
||||
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
|
||||
);
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
mockTransport.sendKey.mockClear();
|
||||
|
||||
const updatedMembers = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(updatedMembers);
|
||||
|
||||
encryptionManager.onMembershipsUpdate(updatedMembers);
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It should not have incremented the key index
|
||||
0,
|
||||
// And send it to the newly joined only
|
||||
[{ userId: "@carl:example.org", deviceId: "CARLDEVICE", membershipTs: 1000 }],
|
||||
);
|
||||
|
||||
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
|
||||
});
|
||||
|
||||
it("Should not resend keys when no changes", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// initial rollout
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
mockTransport.sendKey.mockClear();
|
||||
|
||||
encryptionManager.onMembershipsUpdate(members);
|
||||
await jest.advanceTimersByTimeAsync(200);
|
||||
encryptionManager.onMembershipsUpdate(members);
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
encryptionManager.onMembershipsUpdate(members);
|
||||
await jest.advanceTimersByTimeAsync(50);
|
||||
encryptionManager.onMembershipsUpdate(members);
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(mockTransport.sendKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Should rotate key when a user leaves and delay the rollout", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It is the first key
|
||||
0,
|
||||
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
|
||||
);
|
||||
// initial rollout
|
||||
expect(mockTransport.sendKey).toHaveBeenCalled();
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledTimes(1);
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
|
||||
const updatedMembers = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(updatedMembers);
|
||||
|
||||
encryptionManager.onMembershipsUpdate(updatedMembers);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(200);
|
||||
// The is rotated but not rolled out yet to give time for the key to be sent
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It should have incremented the key index
|
||||
1,
|
||||
// And send it to the updated members
|
||||
updatedMembers.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
|
||||
);
|
||||
|
||||
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
// now should be rolled out
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array<ArrayBufferLike>),
|
||||
1,
|
||||
"@alice:example.org:DEVICE01",
|
||||
);
|
||||
|
||||
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Receiving Keys", () => {
|
||||
beforeEach(() => {
|
||||
const emitter = new TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>();
|
||||
mockTransport = {
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
sendKey: jest.fn().mockResolvedValue(undefined),
|
||||
on: emitter.on.bind(emitter),
|
||||
off: emitter.off.bind(emitter),
|
||||
emit: emitter.emit.bind(emitter),
|
||||
} as unknown as Mocked<ToDeviceKeyTransport>;
|
||||
encryptionManager = new RTCEncryptionManager(
|
||||
"@alice:example.org",
|
||||
"DEVICE01",
|
||||
getMembershipMock,
|
||||
mockTransport,
|
||||
statistics,
|
||||
onEncryptionKeysChanged,
|
||||
);
|
||||
});
|
||||
|
||||
it("should accept keys from transport", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@bob:example.org",
|
||||
"BOBDEVICE",
|
||||
"AAAAAAAAAAA",
|
||||
0 /* KeyId */,
|
||||
0 /* Timestamp */,
|
||||
);
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@bob:example.org",
|
||||
"BOBDEVICE2",
|
||||
"BBBBBBBBBBB",
|
||||
4 /* KeyId */,
|
||||
0 /* Timestamp */,
|
||||
);
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@carl:example.org",
|
||||
"CARLDEVICE",
|
||||
"CCCCCCCCCC",
|
||||
8 /* KeyId */,
|
||||
0 /* Timestamp */,
|
||||
);
|
||||
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledTimes(4);
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
decodeBase64("AAAAAAAAAAA"),
|
||||
0,
|
||||
"@bob:example.org:BOBDEVICE",
|
||||
);
|
||||
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
decodeBase64("BBBBBBBBBBB"),
|
||||
4,
|
||||
"@bob:example.org:BOBDEVICE2",
|
||||
);
|
||||
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
decodeBase64("CCCCCCCCCC"),
|
||||
8,
|
||||
"@carl:example.org:CARLDEVICE",
|
||||
);
|
||||
|
||||
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(3);
|
||||
});
|
||||
|
||||
it("Should support quick re-joiner if keys received out of order", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [aCallMembership("@carl:example.org", "CARLDEVICE")];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// Let's join
|
||||
encryptionManager.join(undefined);
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Simulate Carl leaving then joining back, and key received out of order
|
||||
const initialKey0TimeStamp = 1000;
|
||||
const newKey0TimeStamp = 2000;
|
||||
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@carol:example.org",
|
||||
"CAROLDEVICE",
|
||||
"BBBBBBBBBBB",
|
||||
0 /* KeyId */,
|
||||
newKey0TimeStamp,
|
||||
);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(20);
|
||||
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@carol:example.org",
|
||||
"CAROLDEVICE",
|
||||
"AAAAAAAAAAA",
|
||||
0 /* KeyId */,
|
||||
initialKey0TimeStamp,
|
||||
);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(20);
|
||||
|
||||
// The latest key used for carol should be the one with the latest timestamp
|
||||
|
||||
expect(onEncryptionKeysChanged).toHaveBeenLastCalledWith(
|
||||
decodeBase64("BBBBBBBBBBB"),
|
||||
0,
|
||||
"@carol:example.org:CAROLDEVICE",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("Should only rotate once again if several membership changes during a rollout", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
let members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// Let's join
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// The initial rollout
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array<ArrayBufferLike>),
|
||||
0,
|
||||
"@alice:example.org:DEVICE01",
|
||||
);
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
|
||||
// Trigger a key rotation with a leaver
|
||||
members = [aCallMembership("@bob:example.org", "BOBDEVICE"), aCallMembership("@bob:example.org", "BOBDEVICE2")];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// This should start a new key rollout
|
||||
encryptionManager.onMembershipsUpdate(members);
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Now simulate a new leaver
|
||||
members = [aCallMembership("@bob:example.org", "BOBDEVICE")];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// The key `1` rollout is in progress
|
||||
encryptionManager.onMembershipsUpdate(members);
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// And another one ( plus a joiner)
|
||||
const lastMembership = [aCallMembership("@bob:example.org", "BOBDEVICE3")];
|
||||
getMembershipMock.mockReturnValue(lastMembership);
|
||||
// The key `1` rollout is still in progress
|
||||
encryptionManager.onMembershipsUpdate(lastMembership);
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Let all rollouts finish
|
||||
await jest.advanceTimersByTimeAsync(2000);
|
||||
|
||||
// There should 2 rollout. The `1` rollout, then just one additional one
|
||||
// that has "buffered" the 2 membership changes with leavers
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledTimes(2);
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array<ArrayBufferLike>),
|
||||
1,
|
||||
"@alice:example.org:DEVICE01",
|
||||
);
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array<ArrayBufferLike>),
|
||||
2,
|
||||
"@alice:example.org:DEVICE01",
|
||||
);
|
||||
|
||||
// Key `2` should only be distributed to the last membership
|
||||
expect(mockTransport.sendKey).toHaveBeenLastCalledWith(
|
||||
expect.any(String),
|
||||
2,
|
||||
// And send only to the last membership
|
||||
[
|
||||
{
|
||||
userId: "@bob:example.org",
|
||||
deviceId: "BOBDEVICE3",
|
||||
membershipTs: 1000,
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("Should re-distribute key on transport switch", async () => {
|
||||
const toDeviceEmitter = new TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>();
|
||||
const mockToDeviceTransport = {
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
sendKey: jest.fn().mockResolvedValue(undefined),
|
||||
on: toDeviceEmitter.on.bind(toDeviceEmitter),
|
||||
off: toDeviceEmitter.off.bind(toDeviceEmitter),
|
||||
emit: toDeviceEmitter.emit.bind(toDeviceEmitter),
|
||||
setParentLogger: jest.fn(),
|
||||
} as unknown as Mocked<ToDeviceKeyTransport>;
|
||||
|
||||
const roomEmitter = new TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>();
|
||||
const mockRoomTransport = {
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
sendKey: jest.fn().mockResolvedValue(undefined),
|
||||
on: roomEmitter.on.bind(roomEmitter),
|
||||
off: roomEmitter.off.bind(roomEmitter),
|
||||
emit: roomEmitter.emit.bind(roomEmitter),
|
||||
setParentLogger: jest.fn(),
|
||||
} as unknown as Mocked<RoomKeyTransport>;
|
||||
|
||||
const mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
|
||||
const transport = new RoomAndToDeviceTransport(mockToDeviceTransport, mockRoomTransport, {
|
||||
getChild: jest.fn().mockReturnValue(mockLogger),
|
||||
} as unknown as Mocked<Logger>);
|
||||
|
||||
encryptionManager = new RTCEncryptionManager(
|
||||
"@alice:example.org",
|
||||
"DEVICE01",
|
||||
getMembershipMock,
|
||||
transport,
|
||||
statistics,
|
||||
onEncryptionKeysChanged,
|
||||
);
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// Let's join
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Should have sent the key to the toDevice transport
|
||||
expect(mockToDeviceTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockRoomTransport.sendKey).not.toHaveBeenCalled();
|
||||
|
||||
// Simulate receiving a key by room transport
|
||||
roomEmitter.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@bob:example.org",
|
||||
"BOBDEVICE",
|
||||
"AAAAAAAAAAA",
|
||||
0 /* KeyId */,
|
||||
0 /* Timestamp */,
|
||||
);
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
// The key should have beed re-distributed to the room transport
|
||||
expect(mockRoomTransport.sendKey).toHaveBeenCalled();
|
||||
expect(mockToDeviceTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It is the first key re-distributed
|
||||
0,
|
||||
// to all the members
|
||||
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
|
||||
);
|
||||
});
|
||||
|
||||
function aCallMembership(userId: string, deviceId: string, ts: number = 1000): CallMembership {
|
||||
return mockCallMembership(
|
||||
Object.assign({}, membershipTemplate, { device_id: deviceId, created_ts: ts }),
|
||||
"!room:id",
|
||||
userId,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type Mocked } from "jest-mock";
|
||||
|
||||
import { makeKey, makeMockEvent, makeMockRoom } from "./mocks";
|
||||
import { EventType, type IRoomTimelineData, type Room, RoomEvent, type MatrixClient } from "../../../src";
|
||||
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
|
||||
import {
|
||||
getMockClientWithEventEmitter,
|
||||
mockClientMethodsEvents,
|
||||
mockClientMethodsUser,
|
||||
} from "../../test-utils/client.ts";
|
||||
import { type ParticipantDeviceInfo, type Statistics } from "../../../src/matrixrtc";
|
||||
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport.ts";
|
||||
import { type Logger } from "../../../src/logger.ts";
|
||||
import { RoomAndToDeviceEvents, RoomAndToDeviceTransport } from "../../../src/matrixrtc/RoomAndToDeviceKeyTransport.ts";
|
||||
import { RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport.ts";
|
||||
|
||||
describe("RoomAndToDeviceTransport", () => {
|
||||
const roomId = "!room:id";
|
||||
|
||||
let mockClient: Mocked<MatrixClient>;
|
||||
let statistics: Statistics;
|
||||
let mockLogger: Mocked<Logger>;
|
||||
let transport: RoomAndToDeviceTransport;
|
||||
let mockRoom: Room;
|
||||
let sendEventMock: jest.Mock;
|
||||
let roomKeyTransport: RoomKeyTransport;
|
||||
let toDeviceKeyTransport: ToDeviceKeyTransport;
|
||||
let toDeviceSendKeySpy: jest.SpyInstance;
|
||||
let roomSendKeySpy: jest.SpyInstance;
|
||||
beforeEach(() => {
|
||||
sendEventMock = jest.fn();
|
||||
mockClient = getMockClientWithEventEmitter({
|
||||
encryptAndSendToDevice: jest.fn(),
|
||||
getDeviceId: jest.fn().mockReturnValue("MYDEVICE"),
|
||||
...mockClientMethodsEvents(),
|
||||
...mockClientMethodsUser("@alice:example.org"),
|
||||
sendEvent: sendEventMock,
|
||||
});
|
||||
mockRoom = makeMockRoom([]);
|
||||
mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
getChild: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
mockLogger.getChild.mockReturnValue(mockLogger);
|
||||
statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
roomKeyTransport = new RoomKeyTransport(mockRoom, mockClient, statistics);
|
||||
toDeviceKeyTransport = new ToDeviceKeyTransport(
|
||||
"@alice:example.org",
|
||||
"MYDEVICE",
|
||||
mockRoom.roomId,
|
||||
mockClient,
|
||||
statistics,
|
||||
);
|
||||
transport = new RoomAndToDeviceTransport(toDeviceKeyTransport, roomKeyTransport, mockLogger);
|
||||
toDeviceSendKeySpy = jest.spyOn(toDeviceKeyTransport, "sendKey");
|
||||
roomSendKeySpy = jest.spyOn(roomKeyTransport, "sendKey");
|
||||
});
|
||||
|
||||
it("should enable to device transport when starting", () => {
|
||||
transport.start();
|
||||
expect(transport.enabled.room).toBeFalsy();
|
||||
expect(transport.enabled.toDevice).toBeTruthy();
|
||||
});
|
||||
it("only sends to device keys when sending a key", async () => {
|
||||
transport.start();
|
||||
await transport.sendKey("1235", 0, [
|
||||
{ userId: "@alice:example.org", deviceId: "ALICEDEVICE", membershipTs: 1234 },
|
||||
]);
|
||||
expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(roomSendKeySpy).toHaveBeenCalledTimes(0);
|
||||
expect(transport.enabled.room).toBeFalsy();
|
||||
expect(transport.enabled.toDevice).toBeTruthy();
|
||||
});
|
||||
|
||||
it("enables room transport and disables to device transport when receiving a room key", async () => {
|
||||
transport.start();
|
||||
const onNewKeyFromTransport = jest.fn();
|
||||
const onTransportEnabled = jest.fn();
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, onNewKeyFromTransport);
|
||||
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
|
||||
mockRoom.emit(
|
||||
RoomEvent.Timeline,
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", roomId, {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
}),
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
{} as IRoomTimelineData,
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(transport.enabled.room).toBeTruthy();
|
||||
expect(transport.enabled.toDevice).toBeFalsy();
|
||||
|
||||
await transport.sendKey("1235", 0, [
|
||||
{ userId: "@alice:example.org", deviceId: "AlICEDEV", membershipTs: 1234 },
|
||||
]);
|
||||
expect(sendEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(roomSendKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(0);
|
||||
expect(onTransportEnabled).toHaveBeenCalledWith({ toDevice: false, room: true });
|
||||
});
|
||||
|
||||
it("enables room transport and disables to device transport on widget driver error", async () => {
|
||||
mockClient.encryptAndSendToDevice.mockRejectedValue({
|
||||
message:
|
||||
"unknown variant `send_to_device`, expected one of `supported_api_versions`, `content_loaded`, `get_openid`, `org.matrix.msc2876.read_events`, `send_event`, `org.matrix.msc4157.update_delayed_event` at line 1 column 22",
|
||||
});
|
||||
|
||||
transport.start();
|
||||
const membership: ParticipantDeviceInfo = {
|
||||
userId: "@alice:example.org",
|
||||
deviceId: "ALICEDEVICE",
|
||||
membershipTs: 1234,
|
||||
};
|
||||
const onTransportEnabled = jest.fn();
|
||||
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
|
||||
|
||||
// We start with toDevice transport enabled
|
||||
expect(transport.enabled.room).toBeFalsy();
|
||||
expect(transport.enabled.toDevice).toBeTruthy();
|
||||
|
||||
await transport.sendKey("1235", 0, [membership]);
|
||||
|
||||
// We switched transport, now room transport is enabled
|
||||
expect(onTransportEnabled).toHaveBeenCalledWith({ toDevice: false, room: true });
|
||||
expect(transport.enabled.room).toBeTruthy();
|
||||
expect(transport.enabled.toDevice).toBeFalsy();
|
||||
|
||||
// sanity check that we called the failang to device send key.
|
||||
expect(toDeviceKeyTransport.sendKey).toHaveBeenCalledWith("1235", 0, [membership]);
|
||||
expect(toDeviceKeyTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
// We re-sent the key via the room transport
|
||||
expect(roomKeyTransport.sendKey).toHaveBeenCalledWith("1235", 0, [membership]);
|
||||
expect(roomKeyTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
|
||||
mockClient.encryptAndSendToDevice.mockRestore();
|
||||
});
|
||||
|
||||
it("does log that it did nothing when disabled", () => {
|
||||
transport.start();
|
||||
const onNewKeyFromTransport = jest.fn();
|
||||
const onTransportEnabled = jest.fn();
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, onNewKeyFromTransport);
|
||||
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
|
||||
|
||||
transport.setEnabled({ toDevice: false, room: false });
|
||||
const dateNow = Date.now();
|
||||
roomKeyTransport.emit(KeyTransportEvents.ReceivedKeys, "user", "device", "roomKey", 0, dateNow);
|
||||
toDeviceKeyTransport.emit(KeyTransportEvents.ReceivedKeys, "user", "device", "toDeviceKey", 0, Date.now());
|
||||
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith("To Device transport is disabled, ignoring received keys");
|
||||
// for room key transport we will never get a disabled message because its will always just turn on
|
||||
expect(onTransportEnabled).toHaveBeenNthCalledWith(1, { toDevice: false, room: false });
|
||||
expect(onTransportEnabled).toHaveBeenNthCalledWith(2, { toDevice: false, room: true });
|
||||
expect(onNewKeyFromTransport).toHaveBeenCalledTimes(1);
|
||||
expect(onNewKeyFromTransport).toHaveBeenCalledWith("user", "device", "roomKey", 0, dateNow);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { makeMockEvent, makeMockRoom, membershipTemplate, makeKey } from "./mocks";
|
||||
import { RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport";
|
||||
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport";
|
||||
import { EventType, MatrixClient, RoomEvent } from "../../../src";
|
||||
import { type IRoomTimelineData, MatrixEvent, type Room } from "../../../src";
|
||||
import type { Mocked } from "jest-mock";
|
||||
import type { Logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("RoomKeyTransport", () => {
|
||||
let client: MatrixClient;
|
||||
let room: Room & {
|
||||
emitTimelineEvent: (event: MatrixEvent) => void;
|
||||
};
|
||||
let transport: RoomKeyTransport;
|
||||
let mockLogger: Mocked<Logger>;
|
||||
|
||||
const onCallEncryptionMock = jest.fn();
|
||||
beforeEach(() => {
|
||||
onCallEncryptionMock.mockReset();
|
||||
mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
info: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
|
||||
const statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
room = makeMockRoom([membershipTemplate]);
|
||||
client = new MatrixClient({ baseUrl: "base_url" });
|
||||
client.matrixRTC.start();
|
||||
transport = new RoomKeyTransport(room, client, statistics, {
|
||||
getChild: jest.fn().mockReturnValue(mockLogger),
|
||||
} as unknown as Mocked<Logger>);
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, (...p) => {
|
||||
onCallEncryptionMock(...p);
|
||||
});
|
||||
transport.start();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.stopClient();
|
||||
client.matrixRTC.stop();
|
||||
transport.stop();
|
||||
});
|
||||
|
||||
it("Calls onCallEncryption on encryption keys event", async () => {
|
||||
client.decryptEventIfNeeded = () => Promise.resolve();
|
||||
const timelineEvent = makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
});
|
||||
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
await new Promise(process.nextTick);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("event decryption", () => {
|
||||
it("Retries decryption and processes success", async () => {
|
||||
jest.useFakeTimers();
|
||||
let isDecryptionFailure = true;
|
||||
client.decryptEventIfNeeded = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(Promise.resolve())
|
||||
.mockImplementation(() => {
|
||||
isDecryptionFailure = false;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
const timelineEvent = Object.assign(
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
}),
|
||||
{ isDecryptionFailure: jest.fn().mockImplementation(() => isDecryptionFailure) },
|
||||
);
|
||||
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// should retry after one second:
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(1);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("Retries decryption and processes failure", async () => {
|
||||
try {
|
||||
jest.useFakeTimers();
|
||||
const onCallEncryptionMock = jest.fn();
|
||||
client.decryptEventIfNeeded = jest.fn().mockReturnValue(Promise.resolve());
|
||||
|
||||
const timelineEvent = Object.assign(
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
}),
|
||||
{ isDecryptionFailure: jest.fn().mockReturnValue(true) },
|
||||
);
|
||||
|
||||
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// should retry after one second:
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// doesn't retry again:
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("malformed events", () => {
|
||||
const MALFORMED_EVENT = [
|
||||
// empty content
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
sender: "@alice:example.com",
|
||||
content: {},
|
||||
}),
|
||||
// no sender
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
content: {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
}),
|
||||
// Call_id not empty string
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
sender: "@alice:example.com",
|
||||
content: {
|
||||
call_id: "FOO",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
}),
|
||||
// Various Malformed keys
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
sender: "@alice:example.com",
|
||||
content: {
|
||||
call_id: "",
|
||||
keys: "FOO",
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
}),
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
sender: "@alice:example.com",
|
||||
content: {
|
||||
call_id: "",
|
||||
keys: [{ index: 0 }],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
}),
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
sender: "@alice:example.com",
|
||||
content: {
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
key: "BASE64KEY",
|
||||
index: "mcall",
|
||||
},
|
||||
],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
test.each(MALFORMED_EVENT)("should warn on malformed event %j", (event) => {
|
||||
transport.onEncryptionEvent(event);
|
||||
expect(mockLogger.warn).toHaveBeenCalled();
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type Mocked } from "jest-mock";
|
||||
|
||||
import { makeMockEvent } from "./mocks";
|
||||
import { ClientEvent, EventType, type MatrixClient } from "../../../src";
|
||||
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
|
||||
import { getMockClientWithEventEmitter } from "../../test-utils/client.ts";
|
||||
import { type Statistics } from "../../../src/matrixrtc";
|
||||
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport.ts";
|
||||
import { type Logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("ToDeviceKeyTransport", () => {
|
||||
const roomId = "!room:id";
|
||||
|
||||
let mockClient: Mocked<MatrixClient>;
|
||||
let statistics: Statistics;
|
||||
let mockLogger: Mocked<Logger>;
|
||||
let transport: ToDeviceKeyTransport;
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = getMockClientWithEventEmitter({
|
||||
encryptAndSendToDevice: jest.fn().mockImplementation(() => Promise.resolve()),
|
||||
});
|
||||
mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
|
||||
transport = new ToDeviceKeyTransport("@alice:example.org", "MYDEVICE", roomId, mockClient, statistics, {
|
||||
getChild: jest.fn().mockReturnValue(mockLogger),
|
||||
} as unknown as Mocked<Logger>);
|
||||
});
|
||||
|
||||
it("should send my keys on via to device", async () => {
|
||||
transport.start();
|
||||
|
||||
const keyBase64Encoded = "ABCDEDF";
|
||||
const keyIndex = 2;
|
||||
await transport.sendKey(keyBase64Encoded, keyIndex, [
|
||||
{ userId: "@bob:example.org", deviceId: "BOBDEVICE", membershipTs: 1234 },
|
||||
{ userId: "@carl:example.org", deviceId: "CARLDEVICE", membershipTs: 1234 },
|
||||
{ userId: "@mat:example.org", deviceId: "MATDEVICE", membershipTs: 1234 },
|
||||
]);
|
||||
|
||||
expect(mockClient.encryptAndSendToDevice).toHaveBeenCalledTimes(1);
|
||||
expect(mockClient.encryptAndSendToDevice).toHaveBeenCalledWith(
|
||||
"io.element.call.encryption_keys",
|
||||
[
|
||||
{ userId: "@bob:example.org", deviceId: "BOBDEVICE" },
|
||||
{ userId: "@carl:example.org", deviceId: "CARLDEVICE" },
|
||||
{ userId: "@mat:example.org", deviceId: "MATDEVICE" },
|
||||
],
|
||||
{
|
||||
keys: {
|
||||
index: keyIndex,
|
||||
key: keyBase64Encoded,
|
||||
},
|
||||
member: {
|
||||
claimed_device_id: "MYDEVICE",
|
||||
},
|
||||
room_id: roomId,
|
||||
session: {
|
||||
application: "m.call",
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
},
|
||||
sent_ts: expect.any(Number),
|
||||
},
|
||||
);
|
||||
|
||||
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(1);
|
||||
});
|
||||
|
||||
it("should emit when a key is received", async () => {
|
||||
const receivedKeyResolvers = Promise.withResolvers<{
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
keyBase64Encoded: string;
|
||||
index: number;
|
||||
}>();
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, (userId, deviceId, keyBase64Encoded, index, timestamp) => {
|
||||
receivedKeyResolvers.resolve({ userId, deviceId, keyBase64Encoded, index });
|
||||
});
|
||||
transport.start();
|
||||
|
||||
const testEncoded = "ABCDEDF";
|
||||
const testKeyIndex = 2;
|
||||
|
||||
mockClient.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", undefined, {
|
||||
keys: {
|
||||
index: testKeyIndex,
|
||||
key: testEncoded,
|
||||
},
|
||||
member: {
|
||||
claimed_device_id: "BOBDEVICE",
|
||||
},
|
||||
room_id: roomId,
|
||||
session: {
|
||||
application: "m.call",
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { userId, deviceId, keyBase64Encoded, index } = await receivedKeyResolvers.promise;
|
||||
expect(userId).toBe("@bob:example.org");
|
||||
expect(deviceId).toBe("BOBDEVICE");
|
||||
expect(keyBase64Encoded).toBe(testEncoded);
|
||||
expect(index).toBe(testKeyIndex);
|
||||
|
||||
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(1);
|
||||
});
|
||||
|
||||
it("should not sent to ourself", async () => {
|
||||
const keyBase64Encoded = "ABCDEDF";
|
||||
const keyIndex = 2;
|
||||
await transport.sendKey(keyBase64Encoded, keyIndex, [
|
||||
{ userId: "@alice:example.org", deviceId: "MYDEVICE", membershipTs: 1234 },
|
||||
]);
|
||||
|
||||
transport.start();
|
||||
|
||||
expect(mockClient.encryptAndSendToDevice).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("should warn when there is a room mismatch", () => {
|
||||
transport.start();
|
||||
|
||||
const testEncoded = "ABCDEDF";
|
||||
const testKeyIndex = 2;
|
||||
|
||||
mockClient.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", undefined, {
|
||||
keys: {
|
||||
index: testKeyIndex,
|
||||
key: testEncoded,
|
||||
},
|
||||
member: {
|
||||
claimed_device_id: "BOBDEVICE",
|
||||
},
|
||||
room_id: "!anotherroom:id",
|
||||
session: {
|
||||
application: "m.call",
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith("Malformed Event: Mismatch roomId");
|
||||
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(0);
|
||||
});
|
||||
|
||||
describe("malformed events", () => {
|
||||
const MALFORMED_EVENT = [
|
||||
{
|
||||
keys: {},
|
||||
member: { claimed_device_id: "MYDEVICE" },
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { index: 0 },
|
||||
member: { claimed_device_id: "MYDEVICE" },
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { key: "ABCDEF" },
|
||||
member: { claimed_device_id: "MYDEVICE" },
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { key: "ABCDEF", index: 2 },
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { key: "ABCDEF", index: 2 },
|
||||
member: {},
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { key: "ABCDEF", index: 2 },
|
||||
member: { claimed_device_id: "MYDEVICE" },
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { key: "ABCDEF", index: 2 },
|
||||
member: { claimed_device_id: "MYDEVICE" },
|
||||
room_id: "!wrong_room",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
];
|
||||
|
||||
test.each(MALFORMED_EVENT)("should warn on malformed event %j", (event) => {
|
||||
transport.start();
|
||||
|
||||
mockClient.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", undefined, event),
|
||||
);
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalled();
|
||||
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/*
|
||||
This file adds a custom test environment for the MembershipManager.spec.ts
|
||||
It can be used with the comment at the top of the file:
|
||||
|
||||
@jest-environment ./spec/unit/matrixrtc/memberManagerTestEnvironment.ts
|
||||
|
||||
It is very specific to the MembershipManager.spec.ts file and introduces the following behaviour:
|
||||
- The describe each block in the MembershipManager.spec.ts will go through describe block names `LegacyMembershipManager` and `MembershipManager`
|
||||
- It will check all tests that are a child or indirect child of the `LegacyMembershipManager` block and skip the ones which include "!FailsForLegacy"
|
||||
in their test name.
|
||||
*/
|
||||
|
||||
import { TestEnvironment } from "jest-environment-jsdom";
|
||||
|
||||
import { logger as rootLogger } from "../../../src/logger";
|
||||
const logger = rootLogger.getChild("MatrixRTCSession");
|
||||
|
||||
class MemberManagerTestEnvironment extends TestEnvironment {
|
||||
handleTestEvent(event: any) {
|
||||
if (event.name === "test_start" && event.test.name.includes("!FailsForLegacy")) {
|
||||
let parent = event.test.parent;
|
||||
let isLegacy = false;
|
||||
while (parent) {
|
||||
if (parent.name === "LegacyMembershipManager") {
|
||||
isLegacy = true;
|
||||
break;
|
||||
} else {
|
||||
parent = parent.parent;
|
||||
}
|
||||
}
|
||||
if (isLegacy) {
|
||||
logger.info("skip test: ", event.test.name);
|
||||
event.test.mode = "skip";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = MemberManagerTestEnvironment;
|
||||
@@ -14,7 +14,9 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { EventType, type MatrixClient, type MatrixEvent, type Room } from "../../../src";
|
||||
import { EventEmitter } from "stream";
|
||||
|
||||
import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent } from "../../../src";
|
||||
import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
import { secureRandomString } from "../../../src/randomstring";
|
||||
|
||||
@@ -65,19 +67,24 @@ export function makeMockClient(userId: string, deviceId: string): MockClient {
|
||||
};
|
||||
}
|
||||
|
||||
export function makeMockRoom(membershipData: MembershipData): Room {
|
||||
export function makeMockRoom(
|
||||
membershipData: MembershipData,
|
||||
): Room & { emitTimelineEvent: (event: MatrixEvent) => void } {
|
||||
const roomId = secureRandomString(8);
|
||||
// Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()`
|
||||
const roomState = makeMockRoomState(membershipData, roomId);
|
||||
const room = {
|
||||
const room = Object.assign(new EventEmitter(), {
|
||||
roomId: roomId,
|
||||
hasMembershipState: jest.fn().mockReturnValue(true),
|
||||
getLiveTimeline: jest.fn().mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue(roomState),
|
||||
}),
|
||||
getVersion: jest.fn().mockReturnValue("default"),
|
||||
} as unknown as Room;
|
||||
return room;
|
||||
}) as unknown as Room;
|
||||
return Object.assign(room, {
|
||||
emitTimelineEvent: (event: MatrixEvent) =>
|
||||
room.emit(RoomEvent.Timeline, event, room, undefined, false, {} as any),
|
||||
});
|
||||
}
|
||||
|
||||
export function makeMockRoomState(membershipData: MembershipData, roomId: string) {
|
||||
@@ -113,17 +120,36 @@ export function makeMockRoomState(membershipData: MembershipData, roomId: string
|
||||
};
|
||||
}
|
||||
|
||||
export function mockRTCEvent(membershipData: MembershipData, roomId: string, customSender?: string): MatrixEvent {
|
||||
const sender = customSender ?? "@mock:user.example";
|
||||
export function makeMockEvent(
|
||||
type: string,
|
||||
sender: string,
|
||||
roomId: string | undefined,
|
||||
content: any,
|
||||
timestamp?: number,
|
||||
): MatrixEvent {
|
||||
return {
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue(membershipData),
|
||||
getType: jest.fn().mockReturnValue(type),
|
||||
getContent: jest.fn().mockReturnValue(content),
|
||||
getSender: jest.fn().mockReturnValue(sender),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
getTs: jest.fn().mockReturnValue(timestamp ?? Date.now()),
|
||||
getRoomId: jest.fn().mockReturnValue(roomId),
|
||||
getId: jest.fn().mockReturnValue(secureRandomString(8)),
|
||||
isDecryptionFailure: jest.fn().mockReturnValue(false),
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
export function mockRTCEvent(membershipData: MembershipData, roomId: string, customSender?: string): MatrixEvent {
|
||||
const sender = customSender ?? "@mock:user.example";
|
||||
return makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData);
|
||||
}
|
||||
|
||||
export function mockCallMembership(membershipData: MembershipData, roomId: string, sender?: string): CallMembership {
|
||||
return new CallMembership(mockRTCEvent(membershipData, roomId, sender), membershipData);
|
||||
}
|
||||
|
||||
export function makeKey(id: number, key: string): { key: string; index: number } {
|
||||
return {
|
||||
key: key,
|
||||
index: id,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Direction, type MatrixClient, MatrixEvent, Room } from "../../../src";
|
||||
import type { MockedObject } from "jest-mock";
|
||||
|
||||
const CREATOR_USER_ID = "@creator:example.org";
|
||||
const MODERATOR_USER_ID = "@moderator:example.org";
|
||||
|
||||
describe("Room", () => {
|
||||
function createMockClient(): MatrixClient {
|
||||
return {
|
||||
supportsThreads: jest.fn().mockReturnValue(true),
|
||||
decryptEventIfNeeded: jest.fn().mockReturnThis(),
|
||||
getUserId: jest.fn().mockReturnValue(CREATOR_USER_ID),
|
||||
} as unknown as MockedObject<MatrixClient>;
|
||||
}
|
||||
|
||||
function createEvent(eventId: string): MatrixEvent {
|
||||
return new MatrixEvent({
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
body: eventId, // we do this for ease of use, not practicality
|
||||
},
|
||||
event_id: eventId,
|
||||
sender: CREATOR_USER_ID,
|
||||
});
|
||||
}
|
||||
|
||||
function createRedaction(redactsEventId: string): MatrixEvent {
|
||||
return new MatrixEvent({
|
||||
type: "m.room.redaction",
|
||||
redacts: redactsEventId,
|
||||
event_id: "$redacts_" + redactsEventId.substring(1),
|
||||
sender: CREATOR_USER_ID,
|
||||
});
|
||||
}
|
||||
|
||||
function getNonStateMainTimelineLiveEvents(room: Room): Array<MatrixEvent> {
|
||||
return room
|
||||
.getLiveTimeline()
|
||||
.getEvents()
|
||||
.filter((e) => !e.isState());
|
||||
}
|
||||
|
||||
it("should apply redactions locally", async () => {
|
||||
const mockClient = createMockClient();
|
||||
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
|
||||
const messageEvent = createEvent("$message_event");
|
||||
|
||||
// Set up the room
|
||||
await room.addLiveEvents([messageEvent], { addToState: false });
|
||||
let timeline = getNonStateMainTimelineLiveEvents(room);
|
||||
expect(timeline.length).toEqual(1);
|
||||
expect(timeline[0].getId()).toEqual(messageEvent.getId());
|
||||
expect(timeline[0].isRedacted()).toEqual(false); // "should never happen"
|
||||
|
||||
// Now redact
|
||||
const redactionEvent = createRedaction(messageEvent.getId()!);
|
||||
await room.addLiveEvents([redactionEvent], { addToState: false });
|
||||
timeline = getNonStateMainTimelineLiveEvents(room);
|
||||
expect(timeline.length).toEqual(2);
|
||||
expect(timeline[0].getId()).toEqual(messageEvent.getId());
|
||||
expect(timeline[0].isRedacted()).toEqual(true); // test case
|
||||
expect(timeline[1].getId()).toEqual(redactionEvent.getId());
|
||||
expect(timeline[1].isRedacted()).toEqual(false); // "should never happen"
|
||||
});
|
||||
|
||||
describe("MSC4293: Redact on ban", () => {
|
||||
async function setupRoom(andGrantPermissions: boolean): Promise<{ room: Room; messageEvents: MatrixEvent[] }> {
|
||||
const mockClient = createMockClient();
|
||||
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
|
||||
|
||||
// Pre-populate room
|
||||
const messageEvents: MatrixEvent[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
messageEvents.push(createEvent(`$message_${i}`));
|
||||
}
|
||||
await room.addLiveEvents(messageEvents, { addToState: false });
|
||||
|
||||
if (andGrantPermissions) {
|
||||
room.getLiveTimeline().getState(Direction.Forward)!.maySendRedactionForEvent = (ev, userId) => {
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
return { room, messageEvents };
|
||||
}
|
||||
|
||||
function createRedactOnMembershipChange(
|
||||
targetUserId: string,
|
||||
senderUserId: string,
|
||||
membership: string,
|
||||
): MatrixEvent {
|
||||
return new MatrixEvent({
|
||||
type: "m.room.member",
|
||||
state_key: targetUserId,
|
||||
content: {
|
||||
"membership": membership,
|
||||
"org.matrix.msc4293.redact_events": true,
|
||||
},
|
||||
sender: senderUserId,
|
||||
});
|
||||
}
|
||||
|
||||
function expectRedacted(messageEvents: MatrixEvent[], room: Room, shouldAllBeRedacted: boolean) {
|
||||
const actualEvents = getNonStateMainTimelineLiveEvents(room).filter((e) =>
|
||||
messageEvents.find((e2) => e2.getId() === e.getId()),
|
||||
);
|
||||
expect(actualEvents.length).toEqual(messageEvents.length);
|
||||
const redactedEvents = actualEvents.filter((e) => e.isRedacted());
|
||||
if (shouldAllBeRedacted) {
|
||||
expect(redactedEvents.length).toEqual(messageEvents.length);
|
||||
} else {
|
||||
expect(redactedEvents.length).toEqual(0);
|
||||
}
|
||||
}
|
||||
|
||||
it("should apply on ban", async () => {
|
||||
const { room, messageEvents } = await setupRoom(true);
|
||||
const banEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "ban");
|
||||
await room.addLiveEvents([banEvent], { addToState: true });
|
||||
|
||||
expectRedacted(messageEvents, room, true);
|
||||
});
|
||||
|
||||
it("should apply on kick", async () => {
|
||||
const { room, messageEvents } = await setupRoom(true);
|
||||
const kickEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "leave");
|
||||
await room.addLiveEvents([kickEvent], { addToState: true });
|
||||
|
||||
expectRedacted(messageEvents, room, true);
|
||||
});
|
||||
|
||||
it("should not apply if the user doesn't have permission to redact", async () => {
|
||||
const { room, messageEvents } = await setupRoom(false); // difference from other tests here
|
||||
const banEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "ban");
|
||||
await room.addLiveEvents([banEvent], { addToState: true });
|
||||
|
||||
expectRedacted(messageEvents, room, false);
|
||||
});
|
||||
|
||||
it("should not apply to self-leaves", async () => {
|
||||
const { room, messageEvents } = await setupRoom(true);
|
||||
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "leave");
|
||||
await room.addLiveEvents([leaveEvent], { addToState: true });
|
||||
|
||||
expectRedacted(messageEvents, room, false);
|
||||
});
|
||||
|
||||
it("should not apply to invites", async () => {
|
||||
const { room, messageEvents } = await setupRoom(true);
|
||||
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "invite");
|
||||
await room.addLiveEvents([leaveEvent], { addToState: true });
|
||||
|
||||
expectRedacted(messageEvents, room, false);
|
||||
});
|
||||
|
||||
it("should not apply to joins", async () => {
|
||||
const { room, messageEvents } = await setupRoom(true);
|
||||
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "join");
|
||||
await room.addLiveEvents([leaveEvent], { addToState: true });
|
||||
|
||||
expectRedacted(messageEvents, room, false);
|
||||
});
|
||||
|
||||
it("should not apply to knocks", async () => {
|
||||
const { room, messageEvents } = await setupRoom(true);
|
||||
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "knock");
|
||||
await room.addLiveEvents([leaveEvent], { addToState: true });
|
||||
|
||||
expectRedacted(messageEvents, room, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -130,10 +130,12 @@ describe("OidcTokenRefresher", () => {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should persist the new tokens", async () => {
|
||||
@@ -144,10 +146,12 @@ describe("OidcTokenRefresher", () => {
|
||||
|
||||
await refresher.doRefreshAccessToken("refresh-token");
|
||||
|
||||
expect(refresher.persistTokens).toHaveBeenCalledWith({
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
});
|
||||
expect(refresher.persistTokens).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should only have one inflight refresh request at once", async () => {
|
||||
@@ -189,10 +193,12 @@ describe("OidcTokenRefresher", () => {
|
||||
|
||||
// only one call to token endpoint
|
||||
expect(fetchMock).toHaveFetchedTimes(1, config.token_endpoint);
|
||||
expect(result1).toEqual({
|
||||
accessToken: "first-new-access-token",
|
||||
refreshToken: "first-new-refresh-token",
|
||||
});
|
||||
expect(result1).toEqual(
|
||||
expect.objectContaining({
|
||||
accessToken: "first-new-access-token",
|
||||
refreshToken: "first-new-refresh-token",
|
||||
}),
|
||||
);
|
||||
// same response
|
||||
expect(result1).toEqual(result2);
|
||||
|
||||
@@ -200,10 +206,12 @@ describe("OidcTokenRefresher", () => {
|
||||
const third = await refresher.doRefreshAccessToken("first-new-refresh-token");
|
||||
|
||||
// called token endpoint, got new tokens
|
||||
expect(third).toEqual({
|
||||
accessToken: "second-new-access-token",
|
||||
refreshToken: "second-new-refresh-token",
|
||||
});
|
||||
expect(third).toEqual(
|
||||
expect.objectContaining({
|
||||
accessToken: "second-new-access-token",
|
||||
refreshToken: "second-new-refresh-token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should log and rethrow when token refresh fails", async () => {
|
||||
@@ -261,10 +269,12 @@ describe("OidcTokenRefresher", () => {
|
||||
const result = await refresher.doRefreshAccessToken("first-new-refresh-token");
|
||||
|
||||
// called token endpoint, got new tokens
|
||||
expect(result).toEqual({
|
||||
accessToken: "second-new-access-token",
|
||||
refreshToken: "second-new-refresh-token",
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
accessToken: "second-new-access-token",
|
||||
refreshToken: "second-new-refresh-token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw TokenRefreshLogoutError when expired", async () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
TweakName,
|
||||
} from "../../src";
|
||||
import { mockClientMethodsUser } from "../test-utils/client";
|
||||
import { logger } from "../../src/logger.ts";
|
||||
|
||||
const msc3914RoomCallRule: IPushRule = {
|
||||
rule_id: ".org.matrix.msc3914.rule.room.call",
|
||||
@@ -209,7 +210,7 @@ describe("NotificationService", function () {
|
||||
msgtype: "m.text",
|
||||
},
|
||||
});
|
||||
matrixClient.pushRules = PushProcessor.rewriteDefaultRules(matrixClient.pushRules!);
|
||||
matrixClient.pushRules = PushProcessor.rewriteDefaultRules(logger, matrixClient.pushRules!);
|
||||
pushProcessor = new PushProcessor(matrixClient);
|
||||
});
|
||||
|
||||
@@ -731,7 +732,7 @@ describe("Test PushProcessor.partsForDottedKey", function () {
|
||||
|
||||
describe("rewriteDefaultRules", () => {
|
||||
it("should add default rules in the correct order", () => {
|
||||
const pushRules = PushProcessor.rewriteDefaultRules({
|
||||
const pushRules = PushProcessor.rewriteDefaultRules(logger, {
|
||||
device: {},
|
||||
global: {
|
||||
content: [],
|
||||
@@ -867,7 +868,7 @@ describe("rewriteDefaultRules", () => {
|
||||
});
|
||||
|
||||
it("should add missing msc3914 rule in correct place", () => {
|
||||
const pushRules = PushProcessor.rewriteDefaultRules({
|
||||
const pushRules = PushProcessor.rewriteDefaultRules(logger, {
|
||||
device: {},
|
||||
global: {
|
||||
// Sample push rules from a Synapse user.
|
||||
|
||||
@@ -25,7 +25,6 @@ import { EventType, RelationType, UNSTABLE_MSC2716_MARKER } from "../../src/@typ
|
||||
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
|
||||
import { M_BEACON } from "../../src/@types/beacon";
|
||||
import { type MatrixClient } from "../../src/client";
|
||||
import { defer } from "../../src/utils";
|
||||
import { Room } from "../../src/models/room";
|
||||
import { KnownMembership } from "../../src/@types/membership";
|
||||
import { DecryptionFailureCode } from "../../src/crypto-api";
|
||||
@@ -1084,8 +1083,8 @@ describe("RoomState", function () {
|
||||
timestamp: Date.now() + 1,
|
||||
});
|
||||
|
||||
const deferred = defer<void>();
|
||||
mockClient.decryptEventIfNeeded.mockReturnValue(deferred.promise);
|
||||
const decryptEventResolvers = Promise.withResolvers<void>();
|
||||
mockClient.decryptEventIfNeeded.mockReturnValue(decryptEventResolvers.promise);
|
||||
|
||||
state.setStateEvents([beacon1, beacon2]);
|
||||
const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon;
|
||||
@@ -1095,7 +1094,7 @@ describe("RoomState", function () {
|
||||
// update type after '''decryption'''
|
||||
decryptingRelatedEvent.event.type = M_BEACON.name;
|
||||
decryptingRelatedEvent.event.content = locationEvent.event.content;
|
||||
deferred.resolve();
|
||||
decryptEventResolvers.resolve();
|
||||
await prom;
|
||||
|
||||
expect(addLocationsSpy).toHaveBeenCalledWith([decryptingRelatedEvent]);
|
||||
|
||||
@@ -20,6 +20,7 @@ import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { CrossSigningIdentity } from "../../../src/rust-crypto/CrossSigningIdentity";
|
||||
import { type OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { type ServerSideSecretStorage } from "../../../src/secret-storage";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("CrossSigningIdentity", () => {
|
||||
describe("bootstrapCrossSigning", () => {
|
||||
@@ -55,7 +56,7 @@ describe("CrossSigningIdentity", () => {
|
||||
store: jest.fn(),
|
||||
} as unknown as Mocked<ServerSideSecretStorage>;
|
||||
|
||||
crossSigning = new CrossSigningIdentity(olmMachine, outgoingRequestProcessor, secretStorage);
|
||||
crossSigning = new CrossSigningIdentity(logger, olmMachine, outgoingRequestProcessor, secretStorage);
|
||||
});
|
||||
|
||||
it("should do nothing if keys are present on-device and in secret storage", async () => {
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("KeyClaimManager", () => {
|
||||
markRequestAsSent: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
const outgoingRequestProcessor = new OutgoingRequestProcessor(olmMachine, httpApi);
|
||||
const outgoingRequestProcessor = new OutgoingRequestProcessor(logger, olmMachine, httpApi);
|
||||
|
||||
keyClaimManager = new KeyClaimManager(olmMachine, outgoingRequestProcessor);
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
type UIAuthCallback,
|
||||
} from "../../../src";
|
||||
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { defer } from "../../../src/utils";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("OutgoingRequestProcessor", () => {
|
||||
/** the OutgoingRequestProcessor implementation under test */
|
||||
@@ -77,7 +77,7 @@ describe("OutgoingRequestProcessor", () => {
|
||||
markRequestAsSent: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
processor = new OutgoingRequestProcessor(olmMachine, httpApi);
|
||||
processor = new OutgoingRequestProcessor(logger, olmMachine, httpApi);
|
||||
});
|
||||
|
||||
/* simple requests that map directly to the request body */
|
||||
@@ -285,16 +285,16 @@ describe("OutgoingRequestProcessor", () => {
|
||||
new RustSdkCryptoJs.DeviceId("TEST_DEVICE"),
|
||||
);
|
||||
|
||||
const authRequestResultDefer = defer<string>();
|
||||
const authRequestResultResolvers = Promise.withResolvers<string>();
|
||||
|
||||
const authRequestCalledPromise = new Promise<void>((resolve) => {
|
||||
const mockHttpApi = {
|
||||
authedRequest: async () => {
|
||||
resolve();
|
||||
return await authRequestResultDefer.promise;
|
||||
return await authRequestResultResolvers.promise;
|
||||
},
|
||||
} as unknown as Mocked<MatrixHttpApi<IHttpOpts & { onlyData: true }>>;
|
||||
processor = new OutgoingRequestProcessor(olmMachine, mockHttpApi);
|
||||
processor = new OutgoingRequestProcessor(logger, olmMachine, mockHttpApi);
|
||||
});
|
||||
|
||||
// build a request
|
||||
@@ -308,7 +308,7 @@ describe("OutgoingRequestProcessor", () => {
|
||||
olmMachine.close();
|
||||
|
||||
// the HTTP request completes...
|
||||
authRequestResultDefer.resolve("{}");
|
||||
authRequestResultResolvers.resolve("{}");
|
||||
|
||||
// ... and `makeOutgoingRequest` resolves satisfactorily
|
||||
await result;
|
||||
@@ -326,7 +326,7 @@ describe("OutgoingRequestProcessor", () => {
|
||||
onlyData: true,
|
||||
});
|
||||
|
||||
processor = new OutgoingRequestProcessor(olmMachine, httpApi);
|
||||
processor = new OutgoingRequestProcessor(logger, olmMachine, httpApi);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -20,7 +20,6 @@ import { type OutgoingRequest } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { type OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { OutgoingRequestsManager } from "../../../src/rust-crypto/OutgoingRequestsManager";
|
||||
import { defer, type IDeferred } from "../../../src/utils";
|
||||
import { logger } from "../../../src/logger";
|
||||
|
||||
describe("OutgoingRequestsManager", () => {
|
||||
@@ -70,11 +69,11 @@ describe("OutgoingRequestsManager", () => {
|
||||
const request2 = new RustSdkCryptoJs.KeysUploadRequest("foo2", "{}");
|
||||
const request3 = new RustSdkCryptoJs.KeysBackupRequest("foo3", "{}", "1");
|
||||
|
||||
const firstOutgoingRequestDefer = defer<OutgoingRequest[]>();
|
||||
const firstOutgoingRequestResolvers = Promise.withResolvers<OutgoingRequest[]>();
|
||||
|
||||
olmMachine.outgoingRequests
|
||||
.mockImplementationOnce(async () => {
|
||||
return firstOutgoingRequestDefer.promise;
|
||||
return firstOutgoingRequestResolvers.promise;
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
return [request3];
|
||||
@@ -87,7 +86,7 @@ describe("OutgoingRequestsManager", () => {
|
||||
const thirdRequest = manager.doProcessOutgoingRequests();
|
||||
|
||||
// let the first request complete
|
||||
firstOutgoingRequestDefer.resolve([request1, request2]);
|
||||
firstOutgoingRequestResolvers.resolve([request1, request2]);
|
||||
|
||||
await firstRequest;
|
||||
await secondRequest;
|
||||
@@ -112,10 +111,10 @@ describe("OutgoingRequestsManager", () => {
|
||||
const outgoingRequestCalledPromises: Promise<void>[] = [];
|
||||
|
||||
// deferreds which will provide the results of OlmMachine.outgoingRequests
|
||||
const outgoingRequestResultDeferreds: IDeferred<OutgoingRequest[]>[] = [];
|
||||
const outgoingRequestResultDeferreds: PromiseWithResolvers<OutgoingRequest[]>[] = [];
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const resultDeferred = defer<OutgoingRequest[]>();
|
||||
const resultDeferred = Promise.withResolvers<OutgoingRequest[]>();
|
||||
const calledPromise = new Promise<void>((resolve) => {
|
||||
olmMachine.outgoingRequests.mockImplementationOnce(() => {
|
||||
resolve();
|
||||
@@ -187,10 +186,10 @@ describe("OutgoingRequestsManager", () => {
|
||||
it("When the manager is stopped after outgoingRequests() call, do not make sever requests", async () => {
|
||||
const request1 = new RustSdkCryptoJs.KeysQueryRequest("foo", "{}");
|
||||
|
||||
const firstOutgoingRequestDefer = defer<OutgoingRequest[]>();
|
||||
const firstOutgoingRequestResolvers = Promise.withResolvers<OutgoingRequest[]>();
|
||||
|
||||
olmMachine.outgoingRequests.mockImplementationOnce(async (): Promise<OutgoingRequest[]> => {
|
||||
return firstOutgoingRequestDefer.promise;
|
||||
return firstOutgoingRequestResolvers.promise;
|
||||
});
|
||||
|
||||
const firstRequest = manager.doProcessOutgoingRequests();
|
||||
@@ -199,7 +198,7 @@ describe("OutgoingRequestsManager", () => {
|
||||
manager.stop();
|
||||
|
||||
// let the first request complete
|
||||
firstOutgoingRequestDefer.resolve([request1]);
|
||||
firstOutgoingRequestResolvers.resolve([request1]);
|
||||
|
||||
await firstRequest;
|
||||
|
||||
@@ -210,7 +209,7 @@ describe("OutgoingRequestsManager", () => {
|
||||
const request1 = new RustSdkCryptoJs.KeysQueryRequest("11", "{}");
|
||||
const request2 = new RustSdkCryptoJs.KeysUploadRequest("12", "{}");
|
||||
|
||||
const firstRequestDefer = defer<void>();
|
||||
const firstRequestResolvers = Promise.withResolvers<void>();
|
||||
|
||||
olmMachine.outgoingRequests.mockImplementationOnce(async (): Promise<OutgoingRequest[]> => {
|
||||
return [request1, request2];
|
||||
@@ -219,7 +218,7 @@ describe("OutgoingRequestsManager", () => {
|
||||
processor.makeOutgoingRequest
|
||||
.mockImplementationOnce(async () => {
|
||||
manager.stop();
|
||||
return firstRequestDefer.promise;
|
||||
return firstRequestResolvers.promise;
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
return;
|
||||
@@ -227,7 +226,7 @@ describe("OutgoingRequestsManager", () => {
|
||||
|
||||
const firstRequest = manager.doProcessOutgoingRequests();
|
||||
|
||||
firstRequestDefer.resolve();
|
||||
firstRequestResolvers.resolve();
|
||||
|
||||
await firstRequest;
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { PerSessionKeyBackupDownloader } from "../../../src/rust-crypto/PerSessionKeyBackupDownloader";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { defer, type IDeferred } from "../../../src/utils";
|
||||
import {
|
||||
type RustBackupCryptoEventMap,
|
||||
type RustBackupCryptoEvents,
|
||||
@@ -57,15 +56,15 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
let mockOlmMachine: Mocked<OlmMachine>;
|
||||
let mockBackupDecryptor: Mocked<BackupDecryptor>;
|
||||
|
||||
let expectedSession: { [roomId: string]: { [sessionId: string]: IDeferred<void> } };
|
||||
let expectedSession: { [roomId: string]: { [sessionId: string]: PromiseWithResolvers<void> } };
|
||||
|
||||
function expectSessionImported(roomId: string, sessionId: string) {
|
||||
const deferred = defer<void>();
|
||||
const sessionImportedResolvers = Promise.withResolvers<void>();
|
||||
if (!expectedSession[roomId]) {
|
||||
expectedSession[roomId] = {};
|
||||
}
|
||||
expectedSession[roomId][sessionId] = deferred;
|
||||
return deferred.promise;
|
||||
expectedSession[roomId][sessionId] = sessionImportedResolvers;
|
||||
return sessionImportedResolvers.promise;
|
||||
}
|
||||
|
||||
function mockClearSession(sessionId: string): Mocked<IMegolmSessionData> {
|
||||
@@ -115,9 +114,9 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
mockRustBackupManager.importBackedUpRoomKeys.mockImplementation(async (keys) => {
|
||||
const roomId = keys[0].room_id;
|
||||
const sessionId = keys[0].session_id;
|
||||
const deferred = expectedSession[roomId] && expectedSession[roomId][sessionId];
|
||||
if (deferred) {
|
||||
deferred.resolve();
|
||||
const sessionImportedResolvers = expectedSession[roomId] && expectedSession[roomId][sessionId];
|
||||
if (sessionImportedResolvers) {
|
||||
sessionImportedResolvers.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -143,7 +142,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
});
|
||||
|
||||
it("Should download and import a missing key from backup", async () => {
|
||||
const awaitKeyImported = defer<void>();
|
||||
const awaitKeyImported = Promise.withResolvers<void>();
|
||||
const roomId = "!roomId";
|
||||
const sessionId = "sessionId";
|
||||
const expectAPICall = new Promise<void>((resolve) => {
|
||||
@@ -168,14 +167,14 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
});
|
||||
|
||||
it("Should not hammer the backup if the key is requested repeatedly", async () => {
|
||||
const blockOnServerRequest = defer<void>();
|
||||
const blockOnServerRequest = Promise.withResolvers<void>();
|
||||
|
||||
fetchMock.get(`express:/_matrix/client/v3/room_keys/keys/!roomId/:session_id`, async (url, request) => {
|
||||
await blockOnServerRequest.promise;
|
||||
return [mockCipherKey];
|
||||
});
|
||||
|
||||
const awaitKey2Imported = defer<void>();
|
||||
const awaitKey2Imported = Promise.withResolvers<void>();
|
||||
|
||||
mockRustBackupManager.importBackedUpRoomKeys.mockImplementation(async (keys) => {
|
||||
if (keys[0].session_id === "sessionId2") {
|
||||
@@ -267,8 +266,8 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
|
||||
it("Should stop properly", async () => {
|
||||
// Simulate a call to stop while request is in flight
|
||||
const blockOnServerRequest = defer<void>();
|
||||
const requestRoomKeyCalled = defer<void>();
|
||||
const blockOnServerRequest = Promise.withResolvers<void>();
|
||||
const requestRoomKeyCalled = Promise.withResolvers<void>();
|
||||
|
||||
// Mock the request to block
|
||||
fetchMock.get(`express:/_matrix/client/v3/room_keys/keys/:roomId/:sessionId`, async (url, request) => {
|
||||
@@ -490,7 +489,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
|
||||
// @ts-ignore access to private function
|
||||
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
|
||||
const rateDeferred = defer<void>();
|
||||
const rateDeferred = Promise.withResolvers<void>();
|
||||
|
||||
keyQuerySpy.mockImplementation(
|
||||
// @ts-ignore
|
||||
@@ -544,7 +543,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
|
||||
// @ts-ignore
|
||||
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
|
||||
const errorDeferred = defer<void>();
|
||||
const errorDeferred = Promise.withResolvers<void>();
|
||||
|
||||
keyQuerySpy.mockImplementation(
|
||||
// @ts-ignore
|
||||
@@ -588,7 +587,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
});
|
||||
|
||||
it("On Unknown error on import skip the key and continue", async () => {
|
||||
const keyImported = defer<void>();
|
||||
const keyImported = Promise.withResolvers<void>();
|
||||
mockRustBackupManager.importBackedUpRoomKeys
|
||||
.mockImplementationOnce(async () => {
|
||||
throw new Error("Didn't work");
|
||||
|
||||
@@ -29,7 +29,6 @@ import { type Mocked } from "jest-mock";
|
||||
import { HistoryVisibility, type MatrixEvent, type Room, type RoomMember } from "../../../src";
|
||||
import { RoomEncryptor, toRustHistoryVisibility } from "../../../src/rust-crypto/RoomEncryptor";
|
||||
import { type KeyClaimManager } from "../../../src/rust-crypto/KeyClaimManager";
|
||||
import { defer } from "../../../src/utils";
|
||||
import { type OutgoingRequestsManager } from "../../../src/rust-crypto/OutgoingRequestsManager";
|
||||
import { KnownMembership } from "../../../src/@types/membership";
|
||||
import {
|
||||
@@ -37,6 +36,7 @@ import {
|
||||
AllDevicesIsolationMode,
|
||||
OnlySignedDevicesIsolationMode,
|
||||
} from "../../../src/crypto-api";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("RoomEncryptor", () => {
|
||||
describe("History Visibility", () => {
|
||||
@@ -109,6 +109,7 @@ describe("RoomEncryptor", () => {
|
||||
} as unknown as Mocked<Room>;
|
||||
|
||||
roomEncryptor = new RoomEncryptor(
|
||||
logger,
|
||||
mockOlmMachine,
|
||||
mockKeyClaimManager,
|
||||
mockOutgoingRequestManager,
|
||||
@@ -120,8 +121,8 @@ describe("RoomEncryptor", () => {
|
||||
const defaultDevicesIsolationMode = new AllDevicesIsolationMode(false);
|
||||
|
||||
it("should ensure that there is only one shareRoomKey at a time", async () => {
|
||||
const deferredShare = defer<void>();
|
||||
const insideOlmShareRoom = defer<void>();
|
||||
const deferredShare = Promise.withResolvers<void>();
|
||||
const insideOlmShareRoom = Promise.withResolvers<void>();
|
||||
|
||||
mockOlmMachine.shareRoomKey.mockImplementationOnce(async () => {
|
||||
insideOlmShareRoom.resolve();
|
||||
@@ -149,8 +150,8 @@ describe("RoomEncryptor", () => {
|
||||
|
||||
// Regression test for https://github.com/element-hq/element-web/issues/26684
|
||||
it("Should maintain order of encryption requests", async () => {
|
||||
const firstTargetMembers = defer<void>();
|
||||
const secondTargetMembers = defer<void>();
|
||||
const firstTargetMembers = Promise.withResolvers<void>();
|
||||
const secondTargetMembers = Promise.withResolvers<void>();
|
||||
|
||||
mockOlmMachine.shareRoomKey.mockResolvedValue([]);
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`RustCrypto importing and exporting room keys should import and export keys 1`] = `
|
||||
{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"forwarding_curve25519_key_chain": [],
|
||||
"org.matrix.msc3061.shared_history": false,
|
||||
"room_id": "!room:id",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "QdgHgdpDgihgovpPzUiThXur1fbErTFh7paFvNKSgN0",
|
||||
},
|
||||
"sender_key": "WimPd2udAU/1S/+YBpPbmr9L+0H5H+BnAVHSwDxlPGc",
|
||||
"session_id": "FYOoKQSwe4d9jhTZ/LQCZFJINjPEqZ7Or4Z08reP92M",
|
||||
"session_key": "AQAAAABZ0jXQOprFfXe41tIFmAtHxflJp4O2hM/vzQQpOazOCFeWSoW5P3Z9Q+voU3eXehMwyP8/hm/Q8xLP6/PmJdy+71se/17kdFwcDGgLxBWfa4ODM9zlI4EjKbNqmiii5loJ7rBhA/XXaw80m0hfU6zTDX/KrO55J0Pt4vJ0LDa3LBWDqCkEsHuHfY4U2fy0AmRSSDYzxKmezq+GdPK3j/dj",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`RustCrypto importing and exporting room keys should import and export keys as JSON 1`] = `
|
||||
{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"forwarding_curve25519_key_chain": [],
|
||||
"org.matrix.msc3061.shared_history": false,
|
||||
"room_id": "!room:id",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "QdgHgdpDgihgovpPzUiThXur1fbErTFh7paFvNKSgN0",
|
||||
},
|
||||
"sender_key": "WimPd2udAU/1S/+YBpPbmr9L+0H5H+BnAVHSwDxlPGc",
|
||||
"session_id": "FYOoKQSwe4d9jhTZ/LQCZFJINjPEqZ7Or4Z08reP92M",
|
||||
"session_key": "AQAAAABZ0jXQOprFfXe41tIFmAtHxflJp4O2hM/vzQQpOazOCFeWSoW5P3Z9Q+voU3eXehMwyP8/hm/Q8xLP6/PmJdy+71se/17kdFwcDGgLxBWfa4ODM9zlI4EjKbNqmiii5loJ7rBhA/XXaw80m0hfU6zTDX/KrO55J0Pt4vJ0LDa3LBWDqCkEsHuHfY4U2fy0AmRSSDYzxKmezq+GdPK3j/dj",
|
||||
}
|
||||
`;
|
||||
@@ -8,6 +8,7 @@ import { type OutgoingRequestProcessor } from "../../../src/rust-crypto/Outgoing
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import * as TestData from "../../test-utils/test-data";
|
||||
import { RustBackupManager, type KeyBackup } from "../../../src/rust-crypto/backup";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("Upload keys to backup", () => {
|
||||
/** The backup manager under test */
|
||||
@@ -63,7 +64,7 @@ describe("Upload keys to backup", () => {
|
||||
makeOutgoingRequest: jest.fn(),
|
||||
} as unknown as Mocked<OutgoingRequestProcessor>;
|
||||
|
||||
rustBackupManager = new RustBackupManager(mockOlmMachine, httpAPi, outgoingRequestProcessor);
|
||||
rustBackupManager = new RustBackupManager(logger, mockOlmMachine, httpAPi, outgoingRequestProcessor);
|
||||
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
});
|
||||
|
||||
@@ -68,7 +68,6 @@ import {
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
import { defer } from "../../../src/utils";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { OutgoingRequestsManager } from "../../../src/rust-crypto/OutgoingRequestsManager";
|
||||
import { ClientEvent, type ClientEventHandlerMap } from "../../../src/client";
|
||||
@@ -514,11 +513,8 @@ describe("RustCrypto", () => {
|
||||
expect(Array.isArray(keys)).toBeTruthy();
|
||||
expect(keys.length).toBe(someRoomKeys.length);
|
||||
|
||||
const aSession = someRoomKeys[0];
|
||||
|
||||
const exportedKey = keys.find((k) => k.session_id == aSession.session_id);
|
||||
|
||||
expect(aSession).toStrictEqual(exportedKey);
|
||||
const exportedKey = keys.find((k) => k.session_id == someRoomKeys[0].session_id);
|
||||
expect(exportedKey).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should import and export keys as JSON", async () => {
|
||||
@@ -539,11 +535,8 @@ describe("RustCrypto", () => {
|
||||
expect(Array.isArray(keys)).toBeTruthy();
|
||||
expect(keys.length).toBe(someRoomKeys.length);
|
||||
|
||||
const aSession = someRoomKeys[0];
|
||||
|
||||
const exportedKey = keys.find((k) => k.session_id == aSession.session_id);
|
||||
|
||||
expect(aSession).toStrictEqual(exportedKey);
|
||||
const exportedKey = keys.find((k) => k.session_id == someRoomKeys[0].session_id);
|
||||
expect(exportedKey).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -558,11 +551,11 @@ describe("RustCrypto", () => {
|
||||
const inputs: IToDeviceEvent[] = [
|
||||
{ content: { key: "value" }, type: "org.matrix.test", sender: "@alice:example.com" },
|
||||
];
|
||||
const res = await rustCrypto.preprocessToDeviceMessages(inputs);
|
||||
const res = (await rustCrypto.preprocessToDeviceMessages(inputs)).map((p) => p.message);
|
||||
expect(res).toEqual(inputs);
|
||||
});
|
||||
|
||||
it("should pass through bad encrypted messages", async () => {
|
||||
it("should fail to process bad encrypted messages", async () => {
|
||||
const olmMachine: OlmMachine = rustCrypto["olmMachine"];
|
||||
const keys = olmMachine.identityKeys;
|
||||
const inputs: IToDeviceEvent[] = [
|
||||
@@ -583,7 +576,7 @@ describe("RustCrypto", () => {
|
||||
];
|
||||
|
||||
const res = await rustCrypto.preprocessToDeviceMessages(inputs);
|
||||
expect(res).toEqual(inputs);
|
||||
expect(res.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("emits VerificationRequestReceived on incoming m.key.verification.request", async () => {
|
||||
@@ -934,8 +927,8 @@ describe("RustCrypto", () => {
|
||||
|
||||
it("should go round the loop again if another sync completes while the first `outgoingRequests` is running", async () => {
|
||||
// the first call to `outgoingMessages` will return a promise which blocks for a while
|
||||
const firstOutgoingRequestsDefer = defer<Array<any>>();
|
||||
mocked(olmMachine.outgoingRequests).mockReturnValueOnce(firstOutgoingRequestsDefer.promise);
|
||||
const firstOutgoingRequestsResolvers = Promise.withResolvers<Array<any>>();
|
||||
mocked(olmMachine.outgoingRequests).mockReturnValueOnce(firstOutgoingRequestsResolvers.promise);
|
||||
|
||||
// the second will return a KeysQueryRequest.
|
||||
const testReq = new KeysQueryRequest("1234", "{}");
|
||||
@@ -952,7 +945,7 @@ describe("RustCrypto", () => {
|
||||
|
||||
// the first call now completes, *with an empty result*, which would normally cause us to exit the loop, but
|
||||
// we should have a second call queued. It should trigger a call to `makeOutgoingRequest`.
|
||||
firstOutgoingRequestsDefer.resolve([]);
|
||||
firstOutgoingRequestsResolvers.resolve([]);
|
||||
await awaitCallToMakeOutgoingRequest();
|
||||
expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -2255,10 +2248,10 @@ describe("RustCrypto", () => {
|
||||
let secretStorage: ServerSideSecretStorage;
|
||||
beforeEach(() => {
|
||||
secretStorage = {
|
||||
setDefaultKeyId: jest.fn(),
|
||||
setDefaultKeyId: jest.fn().mockResolvedValue(undefined),
|
||||
hasKey: jest.fn().mockResolvedValue(false),
|
||||
getKey: jest.fn().mockResolvedValue(null),
|
||||
store: jest.fn(),
|
||||
store: jest.fn().mockResolvedValue(undefined),
|
||||
getDefaultKeyId: jest.fn().mockResolvedValue("defaultKeyId"),
|
||||
} as unknown as ServerSideSecretStorage;
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { type OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { type IDeviceKeys } from "../../../src/@types/crypto";
|
||||
import { EventType, MatrixEvent, MsgType } from "../../../src";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("VerificationRequest", () => {
|
||||
describe("pending", () => {
|
||||
@@ -147,6 +148,7 @@ describe("VerificationRequest", () => {
|
||||
methods,
|
||||
);
|
||||
const aliceVerificationRequest = new RustVerificationRequest(
|
||||
logger,
|
||||
aliceOlmMachine,
|
||||
innerVerificationRequest,
|
||||
aliceRequestLoop as unknown as OutgoingRequestProcessor,
|
||||
@@ -174,6 +176,7 @@ describe("VerificationRequest", () => {
|
||||
"$m.key.verification.request",
|
||||
)!;
|
||||
const bobVerificationRequest = new RustVerificationRequest(
|
||||
logger,
|
||||
bobOlmMachine,
|
||||
bobInnerVerificationRequest,
|
||||
bobRequestLoop as unknown as OutgoingRequestProcessor,
|
||||
@@ -278,6 +281,7 @@ describe("VerificationRequest", () => {
|
||||
methods,
|
||||
);
|
||||
const aliceVerificationRequest = new RustVerificationRequest(
|
||||
logger,
|
||||
aliceOlmMachine,
|
||||
innerVerificationRequest,
|
||||
aliceRequestLoop as unknown as OutgoingRequestProcessor,
|
||||
@@ -305,6 +309,7 @@ describe("VerificationRequest", () => {
|
||||
"$m.key.verification.request",
|
||||
)!;
|
||||
const bobVerificationRequest = new RustVerificationRequest(
|
||||
logger,
|
||||
bobOlmMachine,
|
||||
bobInnerVerificationRequest,
|
||||
bobRequestLoop as unknown as OutgoingRequestProcessor,
|
||||
@@ -392,6 +397,7 @@ describe("VerificationRequest", () => {
|
||||
methods,
|
||||
);
|
||||
const aliceVerificationRequest = new RustVerificationRequest(
|
||||
logger,
|
||||
aliceOlmMachine,
|
||||
innerVerificationRequest,
|
||||
aliceRequestLoop as unknown as OutgoingRequestProcessor,
|
||||
@@ -419,6 +425,7 @@ describe("VerificationRequest", () => {
|
||||
"$m.key.verification.request",
|
||||
)!;
|
||||
const bobVerificationRequest = new RustVerificationRequest(
|
||||
logger,
|
||||
bobOlmMachine,
|
||||
bobInnerVerificationRequest,
|
||||
bobRequestLoop as unknown as OutgoingRequestProcessor,
|
||||
@@ -496,7 +503,7 @@ function makeTestRequest(
|
||||
inner ??= makeMockedInner();
|
||||
olmMachine ??= {} as RustSdkCryptoJs.OlmMachine;
|
||||
outgoingRequestProcessor ??= {} as OutgoingRequestProcessor;
|
||||
return new RustVerificationRequest(olmMachine, inner, outgoingRequestProcessor, []);
|
||||
return new RustVerificationRequest(logger, olmMachine, inner, outgoingRequestProcessor, []);
|
||||
}
|
||||
|
||||
/** Mock up a rust-side VerificationRequest */
|
||||
|
||||
+10
-11
@@ -1,7 +1,6 @@
|
||||
// This file had a function whose name is all caps, which displeases eslint
|
||||
/* eslint new-cap: "off" */
|
||||
|
||||
import { defer, type IDeferred } from "../../src/utils";
|
||||
import { MatrixError } from "../../src/http-api";
|
||||
import { MatrixScheduler } from "../../src/scheduler";
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
@@ -14,7 +13,7 @@ describe("MatrixScheduler", function () {
|
||||
let scheduler: MatrixScheduler<Record<string, boolean>>;
|
||||
let retryFn: ((event: MatrixEvent | null, attempt: number, err: MatrixError) => number) | null;
|
||||
let queueFn: ((event: MatrixEvent) => string | null) | null;
|
||||
let deferred: IDeferred<Record<string, boolean>>;
|
||||
let deferred: PromiseWithResolvers<Record<string, boolean>>;
|
||||
const roomId = "!foo:bar";
|
||||
const eventA = utils.mkMessage({
|
||||
user: "@alice:bar",
|
||||
@@ -44,7 +43,7 @@ describe("MatrixScheduler", function () {
|
||||
);
|
||||
retryFn = null;
|
||||
queueFn = null;
|
||||
deferred = defer();
|
||||
deferred = Promise.withResolvers();
|
||||
});
|
||||
|
||||
it("should process events in a queue in a FIFO manner", async function () {
|
||||
@@ -54,8 +53,8 @@ describe("MatrixScheduler", function () {
|
||||
queueFn = function () {
|
||||
return "one_big_queue";
|
||||
};
|
||||
const deferA = defer<Record<string, boolean>>();
|
||||
const deferB = defer<Record<string, boolean>>();
|
||||
const deferA = Promise.withResolvers<Record<string, boolean>>();
|
||||
const deferB = Promise.withResolvers<Record<string, boolean>>();
|
||||
let yieldedA = false;
|
||||
scheduler.setProcessFunction(function (event) {
|
||||
if (yieldedA) {
|
||||
@@ -79,9 +78,9 @@ describe("MatrixScheduler", function () {
|
||||
|
||||
it("should invoke the retryFn on failure and wait the amount of time specified", async function () {
|
||||
const waitTimeMs = 1500;
|
||||
const retryDefer = defer();
|
||||
const retryResolvers = Promise.withResolvers<void>();
|
||||
retryFn = function () {
|
||||
retryDefer.resolve();
|
||||
retryResolvers.resolve();
|
||||
return waitTimeMs;
|
||||
};
|
||||
queueFn = function () {
|
||||
@@ -109,7 +108,7 @@ describe("MatrixScheduler", function () {
|
||||
await Promise.resolve();
|
||||
expect(procCount).toEqual(1);
|
||||
deferred.reject({});
|
||||
await retryDefer.promise;
|
||||
await retryResolvers.promise;
|
||||
expect(procCount).toEqual(1);
|
||||
jest.advanceTimersByTime(waitTimeMs);
|
||||
await Promise.resolve();
|
||||
@@ -127,8 +126,8 @@ describe("MatrixScheduler", function () {
|
||||
return "yep";
|
||||
};
|
||||
|
||||
const deferA = defer<Record<string, boolean>>();
|
||||
const deferB = defer<Record<string, boolean>>();
|
||||
const deferA = Promise.withResolvers<Record<string, boolean>>();
|
||||
const deferB = Promise.withResolvers<Record<string, boolean>>();
|
||||
let procCount = 0;
|
||||
scheduler.setProcessFunction(function (ev) {
|
||||
procCount += 1;
|
||||
@@ -180,7 +179,7 @@ describe("MatrixScheduler", function () {
|
||||
};
|
||||
|
||||
const expectOrder = [eventA.getId(), eventB.getId(), eventD.getId()];
|
||||
const deferA = defer<Record<string, boolean>>();
|
||||
const deferA = Promise.withResolvers<Record<string, boolean>>();
|
||||
const allExpectedEventsSeenInOrderPromise = new Promise((resolve) => {
|
||||
scheduler.setProcessFunction(function (event) {
|
||||
const id = expectOrder.shift();
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
import { secureRandomString } from "../../src/randomstring";
|
||||
import { type SecretInfo } from "../../src/secret-storage.ts";
|
||||
import { type AccountDataEvents, ClientEvent, MatrixEvent, TypedEventEmitter } from "../../src";
|
||||
import { defer, type IDeferred } from "../../src/utils";
|
||||
|
||||
declare module "../../src/@types/event" {
|
||||
interface SecretStorageAccountDataEvents {
|
||||
@@ -289,10 +288,10 @@ describe("ServerSideSecretStorageImpl", function () {
|
||||
describe("setDefaultKeyId", function () {
|
||||
let secretStorage: ServerSideSecretStorage;
|
||||
let accountDataAdapter: Mocked<AccountDataClient>;
|
||||
let accountDataPromise: IDeferred<void>;
|
||||
let accountDataPromise: PromiseWithResolvers<void>;
|
||||
beforeEach(() => {
|
||||
accountDataAdapter = mockAccountDataClient();
|
||||
accountDataPromise = defer();
|
||||
accountDataPromise = Promise.withResolvers();
|
||||
accountDataAdapter.setAccountData.mockImplementation(() => {
|
||||
accountDataPromise.resolve();
|
||||
return Promise.resolve({});
|
||||
|
||||
@@ -18,7 +18,6 @@ import "fake-indexeddb/auto";
|
||||
|
||||
import { type LocalIndexedDBStoreBackend } from "../../../src/store/indexeddb-local-backend";
|
||||
import { IndexedDBStoreWorker } from "../../../src/store/indexeddb-store-worker";
|
||||
import { defer } from "../../../src/utils";
|
||||
|
||||
function setupWorker(worker: IndexedDBStoreWorker): void {
|
||||
worker.onMessage({ data: { command: "setupWorker", args: [] } } as any);
|
||||
@@ -27,16 +26,16 @@ function setupWorker(worker: IndexedDBStoreWorker): void {
|
||||
|
||||
describe("IndexedDBStore Worker", () => {
|
||||
it("should pass 'closed' event via postMessage", async () => {
|
||||
const deferred = defer<void>();
|
||||
const postMessageSuccessResolvers = Promise.withResolvers<void>();
|
||||
const postMessage = jest.fn().mockImplementation(({ seq, command }) => {
|
||||
if (seq === 1 && command === "cmd_success") {
|
||||
deferred.resolve();
|
||||
postMessageSuccessResolvers.resolve();
|
||||
}
|
||||
});
|
||||
const worker = new IndexedDBStoreWorker(postMessage);
|
||||
setupWorker(worker);
|
||||
|
||||
await deferred.promise;
|
||||
await postMessageSuccessResolvers.promise;
|
||||
|
||||
// @ts-ignore - private field access
|
||||
(worker.backend as LocalIndexedDBStoreBackend).db!.onclose!({} as Event);
|
||||
|
||||
@@ -21,7 +21,6 @@ import { IDBFactory } from "fake-indexeddb";
|
||||
import { IndexedDBStore, type IStateEventWithRoomId, MemoryStore, User, UserEvent } from "../../../src";
|
||||
import { emitPromise } from "../../test-utils/test-utils";
|
||||
import { type LocalIndexedDBStoreBackend } from "../../../src/store/indexeddb-local-backend";
|
||||
import { defer } from "../../../src/utils";
|
||||
|
||||
describe("IndexedDBStore", () => {
|
||||
afterEach(() => {
|
||||
@@ -80,7 +79,7 @@ describe("IndexedDBStore", () => {
|
||||
it("Should load presence events on startup", async () => {
|
||||
// 1. Create idb database
|
||||
const indexedDB = new IDBFactory();
|
||||
const setupDefer = defer<Event>();
|
||||
const setupResolvers = Promise.withResolvers<Event>();
|
||||
const req = indexedDB.open("matrix-js-sdk:db3", 1);
|
||||
let db: IDBDatabase;
|
||||
req.onupgradeneeded = () => {
|
||||
@@ -89,11 +88,11 @@ describe("IndexedDBStore", () => {
|
||||
db.createObjectStore("accountData", { keyPath: ["type"] });
|
||||
db.createObjectStore("sync", { keyPath: ["clobber"] });
|
||||
};
|
||||
req.onsuccess = setupDefer.resolve;
|
||||
await setupDefer.promise;
|
||||
req.onsuccess = setupResolvers.resolve;
|
||||
await setupResolvers.promise;
|
||||
|
||||
// 2. Fill in user presence data
|
||||
const writeDefer = defer<Event>();
|
||||
const writeResolvers = Promise.withResolvers<Event>();
|
||||
const transaction = db!.transaction(["users"], "readwrite");
|
||||
const objectStore = transaction.objectStore("users");
|
||||
const request = objectStore.put({
|
||||
@@ -106,8 +105,8 @@ describe("IndexedDBStore", () => {
|
||||
type: "m.presence",
|
||||
},
|
||||
});
|
||||
request.onsuccess = writeDefer.resolve;
|
||||
await writeDefer.promise;
|
||||
request.onsuccess = writeResolvers.resolve;
|
||||
await writeResolvers.promise;
|
||||
|
||||
// 3. Close database
|
||||
req.result.close();
|
||||
@@ -201,7 +200,7 @@ describe("IndexedDBStore", () => {
|
||||
});
|
||||
|
||||
it("should resolve isNewlyCreated to false if database existed already but needs upgrade", async () => {
|
||||
const deferred = defer<Event>();
|
||||
const requestSuccessResolvers = Promise.withResolvers<Event>();
|
||||
// seed db3 to Version 1 so it forces a migration
|
||||
const req = indexedDB.open("matrix-js-sdk:db3", 1);
|
||||
req.onupgradeneeded = () => {
|
||||
@@ -210,8 +209,8 @@ describe("IndexedDBStore", () => {
|
||||
db.createObjectStore("accountData", { keyPath: ["type"] });
|
||||
db.createObjectStore("sync", { keyPath: ["clobber"] });
|
||||
};
|
||||
req.onsuccess = deferred.resolve;
|
||||
await deferred.promise;
|
||||
req.onsuccess = requestSuccessResolvers.resolve;
|
||||
await requestSuccessResolvers.promise;
|
||||
req.result.close();
|
||||
|
||||
const store = new IndexedDBStore({
|
||||
@@ -232,20 +231,20 @@ describe("IndexedDBStore", () => {
|
||||
});
|
||||
await store.startup();
|
||||
|
||||
const deferred = defer<void>();
|
||||
store.on("closed", deferred.resolve);
|
||||
const storeClosedResolvers = Promise.withResolvers<void>();
|
||||
store.on("closed", storeClosedResolvers.resolve);
|
||||
|
||||
// @ts-ignore - private field access
|
||||
(store.backend as LocalIndexedDBStoreBackend).db!.onclose!({} as Event);
|
||||
await deferred.promise;
|
||||
await storeClosedResolvers.promise;
|
||||
});
|
||||
|
||||
it("should use remote backend if workerFactory passed", async () => {
|
||||
const deferred = defer<void>();
|
||||
const workerPostMessageResolvers = Promise.withResolvers<void>();
|
||||
class MockWorker {
|
||||
postMessage(data: any) {
|
||||
if (data.command === "setupWorker") {
|
||||
deferred.resolve();
|
||||
workerPostMessageResolvers.resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,7 +256,7 @@ describe("IndexedDBStore", () => {
|
||||
workerFactory: () => new MockWorker() as Worker,
|
||||
});
|
||||
store.startup();
|
||||
await deferred.promise;
|
||||
await workerPostMessageResolvers.promise;
|
||||
});
|
||||
|
||||
it("remote worker should pass closed event", async () => {
|
||||
@@ -273,10 +272,10 @@ describe("IndexedDBStore", () => {
|
||||
});
|
||||
store.startup();
|
||||
|
||||
const deferred = defer<void>();
|
||||
store.on("closed", deferred.resolve);
|
||||
const storeClosedResolvers = Promise.withResolvers<void>();
|
||||
store.on("closed", storeClosedResolvers.resolve);
|
||||
(worker as any).onmessage({ data: { command: "closed" } });
|
||||
await deferred.promise;
|
||||
await storeClosedResolvers.promise;
|
||||
});
|
||||
|
||||
it("remote worker should pass command failures", async () => {
|
||||
|
||||
Vendored
+7
-31
@@ -14,9 +14,6 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// this is needed to tell TS about globalThis.Olm
|
||||
import "@matrix-org/olm";
|
||||
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
@@ -34,39 +31,18 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
interface MediaDevices {
|
||||
// This is experimental and types don't know about it yet
|
||||
// https://github.com/microsoft/TypeScript/issues/33232
|
||||
getDisplayMedia(constraints: MediaStreamConstraints | DesktopCapturerConstraints): Promise<MediaStream>;
|
||||
getUserMedia(constraints: MediaStreamConstraints | DesktopCapturerConstraints): Promise<MediaStream>;
|
||||
// Chrome-specific getUserMedia constraints
|
||||
interface MediaTrackConstraints {
|
||||
mandatory?: {
|
||||
chromeMediaSource: string;
|
||||
chromeMediaSourceId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DesktopCapturerConstraints {
|
||||
audio:
|
||||
| boolean
|
||||
| {
|
||||
mandatory: {
|
||||
chromeMediaSource: string;
|
||||
chromeMediaSourceId: string;
|
||||
};
|
||||
};
|
||||
video:
|
||||
| boolean
|
||||
| {
|
||||
mandatory: {
|
||||
chromeMediaSource: string;
|
||||
chromeMediaSourceId: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface DummyInterfaceWeShouldntBeUsingThis {}
|
||||
|
||||
interface Navigator {
|
||||
// We check for the webkit-prefixed getUserMedia to detect if we're
|
||||
// on webkit: we should check if we still need to do this
|
||||
webkitGetUserMedia?: DummyInterfaceWeShouldntBeUsingThis;
|
||||
webkitGetUserMedia?: unknown;
|
||||
}
|
||||
|
||||
export interface Uint8ArrayToBase64Options {
|
||||
|
||||
+3
-6
@@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import { UnstableValue } from "../NamespacedValue.ts";
|
||||
import { NamespacedValue } from "../NamespacedValue.ts";
|
||||
import { type IMessageRendering } from "./extensible_events.ts";
|
||||
|
||||
/**
|
||||
@@ -42,20 +42,17 @@ import { type IMessageRendering } from "./extensible_events.ts";
|
||||
/**
|
||||
* The event type for an m.topic event (in content)
|
||||
*/
|
||||
export const M_TOPIC = new UnstableValue("m.topic", "org.matrix.msc3765.topic");
|
||||
export const M_TOPIC = new NamespacedValue("m.topic");
|
||||
|
||||
/**
|
||||
* The event content for an m.topic event (in content)
|
||||
*/
|
||||
export type MTopicContent = IMessageRendering[];
|
||||
|
||||
type MTopicStable = { [M_TOPIC.altName]: MTopicContent };
|
||||
type MTopicUnstable = { [M_TOPIC.name]: MTopicContent };
|
||||
|
||||
/**
|
||||
* The event definition for an m.topic event (in content)
|
||||
*/
|
||||
export type MTopicEvent = (MTopicStable & MTopicUnstable) | MTopicStable | MTopicUnstable;
|
||||
export type MTopicEvent = { "m.topic": MTopicContent };
|
||||
|
||||
/**
|
||||
* The event content for an m.room.topic event
|
||||
|
||||
+13
-10
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { ToDeviceMessageId } from "./@types/event.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
import { type Logger } from "./logger.ts";
|
||||
import { type MatrixClient, ClientEvent } from "./client.ts";
|
||||
import { type MatrixError } from "./http-api/index.ts";
|
||||
import {
|
||||
@@ -40,7 +40,10 @@ export class ToDeviceMessageQueue {
|
||||
private retryTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private retryAttempts = 0;
|
||||
|
||||
public constructor(private client: MatrixClient) {}
|
||||
public constructor(
|
||||
private client: MatrixClient,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
public start(): void {
|
||||
this.running = true;
|
||||
@@ -67,7 +70,7 @@ export class ToDeviceMessageQueue {
|
||||
const msgmap = batchWithTxnId.batch.map(
|
||||
(msg) => `${msg.userId}/${msg.deviceId} (msgid ${msg.payload[ToDeviceMessageId]})`,
|
||||
);
|
||||
logger.info(
|
||||
this.logger.info(
|
||||
`Enqueuing batch of to-device messages. type=${batch.eventType} txnid=${batchWithTxnId.txnId}`,
|
||||
msgmap,
|
||||
);
|
||||
@@ -83,7 +86,7 @@ export class ToDeviceMessageQueue {
|
||||
|
||||
if (this.sending || !this.running) return;
|
||||
|
||||
logger.debug("Attempting to send queued to-device messages");
|
||||
this.logger.debug("Attempting to send queued to-device messages");
|
||||
|
||||
this.sending = true;
|
||||
let headBatch: IndexedToDeviceBatch | null;
|
||||
@@ -99,7 +102,7 @@ export class ToDeviceMessageQueue {
|
||||
// Make sure we're still running after the async tasks: if not, stop.
|
||||
if (!this.running) return;
|
||||
|
||||
logger.debug("All queued to-device messages sent");
|
||||
this.logger.debug("All queued to-device messages sent");
|
||||
} catch (e) {
|
||||
++this.retryAttempts;
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -109,15 +112,15 @@ export class ToDeviceMessageQueue {
|
||||
// the scheduler function doesn't differentiate between fatal errors and just getting
|
||||
// bored and giving up for now
|
||||
if (Math.floor((<MatrixError>e).httpStatus! / 100) === 4) {
|
||||
logger.error("Fatal error when sending to-device message - dropping to-device batch!", e);
|
||||
this.logger.error("Fatal error when sending to-device message - dropping to-device batch!", e);
|
||||
await this.client.store.removeToDeviceBatch(headBatch!.id);
|
||||
} else {
|
||||
logger.info("Automatic retry limit reached for to-device messages.");
|
||||
this.logger.info("Automatic retry limit reached for to-device messages.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Failed to send batch of to-device messages. Will retry in ${retryDelay}ms`, e);
|
||||
this.logger.info(`Failed to send batch of to-device messages. Will retry in ${retryDelay}ms`, e);
|
||||
this.retryTimeout = setTimeout(this.sendQueue, retryDelay);
|
||||
} finally {
|
||||
this.sending = false;
|
||||
@@ -133,7 +136,7 @@ export class ToDeviceMessageQueue {
|
||||
contentMap.getOrCreate(item.userId).set(item.deviceId, item.payload);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
this.logger.info(
|
||||
`Sending batch of ${batch.batch.length} to-device messages with ID ${batch.id} and txnId ${batch.txnId}`,
|
||||
);
|
||||
|
||||
@@ -146,7 +149,7 @@ export class ToDeviceMessageQueue {
|
||||
*/
|
||||
private onResumedSync = (state: SyncState | null, oldState: SyncState | null): void => {
|
||||
if (state === SyncState.Syncing && oldState !== SyncState.Syncing) {
|
||||
logger.info(`Resuming queue after resumed sync`);
|
||||
this.logger.info(`Resuming queue after resumed sync`);
|
||||
this.sendQueue();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -462,7 +462,7 @@ export class AutoDiscovery {
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status !== 200) {
|
||||
return {
|
||||
raw: {},
|
||||
action: AutoDiscoveryAction.FAIL_PROMPT,
|
||||
|
||||
+195
-139
@@ -52,7 +52,7 @@ import {
|
||||
type GroupCallEventHandlerEventHandlerMap,
|
||||
} from "./webrtc/groupCallEventHandler.ts";
|
||||
import * as utils from "./utils.ts";
|
||||
import { deepCompare, defer, noUnsafeEventProps, type QueryDict, replaceParam, safeSet, sleep } from "./utils.ts";
|
||||
import { deepCompare, noUnsafeEventProps, type QueryDict, replaceParam, safeSet, sleep } from "./utils.ts";
|
||||
import { Direction, EventTimeline } from "./models/event-timeline.ts";
|
||||
import { type IActionsObject, PushProcessor } from "./pushprocessor.ts";
|
||||
import { AutoDiscovery, type AutoDiscoveryAction } from "./autodiscovery.ts";
|
||||
@@ -87,7 +87,12 @@ import { type IIdentityServerProvider } from "./@types/IIdentityServerProvider.t
|
||||
import { type MatrixScheduler } from "./scheduler.ts";
|
||||
import { type BeaconEvent, type BeaconEventHandlerMap } from "./models/beacon.ts";
|
||||
import { type AuthDict } from "./interactive-auth.ts";
|
||||
import { type IMinimalEvent, type IRoomEvent, type IStateEvent } from "./sync-accumulator.ts";
|
||||
import {
|
||||
type IMinimalEvent,
|
||||
type IRoomEvent,
|
||||
type IStateEvent,
|
||||
type ReceivedToDeviceMessage,
|
||||
} from "./sync-accumulator.ts";
|
||||
import type { EventTimelineSet } from "./models/event-timeline-set.ts";
|
||||
import * as ContentHelpers from "./content-helpers.ts";
|
||||
import {
|
||||
@@ -207,7 +212,7 @@ import {
|
||||
import { M_BEACON_INFO, type MBeaconInfoEventContent } from "./@types/beacon.ts";
|
||||
import { NamespacedValue, UnstableValue } from "./NamespacedValue.ts";
|
||||
import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue.ts";
|
||||
import { type ToDeviceBatch } from "./models/ToDeviceMessage.ts";
|
||||
import { type ToDeviceBatch, type ToDevicePayload } from "./models/ToDeviceMessage.ts";
|
||||
import { IgnoredInvites } from "./models/invites-ignorer.ts";
|
||||
import { type UIARequest } from "./@types/uia.ts";
|
||||
import { type LocalNotificationSettings } from "./@types/local_notifications.ts";
|
||||
@@ -360,7 +365,7 @@ export interface ICreateClientOpts {
|
||||
* to all requests with this client. Useful for application services which require
|
||||
* `?user_id=`.
|
||||
*/
|
||||
queryParams?: Record<string, string>;
|
||||
queryParams?: QueryDict;
|
||||
|
||||
/**
|
||||
* Encryption key used for encrypting sensitive data (such as e2ee keys) in {@link ICreateClientOpts#cryptoStore}.
|
||||
@@ -883,76 +888,6 @@ interface IWhoamiResponse {
|
||||
const EVENT_ID_PREFIX = "$";
|
||||
|
||||
export enum ClientEvent {
|
||||
Sync = "sync",
|
||||
Event = "event",
|
||||
ToDeviceEvent = "toDeviceEvent",
|
||||
AccountData = "accountData",
|
||||
Room = "Room",
|
||||
DeleteRoom = "deleteRoom",
|
||||
SyncUnexpectedError = "sync.unexpectedError",
|
||||
ClientWellKnown = "WellKnown.client",
|
||||
ReceivedVoipEvent = "received_voip_event",
|
||||
UndecryptableToDeviceEvent = "toDeviceEvent.undecryptable",
|
||||
TurnServers = "turnServers",
|
||||
TurnServersError = "turnServers.error",
|
||||
}
|
||||
|
||||
type RoomEvents =
|
||||
| RoomEvent.Name
|
||||
| RoomEvent.Redaction
|
||||
| RoomEvent.RedactionCancelled
|
||||
| RoomEvent.Receipt
|
||||
| RoomEvent.Tags
|
||||
| RoomEvent.LocalEchoUpdated
|
||||
| RoomEvent.HistoryImportedWithinTimeline
|
||||
| RoomEvent.AccountData
|
||||
| RoomEvent.MyMembership
|
||||
| RoomEvent.Timeline
|
||||
| RoomEvent.TimelineReset;
|
||||
|
||||
type RoomStateEvents =
|
||||
| RoomStateEvent.Events
|
||||
| RoomStateEvent.Members
|
||||
| RoomStateEvent.NewMember
|
||||
| RoomStateEvent.Update
|
||||
| RoomStateEvent.Marker;
|
||||
|
||||
type CryptoEvents = (typeof CryptoEvent)[keyof typeof CryptoEvent];
|
||||
|
||||
type MatrixEventEvents = MatrixEventEvent.Decrypted | MatrixEventEvent.Replaced | MatrixEventEvent.VisibilityChange;
|
||||
|
||||
type RoomMemberEvents =
|
||||
| RoomMemberEvent.Name
|
||||
| RoomMemberEvent.Typing
|
||||
| RoomMemberEvent.PowerLevel
|
||||
| RoomMemberEvent.Membership;
|
||||
|
||||
type UserEvents =
|
||||
| UserEvent.AvatarUrl
|
||||
| UserEvent.DisplayName
|
||||
| UserEvent.Presence
|
||||
| UserEvent.CurrentlyActive
|
||||
| UserEvent.LastPresenceTs;
|
||||
|
||||
export type EmittedEvents =
|
||||
| ClientEvent
|
||||
| RoomEvents
|
||||
| RoomStateEvents
|
||||
| CryptoEvents
|
||||
| MatrixEventEvents
|
||||
| RoomMemberEvents
|
||||
| UserEvents
|
||||
| CallEvent // re-emitted by call.ts using Object.values
|
||||
| CallEventHandlerEvent.Incoming
|
||||
| GroupCallEventHandlerEvent.Incoming
|
||||
| GroupCallEventHandlerEvent.Outgoing
|
||||
| GroupCallEventHandlerEvent.Ended
|
||||
| GroupCallEventHandlerEvent.Participants
|
||||
| HttpApiEvent.SessionLoggedOut
|
||||
| HttpApiEvent.NoConsent
|
||||
| BeaconEvent;
|
||||
|
||||
export type ClientEventHandlerMap = {
|
||||
/**
|
||||
* Fires whenever the SDK's syncing state is updated. The state can be one of:
|
||||
* <ul>
|
||||
@@ -1035,13 +970,15 @@ export type ClientEventHandlerMap = {
|
||||
* trying to sync after stopClient has been called.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param state - An enum representing the syncing state. One of "PREPARED",
|
||||
* The payloads consits of the following 3 parameters:
|
||||
*
|
||||
* - state - An enum representing the syncing state. One of "PREPARED",
|
||||
* "SYNCING", "ERROR", "STOPPED".
|
||||
*
|
||||
* @param prevState - An enum representing the previous syncing state.
|
||||
* - prevState - An enum representing the previous syncing state.
|
||||
* One of "PREPARED", "SYNCING", "ERROR", "STOPPED" <b>or null</b>.
|
||||
*
|
||||
* @param data - Data about this transition.
|
||||
* - data - Data about this transition.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
@@ -1061,14 +998,14 @@ export type ClientEventHandlerMap = {
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
[ClientEvent.Sync]: (state: SyncState, prevState: SyncState | null, data?: ISyncStateData) => void;
|
||||
Sync = "sync",
|
||||
/**
|
||||
* Fires whenever the SDK receives a new event.
|
||||
* <p>
|
||||
* This is only fired for live events received via /sync - it is not fired for
|
||||
* events received over context, search, or pagination APIs.
|
||||
*
|
||||
* @param event - The matrix event which caused this event to fire.
|
||||
* The payload is the matrix event which caused this event to fire.
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("event", function(event){
|
||||
@@ -1076,10 +1013,10 @@ export type ClientEventHandlerMap = {
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
[ClientEvent.Event]: (event: MatrixEvent) => void;
|
||||
/**
|
||||
Event = "event",
|
||||
/** @deprecated Use {@link ReceivedToDeviceMessage}.
|
||||
* Fires whenever the SDK receives a new to-device event.
|
||||
* @param event - The matrix event which caused this event to fire.
|
||||
* The payload is the matrix event ({@link MatrixEvent}) which caused this event to fire.
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("toDeviceEvent", function(event){
|
||||
@@ -1087,8 +1024,69 @@ export type ClientEventHandlerMap = {
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
[ClientEvent.ToDeviceEvent]: (event: MatrixEvent) => void;
|
||||
ToDeviceEvent = "toDeviceEvent",
|
||||
/**
|
||||
* Fires whenever the SDK receives a new (potentially decrypted) to-device message.
|
||||
* The payload is the to-device message and the encryption info for that message ({@link ReceivedToDeviceMessage}).
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("receivedToDeviceMessage", function(payload){
|
||||
* const { message, encryptionInfo } = payload;
|
||||
* var claimed_sender = encryptionInfo ? encryptionInfo.sender : message.sender;
|
||||
* var isVerified = encryptionInfo ? encryptionInfo.verified : false;
|
||||
* var type = message.type;
|
||||
* });
|
||||
*/
|
||||
ReceivedToDeviceMessage = "receivedToDeviceMessage",
|
||||
/**
|
||||
* Fires whenever new user-scoped account_data is added.
|
||||
* The payload is a pair of event ({@link MatrixEvent}) describing the account_data just added, and the previous event, if known:
|
||||
* - event: The event describing the account_data just added
|
||||
* - oldEvent: The previous account data, if known.
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("accountData", function(event, oldEvent){
|
||||
* myAccountData[event.type] = event.content;
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
AccountData = "accountData",
|
||||
/**
|
||||
* Fires whenever a new Room is added. This will fire when you are invited to a
|
||||
* room, as well as when you join a room. <strong>This event is experimental and
|
||||
* may change.</strong>
|
||||
*
|
||||
* The payload is the newly created room, fully populated.
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("Room", function(room){
|
||||
* var roomId = room.roomId;
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
Room = "Room",
|
||||
/**
|
||||
* Fires whenever a Room is removed. This will fire when you forget a room.
|
||||
* <strong>This event is experimental and may change.</strong>
|
||||
* The payload is the roomId of the deleted room.
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("deleteRoom", function(roomId){
|
||||
* // update UI from getRooms()
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
DeleteRoom = "deleteRoom",
|
||||
SyncUnexpectedError = "sync.unexpectedError",
|
||||
/**
|
||||
* Fires when the client .well-known info is fetched.
|
||||
* The payload is the JSON object (see {@link IClientWellKnown}) returned by the server
|
||||
*/
|
||||
ClientWellKnown = "WellKnown.client",
|
||||
ReceivedVoipEvent = "received_voip_event",
|
||||
/**
|
||||
* @deprecated This event is not supported anymore.
|
||||
*
|
||||
* Fires if a to-device event is received that cannot be decrypted.
|
||||
* Encrypted to-device events will (generally) use plain Olm encryption,
|
||||
* in which case decryption failures are fatal: the event will never be
|
||||
@@ -1097,52 +1095,78 @@ export type ClientEventHandlerMap = {
|
||||
*
|
||||
* An undecryptable to-device event is therefore likely to indicate problems.
|
||||
*
|
||||
* @param event - The undecyptable to-device event
|
||||
* The payload is the undecyptable to-device event
|
||||
*/
|
||||
UndecryptableToDeviceEvent = "toDeviceEvent.undecryptable",
|
||||
TurnServers = "turnServers",
|
||||
TurnServersError = "turnServers.error",
|
||||
}
|
||||
|
||||
type RoomEvents =
|
||||
| RoomEvent.Name
|
||||
| RoomEvent.Redaction
|
||||
| RoomEvent.RedactionCancelled
|
||||
| RoomEvent.Receipt
|
||||
| RoomEvent.Tags
|
||||
| RoomEvent.LocalEchoUpdated
|
||||
| RoomEvent.HistoryImportedWithinTimeline
|
||||
| RoomEvent.AccountData
|
||||
| RoomEvent.MyMembership
|
||||
| RoomEvent.Timeline
|
||||
| RoomEvent.TimelineReset;
|
||||
|
||||
type RoomStateEvents =
|
||||
| RoomStateEvent.Events
|
||||
| RoomStateEvent.Members
|
||||
| RoomStateEvent.NewMember
|
||||
| RoomStateEvent.Update
|
||||
| RoomStateEvent.Marker;
|
||||
|
||||
type CryptoEvents = (typeof CryptoEvent)[keyof typeof CryptoEvent];
|
||||
|
||||
type MatrixEventEvents = MatrixEventEvent.Decrypted | MatrixEventEvent.Replaced | MatrixEventEvent.VisibilityChange;
|
||||
|
||||
type RoomMemberEvents =
|
||||
| RoomMemberEvent.Name
|
||||
| RoomMemberEvent.Typing
|
||||
| RoomMemberEvent.PowerLevel
|
||||
| RoomMemberEvent.Membership;
|
||||
|
||||
type UserEvents =
|
||||
| UserEvent.AvatarUrl
|
||||
| UserEvent.DisplayName
|
||||
| UserEvent.Presence
|
||||
| UserEvent.CurrentlyActive
|
||||
| UserEvent.LastPresenceTs;
|
||||
|
||||
export type EmittedEvents =
|
||||
| ClientEvent
|
||||
| RoomEvents
|
||||
| RoomStateEvents
|
||||
| CryptoEvents
|
||||
| MatrixEventEvents
|
||||
| RoomMemberEvents
|
||||
| UserEvents
|
||||
| CallEvent // re-emitted by call.ts using Object.values
|
||||
| CallEventHandlerEvent.Incoming
|
||||
| GroupCallEventHandlerEvent.Incoming
|
||||
| GroupCallEventHandlerEvent.Outgoing
|
||||
| GroupCallEventHandlerEvent.Ended
|
||||
| GroupCallEventHandlerEvent.Participants
|
||||
| HttpApiEvent.SessionLoggedOut
|
||||
| HttpApiEvent.NoConsent
|
||||
| BeaconEvent;
|
||||
|
||||
export type ClientEventHandlerMap = {
|
||||
[ClientEvent.Sync]: (state: SyncState, prevState: SyncState | null, data?: ISyncStateData) => void;
|
||||
[ClientEvent.Event]: (event: MatrixEvent) => void;
|
||||
[ClientEvent.ToDeviceEvent]: (event: MatrixEvent) => void;
|
||||
[ClientEvent.ReceivedToDeviceMessage]: (payload: ReceivedToDeviceMessage) => void;
|
||||
[ClientEvent.UndecryptableToDeviceEvent]: (event: MatrixEvent) => void;
|
||||
/**
|
||||
* Fires whenever new user-scoped account_data is added.
|
||||
* @param event - The event describing the account_data just added
|
||||
* @param event - The previous account data, if known.
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("accountData", function(event, oldEvent){
|
||||
* myAccountData[event.type] = event.content;
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
[ClientEvent.AccountData]: (event: MatrixEvent, lastEvent?: MatrixEvent) => void;
|
||||
/**
|
||||
* Fires whenever a new Room is added. This will fire when you are invited to a
|
||||
* room, as well as when you join a room. <strong>This event is experimental and
|
||||
* may change.</strong>
|
||||
* @param room - The newly created, fully populated room.
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("Room", function(room){
|
||||
* var roomId = room.roomId;
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
[ClientEvent.Room]: (room: Room) => void;
|
||||
/**
|
||||
* Fires whenever a Room is removed. This will fire when you forget a room.
|
||||
* <strong>This event is experimental and may change.</strong>
|
||||
* @param roomId - The deleted room ID.
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("deleteRoom", function(roomId){
|
||||
* // update UI from getRooms()
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
[ClientEvent.DeleteRoom]: (roomId: string) => void;
|
||||
[ClientEvent.SyncUnexpectedError]: (error: Error) => void;
|
||||
/**
|
||||
* Fires when the client .well-known info is fetched.
|
||||
*
|
||||
* @param data - The JSON object returned by the server
|
||||
*/
|
||||
[ClientEvent.ClientWellKnown]: (data: IClientWellKnown) => void;
|
||||
[ClientEvent.ReceivedVoipEvent]: (event: MatrixEvent) => void;
|
||||
[ClientEvent.TurnServers]: (servers: ITurnServer[]) => void;
|
||||
@@ -1341,9 +1365,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
// NB. We initialise MatrixRTC whether we have call support or not: this is just
|
||||
// the underlying session management and doesn't use any actual media capabilities
|
||||
this.matrixRTC = new MatrixRTCSessionManager(this);
|
||||
this.matrixRTC = new MatrixRTCSessionManager(this.logger, this);
|
||||
|
||||
this.serverCapabilitiesService = new ServerCapabilities(this.http);
|
||||
this.serverCapabilitiesService = new ServerCapabilities(this.logger, this.http);
|
||||
|
||||
this.on(ClientEvent.Sync, this.fixupRoomNotifications);
|
||||
|
||||
@@ -1365,7 +1389,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
this.roomNameGenerator = opts.roomNameGenerator;
|
||||
|
||||
this.toDeviceMessageQueue = new ToDeviceMessageQueue(this);
|
||||
this.toDeviceMessageQueue = new ToDeviceMessageQueue(this, this.logger);
|
||||
|
||||
// The SDK doesn't really provide a clean way for events to recalculate the push
|
||||
// actions for themselves, so we have to kinda help them out when they are encrypted.
|
||||
@@ -1482,6 +1506,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
return this.canResetTimelineCallback(roomId);
|
||||
},
|
||||
logger: this.logger.getChild("sync"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1527,9 +1552,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
/**
|
||||
* Clear any data out of the persistent stores used by the client.
|
||||
*
|
||||
* @param args.cryptoDatabasePrefix - The database name to use for indexeddb, defaults to 'matrix-js-sdk'.
|
||||
* @returns Promise which resolves when the stores have been cleared.
|
||||
*/
|
||||
public clearStores(): Promise<void> {
|
||||
public clearStores(
|
||||
args: {
|
||||
cryptoDatabasePrefix?: string;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
if (this.clientRunning) {
|
||||
throw new Error("Cannot clear stores while client is running");
|
||||
}
|
||||
@@ -1552,8 +1582,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return;
|
||||
}
|
||||
for (const dbname of [
|
||||
`${RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto`,
|
||||
`${RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto-meta`,
|
||||
`${args.cryptoDatabasePrefix ?? RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto`,
|
||||
`${args.cryptoDatabasePrefix ?? RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto-meta`,
|
||||
]) {
|
||||
const prom = new Promise((resolve, reject) => {
|
||||
this.logger.info(`Removing IndexedDB instance ${dbname}`);
|
||||
@@ -1901,6 +1931,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* ensuring that only one `MatrixClient` issue is instantiated at a time.
|
||||
*
|
||||
* @param args.useIndexedDB - True to use an indexeddb store, false to use an in-memory store. Defaults to 'true'.
|
||||
* @param args.cryptoDatabasePrefix - The database name to use for indexeddb, defaults to 'matrix-js-sdk'.
|
||||
* Unused if useIndexedDB is 'false'.
|
||||
* @param args.storageKey - A key with which to encrypt the indexeddb store. If provided, it must be exactly
|
||||
* 32 bytes of data, and must be the same each time the client is initialised for a given device.
|
||||
* If both this and `storagePassword` are unspecified, the store will be unencrypted.
|
||||
@@ -1914,6 +1946,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
public async initRustCrypto(
|
||||
args: {
|
||||
useIndexedDB?: boolean;
|
||||
cryptoDatabasePrefix?: string;
|
||||
storageKey?: Uint8Array;
|
||||
storagePassword?: string;
|
||||
} = {},
|
||||
@@ -1950,7 +1983,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
deviceId: deviceId,
|
||||
secretStorage: this.secretStorage,
|
||||
cryptoCallbacks: this.cryptoCallbacks,
|
||||
storePrefix: args.useIndexedDB === false ? null : RUST_SDK_STORE_PREFIX,
|
||||
storePrefix: args.useIndexedDB === false ? null : (args.cryptoDatabasePrefix ?? RUST_SDK_STORE_PREFIX),
|
||||
storeKey: args.storageKey,
|
||||
storePassphrase: args.storagePassword,
|
||||
|
||||
@@ -2191,7 +2224,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
if (existingData && deepCompare(existingData.event.content, content)) return {};
|
||||
|
||||
// Create a promise which will resolve when the update is received
|
||||
const updatedDefer = defer<void>();
|
||||
const updatedResolvers = Promise.withResolvers<void>();
|
||||
function accountDataListener(event: MatrixEvent): void {
|
||||
// Note that we cannot safely check that the content matches what we expected, because there is a race:
|
||||
// * We set the new content
|
||||
@@ -2203,13 +2236,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
//
|
||||
// Anyway, what we *shouldn't* do is get stuck in a loop. I think the best we can do is check that the event
|
||||
// type matches.
|
||||
if (event.getType() === eventType) updatedDefer.resolve();
|
||||
if (event.getType() === eventType) updatedResolvers.resolve();
|
||||
}
|
||||
this.addListener(ClientEvent.AccountData, accountDataListener);
|
||||
|
||||
try {
|
||||
const result = await retryNetworkOperation(5, () => this.setAccountDataRaw(eventType, content));
|
||||
await updatedDefer.promise;
|
||||
await updatedResolvers.promise;
|
||||
return result;
|
||||
} finally {
|
||||
this.removeListener(ClientEvent.AccountData, accountDataListener);
|
||||
@@ -5173,24 +5206,24 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
} else if (!hasDontNotifyRule) {
|
||||
// Remove the existing one before setting the mute push rule
|
||||
// This is a workaround to SYN-590 (Push rule update fails)
|
||||
const deferred = utils.defer();
|
||||
const doneResolvers = Promise.withResolvers<void>();
|
||||
this.deletePushRule(scope, PushRuleKind.RoomSpecific, roomPushRule.rule_id)
|
||||
.then(() => {
|
||||
this.addPushRule(scope, PushRuleKind.RoomSpecific, roomId, {
|
||||
actions: [PushRuleActionName.DontNotify],
|
||||
})
|
||||
.then(() => {
|
||||
deferred.resolve();
|
||||
doneResolvers.resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
deferred.reject(err);
|
||||
doneResolvers.reject(err);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
deferred.reject(err);
|
||||
doneResolvers.reject(err);
|
||||
});
|
||||
|
||||
promise = deferred.promise;
|
||||
promise = doneResolvers.promise;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7295,7 +7328,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*/
|
||||
public setPushRules(rules: IPushRules): void {
|
||||
// Fix-up defaults, if applicable.
|
||||
this.pushRules = PushProcessor.rewriteDefaultRules(rules, this.getUserId()!);
|
||||
this.pushRules = PushProcessor.rewriteDefaultRules(this.logger, rules, this.getUserId()!);
|
||||
// Pre-calculate any necessary caches.
|
||||
this.pushProcessor.updateCachedPushRuleKeys(this.pushRules);
|
||||
}
|
||||
@@ -7942,6 +7975,29 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return this.http.authedRequest(Method.Put, path, undefined, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* This will encrypt the payload for all devices in the list and will queue it.
|
||||
* The type of the sent to-device message will be `m.room.encrypted`.
|
||||
* @param eventType - The type of event to send
|
||||
* @param devices - The list of devices to send the event to.
|
||||
* @param payload - The payload to send. This will be encrypted.
|
||||
* @returns Promise which resolves once queued there is no error feedback when sending fails.
|
||||
*/
|
||||
public async encryptAndSendToDevice(
|
||||
eventType: string,
|
||||
devices: { userId: string; deviceId: string }[],
|
||||
payload: ToDevicePayload,
|
||||
): Promise<void> {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("Cannot encrypt to device event, your client does not support encryption.");
|
||||
}
|
||||
const batch = await this.cryptoBackend.encryptToDeviceMessages(eventType, devices, payload);
|
||||
|
||||
// TODO The batch mechanism removes all possibility to get error feedbacks..
|
||||
// We might want instead to do the API call directly and pass the errors back.
|
||||
await this.queueToDevice(batch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends events directly to specific devices using Matrix's to-device
|
||||
* messaging system. The batch will be split up into appropriately sized
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator.ts";
|
||||
import type { IDeviceLists, IToDeviceEvent, ReceivedToDeviceMessage } from "../sync-accumulator.ts";
|
||||
import { type IClearEvent, type MatrixEvent } from "../models/event.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { type CryptoApi, type DecryptionFailureCode, type ImportRoomKeysOpts } from "../crypto-api/index.ts";
|
||||
@@ -96,9 +96,11 @@ export interface SyncCryptoCallbacks {
|
||||
* messages, rather than the results of any decryption attempts.
|
||||
*
|
||||
* @param events - the received to-device messages
|
||||
* @returns A list of preprocessed to-device messages.
|
||||
* @returns A list of preprocessed to-device messages. This will not map 1:1 to the input list, as some messages may be invalid or
|
||||
* failed to decrypt, and so will be omitted from the output list.
|
||||
*
|
||||
*/
|
||||
preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]>;
|
||||
preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<ReceivedToDeviceMessage[]>;
|
||||
|
||||
/**
|
||||
* Called by the /sync loop when one time key counts and unused fallback key details are received.
|
||||
|
||||
@@ -189,12 +189,14 @@ export type MakeTopicContent = (topic: string | null | undefined, htmlTopic?: st
|
||||
|
||||
export const makeTopicContent: MakeTopicContent = (topic, htmlTopic) => {
|
||||
const renderings = [];
|
||||
if (isProvided(topic)) {
|
||||
renderings.push({ body: topic, mimetype: "text/plain" });
|
||||
}
|
||||
// Put HTML first because clients will render the first type in
|
||||
// the array that they understand
|
||||
if (isProvided(htmlTopic)) {
|
||||
renderings.push({ body: htmlTopic, mimetype: "text/html" });
|
||||
}
|
||||
if (isProvided(topic)) {
|
||||
renderings.push({ body: topic, mimetype: "text/plain" });
|
||||
}
|
||||
return { topic, [M_TOPIC.name]: renderings };
|
||||
};
|
||||
|
||||
|
||||
@@ -1365,6 +1365,27 @@ export interface OwnDeviceKeys {
|
||||
curve25519: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about the encryption of a successfully decrypted to-device message.
|
||||
*/
|
||||
export interface OlmEncryptionInfo {
|
||||
/** The user ID of the event sender, note this is untrusted data unless `isVerified` is true **/
|
||||
sender: string;
|
||||
/**
|
||||
* The device ID of the device that sent us the event.
|
||||
* Note this is untrusted data unless {@link senderVerified} is true.
|
||||
* If the device ID is not known, this will be `null`.
|
||||
**/
|
||||
senderDevice?: string;
|
||||
/** The sender device's public Curve25519 key, base64 encoded **/
|
||||
senderCurve25519KeyBase64: string;
|
||||
/**
|
||||
* If true, this message is guaranteed to be authentic as it is coming from a device belonging to a user that we have verified.
|
||||
* This is the state at the time of decryption (the user could be verified later).
|
||||
*/
|
||||
senderVerified: boolean;
|
||||
}
|
||||
|
||||
export * from "./verification.ts";
|
||||
export type * from "./keybackup.ts";
|
||||
export * from "./recovery-key.ts";
|
||||
|
||||
@@ -204,9 +204,12 @@ export class IndexedDBCryptoStore implements CryptoStore {
|
||||
);
|
||||
|
||||
try {
|
||||
if (!(globalThis.localStorage instanceof Storage)) {
|
||||
throw new Error("localStorage is not available");
|
||||
}
|
||||
return new LocalStorageCryptoStore(globalThis.localStorage);
|
||||
} catch (e) {
|
||||
logger.warn(`unable to open localStorage: falling back to in-memory store: ${e}`);
|
||||
logger.warn(`Unable to open localStorage: falling back to in-memory store: ${e}`);
|
||||
return new MemoryCryptoStore();
|
||||
}
|
||||
})
|
||||
|
||||
+108
-70
@@ -28,6 +28,8 @@ import {
|
||||
type WidgetApiAction,
|
||||
type IWidgetApiResponse,
|
||||
type IWidgetApiResponseData,
|
||||
type IUpdateStateToWidgetActionRequest,
|
||||
UnstableApiVersion,
|
||||
} from "matrix-widget-api";
|
||||
|
||||
import { MatrixEvent, type IEvent, type IContent, EventStatus } from "./models/event.ts";
|
||||
@@ -62,17 +64,6 @@ interface IStateEventRequest {
|
||||
stateKey?: string;
|
||||
}
|
||||
|
||||
export interface OlmDevice {
|
||||
/**
|
||||
* The user ID of the device owner.
|
||||
*/
|
||||
userId: string;
|
||||
/**
|
||||
* The device ID of the device.
|
||||
*/
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
export interface ICapabilities {
|
||||
/**
|
||||
* Event types that this client expects to send.
|
||||
@@ -146,6 +137,7 @@ export type EventHandlerMap = { [RoomWidgetClientEvent.PendingEventsChanged]: ()
|
||||
export class RoomWidgetClient extends MatrixClient {
|
||||
private room?: Room;
|
||||
private readonly widgetApiReady: Promise<void>;
|
||||
private readonly roomStateSynced: Promise<void>;
|
||||
private lifecycle?: AbortController;
|
||||
private syncState: SyncState | null = null;
|
||||
|
||||
@@ -199,6 +191,11 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
};
|
||||
|
||||
this.widgetApiReady = new Promise<void>((resolve) => this.widgetApi.once("ready", resolve));
|
||||
this.roomStateSynced = capabilities.receiveState?.length
|
||||
? new Promise<void>((resolve) =>
|
||||
this.widgetApi.once(`action:${WidgetApiToWidgetAction.UpdateState}`, resolve),
|
||||
)
|
||||
: Promise.resolve();
|
||||
|
||||
// Request capabilities for the functionality this client needs to support
|
||||
if (
|
||||
@@ -251,6 +248,7 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
|
||||
widgetApi.on(`action:${WidgetApiToWidgetAction.SendEvent}`, this.onEvent);
|
||||
widgetApi.on(`action:${WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice);
|
||||
widgetApi.on(`action:${WidgetApiToWidgetAction.UpdateState}`, this.onStateUpdate);
|
||||
|
||||
// Open communication with the host
|
||||
widgetApi.start();
|
||||
@@ -262,6 +260,10 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
if (sendContentLoaded) widgetApi.sendContentLoaded();
|
||||
}
|
||||
|
||||
public async supportUpdateState(): Promise<boolean> {
|
||||
return (await this.widgetApi.getClientVersions()).includes(UnstableApiVersion.MSC2762_UPDATE_STATE);
|
||||
}
|
||||
|
||||
public async startClient(opts: IStartClientOpts = {}): Promise<void> {
|
||||
this.lifecycle = new AbortController();
|
||||
|
||||
@@ -286,27 +288,34 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
|
||||
await this.widgetApiReady;
|
||||
|
||||
// Backfill the requested events
|
||||
// We only get the most recent event for every type + state key combo,
|
||||
// so it doesn't really matter what order we inject them in
|
||||
await Promise.all(
|
||||
this.capabilities.receiveState?.map(async ({ eventType, stateKey }) => {
|
||||
const rawEvents = await this.widgetApi.readStateEvents(eventType, undefined, stateKey, [this.roomId]);
|
||||
const events = rawEvents.map((rawEvent) => new MatrixEvent(rawEvent as Partial<IEvent>));
|
||||
// sync room state:
|
||||
if (await this.supportUpdateState()) {
|
||||
// This will resolve once the client driver has sent us all the allowed room state.
|
||||
await this.roomStateSynced;
|
||||
} else {
|
||||
// Backfill the requested events
|
||||
// We only get the most recent event for every type + state key combo,
|
||||
// so it doesn't really matter what order we inject them in
|
||||
await Promise.all(
|
||||
this.capabilities.receiveState?.map(async ({ eventType, stateKey }) => {
|
||||
const rawEvents = await this.widgetApi.readStateEvents(eventType, undefined, stateKey, [
|
||||
this.roomId,
|
||||
]);
|
||||
const events = rawEvents.map((rawEvent) => new MatrixEvent(rawEvent as Partial<IEvent>));
|
||||
|
||||
if (this.syncApi instanceof SyncApi) {
|
||||
// Passing undefined for `stateAfterEventList` allows will make `injectRoomEvents` run in legacy mode
|
||||
// -> state events in `timelineEventList` will update the state.
|
||||
await this.syncApi.injectRoomEvents(this.room!, undefined, events);
|
||||
} else {
|
||||
await this.syncApi!.injectRoomEvents(this.room!, events); // Sliding Sync
|
||||
}
|
||||
events.forEach((event) => {
|
||||
this.emit(ClientEvent.Event, event);
|
||||
logger.info(`Backfilled event ${event.getId()} ${event.getType()} ${event.getStateKey()}`);
|
||||
});
|
||||
}) ?? [],
|
||||
);
|
||||
if (this.syncApi instanceof SyncApi) {
|
||||
// Passing events as `stateAfterEventList` will update the state.
|
||||
await this.syncApi.injectRoomEvents(this.room!, undefined, events);
|
||||
} else {
|
||||
await this.syncApi!.injectRoomEvents(this.room!, events); // Sliding Sync
|
||||
}
|
||||
events.forEach((event) => {
|
||||
this.emit(ClientEvent.Event, event);
|
||||
logger.info(`Backfilled event ${event.getId()} ${event.getType()} ${event.getStateKey()}`);
|
||||
});
|
||||
}) ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.clientWellKnownPollPeriod !== undefined) {
|
||||
this.clientWellKnownIntervalID = setInterval(() => {
|
||||
@@ -314,9 +323,8 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
}, 1000 * opts.clientWellKnownPollPeriod);
|
||||
this.fetchClientWellKnown();
|
||||
}
|
||||
|
||||
this.setSyncState(SyncState.Syncing);
|
||||
logger.info("Finished backfilling events");
|
||||
logger.info("Finished initial sync");
|
||||
|
||||
this.matrixRTC.start();
|
||||
|
||||
@@ -327,6 +335,7 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
public stopClient(): void {
|
||||
this.widgetApi.off(`action:${WidgetApiToWidgetAction.SendEvent}`, this.onEvent);
|
||||
this.widgetApi.off(`action:${WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice);
|
||||
this.widgetApi.off(`action:${WidgetApiToWidgetAction.UpdateState}`, this.onStateUpdate);
|
||||
|
||||
super.stopClient();
|
||||
this.lifecycle!.abort(); // Signal to other async tasks that the client has stopped
|
||||
@@ -464,6 +473,25 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* by {@link MatrixClient.encryptAndSendToDevice}.
|
||||
*/
|
||||
public async encryptAndSendToDevice(
|
||||
eventType: string,
|
||||
devices: { userId: string; deviceId: string }[],
|
||||
payload: ToDevicePayload,
|
||||
): Promise<void> {
|
||||
// map: user Id → device Id → payload
|
||||
const contentMap: MapWithDefault<string, Map<string, ToDevicePayload>> = new MapWithDefault(() => new Map());
|
||||
for (const { userId, deviceId } of devices) {
|
||||
contentMap.getOrCreate(userId).set(deviceId, payload);
|
||||
}
|
||||
|
||||
await this.widgetApi
|
||||
.sendToDevice(eventType, true, recursiveMapToObject(contentMap))
|
||||
.catch(timeoutToConnectionError);
|
||||
}
|
||||
|
||||
public async sendToDevice(eventType: string, contentMap: SendToDeviceContentMap): Promise<EmptyObject> {
|
||||
await this.widgetApi
|
||||
.sendToDevice(eventType, false, recursiveMapToObject(contentMap))
|
||||
@@ -495,18 +523,6 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
.catch(timeoutToConnectionError);
|
||||
}
|
||||
|
||||
public async encryptAndSendToDevices(userDeviceInfoArr: OlmDevice[], payload: object): Promise<void> {
|
||||
// map: user Id → device Id → payload
|
||||
const contentMap: MapWithDefault<string, Map<string, object>> = new MapWithDefault(() => new Map());
|
||||
for (const { userId, deviceId } of userDeviceInfoArr) {
|
||||
contentMap.getOrCreate(userId).set(deviceId, payload);
|
||||
}
|
||||
|
||||
await this.widgetApi
|
||||
.sendToDevice((payload as { type: string }).type, true, recursiveMapToObject(contentMap))
|
||||
.catch(timeoutToConnectionError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an event to a specific list of devices via the widget API. Optionally encrypts the event.
|
||||
*
|
||||
@@ -604,36 +620,28 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
// Only inject once we have update the txId
|
||||
await this.updateTxId(event);
|
||||
|
||||
// The widget API does not tell us whether a state event came from `state_after` or not so we assume legacy behaviour for now.
|
||||
if (this.syncApi instanceof SyncApi) {
|
||||
// The code will want to be something like:
|
||||
// ```
|
||||
// if (!params.addToTimeline && !params.addToState) {
|
||||
// // Passing undefined for `stateAfterEventList` makes `injectRoomEvents` run in "legacy mode"
|
||||
// // -> state events part of the `timelineEventList` parameter will update the state.
|
||||
// this.injectRoomEvents(this.room!, [], undefined, [event]);
|
||||
// } else {
|
||||
// this.injectRoomEvents(this.room!, undefined, params.addToState ? [event] : [], params.addToTimeline ? [event] : []);
|
||||
// }
|
||||
// ```
|
||||
|
||||
// Passing undefined for `stateAfterEventList` allows will make `injectRoomEvents` run in legacy mode
|
||||
// -> state events in `timelineEventList` will update the state.
|
||||
await this.syncApi.injectRoomEvents(this.room!, [], undefined, [event]);
|
||||
if (await this.supportUpdateState()) {
|
||||
await this.syncApi.injectRoomEvents(this.room!, undefined, [], [event]);
|
||||
} else {
|
||||
// Passing undefined for `stateAfterEventList` will make `injectRoomEvents` run in legacy mode
|
||||
// -> state events in `timelineEventList` will update the state.
|
||||
await this.syncApi.injectRoomEvents(this.room!, [], undefined, [event]);
|
||||
}
|
||||
} else {
|
||||
// The code will want to be something like:
|
||||
// ```
|
||||
// if (!params.addToTimeline && !params.addToState) {
|
||||
// this.injectRoomEvents(this.room!, [], [event]);
|
||||
// } else {
|
||||
// this.injectRoomEvents(this.room!, params.addToState ? [event] : [], params.addToTimeline ? [event] : []);
|
||||
// }
|
||||
// ```
|
||||
await this.syncApi!.injectRoomEvents(this.room!, [], [event]); // Sliding Sync
|
||||
// Sliding Sync
|
||||
if (await this.supportUpdateState()) {
|
||||
await this.syncApi!.injectRoomEvents(this.room!, [], [event]);
|
||||
} else {
|
||||
logger.error(
|
||||
"slididng sync cannot be used in widget mode if the client widget driver does not support the version: 'org.matrix.msc2762_update_state'",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit(ClientEvent.Event, event);
|
||||
this.setSyncState(SyncState.Syncing);
|
||||
logger.info(`Received event ${event.getId()} ${event.getType()} ${event.getStateKey()}`);
|
||||
logger.info(`Received event ${event.getId()} ${event.getType()}`);
|
||||
} else {
|
||||
const { event_id: eventId, room_id: roomId } = ev.detail.data;
|
||||
logger.info(`Received event ${eventId} for a different room ${roomId}; discarding`);
|
||||
@@ -658,6 +666,36 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
await this.ack(ev);
|
||||
};
|
||||
|
||||
private onStateUpdate = async (ev: CustomEvent<IUpdateStateToWidgetActionRequest>): Promise<void> => {
|
||||
ev.preventDefault();
|
||||
if (!(await this.supportUpdateState())) {
|
||||
logger.warn(
|
||||
"received update_state widget action but the widget driver did not claim to support 'org.matrix.msc2762_update_state'",
|
||||
);
|
||||
}
|
||||
for (const rawEvent of ev.detail.data.state) {
|
||||
// Verify the room ID matches, since it's possible for the client to
|
||||
// send us state updates from other rooms if this widget is always
|
||||
// on screen
|
||||
if (rawEvent.room_id === this.roomId) {
|
||||
const event = new MatrixEvent(rawEvent as Partial<IEvent>);
|
||||
|
||||
if (this.syncApi instanceof SyncApi) {
|
||||
await this.syncApi.injectRoomEvents(this.room!, undefined, [event]);
|
||||
} else {
|
||||
// Sliding Sync
|
||||
await this.syncApi!.injectRoomEvents(this.room!, [event]);
|
||||
}
|
||||
logger.info(`Updated state entry ${event.getType()} ${event.getStateKey()} to ${event.getId()}`);
|
||||
} else {
|
||||
const { event_id: eventId, room_id: roomId } = ev.detail.data;
|
||||
logger.info(`Received state entry ${eventId} for a different room ${roomId}; discarding`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.ack(ev);
|
||||
};
|
||||
|
||||
private async watchTurnServers(): Promise<void> {
|
||||
const servers = this.widgetApi.getTurnServers();
|
||||
const onClientStopped = (): void => {
|
||||
|
||||
+13
-11
@@ -99,17 +99,19 @@ export class FilterComponent {
|
||||
* Converts the filter component into the form expected over the wire
|
||||
*/
|
||||
public toJSON(): object {
|
||||
return {
|
||||
types: this.filterJson.types || null,
|
||||
not_types: this.filterJson.not_types || [],
|
||||
rooms: this.filterJson.rooms || null,
|
||||
not_rooms: this.filterJson.not_rooms || [],
|
||||
senders: this.filterJson.senders || null,
|
||||
not_senders: this.filterJson.not_senders || [],
|
||||
contains_url: this.filterJson.contains_url || null,
|
||||
[FILTER_RELATED_BY_SENDERS.name]: this.filterJson[FILTER_RELATED_BY_SENDERS.name] || [],
|
||||
[FILTER_RELATED_BY_REL_TYPES.name]: this.filterJson[FILTER_RELATED_BY_REL_TYPES.name] || [],
|
||||
};
|
||||
return Object.fromEntries(
|
||||
Object.entries({
|
||||
types: this.filterJson.types,
|
||||
not_types: this.filterJson.not_types,
|
||||
rooms: this.filterJson.rooms,
|
||||
not_rooms: this.filterJson.not_rooms,
|
||||
senders: this.filterJson.senders,
|
||||
not_senders: this.filterJson.not_senders,
|
||||
contains_url: this.filterJson.contains_url,
|
||||
[FILTER_RELATED_BY_SENDERS.name]: this.filterJson[FILTER_RELATED_BY_SENDERS.name],
|
||||
[FILTER_RELATED_BY_REL_TYPES.name]: this.filterJson[FILTER_RELATED_BY_REL_TYPES.name],
|
||||
}).filter(([_key, value]) => value),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+39
-68
@@ -18,10 +18,10 @@ limitations under the License.
|
||||
* This is an internal module. See {@link MatrixHttpApi} for the public class.
|
||||
*/
|
||||
|
||||
import { checkObjectHasKeys, encodeParams } from "../utils.ts";
|
||||
import { checkObjectHasKeys, deepCopy, encodeParams } from "../utils.ts";
|
||||
import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { Method } from "./method.ts";
|
||||
import { ConnectionError, MatrixError, TokenRefreshError, TokenRefreshLogoutError } from "./errors.ts";
|
||||
import { ConnectionError, MatrixError, TokenRefreshError } from "./errors.ts";
|
||||
import {
|
||||
HttpApiEvent,
|
||||
type HttpApiEventHandlerMap,
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from "./interface.ts";
|
||||
import { anySignal, parseErrorResponse, timeoutSignal } from "./utils.ts";
|
||||
import { type QueryDict } from "../utils.ts";
|
||||
import { singleAsyncExecution } from "../utils/decorators.ts";
|
||||
import { TokenRefresher, TokenRefreshOutcome } from "./refresh.ts";
|
||||
|
||||
interface TypedResponse<T> extends Response {
|
||||
json(): Promise<T>;
|
||||
@@ -43,14 +43,9 @@ export type ResponseType<T, O extends IHttpOpts> = O extends { json: false }
|
||||
? T
|
||||
: TypedResponse<T>;
|
||||
|
||||
const enum TokenRefreshOutcome {
|
||||
Success = "success",
|
||||
Failure = "failure",
|
||||
Logout = "logout",
|
||||
}
|
||||
|
||||
export class FetchHttpApi<O extends IHttpOpts> {
|
||||
private abortController = new AbortController();
|
||||
private readonly tokenRefresher: TokenRefresher;
|
||||
|
||||
public constructor(
|
||||
private eventEmitter: TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>,
|
||||
@@ -59,6 +54,8 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
checkObjectHasKeys(opts, ["baseUrl", "prefix"]);
|
||||
opts.onlyData = !!opts.onlyData;
|
||||
opts.useAuthorizationHeader = opts.useAuthorizationHeader ?? true;
|
||||
|
||||
this.tokenRefresher = new TokenRefresher(opts);
|
||||
}
|
||||
|
||||
public abort(): void {
|
||||
@@ -113,12 +110,6 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
return this.requestOtherUrl(method, fullUri, body, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise used to block authenticated requests during a token refresh to avoid repeated expected errors.
|
||||
* @private
|
||||
*/
|
||||
private tokenRefreshPromise?: Promise<unknown>;
|
||||
|
||||
/**
|
||||
* Perform an authorised request to the homeserver.
|
||||
* @param method - The HTTP method e.g. "GET".
|
||||
@@ -146,37 +137,49 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
* @returns Rejects with an error if a problem occurred.
|
||||
* This includes network problems and Matrix-specific error JSON.
|
||||
*/
|
||||
public async authedRequest<T>(
|
||||
public authedRequest<T>(
|
||||
method: Method,
|
||||
path: string,
|
||||
queryParams?: QueryDict,
|
||||
queryParams: QueryDict = {},
|
||||
body?: Body,
|
||||
paramOpts: IRequestOpts & { doNotAttemptTokenRefresh?: boolean } = {},
|
||||
paramOpts: IRequestOpts = {},
|
||||
): Promise<ResponseType<T, O>> {
|
||||
if (!queryParams) queryParams = {};
|
||||
return this.doAuthedRequest<T>(1, method, path, queryParams, body, paramOpts);
|
||||
}
|
||||
|
||||
// Wrapper around public method authedRequest to allow for tracking retry attempt counts
|
||||
private async doAuthedRequest<T>(
|
||||
attempt: number,
|
||||
method: Method,
|
||||
path: string,
|
||||
queryParams: QueryDict,
|
||||
body?: Body,
|
||||
paramOpts: IRequestOpts = {},
|
||||
): Promise<ResponseType<T, O>> {
|
||||
// avoid mutating paramOpts so they can be used on retry
|
||||
const opts = { ...paramOpts };
|
||||
const opts = deepCopy(paramOpts);
|
||||
// we have to manually copy the abortSignal over as it is not a plain object
|
||||
opts.abortSignal = paramOpts.abortSignal;
|
||||
|
||||
if (this.opts.accessToken) {
|
||||
// Take a snapshot of the current token state before we start the request so we can reference it if we error
|
||||
const requestSnapshot = await this.tokenRefresher.prepareForRequest();
|
||||
if (requestSnapshot.accessToken) {
|
||||
if (this.opts.useAuthorizationHeader) {
|
||||
if (!opts.headers) {
|
||||
opts.headers = {};
|
||||
}
|
||||
if (!opts.headers.Authorization) {
|
||||
opts.headers.Authorization = "Bearer " + this.opts.accessToken;
|
||||
opts.headers.Authorization = `Bearer ${requestSnapshot.accessToken}`;
|
||||
}
|
||||
if (queryParams.access_token) {
|
||||
delete queryParams.access_token;
|
||||
}
|
||||
} else if (!queryParams.access_token) {
|
||||
queryParams.access_token = this.opts.accessToken;
|
||||
queryParams.access_token = requestSnapshot.accessToken;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Await any ongoing token refresh
|
||||
await this.tokenRefreshPromise;
|
||||
const response = await this.request<T>(method, path, queryParams, body, opts);
|
||||
return response;
|
||||
} catch (error) {
|
||||
@@ -184,27 +187,19 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error.errcode === "M_UNKNOWN_TOKEN" && !opts.doNotAttemptTokenRefresh) {
|
||||
const tokenRefreshPromise = this.tryRefreshToken();
|
||||
this.tokenRefreshPromise = Promise.allSettled([tokenRefreshPromise]);
|
||||
const outcome = await tokenRefreshPromise;
|
||||
|
||||
if (error.errcode === "M_UNKNOWN_TOKEN") {
|
||||
const outcome = await this.tokenRefresher.handleUnknownToken(requestSnapshot, attempt);
|
||||
if (outcome === TokenRefreshOutcome.Success) {
|
||||
// if we got a new token retry the request
|
||||
return this.authedRequest(method, path, queryParams, body, {
|
||||
...paramOpts,
|
||||
doNotAttemptTokenRefresh: true,
|
||||
});
|
||||
return this.doAuthedRequest(attempt + 1, method, path, queryParams, body, paramOpts);
|
||||
}
|
||||
if (outcome === TokenRefreshOutcome.Failure) {
|
||||
throw new TokenRefreshError(error);
|
||||
}
|
||||
// Fall through to SessionLoggedOut handler below
|
||||
}
|
||||
|
||||
// otherwise continue with error handling
|
||||
if (error.errcode == "M_UNKNOWN_TOKEN" && !opts?.inhibitLogoutEmit) {
|
||||
this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, error);
|
||||
if (!opts?.inhibitLogoutEmit) {
|
||||
this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, error);
|
||||
}
|
||||
} else if (error.errcode == "M_CONSENT_NOT_GIVEN") {
|
||||
this.eventEmitter.emit(HttpApiEvent.NoConsent, error.message, error.data.consent_uri);
|
||||
}
|
||||
@@ -213,33 +208,6 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to refresh access tokens.
|
||||
* On success, sets new access and refresh tokens in opts.
|
||||
* @returns Promise that resolves to a boolean - true when token was refreshed successfully
|
||||
*/
|
||||
@singleAsyncExecution
|
||||
private async tryRefreshToken(): Promise<TokenRefreshOutcome> {
|
||||
if (!this.opts.refreshToken || !this.opts.tokenRefreshFunction) {
|
||||
return TokenRefreshOutcome.Logout;
|
||||
}
|
||||
|
||||
try {
|
||||
const { accessToken, refreshToken } = await this.opts.tokenRefreshFunction(this.opts.refreshToken);
|
||||
this.opts.accessToken = accessToken;
|
||||
this.opts.refreshToken = refreshToken;
|
||||
// successfully got new tokens
|
||||
return TokenRefreshOutcome.Success;
|
||||
} catch (error) {
|
||||
this.opts.logger?.warn("Failed to refresh token", error);
|
||||
// If we get a TokenError or MatrixError, we should log out, otherwise assume transient
|
||||
if (error instanceof TokenRefreshLogoutError || error instanceof MatrixError) {
|
||||
return TokenRefreshOutcome.Logout;
|
||||
}
|
||||
return TokenRefreshOutcome.Failure;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a request to the homeserver without any credentials.
|
||||
* @param method - The HTTP method e.g. "GET".
|
||||
@@ -411,9 +379,12 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
? baseUrlWithFallback.slice(0, -1)
|
||||
: baseUrlWithFallback;
|
||||
const url = new URL(baseUrlWithoutTrailingSlash + (prefix ?? this.opts.prefix) + path);
|
||||
if (queryParams) {
|
||||
encodeParams(queryParams, url.searchParams);
|
||||
// If there are any params, encode and append them to the URL.
|
||||
if (this.opts.extraParams || queryParams) {
|
||||
const mergedParams = { ...this.opts.extraParams, ...queryParams };
|
||||
encodeParams(mergedParams, url.searchParams);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
+10
-10
@@ -24,7 +24,7 @@ import {
|
||||
type UploadResponse,
|
||||
} from "./interface.ts";
|
||||
import { MediaPrefix } from "./prefix.ts";
|
||||
import { defer, type QueryDict, removeElement } from "../utils.ts";
|
||||
import { type QueryDict, removeElement } from "../utils.ts";
|
||||
import * as callbacks from "../realtime-callbacks.ts";
|
||||
import { Method } from "./method.ts";
|
||||
import { ConnectionError } from "./errors.ts";
|
||||
@@ -65,14 +65,14 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
|
||||
total: 0,
|
||||
abortController,
|
||||
} as Upload;
|
||||
const deferred = defer<UploadResponse>();
|
||||
const uploadResolvers = Promise.withResolvers<UploadResponse>();
|
||||
|
||||
if (globalThis.XMLHttpRequest) {
|
||||
const xhr = new globalThis.XMLHttpRequest();
|
||||
|
||||
const timeoutFn = function (): void {
|
||||
xhr.abort();
|
||||
deferred.reject(new Error("Timeout"));
|
||||
uploadResolvers.reject(new Error("Timeout"));
|
||||
};
|
||||
|
||||
// set an initial timeout of 30s; we'll advance it each time we get a progress notification
|
||||
@@ -91,16 +91,16 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
|
||||
}
|
||||
|
||||
if (xhr.status >= 400) {
|
||||
deferred.reject(parseErrorResponse(xhr, xhr.responseText));
|
||||
uploadResolvers.reject(parseErrorResponse(xhr, xhr.responseText));
|
||||
} else {
|
||||
deferred.resolve(JSON.parse(xhr.responseText));
|
||||
uploadResolvers.resolve(JSON.parse(xhr.responseText));
|
||||
}
|
||||
} catch (err) {
|
||||
if ((<Error>err).name === "AbortError") {
|
||||
deferred.reject(err);
|
||||
uploadResolvers.reject(err);
|
||||
return;
|
||||
}
|
||||
deferred.reject(new ConnectionError("request failed", <Error>err));
|
||||
uploadResolvers.reject(new ConnectionError("request failed", <Error>err));
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -153,16 +153,16 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
|
||||
.then((response) => {
|
||||
return this.opts.onlyData ? <UploadResponse>response : response.json();
|
||||
})
|
||||
.then(deferred.resolve, deferred.reject);
|
||||
.then(uploadResolvers.resolve, uploadResolvers.reject);
|
||||
}
|
||||
|
||||
// remove the upload from the list on completion
|
||||
upload.promise = deferred.promise.finally(() => {
|
||||
upload.promise = uploadResolvers.promise.finally(() => {
|
||||
removeElement(this.uploads, (elem) => elem === upload);
|
||||
});
|
||||
abortController.signal.addEventListener("abort", () => {
|
||||
removeElement(this.uploads, (elem) => elem === upload);
|
||||
deferred.reject(new DOMException("Aborted", "AbortError"));
|
||||
uploadResolvers.reject(new DOMException("Aborted", "AbortError"));
|
||||
});
|
||||
this.uploads.push(upload);
|
||||
return upload.promise;
|
||||
|
||||
@@ -16,6 +16,7 @@ limitations under the License.
|
||||
|
||||
import { type MatrixError } from "./errors.ts";
|
||||
import { type Logger } from "../logger.ts";
|
||||
import { type QueryDict } from "../utils.ts";
|
||||
|
||||
export type Body = Record<string, any> | BodyInit;
|
||||
|
||||
@@ -24,9 +25,20 @@ export type Body = Record<string, any> | BodyInit;
|
||||
* Unencrypted access and (optional) refresh token
|
||||
*/
|
||||
export type AccessTokens = {
|
||||
/**
|
||||
* The new access token to use for authenticated requests
|
||||
*/
|
||||
accessToken: string;
|
||||
/**
|
||||
* The new refresh token to use for refreshing tokens, optional
|
||||
*/
|
||||
refreshToken?: string;
|
||||
/**
|
||||
* Approximate date when the access token will expire, optional
|
||||
*/
|
||||
expiry?: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Function that performs token refresh using the given refreshToken.
|
||||
@@ -41,7 +53,7 @@ export interface IHttpOpts {
|
||||
baseUrl: string;
|
||||
idBaseUrl?: string;
|
||||
prefix: string;
|
||||
extraParams?: Record<string, string>;
|
||||
extraParams?: QueryDict;
|
||||
|
||||
accessToken?: string;
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixError, TokenRefreshLogoutError } from "./errors.ts";
|
||||
import { type IHttpOpts } from "./interface.ts";
|
||||
import { sleep } from "../utils.ts";
|
||||
|
||||
/**
|
||||
* This is an internal module. See {@link MatrixHttpApi} for the public class.
|
||||
*/
|
||||
|
||||
export const enum TokenRefreshOutcome {
|
||||
Success = "success",
|
||||
Failure = "failure",
|
||||
Logout = "logout",
|
||||
}
|
||||
|
||||
interface Snapshot {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiry?: Date;
|
||||
}
|
||||
|
||||
// If the token expires in less than this time amount of time, we will eagerly refresh it before making the intended request.
|
||||
const REFRESH_IF_TOKEN_EXPIRES_WITHIN_MS = 500;
|
||||
// If we get an unknown token error and the token expires in less than this time amount of time, we will refresh it before making the intended request.
|
||||
// Otherwise, we will error as the token should not have expired yet and we need to avoid retrying indefinitely.
|
||||
const REFRESH_ON_ERROR_IF_TOKEN_EXPIRES_WITHIN_MS = 60 * 1000;
|
||||
|
||||
type Opts = Pick<IHttpOpts, "tokenRefreshFunction" | "logger" | "refreshToken" | "accessToken">;
|
||||
|
||||
/**
|
||||
* This class is responsible for managing the access token and refresh token for authenticated requests.
|
||||
* It will automatically refresh the access token when it is about to expire, and will handle unknown token errors.
|
||||
*/
|
||||
export class TokenRefresher {
|
||||
public constructor(private readonly opts: Opts) {}
|
||||
|
||||
/**
|
||||
* Promise used to block authenticated requests during a token refresh to avoid repeated expected errors.
|
||||
* @private
|
||||
*/
|
||||
private tokenRefreshPromise?: Promise<TokenRefreshOutcome>;
|
||||
|
||||
private latestTokenRefreshExpiry?: Date;
|
||||
|
||||
/**
|
||||
* This function is called before every request to ensure that the access token is valid.
|
||||
* @returns a snapshot containing the access token and other properties which must be passed to the handleUnknownToken
|
||||
* handler if an M_UNKNOWN_TOKEN error is encountered.
|
||||
*/
|
||||
public async prepareForRequest(): Promise<Snapshot> {
|
||||
// Ensure our token is refreshed before we build the headers/params
|
||||
await this.refreshIfNeeded();
|
||||
|
||||
return {
|
||||
accessToken: this.opts.accessToken!,
|
||||
refreshToken: this.opts.refreshToken,
|
||||
expiry: this.latestTokenRefreshExpiry,
|
||||
};
|
||||
}
|
||||
|
||||
private async refreshIfNeeded(): Promise<unknown> {
|
||||
if (this.tokenRefreshPromise) {
|
||||
return this.tokenRefreshPromise;
|
||||
}
|
||||
// If we don't know the token expiry, we can't eagerly refresh
|
||||
if (!this.latestTokenRefreshExpiry) return;
|
||||
|
||||
const expiresIn = this.latestTokenRefreshExpiry.getTime() - Date.now();
|
||||
if (expiresIn <= REFRESH_IF_TOKEN_EXPIRES_WITHIN_MS) {
|
||||
await this._handleUnknownToken();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is called when an M_UNKNOWN_TOKEN error is encountered.
|
||||
* It will attempt to refresh the access token if it is unknown, and will return a TokenRefreshOutcome.
|
||||
* @param snapshot - the snapshot returned by prepareForRequest
|
||||
* @param attempt - the number of attempts made for this request so far
|
||||
* @returns a TokenRefreshOutcome indicating the result of the refresh attempt
|
||||
*/
|
||||
public async handleUnknownToken(snapshot: Snapshot, attempt: number): Promise<TokenRefreshOutcome> {
|
||||
return this._handleUnknownToken(snapshot, attempt);
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
private async _handleUnknownToken(): Promise<TokenRefreshOutcome>;
|
||||
private async _handleUnknownToken(snapshot: Snapshot, attempt: number): Promise<TokenRefreshOutcome>;
|
||||
private async _handleUnknownToken(snapshot?: Snapshot, attempt?: number): Promise<TokenRefreshOutcome> {
|
||||
if (snapshot?.expiry) {
|
||||
// If our token is unknown, but it should not have expired yet, then we should not refresh
|
||||
const expiresIn = snapshot.expiry.getTime() - Date.now();
|
||||
// If it still has plenty of time left on the clock, we assume something else must be wrong and
|
||||
// do not refresh. Otherwise if it's expired, or will soon, we try refreshing.
|
||||
if (expiresIn >= REFRESH_ON_ERROR_IF_TOKEN_EXPIRES_WITHIN_MS) {
|
||||
return TokenRefreshOutcome.Logout;
|
||||
}
|
||||
}
|
||||
|
||||
if (!snapshot || snapshot?.accessToken === this.opts.accessToken) {
|
||||
// If we have a snapshot, but the access token is the same as the current one then a refresh
|
||||
// did not happen behind us but one may be ongoing anyway
|
||||
this.tokenRefreshPromise ??= this.doTokenRefresh(attempt);
|
||||
|
||||
try {
|
||||
return await this.tokenRefreshPromise;
|
||||
} finally {
|
||||
this.tokenRefreshPromise = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// We may end up here if the token was refreshed in the background due to another request
|
||||
return TokenRefreshOutcome.Success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to refresh access tokens.
|
||||
* On success, sets new access and refresh tokens in opts.
|
||||
* @returns Promise that resolves to a boolean - true when token was refreshed successfully
|
||||
*/
|
||||
private async doTokenRefresh(attempt?: number): Promise<TokenRefreshOutcome> {
|
||||
if (!this.opts.refreshToken || !this.opts.tokenRefreshFunction) {
|
||||
this.opts.logger?.error("Unable to refresh token - no refresh token or refresh function");
|
||||
return TokenRefreshOutcome.Logout;
|
||||
}
|
||||
|
||||
if (attempt && attempt > 1) {
|
||||
// Exponential backoff to ensure we don't trash the server, up to 2^5 seconds
|
||||
await sleep(1000 * Math.min(32, 2 ** attempt));
|
||||
}
|
||||
|
||||
try {
|
||||
this.opts.logger?.debug("Attempting to refresh token");
|
||||
const { accessToken, refreshToken, expiry } = await this.opts.tokenRefreshFunction(this.opts.refreshToken);
|
||||
this.opts.accessToken = accessToken;
|
||||
this.opts.refreshToken = refreshToken;
|
||||
this.latestTokenRefreshExpiry = expiry;
|
||||
this.opts.logger?.debug("... token refresh complete, new token expiry:", expiry);
|
||||
|
||||
// successfully got new tokens
|
||||
return TokenRefreshOutcome.Success;
|
||||
} catch (error) {
|
||||
// If we get a TokenError or MatrixError, we should log out, otherwise assume transient
|
||||
if (error instanceof TokenRefreshLogoutError || error instanceof MatrixError) {
|
||||
this.opts.logger?.error("Failed to refresh token", error);
|
||||
return TokenRefreshOutcome.Logout;
|
||||
}
|
||||
|
||||
this.opts.logger?.warn("Failed to refresh token", error);
|
||||
return TokenRefreshOutcome.Failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ limitations under the License.
|
||||
|
||||
import { logger } from "./logger.ts";
|
||||
import { type MatrixClient } from "./client.ts";
|
||||
import { defer, type IDeferred } from "./utils.ts";
|
||||
import { MatrixError } from "./http-api/index.ts";
|
||||
import { type UserIdentifier } from "./@types/auth.ts";
|
||||
|
||||
@@ -262,7 +261,7 @@ export class InteractiveAuth<T> {
|
||||
private data: IAuthData & MatrixError["data"];
|
||||
private emailSid?: string;
|
||||
private requestingEmailToken = false;
|
||||
private attemptAuthDeferred: IDeferred<T> | null = null;
|
||||
private attemptAuthDeferred: PromiseWithResolvers<T> | null = null;
|
||||
private chosenFlow: UIAFlow | null = null;
|
||||
private currentStage: string | null = null;
|
||||
|
||||
@@ -298,7 +297,7 @@ export class InteractiveAuth<T> {
|
||||
public async attemptAuth(): Promise<T> {
|
||||
// This promise will be quite long-lived and will resolve when the
|
||||
// request is authenticated and completes successfully.
|
||||
this.attemptAuthDeferred = defer();
|
||||
this.attemptAuthDeferred = Promise.withResolvers();
|
||||
// pluck the promise out now, as doRequest may clear before we return
|
||||
const promise = this.attemptAuthDeferred.promise;
|
||||
|
||||
|
||||
+21
-3
@@ -34,6 +34,11 @@ export interface Logger extends BaseLogger {
|
||||
/**
|
||||
* Create a child logger.
|
||||
*
|
||||
* This child will use the `methodFactory` of the parent, so any log extensions applied to the parent
|
||||
* at the time of calling `getChild` will be applied to the child as well.
|
||||
* It will NOT apply changes to the parent's `methodFactory` after the child was created.
|
||||
* Those changes need to be applied to the child manually.
|
||||
*
|
||||
* @param namespace - name to add to the current logger to generate the child. Some implementations of `Logger`
|
||||
* use this as a prefix; others use a different mechanism.
|
||||
*/
|
||||
@@ -128,14 +133,24 @@ interface PrefixedLogger extends loglevel.Logger, LoggerWithLogMethod {
|
||||
*
|
||||
* @param prefix Prefix to add to each logged line. If undefined, no prefix will be added.
|
||||
*/
|
||||
function getPrefixedLogger(prefix?: string): LoggerWithLogMethod {
|
||||
function getPrefixedLogger(prefix?: string): PrefixedLogger {
|
||||
const loggerName = DEFAULT_NAMESPACE + (prefix === undefined ? "" : `-${prefix}`);
|
||||
const prefixLogger = loglevel.getLogger(loggerName) as PrefixedLogger;
|
||||
|
||||
if (prefixLogger.getChild === undefined) {
|
||||
// This is a new loglevel Logger which has not been turned into a PrefixedLogger yet.
|
||||
prefixLogger.prefix = prefix;
|
||||
prefixLogger.getChild = (childPrefix): Logger => getPrefixedLogger((prefix ?? "") + childPrefix);
|
||||
prefixLogger.getChild = (childPrefix): Logger => {
|
||||
// create the new child logger
|
||||
const childLogger = getPrefixedLogger((prefix ?? "") + childPrefix);
|
||||
// Assign the methodFactory from the parent logger.
|
||||
// This is useful if we add extensions to the parent logger that modifies
|
||||
// its methodFactory. (An example extension is: storing each log to a rageshake db)
|
||||
childLogger.methodFactory = prefixLogger.methodFactory;
|
||||
// Rebuild the child logger with the new methodFactory.
|
||||
childLogger.rebuild();
|
||||
return childLogger;
|
||||
};
|
||||
prefixLogger.setLevel(loglevel.levels.DEBUG, false);
|
||||
}
|
||||
|
||||
@@ -145,8 +160,11 @@ function getPrefixedLogger(prefix?: string): LoggerWithLogMethod {
|
||||
/**
|
||||
* Drop-in replacement for `console` using {@link https://www.npmjs.com/package/loglevel|loglevel}.
|
||||
* Can be tailored down to specific use cases if needed.
|
||||
*
|
||||
* @deprecated avoid the use of this unless you are the constructor of `MatrixClient`: you should be using the logger
|
||||
* associated with `MatrixClient`.
|
||||
*/
|
||||
export const logger = getPrefixedLogger();
|
||||
export const logger = getPrefixedLogger() as LoggerWithLogMethod;
|
||||
|
||||
/**
|
||||
* A "span" for grouping related log lines together.
|
||||
|
||||
+131
-198
@@ -1,57 +1,54 @@
|
||||
import { type MatrixClient } from "../client.ts";
|
||||
import { logger as rootLogger } from "../logger.ts";
|
||||
import { type MatrixEvent } from "../models/event.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { type Logger, logger as rootLogger } from "../logger.ts";
|
||||
import { type EncryptionConfig } from "./MatrixRTCSession.ts";
|
||||
import { secureRandomBase64Url } from "../randomstring.ts";
|
||||
import { type EncryptionKeysEventContent } from "./types.ts";
|
||||
import { decodeBase64, encodeUnpaddedBase64 } from "../base64.ts";
|
||||
import { type MatrixError, safeGetRetryAfterMs } from "../http-api/errors.ts";
|
||||
import { safeGetRetryAfterMs } from "../http-api/errors.ts";
|
||||
import { type CallMembership } from "./CallMembership.ts";
|
||||
import { EventType } from "../@types/event.ts";
|
||||
const logger = rootLogger.getChild("MatrixRTCSession");
|
||||
|
||||
/**
|
||||
* A type collecting call encryption statistics for a session.
|
||||
*/
|
||||
export type Statistics = {
|
||||
counters: {
|
||||
/**
|
||||
* The number of times we have sent a room event containing encryption keys.
|
||||
*/
|
||||
roomEventEncryptionKeysSent: number;
|
||||
/**
|
||||
* The number of times we have received a room event containing encryption keys.
|
||||
*/
|
||||
roomEventEncryptionKeysReceived: number;
|
||||
};
|
||||
totals: {
|
||||
/**
|
||||
* The total age (in milliseconds) of all room events containing encryption keys that we have received.
|
||||
* We track the total age so that we can later calculate the average age of all keys received.
|
||||
*/
|
||||
roomEventEncryptionKeysReceivedTotalAge: number;
|
||||
};
|
||||
};
|
||||
import { type KeyTransportEventListener, KeyTransportEvents, type IKeyTransport } from "./IKeyTransport.ts";
|
||||
import { isMyMembership, type Statistics } from "./types.ts";
|
||||
import { getParticipantId } from "./utils.ts";
|
||||
import {
|
||||
type EnabledTransports,
|
||||
RoomAndToDeviceEvents,
|
||||
RoomAndToDeviceTransport,
|
||||
} from "./RoomAndToDeviceKeyTransport.ts";
|
||||
|
||||
/**
|
||||
* This interface is for testing and for making it possible to interchange the encryption manager.
|
||||
* @internal
|
||||
*/
|
||||
export interface IEncryptionManager {
|
||||
join(joinConfig: EncryptionConfig | undefined): void;
|
||||
leave(): void;
|
||||
onMembershipsUpdate(oldMemberships: CallMembership[]): void;
|
||||
/**
|
||||
* Process `m.call.encryption_keys` events to track the encryption keys for call participants.
|
||||
* This should be called each time the relevant event is received from a room timeline.
|
||||
* If the event is malformed then it will be logged and ignored.
|
||||
* Joins the encryption manager with the provided configuration.
|
||||
*
|
||||
* @param event the event to process
|
||||
* @param joinConfig - The configuration for joining encryption, or undefined
|
||||
* if no specific configuration is provided.
|
||||
*/
|
||||
join(joinConfig: EncryptionConfig | undefined): void;
|
||||
|
||||
/**
|
||||
* Leaves the encryption manager, cleaning up any associated resources.
|
||||
*/
|
||||
leave(): void;
|
||||
|
||||
/**
|
||||
* Called from the MatrixRTCSession when the memberships in this session updated.
|
||||
*
|
||||
* @param oldMemberships - The previous state of call memberships before the update.
|
||||
*/
|
||||
onMembershipsUpdate(oldMemberships: CallMembership[]): void;
|
||||
|
||||
/**
|
||||
* Retrieves the encryption keys currently managed by the encryption manager.
|
||||
*
|
||||
* @returns A map where the keys are identifiers and the values are arrays of
|
||||
* objects containing encryption keys and their associated timestamps.
|
||||
* @deprecated This method is used internally for testing. It is also used to re-emit keys when there is a change
|
||||
* of RTCSession (matrixKeyProvider#setRTCSession) -Not clear why/when switch RTCSession would occur-. Note that if we switch focus, we do keep the same RTC session,
|
||||
* so no need to re-emit. But it requires the encryption manager to store all keys of all participants, and this is already done
|
||||
* by the key provider. We don't want to add another layer of key storage.
|
||||
*/
|
||||
onCallEncryptionEventReceived(event: MatrixEvent): void;
|
||||
getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>>;
|
||||
statistics: Statistics;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,9 +68,11 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
private get updateEncryptionKeyThrottle(): number {
|
||||
return this.joinConfig?.updateEncryptionKeyThrottle ?? 3_000;
|
||||
}
|
||||
|
||||
private get makeKeyDelay(): number {
|
||||
return this.joinConfig?.makeKeyDelay ?? 3_000;
|
||||
}
|
||||
|
||||
private get useKeyDelay(): number {
|
||||
return this.joinConfig?.useKeyDelay ?? 5_000;
|
||||
}
|
||||
@@ -85,38 +84,43 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
// if it looks like a membership has been updated.
|
||||
private lastMembershipFingerprints: Set<string> | undefined;
|
||||
|
||||
private currentEncryptionKeyIndex = -1;
|
||||
|
||||
public statistics: Statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
private latestGeneratedKeyIndex = -1;
|
||||
private joinConfig: EncryptionConfig | undefined;
|
||||
private logger: Logger;
|
||||
|
||||
public constructor(
|
||||
private client: Pick<MatrixClient, "sendEvent" | "getDeviceId" | "getUserId" | "cancelPendingEvent">,
|
||||
private room: Pick<Room, "roomId">,
|
||||
private userId: string,
|
||||
private deviceId: string,
|
||||
private getMemberships: () => CallMembership[],
|
||||
private transport: IKeyTransport,
|
||||
private statistics: Statistics,
|
||||
private onEncryptionKeysChanged: (
|
||||
keyBin: Uint8Array<ArrayBufferLike>,
|
||||
keyBin: Uint8Array,
|
||||
encryptionKeyIndex: number,
|
||||
participantId: string,
|
||||
) => void,
|
||||
) {}
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
this.logger = (parentLogger ?? rootLogger).getChild(`[EncryptionManager]`);
|
||||
}
|
||||
|
||||
public getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>> {
|
||||
return this.encryptionKeys;
|
||||
}
|
||||
|
||||
private joined = false;
|
||||
|
||||
public join(joinConfig: EncryptionConfig): void {
|
||||
this.joinConfig = joinConfig;
|
||||
this.joined = true;
|
||||
this.manageMediaKeys = this.joinConfig?.manageMediaKeys ?? this.manageMediaKeys;
|
||||
|
||||
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
|
||||
// Deprecate RoomKeyTransport: this can get removed.
|
||||
if (this.transport instanceof RoomAndToDeviceTransport) {
|
||||
this.transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, this.onTransportChanged);
|
||||
}
|
||||
this.transport.start();
|
||||
if (this.joinConfig?.manageMediaKeys) {
|
||||
this.makeNewSenderKey();
|
||||
this.requestSendCurrentKey();
|
||||
@@ -124,15 +128,12 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
}
|
||||
|
||||
public leave(): void {
|
||||
const userId = this.client.getUserId();
|
||||
const deviceId = this.client.getDeviceId();
|
||||
|
||||
if (!userId) throw new Error("No userId");
|
||||
if (!deviceId) throw new Error("No deviceId");
|
||||
// clear our encryption keys as we're done with them now (we'll
|
||||
// make new keys if we rejoin). We leave keys for other participants
|
||||
// as they may still be using the same ones.
|
||||
this.encryptionKeys.set(getParticipantId(userId, deviceId), []);
|
||||
this.encryptionKeys.set(getParticipantId(this.userId, this.deviceId), []);
|
||||
this.transport.off(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
|
||||
this.transport.stop();
|
||||
|
||||
if (this.makeNewKeyTimeout !== undefined) {
|
||||
clearTimeout(this.makeNewKeyTimeout);
|
||||
@@ -146,18 +147,17 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
this.manageMediaKeys = false;
|
||||
this.joined = false;
|
||||
}
|
||||
// TODO deduplicate this method. It also is in MatrixRTCSession.
|
||||
private isMyMembership = (m: CallMembership): boolean =>
|
||||
m.sender === this.client.getUserId() && m.deviceId === this.client.getDeviceId();
|
||||
|
||||
public onMembershipsUpdate(oldMemberships: CallMembership[]): void {
|
||||
if (this.manageMediaKeys && this.joined) {
|
||||
const oldMembershipIds = new Set(
|
||||
oldMemberships.filter((m) => !this.isMyMembership(m)).map(getParticipantIdFromMembership),
|
||||
oldMemberships
|
||||
.filter((m) => !isMyMembership(m, this.userId, this.deviceId))
|
||||
.map(getParticipantIdFromMembership),
|
||||
);
|
||||
const newMembershipIds = new Set(
|
||||
this.getMemberships()
|
||||
.filter((m) => !this.isMyMembership(m))
|
||||
.filter((m) => !isMyMembership(m, this.userId, this.deviceId))
|
||||
.map(getParticipantIdFromMembership),
|
||||
);
|
||||
|
||||
@@ -174,11 +174,11 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
if (this.makeNewKeyTimeout) {
|
||||
// existing rotation in progress, so let it complete
|
||||
} else {
|
||||
logger.debug(`Member(s) have left: queueing sender key rotation`);
|
||||
this.logger.debug(`Member(s) have left: queueing sender key rotation`);
|
||||
this.makeNewKeyTimeout = setTimeout(this.onRotateKeyTimeout, this.makeKeyDelay);
|
||||
}
|
||||
} else if (anyJoined) {
|
||||
logger.debug(`New member(s) have joined: re-sending keys`);
|
||||
this.logger.debug(`New member(s) have joined: re-sending keys`);
|
||||
this.requestSendCurrentKey();
|
||||
} else if (oldFingerprints) {
|
||||
// does it look like any of the members have updated their memberships?
|
||||
@@ -190,7 +190,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
Array.from(oldFingerprints).some((x) => !newFingerprints.has(x)) ||
|
||||
Array.from(newFingerprints).some((x) => !oldFingerprints.has(x));
|
||||
if (candidateUpdates) {
|
||||
logger.debug(`Member(s) have updated/reconnected: re-sending keys to everyone`);
|
||||
this.logger.debug(`Member(s) have updated/reconnected: re-sending keys to everyone`);
|
||||
this.requestSendCurrentKey();
|
||||
}
|
||||
}
|
||||
@@ -204,16 +204,17 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
* @returns The index of the new key
|
||||
*/
|
||||
private makeNewSenderKey(delayBeforeUse = false): number {
|
||||
const userId = this.client.getUserId();
|
||||
const deviceId = this.client.getDeviceId();
|
||||
|
||||
if (!userId) throw new Error("No userId");
|
||||
if (!deviceId) throw new Error("No deviceId");
|
||||
|
||||
const encryptionKey = secureRandomBase64Url(16);
|
||||
const encryptionKeyIndex = this.getNewEncryptionKeyIndex();
|
||||
logger.info("Generated new key at index " + encryptionKeyIndex);
|
||||
this.setEncryptionKey(userId, deviceId, encryptionKeyIndex, encryptionKey, Date.now(), delayBeforeUse);
|
||||
this.logger.info("Generated new key at index " + encryptionKeyIndex);
|
||||
this.setEncryptionKey(
|
||||
this.userId,
|
||||
this.deviceId,
|
||||
encryptionKeyIndex,
|
||||
encryptionKey,
|
||||
Date.now(),
|
||||
delayBeforeUse,
|
||||
);
|
||||
return encryptionKeyIndex;
|
||||
}
|
||||
|
||||
@@ -228,7 +229,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
this.lastEncryptionKeyUpdateRequest &&
|
||||
this.lastEncryptionKeyUpdateRequest + this.updateEncryptionKeyThrottle > Date.now()
|
||||
) {
|
||||
logger.info("Last encryption key event sent too recently: postponing");
|
||||
this.logger.info("Last encryption key event sent too recently: postponing");
|
||||
if (this.keysEventUpdateTimeout === undefined) {
|
||||
this.keysEventUpdateTimeout = setTimeout(
|
||||
() => void this.sendEncryptionKeysEvent(),
|
||||
@@ -264,151 +265,78 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
|
||||
if (!this.joined) return;
|
||||
|
||||
logger.info(`Sending encryption keys event. indexToSend=${indexToSend}`);
|
||||
|
||||
const userId = this.client.getUserId();
|
||||
const deviceId = this.client.getDeviceId();
|
||||
|
||||
if (!userId) throw new Error("No userId");
|
||||
if (!deviceId) throw new Error("No deviceId");
|
||||
|
||||
const myKeys = this.getKeysForParticipant(userId, deviceId);
|
||||
const myKeys = this.getKeysForParticipant(this.userId, this.deviceId);
|
||||
|
||||
if (!myKeys) {
|
||||
logger.warn("Tried to send encryption keys event but no keys found!");
|
||||
this.logger.warn("Tried to send encryption keys event but no keys found!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof indexToSend !== "number" && this.currentEncryptionKeyIndex === -1) {
|
||||
logger.warn("Tried to send encryption keys event but no current key index found!");
|
||||
if (typeof indexToSend !== "number" && this.latestGeneratedKeyIndex === -1) {
|
||||
this.logger.warn("Tried to send encryption keys event but no current key index found!");
|
||||
return;
|
||||
}
|
||||
|
||||
const keyIndexToSend = indexToSend ?? this.currentEncryptionKeyIndex;
|
||||
const keyIndexToSend = indexToSend ?? this.latestGeneratedKeyIndex;
|
||||
|
||||
this.logger.info(
|
||||
`Try sending encryption keys event. keyIndexToSend=${keyIndexToSend} (method parameter: ${indexToSend})`,
|
||||
);
|
||||
const keyToSend = myKeys[keyIndexToSend];
|
||||
|
||||
try {
|
||||
const content: EncryptionKeysEventContent = {
|
||||
keys: [
|
||||
{
|
||||
index: keyIndexToSend,
|
||||
key: encodeUnpaddedBase64(keyToSend),
|
||||
},
|
||||
],
|
||||
device_id: deviceId,
|
||||
call_id: "",
|
||||
sent_ts: Date.now(),
|
||||
};
|
||||
|
||||
this.statistics.counters.roomEventEncryptionKeysSent += 1;
|
||||
|
||||
await this.client.sendEvent(this.room.roomId, EventType.CallEncryptionKeysPrefix, content);
|
||||
|
||||
logger.debug(
|
||||
`Embedded-E2EE-LOG updateEncryptionKeyEvent participantId=${userId}:${deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.currentEncryptionKeyIndex} keyIndexToSend=${keyIndexToSend}`,
|
||||
const targets = this.getMemberships()
|
||||
.filter((membership) => {
|
||||
return membership.sender != undefined;
|
||||
})
|
||||
.map((membership) => {
|
||||
return {
|
||||
userId: membership.sender!,
|
||||
deviceId: membership.deviceId,
|
||||
membershipTs: membership.createdTs(),
|
||||
};
|
||||
});
|
||||
await this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend, targets);
|
||||
this.logger.debug(
|
||||
`sendEncryptionKeysEvent participantId=${this.userId}:${this.deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.latestGeneratedKeyIndex} keyIndexToSend=${keyIndexToSend}`,
|
||||
this.encryptionKeys,
|
||||
);
|
||||
} catch (error) {
|
||||
const matrixError = error as MatrixError;
|
||||
if (matrixError.event) {
|
||||
// cancel the pending event: we'll just generate a new one with our latest
|
||||
// keys when we resend
|
||||
this.client.cancelPendingEvent(matrixError.event);
|
||||
}
|
||||
if (this.keysEventUpdateTimeout === undefined) {
|
||||
const resendDelay = safeGetRetryAfterMs(matrixError, 5000);
|
||||
logger.warn(`Failed to send m.call.encryption_key, retrying in ${resendDelay}`, error);
|
||||
const resendDelay = safeGetRetryAfterMs(error, 5000);
|
||||
this.logger.warn(`Failed to send m.call.encryption_key, retrying in ${resendDelay}`, error);
|
||||
this.keysEventUpdateTimeout = setTimeout(() => void this.sendEncryptionKeysEvent(), resendDelay);
|
||||
} else {
|
||||
logger.info("Not scheduling key resend as another re-send is already pending");
|
||||
this.logger.info("Not scheduling key resend as another re-send is already pending");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public onCallEncryptionEventReceived = (event: MatrixEvent): void => {
|
||||
const userId = event.getSender();
|
||||
const content = event.getContent<EncryptionKeysEventContent>();
|
||||
|
||||
const deviceId = content["device_id"];
|
||||
const callId = content["call_id"];
|
||||
|
||||
if (!userId) {
|
||||
logger.warn(`Received m.call.encryption_keys with no userId: callId=${callId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// We currently only handle callId = "" (which is the default for room scoped calls)
|
||||
if (callId !== "") {
|
||||
logger.warn(
|
||||
`Received m.call.encryption_keys with unsupported callId: userId=${userId}, deviceId=${deviceId}, callId=${callId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content.keys)) {
|
||||
logger.warn(`Received m.call.encryption_keys where keys wasn't an array: callId=${callId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) {
|
||||
// We store our own sender key in the same set along with keys from others, so it's
|
||||
// important we don't allow our own keys to be set by one of these events (apart from
|
||||
// the fact that we don't need it anyway because we already know our own keys).
|
||||
logger.info("Ignoring our own keys event");
|
||||
return;
|
||||
}
|
||||
|
||||
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
|
||||
const age = Date.now() - (typeof content.sent_ts === "number" ? content.sent_ts : event.getTs());
|
||||
this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age;
|
||||
|
||||
for (const key of content.keys) {
|
||||
if (!key) {
|
||||
logger.info("Ignoring false-y key in keys event");
|
||||
continue;
|
||||
}
|
||||
|
||||
const encryptionKey = key.key;
|
||||
const encryptionKeyIndex = key.index;
|
||||
|
||||
if (
|
||||
!encryptionKey ||
|
||||
encryptionKeyIndex === undefined ||
|
||||
encryptionKeyIndex === null ||
|
||||
callId === undefined ||
|
||||
callId === null ||
|
||||
typeof deviceId !== "string" ||
|
||||
typeof callId !== "string" ||
|
||||
typeof encryptionKey !== "string" ||
|
||||
typeof encryptionKeyIndex !== "number"
|
||||
) {
|
||||
logger.warn(
|
||||
`Malformed call encryption_key: userId=${userId}, deviceId=${deviceId}, encryptionKeyIndex=${encryptionKeyIndex} callId=${callId}`,
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Embedded-E2EE-LOG onCallEncryption userId=${userId}:${deviceId} encryptionKeyIndex=${encryptionKeyIndex} age=${age}ms`,
|
||||
this.encryptionKeys,
|
||||
);
|
||||
this.setEncryptionKey(userId, deviceId, encryptionKeyIndex, encryptionKey, event.getTs());
|
||||
}
|
||||
}
|
||||
private onTransportChanged: (enabled: EnabledTransports) => void = () => {
|
||||
this.requestSendCurrentKey();
|
||||
};
|
||||
|
||||
public onNewKeyReceived: KeyTransportEventListener = (userId, deviceId, keyBase64Encoded, index, timestamp) => {
|
||||
this.logger.debug(`Received key over key transport ${userId}:${deviceId} at index ${index}`);
|
||||
this.setEncryptionKey(userId, deviceId, index, keyBase64Encoded, timestamp);
|
||||
};
|
||||
|
||||
private storeLastMembershipFingerprints(): void {
|
||||
this.lastMembershipFingerprints = new Set(
|
||||
this.getMemberships()
|
||||
.filter((m) => !this.isMyMembership(m))
|
||||
.filter((m) => !isMyMembership(m, this.userId, this.deviceId))
|
||||
.map((m) => `${getParticipantIdFromMembership(m)}:${m.createdTs()}`),
|
||||
);
|
||||
}
|
||||
|
||||
private getNewEncryptionKeyIndex(): number {
|
||||
if (this.currentEncryptionKeyIndex === -1) {
|
||||
if (this.latestGeneratedKeyIndex === -1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// maximum key index is 255
|
||||
return (this.currentEncryptionKeyIndex + 1) % 256;
|
||||
return (this.latestGeneratedKeyIndex + 1) % 256;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -433,6 +361,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
timestamp: number,
|
||||
delayBeforeUse = false,
|
||||
): void {
|
||||
this.logger.debug(`Setting encryption key for ${userId}:${deviceId} at index ${encryptionKeyIndex}`);
|
||||
const keyBin = decodeBase64(encryptionKeyString);
|
||||
|
||||
const participantId = getParticipantId(userId, deviceId);
|
||||
@@ -445,7 +374,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
|
||||
if (existingKeyAtIndex) {
|
||||
if (existingKeyAtIndex.timestamp > timestamp) {
|
||||
logger.info(
|
||||
this.logger.info(
|
||||
`Ignoring new key at index ${encryptionKeyIndex} for ${participantId} as it is older than existing known key`,
|
||||
);
|
||||
return;
|
||||
@@ -457,6 +386,15 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
}
|
||||
}
|
||||
|
||||
if (userId === this.userId && deviceId === this.deviceId) {
|
||||
// It is important to already update the latestGeneratedKeyIndex here
|
||||
// NOT IN THE `delayBeforeUse` `setTimeout`.
|
||||
// Even though this is where we call onEncryptionKeysChanged and set the key in EC (and livekit).
|
||||
// It needs to happen here because we will send the key before the timeout has passed and sending
|
||||
// the key will use latestGeneratedKeyIndex as the index. if we update it in the `setTimeout` callback
|
||||
// it will use the wrong index (index - 1)!
|
||||
this.latestGeneratedKeyIndex = encryptionKeyIndex;
|
||||
}
|
||||
participantKeys[encryptionKeyIndex] = {
|
||||
key: keyBin,
|
||||
timestamp,
|
||||
@@ -465,17 +403,12 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
if (delayBeforeUse) {
|
||||
const useKeyTimeout = setTimeout(() => {
|
||||
this.setNewKeyTimeouts.delete(useKeyTimeout);
|
||||
logger.info(`Delayed-emitting key changed event for ${participantId} idx ${encryptionKeyIndex}`);
|
||||
if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) {
|
||||
this.currentEncryptionKeyIndex = encryptionKeyIndex;
|
||||
}
|
||||
this.logger.info(`Delayed-emitting key changed event for ${participantId} index ${encryptionKeyIndex}`);
|
||||
|
||||
this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId);
|
||||
}, this.useKeyDelay);
|
||||
this.setNewKeyTimeouts.add(useKeyTimeout);
|
||||
} else {
|
||||
if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) {
|
||||
this.currentEncryptionKeyIndex = encryptionKeyIndex;
|
||||
}
|
||||
this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId);
|
||||
}
|
||||
}
|
||||
@@ -484,7 +417,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
if (!this.manageMediaKeys) return;
|
||||
|
||||
this.makeNewKeyTimeout = undefined;
|
||||
logger.info("Making new sender key for key rotation");
|
||||
this.logger.info("Making new sender key for key rotation");
|
||||
const newKeyIndex = this.makeNewSenderKey(true);
|
||||
// send immediately: if we're about to start sending with a new key, it's
|
||||
// important we get it out to others as soon as we can.
|
||||
@@ -492,9 +425,9 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
};
|
||||
}
|
||||
|
||||
const getParticipantId = (userId: string, deviceId: string): string => `${userId}:${deviceId}`;
|
||||
function keysEqual(a: Uint8Array | undefined, b: Uint8Array | undefined): boolean {
|
||||
if (a === b) return true;
|
||||
return !!a && !!b && a.length === b.length && a.every((x, i) => x === b[i]);
|
||||
}
|
||||
|
||||
const getParticipantIdFromMembership = (m: CallMembership): string => getParticipantId(m.sender!, m.deviceId);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type ParticipantDeviceInfo } from "./types.ts";
|
||||
|
||||
export enum KeyTransportEvents {
|
||||
ReceivedKeys = "received_keys",
|
||||
NotSupportedError = "not_supported_error",
|
||||
}
|
||||
|
||||
export type KeyTransportEventsHandlerMap = {
|
||||
[KeyTransportEvents.ReceivedKeys]: KeyTransportEventListener;
|
||||
[KeyTransportEvents.NotSupportedError]: () => void;
|
||||
};
|
||||
|
||||
export type KeyTransportEventListener = (
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
keyBase64Encoded: string,
|
||||
index: number,
|
||||
timestamp: number,
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Generic interface for the transport used to share room keys.
|
||||
* Keys can be shared using different transports, e.g. to-device messages or room messages.
|
||||
*/
|
||||
export interface IKeyTransport {
|
||||
/**
|
||||
* Sends the current user media key to the given members.
|
||||
* @param keyBase64Encoded
|
||||
* @param index
|
||||
* @param members - The participants that should get they key
|
||||
*/
|
||||
sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void>;
|
||||
|
||||
/** Subscribe to keys from this transport. */
|
||||
on(event: KeyTransportEvents.ReceivedKeys, listener: KeyTransportEventListener): this;
|
||||
/** Unsubscribe from keys from this transport. */
|
||||
off(event: KeyTransportEvents.ReceivedKeys, listener: KeyTransportEventListener): this;
|
||||
|
||||
/** Once start is called the underlying transport will subscribe to its transport system.
|
||||
* Before start is called this transport will not emit any events.
|
||||
*/
|
||||
start(): void;
|
||||
/** Once stop is called the underlying transport will unsubscribe from its transport system.
|
||||
* After stop is called this transport will not emit any events.
|
||||
*/
|
||||
stop(): void;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { CallMembership } from "./CallMembership.ts";
|
||||
import type { Focus } from "./focus.ts";
|
||||
import type { Status } from "./types.ts";
|
||||
|
||||
export enum MembershipManagerEvent {
|
||||
StatusChanged = "StatusChanged",
|
||||
}
|
||||
|
||||
export type MembershipManagerEventHandlerMap = {
|
||||
[MembershipManagerEvent.StatusChanged]: (prefStatus: Status, newStatus: Status) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* This interface defines what a MembershipManager uses and exposes.
|
||||
* This interface is what we use to write tests and allows changing the actual implementation
|
||||
* without breaking tests because of some internal method renaming.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface IMembershipManager {
|
||||
/**
|
||||
* If we are trying to join, or have successfully joined the session.
|
||||
* It does not reflect if the room state is already configured to represent us being joined.
|
||||
* It only means that the Manager should be trying to connect or to disconnect running.
|
||||
* The Manager is still running right after isJoined becomes false to send the disconnect events.
|
||||
* @returns true if we intend to be participating in the MatrixRTC session
|
||||
* @deprecated This name is confusing and replaced by `isActivated()`. (Returns the same as `isActivated()`)
|
||||
*/
|
||||
isJoined(): boolean;
|
||||
/**
|
||||
* If the manager is activated. This means it tries to do its job to join the call, resend state events...
|
||||
* It does not imply that the room state is already configured to represent being joined.
|
||||
* It means that the Manager tries to connect or is connected. ("the manager is still active")
|
||||
* Once `leave()` is called the manager is not activated anymore but still running until `leave()` resolves.
|
||||
* @returns `true` if we intend to be participating in the MatrixRTC session
|
||||
*/
|
||||
isActivated(): boolean;
|
||||
/**
|
||||
* Get the actual connection status of the manager.
|
||||
*/
|
||||
get status(): Status;
|
||||
/**
|
||||
* The current status while the manager is activated
|
||||
*/
|
||||
/**
|
||||
* Start sending all necessary events to make this user participate in the RTC session.
|
||||
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
|
||||
* @param fociActive the active focus to use in the joined RTC membership event.
|
||||
* @throws can throw if it exceeds a configured maximum retry.
|
||||
*/
|
||||
join(fociPreferred: Focus[], fociActive?: Focus, onError?: (error: unknown) => void): void;
|
||||
/**
|
||||
* Send all necessary events to make this user leave the RTC session.
|
||||
* @param timeout the maximum duration in ms until the promise is forced to resolve.
|
||||
* @returns It resolves with true in case the leave was sent successfully.
|
||||
* It resolves with false in case we hit the timeout before sending successfully.
|
||||
*/
|
||||
leave(timeout?: number): Promise<boolean>;
|
||||
/**
|
||||
* Call this if the MatrixRTC session members have changed.
|
||||
*/
|
||||
onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void>;
|
||||
/**
|
||||
* The used active focus in the currently joined session.
|
||||
* @returns the used active focus in the currently joined session or undefined if not joined.
|
||||
*/
|
||||
getActiveFocus(): Focus | undefined;
|
||||
|
||||
// TypedEventEmitter methods:
|
||||
on(event: MembershipManagerEvent.StatusChanged, listener: (oldStatus: Status, newStatus: Status) => void): this;
|
||||
off(event: MembershipManagerEvent.StatusChanged, listener: (oldStatus: Status, newStatus: Status) => void): this;
|
||||
}
|
||||
@@ -1,411 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { EventType } from "../@types/event.ts";
|
||||
import { UpdateDelayedEventAction } from "../@types/requests.ts";
|
||||
import type { MatrixClient } from "../client.ts";
|
||||
import { HTTPError, MatrixError } from "../http-api/errors.ts";
|
||||
import { logger } from "../logger.ts";
|
||||
import { EventTimeline } from "../models/event-timeline.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { sleep } from "../utils.ts";
|
||||
import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "./CallMembership.ts";
|
||||
import { type Focus } from "./focus.ts";
|
||||
import { isLivekitFocusActive } from "./LivekitFocus.ts";
|
||||
import { type MembershipConfig } from "./MatrixRTCSession.ts";
|
||||
import { type EmptyObject } from "../@types/common.ts";
|
||||
import { type IMembershipManager, type MembershipManagerEvent, Status } from "./types.ts";
|
||||
|
||||
/**
|
||||
* This internal class is used by the MatrixRTCSession to manage the local user's own membership of the session.
|
||||
*
|
||||
* Its responsibitiy is to manage the locals user membership:
|
||||
* - send that sate event
|
||||
* - send the delayed leave event
|
||||
* - update the delayed leave event while connected
|
||||
* - update the state event when it times out (for calls longer than membershipExpiryTimeout ~ 4h)
|
||||
*
|
||||
* It is possible to test this class on its own. The api surface (to use for tests) is
|
||||
* defined in `MembershipManagerInterface`.
|
||||
*
|
||||
* It is recommended to only use this interface for testing to allow replacing this class.
|
||||
*
|
||||
* @internal
|
||||
* @deprecated Use {@link MembershipManager} instead
|
||||
*/
|
||||
export class LegacyMembershipManager implements IMembershipManager {
|
||||
private relativeExpiry: number | undefined;
|
||||
|
||||
private memberEventTimeout?: ReturnType<typeof setTimeout>;
|
||||
|
||||
/**
|
||||
* This is a Foci array that contains the Focus objects this user is aware of and proposes to use.
|
||||
*/
|
||||
private ownFociPreferred?: Focus[];
|
||||
/**
|
||||
* This is a Focus with the specified fields for an ActiveFocus (e.g. LivekitFocusActive for type="livekit")
|
||||
*/
|
||||
private ownFocusActive?: Focus;
|
||||
|
||||
private updateCallMembershipRunning = false;
|
||||
private needCallMembershipUpdate = false;
|
||||
/**
|
||||
* If the server disallows the configured {@link membershipServerSideExpiryTimeout},
|
||||
* this stores a delay that the server does allow.
|
||||
*/
|
||||
private membershipServerSideExpiryTimeoutOverride?: number;
|
||||
private disconnectDelayId: string | undefined;
|
||||
|
||||
private get callMemberEventRetryDelayMinimum(): number {
|
||||
return this.joinConfig?.callMemberEventRetryDelayMinimum ?? 3_000;
|
||||
}
|
||||
private get membershipExpiryTimeout(): number {
|
||||
return this.joinConfig?.membershipExpiryTimeout ?? DEFAULT_EXPIRE_DURATION;
|
||||
}
|
||||
private get membershipServerSideExpiryTimeout(): number {
|
||||
return (
|
||||
this.membershipServerSideExpiryTimeoutOverride ??
|
||||
this.joinConfig?.membershipServerSideExpiryTimeout ??
|
||||
8_000
|
||||
);
|
||||
}
|
||||
private get membershipKeepAlivePeriod(): number {
|
||||
return this.joinConfig?.membershipKeepAlivePeriod ?? 5_000;
|
||||
}
|
||||
private get callMemberEventRetryJitter(): number {
|
||||
return this.joinConfig?.callMemberEventRetryJitter ?? 2_000;
|
||||
}
|
||||
|
||||
public constructor(
|
||||
private joinConfig: MembershipConfig | undefined,
|
||||
private room: Pick<Room, "getLiveTimeline" | "roomId" | "getVersion">,
|
||||
private client: Pick<
|
||||
MatrixClient,
|
||||
| "getUserId"
|
||||
| "getDeviceId"
|
||||
| "sendStateEvent"
|
||||
| "_unstable_sendDelayedStateEvent"
|
||||
| "_unstable_updateDelayedEvent"
|
||||
>,
|
||||
private getOldestMembership: () => CallMembership | undefined,
|
||||
) {}
|
||||
|
||||
public off(
|
||||
event: MembershipManagerEvent.StatusChanged,
|
||||
listener: (oldStatus: Status, newStatus: Status) => void,
|
||||
): this {
|
||||
logger.error("off is not implemented on LegacyMembershipManager");
|
||||
return this;
|
||||
}
|
||||
|
||||
public on(
|
||||
event: MembershipManagerEvent.StatusChanged,
|
||||
listener: (oldStatus: Status, newStatus: Status) => void,
|
||||
): this {
|
||||
logger.error("on is not implemented on LegacyMembershipManager");
|
||||
return this;
|
||||
}
|
||||
|
||||
public isJoined(): boolean {
|
||||
return this.relativeExpiry !== undefined;
|
||||
}
|
||||
public isActivated(): boolean {
|
||||
return this.isJoined();
|
||||
}
|
||||
/**
|
||||
* Unimplemented
|
||||
* @returns Status.Unknown
|
||||
*/
|
||||
public get status(): Status {
|
||||
return Status.Unknown;
|
||||
}
|
||||
|
||||
public join(fociPreferred: Focus[], fociActive?: Focus): void {
|
||||
this.ownFocusActive = fociActive;
|
||||
this.ownFociPreferred = fociPreferred;
|
||||
this.relativeExpiry = this.membershipExpiryTimeout;
|
||||
// We don't wait for this, mostly because it may fail and schedule a retry, so this
|
||||
// function returning doesn't really mean anything at all.
|
||||
void this.triggerCallMembershipEventUpdate();
|
||||
}
|
||||
|
||||
public async leave(timeout: number | undefined = undefined): Promise<boolean> {
|
||||
this.relativeExpiry = undefined;
|
||||
this.ownFocusActive = undefined;
|
||||
|
||||
if (this.memberEventTimeout) {
|
||||
clearTimeout(this.memberEventTimeout);
|
||||
this.memberEventTimeout = undefined;
|
||||
}
|
||||
if (timeout) {
|
||||
// The sleep promise returns the string 'timeout' and the membership update void
|
||||
// A success implies that the membership update was quicker then the timeout.
|
||||
const raceResult = await Promise.race([this.triggerCallMembershipEventUpdate(), sleep(timeout, "timeout")]);
|
||||
return raceResult !== "timeout";
|
||||
} else {
|
||||
await this.triggerCallMembershipEventUpdate();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public async onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void> {
|
||||
const isMyMembership = (m: CallMembership): boolean =>
|
||||
m.sender === this.client.getUserId() && m.deviceId === this.client.getDeviceId();
|
||||
|
||||
if (this.isJoined() && !memberships.some(isMyMembership)) {
|
||||
logger.warn("Missing own membership: force re-join");
|
||||
// TODO: Should this be awaited? And is there anything to tell the focus?
|
||||
return this.triggerCallMembershipEventUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public getActiveFocus(): Focus | undefined {
|
||||
if (this.ownFocusActive) {
|
||||
// A livekit active focus
|
||||
if (isLivekitFocusActive(this.ownFocusActive)) {
|
||||
if (this.ownFocusActive.focus_selection === "oldest_membership") {
|
||||
const oldestMembership = this.getOldestMembership();
|
||||
return oldestMembership?.getPreferredFoci()[0];
|
||||
}
|
||||
} else {
|
||||
logger.warn("Unknown own ActiveFocus type. This makes it impossible to connect to an SFU.");
|
||||
}
|
||||
} else {
|
||||
// We do not understand the membership format (could be legacy). We default to oldestMembership
|
||||
// Once there are other methods this is a hard error!
|
||||
const oldestMembership = this.getOldestMembership();
|
||||
return oldestMembership?.getPreferredFoci()[0];
|
||||
}
|
||||
}
|
||||
|
||||
private triggerCallMembershipEventUpdate = async (): Promise<void> => {
|
||||
// TODO: Should this await on a shared promise?
|
||||
if (this.updateCallMembershipRunning) {
|
||||
this.needCallMembershipUpdate = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateCallMembershipRunning = true;
|
||||
try {
|
||||
// if anything triggers an update while the update is running, do another update afterwards
|
||||
do {
|
||||
this.needCallMembershipUpdate = false;
|
||||
await this.updateCallMembershipEvent();
|
||||
} while (this.needCallMembershipUpdate);
|
||||
} finally {
|
||||
this.updateCallMembershipRunning = false;
|
||||
}
|
||||
};
|
||||
private makeNewMembership(deviceId: string): SessionMembershipData | EmptyObject {
|
||||
// If we're joined, add our own
|
||||
if (this.isJoined()) {
|
||||
return this.makeMyMembership(deviceId);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs our own membership
|
||||
*/
|
||||
private makeMyMembership(deviceId: string): SessionMembershipData {
|
||||
return {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: deviceId,
|
||||
expires: this.relativeExpiry,
|
||||
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
|
||||
foci_preferred: this.ownFociPreferred ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
private async updateCallMembershipEvent(): Promise<void> {
|
||||
if (this.memberEventTimeout) {
|
||||
clearTimeout(this.memberEventTimeout);
|
||||
this.memberEventTimeout = undefined;
|
||||
}
|
||||
|
||||
const roomState = this.room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
if (!roomState) throw new Error("Couldn't get room state for room " + this.room.roomId);
|
||||
|
||||
const localUserId = this.client.getUserId();
|
||||
const localDeviceId = this.client.getDeviceId();
|
||||
if (!localUserId || !localDeviceId) throw new Error("User ID or device ID was null!");
|
||||
|
||||
let newContent: EmptyObject | SessionMembershipData = {};
|
||||
// TODO: add back expiary logic to non-legacy events
|
||||
// previously we checked here if the event is timed out and scheduled a check if not.
|
||||
// maybe there is a better way.
|
||||
newContent = this.makeNewMembership(localDeviceId);
|
||||
|
||||
try {
|
||||
if (this.isJoined()) {
|
||||
const stateKey = this.makeMembershipStateKey(localUserId, localDeviceId);
|
||||
const prepareDelayedDisconnection = async (): Promise<void> => {
|
||||
try {
|
||||
const res = await resendIfRateLimited(() =>
|
||||
this.client._unstable_sendDelayedStateEvent(
|
||||
this.room.roomId,
|
||||
{
|
||||
delay: this.membershipServerSideExpiryTimeout,
|
||||
},
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{}, // leave event
|
||||
stateKey,
|
||||
),
|
||||
);
|
||||
this.disconnectDelayId = res.delay_id;
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof MatrixError &&
|
||||
e.errcode === "M_UNKNOWN" &&
|
||||
e.data["org.matrix.msc4140.errcode"] === "M_MAX_DELAY_EXCEEDED"
|
||||
) {
|
||||
const maxDelayAllowed = e.data["org.matrix.msc4140.max_delay"];
|
||||
if (
|
||||
typeof maxDelayAllowed === "number" &&
|
||||
this.membershipServerSideExpiryTimeout > maxDelayAllowed
|
||||
) {
|
||||
this.membershipServerSideExpiryTimeoutOverride = maxDelayAllowed;
|
||||
return prepareDelayedDisconnection();
|
||||
}
|
||||
}
|
||||
logger.error("Failed to prepare delayed disconnection event:", e);
|
||||
}
|
||||
};
|
||||
|
||||
await prepareDelayedDisconnection();
|
||||
// Send join event _after_ preparing the delayed disconnection event
|
||||
await resendIfRateLimited(() =>
|
||||
this.client.sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, newContent, stateKey),
|
||||
);
|
||||
// If sending state cancels your own delayed state, prepare another delayed state
|
||||
// TODO: Remove this once MSC4140 is stable & doesn't cancel own delayed state
|
||||
if (this.disconnectDelayId !== undefined) {
|
||||
try {
|
||||
const knownDisconnectDelayId = this.disconnectDelayId;
|
||||
await resendIfRateLimited(() =>
|
||||
this.client._unstable_updateDelayedEvent(
|
||||
knownDisconnectDelayId,
|
||||
UpdateDelayedEventAction.Restart,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof MatrixError && e.errcode === "M_NOT_FOUND") {
|
||||
// If we get a M_NOT_FOUND we prepare a new delayed event.
|
||||
// In other error cases we do not want to prepare anything since we do not have the guarantee, that the
|
||||
// future is not still running.
|
||||
logger.warn("Failed to update delayed disconnection event, prepare it again:", e);
|
||||
this.disconnectDelayId = undefined;
|
||||
await prepareDelayedDisconnection();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.disconnectDelayId !== undefined) {
|
||||
this.scheduleDelayDisconnection();
|
||||
}
|
||||
// TODO throw or log an error if this.disconnectDelayId === undefined
|
||||
} else {
|
||||
// Not joined
|
||||
let sentDelayedDisconnect = false;
|
||||
if (this.disconnectDelayId !== undefined) {
|
||||
try {
|
||||
const knownDisconnectDelayId = this.disconnectDelayId;
|
||||
await resendIfRateLimited(() =>
|
||||
this.client._unstable_updateDelayedEvent(
|
||||
knownDisconnectDelayId,
|
||||
UpdateDelayedEventAction.Send,
|
||||
),
|
||||
);
|
||||
sentDelayedDisconnect = true;
|
||||
} catch (e) {
|
||||
logger.error("Failed to send our delayed disconnection event:", e);
|
||||
}
|
||||
this.disconnectDelayId = undefined;
|
||||
}
|
||||
if (!sentDelayedDisconnect) {
|
||||
await resendIfRateLimited(() =>
|
||||
this.client.sendStateEvent(
|
||||
this.room.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{},
|
||||
this.makeMembershipStateKey(localUserId, localDeviceId),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
logger.info("Sent updated call member event.");
|
||||
} catch (e) {
|
||||
const resendDelay = this.callMemberEventRetryDelayMinimum + Math.random() * this.callMemberEventRetryJitter;
|
||||
logger.warn(`Failed to send call member event (retrying in ${resendDelay}): ${e}`);
|
||||
await sleep(resendDelay);
|
||||
await this.triggerCallMembershipEventUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleDelayDisconnection(): void {
|
||||
this.memberEventTimeout = setTimeout(() => void this.delayDisconnection(), this.membershipKeepAlivePeriod);
|
||||
}
|
||||
|
||||
private readonly delayDisconnection = async (): Promise<void> => {
|
||||
try {
|
||||
const knownDisconnectDelayId = this.disconnectDelayId!;
|
||||
await resendIfRateLimited(() =>
|
||||
this.client._unstable_updateDelayedEvent(knownDisconnectDelayId, UpdateDelayedEventAction.Restart),
|
||||
);
|
||||
this.scheduleDelayDisconnection();
|
||||
} catch (e) {
|
||||
logger.error("Failed to delay our disconnection event:", e);
|
||||
}
|
||||
};
|
||||
|
||||
private makeMembershipStateKey(localUserId: string, localDeviceId: string): string {
|
||||
const stateKey = `${localUserId}_${localDeviceId}`;
|
||||
if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) {
|
||||
return stateKey;
|
||||
} else {
|
||||
return `_${stateKey}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resendIfRateLimited<T>(func: () => Promise<T>, numRetriesAllowed: number = 1): Promise<T> {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
try {
|
||||
return await func();
|
||||
} catch (e) {
|
||||
if (numRetriesAllowed > 0 && e instanceof HTTPError && e.isRateLimitError()) {
|
||||
numRetriesAllowed--;
|
||||
let resendDelay: number;
|
||||
const defaultMs = 5000;
|
||||
try {
|
||||
resendDelay = e.getRetryAfterMs() ?? defaultMs;
|
||||
logger.info(`Rate limited by server, retrying in ${resendDelay}ms`);
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
`Error while retrieving a rate-limit retry delay, retrying after default delay of ${defaultMs}`,
|
||||
e,
|
||||
);
|
||||
resendDelay = defaultMs;
|
||||
}
|
||||
await sleep(resendDelay);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger as rootLogger } from "../logger.ts";
|
||||
import { type Logger, logger as rootLogger } from "../logger.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { EventTimeline } from "../models/event-timeline.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
@@ -24,14 +24,20 @@ import { CallMembership } from "./CallMembership.ts";
|
||||
import { RoomStateEvent } from "../models/room-state.ts";
|
||||
import { type Focus } from "./focus.ts";
|
||||
import { KnownMembership } from "../@types/membership.ts";
|
||||
import { type MatrixEvent } from "../models/event.ts";
|
||||
import { MembershipManager } from "./NewMembershipManager.ts";
|
||||
import { EncryptionManager, type IEncryptionManager, type Statistics } from "./EncryptionManager.ts";
|
||||
import { LegacyMembershipManager } from "./LegacyMembershipManager.ts";
|
||||
import { MembershipManager } from "./MembershipManager.ts";
|
||||
import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
|
||||
import { logDurationSync } from "../utils.ts";
|
||||
import type { IMembershipManager } from "./types.ts";
|
||||
|
||||
const logger = rootLogger.getChild("MatrixRTCSession");
|
||||
import { type Statistics } from "./types.ts";
|
||||
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
|
||||
import type { IMembershipManager } from "./IMembershipManager.ts";
|
||||
import { RTCEncryptionManager } from "./RTCEncryptionManager.ts";
|
||||
import {
|
||||
RoomAndToDeviceEvents,
|
||||
type RoomAndToDeviceEventsHandlerMap,
|
||||
RoomAndToDeviceTransport,
|
||||
} from "./RoomAndToDeviceKeyTransport.ts";
|
||||
import { TypedReEmitter } from "../ReEmitter.ts";
|
||||
import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
|
||||
|
||||
export enum MatrixRTCSessionEvent {
|
||||
// A member joined, left, or updated a property of their membership.
|
||||
@@ -59,12 +65,19 @@ export type MatrixRTCSessionEventHandlerMap = {
|
||||
) => void;
|
||||
[MatrixRTCSessionEvent.MembershipManagerError]: (error: unknown) => void;
|
||||
};
|
||||
|
||||
// The names follow these principles:
|
||||
// - we use the technical term delay if the option is related to delayed events.
|
||||
// - we use delayedLeaveEvent if the option is related to the delayed leave event.
|
||||
// - we use membershipEvent if the option is related to the rtc member state event.
|
||||
// - we use the technical term expiry if the option is related to the expiry field of the membership state event.
|
||||
// - we use a `MS` postfix if the option is a duration to avoid using words like:
|
||||
// `time`, `duration`, `delay`, `timeout`... that might be mistaken/confused with technical terms.
|
||||
export interface MembershipConfig {
|
||||
/**
|
||||
* Use the new Manager.
|
||||
*
|
||||
* Default: `false`.
|
||||
* @deprecated does nothing anymore we always default to the new memberhip manager.
|
||||
*/
|
||||
useNewMembershipManager?: boolean;
|
||||
|
||||
@@ -74,6 +87,8 @@ export interface MembershipConfig {
|
||||
*
|
||||
* This is what goes into the m.rtc.member event expiry field and is typically set to a number of hours.
|
||||
*/
|
||||
membershipEventExpiryMs?: number;
|
||||
/** @deprecated renamed to `membershipEventExpiryMs`*/
|
||||
membershipExpiryTimeout?: number;
|
||||
|
||||
/**
|
||||
@@ -85,36 +100,25 @@ export interface MembershipConfig {
|
||||
*
|
||||
* This value does not have an effect on the value of `SessionMembershipData.expires`.
|
||||
*/
|
||||
membershipEventExpiryHeadroomMs?: number;
|
||||
/** @deprecated renamed to `membershipEventExpiryHeadroomMs`*/
|
||||
membershipExpiryTimeoutHeadroom?: number;
|
||||
|
||||
/**
|
||||
* The period (in milliseconds) with which we check that our membership event still exists on the
|
||||
* server. If it is not found we create it again.
|
||||
*/
|
||||
memberEventCheckPeriod?: number;
|
||||
|
||||
/**
|
||||
* The minimum delay (in milliseconds) after which we will retry sending the membership event if it
|
||||
* failed to send.
|
||||
*/
|
||||
callMemberEventRetryDelayMinimum?: number;
|
||||
|
||||
/**
|
||||
* The timeout (in milliseconds) with which the deleayed leave event on the server is configured.
|
||||
* After this time the server will set the event to the disconnected stat if it has not received a keep-alive from the client.
|
||||
*/
|
||||
delayedLeaveEventDelayMs?: number;
|
||||
/** @deprecated renamed to `delayedLeaveEventDelayMs`*/
|
||||
membershipServerSideExpiryTimeout?: number;
|
||||
|
||||
/**
|
||||
* The interval (in milliseconds) in which the client will send membership keep-alives to the server.
|
||||
*/
|
||||
delayedLeaveEventRestartMs?: number;
|
||||
/** @deprecated renamed to `delayedLeaveEventRestartMs`*/
|
||||
membershipKeepAlivePeriod?: number;
|
||||
|
||||
/**
|
||||
* @deprecated It should be possible to make it stable without this.
|
||||
*/
|
||||
callMemberEventRetryJitter?: number;
|
||||
|
||||
/**
|
||||
* The maximum number of retries that the manager will do for delayed event sending/updating and state event sending when a server rate limit has been hit.
|
||||
*/
|
||||
@@ -124,6 +128,19 @@ export interface MembershipConfig {
|
||||
* The maximum number of retries that the manager will do for delayed event sending/updating and state event sending when a network error occurs.
|
||||
*/
|
||||
maximumNetworkErrorRetryCount?: number;
|
||||
|
||||
/**
|
||||
* The time (in milliseconds) after which we will retry a http request if it
|
||||
* failed to send due to a network error. (send membership event, send delayed event, restart delayed event...)
|
||||
*/
|
||||
networkErrorRetryMs?: number;
|
||||
/** @deprecated renamed to `networkErrorRetryMs`*/
|
||||
callMemberEventRetryDelayMinimum?: number;
|
||||
|
||||
/**
|
||||
* If true, use the new to-device transport for sending encryption keys.
|
||||
*/
|
||||
useExperimentalToDeviceTransport?: boolean;
|
||||
}
|
||||
|
||||
export interface EncryptionConfig {
|
||||
@@ -157,12 +174,15 @@ export type JoinSessionConfig = MembershipConfig & EncryptionConfig;
|
||||
* A MatrixRTCSession manages the membership & properties of a MatrixRTC session.
|
||||
* This class doesn't deal with media at all, just membership & properties of a session.
|
||||
*/
|
||||
export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, MatrixRTCSessionEventHandlerMap> {
|
||||
export class MatrixRTCSession extends TypedEventEmitter<
|
||||
MatrixRTCSessionEvent | RoomAndToDeviceEvents,
|
||||
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap
|
||||
> {
|
||||
private membershipManager?: IMembershipManager;
|
||||
private encryptionManager: IEncryptionManager;
|
||||
private encryptionManager?: IEncryptionManager;
|
||||
// The session Id of the call, this is the call_id of the call Member event.
|
||||
private _callId: string | undefined;
|
||||
|
||||
private logger: Logger;
|
||||
/**
|
||||
* This timeout is responsible to track any expiration. We need to know when we have to start
|
||||
* to ignore other call members. There is no callback for this. This timeout will always be configured to
|
||||
@@ -173,9 +193,15 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
/**
|
||||
* The statistics for this session.
|
||||
*/
|
||||
public get statistics(): Statistics {
|
||||
return this.encryptionManager.statistics;
|
||||
}
|
||||
public statistics: Statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The callId (sessionId) of the call.
|
||||
@@ -193,6 +219,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
public static callMembershipsForRoom(
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
|
||||
): CallMembership[] {
|
||||
const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`);
|
||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
if (!roomState) {
|
||||
logger.warn("Couldn't get state for room " + room.roomId);
|
||||
@@ -296,24 +323,24 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
| "_unstable_updateDelayedEvent"
|
||||
| "sendEvent"
|
||||
| "cancelPendingEvent"
|
||||
| "encryptAndSendToDevice"
|
||||
| "off"
|
||||
| "on"
|
||||
| "decryptEventIfNeeded"
|
||||
>,
|
||||
private roomSubset: Pick<
|
||||
Room,
|
||||
"getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState" | "on" | "off"
|
||||
>,
|
||||
private roomSubset: Pick<Room, "getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState">,
|
||||
public memberships: CallMembership[],
|
||||
) {
|
||||
super();
|
||||
this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`);
|
||||
this._callId = memberships[0]?.callId;
|
||||
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
// TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager
|
||||
roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
||||
this.setExpiryTimer();
|
||||
this.encryptionManager = new EncryptionManager(
|
||||
this.client,
|
||||
this.roomSubset,
|
||||
() => this.memberships,
|
||||
(keyBin: Uint8Array<ArrayBufferLike>, encryptionKeyIndex: number, participantId: string) => {
|
||||
this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyBin, encryptionKeyIndex, participantId);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -336,6 +363,10 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
roomState?.off(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
||||
}
|
||||
private reEmitter = new TypedReEmitter<
|
||||
MatrixRTCSessionEvent | RoomAndToDeviceEvents,
|
||||
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap
|
||||
>(this);
|
||||
|
||||
/**
|
||||
* Announces this user and device as joined to the MatrixRTC session,
|
||||
@@ -353,24 +384,72 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
*/
|
||||
public joinRoomSession(fociPreferred: Focus[], fociActive?: Focus, joinConfig?: JoinSessionConfig): void {
|
||||
if (this.isJoined()) {
|
||||
logger.info(`Already joined to session in room ${this.roomSubset.roomId}: ignoring join call`);
|
||||
this.logger.info(`Already joined to session in room ${this.roomSubset.roomId}: ignoring join call`);
|
||||
return;
|
||||
} else {
|
||||
// Create MembershipManager
|
||||
if (joinConfig?.useNewMembershipManager ?? false) {
|
||||
this.membershipManager = new MembershipManager(joinConfig, this.roomSubset, this.client, () =>
|
||||
this.getOldestMembership(),
|
||||
// Create MembershipManager and pass the RTCSession logger (with room id info)
|
||||
|
||||
this.membershipManager = new MembershipManager(
|
||||
joinConfig,
|
||||
this.roomSubset,
|
||||
this.client,
|
||||
() => this.getOldestMembership(),
|
||||
this.logger,
|
||||
);
|
||||
|
||||
// Create Encryption manager
|
||||
let transport;
|
||||
if (joinConfig?.useExperimentalToDeviceTransport) {
|
||||
this.logger.info("Using experimental to-device transport for encryption keys");
|
||||
this.logger.info("Using to-device with room fallback transport for encryption keys");
|
||||
const [uId, dId] = [this.client.getUserId()!, this.client.getDeviceId()!];
|
||||
const [room, client, statistics] = [this.roomSubset, this.client, this.statistics];
|
||||
// Deprecate RoomKeyTransport: only ToDeviceKeyTransport is needed once deprecated
|
||||
const roomKeyTransport = new RoomKeyTransport(room, client, statistics);
|
||||
const toDeviceTransport = new ToDeviceKeyTransport(uId, dId, room.roomId, client, statistics);
|
||||
transport = new RoomAndToDeviceTransport(toDeviceTransport, roomKeyTransport, this.logger);
|
||||
|
||||
// Expose the changes so the ui can display the currently used transport.
|
||||
this.reEmitter.reEmit(transport, [RoomAndToDeviceEvents.EnabledTransportsChanged]);
|
||||
this.encryptionManager = new RTCEncryptionManager(
|
||||
this.client.getUserId()!,
|
||||
this.client.getDeviceId()!,
|
||||
() => this.memberships,
|
||||
transport,
|
||||
this.statistics,
|
||||
(keyBin: Uint8Array, encryptionKeyIndex: number, participantId: string) => {
|
||||
this.emit(
|
||||
MatrixRTCSessionEvent.EncryptionKeyChanged,
|
||||
keyBin,
|
||||
encryptionKeyIndex,
|
||||
participantId,
|
||||
);
|
||||
},
|
||||
this.logger,
|
||||
);
|
||||
} else {
|
||||
this.membershipManager = new LegacyMembershipManager(joinConfig, this.roomSubset, this.client, () =>
|
||||
this.getOldestMembership(),
|
||||
transport = new RoomKeyTransport(this.roomSubset, this.client, this.statistics);
|
||||
this.encryptionManager = new EncryptionManager(
|
||||
this.client.getUserId()!,
|
||||
this.client.getDeviceId()!,
|
||||
() => this.memberships,
|
||||
transport,
|
||||
this.statistics,
|
||||
(keyBin: Uint8Array, encryptionKeyIndex: number, participantId: string) => {
|
||||
this.emit(
|
||||
MatrixRTCSessionEvent.EncryptionKeyChanged,
|
||||
keyBin,
|
||||
encryptionKeyIndex,
|
||||
participantId,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Join!
|
||||
this.membershipManager!.join(fociPreferred, fociActive, (e) => {
|
||||
logger.error("MembershipManager encountered an unrecoverable error: ", e);
|
||||
this.logger.error("MembershipManager encountered an unrecoverable error: ", e);
|
||||
this.emit(MatrixRTCSessionEvent.MembershipManagerError, e);
|
||||
this.emit(MatrixRTCSessionEvent.JoinStateChanged, this.isJoined());
|
||||
});
|
||||
@@ -391,13 +470,13 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
*/
|
||||
public async leaveRoomSession(timeout: number | undefined = undefined): Promise<boolean> {
|
||||
if (!this.isJoined()) {
|
||||
logger.info(`Not joined to session in room ${this.roomSubset.roomId}: ignoring leave call`);
|
||||
this.logger.info(`Not joined to session in room ${this.roomSubset.roomId}: ignoring leave call`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info(`Leaving call session in room ${this.roomSubset.roomId}`);
|
||||
this.logger.info(`Leaving call session in room ${this.roomSubset.roomId}`);
|
||||
|
||||
this.encryptionManager.leave();
|
||||
this.encryptionManager!.leave();
|
||||
|
||||
const leavePromise = this.membershipManager!.leave(timeout);
|
||||
this.emit(MatrixRTCSessionEvent.JoinStateChanged, false);
|
||||
@@ -437,7 +516,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
* the keys.
|
||||
*/
|
||||
public reemitEncryptionKeys(): void {
|
||||
this.encryptionManager.getEncryptionKeys().forEach((keys, participantId) => {
|
||||
this.encryptionManager?.getEncryptionKeys().forEach((keys, participantId) => {
|
||||
keys.forEach((key, index) => {
|
||||
this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, key.key, index, participantId);
|
||||
});
|
||||
@@ -452,7 +531,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
*/
|
||||
public getEncryptionKeys(): IterableIterator<[string, Array<Uint8Array>]> {
|
||||
const keys =
|
||||
this.encryptionManager.getEncryptionKeys() ??
|
||||
this.encryptionManager?.getEncryptionKeys() ??
|
||||
new Map<string, Array<{ key: Uint8Array; timestamp: number }>>();
|
||||
// the returned array doesn't contain the timestamps
|
||||
return Array.from(keys.entries())
|
||||
@@ -484,25 +563,6 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process `m.call.encryption_keys` events to track the encryption keys for call participants.
|
||||
* This should be called each time the relevant event is received from a room timeline.
|
||||
* If the event is malformed then it will be logged and ignored.
|
||||
*
|
||||
* @param event the event to process
|
||||
*/
|
||||
public onCallEncryption = (event: MatrixEvent): void => {
|
||||
this.encryptionManager.onCallEncryptionEventReceived(event);
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated use onRoomMemberUpdate or onRTCSessionMemberUpdate instead. this should be called when any membership in the call is updated
|
||||
* the old name might have implied to only need to call this when your own membership changes.
|
||||
*/
|
||||
public onMembershipUpdate = (): void => {
|
||||
this.recalculateSessionMembers();
|
||||
};
|
||||
|
||||
/**
|
||||
* Call this when the Matrix room members have changed.
|
||||
*/
|
||||
@@ -535,8 +595,10 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
oldMemberships.some((m, i) => !CallMembership.equal(m, this.memberships[i]));
|
||||
|
||||
if (changed) {
|
||||
logger.info(`Memberships for call in room ${this.roomSubset.roomId} have changed: emitting`);
|
||||
logDurationSync(logger, "emit MatrixRTCSessionEvent.MembershipsChanged", () => {
|
||||
this.logger.info(
|
||||
`Memberships for call in room ${this.roomSubset.roomId} have changed: emitting (${this.memberships.length} members)`,
|
||||
);
|
||||
logDurationSync(this.logger, "emit MatrixRTCSessionEvent.MembershipsChanged", () => {
|
||||
this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships);
|
||||
});
|
||||
|
||||
@@ -544,7 +606,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
}
|
||||
// This also needs to be done if `changed` = false
|
||||
// A member might have updated their fingerprint (created_ts)
|
||||
void this.encryptionManager.onMembershipsUpdate(oldMemberships);
|
||||
void this.encryptionManager?.onMembershipsUpdate(oldMemberships);
|
||||
|
||||
this.setExpiryTimer();
|
||||
};
|
||||
|
||||
@@ -14,17 +14,15 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger as rootLogger } from "../logger.ts";
|
||||
import { type Logger } from "../logger.ts";
|
||||
import { type MatrixClient, ClientEvent } from "../client.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { type Room, RoomEvent } from "../models/room.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { type RoomState, RoomStateEvent } from "../models/room-state.ts";
|
||||
import { type MatrixEvent } from "../models/event.ts";
|
||||
import { MatrixRTCSession } from "./MatrixRTCSession.ts";
|
||||
import { EventType } from "../@types/event.ts";
|
||||
|
||||
const logger = rootLogger.getChild("MatrixRTCSessionManager");
|
||||
|
||||
export enum MatrixRTCSessionManagerEvents {
|
||||
// A member has joined the MatrixRTC session, creating an active session in a room where there wasn't previously
|
||||
SessionStarted = "session_started",
|
||||
@@ -50,8 +48,14 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
// longer the correct session object for the room.
|
||||
private roomSessions = new Map<string, MatrixRTCSession>();
|
||||
|
||||
public constructor(private client: MatrixClient) {
|
||||
private readonly logger: Logger;
|
||||
|
||||
public constructor(
|
||||
rootLogger: Logger,
|
||||
private client: MatrixClient,
|
||||
) {
|
||||
super();
|
||||
this.logger = rootLogger.getChild("[MatrixRTCSessionManager]");
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
@@ -65,7 +69,6 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
}
|
||||
|
||||
this.client.on(ClientEvent.Room, this.onRoom);
|
||||
this.client.on(RoomEvent.Timeline, this.onTimeline);
|
||||
this.client.on(RoomStateEvent.Events, this.onRoomState);
|
||||
}
|
||||
|
||||
@@ -76,7 +79,6 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
this.roomSessions.clear();
|
||||
|
||||
this.client.off(ClientEvent.Room, this.onRoom);
|
||||
this.client.off(RoomEvent.Timeline, this.onTimeline);
|
||||
this.client.off(RoomStateEvent.Events, this.onRoomState);
|
||||
}
|
||||
|
||||
@@ -100,37 +102,6 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
return this.roomSessions.get(room.roomId)!;
|
||||
}
|
||||
|
||||
private async consumeCallEncryptionEvent(event: MatrixEvent, isRetry = false): Promise<void> {
|
||||
await this.client.decryptEventIfNeeded(event);
|
||||
if (event.isDecryptionFailure()) {
|
||||
if (!isRetry) {
|
||||
logger.warn(
|
||||
`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason} will retry once only`,
|
||||
);
|
||||
// retry after 1 second. After this we give up.
|
||||
setTimeout(() => void this.consumeCallEncryptionEvent(event, true), 1000);
|
||||
} else {
|
||||
logger.warn(`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason}`);
|
||||
}
|
||||
return;
|
||||
} else if (isRetry) {
|
||||
logger.info(`Decryption succeeded for event ${event.getId()} after retry`);
|
||||
}
|
||||
|
||||
if (event.getType() !== EventType.CallEncryptionKeysPrefix) return Promise.resolve();
|
||||
|
||||
const room = this.client.getRoom(event.getRoomId());
|
||||
if (!room) {
|
||||
logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
this.getRoomSession(room).onCallEncryption(event);
|
||||
}
|
||||
private onTimeline = (event: MatrixEvent): void => {
|
||||
void this.consumeCallEncryptionEvent(event);
|
||||
};
|
||||
|
||||
private onRoom = (room: Room): void => {
|
||||
this.refreshRoom(room);
|
||||
};
|
||||
@@ -138,7 +109,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
private onRoomState = (event: MatrixEvent, _state: RoomState): void => {
|
||||
const room = this.client.getRoom(event.getRoomId());
|
||||
if (!room) {
|
||||
logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
|
||||
this.logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -149,17 +120,23 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
|
||||
private refreshRoom(room: Room): void {
|
||||
const isNewSession = !this.roomSessions.has(room.roomId);
|
||||
const sess = this.getRoomSession(room);
|
||||
const session = this.getRoomSession(room);
|
||||
|
||||
const wasActiveAndKnown = sess.memberships.length > 0 && !isNewSession;
|
||||
const wasActiveAndKnown = session.memberships.length > 0 && !isNewSession;
|
||||
// This needs to be here and the event listener cannot be setup in the MatrixRTCSession,
|
||||
// because we need the update to happen between:
|
||||
// wasActiveAndKnown = session.memberships.length > 0 and
|
||||
// nowActive = session.memberships.length
|
||||
// Alternatively we would need to setup some event emission when the RTC session ended.
|
||||
session.onRTCSessionMemberUpdate();
|
||||
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
|
||||
const nowActive = sess.memberships.length > 0;
|
||||
const nowActive = session.memberships.length > 0;
|
||||
|
||||
if (wasActiveAndKnown && !nowActive) {
|
||||
this.logger.trace(`Session ended for ${room.roomId} (${session.memberships.length} members)`);
|
||||
this.emit(MatrixRTCSessionManagerEvents.SessionEnded, room.roomId, this.roomSessions.get(room.roomId)!);
|
||||
} else if (!wasActiveAndKnown && nowActive) {
|
||||
this.logger.trace(`Session started for ${room.roomId} (${session.memberships.length} members)`);
|
||||
this.emit(MatrixRTCSessionManagerEvents.SessionStarted, room.roomId, this.roomSessions.get(room.roomId)!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,23 +19,20 @@ import { UpdateDelayedEventAction } from "../@types/requests.ts";
|
||||
import { type MatrixClient } from "../client.ts";
|
||||
import { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
|
||||
import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts";
|
||||
import { logger as rootLogger } from "../logger.ts";
|
||||
import { type Logger, logger as rootLogger } from "../logger.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { defer, type IDeferred } from "../utils.ts";
|
||||
import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "./CallMembership.ts";
|
||||
import { type Focus } from "./focus.ts";
|
||||
import {
|
||||
type IMembershipManager,
|
||||
type MembershipManagerEventHandlerMap,
|
||||
MembershipManagerEvent,
|
||||
Status,
|
||||
} from "./types.ts";
|
||||
import { isMyMembership, Status } from "./types.ts";
|
||||
import { isLivekitFocusActive } from "./LivekitFocus.ts";
|
||||
import { type MembershipConfig } from "./MatrixRTCSession.ts";
|
||||
import { ActionScheduler, type ActionUpdate } from "./NewMembershipManagerActionScheduler.ts";
|
||||
import { ActionScheduler, type ActionUpdate } from "./MembershipManagerActionScheduler.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
|
||||
const logger = rootLogger.getChild("MatrixRTCSession");
|
||||
import {
|
||||
MembershipManagerEvent,
|
||||
type IMembershipManager,
|
||||
type MembershipManagerEventHandlerMap,
|
||||
} from "./IMembershipManager.ts";
|
||||
|
||||
/* MembershipActionTypes:
|
||||
|
||||
@@ -146,6 +143,8 @@ export class MembershipManager
|
||||
implements IMembershipManager
|
||||
{
|
||||
private activated = false;
|
||||
private logger: Logger;
|
||||
|
||||
public isActivated(): boolean {
|
||||
return this.activated;
|
||||
}
|
||||
@@ -164,12 +163,12 @@ export class MembershipManager
|
||||
*/
|
||||
public join(fociPreferred: Focus[], focusActive?: Focus, onError?: (error: unknown) => void): void {
|
||||
if (this.scheduler.running) {
|
||||
logger.error("MembershipManager is already running. Ignoring join request.");
|
||||
this.logger.error("MembershipManager is already running. Ignoring join request.");
|
||||
return;
|
||||
}
|
||||
this.fociPreferred = fociPreferred;
|
||||
this.focusActive = focusActive;
|
||||
this.leavePromiseDefer = undefined;
|
||||
this.leavePromiseResolvers = undefined;
|
||||
this.activated = true;
|
||||
this.oldStatus = this.status;
|
||||
this.state = MembershipManager.defaultState;
|
||||
@@ -177,7 +176,7 @@ export class MembershipManager
|
||||
this.scheduler
|
||||
.startWithJoin()
|
||||
.catch((e) => {
|
||||
logger.error("MembershipManager stopped because: ", e);
|
||||
this.logger.error("MembershipManager stopped because: ", e);
|
||||
onError?.(e);
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -188,8 +187,8 @@ export class MembershipManager
|
||||
this.emit(MembershipManagerEvent.StatusChanged, this.oldStatus, this.status);
|
||||
}
|
||||
if (!this.scheduler.running) {
|
||||
this.leavePromiseDefer?.resolve(true);
|
||||
this.leavePromiseDefer = undefined;
|
||||
this.leavePromiseResolvers?.resolve(true);
|
||||
this.leavePromiseResolvers = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -201,43 +200,44 @@ export class MembershipManager
|
||||
*/
|
||||
public leave(timeout?: number): Promise<boolean> {
|
||||
if (!this.scheduler.running) {
|
||||
logger.warn("Called MembershipManager.leave() even though the MembershipManager is not running");
|
||||
this.logger.warn("Called MembershipManager.leave() even though the MembershipManager is not running");
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
// We use the promise to track if we already scheduled a leave event
|
||||
// So we do not check scheduler.actions/scheduler.insertions
|
||||
if (!this.leavePromiseDefer) {
|
||||
if (!this.leavePromiseResolvers) {
|
||||
// reset scheduled actions so we will not do any new actions.
|
||||
this.leavePromiseDefer = defer<boolean>();
|
||||
this.leavePromiseResolvers = Promise.withResolvers<boolean>();
|
||||
this.activated = false;
|
||||
this.scheduler.initiateLeave();
|
||||
if (timeout) setTimeout(() => this.leavePromiseDefer?.resolve(false), timeout);
|
||||
if (timeout) setTimeout(() => this.leavePromiseResolvers?.resolve(false), timeout);
|
||||
}
|
||||
return this.leavePromiseDefer.promise;
|
||||
return this.leavePromiseResolvers.promise;
|
||||
}
|
||||
private leavePromiseDefer?: IDeferred<boolean>;
|
||||
|
||||
private leavePromiseResolvers?: PromiseWithResolvers<boolean>;
|
||||
|
||||
public async onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void> {
|
||||
const isMyMembership = (m: CallMembership): boolean =>
|
||||
m.sender === this.client.getUserId() && m.deviceId === this.client.getDeviceId();
|
||||
|
||||
if (this.isJoined() && !memberships.some(isMyMembership)) {
|
||||
const userId = this.client.getUserId();
|
||||
const deviceId = this.client.getDeviceId();
|
||||
if (userId && deviceId && this.isJoined() && !memberships.some((m) => isMyMembership(m, userId, deviceId))) {
|
||||
// If one of these actions are scheduled or are getting inserted in the next iteration, we should already
|
||||
// take care of our missing membership.
|
||||
const sendingMembershipActions = [
|
||||
MembershipActionType.SendDelayedEvent,
|
||||
MembershipActionType.SendJoinEvent,
|
||||
];
|
||||
logger.warn("Missing own membership: force re-join");
|
||||
this.logger.warn("Missing own membership: force re-join");
|
||||
this.state.hasMemberStateEvent = false;
|
||||
|
||||
if (this.scheduler.actions.find((a) => sendingMembershipActions.includes(a.type as MembershipActionType))) {
|
||||
logger.error(
|
||||
"NewMembershipManger tried adding another `SendFirstDelayedEvent` actions even though we already have one in the Queue\nActionQueueOnMemberUpdate:",
|
||||
this.logger.error(
|
||||
"tried adding another `SendDelayedEvent` actions even though we already have one in the Queue\nActionQueueOnMemberUpdate:",
|
||||
this.scheduler.actions,
|
||||
);
|
||||
} else {
|
||||
// Only react to our own membership missing if we have not already scheduled sending a new membership DirectMembershipManagerAction.Join
|
||||
this.state.hasMemberStateEvent = false;
|
||||
this.scheduler.initiateJoin();
|
||||
}
|
||||
}
|
||||
@@ -253,7 +253,7 @@ export class MembershipManager
|
||||
return oldestMembership?.getPreferredFoci()[0];
|
||||
}
|
||||
} else {
|
||||
logger.warn("Unknown own ActiveFocus type. This makes it impossible to connect to an SFU.");
|
||||
this.logger.warn("Unknown own ActiveFocus type. This makes it impossible to connect to an SFU.");
|
||||
}
|
||||
} else {
|
||||
// We do not understand the membership format (could be legacy). We default to oldestMembership
|
||||
@@ -282,16 +282,38 @@ export class MembershipManager
|
||||
| "_unstable_updateDelayedEvent"
|
||||
>,
|
||||
private getOldestMembership: () => CallMembership | undefined,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
super();
|
||||
this.logger = (parentLogger ?? rootLogger).getChild(`[MembershipManager]`);
|
||||
const [userId, deviceId] = [this.client.getUserId(), this.client.getDeviceId()];
|
||||
if (userId === null) throw Error("Missing userId in client");
|
||||
if (deviceId === null) throw Error("Missing deviceId in client");
|
||||
this.deviceId = deviceId;
|
||||
this.stateKey = this.makeMembershipStateKey(userId, deviceId);
|
||||
this.state = MembershipManager.defaultState;
|
||||
this.scheduler = new ActionScheduler((type): Promise<ActionUpdate> => {
|
||||
if (this.oldStatus) {
|
||||
// we put this at the beginning of the actions scheduler loop handle callback since it is a loop this
|
||||
// is equivalent to running it at the end of the loop. (just after applying the status/action list changes)
|
||||
// This order is required because this method needs to return the action updates.
|
||||
this.logger.debug(
|
||||
`MembershipManager applied action changes. Status: ${this.oldStatus} -> ${this.status}`,
|
||||
);
|
||||
if (this.oldStatus !== this.status) {
|
||||
this.emit(MembershipManagerEvent.StatusChanged, this.oldStatus, this.status);
|
||||
}
|
||||
}
|
||||
this.oldStatus = this.status;
|
||||
this.logger.debug(`MembershipManager before processing action. status=${this.oldStatus}`);
|
||||
return this.membershipLoopHandler(type);
|
||||
}, this.logger);
|
||||
}
|
||||
|
||||
// scheduler
|
||||
private oldStatus?: Status;
|
||||
private scheduler: ActionScheduler;
|
||||
|
||||
// MembershipManager mutable state.
|
||||
private state: MembershipManagerState;
|
||||
private static get defaultState(): MembershipManagerState {
|
||||
@@ -312,33 +334,38 @@ export class MembershipManager
|
||||
private focusActive?: Focus;
|
||||
|
||||
// Config:
|
||||
private membershipServerSideExpiryTimeoutOverride?: number;
|
||||
private delayedLeaveEventDelayMsOverride?: number;
|
||||
|
||||
private get callMemberEventRetryDelayMinimum(): number {
|
||||
return this.joinConfig?.callMemberEventRetryDelayMinimum ?? 3_000;
|
||||
private get networkErrorRetryMs(): number {
|
||||
return this.joinConfig?.networkErrorRetryMs ?? this.joinConfig?.callMemberEventRetryDelayMinimum ?? 3_000;
|
||||
}
|
||||
private get membershipEventExpiryTimeout(): number {
|
||||
return this.joinConfig?.membershipExpiryTimeout ?? DEFAULT_EXPIRE_DURATION;
|
||||
}
|
||||
private get membershipEventExpiryTimeoutHeadroom(): number {
|
||||
return this.joinConfig?.membershipExpiryTimeoutHeadroom ?? 5_000;
|
||||
}
|
||||
private computeNextExpiryActionTs(iteration: number): number {
|
||||
private get membershipEventExpiryMs(): number {
|
||||
return (
|
||||
this.state.startTime +
|
||||
this.membershipEventExpiryTimeout * iteration -
|
||||
this.membershipEventExpiryTimeoutHeadroom
|
||||
this.joinConfig?.membershipEventExpiryMs ??
|
||||
this.joinConfig?.membershipExpiryTimeout ??
|
||||
DEFAULT_EXPIRE_DURATION
|
||||
);
|
||||
}
|
||||
private get membershipServerSideExpiryTimeout(): number {
|
||||
private get membershipEventExpiryHeadroomMs(): number {
|
||||
return (
|
||||
this.membershipServerSideExpiryTimeoutOverride ??
|
||||
this.joinConfig?.membershipEventExpiryHeadroomMs ??
|
||||
this.joinConfig?.membershipExpiryTimeoutHeadroom ??
|
||||
5_000
|
||||
);
|
||||
}
|
||||
private computeNextExpiryActionTs(iteration: number): number {
|
||||
return this.state.startTime + this.membershipEventExpiryMs * iteration - this.membershipEventExpiryHeadroomMs;
|
||||
}
|
||||
private get delayedLeaveEventDelayMs(): number {
|
||||
return (
|
||||
this.delayedLeaveEventDelayMsOverride ??
|
||||
this.joinConfig?.delayedLeaveEventDelayMs ??
|
||||
this.joinConfig?.membershipServerSideExpiryTimeout ??
|
||||
8_000
|
||||
);
|
||||
}
|
||||
private get membershipKeepAlivePeriod(): number {
|
||||
return this.joinConfig?.membershipKeepAlivePeriod ?? 5_000;
|
||||
private get delayedLeaveEventRestartMs(): number {
|
||||
return this.joinConfig?.delayedLeaveEventRestartMs ?? this.joinConfig?.membershipKeepAlivePeriod ?? 5_000;
|
||||
}
|
||||
private get maximumRateLimitRetryCount(): number {
|
||||
return this.joinConfig?.maximumRateLimitRetryCount ?? 10;
|
||||
@@ -347,23 +374,6 @@ export class MembershipManager
|
||||
return this.joinConfig?.maximumNetworkErrorRetryCount ?? 10;
|
||||
}
|
||||
|
||||
// Scheduler:
|
||||
private oldStatus?: Status;
|
||||
private scheduler = new ActionScheduler((type): Promise<ActionUpdate> => {
|
||||
if (this.oldStatus) {
|
||||
// we put this at the beginning of the actions scheduler loop handle callback since it is a loop this
|
||||
// is equivalent to running it at the end of the loop. (just after applying the status/action list changes)
|
||||
// This order is required because this method needs to return the action updates.
|
||||
logger.debug(`MembershipManager applied action changes. Status: ${this.oldStatus} -> ${this.status}`);
|
||||
if (this.oldStatus !== this.status) {
|
||||
this.emit(MembershipManagerEvent.StatusChanged, this.oldStatus, this.status);
|
||||
}
|
||||
}
|
||||
this.oldStatus = this.status;
|
||||
logger.debug(`MembershipManager before processing action. status=${this.oldStatus}`);
|
||||
return this.membershipLoopHandler(type);
|
||||
});
|
||||
|
||||
// LOOP HANDLER:
|
||||
private async membershipLoopHandler(type: MembershipActionType): Promise<ActionUpdate> {
|
||||
switch (type) {
|
||||
@@ -373,13 +383,13 @@ export class MembershipManager
|
||||
return this.sendOrResendDelayedLeaveEvent(); // Normal case without any previous delayed id.
|
||||
} else {
|
||||
// This can happen if someone else (or another client) removes our own membership event.
|
||||
// It will trigger `onRTCSessionMemberUpdate` queue `MembershipActionType.SendFirstDelayedEvent`.
|
||||
// It will trigger `onRTCSessionMemberUpdate` queue `MembershipActionType.SendDelayedEvent`.
|
||||
// We might still have our delayed event from the previous participation and dependent on the server this might not
|
||||
// get removed automatically if the state changes. Hence, it would remove our membership unexpectedly shortly after the rejoin.
|
||||
//
|
||||
// In this block we will try to cancel this delayed event before setting up a new one.
|
||||
|
||||
return this.cancelKnownDelayIdBeforeSendFirstDelayedEvent(this.state.delayId);
|
||||
return this.cancelKnownDelayIdBeforeSendDelayedEvent(this.state.delayId);
|
||||
}
|
||||
}
|
||||
case MembershipActionType.RestartDelayedEvent: {
|
||||
@@ -428,7 +438,7 @@ export class MembershipManager
|
||||
._unstable_sendDelayedStateEvent(
|
||||
this.room.roomId,
|
||||
{
|
||||
delay: this.membershipServerSideExpiryTimeout,
|
||||
delay: this.delayedLeaveEventDelayMs,
|
||||
},
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{}, // leave event
|
||||
@@ -443,7 +453,7 @@ export class MembershipManager
|
||||
// due to lack of https://github.com/element-hq/synapse/pull/17810
|
||||
return createInsertActionUpdate(
|
||||
MembershipActionType.RestartDelayedEvent,
|
||||
this.membershipKeepAlivePeriod,
|
||||
this.delayedLeaveEventRestartMs,
|
||||
);
|
||||
} else {
|
||||
// This action was scheduled because we are in the process of joining
|
||||
@@ -469,9 +479,9 @@ export class MembershipManager
|
||||
// This action was scheduled because we are in the process of joining
|
||||
// log and fall through
|
||||
if (this.isUnsupportedDelayedEndpoint(e)) {
|
||||
logger.info("Not using delayed event because the endpoint is not supported");
|
||||
this.logger.info("Not using delayed event because the endpoint is not supported");
|
||||
} else {
|
||||
logger.info("Not using delayed event because: " + e);
|
||||
this.logger.info("Not using delayed event because: " + e);
|
||||
}
|
||||
// On any other error we fall back to not using delayed events and send the join state event immediately
|
||||
return createInsertActionUpdate(MembershipActionType.SendJoinEvent);
|
||||
@@ -479,7 +489,7 @@ export class MembershipManager
|
||||
});
|
||||
}
|
||||
|
||||
private async cancelKnownDelayIdBeforeSendFirstDelayedEvent(delayId: string): Promise<ActionUpdate> {
|
||||
private async cancelKnownDelayIdBeforeSendDelayedEvent(delayId: string): Promise<ActionUpdate> {
|
||||
// Remove all running updates and restarts
|
||||
return await this.client
|
||||
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Cancel)
|
||||
@@ -521,7 +531,7 @@ export class MembershipManager
|
||||
this.resetRateLimitCounter(MembershipActionType.RestartDelayedEvent);
|
||||
return createInsertActionUpdate(
|
||||
MembershipActionType.RestartDelayedEvent,
|
||||
this.membershipKeepAlivePeriod,
|
||||
this.delayedLeaveEventRestartMs,
|
||||
);
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -562,7 +572,7 @@ export class MembershipManager
|
||||
if (update) return update;
|
||||
|
||||
// On any other error we fall back to SendLeaveEvent (this includes hard errors from rate limiting)
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
"Encountered unexpected error during SendScheduledDelayedLeaveEvent. Falling back to SendLeaveEvent",
|
||||
e,
|
||||
);
|
||||
@@ -575,7 +585,7 @@ export class MembershipManager
|
||||
.sendStateEvent(
|
||||
this.room.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
this.makeMyMembership(this.membershipEventExpiryTimeout),
|
||||
this.makeMyMembership(this.membershipEventExpiryMs),
|
||||
this.stateKey,
|
||||
)
|
||||
.then(() => {
|
||||
@@ -607,7 +617,7 @@ export class MembershipManager
|
||||
.sendStateEvent(
|
||||
this.room.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
this.makeMyMembership(this.membershipEventExpiryTimeout * nextExpireUpdateIteration),
|
||||
this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration),
|
||||
this.stateKey,
|
||||
)
|
||||
.then(() => {
|
||||
@@ -693,10 +703,10 @@ export class MembershipManager
|
||||
error.data["org.matrix.msc4140.errcode"] === "M_MAX_DELAY_EXCEEDED"
|
||||
) {
|
||||
const maxDelayAllowed = error.data["org.matrix.msc4140.max_delay"];
|
||||
if (typeof maxDelayAllowed === "number" && this.membershipServerSideExpiryTimeout > maxDelayAllowed) {
|
||||
this.membershipServerSideExpiryTimeoutOverride = maxDelayAllowed;
|
||||
if (typeof maxDelayAllowed === "number" && this.delayedLeaveEventDelayMs > maxDelayAllowed) {
|
||||
this.delayedLeaveEventDelayMsOverride = maxDelayAllowed;
|
||||
}
|
||||
logger.warn("Retry sending delayed disconnection event due to server timeout limitations:", error);
|
||||
this.logger.warn("Retry sending delayed disconnection event due to server timeout limitations:", error);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -738,9 +748,9 @@ export class MembershipManager
|
||||
const defaultMs = 5000;
|
||||
try {
|
||||
resendDelay = error.getRetryAfterMs() ?? defaultMs;
|
||||
logger.info(`Rate limited by server, retrying in ${resendDelay}ms`);
|
||||
this.logger.info(`Rate limited by server, retrying in ${resendDelay}ms`);
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
`Error while retrieving a rate-limit retry delay, retrying after default delay of ${defaultMs}`,
|
||||
e,
|
||||
);
|
||||
@@ -765,10 +775,10 @@ export class MembershipManager
|
||||
private actionUpdateFromNetworkErrorRetry(error: unknown, type: MembershipActionType): ActionUpdate | undefined {
|
||||
// "Is a network error"-boundary
|
||||
const retries = this.state.networkErrorRetries.get(type) ?? 0;
|
||||
const retryDurationString = this.callMemberEventRetryDelayMinimum / 1000 + "s";
|
||||
const retryDurationString = this.networkErrorRetryMs / 1000 + "s";
|
||||
const retryCounterString = "(" + retries + "/" + this.maximumNetworkErrorRetryCount + ")";
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
"Network local timeout error while sending event, retrying in " +
|
||||
retryDurationString +
|
||||
" " +
|
||||
@@ -786,12 +796,12 @@ export class MembershipManager
|
||||
//
|
||||
// A proper fix would be to either find a place to convert the `HttpError` into a `MatrixError` and the `processError`
|
||||
// method to handle it as expected or to adjust `processError` to also process `HttpError`'s.
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
"delayed event update timeout error, retrying in " + retryDurationString + " " + retryCounterString,
|
||||
error,
|
||||
);
|
||||
} else if (error instanceof ConnectionError) {
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
"Network connection error while sending event, retrying in " +
|
||||
retryDurationString +
|
||||
" " +
|
||||
@@ -804,7 +814,7 @@ export class MembershipManager
|
||||
error.httpStatus >= 500 &&
|
||||
error.httpStatus < 600
|
||||
) {
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
"Server error while sending event, retrying in " + retryDurationString + " " + retryCounterString,
|
||||
error,
|
||||
);
|
||||
@@ -815,7 +825,7 @@ export class MembershipManager
|
||||
// retry boundary
|
||||
if (retries < this.maximumNetworkErrorRetryCount) {
|
||||
this.state.networkErrorRetries.set(type, retries + 1);
|
||||
return createInsertActionUpdate(type, this.callMemberEventRetryDelayMinimum);
|
||||
return createInsertActionUpdate(type, this.networkErrorRetryMs);
|
||||
}
|
||||
|
||||
// Failure
|
||||
@@ -880,7 +890,7 @@ export class MembershipManager
|
||||
return Status.Disconnected;
|
||||
}
|
||||
|
||||
logger.error("MembershipManager has an unknown state. Actions: ", actions);
|
||||
this.logger.error("MembershipManager has an unknown state. Actions: ", actions);
|
||||
return Status.Unknown;
|
||||
}
|
||||
}
|
||||
+11
-9
@@ -1,9 +1,7 @@
|
||||
import { logger as rootLogger } from "../logger.ts";
|
||||
import { type Logger, logger as rootLogger } from "../logger.ts";
|
||||
import { type EmptyObject } from "../matrix.ts";
|
||||
import { sleep } from "../utils.ts";
|
||||
import { MembershipActionType } from "./NewMembershipManager.ts";
|
||||
|
||||
const logger = rootLogger.getChild("MatrixRTCSession");
|
||||
import { MembershipActionType } from "./MembershipManager.ts";
|
||||
|
||||
/** @internal */
|
||||
export interface Action {
|
||||
@@ -40,6 +38,7 @@ export type ActionUpdate =
|
||||
* @internal
|
||||
*/
|
||||
export class ActionScheduler {
|
||||
private logger: Logger;
|
||||
/**
|
||||
* This is tracking the state of the scheduler loop.
|
||||
* Only used to prevent starting the loop twice.
|
||||
@@ -49,11 +48,14 @@ export class ActionScheduler {
|
||||
public constructor(
|
||||
/** This is the callback called for each scheduled action (`this.addAction()`) */
|
||||
private membershipLoopHandler: (type: MembershipActionType) => Promise<ActionUpdate>,
|
||||
) {}
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
this.logger = (parentLogger ?? rootLogger).getChild(`[NewMembershipActionScheduler]`);
|
||||
}
|
||||
|
||||
// function for the wakeup mechanism (in case we add an action externally and need to leave the current sleep)
|
||||
private wakeup: (update: ActionUpdate) => void = (update: ActionUpdate): void => {
|
||||
logger.error("Cannot call wakeup before calling `startWithJoin()`");
|
||||
this.logger.error("Cannot call wakeup before calling `startWithJoin()`");
|
||||
};
|
||||
private _actions: Action[] = [];
|
||||
public get actions(): Action[] {
|
||||
@@ -69,7 +71,7 @@ export class ActionScheduler {
|
||||
*/
|
||||
public async startWithJoin(): Promise<void> {
|
||||
if (this.running) {
|
||||
logger.error("Cannot call startWithJoin() on NewMembershipActionScheduler while already running");
|
||||
this.logger.error("Cannot call startWithJoin() on NewMembershipActionScheduler while already running");
|
||||
return;
|
||||
}
|
||||
this.running = true;
|
||||
@@ -92,7 +94,7 @@ export class ActionScheduler {
|
||||
|
||||
let handlerResult: ActionUpdate = {};
|
||||
if (!wakeupUpdate) {
|
||||
logger.debug(
|
||||
this.logger.debug(
|
||||
`Current MembershipManager processing: ${nextAction.type}\nQueue:`,
|
||||
this._actions,
|
||||
`\nDate.now: "${Date.now()}`,
|
||||
@@ -121,7 +123,7 @@ export class ActionScheduler {
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
logger.debug("Leave MembershipManager ActionScheduler loop (no more actions)");
|
||||
this.logger.debug("Leave MembershipManager ActionScheduler loop (no more actions)");
|
||||
}
|
||||
|
||||
public initiateJoin(): void {
|
||||
@@ -0,0 +1,329 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type IEncryptionManager } from "./EncryptionManager.ts";
|
||||
import { type EncryptionConfig } from "./MatrixRTCSession.ts";
|
||||
import { type CallMembership } from "./CallMembership.ts";
|
||||
import { decodeBase64, encodeBase64 } from "../base64.ts";
|
||||
import { type IKeyTransport, type KeyTransportEventListener, KeyTransportEvents } from "./IKeyTransport.ts";
|
||||
import { type Logger } from "../logger.ts";
|
||||
import { sleep } from "../utils.ts";
|
||||
import type {
|
||||
InboundEncryptionSession,
|
||||
OutboundEncryptionSession,
|
||||
ParticipantDeviceInfo,
|
||||
ParticipantId,
|
||||
Statistics,
|
||||
} from "./types.ts";
|
||||
import { getParticipantId, OutdatedKeyFilter } from "./utils.ts";
|
||||
import {
|
||||
type EnabledTransports,
|
||||
RoomAndToDeviceEvents,
|
||||
RoomAndToDeviceTransport,
|
||||
} from "./RoomAndToDeviceKeyTransport.ts";
|
||||
|
||||
/**
|
||||
* RTCEncryptionManager is used to manage the encryption keys for a call.
|
||||
*
|
||||
* It is responsible for distributing the keys to the other participants and rotating the keys if needed.
|
||||
*
|
||||
* This manager when used with to-device transport will share the existing key only to new joiners, and rotate
|
||||
* if there is a leaver.
|
||||
*
|
||||
* XXX In the future we want to distribute a ratcheted key not the current one for new joiners.
|
||||
*/
|
||||
export class RTCEncryptionManager implements IEncryptionManager {
|
||||
// The current per-sender media key for this device
|
||||
private outboundSession: OutboundEncryptionSession | null = null;
|
||||
|
||||
/**
|
||||
* Ensures that there is only one distribute operation at a time for that call.
|
||||
*/
|
||||
private currentKeyDistributionPromise: Promise<void> | null = null;
|
||||
/**
|
||||
* The time to wait before using the outbound session after it has been distributed.
|
||||
* This is to ensure that the key is delivered to all participants before it is used.
|
||||
* When creating the first key, this is set to 0, so that the key can be used immediately.
|
||||
*/
|
||||
private delayRolloutTimeMillis = 1000;
|
||||
/**
|
||||
* If a new key distribution is being requested while one is going on, we will set this flag to true.
|
||||
* This will ensure that a new round is started after the current one.
|
||||
* @private
|
||||
*/
|
||||
private needToEnsureKeyAgain = false;
|
||||
|
||||
/**
|
||||
* There is a possibility that keys arrive in the wrong order.
|
||||
* For example, after a quick join/leave/join, there will be 2 keys of index 0 distributed, and
|
||||
* if they are received in the wrong order, the stream won't be decryptable.
|
||||
* For that reason we keep a small buffer of keys for a limited time to disambiguate.
|
||||
* @private
|
||||
*/
|
||||
private keyBuffer = new OutdatedKeyFilter();
|
||||
|
||||
private logger: Logger | undefined = undefined;
|
||||
|
||||
public constructor(
|
||||
private userId: string,
|
||||
private deviceId: string,
|
||||
private getMemberships: () => CallMembership[],
|
||||
private transport: IKeyTransport,
|
||||
private statistics: Statistics,
|
||||
// Callback to notify the media layer of new keys
|
||||
private onEncryptionKeysChanged: (
|
||||
keyBin: Uint8Array,
|
||||
encryptionKeyIndex: number,
|
||||
participantId: ParticipantId,
|
||||
) => void,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
this.logger = parentLogger?.getChild(`[EncryptionManager]`);
|
||||
}
|
||||
|
||||
public getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>> {
|
||||
// This is deprecated should be ignored. Only used by tests?
|
||||
return new Map();
|
||||
}
|
||||
|
||||
public join(joinConfig: EncryptionConfig | undefined): void {
|
||||
this.logger?.info(`Joining room`);
|
||||
this.delayRolloutTimeMillis = joinConfig?.useKeyDelay ?? 1000;
|
||||
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
|
||||
// Deprecate RoomKeyTransport: this can get removed.
|
||||
if (this.transport instanceof RoomAndToDeviceTransport) {
|
||||
this.transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, this.onTransportChanged);
|
||||
}
|
||||
|
||||
this.transport.start();
|
||||
}
|
||||
|
||||
public leave(): void {
|
||||
this.transport.off(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
|
||||
this.transport.stop();
|
||||
}
|
||||
|
||||
// Temporary for backwards compatibility
|
||||
// TODO: Remove this in the future
|
||||
private onTransportChanged: (enabled: EnabledTransports) => void = () => {
|
||||
this.logger?.info("Transport change detected, restarting key distribution");
|
||||
if (this.currentKeyDistributionPromise) {
|
||||
this.currentKeyDistributionPromise
|
||||
.then(() => {
|
||||
if (this.outboundSession) {
|
||||
this.outboundSession.sharedWith = [];
|
||||
this.ensureKeyDistribution();
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
this.logger?.error("Failed to restart key distribution", e);
|
||||
});
|
||||
} else {
|
||||
if (this.outboundSession) {
|
||||
this.outboundSession.sharedWith = [];
|
||||
this.ensureKeyDistribution();
|
||||
}
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Will ensure that a new key is distributed and used to encrypt our media.
|
||||
* If there is already a key distribution in progress, it will schedule a new distribution round just after the current one is completed.
|
||||
* If this function is called repeatedly while a distribution is in progress,
|
||||
* the calls will be coalesced to a single new distribution (that will start just after the current one has completed).
|
||||
*/
|
||||
private ensureKeyDistribution(): void {
|
||||
if (this.currentKeyDistributionPromise == null) {
|
||||
this.logger?.debug(`No active rollout, start a new one`);
|
||||
// start a rollout
|
||||
this.currentKeyDistributionPromise = this.rolloutOutboundKey().then(() => {
|
||||
this.logger?.debug(`Rollout completed`);
|
||||
this.currentKeyDistributionPromise = null;
|
||||
if (this.needToEnsureKeyAgain) {
|
||||
this.logger?.debug(`New Rollout needed`);
|
||||
this.needToEnsureKeyAgain = false;
|
||||
// rollout a new one
|
||||
this.ensureKeyDistribution();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// There is a rollout in progress, but a key rotation is requested (could be caused by a membership change)
|
||||
// Remember that a new rotation is needed after the current one.
|
||||
this.logger?.debug(`Rollout in progress, a new rollout will be started after the current one`);
|
||||
this.needToEnsureKeyAgain = true;
|
||||
}
|
||||
}
|
||||
|
||||
public onNewKeyReceived: KeyTransportEventListener = (userId, deviceId, keyBase64Encoded, index, timestamp) => {
|
||||
this.logger?.debug(`Received key over transport ${userId}:${deviceId} at index ${index}`);
|
||||
|
||||
// We received a new key, notify the video layer of this new key so that it can decrypt the frames properly.
|
||||
const participantId = getParticipantId(userId, deviceId);
|
||||
const keyBin = decodeBase64(keyBase64Encoded);
|
||||
const candidateInboundSession: InboundEncryptionSession = {
|
||||
key: keyBin,
|
||||
participantId,
|
||||
keyIndex: index,
|
||||
creationTS: timestamp,
|
||||
};
|
||||
|
||||
const outdated = this.keyBuffer.isOutdated(participantId, candidateInboundSession);
|
||||
if (!outdated) {
|
||||
this.onEncryptionKeysChanged(
|
||||
candidateInboundSession.key,
|
||||
candidateInboundSession.keyIndex,
|
||||
candidateInboundSession.participantId,
|
||||
);
|
||||
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
|
||||
} else {
|
||||
this.logger?.info(`Received an out of order key for ${userId}:${deviceId}, dropping it`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Called when the membership of the call changes.
|
||||
* This encryption manager is very basic, it will rotate the key everytime this is called.
|
||||
* @param oldMemberships
|
||||
*/
|
||||
public onMembershipsUpdate(oldMemberships: CallMembership[]): void {
|
||||
this.logger?.trace(`onMembershipsUpdate`);
|
||||
|
||||
// Ensure the key is distributed. This will be no-op if the key is already being distributed to everyone.
|
||||
// If there is an ongoing distribution, it will be completed before a new one is started.
|
||||
this.ensureKeyDistribution();
|
||||
}
|
||||
|
||||
private async rolloutOutboundKey(): Promise<void> {
|
||||
const isFirstKey = this.outboundSession == null;
|
||||
if (isFirstKey) {
|
||||
// create the first key
|
||||
this.outboundSession = {
|
||||
key: this.generateRandomKey(),
|
||||
creationTS: Date.now(),
|
||||
sharedWith: [],
|
||||
keyId: 0,
|
||||
};
|
||||
this.onEncryptionKeysChanged(
|
||||
this.outboundSession.key,
|
||||
this.outboundSession.keyId,
|
||||
getParticipantId(this.userId, this.deviceId),
|
||||
);
|
||||
}
|
||||
// get current memberships
|
||||
const toShareWith: ParticipantDeviceInfo[] = this.getMemberships()
|
||||
.filter((membership) => {
|
||||
return membership.sender != undefined;
|
||||
})
|
||||
.map((membership) => {
|
||||
return {
|
||||
userId: membership.sender!,
|
||||
deviceId: membership.deviceId,
|
||||
membershipTs: membership.createdTs(),
|
||||
};
|
||||
});
|
||||
|
||||
let alreadySharedWith = this.outboundSession?.sharedWith ?? [];
|
||||
|
||||
// Some users might have rotate their membership event (formally called fingerprint) meaning they might have
|
||||
// clear their key. Reset the `alreadySharedWith` flag for them.
|
||||
alreadySharedWith = alreadySharedWith.filter(
|
||||
(x) =>
|
||||
// If there was a member with same userId and deviceId but different membershipTs, we need to clear it
|
||||
!toShareWith.some(
|
||||
(o) => x.userId == o.userId && x.deviceId == o.deviceId && x.membershipTs != o.membershipTs,
|
||||
),
|
||||
);
|
||||
|
||||
const anyLeft = alreadySharedWith.filter(
|
||||
(x) =>
|
||||
!toShareWith.some(
|
||||
(o) => x.userId == o.userId && x.deviceId == o.deviceId && x.membershipTs == o.membershipTs,
|
||||
),
|
||||
);
|
||||
const anyJoined = toShareWith.filter(
|
||||
(x) =>
|
||||
!alreadySharedWith.some(
|
||||
(o) => x.userId == o.userId && x.deviceId == o.deviceId && x.membershipTs == o.membershipTs,
|
||||
),
|
||||
);
|
||||
|
||||
let toDistributeTo: ParticipantDeviceInfo[] = [];
|
||||
let outboundKey: OutboundEncryptionSession;
|
||||
let hasKeyChanged = false;
|
||||
if (anyLeft.length > 0) {
|
||||
// We need to rotate the key
|
||||
const newOutboundKey: OutboundEncryptionSession = {
|
||||
key: this.generateRandomKey(),
|
||||
creationTS: Date.now(),
|
||||
sharedWith: [],
|
||||
keyId: this.nextKeyIndex(),
|
||||
};
|
||||
hasKeyChanged = true;
|
||||
|
||||
this.logger?.info(`creating new outbound key index:${newOutboundKey.keyId}`);
|
||||
// Set this new key as the current one
|
||||
this.outboundSession = newOutboundKey;
|
||||
|
||||
// Send
|
||||
toDistributeTo = toShareWith;
|
||||
outboundKey = newOutboundKey;
|
||||
} else if (anyJoined.length > 0) {
|
||||
// keep the same key
|
||||
// XXX In the future we want to distribute a ratcheted key not the current one
|
||||
toDistributeTo = anyJoined;
|
||||
outboundKey = this.outboundSession!;
|
||||
} else {
|
||||
// no changes
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger?.trace(`Sending key...`);
|
||||
await this.transport.sendKey(encodeBase64(outboundKey.key), outboundKey.keyId, toDistributeTo);
|
||||
this.statistics.counters.roomEventEncryptionKeysSent += 1;
|
||||
outboundKey.sharedWith.push(...toDistributeTo);
|
||||
this.logger?.trace(
|
||||
`key index:${outboundKey.keyId} sent to ${outboundKey.sharedWith.map((m) => `${m.userId}:${m.deviceId}`).join(",")}`,
|
||||
);
|
||||
if (hasKeyChanged) {
|
||||
// Delay a bit before using this key
|
||||
// It is recommended not to start using a key immediately but instead wait for a short time to make sure it is delivered.
|
||||
this.logger?.trace(`Delay Rollout for key:${outboundKey.keyId}...`);
|
||||
await sleep(this.delayRolloutTimeMillis);
|
||||
this.logger?.trace(`...Delayed rollout of index:${outboundKey.keyId} `);
|
||||
this.onEncryptionKeysChanged(
|
||||
outboundKey.key,
|
||||
outboundKey.keyId,
|
||||
getParticipantId(this.userId, this.deviceId),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger?.error(`Failed to rollout key`, err);
|
||||
}
|
||||
}
|
||||
|
||||
private nextKeyIndex(): number {
|
||||
if (this.outboundSession) {
|
||||
return (this.outboundSession!.keyId + 1) % 256;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private generateRandomKey(): Uint8Array {
|
||||
const key = new Uint8Array(16);
|
||||
globalThis.crypto.getRandomValues(key);
|
||||
return key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger as rootLogger, type Logger } from "../logger.ts";
|
||||
import { KeyTransportEvents, type KeyTransportEventsHandlerMap, type IKeyTransport } from "./IKeyTransport.ts";
|
||||
import type { RoomKeyTransport } from "./RoomKeyTransport.ts";
|
||||
import { NotSupportedError, type ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { type ParticipantDeviceInfo } from "./types.ts";
|
||||
|
||||
// Deprecate RoomAndToDeviceTransport: This whole class is only a stop gap until we remove RoomKeyTransport.
|
||||
export interface EnabledTransports {
|
||||
toDevice: boolean;
|
||||
room: boolean;
|
||||
}
|
||||
|
||||
export enum RoomAndToDeviceEvents {
|
||||
EnabledTransportsChanged = "enabled_transports_changed",
|
||||
}
|
||||
export type RoomAndToDeviceEventsHandlerMap = {
|
||||
[RoomAndToDeviceEvents.EnabledTransportsChanged]: (enabledTransports: EnabledTransports) => void;
|
||||
};
|
||||
/**
|
||||
* A custom transport that subscribes to room key events (via `RoomKeyTransport`) and to device key events (via: `ToDeviceKeyTransport`)
|
||||
* The public setEnabled method allows to turn one or the other on or off on the fly.
|
||||
* It will emit `RoomAndToDeviceEvents.EnabledTransportsChanged` if the enabled transport changes to allow comminitcating this to
|
||||
* the user in the ui.
|
||||
*
|
||||
* Since it will always subscribe to both (room and to device) but only emit for the enabled ones, it can detect
|
||||
* if a room key event was received and autoenable it.
|
||||
*/
|
||||
export class RoomAndToDeviceTransport
|
||||
extends TypedEventEmitter<
|
||||
KeyTransportEvents | RoomAndToDeviceEvents,
|
||||
KeyTransportEventsHandlerMap & RoomAndToDeviceEventsHandlerMap
|
||||
>
|
||||
implements IKeyTransport
|
||||
{
|
||||
private readonly logger: Logger;
|
||||
private _enabled: EnabledTransports = { toDevice: true, room: false };
|
||||
public constructor(
|
||||
private toDeviceTransport: ToDeviceKeyTransport,
|
||||
private roomKeyTransport: RoomKeyTransport,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
super();
|
||||
this.logger = (parentLogger ?? rootLogger).getChild(`[RoomAndToDeviceTransport]`);
|
||||
// update parent loggers for the sub transports so filtering for `RoomAndToDeviceTransport` contains their logs too
|
||||
this.toDeviceTransport.setParentLogger(this.logger);
|
||||
this.roomKeyTransport.setParentLogger(this.logger);
|
||||
|
||||
this.roomKeyTransport.on(KeyTransportEvents.ReceivedKeys, (...props) => {
|
||||
// Turn on the room transport if we receive a roomKey from another participant
|
||||
// and disable the toDevice transport.
|
||||
if (!this._enabled.room) {
|
||||
this.logger.debug("Received room key, enabling room key transport, disabling toDevice transport");
|
||||
this.setEnabled({ toDevice: false, room: true });
|
||||
}
|
||||
this.emit(KeyTransportEvents.ReceivedKeys, ...props);
|
||||
});
|
||||
this.toDeviceTransport.on(KeyTransportEvents.ReceivedKeys, (...props) => {
|
||||
if (this._enabled.toDevice) {
|
||||
this.emit(KeyTransportEvents.ReceivedKeys, ...props);
|
||||
} else {
|
||||
this.logger.debug("To Device transport is disabled, ignoring received keys");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Set which transport type should be used to send and receive keys.*/
|
||||
public setEnabled(enabled: { toDevice: boolean; room: boolean }): void {
|
||||
if (this.enabled.toDevice !== enabled.toDevice || this.enabled.room !== enabled.room) {
|
||||
this._enabled = enabled;
|
||||
this.emit(RoomAndToDeviceEvents.EnabledTransportsChanged, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/** The currently enabled transports that are used to send and receive keys.*/
|
||||
public get enabled(): EnabledTransports {
|
||||
return this._enabled;
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
// always start the underlying transport since we need to enable room transport
|
||||
// when someone else sends us a room key. (we need to listen to roomKeyTransport)
|
||||
this.roomKeyTransport.start();
|
||||
this.toDeviceTransport.start();
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
// always stop since it is always running
|
||||
this.roomKeyTransport.stop();
|
||||
this.toDeviceTransport.stop();
|
||||
}
|
||||
|
||||
public async sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void> {
|
||||
this.logger.debug(
|
||||
`Sending key with index ${index} to call members (count=${members.length}) via:` +
|
||||
(this._enabled.room ? "room transport" : "") +
|
||||
(this._enabled.room && this._enabled.toDevice ? "and" : "") +
|
||||
(this._enabled.toDevice ? "to device transport" : ""),
|
||||
);
|
||||
if (this._enabled.room) await this.roomKeyTransport.sendKey(keyBase64Encoded, index, members);
|
||||
if (this._enabled.toDevice) {
|
||||
try {
|
||||
await this.toDeviceTransport.sendKey(keyBase64Encoded, index, members);
|
||||
} catch (error) {
|
||||
if (error instanceof NotSupportedError && !this._enabled.room) {
|
||||
this.logger.warn(
|
||||
"To device is not supported enabling room key transport, disabling toDevice transport",
|
||||
);
|
||||
this.setEnabled({ toDevice: false, room: true });
|
||||
await this.sendKey(keyBase64Encoded, index, members);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { MatrixClient } from "../client.ts";
|
||||
import { type EncryptionKeysEventContent, type ParticipantDeviceInfo, type Statistics } from "./types.ts";
|
||||
import { EventType } from "../@types/event.ts";
|
||||
import { type MatrixError } from "../http-api/errors.ts";
|
||||
import { logger as rootLogger, type Logger } from "../logger.ts";
|
||||
import { KeyTransportEvents, type KeyTransportEventsHandlerMap, type IKeyTransport } from "./IKeyTransport.ts";
|
||||
import { type MatrixEvent } from "../models/event.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { type Room, RoomEvent } from "../models/room.ts";
|
||||
|
||||
export class RoomKeyTransport
|
||||
extends TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>
|
||||
implements IKeyTransport
|
||||
{
|
||||
private logger: Logger = rootLogger;
|
||||
public setParentLogger(parentLogger: Logger): void {
|
||||
this.logger = parentLogger.getChild(`[RoomKeyTransport]`);
|
||||
}
|
||||
public constructor(
|
||||
private room: Pick<Room, "on" | "off" | "roomId">,
|
||||
private client: Pick<
|
||||
MatrixClient,
|
||||
"sendEvent" | "getDeviceId" | "getUserId" | "cancelPendingEvent" | "decryptEventIfNeeded"
|
||||
>,
|
||||
private statistics: Statistics,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
super();
|
||||
this.setParentLogger(parentLogger ?? rootLogger);
|
||||
}
|
||||
public start(): void {
|
||||
this.room.on(RoomEvent.Timeline, (ev) => void this.consumeCallEncryptionEvent(ev));
|
||||
}
|
||||
public stop(): void {
|
||||
this.room.off(RoomEvent.Timeline, (ev) => void this.consumeCallEncryptionEvent(ev));
|
||||
}
|
||||
|
||||
private async consumeCallEncryptionEvent(event: MatrixEvent, isRetry = false): Promise<void> {
|
||||
await this.client.decryptEventIfNeeded(event);
|
||||
|
||||
if (event.isDecryptionFailure()) {
|
||||
if (!isRetry) {
|
||||
this.logger.warn(
|
||||
`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason} will retry once only`,
|
||||
);
|
||||
// retry after 1 second. After this we give up.
|
||||
setTimeout(() => void this.consumeCallEncryptionEvent(event, true), 1000);
|
||||
} else {
|
||||
this.logger.warn(`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason}`);
|
||||
}
|
||||
return;
|
||||
} else if (isRetry) {
|
||||
this.logger.info(`Decryption succeeded for event ${event.getId()} after retry`);
|
||||
}
|
||||
|
||||
if (event.getType() !== EventType.CallEncryptionKeysPrefix) return Promise.resolve();
|
||||
|
||||
if (!this.room) {
|
||||
this.logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
this.onEncryptionEvent(event);
|
||||
}
|
||||
|
||||
/** implements {@link IKeyTransport#sendKey} */
|
||||
public async sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void> {
|
||||
// members not used in room transports as the keys are sent to all room members
|
||||
const content: EncryptionKeysEventContent = {
|
||||
keys: [
|
||||
{
|
||||
index: index,
|
||||
key: keyBase64Encoded,
|
||||
},
|
||||
],
|
||||
device_id: this.client.getDeviceId()!,
|
||||
call_id: "",
|
||||
sent_ts: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
await this.client.sendEvent(this.room.roomId, EventType.CallEncryptionKeysPrefix, content);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to send call encryption keys", error);
|
||||
const matrixError = error as MatrixError;
|
||||
if (matrixError.event) {
|
||||
// cancel the pending event: we'll just generate a new one with our latest
|
||||
// keys when we resend
|
||||
this.client.cancelPendingEvent(matrixError.event);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public onEncryptionEvent(event: MatrixEvent): void {
|
||||
const userId = event.getSender();
|
||||
const content = event.getContent<EncryptionKeysEventContent>();
|
||||
|
||||
const deviceId = content["device_id"];
|
||||
const callId = content["call_id"];
|
||||
|
||||
if (!userId) {
|
||||
this.logger.warn(`Received m.call.encryption_keys with no userId: callId=${callId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// We currently only handle callId = "" (which is the default for room scoped calls)
|
||||
if (callId !== "") {
|
||||
this.logger.warn(
|
||||
`Received m.call.encryption_keys with unsupported callId: userId=${userId}, deviceId=${deviceId}, callId=${callId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content.keys)) {
|
||||
this.logger.warn(`Received m.call.encryption_keys where keys wasn't an array: callId=${callId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) {
|
||||
// We store our own sender key in the same set along with keys from others, so it's
|
||||
// important we don't allow our own keys to be set by one of these events (apart from
|
||||
// the fact that we don't need it anyway because we already know our own keys).
|
||||
this.logger.info("Ignoring our own keys event");
|
||||
return;
|
||||
}
|
||||
|
||||
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
|
||||
const age = Date.now() - (typeof content.sent_ts === "number" ? content.sent_ts : event.getTs());
|
||||
this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age;
|
||||
|
||||
for (const key of content.keys) {
|
||||
if (!key) {
|
||||
this.logger.info("Ignoring false-y key in keys event");
|
||||
continue;
|
||||
}
|
||||
|
||||
const encryptionKey = key.key;
|
||||
const encryptionKeyIndex = key.index;
|
||||
|
||||
if (
|
||||
!encryptionKey ||
|
||||
encryptionKeyIndex === undefined ||
|
||||
encryptionKeyIndex === null ||
|
||||
callId === undefined ||
|
||||
callId === null ||
|
||||
typeof deviceId !== "string" ||
|
||||
typeof callId !== "string" ||
|
||||
typeof encryptionKey !== "string" ||
|
||||
typeof encryptionKeyIndex !== "number"
|
||||
) {
|
||||
this.logger.warn(
|
||||
`Malformed call encryption_key: userId=${userId}, deviceId=${deviceId}, encryptionKeyIndex=${encryptionKeyIndex} callId=${callId}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.debug(
|
||||
`onCallEncryption userId=${userId}:${deviceId} encryptionKeyIndex=${encryptionKeyIndex} age=${age}ms`,
|
||||
);
|
||||
this.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
userId,
|
||||
deviceId,
|
||||
encryptionKey,
|
||||
encryptionKeyIndex,
|
||||
event.getTs(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type WidgetApiResponseError } from "matrix-widget-api";
|
||||
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { type IKeyTransport, KeyTransportEvents, type KeyTransportEventsHandlerMap } from "./IKeyTransport.ts";
|
||||
import { type Logger, logger as rootLogger } from "../logger.ts";
|
||||
import { type EncryptionKeysToDeviceEventContent, type ParticipantDeviceInfo, type Statistics } from "./types.ts";
|
||||
import { ClientEvent, type MatrixClient } from "../client.ts";
|
||||
import type { MatrixEvent } from "../models/event.ts";
|
||||
import { EventType } from "../@types/event.ts";
|
||||
|
||||
export class NotSupportedError extends Error {
|
||||
public constructor(message?: string) {
|
||||
super(message);
|
||||
}
|
||||
public get name(): string {
|
||||
return "NotSupportedError";
|
||||
}
|
||||
}
|
||||
/**
|
||||
* ToDeviceKeyTransport is used to send MatrixRTC keys to other devices using the
|
||||
* to-device CS-API.
|
||||
*/
|
||||
export class ToDeviceKeyTransport
|
||||
extends TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>
|
||||
implements IKeyTransport
|
||||
{
|
||||
private logger: Logger = rootLogger;
|
||||
|
||||
public setParentLogger(parentLogger: Logger): void {
|
||||
this.logger = parentLogger.getChild(`[ToDeviceKeyTransport]`);
|
||||
}
|
||||
|
||||
public constructor(
|
||||
private userId: string,
|
||||
private deviceId: string,
|
||||
private roomId: string,
|
||||
private client: Pick<MatrixClient, "encryptAndSendToDevice" | "on" | "off">,
|
||||
private statistics: Statistics,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
super();
|
||||
this.setParentLogger(parentLogger ?? rootLogger);
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
}
|
||||
|
||||
public async sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void> {
|
||||
const content: EncryptionKeysToDeviceEventContent = {
|
||||
keys: {
|
||||
index: index,
|
||||
key: keyBase64Encoded,
|
||||
},
|
||||
room_id: this.roomId,
|
||||
member: {
|
||||
claimed_device_id: this.deviceId,
|
||||
},
|
||||
session: {
|
||||
call_id: "",
|
||||
application: "m.call",
|
||||
scope: "m.room",
|
||||
},
|
||||
sent_ts: Date.now(),
|
||||
};
|
||||
|
||||
const targets = members
|
||||
.map((member) => {
|
||||
return {
|
||||
userId: member.userId!,
|
||||
deviceId: member.deviceId!,
|
||||
};
|
||||
})
|
||||
// filter out me
|
||||
.filter((member) => !(member.userId == this.userId && member.deviceId == this.deviceId));
|
||||
|
||||
if (targets.length > 0) {
|
||||
await this.client
|
||||
.encryptAndSendToDevice(EventType.CallEncryptionKeysPrefix, targets, content)
|
||||
.catch((error: WidgetApiResponseError) => {
|
||||
const msg: string = error.message;
|
||||
// This is not ideal. We would want to have a custom error type for unsupported actions.
|
||||
// This is not part of the widget API spec. Since as of now there are only two implementations:
|
||||
// Rust SDK + JS-SDK, and the JS-SDK does support to-device sending, we can assume that
|
||||
// this is a widget driver issue error message.
|
||||
if (
|
||||
(msg.includes("unknown variant") && msg.includes("send_to_device")) ||
|
||||
msg.includes("not supported")
|
||||
) {
|
||||
throw new NotSupportedError("The widget driver does not support to-device encryption");
|
||||
}
|
||||
});
|
||||
this.statistics.counters.roomEventEncryptionKeysSent += 1;
|
||||
} else {
|
||||
this.logger.warn("No targets found for sending key");
|
||||
}
|
||||
}
|
||||
|
||||
private receiveCallKeyEvent(fromUser: string, content: EncryptionKeysToDeviceEventContent): void {
|
||||
// The event has already been validated at this point.
|
||||
|
||||
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
|
||||
|
||||
// What is this, and why is it needed?
|
||||
// Also to device events do not have an origin server ts
|
||||
const now = Date.now();
|
||||
const age = now - (typeof content.sent_ts === "number" ? content.sent_ts : now);
|
||||
this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age;
|
||||
|
||||
this.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
// TODO this is claimed information
|
||||
fromUser,
|
||||
// TODO: This is claimed information
|
||||
content.member.claimed_device_id!,
|
||||
content.keys.key,
|
||||
content.keys.index,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
private onToDeviceEvent = (event: MatrixEvent): void => {
|
||||
if (event.getType() !== EventType.CallEncryptionKeysPrefix) {
|
||||
// Ignore this is not a call encryption event
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Not possible to check if the event is encrypted or not
|
||||
// see https://github.com/matrix-org/matrix-rust-sdk/issues/4883
|
||||
// if (evnt.getWireType() != EventType.RoomMessageEncrypted) {
|
||||
// // WARN: The call keys were sent in clear. Ignore them
|
||||
// logger.warn(`Call encryption keys sent in clear from: ${event.getSender()}`);
|
||||
// return;
|
||||
// }
|
||||
|
||||
const content = this.getValidEventContent(event);
|
||||
if (!content) return;
|
||||
|
||||
if (!event.getSender()) return;
|
||||
|
||||
this.receiveCallKeyEvent(event.getSender()!, content);
|
||||
};
|
||||
|
||||
private getValidEventContent(event: MatrixEvent): EncryptionKeysToDeviceEventContent | undefined {
|
||||
const content = event.getContent();
|
||||
const roomId = content.room_id;
|
||||
if (!roomId) {
|
||||
// Invalid event
|
||||
this.logger.warn("Malformed Event: invalid call encryption keys event, no roomId");
|
||||
return;
|
||||
}
|
||||
if (roomId !== this.roomId) {
|
||||
this.logger.warn("Malformed Event: Mismatch roomId");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.keys || !content.keys.key || typeof content.keys.index !== "number") {
|
||||
this.logger.warn("Malformed Event: Missing keys field");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.member || !content.member.claimed_device_id) {
|
||||
this.logger.warn("Malformed Event: Missing claimed_device_id");
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO check for session related fields once the to-device encryption uses the new format.
|
||||
return content as EncryptionKeysToDeviceEventContent;
|
||||
}
|
||||
}
|
||||
@@ -20,4 +20,5 @@ export * from "./LivekitFocus.ts";
|
||||
export * from "./MatrixRTCSession.ts";
|
||||
export * from "./MatrixRTCSessionManager.ts";
|
||||
export type * from "./types.ts";
|
||||
export { Status, MembershipManagerEvent } from "./types.ts";
|
||||
export { Status } from "./types.ts";
|
||||
export { MembershipManagerEvent } from "./IMembershipManager.ts";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user