Compare commits

...

65 Commits

Author SHA1 Message Date
RiotRobot cccaaf6e56 v37.6.0-rc.0 2025-05-13 10:54:01 +00:00
Michael Telatynski 4dbca983b4 Fix autodiscovery handling of 2xx (non-200) codes (#4833)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-05-12 18:51:30 +00:00
Michael Telatynski 54e8f3c9d0 Simplify global types (#4831)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-05-12 09:02:49 +00:00
Michael Telatynski 1fcc375dd5 Deprecate utils function defer in favour of Promise.withResolvers (#4829)
* Switch from defer to Promise.withResolvers

As supported by the outgoing LTS version (v22) which has 99% support of ES2024

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* delint

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Deprecate defer instead of killing it

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Knip

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate based on review

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate based on review

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate based on review

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Improve coverage

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-05-09 10:16:35 +00:00
Michael Telatynski d24c5d8b2b Update to Node 22 LTS (#4832)
Switch typescript lib to es2024 and make necessary type changes

Fixes https://github.com/matrix-org/matrix-js-sdk/issues/4796

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-05-08 13:38:18 +00:00
Richard van der Hoff bb9280ad6b Write a log line when cancelling verification (#4828)
... in an attempt to figure out what might have caused a spurious cancellation
today
2025-05-08 09:55:22 +00:00
RiotRobot 00bd7f0f02 Merge branch 'master' into develop 2025-05-06 13:48:19 +00:00
RiotRobot a29b8736f3 v37.5.0 2025-05-06 13:47:50 +00:00
ElementRobot 1c8a1cd5a1 Fix token refresh behaviour for non-expired tokens (#4825) (#4827)
The condition was inverted here, but the tests were passing because
they didn't add enough expiry time for the token expiry to be over
the threshold.

Fix the condition and tests, add another test and generally add a
bunch of comments so hopefully this is less confusing for the next
person.

Fixes https://github.com/element-hq/element-web/issues/29858

(cherry picked from commit fea619d34c)

Co-authored-by: David Baker <dbkr@users.noreply.github.com>
2025-05-06 14:20:24 +01:00
David Baker fea619d34c Fix token refresh behaviour for non-expired tokens (#4825)
The condition was inverted here, but the tests were passing because
they didn't add enough expiry time for the token expiry to be over
the threshold.

Fix the condition and tests, add another test and generally add a
bunch of comments so hopefully this is less confusing for the next
person.

Fixes https://github.com/element-hq/element-web/issues/29858
2025-05-06 09:39:13 +00:00
renovate[bot] f322f32a07 Update eslint (#4786)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-29 11:36:13 +00:00
renovate[bot] bc3abd4394 Update dependency typedoc-plugin-coverage to v4 (#4824)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-29 10:48:34 +00:00
renovate[bot] 6721aca293 Update typescript-eslint monorepo to v8.31.0 (#4823)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-29 10:47:48 +00:00
renovate[bot] 80b51c0e30 Update dependency typedoc to v0.28.3 (#4822)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-29 10:47:35 +00:00
renovate[bot] 91aa1dc092 Update dependency @types/node to v18.19.87 (#4821)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-29 10:47:22 +00:00
renovate[bot] 78972e81d1 Update all non-major dependencies (#4820)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-29 10:47:09 +00:00
RiotRobot 663136a95a v37.5.0-rc.0 2025-04-29 10:42:21 +00:00
Michael Telatynski d67b19fa88 Refactor how token refreshing works to be more resilient (#4819)
* Refactor how token refreshing works to be more resilient

1. ensure we do use the new token if it is not explicitly inhibited by the caller
2. eagerly refresh token if we know it is expired
3. allow refreshing a token multiple times if e.g. on bad connection or the environment has been slept and sufficient time has passed since the last refresh attempt

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add exponential backoff

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Ensure no timing effects on `authedRequest` method call

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-04-29 08:13:27 +00:00
Johannes Marbach 6ec200adcf Stabilise MSC3765 (#4767)
* Stabilise MSC3765

Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>

* Remove unstable content and hardcode property name

---------

Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2025-04-25 12:35:18 +00:00
Timo 19b1b901f5 Inherit methodFactory extensions from the parent to the child loggers. (#4809)
* use methodFactory extensions from the rootLogger in child loggers.

* use simple method factory copy AND `childLogger.setLevel(childLogger.getLevel());`
This is the important part that actually registers the new methods.

* add comments and  find a way to make it clearer that the types are correct.

* review

* additionally fix MatrixRTCSessionManager being initialized before the extension is in place.

* Add comment to clarify order of log extensions and creating childs.

* review

* Set "loglevel" min version to guarantee access to `logger.rebuild`
2025-04-22 16:27:18 +00:00
RiotRobot 1f52fa0c43 Merge branch 'master' into develop 2025-04-22 12:52:54 +00:00
RiotRobot b08c083c46 v37.4.0 2025-04-22 12:52:26 +00:00
Richard van der Hoff 69f1bea89b Update dependency @matrix-org/matrix-sdk-crypto-wasm to v14.1.0 (#4811)
* Convert import/export room key tests to snapshots

* Update dependency @matrix-org/matrix-sdk-crypto-wasm to v14.1.0

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-17 13:51:38 +00:00
renovate[bot] a52c64b747 Update typescript-eslint monorepo to v8.29.1 (#4806)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 14:46:11 +00:00
renovate[bot] de9eb0e7f2 Update all non-major dependencies (#4802)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 14:33:53 +00:00
renovate[bot] a97d7f1133 Update guibranco/github-status-action-v2 digest to 5f2b01c (#4801)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 14:19:39 +00:00
renovate[bot] bc399abf6e Update dependency typescript to v5.8.3 (#4805)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 14:14:39 +00:00
renovate[bot] 1085a2469a Update dependency typedoc to v0.28.2 (#4804)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 14:14:02 +00:00
renovate[bot] 52a9c52b12 Update dependency @types/node to v18.19.86 (#4803)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 14:13:28 +00:00
RiotRobot ac49137c0e v37.4.0-rc.0 2025-04-15 13:21:07 +00:00
Michael Telatynski a179227bf1 Revert "Load gpg key without external action (#4784)" (#4800)
This reverts commit 634651859b.
2025-04-15 12:51:18 +00:00
Timo 64e27f5d3c MatrixRTC: Add combined toDeviceAndRoomKeyTransport (#4792)
* Add to-device and room transport

* Lint

* add doc string

* hook up automatic toDeviceKeyTransport -> roomKeyTransport switching

* lint, rename, imports

* fix logging

* fix test logger

* use mockLogger better in tests

* improve logging and reduce `EnabledTransportsChanged` emission.

* fix this binding

* lint

* simplify `onTransportChanged` callback

* refactor to construct the transports outside the RoomAndToDeviceKeyTransport

* update tests to use new RoomAndToDeiviceTransport constructor

* add depractaion comments
2025-04-14 15:25:30 +00:00
Michael Telatynski 634651859b Load gpg key without external action (#4784)
* Load gpg key ourselves

* Update release-make.yml
2025-04-14 11:25:10 +00:00
Michael Telatynski 480c8e86a4 Fix token refresh racing with other requests and not using new token (#4798)
* Fix token refresh racing with other requests and not using new token

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-04-14 09:11:55 +00:00
Michael Telatynski 1ba4412260 Fix fallback to MemoryCryptoStore when LocalStorage unavailable (#4797)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-04-11 09:27:09 +00:00
Timo e3a3a52f2a Make logging consistent for matrixRTC (#4788)
* Consistent RTC logging

* tests: Add more RTC key transport tests

* test: improve rtc key room transport test

* fixup: missing mock

* rtc: more tests

* coverage trick

---------

Co-authored-by: Valere <bill.carson@valrsoft.com>
2025-04-10 15:51:16 +00:00
Valere Fedronic 3f03c1da89 MatrixRTC: ToDevice distribution for media stream keys (#4785)
* MatrixRTC: ToDevice distribution for media stream keys

* test: Add RTC to device transport test

* lint

* fix key indexing

* fix indexing take two
 - use correct value for: `onEncryptionKeysChanged`
 - only update `latestGeneratedKeyIndex` for "this user" key

* test: add test for join config `useExperimentalToDeviceTransport`

* update test to fail without the fixed encryption key index

* review

* review (dave)

---------

Co-authored-by: Timo <toger5@hotmail.de>
2025-04-10 08:28:01 +00:00
RiotRobot eb793aaa08 Merge branch 'master' into develop 2025-04-08 12:38:12 +00:00
RiotRobot f7bab544a7 v37.3.0 2025-04-08 12:37:43 +00:00
Florian Duros b3fd92ad16 fix(crypto): remove duplicate deleteSecretStorage in RustCrypto.resetEncryption (#4789) 2025-04-08 08:04:05 +00:00
Valere Fedronic ba71235539 MatrixRTC: Introduce key transport abstraction as prep work for to-device encryption (#4773)
* refactor: extract RoomKeyTransport class for key distribution

* refact: Call key transport, pass the target recipients to sendKey

* update IKeyTransport interface to event emitter.

* fix not subscribing to KeyTransportEvents in the EncryptionManager + cleanup

* fix one test and broken bits needed for the test (mostly statistics wrangling)

* fix tests

* add back decryptEventIfNeeded

* move and fix room transport tests

* dedupe isMyMembership

* move type declarations around to be at more reasonable places

* remove deprecated `onMembershipUpdate`

* fix imports

* only start keytransport when session is joined

* use makeKey to reduce test loc

* fix todo comment -> note comment

---------

Co-authored-by: Timo <toger5@hotmail.de>
2025-04-07 08:30:10 +00:00
David Baker d6ede767c9 Upgrade eslint, babel & related deps (#4783)
As they aren't passing the tests upgraded individually
2025-04-02 12:01:43 +00:00
renovate[bot] 43f388d89a Update dependency @types/node to v18.19.84 (#4778)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-01 15:40:35 +00:00
renovate[bot] 1d46b182c6 Update all non-major dependencies (#4777)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-01 15:10:24 +00:00
renovate[bot] 352f6986cd Update crazy-max/ghaction-import-gpg digest to e89d409 (#4774)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-01 14:28:41 +00:00
renovate[bot] 0ca72ec466 Update shogo82148/actions-upload-release-asset digest to d22998f (#4776)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-01 14:28:28 +00:00
renovate[bot] 375f086f44 Update guibranco/github-status-action-v2 digest to 9b1d102 (#4775)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-01 14:27:53 +00:00
Florian Duros 5976083e32 Fix RustCrypto.resetEncryption failure (#4772)
* fix(crypto): add missing await to `resetEncryption`

* test(crypto): add `mockResolvedValue` to async mock of 4S
2025-04-01 12:58:41 +00:00
RiotRobot d53d2dff45 v37.3.0-rc.0 2025-04-01 12:28:09 +00:00
renovate[bot] 3657eb61d8 Update dependency matrix-widget-api to v1.13.1 (#4689)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-31 12:52:27 +00:00
RiotRobot 1def88eb35 Merge branch 'master' into develop 2025-03-25 14:42:58 +00:00
Timo 5a65c8436d MatrixRTC MembershipManger: remove redundant sendDelayedEventAction and expose status (#4747)
* Remove redundant sendDelayedEventAction
We do already have the state `hasMemberEvent` that allows to distinguish the two cases. No need to create two dedicated actions.

* fix missing return

* Make membership manager an event emitter to inform about status updates.
 - deprecate isJoined (replaced by isActivated)
 - move Interface types to types.ts

* add tests for status updates.

* lint

* test "reschedules delayed leave event" in case the delayed event gets canceled

* review

* fix types

* prettier

* fix legacy membership manager
2025-03-25 12:49:47 +00:00
Michael Telatynski 2090319bdd Abstract logout-causing error type from tokenRefreshFunction calls (#4765)
* Abstract logout-causing error type from tokenRefreshFunction calls

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add test

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-03-25 10:31:58 +00:00
renovate[bot] f4de8837fd Update dependency typedoc to ^0.28.0 (#4759)
* Update dependency typedoc to ^0.28.0

* Upgrade typedoc-plugin-missing-exports for compatibility

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Upgrade typedoc-plugin-missing-exports for compatibility

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Bump typedoc

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-03-21 13:36:35 +00:00
Michael Telatynski 1e92c13a75 Improve PushProcessor::getPushRuleGlobRegex (#4764)
* Improve PushProcessor::getPushRuleGlobRegex

Fix cache key not taking non-pattern parameters into account
Use lookarounds to ensure the word boundary isn't treated as part of the match

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add tests

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-03-21 10:57:14 +00:00
Michael Telatynski 8061fa924d Export push processor & method for converting matrix glob to regexp (#4763)
* Export push processor method for converting matrix glob to regexp

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Export pushProcessor from MatrixClient

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add capturing group around pattern match

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Improve comment

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-03-21 09:11:04 +00:00
m004 0760c5f64c Add authenticated media parameter to getMediaConfig (#4762)
* feat(client): Add authenticated media parameter to getMediaConfig

* test(client): add tests for mediaconfig

---------

Co-authored-by: Maurizio <maurizio-noah.abbruzzo-santiago@t-systems.com>
2025-03-20 16:16:11 +00:00
Richard van der Hoff e2bdb2f057 Rust crypto: set a timeout on outgoing HTTP requests (#4761) 2025-03-19 16:37:52 +00:00
David Baker fd47a189e0 Switch sliding sync support to simplified sliding sync (#4400)
* Switch sliding sync support to simplified sliding sync

Experimental PR to test js-sdk with simlified sliding sync.

This does not maintain support for regulaer sliding sync.

* Remove txn_id handling, ensure we always resend when req params change

* Fix some tests

* Fix remaining tests

* Mark TODOs on tests which need to die

* Linting

* Make comments lie less

* void

* Always sent full extension request

* Fix test

* Remove usage of deprecated field

* Hopefully fix DM names

* Refactor how heroes are handled in Room

* Fix how heroes work

* Linting

* Ensure that when SSS omits heroes we don't forget we had heroes

Otherwise when the room next appears the name/avatar reset to
'Empty Room' with no avatar.

* Check the right flag when doing timeline trickling

* Also change when the backpagination token is set

* Remove list ops and server-provided sort positions

SSS doesn't have them.

* Linting

* Add Room.bumpStamp

* Update crypto wasm lib

For new functions

* Add performance logging

* Fix breaking change in crypto wasm v8

* Update crypto wasm for breaking changes

See https://github.com/matrix-org/matrix-rust-sdk-crypto-wasm/releases/tag/v8.0.0
for how this was mapped from the previous API.

* Mark all tracked users as dirty on expired SSS connections

See https://github.com/matrix-org/matrix-rust-sdk/pull/3965 for
more information. Requires `Extension.onRequest` to be `async`.

* add ts extension

* Fix typedoc ref

* Add method to interface

* Don't force membership to invite

The membership was set correctly from the stripped state anyway so
this was redundant and was breaking rooms where we'd knocked.

* Missed merge

* Type import

* Make coverage happier

* More test coverage

* Grammar & formatting

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Remove markAllTrackedUsersAsDirty from crypto API

Not sure why this was in there, seems like it just needed to be in
crypto sync callbacks, which it already was.

* Remove I from interface

* API doc

* Move Hero definition to room-summary

* make comment more specific

* Move internal details into room.ts

and make the comment a proper tsdoc comment

* Use terser arrow function syntax

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Move comment to where we do the lookup

* Clarify comment

also prettier says hi

* Add comment

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Add tsdoc

explaining that the summary event will be modified

* more comment

* Remove unrelated changes

* Add docs & make fields optional

* Type import

* Clarify sync versions

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Make tsdoc comment & add info on when it's used.

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Rephrase comment

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Prettier

* Only fetch member for hero in legacy sync mode

* Split out a separate method to set SSS room summary

Rather than trying to fudge up an object that looked enough like the
old one that we could pass it in.

* Type import

* Make link work

* Nope, linter treats it as an unused import

* Add link the other way

* Add more detail to doc

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Remove unnecessary cast

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Remove length > 0 check

as it wasn't really necessary and may cause heroes not to be cleared?

* Doc params

* Remove unnecessary undefined comparison

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Put the comparison back

as it's necessary to stop typescript complaining

* Fix comment

* Fix comment

---------

Co-authored-by: Kegan Dougal <7190048+kegsay@users.noreply.github.com>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2025-03-18 17:23:45 +00:00
renovate[bot] c233334f27 Update all non-major dependencies (#4758)
* Update all non-major dependencies

* Hold back eslint-plugin-matrix-org

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-03-18 14:20:50 +00:00
renovate[bot] 029ab5f109 Update babel monorepo to v7.26.10 (#4755)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-18 13:59:37 +00:00
renovate[bot] 366f686827 Update guibranco/github-status-action-v2 digest to fe98467 (#4754)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-18 13:53:17 +00:00
renovate[bot] 26387287af Update typescript-eslint monorepo to v8.26.1 (#4757)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-18 13:47:00 +00:00
renovate[bot] ba0bbe1cba Update dependency eslint-import-resolver-typescript to v4 (#4760)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-18 13:43:41 +00:00
renovate[bot] 3c37d7b0fb Update dependency @types/node to v18.19.80 (#4756)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-18 13:42:40 +00:00
90 changed files with 3868 additions and 2883 deletions
@@ -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@d22998fda4c1407f60d1ab48cd6fe67f360f34de # 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@d22998fda4c1407f60d1ab48cd6fe67f360f34de # 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@d22998fda4c1407f60d1ab48cd6fe67f360f34de # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ inputs.asset-path }}
+1 -1
View File
@@ -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 }}
+2 -2
View File
@@ -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@5ef6e175c333bc629f3718b083c8a2ff6e0bbfbc
- uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: pending
@@ -87,7 +87,7 @@ jobs:
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.SONAR_TOKEN }}
- uses: guibranco/github-status-action-v2@5ef6e175c333bc629f3718b083c8a2ff6e0bbfbc
- uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63
if: always()
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -116,7 +116,7 @@ jobs:
steps:
- name: Skip SonarCloud on merge queues
if: env.ENABLE_COVERAGE == 'false'
uses: guibranco/github-status-action-v2@5ef6e175c333bc629f3718b083c8a2ff6e0bbfbc
uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
+42
View File
@@ -1,3 +1,45 @@
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
* MatrixRTC MembershipManger: remove redundant sendDelayedEventAction and expose status ([#4747](https://github.com/matrix-org/matrix-js-sdk/pull/4747)). Contributed by @toger5.
* Abstract logout-causing error type from tokenRefreshFunction calls ([#4765](https://github.com/matrix-org/matrix-js-sdk/pull/4765)). Contributed by @t3chguy.
* Improve PushProcessor::getPushRuleGlobRegex ([#4764](https://github.com/matrix-org/matrix-js-sdk/pull/4764)). Contributed by @t3chguy.
* Export push processor \& method for converting matrix glob to regexp ([#4763](https://github.com/matrix-org/matrix-js-sdk/pull/4763)). Contributed by @t3chguy.
* Add authenticated media parameter to getMediaConfig ([#4762](https://github.com/matrix-org/matrix-js-sdk/pull/4762)). Contributed by @m004.
* Rust crypto: set a timeout on outgoing HTTP requests ([#4761](https://github.com/matrix-org/matrix-js-sdk/pull/4761)). Contributed by @richvdh.
* Switch sliding sync support to simplified sliding sync ([#4400](https://github.com/matrix-org/matrix-js-sdk/pull/4400)). Contributed by @dbkr.
Changes in [37.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.2.0) (2025-03-25)
==================================================================================================
## ✨ Features
+1
View File
@@ -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 -9
View File
@@ -1,9 +1,9 @@
{
"name": "matrix-js-sdk",
"version": "37.2.0",
"version": "37.6.0-rc.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=20.0.0"
"node": ">=22.0.0"
},
"scripts": {
"prepack": "yarn build",
@@ -56,7 +56,7 @@
"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",
@@ -97,11 +97,11 @@
"eslint": "8.57.1",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^10.0.0",
"eslint-import-resolver-typescript": "^3.5.1",
"eslint-import-resolver-typescript": "^4.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "^28.0.0",
"eslint-plugin-jsdoc": "^50.0.0",
"eslint-plugin-matrix-org": "^2.1.0",
"eslint-plugin-matrix-org": "2.1.0",
"eslint-plugin-n": "^14.0.0",
"eslint-plugin-tsdoc": "^0.4.0",
"eslint-plugin-unicorn": "^56.0.0",
@@ -117,13 +117,13 @@
"lint-staged": "^15.0.2",
"matrix-mock-request": "^2.5.0",
"node-fetch": "^2.7.0",
"prettier": "3.5.1",
"prettier": "3.5.3",
"rimraf": "^6.0.0",
"ts-node": "^10.9.2",
"typedoc": "^0.27.0",
"typedoc-plugin-coverage": "^3.0.0",
"typedoc": "^0.28.1",
"typedoc-plugin-coverage": "^4.0.0",
"typedoc-plugin-mdn-links": "^5.0.0",
"typedoc-plugin-missing-exports": "^3.0.0",
"typedoc-plugin-missing-exports": "^4.0.0",
"typescript": "^5.4.2"
},
"@casualbot/jest-sonar-reporter": {
+4 -4
View File
@@ -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 {
@@ -1283,13 +1283,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 +1318,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
+2 -3
View File
@@ -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",
+15 -15
View File
@@ -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 {
@@ -1540,10 +1540,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 +1555,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 +1570,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 +1604,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 +1638,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: {
+45
View File
@@ -157,6 +157,30 @@ describe("MatrixClient", function () {
});
});
describe("mediaConfig", function () {
it("should get media config on unauthenticated media call", async () => {
httpBackend.when("GET", "/_matrix/media/v3/config").respond(200, '{"m.upload.size": 50000000}', true);
const prom = client.getMediaConfig();
httpBackend.flushAllExpected();
expect((await prom)["m.upload.size"]).toEqual(50000000);
});
it("should get media config on authenticated media call", async () => {
httpBackend
.when("GET", "/_matrix/client/v1/media/config")
.respond(200, '{"m.upload.size": 50000000}', true);
const prom = client.getMediaConfig(true);
httpBackend.flushAllExpected();
expect((await prom)["m.upload.size"]).toEqual(50000000);
});
});
describe("joinRoom", function () {
it("should no-op given the ID of a room you've already joined", async () => {
const roomId = "!foo:bar";
@@ -1772,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 {
@@ -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" },
+28 -17
View File
@@ -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";
@@ -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,
@@ -649,11 +648,13 @@ describe("SlidingSyncSdk", () => {
ext = findExtension("e2ee");
});
it("gets enabled on the initial request only", () => {
expect(ext.onRequest(true)).toEqual({
it("gets enabled all the time", async () => {
expect(await ext.onRequest(true)).toEqual({
enabled: true,
});
expect(await ext.onRequest(false)).toEqual({
enabled: true,
});
expect(ext.onRequest(false)).toEqual(undefined);
});
it("can update device lists", () => {
@@ -695,11 +696,13 @@ describe("SlidingSyncSdk", () => {
ext = findExtension("account_data");
});
it("gets enabled on the initial request only", () => {
expect(ext.onRequest(true)).toEqual({
it("gets enabled all the time", async () => {
expect(await ext.onRequest(true)).toEqual({
enabled: true,
});
expect(await ext.onRequest(false)).toEqual({
enabled: true,
});
expect(ext.onRequest(false)).toEqual(undefined);
});
it("processes global account data", async () => {
@@ -823,8 +826,12 @@ describe("SlidingSyncSdk", () => {
ext = findExtension("to_device");
});
it("gets enabled with a limit on the initial request only", () => {
const reqJson: any = ext.onRequest(true);
it("gets enabled all the time", async () => {
let reqJson: any = await ext.onRequest(true);
expect(reqJson.enabled).toEqual(true);
expect(reqJson.limit).toBeGreaterThan(0);
expect(reqJson.since).toBeUndefined();
reqJson = await ext.onRequest(false);
expect(reqJson.enabled).toEqual(true);
expect(reqJson.limit).toBeGreaterThan(0);
expect(reqJson.since).toBeUndefined();
@@ -835,7 +842,7 @@ describe("SlidingSyncSdk", () => {
next_batch: "12345",
events: [],
});
expect(ext.onRequest(false)).toEqual({
expect(await ext.onRequest(false)).toMatchObject({
since: "12345",
});
});
@@ -919,11 +926,13 @@ describe("SlidingSyncSdk", () => {
ext = findExtension("typing");
});
it("gets enabled on the initial request only", () => {
expect(ext.onRequest(true)).toEqual({
it("gets enabled all the time", async () => {
expect(await ext.onRequest(true)).toEqual({
enabled: true,
});
expect(await ext.onRequest(false)).toEqual({
enabled: true,
});
expect(ext.onRequest(false)).toEqual(undefined);
});
it("processes typing notifications", async () => {
@@ -1042,11 +1051,13 @@ describe("SlidingSyncSdk", () => {
ext = findExtension("receipts");
});
it("gets enabled on the initial request only", () => {
expect(ext.onRequest(true)).toEqual({
it("gets enabled all the time", async () => {
expect(await ext.onRequest(true)).toEqual({
enabled: true,
});
expect(await ext.onRequest(false)).toEqual({
enabled: true,
});
expect(ext.onRequest(false)).toEqual(undefined);
});
it("processes receipts", async () => {
+30 -747
View File
@@ -41,7 +41,7 @@ describe("SlidingSync", () => {
const selfUserId = "@alice:localhost";
const selfAccessToken = "aseukfgwef";
const proxyBaseUrl = "http://localhost:8008";
const syncUrl = proxyBaseUrl + "/_matrix/client/unstable/org.matrix.msc3575/sync";
const syncUrl = proxyBaseUrl + "/_matrix/client/unstable/org.matrix.simplified_msc3575/sync";
// assign client/httpBackend globals
const setupClient = () => {
@@ -103,8 +103,8 @@ describe("SlidingSync", () => {
};
const ext: Extension<any, any> = {
name: () => "custom_extension",
onRequest: (initial) => {
return { initial: initial };
onRequest: async (_) => {
return { initial: true };
},
onResponse: async (res) => {
return;
@@ -143,18 +143,16 @@ describe("SlidingSync", () => {
});
await httpBackend!.flushAllExpected();
// expect nothing but ranges and non-initial extensions to be sent
// expect all params to be sent TODO: check MSC4186
httpBackend!
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.debug("got ", body);
expect(body.room_subscriptions).toBeFalsy();
expect(body.lists["a"]).toEqual({
ranges: [[0, 10]],
});
expect(body.lists["a"]).toEqual(listInfo);
expect(body.extensions).toBeTruthy();
expect(body.extensions["custom_extension"]).toEqual({ initial: false });
expect(body.extensions["custom_extension"]).toEqual({ initial: true });
expect(req.queryParams!["pos"]).toEqual("11");
})
.respond(200, function () {
@@ -332,6 +330,7 @@ describe("SlidingSync", () => {
await p;
});
// TODO: this does not exist in MSC4186
it("should be able to unsubscribe from a room", async () => {
httpBackend!
.when("POST", syncUrl)
@@ -389,18 +388,19 @@ describe("SlidingSync", () => {
[3, 5],
];
// request first 3 rooms
const listReq = {
ranges: [[0, 2]],
sort: ["by_name"],
timeline_limit: 1,
required_state: [["m.room.topic", ""]],
filters: {
is_dm: true,
},
};
let slidingSync: SlidingSync;
it("should be possible to subscribe to a list", async () => {
// request first 3 rooms
const listReq = {
ranges: [[0, 2]],
sort: ["by_name"],
timeline_limit: 1,
required_state: [["m.room.topic", ""]],
filters: {
is_dm: true,
},
};
slidingSync = new SlidingSync(proxyBaseUrl, new Map([["a", listReq]]), {}, client!, 1);
httpBackend!
.when("POST", syncUrl)
@@ -452,11 +452,6 @@ describe("SlidingSync", () => {
expect(slidingSync.getListData("b")).toBeNull();
const syncData = slidingSync.getListData("a")!;
expect(syncData.joinedCount).toEqual(500); // from previous test
expect(syncData.roomIndexToRoomId).toEqual({
0: roomA,
1: roomB,
2: roomC,
});
});
it("should be possible to adjust list ranges", async () => {
@@ -467,10 +462,9 @@ describe("SlidingSync", () => {
const body = req.data;
logger.log("next ranges", body.lists["a"].ranges);
expect(body.lists).toBeTruthy();
expect(body.lists["a"]).toEqual({
// only the ranges should be sent as the rest are unchanged and sticky
ranges: newRanges,
});
// list range should be changed
listReq.ranges = newRanges;
expect(body.lists["a"]).toEqual(listReq); // resend all values TODO: check MSC4186
})
.respond(200, {
pos: "b",
@@ -495,7 +489,9 @@ describe("SlidingSync", () => {
await httpBackend!.flushAllExpected();
await responseProcessed;
// setListRanges for an invalid list key returns an error
await expect(slidingSync.setListRanges("idontexist", newRanges)).rejects.toBeTruthy();
expect(() => {
slidingSync.setListRanges("idontexist", newRanges);
}).toThrow();
});
it("should be possible to add an extra list", async () => {
@@ -513,10 +509,7 @@ describe("SlidingSync", () => {
const body = req.data;
logger.log("extra list", body);
expect(body.lists).toBeTruthy();
expect(body.lists["a"]).toEqual({
// only the ranges should be sent as the rest are unchanged and sticky
ranges: newRanges,
});
expect(body.lists["a"]).toEqual(listReq); // resend all values TODO: check MSC4186
expect(body.lists["b"]).toEqual(extraListReq);
})
.respond(200, {
@@ -537,16 +530,6 @@ describe("SlidingSync", () => {
},
},
});
listenUntil(slidingSync, "SlidingSync.List", (listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("b");
expect(joinedCount).toEqual(50);
expect(roomIndexToRoomId).toEqual({
0: roomA,
1: roomB,
2: roomC,
});
return true;
});
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
@@ -554,706 +537,6 @@ describe("SlidingSync", () => {
await httpBackend!.flushAllExpected();
await responseProcessed;
});
it("should be possible to get list DELETE/INSERTs", async () => {
// move C (2) to A (0)
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "e",
lists: {
a: {
count: 500,
ops: [
{
op: "DELETE",
index: 2,
},
{
op: "INSERT",
index: 0,
room_id: roomC,
},
],
},
b: {
count: 50,
},
},
});
let listPromise = listenUntil(
slidingSync,
"SlidingSync.List",
(listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("a");
expect(joinedCount).toEqual(500);
expect(roomIndexToRoomId).toEqual({
0: roomC,
1: roomA,
2: roomB,
});
return true;
},
);
let responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
await httpBackend!.flushAllExpected();
await responseProcessed;
await listPromise;
// move C (0) back to A (2)
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "f",
lists: {
a: {
count: 500,
ops: [
{
op: "DELETE",
index: 0,
},
{
op: "INSERT",
index: 2,
room_id: roomC,
},
],
},
b: {
count: 50,
},
},
});
listPromise = listenUntil(slidingSync, "SlidingSync.List", (listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("a");
expect(joinedCount).toEqual(500);
expect(roomIndexToRoomId).toEqual({
0: roomA,
1: roomB,
2: roomC,
});
return true;
});
responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
await httpBackend!.flushAllExpected();
await responseProcessed;
await listPromise;
});
it("should ignore invalid list indexes", async () => {
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "e",
lists: {
a: {
count: 500,
ops: [
{
op: "DELETE",
index: 2324324,
},
],
},
b: {
count: 50,
},
},
});
const listPromise = listenUntil(
slidingSync,
"SlidingSync.List",
(listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("a");
expect(joinedCount).toEqual(500);
expect(roomIndexToRoomId).toEqual({
0: roomA,
1: roomB,
2: roomC,
});
return true;
},
);
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
await httpBackend!.flushAllExpected();
await responseProcessed;
await listPromise;
});
it("should be possible to update a list", async () => {
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "g",
lists: {
a: {
count: 42,
ops: [
{
op: "INVALIDATE",
range: [0, 2],
},
{
op: "SYNC",
range: [0, 1],
room_ids: [roomB, roomC],
},
],
},
b: {
count: 50,
},
},
});
// update the list with a new filter
slidingSync.setList("a", {
filters: {
is_encrypted: true,
},
ranges: [[0, 100]],
});
const listPromise = listenUntil(
slidingSync,
"SlidingSync.List",
(listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("a");
expect(joinedCount).toEqual(42);
expect(roomIndexToRoomId).toEqual({
0: roomB,
1: roomC,
});
return true;
},
);
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
await httpBackend!.flushAllExpected();
await responseProcessed;
await listPromise;
});
// this refers to a set of operations where the end result is no change.
it("should handle net zero operations correctly", async () => {
const indexToRoomId = {
0: roomB,
1: roomC,
};
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual(indexToRoomId);
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "f",
// currently the list is [B,C] so we will insert D then immediately delete it
lists: {
a: {
count: 500,
ops: [
{
op: "DELETE",
index: 2,
},
{
op: "INSERT",
index: 0,
room_id: roomA,
},
{
op: "DELETE",
index: 0,
},
],
},
b: {
count: 50,
},
},
});
const listPromise = listenUntil(
slidingSync,
"SlidingSync.List",
(listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("a");
expect(joinedCount).toEqual(500);
expect(roomIndexToRoomId).toEqual(indexToRoomId);
return true;
},
);
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
await httpBackend!.flushAllExpected();
await responseProcessed;
await listPromise;
});
it("should handle deletions correctly", async () => {
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({
0: roomB,
1: roomC,
});
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "g",
lists: {
a: {
count: 499,
ops: [
{
op: "DELETE",
index: 0,
},
],
},
b: {
count: 50,
},
},
});
const listPromise = listenUntil(
slidingSync,
"SlidingSync.List",
(listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("a");
expect(joinedCount).toEqual(499);
expect(roomIndexToRoomId).toEqual({
0: roomC,
});
return true;
},
);
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
await httpBackend!.flushAllExpected();
await responseProcessed;
await listPromise;
});
it("should handle insertions correctly", async () => {
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({
0: roomC,
});
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "h",
lists: {
a: {
count: 500,
ops: [
{
op: "INSERT",
index: 1,
room_id: roomA,
},
],
},
b: {
count: 50,
},
},
});
let listPromise = listenUntil(
slidingSync,
"SlidingSync.List",
(listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("a");
expect(joinedCount).toEqual(500);
expect(roomIndexToRoomId).toEqual({
0: roomC,
1: roomA,
});
return true;
},
);
let responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
await httpBackend!.flushAllExpected();
await responseProcessed;
await listPromise;
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "h",
lists: {
a: {
count: 501,
ops: [
{
op: "INSERT",
index: 1,
room_id: roomB,
},
],
},
b: {
count: 50,
},
},
});
listPromise = listenUntil(slidingSync, "SlidingSync.List", (listKey, joinedCount, roomIndexToRoomId) => {
expect(listKey).toEqual("a");
expect(joinedCount).toEqual(501);
expect(roomIndexToRoomId).toEqual({
0: roomC,
1: roomB,
2: roomA,
});
return true;
});
responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
await httpBackend!.flushAllExpected();
await responseProcessed;
await listPromise;
slidingSync.stop();
});
// Regression test to make sure things like DELETE 0 INSERT 0 work correctly and we don't
// end up losing room IDs.
it("should handle insertions with a spurious DELETE correctly", async () => {
slidingSync = new SlidingSync(
proxyBaseUrl,
new Map([
[
"a",
{
ranges: [[0, 20]],
},
],
]),
{},
client!,
1,
);
// initially start with nothing
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "a",
lists: {
a: {
count: 0,
ops: [],
},
},
});
slidingSync.start();
await httpBackend!.flushAllExpected();
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({});
// insert a room
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "b",
lists: {
a: {
count: 1,
ops: [
{
op: "DELETE",
index: 0,
},
{
op: "INSERT",
index: 0,
room_id: roomA,
},
],
},
},
});
await httpBackend!.flushAllExpected();
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({
0: roomA,
});
// insert another room
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "c",
lists: {
a: {
count: 1,
ops: [
{
op: "DELETE",
index: 1,
},
{
op: "INSERT",
index: 0,
room_id: roomB,
},
],
},
},
});
await httpBackend!.flushAllExpected();
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({
0: roomB,
1: roomA,
});
// insert a final room
httpBackend!.when("POST", syncUrl).respond(200, {
pos: "c",
lists: {
a: {
count: 1,
ops: [
{
op: "DELETE",
index: 2,
},
{
op: "INSERT",
index: 0,
room_id: roomC,
},
],
},
},
});
await httpBackend!.flushAllExpected();
expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({
0: roomC,
1: roomB,
2: roomA,
});
slidingSync.stop();
});
});
describe("transaction IDs", () => {
beforeAll(setupClient);
afterAll(teardownClient);
const roomId = "!foo:bar";
let slidingSync: SlidingSync;
// really this applies to them all but it's easier to just test one
it("should resolve modifyRoomSubscriptions after SlidingSync.start() is called", async () => {
const roomSubInfo = {
timeline_limit: 1,
required_state: [["m.room.name", ""]],
};
// add the subscription
slidingSync = new SlidingSync(proxyBaseUrl, new Map(), roomSubInfo, client!, 1);
// modification before SlidingSync.start()
const subscribePromise = slidingSync.modifyRoomSubscriptions(new Set([roomId]));
let txnId: string | undefined;
httpBackend!
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.debug("got ", body);
expect(body.room_subscriptions).toBeTruthy();
expect(body.room_subscriptions[roomId]).toEqual(roomSubInfo);
expect(body.txn_id).toBeTruthy();
txnId = body.txn_id;
})
.respond(200, function () {
return {
pos: "aaa",
txn_id: txnId,
lists: {},
extensions: {},
rooms: {
[roomId]: {
name: "foo bar",
required_state: [],
timeline: [],
},
},
};
});
slidingSync.start();
await httpBackend!.flushAllExpected();
await subscribePromise;
});
it("should resolve setList during a connection", async () => {
const newList = {
ranges: [[0, 20]],
};
const promise = slidingSync.setList("a", newList);
let txnId: string | undefined;
httpBackend!
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.debug("got ", body);
expect(body.room_subscriptions).toBeFalsy();
expect(body.lists["a"]).toEqual(newList);
expect(body.txn_id).toBeTruthy();
txnId = body.txn_id;
})
.respond(200, function () {
return {
pos: "bbb",
txn_id: txnId,
lists: { a: { count: 5 } },
extensions: {},
};
});
await httpBackend!.flushAllExpected();
await promise;
expect(txnId).toBeDefined();
});
it("should resolve setListRanges during a connection", async () => {
const promise = slidingSync.setListRanges("a", [[20, 40]]);
let txnId: string | undefined;
httpBackend!
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.debug("got ", body);
expect(body.room_subscriptions).toBeFalsy();
expect(body.lists["a"]).toEqual({
ranges: [[20, 40]],
});
expect(body.txn_id).toBeTruthy();
txnId = body.txn_id;
})
.respond(200, function () {
return {
pos: "ccc",
txn_id: txnId,
lists: { a: { count: 5 } },
extensions: {},
};
});
await httpBackend!.flushAllExpected();
await promise;
expect(txnId).toBeDefined();
});
it("should resolve modifyRoomSubscriptionInfo during a connection", async () => {
const promise = slidingSync.modifyRoomSubscriptionInfo({
timeline_limit: 99,
});
let txnId: string | undefined;
httpBackend!
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.debug("got ", body);
expect(body.room_subscriptions).toBeTruthy();
expect(body.room_subscriptions[roomId]).toEqual({
timeline_limit: 99,
});
expect(body.txn_id).toBeTruthy();
txnId = body.txn_id;
})
.respond(200, function () {
return {
pos: "ddd",
txn_id: txnId,
extensions: {},
};
});
await httpBackend!.flushAllExpected();
await promise;
expect(txnId).toBeDefined();
});
it("should reject earlier pending promises if a later transaction is acknowledged", async () => {
// i.e if we have [A,B,C] and see txn_id=C then A,B should be rejected.
const gotTxnIds: any[] = [];
const pushTxn = function (req: MockHttpBackend["requests"][0]) {
gotTxnIds.push(req.data.txn_id);
};
const failPromise = slidingSync.setListRanges("a", [[20, 40]]);
httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "e" }); // missing txn_id
await httpBackend!.flushAllExpected();
const failPromise2 = slidingSync.setListRanges("a", [[60, 70]]);
httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "f" }); // missing txn_id
await httpBackend!.flushAllExpected();
const okPromise = slidingSync.setListRanges("a", [[0, 20]]);
let txnId: string | undefined;
httpBackend!
.when("POST", syncUrl)
.check((req) => {
txnId = req.data.txn_id;
})
.respond(200, () => {
// include the txn_id, earlier requests should now be reject()ed.
return {
pos: "g",
txn_id: txnId,
};
});
await Promise.all([
expect(failPromise).rejects.toEqual(gotTxnIds[0]),
expect(failPromise2).rejects.toEqual(gotTxnIds[1]),
httpBackend!.flushAllExpected(),
okPromise,
]);
expect(txnId).toBeDefined();
});
it("should not reject later pending promises if an earlier transaction is acknowledged", async () => {
// i.e if we have [A,B,C] and see txn_id=B then C should not be rejected but A should.
const gotTxnIds: any[] = [];
const pushTxn = function (req: MockHttpBackend["requests"][0]) {
gotTxnIds.push(req.data?.txn_id);
};
const A = slidingSync.setListRanges("a", [[20, 40]]);
httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "A" });
await httpBackend!.flushAllExpected();
const B = slidingSync.setListRanges("a", [[60, 70]]);
httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "B" }); // missing txn_id
await httpBackend!.flushAllExpected();
// attach rejection handlers now else if we do it later Jest treats that as an unhandled rejection
// which is a fail.
const C = slidingSync.setListRanges("a", [[0, 20]]);
let pendingC = true;
C.finally(() => {
pendingC = false;
});
httpBackend!
.when("POST", syncUrl)
.check(pushTxn)
.respond(200, () => {
// include the txn_id for B, so C's promise is outstanding
return {
pos: "C",
txn_id: gotTxnIds[1],
};
});
await Promise.all([
expect(A).rejects.toEqual(gotTxnIds[0]),
httpBackend!.flushAllExpected(),
// A is rejected, see above
expect(B).resolves.toEqual(gotTxnIds[1]), // B is resolved
]);
expect(pendingC).toBe(true); // C is pending still
});
it("should do nothing for unknown txn_ids", async () => {
const promise = slidingSync.setListRanges("a", [[20, 40]]);
let pending = true;
promise.finally(() => {
pending = false;
});
let txnId: string | undefined;
httpBackend!
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.debug("got ", body);
expect(body.room_subscriptions).toBeFalsy();
expect(body.lists["a"]).toEqual({
ranges: [[20, 40]],
});
expect(body.txn_id).toBeTruthy();
txnId = body.txn_id;
})
.respond(200, function () {
return {
pos: "ccc",
txn_id: "bogus transaction id",
lists: { a: { count: 5 } },
extensions: {},
};
});
await httpBackend!.flushAllExpected();
expect(txnId).toBeDefined();
expect(pending).toBe(true);
slidingSync.stop();
});
});
describe("custom room subscriptions", () => {
@@ -1543,7 +826,7 @@ describe("SlidingSync", () => {
const extPre: Extension<any, any> = {
name: () => preExtName,
onRequest: (initial) => {
onRequest: async (initial) => {
return onPreExtensionRequest(initial);
},
onResponse: (res) => {
@@ -1553,7 +836,7 @@ describe("SlidingSync", () => {
};
const extPost: Extension<any, any> = {
name: () => postExtName,
onRequest: (initial) => {
onRequest: async (initial) => {
return onPostExtensionRequest(initial);
},
onResponse: (res) => {
@@ -1568,7 +851,7 @@ describe("SlidingSync", () => {
const callbackOrder: string[] = [];
let extensionOnResponseCalled = false;
onPreExtensionRequest = () => {
onPreExtensionRequest = async () => {
return extReq;
};
onPreExtensionResponse = async (resp) => {
@@ -1608,7 +891,7 @@ describe("SlidingSync", () => {
});
it("should be able to send nothing in an extension request/response", async () => {
onPreExtensionRequest = () => {
onPreExtensionRequest = async () => {
return undefined;
};
let responseCalled = false;
@@ -1643,7 +926,7 @@ describe("SlidingSync", () => {
it("is possible to register extensions after start() has been called", async () => {
slidingSync.registerExtension(extPost);
onPostExtensionRequest = () => {
onPostExtensionRequest = async () => {
return extReq;
};
let responseCalled = false;
+9 -10
View File
@@ -5,7 +5,6 @@ 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";
describe("onResumedSync", () => {
let batch: IndexedToDeviceBatch | null;
@@ -60,7 +59,7 @@ describe("onResumedSync", () => {
});
it("resends queue after connectivity restored", async () => {
const deferred = defer();
const successResolvers = Promise.withResolvers<void>();
onSendToDeviceFailure = () => {
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
@@ -73,15 +72,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 +88,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 +106,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;
});
});
+4 -4
View File
@@ -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",
},
],
});
});
+4 -3
View File
@@ -722,13 +722,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" },
+137 -12
View File
@@ -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);
});
});
});
});
@@ -460,8 +525,8 @@ describe("FetchHttpApi", () => {
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 +538,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 +557,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 +586,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 +610,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");
});
});
+4 -4
View File
@@ -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();
});
+206 -217
View File
@@ -20,7 +20,7 @@ import { DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "../../../sr
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";
const mockFocus = { type: "mock" };
@@ -34,6 +34,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 () => {
@@ -478,19 +480,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();
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 +537,7 @@ describe("MatrixRTCSession", () => {
{
call_id: "",
device_id: "AAAAAAA",
keys: [
{
index: 0,
key: expect.stringMatching(".*"),
},
],
keys: [makeKey(0, expect.stringMatching(".*"))],
sent_ts: Date.now(),
},
);
@@ -584,7 +585,7 @@ describe("MatrixRTCSession", () => {
});
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
jest.advanceTimersByTime(10000);
await jest.runAllTimersAsync();
await eventSentPromise;
@@ -739,12 +740,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 +789,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 +822,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 +835,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 +856,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 +874,24 @@ 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);
// 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);
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 +980,75 @@ 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,
useNewMembershipManager: true,
useExperimentalToDeviceTransport: true,
});
await keySentPromise;
expect(sendToDeviceMock).toHaveBeenCalled();
} 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 +1058,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 +1109,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 +1215,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 +1241,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();
}
});
});
});
+85 -6
View File
@@ -20,11 +20,17 @@ limitations under the License.
import { type MockedFunction, type Mock } from "jest-mock";
import { EventType, HTTPError, MatrixError, UnsupportedDelayedEventsEndpointError, type Room } from "../../../src";
import { type Focus, type LivekitFocusActive, type SessionMembershipData } from "../../../src/matrixrtc";
import {
MembershipManagerEvent,
Status,
type Focus,
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";
function waitForMockCall(method: MockedFunction<any>, returnVal?: Promise<any>) {
return new Promise<void>((resolve) => {
@@ -34,9 +40,17 @@ function waitForMockCall(method: MockedFunction<any>, returnVal?: Promise<any>)
});
});
}
function waitForMockCallOnce(method: MockedFunction<any>, returnVal?: Promise<any>) {
return new Promise<void>((resolve) => {
method.mockImplementationOnce(() => {
resolve();
return returnVal ?? Promise.resolve();
});
});
}
function createAsyncHandle(method: MockedFunction<any>) {
const { reject, resolve, promise } = defer();
const { reject, resolve, promise } = Promise.withResolvers<void>();
method.mockImplementation(() => promise);
return { reject, resolve };
}
@@ -78,16 +92,16 @@ describe.each([
// There is no need to clean up mocks since we will recreate the client.
});
describe("isJoined()", () => {
describe("isActivated()", () => {
it("defaults to false", () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
expect(manager.isJoined()).toEqual(false);
expect(manager.isActivated()).toEqual(false);
});
it("returns true after join()", () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
manager.join([]);
expect(manager.isJoined()).toEqual(true);
expect(manager.isActivated()).toEqual(true);
});
});
@@ -125,6 +139,23 @@ describe.each([
{},
"_@alice:example.org_AAAAAAA",
);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
});
it("reschedules delayed leave event if sending state cancels it", async () => {
const memberManager = new TestMembershipManager(undefined, room, client, () => undefined);
const waitForSendState = waitForMockCall(client.sendStateEvent);
const waitForUpdateDelaye = waitForMockCallOnce(
client._unstable_updateDelayedEvent,
Promise.reject(new MatrixError({ errcode: "M_NOT_FOUND" })),
);
memberManager.join([focus], focusActive);
await waitForSendState;
await waitForUpdateDelaye;
await jest.advanceTimersByTimeAsync(1);
// Once for the initial event and once because of the errcode: "M_NOT_FOUND"
// Different to "sends a membership event and schedules delayed leave when joining a call" where its only called once (1)
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
});
describe("does not prefix the state key with _ for rooms that support user-owned state events", () => {
@@ -505,7 +536,39 @@ describe.each([
await testExpires(10_000, 1_000);
});
});
describe("status updates", () => {
it("starts 'Disconnected' !FailsForLegacy", () => {
const manager = new TestMembershipManager({}, 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);
const manager = new TestMembershipManager({}, room, client, () => undefined);
expect(manager.status).toBe(Status.Disconnected);
const connectEmit = jest.fn();
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
manager.join([focus], focusActive);
expect(manager.status).toBe(Status.Connecting);
handleDelayedEvent.resolve();
await jest.advanceTimersByTimeAsync(1);
expect(connectEmit).toHaveBeenCalledWith(Status.Disconnected, Status.Connecting);
handleStateEvent.resolve();
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);
const connectEmit = jest.fn();
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
await manager.leave();
expect(connectEmit).toHaveBeenCalledWith(Status.Connected, Status.Disconnecting);
expect(connectEmit).toHaveBeenCalledWith(Status.Disconnecting, Status.Disconnected);
});
});
describe("server error handling", () => {
// Types of server error: 429 rate limit with no retry-after header, 429 with retry-after, 50x server error (maybe retry every second), connection/socket timeout
describe("retries sending delayed leave event", () => {
@@ -700,3 +763,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("[NewMembershipManager]")).toBe(true);
});
@@ -0,0 +1,146 @@
/*
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, membershipTemplate, mockCallMembership } 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 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, [mockCallMembership(membershipTemplate, roomId, "@alice:example.org")]);
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, [mockCallMembership(membershipTemplate, roomId, "@alice:example.org")]);
expect(sendEventMock).toHaveBeenCalledTimes(1);
expect(roomSendKeySpy).toHaveBeenCalledTimes(1);
expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(0);
expect(onTransportEnabled).toHaveBeenCalledWith({ toDevice: false, room: true });
});
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,253 @@
/*
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, membershipTemplate, mockCallMembership } 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(),
});
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, [
mockCallMembership(
Object.assign({}, membershipTemplate, { device_id: "BOBDEVICE" }),
roomId,
"@bob:example.org",
),
mockCallMembership(
Object.assign({}, membershipTemplate, { device_id: "CARLDEVICE" }),
roomId,
"@carl:example.org",
),
mockCallMembership(
Object.assign({}, membershipTemplate, { device_id: "MATDEVICE" }),
roomId,
"@mat:example.org",
),
]);
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",
},
},
);
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, [
mockCallMembership(
Object.assign({}, membershipTemplate, { device_id: "MYDEVICE" }),
roomId,
"@alice:example.org",
),
]);
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);
});
});
});
@@ -29,7 +29,7 @@ It is very specific to the MembershipManager.spec.ts file and introduces the fol
import { TestEnvironment } from "jest-environment-jsdom";
import { logger as rootLogger } from "../../../src/logger";
const logger = rootLogger.getChild("MatrixRTCSession");
const logger = rootLogger.getChild("[MatrixRTCSession]");
class MemberManagerTestEnvironment extends TestEnvironment {
handleTestEvent(event: any) {
+36 -10
View File
@@ -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,
};
}
+53 -21
View File
@@ -20,7 +20,7 @@ limitations under the License.
import fetchMock from "fetch-mock-jest";
import { OidcTokenRefresher } from "../../../src";
import { OidcTokenRefresher, TokenRefreshLogoutError } from "../../../src";
import { logger } from "../../../src/logger";
import { makeDelegatedAuthConfig } from "../../test-utils/oidc";
@@ -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,34 @@ 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 () => {
fetchMock.post(
config.token_endpoint,
{
status: 400,
headers: {
"Content-Type": "application/json",
},
body: {
error: "invalid_grant",
error_description: "The provided access grant is invalid, expired, or revoked.",
},
},
{ overwriteRoutes: true },
);
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
await expect(refresher.doRefreshAccessToken("refresh-token")).rejects.toThrow(TokenRefreshLogoutError);
});
});
});
+38
View File
@@ -1,3 +1,19 @@
/*
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 * as utils from "../test-utils/test-utils";
import { type IActionsObject, PushProcessor } from "../../src/pushprocessor";
import {
@@ -1004,3 +1020,25 @@ describe("rewriteDefaultRules", () => {
]);
});
});
describe("getPushRuleGlobRegex", () => {
it("should not confuse flags in cache", () => {
const pattern = "Test";
const regex1 = PushProcessor.getPushRuleGlobRegex(pattern, false, "i");
const regex2 = PushProcessor.getPushRuleGlobRegex(pattern, false, "g");
const regex3 = PushProcessor.getPushRuleGlobRegex(pattern, false, "i");
expect(regex1.flags).toBe("i");
expect(regex2.flags).toBe("g");
expect(regex1).not.toEqual(regex2);
expect(regex1).toEqual(regex3);
});
it("should not include word boundary in match", () => {
const pattern = "@room";
const regex = PushProcessor.getPushRuleGlobRegex(pattern, true);
const input = "Foo @room Bar";
expect(input.split(regex)).toEqual(["Foo ", "@room", " Bar"]);
});
});
+3 -4
View File
@@ -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]);
+15
View File
@@ -1234,6 +1234,16 @@ describe("Room", function () {
expect(room.name).toEqual(nameB);
});
it("supports MSC4186 style heroes", () => {
const nameB = "Bertha Bobbington";
const nameC = "Clarissa Harissa";
addMember(userB, KnownMembership.Join, { name: nameB });
addMember(userC, KnownMembership.Join, { name: nameC });
room.setMSC4186SummaryData([{ user_id: userB }, { user_id: userC }], undefined, undefined);
room.recalculate();
expect(room.name).toEqual(`${nameB} and ${nameC}`);
});
it("reverts to empty room in case of self chat", function () {
room.setSummary({
"m.heroes": [],
@@ -4276,4 +4286,9 @@ describe("Room", function () {
expect(filteredEvents[0].getContent().body).toEqual("ev2");
});
});
it("saves and retrieves the bump stamp", () => {
room.setBumpStamp(123456789);
expect(room.getBumpStamp()).toEqual(123456789);
});
});
@@ -40,7 +40,6 @@ import {
type UIAuthCallback,
} from "../../../src";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import { defer } from "../../../src/utils";
describe("OutgoingRequestProcessor", () => {
/** the OutgoingRequestProcessor implementation under test */
@@ -285,13 +284,13 @@ 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);
@@ -308,7 +307,7 @@ describe("OutgoingRequestProcessor", () => {
olmMachine.close();
// the HTTP request completes...
authRequestResultDefer.resolve("{}");
authRequestResultResolvers.resolve("{}");
// ... and `makeOutgoingRequest` resolves satisfactorily
await result;
@@ -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");
+4 -5
View File
@@ -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 {
@@ -120,8 +119,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 +148,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",
}
`;
+9 -16
View File
@@ -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();
});
});
@@ -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;
+8 -1
View File
@@ -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
View File
@@ -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();
+2 -3
View File
@@ -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);
+18 -19
View File
@@ -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 () => {
+7 -31
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -462,7 +462,7 @@ export class AutoDiscovery {
};
}
if (!response.ok) {
if (response.status !== 200) {
return {
raw: {},
action: AutoDiscoveryAction.FAIL_PROMPT,
+45 -15
View File
@@ -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";
@@ -207,7 +207,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";
@@ -1234,7 +1234,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public canSupport = new Map<Feature, ServerSupport>();
// The pushprocessor caches useful things, so keep one and re-use it
protected pushProcessor = new PushProcessor(this);
public readonly pushProcessor = new PushProcessor(this);
// Promise to a response of the server's /versions response
// TODO: This should expire: https://github.com/matrix-org/matrix-js-sdk/issues/1020
@@ -2069,11 +2069,18 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* Get the config for the media repository.
*
* @param useAuthenticatedMedia - If true, the caller supports authenticated
* media and wants an authentication-required URL. Note that server support
* for authenticated media will *not* be checked - it is the caller's responsibility
* to do so before calling this function.
*
* @returns Promise which resolves with an object containing the config.
*/
public getMediaConfig(): Promise<IMediaConfig> {
return this.http.authedRequest(Method.Get, "/config", undefined, undefined, {
prefix: MediaPrefix.V3,
public getMediaConfig(useAuthenticatedMedia: boolean = false): Promise<IMediaConfig> {
const path = useAuthenticatedMedia ? "/media/config" : "/config";
return this.http.authedRequest(Method.Get, path, undefined, undefined, {
prefix: useAuthenticatedMedia ? ClientPrefix.V1 : MediaPrefix.V3,
});
}
@@ -2184,7 +2191,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
@@ -2196,13 +2203,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);
@@ -5166,24 +5173,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;
}
}
@@ -7935,6 +7942,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
@@ -8194,7 +8224,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const clientTimeout = req.clientTimeout;
delete req.clientTimeout;
return this.http.authedRequest<MSC3575SlidingSyncResponse>(Method.Post, "/sync", qps, req, {
prefix: "/_matrix/client/unstable/org.matrix.msc3575",
prefix: "/_matrix/client/unstable/org.matrix.simplified_msc3575",
baseUrl: proxyBaseUrl,
localTimeoutMs: clientTimeout,
abortSignal,
+9
View File
@@ -138,6 +138,15 @@ export interface SyncCryptoCallbacks {
* @param syncState - information about the completed sync.
*/
onSyncCompleted(syncState: OnSyncCompletedData): void;
/**
* Mark all tracked users' device lists as dirty.
*
* This method will cause additional `/keys/query` requests on the server, so should be used only
* when the client has desynced tracking device list deltas from the server.
* In MSC4186: Simplified Sliding Sync, this can happen when the server expires the connection.
*/
markAllTrackedUsersAsDirty(): Promise<void>;
}
/**
+5 -3
View File
@@ -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 };
};
+4 -1
View File
@@ -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();
}
})
+19 -23
View File
@@ -62,17 +62,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.
@@ -464,6 +453,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 +503,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.
*
+14
View File
@@ -212,3 +212,17 @@ export class TokenRefreshError extends Error {
return "TokenRefreshError";
}
}
/**
* Construct a TokenRefreshError. This indicates that a request failed due to the token being expired,
* and attempting to refresh said token failed in a way indicative of token invalidation.
*/
export class TokenRefreshLogoutError extends Error {
public constructor(cause?: Error) {
super(cause?.message ?? "");
}
public get name(): string {
return "TokenRefreshLogoutError";
}
}
+33 -66
View File
@@ -18,9 +18,7 @@ limitations under the License.
* This is an internal module. See {@link MatrixHttpApi} for the public class.
*/
import { ErrorResponse as OidcAuthError } from "oidc-client-ts";
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 } from "./errors.ts";
@@ -33,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>;
@@ -45,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>,
@@ -61,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 {
@@ -115,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".
@@ -148,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) {
@@ -186,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);
}
@@ -215,32 +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 (error instanceof OidcAuthError || 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".
+10 -10
View File
@@ -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;
+11
View File
@@ -24,9 +24,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.
+167
View File
@@ -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;
}
}
}
+2 -3
View File
@@ -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;
+18 -3
View File
@@ -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);
}
@@ -146,7 +161,7 @@ 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.
*/
export const logger = getPrefixedLogger();
export const logger = getPrefixedLogger() as LoggerWithLogMethod;
/**
* A "span" for grouping related log lines together.
+116 -198
View File
@@ -1,57 +1,49 @@
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 {
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.
*/
onCallEncryptionEventReceived(event: MatrixEvent): void;
getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>>;
statistics: Statistics;
}
/**
@@ -71,9 +63,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 +79,42 @@ 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 +122,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 +141,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 +168,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 +184,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 +198,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 +223,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 +259,67 @@ 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}`,
await this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend, this.getMemberships());
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 +344,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 +357,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 +369,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 +386,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 +400,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.
@@ -493,8 +409,10 @@ 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);
+61
View File
@@ -0,0 +1,61 @@
/*
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";
export enum KeyTransportEvents {
ReceivedKeys = "received_keys",
}
export type KeyTransportEventsHandlerMap = {
[KeyTransportEvents.ReceivedKeys]: KeyTransportEventListener;
};
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: CallMembership[]): 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;
}
+88
View File
@@ -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;
}
+28 -1
View File
@@ -27,7 +27,8 @@ 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 } from "./NewMembershipManager.ts";
import { Status } from "./types.ts";
import type { IMembershipManager, MembershipManagerEvent } from "./IMembershipManager.ts";
/**
* This internal class is used by the MatrixRTCSession to manage the local user's own membership of the session.
@@ -103,9 +104,35 @@ export class LegacyMembershipManager implements IMembershipManager {
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;
+91 -53
View File
@@ -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,13 +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, type IMembershipManager } from "./NewMembershipManager.ts";
import { EncryptionManager, type IEncryptionManager, type Statistics } from "./EncryptionManager.ts";
import { MembershipManager } from "./NewMembershipManager.ts";
import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
import { LegacyMembershipManager } from "./LegacyMembershipManager.ts";
import { logDurationSync } from "../utils.ts";
const logger = rootLogger.getChild("MatrixRTCSession");
import { type Statistics } from "./types.ts";
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
import type { IMembershipManager } from "./IMembershipManager.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.
@@ -123,6 +130,11 @@ 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;
/**
* If true, use the new to-device transport for sending encryption keys.
*/
useExperimentalToDeviceTransport?: boolean;
}
export interface EncryptionConfig {
@@ -156,12 +168,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
@@ -172,9 +187,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.
@@ -192,6 +213,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);
@@ -295,24 +317,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);
},
);
}
/*
@@ -335,6 +357,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,
@@ -352,24 +378,55 @@ 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
// Create MembershipManager and pass the RTCSession logger (with room id info)
if (joinConfig?.useNewMembershipManager ?? false) {
this.membershipManager = new MembershipManager(joinConfig, this.roomSubset, this.client, () =>
this.getOldestMembership(),
this.membershipManager = new MembershipManager(
joinConfig,
this.roomSubset,
this.client,
() => this.getOldestMembership(),
this.logger,
);
} else {
this.membershipManager = new LegacyMembershipManager(joinConfig, this.roomSubset, this.client, () =>
this.getOldestMembership(),
);
}
// Create Encryption manager
let transport;
if (joinConfig?.useExperimentalToDeviceTransport) {
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]);
} else {
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);
},
this.logger,
);
}
// 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());
});
@@ -390,13 +447,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);
@@ -436,7 +493,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);
});
@@ -451,7 +508,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())
@@ -483,25 +540,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.
*/
@@ -534,8 +572,8 @@ 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`);
logDurationSync(this.logger, "emit MatrixRTCSessionEvent.MembershipsChanged", () => {
this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships);
});
@@ -543,7 +581,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();
};
+16 -43
View File
@@ -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 { logger as rootLogger, 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,10 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
// longer the correct session object for the room.
private roomSessions = new Map<string, MatrixRTCSession>();
private logger: Logger;
public constructor(private client: MatrixClient) {
super();
this.logger = rootLogger.getChild("[MatrixRTCSessionManager]");
}
public start(): void {
@@ -65,7 +65,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 +75,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 +98,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 +105,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 +116,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)!);
}
}
+164 -205
View File
@@ -19,115 +19,83 @@ 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 { isMyMembership, Status } from "./types.ts";
import { isLivekitFocusActive } from "./LivekitFocus.ts";
import { type MembershipConfig } from "./MatrixRTCSession.ts";
import { ActionScheduler, type ActionUpdate } from "./NewMembershipManagerActionScheduler.ts";
const logger = rootLogger.getChild("MatrixRTCSession");
/**
* 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.
* (A more accurate name would be `isActivated`)
* @returns true if we intend to be participating in the MatrixRTC session
*/
isJoined(): boolean;
/**
* 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;
}
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import {
MembershipManagerEvent,
type IMembershipManager,
type MembershipManagerEventHandlerMap,
} from "./IMembershipManager.ts";
/* MembershipActionTypes:
SendFirstDelayedEvent
SendJoinEvent
On Join: (1)
SendDelayedEvent (2)
(3)
(4)SendJoinEvent(4)
UpdateExpiry RestartDelayedEvent
UpdateExpiry (s) (s)|RestartDelayedEvent
SendMainDelayedEvent
STOP ALL ABOVE
On Leave: STOP ALL ABOVE
SendScheduledDelayedLeaveEvent
SendScheduledDelayedLeaveEvent
(5)
SendLeaveEvent
(1) [Not found error] results in resending the delayed event
(2) [hasMemberEvent = true] Sending the delayed event if we
already have a call member event results jumping to the
RestartDelayedEvent loop directly
(3) [hasMemberEvent = false] if there is not call member event
sending it is the next step
(4) Both (UpdateExpiry and RestartDelayedEvent) actions are
scheduled when successfully sending the state event
(5) Only if delayed event sending failed (fallback)
(s) Successful restart/resend
*/
/**
* The different types of actions the MembershipManager can take.
* @internal
*/
export enum MembershipActionType {
SendFirstDelayedEvent = "SendFirstDelayedEvent",
SendDelayedEvent = "SendDelayedEvent",
// -> MembershipActionType.SendJoinEvent if successful
// -> DelayedLeaveActionType.SendFirstDelayedEvent on error, retry sending the first delayed event.
// -> DelayedLeaveActionType.SendDelayedEvent on error, retry sending the first delayed event.
// -> DelayedLeaveActionType.RestartDelayedEvent on success start updating the delayed event
SendJoinEvent = "SendJoinEvent",
// -> MembershipActionType.SendJoinEvent if we run into a rate limit and need to retry
// -> MembershipActionType.Update if we successfully send the join event then schedule the expire event update
// -> DelayedLeaveActionType.RestartDelayedEvent to recheck the delayed event
RestartDelayedEvent = "RestartDelayedEvent",
// -> DelayedLeaveActionType.SendMainDelayedEvent on missing delay id but there is a rtc state event
// -> DelayedLeaveActionType.SendFirstDelayedEvent on missing delay id and there is no state event
// -> DelayedLeaveActionType.SendDelayedEvent on missing delay id and there is no state event
// -> DelayedLeaveActionType.RestartDelayedEvent on success we schedule the next restart
UpdateExpiry = "UpdateExpiry",
// -> MembershipActionType.Update if the timeout has passed so the next update is required.
SendMainDelayedEvent = "SendMainDelayedEvent",
// -> DelayedLeaveActionType.RestartDelayedEvent on success start updating the delayed event
// -> DelayedLeaveActionType.SendMainDelayedEvent on error try again
SendScheduledDelayedLeaveEvent = "SendScheduledDelayedLeaveEvent",
// -> MembershipActionType.SendLeaveEvent on failiour (not found) we need to send the leave manually and cannot use the scheduled delayed event
// -> DelayedLeaveActionType.SendScheduledDelayedLeaveEvent on error we try again.
@@ -138,7 +106,7 @@ export enum MembershipActionType {
/**
* @internal
*/
export interface ActionSchedulerState {
export interface MembershipManagerState {
/** The delayId we got when successfully sending the delayed leave event.
* Gets set to undefined if the server claims it cannot find the delayed event anymore. */
delayId?: string;
@@ -158,17 +126,6 @@ export interface ActionSchedulerState {
networkErrorRetries: Map<MembershipActionType, number>;
}
enum Status {
Disconnected = "Disconnected",
Connecting = "Connecting",
ConnectingFailed = "ConnectingFailed",
Connected = "Connected",
Reconnecting = "Reconnecting",
Disconnecting = "Disconnecting",
Stuck = "Stuck",
Unknown = "Unknown",
}
/**
* This class is responsible for sending all events relating to the own membership of a matrixRTC call.
* It has the following tasks:
@@ -182,11 +139,20 @@ enum Status {
* - Stop the timer for the delay refresh
* - Stop the timer for updating the state event
*/
export class MembershipManager implements IMembershipManager {
export class MembershipManager
extends TypedEventEmitter<MembershipManagerEvent, MembershipManagerEventHandlerMap>
implements IMembershipManager
{
private activated = false;
public isJoined(): boolean {
private logger: Logger;
public isActivated(): boolean {
return this.activated;
}
// DEPRECATED use isActivated
public isJoined(): boolean {
return this.isActivated();
}
/**
* Puts the MembershipManager in a state where it tries to be joined.
@@ -198,30 +164,34 @@ export class MembershipManager implements IMembershipManager {
*/
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;
this.scheduler
.startWithJoin()
.then(() => {
if (!this.scheduler.running) {
this.leavePromiseDefer?.resolve(true);
this.leavePromiseDefer = undefined;
}
})
.catch((e) => {
logger.error("MembershipManager stopped because: ", e);
this.logger.error("MembershipManager stopped because: ", e);
onError?.(e);
})
// Should already be set to false when calling `leave` in non error cases.
.finally(() => (this.activated = false));
.finally(() => {
// Should already be set to false when calling `leave` in non error cases.
this.activated = false;
// Here the scheduler is not running anymore so we the `membershipLoopHandler` is not called to emit.
if (this.oldStatus && this.oldStatus !== this.status) {
this.emit(MembershipManagerEvent.StatusChanged, this.oldStatus, this.status);
}
if (!this.scheduler.running) {
this.leavePromiseResolvers?.resolve(true);
this.leavePromiseResolvers = undefined;
}
});
}
/**
@@ -231,37 +201,36 @@ export class MembershipManager implements IMembershipManager {
*/
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 = defer<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?: IDeferred<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.SendFirstDelayedEvent,
MembershipActionType.SendDelayedEvent,
MembershipActionType.SendJoinEvent,
];
logger.warn("Missing own membership: force re-join");
this.logger.warn("Missing own membership: force re-join");
if (this.scheduler.actions.find((a) => sendingMembershipActions.includes(a.type as MembershipActionType))) {
logger.error(
this.logger.error(
"NewMembershipManger tried adding another `SendFirstDelayedEvent` actions even though we already have one in the Queue\nActionQueueOnMemberUpdate:",
this.scheduler.actions,
);
@@ -283,7 +252,7 @@ export class MembershipManager implements IMembershipManager {
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
@@ -312,18 +281,41 @@ export class MembershipManager implements IMembershipManager {
| "_unstable_updateDelayedEvent"
>,
private getOldestMembership: () => CallMembership | undefined,
parentLogger?: Logger,
) {
super();
this.logger = (parentLogger ?? rootLogger).getChild(`[NewMembershipManager]`);
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: ActionSchedulerState;
private static get defaultState(): ActionSchedulerState {
private state: MembershipManagerState;
private static get defaultState(): MembershipManagerState {
return {
hasMemberStateEvent: false,
delayId: undefined,
@@ -376,32 +368,18 @@ export class MembershipManager implements IMembershipManager {
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)
logger.debug(`MembershipManager applied action changes. Status: ${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> {
this.oldStatus = this.status;
switch (type) {
case MembershipActionType.SendFirstDelayedEvent: {
case MembershipActionType.SendDelayedEvent: {
// Before we start we check if we come from a state where we have a delay id.
if (!this.state.delayId) {
return this.sendFirstDelayedLeaveEvent(); // Normal case without any previous delayed id.
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`.
// We might still have our delayed event from the previous participation and dependent on the server this might not
// get automatically removed if the state changes. Hence It would remove our membership unexpectedly shortly after the rejoin.
// 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.
@@ -411,17 +389,10 @@ export class MembershipManager implements IMembershipManager {
case MembershipActionType.RestartDelayedEvent: {
if (!this.state.delayId) {
// Delay id got reset. This action was used to check if the hs canceled the delayed event when the join state got sent.
return createInsertActionUpdate(
this.state.hasMemberStateEvent
? MembershipActionType.SendMainDelayedEvent
: MembershipActionType.SendFirstDelayedEvent,
);
return createInsertActionUpdate(MembershipActionType.SendDelayedEvent);
}
return this.restartDelayedEvent(this.state.delayId);
}
case MembershipActionType.SendMainDelayedEvent: {
return this.sendMainDelayedEvent();
}
case MembershipActionType.SendScheduledDelayedLeaveEvent: {
// We are already good
if (!this.state.hasMemberStateEvent) {
@@ -452,7 +423,11 @@ export class MembershipManager implements IMembershipManager {
}
// HANDLERS (used in the membershipLoopHandler)
private async sendFirstDelayedLeaveEvent(): Promise<ActionUpdate> {
private async sendOrResendDelayedLeaveEvent(): Promise<ActionUpdate> {
// We can reach this at the start of a call (where we do not yet have a membership: state.hasMemberStateEvent=false)
// or during a call if the state event canceled our delayed event or caused by an unexpected error that removed our delayed event.
// (Another client could have canceled it, the homeserver might have removed/lost it due to a restart, ...)
// In the `then` and `catch` block we treat both cases differently. "if (this.state.hasMemberStateEvent) {} else {}"
return await this.client
._unstable_sendDelayedStateEvent(
this.room.roomId,
@@ -465,27 +440,46 @@ export class MembershipManager implements IMembershipManager {
)
.then((response) => {
// On success we reset retries and set delayId.
this.state.rateLimitRetries.set(MembershipActionType.SendFirstDelayedEvent, 0);
this.state.networkErrorRetries.set(MembershipActionType.SendFirstDelayedEvent, 0);
this.resetRateLimitCounter(MembershipActionType.SendDelayedEvent);
this.state.delayId = response.delay_id;
return createInsertActionUpdate(MembershipActionType.SendJoinEvent);
if (this.state.hasMemberStateEvent) {
// This action was scheduled because the previous delayed event was cancelled
// due to lack of https://github.com/element-hq/synapse/pull/17810
return createInsertActionUpdate(
MembershipActionType.RestartDelayedEvent,
this.membershipKeepAlivePeriod,
);
} else {
// This action was scheduled because we are in the process of joining
return createInsertActionUpdate(MembershipActionType.SendJoinEvent);
}
})
.catch((e) => {
const repeatActionType = MembershipActionType.SendFirstDelayedEvent;
const repeatActionType = MembershipActionType.SendDelayedEvent;
if (this.manageMaxDelayExceededSituation(e)) {
return createInsertActionUpdate(repeatActionType);
}
const update = this.actionUpdateFromErrors(e, repeatActionType, "sendDelayedStateEvent");
if (update) return update;
// log and fall through
if (this.isUnsupportedDelayedEndpoint(e)) {
logger.info("Not using delayed event because the endpoint is not supported");
if (this.state.hasMemberStateEvent) {
// This action was scheduled because the previous delayed event was cancelled
// due to lack of https://github.com/element-hq/synapse/pull/17810
// Don't do any other delayed event work if its not supported.
if (this.isUnsupportedDelayedEndpoint(e)) return {};
throw Error("Could not send delayed event, even though delayed events are supported. " + e);
} else {
logger.info("Not using delayed event because: " + e);
// This action was scheduled because we are in the process of joining
// log and fall through
if (this.isUnsupportedDelayedEndpoint(e)) {
this.logger.info("Not using delayed event because the endpoint is not supported");
} else {
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);
}
// On any other error we fall back to not using delayed events and send the join state event immediately
return createInsertActionUpdate(MembershipActionType.SendJoinEvent);
});
}
@@ -495,11 +489,11 @@ export class MembershipManager implements IMembershipManager {
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Cancel)
.then(() => {
this.state.delayId = undefined;
this.resetRateLimitCounter(MembershipActionType.SendFirstDelayedEvent);
return createReplaceActionUpdate(MembershipActionType.SendFirstDelayedEvent);
this.resetRateLimitCounter(MembershipActionType.SendDelayedEvent);
return createReplaceActionUpdate(MembershipActionType.SendDelayedEvent);
})
.catch((e) => {
const repeatActionType = MembershipActionType.SendFirstDelayedEvent;
const repeatActionType = MembershipActionType.SendDelayedEvent;
const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent");
if (update) return update;
@@ -538,7 +532,7 @@ export class MembershipManager implements IMembershipManager {
const repeatActionType = MembershipActionType.RestartDelayedEvent;
if (this.isNotFoundError(e)) {
this.state.delayId = undefined;
return createInsertActionUpdate(MembershipActionType.SendMainDelayedEvent);
return createInsertActionUpdate(MembershipActionType.SendDelayedEvent);
}
// If the HS does not support delayed events we wont reschedule.
if (this.isUnsupportedDelayedEndpoint(e)) return {};
@@ -552,40 +546,6 @@ export class MembershipManager implements IMembershipManager {
});
}
private async sendMainDelayedEvent(): Promise<ActionUpdate> {
return await this.client
._unstable_sendDelayedStateEvent(
this.room.roomId,
{
delay: this.membershipServerSideExpiryTimeout,
},
EventType.GroupCallMemberPrefix,
{}, // leave event
this.stateKey,
)
.then((response) => {
this.state.delayId = response.delay_id;
this.resetRateLimitCounter(MembershipActionType.SendMainDelayedEvent);
return createInsertActionUpdate(
MembershipActionType.RestartDelayedEvent,
this.membershipKeepAlivePeriod,
);
})
.catch((e) => {
const repeatActionType = MembershipActionType.SendMainDelayedEvent;
// Don't do any other delayed event work if its not supported.
if (this.isUnsupportedDelayedEndpoint(e)) return {};
if (this.manageMaxDelayExceededSituation(e)) {
return createInsertActionUpdate(repeatActionType);
}
const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent");
if (update) return update;
throw Error("Could not send delayed event, even though delayed events are supported. " + e);
});
}
private async sendScheduledDelayedLeaveEventOrFallbackToSendLeaveEvent(delayId: string): Promise<ActionUpdate> {
return await this.client
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Send)
@@ -606,7 +566,7 @@ export class MembershipManager implements IMembershipManager {
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,
);
@@ -740,7 +700,7 @@ export class MembershipManager implements IMembershipManager {
if (typeof maxDelayAllowed === "number" && this.membershipServerSideExpiryTimeout > maxDelayAllowed) {
this.membershipServerSideExpiryTimeoutOverride = 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;
@@ -782,9 +742,9 @@ export class MembershipManager implements IMembershipManager {
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,
);
@@ -812,7 +772,7 @@ export class MembershipManager implements IMembershipManager {
const retryDurationString = this.callMemberEventRetryDelayMinimum / 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 +
" " +
@@ -830,12 +790,12 @@ export class MembershipManager implements IMembershipManager {
//
// 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 +
" " +
@@ -848,7 +808,7 @@ export class MembershipManager implements IMembershipManager {
error.httpStatus >= 500 &&
error.httpStatus < 600
) {
logger.warn(
this.logger.warn(
"Server error while sending event, retrying in " + retryDurationString + " " + retryCounterString,
error,
);
@@ -887,9 +847,8 @@ export class MembershipManager implements IMembershipManager {
if (actions.length === 1) {
const { type } = actions[0];
switch (type) {
case MembershipActionType.SendFirstDelayedEvent:
case MembershipActionType.SendDelayedEvent:
case MembershipActionType.SendJoinEvent:
case MembershipActionType.SendMainDelayedEvent:
return Status.Connecting;
case MembershipActionType.UpdateExpiry: // where no delayed events
return Status.Connected;
@@ -904,7 +863,7 @@ export class MembershipManager implements IMembershipManager {
// normal state for connected with delayed events
if (
(types.includes(MembershipActionType.RestartDelayedEvent) ||
types.includes(MembershipActionType.SendMainDelayedEvent)) &&
(types.includes(MembershipActionType.SendDelayedEvent) && this.state.hasMemberStateEvent)) &&
types.includes(MembershipActionType.UpdateExpiry)
) {
return Status.Connected;
@@ -925,7 +884,7 @@ export class MembershipManager implements IMembershipManager {
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;
}
}
@@ -1,10 +1,8 @@
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");
/** @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,11 +71,11 @@ 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;
this._actions = [{ ts: Date.now(), type: MembershipActionType.SendFirstDelayedEvent }];
this._actions = [{ ts: Date.now(), type: MembershipActionType.SendDelayedEvent }];
try {
while (this._actions.length > 0) {
// Sort so next (smallest ts) action is at the beginning
@@ -92,13 +94,13 @@ 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()}`,
);
try {
// `this.wakeup` can also be called and sets the `wakupUpdate` object while we are in the handler.
// `this.wakeup` can also be called and sets the `wakeupUpdate` object while we are in the handler.
handlerResult = await this.membershipLoopHandler(nextAction.type as MembershipActionType);
} catch (e) {
throw Error(`The MembershipManager shut down because of the end condition: ${e}`);
@@ -121,11 +123,11 @@ 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 {
this.wakeup?.({ replace: [{ ts: Date.now(), type: MembershipActionType.SendFirstDelayedEvent }] });
this.wakeup?.({ replace: [{ ts: Date.now(), type: MembershipActionType.SendDelayedEvent }] });
}
public initiateLeave(): void {
this.wakeup?.({ replace: [{ ts: Date.now(), type: MembershipActionType.SendScheduledDelayedLeaveEvent }] });
@@ -0,0 +1,119 @@
/*
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 CallMembership } from "./CallMembership.ts";
import type { RoomKeyTransport } from "./RoomKeyTransport.ts";
import type { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.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: CallMembership[]): 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) await this.toDeviceTransport.sendKey(keyBase64Encoded, index, members);
}
}
+187
View File
@@ -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 { MatrixClient } from "../client.ts";
import type { EncryptionKeysEventContent, 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 { type CallMembership } from "./CallMembership.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: CallMembership[]): 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(),
);
}
}
}
}
+172
View File
@@ -0,0 +1,172 @@
/*
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 { 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 { CallMembership } from "./CallMembership.ts";
import type { EncryptionKeysToDeviceEventContent, Statistics } from "./types.ts";
import { ClientEvent, type MatrixClient } from "../client.ts";
import type { MatrixEvent } from "../models/event.ts";
import { EventType } from "../@types/event.ts";
/**
* 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: CallMembership[]): 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",
},
};
const targets = members
.filter((member) => {
// filter malformed call members
if (member.sender == undefined || member.deviceId == undefined) {
this.logger.warn(`Malformed call member: ${member.sender}|${member.deviceId}`);
return false;
}
// Filter out me
return !(member.sender == this.userId && member.deviceId == this.deviceId);
})
.map((member) => {
return {
userId: member.sender!,
deviceId: member.deviceId!,
};
});
if (targets.length > 0) {
await this.client.encryptAndSendToDevice(EventType.CallEncryptionKeysPrefix, targets, content);
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;
}
}
+2
View File
@@ -20,3 +20,5 @@ export * from "./LivekitFocus.ts";
export * from "./MatrixRTCSession.ts";
export * from "./MatrixRTCSessionManager.ts";
export type * from "./types.ts";
export { Status } from "./types.ts";
export { MembershipManagerEvent } from "./IMembershipManager.ts";
+58 -1
View File
@@ -13,7 +13,9 @@ 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 IMentions } from "../matrix.ts";
import type { IMentions } from "../matrix.ts";
import type { CallMembership } from "./CallMembership.ts";
export interface EncryptionKeyEntry {
index: number;
key: string;
@@ -26,6 +28,24 @@ export interface EncryptionKeysEventContent {
sent_ts?: number;
}
export interface EncryptionKeysToDeviceEventContent {
keys: { index: number; key: string };
member: {
// id: ParticipantId,
// TODO Remove that it is claimed, need to get the sealed sender from decryption info
claimed_device_id: string;
// user_id: string
};
room_id: string;
session: {
application: string;
call_id: string;
scope: string;
};
// Why is this needed?
sent_ts?: number;
}
export type CallNotifyType = "ring" | "notify";
export interface ICallNotifyContent {
@@ -34,3 +54,40 @@ export interface ICallNotifyContent {
"notify_type": CallNotifyType;
"call_id": string;
}
export enum Status {
Disconnected = "Disconnected",
Connecting = "Connecting",
ConnectingFailed = "ConnectingFailed",
Connected = "Connected",
Reconnecting = "Reconnecting",
Disconnecting = "Disconnecting",
Stuck = "Stuck",
Unknown = "Unknown",
}
/**
* 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;
};
};
export const isMyMembership = (m: CallMembership, userId: string, deviceId: string): boolean =>
m.sender === userId && m.deviceId === deviceId;
+31
View File
@@ -14,9 +14,40 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* A stripped m.room.member event which contains the key renderable fields from the event,
* sent only in simplified sliding sync (not `/v3/sync`).
* This is very similar to MSC4186Hero from sliding-sync.ts but an internal format with
* camelCase rather than underscores.
*/
export type Hero = {
userId: string;
displayName?: string;
avatarUrl?: string;
/**
* If true, the hero is from an MSC4186 summary, in which case `displayName` and `avatarUrl` will
* have been set by the server if available. If false, the `Hero` has been constructed from a `/v3/sync` response,
* so these fields will always be undefined.
*/
fromMSC4186: boolean;
};
/**
* High level summary information for a room, as returned by `/v3/sync`.
*/
export interface IRoomSummary {
/**
* The room heroes: a selected set of members that can be used when summarising or
* generating a name for a room. List of user IDs.
*/
"m.heroes": string[];
/**
* The number of joined members in the room.
*/
"m.joined_member_count"?: number;
/**
* The number of invited members in the room.
*/
"m.invited_member_count"?: number;
}
+133 -23
View File
@@ -35,7 +35,7 @@ import {
} from "./event.ts";
import { EventStatus } from "./event-status.ts";
import { RoomMember } from "./room-member.ts";
import { type IRoomSummary, RoomSummary } from "./room-summary.ts";
import { type IRoomSummary, type Hero, RoomSummary } from "./room-summary.ts";
import { logger } from "../logger.ts";
import { TypedReEmitter } from "../ReEmitter.ts";
import {
@@ -77,6 +77,7 @@ import { compareEventOrdering } from "./compare-event-ordering.ts";
import * as utils from "../utils.ts";
import { KnownMembership, type Membership } from "../@types/membership.ts";
import { type Capabilities, type IRoomVersionsCapability, RoomVersionStability } from "../serverCapabilities.ts";
import { type MSC4186Hero } from "../sliding-sync.ts";
// These constants are used as sane defaults when the homeserver doesn't support
// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be
@@ -335,6 +336,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
public readonly reEmitter: TypedReEmitter<RoomEmittedEvents, RoomEventHandlerMap>;
private txnToEvent: Map<string, MatrixEvent> = new Map(); // Pending in-flight requests { string: MatrixEvent }
private notificationCounts: NotificationCount = {};
private bumpStamp: number | undefined = undefined;
private readonly threadNotifications = new Map<string, NotificationCount>();
public readonly cachedThreadReadReceipts = new Map<string, CachedReceiptStructure[]>();
// Useful to know at what point the current user has started using threads in this room
@@ -361,7 +363,16 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
// read by megolm via getter; boolean value - null indicates "use global value"
private blacklistUnverifiedDevices?: boolean;
private selfMembership?: Membership;
private summaryHeroes: string[] | null = null;
/**
* A `Hero` is a stripped `m.room.member` event which contains the important renderable fields from the event.
*
* It is used in MSC4186 (Simplified Sliding Sync) as a replacement for the old `summary` field.
*
* When we are doing old-style (`/v3/sync`) sync, we simulate the SSS behaviour by constructing
* a `Hero` object based on the user id we get from the summary. Obviously, in that case,
* the `Hero` will lack a `displayName` or `avatarUrl`.
*/
private heroes: Hero[] | null = null;
// flags to stop logspam about missing m.room.create events
private getTypeWarning = false;
private getVersionWarning = false;
@@ -879,7 +890,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
// fall back to summary information
const memberCount = this.getInvitedAndJoinedMemberCount();
if (memberCount === 2) {
return this.summaryHeroes?.[0];
return this.heroes?.[0]?.userId;
}
}
}
@@ -897,8 +908,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
}
// Remember, we're assuming this room is a DM, so returning the first member we find should be fine
if (Array.isArray(this.summaryHeroes) && this.summaryHeroes.length) {
return this.summaryHeroes[0];
if (Array.isArray(this.heroes) && this.heroes.length) {
return this.heroes[0].userId;
}
const members = this.currentState.getMembers();
const anyMember = members.find((m) => m.userId !== this.myUserId);
@@ -940,12 +951,45 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
if (nonFunctionalMemberCount > 2) return;
// Prefer the list of heroes, if present. It should only include the single other user in the DM.
const nonFunctionalHeroes = this.summaryHeroes?.filter((h) => !functionalMembers.includes(h));
const nonFunctionalHeroes = this.heroes?.filter((h) => !functionalMembers.includes(h.userId));
const hasHeroes = Array.isArray(nonFunctionalHeroes) && nonFunctionalHeroes.length;
if (hasHeroes) {
// use first hero that has a display name or avatar url, or whose user ID
// can be looked up as a member of the room
for (const hero of nonFunctionalHeroes) {
// If the hero was from a legacy sync (`/v3/sync`), we will need to look the user ID up in the room
// the display name and avatar URL will not be set.
if (!hero.fromMSC4186) {
// attempt to look up renderable fields from the m.room.member event if it exists
const member = this.getMember(hero.userId);
if (member) {
return member;
}
} else {
// use the Hero supplied values for the room member.
// TODO: It's unfortunate that this function, which clearly only cares about the
// avatar url, returns the entire RoomMember event. We need to fake an event
// to meet this API shape.
const heroMember = new RoomMember(this.roomId, hero.userId);
// set the display name and avatar url
heroMember.setMembershipEvent(
new MatrixEvent({
// ensure it's unique even if we hit the same millisecond
event_id: "$" + this.roomId + hero.userId + new Date().getTime(),
type: EventType.RoomMember,
state_key: hero.userId,
content: {
displayname: hero.displayName,
avatar_url: hero.avatarUrl,
},
}),
);
return heroMember;
}
}
const availableMember = nonFunctionalHeroes
.map((userId) => {
return this.getMember(userId);
.map((hero) => {
return this.getMember(hero.userId);
})
.find((member) => !!member);
if (availableMember) {
@@ -970,8 +1014,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
// trust and try falling back to a hero, creating a one-off member for it
if (hasHeroes) {
const availableUser = nonFunctionalHeroes
.map((userId) => {
return this.client.getUser(userId);
.map((hero) => {
return this.client.getUser(hero.userId);
})
.find((user) => !!user);
if (availableUser) {
@@ -1602,6 +1646,24 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
this.emit(RoomEvent.UnreadNotifications);
}
/**
* Set the bump stamp for this room. This can be used for sorting rooms when the timeline
* entries are unknown. Used in MSC4186: Simplified Sliding Sync.
* @param bumpStamp The bump_stamp value from the server
*/
public setBumpStamp(bumpStamp: number): void {
this.bumpStamp = bumpStamp;
}
/**
* Get the bump stamp for this room. This can be used for sorting rooms when the timeline
* entries are unknown. Used in MSC4186: Simplified Sliding Sync.
* @returns The bump stamp for the room, if it exists.
*/
public getBumpStamp(): number | undefined {
return this.bumpStamp;
}
/**
* Set one of the notification counts for this room
* @param type - The type of notification count to set.
@@ -1616,8 +1678,13 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
return this.setUnreadNotificationCount(type, count);
}
/**
* Takes a legacy room summary (/v3/sync as opposed to MSC4186) and updates the room with it.
*
* @param summary - The room summary to update the room with
*/
public setSummary(summary: IRoomSummary): void {
const heroes = summary["m.heroes"];
const heroes = summary["m.heroes"]?.map((h) => ({ userId: h, fromMSC4186: false }));
const joinedCount = summary["m.joined_member_count"];
const invitedCount = summary["m.invited_member_count"];
if (Number.isInteger(joinedCount)) {
@@ -1627,17 +1694,53 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
this.currentState.setInvitedMemberCount(invitedCount!);
}
if (Array.isArray(heroes)) {
// be cautious about trusting server values,
// and make sure heroes doesn't contain our own id
// just to be sure
this.summaryHeroes = heroes.filter((userId) => {
return userId !== this.myUserId;
// filter out ourselves just in case
this.heroes = heroes.filter((h) => {
return h.userId != this.myUserId;
});
}
this.emit(RoomEvent.Summary, summary);
}
/**
* Takes information from the MSC4186 room summary and updates the room with it.
*
* @param heroes - The room's hero members
* @param joinedCount - The number of joined members
* @param invitedCount - The number of invited members
*/
public setMSC4186SummaryData(
heroes: MSC4186Hero[] | undefined,
joinedCount: number | undefined,
invitedCount: number | undefined,
): void {
if (heroes) {
this.heroes = heroes
.filter((h) => h.user_id !== this.myUserId)
.map((h) => ({
userId: h.user_id,
displayName: h.displayname,
avatarUrl: h.avatar_url,
fromMSC4186: true,
}));
}
if (joinedCount !== undefined && Number.isInteger(joinedCount)) {
this.currentState.setJoinedMemberCount(joinedCount);
}
if (invitedCount !== undefined && Number.isInteger(invitedCount)) {
this.currentState.setInvitedMemberCount(invitedCount);
}
// Construct a summary object to emit as the event wants the info in a single object
// more like old-style (/v3/sync) summaries.
this.emit(RoomEvent.Summary, {
"m.heroes": this.heroes ? this.heroes.map((h) => h.userId) : [],
"m.joined_member_count": joinedCount,
"m.invited_member_count": invitedCount,
});
}
/**
* Whether to send encrypted messages to devices within this room.
* @param value - true to blacklist unverified devices, null
@@ -3459,18 +3562,25 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
// get service members (e.g. helper bots) for exclusion
const excludedUserIds = this.getFunctionalMembers();
// get members that are NOT ourselves and are actually in the room.
// get members from heroes that are NOT ourselves
let otherNames: string[] = [];
if (this.summaryHeroes) {
// if we have a summary, the member state events should be in the room state
this.summaryHeroes.forEach((userId) => {
if (this.heroes) {
// if we have heroes, use those as the names
this.heroes.forEach((hero) => {
// filter service members
if (excludedUserIds.includes(userId)) {
if (excludedUserIds.includes(hero.userId)) {
inviteJoinCount--;
return;
}
const member = this.getMember(userId);
otherNames.push(member ? member.name : userId);
// If the hero has a display name, use that.
// Otherwise, look their user ID up in the membership and use
// the name from there, or the user ID as a last resort.
if (hero.displayName) {
otherNames.push(hero.displayName);
} else {
const member = this.getMember(hero.userId);
otherNames.push(member ? member.name : hero.userId);
}
});
} else {
let otherMembers = this.currentState.getMembers().filter((m) => {
+12 -3
View File
@@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type IdTokenClaims, OidcClient, WebStorageStateStore } from "oidc-client-ts";
import { type IdTokenClaims, OidcClient, WebStorageStateStore, ErrorResponse } from "oidc-client-ts";
import { type AccessTokens } from "../http-api/index.ts";
import { type AccessTokens, TokenRefreshLogoutError } from "../http-api/index.ts";
import { generateScope } from "./authorize.ts";
import { discoverAndValidateOIDCIssuerWellKnown } from "./discovery.ts";
import { logger } from "../logger.ts";
@@ -104,6 +104,12 @@ export class OidcTokenRefresher {
try {
const tokens = await this.inflightRefreshRequest;
return tokens;
} catch (e) {
// If we encounter an OIDC error then signal that it should cause a logout by upgrading it to a TokenRefreshLogoutError
if (e instanceof ErrorResponse) {
throw new TokenRefreshLogoutError(e);
}
throw e;
} finally {
this.inflightRefreshRequest = undefined;
}
@@ -133,6 +139,7 @@ export class OidcTokenRefresher {
profile: this.idTokenClaims,
};
const requestStart = Date.now();
const response = await this.oidcClient.useRefreshToken({
state: refreshTokenState,
timeoutInSeconds: 300,
@@ -141,7 +148,9 @@ export class OidcTokenRefresher {
const tokens = {
accessToken: response.access_token,
refreshToken: response.refresh_token,
};
// We use the request start time to calculate the expiry time as we don't know when the server received our request
expiry: response.expires_in ? new Date(requestStart + response.expires_in * 1000) : undefined,
} satisfies AccessTokens;
await this.persistTokens(tokens);
+25 -16
View File
@@ -308,6 +308,28 @@ export class PushProcessor {
return newRules;
}
/**
* Create a RegExp object for the given glob pattern with a single capture group around the pattern itself, caching the result.
* No cache invalidation is present currently,
* as this will be inherently bounded to the size of the user's own push rules.
* @param pattern - the glob pattern to convert to a RegExp
* @param alignToWordBoundary - whether to align the pattern to word boundaries,
* as specified for `content.body` matches, will use lookaround assertions to ensure the match only includes the pattern
* @param flags - the flags to pass to the RegExp constructor, defaults to case-insensitive
*/
public static getPushRuleGlobRegex(pattern: string, alignToWordBoundary = false, flags = "i"): RegExp {
const [prefix, suffix] = alignToWordBoundary ? ["(?<=^|\\W)", "(?=\\W|$)"] : ["^", "$"];
const cacheKey = `${alignToWordBoundary}-${flags}-${pattern}`;
if (!PushProcessor.cachedGlobToRegex[cacheKey]) {
PushProcessor.cachedGlobToRegex[cacheKey] = new RegExp(
prefix + "(" + globToRegexp(pattern) + ")" + suffix,
flags,
);
}
return PushProcessor.cachedGlobToRegex[cacheKey];
}
/**
* Pre-caches the parsed keys for push rules and cleans out any obsolete cache
* entries. Should be called after push rules are updated.
@@ -567,11 +589,9 @@ export class PushProcessor {
return false;
}
const regex =
cond.key === "content.body"
? this.createCachedRegex("(^|\\W)", cond.pattern, "(\\W|$)")
: this.createCachedRegex("^", cond.pattern, "$");
// Align to word boundary on `content.body` matches, whole string otherwise
// https://spec.matrix.org/v1.13/client-server-api/#conditions-1
const regex = PushProcessor.getPushRuleGlobRegex(cond.pattern, cond.key === "content.body");
return !!val.match(regex);
}
@@ -621,17 +641,6 @@ export class PushProcessor {
);
}
private createCachedRegex(prefix: string, glob: string, suffix: string): RegExp {
if (PushProcessor.cachedGlobToRegex[glob]) {
return PushProcessor.cachedGlobToRegex[glob];
}
PushProcessor.cachedGlobToRegex[glob] = new RegExp(
prefix + globToRegexp(glob) + suffix,
"i", // Case insensitive
);
return PushProcessor.cachedGlobToRegex[glob];
}
/**
* Parse the key into the separate fields to search by splitting on
* unescaped ".", and then removing any escape characters.
@@ -219,6 +219,12 @@ export class OutgoingRequestProcessor {
// we use the full prefix
prefix: "",
// We set a timeout of 60 seconds to guard against requests getting stuck forever and wedging the
// request loop (cf https://github.com/element-hq/element-web/issues/29534).
//
// (XXX: should we do this in the whole of the js-sdk?)
localTimeoutMs: 60000,
};
return await this.http.authedRequest<string>(method, path, queryParams, body, opts);
+5 -5
View File
@@ -18,7 +18,7 @@ import { type OlmMachine, type OutgoingRequest } from "@matrix-org/matrix-sdk-cr
import { type OutgoingRequestProcessor } from "./OutgoingRequestProcessor.ts";
import { type Logger } from "../logger.ts";
import { defer, type IDeferred, logDuration } from "../utils.ts";
import { logDuration } from "../utils.ts";
/**
* OutgoingRequestsManager: responsible for processing outgoing requests from the OlmMachine.
@@ -39,7 +39,7 @@ export class OutgoingRequestsManager {
* will resolve once that next iteration completes. If it is undefined, there have been no new calls
* to `doProcessOutgoingRequests` since the current iteration started.
*/
private nextLoopDeferred?: IDeferred<void>;
private nextLoopDeferred?: PromiseWithResolvers<void>;
public constructor(
private readonly logger: Logger,
@@ -74,7 +74,7 @@ export class OutgoingRequestsManager {
// In order to circumvent the race, we set a flag which tells the loop to go round once again even if the
// queue appears to be empty.
if (!this.nextLoopDeferred) {
this.nextLoopDeferred = defer();
this.nextLoopDeferred = Promise.withResolvers();
}
// ... and wait for it to complete.
@@ -99,14 +99,14 @@ export class OutgoingRequestsManager {
this.outgoingRequestLoopRunning = true;
try {
while (!this.stopped && this.nextLoopDeferred) {
const deferred = this.nextLoopDeferred;
const loopTickResolvers = this.nextLoopDeferred;
// reset `nextLoopDeferred` so that any future calls to `doProcessOutgoingRequests` are queued
// for another additional iteration.
this.nextLoopDeferred = undefined;
// make the requests and feed the results back to the `nextLoopDeferred`
await this.processOutgoingRequests().then(deferred.resolve, deferred.reject);
await this.processOutgoingRequests().then(loopTickResolvers.resolve, loopTickResolvers.reject);
}
} finally {
this.outgoingRequestLoopRunning = false;
+25 -44
View File
@@ -969,15 +969,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
);
return requests
.filter((request) => request.roomId === undefined)
.map(
(request) =>
new RustVerificationRequest(
this.olmMachine,
request,
this.outgoingRequestProcessor,
this._supportedVerificationMethods,
),
);
.map((request) => this.makeVerificationRequest(request));
}
/**
@@ -1002,12 +994,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
const request = requests.find((request) => request.roomId?.toString() === roomId);
if (request) {
return new RustVerificationRequest(
this.olmMachine,
request,
this.outgoingRequestProcessor,
this._supportedVerificationMethods,
);
return this.makeVerificationRequest(request);
}
}
@@ -1038,12 +1025,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
new RustSdkCryptoJs.EventId(eventId),
methods,
);
return new RustVerificationRequest(
this.olmMachine,
request,
this.outgoingRequestProcessor,
this._supportedVerificationMethods,
);
return this.makeVerificationRequest(request);
} finally {
userIdentity.free();
}
@@ -1114,12 +1096,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
this._supportedVerificationMethods.map(verificationMethodIdentifierToMethod),
);
await this.outgoingRequestProcessor.makeOutgoingRequest(outgoingRequest);
return new RustVerificationRequest(
this.olmMachine,
request,
this.outgoingRequestProcessor,
this._supportedVerificationMethods,
);
return this.makeVerificationRequest(request);
} finally {
userIdentity.free();
}
@@ -1152,12 +1129,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
this._supportedVerificationMethods.map(verificationMethodIdentifierToMethod),
);
await this.outgoingRequestProcessor.makeOutgoingRequest(outgoingRequest);
return new RustVerificationRequest(
this.olmMachine,
request,
this.outgoingRequestProcessor,
this._supportedVerificationMethods,
);
return this.makeVerificationRequest(request);
} finally {
device.free();
}
@@ -1465,7 +1437,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
// Disable backup, and delete all the backups from the server
await this.backupManager.deleteAllKeyBackupVersions();
this.deleteSecretStorage();
await this.deleteSecretStorage();
// Reset the cross-signing keys
await this.crossSigningIdentity.bootstrapCrossSigning({
@@ -1635,7 +1607,6 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
/** called by the sync loop after processing each sync.
*
* TODO: figure out something equivalent for sliding sync.
*
* @param syncState - information on the completed sync.
*/
@@ -1647,6 +1618,13 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
});
}
/**
* Implementation of {@link CryptoApi#markAllTrackedUsersAsDirty}.
*/
public async markAllTrackedUsersAsDirty(): Promise<void> {
await this.olmMachine.markAllTrackedUsersAsDirty();
}
/**
* Handle an incoming m.key.verification.request event, received either in-room or in a to-device message.
*
@@ -1661,15 +1639,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
);
if (request) {
this.emit(
CryptoEvent.VerificationRequestReceived,
new RustVerificationRequest(
this.olmMachine,
request,
this.outgoingRequestProcessor,
this._supportedVerificationMethods,
),
);
this.emit(CryptoEvent.VerificationRequestReceived, this.makeVerificationRequest(request));
} else {
// There are multiple reasons this can happen; probably the most likely is that the event is an
// in-room event which is too old.
@@ -1679,6 +1649,17 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
}
}
/** Utility function to wrap a rust `VerificationRequest` with our own {@link VerificationRequest}. */
private makeVerificationRequest(request: RustSdkCryptoJs.VerificationRequest): VerificationRequest {
return new RustVerificationRequest(
this.logger,
this.olmMachine,
request,
this.outgoingRequestProcessor,
this._supportedVerificationMethods,
);
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Other public functions
+6 -3
View File
@@ -34,8 +34,8 @@ import { type OutgoingRequestProcessor } from "./OutgoingRequestProcessor.ts";
import { TypedReEmitter } from "../ReEmitter.ts";
import { type MatrixEvent } from "../models/event.ts";
import { EventType, MsgType } from "../@types/event.ts";
import { defer, type IDeferred } from "../utils.ts";
import { VerificationMethod } from "../types.ts";
import type { Logger } from "../logger.ts";
/**
* An incoming, or outgoing, request to verify a user or a device via cross-signing.
@@ -60,12 +60,14 @@ export class RustVerificationRequest
/**
* Construct a new RustVerificationRequest to wrap the rust-level `VerificationRequest`.
*
* @param logger - A logger instance which will be used to log events.
* @param olmMachine - The `OlmMachine` from the underlying rust crypto sdk.
* @param inner - VerificationRequest from the Rust SDK.
* @param outgoingRequestProcessor - `OutgoingRequestProcessor` to use for making outgoing HTTP requests.
* @param supportedVerificationMethods - Verification methods to use when `accept()` is called.
*/
public constructor(
private readonly logger: Logger,
private readonly olmMachine: RustSdkCryptoJs.OlmMachine,
private readonly inner: RustSdkCryptoJs.VerificationRequest,
private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
@@ -309,6 +311,7 @@ export class RustVerificationRequest
return;
}
this.logger.info("Cancelling verification request with params:", params);
this._cancelling = true;
try {
const req: undefined | OutgoingRequest = this.inner.cancel();
@@ -474,7 +477,7 @@ abstract class BaseRustVerifer<InnerType extends RustSdkCryptoJs.Qr | RustSdkCry
VerifierEventHandlerMap & VerificationRequestEventHandlerMap
> {
/** A deferred which completes when the verification completes (or rejects when it is cancelled/fails) */
protected readonly completionDeferred: IDeferred<void>;
protected readonly completionDeferred: PromiseWithResolvers<void>;
public constructor(
protected inner: InnerType,
@@ -482,7 +485,7 @@ abstract class BaseRustVerifer<InnerType extends RustSdkCryptoJs.Qr | RustSdkCry
) {
super();
this.completionDeferred = defer();
this.completionDeferred = Promise.withResolvers();
// As with RustVerificationRequest, we need to avoid a reference cycle.
// See the comments in RustVerificationRequest.
+8 -8
View File
@@ -21,7 +21,7 @@ limitations under the License.
import { logger } from "./logger.ts";
import { type MatrixEvent } from "./models/event.ts";
import { EventType } from "./@types/event.ts";
import { defer, type IDeferred, removeElement } from "./utils.ts";
import { removeElement } from "./utils.ts";
import { calculateRetryBackoff, type MatrixError } from "./http-api/index.ts";
import { type ISendEventResponse } from "./@types/requests.ts";
@@ -29,7 +29,7 @@ const DEBUG = false; // set true to enable console logging.
interface IQueueEntry<T> {
event: MatrixEvent;
defer: IDeferred<T>;
resolvers: PromiseWithResolvers<T>;
attempts: number;
}
@@ -70,7 +70,7 @@ export class MatrixScheduler<T = ISendEventResponse> {
// queueName: [{
// event: MatrixEvent, // event to send
// defer: Deferred, // defer to resolve/reject at the END of the retries
// defer: PromiseWithResolvers, // defer to resolve/reject at the END of the retries
// attempts: Number // number of times we've called processFn
// }, ...]
private readonly queues: Record<string, IQueueEntry<T>[]> = {};
@@ -188,15 +188,15 @@ export class MatrixScheduler<T = ISendEventResponse> {
if (!this.queues[queueName]) {
this.queues[queueName] = [];
}
const deferred = defer<T>();
const eventResolvers = Promise.withResolvers<T>();
this.queues[queueName].push({
event: event,
defer: deferred,
resolvers: eventResolvers,
attempts: 0,
});
debuglog("Queue algorithm dumped event %s into queue '%s'", event.getId(), queueName);
this.startProcessingQueues();
return deferred.promise;
return eventResolvers.promise;
}
private startProcessingQueues(): void {
@@ -239,7 +239,7 @@ export class MatrixScheduler<T = ISendEventResponse> {
// remove this from the queue
this.removeNextEvent(queueName);
debuglog("Queue '%s' sent event %s", queueName, obj.event.getId());
obj.defer.resolve(res);
obj.resolvers.resolve(res);
// keep processing
this.processQueue(queueName);
},
@@ -279,7 +279,7 @@ export class MatrixScheduler<T = ISendEventResponse> {
logger.info("clearing queue '%s'", queueName);
let obj: IQueueEntry<T> | undefined;
while ((obj = this.removeNextEvent(queueName))) {
obj.defer.reject(err);
obj.resolvers.reject(err);
}
this.disableQueue(queueName);
}
+28 -28
View File
@@ -82,9 +82,16 @@ class ExtensionE2EE implements Extension<ExtensionE2EERequest, ExtensionE2EEResp
return ExtensionState.PreProcess;
}
public onRequest(isInitial: boolean): ExtensionE2EERequest | undefined {
if (!isInitial) {
return undefined;
public async onRequest(isInitial: boolean): Promise<ExtensionE2EERequest> {
if (isInitial) {
// In SSS, the `?pos=` contains the stream position for device list updates.
// If we do not have a `?pos=` (e.g because we forgot it, or because the server
// invalidated our connection) then we MUST invlaidate all device lists because
// the server will not tell us the delta. This will then cause UTDs as we will fail
// to encrypt for new devices. This is an expensive call, so we should
// really really remember `?pos=` wherever possible.
logger.log("ExtensionE2EE: invalidating all device lists due to missing 'pos'");
await this.crypto.markAllTrackedUsersAsDirty();
}
return {
enabled: true, // this is sticky so only send it on the initial request
@@ -134,15 +141,12 @@ class ExtensionToDevice implements Extension<ExtensionToDeviceRequest, Extension
return ExtensionState.PreProcess;
}
public onRequest(isInitial: boolean): ExtensionToDeviceRequest {
const extReq: ExtensionToDeviceRequest = {
public async onRequest(isInitial: boolean): Promise<ExtensionToDeviceRequest> {
return {
since: this.nextBatch !== null ? this.nextBatch : undefined,
limit: 100,
enabled: true,
};
if (isInitial) {
extReq["limit"] = 100;
extReq["enabled"] = true;
}
return extReq;
}
public async onResponse(data: ExtensionToDeviceResponse): Promise<void> {
@@ -216,10 +220,7 @@ class ExtensionAccountData implements Extension<ExtensionAccountDataRequest, Ext
return ExtensionState.PostProcess;
}
public onRequest(isInitial: boolean): ExtensionAccountDataRequest | undefined {
if (!isInitial) {
return undefined;
}
public async onRequest(isInitial: boolean): Promise<ExtensionAccountDataRequest> {
return {
enabled: true,
};
@@ -286,10 +287,7 @@ class ExtensionTyping implements Extension<ExtensionTypingRequest, ExtensionTypi
return ExtensionState.PostProcess;
}
public onRequest(isInitial: boolean): ExtensionTypingRequest | undefined {
if (!isInitial) {
return undefined; // don't send a JSON object for subsequent requests, we don't need to.
}
public async onRequest(isInitial: boolean): Promise<ExtensionTypingRequest> {
return {
enabled: true,
};
@@ -325,13 +323,10 @@ class ExtensionReceipts implements Extension<ExtensionReceiptsRequest, Extension
return ExtensionState.PostProcess;
}
public onRequest(isInitial: boolean): ExtensionReceiptsRequest | undefined {
if (isInitial) {
return {
enabled: true,
};
}
return undefined; // don't send a JSON object for subsequent requests, we don't need to.
public async onRequest(isInitial: boolean): Promise<ExtensionReceiptsRequest> {
return {
enabled: true,
};
}
public async onResponse(data: ExtensionReceiptsResponse): Promise<void> {
@@ -442,6 +437,7 @@ export class SlidingSyncSdk {
}
} else {
this.failCount = 0;
logger.log(`SlidingSyncState.RequestFinished with ${Object.keys(resp?.rooms || []).length} rooms`);
}
break;
}
@@ -580,7 +576,7 @@ export class SlidingSyncSdk {
// TODO: handle threaded / beacon events
if (roomData.initial) {
if (roomData.limited || roomData.initial) {
// we should not know about any of these timeline entries if this is a genuinely new room.
// If we do, then we've effectively done scrollback (e.g requesting timeline_limit: 1 for
// this room, then timeline_limit: 50).
@@ -637,6 +633,9 @@ export class SlidingSyncSdk {
room.setUnreadNotificationCount(NotificationCountType.Highlight, roomData.highlight_count);
}
}
if (roomData.bump_stamp) {
room.setBumpStamp(roomData.bump_stamp);
}
if (Number.isInteger(roomData.invited_count)) {
room.currentState.setInvitedMemberCount(roomData.invited_count!);
@@ -656,11 +655,10 @@ export class SlidingSyncSdk {
inviteStateEvents.forEach((e) => {
this.client.emit(ClientEvent.Event, e);
});
room.updateMyMembership(KnownMembership.Invite);
return;
}
if (roomData.initial) {
if (roomData.limited) {
// set the back-pagination token. Do this *before* adding any
// events so that clients can start back-paginating.
room.getLiveTimeline().setPaginationToken(roomData.prev_batch ?? null, EventTimeline.BACKWARDS);
@@ -728,6 +726,8 @@ export class SlidingSyncSdk {
// synchronous execution prior to emitting SlidingSyncState.Complete
room.updateMyMembership(KnownMembership.Join);
room.setMSC4186SummaryData(roomData.heroes, roomData.joined_count, roomData.invited_count);
room.recalculate();
if (roomData.initial) {
client.store.storeRoom(room);
+38 -329
View File
@@ -1,5 +1,5 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2022-2024 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.
@@ -18,7 +18,7 @@ import { logger } from "./logger.ts";
import { type MatrixClient } from "./client.ts";
import { type IRoomEvent, type IStateEvent } from "./sync-accumulator.ts";
import { TypedEventEmitter } from "./models/typed-event-emitter.ts";
import { sleep, type IDeferred, defer } from "./utils.ts";
import { sleep } from "./utils.ts";
import { type HTTPError } from "./http-api/index.ts";
// /sync requests allow you to set a timeout= but the request may continue
@@ -82,10 +82,23 @@ export interface MSC3575SlidingSyncRequest {
clientTimeout?: number;
}
/**
* New format of hero introduced in MSC4186 with display name and avatar URL
* in addition to just user_id (as it is on the wire, with underscores)
* as opposed to Hero in room-summary.ts which has fields in camelCase
* (and also a flag to note what format the hero came from).
*/
export interface MSC4186Hero {
user_id: string;
displayname?: string;
avatar_url?: string;
}
export interface MSC3575RoomData {
name: string;
required_state: IStateEvent[];
timeline: (IRoomEvent | IStateEvent)[];
heroes?: MSC4186Hero[];
notification_count?: number;
highlight_count?: number;
joined_count?: number;
@@ -96,41 +109,13 @@ export interface MSC3575RoomData {
is_dm?: boolean;
prev_batch?: string;
num_live?: number;
bump_stamp?: number;
}
interface ListResponse {
count: number;
ops: Operation[];
}
interface BaseOperation {
op: string;
}
interface DeleteOperation extends BaseOperation {
op: "DELETE";
index: number;
}
interface InsertOperation extends BaseOperation {
op: "INSERT";
index: number;
room_id: string;
}
interface InvalidateOperation extends BaseOperation {
op: "INVALIDATE";
range: [number, number];
}
interface SyncOperation extends BaseOperation {
op: "SYNC";
range: [number, number];
room_ids: string[];
}
type Operation = DeleteOperation | InsertOperation | InvalidateOperation | SyncOperation;
/**
* A complete Sliding Sync response
*/
@@ -163,7 +148,6 @@ class SlidingList {
private isModified?: boolean;
// returned data
public roomIndexToRoomId: Record<number, string> = {};
public joinedCount = 0;
/**
@@ -204,9 +188,6 @@ class SlidingList {
// reset values as the join count may be very different (if filters changed) including the rooms
// (e.g. sort orders or sliding window ranges changed)
// the constantly changing sliding window ranges. Not an array for performance reasons
// E.g. tracking ranges 0-99, 500-599, we don't want to have a 600 element array
this.roomIndexToRoomId = {};
// the total number of joined rooms according to the server, always >= len(roomIndexToRoomId)
this.joinedCount = 0;
}
@@ -226,26 +207,6 @@ class SlidingList {
}
return list;
}
/**
* Check if a given index is within the list range. This is required even though the /sync API
* provides explicit updates with index positions because of the following situation:
* 0 1 2 3 4 5 6 7 8 indexes
* a b c d e f COMMANDS: SYNC 0 2 a b c; SYNC 6 8 d e f;
* a b c d _ f COMMAND: DELETE 7;
* e a b c d f COMMAND: INSERT 0 e;
* c=3 is wrong as we are not tracking it, ergo we need to see if `i` is in range else drop it
* @param i - The index to check
* @returns True if the index is within a sliding window
*/
public isIndexInRange(i: number): boolean {
for (const r of this.list.ranges) {
if (r[0] <= i && i <= r[1]) {
return true;
}
}
return false;
}
}
/**
@@ -274,10 +235,10 @@ export interface Extension<Req extends object, Res extends object> {
/**
* A function which is called when the request JSON is being formed.
* Returns the data to insert under this key.
* @param isInitial - True when this is part of the initial request (send sticky params)
* @param isInitial - True when this is part of the initial request.
* @returns The request JSON to send.
*/
onRequest(isInitial: boolean): Req | undefined;
onRequest(isInitial: boolean): Promise<Req>;
/**
* A function which is called when there is response JSON under this extension.
* @param data - The response JSON under the extension name.
@@ -295,12 +256,10 @@ export interface Extension<Req extends object, Res extends object> {
* of information when processing sync responses.
* - RoomData: concerns rooms, useful for SlidingSyncSdk to update its knowledge of rooms.
* - Lifecycle: concerns callbacks at various well-defined points in the sync process.
* - List: concerns lists, useful for UI layers to re-render room lists.
* Specifically, the order of event invocation is:
* - Lifecycle (state=RequestFinished)
* - RoomData (N times)
* - Lifecycle (state=Complete)
* - List (at most once per list)
*/
export enum SlidingSyncEvent {
/**
@@ -313,16 +272,9 @@ export enum SlidingSyncEvent {
* - SlidingSyncState.RequestFinished: Fires after we receive a valid response but before the
* response has been processed. Perform any pre-process steps here. If there was a problem syncing,
* `err` will be set (e.g network errors).
* - SlidingSyncState.Complete: Fires after all SlidingSyncEvent.RoomData have been fired but before
* SlidingSyncEvent.List.
* - SlidingSyncState.Complete: Fires after the response has been processed.
*/
Lifecycle = "SlidingSync.Lifecycle",
/**
* This event fires whenever there has been a change to this list index. It fires exactly once
* per list, even if there were multiple operations for the list.
* It fires AFTER Lifecycle and RoomData events.
*/
List = "SlidingSync.List",
}
export type SlidingSyncEventHandlerMap = {
@@ -332,7 +284,6 @@ export type SlidingSyncEventHandlerMap = {
resp: MSC3575SlidingSyncResponse | null,
err?: Error,
) => void;
[SlidingSyncEvent.List]: (listKey: string, joinedCount: number, roomIndexToRoomId: Record<number, string>) => void;
};
/**
@@ -347,11 +298,6 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
private terminated = false;
// flag set when resend() is called because we cannot rely on detecting AbortError in JS SDK :(
private needsResend = false;
// the txn_id to send with the next request.
private txnId: string | null = null;
// a list (in chronological order of when they were sent) of objects containing the txn ID and
// a defer to resolve/reject depending on whether they were successfully sent or not.
private txnIdDefers: (IDeferred<string> & { txnId: string })[] = [];
// map of extension name to req/resp handler
private extensions: Record<string, Extension<any, any>> = {};
@@ -426,14 +372,13 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
* @param key - The list key
* @returns The list data which contains the rooms in this list
*/
public getListData(key: string): { joinedCount: number; roomIndexToRoomId: Record<number, string> } | null {
public getListData(key: string): { joinedCount: number } | null {
const data = this.lists.get(key);
if (!data) {
return null;
}
return {
joinedCount: data.joinedCount,
roomIndexToRoomId: Object.assign({}, data.roomIndexToRoomId),
};
}
@@ -461,13 +406,13 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
* (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
* immediately after sending, in which case the action will be applied in the subsequent request)
*/
public setListRanges(key: string, ranges: number[][]): Promise<string> {
public setListRanges(key: string, ranges: number[][]): void {
const list = this.lists.get(key);
if (!list) {
return Promise.reject(new Error("no list with key " + key));
throw new Error("no list with key " + key);
}
list.updateListRange(ranges);
return this.resend();
this.resend();
}
/**
@@ -479,7 +424,7 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
* (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
* immediately after sending, in which case the action will be applied in the subsequent request)
*/
public setList(key: string, list: MSC3575List): Promise<string> {
public setList(key: string, list: MSC3575List): void {
const existingList = this.lists.get(key);
if (existingList) {
existingList.replaceList(list);
@@ -488,7 +433,7 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
this.lists.set(key, new SlidingList(list));
}
this.listModifiedCount += 1;
return this.resend();
this.resend();
}
/**
@@ -504,27 +449,21 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
* /sync request to resend new subscriptions. If the /sync stream has not started, this will
* prepare the room subscriptions for when start() is called.
* @param s - The new desired room subscriptions.
* @returns A promise which resolves to the transaction ID when it has been received down sync
* (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
* immediately after sending, in which case the action will be applied in the subsequent request)
*/
public modifyRoomSubscriptions(s: Set<string>): Promise<string> {
public modifyRoomSubscriptions(s: Set<string>): void {
this.desiredRoomSubscriptions = s;
return this.resend();
this.resend();
}
/**
* Modify which events to retrieve for room subscriptions. Invalidates all room subscriptions
* such that they will be sent up afresh.
* @param rs - The new room subscription fields to fetch.
* @returns A promise which resolves to the transaction ID when it has been received down sync
* (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
* immediately after sending, in which case the action will be applied in the subsequent request)
*/
public modifyRoomSubscriptionInfo(rs: MSC3575RoomSubscription): Promise<string> {
public modifyRoomSubscriptionInfo(rs: MSC3575RoomSubscription): void {
this.roomSubscriptionInfo = rs;
this.confirmedRoomSubscriptions = new Set<string>();
return this.resend();
this.resend();
}
/**
@@ -538,11 +477,11 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
this.extensions[ext.name()] = ext;
}
private getExtensionRequest(isInitial: boolean): Record<string, object | undefined> {
private async getExtensionRequest(isInitial: boolean): Promise<Record<string, object | undefined>> {
const ext: Record<string, object | undefined> = {};
Object.keys(this.extensions).forEach((extName) => {
ext[extName] = this.extensions[extName].onRequest(isInitial);
});
for (const extName in this.extensions) {
ext[extName] = await this.extensions[extName].onRequest(isInitial);
}
return ext;
}
@@ -595,203 +534,13 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
this.emit(SlidingSyncEvent.Lifecycle, state, resp, err);
}
private shiftRight(listKey: string, hi: number, low: number): void {
const list = this.lists.get(listKey);
if (!list) {
return;
}
// l h
// 0,1,2,3,4 <- before
// 0,1,2,2,3 <- after, hi is deleted and low is duplicated
for (let i = hi; i > low; i--) {
if (list.isIndexInRange(i)) {
list.roomIndexToRoomId[i] = list.roomIndexToRoomId[i - 1];
}
}
}
private shiftLeft(listKey: string, hi: number, low: number): void {
const list = this.lists.get(listKey);
if (!list) {
return;
}
// l h
// 0,1,2,3,4 <- before
// 0,1,3,4,4 <- after, low is deleted and hi is duplicated
for (let i = low; i < hi; i++) {
if (list.isIndexInRange(i)) {
list.roomIndexToRoomId[i] = list.roomIndexToRoomId[i + 1];
}
}
}
private removeEntry(listKey: string, index: number): void {
const list = this.lists.get(listKey);
if (!list) {
return;
}
// work out the max index
let max = -1;
for (const n in list.roomIndexToRoomId) {
if (Number(n) > max) {
max = Number(n);
}
}
if (max < 0 || index > max) {
return;
}
// Everything higher than the gap needs to be shifted left.
this.shiftLeft(listKey, max, index);
delete list.roomIndexToRoomId[max];
}
private addEntry(listKey: string, index: number): void {
const list = this.lists.get(listKey);
if (!list) {
return;
}
// work out the max index
let max = -1;
for (const n in list.roomIndexToRoomId) {
if (Number(n) > max) {
max = Number(n);
}
}
if (max < 0 || index > max) {
return;
}
// Everything higher than the gap needs to be shifted right, +1 so we don't delete the highest element
this.shiftRight(listKey, max + 1, index);
}
private processListOps(list: ListResponse, listKey: string): void {
let gapIndex = -1;
const listData = this.lists.get(listKey);
if (!listData) {
return;
}
list.ops.forEach((op: Operation) => {
if (!listData) {
return;
}
switch (op.op) {
case "DELETE": {
logger.debug("DELETE", listKey, op.index, ";");
delete listData.roomIndexToRoomId[op.index];
if (gapIndex !== -1) {
// we already have a DELETE operation to process, so process it.
this.removeEntry(listKey, gapIndex);
}
gapIndex = op.index;
break;
}
case "INSERT": {
logger.debug("INSERT", listKey, op.index, op.room_id, ";");
if (listData.roomIndexToRoomId[op.index]) {
// something is in this space, shift items out of the way
if (gapIndex < 0) {
// we haven't been told where to shift from, so make way for a new room entry.
this.addEntry(listKey, op.index);
} else if (gapIndex > op.index) {
// the gap is further down the list, shift every element to the right
// starting at the gap so we can just shift each element in turn:
// [A,B,C,_] gapIndex=3, op.index=0
// [A,B,C,C] i=3
// [A,B,B,C] i=2
// [A,A,B,C] i=1
// Terminate. We'll assign into op.index next.
this.shiftRight(listKey, gapIndex, op.index);
} else if (gapIndex < op.index) {
// the gap is further up the list, shift every element to the left
// starting at the gap so we can just shift each element in turn
this.shiftLeft(listKey, op.index, gapIndex);
}
}
// forget the gap, we don't need it anymore. This is outside the check for
// a room being present in this index position because INSERTs always universally
// forget the gap, not conditionally based on the presence of a room in the INSERT
// position. Without this, DELETE 0; INSERT 0; would do the wrong thing.
gapIndex = -1;
listData.roomIndexToRoomId[op.index] = op.room_id;
break;
}
case "INVALIDATE": {
const startIndex = op.range[0];
for (let i = startIndex; i <= op.range[1]; i++) {
delete listData.roomIndexToRoomId[i];
}
logger.debug("INVALIDATE", listKey, op.range[0], op.range[1], ";");
break;
}
case "SYNC": {
const startIndex = op.range[0];
for (let i = startIndex; i <= op.range[1]; i++) {
const roomId = op.room_ids[i - startIndex];
if (!roomId) {
break; // we are at the end of list
}
listData.roomIndexToRoomId[i] = roomId;
}
logger.debug("SYNC", listKey, op.range[0], op.range[1], (op.room_ids || []).join(" "), ";");
break;
}
}
});
if (gapIndex !== -1) {
// we already have a DELETE operation to process, so process it
// Everything higher than the gap needs to be shifted left.
this.removeEntry(listKey, gapIndex);
}
}
/**
* Resend a Sliding Sync request. Used when something has changed in the request. Resolves with
* the transaction ID of this request on success. Rejects with the transaction ID of this request
* on failure.
* Resend a Sliding Sync request. Used when something has changed in the request.
*/
public resend(): Promise<string> {
if (this.needsResend && this.txnIdDefers.length > 0) {
// we already have a resend queued, so just return the same promise
return this.txnIdDefers[this.txnIdDefers.length - 1].promise;
}
public resend(): void {
this.needsResend = true;
this.txnId = this.client.makeTxnId();
const d = defer<string>();
this.txnIdDefers.push({
...d,
txnId: this.txnId,
});
this.abortController?.abort();
this.abortController = new AbortController();
return d.promise;
}
private resolveTransactionDefers(txnId?: string): void {
if (!txnId) {
return;
}
// find the matching index
let txnIndex = -1;
for (let i = 0; i < this.txnIdDefers.length; i++) {
if (this.txnIdDefers[i].txnId === txnId) {
txnIndex = i;
break;
}
}
if (txnIndex === -1) {
// this shouldn't happen; we shouldn't be seeing txn_ids for things we don't know about,
// whine about it.
logger.warn(`resolveTransactionDefers: seen ${txnId} but it isn't a pending txn, ignoring.`);
return;
}
// This list is sorted in time, so if the input txnId ACKs in the middle of this array,
// then everything before it that hasn't been ACKed yet never will and we should reject them.
for (let i = 0; i < txnIndex; i++) {
this.txnIdDefers[i].reject(this.txnIdDefers[i].txnId);
}
this.txnIdDefers[txnIndex].resolve(txnId);
// clear out settled promises, including the one we resolved.
this.txnIdDefers = this.txnIdDefers.slice(txnIndex + 1);
}
/**
@@ -802,7 +551,6 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
this.abortController?.abort();
// remove all listeners so things can be GC'd
this.removeAllListeners(SlidingSyncEvent.Lifecycle);
this.removeAllListeners(SlidingSyncEvent.List);
this.removeAllListeners(SlidingSyncEvent.RoomData);
}
@@ -811,20 +559,13 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
*/
private resetup(): void {
logger.warn("SlidingSync: resetting connection info");
// any pending txn ID defers will be forgotten already by the server, so clear them out
this.txnIdDefers.forEach((d) => {
d.reject(d.txnId);
});
this.txnIdDefers = [];
// resend sticky params and de-confirm all subscriptions
this.lists.forEach((l) => {
l.setModified(true);
});
this.confirmedRoomSubscriptions = new Set<string>(); // leave desired ones alone though!
// reset the connection as we might be wedged
this.needsResend = true;
this.abortController?.abort();
this.abortController = new AbortController();
this.resend();
}
/**
@@ -836,20 +577,18 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
let currentPos: string | undefined;
while (!this.terminated) {
this.needsResend = false;
let doNotUpdateList = false;
let resp: MSC3575SlidingSyncResponse | undefined;
try {
const listModifiedCount = this.listModifiedCount;
const reqLists: Record<string, MSC3575List> = {};
this.lists.forEach((l: SlidingList, key: string) => {
reqLists[key] = l.getList(false);
reqLists[key] = l.getList(true);
});
const reqBody: MSC3575SlidingSyncRequest = {
lists: reqLists,
pos: currentPos,
timeout: this.timeoutMS,
clientTimeout: this.timeoutMS + BUFFER_PERIOD_MS,
extensions: this.getExtensionRequest(currentPos === undefined),
extensions: await this.getExtensionRequest(currentPos === undefined),
};
// check if we are (un)subscribing to a room and modify request this one time for it
const newSubscriptions = difference(this.desiredRoomSubscriptions, this.confirmedRoomSubscriptions);
@@ -868,10 +607,6 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
reqBody.room_subscriptions[roomId] = sub;
}
}
if (this.txnId) {
reqBody.txn_id = this.txnId;
this.txnId = null;
}
this.pendingReq = this.client.slidingSync(reqBody, this.proxyBaseUrl, this.abortController.signal);
resp = await this.pendingReq;
currentPos = resp.pos;
@@ -882,13 +617,6 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
for (const roomId of unsubscriptions) {
this.confirmedRoomSubscriptions.delete(roomId);
}
if (listModifiedCount !== this.listModifiedCount) {
// the lists have been modified whilst we were waiting for 'await' to return, but the abort()
// call did nothing. It is NOT SAFE to modify the list array now. We'll process the response but
// not update list pointers.
logger.debug("list modified during await call, not updating list");
doNotUpdateList = true;
}
// mark all these lists as having been sent as sticky so we don't keep sending sticky params
this.lists.forEach((l) => {
l.setModified(false);
@@ -931,27 +659,8 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
await this.invokeRoomDataListeners(roomId, resp!.rooms[roomId]);
}
const listKeysWithUpdates: Set<string> = new Set();
if (!doNotUpdateList) {
for (const [key, list] of Object.entries(resp.lists)) {
list.ops = list.ops ?? [];
if (list.ops.length > 0) {
listKeysWithUpdates.add(key);
}
this.processListOps(list, key);
}
}
this.invokeLifecycleListeners(SlidingSyncState.Complete, resp);
await this.onPostExtensionsResponse(resp.extensions);
listKeysWithUpdates.forEach((listKey: string) => {
const list = this.lists.get(listKey);
if (!list) {
return;
}
this.emit(SlidingSyncEvent.List, listKey, list.joinedCount, Object.assign({}, list.roomIndexToRoomId));
});
this.resolveTransactionDefers(resp.txn_id);
}
}
}
+2 -3
View File
@@ -15,7 +15,6 @@ limitations under the License.
*/
import { logger } from "../logger.ts";
import { defer, type IDeferred } from "../utils.ts";
import { type ISavedSync } from "./index.ts";
import { type IStoredClientOpts } from "../client.ts";
import { type IStateEventWithRoomId, type ISyncResponse } from "../matrix.ts";
@@ -26,7 +25,7 @@ export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend {
private worker?: Worker;
private nextSeq = 0;
// The currently in-flight requests to the actual backend
private inFlight: Record<number, IDeferred<any>> = {}; // seq: promise
private inFlight: Record<number, PromiseWithResolvers<any>> = {}; // seq: promise
// Once we start connecting, we keep the promise and re-use it
// if we try to connect again
private startPromise?: Promise<void>;
@@ -164,7 +163,7 @@ export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend {
// the promise automatically gets rejected
return Promise.resolve().then(() => {
const seq = this.nextSeq++;
const def = defer<T>();
const def = Promise.withResolvers<T>();
this.inFlight[seq] = def;
+19 -19
View File
@@ -28,7 +28,7 @@ import { type Optional } from "matrix-events-sdk";
import type { SyncCryptoCallbacks } from "./common-crypto/CryptoBackend.ts";
import { User } from "./models/user.ts";
import { NotificationCountType, Room, RoomEvent } from "./models/room.ts";
import { deepCopy, defer, type IDeferred, noUnsafeEventProps, promiseMapSeries, unsafeProp } from "./utils.ts";
import { deepCopy, noUnsafeEventProps, promiseMapSeries, unsafeProp } from "./utils.ts";
import { Filter } from "./filter.ts";
import { EventTimeline } from "./models/event-timeline.ts";
import { logger } from "./logger.ts";
@@ -220,7 +220,7 @@ export class SyncApi {
private catchingUp = false;
private running = false;
private keepAliveTimer?: ReturnType<typeof setTimeout>;
private connectionReturnedDefer?: IDeferred<boolean>;
private connectionReturnedResolvers?: PromiseWithResolvers<boolean>;
private notifEvents: MatrixEvent[] = []; // accumulator of sync events in the current sync response
private failedSyncCount = 0; // Number of consecutive failed /sync requests
private storeIsInvalid = false; // flag set if the store needs to be cleared before we can start
@@ -792,7 +792,7 @@ export class SyncApi {
* @returns True if this resulted in a request being retried.
*/
public retryImmediately(): boolean {
if (!this.connectionReturnedDefer) {
if (!this.connectionReturnedResolvers) {
return false;
}
this.startKeepAlives(0);
@@ -916,9 +916,9 @@ export class SyncApi {
if (!this.running) {
debuglog("Sync no longer running: exiting.");
if (this.connectionReturnedDefer) {
this.connectionReturnedDefer.reject();
this.connectionReturnedDefer = undefined;
if (this.connectionReturnedResolvers) {
this.connectionReturnedResolvers.reject();
this.connectionReturnedResolvers = undefined;
}
this.updateSyncState(SyncState.Stopped);
}
@@ -999,9 +999,9 @@ export class SyncApi {
private async onSyncError(err: MatrixError): Promise<boolean> {
if (!this.running) {
debuglog("Sync no longer running: exiting");
if (this.connectionReturnedDefer) {
this.connectionReturnedDefer.reject();
this.connectionReturnedDefer = undefined;
if (this.connectionReturnedResolvers) {
this.connectionReturnedResolvers.reject();
this.connectionReturnedResolvers = undefined;
}
this.updateSyncState(SyncState.Stopped);
return true; // abort
@@ -1551,10 +1551,10 @@ export class SyncApi {
} else {
this.pokeKeepAlive();
}
if (!this.connectionReturnedDefer) {
this.connectionReturnedDefer = defer();
if (!this.connectionReturnedResolvers) {
this.connectionReturnedResolvers = Promise.withResolvers();
}
return this.connectionReturnedDefer.promise;
return this.connectionReturnedResolvers.promise;
}
/**
@@ -1562,7 +1562,7 @@ export class SyncApi {
* reachable.
*
* On failure, schedules a call back to itself. On success, resolves
* this.connectionReturnedDefer.
* this.connectionReturnedResolvers.
*
* @param connDidFail - True if a connectivity failure has been detected. Optional.
*/
@@ -1571,18 +1571,18 @@ export class SyncApi {
// we are in a keepAlive, retrying to connect, but the syncronization
// was stopped, so we are stopping the retry.
clearTimeout(this.keepAliveTimer);
if (this.connectionReturnedDefer) {
this.connectionReturnedDefer.reject("SyncApi.stop() was called");
this.connectionReturnedDefer = undefined;
if (this.connectionReturnedResolvers) {
this.connectionReturnedResolvers.reject("SyncApi.stop() was called");
this.connectionReturnedResolvers = undefined;
}
return;
}
const success = (): void => {
clearTimeout(this.keepAliveTimer);
if (this.connectionReturnedDefer) {
this.connectionReturnedDefer.resolve(connDidFail);
this.connectionReturnedDefer = undefined;
if (this.connectionReturnedResolvers) {
this.connectionReturnedResolvers.resolve(connDidFail);
this.connectionReturnedResolvers = undefined;
}
};
+6 -15
View File
@@ -436,23 +436,14 @@ export function isNullOrUndefined(val: any): boolean {
return val === null || val === undefined;
}
export interface IDeferred<T> {
resolve: (value: T | Promise<T>) => void;
reject: (reason?: any) => void;
promise: Promise<T>;
}
export type IDeferred<T> = PromiseWithResolvers<T>;
// Returns a Deferred
/**
* Creates a deferred promise. This is a promise that can be resolved or rejected.
* @deprecated use {@link Promise.withResolvers} instead.
*/
export function defer<T = void>(): IDeferred<T> {
let resolve!: IDeferred<T>["resolve"];
let reject!: IDeferred<T>["reject"];
const promise = new Promise<T>((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
return { resolve, reject, promise };
return Promise.withResolvers<T>();
}
export async function promiseMapSeries<T>(
-39
View File
@@ -1,39 +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.
*/
/**
* Method decorator to ensure that only one instance of the method is running at a time,
* and any concurrent calls will return the same promise as the original call.
* After execution is complete a new call will be able to run the method again.
*/
export function singleAsyncExecution<This, Args extends unknown[], Return>(
target: (this: This, ...args: Args) => Promise<Return>,
): (this: This, ...args: Args) => Promise<Return> {
let promise: Promise<Return> | undefined;
async function replacementMethod(this: This, ...args: Args): Promise<Return> {
if (promise) return promise;
try {
promise = target.call(this, ...args);
await promise;
return promise;
} finally {
promise = undefined;
}
}
return replacementMethod;
}
+1 -1
View File
@@ -2510,7 +2510,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
return;
}
// TODO: Here we were sending the event to the opponent's device as a to-device message with MatrixClient.encryptAndSendToDevices.
// TODO: Here we were sending the event to the opponent's device as a to-device message with MatrixClient.encryptAndSendToDevice.
// However due to the switch to Rust cryptography we need to migrate to the new encryptToDeviceMessages API.
throw new Error("Unimplemented");
} else {
+1 -1
View File
@@ -462,7 +462,7 @@ export class MediaHandler extends TypedEventEmitter<
};
}
private getScreenshareContraints(opts: IScreensharingOpts): DesktopCapturerConstraints {
private getScreenshareContraints(opts: IScreensharingOpts): MediaStreamConstraints {
const { desktopCapturerSourceId, audio } = opts;
if (desktopCapturerSourceId) {
return {
+2 -1
View File
@@ -9,7 +9,8 @@
"noEmit": true,
"declaration": true,
"strict": true,
"allowImportingTsExtensions": true
"allowImportingTsExtensions": true,
"lib": ["es2024"]
},
"include": ["./src/**/*.ts", "./spec/**/*.ts"]
}
+531 -356
View File
File diff suppressed because it is too large Load Diff